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()); }); } }