wx-accessible-grid 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wx_accessible_grid-0.1.0/.gitignore +8 -0
- wx_accessible_grid-0.1.0/CONTRIBUTING.md +42 -0
- wx_accessible_grid-0.1.0/LICENSE +21 -0
- wx_accessible_grid-0.1.0/PKG-INFO +164 -0
- wx_accessible_grid-0.1.0/README.md +140 -0
- wx_accessible_grid-0.1.0/agentnotes.md +101 -0
- wx_accessible_grid-0.1.0/examples/demo.py +154 -0
- wx_accessible_grid-0.1.0/pyproject.toml +47 -0
- wx_accessible_grid-0.1.0/src/wx_accessible_grid/__init__.py +66 -0
- wx_accessible_grid-0.1.0/src/wx_accessible_grid/assets.py +404 -0
- wx_accessible_grid-0.1.0/src/wx_accessible_grid/grid.py +317 -0
- wx_accessible_grid-0.1.0/src/wx_accessible_grid/model.py +162 -0
- wx_accessible_grid-0.1.0/src/wx_accessible_grid/render.py +158 -0
- wx_accessible_grid-0.1.0/tests/test_model.py +74 -0
- wx_accessible_grid-0.1.0/tests/test_render.py +110 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
**Contributions are welcome — anyone can contribute, and we want them.** This is
|
|
4
|
+
a Community Access open-source project, created by Taylor Arndt.
|
|
5
|
+
|
|
6
|
+
## Ways to help
|
|
7
|
+
- **Report bugs** — open an issue with what you expected, what happened, your OS,
|
|
8
|
+
wxPython version, whether you use a webview, and your screen reader.
|
|
9
|
+
- **Test with screen readers** — NVDA, JAWS, Narrator (Windows), VoiceOver
|
|
10
|
+
(macOS), Orca (Linux). Real-world a11y reports are the most valuable thing here.
|
|
11
|
+
Tell us how arrow navigation, in-cell editing, and selection announced.
|
|
12
|
+
- **Send pull requests** — features, fixes, docs, examples.
|
|
13
|
+
|
|
14
|
+
## Ground rules
|
|
15
|
+
- **Accessibility first.** The whole point is a data grid that is fully
|
|
16
|
+
keyboard-operable and reads correctly in NVDA and JAWS. The grid must speak the
|
|
17
|
+
column header as you move across a row, the row header as you move down a
|
|
18
|
+
column, and only the focused cell (never the whole table on every arrow).
|
|
19
|
+
Changes shouldn't regress that; note how you tested.
|
|
20
|
+
- **Real ARIA grid, never a div soup.** We render a semantic `<table role="grid">`
|
|
21
|
+
so the screen reader gets header association and row/column counts for free. A
|
|
22
|
+
hand-drawn grid reads worse.
|
|
23
|
+
- **The model owns the truth.** The grid never mutates data directly — it asks the
|
|
24
|
+
model and shows what the model says is now true, so an edit the user hears
|
|
25
|
+
confirmed is the validated, normalized value.
|
|
26
|
+
- Keep it **dependency-light** — wxPython plus its sibling `wx-accessible-webview`.
|
|
27
|
+
- Match the existing style; format with `ruff format` (line length 100).
|
|
28
|
+
|
|
29
|
+
## Dev setup
|
|
30
|
+
```bash
|
|
31
|
+
pip install -e ".[dev]"
|
|
32
|
+
python examples/demo.py # try it with a screen reader running
|
|
33
|
+
pytest # the model and renderer are tested without wx
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Pull requests
|
|
37
|
+
- Describe the change and how you verified it (which screen reader / OS, native
|
|
38
|
+
or webview).
|
|
39
|
+
- One focused change per PR is easiest to review.
|
|
40
|
+
|
|
41
|
+
Thanks for helping make accessible data grids in wxPython the default, not the
|
|
42
|
+
exception.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Taylor Arndt and Community Access
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wx-accessible-grid
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An accessible, editable data grid for wxPython rendered as a real ARIA grid in a WebView (arrow-key cell navigation, in-cell editing, row selection, context menus) that reads correctly in NVDA and JAWS.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Community-Access/wx-accessible-grid
|
|
6
|
+
Author: Community Access
|
|
7
|
+
Author-email: Taylor Arndt <taylor@techopolisonline.com>
|
|
8
|
+
Maintainer-email: Taylor Arndt <taylor@techopolisonline.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: a11y,accessibility,aria,datagrid,grid,jaws,keyboard,nvda,screen-reader,table,webview,webview2,wxpython,wxwidgets
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Adaptive Technologies
|
|
17
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: wx-accessible-webview>=0.2.0
|
|
20
|
+
Requires-Dist: wxpython>=4.2.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# wx-accessible-grid
|
|
26
|
+
|
|
27
|
+
An accessible, editable data grid for wxPython that a blind person can actually
|
|
28
|
+
use. Native `wx.grid.Grid` reads poorly or not at all in NVDA and JAWS, and a
|
|
29
|
+
hand-built grid of `<div>`s is worse. This library takes the approach proven by
|
|
30
|
+
its sibling [wx-accessible-webview](https://github.com/Community-Access/wx-accessible-webview):
|
|
31
|
+
render a real, semantic ARIA grid into a WebView and let the screen reader follow
|
|
32
|
+
it like any web data table, then layer spreadsheet-style keyboard behavior on top.
|
|
33
|
+
|
|
34
|
+
It is built for data entry, not a spreadsheet engine. There are no formulas. What
|
|
35
|
+
there is, is every control you actually edit data with, fully keyboard-operable
|
|
36
|
+
and announced correctly.
|
|
37
|
+
|
|
38
|
+
## What you get
|
|
39
|
+
|
|
40
|
+
- Arrow keys move a single focused cell, not the document. Moving across a row
|
|
41
|
+
speaks the column header. Moving down a column speaks the row header. Only the
|
|
42
|
+
focused cell is read, so a grid with thousands of rows stays fast instead of
|
|
43
|
+
making the screen reader re-read the whole table on every keystroke.
|
|
44
|
+
- `F2` or `Enter` edits a cell in place with the right control for the data: edit
|
|
45
|
+
box, combo box, checkbox, slider, or stepper. `Enter` commits, `Escape` cancels.
|
|
46
|
+
- `Space`, or `Ctrl+Space`, selects a row. `Delete` deletes the selection. The
|
|
47
|
+
context menu key, or `Shift+F10`, fires a callback so you can show a native menu.
|
|
48
|
+
- Pass `row_select=True` to add a real checkbox at the start of every row, so
|
|
49
|
+
selecting rows for a bulk operation (move, reorder, edit a region) is visible
|
|
50
|
+
and discoverable, not just a keystroke. The host reads `grid.selected_rows()`,
|
|
51
|
+
acts through the model, and calls `grid.refresh()`.
|
|
52
|
+
- Editing round-trips through your model, so the value the screen reader confirms
|
|
53
|
+
is the validated, normalized one, never the raw keystrokes. If an edit is
|
|
54
|
+
rejected the user hears why and the editor reopens so they can fix it.
|
|
55
|
+
- Only one page of rows is ever in the DOM, but `aria-rowcount` keeps the user's
|
|
56
|
+
sense of "row N of many" correct. Arrow past the bottom and the next page loads
|
|
57
|
+
and navigation just keeps going.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install wx-accessible-grid
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
That pulls in wxPython and wx-accessible-webview.
|
|
66
|
+
|
|
67
|
+
## Use it
|
|
68
|
+
|
|
69
|
+
Subclass `GridModel` to describe your columns and provide the data, then drop an
|
|
70
|
+
`AccessibleGrid` into a sizer.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import wx
|
|
74
|
+
from wx_accessible_grid import AccessibleGrid, GridModel, Column, SetResult
|
|
75
|
+
from wx_accessible_grid import TEXT, COMBO, CHECKBOX, SLIDER, STEPPER, NONE
|
|
76
|
+
|
|
77
|
+
class ChannelModel(GridModel):
|
|
78
|
+
def __init__(self, rows):
|
|
79
|
+
self._rows = rows
|
|
80
|
+
self._cols = [
|
|
81
|
+
Column("num", "#", editor=NONE, is_row_header=True),
|
|
82
|
+
Column("name", "Name", editor=TEXT),
|
|
83
|
+
Column("mode", "Mode", editor=COMBO, choices=["FM", "AM", "USB"]),
|
|
84
|
+
Column("active", "Active", editor=CHECKBOX),
|
|
85
|
+
Column("volume", "Volume", editor=SLIDER, min=0, max=100, step=1),
|
|
86
|
+
Column("priority", "Priority", editor=STEPPER, min=0, max=10),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
def columns(self):
|
|
90
|
+
return self._cols
|
|
91
|
+
|
|
92
|
+
def row_count(self):
|
|
93
|
+
return len(self._rows)
|
|
94
|
+
|
|
95
|
+
def display(self, row, column):
|
|
96
|
+
if column == "num":
|
|
97
|
+
return str(row + 1)
|
|
98
|
+
val = self._rows[row][column]
|
|
99
|
+
return "Yes" if (column == "active" and val) else "No" if column == "active" else str(val)
|
|
100
|
+
|
|
101
|
+
def edit_value(self, row, column):
|
|
102
|
+
# the form the editor wants, when it differs from the shown text
|
|
103
|
+
if column == "active":
|
|
104
|
+
return "true" if self._rows[row]["active"] else "false"
|
|
105
|
+
return self.display(row, column)
|
|
106
|
+
|
|
107
|
+
def set_cell(self, row, column, value):
|
|
108
|
+
# validate and normalize here; what you return is what gets announced
|
|
109
|
+
self._rows[row][column] = value
|
|
110
|
+
return SetResult(True, display=value, message=f"{column} updated")
|
|
111
|
+
|
|
112
|
+
grid = AccessibleGrid(panel, ChannelModel(rows), label="Memory channels",
|
|
113
|
+
page_size=100)
|
|
114
|
+
sizer.Add(grid.control, 1, wx.EXPAND)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The `dev` extra (`pip install "wx-accessible-grid[dev]"`) adds pytest for the
|
|
118
|
+
model and renderer tests, which run without wx.
|
|
119
|
+
|
|
120
|
+
## The editors
|
|
121
|
+
|
|
122
|
+
- `TEXT`: a single-line edit box. Type, then Enter to commit.
|
|
123
|
+
- `COMBO`: a drop-down of fixed choices, which also stands in for a radio group.
|
|
124
|
+
Arrow through the list, Enter to commit.
|
|
125
|
+
- `CHECKBOX`: a boolean toggle. Space toggles it, Enter commits.
|
|
126
|
+
- `SLIDER`: a range control adjusted with the arrow keys.
|
|
127
|
+
- `STEPPER`: a number spinner. Type a value or step it with the arrows.
|
|
128
|
+
- `NONE`: read-only, for ids and computed columns. Not editable.
|
|
129
|
+
|
|
130
|
+
`edit_value` gives the editor its starting value when that differs from the
|
|
131
|
+
displayed text (a checkbox wants `true`/`false`, a slider wants a bare number,
|
|
132
|
+
even if the cell shows `Yes` or `146.520 MHz`).
|
|
133
|
+
|
|
134
|
+
## Keyboard
|
|
135
|
+
|
|
136
|
+
- Arrows: move the focused cell. `Home` / `End`: first / last cell in the row.
|
|
137
|
+
`Ctrl+Home` / `Ctrl+End`: first / last cell in the grid. `Page Up` / `Page Down`:
|
|
138
|
+
previous / next page.
|
|
139
|
+
- `F2` or `Enter`: edit the focused cell. `Enter`: commit. `Escape`: cancel.
|
|
140
|
+
- `Space` or `Ctrl+Space`: select or unselect the row.
|
|
141
|
+
- `Delete`: delete the selected rows, or the focused row if none are selected.
|
|
142
|
+
- Context menu key or `Shift+F10`: ask the host for a row menu.
|
|
143
|
+
|
|
144
|
+
## How it works
|
|
145
|
+
|
|
146
|
+
The grid is a `<table role="grid">` with `<th scope="col">` column headers,
|
|
147
|
+
a `<th scope="row">` row header, and `<td role="gridcell">` cells, rendered into
|
|
148
|
+
an `AccessibleWebView`. A roving `tabindex` gives exactly one cell focus at a
|
|
149
|
+
time, which puts the screen reader in focus mode so it reads that cell and the
|
|
150
|
+
headers that changed, not the whole table. A small vanilla-JS runtime, installed
|
|
151
|
+
once, handles navigation, editing, and selection, and talks to Python over the
|
|
152
|
+
WebView bridge. Your `GridModel` does the validation and persistence on the
|
|
153
|
+
Python side.
|
|
154
|
+
|
|
155
|
+
## Status
|
|
156
|
+
|
|
157
|
+
Version 0.1.0, first release, built for VRP (the accessible radio programmer) and
|
|
158
|
+
extracted as a reusable library. Tested on macOS WebView with VoiceOver and in
|
|
159
|
+
unit tests for the model and renderer. NVDA and JAWS verification on Windows
|
|
160
|
+
WebView2 is the next milestone; reports welcome.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT. A Community Access open-source project, created by Taylor Arndt.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# wx-accessible-grid
|
|
2
|
+
|
|
3
|
+
An accessible, editable data grid for wxPython that a blind person can actually
|
|
4
|
+
use. Native `wx.grid.Grid` reads poorly or not at all in NVDA and JAWS, and a
|
|
5
|
+
hand-built grid of `<div>`s is worse. This library takes the approach proven by
|
|
6
|
+
its sibling [wx-accessible-webview](https://github.com/Community-Access/wx-accessible-webview):
|
|
7
|
+
render a real, semantic ARIA grid into a WebView and let the screen reader follow
|
|
8
|
+
it like any web data table, then layer spreadsheet-style keyboard behavior on top.
|
|
9
|
+
|
|
10
|
+
It is built for data entry, not a spreadsheet engine. There are no formulas. What
|
|
11
|
+
there is, is every control you actually edit data with, fully keyboard-operable
|
|
12
|
+
and announced correctly.
|
|
13
|
+
|
|
14
|
+
## What you get
|
|
15
|
+
|
|
16
|
+
- Arrow keys move a single focused cell, not the document. Moving across a row
|
|
17
|
+
speaks the column header. Moving down a column speaks the row header. Only the
|
|
18
|
+
focused cell is read, so a grid with thousands of rows stays fast instead of
|
|
19
|
+
making the screen reader re-read the whole table on every keystroke.
|
|
20
|
+
- `F2` or `Enter` edits a cell in place with the right control for the data: edit
|
|
21
|
+
box, combo box, checkbox, slider, or stepper. `Enter` commits, `Escape` cancels.
|
|
22
|
+
- `Space`, or `Ctrl+Space`, selects a row. `Delete` deletes the selection. The
|
|
23
|
+
context menu key, or `Shift+F10`, fires a callback so you can show a native menu.
|
|
24
|
+
- Pass `row_select=True` to add a real checkbox at the start of every row, so
|
|
25
|
+
selecting rows for a bulk operation (move, reorder, edit a region) is visible
|
|
26
|
+
and discoverable, not just a keystroke. The host reads `grid.selected_rows()`,
|
|
27
|
+
acts through the model, and calls `grid.refresh()`.
|
|
28
|
+
- Editing round-trips through your model, so the value the screen reader confirms
|
|
29
|
+
is the validated, normalized one, never the raw keystrokes. If an edit is
|
|
30
|
+
rejected the user hears why and the editor reopens so they can fix it.
|
|
31
|
+
- Only one page of rows is ever in the DOM, but `aria-rowcount` keeps the user's
|
|
32
|
+
sense of "row N of many" correct. Arrow past the bottom and the next page loads
|
|
33
|
+
and navigation just keeps going.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install wx-accessible-grid
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
That pulls in wxPython and wx-accessible-webview.
|
|
42
|
+
|
|
43
|
+
## Use it
|
|
44
|
+
|
|
45
|
+
Subclass `GridModel` to describe your columns and provide the data, then drop an
|
|
46
|
+
`AccessibleGrid` into a sizer.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import wx
|
|
50
|
+
from wx_accessible_grid import AccessibleGrid, GridModel, Column, SetResult
|
|
51
|
+
from wx_accessible_grid import TEXT, COMBO, CHECKBOX, SLIDER, STEPPER, NONE
|
|
52
|
+
|
|
53
|
+
class ChannelModel(GridModel):
|
|
54
|
+
def __init__(self, rows):
|
|
55
|
+
self._rows = rows
|
|
56
|
+
self._cols = [
|
|
57
|
+
Column("num", "#", editor=NONE, is_row_header=True),
|
|
58
|
+
Column("name", "Name", editor=TEXT),
|
|
59
|
+
Column("mode", "Mode", editor=COMBO, choices=["FM", "AM", "USB"]),
|
|
60
|
+
Column("active", "Active", editor=CHECKBOX),
|
|
61
|
+
Column("volume", "Volume", editor=SLIDER, min=0, max=100, step=1),
|
|
62
|
+
Column("priority", "Priority", editor=STEPPER, min=0, max=10),
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
def columns(self):
|
|
66
|
+
return self._cols
|
|
67
|
+
|
|
68
|
+
def row_count(self):
|
|
69
|
+
return len(self._rows)
|
|
70
|
+
|
|
71
|
+
def display(self, row, column):
|
|
72
|
+
if column == "num":
|
|
73
|
+
return str(row + 1)
|
|
74
|
+
val = self._rows[row][column]
|
|
75
|
+
return "Yes" if (column == "active" and val) else "No" if column == "active" else str(val)
|
|
76
|
+
|
|
77
|
+
def edit_value(self, row, column):
|
|
78
|
+
# the form the editor wants, when it differs from the shown text
|
|
79
|
+
if column == "active":
|
|
80
|
+
return "true" if self._rows[row]["active"] else "false"
|
|
81
|
+
return self.display(row, column)
|
|
82
|
+
|
|
83
|
+
def set_cell(self, row, column, value):
|
|
84
|
+
# validate and normalize here; what you return is what gets announced
|
|
85
|
+
self._rows[row][column] = value
|
|
86
|
+
return SetResult(True, display=value, message=f"{column} updated")
|
|
87
|
+
|
|
88
|
+
grid = AccessibleGrid(panel, ChannelModel(rows), label="Memory channels",
|
|
89
|
+
page_size=100)
|
|
90
|
+
sizer.Add(grid.control, 1, wx.EXPAND)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The `dev` extra (`pip install "wx-accessible-grid[dev]"`) adds pytest for the
|
|
94
|
+
model and renderer tests, which run without wx.
|
|
95
|
+
|
|
96
|
+
## The editors
|
|
97
|
+
|
|
98
|
+
- `TEXT`: a single-line edit box. Type, then Enter to commit.
|
|
99
|
+
- `COMBO`: a drop-down of fixed choices, which also stands in for a radio group.
|
|
100
|
+
Arrow through the list, Enter to commit.
|
|
101
|
+
- `CHECKBOX`: a boolean toggle. Space toggles it, Enter commits.
|
|
102
|
+
- `SLIDER`: a range control adjusted with the arrow keys.
|
|
103
|
+
- `STEPPER`: a number spinner. Type a value or step it with the arrows.
|
|
104
|
+
- `NONE`: read-only, for ids and computed columns. Not editable.
|
|
105
|
+
|
|
106
|
+
`edit_value` gives the editor its starting value when that differs from the
|
|
107
|
+
displayed text (a checkbox wants `true`/`false`, a slider wants a bare number,
|
|
108
|
+
even if the cell shows `Yes` or `146.520 MHz`).
|
|
109
|
+
|
|
110
|
+
## Keyboard
|
|
111
|
+
|
|
112
|
+
- Arrows: move the focused cell. `Home` / `End`: first / last cell in the row.
|
|
113
|
+
`Ctrl+Home` / `Ctrl+End`: first / last cell in the grid. `Page Up` / `Page Down`:
|
|
114
|
+
previous / next page.
|
|
115
|
+
- `F2` or `Enter`: edit the focused cell. `Enter`: commit. `Escape`: cancel.
|
|
116
|
+
- `Space` or `Ctrl+Space`: select or unselect the row.
|
|
117
|
+
- `Delete`: delete the selected rows, or the focused row if none are selected.
|
|
118
|
+
- Context menu key or `Shift+F10`: ask the host for a row menu.
|
|
119
|
+
|
|
120
|
+
## How it works
|
|
121
|
+
|
|
122
|
+
The grid is a `<table role="grid">` with `<th scope="col">` column headers,
|
|
123
|
+
a `<th scope="row">` row header, and `<td role="gridcell">` cells, rendered into
|
|
124
|
+
an `AccessibleWebView`. A roving `tabindex` gives exactly one cell focus at a
|
|
125
|
+
time, which puts the screen reader in focus mode so it reads that cell and the
|
|
126
|
+
headers that changed, not the whole table. A small vanilla-JS runtime, installed
|
|
127
|
+
once, handles navigation, editing, and selection, and talks to Python over the
|
|
128
|
+
WebView bridge. Your `GridModel` does the validation and persistence on the
|
|
129
|
+
Python side.
|
|
130
|
+
|
|
131
|
+
## Status
|
|
132
|
+
|
|
133
|
+
Version 0.1.0, first release, built for VRP (the accessible radio programmer) and
|
|
134
|
+
extracted as a reusable library. Tested on macOS WebView with VoiceOver and in
|
|
135
|
+
unit tests for the model and renderer. NVDA and JAWS verification on Windows
|
|
136
|
+
WebView2 is the next milestone; reports welcome.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT. A Community Access open-source project, created by Taylor Arndt.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Agent notes — wx-accessible-grid
|
|
2
|
+
|
|
3
|
+
Handoff log. Read this first; update it as you go.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
A reusable, accessible, editable data grid for wxPython, spun out the same way as
|
|
8
|
+
its siblings `wx-accessible-webview` and `wx-accessible-menubar`. Community Access
|
|
9
|
+
open-source, MIT, created by Taylor Arndt. First consumer is VRP (the accessible
|
|
10
|
+
radio programmer), whose channel grid is the motivating use case.
|
|
11
|
+
|
|
12
|
+
The hard problem it solves: native `wx.grid.Grid` is inaccessible, and earlier
|
|
13
|
+
in-grid editing attempts in VRP made NVDA re-read the whole table on every
|
|
14
|
+
keystroke (so they fell back to a read-only table plus a native edit dialog).
|
|
15
|
+
The fix here is a real `<table role="grid">` with a roving tabindex, so NVDA goes
|
|
16
|
+
into focus mode and reads only the focused cell plus the headers that changed.
|
|
17
|
+
|
|
18
|
+
## Architecture
|
|
19
|
+
|
|
20
|
+
- `model.py` — `GridModel` (subclass it), `Column` (with one of five editors:
|
|
21
|
+
text, combo, checkbox, slider, stepper, plus `none`), `SetResult`. The model
|
|
22
|
+
owns all data, validation, and persistence; the grid never mutates data
|
|
23
|
+
directly. wx-free, unit tested.
|
|
24
|
+
- `render.py` — renders the model to ARIA grid HTML one page at a time.
|
|
25
|
+
`aria-rowcount` = total + 1 (header), `aria-rowindex` absolute (header = 1,
|
|
26
|
+
first data row = 2). wx-free, unit tested.
|
|
27
|
+
- `assets.py` — `GRID_CSS` and the vanilla-JS runtime (`runtime_js(handler)`).
|
|
28
|
+
The runtime is delegated off `document` so it survives re-renders; it manages
|
|
29
|
+
the roving tabindex, navigation, selection, the five in-cell editors, and the
|
|
30
|
+
Python bridge. `window.__wag` exposes setColumns/setCell/editFailed/removeRow/
|
|
31
|
+
announce/focusCell for Python to call back.
|
|
32
|
+
- `grid.py` — `AccessibleGrid` widget. Owns an `AccessibleWebView`, installs the
|
|
33
|
+
runtime on load (EVT_WEBVIEW_LOADED + CallAfter), pushes column metadata,
|
|
34
|
+
renders pages, and brokers edit/select/delete/page/context bridge messages to
|
|
35
|
+
the model. `grid.control` goes in a sizer.
|
|
36
|
+
- `examples/demo.py` — standalone 2,500-row demo with all five editors (run it
|
|
37
|
+
with NVDA on Windows). `tests/` — model + renderer tests (no wx needed).
|
|
38
|
+
|
|
39
|
+
## Bridge protocol
|
|
40
|
+
|
|
41
|
+
Page to Python (all `{type:"wag", action, ...}`): `edit{row,col,value}`,
|
|
42
|
+
`select{row,selected,count}`, `delete{rows}`, `page{dir,col,fromRow}`,
|
|
43
|
+
`context{row,col}`. `col` is the 0-based column index; `row` is the absolute
|
|
44
|
+
0-based row index.
|
|
45
|
+
|
|
46
|
+
## Focus model (important — read before editing assets.py/render.py)
|
|
47
|
+
|
|
48
|
+
The grid uses the **aria-activedescendant** pattern, NOT roving tabindex. The
|
|
49
|
+
`<table>` is the only focusable element (`tabindex=0`) and owns
|
|
50
|
+
`aria-activedescendant`; the runtime moves that to the active cell's id instead
|
|
51
|
+
of calling `cell.focus()`. This is deliberate: the accessibility-lead review
|
|
52
|
+
found a focused `role=gridcell` with roving tabindex does NOT reliably put NVDA
|
|
53
|
+
in focus mode under WebView2 (arrows would be dead), and every innerHTML swap
|
|
54
|
+
bounced focus through document.body and out of focus mode. activedescendant fixes
|
|
55
|
+
both. Do not "simplify" back to roving tabindex + cell.focus().
|
|
56
|
+
|
|
57
|
+
Paging and delete replace ONLY the tbody (`window.__wag.setRows`) so the table
|
|
58
|
+
element — and thus focus + focus mode — survives. Full `set_content` is only used
|
|
59
|
+
for the initial render and `refresh()`.
|
|
60
|
+
|
|
61
|
+
## Status (2026-06-20)
|
|
62
|
+
|
|
63
|
+
- Built v0.1.0. accessibility-lead review done (4 specialists). It was a NO-SHIP;
|
|
64
|
+
all three Criticals fixed: (1) migrated to aria-activedescendant; (2) edit/
|
|
65
|
+
delete/page no longer drop focus to body; (3) two real aria-live regions are
|
|
66
|
+
rendered (polite + assertive) and success edits now announce the authoritative
|
|
67
|
+
value. Should-fix items also applied: Tab/blur edit trap, read-only announce on
|
|
68
|
+
F2, header-row navigation, page-edge guards, cell-level aria-selected,
|
|
69
|
+
aria-invalid + message-in-name on reject, identical-text re-announce. (A few
|
|
70
|
+
nice-to-haves from the review remain, noted in the review output.)
|
|
71
|
+
- 11 unit tests pass (`PYTHONPATH=src pytest`). Mac WKWebView smoke passes:
|
|
72
|
+
build, edit (incl. reject keeps old value), paging, delete, runtime install.
|
|
73
|
+
- WIRED INTO VRP and tested against a real CHIRP image (Baofeng UV-5R, 128
|
|
74
|
+
channels): all real columns render, name edit persisted (and the UV-5R's name
|
|
75
|
+
truncation came back as the authoritative display — the round-trip works), bad
|
|
76
|
+
frequency rejected. VRP files: `vrp/channel_grid_model.py` (ChannelGridModel),
|
|
77
|
+
`tools/grid_preview.py` (launcher), dep added to VRP pyproject + installed
|
|
78
|
+
editable. VRP's own 63 tests still pass.
|
|
79
|
+
- NOT yet verified on Windows + NVDA/WebView2 — the make-or-break test, next
|
|
80
|
+
milestone. On the Parallels VM: `uv run python tools/grid_preview.py` in the
|
|
81
|
+
VRP clone (see windows-parallels-prlctl memory). The review's step-by-step VM
|
|
82
|
+
test script is in that review output; step 1 (Tab in, press Down, expect a
|
|
83
|
+
sibling cell, not a document line) is the focus-mode gate.
|
|
84
|
+
- VRP's read-only table + native edit dialog are left in place; the grid is a
|
|
85
|
+
preview/beta path until NVDA proves out, then it replaces the channels view
|
|
86
|
+
(keep the dialog as the full-row fallback).
|
|
87
|
+
- No git commits yet (Taylor commits/pushes himself). No PyPI release yet.
|
|
88
|
+
|
|
89
|
+
## Testing
|
|
90
|
+
|
|
91
|
+
- `PYTHONPATH=src <python-with-wx> -m pytest -q` — model + renderer.
|
|
92
|
+
- `pip install -e ".[dev]"` then `pytest` in a clean env.
|
|
93
|
+
- Real test is manual: `python examples/demo.py` with a screen reader. Drive the
|
|
94
|
+
arrow navigation, F2/Enter editing for each editor, Space selection, Delete,
|
|
95
|
+
and the context-menu key, and confirm the announcements.
|
|
96
|
+
|
|
97
|
+
## Conventions
|
|
98
|
+
|
|
99
|
+
Mirror the sibling libs: hatchling build, `src/` layout, MIT, ruff line length
|
|
100
|
+
100, dependency-light (wxPython + wx-accessible-webview). No emojis, no markdown
|
|
101
|
+
tables in docs (screen-reader rule).
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Standalone demo of AccessibleGrid — run it with a screen reader.
|
|
2
|
+
|
|
3
|
+
pip install -e .
|
|
4
|
+
python examples/demo.py
|
|
5
|
+
|
|
6
|
+
It builds a 2,500-row grid (to exercise paging and large-dataset performance,
|
|
7
|
+
the exact condition that broke earlier in-grid attempts) with one of every
|
|
8
|
+
editor: text, combo, checkbox, slider, stepper. Try, with NVDA running:
|
|
9
|
+
|
|
10
|
+
* Arrow around — moving across a row speaks the column; moving down speaks the
|
|
11
|
+
row number. Arrow past the bottom/top to roll onto the next/previous page.
|
|
12
|
+
* F2 or Enter on a cell to edit; Enter commits, Escape cancels.
|
|
13
|
+
* Space (or Ctrl+Space) to select a row; Delete to delete selected rows.
|
|
14
|
+
* The Applications key (or Shift+F10) on a cell opens a native row menu.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import wx
|
|
20
|
+
|
|
21
|
+
from wx_accessible_grid import (
|
|
22
|
+
CHECKBOX,
|
|
23
|
+
COMBO,
|
|
24
|
+
NONE,
|
|
25
|
+
SLIDER,
|
|
26
|
+
STEPPER,
|
|
27
|
+
TEXT,
|
|
28
|
+
AccessibleGrid,
|
|
29
|
+
Column,
|
|
30
|
+
GridModel,
|
|
31
|
+
SetResult,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
MODES = ["FM", "NFM", "AM", "USB", "LSB", "CW", "DV"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DemoModel(GridModel):
|
|
38
|
+
"""An in-memory grid: a list of dict rows, all validation inline."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, n: int = 2500) -> None:
|
|
41
|
+
self._cols = [
|
|
42
|
+
Column("num", "#", editor=NONE, is_row_header=True),
|
|
43
|
+
Column("name", "Name", editor=TEXT),
|
|
44
|
+
Column("mode", "Mode", editor=COMBO, choices=MODES),
|
|
45
|
+
Column("active", "Active", editor=CHECKBOX),
|
|
46
|
+
Column("volume", "Volume", editor=SLIDER, min=0, max=100, step=1),
|
|
47
|
+
Column("priority", "Priority", editor=STEPPER, min=0, max=10, step=1),
|
|
48
|
+
Column("comment", "Comment", editor=TEXT),
|
|
49
|
+
]
|
|
50
|
+
self._rows = [
|
|
51
|
+
{
|
|
52
|
+
"name": f"Channel {i + 1}",
|
|
53
|
+
"mode": MODES[i % len(MODES)],
|
|
54
|
+
"active": i % 3 == 0,
|
|
55
|
+
"volume": (i * 7) % 101,
|
|
56
|
+
"priority": i % 11,
|
|
57
|
+
"comment": "",
|
|
58
|
+
}
|
|
59
|
+
for i in range(n)
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
def columns(self) -> list[Column]:
|
|
63
|
+
return self._cols
|
|
64
|
+
|
|
65
|
+
def row_count(self) -> int:
|
|
66
|
+
return len(self._rows)
|
|
67
|
+
|
|
68
|
+
def display(self, row: int, column: str) -> str:
|
|
69
|
+
if column == "num":
|
|
70
|
+
return str(row + 1)
|
|
71
|
+
val = self._rows[row][column]
|
|
72
|
+
if column == "active":
|
|
73
|
+
return "Yes" if val else "No"
|
|
74
|
+
return str(val)
|
|
75
|
+
|
|
76
|
+
def edit_value(self, row: int, column: str) -> str:
|
|
77
|
+
if column == "active":
|
|
78
|
+
return "true" if self._rows[row]["active"] else "false"
|
|
79
|
+
if column == "num":
|
|
80
|
+
return str(row + 1)
|
|
81
|
+
return str(self._rows[row][column])
|
|
82
|
+
|
|
83
|
+
def set_cell(self, row: int, column: str, value: str) -> SetResult:
|
|
84
|
+
if column in ("volume", "priority"):
|
|
85
|
+
try:
|
|
86
|
+
num = int(float(value))
|
|
87
|
+
except ValueError:
|
|
88
|
+
return SetResult(False, message=f"{column} must be a number")
|
|
89
|
+
lo, hi = (0, 100) if column == "volume" else (0, 10)
|
|
90
|
+
if not lo <= num <= hi:
|
|
91
|
+
return SetResult(False, message=f"{column} must be {lo} to {hi}")
|
|
92
|
+
self._rows[row][column] = num
|
|
93
|
+
return SetResult(True, str(num), f"{column} set to {num}")
|
|
94
|
+
if column == "active":
|
|
95
|
+
on = value in ("true", "1", "yes")
|
|
96
|
+
self._rows[row]["active"] = on
|
|
97
|
+
return SetResult(True, "Yes" if on else "No", "Active on" if on else "Active off")
|
|
98
|
+
if column == "mode":
|
|
99
|
+
if value not in MODES:
|
|
100
|
+
return SetResult(False, message=f"{value} is not a valid mode")
|
|
101
|
+
self._rows[row]["mode"] = value
|
|
102
|
+
return SetResult(True, value, f"Mode {value}")
|
|
103
|
+
self._rows[row][column] = value
|
|
104
|
+
return SetResult(True, value, f"{column} updated")
|
|
105
|
+
|
|
106
|
+
def delete_rows(self, rows: list[int]) -> SetResult:
|
|
107
|
+
for r in sorted(rows, reverse=True):
|
|
108
|
+
if 0 <= r < len(self._rows):
|
|
109
|
+
del self._rows[r]
|
|
110
|
+
return SetResult(True, message=f"Deleted {len(rows)} row(s)")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class DemoFrame(wx.Frame):
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
super().__init__(None, title="wx-accessible-grid demo", size=(900, 600))
|
|
116
|
+
panel = wx.Panel(self)
|
|
117
|
+
self.model = DemoModel()
|
|
118
|
+
self.grid = AccessibleGrid(
|
|
119
|
+
panel,
|
|
120
|
+
self.model,
|
|
121
|
+
label="Demo channels",
|
|
122
|
+
page_size=100,
|
|
123
|
+
row_select=True,
|
|
124
|
+
on_context=self._on_context,
|
|
125
|
+
description="Arrow to move, F2 or Enter to edit, Space to select.",
|
|
126
|
+
)
|
|
127
|
+
sizer = wx.BoxSizer(wx.VERTICAL)
|
|
128
|
+
sizer.Add(self.grid.control, 1, wx.EXPAND)
|
|
129
|
+
panel.SetSizer(sizer)
|
|
130
|
+
self.Show()
|
|
131
|
+
wx.CallAfter(self.grid.focus)
|
|
132
|
+
|
|
133
|
+
def _on_context(self, row: int, column: str) -> None:
|
|
134
|
+
menu = wx.Menu()
|
|
135
|
+
edit = menu.Append(wx.ID_ANY, f"Edit row {row + 1} (full)\tEnter")
|
|
136
|
+
delete = menu.Append(wx.ID_ANY, f"Delete row {row + 1}\tDel")
|
|
137
|
+
self.Bind(
|
|
138
|
+
wx.EVT_MENU,
|
|
139
|
+
lambda _e: self.grid.announce(f"Would open full editor for row {row + 1}"),
|
|
140
|
+
edit,
|
|
141
|
+
)
|
|
142
|
+
self.Bind(
|
|
143
|
+
wx.EVT_MENU,
|
|
144
|
+
lambda _e: (self.model.delete_rows([row]), self.grid.refresh()),
|
|
145
|
+
delete,
|
|
146
|
+
)
|
|
147
|
+
self.grid.control.PopupMenu(menu)
|
|
148
|
+
menu.Destroy()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
app = wx.App()
|
|
153
|
+
DemoFrame()
|
|
154
|
+
app.MainLoop()
|