Accessibility: color and channel sliders report their values to screen readers

The color and channel sliders gave screen readers a name but no value.
Each caller maps its own range onto the thumb's position, so a screen
reader announced the Color panel's G slider, Transform Each's Scale or
the Navigator's Zoom without the setting the field beside it shows.

color_slider_valued and channel_slider_valued take an optional
SliderValue: the number and text of the field beside the slider and
the slider's range. The accessibility node gets the number, the text
and the range, which extends to a number past the slider's end.
color_slider_named and channel_slider_named keep their arguments and
report no value. Every caller outside the tests uses the new variants:
the Color panel, Swatch Options, the Color Picker, the Navigator, the
Character panel's variable font axes and the dialog sliders.
form::slider_rail_named reports the number it edits, and the harmony
wheel's Brightness slider, which has no field, reports its brightness
in percent.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-18 12:34:35 +07:00
committed by bflatastic
parent e461b8b818
commit 30e54dedcf
15 changed files with 361 additions and 38 deletions
+17 -1
View File
@@ -138,6 +138,20 @@ pub fn slider_color(ch: Channel, z: f32, x: f32, y: f32) -> Color {
if ch == Channel::Hue { Color::from_hsb(z * 360.0, 1.0, 1.0) } else { field_pick(ch, z, x, y).color }
}
/// The number and suffix that [`value_fields`] shows in the field of `ch` for `p`, and the field's
/// largest number: H in degrees (to 360), S and B in percent (to 100), and R, G and B from 0 to 255.
fn channel_value(ch: Channel, p: &Picked) -> (f32, &'static str, f32) {
let [r, g, b] = p.color.to_rgb();
match ch {
Channel::Hue => (p.hsb[0], "°", 360.0),
Channel::Saturation => (p.hsb[1] * 100.0, "%", 100.0),
Channel::Brightness => (p.hsb[2] * 100.0, "%", 100.0),
Channel::Red => (r * 255.0, "", 255.0),
Channel::Green => (g * 255.0, "", 255.0),
Channel::Blue => (b * 255.0, "", 255.0),
}
}
/// The colour the dialog holds: an edited `hex` wins over `color` (agents set either one).
fn picked(d: &Dialog) -> Picked {
let hex = d.str("hex");
@@ -211,7 +225,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
ui.add_space(6.0);
let track = |z: f32| c32(&Picked::of(slider_color(channel, z, x, y)).snapped(web).color);
if let (Some(z), _) = widgets::channel_slider_named(ui, "cp-slider", tl!(channel.label()), z, vec2(22.0, FIELD), &track) {
let (v, suffix, max) = channel_value(channel, &p);
let shown = widgets::SliderValue::field(v as f64, 0.0..=max as f64, suffix, 0);
if let (Some(z), _) = widgets::channel_slider_valued(ui, "cp-slider", tl!(channel.label()), Some(shown), z, vec2(22.0, FIELD), &track) {
pick = Some(field_pick(channel, z, x, y));
}
}
+6 -1
View File
@@ -458,7 +458,12 @@ fn zig_zag_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit
widgets::subheader(ui, tl!("Options"));
// Size is a share of the object's size (Relative) or a distance (Absolute).
widgets::label_row(ui, tl!("Size:"), LABEL_W, |ui| {
form::slider_rail_named(ui, d, "size", tl!("Size:"), 0.0..=100.0, &track);
let shown = if relative {
Some(widgets::SliderValue::field(d.f64("size", 10.0).clamp(0.0, 100.0), 0.0..=100.0, "%", 0))
} else {
form::length_value(d, "size", unit, 0.0..=100.0)
};
form::slider_rail_valued(ui, d, "size", tl!("Size:"), 0.0..=100.0, shown, &track);
if !relative {
form::length(ui, d, "size", unit, FIELD_W);
} else if let Some(n) = widgets::range_field(ui, ("zz", "size"), d.f64("size", 10.0), 0.0..=100.0, "%", 0, FIELD_W) {
+10 -5
View File
@@ -191,11 +191,16 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions
ui.spacing_mut().item_spacing.x = 6.0;
dim(ui, tl!("Rasters"));
let rail = (ui.available_width() - FIELD - 56.0).clamp(80.0, 200.0);
if let (Some(x), _) =
widgets::color_slider_named(ui, (id, "balance-slider"), tl!("Raster/Vector Balance:"), (o.balance / 100.0) as f32, rail, &|_| {
t.input_border
})
{
let shown = widgets::SliderValue::field(o.balance, 0.0..=100.0, "", 0);
if let (Some(x), _) = widgets::color_slider_valued(
ui,
(id, "balance-slider"),
tl!("Raster/Vector Balance:"),
Some(shown),
(o.balance / 100.0) as f32,
rail,
&|_| t.input_border,
) {
o.balance = (x as f64 * 100.0).round();
}
dim(ui, tl!("Vectors"));
+31 -4
View File
@@ -45,6 +45,15 @@ pub(super) fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, w
true
}
/// What [`length`] shows for `d.fields[key]`, as the screen-reader value of a rail over `range`
/// (in points) beside it: the number in `unit` and the field's text. `None` while the field is
/// blank.
pub(super) fn length_value(d: &Dialog, key: &str, unit: Unit, range: std::ops::RangeInclusive<f64>) -> Option<crate::widgets::SliderValue> {
let pt = d.fields.contains_key(key).then(|| d.f64(key, 0.0))?;
let number = unit.number(pt).parse().unwrap_or(unit.from_pt(pt));
Some(crate::widgets::SliderValue::text(number, unit.from_pt(*range.start())..=unit.from_pt(*range.end()), unit.format(pt)))
}
/// The fields of the form dialogs ([`grid`]) that are distances, by dialog kind.
fn lengths(kind: &str) -> &'static [&'static str] {
match kind {
@@ -491,7 +500,8 @@ pub(super) fn slider_w(
}
/// The rail and value field of [`slider`] without its label (for a row that draws its own); screen
/// readers call the rail `name` (the row's label, already translated).
/// readers call the rail `name` (the row's label, already translated), with the number and text
/// the field shows as its value.
pub(super) fn slider_field_named(
ui: &mut egui::Ui,
d: &mut Dialog,
@@ -503,7 +513,8 @@ pub(super) fn slider_field_named(
) {
let (min, max) = (*range.start(), *range.end());
let v = d.f64(key, 0.0).clamp(min, max);
slider_rail_named(ui, d, key, name, range, track);
let shown = crate::widgets::SliderValue::field(v, range.clone(), suffix, 0);
slider_rail_valued(ui, d, key, name, range, Some(shown), track);
if let Some(n) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0).map(|x| x.round().clamp(min, max)).filter(|n| *n != v) {
d.fields.insert(key.into(), json!(n));
}
@@ -511,7 +522,7 @@ pub(super) fn slider_field_named(
/// The rail of [`slider_field_named`] alone: dragging it sets the number `d.fields[key]` to a
/// whole number in `range` (a value past the range shows at its end until the rail moves); screen
/// readers call it `name` (already translated).
/// readers call it `name` (already translated), with the number `d.fields[key]` as its value.
pub(super) fn slider_rail_named(
ui: &mut egui::Ui,
d: &mut Dialog,
@@ -519,10 +530,26 @@ pub(super) fn slider_rail_named(
name: &str,
range: std::ops::RangeInclusive<f64>,
track: &dyn Fn(f32) -> egui::Color32,
) {
let shown = crate::widgets::SliderValue::number(d.f64(key, 0.0), range.clone());
slider_rail_valued(ui, d, key, name, range, Some(shown), track);
}
/// [`slider_rail_named`] that screen readers get `shown` from: the number and text of the field
/// the row draws beside it (no value for `None`, as for a blank field).
pub(super) fn slider_rail_valued(
ui: &mut egui::Ui,
d: &mut Dialog,
key: &str,
name: &str,
range: std::ops::RangeInclusive<f64>,
shown: Option<crate::widgets::SliderValue>,
track: &dyn Fn(f32) -> egui::Color32,
) {
let (min, max) = (*range.start(), *range.end());
let v = d.f64(key, 0.0).clamp(min, max);
let (dragged, _) = crate::widgets::color_slider_named(ui, ("dlg-slider", key), name, ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track);
let (dragged, _) =
crate::widgets::color_slider_valued(ui, ("dlg-slider", key), name, shown, ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track);
if let Some(n) = dragged.map(|x| (min + x as f64 * (max - min)).round()).filter(|n| *n != v) {
d.fields.insert(key.into(), json!(n));
}
+4 -1
View File
@@ -71,7 +71,10 @@ fn fidelity(ui: &mut egui::Ui, d: &mut Dialog) {
let mut new = None;
widgets::label_row(ui, tl!("Fidelity:"), label_w, |ui| {
let at = ((v.clamp(min, max) - min) / (max - min)) as f32;
if let (Some(x), _) = widgets::color_slider_named(ui, "freehand-fidelity", tl!("Fidelity:"), at, form::SLIDER_WIDTH, &|_| t.input_border) {
let shown = widgets::SliderValue::field(v, min..=max, "pt", 1);
if let (Some(x), _) =
widgets::color_slider_valued(ui, "freehand-fidelity", tl!("Fidelity:"), Some(shown), at, form::SLIDER_WIDTH, &|_| t.input_border)
{
new = Some(((min + x as f64 * (max - min)) * 10.0).round() / 10.0);
}
if let Some(x) = widgets::plain_field(ui, "freehand-fidelity-field", v, "pt", 1, 64.0) {
@@ -332,7 +332,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
widgets::label_row(ui, tl!("Opacity:"), LABEL, |ui| {
let v = d.f64("opacity", 50.0).clamp(0.0, 100.0);
let track = |x: f32| egui::Color32::from_gray((60.0 + x * 160.0) as u8);
let mut new = widgets::color_slider_named(ui, "persp-opacity", tl!("Opacity:"), (v / 100.0) as f32, form::SLIDER_WIDTH, &track)
let shown = widgets::SliderValue::field(v, 0.0..=100.0, "%", 0);
let mut new = widgets::color_slider_valued(ui, "persp-opacity", tl!("Opacity:"), Some(shown), (v / 100.0) as f32, form::SLIDER_WIDTH, &track)
.0
.map(|x| (f64::from(x) * 100.0).round());
if let Some(x) = widgets::plain_field(ui, "persp-opacity-field", v, "%", 0, 52.0) {
+9 -2
View File
@@ -472,6 +472,7 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64,
let typed = unit.map(|u| widgets::typed_unit(ui.ctx(), u));
let mut value = from(d.f64(key, default)).clamp(from(range.0), from(range.1));
let suffix = unit.map_or_else(|| suffix.to_string(), |u| format!(" {}", u.suffix()));
let speed = if decimals == 0 { 1.0 } else { 0.25 };
let mut next = None;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
@@ -485,7 +486,7 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64,
[VALUE_W, 24.0],
egui::DragValue::new(&mut value)
.range(from(range.0)..=from(range.1))
.speed(if decimals == 0 { 1.0 } else { 0.25 })
.speed(speed)
.max_decimals(decimals)
.suffix(&suffix)
.update_while_editing(false)
@@ -513,10 +514,16 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64,
};
let rail_min = from(range.0);
let rail = rail_width(ui) + VALUE_W;
let slider = widgets::color_slider_named(
// The text the DragValue shows: at least the places its speed gives (one for 0.25 at 1x to
// 3x scaling, as egui computes them), at most `decimals`.
let places = ((f64::from(ui.input(|i| i.aim_radius())) / speed).log10().ceil().clamp(0.0, 15.0) as usize).min(decimals);
let number = ui.style().number_formatter.format(value, places..=decimals);
let shown = widgets::SliderValue::text(number.parse().unwrap_or(value), rail_min..=rail_max, format!("{number}{suffix}"));
let slider = widgets::color_slider_valued(
ui,
("revolve-slider", key),
name,
Some(shown),
((value - rail_min) / (rail_max - rail_min)).clamp(0.0, 1.0) as f32,
rail,
&|_| t.input_border,
+2 -1
View File
@@ -214,7 +214,8 @@ fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new(*lbl).size(12.5).color(t.text)));
let v = comps.get(i).copied().unwrap_or(0.0);
let track = |x: f32| crate::panels::c32(&track_color(mode, &comps, i, x));
if let (Some(nv), _) = widgets::color_slider_named(ui, ("swatch-slider", i), lbl, mode.unit(i, v), 200.0, &track) {
let shown = widgets::SliderValue::field(v as f64, mode.min(i) as f64..=mode.max(i) as f64, mode.suffix(i), 0);
if let (Some(nv), _) = widgets::color_slider_valued(ui, ("swatch-slider", i), lbl, Some(shown), mode.unit(i, v), 200.0, &track) {
let mut c2 = comps.clone();
c2[i] = mode.value_at(i, nv);
new = Some((from_components(mode, &c2), c2));
+91
View File
@@ -1139,6 +1139,97 @@ fn zig_zag_has_sliders_and_radio_buttons() {
assert!(!lines.contains(&"Ridges:"), "{lines:?}");
}
/// Screen readers get the number and text of the field beside each dialog slider, in the field's
/// unit, with the rail's range (extended to a number past the rail's end): Transform Each's Scale
/// and Move, Zig Zag's Size and Ridges, a form slider (Vector Halftone's Frequency), the Pencil's
/// Fidelity, the Color Picker's channel slider, the Perspective Grid's Opacity, Revolve's angle and
/// Offset, Extrude & Bevel's Rotation X and Y, Swatch Options' components and the flattener's Raster/Vector Balance.
#[test]
fn dialog_sliders_report_the_numbers_their_fields_show() {
use crate::widgets::accessibility_tests::{slider_value, tree};
use egui::accesskit::Role::Slider;
fn set(app: &mut VectorcraftApp, k: &str, v: serde_json::Value) {
app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(k.into(), v);
}
let v = |n: f64, (lo, hi): (f64, f64), text: &str| (Some(n), Some(lo), Some(hi), Some(text.to_string()));
let mut app = app();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
crate::menus::invoke(&mut app, "object.transformEach", json!({}));
set(&mut app, "scaleH", json!(250));
set(&mut app, "moveH", json!(-36));
let shown = tree(|ui| show(&mut app, ui.ctx()));
let horizontal: Vec<_> = shown
.iter()
.filter(|n| n.role() == Slider && n.label() == Some("Horizontal:"))
.map(|n| (n.numeric_value(), n.min_numeric_value(), n.max_numeric_value(), n.value().map(str::to_string)))
.collect();
assert_eq!(
horizontal,
[(Some(250.0), Some(0.0), Some(250.0), Some("250%".into())), (Some(-36.0), Some(-1000.0), Some(1000.0), Some("-36 pt".into()))]
);
app.ui.dialog = None;
app.run("effect.dialog", json!({"effect": "distort.zigZag"})).unwrap();
set(&mut app, "relative", json!(true));
set(&mut app, "size", json!(30));
set(&mut app, "ridges", json!(6));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Size:"), v(30.0, (0.0, 100.0), "30%"));
assert_eq!(slider_value(&shown, "Ridges per segment:"), (Some(6.0), Some(0.0), Some(100.0), None));
set(&mut app, "relative", json!(false));
set(&mut app, "size", json!(12));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Size:"), v(12.0, (0.0, 100.0), "12 pt"), "Absolute: a distance");
open(&mut app, halftone::KIND, halftone::fields());
set(&mut app, "frequency", json!(60));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Frequency"), v(60.0, (1.0, 150.0), "60 lpi"));
app.ui.dialog = None;
app.run("tool.options", json!({"tool": "pencil"})).unwrap();
set(&mut app, "fidelity", json!(2.5));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Fidelity:"), v(2.5, (0.5, 20.0), "2.5pt"));
app.ui.dialog = None;
app.run("paint.setFill", json!({"color": "#ff8000"})).unwrap();
app.run("ui.colorPicker", json!({})).unwrap();
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "H"), v(30.0, (0.0, 360.0), "30°"));
set(&mut app, "channel", json!("green"));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "G"), v(128.0, (0.0, 255.0), "128"));
app.ui.dialog = None;
app.run("ui.perspectiveGridDialog", json!({})).unwrap();
set(&mut app, "opacity", json!(40));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Opacity:"), v(40.0, (0.0, 100.0), "40%"));
app.ui.dialog = None;
app.run("path.create", json!({"d": "M150 40 L180 60 L180 140 L150 160"})).unwrap();
app.run("effect.dialog", json!({"effect": vectorcraft_effects::REVOLVE, "item": null})).unwrap();
set(&mut app, "angle", json!(270));
set(&mut app, "offset", json!(600));
let shown = tree(|ui| show(&mut app, ui.ctx()));
// The 3D dialogs' fields are DragValues, which keep a trailing zero at one place.
assert_eq!(slider_value(&shown, "Revolve angle"), v(270.0, (0.0, 360.0), "270.0°"));
assert_eq!(slider_value(&shown, "Offset"), v(600.0, (0.0, 600.0), "600.0 pt"), "past the rail's 500 pt");
app.ui.dialog = None;
app.run("effect.dialog", json!({"effect": vectorcraft_effects::EXTRUDE, "item": null})).unwrap();
set(&mut app, "__tab", json!("rotation"));
set(&mut app, "rotationX", json!(30));
set(&mut app, "rotationY", json!(12.25));
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "Rotation X"), v(30.0, (-180.0, 180.0), "30.0°"), "at most 2 places");
assert_eq!(slider_value(&shown, "Rotation Y"), v(12.25, (-180.0, 180.0), "12.25°"));
app.ui.dialog = None;
app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 80, "y": 60, "k": 0}, "spot": true})).unwrap();
app.run("ui.swatchOptions", json!({"name": "Ink"})).unwrap();
let shown = tree(|ui| show(&mut app, ui.ctx()));
assert_eq!(slider_value(&shown, "M"), v(80.0, (0.0, 100.0), "80%"));
let mut o = vectorcraft_engine::cmd::FlattenOptions { balance: 30.0, ..Default::default() };
let shown = tree(|ui| {
flatten::options_editor(ui, "flatten-test", &mut o, true);
});
assert_eq!(slider_value(&shown, "Raster/Vector Balance:"), v(30.0, (0.0, 100.0), "30"));
}
/// Frames of the dialog layer on one context (so a drag and the keyboard focus last from frame to
/// frame), with the screen-reader tree turned on.
struct Frames {
+4 -2
View File
@@ -56,7 +56,8 @@ pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc
widgets::subheader(ui, tl!("Scale"));
for (key, label) in axes("scaleH", "scaleV") {
widgets::label_row(ui, label, LABEL_W, |ui| {
form::slider_rail_named(ui, d, key, label, 0.0..=200.0, &track);
let shown = widgets::SliderValue::field(d.f64(key, 100.0).clamp(-MAX_SCALE, MAX_SCALE), 0.0..=200.0, "%", 2);
form::slider_rail_valued(ui, d, key, label, 0.0..=200.0, Some(shown), &track);
if let Some(n) = widgets::range_field(ui, ("te", key), d.f64(key, 100.0), -MAX_SCALE..=MAX_SCALE, "%", 2, FIELD_W) {
d.fields.insert(key.into(), json!(n));
}
@@ -65,7 +66,8 @@ pub(super) fn sections(ui: &mut egui::Ui, d: &mut Dialog, units: vectorcraft_doc
widgets::subheader(ui, tl!("Move"));
for (key, label) in axes("moveH", "moveV") {
widgets::label_row(ui, label, LABEL_W, |ui| {
form::slider_rail_named(ui, d, key, label, -1000.0..=1000.0, &track);
let shown = form::length_value(d, key, units, -1000.0..=1000.0);
form::slider_rail_valued(ui, d, key, label, -1000.0..=1000.0, shown, &track);
form::length(ui, d, key, units, FIELD_W);
});
}
+12 -1
View File
@@ -575,7 +575,10 @@ pub(crate) fn axis_sliders(app: &mut VectorcraftApp, ui: &mut Ui, face: &vectorc
});
});
let at = value.map_or(0.0, |v| if max > min { ((v - min) / (max - min)) as f32 } else { 0.0 });
let (picked, phase) = widgets::color_slider_named(ui, ("ch-axis", axis.tag), &axis.name, at, ui.available_width(), &|_| t.button_border);
// A mixed value leaves the field blank and the slider without a value.
let shown = value.map(|v| widgets::SliderValue::field(v, min..=max, "", 1));
let (picked, phase) =
widgets::color_slider_valued(ui, ("ch-axis", axis.tag), &axis.name, shown, at, ui.available_width(), &|_| t.button_border);
if let Some(p) = picked {
let v = ((min + f64::from(p) * (max - min)) * 10.0).round() / 10.0;
axes_cmd(app, json!({ &tag: v }), phase);
@@ -956,6 +959,11 @@ mod tests {
for name in ["Weight", "Width"] {
assert!(tree.iter().any(|n| n.role() == egui::accesskit::Role::Slider && n.label() == Some(name)), "{name}");
}
// ...with the number its field shows, on the axis's range.
use crate::widgets::accessibility_tests::slider_value;
let wght = face.axes().into_iter().find(|a| &a.tag == b"wght").unwrap();
let range = (Some(f64::from(wght.min)), Some(f64::from(wght.max)));
assert_eq!(slider_value(&tree, "Weight"), (Some(400.0), range.0, range.1, Some("400".into())));
// Drag the Weight slider from its start to past its end.
let ctx = egui::Context::default();
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
@@ -989,6 +997,9 @@ mod tests {
app.run("select.set", json!({"ids": [id, bold]})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face));
assert!(!text.contains("400") && !text.contains("700") && text.contains("100"), "{text}");
let tree = crate::widgets::accessibility_tests::tree(|ui| axis_sliders(&mut app, ui, &face));
assert_eq!(slider_value(&tree, "Weight"), (None, None, None, None), "a blank field: no value");
assert_eq!(slider_value(&tree, "Width").0, Some(100.0));
// Reset Panel leaves the settings, as it leaves the font and style.
app.run("select.set", json!({"ids": [id]})).unwrap();
app.run("text.setFormat", json!({"fontAxes": {"wght": 650}})).unwrap();
+34 -4
View File
@@ -498,8 +498,10 @@ fn tint_rows(ui: &mut Ui, tint: &Tint, slider_w: f32, field_w: f32) -> Option<(f
ui.horizontal(|ui| {
ui.add_sized(vec2(12.0, 22.0), egui::Label::new(egui::RichText::new("T").size(12.5).color(t.text)));
let track = |x: f32| to32(&tint.base.tinted(x));
let (nv, phase) = widgets::color_slider_named(ui, ("color-slider", "tint"), "T", tint.tint, slider_w, &track);
let field = widgets::plain_field(ui, ("color-field", "tint"), (tint.tint * 100.0).round() as f64, "%", 0, field_w);
let percent = (tint.tint * 100.0).round() as f64;
let shown = widgets::SliderValue::field(percent, 0.0..=100.0, "%", 0);
let (nv, phase) = widgets::color_slider_valued(ui, ("color-slider", "tint"), "T", Some(shown), tint.tint, slider_w, &track);
let field = widgets::plain_field(ui, ("color-field", "tint"), percent, "%", 0, field_w);
match (nv, field) {
(Some(v), _) => Some(((v * 100.0).round() / 100.0, phase)),
(None, Some(v)) => Some(((v as f32 / 100.0).clamp(0.0, 1.0), Live::Released)),
@@ -612,10 +614,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let v = comps.get(i).copied().unwrap_or(0.0);
let track = |x: f32| to32(&track_color(mode, &comps, i, x));
let grey = |_x: f32| t.input;
let shown = widgets::SliderValue::field(v as f64, mode.min(i) as f64..=mode.max(i) as f64, mode.suffix(i), 0);
let (nv, phase) = if enabled {
widgets::color_slider_named(ui, ("color-slider", i), lbl, mode.unit(i, v), slider_w, &track)
widgets::color_slider_valued(ui, ("color-slider", i), lbl, Some(shown), mode.unit(i, v), slider_w, &track)
} else {
widgets::color_slider_named(ui, ("color-slider", i), lbl, 0.0, slider_w, &grey)
widgets::color_slider_valued(ui, ("color-slider", i), lbl, Some(shown), 0.0, slider_w, &grey)
};
if let Some(nv) = nv
&& enabled
@@ -1087,6 +1090,33 @@ mod tests {
assert!(matches!(crate::panels::current_paints(&app).0, Paint::Solid { swatch: None, .. }));
}
/// Screen readers get each slider's number and the text of the field beside it, with the
/// component's range: R, G and B from 0 to 255, H in degrees, S, B, the inks and T in percent.
#[test]
fn screen_readers_get_the_numbers_the_fields_show() {
use crate::widgets::accessibility_tests::{slider_value, tree};
let mut app = app();
let v = |n: f64, max: f64, text: &str| (Some(n), Some(0.0), Some(max), Some(text.to_string()));
app.run("paint.setFill", json!({"color": "#ff8000"})).unwrap();
let rgb = tree(|ui| show(&mut app, ui));
assert_eq!(slider_value(&rgb, "R"), v(255.0, 255.0, "255"));
assert_eq!(slider_value(&rgb, "G"), v(128.0, 255.0, "128"));
assert_eq!(slider_value(&rgb, "B"), v(0.0, 255.0, "0"));
app.run("ui.colorPanel", json!({"mode": "hsb"})).unwrap();
let hsb = tree(|ui| show(&mut app, ui));
assert_eq!(slider_value(&hsb, "H"), v(30.0, 360.0, "30°"));
assert_eq!(slider_value(&hsb, "S"), v(100.0, 100.0, "100%"));
assert_eq!(slider_value(&hsb, "B"), v(100.0, 100.0, "100%"));
app.run("paint.setFill", json!({"color": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap();
let cmyk = tree(|ui| show(&mut app, ui));
assert_eq!(slider_value(&cmyk, "C"), v(10.0, 100.0, "10%"));
assert_eq!(slider_value(&cmyk, "K"), v(40.0, 100.0, "40%"));
app.run("swatch.new", json!({"name": "Ink", "color": {"c": 0, "m": 1, "y": 0, "k": 0}, "spot": true})).unwrap();
app.run("paint.setFill", json!({"swatch": "Ink", "tint": 35})).unwrap();
let tint = tree(|ui| show(&mut app, ui));
assert_eq!(slider_value(&tint, "T"), v(35.0, 100.0, "35%"));
}
#[test]
fn hide_options_leaves_the_proxy_and_spectrum() {
let mut app = app();
+14 -1
View File
@@ -263,7 +263,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
let sw = (ui.available_width() - 30.0).max(40.0);
let track = |_: f32| t.input_border;
if let (Some(v), phase) = widgets::color_slider_named(ui, "nav-slider", tl!("Zoom"), zoom_to_slider(zoom) as f32, sw, &track)
let shown = widgets::SliderValue::field(zoom, 3.13..=64000.0, "%", 2);
if let (Some(v), phase) = widgets::color_slider_valued(ui, "nav-slider", tl!("Zoom"), Some(shown), zoom_to_slider(zoom) as f32, sw, &track)
&& phase != widgets::Live::Idle
{
z = Some(slider_to_zoom(v as f64));
@@ -289,6 +290,18 @@ pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) {
mod tests {
use super::*;
/// Screen readers get the Zoom slider's percent as the zoom field beside it shows it, on the
/// slider's 3.13% to 64000% range.
#[test]
fn screen_readers_get_the_zoom_the_field_shows() {
use crate::widgets::accessibility_tests::{slider_value, tree};
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("view.setZoom", json!({"zoom": 250})).unwrap();
let shown = tree(|ui| show(&mut app, ui));
assert_eq!(slider_value(&shown, "Zoom"), (Some(250.0), Some(3.13), Some(64000.0), Some("250%".to_string())));
}
#[test]
fn thumb_mapping_roundtrip() {
let region = DRect::new(0.0, 0.0, 200.0, 100.0);
+124 -14
View File
@@ -620,11 +620,7 @@ pub fn mixed_field(
width: f32,
) -> Option<f64> {
let id = ui.id().with(id);
let show = |value: f64| {
let s = format!("{:.*}", decimals, value);
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
format!("{s}{suffix}")
};
let show = |value: f64| field_text(value, suffix, decimals);
// The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`.
let read = |buf: &str| {
let suffix = suffix.trim();
@@ -643,6 +639,14 @@ pub fn mixed_field(
if commit && buf != shown { read(&buf) } else { stepped.or(scrubbed).filter(|&v| Some(v) != value) }
}
/// The text [`plain_field`] shows for `value`: at most `decimals` places, without trailing zeros,
/// then `suffix`.
fn field_text(value: f64, suffix: &str, decimals: usize) -> String {
let s = format!("{:.*}", decimals, value);
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s };
format!("{s}{suffix}")
}
/// Draw a paint preview (swatch chip) into `rect`.
pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) {
let p = ui.painter();
@@ -1593,6 +1597,69 @@ pub fn color_slider_named(
value: f32,
width: f32,
track: &dyn Fn(f32) -> Color32,
) -> (Option<f32>, Live) {
color_slider_valued(ui, id, name, None, value, width, track)
}
/// The value a slider gives screen readers: the number the field beside it shows, the range of the
/// slider's rail and the field's text.
#[derive(Clone, Debug, PartialEq)]
pub struct SliderValue {
pub value: f64,
pub range: std::ops::RangeInclusive<f64>,
/// The field's text, such as "50%"; `None` gives the number alone.
pub text: Option<String>,
}
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 }
}
/// `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}")) }
}
/// `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) }
}
}
/// Give `resp` a slider's screen-reader node named `name` (nothing when `name` is empty), with
/// `shown`'s number, text and range: the rail's range, extended to the number when the field holds
/// a number past the rail's end.
fn slider_info(ui: &Ui, resp: &Response, name: &str, shown: Option<SliderValue>) {
if name.is_empty() {
return;
}
let enabled = ui.is_enabled();
let Some(s) = shown else {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, enabled, name));
return;
};
resp.widget_info(|| egui::WidgetInfo { current_text_value: s.text.clone(), ..egui::WidgetInfo::slider(enabled, s.value, name) });
ui.ctx().accesskit_node_builder(resp.id, |node| {
node.set_min_numeric_value(s.range.start().min(s.value));
node.set_max_numeric_value(s.range.end().max(s.value));
});
}
/// [`color_slider_named`] that screen readers also get `shown` from (see [`SliderValue`]). With
/// `None`, as for a field left blank for a mixed value, it reports no value.
pub fn color_slider_valued(
ui: &mut Ui,
id: impl std::hash::Hash + std::fmt::Debug,
name: &str,
shown: Option<SliderValue>,
value: f32,
width: f32,
track: &dyn Fn(f32) -> Color32,
) -> (Option<f32>, Live) {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover());
@@ -1606,9 +1673,7 @@ pub fn color_slider_named(
let thumb = vec![tip, pos2(x + 5.5, tip.y + 6.0), pos2(x + 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 10.0), pos2(x - 5.5, tip.y + 6.0)];
let fill = if resp.dragged() || resp.hovered() { Color32::WHITE } else { t.icon };
ui.painter().add(egui::Shape::convex_polygon(thumb, fill, Stroke::new(1.0, t.border)));
if !name.is_empty() {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), name));
}
slider_info(ui, &resp, name, shown);
match pointer_phase(&resp) {
Some((p, phase)) => (Some(((p.x - bar.left()) / bar.width()).clamp(0.0, 1.0)), phase),
None => (None, Live::Idle),
@@ -1710,6 +1775,20 @@ pub fn channel_slider_named(
value: f32,
size: Vec2,
track: &dyn Fn(f32) -> Color32,
) -> (Option<f32>, Live) {
channel_slider_valued(ui, id, name, None, value, size, track)
}
/// [`channel_slider_named`] that screen readers also get `shown` from, as for
/// [`color_slider_valued`].
pub fn channel_slider_valued(
ui: &mut Ui,
id: impl std::hash::Hash + std::fmt::Debug,
name: &str,
shown: Option<SliderValue>,
value: f32,
size: Vec2,
track: &dyn Fn(f32) -> Color32,
) -> (Option<f32>, Live) {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
@@ -1723,9 +1802,7 @@ pub fn channel_slider_named(
let tip = pos2(x + d * 5.0, y);
ui.painter().add(egui::Shape::convex_polygon(vec![tip, pos2(x, y + 4.0 * d), pos2(x, y - 4.0 * d)], fill, Stroke::NONE));
}
if !name.is_empty() {
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), name));
}
slider_info(ui, &resp, name, shown);
match pointer_phase(&resp) {
Some((p, phase)) => (Some(((bar.bottom() - p.y) / bar.height()).clamp(0.0, 1.0)), phase),
None => (None, Live::Idle),
@@ -2102,9 +2179,12 @@ pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: O
ui.horizontal(|ui| {
ui.add_space(((ui.available_width() - size - 76.0) / 2.0).max(0.0));
ui.add_sized([70.0, 22.0], egui::Label::new(egui::RichText::new(tl!("Brightness")).color(t.text_dim)));
// Screen readers get the brightness in percent, as the Color panel's HSB B field shows it.
let shown = SliderValue::field((f64::from(v) * 100.0).round(), 0.0..=100.0, "%", 0);
if let (Some(b), Some([h, s, _])) = (base, base_hsb)
&& let (Some(nv), _) =
color_slider_named(ui, (id, "brightness"), tl!("Brightness"), v, size, &|x| crate::panels::c32(&Color::from_hsb(h, s, x)))
&& let (Some(nv), _) = color_slider_valued(ui, (id, "brightness"), tl!("Brightness"), Some(shown), v, size, &|x| {
crate::panels::c32(&Color::from_hsb(h, s, x))
})
{
out.moved = Some((b, [None, None, Some(nv)]));
}
@@ -2413,9 +2493,39 @@ pub(crate) mod accessibility_tests {
assert_eq!(slider("Angle"), (Role::Slider, Some(45.0)));
assert_eq!(slider("H"), (Role::Slider, None));
assert_eq!(slider("Color wheel"), (Role::Slider, None));
assert_eq!(slider("Brightness"), (Role::Slider, None));
assert!(tree.iter().any(|n| n.role() == Role::ComboBox && n.value() == Some("Regular")), "the searchable combo box shows its text");
}
/// The number, range and text of the slider of `tree` that screen readers name `label`.
pub(crate) fn slider_value(tree: &[Node], label: &str) -> (Option<f64>, Option<f64>, Option<f64>, Option<String>) {
let n = tree.iter().find(|n| n.role() == Role::Slider && n.label() == Some(label)).unwrap_or_else(|| panic!("no slider is named {label:?}"));
(n.numeric_value(), n.min_numeric_value(), n.max_numeric_value(), n.value().map(str::to_string))
}
/// The `_valued` sliders give screen readers the number and text of their field, with the rail's
/// range extended to a number past it; the harmony wheel's Brightness gives its percent.
#[test]
fn sliders_report_the_value_their_field_shows() {
let dark_red = Color::from_hsb(0.0, 1.0, 0.5);
let gray = |_: f32| Color32::GRAY;
let tree = tree(|ui| {
color_slider_valued(ui, "opacity", "Opacity:", Some(SliderValue::field(49.6, 0.0..=100.0, "%", 0)), 0.5, 120.0, &gray);
color_slider_valued(ui, "scale", "Horizontal:", Some(SliderValue::field(312.5, 0.0..=200.0, "%", 2)), 1.0, 120.0, &gray);
color_slider_valued(ui, "move", "Vertical:", Some(SliderValue::text(-1.5, -14.0..=14.0, "-1.5 in".into())), 0.4, 120.0, &gray);
color_slider_valued(ui, "count", "Ridges per segment:", Some(SliderValue::number(4.0, 0.0..=100.0)), 0.04, 120.0, &gray);
color_slider_valued(ui, "axis", "Weight", None, 0.5, 120.0, &gray);
channel_slider_valued(ui, "channel", "H", Some(SliderValue::field(210.0, 0.0..=360.0, "°", 0)), 210.0 / 360.0, vec2(22.0, 80.0), &gray);
harmony_wheel(ui, "wheel", 120.0, &[dark_red], Some(0));
});
let s = |label| slider_value(&tree, label);
assert_eq!(s("Opacity:"), (Some(50.0), Some(0.0), Some(100.0), Some("50%".into())));
assert_eq!(s("Horizontal:"), (Some(312.5), Some(0.0), Some(312.5), Some("312.5%".into())));
assert_eq!(s("Vertical:"), (Some(-1.5), Some(-14.0), Some(14.0), Some("-1.5 in".into())));
assert_eq!(s("Ridges per segment:"), (Some(4.0), Some(0.0), Some(100.0), None));
assert_eq!(s("Weight"), (None, None, None, None), "a mixed value");
assert_eq!(s("H"), (Some(210.0), Some(0.0), Some(360.0), Some("210°".into())));
assert_eq!(s("Brightness"), (Some(50.0), Some(0.0), Some(100.0), Some("50%".into())));
}
}
#[cfg(test)]
+1
View File
@@ -457,6 +457,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- With anchor points selected with Direct Selection, the contextual task bar offers Remove Anchor Points, Connect (`path.join`), Cut Path, Corner and Smooth (`path.convertAnchors` with `to` set to `corner` or `smooth`), the commands of the Control bar's anchor buttons. As in Illustrator ("Remove selected anchor points, Connect selected end points, Cut path at selected anchor points, Convert selected anchor points to corner, and Convert selected anchor points to smooth": [Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Not done: Simplify, which the bar offers only while no anchors are selected, and the bar's Smooth item that adjusts a path's curvature, a command the app doesn't have (Object › Path › Smooth).
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
- Screen readers get a name for the Tools panel's tool buttons, flyout rows, floating strips and tear-off bars, and for the icon buttons, text buttons, toggles, checkboxes, radio buttons, tabs, chips, sliders, angle dials and combo boxes that panels and dialogs draw with `crates/ui-egui/src/widgets.rs`: the text, label or tool tip each one shows, or a name of its own where it shows none (the angle dial, the spinner, the preset arrow, the color wheel and the Reference Point's squares). The tool buttons, toggles, checkboxes, radio buttons, tabs and the Reference Point's nine squares also report whether they are on, an icon button that is lit, such as the chosen Align To button, reports that it is pressed, and the angle dial reports its angle. The status bar's First, Previous, Next and Last Artboard buttons have tool tips.
- Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent.
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into the default tabbed group it doesn't belong to, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md).