diff --git a/src/dialogs/animatedexpander.cpp b/src/dialogs/animatedexpander.cpp index 828181a45..0d108c830 100644 --- a/src/dialogs/animatedexpander.cpp +++ b/src/dialogs/animatedexpander.cpp @@ -1,113 +1,113 @@ /* This file is part of Kleopatra, the KDE keymanager SPDX-FileCopyrightText: 2019, 2021 g10 Code GmbH SPDX-FileContributor: Ingo Klöcker SPDX-License-Identifier: GPL-2.0-or-later */ #include "dialogs/animatedexpander.h" #include void AnimatedExpander::setContentLayout(QLayout *contentLayout) { delete contentArea.layout(); contentArea.setLayout(contentLayout); } bool AnimatedExpander::isExpanded() const { return toggleButton.isChecked(); } void AnimatedExpander::setExpanded(bool expanded) { toggleButton.setChecked(expanded); } AnimatedExpander::AnimatedExpander(const QString &title, const QString &accessibleTitle, QWidget *parent) : QWidget{parent} { #ifdef Q_OS_WIN // draw dotted focus frame if button has focus; otherwise, draw invisible frame using background color toggleButton.setStyleSheet( QStringLiteral("QToolButton { border: 1px solid palette(window); }" "QToolButton:focus { border: 1px dotted palette(window-text); }")); #else // this works with Breeze style because Breeze draws the focus frame when drawing CE_ToolButtonLabel // while the Windows styles (and Qt's common base style) draw the focus frame before drawing CE_ToolButtonLabel toggleButton.setStyleSheet(QStringLiteral("QToolButton { border: none; }")); #endif toggleButton.setToolButtonStyle(Qt::ToolButtonTextBesideIcon); toggleButton.setArrowType(Qt::ArrowType::RightArrow); toggleButton.setText(title); if (!accessibleTitle.isEmpty()) { toggleButton.setAccessibleName(accessibleTitle); } toggleButton.setCheckable(true); toggleButton.setChecked(false); headerLine.setFrameShape(QFrame::HLine); headerLine.setFrameShadow(QFrame::Sunken); headerLine.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum); contentArea.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); // start out collapsed contentArea.setMaximumHeight(0); contentArea.setMinimumHeight(0); contentArea.setVisible(false); // let the entire widget grow and shrink with its content toggleAnimation.addAnimation(new QPropertyAnimation(this, "minimumHeight")); toggleAnimation.addAnimation(new QPropertyAnimation(this, "maximumHeight")); toggleAnimation.addAnimation(new QPropertyAnimation(&contentArea, "maximumHeight")); mainLayout.setVerticalSpacing(0); mainLayout.setContentsMargins(0, 0, 0, 0); int row = 0; mainLayout.addWidget(&toggleButton, row, 0, 1, 1, Qt::AlignLeft); mainLayout.addWidget(&headerLine, row++, 2, 1, 1); mainLayout.addWidget(&contentArea, row, 0, 1, 3); setLayout(&mainLayout); connect(&toggleButton, &QToolButton::toggled, this, [this](const bool checked) { - Q_EMIT startExpanding(); if (checked) { + Q_EMIT startExpanding(); // make the content visible when expanding starts contentArea.setVisible(true); } // use instant animation if widget isn't visible (e.g. before widget is shown) const int duration = isVisible() ? animationDuration : 0; // update the size of the content area const auto collapsedHeight = sizeHint().height() - contentArea.maximumHeight(); const auto contentHeight = contentArea.layout()->sizeHint().height(); for (int i = 0; i < toggleAnimation.animationCount() - 1; ++i) { auto expanderAnimation = static_cast(toggleAnimation.animationAt(i)); expanderAnimation->setDuration(duration); expanderAnimation->setStartValue(collapsedHeight); expanderAnimation->setEndValue(collapsedHeight + contentHeight); } auto contentAnimation = static_cast(toggleAnimation.animationAt(toggleAnimation.animationCount() - 1)); contentAnimation->setDuration(duration); contentAnimation->setStartValue(0); contentAnimation->setEndValue(contentHeight); toggleButton.setArrowType(checked ? Qt::ArrowType::DownArrow : Qt::ArrowType::RightArrow); toggleAnimation.setDirection(checked ? QAbstractAnimation::Forward : QAbstractAnimation::Backward); toggleAnimation.start(); }); connect(&toggleAnimation, &QAbstractAnimation::finished, this, [this]() { // hide the content area when it is fully collapsed if (!toggleButton.isChecked()) { contentArea.setVisible(false); } }); } int AnimatedExpander::contentHeight() const { return contentArea.layout()->sizeHint().height(); } #include "moc_animatedexpander.cpp" diff --git a/src/dialogs/revokekeydialog.cpp b/src/dialogs/revokekeydialog.cpp index 2fe4aa2c9..175f0dd16 100644 --- a/src/dialogs/revokekeydialog.cpp +++ b/src/dialogs/revokekeydialog.cpp @@ -1,311 +1,315 @@ /* -*- mode: c++; c-basic-offset:4 -*- dialogs/revokekeydialog.cpp This file is part of Kleopatra, the KDE keymanager SPDX-FileCopyrightText: 2022 g10 Code GmbH SPDX-FileContributor: Ingo Klöcker SPDX-License-Identifier: GPL-2.0-or-later */ #include "revokekeydialog.h" +#include "dialogs/animatedexpander.h" #include "utils/accessibility.h" #include "view/errorlabel.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace Kleo; using namespace GpgME; namespace { class TextEdit : public QTextEdit { Q_OBJECT public: using QTextEdit::QTextEdit; Q_SIGNALS: void editingFinished(); protected: void focusOutEvent(QFocusEvent *event) override { Qt::FocusReason reason = event->reason(); if (reason != Qt::PopupFocusReason || !(QApplication::activePopupWidget() && QApplication::activePopupWidget()->parentWidget() == this)) { Q_EMIT editingFinished(); } QTextEdit::focusOutEvent(event); } QSize minimumSizeHint() const override { return {0, fontMetrics().height() * 3}; } }; } class RevokeKeyDialog::Private { friend class ::Kleo::RevokeKeyDialog; RevokeKeyDialog *const q; struct { QLabel *infoLabel = nullptr; QLabel *descriptionLabel = nullptr; TextEdit *description = nullptr; ErrorLabel *descriptionError = nullptr; QDialogButtonBox *buttonBox = nullptr; } ui; Key key; QButtonGroup reasonGroup; bool descriptionEditingInProgress = false; QString descriptionAccessibleName; public: Private(RevokeKeyDialog *qq) : q(qq) { q->setWindowTitle(i18nc("title:window", "Revoke Certificate")); auto mainLayout = new QVBoxLayout{q}; ui.infoLabel = new QLabel{q}; auto infoGroupBox = new QGroupBox{i18nc("@title:group", "Information")}; infoGroupBox->setFlat(true); auto infoLayout = new QVBoxLayout; infoGroupBox->setLayout(infoLayout); infoLayout->addWidget(ui.infoLabel); mainLayout->addWidget(infoGroupBox); - auto groupBox = new QGroupBox{i18nc("@title:group", "Reason for revocation"), q}; - groupBox->setFlat(true); - reasonGroup.addButton(new QRadioButton{i18nc("@option:radio", "Certificate has been compromised"), q}, static_cast(RevocationReason::Compromised)); reasonGroup.addButton(new QRadioButton{i18nc("@option:radio", "Certificate is superseded"), q}, static_cast(RevocationReason::Superseded)); reasonGroup.addButton(new QRadioButton{i18nc("@option:radio", "Certificate is no longer used"), q}, static_cast(RevocationReason::NoLongerUsed)); reasonGroup.addButton(new QRadioButton{i18nc("@option:radio", "For a different reason"), q}, static_cast(RevocationReason::Unspecified)); reasonGroup.button(static_cast(RevocationReason::Unspecified))->setChecked(true); - { - auto boxLayout = new QVBoxLayout{groupBox}; - for (auto radio : reasonGroup.buttons()) { - boxLayout->addWidget(radio); - } - } + auto reasonLayout = new QVBoxLayout; + auto expander = new AnimatedExpander(i18nc("@title", "Reason for Revocation (optional)")); + connect(expander, &AnimatedExpander::startExpanding, q, [this, expander]() { + q->resize(q->size().width(), std::max(q->sizeHint().height() + expander->contentHeight() + 20, q->size().height())); + }); + expander->setContentLayout(reasonLayout); - mainLayout->addWidget(groupBox); + mainLayout->addWidget(expander); + + mainLayout->addStretch(1); + + for (auto radio : reasonGroup.buttons()) { + reasonLayout->addWidget(radio); + } { ui.descriptionLabel = new QLabel{i18nc("@label:textbox", "Description (optional):"), q}; ui.description = new TextEdit{q}; ui.description->setAcceptRichText(false); // do not accept Tab as input; this is better for accessibility and // tabulators are not really that useful in the description ui.description->setTabChangesFocus(true); ui.descriptionLabel->setBuddy(ui.description); ui.descriptionError = new ErrorLabel{q}; ui.descriptionError->setVisible(false); - mainLayout->addWidget(ui.descriptionLabel); - mainLayout->addWidget(ui.description); - mainLayout->addWidget(ui.descriptionError); + reasonLayout->addWidget(ui.descriptionLabel); + reasonLayout->addWidget(ui.description); + reasonLayout->addWidget(ui.descriptionError); } connect(ui.description, &TextEdit::editingFinished, q, [this]() { onDescriptionEditingFinished(); }); connect(ui.description, &TextEdit::textChanged, q, [this]() { onDescriptionTextChanged(); }); ui.buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); auto okButton = ui.buttonBox->button(QDialogButtonBox::Ok); okButton->setText(i18nc("@action:button", "Revoke Certificate")); okButton->setIcon(QIcon::fromTheme(QStringLiteral("edit-delete-remove"))); mainLayout->addWidget(ui.buttonBox); connect(ui.buttonBox, &QDialogButtonBox::accepted, q, [this]() { checkAccept(); }); connect(ui.buttonBox, &QDialogButtonBox::rejected, q, &QDialog::reject); restoreGeometry(); } ~Private() { saveGeometry(); } private: void saveGeometry() { KConfigGroup cfgGroup(KSharedConfig::openStateConfig(), QStringLiteral("RevokeKeyDialog")); cfgGroup.writeEntry("Size", q->size()); cfgGroup.sync(); } void restoreGeometry(const QSize &defaultSize = {}) { KConfigGroup cfgGroup(KSharedConfig::openStateConfig(), QStringLiteral("RevokeKeyDialog")); const QSize size = cfgGroup.readEntry("Size", defaultSize); if (size.isValid()) { q->resize(size); } else { q->resize(q->minimumSizeHint()); } } void checkAccept() { if (!descriptionHasAcceptableInput()) { KMessageBox::error(q, descriptionErrorMessage()); } else { q->accept(); } } bool descriptionHasAcceptableInput() const { return !q->description().contains(QLatin1StringView{"\n\n"}); } QString descriptionErrorMessage() const { QString message; if (!descriptionHasAcceptableInput()) { message = i18n("Error: The description must not contain empty lines."); } return message; } void updateDescriptionError() { const auto currentErrorMessage = ui.descriptionError->text(); const auto newErrorMessage = descriptionErrorMessage(); if (newErrorMessage == currentErrorMessage) { return; } if (currentErrorMessage.isEmpty() && descriptionEditingInProgress) { // delay showing the error message until editing is finished, so that we // do not annoy the user with an error message while they are still // entering the recipient; // on the other hand, we clear the error message immediately if it does // not apply anymore and we update the error message immediately if it // changed return; } ui.descriptionError->setVisible(!newErrorMessage.isEmpty()); ui.descriptionError->setText(newErrorMessage); updateAccessibleNameAndDescription(); } void updateAccessibleNameAndDescription() { // fall back to default accessible name if accessible name wasn't set explicitly if (descriptionAccessibleName.isEmpty()) { descriptionAccessibleName = getAccessibleName(ui.description); } const bool errorShown = ui.descriptionError->isVisible(); // Qt does not support "described-by" relations (like WCAG's "aria-describedby" relationship attribute); // emulate this by setting the error message as accessible description of the input field const auto description = errorShown ? ui.descriptionError->text() : QString{}; if (ui.description->accessibleDescription() != description) { ui.description->setAccessibleDescription(description); } // Qt does not support IA2's "invalid entry" state (like WCAG's "aria-invalid" state attribute); // screen readers say something like "invalid entry" if this state is set; // emulate this by adding "invalid entry" to the accessible name of the input field // and its label const auto name = errorShown ? descriptionAccessibleName + QLatin1StringView{", "} + invalidEntryText() // : descriptionAccessibleName; if (ui.descriptionLabel->accessibleName() != name) { ui.descriptionLabel->setAccessibleName(name); } if (ui.description->accessibleName() != name) { ui.description->setAccessibleName(name); } } void onDescriptionTextChanged() { descriptionEditingInProgress = true; updateDescriptionError(); } void onDescriptionEditingFinished() { descriptionEditingInProgress = false; updateDescriptionError(); } }; RevokeKeyDialog::RevokeKeyDialog(QWidget *parent, Qt::WindowFlags f) : QDialog{parent, f} , d{new Private{this}} { } RevokeKeyDialog::~RevokeKeyDialog() = default; void RevokeKeyDialog::setKey(const GpgME::Key &key) { d->key = key; d->ui.infoLabel->setText( xi18nc("@info", "You are about to revoke the following certificate:        %1The " "revocation will take effect " "immediately and " "cannot be reverted.Consequences: You cannot sign anything anymore with this certificate.You " "cannot certify other certificates anymore with this certificate.You " "can still decrypt everything encrypted for this certificate.Other people can no longer encrypt for this certificate after " "receiving the revocation.") .arg(Formatting::summaryLine(key))); } GpgME::RevocationReason RevokeKeyDialog::reason() const { return static_cast(d->reasonGroup.checkedId()); } QString RevokeKeyDialog::description() const { static const QRegularExpression whitespaceAtEndOfLine{QStringLiteral(R"([ \t\r]+\n)")}; static const QRegularExpression trailingWhitespace{QStringLiteral(R"(\s*$)")}; return d->ui.description->toPlainText().remove(whitespaceAtEndOfLine).remove(trailingWhitespace); } #include "revokekeydialog.moc" #include "moc_revokekeydialog.cpp"