use accesskit::{Action, NodeId, TreeUpdate};
use collections::FxHashMap;
use crate::{Bounds, Pixels, SharedString, Size};
#[derive(Default)]
pub(crate) struct FrameDebugInfo {
pub viewport_size: Size<Pixels>,
pub scale_factor: f32,
pub tab_stop_count: usize,
}
struct CapturedFrame {
rendered_at: String,
frame_number: u64,
window_title: Option<SharedString>,
node_count: usize,
tab_stop_count: usize,
viewport_size: Size<Pixels>,
scale_factor: f32,
}
#[cfg(debug_assertions)]
#[derive(Clone, Default)]
pub(crate) struct NodeDebugInfo {
pub synthetic: bool,
pub view: Option<&'static str>,
pub element_id: Option<String>,
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<String>,
pub source_location: Option<&'static core::panic::Location<'static>>,
}
#[derive(Default)]
pub(crate) struct A11yDebug {
last_tree_update: Option<TreeUpdate>,
last_gpui_focus: Option<NodeId>,
last_active_descendant: Option<NodeId>,
frame_number: u64,
last_frame: Option<CapturedFrame>,
last_node_bounds: FxHashMap<NodeId, Bounds<Pixels>>,
#[cfg(debug_assertions)]
last_node_info: FxHashMap<NodeId, NodeDebugInfo>,
}
impl A11yDebug {
pub(crate) fn capture(
&mut self,
update: &TreeUpdate,
gpui_focus: Option<NodeId>,
active_descendant: Option<NodeId>,
window_title: Option<&SharedString>,
frame: FrameDebugInfo,
node_bounds: &FxHashMap<NodeId, Bounds<Pixels>>,
) {
self.last_tree_update = Some(update.clone());
self.last_node_bounds = node_bounds.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,
});
}
pub(crate) fn last_tree_update(&self) -> Option<&TreeUpdate> {
self.last_tree_update.as_ref()
}
pub(crate) fn node_bounds(&self, id: NodeId) -> Option<Bounds<Pixels>> {
self.last_node_bounds.get(&id).copied()
}
pub(crate) fn frame_number(&self) -> u64 {
self.frame_number
}
#[cfg(debug_assertions)]
pub(crate) fn capture_node_info(&mut self, node_info: &FxHashMap<NodeId, NodeDebugInfo>) {
self.last_node_info = node_info.clone();
}
pub(crate) fn to_json(&self) -> Option<String> {
let update = self.last_tree_update.as_ref()?;
let mut ephemeral: FxHashMap<NodeId, String> = 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()),
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,
self.last_node_bounds.get(id).copied(),
);
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<String>,
synthetic: Option<bool>,
}
fn node_to_json(
id: NodeId,
node: &accesskit::Node,
ephemeral: &FxHashMap<NodeId, String>,
provenance: &NodeProvenance,
bounds: Option<Bounds<Pixels>>,
) -> serde_json::Value {
use serde_json::json;
let mut map = serde_json::Map::new();
map.insert("accesskit_id".into(), json!(id.0.to_string()));
if let Some(bounds) = bounds {
map.insert(
"bounds".into(),
json!({
"x": bounds.origin.x.0,
"y": bounds.origin.y.0,
"width": bounds.size.width.0,
"height": bounds.size.height.0,
}),
);
}
let children: Vec<String> = 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));
}
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));
}
let mut aria = serde_json::Map::new();
aria.insert("role".into(), json!(format!("{:?}", node.role())));
let mut next_action = 0u8;
let on_action: Vec<String> = 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));
}
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));
}
if let Some(v) = node.author_id() {
aria.insert("author_id".into(), json!(v));
}
if let Some(v) = node.url() {
aria.insert("url".into(), json!(v));
}
if node.is_disabled() {
aria.insert("disabled".into(), json!(true));
}
if node.is_hidden() {
aria.insert("hidden".into(), json!(true));
}
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:?}")));
}
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));
}
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)
}
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()
}
#[cfg(test)]
mod tests {
use crate::{
AnyWindowHandle, AppContext as _, Context, InteractiveElement as _, IntoElement,
ParentElement as _, Render, StatefulInteractiveElement as _, TestAppContext, Window, div,
util::FluentBuilder as _,
};
use accesskit::{Role, TreeUpdate};
use accesskit_consumer::{Tree, common_filter};
use std::sync::atomic::Ordering;
struct HiddenSubtree {
hidden: bool,
}
impl Render for HiddenSubtree {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.child(
div()
.id("hidden-boundary")
.when(self.hidden, |element| element.aria_hidden())
.child(div().id("inside").role(Role::Button).aria_label("Inside")),
)
.child(div().id("outside").role(Role::Button).aria_label("Outside"))
}
}
#[test]
fn aria_hidden_controls_subtree_visibility() {
let mut cx = TestAppContext::single();
let window = cx.add_window(|_, _| HiddenSubtree { hidden: false });
let any_window: AnyWindowHandle = window.into();
let draw_tree = |window: AnyWindowHandle, cx: &mut TestAppContext| -> TreeUpdate {
cx.update_window(window, |_, window, cx| {
window.a11y.active_flag.store(true, Ordering::SeqCst);
window.draw(cx).clear(cx);
window
.a11y
.debug
.last_tree_update
.clone()
.expect("drawing with accessibility active should produce a tree")
})
.unwrap()
};
let filtered_labels = |update| {
let tree = Tree::new(update, false);
tree.state()
.root()
.filtered_children(common_filter)
.filter_map(|node| node.label())
.collect::<Vec<_>>()
};
let set_hidden = |hidden, cx: &mut TestAppContext| {
window
.update(cx, |view, _, cx| {
view.hidden = hidden;
cx.notify();
})
.unwrap();
};
let visible_update = draw_tree(any_window, &mut cx);
assert_eq!(filtered_labels(visible_update), ["Inside", "Outside"]);
set_hidden(true, &mut cx);
let hidden_update = draw_tree(any_window, &mut cx);
assert_eq!(filtered_labels(hidden_update), ["Outside"]);
set_hidden(false, &mut cx);
let restored_update = draw_tree(any_window, &mut cx);
assert_eq!(filtered_labels(restored_update), ["Inside", "Outside"]);
}
}