From b8f707165b1d375f62b397e0bd0e84fba3544e1a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 08:29:16 -0700 Subject: [PATCH 1/2] Status bar: screen readers can name its controls and read the slide counter and status message The status bar's Fit, Zoom In, Zoom Out, Slide Show, Reading View, Slide Sorter and Normal buttons and the language name reported no widget info, the zoom percentage reported only its own text as a label, and the zoom slider had no name, so a screen reader announced the buttons and the language without a name. The slide counter and the status message were painted text with no accessibility node, so a screen reader could not read them. Each icon button now reports its tooltip as its name, and the four view buttons also report whether their view is the one shown. The zoom percentage is the "Zoom level" button with the percentage as its value, and the slider is "Zoom" with the percentage as its text value. The language name is the "Language" button with the language as its value. The slide counter and the status message are labels with the text they show. --- crates/ui-egui/src/status.rs | 90 ++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 2fc91aab..c9586a65 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -24,6 +24,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { // Left to right: slide counter, language, status message. Each starts at its usual column, or // after the one before it when that runs long ("Slide 100 of 120", "… (not checked)"). let sr = p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim); + shown_text(ui, sr, "status_slide", &left); // Where the controls on the right begin; nothing on the left paints or clicks past it. let right_x = rect.max.x - 560.0; // The selection's proofing language; a click opens Review › Language. @@ -33,14 +34,19 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { let lclip = Rect::from_min_max(pos2(lx - 3.0, rect.min.y), pos2(right_x.max(lx - 3.0), rect.max.y)); let lr = p.with_clip_rect(lclip).text(pos2(lx, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim); let hit = lr.expand(3.0).intersect(lclip); - if hit.is_positive() && ui.interact(hit, ui.id().with("status_lang"), Sense::click()).on_hover_text("Language").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("language")); + if hit.is_positive() { + let resp = ui.interact(hit, ui.id().with("status_lang"), Sense::click()).on_hover_text("Language"); + resp.widget_info(|| named_with_value("Language", &name)); + if resp.clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("language")); + } } // Transient status message, clipped before the controls on the right. if let Some(msg) = app.fresh_status() { let mx = (rect.min.x + 290.0).max(lr.max.x + 16.0); let clip = Rect::from_min_max(pos2(mx, rect.min.y), pos2(right_x.max(mx), rect.max.y)); - p.with_clip_rect(clip).text(pos2(mx, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent); + let mr = p.with_clip_rect(clip).text(pos2(mx, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent); + shown_text(ui, mr.intersect(clip), "status_message", &msg); ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); } let mut right = ui.new_child( @@ -54,6 +60,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { } let pct = (app.canvas_scale * 100.0).round(); let lbl = right.add(egui::Label::new(egui::RichText::new(format!("{pct}%")).size(12.0).color(t.text_dim)).sense(Sense::click())); + lbl.widget_info(|| named_with_value("Zoom level", &format!("{pct}%"))); if lbl.clicked() { let _ = app.run("view.zoomDialog", json!({})); } @@ -64,6 +71,8 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { let mut z = app.ui.zoom.unwrap_or(app.canvas_scale).clamp(0.1, 4.0); let mut lz = z.ln(); let s = right.add_sized(vec2(110.0, 18.0), egui::Slider::new(&mut lz, (0.1f32).ln()..=(4.0f32).ln()).show_value(false)); + let shown = format!("{}%", (z * 100.0).round()); + s.widget_info(|| egui::WidgetInfo { current_text_value: Some(shown.clone()), ..egui::WidgetInfo::slider(true, f64::from(lz), "Zoom") }); if s.changed() { z = lz.exp(); app.ui.zoom = Some(z); @@ -73,17 +82,17 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { } right.add_space(8.0); let view = app.ui.view; - if small_icon(&mut right, Icon::ViewShowSmall, "Slide Show", false).clicked() { + if view_icon(&mut right, Icon::ViewShowSmall, "Slide Show", false).clicked() { let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); app.start_show(i, false); } - if small_icon(&mut right, Icon::ViewReadingSmall, "Reading View", false).clicked() { + if view_icon(&mut right, Icon::ViewReadingSmall, "Reading View", false).clicked() { let _ = app.run("view.reading", json!({})); } - if small_icon(&mut right, Icon::ViewSorterSmall, "Slide Sorter", view == ViewMode::Sorter).clicked() { + if view_icon(&mut right, Icon::ViewSorterSmall, "Slide Sorter", view == ViewMode::Sorter).clicked() { app.ui.view = ViewMode::Sorter; } - if small_icon(&mut right, Icon::ViewNormalSmall, "Normal", view == ViewMode::Normal).clicked() { + if view_icon(&mut right, Icon::ViewNormalSmall, "Normal", view == ViewMode::Normal).clicked() { app.ui.view = ViewMode::Normal; } right.add_space(8.0); @@ -100,13 +109,39 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { } fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { + let resp = icon_button(ui, icon, on); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + resp.on_hover_text(tip) +} + +/// A view button: screen readers hear its name and whether its view is the one shown. +fn view_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { + let resp = icon_button(ui, icon, on); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip)); + resp.on_hover_text(tip) +} + +fn icon_button(ui: &mut Ui, icon: Icon, on: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(26.0, 20.0), Sense::click()); if on || resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(4), if on { t.selected } else { t.hover }); } icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), icon, t.text_dim, false); - resp.on_hover_text(tip) + resp +} + +/// A clickable status-bar text: screen readers hear `label` as its name and `text` as its value. +fn named_with_value(label: &str, text: &str) -> egui::WidgetInfo { + egui::WidgetInfo { current_text_value: Some(text.to_string()), ..egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label) } +} + +/// Painted status-bar text: screen readers read `text` from a label over `rect`. +fn shown_text(ui: &Ui, rect: Rect, id: &str, text: &str) { + if rect.is_positive() { + let resp = ui.interact(rect, ui.id().with(id), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text)); + } } fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response { @@ -265,11 +300,13 @@ mod tests { #![allow(clippy::unwrap_used)] use deckcraft_engine::Session; + use egui::accesskit::{Role, Toggled}; use egui::{Event, Modifiers, PointerButton}; use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; use serde_json::json; - use crate::{Services, SlideApp}; + use crate::{Services, SlideApp, ViewMode}; /// A long proofing language in a narrow window must not take clicks meant for the controls on /// the right: Notes toggles the notes pane and the Language dialog stays closed. @@ -309,4 +346,39 @@ mod tests { assert!(h.state().ui.notes, "the click reached the Notes button"); assert!(h.state().dialog.is_none(), "the Language dialog stayed closed"); } + + /// Screen readers find each status-bar control by name, hear the language and zoom it shows, + /// hear which view button is on, and read the slide counter and the status message. + #[test] + fn status_bar_controls_have_names() { + use egui_kittest::kittest::NodeT; + + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + h.run_steps(3); + for name in ["Fit slide to current window", "Zoom In", "Zoom Out", "Slide Show", "Reading View"] { + h.get_by_role_and_label(Role::Button, name); + } + let pct = format!("{}%", (h.state().canvas_scale * 100.0).round()); + assert_eq!(h.get_by_role_and_label(Role::Button, "Zoom level").value().as_deref(), Some(pct.as_str())); + assert_eq!(h.get_by_role_and_label(Role::Slider, "Zoom").value().as_deref(), Some(pct.as_str())); + assert_eq!(h.get_by_role_and_label(Role::Button, "Language").value().as_deref(), Some("English (United States)")); + let toggled = |h: &Harness<'_, SlideApp>, name: &str| h.get_by_role_and_label(Role::Button, name).accesskit_node().toggled(); + assert_eq!(toggled(&h, "Normal"), Some(Toggled::True)); + assert_eq!(toggled(&h, "Slide Sorter"), Some(Toggled::False)); + h.get_by_role_and_label(Role::Button, "Slide Sorter").click(); + h.run_steps(3); + assert_eq!(h.state().ui.view, ViewMode::Sorter, "the click by name reached the Slide Sorter button"); + assert_eq!(toggled(&h, "Slide Sorter"), Some(Toggled::True)); + assert_eq!(toggled(&h, "Normal"), Some(Toggled::False)); + assert_eq!(h.get_by_value("Slide 1 of 1").accesskit_node().role(), Role::Label); + h.state_mut().set_status("Saved a copy"); + h.run_steps(3); + assert_eq!(h.get_by_value("Saved a copy").accesskit_node().role(), Role::Label); + } } From 052afb3efd8b41e7838ea6c53b53aefbcb42f2e1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 11:12:04 -0700 Subject: [PATCH 2/2] Status bar: the zoom slider reports percent, and Reading View reports when it is on The zoom slider's number and range were the natural log of the zoom, so a screen reader read 0 at 100% and set the zoom in log units. The slider now runs from 10 to 400 percent on a log scale, and its number is the percentage its text shows. Reading View reports itself as selected when the view is Reading View, as the other view buttons do. --- crates/ui-egui/src/status.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index c9586a65..b6e7ad52 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -69,12 +69,12 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { } // Slider 10%–400% (log scale). let mut z = app.ui.zoom.unwrap_or(app.canvas_scale).clamp(0.1, 4.0); - let mut lz = z.ln(); - let s = right.add_sized(vec2(110.0, 18.0), egui::Slider::new(&mut lz, (0.1f32).ln()..=(4.0f32).ln()).show_value(false)); - let shown = format!("{}%", (z * 100.0).round()); - s.widget_info(|| egui::WidgetInfo { current_text_value: Some(shown.clone()), ..egui::WidgetInfo::slider(true, f64::from(lz), "Zoom") }); + let mut zp = z * 100.0; + let s = right.add_sized(vec2(110.0, 18.0), egui::Slider::new(&mut zp, 10.0..=400.0).logarithmic(true).show_value(false)); + let shown = format!("{}%", zp.round()); + s.widget_info(|| egui::WidgetInfo { current_text_value: Some(shown.clone()), ..egui::WidgetInfo::slider(true, f64::from(zp), "Zoom") }); if s.changed() { - z = lz.exp(); + z = zp / 100.0; app.ui.zoom = Some(z); } if small_icon(&mut right, Icon::ZoomOut, "Zoom Out", false).clicked() { @@ -86,7 +86,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); app.start_show(i, false); } - if view_icon(&mut right, Icon::ViewReadingSmall, "Reading View", false).clicked() { + if view_icon(&mut right, Icon::ViewReadingSmall, "Reading View", view == ViewMode::Reading).clicked() { let _ = app.run("view.reading", json!({})); } if view_icon(&mut right, Icon::ViewSorterSmall, "Slide Sorter", view == ViewMode::Sorter).clicked() { @@ -366,7 +366,12 @@ mod tests { } let pct = format!("{}%", (h.state().canvas_scale * 100.0).round()); assert_eq!(h.get_by_role_and_label(Role::Button, "Zoom level").value().as_deref(), Some(pct.as_str())); - assert_eq!(h.get_by_role_and_label(Role::Slider, "Zoom").value().as_deref(), Some(pct.as_str())); + let zoom = h.get_by_role_and_label(Role::Slider, "Zoom"); + assert_eq!(zoom.value().as_deref(), Some(pct.as_str())); + // Its number and range are in percent, as the value text is. + let node = zoom.accesskit_node(); + assert_eq!(node.numeric_value().map(f64::round), Some((h.state().canvas_scale * 100.0).round().into())); + assert_eq!((node.min_numeric_value(), node.max_numeric_value()), (Some(10.0), Some(400.0))); assert_eq!(h.get_by_role_and_label(Role::Button, "Language").value().as_deref(), Some("English (United States)")); let toggled = |h: &Harness<'_, SlideApp>, name: &str| h.get_by_role_and_label(Role::Button, name).accesskit_node().toggled(); assert_eq!(toggled(&h, "Normal"), Some(Toggled::True)); @@ -376,6 +381,11 @@ mod tests { assert_eq!(h.state().ui.view, ViewMode::Sorter, "the click by name reached the Slide Sorter button"); assert_eq!(toggled(&h, "Slide Sorter"), Some(Toggled::True)); assert_eq!(toggled(&h, "Normal"), Some(Toggled::False)); + assert_eq!(toggled(&h, "Reading View"), Some(Toggled::False)); + h.state_mut().ui.view = ViewMode::Reading; + h.run_steps(3); + assert_eq!(toggled(&h, "Reading View"), Some(Toggled::True)); + assert_eq!(toggled(&h, "Slide Sorter"), Some(Toggled::False)); assert_eq!(h.get_by_value("Slide 1 of 1").accesskit_node().role(), Role::Label); h.state_mut().set_status("Saved a copy"); h.run_steps(3);