mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 08:20:13 +00:00
Merge pull request #5617 from dotCooCoo/fix/angle-rail-set-value
Accessibility: screen readers can set a dialog slider's number
This commit is contained in:
@@ -790,7 +790,8 @@ pub(super) fn slider_rail_named(
|
||||
range: std::ops::RangeInclusive<f64>,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -813,8 +814,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));
|
||||
}
|
||||
|
||||
@@ -2015,6 +2015,102 @@ 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. 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_a_dialog_slider_number() {
|
||||
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");
|
||||
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;
|
||||
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");
|
||||
// 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");
|
||||
// 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]
|
||||
fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() {
|
||||
use vectorcraft_doc::NodeId;
|
||||
|
||||
@@ -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<fn(f64, f64) -> 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<usize>,
|
||||
}
|
||||
|
||||
/// 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<f64>) -> 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<f64>, 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<f64>, 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,11 @@ 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 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
|
||||
/// an action gives.
|
||||
@@ -167,6 +173,7 @@ fn slider_keys(ui: &Ui, resp: &Response, shown: Option<SliderValue>) -> Option<f
|
||||
let stepped = shown.filter(|_| ui.is_enabled()).and_then(|s| {
|
||||
ui.ctx().accesskit_node_builder(id, |node| {
|
||||
node.set_numeric_value_step(1.0);
|
||||
node.add_action(egui::accesskit::Action::SetValue);
|
||||
node.add_action(egui::accesskit::Action::Increment);
|
||||
node.add_action(egui::accesskit::Action::Decrement);
|
||||
});
|
||||
@@ -185,7 +192,7 @@ fn slider_keys(ui: &Ui, resp: &Response, shown: Option<SliderValue>) -> Option<f
|
||||
/// The number of slider `id` (`s`) after this frame's keys while it is `focused`, and after screen
|
||||
/// readers' actions on it ([`color_slider_stepped`]); `None` when they leave it as it was.
|
||||
fn rail_step(ui: &Ui, id: egui::Id, focused: bool, s: &SliderValue) -> Option<f64> {
|
||||
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 +242,27 @@ fn rail_step(ui: &Ui, id: egui::Id, focused: bool, s: &SliderValue) -> Option<f6
|
||||
ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
|
||||
}
|
||||
}
|
||||
// A set-value request is rounded to the field's places when it has them and otherwise kept as
|
||||
// asked; only the keys' and the steps' numbers are rounded to `STEP_DECIMALS`.
|
||||
let mut exact = false;
|
||||
ui.input(|i| {
|
||||
let n = i.num_accesskit_action_requests(id, Action::Increment) as f64 - i.num_accesskit_action_requests(id, Action::Decrement) as f64;
|
||||
v = by(v, n).clamp(lo, hi);
|
||||
// The last set-value request this frame.
|
||||
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));
|
||||
// The range screen readers get (`slider_info`), which takes in a number past the rail.
|
||||
v = shown.clamp(lo.min(s.value), hi.max(s.value));
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user