Skip to main content

max / quasi

A table row says it can be opened, once, on its first column The second half of 461582b5, and the half left open when the list half landed. `cell_row_response` claims the press per cell and registers no widget info, so a table row that opens reached the accessibility tree as nothing at all: it worked under a mouse and did not exist for a keyboard or a screen reader. Announced on the first column only. Saying it per cell would put a button in the tree for every column of every row, all called the same thing, which is noise rather than access; the first column is where the row's name already is. Pressing any cell still opens it, exactly as before. The open question in the earlier note -- whether this needs a row-wide rect from makeover-immediate's table -- turns out to be no. The cell closure already knows its column index, and one announcement in the right place is worth more than one rect covering the wrong ones. Found by audiofiles' bulk-move flip, where the described screen is a table of destinations that each submit themselves, so the row press is the only way to choose one.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 20:45 UTC
Signed with PGP, not checked
Commit: 0f241cef5c2613adfb7ec24d4c1a41023da7c180
Parent: 4de123f
3 files changed, +102 insertions, -11 deletions
M Cargo.lock +4 -4
@@ -6213,10 +6213,10 @@
6213 6213 name = "tagtree"
6214 6214 version = "0.4.1"
6215 6215
6216 - [[patch.unused]]
6217 - name = "synckit-client"
6218 - version = "0.8.1"
6219 -
6220 6216 [[patch.unused]]
6221 6217 name = "quasi-type"
6222 6218 version = "0.1.0"
6219 +
6220 + [[patch.unused]]
6221 + name = "synckit-client"
6222 + version = "0.8.1"
@@ -1231,15 +1231,46 @@
1231 1231 }
1232 1232 }
1233 1233
1234 - /// What a row is called: the first thing it says.
1234 + /// Say that a table row can be opened, once, on its first column.
1235 1235 ///
1236 - /// For [`egui::WidgetInfo`], which needs one string where a row is a run of
1237 - /// leaves. The first textual part is what `Row::new` takes and what a reader
1238 - /// would call the row, so there is nothing to invent here.
1239 - fn row_name(row: &quasi_router::Row) -> String {
1240 - row.parts
1236 + /// The press is claimed per cell, and saying so per cell would put a button in
1237 + /// the tree for every column of every row -- five buttons all called "kick.wav"
1238 + /// on a five-column table, which is noise rather than access. The first column
1239 + /// is where the row's name already is, so that is where it is announced;
1240 + /// pressing any cell still opens it, exactly as before.
1241 + ///
1242 + /// Second half of `461582b5`. The list half is [`row`], where a row has one
1243 + /// rect and one place to say this.
1244 + fn announce_row(ui: &Ui, response: &egui::Response, index: usize, row: &Cells) {
1245 + if index != 0 || (row.activate.is_none() && row.menu.is_empty()) {
1246 + return;
1247 + }
1248 + let named = cells_name(row);
1249 + response.widget_info(|| {
1250 + egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &named)
1251 + });
1252 + }
1253 +
1254 + /// What a table row is called: the first thing in its first cell.
1255 + ///
1256 + /// [`row_name`]'s counterpart for [`quasi_router::Cells`], which holds cells
1257 + /// rather than parts. Same rule and the same reason: it is where the row's name
1258 + /// already is.
1259 + fn cells_name(row: &quasi_router::Cells) -> String {
1260 + row.values
1241 1261 .iter()
1242 - .find_map(|part| match &part.node {
1262 + .find_map(|cell| {
1263 + let said = row_leaf_text(&cell.parts);
1264 + (!said.is_empty()).then_some(said)
1265 + })
1266 + .unwrap_or_default()
1267 + }
1268 +
1269 + /// The first thing a run of leaves says.
1270 + fn row_leaf_text(parts: &[Node]) -> String {
1271 + parts
1272 + .iter()
1273 + .find_map(|part| match part {
1243 1274 Node::Text { text, .. } | Node::Heading { text, .. } | Node::Link { text, .. } => {
1244 1275 Some(text.clone())
1245 1276 }
@@ -1248,6 +1279,16 @@
1248 1279 .unwrap_or_default()
1249 1280 }
1250 1281
1282 + /// What a row is called: the first thing it says.
1283 + ///
1284 + /// For [`egui::WidgetInfo`], which needs one string where a row is a run of
1285 + /// leaves. The first textual part is what `Row::new` takes and what a reader
1286 + /// would call the row, so there is nothing to invent here.
1287 + fn row_name(row: &quasi_router::Row) -> String {
1288 + let parts: Vec<Node> = row.parts.iter().map(|part| part.node.clone()).collect();
1289 + row_leaf_text(&parts)
1290 + }
1291 +
1251 1292 /// One part of a row's run.
1252 1293 ///
1253 1294 /// A part is a role and a node, so the drawing is `draw` again: the role says
@@ -1624,6 +1665,7 @@
1624 1665 // its cells". That is the honest reading of what this renderer can
1625 1666 // see, and it is also what a user expects of a table row.
1626 1667 let response = cell_row_response(ui);
1668 + announce_row(ui, &response, index, row);
1627 1669 if let Some((action, confirm)) = row_menu(pass.immediate, &response, &row.menu) {
1628 1670 menued = Some((action, confirm));
1629 1671 } else if let Some(action) = &row.activate
@@ -2563,3 +2563,52 @@
2563 2563 "{drawn:?}"
2564 2564 );
2565 2565 }
2566 +
2567 + #[test]
2568 + fn a_table_row_that_opens_is_announced_once() {
2569 + // The table half of the same defect the list half fixed. Claimed per cell,
2570 + // so the announcement goes on the first column only: saying it per cell
2571 + // would put one button per column in the tree, all called the same thing.
2572 + let screen = screen_of([Node::Table {
2573 + columns: vec![
2574 + quasi_router::Column::new("Name"),
2575 + quasi_router::Column::new("Tempo"),
2576 + ],
2577 + rows: vec![
2578 + quasi_router::Cells::new(vec![
2579 + quasi_router::Cell::new("kick.wav"),
2580 + quasi_router::Cell::new("90"),
2581 + ])
2582 + .activate(Action::post("/files/1/open")),
2583 + ],
2584 + more: None,
2585 + }]);
2586 + let mut view = View::new();
2587 + let drawn = announced(&screen, &mut view);
2588 +
2589 + let named: Vec<_> = drawn
2590 + .iter()
2591 + .filter(|(role, name)| *role == egui::accesskit::Role::Button && name == "kick.wav")
2592 + .collect();
2593 + assert_eq!(named.len(), 1, "once, not once per column: {drawn:?}");
2594 + }
2595 +
2596 + #[test]
2597 + fn a_table_row_that_only_lists_claims_nothing() {
2598 + let screen = screen_of([Node::Table {
2599 + columns: vec![quasi_router::Column::new("Name")],
2600 + rows: vec![quasi_router::Cells::new(vec![quasi_router::Cell::new(
2601 + "kick.wav",
2602 + )])],
2603 + more: None,
2604 + }]);
2605 + let mut view = View::new();
2606 + let drawn = announced(&screen, &mut view);
2607 +
2608 + assert!(
2609 + !drawn
2610 + .iter()
2611 + .any(|(role, _)| *role == egui::accesskit::Role::Button),
2612 + "{drawn:?}"
2613 + );
2614 + }