From f208acd5f68c8516b9f6a920cc229803637e23e9 Mon Sep 17 00:00:00 2001 From: Felix Ernst Date: Mon, 28 Oct 2024 13:25:10 +0000 Subject: [PATCH] Overhaul main view accessibility This commit brings the main view of Dolphin into a usable state accessibility-wise. Users of screen readers should have a way better experience while browsing files and folders and navigating along the file system hierarchy. This commit fixes most of the remaining already-identified accessibility issues listed in https://invent.kde.org/teams/accessibility/collaboration/-/issues/28, but not all. Namely, these should now be fixed: 1. Orca should read the element type in dolphin (file, folder, device, link to folder, link to file) 2. Orca should read complete label in icon and compact view mode, currently it only speaks the name, but there could be additional information like the number of elements or the file size. 3. Orca is not able to announce Selecting / Unselecting files in Dolphin. It also never announces how many items are selected in total. (Announcing the total selection can be done by reading out the view element or by pressing the Tab key to get to the status bar with the relevant information.) 4. Dolphin opens on the home directory, but Orca doesn't tell you so. Consider enclosing the area in a frame/panel which updates its accessible name each time you modify the current path by entering or leaving a directory. 5. I don't know what the folder presentation widget is, but it should be presented as a grid view. Currently, we have a terrible experience because the entire row of folders is read at once, with no indication that we can move left and right with the arrows to go between the elements of a row. When I found that out, however, I discovered that when you're on the last icon of the first row and press right arrow, you get to the first icon of the next row, but that's not announced, instead, the whole row is announced at once 6. Orca should announce the current elements instead of "layered pane" when the Folder / File view gets the focus in dolphin 7. Orca reads only name in Table View only of Dolphin 8. Items are sometimes confusingly announced as "collapsed" in contexts in which there is no concept of collapsing/expanding e.g. in icon view mode. A lot of code was moved around and renamed. The three accessibility classes, which all used to be in the same file, are moved into separate files. *Acknowledgement* Thanks to Christian Hempfling and bgt lover for testing as well as originally identifying a lot of the pain points being addressed here. This work is part of a my project funded through the NGI0 Entrust Fund, a fund established by NLnet with financial support from the European Commission's Next Generation Internet programme, under the aegis of DG Communications Networks, Content and Technology. https://kde.org/announcements/2024_ngi_openletter/ --- src/CMakeLists.txt | 15 +- src/animatedheightwidget.cpp | 1 + src/dolphintabwidget.cpp | 8 + .../kitemlistcontaineraccessible.cpp | 85 ++++ .../kitemlistcontaineraccessible.h | 45 ++ .../kitemlistdelegateaccessible.cpp | 240 +++++++++ .../kitemlistdelegateaccessible.h | 61 +++ .../accessibility/kitemlistviewaccessible.cpp | 479 ++++++++++++++++++ .../accessibility/kitemlistviewaccessible.h | 146 ++++++ src/kitemviews/kfileitemlistwidget.cpp | 12 +- src/kitemviews/kfileitemlistwidget.h | 5 +- src/kitemviews/kfileitemmodel.cpp | 3 + src/kitemviews/kitemlistcontainer.cpp | 14 + src/kitemviews/kitemlistcontroller.cpp | 6 + src/kitemviews/kitemlistview.cpp | 61 ++- src/kitemviews/kitemlistview.h | 2 +- src/kitemviews/kitemlistviewaccessible.cpp | 467 ----------------- src/kitemviews/kitemlistviewaccessible.h | 144 ------ src/kitemviews/kstandarditemlistwidget.cpp | 15 +- src/kitemviews/kstandarditemlistwidget.h | 9 +- src/statusbar/dolphinstatusbar.cpp | 1 + src/views/dolphinview.cpp | 18 + 22 files changed, 1203 insertions(+), 634 deletions(-) create mode 100644 src/kitemviews/accessibility/kitemlistcontaineraccessible.cpp create mode 100644 src/kitemviews/accessibility/kitemlistcontaineraccessible.h create mode 100644 src/kitemviews/accessibility/kitemlistdelegateaccessible.cpp create mode 100644 src/kitemviews/accessibility/kitemlistdelegateaccessible.h create mode 100644 src/kitemviews/accessibility/kitemlistviewaccessible.cpp create mode 100644 src/kitemviews/accessibility/kitemlistviewaccessible.h delete mode 100644 src/kitemviews/kitemlistviewaccessible.cpp delete mode 100644 src/kitemviews/kitemlistviewaccessible.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7d1206e48..ef50cf77d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,6 +2,7 @@ include(ECMAddAppIcon) set(ADMIN_WORKER_PACKAGE_NAME "kio-admin") set(FILELIGHT_PACKAGE_NAME "filelight") + configure_file(config-dolphin.h.cmake ${CMAKE_CURRENT_BINARY_DIR}/config-dolphin.h) add_definitions( @@ -52,6 +53,18 @@ install(FILES ${dolphinvcs_LIB_HEADERS} DESTINATION "${KDE_INSTALL_INCLUDEDIR}/D add_library(dolphinprivate SHARED) +if(NOT QT_NO_ACCESSIBILITY) + target_sources(dolphinprivate PRIVATE + kitemviews/accessibility/kitemlistcontaineraccessible.cpp + kitemviews/accessibility/kitemlistdelegateaccessible.cpp + kitemviews/accessibility/kitemlistviewaccessible.cpp + + kitemviews/accessibility/kitemlistcontaineraccessible.h + kitemviews/accessibility/kitemlistdelegateaccessible.h + kitemviews/accessibility/kitemlistviewaccessible.h + ) +endif() + target_sources(dolphinprivate PRIVATE kitemviews/kfileitemlistview.cpp kitemviews/kfileitemlistwidget.cpp @@ -65,7 +78,6 @@ target_sources(dolphinprivate PRIVATE kitemviews/kitemlistselectionmanager.cpp kitemviews/kitemliststyleoption.cpp kitemviews/kitemlistview.cpp - kitemviews/kitemlistviewaccessible.cpp kitemviews/kitemlistwidget.cpp kitemviews/kitemmodelbase.cpp kitemviews/kitemset.cpp @@ -120,7 +132,6 @@ target_sources(dolphinprivate PRIVATE kitemviews/kitemlistselectionmanager.h kitemviews/kitemliststyleoption.h kitemviews/kitemlistview.h - kitemviews/kitemlistviewaccessible.h kitemviews/kitemlistwidget.h kitemviews/kitemmodelbase.h kitemviews/kitemset.h diff --git a/src/animatedheightwidget.cpp b/src/animatedheightwidget.cpp index cee1a4922..f1631bb6f 100644 --- a/src/animatedheightwidget.cpp +++ b/src/animatedheightwidget.cpp @@ -81,6 +81,7 @@ QWidget *AnimatedHeightWidget::prepareContentsContainer(QWidget *contentsContain "Another contentsContainer has already been prepared. There can only be one."); contentsContainer->setParent(m_contentsContainerParent); m_contentsContainerParent->setWidget(contentsContainer); + m_contentsContainerParent->setFocusProxy(contentsContainer); return contentsContainer; } diff --git a/src/dolphintabwidget.cpp b/src/dolphintabwidget.cpp index f80b94ea7..825ff3c7f 100644 --- a/src/dolphintabwidget.cpp +++ b/src/dolphintabwidget.cpp @@ -21,6 +21,7 @@ #include #include +#include DolphinTabWidget::DolphinTabWidget(DolphinNavigatorsWidgetAction *navigatorsWidget, QWidget *parent) : QTabWidget(parent) @@ -43,6 +44,13 @@ DolphinTabWidget::DolphinTabWidget(DolphinNavigatorsWidgetAction *navigatorsWidg setElideMode(Qt::ElideRight); setUsesScrollButtons(true); setTabBarAutoHide(true); + + auto stackWidget{findChild()}; + // i18n: This accessible name will be announced any time the user moves keyboard focus e.g. from the toolbar or the places panel towards the main working + // area of Dolphin. It gives structure. This container does not only contain the main view but also the status bar, the search panel, filter, and selection + // mode bars, so calling it just a "View" is a bit wrong, but hopefully still gets the point across. + stackWidget->setAccessibleName(i18nc("accessible name of Dolphin's view container", "Location View")); // Without this call, the non-descript Qt provided + // "Layered Pane" role is announced. } DolphinTabPage *DolphinTabWidget::currentTabPage() const diff --git a/src/kitemviews/accessibility/kitemlistcontaineraccessible.cpp b/src/kitemviews/accessibility/kitemlistcontaineraccessible.cpp new file mode 100644 index 000000000..6abf45025 --- /dev/null +++ b/src/kitemviews/accessibility/kitemlistcontaineraccessible.cpp @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: 2012 Amandeep Singh + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "kitemlistcontaineraccessible.h" + +#include "kitemlistcontaineraccessible.h" +#include "kitemlistviewaccessible.h" +#include "kitemviews/kitemlistcontainer.h" +#include "kitemviews/kitemlistcontroller.h" +#include "kitemviews/kitemlistselectionmanager.h" +#include "kitemviews/kitemlistview.h" +#include "kitemviews/kitemmodelbase.h" + +#include + +KItemListContainerAccessible::KItemListContainerAccessible(KItemListContainer *container) + : QAccessibleWidget(container) +{ +} + +KItemListContainerAccessible::~KItemListContainerAccessible() +{ +} + +QString KItemListContainerAccessible::text(QAccessible::Text t) const +{ + Q_UNUSED(t) + return QString(); // This class should never have focus. Instead KItemListViewAccessible should be focused and read out. +} + +int KItemListContainerAccessible::childCount() const +{ + return 1; +} + +int KItemListContainerAccessible::indexOfChild(const QAccessibleInterface *child) const +{ + if (child == KItemListContainerAccessible::child(0)) { + return 0; + } + return -1; +} + +QAccessibleInterface *KItemListContainerAccessible::child(int index) const +{ + if (index == 0) { + Q_CHECK_PTR(static_cast(QAccessible::queryAccessibleInterface(container()->controller()->view()))); + return QAccessible::queryAccessibleInterface(container()->controller()->view()); + } + qWarning("Calling KItemListContainerAccessible::child(index) with index != 0 is always pointless."); + return nullptr; +} + +QAccessibleInterface *KItemListContainerAccessible::focusChild() const +{ + return child(0); +} + +QAccessible::State KItemListContainerAccessible::state() const +{ + auto state = QAccessibleWidget::state(); + state.focusable = false; + state.focused = false; + return state; +} + +void KItemListContainerAccessible::doAction(const QString &actionName) +{ + auto view = static_cast(child(0)); + Q_CHECK_PTR(view); // A container should always have a view. Otherwise it has no reason to exist. + if (actionName == setFocusAction() && view) { + view->doAction(actionName); + return; + } + QAccessibleWidget::doAction(actionName); +} + +const KItemListContainer *KItemListContainerAccessible::container() const +{ + Q_CHECK_PTR(qobject_cast(object())); + return static_cast(object()); +} diff --git a/src/kitemviews/accessibility/kitemlistcontaineraccessible.h b/src/kitemviews/accessibility/kitemlistcontaineraccessible.h new file mode 100644 index 000000000..5a7147a36 --- /dev/null +++ b/src/kitemviews/accessibility/kitemlistcontaineraccessible.h @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2012 Amandeep Singh + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef KITEMLISTCONTAINERACCESSIBLE_H +#define KITEMLISTCONTAINERACCESSIBLE_H + +#include "dolphin_export.h" + +#include + +class KItemListContainer; +class KItemListViewAccessible; + +/** + * The accessible interface for KItemListContainer. + * + * Truthfully, there is absolutely no reason for screen reader users to interact with this interface. + * It is only there to bridge the gap between custom accessible interfaces and the automatically by Qt and QWidgets provided accessible interfaces. + * Really, the main issue is that KItemListContainer itself is the last proper QWidget in the hierarchy while the actual main view is completely custom using + * QGraphicsView instead, so focus usually officially goes to KItemListContainer which messes with the custom accessibility hierarchy. + */ +class DOLPHIN_EXPORT KItemListContainerAccessible : public QAccessibleWidget +{ +public: + explicit KItemListContainerAccessible(KItemListContainer *container); + ~KItemListContainerAccessible() override; + + QString text(QAccessible::Text t) const override; + + QAccessibleInterface *child(int index) const override; + QAccessibleInterface *focusChild() const override; + int childCount() const override; + int indexOfChild(const QAccessibleInterface *child) const override; + + QAccessible::State state() const override; + void doAction(const QString &actionName) override; + + /** @returns the object() of this interface cast to its actual class. */ + const KItemListContainer *container() const; +}; + +#endif // KITEMLISTCONTAINERACCESSIBLE_H diff --git a/src/kitemviews/accessibility/kitemlistdelegateaccessible.cpp b/src/kitemviews/accessibility/kitemlistdelegateaccessible.cpp new file mode 100644 index 000000000..dcfe3af80 --- /dev/null +++ b/src/kitemviews/accessibility/kitemlistdelegateaccessible.cpp @@ -0,0 +1,240 @@ +/* + * SPDX-FileCopyrightText: 2012 Amandeep Singh + * SPDX-FileCopyrightText: 2024 Felix Ernst + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "kitemlistdelegateaccessible.h" +#include "kitemviews/kfileitemlistwidget.h" +#include "kitemviews/kfileitemmodel.h" +#include "kitemviews/kitemlistcontroller.h" +#include "kitemviews/kitemlistselectionmanager.h" +#include "kitemviews/kitemlistview.h" +#include "kitemviews/private/kitemlistviewlayouter.h" + +#include + +#include +#include + +KItemListDelegateAccessible::KItemListDelegateAccessible(KItemListView *view, int index) + : m_view(view) + , m_index(index) +{ + Q_ASSERT(index >= 0 && index < view->model()->count()); +} + +void *KItemListDelegateAccessible::interface_cast(QAccessible::InterfaceType type) +{ + if (type == QAccessible::TableCellInterface) { + return static_cast(this); + } + return nullptr; +} + +int KItemListDelegateAccessible::columnExtent() const +{ + return 1; +} + +int KItemListDelegateAccessible::rowExtent() const +{ + return 1; +} + +QList KItemListDelegateAccessible::rowHeaderCells() const +{ + return QList(); +} + +QList KItemListDelegateAccessible::columnHeaderCells() const +{ + return QList(); +} + +int KItemListDelegateAccessible::columnIndex() const +{ + return m_view->m_layouter->itemColumn(m_index); +} + +int KItemListDelegateAccessible::rowIndex() const +{ + return m_view->m_layouter->itemRow(m_index); +} + +bool KItemListDelegateAccessible::isSelected() const +{ + return m_view->controller()->selectionManager()->isSelected(m_index); +} + +QAccessibleInterface *KItemListDelegateAccessible::table() const +{ + return QAccessible::queryAccessibleInterface(m_view); +} + +QAccessible::Role KItemListDelegateAccessible::role() const +{ + return QAccessible::ListItem; // We could also return "Cell" here which would then announce the exact row and column of the item. However, different from + // applications that actually have a strong cell workflow -- like LibreOfficeCalc -- we have no advantage of announcing the row or column aside from us + // generally being interested in announcing that users in Icon View mode need to use the Left and Right arrow keys to arrive at every item. There are ways + // for users to figure this out regardless by paying attention to the index that is being announced for each list item. In KitemListViewAccessible in icon + // view mode it is also mentioned that the items are positioned in a grid, so the two-dimensionality should be clear enough. +} + +QAccessible::State KItemListDelegateAccessible::state() const +{ + QAccessible::State state; + + state.selectable = true; + if (isSelected()) { + state.selected = true; + } + + state.focusable = true; + if (m_view->controller()->selectionManager()->currentItem() == m_index) { + state.focused = true; + state.active = true; + } + + if (m_view->controller()->selectionBehavior() == KItemListController::MultiSelection) { + state.multiSelectable = true; + } + + if (m_view->supportsItemExpanding() && m_view->model()->isExpandable(m_index)) { + state.expandable = true; + state.expanded = m_view->model()->isExpanded(m_index); + state.collapsed = !state.expanded; + } + + return state; +} + +bool KItemListDelegateAccessible::isExpandable() const +{ + return m_view->model()->isExpandable(m_index); +} + +QRect KItemListDelegateAccessible::rect() const +{ + QRect rect = m_view->itemRect(m_index).toRect(); + + if (rect.isNull()) { + return QRect(); + } + + rect.translate(m_view->mapToScene(QPointF(0.0, 0.0)).toPoint()); + rect.translate(m_view->scene()->views()[0]->mapToGlobal(QPoint(0, 0))); + return rect; +} + +QString KItemListDelegateAccessible::text(QAccessible::Text t) const +{ + const QHash data = m_view->model()->data(m_index); + switch (t) { + case QAccessible::Name: { + return data["text"].toString(); + } + case QAccessible::Description: { + QString description; + + if (data["isHidden"].toBool()) { + description += i18nc("@info", "hidden"); + } + + QString mimeType{data["type"].toString()}; + if (mimeType.isEmpty()) { + const KFileItemModel *model = qobject_cast(m_view->model()); + if (model) { + mimeType = model->fileItem(m_index).mimeComment(); + } + Q_ASSERT_X(!mimeType.isEmpty(), "KItemListDelegateAccessible::text", "Unable to retrieve mime type."); + } + + if (data["isLink"].toBool()) { + QString linkDestination{data["destination"].toString()}; + if (linkDestination.isEmpty()) { + const KFileItemModel *model = qobject_cast(m_view->model()); + if (model) { + linkDestination = model->fileItem(m_index).linkDest(); + } + Q_ASSERT_X(!linkDestination.isEmpty(), "KItemListDelegateAccessible::text", "Unable to retrieve link destination."); + } + + description += i18nc("@info enumeration saying this is a link to $1, %1 is mimeType", ", link to %1 at %2", mimeType, linkDestination); + } else { + description += i18nc("@info enumeration, %1 is mimeType", ", %1", mimeType); + } + const QList additionallyShownInformation{m_view->visibleRoles()}; + const KItemModelBase *model = m_view->model(); + for (const auto &roleInformation : additionallyShownInformation) { + if (roleInformation == "text") { + continue; + } + KFileItemListWidgetInformant informant; + const auto roleText{informant.roleText(roleInformation, data, KFileItemListWidgetInformant::ForUsageAs::SpokenText)}; + if (roleText.isEmpty()) { + continue; // No need to announce roles which are empty for this item. + } + description += + // i18n: The text starts with a comma because multiple occurences of this text can follow after each others as an enumeration. + // Normally it would make sense to have a colon between property and value to make the relation between the property and its property value + // clear, however this is accessible text that will be read out by screen readers. That's why there is only a space between the two here, + // because screen readers would read the colon literally as "colon", which is just a waste of time for users who might go through a list of + // hundreds of items. So, if you want to add any more punctation there to improve structure, try to make sure that it will not lead to annoying + // announcements when read out by a screen reader. + i18nc("@info accessibility enumeration, %1 is property, %2 is value", ", %1 %2", model->roleDescription(roleInformation), roleText); + } + return description; + } + default: + break; + } + + return QString(); +} + +void KItemListDelegateAccessible::setText(QAccessible::Text, const QString &) +{ +} + +QAccessibleInterface *KItemListDelegateAccessible::child(int) const +{ + return nullptr; +} + +bool KItemListDelegateAccessible::isValid() const +{ + return m_view && (m_index >= 0) && (m_index < m_view->model()->count()); +} + +QAccessibleInterface *KItemListDelegateAccessible::childAt(int, int) const +{ + return nullptr; +} + +int KItemListDelegateAccessible::childCount() const +{ + return 0; +} + +int KItemListDelegateAccessible::indexOfChild(const QAccessibleInterface *child) const +{ + Q_UNUSED(child) + return -1; +} + +QAccessibleInterface *KItemListDelegateAccessible::parent() const +{ + return QAccessible::queryAccessibleInterface(m_view); +} + +int KItemListDelegateAccessible::index() const +{ + return m_index; +} + +QObject *KItemListDelegateAccessible::object() const +{ + return nullptr; +} diff --git a/src/kitemviews/accessibility/kitemlistdelegateaccessible.h b/src/kitemviews/accessibility/kitemlistdelegateaccessible.h new file mode 100644 index 000000000..f9f6d5738 --- /dev/null +++ b/src/kitemviews/accessibility/kitemlistdelegateaccessible.h @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2012 Amandeep Singh + * SPDX-FileCopyrightText: 2024 Felix Ernst + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef KITEMLISTDELEGATEACCESSIBLE_H +#define KITEMLISTDELEGATEACCESSIBLE_H + +#include "dolphin_export.h" + +#include +#include +#include + +class KItemListView; + +/** + * The accessibility class that represents singular files or folders in the main view. + */ +class DOLPHIN_EXPORT KItemListDelegateAccessible : public QAccessibleInterface, public QAccessibleTableCellInterface +{ +public: + KItemListDelegateAccessible(KItemListView *view, int m_index); + + void *interface_cast(QAccessible::InterfaceType type) override; + QObject *object() const override; + bool isValid() const override; + QAccessible::Role role() const override; + QAccessible::State state() const override; + QRect rect() const override; + QString text(QAccessible::Text t) const override; + void setText(QAccessible::Text t, const QString &text) override; + + QAccessibleInterface *child(int index) const override; + int childCount() const override; + QAccessibleInterface *childAt(int x, int y) const override; + int indexOfChild(const QAccessibleInterface *) const override; + + QAccessibleInterface *parent() const override; + bool isExpandable() const; + + // Cell Interface + int columnExtent() const override; + QList columnHeaderCells() const override; + int columnIndex() const override; + int rowExtent() const override; + QList rowHeaderCells() const override; + int rowIndex() const override; + bool isSelected() const override; + QAccessibleInterface *table() const override; + + int index() const; + +private: + QPointer m_view; + int m_index; +}; + +#endif // KITEMLISTDELEGATEACCESSIBLE_H diff --git a/src/kitemviews/accessibility/kitemlistviewaccessible.cpp b/src/kitemviews/accessibility/kitemlistviewaccessible.cpp new file mode 100644 index 000000000..2643eb302 --- /dev/null +++ b/src/kitemviews/accessibility/kitemlistviewaccessible.cpp @@ -0,0 +1,479 @@ +/* + * SPDX-FileCopyrightText: 2012 Amandeep Singh + * SPDX-FileCopyrightText: 2024 Felix Ernst + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "kitemlistviewaccessible.h" +#include "kitemlistcontaineraccessible.h" +#include "kitemlistdelegateaccessible.h" + +#include "kitemviews/kitemlistcontainer.h" +#include "kitemviews/kitemlistcontroller.h" +#include "kitemviews/kitemlistselectionmanager.h" +#include "kitemviews/kitemlistview.h" +#include "kitemviews/kitemmodelbase.h" +#include "kitemviews/kstandarditemlistview.h" +#include "kitemviews/private/kitemlistviewlayouter.h" + +#include + +#include // for figuring out if we should move focus to this view. +#include +#include + +KItemListSelectionManager *KItemListViewAccessible::selectionManager() const +{ + return view()->controller()->selectionManager(); +} + +KItemListViewAccessible::KItemListViewAccessible(KItemListView *view_, KItemListContainerAccessible *parent) + : QAccessibleObject(view_) + , m_parent(parent) +{ + Q_ASSERT(view()); + Q_CHECK_PTR(parent); + m_accessibleDelegates.resize(childCount()); + + m_announceDescriptionChangeTimer = new QTimer{view_}; + m_announceDescriptionChangeTimer->setSingleShot(true); + m_announceDescriptionChangeTimer->setInterval(100); + KItemListGroupHeader::connect(m_announceDescriptionChangeTimer, &QTimer::timeout, view_, [this]() { + // The below will have no effect if one of the list items has focus and not the view itself. Still we announce the accessibility description change + // here in case the view itself has focus e.g. after tabbing there or after opening a new location. + QAccessibleEvent announceAccessibleDescriptionEvent(this, QAccessible::DescriptionChanged); + QAccessible::updateAccessibility(&announceAccessibleDescriptionEvent); + }); +} + +KItemListViewAccessible::~KItemListViewAccessible() +{ + for (AccessibleIdWrapper idWrapper : std::as_const(m_accessibleDelegates)) { + if (idWrapper.isValid) { + QAccessible::deleteAccessibleInterface(idWrapper.id); + } + } +} + +void *KItemListViewAccessible::interface_cast(QAccessible::InterfaceType type) +{ + switch (type) { + case QAccessible::SelectionInterface: + return static_cast(this); + case QAccessible::TableInterface: + return static_cast(this); + case QAccessible::ActionInterface: + return static_cast(this); + default: + return nullptr; + } +} + +void KItemListViewAccessible::modelReset() +{ +} + +QAccessibleInterface *KItemListViewAccessible::accessibleDelegate(int index) const +{ + if (index < 0 || index >= view()->model()->count()) { + return nullptr; + } + + if (m_accessibleDelegates.size() <= index) { + m_accessibleDelegates.resize(childCount()); + } + Q_ASSERT(index < m_accessibleDelegates.size()); + + AccessibleIdWrapper idWrapper = m_accessibleDelegates.at(index); + if (!idWrapper.isValid) { + idWrapper.id = QAccessible::registerAccessibleInterface(new KItemListDelegateAccessible(view(), index)); + idWrapper.isValid = true; + m_accessibleDelegates.insert(index, idWrapper); + } + return QAccessible::accessibleInterface(idWrapper.id); +} + +QAccessibleInterface *KItemListViewAccessible::cellAt(int row, int column) const +{ + return accessibleDelegate(columnCount() * row + column); +} + +QAccessibleInterface *KItemListViewAccessible::caption() const +{ + return nullptr; +} + +QString KItemListViewAccessible::columnDescription(int) const +{ + return QString(); +} + +int KItemListViewAccessible::columnCount() const +{ + return view()->m_layouter->columnCount(); +} + +int KItemListViewAccessible::rowCount() const +{ + if (columnCount() <= 0) { + return 0; + } + + int itemCount = view()->model()->count(); + int rowCount = itemCount / columnCount(); + + if (rowCount <= 0) { + return 0; + } + + if (itemCount % columnCount()) { + ++rowCount; + } + return rowCount; +} + +int KItemListViewAccessible::selectedCellCount() const +{ + return selectionManager()->selectedItems().count(); +} + +int KItemListViewAccessible::selectedColumnCount() const +{ + return 0; +} + +int KItemListViewAccessible::selectedRowCount() const +{ + return 0; +} + +QString KItemListViewAccessible::rowDescription(int) const +{ + return QString(); +} + +QList KItemListViewAccessible::selectedCells() const +{ + QList cells; + const auto items = selectionManager()->selectedItems(); + cells.reserve(items.count()); + for (int index : items) { + cells.append(accessibleDelegate(index)); + } + return cells; +} + +QList KItemListViewAccessible::selectedColumns() const +{ + return QList(); +} + +QList KItemListViewAccessible::selectedRows() const +{ + return QList(); +} + +QAccessibleInterface *KItemListViewAccessible::summary() const +{ + return nullptr; +} + +bool KItemListViewAccessible::isColumnSelected(int) const +{ + return false; +} + +bool KItemListViewAccessible::isRowSelected(int) const +{ + return false; +} + +bool KItemListViewAccessible::selectRow(int) +{ + return true; +} + +bool KItemListViewAccessible::selectColumn(int) +{ + return true; +} + +bool KItemListViewAccessible::unselectRow(int) +{ + return true; +} + +bool KItemListViewAccessible::unselectColumn(int) +{ + return true; +} + +void KItemListViewAccessible::modelChange(QAccessibleTableModelChangeEvent * /*event*/) +{ +} + +QAccessible::Role KItemListViewAccessible::role() const +{ + return QAccessible::List; +} + +QAccessible::State KItemListViewAccessible::state() const +{ + QAccessible::State s; + s.focusable = true; + s.active = true; + const KItemListController *controller = view()->m_controller; + s.multiSelectable = controller->selectionBehavior() == KItemListController::MultiSelection; + s.focused = !childCount() && (view()->hasFocus() || m_parent->container()->hasFocus()); // Usually the children have focus. + return s; +} + +QAccessibleInterface *KItemListViewAccessible::childAt(int x, int y) const +{ + const QPointF point = QPointF(x, y); + const std::optional itemIndex = view()->itemAt(view()->mapFromScene(point)); + return child(itemIndex.value_or(-1)); +} + +QAccessibleInterface *KItemListViewAccessible::parent() const +{ + return m_parent; +} + +int KItemListViewAccessible::childCount() const +{ + return view()->model()->count(); +} + +int KItemListViewAccessible::indexOfChild(const QAccessibleInterface *interface) const +{ + const KItemListDelegateAccessible *widget = static_cast(interface); + return widget->index(); +} + +QString KItemListViewAccessible::text(QAccessible::Text t) const +{ + const KItemListController *controller = view()->m_controller; + const KItemModelBase *model = controller->model(); + const QUrl modelRootUrl = model->directory(); + if (t == QAccessible::Name) { + return modelRootUrl.fileName(); + } + if (t != QAccessible::Description) { + return QString(); + } + const auto currentItem = child(controller->selectionManager()->currentItem()); + if (!currentItem) { + return i18nc("@info 1 states that the folder is empty and sometimes why, 2 is the full filesystem path", + "%1 at location %2", + m_placeholderMessage, + modelRootUrl.toDisplayString()); + } + + const QString selectionStateString{isSelected(currentItem) ? QString() + // i18n: There is a comma at the end because this is one property in an enumeration of + // properties that a file or folder has. Accessible text for accessibility software like screen + // readers. + : i18n("not selected,")}; + + QString expandableStateString; + if (currentItem->state().expandable) { + if (currentItem->state().collapsed) { + // i18n: There is a comma at the end because this is one property in an enumeration of properties that a folder in a tree view has. + // Accessible text for accessibility software like screen readers. + expandableStateString = i18n("collapsed,"); + } else { + // i18n: There is a comma at the end because this is one property in an enumeration of properties that a folder in a tree view has. + // Accessible text for accessibility software like screen readers. + expandableStateString = i18n("expanded,"); + } + } + + const QString selectedItemCountString{selectedItemCount() > 1 + // i18n: There is a "—" at the beginning because this is a followup sentence to a text that did not properly end + // with a period. Accessible text for accessibility software like screen readers. + ? i18np("— %1 selected item", "— %1 selected items", selectedItemCount()) + : QString()}; + + // Determine if we should announce the item layout. For end users of the accessibility tree there is an expectation that a list can be scrolled through by + // pressing the "Down" key repeatedly. This is not the case in the icon view mode, where pressing "Right" or "Left" moves through the whole list of items. + // Therefore we need to announce this layout when in icon view mode. + QString layoutAnnouncementString; + if (auto standardView = qobject_cast(view())) { + if (standardView->itemLayout() == KStandardItemListView::ItemLayout::IconsLayout) { + layoutAnnouncementString = i18nc("@info refering to a file or folder", "in a grid layout"); + } + } + + /** + * Announce it in this order so the most important information is at the beginning and the potentially very long path at the end: + * "$currentlyFocussedItemName, $currentlyFocussedItemDescription, $currentFolderPath". + * We do not need to announce the total count of items here because accessibility software like Orca alrady announces this automatically for lists. + * Normally for list items the selection and expandadable state are also automatically announced by Orca, however we are building the accessible + * description of the view here, so we need to manually add all infomation about the current item we also want to announce. + */ + return i18nc( + "@info 1 is currentlyFocussedItemName, 2 is empty or \"not selected, \", 3 is currentlyFocussedItemDescription, 3 is currentFolderName, 4 is " + "currentFolderPath", + "%1, %2 %3 %4 %5 %6 in location %7", + currentItem->text(QAccessible::Name), + selectionStateString, + expandableStateString, + currentItem->text(QAccessible::Description), + selectedItemCountString, + layoutAnnouncementString, + modelRootUrl.toDisplayString()); +} + +QRect KItemListViewAccessible::rect() const +{ + if (!view()->isVisible()) { + return QRect(); + } + + const QGraphicsScene *scene = view()->scene(); + if (scene) { + const QPoint origin = scene->views().at(0)->mapToGlobal(QPoint(0, 0)); + const QRect viewRect = view()->geometry().toRect(); + return viewRect.translated(origin); + } else { + return QRect(); + } +} + +QAccessibleInterface *KItemListViewAccessible::child(int index) const +{ + if (index >= 0 && index < childCount()) { + return accessibleDelegate(index); + } + return nullptr; +} + +KItemListViewAccessible::AccessibleIdWrapper::AccessibleIdWrapper() + : isValid(false) + , id(0) +{ +} + +/* Selection interface */ + +bool KItemListViewAccessible::clear() +{ + selectionManager()->clearSelection(); + return true; +} + +bool KItemListViewAccessible::isSelected(QAccessibleInterface *childItem) const +{ + Q_CHECK_PTR(childItem); + return static_cast(childItem)->isSelected(); +} + +bool KItemListViewAccessible::select(QAccessibleInterface *childItem) +{ + selectionManager()->setSelected(indexOfChild(childItem)); + return true; +} + +bool KItemListViewAccessible::selectAll() +{ + selectionManager()->setSelected(0, childCount()); + return true; +} + +QAccessibleInterface *KItemListViewAccessible::selectedItem(int selectionIndex) const +{ + const auto selectedItems = selectionManager()->selectedItems(); + int i = 0; + for (auto it = selectedItems.rbegin(); it != selectedItems.rend(); ++it) { + if (i == selectionIndex) { + return child(*it); + } + } + return nullptr; +} + +int KItemListViewAccessible::selectedItemCount() const +{ + return selectionManager()->selectedItems().count(); +} + +QList KItemListViewAccessible::selectedItems() const +{ + const auto selectedItems = selectionManager()->selectedItems(); + QList selectedItemsInterfaces; + for (auto it = selectedItems.rbegin(); it != selectedItems.rend(); ++it) { + selectedItemsInterfaces.append(child(*it)); + } + return selectedItemsInterfaces; +} + +bool KItemListViewAccessible::unselect(QAccessibleInterface *childItem) +{ + selectionManager()->setSelected(indexOfChild(childItem), 1, KItemListSelectionManager::Deselect); + return true; +} + +/* Action Interface */ + +QStringList KItemListViewAccessible::actionNames() const +{ + return {setFocusAction()}; +} + +void KItemListViewAccessible::doAction(const QString &actionName) +{ + if (actionName == setFocusAction()) { + view()->setFocus(); + } +} + +QStringList KItemListViewAccessible::keyBindingsForAction(const QString &actionName) const +{ + Q_UNUSED(actionName) + return {}; +} + +/* Custom non-interface methods */ + +KItemListView *KItemListViewAccessible::view() const +{ + Q_CHECK_PTR(qobject_cast(object())); + return static_cast(object()); +} + +void KItemListViewAccessible::announceOverallViewState(const QString &placeholderMessage) +{ + m_placeholderMessage = placeholderMessage; + + // Make sure we announce this placeholderMessage. However, do not announce it when the focus is on an unrelated object currently. + // We for example do not want to announce "Loading cancelled" when the focus is currently on an error message explaining why the loading was cancelled. + if (view()->hasFocus() || !QApplication::focusWidget() || static_cast(m_parent->object())->isAncestorOf(QApplication::focusWidget())) { + view()->setFocus(); + // If we move focus to an item and right after that the description of the item is changed, the item will be announced twice. + // We want to avoid that so we wait until after the description change was announced to move focus. + KItemListGroupHeader::connect( + m_announceDescriptionChangeTimer, + &QTimer::timeout, + view(), + [this]() { + if (view()->hasFocus() || !QApplication::focusWidget() + || static_cast(m_parent->object())->isAncestorOf(QApplication::focusWidget())) { + QAccessibleEvent accessibleFocusEvent(this, QAccessible::Focus); + QAccessible::updateAccessibility(&accessibleFocusEvent); // This accessibility update is perhaps even too important: It is generally + // the last triggered update after changing the currently viewed folder. This call makes sure that we announce the new directory in + // full. Furthermore it also serves its original purpose of making sure we announce the placeholderMessage in empty folders. + } + }, + Qt::SingleShotConnection); + if (!m_announceDescriptionChangeTimer->isActive()) { + m_announceDescriptionChangeTimer->start(); + } + } +} + +void KItemListViewAccessible::announceDescriptionChange() +{ + m_announceDescriptionChangeTimer->start(); +} diff --git a/src/kitemviews/accessibility/kitemlistviewaccessible.h b/src/kitemviews/accessibility/kitemlistviewaccessible.h new file mode 100644 index 000000000..4c44b18ad --- /dev/null +++ b/src/kitemviews/accessibility/kitemlistviewaccessible.h @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: 2012 Amandeep Singh + * SPDX-FileCopyrightText: 2024 Felix Ernst + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef KITEMLISTVIEWACCESSIBLE_H +#define KITEMLISTVIEWACCESSIBLE_H + +#include "dolphin_export.h" + +#include +#include +#include +#include + +class KItemListView; +class KItemListContainer; +class KItemListContainerAccessible; +class KItemListSelectionManager; + +/** + * The main class for making the main view accessible. + * + * Such a class is necessary because the KItemListView is a mostly custom entity. This class provides a lot of the functionality to make it possible to + * interact with the view using accessibility tools. It implements various interfaces mostly to generally allow working with the view as a whole. However, + * actually interacting with singular items within the view is implemented in KItemListDelegateAccessible. + * + * @note For documentation of most of the methods within this class, check out the documentation of the methods which are being overriden here. + */ +class DOLPHIN_EXPORT KItemListViewAccessible : public QAccessibleObject, + public QAccessibleSelectionInterface, + public QAccessibleTableInterface, + public QAccessibleActionInterface +{ +public: + explicit KItemListViewAccessible(KItemListView *view, KItemListContainerAccessible *parent); + ~KItemListViewAccessible() override; + + // QAccessibleObject + void *interface_cast(QAccessible::InterfaceType type) override; + + QAccessible::Role role() const override; + QAccessible::State state() const override; + QString text(QAccessible::Text t) const override; + QRect rect() const override; + + QAccessibleInterface *child(int index) const override; + int childCount() const override; + int indexOfChild(const QAccessibleInterface *) const override; + QAccessibleInterface *childAt(int x, int y) const override; + QAccessibleInterface *parent() const override; + + // Table interface + QAccessibleInterface *cellAt(int row, int column) const override; + QAccessibleInterface *caption() const override; + QAccessibleInterface *summary() const override; + QString columnDescription(int column) const override; + QString rowDescription(int row) const override; + int columnCount() const override; + int rowCount() const override; + + // Selection + int selectedCellCount() const override; + int selectedColumnCount() const override; + int selectedRowCount() const override; + QList selectedCells() const override; + QList selectedColumns() const override; + QList selectedRows() const override; + bool isColumnSelected(int column) const override; + bool isRowSelected(int row) const override; + bool selectRow(int row) override; + bool selectColumn(int column) override; + bool unselectRow(int row) override; + bool unselectColumn(int column) override; + void modelChange(QAccessibleTableModelChangeEvent *) override; + + // Selection interface + /** Clear selection */ + bool clear() override; + bool isSelected(QAccessibleInterface *childItem) const override; + bool select(QAccessibleInterface *childItem) override; + bool selectAll() override; + QAccessibleInterface *selectedItem(int selectionIndex) const override; + int selectedItemCount() const override; + QList selectedItems() const override; + bool unselect(QAccessibleInterface *childItem) override; + + // Action interface + QStringList actionNames() const override; + void doAction(const QString &actionName) override; + QStringList keyBindingsForAction(const QString &actionName) const override; + + // Custom non-interface methods + KItemListView *view() const; + + /** + * Moves the focus to the list view itself so an overview over the state can be given. + * @param placeholderMessage the message that should be announced when no items are visible (yet). This message is mostly identical to + * DolphinView::m_placeholderLabel in both content and purpose. @see DolphinView::updatePlaceHolderLabel(). + */ + void announceOverallViewState(const QString &placeholderMessage); + + /** + * Announces that the description of the view has changed. The changed description will only be announced if the view has focus (from an accessibility + * point of view). This method ensures that multiple calls to this method within a small time frame will only lead to a singular announcement instead of + * multiple or already outdated ones, so calling this method instead of manually sending accessibility events for this view is preferred. + */ + void announceDescriptionChange(); + +protected: + virtual void modelReset(); + /** + * @returns a KItemListDelegateAccessible representing the file or folder at the @index. Returns nullptr for invalid indices. + * If a KItemListDelegateAccessible for an index does not yet exist, it will be created. + * Index is 0-based. + */ + inline QAccessibleInterface *accessibleDelegate(int index) const; + + KItemListSelectionManager *selectionManager() const; + +private: + /** @see setPlaceholderMessage(). */ + QString m_placeholderMessage; + + QTimer *m_announceDescriptionChangeTimer; + + class AccessibleIdWrapper + { + public: + AccessibleIdWrapper(); + bool isValid; + QAccessible::Id id; + }; + /** + * A list that maps the indices of the children of this KItemListViewAccessible to the accessible ids of the matching KItemListDelegateAccessible objects. + * For example: m_accessibleDelegates.at(2) would be the AccessibleIdWrapper with an id which can be used to retrieve the QAccessibleObject that represents + * the third file in this view. + */ + mutable QVector m_accessibleDelegates; + + KItemListContainerAccessible *m_parent; +}; + +#endif diff --git a/src/kitemviews/kfileitemlistwidget.cpp b/src/kitemviews/kfileitemlistwidget.cpp index b4e0895f2..3a7b37895 100644 --- a/src/kitemviews/kfileitemlistwidget.cpp +++ b/src/kitemviews/kfileitemlistwidget.cpp @@ -45,7 +45,7 @@ bool KFileItemListWidgetInformant::itemIsLink(int index, const KItemListView *vi return item.isLink(); } -QString KFileItemListWidgetInformant::roleText(const QByteArray &role, const QHash &values) const +QString KFileItemListWidgetInformant::roleText(const QByteArray &role, const QHash &values, ForUsageAs forUsageAs) const { QString text; const QVariant roleValue = values.value(role); @@ -55,11 +55,13 @@ QString KFileItemListWidgetInformant::roleText(const QByteArray &role, const QHa // Implementation note: In case if more roles require a custom handling // use a hash + switch for a linear runtime. - auto formatDate = [formatter, local](const QDateTime &time) { + auto formatDate = [formatter, local, forUsageAs](const QDateTime &time) { if (ContentDisplaySettings::useShortRelativeDates()) { - return formatter.formatRelativeDateTime(time, QLocale::ShortFormat); + return formatter.formatRelativeDateTime(time, + forUsageAs == KStandardItemListWidgetInformant::ForUsageAs::DisplayedText ? QLocale::ShortFormat + : QLocale::LongFormat); } else { - return local.toString(time, QLocale::ShortFormat); + return local.toString(time, forUsageAs == KStandardItemListWidgetInformant::ForUsageAs::DisplayedText ? QLocale::ShortFormat : QLocale::LongFormat); } }; @@ -114,7 +116,7 @@ QString KFileItemListWidgetInformant::roleText(const QByteArray &role, const QHa break; } } else { - text = KStandardItemListWidgetInformant::roleText(role, values); + text = KStandardItemListWidgetInformant::roleText(role, values, forUsageAs); } return text; diff --git a/src/kitemviews/kfileitemlistwidget.h b/src/kitemviews/kfileitemlistwidget.h index 5ce11b6da..e2db43178 100644 --- a/src/kitemviews/kfileitemlistwidget.h +++ b/src/kitemviews/kfileitemlistwidget.h @@ -28,8 +28,11 @@ public: protected: QString itemText(int index, const KItemListView *view) const override; bool itemIsLink(int index, const KItemListView *view) const override; - QString roleText(const QByteArray &role, const QHash &values) const override; + /** @see KStandardItemListWidget::roleText(). */ + QString roleText(const QByteArray &role, const QHash &values, ForUsageAs forUsageAs = ForUsageAs::DisplayedText) const override; QFont customizedFontForLinks(const QFont &baseFont) const override; + + friend class KItemListDelegateAccessible; }; /** diff --git a/src/kitemviews/kfileitemmodel.cpp b/src/kitemviews/kfileitemmodel.cpp index 3a60834af..fb5851c36 100644 --- a/src/kitemviews/kfileitemmodel.cpp +++ b/src/kitemviews/kfileitemmodel.cpp @@ -20,6 +20,9 @@ #include #include +#ifndef QT_NO_ACCESSIBILITY +#include +#endif #include #include #include diff --git a/src/kitemviews/kitemlistcontainer.cpp b/src/kitemviews/kitemlistcontainer.cpp index 2f9f5d401..65250832b 100644 --- a/src/kitemviews/kitemlistcontainer.cpp +++ b/src/kitemviews/kitemlistcontainer.cpp @@ -12,6 +12,9 @@ #include "kitemlistview.h" #include "private/kitemlistsmoothscroller.h" +#ifndef QT_NO_ACCESSIBILITY +#include +#endif #include #include #include @@ -195,6 +198,17 @@ void KItemListContainer::focusInEvent(QFocusEvent *event) KItemListView *view = m_controller->view(); if (view) { QApplication::sendEvent(view, event); + + // We need to set the focus to the view or accessibility software will only announce the container (which has no information available itself). + // For some reason actively setting the focus to the view needs to be delayed or the focus will immediately go back to this container. + QTimer::singleShot(0, this, [this, view]() { + view->setFocus(); +#ifndef QT_NO_ACCESSIBILITY + QAccessibleEvent accessibleFocusInEvent(this, QAccessible::Focus); + accessibleFocusInEvent.setChild(0); + QAccessible::updateAccessibility(&accessibleFocusInEvent); +#endif + }); } } diff --git a/src/kitemviews/kitemlistcontroller.cpp b/src/kitemviews/kitemlistcontroller.cpp index 821e1b75f..1db665f47 100644 --- a/src/kitemviews/kitemlistcontroller.cpp +++ b/src/kitemviews/kitemlistcontroller.cpp @@ -467,6 +467,12 @@ bool KItemListController::keyPressEvent(QKeyEvent *event) case Qt::Key_Space: if (m_selectionBehavior == MultiSelection) { +#ifndef QT_NO_ACCESSIBILITY + // Move accessible focus to the item that is acted upon, so only the state change of this item is announced and not the whole view. + QAccessibleEvent accessibilityEvent(view(), QAccessible::Focus); + accessibilityEvent.setChild(index); + QAccessible::updateAccessibility(&accessibilityEvent); +#endif if (controlPressed) { // Toggle the selection state of the current item. m_selectionManager->endAnchoredSelection(); diff --git a/src/kitemviews/kitemlistview.cpp b/src/kitemviews/kitemlistview.cpp index afc392810..b0ea32940 100644 --- a/src/kitemviews/kitemlistview.cpp +++ b/src/kitemviews/kitemlistview.cpp @@ -8,12 +8,16 @@ #include "kitemlistview.h" +#ifndef QT_NO_ACCESSIBILITY +#include "accessibility/kitemlistcontaineraccessible.h" +#include "accessibility/kitemlistdelegateaccessible.h" +#include "accessibility/kitemlistviewaccessible.h" +#endif #include "dolphindebug.h" #include "kitemlistcontainer.h" #include "kitemlistcontroller.h" #include "kitemlistheader.h" #include "kitemlistselectionmanager.h" -#include "kitemlistviewaccessible.h" #include "kstandarditemlistwidget.h" #include "private/kitemlistheaderwidget.h" @@ -1240,6 +1244,11 @@ void KItemListView::slotItemsInserted(const KItemRangeList &itemRanges) if (useAlternateBackgrounds()) { updateAlternateBackgrounds(); } +#ifndef QT_NO_ACCESSIBILITY + if (QAccessible::isActive()) { // Announce that the count of items has changed. + static_cast(QAccessible::queryAccessibleInterface(this))->announceDescriptionChange(); + } +#endif } void KItemListView::slotItemsRemoved(const KItemRangeList &itemRanges) @@ -1358,6 +1367,11 @@ void KItemListView::slotItemsRemoved(const KItemRangeList &itemRanges) if (useAlternateBackgrounds()) { updateAlternateBackgrounds(); } +#ifndef QT_NO_ACCESSIBILITY + if (QAccessible::isActive()) { // Announce that the count of items has changed. + static_cast(QAccessible::queryAccessibleInterface(this))->announceDescriptionChange(); + } +#endif } void KItemListView::slotItemsMoved(const KItemRange &itemRange, const QList &movedToIndexes) @@ -1416,10 +1430,12 @@ void KItemListView::slotItemsChanged(const KItemRangeList &itemRanges, const QSe doLayout(NoAnimation); } +#ifndef QT_NO_ACCESSIBILITY QAccessibleTableModelChangeEvent ev(this, QAccessibleTableModelChangeEvent::DataChanged); ev.setFirstRow(itemRange.index); ev.setLastRow(itemRange.index + itemRange.count); QAccessible::updateAccessibility(&ev); +#endif } doLayout(NoAnimation); @@ -1483,8 +1499,6 @@ void KItemListView::slotSortRoleChanged(const QByteArray ¤t, const QByteAr void KItemListView::slotCurrentChanged(int current, int previous) { - Q_UNUSED(previous) - // In SingleSelection mode (e.g., in the Places Panel), the current item is // always the selected item. It is not necessary to highlight the current item then. if (m_controller->selectionBehavior() != KItemListController::SingleSelection) { @@ -1496,25 +1510,52 @@ void KItemListView::slotCurrentChanged(int current, int previous) KItemListWidget *currentWidget = m_visibleItems.value(current, nullptr); if (currentWidget) { currentWidget->setCurrent(true); + if (hasFocus() || (previousWidget && previousWidget->hasFocus())) { + currentWidget->setFocus(); // Mostly for accessibility, because keyboard events are handled correctly either way. + } } } - - QAccessibleEvent ev(this, QAccessible::Focus); - ev.setChild(current); - QAccessible::updateAccessibility(&ev); +#ifndef QT_NO_ACCESSIBILITY + if (QAccessible::isActive()) { + if (current >= 0) { + QAccessibleEvent accessibleFocusCurrentItemEvent(this, QAccessible::Focus); + accessibleFocusCurrentItemEvent.setChild(current); + QAccessible::updateAccessibility(&accessibleFocusCurrentItemEvent); + } + static_cast(QAccessible::queryAccessibleInterface(this))->announceDescriptionChange(); + } +#endif } void KItemListView::slotSelectionChanged(const KItemSet ¤t, const KItemSet &previous) { - Q_UNUSED(previous) - QHashIterator it(m_visibleItems); while (it.hasNext()) { it.next(); const int index = it.key(); KItemListWidget *widget = it.value(); - widget->setSelected(current.contains(index)); + const bool isSelected(current.contains(index)); + widget->setSelected(isSelected); + +#ifndef QT_NO_ACCESSIBILITY + if (!QAccessible::isActive()) { + continue; + } + // Let the screen reader announce "selected" or "not selected" for the active item. + const bool wasSelected(previous.contains(index)); + if (isSelected != wasSelected) { + QAccessibleEvent accessibleSelectionChangedEvent(this, QAccessible::Selection); + accessibleSelectionChangedEvent.setChild(index); + QAccessible::updateAccessibility(&accessibleSelectionChangedEvent); + } } + // Usually the below does not have an effect because the view will not have focus at this moment but one of its list items. Still we announce the + // change of the accessibility description just in case the user manually moved focus up by one. + static_cast(QAccessible::queryAccessibleInterface(this))->announceDescriptionChange(); +#else + } + Q_UNUSED(previous) +#endif } void KItemListView::slotAnimationFinished(QGraphicsWidget *widget, KItemListViewAnimation::AnimationType type) diff --git a/src/kitemviews/kitemlistview.h b/src/kitemviews/kitemlistview.h index 8812eb8cc..7be08302c 100644 --- a/src/kitemviews/kitemlistview.h +++ b/src/kitemviews/kitemlistview.h @@ -793,7 +793,7 @@ private: friend class KItemListController; friend class KItemListControllerTest; friend class KItemListViewAccessible; - friend class KItemListAccessibleCell; + friend class KItemListDelegateAccessible; }; /** diff --git a/src/kitemviews/kitemlistviewaccessible.cpp b/src/kitemviews/kitemlistviewaccessible.cpp deleted file mode 100644 index a8d80ab52..000000000 --- a/src/kitemviews/kitemlistviewaccessible.cpp +++ /dev/null @@ -1,467 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2012 Amandeep Singh - * - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#ifndef QT_NO_ACCESSIBILITY -#include "kitemlistviewaccessible.h" - -#include "kitemlistcontainer.h" -#include "kitemlistcontroller.h" -#include "kitemlistselectionmanager.h" -#include "kitemlistview.h" -#include "private/kitemlistviewlayouter.h" - -#include -#include - -KItemListView *KItemListViewAccessible::view() const -{ - return qobject_cast(object()); -} - -KItemListViewAccessible::KItemListViewAccessible(KItemListView *view_, KItemListContainerAccessible *parent) - : QAccessibleObject(view_) - , m_parent(parent) -{ - Q_ASSERT(view()); - Q_CHECK_PTR(parent); - m_cells.resize(childCount()); -} - -KItemListViewAccessible::~KItemListViewAccessible() -{ - for (AccessibleIdWrapper idWrapper : std::as_const(m_cells)) { - if (idWrapper.isValid) { - QAccessible::deleteAccessibleInterface(idWrapper.id); - } - } -} - -void *KItemListViewAccessible::interface_cast(QAccessible::InterfaceType type) -{ - if (type == QAccessible::TableInterface) { - return static_cast(this); - } - return nullptr; -} - -void KItemListViewAccessible::modelReset() -{ -} - -QAccessibleInterface *KItemListViewAccessible::cell(int index) const -{ - if (index < 0 || index >= view()->model()->count()) { - return nullptr; - } - - if (m_cells.size() <= index) { - m_cells.resize(childCount()); - } - Q_ASSERT(index < m_cells.size()); - - AccessibleIdWrapper idWrapper = m_cells.at(index); - if (!idWrapper.isValid) { - idWrapper.id = QAccessible::registerAccessibleInterface(new KItemListAccessibleCell(view(), index)); - idWrapper.isValid = true; - m_cells.insert(index, idWrapper); - } - return QAccessible::accessibleInterface(idWrapper.id); -} - -QAccessibleInterface *KItemListViewAccessible::cellAt(int row, int column) const -{ - return cell(columnCount() * row + column); -} - -QAccessibleInterface *KItemListViewAccessible::caption() const -{ - return nullptr; -} - -QString KItemListViewAccessible::columnDescription(int) const -{ - return QString(); -} - -int KItemListViewAccessible::columnCount() const -{ - return view()->m_layouter->columnCount(); -} - -int KItemListViewAccessible::rowCount() const -{ - if (columnCount() <= 0) { - return 0; - } - - int itemCount = view()->model()->count(); - int rowCount = itemCount / columnCount(); - - if (rowCount <= 0) { - return 0; - } - - if (itemCount % columnCount()) { - ++rowCount; - } - return rowCount; -} - -int KItemListViewAccessible::selectedCellCount() const -{ - return view()->controller()->selectionManager()->selectedItems().count(); -} - -int KItemListViewAccessible::selectedColumnCount() const -{ - return 0; -} - -int KItemListViewAccessible::selectedRowCount() const -{ - return 0; -} - -QString KItemListViewAccessible::rowDescription(int) const -{ - return QString(); -} - -QList KItemListViewAccessible::selectedCells() const -{ - QList cells; - const auto items = view()->controller()->selectionManager()->selectedItems(); - cells.reserve(items.count()); - for (int index : items) { - cells.append(cell(index)); - } - return cells; -} - -QList KItemListViewAccessible::selectedColumns() const -{ - return QList(); -} - -QList KItemListViewAccessible::selectedRows() const -{ - return QList(); -} - -QAccessibleInterface *KItemListViewAccessible::summary() const -{ - return nullptr; -} - -bool KItemListViewAccessible::isColumnSelected(int) const -{ - return false; -} - -bool KItemListViewAccessible::isRowSelected(int) const -{ - return false; -} - -bool KItemListViewAccessible::selectRow(int) -{ - return true; -} - -bool KItemListViewAccessible::selectColumn(int) -{ - return true; -} - -bool KItemListViewAccessible::unselectRow(int) -{ - return true; -} - -bool KItemListViewAccessible::unselectColumn(int) -{ - return true; -} - -void KItemListViewAccessible::modelChange(QAccessibleTableModelChangeEvent * /*event*/) -{ -} - -QAccessible::Role KItemListViewAccessible::role() const -{ - return QAccessible::Table; -} - -QAccessible::State KItemListViewAccessible::state() const -{ - QAccessible::State s; - return s; -} - -QAccessibleInterface *KItemListViewAccessible::childAt(int x, int y) const -{ - const QPointF point = QPointF(x, y); - const std::optional itemIndex = view()->itemAt(view()->mapFromScene(point)); - return child(itemIndex.value_or(-1)); -} - -QAccessibleInterface *KItemListViewAccessible::parent() const -{ - return m_parent; -} - -int KItemListViewAccessible::childCount() const -{ - return view()->model()->count(); -} - -int KItemListViewAccessible::indexOfChild(const QAccessibleInterface *interface) const -{ - const KItemListAccessibleCell *widget = static_cast(interface); - return widget->index(); -} - -QString KItemListViewAccessible::text(QAccessible::Text) const -{ - return QString(); -} - -QRect KItemListViewAccessible::rect() const -{ - if (!view()->isVisible()) { - return QRect(); - } - - const QGraphicsScene *scene = view()->scene(); - if (scene) { - const QPoint origin = scene->views().at(0)->mapToGlobal(QPoint(0, 0)); - const QRect viewRect = view()->geometry().toRect(); - return viewRect.translated(origin); - } else { - return QRect(); - } -} - -QAccessibleInterface *KItemListViewAccessible::child(int index) const -{ - if (index >= 0 && index < childCount()) { - return cell(index); - } - return nullptr; -} - -KItemListViewAccessible::AccessibleIdWrapper::AccessibleIdWrapper() - : isValid(false) - , id(0) -{ -} - -// Table Cell - -KItemListAccessibleCell::KItemListAccessibleCell(KItemListView *view, int index) - : m_view(view) - , m_index(index) -{ - Q_ASSERT(index >= 0 && index < view->model()->count()); -} - -void *KItemListAccessibleCell::interface_cast(QAccessible::InterfaceType type) -{ - if (type == QAccessible::TableCellInterface) { - return static_cast(this); - } - return nullptr; -} - -int KItemListAccessibleCell::columnExtent() const -{ - return 1; -} - -int KItemListAccessibleCell::rowExtent() const -{ - return 1; -} - -QList KItemListAccessibleCell::rowHeaderCells() const -{ - return QList(); -} - -QList KItemListAccessibleCell::columnHeaderCells() const -{ - return QList(); -} - -int KItemListAccessibleCell::columnIndex() const -{ - return m_view->m_layouter->itemColumn(m_index); -} - -int KItemListAccessibleCell::rowIndex() const -{ - return m_view->m_layouter->itemRow(m_index); -} - -bool KItemListAccessibleCell::isSelected() const -{ - return m_view->controller()->selectionManager()->isSelected(m_index); -} - -QAccessibleInterface *KItemListAccessibleCell::table() const -{ - return QAccessible::queryAccessibleInterface(m_view); -} - -QAccessible::Role KItemListAccessibleCell::role() const -{ - return QAccessible::Cell; -} - -QAccessible::State KItemListAccessibleCell::state() const -{ - QAccessible::State state; - - state.selectable = true; - if (isSelected()) { - state.selected = true; - } - - state.focusable = true; - if (m_view->controller()->selectionManager()->currentItem() == m_index) { - state.focused = true; - } - - if (m_view->controller()->selectionBehavior() == KItemListController::MultiSelection) { - state.multiSelectable = true; - } - - if (m_view->model()->isExpandable(m_index)) { - if (m_view->model()->isExpanded(m_index)) { - state.expanded = true; - } else { - state.collapsed = true; - } - } - - return state; -} - -bool KItemListAccessibleCell::isExpandable() const -{ - return m_view->model()->isExpandable(m_index); -} - -QRect KItemListAccessibleCell::rect() const -{ - QRect rect = m_view->itemRect(m_index).toRect(); - - if (rect.isNull()) { - return QRect(); - } - - rect.translate(m_view->mapToScene(QPointF(0.0, 0.0)).toPoint()); - rect.translate(m_view->scene()->views()[0]->mapToGlobal(QPoint(0, 0))); - return rect; -} - -QString KItemListAccessibleCell::text(QAccessible::Text t) const -{ - switch (t) { - case QAccessible::Name: { - const QHash data = m_view->model()->data(m_index); - return data["text"].toString(); - } - - default: - break; - } - - return QString(); -} - -void KItemListAccessibleCell::setText(QAccessible::Text, const QString &) -{ -} - -QAccessibleInterface *KItemListAccessibleCell::child(int) const -{ - return nullptr; -} - -bool KItemListAccessibleCell::isValid() const -{ - return m_view && (m_index >= 0) && (m_index < m_view->model()->count()); -} - -QAccessibleInterface *KItemListAccessibleCell::childAt(int, int) const -{ - return nullptr; -} - -int KItemListAccessibleCell::childCount() const -{ - return 0; -} - -int KItemListAccessibleCell::indexOfChild(const QAccessibleInterface *child) const -{ - Q_UNUSED(child) - return -1; -} - -QAccessibleInterface *KItemListAccessibleCell::parent() const -{ - return QAccessible::queryAccessibleInterface(m_view); -} - -int KItemListAccessibleCell::index() const -{ - return m_index; -} - -QObject *KItemListAccessibleCell::object() const -{ - return nullptr; -} - -// Container Interface -KItemListContainerAccessible::KItemListContainerAccessible(KItemListContainer *container) - : QAccessibleWidget(container) -{ -} - -KItemListContainerAccessible::~KItemListContainerAccessible() -{ -} - -int KItemListContainerAccessible::childCount() const -{ - return 1; -} - -int KItemListContainerAccessible::indexOfChild(const QAccessibleInterface *child) const -{ - if (child->object() == container()->controller()->view()) { - return 0; - } - return -1; -} - -QAccessibleInterface *KItemListContainerAccessible::child(int index) const -{ - if (index == 0) { - return QAccessible::queryAccessibleInterface(container()->controller()->view()); - } - return nullptr; -} - -const KItemListContainer *KItemListContainerAccessible::container() const -{ - return qobject_cast(object()); -} - -#endif // QT_NO_ACCESSIBILITY diff --git a/src/kitemviews/kitemlistviewaccessible.h b/src/kitemviews/kitemlistviewaccessible.h deleted file mode 100644 index 41aacf367..000000000 --- a/src/kitemviews/kitemlistviewaccessible.h +++ /dev/null @@ -1,144 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2012 Amandeep Singh - * - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#ifndef KITEMLISTVIEWACCESSIBLE_H -#define KITEMLISTVIEWACCESSIBLE_H - -#ifndef QT_NO_ACCESSIBILITY - -#include "dolphin_export.h" - -#include -#include -#include -#include - -class KItemListView; -class KItemListContainer; -class KItemListContainerAccessible; - -class DOLPHIN_EXPORT KItemListViewAccessible : public QAccessibleObject, public QAccessibleTableInterface -{ -public: - explicit KItemListViewAccessible(KItemListView *view, KItemListContainerAccessible *parent); - ~KItemListViewAccessible() override; - - void *interface_cast(QAccessible::InterfaceType type) override; - - QAccessible::Role role() const override; - QAccessible::State state() const override; - QString text(QAccessible::Text t) const override; - QRect rect() const override; - - QAccessibleInterface *child(int index) const override; - int childCount() const override; - int indexOfChild(const QAccessibleInterface *) const override; - QAccessibleInterface *childAt(int x, int y) const override; - QAccessibleInterface *parent() const override; - - // Table interface - QAccessibleInterface *cellAt(int row, int column) const override; - QAccessibleInterface *caption() const override; - QAccessibleInterface *summary() const override; - QString columnDescription(int column) const override; - QString rowDescription(int row) const override; - int columnCount() const override; - int rowCount() const override; - - // Selection - int selectedCellCount() const override; - int selectedColumnCount() const override; - int selectedRowCount() const override; - QList selectedCells() const override; - QList selectedColumns() const override; - QList selectedRows() const override; - bool isColumnSelected(int column) const override; - bool isRowSelected(int row) const override; - bool selectRow(int row) override; - bool selectColumn(int column) override; - bool unselectRow(int row) override; - bool unselectColumn(int column) override; - void modelChange(QAccessibleTableModelChangeEvent *) override; - - KItemListView *view() const; - -protected: - virtual void modelReset(); - /** - * Create an QAccessibleTableCellInterface representing the table - * cell at the @index. Index is 0-based. - */ - inline QAccessibleInterface *cell(int index) const; - -private: - class AccessibleIdWrapper - { - public: - AccessibleIdWrapper(); - bool isValid; - QAccessible::Id id; - }; - mutable QVector m_cells; - - KItemListContainerAccessible *m_parent; -}; - -class DOLPHIN_EXPORT KItemListAccessibleCell : public QAccessibleInterface, public QAccessibleTableCellInterface -{ -public: - KItemListAccessibleCell(KItemListView *view, int m_index); - - void *interface_cast(QAccessible::InterfaceType type) override; - QObject *object() const override; - bool isValid() const override; - QAccessible::Role role() const override; - QAccessible::State state() const override; - QRect rect() const override; - QString text(QAccessible::Text t) const override; - void setText(QAccessible::Text t, const QString &text) override; - - QAccessibleInterface *child(int index) const override; - int childCount() const override; - QAccessibleInterface *childAt(int x, int y) const override; - int indexOfChild(const QAccessibleInterface *) const override; - - QAccessibleInterface *parent() const override; - bool isExpandable() const; - - // Cell Interface - int columnExtent() const override; - QList columnHeaderCells() const override; - int columnIndex() const override; - int rowExtent() const override; - QList rowHeaderCells() const override; - int rowIndex() const override; - bool isSelected() const override; - QAccessibleInterface *table() const override; - - inline int index() const; - -private: - QPointer m_view; - int m_index; -}; - -class DOLPHIN_EXPORT KItemListContainerAccessible : public QAccessibleWidget -{ -public: - explicit KItemListContainerAccessible(KItemListContainer *container); - ~KItemListContainerAccessible() override; - - QAccessibleInterface *child(int index) const override; - int childCount() const override; - int indexOfChild(const QAccessibleInterface *child) const override; - -private: - const KItemListContainer *container() const; -}; - -#endif // QT_NO_ACCESSIBILITY - -#endif diff --git a/src/kitemviews/kstandarditemlistwidget.cpp b/src/kitemviews/kstandarditemlistwidget.cpp index fe686d4fe..a8fee6244 100644 --- a/src/kitemviews/kstandarditemlistwidget.cpp +++ b/src/kitemviews/kstandarditemlistwidget.cpp @@ -109,11 +109,20 @@ bool KStandardItemListWidgetInformant::itemIsLink(int index, const KItemListView return false; } -QString KStandardItemListWidgetInformant::roleText(const QByteArray &role, const QHash &values) const +QString KStandardItemListWidgetInformant::roleText(const QByteArray &role, const QHash &values, ForUsageAs forUsageAs) const { if (role == "rating") { - // Always use an empty text, as the rating is shown by the image m_rating. - return QString(); + if (forUsageAs == ForUsageAs::DisplayedText) { + // Always use an empty text, as the rating is shown by the image m_rating. + return QString(); + } else { + const int rating{values.value(role).toInt()}; + // Check if there are half stars + if (rating % 2) { + return i18ncp("@accessible rating", "%1 and a half stars", "%1 and a half stars", rating / 2); + } + return i18ncp("@accessible rating", "%1 star", "%1 stars", rating / 2); + } } return values.value(role).toString(); } diff --git a/src/kitemviews/kstandarditemlistwidget.h b/src/kitemviews/kstandarditemlistwidget.h index d182755fa..588ec3548 100644 --- a/src/kitemviews/kstandarditemlistwidget.h +++ b/src/kitemviews/kstandarditemlistwidget.h @@ -54,12 +54,19 @@ protected: */ virtual bool itemIsLink(int index, const KItemListView *view) const; + /** Configure whether the requested text should be optimized for viewing on a screen or for being read out aloud by a text-to-speech engine. */ + enum class ForUsageAs { DisplayedText, SpokenText }; + /** + * @param role The role the text is being requested for. + * @param values The data of the item. All the data is passed because the text might depend on multiple data points. + * @param forUsageAs Whether the roleText should be optimized for displaying (i.e. kept somewhat short) or optimized for speaking e.g. by screen readers + * or text-to-speech in general (i.e. by prefering announcing a month as July instead of as the number 7). * @return String representation of the role \a role. The representation of * a role might depend on other roles, so the values of all roles * are passed as parameter. */ - virtual QString roleText(const QByteArray &role, const QHash &values) const; + virtual QString roleText(const QByteArray &role, const QHash &values, ForUsageAs forUsageAs = ForUsageAs::DisplayedText) const; /** * @return A font based on baseFont which is customized for symlinks. diff --git a/src/statusbar/dolphinstatusbar.cpp b/src/statusbar/dolphinstatusbar.cpp index c8369febc..99affde6f 100644 --- a/src/statusbar/dolphinstatusbar.cpp +++ b/src/statusbar/dolphinstatusbar.cpp @@ -53,6 +53,7 @@ DolphinStatusBar::DolphinStatusBar(QWidget *parent) // Initialize text label m_label = new KSqueezedTextLabel(m_text, contentsContainer); m_label->setTextFormat(Qt::PlainText); + m_label->setTextInteractionFlags(Qt::TextBrowserInteraction | Qt::TextSelectableByKeyboard); // for accessibility but also to allow copy-pasting this text. // Initialize zoom slider's explanatory label m_zoomLabel = new KSqueezedTextLabel(i18nc("Used as a noun, i.e. 'Here is the zoom level:'", "Zoom:"), contentsContainer); diff --git a/src/views/dolphinview.cpp b/src/views/dolphinview.cpp index 0c5ebb1df..6da285a87 100644 --- a/src/views/dolphinview.cpp +++ b/src/views/dolphinview.cpp @@ -12,6 +12,9 @@ #include "dolphinitemlistview.h" #include "dolphinnewfilemenuobserver.h" #include "draganddrophelper.h" +#ifndef QT_NO_ACCESSIBILITY +#include "kitemviews/accessibility/kitemlistviewaccessible.h" +#endif #include "kitemviews/kfileitemlistview.h" #include "kitemviews/kfileitemmodel.h" #include "kitemviews/kitemlistcontainer.h" @@ -50,6 +53,9 @@ #include #include +#ifndef QT_NO_ACCESSIBILITY +#include +#endif #include #include #include @@ -2333,6 +2339,12 @@ void DolphinView::showLoadingPlaceholder() { m_placeholderLabel->setText(i18n("Loading…")); m_placeholderLabel->setVisible(true); +#ifndef QT_NO_ACCESSIBILITY + if (QAccessible::isActive()) { + auto accessibleViewInterface = static_cast(QAccessible::queryAccessibleInterface(m_view)); + accessibleViewInterface->announceOverallViewState(m_placeholderLabel->text()); + } +#endif } void DolphinView::updatePlaceholderLabel() @@ -2382,6 +2394,12 @@ void DolphinView::updatePlaceholderLabel() } m_placeholderLabel->setVisible(true); +#ifndef QT_NO_ACCESSIBILITY + if (QAccessible::isActive()) { + auto accessibleViewInterface = static_cast(QAccessible::queryAccessibleInterface(m_view)); + accessibleViewInterface->announceOverallViewState(m_placeholderLabel->text()); + } +#endif } bool DolphinView::tryShowNameToolTip(QHelpEvent *event) -- 2.47.3