Skip to main content

max / makeover-immediate

0.34.0: a control is announced by what it is and what it asks Two accessibility defects, both found by reading what these renderers actually draw through egui's AccessKit tree rather than by looking at them. Both were in the half of the interface that is not pixels, which is why neither showed up in a year of use. `field` drew `Field::label` above the control and stopped, so what reached the tree was an unnamed box with some text near it. A screen reader announced a text field with no question; a prefilled one announced its own contents, so a rename field read out the name it was seeded with and never said what was being asked. `Response::labelled_by` attaches it. A checkbox is unaffected: `FieldKind::labels_itself` routes past the label and egui names one from its own text. `table::press` drew a sortable heading as a `Label` that senses a click, and egui maps a `Label` to `Role::Label` whatever it senses, so the tree said static text where a user can press to reorder. That is the same argument the function already makes in a comment about affordance -- a heading a user cannot press must not look like one they can -- applied to the other half of the interface. The name is the column's own rather than the drawn heading's: the caret renders `Column::sorted` and reading a triangle aloud after every heading is noise. Worth fixing here rather than in each app: `Field::label` and `Column::name` are members the description carries so a renderer does not have to guess, and `makeover-webview` has always named them. Two renderers disagreeing about a fact the description states is the thing this layer exists to prevent. Found flipping audiofiles' screens onto the description layer, by a parity harness that reads a drawn panel through its AccessKit tree. Five tests here, and each one fails without its fix.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 17:42 UTC
Signed with PGP, not checked
Commit: af9a551143a80edc2583b6c71277eafe4459028f
Parent: 3d91678
3 files changed, +242 insertions, -4 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-immediate"
3 - version = "0.33.0"
3 + version = "0.34.0"
4 4 edition = "2024"
5 5 description = "The immediate-mode renderer for makeover-layout. Immediate mode is the constraint that matters, not the library: no cascade, no retained tree, one stroke per widget. Backed by egui."
6 6 license = "MIT"
M src/lib.rs +146 -3
@@ -1200,14 +1200,34 @@
1200 1200 // A checkbox labels itself, on the right of the box.
1201 1201 // `FieldKind::labels_itself` is the description saying so, and both
1202 1202 // webview apps special-cased it inline before it did.
1203 - if !field.kind.labels_itself() {
1204 - ui.label(RichText::new(label_text(field, style)).color(text));
1205 - }
1203 + let named_by = (!field.kind.labels_itself())
1204 + .then(|| ui.label(RichText::new(label_text(field, style)).color(text)));
1206 1205
1207 1206 let response = ui
1208 1207 .add_enabled_ui(enabled, |ui| control(ui, field, filling, palette, style))
1209 1208 .inner;
1210 1209
1210 + // The label, attached rather than merely adjacent.
1211 + //
1212 + // Drawing it above the control and stopping there is what this did
1213 + // until 2026-08-22, and it put an unnamed box in the accessibility
1214 + // tree with some text near it: a screen reader announced a text
1215 + // field with no question, and a prefilled one announced its own
1216 + // contents instead. audiofiles' four name modals were the site that
1217 + // measured it, through a harness reading what the panel drew.
1218 + //
1219 + // Worth stating why it was worth fixing here rather than in each
1220 + // app: `Field::label` is a member the description carries so a
1221 + // renderer does not have to guess, `makeover-webview` has always
1222 + // named it in `aria-describedby`, and the two renderers disagreeing
1223 + // about a fact the description states is the one thing this layer
1224 + // exists to prevent. A checkbox is unaffected -- egui names one from
1225 + // its own text, which is what `labels_itself` already says.
1226 + let response = match named_by {
1227 + Some(label) => response.labelled_by(label.id),
1228 + None => response,
1229 + };
1230 +
1211 1231 // Standing help first, then what is wrong now. Both, in that order,
1212 1232 // for the reason the webview renderer names both in
1213 1233 // `aria-describedby`: an error appearing must not take the hint
@@ -1633,6 +1653,129 @@
1633 1653 assert_ne!(option_color("wav", "aiff", &p), p.content_muted);
1634 1654 }
1635 1655
1656 + /// What the accessibility tree says a screen drew, as `(role, name)` pairs.
1657 + ///
1658 + /// egui builds this from the same `WidgetInfo` every widget already reports,
1659 + /// so it is what a screen reader would be handed rather than a second
1660 + /// opinion about it. A name that arrives through `labelled_by` is resolved
1661 + /// the way a client resolves it: the relation names another node, and that
1662 + /// node's text is the control's accessible name.
1663 + fn announced(draw: impl FnMut(&mut egui::Ui)) -> Vec<(egui::accesskit::Role, String)> {
1664 + let ctx = egui::Context::default();
1665 + ctx.enable_accesskit();
1666 + let mut draw = draw;
1667 + let input = || egui::RawInput {
1668 + screen_rect: Some(egui::Rect::from_min_size(
1669 + egui::Pos2::ZERO,
1670 + egui::vec2(800.0, 600.0),
1671 + )),
1672 + ..Default::default()
1673 + };
1674 + // Two passes: egui lays out against the previous frame, so the first
1675 + // sees widgets at the wrong rect.
1676 + let _ = ctx.run_ui(input(), &mut draw);
1677 + let out = ctx.run_ui(input(), &mut draw);
1678 +
1679 + let update = out
1680 + .platform_output
1681 + .accesskit_update
1682 + .expect("accesskit is on, so a tree was built");
1683 + let by_id: std::collections::HashMap<_, _> = update.nodes.iter().cloned().collect();
1684 + update
1685 + .nodes
1686 + .iter()
1687 + .map(|(_, node)| {
1688 + let named = node.label().map(str::to_owned).or_else(|| {
1689 + node.labelled_by()
1690 + .iter()
1691 + .find_map(|id| by_id.get(id))
1692 + .and_then(|by| by.label().or_else(|| by.value()).map(str::to_owned))
1693 + });
1694 + (node.role(), named.unwrap_or_default())
1695 + })
1696 + .collect()
1697 + }
1698 +
1699 + #[test]
1700 + fn a_fields_label_names_its_control_rather_than_sitting_beside_it() {
1701 + let f = Field::new(FieldKind::Text, "name", "Vault name");
1702 + let p = palette(Color32::from_rgb(9, 9, 9));
1703 + let drawn = announced(|ui| {
1704 + let mut text = String::new();
1705 + field(
1706 + ui,
1707 + &f,
1708 + Filling::Text(&mut text),
1709 + None,
1710 + &p,
1711 + &FieldStyle::default(),
1712 + );
1713 + });
1714 +
1715 + let box_ = drawn
1716 + .iter()
1717 + .find(|(role, _)| *role == egui::accesskit::Role::TextInput)
1718 + .expect("a text field draws a text input");
1719 + assert_eq!(
1720 + box_.1, "Vault name",
1721 + "the box is announced by the question rather than unnamed: {drawn:?}"
1722 + );
1723 + }
1724 +
1725 + #[test]
1726 + fn a_prefilled_box_is_still_announced_by_its_question() {
1727 + // The sharper half. With nothing attached, a box carrying a value is
1728 + // announced as that value, so a rename field read out the name it was
1729 + // seeded with and never said what was being asked.
1730 + let f = Field::new(FieldKind::Text, "name", "New name");
1731 + let p = palette(Color32::from_rgb(9, 9, 9));
1732 + let drawn = announced(|ui| {
1733 + let mut text = String::from("Drums");
1734 + field(
1735 + ui,
1736 + &f,
1737 + Filling::Text(&mut text),
1738 + None,
1739 + &p,
1740 + &FieldStyle::default(),
1741 + );
1742 + });
1743 +
1744 + let box_ = drawn
1745 + .iter()
1746 + .find(|(role, _)| *role == egui::accesskit::Role::TextInput)
1747 + .expect("a text field draws a text input");
1748 + assert_eq!(box_.1, "New name", "{drawn:?}");
1749 + }
1750 +
1751 + #[test]
1752 + fn a_checkbox_keeps_naming_itself() {
1753 + // `FieldKind::labels_itself` routes past the label, and egui names a
1754 + // checkbox from its own text, so there is nothing to attach and
1755 + // attaching one would say the name twice.
1756 + let f = Field::new(FieldKind::Checkbox, "loop", "Loop playback");
1757 + let p = palette(Color32::from_rgb(9, 9, 9));
1758 + let drawn = announced(|ui| {
1759 + let mut ticked = false;
1760 + field(
1761 + ui,
1762 + &f,
1763 + Filling::On(&mut ticked),
1764 + None,
1765 + &p,
1766 + &FieldStyle::default(),
1767 + );
1768 + });
1769 +
1770 + assert!(
1771 + drawn
1772 + .iter()
1773 + .any(|(role, name)| *role == egui::accesskit::Role::CheckBox
1774 + && name == "Loop playback"),
1775 + "{drawn:?}"
1776 + );
1777 + }
1778 +
1636 1779 #[test]
1637 1780 fn a_hidden_field_draws_nothing_and_answers_nothing() {
1638 1781 // Where the two renderers legitimately part: a webview still emits an
M src/table.rs +95
@@ -432,12 +432,107 @@
432 432 let response: Response = ui
433 433 .add(egui::Label::new(text.strong()).sense(Sense::click()))
434 434 .on_hover_cursor(egui::CursorIcon::PointingHand);
435 + // Announced as the control it is, rather than as the `Label` it is drawn
436 + // with. egui maps a `Label` to `Role::Label` whatever it senses, so until
437 + // 2026-08-22 a screen reader was told this was static text and a user who
438 + // could not see the pointer change had no way to know the table sorts.
439 + // The same argument the comment above makes about affordance, made about
440 + // the half of the interface that is not pixels.
441 + //
442 + // The name is the column's own, not `heading`'s: the caret is a rendering of
443 + // `Column::sorted`, and reading a triangle aloud after every heading is
444 + // noise. Which column is in force is a fact a client should get from the
445 + // sort state, and egui has nowhere to put that yet -- worth revisiting if it
446 + // grows a sort field on `WidgetInfo`.
447 + response.widget_info(|| {
448 + egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), column.name)
449 + });
435 450 response.clicked()
436 451 }
437 452
438 453 #[cfg(test)]
439 454 mod tests {
440 455 use super::*;
456 +
457 + /// What the accessibility tree says a heading row drew.
458 + ///
459 + /// egui builds it from the `WidgetInfo` each widget reports, so this is
460 + /// what a screen reader would be handed rather than a second opinion.
461 + fn announced(draw: impl FnMut(&mut Ui)) -> Vec<(egui::accesskit::Role, String)> {
462 + let ctx = egui::Context::default();
463 + ctx.enable_accesskit();
464 + let mut draw = draw;
465 + let input = || egui::RawInput {
466 + screen_rect: Some(egui::Rect::from_min_size(
467 + egui::Pos2::ZERO,
468 + egui::vec2(800.0, 600.0),
469 + )),
470 + ..Default::default()
471 + };
472 + let _ = ctx.run_ui(input(), &mut draw);
473 + let out = ctx.run_ui(input(), &mut draw);
474 + out.platform_output
475 + .accesskit_update
476 + .expect("accesskit is on")
477 + .nodes
478 + .iter()
479 + .map(|(_, node)| {
480 + (
481 + node.role(),
482 + node.label()
483 + .or_else(|| node.value())
484 + .unwrap_or_default()
485 + .to_owned(),
486 + )
487 + })
488 + .collect()
489 + }
490 +
491 + #[test]
492 + fn a_sortable_heading_is_announced_as_something_you_press() {
493 + let column = Column {
494 + name: "Name",
495 + width: Width::Fill,
496 + priority: Priority::Essential,
497 + sortable: true,
498 + sorted: Some(Sort::Ascending),
499 + };
500 + let p = palette();
501 + let drawn = announced(|ui| {
502 + press(ui, &column, &p, &TableStyle::default());
503 + });
504 +
505 + // The name is the column's, with no caret in it: the glyph renders
506 + // `Column::sorted` and is not part of what the control is called.
507 + assert!(
508 + drawn
509 + .iter()
510 + .any(|(role, name)| *role == egui::accesskit::Role::Button && name == "Name"),
511 + "{drawn:?}"
512 + );
513 + }
514 +
515 + #[test]
516 + fn a_heading_that_is_not_a_control_is_not_announced_as_one() {
517 + let column = Column {
518 + name: "Tags",
519 + width: Width::Fixed,
520 + priority: Priority::Optional,
521 + sortable: false,
522 + sorted: None,
523 + };
524 + let p = palette();
525 + let drawn = announced(|ui| {
526 + press(ui, &column, &p, &TableStyle::default());
527 + });
528 +
529 + assert!(
530 + !drawn
531 + .iter()
532 + .any(|(role, _)| *role == egui::accesskit::Role::Button),
533 + "a heading with no sort answers nothing and must not claim to: {drawn:?}"
534 + );
535 + }
441 536 use egui::Color32;
442 537
443 538 fn palette() -> Palette {