From a89dcc1a5df17e55001a5aab2d3c75f681d86ba2 Mon Sep 17 00:00:00 2001 From: Ahmed Gomaa Date: Sat, 7 Nov 2026 20:40:05 +0200 Subject: [PATCH 1/2] Preferences: inputs expose their labels to accessibility tools The Preferences labeled() helper painted the visible label and then drew the input beside it without associating them. Call label_inputs_by so screen readers and the test harness can find each field by its label. --- crates/ui-egui/src/prefs_dialog.rs | 12 +++++++----- crates/ui-egui/src/prefs_dialog/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 8822dc69e..2ad3d630f 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -667,11 +667,13 @@ fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { - ui.allocate_ui_with_layout(egui::vec2(210.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.set_min_width(210.0); - ui.add(egui::Label::new(egui::RichText::new(format!("{}{}", tl!(label), tl!(":"))).color(t.text)).truncate()); - }); - add(ui); + let label = ui + .allocate_ui_with_layout(egui::vec2(210.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(210.0); + ui.add(egui::Label::new(egui::RichText::new(format!("{}{}", tl!(label), tl!(":"))).color(t.text)).truncate()) + }) + .inner; + ui.label_inputs_by(label.id, add); }); } diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 99bc68c6b..6df98a651 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -955,6 +955,27 @@ fn ok_refuses_a_typed_folder_that_choose_would_refuse() { let _ = std::fs::remove_dir_all(&dir); } +/// Preferences form rows associate each input with its visible label for accessibility tools. +#[test] +fn preference_inputs_expose_their_visible_labels() { + use egui_kittest::kittest::Queryable; + let mut app = app(); + open(&mut app, Some("General")); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 700.0)).with_accessibility_check(false).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Keyboard Increment:"); +} + /// Under automation roots, OK refuses a typed folder outside them before it looks at the folder. #[test] fn ok_refuses_a_typed_folder_outside_the_automation_roots_before_looking_at_it() { From 23c5f6ff7c61b1fc6afaa57329246717b8c29f70 Mon Sep 17 00:00:00 2001 From: Ahmed Gomaa Date: Sat, 7 Nov 2026 22:00:20 +0200 Subject: [PATCH 2/2] Preferences: colour wells and sliders take their row's label `label_inputs_by` skips controls that already have a name, so colour wells stayed "Color" and sliders kept their internal text. Name them from the visible row label, on both halves of a slider across focus changes, and keep action button names. --- crates/ui-egui/src/prefs_dialog.rs | 31 ++++++----- crates/ui-egui/src/prefs_dialog/tests.rs | 68 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 2ad3d630f..b64d54e67 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -511,10 +511,13 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, } PrefKind::Bool => bool_row(ui, d, sp.key, sp.label), PrefKind::Num { min, max, unit } => { - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, label_id| { let mut x = v.as_f64().unwrap_or(min); let new = if sp.key == "uiScaling" { - widgets::slider(ui, egui::Slider::new(&mut x, min..=max).step_by(0.05).text(tl!("Smaller ↔ Larger"))).changed().then_some(x) + // Without internal text, both slider controls inherit the row's name across focus changes. + let response = widgets::slider(ui, egui::Slider::new(&mut x, min..=max).step_by(0.05)).labelled_by(label_id); + ui.add(egui::Label::new(tl!("Smaller ↔ Larger")).wrap_mode(egui::TextWrapMode::Extend)); + response.changed().then_some(x) } else { widgets::range_field(ui, sp.key, x, min..=max, &format!(" {unit}"), 3, 110.0) }; @@ -527,17 +530,19 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, // In the dialog's own Units choice, so a change there shows right away. let unit = vectorcraft_doc::Unit::named(&d.str(measure.pref_key())).unwrap_or_default(); let x = d.f64(sp.key, min); - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, _| { if let Some(x) = widgets::num_field(ui, sp.key, Some(x), unit, 110.0) { d.fields.insert(sp.key.into(), json!(x.clamp(min, max))); } }); } PrefKind::Int { min, max } => { - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, label_id| { let mut x = v.as_i64().unwrap_or(min); let new = if sp.key == "anchorSize" { - widgets::slider(ui, egui::Slider::new(&mut x, min..=max).show_value(false).text(tl!("Size"))).changed().then_some(x) + let response = widgets::slider(ui, egui::Slider::new(&mut x, min..=max).show_value(false)).labelled_by(label_id); + ui.add(egui::Label::new(tl!("Size")).wrap_mode(egui::TextWrapMode::Extend)); + response.changed().then_some(x) } else { widgets::range_field(ui, sp.key, x as f64, min as f64..=max as f64, "", 0, 110.0).map(|x| x as i64) }; @@ -569,11 +574,11 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, } } PrefKind::Color => { - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, label_id| { let hex = v.as_str().unwrap_or("#000000"); let c = vectorcraft_color::Color::from_hex(hex).map(|c| c.to_rgba8(1.0)).unwrap_or([0, 0, 0, 255]); let mut rgb = [c[0], c[1], c[2]]; - if ui.color_edit_button_srgb(&mut rgb).changed() { + if ui.color_edit_button_srgb(&mut rgb).labelled_by(label_id).changed() { d.fields.insert(sp.key.into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); } ui.label(egui::RichText::new(hex).color(t.text_dim).size(11.0)); @@ -581,7 +586,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, } PrefKind::Text if sp.key == "interfaceLanguage" => { // `auto` plus every registered language, by its own name. - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, _| { let cur = v.as_str().unwrap_or("auto"); let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::for_picker().into_iter().map(|l| l.code())).collect(); let labels: Vec<&str> = @@ -598,7 +603,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, // Plug-ins Folder: typed, or picked with Choose… where the host has a folder picker // (not on the web). PrefKind::Text if folder_check(sp.key).is_some() => { - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, _| { let mut s = v.as_str().unwrap_or("").to_string(); if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(200.0)).changed() { d.fields.insert(sp.key.into(), json!(s)); @@ -609,7 +614,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, }); } PrefKind::Text => { - labeled(ui, sp.label, |ui| { + labeled(ui, sp.label, |ui, _| { let mut s = v.as_str().unwrap_or("").to_string(); if ui.add(egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(260.0)).changed() { d.fields.insert(sp.key.into(), json!(s)); @@ -644,7 +649,7 @@ fn picas_in_use(d: &Dialog) -> bool { } fn choice_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, opts: &[(&str, &str)], v: &Value) { - labeled(ui, label, |ui| { + labeled(ui, label, |ui, _| { let cur = v.as_str().unwrap_or(""); let cur_label = opts.iter().find(|o| o.0 == cur).map(|o| o.1).unwrap_or(cur); let labels: Vec<&str> = opts.iter().map(|o| o.1).collect(); @@ -664,7 +669,7 @@ fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { } } -fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { +fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui, egui::Id)) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { let label = ui @@ -673,7 +678,7 @@ fn labeled(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { ui.add(egui::Label::new(egui::RichText::new(format!("{}{}", tl!(label), tl!(":"))).color(t.text)).truncate()) }) .inner; - ui.label_inputs_by(label.id, add); + ui.label_inputs_by(label.id, |ui| add(ui, label.id)); }); } diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 6df98a651..11fd0f418 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -976,6 +976,74 @@ fn preference_inputs_expose_their_visible_labels() { let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Keyboard Increment:"); } +/// Render the real category without the dialog's scroll viewport clipping later rows. +fn preference_category_harness(category: &'static str) -> egui_kittest::Harness<'static, (VectorcraftApp, Dialog)> { + let mut app = app(); + app.services.pick_folder = Some(Box::new(|| None)); + open(&mut app, Some(category)); + let dialog = app.ui.dialog.take().unwrap(); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 1400.0)).with_accessibility_check(false).build_ui_state( + move |ui, (app, dialog): &mut (VectorcraftApp, Dialog)| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + category_fields(ui, app, dialog, category); + }, + (app, dialog), + ); + h.run_steps(3); + h +} + +#[test] +fn preference_colour_well_exposes_its_row_label_and_value() { + use egui_kittest::kittest::{NodeT, Queryable}; + let h = preference_category_harness("Guides & Grid"); + let expected = format!("{}ff", h.state().1.str("patternTileEdgeColor").to_lowercase()); + let colour = h.get_by_role_and_label(egui::accesskit::Role::ColorWell, "Tile Edge Color:"); + assert_eq!(colour.accesskit_node().value().as_deref(), Some(expected.as_str())); +} + +#[test] +fn preference_scale_labels_both_controls_across_focus_changes() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + let mut h = preference_category_harness("User Interface"); + let expected = h.state().1.f64("uiScaling", 1.0); + for focus in [None, Some(Role::SpinButton), Some(Role::Slider)] { + if let Some(role) = focus { + h.get_by_role_and_label(role, "Scale:").focus(); + h.run_steps(3); + assert!(h.get_by_role_and_label(role, "Scale:").accesskit_node().is_focused()); + } + for role in [Role::Slider, Role::SpinButton] { + let control = h.get_by_role_and_label(role, "Scale:"); + assert_eq!(control.accesskit_node().numeric_value(), Some(expected)); + } + } +} + +#[test] +fn preference_anchor_slider_exposes_its_row_label_and_value() { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + let h = preference_category_harness("Selection & Anchor Display"); + let slider = h.get_by_role_and_label(Role::Slider, "Size:"); + assert_eq!(slider.accesskit_node().numeric_value(), Some(h.state().1.f64("anchorSize", 3.0))); +} + +#[test] +fn preference_folder_label_preserves_the_choose_action_name() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + let h = preference_category_harness("File Handling"); + let _ = h.get_by_role_and_label(Role::TextInput, "Folder:"); + let _ = h.get_by_role_and_label(Role::Button, "Choose…"); + assert!(h.query_by_role_and_label(Role::Button, "Folder:").is_none()); +} + /// Under automation roots, OK refuses a typed folder outside them before it looks at the folder. #[test] fn ok_refuses_a_typed_folder_outside_the_automation_roots_before_looking_at_it() {