Vendor gpui-pre 0.3.8 unmodified (crates.io source)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-11 00:25:10 -04:00
1 parent ba5c0798cb
commit 48a66ca6c1
162 files changed
+104826 -2

No files matched your search

+905
View File
@@ -0,0 +1,905 @@
//! Accessibility support, provided by [AccessKit][accesskit].
//!
//! There are user-facing guide-level docs [here](crate::_accessibility).
//!
//! ## Architecture
//!
//! ```text
//! ┌────────────────────────────────┐ ┌─────────────────────┐
//! ┌─▶│ AccessKit Adapter (MacOS) │◀─▶│ MacOS System APIs │
//! │ └────────────────────────────────┘ └─────────────────────┘
//! │
//! ┌──────┐ ┌───────────┐ │ ┌────────────────────────────────┐ ┌─────────────────────┐
//! │ GPUI │◀─▶│ AccessKit │◀─┼─▶│ AccessKit Adapter (Windows) │◀─▶│ Windows System APIs │
//! └──────┘ └───────────┘ │ └────────────────────────────────┘ └─────────────────────┘
//! │
//! │ ┌────────────────────────────────┐ ┌─────────────────────┐
//! └─▶│ AccessKit Adapter (Linux) │◀─▶│ dbus │
//! └────────────────────────────────┘ └─────────────────────┘
//! ```
//!
//! In order for GPUI apps to be usable for people using assistive technology,
//! we must do a few things:
//! - Inform the system when the UI changes meaningfully. This includes:
//! - Reporting new/removed/changed UI elements
//! - *Not* reporting irrelevant UI changes, e.g. an invisible `div()` being
//! added.
//! - Reporting the appearance and capabilities of each UI element. For example:
//! - What does this piece of text say?
//! - How far along is this progress bar?
//! - Can this node be focused?
//! - Can this node have a value directly assigned? (e.g. a slider)
//! - Allowing the system to interact with the UI by dispatching actions to
//! nodes. Note that AccessKit has its own [`Action`] type, which is not the
//! [`crate::Action`] trait.
//! - Activate and deactivate accessibility features when requested by the
//! system.
//!
//! Activating and deactivating at the right time is trivial, so I won't go into
//! detail here. The other two are almost orthogonal in implementation.
//!
//! The state for both lives in the [`A11y`] struct in this module.
//!
//! ### Reporting UI changes
//!
//! Every frame, we build a [`TreeUpdate`] and send it to the platform-specific
//! adapter. A [`TreeUpdate`] is a representation of a subset of the UI tree.
//! When the adapter receives the update, it diffs it against the previous
//! update, and calls platform-specific APIs to inform screen readers about the
//! changes. Nodes may have been created, destroyed, or updated.
//!
//! Each node has an ID, and this ID *should* be stable across frames. If a
//! node's ID changes, then, from AccessKit's point of view, it is a different
//! node.
//!
//! We derive the node ID from the [`GlobalElementId`] in
//! [`GlobalElementId::accesskit_node_id`]. Nodes without [`GlobalElementId`]s
//! cannot produce an AccessKit [`NodeId`], and so are not included in the
//! accessibility tree. We try to warn when using accessibility APIs on
//! [`div()`] without setting an ID.
//!
//! This all happens in [`Drawable::prepaint`]. The [`A11y`] struct maintains a
//! stack of nodes during prepainting, which we can use to calculate the
//! [`NodeId`]s, and record parent-child relationships. Once all [`Element`]s in
//! a frame have been prepainted, we send the resulting [`TreeUpdate`] object to
//! the adapter and the screen reader can announce the changes.
//!
//! #### Synthetic children
//!
//! Additionally, some nodes can register "synthetic children" using
//! [`Element::a11y_synthetic_children`]. Normally, one accesskit node is pushed
//! for every [`Element`] with a role and id. However, sometimes a single
//! element may want to produce many accesskit nodes. These extra nodes are
//! referred to as "synthetic children" of the element providing a non-default
//! [`Element::a11y_synthetic_children`] implementation.
//!
//! The user is provided a builder-style API using [`A11ySubtreeBuilder`], which
//! allows them to create push nodes that are children of the current node, as
//! well as modify the current node itself.
//!
//! GPUI calls this callback *after* prepainting (and just before popping the
//! corresponding element), since this step may need prepaint information to be
//! available. In the future, we may want to add prepaint information more
//! generally to [`Element::write_a11y_info`], but for now that's not necessary.
//!
//! ### Responding to actions
//!
//! On adapter creation, we provide a callback to the adapter, which can be used
//! to dispatch actions. This callback forwards to [`A11y::action_listeners`], a
//! mapping from [`NodeId`]s to action handlers (basically just `Box<dyn
//! Fn()>`).
//!
//! This is populated in:
//! - [`Window::on_a11y_action`], which is called by:
//! - [`Interactivity::paint`], which is called by:
//! - [`StatefulInteractiveElement::on_a11y_action`], which is a public-facing API
//!
//! These are cleared at the start of a frame, and re-populated during painting.
//!
//! [`NodeId`]: accesskit::NodeId
use crate::*;
pub(crate) mod debug;
use crate::{App, Bounds, FocusId, Pixels, SharedString, Window};
use accesskit::{Action, NodeId, TreeUpdate};
use collections::{FxHashMap, FxHashSet};
use smallvec::SmallVec;
use std::hash::{Hash, Hasher};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
/// The fixed AccessKit node ID used for the root of every window's a11y tree.
pub(crate) const ROOT_NODE_ID: NodeId = NodeId(0);
/// A listener for an accessibility action on a specific node.
pub(crate) type A11yActionListener =
Box<dyn FnMut(Option<&accesskit::ActionData>, &mut Window, &mut App) + 'static>;
/// Per-window accessibility state.
///
/// Manages the AccessKit tree that is built each frame and the mappings
/// needed to dispatch incoming action requests back to the right elements.
pub(crate) struct A11y {
/// Whether accessibility has been [forcibly disabled] for this window.
///
/// [forcibly disabled]: crate::Application::new_inaccessible
force_disabled: bool,
/// Whether a11y features have been requested by the system.
///
/// Updated by AccessKit using callbacks provided to the adapter. Can change
/// halfway through a frame.
active_flag: Arc<AtomicBool>,
/// Whether a11y features are active for *this specific frame*.
///
/// At the start of each frame, we load [`Self::active_flag`] (using
/// [`Self::sync_active_flag`]) and use this to determine whether we
/// should construct a [`TreeUpdate`] for this frame. It's important that
/// this value is stable within a frame, because the builder API exposed by
/// this type maintains a stack of nodes and each must be pushed and popped
/// exactly once.
///
/// At the end of the frame, we re-call [`Self::sync_active_flag`] to
/// determine whether we should actually send the finished [`TreeUpdate`].
active_this_frame: bool,
pub(crate) nodes: A11yNodeBuilder,
pub(crate) focus_ids: FxHashMap<NodeId, FocusId>,
pub(crate) node_bounds: FxHashMap<NodeId, Bounds<Pixels>>,
pub(crate) action_listeners: FxHashMap<NodeId, Vec<(Action, A11yActionListener)>>,
/// The window's title, used to label the root node so assistive
/// technology can tell windows apart.
window_title: Option<SharedString>,
/// The focus id we most recently reported as having no accessibility node,
/// used to log at most once per focus change rather than every frame.
last_focus_without_node: Option<FocusId>,
/// Retains the last tree update (and, in debug builds, per-node provenance)
/// so it can be dumped via [`crate::Window::debug_a11y_tree_json`].
debug: debug::A11yDebug,
/// Maps a view's [`EntityId`] to its `Render` type name
#[cfg(debug_assertions)]
pub(crate) view_type_names: FxHashMap<EntityId, &'static str>,
}
impl A11y {
pub(crate) fn new(
active_flag: Arc<AtomicBool>,
force_disabled: bool,
window_title: Option<SharedString>,
) -> Self {
Self {
force_disabled,
active_flag,
active_this_frame: false,
nodes: A11yNodeBuilder::new(),
focus_ids: FxHashMap::default(),
node_bounds: FxHashMap::default(),
action_listeners: FxHashMap::default(),
window_title,
last_focus_without_node: None,
debug: debug::A11yDebug::default(),
#[cfg(debug_assertions)]
view_type_names: FxHashMap::default(),
}
}
/// Logs (once per focus change) that the focused element is not exposed to
/// assistive technology because it has no accessibility node. When this
/// happens, screen readers fall back to announcing the whole window instead
/// of the focused element. The fix is to give the element both an
/// `.id(...)` and a `.role(...)`.
pub(crate) fn note_focus_without_node(&mut self, focus_id: FocusId, reason: &str) {
if self.last_focus_without_node != Some(focus_id) {
self.last_focus_without_node = Some(focus_id);
log::info!(
"a11y: focused element ({focus_id:?}) has no accessibility node \
({reason}); assistive technology will announce the whole window \
instead. Give it both an `.id(...)` and a `.role(...)` to expose it."
);
}
}
pub(crate) fn set_window_title(&mut self, title: impl Into<SharedString>) {
self.window_title = Some(title.into());
}
/// Ensures that [`Self::is_active`] returns up to date information.
///
/// See the docs for [`Self::active_flag`] and [`Self::active_this_frame`]
/// for more commentary.
pub(crate) fn sync_active_flag(&mut self) {
self.active_this_frame = self.is_enabled() && self.active_flag.load(Ordering::SeqCst);
}
pub(crate) fn is_enabled(&self) -> bool {
!self.force_disabled
}
pub(crate) fn is_active(&self) -> bool {
self.active_this_frame
}
pub(crate) fn set_focusable(&mut self, node_id: NodeId, focus_id: FocusId) {
self.focus_ids.insert(node_id, focus_id);
}
/// Report `node_id` as the currently-focused node, if it is present in the
/// tree.
///
/// Must only be called once per frame.
pub(crate) fn set_focus(&mut self, node_id: NodeId) {
// A focused node must have been registered as focusable this frame.
if !self.focus_ids.contains_key(&node_id) {
if cfg!(debug_assertions) {
panic!("set_focus called for a node that was not registered with set_focusable");
} else {
log::warn!(
"a11y: set_focus called for a node that was not registered with \
set_focusable ({node_id:?})"
);
}
}
if self.nodes.has_node(node_id) {
// The focused element is properly exposed; reset the dedup so a
// later focus on a node-less element logs again.
self.last_focus_without_node = None;
self.nodes.set_focus(node_id);
} else {
// The element registered a focus handle and an id, but never got a
// node because it has no role.
if let Some(focus_id) = self.focus_ids.get(&node_id).copied() {
self.note_focus_without_node(focus_id, "it has an id but no role");
}
}
}
pub(crate) fn set_active_descendant(&mut self, node_id: NodeId) {
// The active descendant must be a descendant of the focused container,
// not the focused node itself.
if self.nodes.node_is_focused(node_id) {
if cfg!(debug_assertions) {
panic!("set_active_descendant called on the focused node");
} else {
log::warn!("a11y: set_active_descendant called on the focused node ({node_id:?})");
}
return;
}
if self.nodes.has_node(node_id) && self.nodes.focus_is_ancestor_of_current() {
self.nodes.set_active_descendant(node_id);
}
}
/// Clear per-frame state and push the root node to start a new frame.
pub(crate) fn begin_frame(&mut self) {
self.focus_ids.clear();
self.node_bounds.clear();
self.action_listeners.clear();
self.nodes.begin_frame(self.window_title.as_ref());
}
/// Finalize the tree and produce a [`TreeUpdate`] for the platform adapter.
pub(crate) fn end_frame(&mut self, frame: debug::FrameDebugInfo) -> TreeUpdate {
let update = self.nodes.finalize();
self.debug.capture(
&update,
self.nodes.focus,
self.nodes.active_descendant,
self.window_title.as_ref(),
frame,
);
#[cfg(debug_assertions)]
self.debug.capture_node_info(&self.nodes.node_info);
update
}
pub(crate) fn debug_tree_json(&self) -> Option<String> {
self.debug.to_json()
}
}
/// Builder API for synthetic children. See the docs for
/// [`Element::a11y_synthetic_children`].
pub struct A11ySubtreeBuilder<'a> {
parent_id: NodeId,
nodes: &'a mut A11yNodeBuilder,
/// Provenance of the real element whose `a11y_synthetic_children` is
/// running.
#[cfg(debug_assertions)]
creator: debug::NodeCreator,
}
impl<'a> A11ySubtreeBuilder<'a> {
pub(crate) fn new(parent_id: NodeId, nodes: &'a mut A11yNodeBuilder) -> Self {
Self {
parent_id,
nodes,
#[cfg(debug_assertions)]
creator: debug::NodeCreator::default(),
}
}
#[cfg(debug_assertions)]
pub(crate) fn with_creator(mut self, creator: debug::NodeCreator) -> Self {
self.creator = creator;
self
}
/// Derive a [`NodeId`] for a synthetic child.
///
/// The generated ID is based on the hash of `key`, as well as the parent's
/// ID. This means that `key`s must be unique within the same
/// [`Element::a11y_synthetic_children`] call, but may be duplicated across
/// different calls.
pub fn synthetic_node_id(&self, key: impl Hash) -> NodeId {
let mut hasher = std::hash::DefaultHasher::default();
self.parent_id.0.hash(&mut hasher);
key.hash(&mut hasher);
NodeId(hasher.finish())
}
/// Append a synthetic leaf node as a child of this element's node.
///
/// Returns `false` if a node with this id is already present in the tree,
/// in which case the node is discarded.
pub fn push_child(&mut self, id: NodeId, node: accesskit::Node) -> bool {
let pushed = self.nodes.push_leaf(id, node);
#[cfg(debug_assertions)]
if pushed {
self.nodes.record_node_info(
id,
debug::NodeDebugInfo {
synthetic: true,
view: self.creator.view,
element_id: self.creator.element_id.clone(),
source_location: self.creator.source_location,
},
);
}
pushed
}
/// A mutable reference to the parent node.
pub fn parent_node(&mut self) -> &mut accesskit::Node {
self.nodes
.current_node_mut()
.expect("A11ySubtreeBuilder exists only while its element's node is on the stack")
}
}
pub(crate) struct A11yNodeBuilder {
ids_stack: SmallVec<[NodeId; 16]>,
nodes_stack: SmallVec<[accesskit::Node; 16]>,
/// This is the exact type required by accesskit, so we can't just make it a
/// `HashMap<NodeId, Node>` to remove the need for `seen_ids`
all_nodes: Vec<(NodeId, accesskit::Node)>,
seen_ids: FxHashSet<NodeId>,
/// The node that GPUI considers focused. Note that this may be different to
/// what is reported to accesskit - see [`Self::active_descendant`]
focus: Option<NodeId>,
/// If a node calls `.aria_active_descendant()`, AND an ancestor is focused,
/// override it as the focused node. This supports the "active descendant"
/// pattern, which allows a focused container to act as if a descendant is
/// focused.
active_descendant: Option<NodeId>,
#[cfg(debug_assertions)]
node_info: FxHashMap<NodeId, debug::NodeDebugInfo>,
}
impl A11yNodeBuilder {
fn new() -> Self {
Self {
ids_stack: SmallVec::new(),
nodes_stack: SmallVec::new(),
all_nodes: Vec::new(),
seen_ids: FxHashSet::default(),
focus: None,
active_descendant: None,
#[cfg(debug_assertions)]
node_info: FxHashMap::default(),
}
}
/// Records provenance for a node already pushed this frame. Debug builds only.
#[cfg(debug_assertions)]
pub(crate) fn record_node_info(&mut self, id: NodeId, info: debug::NodeDebugInfo) {
self.node_info.insert(id, info);
}
#[must_use]
fn can_push(&mut self, id: NodeId) -> bool {
debug_assert!(!self.ids_stack.is_empty(), "node pushed before push_root");
if !self.seen_ids.insert(id) {
debug_assert!(
false,
"Duplicate a11y node id: {id:?}. In a release build, this node would be silently discarded from the a11y tree."
);
return false;
}
true
}
/// Push a new node onto the stack. It becomes a child of the current
/// top-of-stack node.
///
/// Returns `true` if the node was successfully pushed.
pub(crate) fn push(&mut self, id: NodeId, node: accesskit::Node) -> bool {
if !self.can_push(id) {
return false;
}
if let Some(parent) = self.nodes_stack.last_mut() {
parent.push_child(id);
}
self.ids_stack.push(id);
self.nodes_stack.push(node);
true
}
/// Add a leaf node as a child of the current top-of-stack node, without
/// pushing it onto the stack. Semantically equivalent to a [`Self::push`]
/// followed by a [`Self::pop`].
///
/// Returns `true` if the node was successfully pushed.
pub(crate) fn push_leaf(&mut self, id: NodeId, node: accesskit::Node) -> bool {
if !self.can_push(id) {
return false;
}
if let Some(parent) = self.nodes_stack.last_mut() {
parent.push_child(id);
}
self.all_nodes.push((id, node));
true
}
pub(crate) fn current_node_mut(&mut self) -> Option<&mut accesskit::Node> {
self.nodes_stack.last_mut()
}
/// Pop the current node off the stack and finalize it into the all_nodes
/// list.
pub(crate) fn pop(&mut self) {
debug_assert!(self.ids_stack.len() > 1, "pop would remove the root node");
if let (Some(id), Some(node)) = (self.ids_stack.pop(), self.nodes_stack.pop()) {
self.all_nodes.push((id, node));
}
}
/// Push the root node to start a new frame.
fn begin_frame(&mut self, window_title: Option<&SharedString>) {
self.all_nodes.clear();
self.ids_stack.clear();
self.nodes_stack.clear();
self.seen_ids.clear();
#[cfg(debug_assertions)]
self.node_info.clear();
let mut root_node = accesskit::Node::new(accesskit::Role::Window);
if let Some(title) = window_title {
root_node.set_label(title.to_string());
}
self.ids_stack.push(ROOT_NODE_ID);
self.nodes_stack.push(root_node);
self.focus = None;
self.active_descendant = None;
}
/// Returns whether a node with the given ID has been pushed in this frame.
pub(crate) fn has_node(&self, id: NodeId) -> bool {
id == ROOT_NODE_ID || self.seen_ids.contains(&id)
}
/// Returns whether `id` is the node currently reported as focused.
pub(crate) fn node_is_focused(&self, id: NodeId) -> bool {
self.focus == Some(id)
}
pub(crate) fn focus_is_ancestor_of_current(&self) -> bool {
let Some(focus) = self.focus else {
return false;
};
// The current node is on top of the stack; everything below it is an
// ancestor.
let ancestor_count = self.ids_stack.len().saturating_sub(1);
self.ids_stack[..ancestor_count].contains(&focus)
}
pub(crate) fn set_active_descendant(&mut self, id: NodeId) {
if self
.active_descendant
.is_some_and(|existing| existing != id)
{
if cfg!(debug_assertions) {
panic!("active descendant claimed by multiple nodes in one frame");
} else {
log::warn!(
"a11y: multiple nodes claimed the active descendant this frame; \
using last-wins ({id:?})"
);
}
}
self.active_descendant = Some(id);
}
pub(crate) fn set_focus(&mut self, id: NodeId) {
if self.focus.is_some() {
if cfg!(debug_assertions) {
panic!("set_focus called more than once in a single frame");
} else {
log::warn!(
"a11y: set_focus called more than once in a single frame; \
using last-wins ({id:?})"
);
}
}
self.focus = Some(id);
}
fn finalize(&mut self) -> TreeUpdate {
// Stack should contain only the root node
debug_assert_eq!(self.ids_stack.len(), 1);
debug_assert_eq!(self.ids_stack[0], ROOT_NODE_ID);
if self.ids_stack.len() != 1 {
log::error!(
"a11y: Stack imbalance at end of frame: expected 1 (root), got {}. \
Some elements may have pushed without popping.",
self.ids_stack.len()
);
}
// Pop remaining nodes (should just be the root).
while !self.ids_stack.is_empty() {
if let (Some(id), Some(node)) = (self.ids_stack.pop(), self.nodes_stack.pop()) {
self.all_nodes.push((id, node));
}
}
let focus = match self.active_descendant {
Some(id) if self.has_node(id) => id,
Some(id) => {
if cfg!(debug_assertions) {
panic!("active_descendant set to {id:?}, which is not in the tree");
} else {
log::warn!("active_descendant set to {id:?}, which is not in the tree");
self.focus.unwrap_or(ROOT_NODE_ID)
}
}
_ => self.focus.unwrap_or(ROOT_NODE_ID),
};
let nodes = std::mem::take(&mut self.all_nodes);
let update = TreeUpdate {
nodes,
tree: Some(accesskit::Tree::new(ROOT_NODE_ID)),
tree_id: accesskit::TreeId::ROOT,
focus,
};
Self::repair_tree_update(update)
}
/// Accesskit panics on invalid [`TreeUpdate`]s. This function defensively
/// checks invariants that accesskit panics on, and tries to fix them.
fn repair_tree_update(mut update: TreeUpdate) -> TreeUpdate {
let node_ids: FxHashSet<NodeId> = update.nodes.iter().map(|(id, _)| *id).collect();
// Focus must point to a node in the tree.
if !node_ids.contains(&update.focus) {
log::error!(
"a11y: Focused node {:?} is not in the tree ({} nodes). \
Falling back to root. This is a bug in the a11y tree builder.",
update.focus,
update.nodes.len()
);
update.focus = ROOT_NODE_ID;
}
// Every child reference must point to a node in the update.
for (id, node) in &mut update.nodes {
let has_invalid_child = node
.children()
.iter()
.any(|child_id| !node_ids.contains(child_id));
if has_invalid_child {
let children = node.children();
let invalid_count = children
.iter()
.filter(|child_id| !node_ids.contains(child_id))
.count();
log::error!(
"a11y: Node {:?} references {} children not present in the tree. \
Stripping invalid child references.",
id,
invalid_count
);
let valid: Vec<NodeId> = children
.iter()
.copied()
.filter(|child_id| node_ids.contains(child_id))
.collect();
node.set_children(valid);
}
}
update
}
}
#[cfg(test)]
mod tests {
// Import specific items rather than glob-importing `super`, which would pull
// in gpui's own `test` attribute macro and shadow the standard one.
use super::{A11y, A11yNodeBuilder, ROOT_NODE_ID};
use crate::FocusId;
use accesskit::{NodeId, Role};
use std::sync::{Arc, atomic::AtomicBool};
fn test_node() -> accesskit::Node {
accesskit::Node::new(Role::GenericContainer)
}
fn new_builder() -> A11yNodeBuilder {
let mut builder = A11yNodeBuilder::new();
builder.begin_frame(None);
builder
}
fn new_a11y() -> A11y {
let mut a11y = A11y::new(Arc::new(AtomicBool::new(true)), false, None);
a11y.begin_frame();
a11y
}
#[test]
fn accessibility_enabled_is_independent_of_activation() {
for force_disabled in [false, true] {
for active in [false, true] {
let mut a11y = A11y::new(Arc::new(AtomicBool::new(active)), force_disabled, None);
a11y.sync_active_flag();
assert_eq!(a11y.is_enabled(), !force_disabled);
assert_eq!(a11y.is_active(), !force_disabled && active);
}
}
}
#[test]
fn active_descendant_honored_when_container_focused() {
let mut builder = new_builder();
let container = NodeId(1);
let item = NodeId(2);
assert!(builder.push(container, test_node()));
builder.set_focus(container);
assert!(builder.push(item, test_node()));
// The item is on top of the stack; the focused container is its
// ancestor, so the claim is honored.
assert!(builder.focus_is_ancestor_of_current());
builder.set_active_descendant(item);
builder.pop(); // item
builder.pop(); // container
let update = builder.finalize();
assert_eq!(update.focus, item);
}
#[test]
fn active_descendant_honored_for_deep_descendant() {
let mut builder = new_builder();
let container = NodeId(1);
let group = NodeId(2);
let item = NodeId(3);
assert!(builder.push(container, test_node()));
builder.set_focus(container);
assert!(builder.push(group, test_node()));
assert!(builder.push(item, test_node()));
// The item is a grandchild of the focused container; depth doesn't
// matter, the focused ancestor is still on the stack.
assert!(builder.focus_is_ancestor_of_current());
builder.set_active_descendant(item);
builder.pop(); // item
builder.pop(); // group
builder.pop(); // container
let update = builder.finalize();
assert_eq!(update.focus, item);
}
#[test]
fn active_descendant_ignored_when_focus_in_other_subtree() {
let mut builder = new_builder();
let focused_container = NodeId(1);
let focused_leaf = NodeId(2);
let other_container = NodeId(3);
let other_item = NodeId(4);
// First subtree holds real focus.
assert!(builder.push(focused_container, test_node()));
assert!(builder.push(focused_leaf, test_node()));
builder.set_focus(focused_leaf);
builder.pop(); // focused_leaf
builder.pop(); // focused_container
// Second subtree: its item would claim the active descendant, but the
// focus is not on any of its ancestors, so the gate rejects it.
assert!(builder.push(other_container, test_node()));
assert!(builder.push(other_item, test_node()));
assert!(!builder.focus_is_ancestor_of_current());
builder.pop(); // other_item
builder.pop(); // other_container
let update = builder.finalize();
assert_eq!(update.focus, focused_leaf);
}
#[test]
fn active_descendant_ignored_when_nothing_focused() {
let mut builder = new_builder();
let container = NodeId(1);
let item = NodeId(2);
assert!(builder.push(container, test_node()));
assert!(builder.push(item, test_node()));
// Nothing is focused (focus defaults to the root window node), so the
// gate rejects the claim.
assert!(!builder.focus_is_ancestor_of_current());
builder.pop();
builder.pop();
let update = builder.finalize();
assert_eq!(update.focus, ROOT_NODE_ID);
}
#[test]
fn regular_focus_used_when_no_active_descendant() {
let mut builder = new_builder();
let focused = NodeId(1);
assert!(builder.push(focused, test_node()));
builder.set_focus(focused);
builder.pop();
let update = builder.finalize();
assert_eq!(update.focus, focused);
}
#[test]
fn focus_is_ancestor_excludes_self_and_non_ancestors() {
let mut builder = new_builder();
let container = NodeId(1);
let item = NodeId(2);
assert!(builder.push(container, test_node()));
builder.set_focus(container);
// With the focused container itself on top, it is not its own (strict)
// ancestor, so the gate is false.
assert!(!builder.focus_is_ancestor_of_current());
assert!(builder.push(item, test_node()));
// Now the focused container is a strict ancestor of the item on top.
assert!(builder.focus_is_ancestor_of_current());
builder.pop();
builder.pop();
}
// The double-claim guard panics only in debug builds; in release it falls
// back to last-wins with a warning.
#[test]
#[cfg_attr(
debug_assertions,
should_panic(expected = "active descendant claimed by multiple nodes")
)]
fn multiple_active_descendant_claims_panic_in_debug() {
let mut builder = new_builder();
builder.set_active_descendant(NodeId(1));
builder.set_active_descendant(NodeId(2));
}
// Setting focus twice in one frame means two elements both claimed window
// focus; that panics in debug and falls back to last-wins in release.
#[test]
#[cfg_attr(
debug_assertions,
should_panic(expected = "set_focus called more than once")
)]
fn setting_focus_twice_panics_in_debug() {
let mut builder = new_builder();
builder.set_focus(NodeId(1));
builder.set_focus(NodeId(2));
}
// Focusing a node that was never registered as focusable is a bug: panic in
// debug, warn in release.
#[test]
#[cfg_attr(
debug_assertions,
should_panic(expected = "was not registered with set_focusable")
)]
fn set_focus_without_set_focusable() {
let mut a11y = new_a11y();
let node = NodeId(1);
assert!(a11y.nodes.push(node, test_node()));
// set_focusable was never called for `node`.
a11y.set_focus(node);
}
// The focused node cannot also be its own active descendant: panic in
// debug, warn in release.
#[test]
#[cfg_attr(debug_assertions, should_panic(expected = "on the focused node"))]
fn set_active_descendant_on_focused_node() {
let mut a11y = new_a11y();
let node = NodeId(1);
assert!(a11y.nodes.push(node, test_node()));
a11y.set_focusable(node, FocusId::default());
a11y.set_focus(node);
a11y.set_active_descendant(node);
}
// Two sibling children of a focused container both claim the active
// descendant (both pass the focus gate). The second claim is a bug: panic
// in debug, last-wins + warn in release.
#[test]
#[cfg_attr(
debug_assertions,
should_panic(expected = "active descendant claimed by multiple nodes")
)]
fn two_siblings_claiming_active_descendant() {
let mut a11y = new_a11y();
let container = NodeId(1);
let first = NodeId(2);
let second = NodeId(3);
assert!(a11y.nodes.push(container, test_node()));
a11y.set_focusable(container, FocusId::default());
a11y.set_focus(container);
assert!(a11y.nodes.push(first, test_node()));
a11y.set_active_descendant(first);
a11y.nodes.pop(); // first
assert!(a11y.nodes.push(second, test_node()));
a11y.set_active_descendant(second);
a11y.nodes.pop(); // second
a11y.nodes.pop(); // container
}
// Node A is focused; node C (a child of the unfocused node B) claims the
// active descendant. The final tree must still report A as focused.
#[test]
fn active_descendant_in_unfocused_subtree_keeps_real_focus() {
let mut a11y = new_a11y();
let a = NodeId(1);
let b = NodeId(2);
let c = NodeId(3);
assert!(a11y.nodes.push(a, test_node()));
a11y.set_focusable(a, FocusId::default());
a11y.set_focus(a);
a11y.nodes.pop(); // a
assert!(a11y.nodes.push(b, test_node()));
assert!(a11y.nodes.push(c, test_node()));
a11y.set_active_descendant(c);
a11y.nodes.pop(); // c
a11y.nodes.pop(); // b
let update = a11y.end_frame(Default::default());
assert_eq!(update.focus, a);
}
}
+330
View File
@@ -0,0 +1,330 @@
//! 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<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 {
/// 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<String>,
/// 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<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>,
/// 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<CapturedFrame>,
#[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,
) {
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<NodeId, NodeDebugInfo>) {
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<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()),
// 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<String>,
synthetic: Option<bool>,
}
fn node_to_json(
id: NodeId,
node: &accesskit::Node,
ephemeral: &FxHashMap<NodeId, String>,
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<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));
}
// 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<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));
}
// 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()
}
+232
View File
@@ -0,0 +1,232 @@
use std::ops::Deref;
use futures::channel::oneshot;
use crate::{
AnyView, App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, PromptButton, PromptLevel, Render,
StatefulInteractiveElement, Styled, div, opaque_grey, white,
};
use super::Window;
/// The event emitted when a prompt's option is selected.
/// The usize is the index of the selected option, from the actions
/// passed to the prompt.
pub struct PromptResponse(pub usize);
/// A prompt that can be rendered in the window.
pub trait Prompt: EventEmitter<PromptResponse> + Focusable {}
impl<V: EventEmitter<PromptResponse> + Focusable> Prompt for V {}
/// A handle to a prompt that can be used to interact with it.
pub struct PromptHandle {
sender: oneshot::Sender<usize>,
}
impl PromptHandle {
pub(crate) fn new(sender: oneshot::Sender<usize>) -> Self {
Self { sender }
}
/// Construct a new prompt handle from a view of the appropriate types
pub fn with_view<V: Prompt + Render>(
self,
view: Entity<V>,
window: &mut Window,
cx: &mut App,
) -> RenderablePromptHandle {
let mut sender = Some(self.sender);
let previous_focus = window.focused(cx);
let window_handle = window.window_handle();
cx.subscribe(&view, move |_: Entity<V>, e: &PromptResponse, cx| {
if let Some(sender) = sender.take() {
sender.send(e.0).ok();
window_handle
.update(cx, |_, window, cx| {
window.prompt.take();
if let Some(previous_focus) = &previous_focus {
window.focus(previous_focus, cx);
}
})
.ok();
}
})
.detach();
window.focus(&view.focus_handle(cx), cx);
RenderablePromptHandle {
view: Box::new(view),
}
}
}
/// A prompt handle capable of being rendered in a window.
pub struct RenderablePromptHandle {
pub(crate) view: Box<dyn PromptViewHandle>,
}
/// Use this function in conjunction with [App::set_prompt_builder] to force
/// GPUI to always use the fallback prompt renderer.
pub fn fallback_prompt_renderer(
level: PromptLevel,
message: &str,
detail: Option<&str>,
actions: &[PromptButton],
handle: PromptHandle,
window: &mut Window,
cx: &mut App,
) -> RenderablePromptHandle {
let renderer = cx.new(|cx| FallbackPromptRenderer {
_level: level,
message: message.to_string(),
detail: detail.map(ToString::to_string),
actions: actions.to_vec(),
focus: cx.focus_handle(),
});
handle.with_view(renderer, window, cx)
}
/// The default GPUI fallback for rendering prompts, when the platform doesn't support it.
pub struct FallbackPromptRenderer {
_level: PromptLevel,
message: String,
detail: Option<String>,
actions: Vec<PromptButton>,
focus: FocusHandle,
}
impl Render for FallbackPromptRenderer {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let prompt = div()
.cursor_default()
.track_focus(&self.focus)
.w_72()
.bg(white())
.rounded_lg()
.overflow_hidden()
.p_3()
.child(
div()
.w_full()
.flex()
.flex_row()
.justify_around()
.child(div().overflow_hidden().child(self.message.clone())),
)
.children(self.detail.clone().map(|detail| {
div()
.w_full()
.flex()
.flex_row()
.justify_around()
.text_sm()
.mb_2()
.child(div().child(detail))
}))
.children(self.actions.iter().enumerate().map(|(ix, action)| {
div()
.flex()
.flex_row()
.justify_around()
.border_1()
.border_color(opaque_grey(0.2, 0.5))
.mt_1()
.rounded_xs()
.cursor_pointer()
.text_sm()
.child(action.label().clone())
.id(ix)
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(PromptResponse(ix));
cx.stop_propagation();
}))
}));
div()
.size_full()
.child(
div()
.size_full()
.bg(opaque_grey(0.5, 0.6))
.absolute()
.top_0()
.left_0(),
)
.child(
div()
.size_full()
.absolute()
.top_0()
.left_0()
.flex()
.flex_col()
.justify_around()
.child(
div()
.w_full()
.flex()
.flex_row()
.justify_around()
.child(prompt),
),
)
}
}
impl EventEmitter<PromptResponse> for FallbackPromptRenderer {}
impl Focusable for FallbackPromptRenderer {
fn focus_handle(&self, _: &crate::App) -> FocusHandle {
self.focus.clone()
}
}
pub(crate) trait PromptViewHandle {
fn any_view(&self) -> AnyView;
}
impl<V: Prompt + Render> PromptViewHandle for Entity<V> {
fn any_view(&self) -> AnyView {
self.clone().into()
}
}
pub(crate) enum PromptBuilder {
Default,
Custom(
Box<
dyn Fn(
PromptLevel,
&str,
Option<&str>,
&[PromptButton],
PromptHandle,
&mut Window,
&mut App,
) -> RenderablePromptHandle,
>,
),
}
impl Deref for PromptBuilder {
type Target = dyn Fn(
PromptLevel,
&str,
Option<&str>,
&[PromptButton],
PromptHandle,
&mut Window,
&mut App,
) -> RenderablePromptHandle;
fn deref(&self) -> &Self::Target {
match self {
Self::Default => &fallback_prompt_renderer,
Self::Custom(f) => f.as_ref(),
}
}
}