From 041736753219759a73e797c02e423ed694832d24 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 04:39:49 -0800 Subject: [PATCH 1/5] Accessibility: screen readers can set a dialog slider's number Dialog sliders and the Blob Brush tip preview offered screen readers only the increment and decrement actions. They now also offer the set-value action: the last request in a frame sets the number within the slider's range, rounded to the places its field shows, and a value that is not a finite number leaves it as it is. --- crates/ui-egui/src/dialogs/tests.rs | 42 +++++++++++++++++++++++++++++ crates/ui-egui/src/widgets/color.rs | 39 ++++++++++++++++++++++----- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index ef3bf8848..9b6365ac8 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2015,6 +2015,48 @@ fn the_blob_brush_tip_preview_is_a_named_slider_with_arrow_keys() { assert_eq!(angle(&app), -44.0, "the drag gives the preview the focus"); } +/// Screen readers can set the Blob Brush tip preview's Angle to a number: the slider offers the +/// set-value action, a fraction rounds to the whole degrees the field shows, a number past -180° to +/// 180° sets the nearer end, the last request in a frame wins, and a value that is not a finite +/// number leaves the Angle as it is. +#[test] +fn screen_readers_set_the_blob_brush_angle() { + use egui::accesskit::{Action, ActionData, ActionRequest, TreeId}; + let mut app = app(); + app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); + let angle = |app: &VectorcraftApp| app.ui.dialog.as_ref().expect("Blob Brush Tool Options is open").f64("angle", f64::NAN); + let mut f = Frames::new(); + f.run(&mut app, vec![]); + let out = f.run(&mut app, vec![]); + let (node, ..) = slider_node(&out, "Angle"); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let advertised = nodes.iter().find(|(id, _)| *id == node).is_some_and(|(_, n)| n.supports_action(Action::SetValue)); + assert!(advertised, "the Angle slider offers the set-value action"); + let set = |value: f64| { + egui::Event::AccessKitActionRequest(ActionRequest { + action: Action::SetValue, + target_node: node, + target_tree: TreeId::ROOT, + data: Some(ActionData::NumericValue(value)), + }) + }; + f.run(&mut app, vec![set(30.0)]); + assert_eq!(angle(&app), 30.0); + f.run(&mut app, vec![set(12.4)]); + assert_eq!(angle(&app), 12.0, "the field shows whole degrees"); + let shown = crate::widgets::SliderValue::field(12.5, -180.0..=180.0, "°", 0).value; + f.run(&mut app, vec![set(12.5)]); + assert_eq!(angle(&app), shown, "as the field shows 12.5"); + f.run(&mut app, vec![set(500.0)]); + assert_eq!(angle(&app), 180.0, "past the end"); + f.run(&mut app, vec![set(-12.0), set(-90.0)]); + assert_eq!(angle(&app), -90.0, "the last request"); + f.run(&mut app, vec![set(f64::NAN)]); + assert_eq!(angle(&app), -90.0, "not a number"); + f.run(&mut app, vec![set(30.0), set(f64::INFINITY)]); + assert_eq!(angle(&app), -90.0, "the last request is not a finite number"); +} + #[test] fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() { use vectorcraft_doc::NodeId; diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index bd40e174d..747de05cb 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -42,6 +42,9 @@ pub struct SliderValue { /// steps from `value` (±1 for an arrow, ±10 with Shift or Page Up/Down). `None` steps to whole /// numbers. pub step: Option f64>, + /// The places the field shows, which a screen reader's set-value action rounds to; `None` + /// keeps the number it sets. + pub decimals: Option, } /// Two values are the same when they show the same number, range and text, whatever their keys' steps @@ -55,20 +58,20 @@ impl PartialEq for SliderValue { impl SliderValue { /// `value` on a rail over `range`, without a text. pub fn number(value: f64, range: std::ops::RangeInclusive) -> Self { - Self { value, range, text: None, step: None } + Self { value, range, text: None, step: None, decimals: None } } /// `value` on a rail over `range` as [`plain_field`] shows it with `suffix` and at most /// `decimals` places: the number it shows (`value` rounded) and its text. pub fn field(value: f64, range: std::ops::RangeInclusive, suffix: &str, decimals: usize) -> Self { let number = field_text(value, "", decimals); - Self { value: number.parse().unwrap_or(value), range, text: Some(format!("{number}{suffix}")), step: None } + Self { value: number.parse().unwrap_or(value), range, text: Some(format!("{number}{suffix}")), step: None, decimals: Some(decimals) } } /// `value` on a rail over `range`, with `text`, for a field that writes its own text (a length /// in the document's unit, an egui `DragValue`). pub fn text(value: f64, range: std::ops::RangeInclusive, text: String) -> Self { - Self { value, range, text: Some(text), step: None } + Self { value, range, text: Some(text), step: None, decimals: None } } /// This value, stepped by the keys with `step` instead of to whole numbers (see @@ -119,8 +122,10 @@ pub fn color_slider_valued( /// While it has the focus, → and ↑ raise the number `shown` holds to the next whole number and ← /// and ↓ lower it to the next whole number, Shift with an arrow, Page Up and Page Down add or take /// ten, and Home and End set the start and the end of `shown`'s range. Screen readers' increment -/// and decrement actions step the number as → and ← do. A number past the range steps from the -/// range's nearer end, and no step goes past either end. With `shown` `None` (a blank field), or +/// and decrement actions step the number as → and ← do, and their set-value action sets it within +/// the range, rounded to [`SliderValue::decimals`] (a value that is not a finite number leaves it +/// as it is). A number past the range steps from the range's nearer end, and no step goes past +/// either end. With `shown` `None` (a blank field), or /// in a disabled `ui`, the keys and actions leave the rail as it is. Returns the new value from 0 /// to 1 that a click or a drag gives, its phase, and the number in `shown`'s units that a key or /// an action gives. @@ -167,6 +172,7 @@ fn slider_keys(ui: &Ui, resp: &Response, shown: Option) -> Option) -> Option Option { - use egui::accesskit::Action; + use egui::accesskit::{Action, ActionData}; use egui::{Event, Key}; let (lo, hi) = (*s.range.start(), *s.range.end()); if !lo.is_finite() || !hi.is_finite() || hi <= lo { @@ -235,11 +241,30 @@ fn rail_step(ui: &Ui, id: egui::Id, focused: bool, s: &SliderValue) -> Option Some(value), + _ => None, + }) + .last() + .filter(|value| value.is_finite()); + if let Some(value) = set { + // The number the field would show, as `SliderValue::field` reads it. + let shown = s.decimals.map_or(value, |d| field_text(value, "", d).parse().unwrap_or(value)); + v = shown.clamp(lo, hi); + end = true; + exact = true; + } }); - (v != from || (end && v != s.value)).then_some(round_to(v, STEP_DECIMALS).clamp(lo, hi)) + (v != from || (end && v != s.value)).then_some(if exact { v } else { round_to(v, STEP_DECIMALS).clamp(lo, hi) }) } /// The rail of [`color_slider_valued`] with the id `id`: its response, the new value from 0 to 1 From d1e69239a75fc740abfdb574396a8065939d6091 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 05:00:46 -0800 Subject: [PATCH 2/5] Accessibility: a set value can reach a field's number past the rail The range screen readers get for a dialog slider takes in a field's number past the rail's end, and the set-value action now accepts that range too. --- crates/ui-egui/src/dialogs/tests.rs | 27 +++++++++++++++++++++++++-- crates/ui-egui/src/widgets/color.rs | 10 ++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 9b6365ac8..37a027e73 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2018,9 +2018,10 @@ fn the_blob_brush_tip_preview_is_a_named_slider_with_arrow_keys() { /// Screen readers can set the Blob Brush tip preview's Angle to a number: the slider offers the /// set-value action, a fraction rounds to the whole degrees the field shows, a number past -180° to /// 180° sets the nearer end, the last request in a frame wins, and a value that is not a finite -/// number leaves the Angle as it is. +/// number leaves the Angle as it is. On a field past its rail's end, a set value can go as far as +/// the field's number. #[test] -fn screen_readers_set_the_blob_brush_angle() { +fn screen_readers_set_a_dialog_slider_number() { use egui::accesskit::{Action, ActionData, ActionRequest, TreeId}; let mut app = app(); app.run("tool.options", json!({"tool": "blobBrush"})).unwrap(); @@ -2055,6 +2056,28 @@ fn screen_readers_set_the_blob_brush_angle() { assert_eq!(angle(&app), -90.0, "not a number"); f.run(&mut app, vec![set(30.0), set(f64::INFINITY)]); assert_eq!(angle(&app), -90.0, "the last request is not a finite number"); + // A field past its rail's end extends the range screen readers get, and a set value can go + // that far: Transform Each's Horizontal Scale at 300% on a rail that ends at 200%. + app.ui.dialog = None; + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + app.ui.dialog.as_mut().expect("Transform Each is open").fields.insert("scaleH".into(), json!(300)); + let scale = |app: &VectorcraftApp| app.ui.dialog.as_ref().expect("Transform Each is open").f64("scaleH", f64::NAN); + f.run(&mut app, vec![]); + let out = f.run(&mut app, vec![]); + let (node, ..) = slider_node(&out, "Horizontal:"); + let set = |value: f64| { + egui::Event::AccessKitActionRequest(ActionRequest { + action: Action::SetValue, + target_node: node, + target_tree: TreeId::ROOT, + data: Some(ActionData::NumericValue(value)), + }) + }; + f.run(&mut app, vec![set(250.0)]); + assert_eq!(scale(&app), 250.0, "within the range screen readers get"); + f.run(&mut app, vec![set(400.0)]); + assert_eq!(scale(&app), 250.0, "past 250%, the range's end now"); } #[test] diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index 747de05cb..b982d1707 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -123,8 +123,9 @@ pub fn color_slider_valued( /// and ↓ lower it to the next whole number, Shift with an arrow, Page Up and Page Down add or take /// ten, and Home and End set the start and the end of `shown`'s range. Screen readers' increment /// and decrement actions step the number as → and ← do, and their set-value action sets it within -/// the range, rounded to [`SliderValue::decimals`] (a value that is not a finite number leaves it -/// as it is). A number past the range steps from the range's nearer end, and no step goes past +/// the range they get (the range, extended to a number past its end), rounded to +/// [`SliderValue::decimals`] (a value that is not a finite number leaves it as it is). A number +/// past the range steps from the range's nearer end, and no step goes past /// either end. With `shown` `None` (a blank field), or /// in a disabled `ui`, the keys and actions leave the rail as it is. Returns the new value from 0 /// to 1 that a click or a drag gives, its phase, and the number in `shown`'s units that a key or @@ -247,7 +248,7 @@ fn rail_step(ui: &Ui, id: egui::Id, focused: bool, s: &SliderValue) -> Option Option Date: Wed, 4 Nov 2026 05:35:28 -0800 Subject: [PATCH 3/5] Accessibility: a set value in another unit keeps a number past the rail A dialog slider whose field shows another unit, such as Transform Each's Move in millimeters, converted the number back to points and held it to the rail's range, so a set value past the rail that the field holds came back as the rail's end. --- crates/ui-egui/src/dialogs/form.rs | 5 +++-- crates/ui-egui/src/dialogs/tests.rs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index faad934c2..333c9de1b 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -813,8 +813,9 @@ pub(super) fn slider_rail_valued( let units = shown.as_ref().map(|s| s.range.clone()); let (dragged, _, keyed) = crate::widgets::color_slider_stepped(ui, ("dlg-slider", key), name, shown, ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track); - let keyed = - keyed.zip(units).map(|(k, u)| if u == range { k } else { (min + (k - u.start()) / (u.end() - u.start()) * (max - min)).clamp(min, max) }); + // Not clamped to the rail: a screen reader's set value can reach a number past it that the + // field holds. + let keyed = keyed.zip(units).map(|(k, u)| if u == range { k } else { min + (k - u.start()) / (u.end() - u.start()) * (max - min) }); if let Some(n) = dragged.map(|x| (min + x as f64 * (max - min)).round()).or(keyed).filter(|n| *n != held) { d.fields.insert(key.into(), json!(n)); } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 37a027e73..b52328dd1 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2078,6 +2078,23 @@ fn screen_readers_set_a_dialog_slider_number() { assert_eq!(scale(&app), 250.0, "within the range screen readers get"); f.run(&mut app, vec![set(400.0)]); assert_eq!(scale(&app), 250.0, "past 250%, the range's end now"); + // The same for a Move slider in millimeters, whose rail ends at 1000 pt: with the field at + // 1200 pt, 400 mm sets about 1134 pt. + app.ui.dialog = None; + app.run("document.setUnits", json!({"units": "millimeters"})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + app.ui.dialog.as_mut().expect("Transform Each is open").fields.insert("moveH".into(), json!(1200)); + let mut f = Frames::new(); + f.run(&mut app, vec![]); + let out = f.run(&mut app, vec![]); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let mut horizontal = nodes.iter().filter(|(_, n)| n.role() == egui::accesskit::Role::Slider && n.label() == Some("Horizontal:")); + let (node, _) = horizontal.nth(1).expect("the Move slider named Horizontal:"); + let request = + ActionRequest { action: Action::SetValue, target_node: *node, target_tree: TreeId::ROOT, data: Some(ActionData::NumericValue(400.0)) }; + f.run(&mut app, vec![egui::Event::AccessKitActionRequest(request)]); + let moved = app.ui.dialog.as_ref().expect("Transform Each is open").f64("moveH", f64::NAN); + assert!((moved - 400.0 * 72.0 / 25.4).abs() < 1e-6, "{moved} pt"); } #[test] From c743662add0573283ea443f1c8682924ef4357fe Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 05:46:56 -0800 Subject: [PATCH 4/5] Accessibility: a set-value request without a number leaves a dialog slider as it is --- crates/ui-egui/src/dialogs/tests.rs | 3 +++ crates/ui-egui/src/widgets/color.rs | 12 ++++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index b52328dd1..782b3de29 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2056,6 +2056,9 @@ fn screen_readers_set_a_dialog_slider_number() { assert_eq!(angle(&app), -90.0, "not a number"); f.run(&mut app, vec![set(30.0), set(f64::INFINITY)]); assert_eq!(angle(&app), -90.0, "the last request is not a finite number"); + let empty = ActionRequest { action: Action::SetValue, target_node: node, target_tree: TreeId::ROOT, data: None }; + f.run(&mut app, vec![set(30.0), egui::Event::AccessKitActionRequest(empty)]); + assert_eq!(angle(&app), -90.0, "the last request has no number"); // A field past its rail's end extends the range screen readers get, and a set value can go // that far: Transform Each's Horizontal Scale at 300% on a rail that ends at 200%. app.ui.dialog = None; diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index b982d1707..cb44bfe04 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -249,14 +249,10 @@ fn rail_step(ui: &Ui, id: egui::Id, focused: bool, s: &SliderValue) -> Option Some(value), - _ => None, - }) - .last() - .filter(|value| value.is_finite()); + let set = i.accesskit_action_requests(id, Action::SetValue).last().and_then(|request| match request.data { + Some(ActionData::NumericValue(value)) if value.is_finite() => Some(value), + _ => None, + }); if let Some(value) = set { // The number the field would show, as `SliderValue::field` reads it. let shown = s.decimals.map_or(value, |d| field_text(value, "", d).parse().unwrap_or(value)); From 69ef2a4e50afc3a9d83e9325f1e0a251bbd03279 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 06:06:43 -0800 Subject: [PATCH 5/5] Accessibility: a whole-number dialog slider rounds a set value --- crates/ui-egui/src/dialogs/form.rs | 3 ++- crates/ui-egui/src/dialogs/tests.rs | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 333c9de1b..8bfcef3d3 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -790,7 +790,8 @@ pub(super) fn slider_rail_named( range: std::ops::RangeInclusive, track: &dyn Fn(f32) -> egui::Color32, ) { - let shown = crate::widgets::SliderValue::number(d.f64(key, 0.0), range.clone()); + // Whole numbers, as a drag sets them. + let shown = crate::widgets::SliderValue { decimals: Some(0), ..crate::widgets::SliderValue::number(d.f64(key, 0.0), range.clone()) }; slider_rail_valued(ui, d, key, name, range, Some(shown), track); } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 782b3de29..56e59c753 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2098,6 +2098,17 @@ fn screen_readers_set_a_dialog_slider_number() { f.run(&mut app, vec![egui::Event::AccessKitActionRequest(request)]); let moved = app.ui.dialog.as_ref().expect("Transform Each is open").f64("moveH", f64::NAN); assert!((moved - 400.0 * 72.0 / 25.4).abs() < 1e-6, "{moved} pt"); + // A slider without a field's places, Roughen's Detail, sets whole numbers as a drag does. + app.ui.dialog = None; + app.run("effect.dialog", json!({"effect": "distort.roughen"})).unwrap(); + let mut f = Frames::new(); + f.run(&mut app, vec![]); + let out = f.run(&mut app, vec![]); + let (node, ..) = slider_node(&out, "Detail:"); + let request = + ActionRequest { action: Action::SetValue, target_node: node, target_tree: TreeId::ROOT, data: Some(ActionData::NumericValue(12.4)) }; + f.run(&mut app, vec![egui::Event::AccessKitActionRequest(request)]); + assert_eq!(app.ui.dialog.as_ref().expect("Roughen is open").f64("detail", f64::NAN), 12.0); } #[test]