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
This commit is contained in:
Lucas Meurer 2025-10-07 14:39:49 +02:00 committed by GitHub
parent d83f4500a3
commit 7fc80d8623
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 285 additions and 2 deletions

View File

@ -1331,6 +1331,8 @@ dependencies = [
name = "egui_demo_app"
version = "0.32.3"
dependencies = [
"accesskit",
"accesskit_consumer",
"bytemuck",
"chrono",
"eframe",

View File

@ -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

View File

@ -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 {

View File

@ -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 }

View File

@ -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<accesskit_consumer::Tree>,
selected_node: Option<Id>,
queued_action: Option<ActionRequest>,
}
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<Id>) {
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());
});
}
}

View File

@ -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;

View File

@ -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(),