Accessibility improvements.

This commit is contained in:
Storm Dragon committed 2026-09-15 14:33:20 -04:00
1 parent d2e68bdcb4
commit c4ccc39aac
5 files changed
+186 -139

No files matched your search

@@ -1,6 +1,6 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org> # Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=navipy-git pkgname=navipy-git
pkgver=r8.gd5545da pkgver=r9.gd2e68bd
pkgrel=1 pkgrel=1
pkgdesc='Accessible Subsonic desktop client tested with Navidrome' pkgdesc='Accessible Subsonic desktop client tested with Navidrome'
arch=('any') arch=('any')
+50 -132
View File
@@ -3,7 +3,6 @@ Accessible tree widget for hierarchical navigation
Based on the implementation from Bifrost Based on the implementation from Bifrost
""" """
import re
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QKeyEvent from PySide6.QtGui import QKeyEvent
@@ -63,12 +62,12 @@ class AccessibleTreeWidget(QTreeWidget):
if current.childCount() > 0: if current.childCount() > 0:
if not current.isExpanded(): if not current.isExpanded():
self.expandItem(current) self.expandItem(current)
self.updateChildAccessibility(current, True)
return return
# If already expanded and no shift, move to first child # If already expanded and no shift, move to first child
elif not hasShift: elif not hasShift:
firstChild = current.child(0) firstChild = next((current.child(i) for i in range(current.childCount())
if firstChild and self.isItemNavigable(firstChild): if self.isItemNavigable(current.child(i))), None)
if firstChild:
self.setCurrentItem(firstChild) self.setCurrentItem(firstChild)
self.scrollToItem(firstChild) self.scrollToItem(firstChild)
return return
@@ -85,9 +84,6 @@ class AccessibleTreeWidget(QTreeWidget):
# Plain Left: Collapse current item if expanded, otherwise move to parent # Plain Left: Collapse current item if expanded, otherwise move to parent
if current.childCount() > 0 and current.isExpanded(): if current.childCount() > 0 and current.isExpanded():
self.collapseItem(current) self.collapseItem(current)
self.updateChildAccessibility(current, False)
self.repaint()
self.update()
return return
elif current.parent(): elif current.parent():
parentItem = current.parent() parentItem = current.parent()
@@ -139,7 +135,7 @@ class AccessibleTreeWidget(QTreeWidget):
elif key == Qt.Key_Home: elif key == Qt.Key_Home:
# Go to first item # Go to first item
firstItem = self.topLevelItem(0) firstItem = self.getFirstVisibleItem()
if firstItem: if firstItem:
self.setCurrentItem(firstItem) self.setCurrentItem(firstItem)
self.scrollToItem(firstItem) self.scrollToItem(firstItem)
@@ -159,135 +155,54 @@ class AccessibleTreeWidget(QTreeWidget):
super().keyPressEvent(event) super().keyPressEvent(event)
def getNextVisibleItem(self, item: QTreeWidgetItem) -> QTreeWidgetItem: def getNextVisibleItem(self, item: QTreeWidgetItem) -> QTreeWidgetItem:
"""Get the next visible item in the tree""" """Follow Qt's visible row order, skipping unavailable items."""
if not item: candidate = self.itemBelow(item) if item else None
return None while candidate and not self.isItemNavigable(candidate):
candidate = self.itemBelow(candidate)
# If item has children, check if first child is navigable return candidate
if item.childCount() > 0:
firstChild = item.child(0)
if firstChild and self.isItemNavigable(firstChild):
return firstChild
# Otherwise, find next sibling or ancestor's sibling
current = item
while current:
parent = current.parent()
if parent:
# Find next sibling
index = parent.indexOfChild(current)
if index + 1 < parent.childCount():
nextSibling = parent.child(index + 1)
if nextSibling and self.isItemNavigable(nextSibling):
return nextSibling
# No more siblings, go up to parent
current = parent
else:
# Top-level item, find next top-level item
index = self.indexOfTopLevelItem(current)
if index + 1 < self.topLevelItemCount():
nextItem = self.topLevelItem(index + 1)
if nextItem and self.isItemNavigable(nextItem):
return nextItem
# No more items
return None
return None
def getPreviousVisibleItem(self, item: QTreeWidgetItem) -> QTreeWidgetItem: def getPreviousVisibleItem(self, item: QTreeWidgetItem) -> QTreeWidgetItem:
"""Get the previous visible item in the tree""" """Follow Qt's visible row order backwards."""
if not item: candidate = self.itemAbove(item) if item else None
return None while candidate and not self.isItemNavigable(candidate):
candidate = self.itemAbove(candidate)
return candidate
parent = item.parent() def getFirstVisibleItem(self) -> QTreeWidgetItem:
for i in range(self.topLevelItemCount()):
if parent: item = self.topLevelItem(i)
# Find previous sibling if not item.isHidden():
index = parent.indexOfChild(item) return item if self.isItemNavigable(item) else self.getNextVisibleItem(item)
if index > 0:
# Get previous sibling and its last visible descendant
prevSibling = parent.child(index - 1)
if prevSibling and self.isItemNavigable(prevSibling):
return self.getLastVisibleDescendant(prevSibling)
else:
# No previous sibling, go to parent
if self.isItemNavigable(parent):
return parent
else:
# Top-level item
index = self.indexOfTopLevelItem(item)
if index > 0:
# Get previous top-level item and its last visible descendant
prevItem = self.topLevelItem(index - 1)
if prevItem and self.isItemNavigable(prevItem):
return self.getLastVisibleDescendant(prevItem)
return None return None
def getLastVisibleDescendant(self, item: QTreeWidgetItem) -> QTreeWidgetItem: def getLastVisibleItem(self) -> QTreeWidgetItem:
"""Get the last visible descendant of an item""" item = self.getFirstVisibleItem()
if not item or not self.isItemNavigable(item): while item:
return None following = self.getNextVisibleItem(item)
if following is None:
# Check if any children are navigable break
if item.childCount() > 0: item = following
# Get the last child and check if it's navigable
lastChild = item.child(item.childCount() - 1)
if lastChild and self.isItemNavigable(lastChild):
result = self.getLastVisibleDescendant(lastChild)
return result if result else item
return item return item
def getLastVisibleItem(self) -> QTreeWidgetItem:
"""Get the last visible item in the tree"""
if self.topLevelItemCount() == 0:
return None
lastTopLevel = self.topLevelItem(self.topLevelItemCount() - 1)
return self.getLastVisibleDescendant(lastTopLevel)
def onItemExpanded(self, item: QTreeWidgetItem): def onItemExpanded(self, item: QTreeWidgetItem):
"""Handle item expansion""" # Qt exposes expansion state and accessibility events itself. Changing
self.announceItemState(item, "expanded") # the name here loses custom labels and leaves stale lazy-load counts.
# Make child items accessible self.itemStateChanged.emit(item, "expanded")
self.updateChildAccessibility(item, True)
def onItemCollapsed(self, item: QTreeWidgetItem): def onItemCollapsed(self, item: QTreeWidgetItem):
"""Handle item collapse""" """Keep the current item visible when an ancestor is collapsed."""
self.announceItemState(item, "collapsed") current = self.currentItem()
# Hide child items from screen readers ancestor = current.parent() if current else None
self.updateChildAccessibility(item, False) while ancestor:
if ancestor is item:
def updateChildAccessibility(self, item: QTreeWidgetItem, visible: bool): if self.isItemNavigable(item):
"""Update accessibility properties of child items""" self.setCurrentItem(item)
for i in range(item.childCount()): else:
child = item.child(i) self.setCurrentItem(self.getNextVisibleItem(item)
if visible: or self.getPreviousVisibleItem(item))
# Make child accessible and ensure proper selection break
child.setFlags(child.flags() | Qt.ItemIsEnabled | Qt.ItemIsSelectable) ancestor = ancestor.parent()
child.setData(0, Qt.AccessibleDescriptionRole, "") # Clear hidden marker self.itemStateChanged.emit(item, "collapsed")
# Recursively handle nested children
self.updateChildAccessibility(child, child.isExpanded())
else:
# Hide from screen readers and navigation
child.setData(0, Qt.AccessibleDescriptionRole, "hidden")
# Remove selectable flag - collapsed items should not be navigable
child.setFlags((child.flags() | Qt.ItemIsEnabled) & ~Qt.ItemIsSelectable)
# Hide all nested children too
self.updateChildAccessibility(child, False)
def announceItemState(self, item: QTreeWidgetItem, state: str):
"""Announce item state change for screen readers"""
# Update accessible text to include state
if item.childCount() > 0:
originalText = item.text(0)
childCount = item.childCount()
# Remove any existing state information to prevent duplication
cleanText = re.sub(r' \(\d+ items?, (?:collapsed|expanded)\)$', '', originalText)
accessibleText = f"{cleanText} ({childCount} items, {state})"
item.setData(0, Qt.AccessibleTextRole, accessibleText)
# Emit signal for potential sound feedback
self.itemStateChanged.emit(item, state)
def getItemDescription(self, item: QTreeWidgetItem) -> str: def getItemDescription(self, item: QTreeWidgetItem) -> str:
"""Get a full description of an item for screen readers""" """Get a full description of an item for screen readers"""
@@ -332,9 +247,12 @@ class AccessibleTreeWidget(QTreeWidget):
"""Check if an item is safe to navigate to""" """Check if an item is safe to navigate to"""
if not item: if not item:
return False return False
# Check if item is enabled and selectable, and not hidden if item.treeWidget() is not self or item.isHidden():
return False
ancestor = item.parent()
while ancestor:
if ancestor.isHidden() or not ancestor.isExpanded():
return False
ancestor = ancestor.parent()
flags = item.flags() flags = item.flags()
isHidden = item.data(0, Qt.AccessibleDescriptionRole) == "hidden" return bool(flags & Qt.ItemIsEnabled) and bool(flags & Qt.ItemIsSelectable)
isEnabled = bool(flags & Qt.ItemIsEnabled)
isSelectable = bool(flags & Qt.ItemIsSelectable)
return isEnabled and isSelectable and not isHidden
+5 -5
View File
@@ -735,30 +735,30 @@ class MainWindow(QMainWindow):
# Root sections # Root sections
self.artistsRoot = QTreeWidgetItem(["Artists"]) self.artistsRoot = QTreeWidgetItem(["Artists"])
self.artistsRoot.setData(0, Qt.UserRole, {"type": "artists_root", "loaded": True}) self.artistsRoot.setData(0, Qt.UserRole, {"type": "artists_root", "loaded": True})
self.artistsRoot.setExpanded(True)
self.libraryTree.addTopLevelItem(self.artistsRoot) self.libraryTree.addTopLevelItem(self.artistsRoot)
self.artistsRoot.setExpanded(True)
self.favoritesRoot = QTreeWidgetItem(["Favorites"]) self.favoritesRoot = QTreeWidgetItem(["Favorites"])
self.favoritesRoot.setData(0, Qt.UserRole, {"type": "favorites_root", "loaded": True}) self.favoritesRoot.setData(0, Qt.UserRole, {"type": "favorites_root", "loaded": True})
self.favoritesRoot.setExpanded(True)
self.libraryTree.addTopLevelItem(self.favoritesRoot) self.libraryTree.addTopLevelItem(self.favoritesRoot)
self.favoritesRoot.setExpanded(True)
self._populateFavorites(data.get("favorites", {})) self._populateFavorites(data.get("favorites", {}))
self.discoverRoot = QTreeWidgetItem(["Discover"]) self.discoverRoot = QTreeWidgetItem(["Discover"])
self.discoverRoot.setData(0, Qt.UserRole, {"type": "discover_root", "loaded": True}) self.discoverRoot.setData(0, Qt.UserRole, {"type": "discover_root", "loaded": True})
self.discoverRoot.setExpanded(True)
self.libraryTree.addTopLevelItem(self.discoverRoot) self.libraryTree.addTopLevelItem(self.discoverRoot)
self.discoverRoot.setExpanded(True)
self.loadDiscoverSections() self.loadDiscoverSections()
self.playlistsRoot = QTreeWidgetItem(["Playlists"]) self.playlistsRoot = QTreeWidgetItem(["Playlists"])
self.playlistsRoot.setData(0, Qt.UserRole, {"type": "playlists_root", "loaded": True}) self.playlistsRoot.setData(0, Qt.UserRole, {"type": "playlists_root", "loaded": True})
self.playlistsRoot.setExpanded(True)
self.libraryTree.addTopLevelItem(self.playlistsRoot) self.libraryTree.addTopLevelItem(self.playlistsRoot)
self.playlistsRoot.setExpanded(True)
self.genresRoot = QTreeWidgetItem(["Genres"]) self.genresRoot = QTreeWidgetItem(["Genres"])
self.genresRoot.setData(0, Qt.UserRole, {"type": "genres_root", "loaded": True}) self.genresRoot.setData(0, Qt.UserRole, {"type": "genres_root", "loaded": True})
self.genresRoot.setExpanded(True)
self.libraryTree.addTopLevelItem(self.genresRoot) self.libraryTree.addTopLevelItem(self.genresRoot)
self.genresRoot.setExpanded(True)
self._populateArtists(data.get("artists", [])) self._populateArtists(data.get("artists", []))
self._populatePlaylists(data.get("playlists", [])) self._populatePlaylists(data.get("playlists", []))
-1
View File
@@ -301,7 +301,6 @@ class SearchDialog(QDialog):
item.addChild(child) item.addChild(child)
data.update(loaded=True, loading=False) data.update(loaded=True, loading=False)
item.setData(0, Qt.UserRole, data) item.setData(0, Qt.UserRole, data)
self.resultsTree.updateChildAccessibility(item, item.isExpanded())
def failed(error): def failed(error):
data["loading"] = False data["loading"] = False
+130
View File
@@ -0,0 +1,130 @@
"""Tree navigation and accessibility regressions, without a server or audio."""
import pytest
from PySide6.QtCore import Qt
from PySide6.QtGui import QAccessible
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QTreeWidgetItem
from src.accessibility.accessible_tree import AccessibleTreeWidget
@pytest.fixture
def tree():
app = QApplication.instance() or QApplication([])
widget = AccessibleTreeWidget()
widget.setHeaderHidden(True)
widget.show()
app.processEvents()
yield widget
widget.close()
def add(parent, text):
item = QTreeWidgetItem([text])
if isinstance(parent, AccessibleTreeWidget):
parent.addTopLevelItem(item)
else:
parent.addChild(item)
return item
def press(tree, key, expected):
QTest.keyClick(tree, key)
assert tree.currentItem() is expected
def test_collapsed_children_and_late_results_are_not_navigable(tree):
root = add(tree, 'Albums')
child = add(root, 'Album')
last = add(tree, 'Playlists')
tree.setCurrentItem(root)
press(tree, Qt.Key_Down, last)
press(tree, Qt.Key_Up, root)
press(tree, Qt.Key_Right, root)
press(tree, Qt.Key_Right, child)
root.setExpanded(False)
assert tree.currentItem() is root
late = add(root, 'Loaded after collapse')
assert not tree.isItemNavigable(late)
press(tree, Qt.Key_Down, last)
root.setExpanded(True)
assert tree.isItemNavigable(late)
def test_hidden_rows_are_skipped_in_both_directions_and_at_edges(tree):
hidden_first = add(tree, 'Hidden first')
hidden_first.setHidden(True)
root = add(tree, 'Root')
hidden_child = add(root, 'Hidden child')
hidden_child.setHidden(True)
child = add(root, 'Visible child')
disabled = add(root, 'Disabled')
disabled.setDisabled(True)
other = add(root, 'Other child')
hidden_last = add(tree, 'Hidden last')
hidden_last.setHidden(True)
root.setExpanded(True)
tree.setCurrentItem(root)
press(tree, Qt.Key_Right, child)
press(tree, Qt.Key_Down, other)
press(tree, Qt.Key_Up, child)
press(tree, Qt.Key_Home, root)
press(tree, Qt.Key_End, other)
press(tree, Qt.Key_PageUp, root)
press(tree, Qt.Key_PageDown, other)
def test_expansion_preserves_names_descriptions_and_flags(tree):
root = add(tree, 'Artist')
root.setData(0, Qt.AccessibleTextRole, 'Artist, 12 albums')
child = add(root, 'Album')
child.setData(0, Qt.AccessibleDescriptionRole, 'Released in 2024')
child.setFlags(child.flags() & ~Qt.ItemIsSelectable)
disabled = add(root, 'Unavailable')
disabled.setDisabled(True)
original_flags = [child.flags(), disabled.flags()]
states = []
tree.itemStateChanged.connect(lambda item, state: states.append(state))
for _ in range(2):
root.setExpanded(True)
root.setExpanded(False)
assert root.data(0, Qt.AccessibleTextRole) == 'Artist, 12 albums'
assert child.data(0, Qt.AccessibleDescriptionRole) == 'Released in 2024'
assert [child.flags(), disabled.flags()] == original_flags
assert states == ['expanded', 'collapsed'] * 2
def test_nested_collapsed_branch_stays_closed_on_parent_reopen(tree):
root = add(tree, 'Root')
branch = add(root, 'Branch')
leaf = add(branch, 'Leaf')
root.setExpanded(True)
branch.setExpanded(True)
tree.setCurrentItem(leaf)
branch.setExpanded(False)
assert tree.currentItem() is branch
root.setExpanded(False)
root.setExpanded(True)
assert not tree.isItemNavigable(leaf)
tree.setCurrentItem(branch)
press(tree, Qt.Key_End, branch)
def test_accessibility_exposes_only_expanded_rows(tree):
root = add(tree, 'Albums')
add(root, 'Song')
tree.setCurrentItem(root)
QApplication.processEvents()
interface = QAccessible.queryAccessibleInterface(tree)
def spoken_rows():
return [interface.child(i).text(QAccessible.Name)
for i in range(interface.childCount())
if interface.child(i).role() == QAccessible.TreeItem]
assert spoken_rows() == ['Albums']
root.setExpanded(True)
QApplication.processEvents()
assert spoken_rows() == ['Albums', 'Song']
root.setExpanded(False)
QApplication.processEvents()
assert spoken_rows() == ['Albums']