//! Developer tooling for inspecting the accessibility tree. //! //! [`A11yDebug`] retains the last [`TreeUpdate`] sent to the platform adapter so //! it can be serialized on demand (see //! [`crate::Window::debug_a11y_tree_json`]). In `cfg(debug_assertions)` builds, //! we capture extra info. use accesskit::{Action, NodeId, TreeUpdate}; use collections::FxHashMap; use crate::{Pixels, SharedString, Size}; #[derive(Default)] pub(crate) struct FrameDebugInfo { pub viewport_size: Size, pub scale_factor: f32, pub tab_stop_count: usize, } struct CapturedFrame { rendered_at: String, frame_number: u64, window_title: Option, node_count: usize, tab_stop_count: usize, viewport_size: Size, scale_factor: f32, } #[cfg(debug_assertions)] #[derive(Clone, Default)] pub(crate) struct NodeDebugInfo { /// Whether the node was synthesized via /// [`crate::Element::a11y_synthetic_children`] rather than created from a /// real element with a role and ID. pub synthetic: bool, /// The type name of the `Render` view that was rendering when the node was /// created. pub view: Option<&'static str>, /// The [`ElementId`](crate::ElementId) of the creating element (the leaf of /// its `GlobalElementId`, not the full path). For a synthetic node, this is /// the real element whose `a11y_synthetic_children` produced it. pub element_id: Option, /// Source location where the creating element was constructed. pub source_location: Option<&'static core::panic::Location<'static>>, } #[cfg(debug_assertions)] #[derive(Clone, Default)] pub(crate) struct NodeCreator { pub view: Option<&'static str>, pub element_id: Option, pub source_location: Option<&'static core::panic::Location<'static>>, } #[derive(Default)] pub(crate) struct A11yDebug { last_tree_update: Option, last_gpui_focus: Option, last_active_descendant: Option, /// Monotonic counter incremented on each captured frame, so a re-dump makes /// it obvious whether the tree actually refreshed. frame_number: u64, /// Metadata about the most recently captured frame. last_frame: Option, #[cfg(debug_assertions)] last_node_info: FxHashMap, } impl A11yDebug { pub(crate) fn capture( &mut self, update: &TreeUpdate, gpui_focus: Option, active_descendant: Option, window_title: Option<&SharedString>, frame: FrameDebugInfo, ) { self.last_tree_update = Some(update.clone()); self.last_gpui_focus = gpui_focus; self.last_active_descendant = active_descendant; self.frame_number += 1; self.last_frame = Some(CapturedFrame { rendered_at: chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, false), frame_number: self.frame_number, window_title: window_title.cloned(), node_count: update.nodes.len(), tab_stop_count: frame.tab_stop_count, viewport_size: frame.viewport_size, scale_factor: frame.scale_factor, }); } #[cfg(debug_assertions)] pub(crate) fn capture_node_info(&mut self, node_info: &FxHashMap) { self.last_node_info = node_info.clone(); } /// Serialize the last tree update to a readable JSON string. Node ids are /// replaced with short ephemeral ids (`a`, `b`, ..., `z`, `aa`, ...). pub(crate) fn to_json(&self) -> Option { let update = self.last_tree_update.as_ref()?; let mut ephemeral: FxHashMap = FxHashMap::default(); for (index, (id, _)) in update.nodes.iter().enumerate() { ephemeral.insert(*id, ephemeral_id(index)); } let mut nodes = serde_json::Map::new(); for (id, node) in &update.nodes { let key = ephemeral .get(id) .cloned() .unwrap_or_else(|| id.0.to_string()); #[cfg(debug_assertions)] let provenance = self .last_node_info .get(id) .map(|info| NodeProvenance { element_id: info.element_id.as_deref(), view: info.view, source_location: info.source_location.map(|loc| loc.to_string()), // Only surface synthetic nodes; `false` is the default and // would just be noise on every real node. synthetic: info.synthetic.then_some(true), }) .unwrap_or_default(); #[cfg(not(debug_assertions))] let provenance = NodeProvenance::default(); let value = node_to_json(*id, node, &ephemeral, &provenance); nodes.insert(key, value); } let frame = self.last_frame.as_ref().map(|frame| { serde_json::json!({ "rendered_at": frame.rendered_at, "frame_number": frame.frame_number, "window_title": frame.window_title.as_ref().map(|title| title.to_string()), "node_count": frame.node_count, "tab_stop_count": frame.tab_stop_count, "viewport_size": { "width": frame.viewport_size.width.0, "height": frame.viewport_size.height.0, }, "scale_factor": frame.scale_factor, }) }); let root = update .tree .as_ref() .map(|tree| tree.root) .and_then(|id| ephemeral.get(&id).cloned()); let value = serde_json::json!({ "root": root, "gpui_focus": self.last_gpui_focus.and_then(|id| ephemeral.get(&id).cloned()), "active_descendant_focus": self.last_active_descendant.and_then(|id| ephemeral.get(&id).cloned()), "frame": frame, "nodes": nodes, }); Some(serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())) } } #[derive(Default)] struct NodeProvenance<'a> { element_id: Option<&'a str>, view: Option<&'a str>, source_location: Option, synthetic: Option, } fn node_to_json( id: NodeId, node: &accesskit::Node, ephemeral: &FxHashMap, provenance: &NodeProvenance, ) -> serde_json::Value { use serde_json::json; let mut map = serde_json::Map::new(); map.insert("accesskit_id".into(), json!(id.0.to_string())); let children: Vec = node .children() .iter() .map(|child| { ephemeral .get(child) .cloned() .unwrap_or_else(|| child.0.to_string()) }) .collect(); if !children.is_empty() { map.insert("children".into(), json!(children)); } // Provenance (debug builds only), ordered before the accessibility section. if let Some(element_id) = provenance.element_id { map.insert("element_id".into(), json!(element_id)); } if let Some(view) = provenance.view { map.insert("view".into(), json!(view)); } if let Some(source_location) = &provenance.source_location { map.insert("source_location".into(), json!(source_location)); } if let Some(synthetic) = provenance.synthetic { map.insert("synthetic".into(), json!(synthetic)); } // Accessibility semantics for this node, grouped together. let mut aria = serde_json::Map::new(); aria.insert("role".into(), json!(format!("{:?}", node.role()))); // Which action types the node supports. AccessKit keeps these in a private // bitset with no getter or iterator, so we probe each variant. `Action::n` // (from AccessKit's `enumn` feature) maps a discriminant to its variant, // returning `None` past the last one - so this can't drift out of sync with // AccessKit's `Action` enum the way a hand-maintained list would. let mut next_action = 0u8; let on_action: Vec = std::iter::from_fn(move || { let action = Action::n(next_action)?; next_action += 1; Some(action) }) .filter(|action| node.supports_action(*action)) .map(|action| format!("{action:?}")) .collect(); if !on_action.is_empty() { aria.insert("on_action".into(), json!(on_action)); } // String properties. if let Some(v) = node.label() { aria.insert("label".into(), json!(v)); } if let Some(v) = node.description() { aria.insert("description".into(), json!(v)); } if let Some(v) = node.value() { aria.insert("value".into(), json!(v)); } if let Some(v) = node.keyboard_shortcut() { aria.insert("keyboard_shortcut".into(), json!(v)); } if let Some(v) = node.access_key() { aria.insert("access_key".into(), json!(v)); } if let Some(v) = node.placeholder() { aria.insert("placeholder".into(), json!(v)); } if let Some(v) = node.tooltip() { aria.insert("tooltip".into(), json!(v)); } if let Some(v) = node.role_description() { aria.insert("role_description".into(), json!(v)); } // Boolean / enum states. if let Some(v) = node.is_selected() { aria.insert("selected".into(), json!(v)); } if let Some(v) = node.is_expanded() { aria.insert("expanded".into(), json!(v)); } if let Some(v) = node.toggled() { aria.insert("toggled".into(), json!(format!("{v:?}"))); } if let Some(v) = node.orientation() { aria.insert("orientation".into(), json!(format!("{v:?}"))); } // Numeric properties. if let Some(v) = node.numeric_value() { aria.insert("numeric_value".into(), json!(v)); } if let Some(v) = node.min_numeric_value() { aria.insert("min_numeric_value".into(), json!(v)); } if let Some(v) = node.max_numeric_value() { aria.insert("max_numeric_value".into(), json!(v)); } if let Some(v) = node.numeric_value_step() { aria.insert("numeric_value_step".into(), json!(v)); } // Set / table properties. if let Some(v) = node.level() { aria.insert("level".into(), json!(v)); } if let Some(v) = node.position_in_set() { aria.insert("position_in_set".into(), json!(v)); } if let Some(v) = node.size_of_set() { aria.insert("size_of_set".into(), json!(v)); } if let Some(v) = node.row_index() { aria.insert("row_index".into(), json!(v)); } if let Some(v) = node.column_index() { aria.insert("column_index".into(), json!(v)); } if let Some(v) = node.row_count() { aria.insert("row_count".into(), json!(v)); } if let Some(v) = node.column_count() { aria.insert("column_count".into(), json!(v)); } map.insert("aria".into(), serde_json::Value::Object(aria)); serde_json::Value::Object(map) } /// Maps a 0-based index to a short id in the sequence `a, b, ..., z, aa, ab, /// ...` (bijective base-26). fn ephemeral_id(mut index: usize) -> String { let mut bytes = Vec::new(); loop { bytes.push(b'a' + (index % 26) as u8); if index < 26 { break; } index = index / 26 - 1; } bytes.reverse(); String::from_utf8(bytes).unwrap_or_default() }