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:
parent
d83f4500a3
commit
7fc80d8623
|
|
@ -1331,6 +1331,8 @@ dependencies = [
|
|||
name = "egui_demo_app"
|
||||
version = "0.32.3"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"eframe",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue