Skip to main content

max / audiofiles

Take makeover-immediate 0.35.0, and drop every allowance it retires The three accessibility fixes are published, so the claims the flip set was carrying about them are false and come out: `unnamed_field`, `unnamed_fields` and `mute_chips` are gone from the harness along with every call site. Six screens now match what they describe with nothing granted but the window frame. Four reader corrections fell out of comparing against a renderer that finally names things: - A control named by a separate label carries a `labelled_by` relation and no label of its own, so the reader follows the relation the way a client does. Stopping at `label()` would have reported every properly-labelled field as nameless, which is the opposite of the truth. - An interval is one question and two controls, and both are named by it. - A bare `Number` is a well you type into, not a spin button: `control_shape` sends everything it cannot draw natively to `Control::Typed`, and the reader follows the renderer rather than insisting on the kind. - A row is claimed when it opens or when it offers a menu, which is `node::row`'s own condition. A tag chip with a Remove in its menu is right-clickable and announced, and the reader was seeing only the first half. One allowance is new and is not a defect: egui builds a `DragValue` inside a `Slider` and never hands it back, so a described `Range` reaches the tree as one named control and one unnamed number. `slider_readouts` names it, because the alternative takes the number off the screen.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 19:41 UTC
Signed with PGP, not checked
Commit: ec2d6af552cb533528d605dd5a9454609ee2d6e0
Parent: ba2b1e9
3 files changed, +91 insertions, -158 deletions
M Cargo.lock +14 -14
@@ -3074,9 +3074,9 @@
3074 3074
3075 3075 [[package]]
3076 3076 name = "makeover-immediate"
3077 - version = "0.33.0"
3077 + version = "0.35.0"
3078 3078 source = "registry+https://github.com/rust-lang/crates.io-index"
3079 - checksum = "43d67f743414e6b1ffcce6da7e9500cd2d4efe5cf42d730ae8f6e6c8d87c3d5a"
3079 + checksum = "d12682cc00dee1866075ff3cd9890d393e68b801c1c93e9dd525d332a8dadee0"
3080 3080 dependencies = [
3081 3081 "egui",
3082 3082 "egui_extras",
@@ -7554,18 +7554,6 @@
7554 7554 "winnow 1.0.4",
7555 7555 ]
7556 7556
7557 - [[patch.unused]]
7558 - name = "kberg"
7559 - version = "0.1.0"
7560 -
7561 - [[patch.unused]]
7562 - name = "ops-status"
7563 - version = "0.1.0"
7564 -
7565 - [[patch.unused]]
7566 - name = "painhours"
7567 - version = "0.1.0"
7568 -
7569 7557 [[patch.unused]]
7570 7558 name = "quasi-axum"
7571 7559 version = "0.54.0"
@@ -7594,6 +7582,18 @@
7594 7582 name = "quasi-webview"
7595 7583 version = "0.54.0"
7596 7584
7585 + [[patch.unused]]
7586 + name = "kberg"
7587 + version = "0.1.0"
7588 +
7589 + [[patch.unused]]
7590 + name = "ops-status"
7591 + version = "0.1.0"
7592 +
7593 + [[patch.unused]]
7594 + name = "painhours"
7595 + version = "0.1.0"
7596 +
7597 7597 [[patch.unused]]
7598 7598 name = "quasi-type"
7599 7599 version = "0.1.0"
M Cargo.toml +1 -1
@@ -18,7 +18,7 @@
18 18 # makeover-immediate re-exports nothing, so the `Column` the app describes and
19 19 # the `Column` the renderer matches on have to be the same type.
20 20 makeover-layout = "0.35.0"
21 - makeover-immediate = "0.33.0"
21 + makeover-immediate = "0.35.0"
22 22 # The time axis. Minor rather than exact patch: it re-exports nothing and no
23 23 # type of its crosses a renderer boundary, so it is under the same rule as
24 24 # makeover-geometry above rather than the layout/immediate pair below.
@@ -211,7 +211,18 @@
211 211 }
212 212 return;
213 213 }
214 - out.push(field_role(field.kind), field.label.clone(), false);
214 + // An interval is one question with two ends, and both ends are controls.
215 + // makeover-immediate names each of them by the question, which is the
216 + // right answer -- "BPM Range" twice reads correctly to a screen reader
217 + // stepping through them -- so the offering has two.
218 + let ends = if field.kind == layout::FieldKind::Interval {
219 + 2
220 + } else {
221 + 1
222 + };
223 + for _ in 0..ends {
224 + out.push(field_role(field.kind), field.label.clone(), false);
225 + }
215 226 }
216 227
217 228 /// The role a field of this kind is drawn as.
@@ -220,7 +231,13 @@
220 231 match kind {
221 232 K::Checkbox => Role::Check,
222 233 K::Select | K::Radio => Role::Choice,
223 - K::Number | K::Range | K::Interval => Role::Number,
234 + // A slider and an interval's two ends. A bare `Number` is not here:
235 + // `makeover_immediate::control_shape` sends everything it cannot draw
236 + // natively to `Control::Typed`, so a number is a well you type into and
237 + // reaches the tree as a text input. That is the renderer's answer and a
238 + // true report of the value, so the reader follows it rather than
239 + // insisting on the kind.
240 + K::Range | K::Interval => Role::Number,
224 241 // Everything else is a box you type into. `File` is the one stretch and
225 242 // it is the honest answer: a host draws it as a control that opens a
226 243 // picker, which is a button on some hosts and a path box on others, and
@@ -308,9 +325,17 @@
308 325 }
309 326 Node::List { rows, .. } => {
310 327 for row in rows {
311 - if let Some(activate) = &row.activate {
328 + // A row is claimed when it opens OR when it offers a menu,
329 + // which is `quasi_immediate::node::row`'s own condition: a row
330 + // that only offers a menu still needs somewhere to right-click,
331 + // and it is announced by its first text either way. The menu's
332 + // own acts are not offers here -- they are not on screen until
333 + // the gesture -- but the row that carries them is.
334 + if row.activate.is_some() || !row.menu.is_empty() {
312 335 let named: Vec<_> = row.parts.iter().map(|part| part.node.clone()).collect();
313 336 out.push(Role::Button, first_text(&named), false);
337 + }
338 + if let Some(activate) = &row.activate {
314 339 out.addresses.push(activate.destination.as_str().to_owned());
315 340 }
316 341 for part in &row.parts {
@@ -444,23 +469,45 @@
444 469 let Some(update) = output.platform_output.accesskit_update else {
445 470 panic!("accesskit produced no tree: the panel drew nothing at all");
446 471 };
447 - for (_, node) in update.nodes {
448 - let Some(role) = role_of(&node) else {
472 + let by_id: std::collections::HashMap<_, _> = update.nodes.iter().cloned().collect();
473 + for (_, node) in &update.nodes {
474 + let Some(role) = role_of(node) else {
449 475 continue;
450 476 };
451 - // egui puts a label's text in `value` and every other widget's in
452 - // `label`, because a `Role::Label` IS its text. See
453 - // `Response::fill_accesskit_node_from_widget_info`.
454 - let said = node
455 - .label()
456 - .map(str::to_owned)
457 - .or_else(|| node.value().map(str::to_owned))
458 - .unwrap_or_default();
477 + let said = named(node, &by_id);
459 478 out.push(role, undecorated(&said), node.is_disabled());
460 479 }
461 480 out
462 481 }
463 482
483 + /// What a control is called, the way a client works it out.
484 + ///
485 + /// egui puts a label's own text in `value` and every other widget's in `label`,
486 + /// because a `Role::Label` IS its text. A control named by a *separate* label
487 + /// has neither: it carries a `labelled_by` relation naming the node that says
488 + /// it, which is what `Response::labelled_by` sets and what
489 + /// `makeover_immediate::field` uses so a box is announced by its question
490 + /// rather than by its own contents. Following the relation is not a
491 + /// convenience here -- a reader that stopped at `label()` would report every
492 + /// properly-labelled field as nameless, which is the opposite of the truth.
493 + fn named(
494 + node: &egui::accesskit::Node,
495 + by_id: &std::collections::HashMap<egui::accesskit::NodeId, egui::accesskit::Node>,
496 + ) -> String {
497 + if let Some(label) = node.label() {
498 + return label.to_owned();
499 + }
500 + if let Some(said) = node
501 + .labelled_by()
502 + .iter()
503 + .find_map(|id| by_id.get(id))
504 + .and_then(|by| by.label().or_else(|| by.value()))
505 + {
506 + return said.to_owned();
507 + }
508 + node.value().unwrap_or_default().to_owned()
509 + }
510 +
464 511 /// A rendered label with the renderer's own decoration taken back off.
465 512 ///
466 513 /// A sorted column heading is drawn as its name plus a caret, because a glyph
@@ -582,64 +629,25 @@
582 629 .dropping("Close window")
583 630 }
584 631
585 - /// A field the description names and `makeover-immediate` draws unnamed.
632 + /// The numeric readout egui draws inside a slider.
586 633 ///
587 - /// `makeover_immediate::field` draws `Field::label` as a `ui.label` above
588 - /// the control and never associates the two, so what reaches the widget tree
589 - /// is an unnamed box with some text near it. A screen reader announces a
590 - /// text field with no question, and this reader -- which drops a control
591 - /// with nothing to say -- sees no control at all.
634 + /// A `Slider` is two widgets: the track, which `makeover_immediate::field`
635 + /// names from the question, and a `DragValue` showing the number, which
636 + /// egui builds inside and never hands back. So a described `Range` reaches
637 + /// the tree as one named control and one unnamed number.
592 638 ///
593 - /// The same gap the shipped panels have, inherited. It is worse here only
594 - /// because the description **knows** the label: `Field::label` is right
595 - /// there, and `egui::Response::labelled_by` is the one call that would
596 - /// attach it. Filed against makeover-immediate; until it is fixed and
597 - /// published, every described field is an allowance at a call site.
639 + /// Not a defect to chase: the readout is a second view of a value the
640 + /// question already names, and the alternative is `show_value(false)`,
641 + /// which takes the number off the screen. Named here so it is a claim
642 + /// rather than a silence.
598 643 #[must_use]
599 - pub(super) fn unnamed_field(self, label: &str) -> Self {
600 - self.gaining(label)
601 - }
602 -
603 - /// Every field on a screen the renderer draws unnamed, both sides at once.
604 - ///
605 - /// [`unnamed_field`](Self::unnamed_field)'s bulk form, for the screens where
606 - /// the gap is most of the diff. `asked` is what the description calls each
607 - /// question; `shown` is what the unnamed control is announced as instead,
608 - /// which is its value where it has one and nothing at all where it does not
609 - /// -- a slider drawn with `show_value(false)` says neither.
610 - ///
611 - /// Two lists rather than pairs, because they do not pair one to one: a
612 - /// field whose control shows nothing contributes to `asked` and not to
613 - /// `shown`. One call is still one claim, which is the point.
614 - #[must_use]
615 - pub(super) fn unnamed_fields(mut self, asked: &[&str], shown: &[&str]) -> Self {
616 - for label in asked {
617 - self = self.gaining(label);
618 - }
644 + pub(super) fn slider_readouts(mut self, shown: &[&str]) -> Self {
619 645 for value in shown {
620 646 self = self.dropping(value);
621 647 }
622 648 self
623 649 }
624 650
625 - /// A chip the description makes pressable and the renderer draws mute.
626 - ///
627 - /// `makeover_immediate::widget::token` allocated its rect and painted its
628 - /// own text, registering no `WidgetInfo`, so a chip reached the tree as
629 - /// nothing: pressable by a mouse and invisible to everything else. Same
630 - /// class as the row and the field before it.
631 - ///
632 - /// Fixed in makeover-immediate 0.34.0 and not published, so it is an
633 - /// allowance here. `drums` runs the other way: the shipped tag chip is an
634 - /// ordinary button and the described one is a token.
635 - #[must_use]
636 - pub(super) fn mute_chips(mut self, labels: &[&str]) -> Self {
637 - for label in labels {
638 - self = self.gaining(label);
639 - }
640 - self
641 - }
642 -
643 651 /// Assert the two sides offer the same thing, panicking with a diff if not.
644 652 pub(super) fn assert(&self, described: &Offering, shipped: &Offering) {
645 653 let mut want: BTreeMap<Offer, isize> = BTreeMap::new();
@@ -890,11 +898,7 @@
890 898 }),
891 899 ];
892 900
893 - // What each modal asks, and what the renderer does not attach to the box.
894 - // See `Parity::unnamed_field`.
895 - let asked = ["Vault name", "New name", "Folder name", "New name"];
896 -
897 - for ((title, address, show), asked) in modals.into_iter().zip(asked) {
901 + for (title, address, show) in modals {
898 902 let (mut state, _dir) = fixture();
899 903 // A folder to rename, which the sample-only fixture does not have.
900 904 let vault = state.current_vfs_id().unwrap();
@@ -906,22 +910,6 @@
906 910 state.refresh_contents();
907 911 show(&mut state);
908 912
909 - // What a prefilled unnamed box is announced as: its own contents. The
910 - // other half of `unnamed_field` -- with no label attached, a rename
911 - // modal's field reports the name it was seeded with.
912 - let prefilled = state
913 - .vfs_modal
914 - .vfs_rename_target
915 - .as_ref()
916 - .map(|(_, name)| name.clone())
917 - .or_else(|| {
918 - state
919 - .vfs_modal
920 - .dir_rename_target
921 - .as_ref()
922 - .map(|(_, name)| name.clone())
923 - });
924 -
925 913 let address = address.replace("{id}", &real_id(&state, address).to_string());
926 914 let described = described(&super::panel::described_screen(&state, &address));
927 915 let drawn = shipped(|ui| {
@@ -929,11 +917,9 @@
929 917 });
930 918
931 919 described.addresses_resolve();
932 - let mut parity = Parity::strict().in_a_window(title).unnamed_field(asked);
933 - if let Some(prefilled) = &prefilled {
934 - parity = parity.dropping(prefilled);
935 - }
936 - parity.assert(&described, &drawn);
920 + Parity::strict()
921 + .in_a_window(title)
922 + .assert(&described, &drawn);
937 923 }
938 924 }
939 925
@@ -1021,11 +1007,6 @@
1021 1007 described.addresses_resolve();
1022 1008 Parity::strict()
1023 1009 .in_a_window("Sample Forge")
1024 - // The device picker, unnamed by the renderer and therefore announced by
1025 - // its own placeholder. See `Parity::unnamed_field`; both lines come out
1026 - // when makeover-immediate 0.34.0 is published.
1027 - .unnamed_field("Conform for device")
1028 - .dropping("Select device...")
1029 1010 .assert(&described, &drawn);
1030 1011 }
1031 1012
@@ -1048,27 +1029,7 @@
1048 1029 described.addresses_resolve();
1049 1030 Parity::strict()
1050 1031 .in_a_window("Sample Editor")
1051 - // The unnamed-field gap, on the screen that has the most of it. What
1052 - // the renderer announces is each control's own value, so the right-hand
1053 - // list is this fixture's numbers rather than anything either side says.
1054 - // Both lists come out when makeover-immediate 0.34.0 is published.
1055 - .unnamed_fields(
1056 - &[
1057 - "Start",
1058 - "End",
1059 - "Gain",
1060 - "Target",
1061 - "Duration",
1062 - "Length",
1063 - "Insert at",
1064 - "Remove from",
1065 - "to",
1066 - "Curve",
1067 - ],
1068 - &[
1069 - "0", "0", "0", "100", "-1.0", "0.0", "0.000", "1.000", "100", "Linear",
1070 - ],
1071 - )
1032 + .slider_readouts(&["-1.0", "0.0", "0.000", "1.000", "100"])
1072 1033 .assert(&described, &drawn);
1073 1034 }
1074 1035
@@ -1105,33 +1066,5 @@
1105 1066 described.addresses_resolve();
1106 1067 // A left pane rather than a window, which is what the shipped panel was, so
1107 1068 // there is no frame to discount.
1108 - Parity::strict()
1109 - // Twenty-four key chips, two spellings of each of twelve notes. See
1110 - // `Parity::mute_chips`; they come out when makeover-immediate 0.34.0 is
1111 - // published, and the tag chip goes with them.
1112 - .mute_chips(&[
1113 - "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B", "C", "C#", "D", "D#",
1114 - "E", "F", "F#", "G", "G#", "A", "A#", "B",
1115 - ])
1116 - .dropping("drums")
1117 - // The six intervals, unnamed and therefore announced as their ends.
1118 - .unnamed_fields(
1119 - &[
1120 - "BPM Range",
1121 - "Duration",
1122 - "Loudness",
1123 - "Brightness",
1124 - "Tonal / Noisy",
1125 - "Attack",
1126 - ],
1127 - &[
1128 - "90", "300", "1.0 s", "600.0 s", "-12 dB", "0 dB", "500 Hz", "20000 Hz", "0.20",
1129 - "1.00", "1.000 s", "1.000 s",
1130 - ],
1131 - )
1132 - // Two text boxes the renderer leaves unnamed and empty, so they are
1133 - // announced as nothing at all rather than as their contents.
1134 - .unnamed_field("Collection name")
1135 - .unnamed_field("Require a tag")
1136 - .assert(&described, &drawn);
1069 + Parity::strict().assert(&described, &drawn);
1137 1070 }