From 5a49d895bf4ba603436b7bbc1a1488b82fd14a33 Mon Sep 17 00:00:00 2001 From: Antoine Cellerier Date: Wed, 24 Jun 2026 13:58:30 +0200 Subject: [PATCH] Announce pressed state of selectable buttons to screen readers (#8130) A `Button` used as a toggle (via `Button::selected(true)`, `Button::selectable(...)`, or `Ui::selectable_label`) now announces its pressed / not-pressed state to screen readers. Plain buttons that never call `.selected(...)` stay un-toggled, so their announcement doesn't change. `Checkbox` and the pre-existing selectable-label code path already did this; `Button` was the odd one out. No public API change: the field is private, `Button::selected(bool)` keeps its signature, and visuals are identical. Internally the field becomes `Option` so we can distinguish "plain button" from "toggle button currently off". Regression test added in `regression_tests.rs`, do let me know if some other file would be a better location. ### Note for manual testing `egui_demo_app` pulls in `eframe` with `default-features = false` and doesn't re-enable `accesskit`, so `cargo run -p egui_demo_app` publishes no AccessKit tree at all. To verify manually: `cargo run -p egui_demo_app --features accessibility_inspector` Happy to send a small follow-up PR enabling `accesskit` in the demo app's defaults if that's desirable, since that makes a11y work much easier to smoke-test locally. ### Use of AI This PR was drafted with Claude Code. I understand different projects have different policies regarding AI generated code. Do let me know if this is not acceptable here. Also happy to take any other feedback. Co-authored-by: Claude Opus 4.7 (1M context) --- crates/egui/src/widgets/button.rs | 29 +++++++++++++++------- tests/egui_tests/tests/regression_tests.rs | 29 ++++++++++++++++++++-- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/crates/egui/src/widgets/button.rs b/crates/egui/src/widgets/button.rs index a1526c50e..5330ff098 100644 --- a/crates/egui/src/widgets/button.rs +++ b/crates/egui/src/widgets/button.rs @@ -35,7 +35,7 @@ pub struct Button<'a> { frame_when_inactive: bool, min_size: Vec2, corner_radius: Option, - selected: bool, + selected: Option, image_tint_follows_text_color: bool, limit_image_size: bool, classes: Classes, @@ -54,7 +54,7 @@ impl<'a> Button<'a> { frame_when_inactive: true, min_size: Vec2::ZERO, corner_radius: None, - selected: false, + selected: None, image_tint_follows_text_color: false, limit_image_size: false, classes: Classes::default(), @@ -261,9 +261,14 @@ impl<'a> Button<'a> { } /// If `true`, mark this button as "selected". + /// + /// Calling this method opts the button into toggle semantics and the + /// current pressed/not-pressed state will be reported to assistive + /// technologies (e.g. screen readers). Plain buttons that never call + /// `selected` are not announced as toggles. #[inline] pub fn selected(mut self, selected: bool) -> Self { - self.selected = selected; + self.selected = Some(selected); self } @@ -314,7 +319,7 @@ impl<'a> Button<'a> { let response: Option = ui.ctx().read_response(id); let state = response.map(|r| r.widget_state()).unwrap_or_default(); - classes.add_class_if(SELECTED_CLASS, selected); + classes.add_class_if(SELECTED_CLASS, selected.unwrap_or(false)); let ButtonStyle { frame, text_style } = ui.style().button_style(&classes, state); @@ -376,12 +381,18 @@ impl<'a> Button<'a> { ui.ctx().set_cursor_icon(cursor); } - response.response.widget_info(|| { - if let Some(text) = &text { - WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), text) - } else { - WidgetInfo::new(WidgetType::Button) + response.response.widget_info(|| match (selected, &text) { + (Some(selected), Some(text)) => { + WidgetInfo::selected(WidgetType::Button, ui.is_enabled(), selected, text) } + (Some(selected), None) => { + let mut info = WidgetInfo::new(WidgetType::Button); + info.enabled = ui.is_enabled(); + info.selected = Some(selected); + info + } + (None, Some(text)) => WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), text), + (None, None) => WidgetInfo::new(WidgetType::Button), }); response diff --git a/tests/egui_tests/tests/regression_tests.rs b/tests/egui_tests/tests/regression_tests.rs index 4b19768f2..8710b523f 100644 --- a/tests/egui_tests/tests/regression_tests.rs +++ b/tests/egui_tests/tests/regression_tests.rs @@ -6,11 +6,11 @@ use egui::epaint::Shape; use egui::style::ScrollAnimation; use egui::text::{LayoutJob, TextWrapping}; use egui::{ - Align, Color32, FontFamily, FontId, Image, Label, Layout, RichText, Sense, TextBuffer, + Align, Button, Color32, FontFamily, FontId, Image, Label, Layout, RichText, Sense, TextBuffer, TextFormat, TextWrapMode, Ui, include_image, vec2, }; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable as _; +use egui_kittest::kittest::{NodeT as _, Queryable as _}; #[test] fn image_button_should_have_alt_text() { @@ -23,6 +23,31 @@ fn image_button_should_have_alt_text() { harness.get_by_label("Egui"); } +#[test] +fn button_selected_should_announce_toggled_state() { + use egui::accesskit::Toggled; + + let harness = Harness::new_ui(|ui| { + ui.add(Button::new("Plain")); + ui.add(Button::new("Off").selected(false)); + ui.add(Button::new("On").selected(true)); + }); + + assert_eq!( + harness.get_by_label("Plain").accesskit_node().toggled(), + None, + "a plain Button must not be announced as a toggle", + ); + assert_eq!( + harness.get_by_label("Off").accesskit_node().toggled(), + Some(Toggled::False), + ); + assert_eq!( + harness.get_by_label("On").accesskit_node().toggled(), + Some(Toggled::True), + ); +} + #[test] fn hovering_should_preserve_text_format() { let mut harness = Harness::builder().with_size((200.0, 70.0)).build_ui(|ui| {