diff --git a/chirp/wxui/accessibility.py b/chirp/wxui/accessibility.py new file mode 100644 index 00000000..cd9342e1 --- /dev/null +++ b/chirp/wxui/accessibility.py @@ -0,0 +1,429 @@ +# Copyright 2026 Deniz Sincar +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Screen reader accessibility helpers for wx.grid.Grid and +wx.propgrid.PropertyGrid. + +Both controls paint their own rows/cells instead of using one native +child window per item, so under MSAA/IAccessible they expose a single +ROLE_SYSTEM_CLIENT/PANE object for the whole control with no children: +arrow-key navigation between rows is otherwise completely silent for +screen reader users. + +An earlier version of this fix (see PR #1597) spoke each row directly +through prismatoid (ethindp/prism) whenever the row changed. That +worked, but it bypassed the OS accessibility layer entirely, so it +fought with the narration wx already produces natively for the +surrounding UI (tab control, panel labels, etc) -- two voices talking +over each other -- and, on macOS, spoke constantly for every user +regardless of whether a screen reader was even running, since nothing +gated it on assistive technology actually being active. + +This version does the same job the way any native control does: by +implementing wx.Accessible so the grid answers IAccessible queries +(name/value/role/state/focused child) truthfully, and by calling +wx.Accessible.NotifyEvent() when the focused row/cell changes so +Windows re-queries it. No screen-reader-specific package or manual +speech call is involved anywhere in this module -- wx.Accessible and +NotifyEvent are both part of wxPython itself. + +Caveat: wx.Accessible is only implemented for MSW (see the wxWidgets +docs for wx.Accessible -- "Availability: Only available for MSW"). +On other platforms attach_grid_accessible()/attach_propgrid_accessible() +still set a plain accessible name (which GTK/Cocoa do surface) but +install no per-row bridging, so this specifically fixes Windows/NVDA +and does not attempt -- and does not risk regressing anything -- on +Linux or macOS. That matches the double/forced-narration report in +PR #1597, which was on macOS, a platform this module now does nothing +extra on. +""" + +import logging + +import wx + +from chirp.wxui import config + +LOG = logging.getLogger(__name__) + +CONF = config.get() + + +def _accessible_supported(): + """Probe whether wx.Accessible actually works on this platform. + + wx.Accessible exists as a class attribute on every platform (it's + part of the Phoenix bindings), but it's only *implemented* for + MSW: on GTK/Cocoa builds ``wx.Accessible()`` raises + NotImplementedError from the underlying C++ the moment you try to + construct one, even though ``hasattr(wx, 'Accessible')`` is True + there too. So we have to actually try instantiating it rather than + checking for the attribute. + """ + if CONF.get_bool('disable_accessibility', 'state', default=False): + return False + accessible_cls = getattr(wx, 'Accessible', None) + if accessible_cls is None: + return False + try: + accessible_cls() + except NotImplementedError: + return False + return True + + +# Real per-row screen reader support (wx.Accessible) only works on +# Windows builds of wxWidgets/wxPython; see _accessible_supported(). +HAS_ACCESSIBLE = _accessible_supported() + + +def _acc_state(*names): + """OR together wx.ACC_STATE_SYSTEM_ flags looked up by name. + + Looked up defensively (rather than hardcoded as integers) since + only a handful of these are documented for wxPython and the exact + set available can vary by version; a name that isn't present in + this build is simply skipped instead of raising. + """ + value = 0 + for name in names: + value |= getattr(wx, 'ACC_STATE_SYSTEM_' + name, 0) + return value + + +def _notify(event_name, window, child_id): + """Tell the OS a virtual child's focus/selection/value changed. + + This is the piece that makes screen readers re-query our + wx.Accessible object on their own, instead of us pushing speech + ourselves. No-op if wx.Accessible isn't available on this platform + or this wxPython build doesn't expose the named event constant. + """ + if not HAS_ACCESSIBLE: + return + event_type = getattr(wx, event_name, None) + if event_type is None: + LOG.debug('wx.%s not available in this wxPython build', event_name) + return + try: + wx.Accessible.NotifyEvent(event_type, window, + wx.OBJID_CLIENT, child_id) + except Exception: + LOG.debug('NotifyEvent failed', exc_info=True) + + +def _notify_focus(window, child_id): + _notify('ACC_EVENT_OBJECT_FOCUS', window, child_id) + _notify('ACC_EVENT_OBJECT_SELECTION', window, child_id) + + +def _notify_value_change(window, child_id): + _notify('ACC_EVENT_OBJECT_VALUECHANGE', window, child_id) + + +class GridAccessible(wx.Accessible): + """wx.Accessible for a wx.grid.Grid's grid window. + + Exposes one virtual child per cell (1-based childId, row-major, as + IAccessible requires) whose name is built from the column header + and current cell text, so a screen reader reads it the same way it + would any other table/list control. + """ + + def __init__(self, grid): + super().__init__() + self.grid = grid # the wx.grid.Grid, not GetGridWindow() + + def _row_col(self, child_id): + cols = self.grid.GetNumberCols() + if child_id < 1 or cols <= 0: + return None + row, col = divmod(child_id - 1, cols) + if row >= self.grid.GetNumberRows(): + return None + return row, col + + def GetChildCount(self): + return (wx.ACC_OK, + self.grid.GetNumberRows() * self.grid.GetNumberCols()) + + def GetChild(self, childId): + if childId == 0: + return wx.ACC_OK, self + # A plain int childId with no wx.Accessible of its own is a + # "simple element" per the wx.Accessible docs. + return wx.ACC_OK, None + + def GetName(self, childId): + if childId == 0: + return wx.ACC_OK, self.grid.GetGridWindow().GetName() + cell = self._row_col(childId) + if not cell: + return wx.ACC_FAIL, '' + row, col = cell + header = self.grid.GetColLabelValue(col) + value = self.grid.GetCellValue(row, col) or _('empty') + return wx.ACC_OK, _('%s %s, row %d') % (header, value, row + 1) + + def GetValue(self, childId): + cell = self._row_col(childId) + if not cell: + return wx.ACC_OK, '' + row, col = cell + return wx.ACC_OK, self.grid.GetCellValue(row, col) + + def GetRole(self, childId): + if childId == 0: + return wx.ACC_OK, wx.ROLE_SYSTEM_TABLE + return wx.ACC_OK, wx.ROLE_SYSTEM_CELL + + def GetState(self, childId): + state = _acc_state('FOCUSABLE', 'SELECTABLE') + if childId == 0: + return wx.ACC_OK, state + cell = self._row_col(childId) + if not cell: + return wx.ACC_FAIL, 0 + if cell == (self.grid.GetGridCursorRow(), + self.grid.GetGridCursorCol()): + state |= _acc_state('FOCUSED', 'SELECTED') + return wx.ACC_OK, state + + def GetFocus(self): + row = self.grid.GetGridCursorRow() + col = self.grid.GetGridCursorCol() + cols = self.grid.GetNumberCols() + if row < 0 or col < 0 or cols <= 0: + return wx.ACC_OK, self + return wx.ACC_OK, row * cols + col + 1 + + def GetLocation(self, elementId): + window = self.grid.GetGridWindow() + if elementId == 0: + return wx.ACC_OK, window.GetScreenRect() + cell = self._row_col(elementId) + if not cell: + return wx.ACC_FAIL, wx.Rect() + row, col = cell + rect = self.grid.CellToRect(row, col) + rect.SetPosition(window.ClientToScreen(rect.GetTopLeft())) + return wx.ACC_OK, rect + + +def attach_grid_accessible(grid, name=None): + """Give a wx.grid.Grid a real per-cell accessible tree. + + Binds EVT_GRID_SELECT_CELL to notify Windows of the new focused + cell so screen readers announce cell navigation on their own -- + no speech is pushed by this code. + """ + window = grid.GetGridWindow() + if name: + window.SetName(name) + if not HAS_ACCESSIBLE: + return None + + try: + accessible = GridAccessible(grid) + except NotImplementedError: + # Belt-and-suspenders: _accessible_supported() already probed + # this at import time, but if it's ever wrong for some build + # we still shouldn't crash grid construction over it. + return None + window.SetAccessible(accessible) + + def _on_select_cell(event): + event.Skip() + cols = grid.GetNumberCols() + if cols <= 0: + return + child_id = event.GetRow() * cols + event.GetCol() + 1 + _notify_focus(window, child_id) + + grid.Bind(wx.grid.EVT_GRID_SELECT_CELL, _on_select_cell) + return accessible + + +class PropertyGridAccessible(wx.Accessible): + """wx.Accessible for a wx.propgrid.PropertyGrid. + + Exposes one virtual child per visible row (1-based childId, in + display order) so screen readers get a real name/value/role/state + for each property instead of the single opaque pane + wx.propgrid.PropertyGrid otherwise reports. + + speech_fn(prop) -> str, if given, overrides the default + "