diff --git a/src/autotype/AutoTypeSelectDialog.cpp b/src/autotype/AutoTypeSelectDialog.cpp index 53d9e3da56..08d57305d3 100644 --- a/src/autotype/AutoTypeSelectDialog.cpp +++ b/src/autotype/AutoTypeSelectDialog.cpp @@ -68,6 +68,8 @@ AutoTypeSelectDialog::AutoTypeSelectDialog(QWidget* parent) }); m_ui->helpButton->setIcon(icons()->icon("system-help")); + m_ui->helpButton->setAccessibleName(tr("Show Auto-Type shortcuts")); + m_ui->helpButton->setFocusPolicy(Qt::TabFocus); m_ui->search->installEventFilter(this); diff --git a/src/autotype/PickcharsDialog.cpp b/src/autotype/PickcharsDialog.cpp index 50ac6bf814..2013861220 100644 --- a/src/autotype/PickcharsDialog.cpp +++ b/src/autotype/PickcharsDialog.cpp @@ -52,7 +52,8 @@ PickcharsDialog::PickcharsDialog(const QString& string, QWidget* parent) for (const auto& ch : string) { auto btn = new QPushButton(QString::number(count + 1)); btn->setProperty("char", ch); - btn->setProperty("count", count); + btn->setProperty("count", count); + btn->setAccessibleName(tr("Character %1, position %2").arg(ch).arg(count + 1)); connect(btn, &QPushButton::clicked, this, &PickcharsDialog::charSelected); m_ui->charsGrid->addWidget(btn, count / width, count % width); m_lastSelected = count; diff --git a/src/gui/ApplicationSettingsWidget.cpp b/src/gui/ApplicationSettingsWidget.cpp index ca2c587d31..daa0bd9042 100644 --- a/src/gui/ApplicationSettingsWidget.cpp +++ b/src/gui/ApplicationSettingsWidget.cpp @@ -20,6 +20,7 @@ #include "gui/osutils/OSUtilsBase.h" #include "ui_ApplicationSettingsWidgetGeneral.h" #include "ui_ApplicationSettingsWidgetSecurity.h" +#include #include #include #include @@ -151,25 +152,45 @@ ApplicationSettingsWidget::ApplicationSettingsWidget(QWidget* parent) connect(m_generalUi->resetSettingsButton, SIGNAL(clicked()), SLOT(resetSettings())); connect(m_generalUi->importSettingsButton, SIGNAL(clicked()), SLOT(importSettings())); connect(m_generalUi->exportSettingsButton, SIGNAL(clicked()), SLOT(exportSettings())); - connect(m_generalUi->useAlternativeSaveCheckBox, SIGNAL(toggled(bool)), - m_generalUi->alternativeSaveComboBox, SLOT(setEnabled(bool))); + connect(m_generalUi->useAlternativeSaveCheckBox, &QCheckBox::toggled, this, [this](bool enabled) { + if (!enabled && m_generalUi->alternativeSaveComboBox->hasFocus()) { + m_generalUi->useAlternativeSaveCheckBox->setFocus(); + } + m_generalUi->alternativeSaveComboBox->setEnabled(enabled); + }); - connect(m_generalUi->backupBeforeSaveCheckBox, SIGNAL(toggled(bool)), - m_generalUi->backupFilePath, SLOT(setEnabled(bool))); - connect(m_generalUi->backupBeforeSaveCheckBox, SIGNAL(toggled(bool)), - m_generalUi->backupFilePathPicker, SLOT(setEnabled(bool))); + connect(m_generalUi->backupBeforeSaveCheckBox, &QCheckBox::toggled, this, [this](bool enabled) { + if (!enabled + && (m_generalUi->backupFilePath->hasFocus() || m_generalUi->backupFilePathPicker->hasFocus())) { + m_generalUi->backupBeforeSaveCheckBox->setFocus(); + } + m_generalUi->backupFilePath->setEnabled(enabled); + m_generalUi->backupFilePathPicker->setEnabled(enabled); + }); connect(m_generalUi->backupFilePathPicker, SIGNAL(pressed()), SLOT(selectBackupDirectory())); connect(m_generalUi->showExpiredEntriesOnDatabaseUnlockCheckBox, SIGNAL(toggled(bool)), SLOT(showExpiredEntriesOnDatabaseUnlockToggled(bool))); connect(m_generalUi->autoTypeAskCheckBox, SIGNAL(toggled(bool)), SLOT(autoTypeAskToggled(bool))); - connect(m_secUi->clearClipboardCheckBox, SIGNAL(toggled(bool)), - m_secUi->clearClipboardSpinBox, SLOT(setEnabled(bool))); - connect(m_secUi->clearSearchCheckBox, SIGNAL(toggled(bool)), - m_secUi->clearSearchSpinBox, SLOT(setEnabled(bool))); - connect(m_secUi->lockDatabaseIdleCheckBox, SIGNAL(toggled(bool)), - m_secUi->lockDatabaseIdleSpinBox, SLOT(setEnabled(bool))); + connect(m_secUi->clearClipboardCheckBox, &QCheckBox::toggled, this, [this](bool enabled) { + if (!enabled && m_secUi->clearClipboardSpinBox->hasFocus()) { + m_secUi->clearClipboardCheckBox->setFocus(); + } + m_secUi->clearClipboardSpinBox->setEnabled(enabled); + }); + connect(m_secUi->clearSearchCheckBox, &QCheckBox::toggled, this, [this](bool enabled) { + if (!enabled && m_secUi->clearSearchSpinBox->hasFocus()) { + m_secUi->clearSearchCheckBox->setFocus(); + } + m_secUi->clearSearchSpinBox->setEnabled(enabled); + }); + connect(m_secUi->lockDatabaseIdleCheckBox, &QCheckBox::toggled, this, [this](bool enabled) { + if (!enabled && m_secUi->lockDatabaseIdleSpinBox->hasFocus()) { + m_secUi->lockDatabaseIdleCheckBox->setFocus(); + } + m_secUi->lockDatabaseIdleSpinBox->setEnabled(enabled); + }); // clang-format on connect(m_generalUi->minimizeAfterUnlockCheckBox, &QCheckBox::toggled, this, [this](bool state) { @@ -178,6 +199,9 @@ ApplicationSettingsWidget::ApplicationSettingsWidget(QWidget* parent) } m_secUi->lockDatabaseMinimizeCheckBox->setToolTip( state ? tr("This setting cannot be enabled when minimize on unlock is enabled.") : ""); + if (state && m_secUi->lockDatabaseMinimizeCheckBox->hasFocus()) { + m_secUi->lockDatabaseIdleCheckBox->setFocus(); + } m_secUi->lockDatabaseMinimizeCheckBox->setEnabled(!state); }); @@ -187,8 +211,12 @@ ApplicationSettingsWidget::ApplicationSettingsWidget(QWidget* parent) QString error; if (autoType()->registerGlobalShortcut(key, modifiers, &error)) { m_generalUi->autoTypeShortcutWidget->setStyleSheet(""); + m_generalUi->autoTypeShortcutWidget->setAccessibleDescription({}); } else { QToolTip::showText(mapToGlobal(rect().bottomLeft()), error); + m_generalUi->autoTypeShortcutWidget->setAccessibleDescription(error); + QAccessibleEvent alertEvent(m_generalUi->autoTypeShortcutWidget, QAccessible::Alert); + QAccessible::updateAccessibility(&alertEvent); StateColorPalette statePalette; auto color = statePalette.color(StateColorPalette::ColorRole::Error); m_generalUi->autoTypeShortcutWidget->setStyleSheet( @@ -658,18 +686,30 @@ void ApplicationSettingsWidget::autoSaveToggled(bool checked) m_generalUi->autoSaveOnExitCheckBox->setChecked(true); m_generalUi->autoSaveNonDataChangesCheckBox->setChecked(true); } + if (checked + && (m_generalUi->autoSaveOnExitCheckBox->hasFocus() || m_generalUi->autoSaveNonDataChangesCheckBox->hasFocus())) { + m_generalUi->autoSaveAfterEveryChangeCheckBox->setFocus(); + } m_generalUi->autoSaveOnExitCheckBox->setEnabled(!checked); m_generalUi->autoSaveNonDataChangesCheckBox->setEnabled(!checked); } void ApplicationSettingsWidget::hideWindowOnCopyCheckBoxToggled(bool checked) { + if (!checked + && (m_generalUi->minimizeOnCopyRadioButton->hasFocus() || m_generalUi->dropToBackgroundOnCopyRadioButton->hasFocus())) { + m_generalUi->hideWindowOnCopyCheckBox->setFocus(); + } m_generalUi->minimizeOnCopyRadioButton->setEnabled(checked); m_generalUi->dropToBackgroundOnCopyRadioButton->setEnabled(checked); } void ApplicationSettingsWidget::systrayToggled(bool checked) { + if (!checked + && (m_generalUi->trayIconAppearance->hasFocus() || m_generalUi->systrayMinimizeToTrayCheckBox->hasFocus())) { + m_generalUi->systrayShowCheckBox->setFocus(); + } m_generalUi->trayIconAppearance->setEnabled(checked); m_generalUi->trayIconAppearanceLabel->setEnabled(checked); m_generalUi->systrayMinimizeToTrayCheckBox->setEnabled(checked); @@ -682,6 +722,11 @@ void ApplicationSettingsWidget::rememberDatabasesToggled(bool checked) m_generalUi->openPreviousDatabasesOnStartupCheckBox->setChecked(false); } + if (!checked + && (m_generalUi->rememberLastDatabasesSpinbox->hasFocus() || m_generalUi->rememberLastKeyFilesCheckBox->hasFocus() + || m_generalUi->openPreviousDatabasesOnStartupCheckBox->hasFocus())) { + m_generalUi->rememberLastDatabasesCheckBox->setFocus(); + } m_generalUi->rememberLastDatabasesSpinbox->setEnabled(checked); m_generalUi->rememberLastKeyFilesCheckBox->setEnabled(checked); m_generalUi->openPreviousDatabasesOnStartupCheckBox->setEnabled(checked); @@ -689,16 +734,25 @@ void ApplicationSettingsWidget::rememberDatabasesToggled(bool checked) void ApplicationSettingsWidget::checkUpdatesToggled(bool checked) { + if (!checked && m_generalUi->checkForUpdatesIncludeBetasCheckBox->hasFocus()) { + m_generalUi->checkForUpdatesOnStartupCheckBox->setFocus(); + } m_generalUi->checkForUpdatesIncludeBetasCheckBox->setEnabled(checked); } void ApplicationSettingsWidget::showExpiredEntriesOnDatabaseUnlockToggled(bool checked) { + if (!checked && m_generalUi->showExpiredEntriesOnDatabaseUnlockOffsetSpinBox->hasFocus()) { + m_generalUi->showExpiredEntriesOnDatabaseUnlockCheckBox->setFocus(); + } m_generalUi->showExpiredEntriesOnDatabaseUnlockOffsetSpinBox->setEnabled(checked); } void ApplicationSettingsWidget::autoTypeAskToggled(bool checked) { + if (!checked && m_generalUi->autoTypeSkipMainWindowConfirmationCheckBox->hasFocus()) { + m_generalUi->autoTypeAskCheckBox->setFocus(); + } m_generalUi->autoTypeSkipMainWindowConfirmationCheckBox->setEnabled(checked); } diff --git a/src/gui/ApplicationSettingsWidgetGeneral.ui b/src/gui/ApplicationSettingsWidgetGeneral.ui index 302ba0a2b2..d39c793740 100644 --- a/src/gui/ApplicationSettingsWidgetGeneral.ui +++ b/src/gui/ApplicationSettingsWidgetGeneral.ui @@ -126,6 +126,12 @@ + + Number of recent databases to remember + + + Sets how many previously used database files are kept in the recent files list. + recent files @@ -493,6 +499,12 @@ false + + Alternative saving method + + + Select how KeePassXC saves the database when the alternative saving method is enabled. + Qt::FocusPolicy::StrongFocus diff --git a/src/gui/ApplicationSettingsWidgetSecurity.ui b/src/gui/ApplicationSettingsWidgetSecurity.ui index c4cb0e01f1..b64e08830f 100644 --- a/src/gui/ApplicationSettingsWidgetSecurity.ui +++ b/src/gui/ApplicationSettingsWidgetSecurity.ui @@ -76,6 +76,9 @@ + + Search query clear time in minutes + false diff --git a/src/gui/CategoryListWidget.cpp b/src/gui/CategoryListWidget.cpp index cae806f62d..73296160bc 100644 --- a/src/gui/CategoryListWidget.cpp +++ b/src/gui/CategoryListWidget.cpp @@ -33,6 +33,9 @@ CategoryListWidget::CategoryListWidget(QWidget* parent) m_itemDelegate = new CategoryListWidgetDelegate(m_ui->categoryList); m_ui->categoryList->setItemDelegate(m_itemDelegate); + // This widget is a composite tab stop; direct focus must enter the actual category list. + setFocusProxy(m_ui->categoryList); + connect(m_ui->categoryList, SIGNAL(currentRowChanged(int)), SLOT(emitCategoryChanged(int))); connect(m_ui->scrollUp, SIGNAL(clicked()), SLOT(scrollCategoriesUp())); @@ -118,12 +121,18 @@ void CategoryListWidget::resizeEvent(QResizeEvent* event) void CategoryListWidget::updateCategoryScrollButtons() { + const bool canScroll = m_ui->categoryList->verticalScrollBar()->maximum() > 0; + + if (!canScroll && (m_ui->scrollUp->hasFocus() || m_ui->scrollDown->hasFocus())) { + m_ui->categoryList->setFocus(Qt::OtherFocusReason); + } + m_ui->scrollUp->setEnabled(m_ui->categoryList->verticalScrollBar()->value() != 0); m_ui->scrollDown->setEnabled(m_ui->categoryList->verticalScrollBar()->value() != m_ui->categoryList->verticalScrollBar()->maximum()); - m_ui->scrollUp->setVisible(m_ui->categoryList->verticalScrollBar()->maximum() > 0); - m_ui->scrollDown->setVisible(m_ui->scrollUp->isVisible()); + m_ui->scrollUp->setVisible(canScroll); + m_ui->scrollDown->setVisible(canScroll); } void CategoryListWidget::scrollCategoriesUp() diff --git a/src/gui/DatabaseOpenWidget.cpp b/src/gui/DatabaseOpenWidget.cpp index ec93953eb8..046d2abf84 100644 --- a/src/gui/DatabaseOpenWidget.cpp +++ b/src/gui/DatabaseOpenWidget.cpp @@ -90,7 +90,12 @@ DatabaseOpenWidget::DatabaseOpenWidget(QWidget* parent) m_ui->addKeyFileLinkLabel->setVisible(!state); m_ui->selectKeyFileComponent->setVisible(state); }); - connect(m_ui->useHardwareKeyCheckBox, &QCheckBox::toggled, m_ui->hardwareKeyCombo, &QComboBox::setEnabled); + connect(m_ui->useHardwareKeyCheckBox, &QCheckBox::toggled, this, [this](bool enabled) { + if (!enabled && m_ui->hardwareKeyCombo->hasFocus()) { + m_ui->useHardwareKeyCheckBox->setFocus(Qt::OtherFocusReason); + } + m_ui->hardwareKeyCombo->setEnabled(enabled); + }); m_ui->selectKeyFileComponent->setVisible(false); toggleHardwareKeyComponent(false); diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index 8a29d1822a..90c9358747 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -19,6 +19,7 @@ #include "DatabaseWidget.h" #include +#include #include #include #include @@ -157,21 +158,25 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) m_previewSplitter->setChildrenCollapsible(true); m_groupView->setObjectName("groupView"); + m_groupView->setAccessibleName(tr("Groups")); m_groupView->setContextMenuPolicy(Qt::CustomContextMenu); connect(m_groupView, SIGNAL(customContextMenuRequested(QPoint)), SLOT(emitGroupContextMenuRequested(QPoint))); m_entryView->setObjectName("entryView"); + m_entryView->setAccessibleName(tr("Entries")); m_entryView->setContextMenuPolicy(Qt::CustomContextMenu); m_entryView->displayGroup(m_db->rootGroup()); connect(m_entryView, SIGNAL(customContextMenuRequested(QPoint)), SLOT(emitEntryContextMenuRequested(QPoint))); // Add a notification for when we are searching m_searchingLabel->setObjectName("SearchBanner"); + m_searchingLabel->setAccessibleName(tr("Search status")); m_searchingLabel->setText(tr("Searching…")); m_searchingLabel->setAlignment(Qt::AlignCenter); m_searchingLabel->setVisible(false); m_shareLabel->setObjectName("KeeShareBanner"); + m_shareLabel->setAccessibleName(tr("Shared group status")); m_shareLabel->setRawText(tr("Shared group…")); m_shareLabel->setAlignment(Qt::AlignCenter); m_shareLabel->setVisible(false); @@ -415,8 +420,16 @@ void DatabaseWidget::setSplitterSizes(const QHash> void DatabaseWidget::onConfigChanged(Config::ConfigKey key) { if (key == Config::GUI_HideGroupPanel) { - // Toggle the group splitter visibility and reset the size - m_groupSplitter->setVisible(!config()->get(Config::GUI_HideGroupPanel).toBool()); + // Hiding the group panel can remove the focused group/tag view. Move + // focus to the entry view first so keyboard and screen-reader users + // are not left with focus on a hidden widget. + const bool hideGroupPanel = config()->get(Config::GUI_HideGroupPanel).toBool(); + const auto focusWidget = QApplication::focusWidget(); + if (hideGroupPanel && m_groupSplitter->isVisible() && focusWidget + && m_groupSplitter->isAncestorOf(focusWidget)) { + m_entryView->setFocus(); + } + m_groupSplitter->setVisible(!hideGroupPanel); setSplitterSizes({{Config::GUI_SplitterState, QList({})}}); } } @@ -1773,6 +1786,10 @@ void DatabaseWidget::search(const QString& searchtext) m_lastSearchText = searchtext; m_searchingLabel->setVisible(true); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(m_searchingLabel, m_searchingLabel->text()); + QAccessible::updateAccessibility(&announcementEvent); +#endif m_shareLabel->setVisible(false); emit searchModeActivated(); diff --git a/src/gui/EditWidgetIcons.ui b/src/gui/EditWidgetIcons.ui index eac83dbb31..e2d432012f 100644 --- a/src/gui/EditWidgetIcons.ui +++ b/src/gui/EditWidgetIcons.ui @@ -35,6 +35,9 @@ QAbstractItemView::NoEditTriggers + + false + QListView::Static diff --git a/src/gui/EditWidgetProperties.ui b/src/gui/EditWidgetProperties.ui index 68ce8236e0..f20da7a15a 100644 --- a/src/gui/EditWidgetProperties.ui +++ b/src/gui/EditWidgetProperties.ui @@ -138,6 +138,9 @@ QAbstractItemView::SelectRows + + false + 200 @@ -185,6 +188,8 @@ createdEdit modifiedEdit uuidEdit + customDataTable + removeCustomDataButton diff --git a/src/gui/EntryPreviewWidget.cpp b/src/gui/EntryPreviewWidget.cpp index f587d4fa44..31dd5f06f9 100644 --- a/src/gui/EntryPreviewWidget.cpp +++ b/src/gui/EntryPreviewWidget.cpp @@ -28,6 +28,7 @@ #include "keeshare/KeeShare.h" #include "keeshare/KeeShareSettings.h" +#include #include #include namespace @@ -122,6 +123,13 @@ bool EntryPreviewWidget::eventFilter(QObject* object, QEvent* event) void EntryPreviewWidget::clear() { + if (auto* focusedWidget = QApplication::focusWidget(); + focusedWidget && (focusedWidget == this || isAncestorOf(focusedWidget))) { + if (auto* parent = parentWidget(); parent && parent->isVisibleTo(window()) && parent->isEnabled() + && parent->focusPolicy() != Qt::NoFocus) { + parent->setFocus(Qt::OtherFocusReason); + } + } hide(); m_currentEntry = nullptr; m_currentGroup = nullptr; @@ -145,6 +153,13 @@ void EntryPreviewWidget::setEntry(Entry* selectedEntry) m_currentGroup = nullptr; if (!m_currentEntry) { + if (auto* focusedWidget = QApplication::focusWidget(); + focusedWidget && (focusedWidget == this || isAncestorOf(focusedWidget))) { + if (auto* parent = parentWidget(); parent && parent->isVisibleTo(window()) && parent->isEnabled() + && parent->focusPolicy() != Qt::NoFocus) { + parent->setFocus(Qt::OtherFocusReason); + } + } hide(); return; } @@ -174,6 +189,13 @@ void EntryPreviewWidget::setGroup(Group* selectedGroup) m_currentGroup = selectedGroup; if (!m_currentGroup) { + if (auto* focusedWidget = QApplication::focusWidget(); + focusedWidget && (focusedWidget == this || isAncestorOf(focusedWidget))) { + if (auto* parent = parentWidget(); parent && parent->isVisibleTo(window()) && parent->isEnabled() + && parent->focusPolicy() != Qt::NoFocus) { + parent->setFocus(Qt::OtherFocusReason); + } + } hide(); return; } @@ -434,10 +456,23 @@ void EntryPreviewWidget::updateEntryAdvancedTab() button->setChecked(false); button->setIcon(icons()->onOffIcon("password-show", false)); button->setProperty("row", i); + button->setProperty("attributeKey", key); button->setIconSize({12, 12}); + button->setToolTip(tr("Show %1").arg(key)); + button->setAccessibleName(tr("Show %1").arg(key)); connect(button, &QToolButton::clicked, this, [this](bool state) { auto btn = qobject_cast(sender()); + const auto attrKey = btn->property("attributeKey").toString(); btn->setIcon(icons()->onOffIcon("password-show", state)); + const auto label = state ? tr("Hide %1").arg(attrKey) : tr("Show %1").arg(attrKey); + btn->setToolTip(label); + btn->setAccessibleName(label); + QAccessibleEvent nameChanged(btn, QAccessible::NameChanged); + QAccessible::updateAccessibility(&nameChanged); + QAccessible::State changedState; + changedState.checked = state; + QAccessibleStateChangeEvent stateChanged(btn, changedState); + QAccessible::updateAccessibility(&stateChanged); auto item = m_ui->entryAttributesTable->item(btn->property("row").toInt(), 2); if (state) { item->setText(item->data(Qt::UserRole).toString()); diff --git a/src/gui/IconDownloaderDialog.cpp b/src/gui/IconDownloaderDialog.cpp index 72e96785be..7e308142de 100644 --- a/src/gui/IconDownloaderDialog.cpp +++ b/src/gui/IconDownloaderDialog.cpp @@ -31,6 +31,7 @@ #include "gui/osutils/macutils/MacUtils.h" #endif +#include #include IconDownloaderDialog::IconDownloaderDialog(QWidget* parent) @@ -189,6 +190,12 @@ void IconDownloaderDialog::updateProgressBar() m_ui->progressBar->setMaximum(total); m_ui->progressLabel->setText( tr("Downloading favicons (%1/%2)…").arg(QString::number(value), QString::number(total))); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent( + m_ui->progressLabel, m_ui->progressLabel->text()); + QAccessible::updateAccessibility(&announcementEvent); +#endif } void IconDownloaderDialog::updateCancelButton() @@ -200,7 +207,12 @@ void IconDownloaderDialog::updateTable(const QString& url, const QString& messag { for (int i = 0; i < m_dataModel->rowCount(); ++i) { if (m_dataModel->item(i, 0)->text() == url) { - m_dataModel->item(i, 1)->setText(message); + auto* statusItem = m_dataModel->item(i, 1); + statusItem->setText(message); + statusItem->setData(message, Qt::AccessibleDescriptionRole); + + QAccessibleEvent event(m_ui->tableView, QAccessible::VisibleDataChanged); + QAccessible::updateAccessibility(&event); } } } diff --git a/src/gui/KMessageWidget.cpp b/src/gui/KMessageWidget.cpp index 6b402f3134..2b7abb626b 100644 --- a/src/gui/KMessageWidget.cpp +++ b/src/gui/KMessageWidget.cpp @@ -24,6 +24,7 @@ #include "gui/Icons.h" #include +#include #include #include #include @@ -393,6 +394,23 @@ bool KMessageWidget::isCloseButtonVisible() const void KMessageWidget::setCloseButtonVisible(bool show) { + if (!show && d->closeButton->hasFocus()) { + if (!d->buttons.isEmpty()) { + d->buttons.first()->setFocus(Qt::OtherFocusReason); + } else { + QWidget* candidate = d->closeButton->nextInFocusChain(); + const QWidget* const boundary = this; + while (candidate && candidate != boundary) { + if (candidate->isVisibleTo(window()) && candidate->isEnabled() + && candidate->focusPolicy() != Qt::NoFocus && !isAncestorOf(candidate)) { + candidate->setFocus(Qt::OtherFocusReason); + break; + } + candidate = candidate->nextInFocusChain(); + } + } + } + d->closeButton->setVisible(show); updateGeometry(); } @@ -436,6 +454,31 @@ void KMessageWidget::animatedShow() void KMessageWidget::animatedHide() { + const auto focusedWidget = QApplication::focusWidget(); + if (focusedWidget && (focusedWidget == this || isAncestorOf(focusedWidget))) { + QWidget* candidate = focusedWidget->nextInFocusChain(); + while (candidate && candidate != this) { + if (!isAncestorOf(candidate) && candidate->isVisibleTo(window()) && candidate->isEnabled() + && candidate->focusPolicy() != Qt::NoFocus) { + candidate->setFocus(Qt::OtherFocusReason); + break; + } + candidate = candidate->nextInFocusChain(); + } + + if (QApplication::focusWidget() == focusedWidget) { + candidate = focusedWidget->previousInFocusChain(); + while (candidate && candidate != this) { + if (!isAncestorOf(candidate) && candidate->isVisibleTo(window()) && candidate->isEnabled() + && candidate->focusPolicy() != Qt::NoFocus) { + candidate->setFocus(Qt::OtherFocusReason); + break; + } + candidate = candidate->previousInFocusChain(); + } + } + } + if (!style()->styleHint(QStyle::SH_Widget_Animate, nullptr, this)) { hide(); emit hideAnimationFinished(); diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index c7a0d8e5c5..ff6eb66ecb 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -19,6 +19,7 @@ #include "MainWindow.h" #include "ui_MainWindow.h" +#include #include #include #include @@ -108,6 +109,8 @@ MainWindow::MainWindow() m_ui->toolBar->setIconSize({20, 20}); } + m_ui->toolBar->setAccessibleName(tr("Database toolbar")); + // Setup the search widget in the toolbar m_searchWidget = new SearchWidget(); m_searchWidget->connectSignals(m_actionMultiplexer); @@ -548,6 +551,11 @@ MainWindow::MainWindow() auto* eventFilter = new MainWindowEventFilter(this); m_ui->menubar->installEventFilter(eventFilter); m_ui->toolBar->installEventFilter(eventFilter); + // SearchWidget uses a focus proxy, so its line edit receives Tab before + // the normal focus traversal can reach MainWindow::focusNextPrevChild(). + // Filter that actual focus widget so Tab and Shift+Tab can cross the + // composite search control and enter/leave the toolbar predictably. + m_searchWidget->focusProxy()->installEventFilter(eventFilter); m_ui->tabWidget->tabBar()->installEventFilter(eventFilter); installEventFilter(eventFilter); @@ -637,6 +645,7 @@ MainWindow::MainWindow() // Setup the status bar statusBar()->setFixedHeight(24); m_progressBarLabel = new QLabel(statusBar()); + m_progressBarLabel->setObjectName("progressBarLabel"); m_progressBarLabel->setVisible(false); statusBar()->addPermanentWidget(m_progressBarLabel); m_progressBar = new QProgressBar(statusBar()); @@ -1419,26 +1428,86 @@ bool MainWindow::focusNextPrevChild(bool next) // Only navigate around the main window if the database widget is showing the entry view auto dbWidget = m_ui->tabWidget->currentDatabaseWidget(); if (dbWidget && dbWidget->isVisible() && dbWidget->isEntryViewActive()) { - // Search Widget <-> Tab Widget <-> DbWidget + // Toolbar <-> Search <-> Tab Widget <-> DbWidget (groups/entries) + const bool searchFocused = m_searchWidget->hasFocus(); + const QWidget* focusWidget = QApplication::focusWidget(); + const bool toolbarFocused = m_ui->toolBar->isVisible() && !searchFocused && focusWidget + && (focusWidget == m_ui->toolBar || m_ui->toolBar->isAncestorOf(focusWidget)); + const bool tabFocused = m_ui->tabWidget->hasFocus(); + const bool multiTabs = m_ui->tabWidget->count() > 1; + + auto toolbarFocusableWidgets = [this] { + QList widgets; + for (QAction* action : m_ui->toolBar->actions()) { + if (action->isSeparator() || !action->isEnabled() || !action->isVisible()) { + continue; + } + QWidget* w = m_ui->toolBar->widgetForAction(action); + if (!w || !w->isVisible() || w->focusPolicy() == Qt::NoFocus || w == m_searchWidget + || m_searchWidget->isAncestorOf(w)) { + continue; + } + widgets.append(w); + } + return widgets; + }; + + auto focusToolbar = [this, &toolbarFocusableWidgets](Qt::FocusReason reason) { + m_ui->toolBar->setVisible(true); + m_ui->toolBar->setExpanded(true); + const auto widgets = toolbarFocusableWidgets(); + if (widgets.isEmpty()) { + m_ui->toolBar->setFocus(reason); + return; + } + const bool forward = (reason == Qt::TabFocusReason); + widgets.at(forward ? 0 : widgets.size() - 1)->setFocus(reason); + }; + + auto focusAdjacentToolbarWidget = [&toolbarFocusableWidgets](const QWidget* current, bool forward) { + const auto widgets = toolbarFocusableWidgets(); + int index = -1; + for (int i = 0; i < widgets.size(); ++i) { + if (widgets.at(i) == current) { + index = i; + break; + } + } + const int adjacent = index + (forward ? 1 : -1); + if (index < 0 || adjacent < 0 || adjacent >= widgets.size()) { + return false; + } + widgets.at(adjacent)->setFocus(forward ? Qt::TabFocusReason : Qt::BacktabFocusReason); + return true; + }; + if (next) { - if (m_searchWidget->hasFocus()) { - if (m_ui->tabWidget->count() > 1) { + if (toolbarFocused) { + if (!focusAdjacentToolbarWidget(focusWidget, true)) { + focusSearchWidget(); + } + } else if (searchFocused) { + if (multiTabs) { m_ui->tabWidget->setFocus(Qt::TabFocusReason); } else { dbWidget->setFocus(Qt::TabFocusReason); } - } else if (m_ui->tabWidget->hasFocus()) { + } else if (tabFocused) { dbWidget->setFocus(Qt::TabFocusReason); } else { - focusSearchWidget(); + focusToolbar(Qt::TabFocusReason); } } else { - if (m_searchWidget->hasFocus()) { - dbWidget->setFocus(Qt::BacktabFocusReason); - } else if (m_ui->tabWidget->hasFocus()) { + if (searchFocused) { + focusToolbar(Qt::BacktabFocusReason); + } else if (toolbarFocused) { + if (!focusAdjacentToolbarWidget(focusWidget, false)) { + dbWidget->setFocus(Qt::BacktabFocusReason); + } + } else if (tabFocused) { focusSearchWidget(); } else { - if (m_ui->tabWidget->count() > 1) { + if (multiTabs) { m_ui->tabWidget->setFocus(Qt::BacktabFocusReason); } else { focusSearchWidget(); @@ -1451,7 +1520,6 @@ bool MainWindow::focusNextPrevChild(bool next) // Defer to Qt to make a decision, this maintains normal behavior return QMainWindow::focusNextPrevChild(next); } - void MainWindow::focusSearchWidget() { if (m_searchWidgetAction->isEnabled()) { @@ -1585,6 +1653,9 @@ void MainWindow::updateProgressBar(int percentage, QString message) m_progressBar->setVisible(true); m_progressBarLabel->setText(message); m_progressBarLabel->setVisible(true); + + QAccessibleEvent accessibleEvent(m_progressBarLabel, QAccessible::NameChanged); + QAccessible::updateAccessibility(&accessibleEvent); } } @@ -1597,6 +1668,9 @@ void MainWindow::updateEntryCountLabel() } else { m_statusBarLabel->setText(""); } + + QAccessibleValueChangeEvent accessibleEvent(m_statusBarLabel, m_statusBarLabel->text()); + QAccessible::updateAccessibility(&accessibleEvent); } void MainWindow::obtainContextFocusLock() diff --git a/src/gui/MergeDialog.ui b/src/gui/MergeDialog.ui index b85bb20d6e..7ef46ef639 100644 --- a/src/gui/MergeDialog.ui +++ b/src/gui/MergeDialog.ui @@ -15,7 +15,17 @@ - + + + Database merge changes + + + Lists the changes that will be applied during the database merge. + + + false + + @@ -26,6 +36,10 @@ + + changeTable + buttonBox + diff --git a/src/gui/MessageBox.cpp b/src/gui/MessageBox.cpp index 5a67b4f53c..0a0d3e25e1 100644 --- a/src/gui/MessageBox.cpp +++ b/src/gui/MessageBox.cpp @@ -18,15 +18,46 @@ #include "MessageBox.h" +#include #include #include #include #include #include +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) +#include +#endif #include +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) +namespace +{ + QString accessiblePlainText(const QString& richText) + { + QTextDocument doc; + doc.setHtml(richText); + return doc.toPlainText(); + } +} // namespace +#endif + QWindow* MessageBox::m_overrideParent(nullptr); +void MessageBox::announce(QMessageBox& messageBox) +{ + messageBox.show(); + + QAccessibleEvent alertEvent(&messageBox, QAccessible::Alert); + QAccessible::updateAccessibility(&alertEvent); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(&messageBox, accessiblePlainText(messageBox.text())); + if (messageBox.icon() == QMessageBox::Warning || messageBox.icon() == QMessageBox::Critical) { + announcementEvent.setPoliteness(QAccessible::AnnouncementPoliteness::Assertive); + } + QAccessible::updateAccessibility(&announcementEvent); +#endif +} + MessageBox::Button MessageBox::m_nextAnswer(MessageBox::NoButton); QHash MessageBox::m_addedButtonLookup = @@ -132,6 +163,7 @@ MessageBox::Button MessageBox::messageBox(QWidget* parent, msgBox.raise(); } msgBox.layout()->setSizeConstraint(QLayout::SetMinimumSize); + announce(msgBox); msgBox.exec(); Button returnButton = m_addedButtonLookup[msgBox.clickedButton()]; diff --git a/src/gui/MessageBox.h b/src/gui/MessageBox.h index 1dbcc2076c..b32e776ef9 100644 --- a/src/gui/MessageBox.h +++ b/src/gui/MessageBox.h @@ -75,6 +75,7 @@ class MessageBox static void initializeButtonDefs(); static void setNextAnswer(Button button); + static void announce(QMessageBox& messageBox); static Button critical(QWidget* parent, const QString& title, diff --git a/src/gui/MessageWidget.cpp b/src/gui/MessageWidget.cpp index 6479dd280a..ec0b9d97ae 100644 --- a/src/gui/MessageWidget.cpp +++ b/src/gui/MessageWidget.cpp @@ -18,10 +18,22 @@ #include "MessageWidget.h" +#include #include +#include #include #include +namespace +{ + QString accessiblePlainText(const QString& richText) + { + QTextDocument doc; + doc.setHtml(richText); + return doc.toPlainText(); + } +} // namespace + const int MessageWidget::DefaultAutoHideTimeout = 6000; const int MessageWidget::LongAutoHideTimeout = 15000; const int MessageWidget::DisableAutoHide = -1; @@ -56,6 +68,10 @@ void MessageWidget::showMessage(const QString& text, KMessageWidget::MessageType setMessageType(type); setText(text); +#if QT_VERSION < QT_VERSION_CHECK(6, 8, 0) + setAccessibleName(accessiblePlainText(text)); +#endif + emit showAnimationStarted(); if (m_animate) { animatedShow(); @@ -64,6 +80,16 @@ void MessageWidget::showMessage(const QString& text, KMessageWidget::MessageType emit showAnimationFinished(); } + QAccessibleEvent alertEvent(this, QAccessible::Alert); + QAccessible::updateAccessibility(&alertEvent); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(this, accessiblePlainText(text)); + if (type == KMessageWidget::Error || type == KMessageWidget::Warning) { + announcementEvent.setPoliteness(QAccessible::AnnouncementPoliteness::Assertive); + } + QAccessible::updateAccessibility(&announcementEvent); +#endif + if (autoHideTimeout > 0) { m_autoHideTimer->start(autoHideTimeout); } else { diff --git a/src/gui/PasswordGeneratorWidget.cpp b/src/gui/PasswordGeneratorWidget.cpp index 0bc2d7d1e3..69a1dde8f0 100644 --- a/src/gui/PasswordGeneratorWidget.cpp +++ b/src/gui/PasswordGeneratorWidget.cpp @@ -19,6 +19,8 @@ #include "PasswordGeneratorWidget.h" #include "ui_PasswordGeneratorWidget.h" +#include +#include #include #include #include @@ -285,9 +287,32 @@ void PasswordGeneratorWidget::updatePasswordStrength() } // Update the entropy text labels - m_ui->entropyLabel->setText(tr("Entropy: %1 bit").arg(QString::number(passwordHealth.entropy(), 'f', 2))); + const auto entropyText = QString::number(passwordHealth.entropy(), 'f', 2); + m_ui->entropyLabel->setText(tr("Entropy: %1 bit").arg(entropyText)); m_ui->entropyProgressBar->setValue(std::min(int(passwordHealth.entropy()), m_ui->entropyProgressBar->maximum())); + // Keep the exact entropy and quality available to assistive technology. + // The progress bar's exposed range/value may otherwise be reported as a + // percentage, which does not communicate the actual entropy in bits. + QString qualityText; + switch (passwordHealth.quality()) { + case PasswordHealth::Quality::Bad: + case PasswordHealth::Quality::Poor: + qualityText = tr("Poor", "Password quality"); + break; + case PasswordHealth::Quality::Weak: + qualityText = tr("Weak", "Password quality"); + break; + case PasswordHealth::Quality::Good: + qualityText = tr("Good", "Password quality"); + break; + case PasswordHealth::Quality::Excellent: + qualityText = tr("Excellent", "Password quality"); + break; + } + m_ui->entropyProgressBar->setAccessibleDescription( + tr("Password entropy: %1 bits. Password quality: %2.").arg(entropyText, qualityText)); + // Update the visual strength meter QString style = m_ui->entropyProgressBar->styleSheet(); QRegularExpression re("(QProgressBar::chunk\\s*\\{.*?background-color:)[^;]+;", @@ -461,16 +486,34 @@ void PasswordGeneratorWidget::setAdvancedMode(bool advanced) { saveSettings(); + m_ui->buttonAdvancedMode->setAccessibleDescription( + advanced ? tr("Hide advanced password generation options") : tr("Show advanced password generation options")); + if (advanced) { + m_ui->buttonAdvancedMode->setToolTip(tr("Switch to basic mode")); m_ui->checkBoxSpecialChars->setText("# $ % && @ ^ ` ~"); m_ui->checkBoxSpecialChars->setToolTip(tr("Logograms")); + m_ui->checkBoxSpecialChars->setAccessibleName(tr("Logograms")); m_ui->checkBoxSpecialChars->setChecked(config()->get(Config::PasswordGenerator_Logograms).toBool()); } else { + m_ui->buttonAdvancedMode->setToolTip(tr("Switch to advanced mode")); m_ui->checkBoxSpecialChars->setText("/ * + && …"); m_ui->checkBoxSpecialChars->setToolTip(tr("Special Characters")); + m_ui->checkBoxSpecialChars->setAccessibleName(tr("Special characters")); m_ui->checkBoxSpecialChars->setChecked(config()->get(Config::PasswordGenerator_SpecialChars).toBool()); } + // Switching back to basic mode hides the advanced options. If keyboard or + // screen-reader focus is inside that container, move it to the still-visible + // mode toggle before hiding the container. + if (!advanced) { + auto* focusedWidget = QApplication::focusWidget(); + if (focusedWidget + && (focusedWidget == m_ui->advancedContainer || m_ui->advancedContainer->isAncestorOf(focusedWidget))) { + m_ui->buttonAdvancedMode->setFocus(Qt::OtherFocusReason); + } + } + m_ui->advancedContainer->setVisible(advanced); m_ui->checkBoxBraces->setVisible(advanced); m_ui->checkBoxPunctuation->setVisible(advanced); @@ -574,6 +617,11 @@ PasswordGenerator::GeneratorFlags PasswordGeneratorWidget::generatorFlags() void PasswordGeneratorWidget::updateGenerator() { if (m_ui->tabWidget->currentIndex() == Password) { + // The passphrase wordlist warning is only meaningful on the Passphrase tab. + // Hide it when switching back to password generation so it does not remain + // in the screen reader's accessible tree as stale status information. + m_ui->labelWordListWarning->setVisible(false); + auto classes = charClasses(); auto flags = generatorFlags(); @@ -596,6 +644,9 @@ void PasswordGeneratorWidget::updateGenerator() auto path = m_ui->comboBoxWordList->currentData().toString(); if (m_ui->comboBoxWordList->currentIndex() < m_firstCustomWordlistIndex) { path = resources()->wordlistPath(path); + if (m_ui->buttonDeleteWordList->hasFocus()) { + m_ui->comboBoxWordList->setFocus(Qt::OtherFocusReason); + } m_ui->buttonDeleteWordList->setEnabled(false); } else { m_ui->buttonDeleteWordList->setEnabled(true); @@ -604,7 +655,16 @@ void PasswordGeneratorWidget::updateGenerator() m_dicewareGenerator->setWordSeparator(m_ui->editWordSeparator->text()); - m_ui->labelWordListWarning->setVisible(!m_dicewareGenerator->isWordListValid()); + const bool wordListWarningVisible = !m_dicewareGenerator->isWordListValid(); + const bool wordListWarningChanged = m_ui->labelWordListWarning->isVisible() != wordListWarningVisible; + m_ui->labelWordListWarning->setVisible(wordListWarningVisible); + if (wordListWarningChanged && wordListWarningVisible) { + m_ui->labelWordListWarning->setAccessibleName(tr("Wordlist warning")); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(m_ui->labelWordListWarning, m_ui->labelWordListWarning->text()); + QAccessible::updateAccessibility(&announcementEvent); +#endif + } m_ui->buttonGenerate->setEnabled(true); } diff --git a/src/gui/PasswordGeneratorWidget.ui b/src/gui/PasswordGeneratorWidget.ui index eac5421cdb..0e631a8e5d 100644 --- a/src/gui/PasswordGeneratorWidget.ui +++ b/src/gui/PasswordGeneratorWidget.ui @@ -154,6 +154,12 @@ + + Password entropy + + + Shows the generated password entropy from 0 to 200 bits. + 50 @@ -207,6 +213,9 @@ QProgressBar::chunk { Qt::TabFocus + + Regenerate password + Regenerate password @@ -220,6 +229,9 @@ QProgressBar::chunk { Qt::TabFocus + + Copy password + Copy password @@ -235,6 +247,12 @@ QProgressBar::chunk { + + Password generation type + + + Choose between password and passphrase generation + 0 @@ -319,6 +337,12 @@ QProgressBar::chunk { + + Advanced mode + + + Show advanced password generation options + Qt::TabFocus @@ -672,7 +696,7 @@ QProgressBar::chunk { Add non-hex letters to "do not include" list - Hex Passwords + Add non-hex letters to excluded characters Hex @@ -715,6 +739,9 @@ QProgressBar::chunk { + + Exclude look-alike characters + Excluded characters: "0", "1", "l", "I", "O", "|", "﹒", "B", "8", "G", "6" @@ -731,6 +758,9 @@ QProgressBar::chunk { + + Pick characters from every group + Pick characters from every group @@ -786,6 +816,9 @@ QProgressBar::chunk { + + Word separator + 0 @@ -824,7 +857,7 @@ QProgressBar::chunk { Word Count: - spinBoxLength + spinBoxWordCount @@ -833,12 +866,18 @@ QProgressBar::chunk { Word Case: + + wordCaseComboBox + + + Wordlist + 0 @@ -852,6 +891,9 @@ QProgressBar::chunk { Qt::TabFocus + + Delete selected wordlist + Delete selected wordlist @@ -865,6 +907,9 @@ QProgressBar::chunk { Qt::TabFocus + + Add custom wordlist + Add custom wordlist @@ -882,6 +927,9 @@ QProgressBar::chunk { + + Word count + 1 @@ -907,6 +955,9 @@ QProgressBar::chunk { + + Word count + 1 @@ -923,7 +974,11 @@ QProgressBar::chunk { - + + + Word case + + @@ -945,6 +1000,9 @@ QProgressBar::chunk { Word Separator: + + editWordSeparator + @@ -1002,6 +1060,9 @@ QProgressBar::chunk { + + Close + Close @@ -1012,6 +1073,9 @@ QProgressBar::chunk { + + Apply password + Apply Password diff --git a/src/gui/PasswordWidget.cpp b/src/gui/PasswordWidget.cpp index d1047ae33e..a959c0793f 100644 --- a/src/gui/PasswordWidget.cpp +++ b/src/gui/PasswordWidget.cpp @@ -27,9 +27,12 @@ #include "gui/osutils/OSUtils.h" #include "gui/styles/StateColorPalette.h" +#include +#include #include #include #include +#include #include PasswordWidget::PasswordWidget(QWidget* parent) @@ -39,6 +42,7 @@ PasswordWidget::PasswordWidget(QWidget* parent) m_ui->setupUi(this); setFocusProxy(m_ui->passwordEdit); m_ui->passwordEdit->installEventFilter(this); + m_defaultAccessibleDescription = m_ui->passwordEdit->accessibleDescription(); const QIcon errorIcon = icons()->icon("dialog-error"); m_errorAction = m_ui->passwordEdit->addAction(errorIcon, QLineEdit::TrailingPosition); @@ -78,6 +82,37 @@ PasswordWidget::PasswordWidget(QWidget* parent) m_ui->passwordEdit->addAction(m_passwordGeneratorAction, QLineEdit::TrailingPosition); m_passwordGeneratorAction->setVisible(false); + // QLineEdit::addAction() renders each action as an internal + // QLineEditIconButton (a QToolButton parented to the line edit), but that + // button defaults to Qt::NoFocus -- it responds to a mouse click but is + // skipped entirely by Tab and never receives keyboard focus. Confirmed + // live via UIA: every other checkbox in a dialog using this widget + // reports "focusable"; this action's button doesn't. That leaves the + // toggle/generate controls reachable only by already knowing their + // Ctrl+H / Ctrl+G shortcuts, which a keyboard-only or screen-reader user + // exploring the form by Tab has no way to discover. Qt::TabFocus (not + // StrongFocus) is enough here since mouse click-to-activate already + // works regardless of focus policy; this only adds Tab reachability. + for (QAction* action : {m_toggleVisibleAction.data(), m_passwordGeneratorAction.data()}) { + for (QToolButton* button : m_ui->passwordEdit->findChildren()) { + if (button->defaultAction() == action) { + button->setFocusPolicy(Qt::TabFocus); + if (action == m_toggleVisibleAction) { + button->setAccessibleName(tr("Toggle password visibility")); + button->setAccessibleDescription( + tr("Shortcut: %1") + .arg(QKeySequence(action->shortcut()).toString(QKeySequence::NativeText))); + } else if (action == m_passwordGeneratorAction) { + button->setAccessibleName(tr("Generate password")); + button->setAccessibleDescription( + tr("Shortcut: %1") + .arg(QKeySequence(action->shortcut()).toString(QKeySequence::NativeText))); + } + break; + } + } + } + m_capslockAction = new QAction(icons()->icon("dialog-warning", true, StateColorPalette().color(StateColorPalette::Error)), tr("Warning: Caps Lock enabled!"), @@ -104,6 +139,19 @@ PasswordWidget::~PasswordWidget() { } +void PasswordWidget::setAccessibleName(const QString& name) +{ + // Forward to the focus proxy: this is the widget a screen reader actually + // reports when the user tabs into this control. + m_ui->passwordEdit->setAccessibleName(name); + QWidget::setAccessibleName(name); +} + +QString PasswordWidget::accessibleName() const +{ + return m_ui->passwordEdit->accessibleName(); +} + void PasswordWidget::setQualityVisible(bool state) { m_ui->qualityProgressBar->setVisible(state); @@ -155,6 +203,22 @@ void PasswordWidget::setRepeatPartner(PasswordWidget* repeatPartner) void PasswordWidget::setParentPasswordEdit(PasswordWidget* parent) { m_parentPasswordWidget = parent; + + // These actions are keyboard-focusable. If either internal QLineEdit action + // button currently has focus, hiding it would remove the focused widget from + // the focus chain. Return focus to the password editor before hiding them. + if (auto* focusedWidget = QApplication::focusWidget(); + focusedWidget && m_ui->passwordEdit->isAncestorOf(focusedWidget)) { + for (QToolButton* button : m_ui->passwordEdit->findChildren()) { + if (button == focusedWidget + && (button->defaultAction() == m_toggleVisibleAction + || button->defaultAction() == m_passwordGeneratorAction)) { + m_ui->passwordEdit->setFocus(Qt::OtherFocusReason); + break; + } + } + } + // Hide actions m_toggleVisibleAction->setVisible(false); m_passwordGeneratorAction->setVisible(false); @@ -206,6 +270,7 @@ void PasswordWidget::updateRepeatStatus() const auto otherPassword = m_parentPasswordWidget->text(); const auto password = text(); + QString statusText; if (otherPassword != password) { bool isCorrect = false; StateColorPalette statePalette; @@ -217,10 +282,36 @@ void PasswordWidget::updateRepeatStatus() setStyleSheet(stylesheetTemplate.arg(color.name())); m_correctAction->setVisible(isCorrect); m_errorAction->setVisible(!isCorrect); + statusText = isCorrect ? m_correctAction->toolTip() : m_errorAction->toolTip(); } else { m_correctAction->setVisible(false); m_errorAction->setVisible(false); setStyleSheet(""); + statusText = password.isEmpty() ? QString() : tr("Passwords match"); + } + + // The match state above is otherwise conveyed only by background color and a + // trailing icon (m_correctAction/m_errorAction) -- neither perceivable by a + // screen reader user typing in this field. accessibleDescription is set from + // the fixed strings above, never from password content, so this cannot leak + // what was typed. Set it on the focus proxy (m_ui->passwordEdit), since that's + // the object a screen reader actually reports on (see setAccessibleName() + // above), and fire Alert the same way MessageWidget::showMessage() does so the + // change is announced immediately without moving keyboard focus. + if (statusText != m_ui->passwordEdit->accessibleDescription()) { + m_ui->passwordEdit->setAccessibleDescription(statusText.isEmpty() ? m_defaultAccessibleDescription : statusText); + if (!statusText.isEmpty()) { + QAccessibleEvent alertEvent(m_ui->passwordEdit, QAccessible::Alert); + QAccessible::updateAccessibility(&alertEvent); + // Alert alone never reaches Windows UI Automation as an event + // (confirmed against qtbase's qwindowsuiaaccessibility.cpp -- + // only a system sound). QAccessibleAnnouncementEvent (Qt 6.8+) + // is what actually raises a UIA notification. +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(m_ui->passwordEdit, statusText); + QAccessible::updateAccessibility(&announcementEvent); +#endif + } } } @@ -266,8 +357,19 @@ void PasswordWidget::checkCapslockState() if (newCapslockState) { QTimer::singleShot( 150, [this] { QToolTip::showText(mapToGlobal(rect().bottomLeft()), m_capslockAction->text()); }); - } else if (QToolTip::isVisible()) { - QToolTip::hideText(); + m_ui->passwordEdit->setAccessibleDescription(tr("Warning: Caps Lock enabled!")); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(m_ui->passwordEdit, tr("Warning: Caps Lock enabled!")); + QAccessible::updateAccessibility(&announcementEvent); +#endif + } else { + // The tooltip can disappear independently of the Caps Lock state. + // Keep the screen-reader state synchronized even when there is no + // tooltip left to hide. + m_ui->passwordEdit->setAccessibleDescription(m_defaultAccessibleDescription); + if (QToolTip::isVisible()) { + QToolTip::hideText(); + } } } } @@ -277,6 +379,7 @@ void PasswordWidget::updatePasswordStrength(const QString& password) if (password.isEmpty()) { m_ui->qualityProgressBar->setValue(0); m_ui->qualityProgressBar->setToolTip((tr(""))); + m_ui->qualityProgressBar->setAccessibleDescription(tr("Password quality: Not available")); return; } @@ -322,4 +425,23 @@ void PasswordWidget::updatePasswordStrength(const QString& password) break; } + + QString qualityText; + switch (health.quality()) { + case PasswordHealth::Quality::Bad: + case PasswordHealth::Quality::Poor: + qualityText = tr("Poor", "Password quality"); + break; + case PasswordHealth::Quality::Weak: + qualityText = tr("Weak", "Password quality"); + break; + case PasswordHealth::Quality::Good: + qualityText = tr("Good", "Password quality"); + break; + case PasswordHealth::Quality::Excellent: + qualityText = tr("Excellent", "Password quality"); + break; + } + m_ui->qualityProgressBar->setAccessibleDescription( + tr("Password quality: %1.").arg(qualityText)); } diff --git a/src/gui/PasswordWidget.h b/src/gui/PasswordWidget.h index 602860122f..343c0ad43f 100644 --- a/src/gui/PasswordWidget.h +++ b/src/gui/PasswordWidget.h @@ -46,6 +46,18 @@ class PasswordWidget : public QWidget bool eventFilter(QObject* watched, QEvent* event) override; + // QWidget::setAccessibleName()/accessibleName() are not virtual, but since + // PasswordWidget always installs a focus proxy (see constructor), keyboard + // focus -- and therefore what a screen reader like JAWS actually announces + // -- lands on the internal QLineEdit, not on this outer container widget. + // Hiding the base implementations here ensures that callers who set + // accessibleName on a PasswordWidget instance (statically typed as + // PasswordWidget*, e.g. generated setupUi() code from a .ui file) reach the + // widget that is actually announced, instead of silently naming a widget + // no screen reader ever reports on. + void setAccessibleName(const QString& name); + QString accessibleName() const; + signals: void textChanged(QString text); void requestPlaceholderResolution(const QString& rawText, QString& resolvedText); @@ -82,6 +94,7 @@ private slots: QPointer m_repeatPasswordWidget; QPointer m_parentPasswordWidget; + QString m_defaultAccessibleDescription; bool m_capslockState = false; }; diff --git a/src/gui/PasswordWidget.ui b/src/gui/PasswordWidget.ui index 97fe71e088..a5a0b13118 100644 --- a/src/gui/PasswordWidget.ui +++ b/src/gui/PasswordWidget.ui @@ -35,6 +35,12 @@ + + Password quality + + + Shows the quality of the password currently entered. + 16777215 diff --git a/src/gui/SearchWidget.cpp b/src/gui/SearchWidget.cpp index 13c08318ae..f1d7609c9d 100644 --- a/src/gui/SearchWidget.cpp +++ b/src/gui/SearchWidget.cpp @@ -20,6 +20,7 @@ #include "ui_SearchHelpWidget.h" #include "ui_SearchWidget.h" +#include #include #include #include @@ -232,6 +233,10 @@ void SearchWidget::focusSearch() void SearchWidget::clearSearch() { m_ui->searchEdit->clear(); + if (m_ui->saveIcon->isVisible() && qobject_cast(QApplication::focusWidget()) + && qobject_cast(QApplication::focusWidget())->defaultAction() == m_ui->saveIcon) { + m_ui->searchEdit->setFocus(Qt::OtherFocusReason); + } m_ui->saveIcon->setVisible(false); emit searchCanceled(); } @@ -272,5 +277,10 @@ void SearchWidget::performRequestedSearch(const QString& text) void SearchWidget::updateSaveButtonVisibility() { // Show save button whenever there's non-empty text in the search field - m_ui->saveIcon->setVisible(!m_ui->searchEdit->text().isEmpty()); + const bool visible = !m_ui->searchEdit->text().isEmpty(); + if (!visible && m_ui->saveIcon->isVisible() && qobject_cast(QApplication::focusWidget()) + && qobject_cast(QApplication::focusWidget())->defaultAction() == m_ui->saveIcon) { + m_ui->searchEdit->setFocus(Qt::OtherFocusReason); + } + m_ui->saveIcon->setVisible(visible); } diff --git a/src/gui/ShortcutSettingsPage.cpp b/src/gui/ShortcutSettingsPage.cpp index ffc6d32e8d..00037bd72e 100644 --- a/src/gui/ShortcutSettingsPage.cpp +++ b/src/gui/ShortcutSettingsPage.cpp @@ -45,6 +45,7 @@ class KeySequenceDialog final : public QDialog | QDialogButtonBox::RestoreDefaults, this)) { + setWindowTitle(QObject::tr("Change Shortcut")); auto* l = new QVBoxLayout(this); connect(m_btnBox, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(m_btnBox, &QDialogButtonBox::rejected, this, &QDialog::reject); @@ -100,7 +101,7 @@ class ShortcutSettingsWidget final : public QWidget h->setStretch(0, 1); auto l = new QVBoxLayout(this); - l->addWidget(new QLabel(QObject::tr("Double click an action to change its shortcut"))); + l->addWidget(new QLabel(QObject::tr("Double click or press Enter on an action to change its shortcut"))); l->addLayout(h); l->addWidget(m_tableView); @@ -133,6 +134,7 @@ class ShortcutSettingsWidget final : public QWidget m_tableView->setSelectionBehavior(QAbstractItemView::SelectRows); connect(m_tableView, &QTableView::doubleClicked, this, &ShortcutSettingsWidget::onDoubleClicked); + connect(m_tableView, &QTableView::activated, this, &ShortcutSettingsWidget::onDoubleClicked); } void loadSettings() diff --git a/src/gui/TotpDialog.cpp b/src/gui/TotpDialog.cpp index 7b794c08d1..8ce85ec08e 100644 --- a/src/gui/TotpDialog.cpp +++ b/src/gui/TotpDialog.cpp @@ -24,6 +24,7 @@ #include "gui/Clipboard.h" #include "gui/MainWindow.h" +#include #include #include @@ -48,7 +49,13 @@ TotpDialog::TotpDialog(QWidget* parent, Entry* entry) new QShortcut(QKeySequence(QKeySequence::Copy), this, SLOT(copyToClipboard())); - m_ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Copy")); + auto* closeButton = m_ui->buttonBox->button(QDialogButtonBox::Cancel); + auto* copyButton = m_ui->buttonBox->button(QDialogButtonBox::Ok); + copyButton->setText(tr("Copy")); + setTabOrder(m_ui->totpLabel, closeButton); + setTabOrder(closeButton, copyButton); + m_ui->totpLabel->setFocusPolicy(Qt::StrongFocus); + m_ui->totpLabel->setFocus(Qt::OtherFocusReason); connect(m_ui->buttonBox, SIGNAL(rejected()), SLOT(close())); connect(m_ui->buttonBox, SIGNAL(accepted()), SLOT(copyToClipboard())); @@ -84,7 +91,16 @@ void TotpDialog::updateProgressBar() void TotpDialog::updateSeconds() { uint epoch = Clock::currentSecondsSinceEpoch() - 1; - m_ui->timerLabel->setText(tr("Expires in %n second(s)", "", m_step - (epoch % m_step))); + const auto remaining = m_step - (epoch % m_step); + m_ui->timerLabel->setText(tr("Expires in %n second(s)", "", remaining)); + + if (remaining <= 10 && remaining >= 1) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent( + m_ui->timerLabel, tr("TOTP code expires in %n second(s)", "", remaining)); + QAccessible::updateAccessibility(&announcementEvent); +#endif + } } void TotpDialog::updateTotp() @@ -94,10 +110,18 @@ void TotpDialog::updateTotp() if (isValid) { totpCode.insert(totpCode.size() / 2, " "); } - m_ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(isValid); + + auto* copyButton = m_ui->buttonBox->button(QDialogButtonBox::Ok); + if (!isValid && copyButton->hasFocus()) { + m_ui->buttonBox->button(QDialogButtonBox::Cancel)->setFocus(Qt::OtherFocusReason); + } + copyButton->setEnabled(isValid); m_ui->progressBar->setVisible(isValid); m_ui->timerLabel->setVisible(isValid); m_ui->totpLabel->setText(totpCode); + + // Keep the code available when the focused label is read, but do not + // announce the secret automatically through assistive technology. } void TotpDialog::resetCounter() diff --git a/src/gui/TotpDialog.ui b/src/gui/TotpDialog.ui index 4ab386604e..ed77a54dc3 100644 --- a/src/gui/TotpDialog.ui +++ b/src/gui/TotpDialog.ui @@ -15,12 +15,24 @@ - + + + TOTP code + + + Current six-digit timed password. + 53 + + true + + + false + 000000 @@ -31,6 +43,12 @@ + + TOTP expiration progress + + + Shows the remaining time before the current timed password expires. + 0 diff --git a/src/gui/TotpExportSettingsDialog.cpp b/src/gui/TotpExportSettingsDialog.cpp index 66d6fb1d27..1714250dbe 100644 --- a/src/gui/TotpExportSettingsDialog.cpp +++ b/src/gui/TotpExportSettingsDialog.cpp @@ -23,10 +23,12 @@ #include "gui/SquareSvgWidget.h" #include "qrcode/QrCode.h" +#include #include #include #include #include +#include #include #include #include @@ -40,14 +42,22 @@ TotpExportSettingsDialog::TotpExportSettingsDialog(DatabaseWidget* parent, Entry , m_totpSvgWidget(new SquareSvgWidget(m_totpSvgContainerWidget)) , m_countDown(new QLabel()) , m_warningLabel(new QLabel()) + , m_accessibilityText(new QLineEdit()) , m_buttonBox(new QDialogButtonBox(QDialogButtonBox::Close | QDialogButtonBox::Ok)) { setObjectName("entryQrCodeWidget"); m_totpSvgContainerWidget->addWidget(m_totpSvgWidget); + m_totpSvgWidget->setAccessibleName(tr("TOTP QR code")); + m_totpSvgWidget->setFocusPolicy(Qt::StrongFocus); m_verticalLayout->addWidget(m_warningLabel); m_verticalLayout->addItem(new QSpacerItem(0, 0)); m_verticalLayout->addWidget(m_totpSvgContainerWidget); + m_accessibilityText->setReadOnly(true); + m_accessibilityText->setFrame(false); + m_accessibilityText->setAccessibleName(tr("TOTP QR code instructions")); + m_accessibilityText->setText(tr("TOTP QR code. Scan this QR code with an authenticator app to configure TOTP.")); + m_verticalLayout->addWidget(m_accessibilityText); m_verticalLayout->addWidget(m_countDown); m_verticalLayout->addWidget(m_buttonBox); @@ -65,6 +75,9 @@ TotpExportSettingsDialog::TotpExportSettingsDialog(DatabaseWidget* parent, Entry m_buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Copy")); m_buttonBox->setFocus(); m_countDown->setAlignment(Qt::AlignCenter); + setTabOrder(m_totpSvgWidget, m_accessibilityText); + setTabOrder(m_accessibilityText, m_buttonBox->button(QDialogButtonBox::Cancel)); + setTabOrder(m_buttonBox->button(QDialogButtonBox::Cancel), m_buttonBox->button(QDialogButtonBox::Ok)); m_secTillClose = 45; autoClose(); @@ -87,6 +100,13 @@ TotpExportSettingsDialog::TotpExportSettingsDialog(DatabaseWidget* parent, Entry QBuffer buffer; qrc.writeSvg(&buffer, logicalDpiX()); m_totpSvgWidget->load(buffer.data()); + m_totpSvgWidget->setAccessibleDescription( + tr("Scan this QR code with an authenticator app to configure TOTP.")); + m_totpSvgWidget->setFocus(Qt::OtherFocusReason); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(m_totpSvgWidget, tr("TOTP QR code")); + QAccessible::updateAccessibility(&announcementEvent); +#endif const auto minsize = static_cast(logicalDpiX() * 2.5); m_totpSvgWidget->setMinimumSize(minsize, minsize); } else { @@ -115,7 +135,18 @@ void TotpExportSettingsDialog::copyToClipboard() void TotpExportSettingsDialog::autoClose() { if (--m_secTillClose > 0) { - m_countDown->setText(tr("Closing in %1 seconds.").arg(m_secTillClose)); + const auto countdownText = tr("Closing in %1 seconds.").arg(m_secTillClose); + m_countDown->setText(countdownText); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + // The dialog closes automatically. Announce the final countdown milestones + // so screen-reader users are not left unaware that the window is about to + // disappear while focus remains on another control. + if (m_secTillClose <= 5 || m_secTillClose == 10) { + QAccessibleAnnouncementEvent announcementEvent(m_countDown, countdownText); + announcementEvent.setPoliteness(QAccessible::AnnouncementPoliteness::Assertive); + QAccessible::updateAccessibility(&announcementEvent); + } +#endif } else { m_timer->stop(); close(); diff --git a/src/gui/TotpExportSettingsDialog.h b/src/gui/TotpExportSettingsDialog.h index c36836f1ae..71e676a365 100644 --- a/src/gui/TotpExportSettingsDialog.h +++ b/src/gui/TotpExportSettingsDialog.h @@ -26,6 +26,7 @@ class QVBoxLayout; class SquareSvgWidget; class QLabel; +class QLineEdit; class QDialogButtonBox; class TotpExportSettingsDialog : public QDialog @@ -50,6 +51,7 @@ private slots: SquareSvgWidget* m_totpSvgWidget; QLabel* m_countDown; QLabel* m_warningLabel; + QLineEdit* m_accessibilityText; QDialogButtonBox* m_buttonBox; }; diff --git a/src/gui/TotpSetupDialog.cpp b/src/gui/TotpSetupDialog.cpp index e7e0bd7498..f92c1c8337 100644 --- a/src/gui/TotpSetupDialog.cpp +++ b/src/gui/TotpSetupDialog.cpp @@ -15,6 +15,9 @@ * along with this program. If not, see . */ +#include +#include + #include "TotpSetupDialog.h" #include "ui_TotpSetupDialog.h" @@ -35,12 +38,51 @@ TotpSetupDialog::TotpSetupDialog(QWidget* parent, Entry* entry) connect(m_ui->buttonBox, SIGNAL(rejected()), SLOT(close())); connect(m_ui->buttonBox, SIGNAL(accepted()), SLOT(saveSettings())); connect(m_ui->radioCustom, SIGNAL(toggled(bool)), SLOT(toggleCustom(bool))); + connect(m_ui->algorithmComboBox, SIGNAL(currentIndexChanged(int)), SLOT(algorithmChanged(int))); + connect(m_ui->stepSpinBox, SIGNAL(valueChanged(int)), SLOT(stepChanged(int))); + connect(m_ui->digitsSpinBox, SIGNAL(valueChanged(int)), SLOT(digitsChanged(int))); init(); } TotpSetupDialog::~TotpSetupDialog() = default; +void TotpSetupDialog::algorithmChanged(int value) +{ + Q_UNUSED(value); + + const auto text = m_ui->algorithmComboBox->currentText(); + QAccessibleValueChangeEvent event(m_ui->algorithmComboBox, text); + QAccessible::updateAccessibility(&event); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(m_ui->algorithmComboBox, text); + QAccessible::updateAccessibility(&announcementEvent); +#endif +} + +void TotpSetupDialog::stepChanged(int value) +{ + QAccessibleValueChangeEvent event(m_ui->stepSpinBox, value); + QAccessible::updateAccessibility(&event); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(m_ui->stepSpinBox, QString::number(value)); + QAccessible::updateAccessibility(&announcementEvent); +#endif +} + +void TotpSetupDialog::digitsChanged(int value) +{ + QAccessibleValueChangeEvent event(m_ui->digitsSpinBox, value); + QAccessible::updateAccessibility(&event); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(m_ui->digitsSpinBox, QString::number(value)); + QAccessible::updateAccessibility(&announcementEvent); +#endif +} + void TotpSetupDialog::saveSettings() { // Secret key sanity check @@ -97,6 +139,9 @@ void TotpSetupDialog::saveSettings() void TotpSetupDialog::toggleCustom(bool status) { + if (!status && m_ui->customSettingsGroup->isAncestorOf(QApplication::focusWidget())) { + m_ui->radioCustom->setFocus(Qt::OtherFocusReason); + } m_ui->customSettingsGroup->setEnabled(status); } @@ -130,6 +175,11 @@ void TotpSetupDialog::init() } auto error = Totp::checkValidSettings(settings); - m_ui->invalidKeyLabel->setVisible(!error.isEmpty()); + const bool invalid = !error.isEmpty(); + m_ui->invalidKeyLabel->setVisible(invalid); + m_ui->seedEdit->setAccessibleDescription(invalid ? tr("Error: secret key is invalid") : QString()); + if (invalid) { + QAccessible::updateAccessibility(new QAccessibleEvent(m_ui->invalidKeyLabel, QAccessible::Alert)); + } } } diff --git a/src/gui/TotpSetupDialog.h b/src/gui/TotpSetupDialog.h index 8b88cb8e0e..2a3dca05bc 100644 --- a/src/gui/TotpSetupDialog.h +++ b/src/gui/TotpSetupDialog.h @@ -43,6 +43,9 @@ class TotpSetupDialog : public QDialog private slots: void toggleCustom(bool status); + void algorithmChanged(int value); + void stepChanged(int value); + void digitsChanged(int value); void saveSettings(); private: diff --git a/src/gui/TotpSetupDialog.ui b/src/gui/TotpSetupDialog.ui index f8c95f4c45..c5d7ed3cb0 100644 --- a/src/gui/TotpSetupDialog.ui +++ b/src/gui/TotpSetupDialog.ui @@ -155,10 +155,17 @@ Algorithm: + + algorithmComboBox + - + + + TOTP algorithm + + @@ -191,10 +198,16 @@ Code size: + + digitsSpinBox + + + TOTP code size + digits @@ -236,6 +249,7 @@ algorithmComboBox stepSpinBox digitsSpinBox + buttonBox diff --git a/src/gui/URLEdit.cpp b/src/gui/URLEdit.cpp index 33d15e2605..07b61fd64c 100644 --- a/src/gui/URLEdit.cpp +++ b/src/gui/URLEdit.cpp @@ -22,6 +22,8 @@ #include "gui/UrlTools.h" #include "gui/styles/StateColorPalette.h" +#include + URLEdit::URLEdit(QWidget* parent) : QLineEdit(parent) { @@ -55,8 +57,12 @@ void URLEdit::updateStylesheet(const QString& url) const auto color = statePalette.color(StateColorPalette::ColorRole::Error); setStyleSheet(stylesheetTemplate.arg(color.name())); m_errorAction->setVisible(true); + setAccessibleDescription(tr("Invalid URL")); + QAccessibleEvent alertEvent(this, QAccessible::Alert); + QAccessible::updateAccessibility(&alertEvent); } else { m_errorAction->setVisible(false); setStyleSheet(""); + setAccessibleDescription({}); } } diff --git a/src/gui/UpdateCheckDialog.cpp b/src/gui/UpdateCheckDialog.cpp index 9e3d871497..7050c176e7 100644 --- a/src/gui/UpdateCheckDialog.cpp +++ b/src/gui/UpdateCheckDialog.cpp @@ -18,6 +18,7 @@ #include "UpdateCheckDialog.h" #include "ui_UpdateCheckDialog.h" +#include #include #include "config-keepassx.h" @@ -58,6 +59,13 @@ void UpdateCheckDialog::showUpdateCheckResponse(bool hasUpdate, const QString& v } else { m_ui->statusLabel->setText(tr("You have the latest version of KeePassXC")); } + + QAccessibleEvent statusChanged(m_ui->statusLabel, QAccessible::Alert); + QAccessible::updateAccessibility(&statusChanged); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(m_ui->statusLabel, m_ui->statusLabel->text()); + QAccessible::updateAccessibility(&announcementEvent); +#endif } UpdateCheckDialog::~UpdateCheckDialog() diff --git a/src/gui/WelcomeWidget.cpp b/src/gui/WelcomeWidget.cpp index 98b0e360e8..1767d35bdd 100644 --- a/src/gui/WelcomeWidget.cpp +++ b/src/gui/WelcomeWidget.cpp @@ -18,6 +18,7 @@ #include "WelcomeWidget.h" #include "ui_WelcomeWidget.h" +#include #include #include "config-keepassx.h" @@ -53,6 +54,7 @@ WelcomeWidget::WelcomeWidget(QWidget* parent) SIGNAL(itemActivated(QListWidgetItem*)), this, SLOT(openDatabaseFromFile(QListWidgetItem*))); + m_ui->recentListWidget->installEventFilter(this); } WelcomeWidget::~WelcomeWidget() = default; @@ -95,17 +97,17 @@ void WelcomeWidget::refreshLastDatabases() m_ui->recentLabel->setVisible(recent_visibility); } -void WelcomeWidget::keyPressEvent(QKeyEvent* event) +bool WelcomeWidget::eventFilter(QObject* watched, QEvent* event) { - if (m_ui->recentListWidget->hasFocus()) { - if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { - openDatabaseFromFile(m_ui->recentListWidget->currentItem()); - } else if (event->key() == Qt::Key_Delete || event->key() == Qt::Key_Backspace) { + if (watched == m_ui->recentListWidget && event->type() == QEvent::KeyPress) { + auto* keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Delete || keyEvent->key() == Qt::Key_Backspace) { removeFromLastDatabases(m_ui->recentListWidget->currentItem()); + return true; } } - QWidget::keyPressEvent(event); + return QWidget::eventFilter(watched, event); } void WelcomeWidget::showEvent(QShowEvent* event) diff --git a/src/gui/WelcomeWidget.h b/src/gui/WelcomeWidget.h index ed92c7b4b7..19c42fb981 100644 --- a/src/gui/WelcomeWidget.h +++ b/src/gui/WelcomeWidget.h @@ -42,7 +42,7 @@ class WelcomeWidget : public QWidget void importFile(); protected: - void keyPressEvent(QKeyEvent* event) override; + bool eventFilter(QObject* watched, QEvent* event) override; void showEvent(QShowEvent* event) override; private slots: diff --git a/src/gui/csvImport/CsvImportWidget.cpp b/src/gui/csvImport/CsvImportWidget.cpp index c319818915..28198af2b4 100644 --- a/src/gui/csvImport/CsvImportWidget.cpp +++ b/src/gui/csvImport/CsvImportWidget.cpp @@ -74,7 +74,6 @@ CsvImportWidget::CsvImportWidget(QWidget* parent) m_ui->setupUi(this); m_ui->tableViewFields->setSelectionMode(QAbstractItemView::NoSelection); - m_ui->tableViewFields->setFocusPolicy(Qt::NoFocus); m_columnHeader << QObject::tr("Group") << QObject::tr("Title") << QObject::tr("Username") << QObject::tr("Password") << QObject::tr("URL") << QObject::tr("Tags") << QObject::tr("Notes") << QObject::tr("TOTP") diff --git a/src/gui/databasekey/KeyComponentWidget.cpp b/src/gui/databasekey/KeyComponentWidget.cpp index 1fad344277..42829baaa3 100644 --- a/src/gui/databasekey/KeyComponentWidget.cpp +++ b/src/gui/databasekey/KeyComponentWidget.cpp @@ -75,6 +75,10 @@ void KeyComponentWidget::updateAddStatus(bool added) { if (m_ui->stackedWidget->currentIndex() == Page::Edit) { emit editCanceled(); + } else if (!added && m_ui->stackedWidget->currentIndex() == Page::LeaveOrRemove) { + if (m_ui->changeButton->hasFocus() || m_ui->removeButton->hasFocus()) { + m_ui->addButton->setFocus(Qt::OtherFocusReason); + } } if (added) { @@ -87,11 +91,27 @@ void KeyComponentWidget::updateAddStatus(bool added) void KeyComponentWidget::doAdd() { changeVisiblePage(Page::Edit); + if (m_componentWidget) { + for (auto* widget : m_componentWidget->findChildren(QString(), Qt::FindChildrenRecursively)) { + if (widget->isEnabled() && widget->isVisible() && widget->focusPolicy() != Qt::NoFocus) { + widget->setFocus(Qt::OtherFocusReason); + break; + } + } + } } void KeyComponentWidget::doEdit() { changeVisiblePage(Page::Edit); + if (m_componentWidget) { + for (auto* widget : m_componentWidget->findChildren(QString(), Qt::FindChildrenRecursively)) { + if (widget->isEnabled() && widget->isVisible() && widget->focusPolicy() != Qt::NoFocus) { + widget->setFocus(Qt::OtherFocusReason); + break; + } + } + } } void KeyComponentWidget::doRemove() diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetBrowser.ui b/src/gui/dbsettings/DatabaseSettingsWidgetBrowser.ui index d323e2b479..de06573d2b 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetBrowser.ui +++ b/src/gui/dbsettings/DatabaseSettingsWidgetBrowser.ui @@ -114,7 +114,7 @@ Stored browser keys - QAbstractItemView::NoEditTriggers + QAbstractItemView::DoubleClicked|QAbstractItemView::EditKeyPressed QAbstractItemView::SelectRows diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp b/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp index 473c8c161b..29aae5f691 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp +++ b/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp @@ -238,6 +238,16 @@ void DatabaseSettingsWidgetDatabaseKey::discard() void DatabaseSettingsWidgetDatabaseKey::showAdditionalKeyOptions() { setAdditionalKeyOptionsVisible(true); + + if (m_keyFileEditWidget) { + for (auto* widget : + m_keyFileEditWidget->findChildren(QString(), Qt::FindChildrenRecursively)) { + if (widget->isEnabled() && widget->isVisible() && widget->focusPolicy() != Qt::NoFocus) { + widget->setFocus(Qt::OtherFocusReason); + break; + } + } + } } void DatabaseSettingsWidgetDatabaseKey::setAdditionalKeyOptionsVisible(bool show) diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetEncryption.cpp b/src/gui/dbsettings/DatabaseSettingsWidgetEncryption.cpp index 7ccf6bc577..38e46329fe 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetEncryption.cpp +++ b/src/gui/dbsettings/DatabaseSettingsWidgetEncryption.cpp @@ -27,6 +27,7 @@ #include "format/KeePass2Writer.h" #include "gui/MessageBox.h" +#include #include const char* DatabaseSettingsWidgetEncryption::CD_DECRYPTION_TIME_PREFERENCE_KEY = "KPXC_DECRYPTION_TIME_PREFERENCE"; @@ -36,6 +37,18 @@ const char* DatabaseSettingsWidgetEncryption::CD_DECRYPTION_TIME_PREFERENCE_KEY namespace { + void announceWarning(QMessageBox& warning) + { + warning.show(); + QAccessibleEvent alertEvent(&warning, QAccessible::Alert); + QAccessible::updateAccessibility(&alertEvent); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(&warning, warning.text()); + announcementEvent.setPoliteness(QAccessible::AnnouncementPoliteness::Assertive); + QAccessible::updateAccessibility(&announcementEvent); +#endif + } + QString getTextualEncryptionTime(int millisecs) { if (millisecs < 1000) { @@ -58,6 +71,7 @@ DatabaseSettingsWidgetEncryption::DatabaseSettingsWidgetEncryption(QWidget* pare connect(m_ui->memorySpinBox, SIGNAL(valueChanged(int)), this, SLOT(memoryChanged(int))); connect(m_ui->parallelismSpinBox, SIGNAL(valueChanged(int)), this, SLOT(parallelismChanged(int))); + connect(m_ui->transformRoundsSpinBox, SIGNAL(valueChanged(int)), this, SLOT(transformRoundsChanged())); m_ui->compatibilitySelection->addItem(tr("KDBX 4 (recommended)"), KeePass2::KDF_ARGON2D); m_ui->compatibilitySelection->addItem(tr("KDBX 3"), KeePass2::KDF_AES_KDBX3); @@ -383,6 +397,8 @@ void DatabaseSettingsWidgetEncryption::benchmarkTransformRounds(int millisecs) // Determine the number of rounds required to meet 1 second delay int rounds = AsyncTask::runAndWaitForFuture([&kdf, millisecs]() { return kdf->benchmark(millisecs); }); + // setValue() triggers transformRoundsChanged() (if the value actually changed), which + // announces the new value -- no separate accessibility event needed here. m_ui->transformRoundsSpinBox->setValue(rounds); m_ui->transformBenchmarkButton->setEnabled(true); m_ui->transformRoundsSpinBox->setEnabled(true); @@ -397,6 +413,14 @@ void DatabaseSettingsWidgetEncryption::benchmarkTransformRounds(int millisecs) void DatabaseSettingsWidgetEncryption::memoryChanged(int value) { m_ui->memorySpinBox->setSuffix(tr(" MiB", "Abbreviation for Mebibytes (KDF settings)", value)); + + QAccessibleValueChangeEvent event(m_ui->memorySpinBox, value); + QAccessible::updateAccessibility(&event); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(m_ui->memorySpinBox, QString::number(value)); + QAccessible::updateAccessibility(&announcementEvent); +#endif } /** @@ -405,6 +429,26 @@ void DatabaseSettingsWidgetEncryption::memoryChanged(int value) void DatabaseSettingsWidgetEncryption::parallelismChanged(int value) { m_ui->parallelismSpinBox->setSuffix(tr(" thread(s)", "Threads for parallel execution (KDF settings)", value)); + + QAccessibleValueChangeEvent event(m_ui->parallelismSpinBox, value); + QAccessible::updateAccessibility(&event); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(m_ui->parallelismSpinBox, QString::number(value)); + QAccessible::updateAccessibility(&announcementEvent); +#endif +} + +void DatabaseSettingsWidgetEncryption::transformRoundsChanged() +{ + const auto value = m_ui->transformRoundsSpinBox->value(); + QAccessibleValueChangeEvent event(m_ui->transformRoundsSpinBox, value); + QAccessible::updateAccessibility(&event); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + QAccessibleAnnouncementEvent announcementEvent(m_ui->transformRoundsSpinBox, QString::number(value)); + QAccessible::updateAccessibility(&announcementEvent); +#endif } bool DatabaseSettingsWidgetEncryption::isAdvancedMode() diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetEncryption.h b/src/gui/dbsettings/DatabaseSettingsWidgetEncryption.h index 6b87bd4624..a34d872a3e 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetEncryption.h +++ b/src/gui/dbsettings/DatabaseSettingsWidgetEncryption.h @@ -49,6 +49,7 @@ private slots: void benchmarkTransformRounds(int millisecs = Kdf::DEFAULT_ENCRYPTION_TIME); void memoryChanged(int value); void parallelismChanged(int value); + void transformRoundsChanged(); void updateDecryptionTime(int value); void loadKdfAlgorithms(); void loadKdfParameters(); diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetEncryption.ui b/src/gui/dbsettings/DatabaseSettingsWidgetEncryption.ui index 581bc6ca2a..2a4f78ee44 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetEncryption.ui +++ b/src/gui/dbsettings/DatabaseSettingsWidgetEncryption.ui @@ -128,6 +128,12 @@ Encryption Settings: + + Encryption Settings + + + Configure the encryption algorithm, key derivation function, and decryption time. + @@ -430,7 +436,11 @@ - Qt::WheelFocus + Qt::TabFocus + + + Benchmark transform delay + diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetGeneral.ui b/src/gui/dbsettings/DatabaseSettingsWidgetGeneral.ui index 1dc9fda79e..389da4ab62 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetGeneral.ui +++ b/src/gui/dbsettings/DatabaseSettingsWidgetGeneral.ui @@ -193,6 +193,9 @@ + + Database public display icon chooser + 30 diff --git a/src/gui/entry/EditEntryWidget.cpp b/src/gui/entry/EditEntryWidget.cpp index e98c05fce3..b36a7cf0af 100644 --- a/src/gui/entry/EditEntryWidget.cpp +++ b/src/gui/entry/EditEntryWidget.cpp @@ -86,6 +86,9 @@ namespace { auto* editor = QStyledItemDelegate::createEditor(parent, option, index); if (auto* lineEdit = qobject_cast(editor)) { + lineEdit->setAccessibleName(QCoreApplication::translate("EditEntryWidget", "Attribute name")); + lineEdit->setAccessibleDescription( + QCoreApplication::translate("EditEntryWidget", "Name of the custom attribute being edited.")); if (config()->get(Config::AutocompleteSuggestions).toBool()) { auto* completer = new QCompleter(m_getSuggestion ? m_getSuggestion() : QStringList(), lineEdit); completer->setCaseSensitivity(Qt::CaseInsensitive); @@ -769,6 +772,19 @@ void EditEntryWidget::updateSSHAgentAttachments() void EditEntryWidget::updateSSHAgentKeyInfo() { + const bool addHasFocus = m_sshAgentUi->addToAgentButton->hasFocus(); + const bool removeHasFocus = m_sshAgentUi->removeFromAgentButton->hasFocus(); + const bool copyHasFocus = m_sshAgentUi->copyToClipboardButton->hasFocus(); + const bool decryptHasFocus = m_sshAgentUi->decryptButton->hasFocus(); + + if (addHasFocus || removeHasFocus || copyHasFocus || decryptHasFocus) { + if (m_sshAgentUi->attachmentRadioButton->isChecked()) { + m_sshAgentUi->attachmentComboBox->setFocus(Qt::OtherFocusReason); + } else { + m_sshAgentUi->externalFileEdit->setFocus(Qt::OtherFocusReason); + } + } + m_sshAgentUi->addToAgentButton->setEnabled(false); m_sshAgentUi->removeFromAgentButton->setEnabled(false); m_sshAgentUi->copyToClipboardButton->setEnabled(false); @@ -983,6 +999,9 @@ void EditEntryWidget::useExpiryPreset(QAction* action) void EditEntryWidget::toggleHideNotes(bool visible) { + if (!visible && m_mainUi->notesEdit->hasFocus()) { + m_mainUi->revealNotesButton->setFocus(Qt::OtherFocusReason); + } m_mainUi->notesEdit->setVisible(visible); m_mainUi->revealNotesButton->setIcon(icons()->onOffIcon("password-show", visible)); } @@ -1080,7 +1099,7 @@ void EditEntryWidget::setForms(Entry* entry, bool restore) if (m_history) { editTriggers = QAbstractItemView::NoEditTriggers; } else { - editTriggers = QAbstractItemView::DoubleClicked; + editTriggers = QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed; } m_advancedUi->attributesView->setEditTriggers(editTriggers); m_advancedUi->excludeReportsCheckBox->setChecked(entry->excludeFromReports()); @@ -1564,9 +1583,13 @@ void EditEntryWidget::displayAttribute(QModelIndex index, bool showProtected) if (index.isValid()) { QString key = m_attributesModel->keyByIndex(index); if (showProtected) { + const bool attributesEditHasFocus = m_advancedUi->attributesEdit->hasFocus(); m_advancedUi->attributesEdit->setPlainText(tr("[PROTECTED] Press Reveal to view or edit")); - m_advancedUi->attributesEdit->setEnabled(false); m_advancedUi->revealAttributeButton->setEnabled(true); + if (attributesEditHasFocus) { + m_advancedUi->revealAttributeButton->setFocus(Qt::OtherFocusReason); + } + m_advancedUi->attributesEdit->setEnabled(false); m_advancedUi->protectAttributeButton->setChecked(true); } else { m_advancedUi->attributesEdit->setPlainText(m_entryAttributes->value(key)); @@ -1621,6 +1644,7 @@ void EditEntryWidget::toggleCurrentAttributeVisibility() m_advancedUi->attributesEdit->setPlainText(m_entryAttributes->value(key)); m_advancedUi->attributesEdit->setEnabled(true); m_advancedUi->attributesEdit->blockSignals(oldBlockSignals); + m_advancedUi->attributesEdit->setFocus(); } m_advancedUi->revealAttributeButton->setText(tr("Hide")); } else { @@ -1631,8 +1655,37 @@ void EditEntryWidget::toggleCurrentAttributeVisibility() void EditEntryWidget::updateAutoTypeEnabled() { - bool autoTypeEnabled = m_autoTypeUi->enableButton->isChecked(); - bool validIndex = m_autoTypeUi->assocView->currentIndex().isValid() && m_autoTypeAssoc->size() != 0; + QWidget* focusedWidget = QApplication::focusWidget(); + const bool autoTypeEnabled = m_autoTypeUi->enableButton->isChecked(); + const bool validIndex = m_autoTypeUi->assocView->currentIndex().isValid() && m_autoTypeAssoc->size() != 0; + + const auto hasFocusWithin = [focusedWidget](QWidget* widget) { + return focusedWidget && (focusedWidget == widget || widget->isAncestorOf(focusedWidget)); + }; + + const bool focusedControlWillDisable = + (hasFocusWithin(m_autoTypeUi->enableButton) && m_history) + || (hasFocusWithin(m_autoTypeUi->inheritSequenceButton) && (m_history || !autoTypeEnabled)) + || (hasFocusWithin(m_autoTypeUi->customSequenceButton) && (m_history || !autoTypeEnabled)) + || (hasFocusWithin(m_autoTypeUi->sequenceEdit) + && (!autoTypeEnabled || !m_autoTypeUi->customSequenceButton->isChecked())) + || (hasFocusWithin(m_autoTypeUi->openHelpButton) && !autoTypeEnabled) + || (hasFocusWithin(m_autoTypeUi->assocView) && !autoTypeEnabled) + || (hasFocusWithin(m_autoTypeUi->assocAddButton) && m_history) + || (hasFocusWithin(m_autoTypeUi->assocRemoveButton) && (m_history || !validIndex)) + || (hasFocusWithin(m_autoTypeUi->windowTitleCombo) && (!autoTypeEnabled || !validIndex)) + || (hasFocusWithin(m_autoTypeUi->customWindowSequenceButton) + && (m_history || !autoTypeEnabled || !validIndex)) + || (hasFocusWithin(m_autoTypeUi->windowSequenceEdit) + && (!autoTypeEnabled || !validIndex || !m_autoTypeUi->customWindowSequenceButton->isChecked())); + + if (focusedControlWillDisable) { + if (!m_history) { + m_autoTypeUi->enableButton->setFocus(); + } else if (m_mainUi->titleEdit->isEnabled()) { + m_mainUi->titleEdit->setFocus(); + } + } m_autoTypeUi->enableButton->setEnabled(!m_history); m_autoTypeUi->inheritSequenceButton->setEnabled(!m_history && autoTypeEnabled); diff --git a/src/gui/entry/EditEntryWidgetAdvanced.ui b/src/gui/entry/EditEntryWidgetAdvanced.ui index 044145226d..ef5eeda8b2 100644 --- a/src/gui/entry/EditEntryWidgetAdvanced.ui +++ b/src/gui/entry/EditEntryWidgetAdvanced.ui @@ -47,6 +47,9 @@ Attribute selection + + false + QAbstractScrollArea::AdjustToContents diff --git a/src/gui/entry/EditEntryWidgetBrowser.ui b/src/gui/entry/EditEntryWidgetBrowser.ui index 7b4d5016b3..2dfcef13fc 100644 --- a/src/gui/entry/EditEntryWidgetBrowser.ui +++ b/src/gui/entry/EditEntryWidgetBrowser.ui @@ -91,6 +91,9 @@ + + false + 0 diff --git a/src/gui/entry/EditEntryWidgetMain.ui b/src/gui/entry/EditEntryWidgetMain.ui index 2f785f0350..0e526e96c7 100644 --- a/src/gui/entry/EditEntryWidgetMain.ui +++ b/src/gui/entry/EditEntryWidgetMain.ui @@ -105,6 +105,9 @@ Notes field + + true + diff --git a/src/gui/entry/EditEntryWidgetSSHAgent.ui b/src/gui/entry/EditEntryWidgetSSHAgent.ui index 3215c1a274..54abf7b5a2 100644 --- a/src/gui/entry/EditEntryWidgetSSHAgent.ui +++ b/src/gui/entry/EditEntryWidgetSSHAgent.ui @@ -131,7 +131,7 @@ - Qt::ClickFocus + Qt::StrongFocus External key file @@ -173,7 +173,7 @@ - Browser for key file + Browse for key file Browse… diff --git a/src/gui/entry/attachments/ImageAttachmentsWidget.cpp b/src/gui/entry/attachments/ImageAttachmentsWidget.cpp index 4b8f81113b..a1e30f5625 100644 --- a/src/gui/entry/attachments/ImageAttachmentsWidget.cpp +++ b/src/gui/entry/attachments/ImageAttachmentsWidget.cpp @@ -23,6 +23,8 @@ #include #include +#include + #include #include #include @@ -167,6 +169,10 @@ void ImageAttachmentsWidget::openAttachment(attachments::Attachment attachment, { m_attachment = std::move(attachment); + m_ui->imagesView->setAccessibleName(m_attachment.name.isEmpty() + ? tr("Attachment image") + : tr("Attachment image: %1").arg(m_attachment.name)); + if (mode == attachments::OpenMode::ReadWrite) { qWarning() << "Read-write mode is not supported for image attachments"; } @@ -180,10 +186,15 @@ void ImageAttachmentsWidget::loadImage() pixmap.loadFromData(m_attachment.data); if (pixmap.isNull()) { qWarning() << "Failed to load image from data"; + m_scene->clear(); + m_ui->imagesView->setAccessibleDescription(tr("Unable to display attachment image")); + QAccessibleEvent alertEvent(m_ui->imagesView, QAccessible::Alert); + QAccessible::updateAccessibility(&alertEvent); return; } m_scene->clear(); + m_ui->imagesView->setAccessibleDescription({}); m_scene->addPixmap(std::move(pixmap)); } diff --git a/src/gui/entry/attachments/TextAttachmentsWidget.cpp b/src/gui/entry/attachments/TextAttachmentsWidget.cpp index b28465a460..771bf6c8aa 100644 --- a/src/gui/entry/attachments/TextAttachmentsWidget.cpp +++ b/src/gui/entry/attachments/TextAttachmentsWidget.cpp @@ -19,6 +19,8 @@ #include "TextAttachmentsEditWidget.h" #include "TextAttachmentsPreviewWidget.h" +#include +#include #include #include #include @@ -55,20 +57,27 @@ void TextAttachmentsWidget::updateWidget() { if (m_mode == attachments::OpenMode::ReadOnly) { // Only show the preview widget in read-only mode + auto* focusedWidget = QApplication::focusWidget(); + if (focusedWidget && (focusedWidget == m_editWidget || m_editWidget->isAncestorOf(focusedWidget))) { + m_previewWidget->setFocus(Qt::OtherFocusReason); + } m_splitter->setSizes({0, 1}); m_editWidget->hide(); m_previewWidget->openAttachment(m_attachment, m_mode); + m_editWidget->findChild("previewPushButton")->setChecked(false); } else { // Show the edit widget and hide the preview by default in read-write mode m_splitter->setSizes({1, 0}); m_editWidget->show(); m_editWidget->openAttachment(m_attachment, m_mode); + m_editWidget->findChild("previewPushButton")->setChecked(false); } } void TextAttachmentsWidget::updatePreviewWidget() { m_previewVisible = isPreviewVisible(); + m_editWidget->findChild("previewPushButton")->setChecked(m_previewVisible); if (m_previewVisible) { m_attachment = m_editWidget->getAttachment(); m_previewWidget->openAttachment(m_attachment, attachments::OpenMode::ReadOnly); @@ -108,8 +117,10 @@ void TextAttachmentsWidget::initWidget() auto visible = isPreviewVisible(); if (visible && !m_previewVisible) { updatePreviewWidget(); + } else { + m_previewVisible = visible; + m_editWidget->findChild("previewPushButton")->setChecked(visible); } - m_previewVisible = visible; }); m_splitter->addWidget(m_editWidget); diff --git a/src/gui/group/EditGroupWidget.cpp b/src/gui/group/EditGroupWidget.cpp index 43d20da076..86e45c814f 100644 --- a/src/gui/group/EditGroupWidget.cpp +++ b/src/gui/group/EditGroupWidget.cpp @@ -76,6 +76,10 @@ EditGroupWidget::EditGroupWidget(QWidget* parent) { m_mainUi->setupUi(m_editGroupWidgetMain); + // QPlainTextEdit normally consumes Tab as a literal tab character. + // Let Tab and Shift+Tab move through the group editor controls instead. + m_mainUi->editNotes->setTabChangesFocus(true); + addPage(tr("Group"), icons()->icon("document-edit"), m_editGroupWidgetMain); addPage(tr("Icon"), icons()->icon("preferences-desktop-icons"), m_editGroupWidgetIcons); #if defined(KPXC_FEATURE_BROWSER) @@ -86,11 +90,18 @@ EditGroupWidget::EditGroupWidget(QWidget* parent) addEditPage(new EditGroupPageKeeShare(this)); addPage(tr("Properties"), icons()->icon("document-properties"), m_editWidgetProperties); - connect(m_mainUi->expireCheck, SIGNAL(toggled(bool)), m_mainUi->expireDatePicker, SLOT(setEnabled(bool))); - connect(m_mainUi->autoTypeSequenceCustomRadio, - SIGNAL(toggled(bool)), - m_mainUi->autoTypeSequenceCustomEdit, - SLOT(setEnabled(bool))); + connect(m_mainUi->expireCheck, &QCheckBox::toggled, this, [this](bool enabled) { + if (!enabled && m_mainUi->expireDatePicker->hasFocus()) { + m_mainUi->expireCheck->setFocus(); + } + m_mainUi->expireDatePicker->setEnabled(enabled); + }); + connect(m_mainUi->autoTypeSequenceCustomRadio, &QRadioButton::toggled, this, [this](bool enabled) { + if (!enabled && m_mainUi->autoTypeSequenceCustomEdit->hasFocus()) { + m_mainUi->autoTypeSequenceCustomRadio->setFocus(); + } + m_mainUi->autoTypeSequenceCustomEdit->setEnabled(enabled); + }); connect(this, SIGNAL(apply()), SLOT(apply())); connect(this, SIGNAL(accepted()), SLOT(save())); diff --git a/src/gui/passkeys/PasskeyExportDialog.ui b/src/gui/passkeys/PasskeyExportDialog.ui index bf9f4c42d8..9ecb35e5fa 100644 --- a/src/gui/passkeys/PasskeyExportDialog.ui +++ b/src/gui/passkeys/PasskeyExportDialog.ui @@ -48,7 +48,16 @@ - + + Passkey entries to export + + + Select one or more passkey entries to include in the export. + + + false + + QAbstractItemView::NoEditTriggers diff --git a/src/gui/passkeys/PasskeyImportDialog.ui b/src/gui/passkeys/PasskeyImportDialog.ui index 3b00809970..a23544a1fc 100644 --- a/src/gui/passkeys/PasskeyImportDialog.ui +++ b/src/gui/passkeys/PasskeyImportDialog.ui @@ -82,7 +82,11 @@ - + + + Database to import passkey into + + @@ -92,7 +96,11 @@ - + + + Group to import passkey into + + @@ -102,7 +110,11 @@ - + + + Entry to import passkey into + + diff --git a/src/gui/reports/ReportsWidgetBrowserStatistics.cpp b/src/gui/reports/ReportsWidgetBrowserStatistics.cpp index 63267d77fd..0432513a6c 100644 --- a/src/gui/reports/ReportsWidgetBrowserStatistics.cpp +++ b/src/gui/reports/ReportsWidgetBrowserStatistics.cpp @@ -154,12 +154,17 @@ void ReportsWidgetBrowserStatistics::addStatisticsRow(bool hasUrls, row << new QStandardItem(allowedUrlsList.join('\n')); row << new QStandardItem(deniedUrlsList.join('\n')); - // Set tooltips + // Set tooltips and accessible descriptions row[2]->setToolTip(urlToolTip); + row[2]->setData(urlToolTip, Qt::AccessibleDescriptionRole); row[3]->setToolTip(allowedUrlsToolTip); + row[3]->setData(allowedUrlsToolTip, Qt::AccessibleDescriptionRole); row[4]->setToolTip(deniedUrlsToolTip); + row[4]->setData(deniedUrlsToolTip, Qt::AccessibleDescriptionRole); if (excluded) { - row[0]->setToolTip(tr("This entry is being excluded from reports")); + const auto excludedDescription = tr("This entry is being excluded from reports"); + row[0]->setToolTip(excludedDescription); + row[0]->setData(excludedDescription, Qt::AccessibleDescriptionRole); } // Store entry pointer per table row (used in double click handler) diff --git a/src/gui/reports/ReportsWidgetBrowserStatistics.ui b/src/gui/reports/ReportsWidgetBrowserStatistics.ui index 9f631bef65..5c26a78ba0 100644 --- a/src/gui/reports/ReportsWidgetBrowserStatistics.ui +++ b/src/gui/reports/ReportsWidgetBrowserStatistics.ui @@ -25,6 +25,15 @@ + + false + + + Browser integration report entries + + + Use the arrow keys to review browser integration report entries. Double-click a row to edit the entry; use the context menu for entry actions. + Qt::CustomContextMenu @@ -96,6 +105,7 @@ browserStatisticsTableView showEntriesWithUrlOnlyCheckBox showAllowDenyCheckBox + showExpired diff --git a/src/gui/reports/ReportsWidgetHealthcheck.cpp b/src/gui/reports/ReportsWidgetHealthcheck.cpp index 1c34c2f36d..1bdd03c772 100644 --- a/src/gui/reports/ReportsWidgetHealthcheck.cpp +++ b/src/gui/reports/ReportsWidgetHealthcheck.cpp @@ -204,7 +204,7 @@ void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer healt } auto row = QList(); - row << new QStandardItem(Icons::instance()->icon(iconName, true, qualityColor), ""); + row << new QStandardItem(Icons::instance()->icon(iconName, true, qualityColor), tip); row << new QStandardItem(Icons::entryIconPixmap(entry), title); row << new QStandardItem(Icons::groupIconPixmap(group), group->hierarchy().join("/")); row << new QStandardItem(QString::number(health->score())); @@ -282,7 +282,7 @@ void ReportsWidgetHealthcheck::calculateHealth() if (m_referencesModel->rowCount() == 0) { m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("Congratulations, everything is healthy!")); } else { - m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("") << tr("Title") << tr("Path") << tr("Score") + m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("Health") << tr("Title") << tr("Path") << tr("Score") << tr("Reason")); } diff --git a/src/gui/reports/ReportsWidgetHealthcheck.ui b/src/gui/reports/ReportsWidgetHealthcheck.ui index 5bc2aa1184..a780228edb 100644 --- a/src/gui/reports/ReportsWidgetHealthcheck.ui +++ b/src/gui/reports/ReportsWidgetHealthcheck.ui @@ -25,6 +25,15 @@ + + false + + + Database health check results + + + Use the arrow keys to review database health results. Double-click a row to edit the entry; use the context menu for entry actions. + Qt::CustomContextMenu @@ -84,6 +93,7 @@ healthcheckTableView + showExpired showExcluded diff --git a/src/gui/reports/ReportsWidgetHibp.ui b/src/gui/reports/ReportsWidgetHibp.ui index b5f0a489c6..cc35123258 100644 --- a/src/gui/reports/ReportsWidgetHibp.ui +++ b/src/gui/reports/ReportsWidgetHibp.ui @@ -116,6 +116,12 @@ + + Online password exposure check progress + + + Shows the progress of checking database passwords against the online exposure service. + 0 @@ -139,6 +145,15 @@ + + false + + + Online password exposure report entries + + + Use the arrow keys to review password exposure results. Select a row to access entry actions from the context menu. + Qt::CustomContextMenu diff --git a/src/gui/reports/ReportsWidgetPasskeys.cpp b/src/gui/reports/ReportsWidgetPasskeys.cpp index 831f4c7211..cfa996a5e6 100644 --- a/src/gui/reports/ReportsWidgetPasskeys.cpp +++ b/src/gui/reports/ReportsWidgetPasskeys.cpp @@ -279,7 +279,14 @@ QList ReportsWidgetPasskeys::getSelectedEntries() void ReportsWidgetPasskeys::selectionChanged() { - m_ui->exportButton->setEnabled(!m_ui->passkeysTableView->selectionModel()->selectedIndexes().isEmpty()); + const bool hasSelection = !m_ui->passkeysTableView->selectionModel()->selectedIndexes().isEmpty(); + const bool exportHasFocus = m_ui->exportButton->hasFocus(); + + if (exportHasFocus && !hasSelection) { + m_ui->passkeysTableView->setFocus(); + } + + m_ui->exportButton->setEnabled(hasSelection); } void ReportsWidgetPasskeys::importPasskey() diff --git a/src/gui/reports/ReportsWidgetPasskeys.ui b/src/gui/reports/ReportsWidgetPasskeys.ui index c1e321fc8b..2a8aeee57f 100644 --- a/src/gui/reports/ReportsWidgetPasskeys.ui +++ b/src/gui/reports/ReportsWidgetPasskeys.ui @@ -25,6 +25,15 @@ + + false + + + Passkey report entries + + + Use the arrow keys to review passkey report entries. Select a row to use Import or Export. + Qt::CustomContextMenu diff --git a/src/gui/reports/ReportsWidgetStatistics.cpp b/src/gui/reports/ReportsWidgetStatistics.cpp index b4d5b49f7c..316c9b92ec 100644 --- a/src/gui/reports/ReportsWidgetStatistics.cpp +++ b/src/gui/reports/ReportsWidgetStatistics.cpp @@ -54,7 +54,9 @@ void ReportsWidgetStatistics::addStatsRow(QString name, QString value, bool bad, if (bad) { m_referencesModel->item(m_referencesModel->rowCount() - 1, 1)->setIcon(m_errIcon); if (!badMsg.isEmpty()) { - m_referencesModel->item(m_referencesModel->rowCount() - 1, 1)->setToolTip(badMsg); + auto* valueItem = m_referencesModel->item(m_referencesModel->rowCount() - 1, 1); + valueItem->setToolTip(badMsg); + valueItem->setData(badMsg, Qt::AccessibleDescriptionRole); } } }; diff --git a/src/gui/tag/TagView.cpp b/src/gui/tag/TagView.cpp index 0330612920..f3d0e2a037 100644 --- a/src/gui/tag/TagView.cpp +++ b/src/gui/tag/TagView.cpp @@ -54,6 +54,7 @@ TagView::TagView(QWidget* parent) , m_model(new TagModel(this)) { setModel(m_model); + setAccessibleName(tr("Searches and Tags")); setFrameStyle(QFrame::NoFrame); setSelectionMode(QListView::ExtendedSelection); setSelectionBehavior(QListView::SelectRows); diff --git a/src/gui/tag/TagsEdit.cpp b/src/gui/tag/TagsEdit.cpp index 1d6bcfa73c..f219687661 100644 --- a/src/gui/tag/TagsEdit.cpp +++ b/src/gui/tag/TagsEdit.cpp @@ -25,6 +25,7 @@ #include "TagsEdit.h" #include "gui/MainWindow.h" #include +#include #include #include #include @@ -588,6 +589,7 @@ TagsEdit::TagsEdit(QWidget* parent) setSizePolicy(size_policy); setFocusPolicy(Qt::StrongFocus); + setAccessibleName(tr("Tags")); viewport()->setCursor(Qt::IBeamCursor); setAttribute(Qt::WA_InputMethodEnabled, true); setMouseTracking(true); @@ -632,6 +634,15 @@ void TagsEdit::focusInEvent(QFocusEvent*) impl->calcRects(impl->tags); impl->completer->complete(); viewport()->update(); + + if (!m_usageHintAnnounced) { + m_usageHintAnnounced = true; + announceTagsState(tr("Type text, then press Enter, comma, or semicolon to add a tag. " + "Press Backspace in an empty field to edit or remove the previous tag. %1") + .arg(tagsSummary())); + } else { + announceTagsState(tagsSummary()); + } } void TagsEdit::focusOutEvent(QFocusEvent*) @@ -698,12 +709,15 @@ void TagsEdit::mousePressEvent(QMouseEvent* event) bool found = false; for (int i = 0; i < impl->tags.size(); ++i) { if (impl->inCrossArea(i, event->pos())) { + const auto removedTag = impl->tags[i].text; impl->tags.erase(impl->tags.begin() + std::ptrdiff_t(i)); if (i <= impl->editing_index) { --impl->editing_index; } emit tagsEdited(); found = true; + announceTagsState(tr("Removed tag %1. %2").arg(removedTag, tagsSummary())); + found = true; break; } @@ -802,9 +816,19 @@ void TagsEdit::keyPressEvent(QKeyEvent* event) } else if (event == QKeySequence::Paste) { auto clipboard = QApplication::clipboard(); if (clipboard) { + QStringList addedTags; for (auto tagtext : clipboard->text().split(",")) { + const auto trimmed = tagtext.trimmed(); impl->currentText().insert(impl->cursor, tagtext); impl->editNewTag(impl->editing_index + 1); + if (!trimmed.isEmpty()) { + addedTags << trimmed; + } + } + if (!addedTags.isEmpty()) { + announceTagsState( + tr("Added %n tag(s) from paste: %1. %2", "", addedTags.size()) + .arg(addedTags.join(", "), tagsSummary())); } } event->accept(); @@ -861,8 +885,10 @@ void TagsEdit::keyPressEvent(QKeyEvent* event) // Make existing text into a tag if (!impl->currentText().isEmpty()) { + const auto committedTag = impl->currentText().trimmed(); impl->editNewTag(impl->editing_index + 1); event->accept(); + announceTagsState(tr("Added tag %1. %2").arg(committedTag, tagsSummary())); } break; default: @@ -926,6 +952,7 @@ void TagsEdit::tags(QStringList const& tags) impl->calcRectsAndUpdateScrollRanges(); viewport()->update(); updateGeometry(); + setAccessibleDescription(tagsSummary()); } QStringList TagsEdit::tags() const @@ -955,6 +982,22 @@ void TagsEdit::mouseMoveEvent(QMouseEvent* event) } } +QString TagsEdit::tagsSummary() const +{ + const auto currentTags = tags(); + if (currentTags.isEmpty()) { + return tr("No tags"); + } + return tr("%n tag(s): %1", "", currentTags.size()).arg(currentTags.join(QStringLiteral(", "))); +} + +void TagsEdit::announceTagsState(const QString& message) +{ + setAccessibleDescription(message); + QAccessibleEvent event(this, QAccessible::Alert); + QAccessible::updateAccessibility(&event); +} + bool TagsEdit::isAcceptableInput(const QKeyEvent* event) const { const QString text = event->text(); diff --git a/src/gui/tag/TagsEdit.h b/src/gui/tag/TagsEdit.h index 44297fb343..6677ad897a 100644 --- a/src/gui/tag/TagsEdit.h +++ b/src/gui/tag/TagsEdit.h @@ -73,7 +73,11 @@ class TagsEdit : public QAbstractScrollArea private: bool isAcceptableInput(QKeyEvent const* event) const; + QString tagsSummary() const; + void announceTagsState(const QString& message); + struct Impl; std::unique_ptr impl; bool m_readOnly; + bool m_usageHintAnnounced = false; }; diff --git a/src/gui/widgets/KPToolBar.cpp b/src/gui/widgets/KPToolBar.cpp index 88b75b9b1e..33949918bf 100644 --- a/src/gui/widgets/KPToolBar.cpp +++ b/src/gui/widgets/KPToolBar.cpp @@ -18,8 +18,10 @@ #include "KPToolBar.h" #include +#include #include #include +#include KPToolBar::KPToolBar(const QString& title, QWidget* parent) : QToolBar(title, parent) @@ -50,6 +52,25 @@ bool KPToolBar::canExpand() return m_expandButton && m_expandButton->isVisible(); } +void KPToolBar::updateButtonAccessibility() +{ + for (QAction* action : actions()) { + if (action->isSeparator()) { + continue; + } + QWidget* widget = widgetForAction(action); + if (auto* button = qobject_cast(widget)) { + const QString name = action->toolTip().isEmpty() + ? action->text().remove(QLatin1Char('&')) + : action->toolTip(); + if (!name.isEmpty()) { + button->setAccessibleName(name); + } + button->setFocusPolicy(Qt::TabFocus); + } + } +} + void KPToolBar::setExpanded(bool state) { if (canExpand() && !QMetaObject::invokeMethod(layout(), "setExpanded", Q_ARG(bool, state))) { @@ -69,7 +90,12 @@ bool KPToolBar::event(QEvent* event) // Mouse came back in, stop hiding timer m_expandTimer.stop(); return true; + case QEvent::Show: + case QEvent::LayoutRequest: + updateButtonAccessibility(); + break; default: - return QToolBar::event(event); + break; } + return QToolBar::event(event); } diff --git a/src/gui/widgets/KPToolBar.h b/src/gui/widgets/KPToolBar.h index 8883982bdd..e4b932f874 100644 --- a/src/gui/widgets/KPToolBar.h +++ b/src/gui/widgets/KPToolBar.h @@ -34,7 +34,8 @@ class KPToolBar : public QToolBar ~KPToolBar() override = default; bool isExpanded(); - bool canExpand(); + bool canExpand(); + void updateButtonAccessibility(); public slots: void setExpanded(bool state); diff --git a/src/gui/widgets/PopupHelpWidget.cpp b/src/gui/widgets/PopupHelpWidget.cpp index 0d37348b96..fece86539d 100644 --- a/src/gui/widgets/PopupHelpWidget.cpp +++ b/src/gui/widgets/PopupHelpWidget.cpp @@ -18,6 +18,7 @@ #include "PopupHelpWidget.h" #include +#include #include "gui/MainWindow.h" @@ -30,6 +31,8 @@ PopupHelpWidget::PopupHelpWidget(QWidget* parent) Q_ASSERT(parent); setWindowFlags(Qt::FramelessWindowHint | Qt::Tool); + setFocusPolicy(Qt::StrongFocus); + setAccessibleName(tr("Search help")); hide(); m_appWindow->installEventFilter(this); @@ -70,6 +73,20 @@ bool PopupHelpWidget::eventFilter(QObject* obj, QEvent* event) return QFrame::eventFilter(obj, event); } +void PopupHelpWidget::keyPressEvent(QKeyEvent* event) +{ + if (event->key() == Qt::Key_Escape) { + hide(); + if (parentWidget()) { + parentWidget()->setFocus(Qt::OtherFocusReason); + } + event->accept(); + return; + } + + QFrame::keyPressEvent(event); +} + void PopupHelpWidget::showEvent(QShowEvent* event) { alignWithParent(); diff --git a/src/gui/widgets/PopupHelpWidget.h b/src/gui/widgets/PopupHelpWidget.h index 353121c6a9..272df1d2be 100644 --- a/src/gui/widgets/PopupHelpWidget.h +++ b/src/gui/widgets/PopupHelpWidget.h @@ -21,6 +21,8 @@ #include #include +class QKeyEvent; + class PopupHelpWidget : public QFrame { Q_OBJECT @@ -33,6 +35,7 @@ class PopupHelpWidget : public QFrame protected: bool eventFilter(QObject* obj, QEvent* event) override; + void keyPressEvent(QKeyEvent* event) override; void showEvent(QShowEvent* event) override; private: diff --git a/src/gui/widgets/ShortcutWidget.cpp b/src/gui/widgets/ShortcutWidget.cpp index 4f9c144764..c95acc1871 100644 --- a/src/gui/widgets/ShortcutWidget.cpp +++ b/src/gui/widgets/ShortcutWidget.cpp @@ -56,6 +56,7 @@ void ShortcutWidget::resetShortcut() m_key = Qt::Key_unknown; m_modifiers = Qt::NoModifier; m_locked = false; + displayShortcut(Qt::Key_unknown, Qt::NoModifier); emit shortcutReset(); } diff --git a/src/gui/wizard/ImportTypeListWidget.h b/src/gui/wizard/ImportTypeListWidget.h new file mode 100644 index 0000000000..954941c4e6 --- /dev/null +++ b/src/gui/wizard/ImportTypeListWidget.h @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_IMPORTTYPELISTWIDGET_H +#define KEEPASSXC_IMPORTTYPELISTWIDGET_H + +#include + +class ImportTypeListWidget : public QListWidget +{ +public: + using QListWidget::QListWidget; + +protected: + bool focusNextPrevChild(bool next) override + { + // QAbstractItemView normally handles Tab through its own focusNextPrevChild() + // implementation. For this selector, Tab must move to the next control rather + // than change the selected import type. + return QWidget::focusNextPrevChild(next); + } +}; + +#endif diff --git a/src/gui/wizard/ImportWizardPageSelect.cpp b/src/gui/wizard/ImportWizardPageSelect.cpp index ba5f285216..9d14432694 100644 --- a/src/gui/wizard/ImportWizardPageSelect.cpp +++ b/src/gui/wizard/ImportWizardPageSelect.cpp @@ -33,6 +33,30 @@ ImportWizardPageSelect::ImportWizardPageSelect(QWidget* parent) { m_ui->setupUi(this); + // MacStyle only renders subTitle as a visible/accessible in-page label; title alone + // is not exposed as an in-page heading under this wizard style. + setSubTitle(tr("Choose a file to import and where to import it.")); + setAccessibleDescription(tr("%1. %2").arg(title(), subTitle())); + + m_ui->importTypeList->setAccessibleName(tr("Import type. %1").arg(subTitle())); + m_ui->downloadCommandInput->setTabChangesFocus(true); + m_ui->importTypeList->setTabKeyNavigation(false); + + // Keep the import controls in logical keyboard order. Hidden or disabled + // controls are skipped automatically as import types change. + QWidget::setTabOrder(m_ui->importTypeList, m_ui->importFileEdit); + QWidget::setTabOrder(m_ui->importFileEdit, m_ui->importFileButton); + QWidget::setTabOrder(m_ui->importFileButton, m_ui->downloadCommand); + QWidget::setTabOrder(m_ui->downloadCommand, m_ui->downloadCommandHelpButton); + QWidget::setTabOrder(m_ui->downloadCommandHelpButton, m_ui->downloadCommandInput); + QWidget::setTabOrder(m_ui->downloadCommandInput, m_ui->passwordEdit); + QWidget::setTabOrder(m_ui->passwordEdit, m_ui->keyFileEdit); + QWidget::setTabOrder(m_ui->keyFileEdit, m_ui->keyFileButton); + QWidget::setTabOrder(m_ui->keyFileButton, m_ui->newDatabaseRadio); + QWidget::setTabOrder(m_ui->newDatabaseRadio, m_ui->existingDatabaseRadio); + QWidget::setTabOrder(m_ui->existingDatabaseRadio, m_ui->existingDatabaseChoice); + QWidget::setTabOrder(m_ui->existingDatabaseChoice, m_ui->temporaryDatabaseRadio); + new QListWidgetItem(icons()->icon("csv"), tr("Comma Separated Values (.csv)"), m_ui->importTypeList); new QListWidgetItem(icons()->icon("onepassword"), tr("1Password Export (.1pux)"), m_ui->importTypeList); new QListWidgetItem(icons()->icon("onepassword"), tr("1Password Vault (.opvault)"), m_ui->importTypeList); @@ -50,11 +74,15 @@ ImportWizardPageSelect::ImportWizardPageSelect(QWidget* parent) m_ui->importTypeList->item(6)->setData(Qt::UserRole, ImportWizard::IMPORT_KEEPASS1); connect(m_ui->importTypeList, &QListWidget::currentItemChanged, this, &ImportWizardPageSelect::itemSelected); + connect(m_ui->importTypeList, &QListWidget::itemActivated, this, &ImportWizardPageSelect::advanceFocusFromTypeList); m_ui->importTypeList->setCurrentRow(0); connect(m_ui->importFileButton, &QAbstractButton::clicked, this, &ImportWizardPageSelect::chooseImportFile); connect(m_ui->keyFileButton, &QAbstractButton::clicked, this, &ImportWizardPageSelect::chooseKeyFile); connect(m_ui->existingDatabaseRadio, &QRadioButton::toggled, this, [this](bool state) { + if (!state && m_ui->existingDatabaseChoice->hasFocus()) { + m_ui->newDatabaseRadio->setFocus(); + } m_ui->existingDatabaseChoice->setEnabled(state); }); @@ -158,6 +186,15 @@ void ImportWizardPageSelect::itemSelected(QListWidgetItem* current, QListWidgetI } } +void ImportWizardPageSelect::advanceFocusFromTypeList() +{ + if (m_ui->downloadCommand->isVisible()) { + m_ui->downloadCommand->setFocus(); + } else { + m_ui->importFileEdit->setFocus(); + } +} + void ImportWizardPageSelect::updateDatabaseChoices() const { m_ui->existingDatabaseChoice->clear(); @@ -207,6 +244,9 @@ void ImportWizardPageSelect::updateDatabaseChoices() const } if (m_ui->existingDatabaseChoice->count() == 0) { + if (m_ui->existingDatabaseRadio->hasFocus() || m_ui->existingDatabaseChoice->hasFocus()) { + m_ui->newDatabaseRadio->setFocus(); + } m_ui->existingDatabaseRadio->setEnabled(false); m_ui->newDatabaseRadio->setChecked(true); } @@ -242,6 +282,9 @@ void ImportWizardPageSelect::chooseKeyFile() void ImportWizardPageSelect::setCredentialState(bool passwordEnabled, bool keyFileEnable) { + const bool passwordHadFocus = m_ui->passwordEdit->hasFocus(); + const bool keyFileHadFocus = m_ui->keyFileEdit->hasFocus(); + const bool keyFileButtonHadFocus = m_ui->keyFileButton->hasFocus(); bool passwordStateChanged = m_ui->passwordLabel->isVisible() != passwordEnabled; m_ui->passwordLabel->setVisible(passwordEnabled); m_ui->passwordEdit->setVisible(passwordEnabled); @@ -251,6 +294,10 @@ void ImportWizardPageSelect::setCredentialState(bool passwordEnabled, bool keyFi m_ui->keyFileEdit->setVisible(keyFileEnable); m_ui->keyFileButton->setVisible(keyFileEnable); + if ((!passwordEnabled && passwordHadFocus) || (!keyFileEnable && (keyFileHadFocus || keyFileButtonHadFocus))) { + m_ui->importTypeList->setFocus(Qt::OtherFocusReason); + } + // Workaround Qt bug where the wizard window is not updated when the internal layout changes if (window()) { int height = window()->height(); @@ -268,6 +315,12 @@ void ImportWizardPageSelect::setCredentialState(bool passwordEnabled, bool keyFi void ImportWizardPageSelect::setDownloadCommand(bool downloadCommandEnabled) { + const bool importFileHadFocus = m_ui->importFileEdit->hasFocus(); + const bool importFileButtonHadFocus = m_ui->importFileButton->hasFocus(); + const bool downloadCommandHadFocus = m_ui->downloadCommand->hasFocus(); + const bool downloadInputHadFocus = m_ui->downloadCommandInput->hasFocus(); + const bool downloadHelpButtonHadFocus = m_ui->downloadCommandHelpButton->hasFocus(); + const bool temporaryDatabaseRadioHadFocus = m_ui->temporaryDatabaseRadio->hasFocus(); bool downloadCommandStateChanged = m_ui->downloadCommandLabel->isVisible() != downloadCommandEnabled; m_ui->downloadCommandLabel->setVisible(downloadCommandEnabled); m_ui->downloadCommand->setVisible(downloadCommandEnabled); @@ -281,6 +334,12 @@ void ImportWizardPageSelect::setDownloadCommand(bool downloadCommandEnabled) m_ui->importFileEdit->setVisible(!downloadCommandEnabled); m_ui->importFileButton->setVisible(!downloadCommandEnabled); + if ((downloadCommandEnabled && (importFileHadFocus || importFileButtonHadFocus)) + || (!downloadCommandEnabled + && (downloadCommandHadFocus || downloadInputHadFocus || downloadHelpButtonHadFocus || temporaryDatabaseRadioHadFocus))) { + m_ui->importTypeList->setFocus(Qt::OtherFocusReason); + } + // Workaround Qt bug where the wizard window is not updated when the internal layout changes if (window()) { int height = window()->height(); diff --git a/src/gui/wizard/ImportWizardPageSelect.h b/src/gui/wizard/ImportWizardPageSelect.h index 5c4024ea5a..befc9513c9 100644 --- a/src/gui/wizard/ImportWizardPageSelect.h +++ b/src/gui/wizard/ImportWizardPageSelect.h @@ -43,6 +43,7 @@ class ImportWizardPageSelect : public QWizardPage private slots: void itemSelected(QListWidgetItem* current, QListWidgetItem* previous); + void advanceFocusFromTypeList(); void chooseImportFile(); void chooseKeyFile(); void updateDatabaseChoices() const; diff --git a/src/gui/wizard/ImportWizardPageSelect.ui b/src/gui/wizard/ImportWizardPageSelect.ui index ca37fec71b..4ba3449dce 100644 --- a/src/gui/wizard/ImportWizardPageSelect.ui +++ b/src/gui/wizard/ImportWizardPageSelect.ui @@ -30,7 +30,13 @@ 0 - + + + Import format + + + Select the type of database or file to import. + 0 @@ -50,7 +56,7 @@ QAbstractItemView::NoEditTriggers - true + false QAbstractItemView::SelectRows @@ -174,6 +180,12 @@ + + Import into + + + Choose whether to import into a new database, an existing database, or a temporary database. + 0 @@ -286,6 +298,12 @@ + + Remote database download command input + + + true + e.g.: get DatabaseOnRemote.kdbx {TEMP_DATABASE} @@ -319,6 +337,11 @@ The command has to exit. In case of `sftp` as last commend `exit` has to be sent + + ImportTypeListWidget + QListWidget +
ImportTypeListWidget.h
+
PasswordWidget QWidget diff --git a/src/gui/wizard/NewDatabaseWizardPage.cpp b/src/gui/wizard/NewDatabaseWizardPage.cpp index 17dcaa673c..250456cf04 100644 --- a/src/gui/wizard/NewDatabaseWizardPage.cpp +++ b/src/gui/wizard/NewDatabaseWizardPage.cpp @@ -19,6 +19,8 @@ #include "NewDatabaseWizardPage.h" #include "ui_NewDatabaseWizardPage.h" +#include + #include "core/Database.h" #include "gui/dbsettings/DatabaseSettingsWidget.h" @@ -69,6 +71,14 @@ void NewDatabaseWizardPage::initializePage() return; } + if (auto* formatCombo = m_pageWidget->findChild(QStringLiteral("compatibilitySelection"))) { + const QString guidance = tr("%1. %2").arg(title(), subTitle()); + formatCombo->setAccessibleName(tr("Database format. %1").arg(guidance)); + formatCombo->setAccessibleDescription( + tr("Database Format and Encryption. Choose the database format and configure encryption settings.")); + } + setAccessibleDescription(tr("%1. %2").arg(title(), subTitle())); + m_pageWidget->loadSettings(m_db); }