From 7fc80d8623f774678ed7d903c72eb6f5ee3e443e Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Tue, 7 Oct 2025 14:39:49 +0200 Subject: [PATCH] Accessibility inspector plugin (#7368) Adds an accessibility inspector plugin that shows the current AccessKit tree: https://github.com/user-attachments/assets/78f4f221-1bd2-4ce4-adf5-fc3b00f5c16c Macos has a built in accessibility inspector, but it doesn't seem to work with AccessKit / eframe so this provides some insight into the accesskit state. This also showed a couple issues that are easy to fix: - [ ] Links show up as `Label` instead of links - [ ] Not all supported actions are advertised (e.g. scrolling) - [ ] The resize handles in windows shouldn't be focusable - [ ] Checkbox has no value - [ ] Menus should have the button as parent widget (not 100% sure on this one) Currently the plugin lives in the demo app, but I think it should be moved somewhere else. Maybe egui_extras? This could also be relevant for #4650 --- Cargo.lock | 2 + Cargo.toml | 1 + crates/egui/src/id.rs | 19 ++ crates/egui_demo_app/Cargo.toml | 4 +- .../src/accessibility_inspector.rs | 254 ++++++++++++++++++ crates/egui_demo_app/src/lib.rs | 3 +- crates/egui_demo_app/src/wrap_app.rs | 4 + 7 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 crates/egui_demo_app/src/accessibility_inspector.rs diff --git a/Cargo.lock b/Cargo.lock index 8d5913da..8338f81b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1331,6 +1331,8 @@ dependencies = [ name = "egui_demo_app" version = "0.32.3" dependencies = [ + "accesskit", + "accesskit_consumer", "bytemuck", "chrono", "eframe", diff --git a/Cargo.toml b/Cargo.toml index 52dad4fa..2e35a139 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ egui_kittest = { version = "0.32.3", path = "crates/egui_kittest", default-featu eframe = { version = "0.32.3", path = "crates/eframe", default-features = false } accesskit = "0.21.0" +accesskit_consumer = "0.30.0" accesskit_winit = "0.29" ahash = { version = "0.8.11", default-features = false, features = [ "no-rng", # we don't need DOS-protection, so we let users opt-in to it instead diff --git a/crates/egui/src/id.rs b/crates/egui/src/id.rs index 7bcef8dc..0565dc56 100644 --- a/crates/egui/src/id.rs +++ b/crates/egui/src/id.rs @@ -83,6 +83,25 @@ impl Id { pub(crate) fn accesskit_id(&self) -> accesskit::NodeId { self.value().into() } + + /// Create a new [`Id`] from a high-entropy value. No hashing is done. + /// + /// This can be useful if you have an [`Id`] that was converted to some other type + /// (e.g. accesskit::NodeId) and you want to convert it back to an [`Id`]. + /// + /// # Safety + /// You need to ensure that the value is high-entropy since it might be used in + /// a [`IdSet`] or [`IdMap`], which rely on the assumption that [`Id`]s have good entropy. + /// + /// The method is not unsafe in terms of memory safety. + /// + /// # Panics + /// If the value is zero, this will panic. + #[doc(hidden)] + #[expect(unsafe_code)] + pub unsafe fn from_high_entropy_bits(value: u64) -> Self { + Self(NonZeroU64::new(value).expect("Id must be non-zero.")) + } } impl std::fmt::Debug for Id { diff --git a/crates/egui_demo_app/Cargo.toml b/crates/egui_demo_app/Cargo.toml index 97e6806f..703347a3 100644 --- a/crates/egui_demo_app/Cargo.toml +++ b/crates/egui_demo_app/Cargo.toml @@ -28,6 +28,7 @@ default = ["glow", "persistence"] # image_viewer adds about 0.9 MB of WASM web_app = ["http", "persistence"] +accessibility_inspector = ["dep:accesskit", "dep:accesskit_consumer", "eframe/accesskit"] http = ["ehttp", "image/jpeg", "poll-promise", "egui_extras/image"] image_viewer = ["image/jpeg", "egui_extras/all_loaders", "rfd"] persistence = [ @@ -64,7 +65,8 @@ log.workspace = true profiling.workspace = true # Optional dependencies: - +accesskit = { workspace = true, optional = true } +accesskit_consumer = { workspace = true, optional = true } bytemuck = { workspace = true, optional = true } puffin = { workspace = true, optional = true } puffin_http = { workspace = true, optional = true } diff --git a/crates/egui_demo_app/src/accessibility_inspector.rs b/crates/egui_demo_app/src/accessibility_inspector.rs new file mode 100644 index 00000000..df80149d --- /dev/null +++ b/crates/egui_demo_app/src/accessibility_inspector.rs @@ -0,0 +1,254 @@ +use accesskit::{Action, ActionRequest, NodeId}; +use accesskit_consumer::{FilterResult, Node, Tree, TreeChangeHandler}; +use eframe::epaint::text::TextWrapMode; +use egui::collapsing_header::CollapsingState; +use egui::{ + Button, Color32, Context, Event, Frame, FullOutput, Id, Key, KeyboardShortcut, Label, + Modifiers, RawInput, RichText, ScrollArea, SidePanel, TopBottomPanel, Ui, +}; +use std::mem; + +/// This [`egui::Plugin`] adds an inspector Panel. +/// +/// It can be opened with the `(Cmd/Ctrl)+Alt+I`. It shows the current AccessKit tree and details +/// for the selected node. +/// Useful when debugging accessibility issues or trying to understand the structure of the Ui. +/// +/// Add via +/// ``` +/// # use egui_demo_app::accessibility_inspector::AccessibilityInspectorPlugin; +/// # let ctx = egui::Context::default(); +/// ctx.add_plugin(AccessibilityInspectorPlugin::default()); +/// ``` +#[derive(Default, Debug)] +pub struct AccessibilityInspectorPlugin { + pub open: bool, + tree: Option, + selected_node: Option, + queued_action: Option, +} + +struct ChangeHandler; + +impl TreeChangeHandler for ChangeHandler { + fn node_added(&mut self, _node: &Node<'_>) {} + + fn node_updated(&mut self, _old_node: &Node<'_>, _new_node: &Node<'_>) {} + + fn focus_moved(&mut self, _old_node: Option<&Node<'_>>, _new_node: Option<&Node<'_>>) {} + + fn node_removed(&mut self, _node: &Node<'_>) {} +} + +impl egui::Plugin for AccessibilityInspectorPlugin { + fn debug_name(&self) -> &'static str { + "Accessibility Inspector" + } + + fn input_hook(&mut self, input: &mut RawInput) { + if let Some(queued_action) = self.queued_action.take() { + input + .events + .push(Event::AccessKitActionRequest(queued_action)); + } + } + + fn output_hook(&mut self, output: &mut FullOutput) { + if let Some(update) = output.platform_output.accesskit_update.clone() { + self.tree = match mem::take(&mut self.tree) { + None => { + // Create a new tree if it doesn't exist + Some(Tree::new(update, true)) + } + Some(mut tree) => { + // Update the tree with the latest accesskit data + tree.update_and_process_changes(update, &mut ChangeHandler); + + Some(tree) + } + } + } + } + + fn on_begin_pass(&mut self, ctx: &Context) { + if ctx.input_mut(|i| { + i.consume_shortcut(&KeyboardShortcut::new( + Modifiers::COMMAND | Modifiers::ALT, + Key::I, + )) + }) { + self.open = !self.open; + } + + if !self.open { + return; + } + + ctx.enable_accesskit(); + + SidePanel::right(Self::id()).show(ctx, |ui| { + let response = ui.heading("🔎 AccessKit Inspector"); + ctx.with_accessibility_parent(response.id, || { + if let Some(selected_node) = self.selected_node { + TopBottomPanel::bottom(Self::id().with("details_panel")) + .frame(Frame::new()) + .show_separator_line(false) + .show_inside(ui, |ui| { + self.selection_ui(ui, selected_node); + }); + } + + ui.style_mut().wrap_mode = Some(TextWrapMode::Truncate); + ScrollArea::vertical().show(ui, |ui| { + if let Some(tree) = &self.tree { + Self::node_ui(ui, &tree.state().root(), &mut self.selected_node); + } + }); + }); + }); + } +} + +impl AccessibilityInspectorPlugin { + fn id() -> Id { + Id::new("Accessibility Inspector") + } + + fn selection_ui(&mut self, ui: &mut Ui, selected_node: Id) { + ui.separator(); + + if let Some(tree) = &self.tree + && let Some(node) = tree.state().node_by_id(NodeId::from(selected_node.value())) + { + let node_response = ui.ctx().read_response(selected_node); + + if let Some(widget_response) = node_response { + ui.ctx().debug_painter().debug_rect( + widget_response.rect, + ui.style_mut().visuals.selection.bg_fill, + "", + ); + } + + egui::Grid::new("node_details_grid") + .num_columns(2) + .show(ui, |ui| { + ui.label("Node ID"); + ui.strong(format!("{selected_node:?}")); + ui.end_row(); + + ui.label("Role"); + ui.strong(format!("{:?}", node.role())); + ui.end_row(); + + ui.label("Label"); + ui.add( + Label::new(RichText::new(node.label().unwrap_or_default()).strong()) + .truncate(), + ); + ui.end_row(); + + ui.label("Value"); + ui.add( + Label::new(RichText::new(node.value().unwrap_or_default()).strong()) + .truncate(), + ); + ui.end_row(); + + ui.label("Children"); + ui.label(RichText::new(node.children().len().to_string()).strong()); + ui.end_row(); + }); + + ui.label("Actions"); + ui.horizontal_wrapped(|ui| { + // Iterate through all possible actions via the `Action::n` helper. + let mut current_action = 0; + let all_actions = std::iter::from_fn(|| { + let action = Action::n(current_action); + current_action += 1; + action + }); + + for action in all_actions { + if node.supports_action(action, &|_node| FilterResult::Include) + && ui.button(format!("{action:?}")).clicked() + { + let action_request = ActionRequest { + target: node.id(), + action, + data: None, + }; + self.queued_action = Some(action_request); + } + } + }); + } else { + ui.label("Node not found"); + } + } + + fn node_ui(ui: &mut Ui, node: &Node<'_>, selected_node: &mut Option) { + if node.id() == Self::id().value().into() + || node + .value() + .as_deref() + .is_some_and(|l| l.contains("AccessKit Inspector")) + { + return; + } + let label = node + .label() + .or(node.value()) + .unwrap_or(node.id().0.to_string()); + let label = format!("({:?}) {}", node.role(), label); + + // Safety: This is safe since the `accesskit::NodeId` was created from an `egui::Id`. + #[expect(unsafe_code)] + let egui_node_id = unsafe { Id::from_high_entropy_bits(node.id().0) }; + + ui.push_id(node.id(), |ui| { + let child_count = node.children().len(); + let has_children = child_count > 0; + let default_open = child_count == 1 && node.role() != accesskit::Role::Label; + + let mut collapsing = CollapsingState::load_with_default_open( + ui.ctx(), + egui_node_id.with("ak_collapse"), + default_open, + ); + + let header_response = ui.horizontal(|ui| { + let text = if collapsing.is_open() { "⏷" } else { "⏵" }; + + if ui + .add_visible(has_children, Button::new(text).frame_when_inactive(false)) + .clicked() + { + collapsing.set_open(!collapsing.is_open()); + } + let label_response = + ui.selectable_value(selected_node, Some(egui_node_id), label.clone()); + if label_response.hovered() { + let widget_response = ui.ctx().read_response(egui_node_id); + + if let Some(widget_response) = widget_response { + ui.ctx() + .debug_painter() + .debug_rect(widget_response.rect, Color32::RED, ""); + } + } + }); + + if has_children { + collapsing.show_body_indented(&header_response.response, ui, |ui| { + node.children().for_each(|c| { + Self::node_ui(ui, &c, selected_node); + }); + }); + } + + collapsing.store(ui.ctx()); + }); + } +} diff --git a/crates/egui_demo_app/src/lib.rs b/crates/egui_demo_app/src/lib.rs index 9cfa26ba..05b3c4bd 100644 --- a/crates/egui_demo_app/src/lib.rs +++ b/crates/egui_demo_app/src/lib.rs @@ -16,7 +16,8 @@ pub(crate) fn seconds_since_midnight() -> f64 { } // ---------------------------------------------------------------------------- - +#[cfg(feature = "accessibility_inspector")] +pub mod accessibility_inspector; #[cfg(target_arch = "wasm32")] mod web; diff --git a/crates/egui_demo_app/src/wrap_app.rs b/crates/egui_demo_app/src/wrap_app.rs index 127139a9..bec0aacf 100644 --- a/crates/egui_demo_app/src/wrap_app.rs +++ b/crates/egui_demo_app/src/wrap_app.rs @@ -183,6 +183,10 @@ impl WrapApp { // This gives us image support: egui_extras::install_image_loaders(&cc.egui_ctx); + #[cfg(feature = "accessibility_inspector")] + cc.egui_ctx + .add_plugin(crate::accessibility_inspector::AccessibilityInspectorPlugin::default()); + #[allow(unused_mut, clippy::allow_attributes)] let mut slf = Self { state: State::default(),