┌   ┐
54
└   ┘

summaryrefslogtreecommitdiff
path: root/src/kitemviews/accessibility/kitemlistviewaccessible.cpp
diff options
context:
space:
mode:
authorFelix Ernst <[email protected]>2024-10-28 13:25:10 +0000
committerFelix Ernst <[email protected]>2024-10-28 13:25:10 +0000
commitf208acd5f68c8516b9f6a920cc229803637e23e9 (patch)
treef748d6386ed579c494497998097bdc92a432e704 /src/kitemviews/accessibility/kitemlistviewaccessible.cpp
parent4d5cab6a5fcaa8edeb18cbacd2061cc098054882 (diff)
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/
Diffstat (limited to 'src/kitemviews/accessibility/kitemlistviewaccessible.cpp')
-rw-r--r--src/kitemviews/accessibility/kitemlistviewaccessible.cpp479
1 files changed, 479 insertions, 0 deletions
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 <[email protected]>
+ * SPDX-FileCopyrightText: 2024 Felix Ernst <[email protected]>
+ *
+ * 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 <KLocalizedString>
+
+#include <QApplication> // for figuring out if we should move focus to this view.
+#include <QGraphicsScene>
+#include <QGraphicsView>
+
+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<QAccessibleSelectionInterface *>(this);
+ case QAccessible::TableInterface:
+ return static_cast<QAccessibleTableInterface *>(this);
+ case QAccessible::ActionInterface:
+ return static_cast<QAccessibleActionInterface *>(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<QAccessibleInterface *> KItemListViewAccessible::selectedCells() const
+{
+ QList<QAccessibleInterface *> cells;
+ const auto items = selectionManager()->selectedItems();
+ cells.reserve(items.count());
+ for (int index : items) {
+ cells.append(accessibleDelegate(index));
+ }
+ return cells;
+}
+
+QList<int> KItemListViewAccessible::selectedColumns() const
+{
+ return QList<int>();
+}
+
+QList<int> KItemListViewAccessible::selectedRows() const
+{
+ return QList<int>();
+}
+
+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<int> 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<const KItemListDelegateAccessible *>(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<const KStandardItemListView *>(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<KItemListDelegateAccessible *>(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<QAccessibleInterface *> KItemListViewAccessible::selectedItems() const
+{
+ const auto selectedItems = selectionManager()->selectedItems();
+ QList<QAccessibleInterface *> 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<KItemListView *>(object()));
+ return static_cast<KItemListView *>(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<QWidget *>(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<QWidget *>(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();
+}