diff --git a/README.md b/README.md index 1cac210..d582816 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Logs are written to `~/.local/share/stormux/navipy/navipy.log`. - Use `Backspace`, `Alt+Left`, or the Back button to return to the previous table and selection. - Use Play selected to play an entire selected album, artist, playlist, or genre without leaving the table. - Use `Shift+F10`, the Menu key, or Actions for play, queue, playlist, and favorite actions. -- In library tables, press `Space` to check or uncheck an artist, album, or song. Checks remain selected while you browse other tabs and collections. `Alt+A` opens actions for all checked items. +- Library tables have a first `Selected` checkbox column and initially focus the second, informational column. Press `Space` from either column to check or uncheck an artist, album, or song. Checks remain selected while you browse other tabs and collections. `Alt+A` opens actions for all checked items. - Collection actions applies to the collection currently being viewed, including all artists or favorites. - Albums and genre tracks have a Load more button when more results are available. - Each tab keeps its own browsing position. Refreshing the library resets the tables. diff --git a/distro-packages/ArchLinux/navipy-git/PKGBUILD b/distro-packages/ArchLinux/navipy-git/PKGBUILD index ea69d01..b197cf5 100644 --- a/distro-packages/ArchLinux/navipy-git/PKGBUILD +++ b/distro-packages/ArchLinux/navipy-git/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Storm Dragon pkgname=navipy-git -pkgver=r13.gbd6cfb8 +pkgver=r14.g059e4e1 pkgrel=1 pkgdesc='Accessible Subsonic desktop client tested with Navidrome' arch=('any') diff --git a/src/main_window.py b/src/main_window.py index a4b05ec..247118f 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -368,6 +368,7 @@ class MainWindow(QMainWindow): """Connect signals after widgets exist.""" self.libraryBrowser.playRequested.connect(self.playLibrarySelection) self.libraryBrowser.menuRequested.connect(self.onLibraryContextMenu) + self.libraryBrowser.checkedStateAnnouncement.connect(self.announceLive) self.playButton.clicked.connect(self.playPauseAction.trigger) self.prevButton.clicked.connect(self.previousAction.trigger) diff --git a/src/widgets/library_browser.py b/src/widgets/library_browser.py index 3069c1c..67d3822 100644 --- a/src/widgets/library_browser.py +++ b/src/widgets/library_browser.py @@ -59,16 +59,32 @@ class LibraryTableModel(QAbstractTableModel): return 0 if parent.isValid() else len(self.rows) def columnCount(self, parent=QModelIndex()): - return 0 if parent.isValid() else len(self.headers) + return 0 if parent.isValid() else len(self.headers) + int(self.hasCheckboxColumn()) + + def hasCheckboxColumn(self): + return self.headers in (HEADERS['artist'], HEADERS['album'], HEADERS['song']) def data(self, index, role=Qt.DisplayRole): - if (index.isValid() and index.column() == 0 - and role == Qt.CheckStateRole - and self.isCheckable(index)): - return Qt.Checked if self.browser.isChecked( - self.rows[index.row()].data) else Qt.Unchecked - if index.isValid() and role in (Qt.DisplayRole, Qt.AccessibleTextRole, Qt.ToolTipRole): - return str(self.rows[index.row()].values[index.column()]) + if not index.isValid(): + return None + if self.hasCheckboxColumn() and index.column() == 0: + if not self.isCheckable(index): + return None + checked = self.browser.isChecked(self.rows[index.row()].data) + if role == Qt.CheckStateRole: + return Qt.Checked if checked else Qt.Unchecked + label = self.rows[index.row()].data.get('label', 'item') + if role == Qt.AccessibleTextRole: + return f'Select {label}, {"checked" if checked else "unchecked"}' + if role == Qt.AccessibleDescriptionRole: + return ('Checked. Press Space to uncheck.' if checked + else 'Unchecked. Press Space to check.') + if role == Qt.ToolTipRole: + return f'{"Checked" if checked else "Unchecked"}: {label}' + return None + if role in (Qt.DisplayRole, Qt.AccessibleTextRole, Qt.ToolTipRole): + offset = 1 if self.hasCheckboxColumn() else 0 + return str(self.rows[index.row()].values[index.column() - offset]) return None def flags(self, index): @@ -90,11 +106,16 @@ class LibraryTableModel(QAbstractTableModel): def refreshChecks(self): if self.rows and self.headers: self.dataChanged.emit(self.index(0, 0), self.index(len(self.rows) - 1, 0), - [Qt.CheckStateRole]) + [Qt.CheckStateRole, Qt.AccessibleTextRole, + Qt.AccessibleDescriptionRole]) def headerData(self, section, orientation, role=Qt.DisplayRole): if role == Qt.DisplayRole: - return self.headers[section] if orientation == Qt.Horizontal else str(section + 1) + if orientation == Qt.Horizontal: + if self.hasCheckboxColumn(): + return 'Selected' if section == 0 else self.headers[section - 1] + return self.headers[section] + return str(section + 1) return None def replace(self, headers, rows): @@ -266,8 +287,13 @@ class BrowserPage(QWidget): self.heading.setText(state.title) self.table.setAccessibleName(f'{state.title} table') self.model.replace(state.headers, state.rows) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + if self.model.hasCheckboxColumn(): + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) if state.rows: - self.table.setCurrentIndex(self.model.index(min(state.selected, len(state.rows) - 1), 0)) + startColumn = 1 if self.model.hasCheckboxColumn() else 0 + self.table.setCurrentIndex( + self.model.index(min(state.selected, len(state.rows) - 1), startColumn)) self.table.verticalScrollBar().setValue(state.scroll) self.backButton.setEnabled(len(self.history) > 1) self.collectionButton.setEnabled(state.context.get('type') in ( @@ -387,6 +413,7 @@ class BrowserPage(QWidget): class LibraryBrowser(QTabWidget): playRequested = Signal(dict) menuRequested = Signal(dict, object) + checkedStateAnnouncement = Signal(str) PAGE_SIZE = 200 def __init__(self, runTask, currentSong, parent=None): @@ -457,6 +484,14 @@ class LibraryBrowser(QTabWidget): else: self._checked.pop(key, None) self._refreshCheckedState() + currentIndex = self.currentWidget().table.currentIndex() + if currentIndex.column() != 0: + count = self.checkedCount() + countText = (f'{count} item{"s" if count != 1 else ""} checked' + if count else 'no items checked') + self.checkedStateAnnouncement.emit( + f'{data.get("label", "Item")} {"checked" if checked else "unchecked"}; ' + f'{countText}.') def clearChecked(self): if not self._checked: diff --git a/tests/test_library_ui.py b/tests/test_library_ui.py index 078e976..78edabe 100644 --- a/tests/test_library_ui.py +++ b/tests/test_library_ui.py @@ -80,7 +80,7 @@ def test_browse_and_back_replace_rows_without_hidden_descendants(window): QTest.keyClick(page.table, Qt.Key_Backspace) assert page.selectedData()['type'] == 'artist' assert page.model.rowCount() == 1 - assert page.model.columnCount() == 2 + assert page.model.columnCount() == 3 assert not page.backButton.isEnabled() assert not pending assert page.table.hasFocus() @@ -213,8 +213,38 @@ def test_native_accessibility_reports_table_headers_and_current_cells(window): interface = QAccessible.queryAccessibleInterface(page.table) assert interface.role() == QAccessible.Table names = [interface.child(i).text(QAccessible.Name) for i in range(interface.childCount())] - assert 'Artist' in names and 'Albums' in names + assert 'Selected' in names and 'Artist' in names and 'Albums' in names assert 'Song' not in names and 'Album' not in names + assert page.table.currentIndex().column() == 1 + + +def test_checkbox_cell_exposes_checked_state_and_accessible_text(window): + w, _ = window + page = w.libraryBrowser.currentWidget() + checkboxIndex = page.model.index(0, 0) + announcements = [] + w.libraryBrowser.checkedStateAnnouncement.connect(announcements.append) + + assert page.model.headerData(0, Qt.Horizontal) == 'Selected' + assert page.model.data(checkboxIndex, Qt.AccessibleTextRole) == 'Select Artist, unchecked' + assert page.model.flags(checkboxIndex) & Qt.ItemIsUserCheckable + interface = QAccessible.queryAccessibleInterface(page.table) + checkbox = next(interface.child(i) for i in range(interface.childCount()) + if interface.child(i).text(QAccessible.Name) == 'Select Artist, unchecked') + assert checkbox.state().checkable + assert not checkbox.state().checked + + QTest.keyClick(page.table, Qt.Key_Space) + QApplication.processEvents() + + assert page.model.data(checkboxIndex, Qt.CheckStateRole) == Qt.Checked + assert page.model.data(checkboxIndex, Qt.AccessibleTextRole) == 'Select Artist, checked' + assert announcements == ['Artist checked; 1 item checked.'] + interface = QAccessible.queryAccessibleInterface(page.table) + checkbox = next(interface.child(i) for i in range(interface.childCount()) + if interface.child(i).text(QAccessible.Name) == 'Select Artist, checked') + assert checkbox.state().checkable + assert checkbox.state().checked def test_collection_actions_and_queue_use_current_payload(window, monkeypatch):