From 30e54dedcfbd787969fb57c0a76cc011572a321a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 11:05:06 -0700 Subject: [PATCH] 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. --- crates/ui-egui/src/dialogs/color_picker.rs | 18 ++- crates/ui-egui/src/dialogs/effect.rs | 7 +- crates/ui-egui/src/dialogs/flatten.rs | 15 +- crates/ui-egui/src/dialogs/form.rs | 35 ++++- crates/ui-egui/src/dialogs/freehand.rs | 5 +- .../ui-egui/src/dialogs/perspective_grid.rs | 3 +- crates/ui-egui/src/dialogs/revolve.rs | 11 +- crates/ui-egui/src/dialogs/swatch_options.rs | 3 +- crates/ui-egui/src/dialogs/tests.rs | 91 ++++++++++++ crates/ui-egui/src/dialogs/transform_each.rs | 6 +- crates/ui-egui/src/panels/character.rs | 13 +- crates/ui-egui/src/panels/color.rs | 38 ++++- crates/ui-egui/src/panels/navigator.rs | 15 +- crates/ui-egui/src/widgets.rs | 138 ++++++++++++++++-- docs/target-app-parity.md | 1 + 15 files changed, 361 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index ea1131bb8..75d337c55 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -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)); } } diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index e4401cc22..16a48d23e 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -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) { diff --git a/crates/ui-egui/src/dialogs/flatten.rs b/crates/ui-egui/src/dialogs/flatten.rs index 8b305d750..c387555b2 100644 --- a/crates/ui-egui/src/dialogs/flatten.rs +++ b/crates/ui-egui/src/dialogs/flatten.rs @@ -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")); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 842125359..2a8350641 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -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) -> Option { + 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, 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, + shown: Option, + 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)); } diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index f87cef445..28592c85d 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -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) { diff --git a/crates/ui-egui/src/dialogs/perspective_grid.rs b/crates/ui-egui/src/dialogs/perspective_grid.rs index 5734ca7fc..caf0603dd 100644 --- a/crates/ui-egui/src/dialogs/perspective_grid.rs +++ b/crates/ui-egui/src/dialogs/perspective_grid.rs @@ -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) { diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index 7a7fed9ae..a27b39403 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -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, diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index 51bc62f5e..4b74171be 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -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)); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 2dc11289f..2d12c7fd2 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -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 { diff --git a/crates/ui-egui/src/dialogs/transform_each.rs b/crates/ui-egui/src/dialogs/transform_each.rs index 1d3f070cb..88ce141ce 100644 --- a/crates/ui-egui/src/dialogs/transform_each.rs +++ b/crates/ui-egui/src/dialogs/transform_each.rs @@ -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); }); } diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index fdad86744..c7252993f 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -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| { @@ -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(); diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 61349e934..f6dc7bc0a 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -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(); diff --git a/crates/ui-egui/src/panels/navigator.rs b/crates/ui-egui/src/panels/navigator.rs index fa56be3cf..a0d068c80 100644 --- a/crates/ui-egui/src/panels/navigator.rs +++ b/crates/ui-egui/src/panels/navigator.rs @@ -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); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c9e566c22..7348dadfa 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -620,11 +620,7 @@ pub fn mixed_field( width: f32, ) -> Option { 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, 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, + /// The field's text, such as "50%"; `None` gives the number alone. + pub text: Option, +} + +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 } + } + + /// `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}")) } + } + + /// `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) } + } +} + +/// 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) { + 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, + value: f32, + width: f32, + track: &dyn Fn(f32) -> Color32, ) -> (Option, 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, 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, + value: f32, + size: Vec2, + track: &dyn Fn(f32) -> Color32, ) -> (Option, 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, Option, Option, Option) { + 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)] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8d77929d2..f0525689e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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).