From d10cffe1733fd62eda483aa1a9893d5acd6b9247 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Tue, 1 Sep 2026 17:52:06 +0300 Subject: [PATCH] fix: Base AT-SPI ENABLED/SENSITIVE states on the disabled flag STATE_ENABLED and STATE_SENSITIVE were set by the else arm of the read-only branch, so they never consulted `is_disabled`. For any role that doesn't support aria-readonly, a disabled node was exposed as enabled and sensitive; conversely a read-only-but-enabled control lost both states. Core-AAM 1.2 maps aria-disabled=true to "STATE_ENABLED not exposed" and aria-disabled=false to STATE_ENABLED, and its aria-readonly=true row withdraws only STATE_EDITABLE. The other four adapters already derive the platform enabled state from `is_disabled`. --- adapters/atspi-common/src/node.rs | 74 ++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/adapters/atspi-common/src/node.rs b/adapters/atspi-common/src/node.rs index f3223e530..c9181518e 100644 --- a/adapters/atspi-common/src/node.rs +++ b/adapters/atspi-common/src/node.rs @@ -375,7 +375,12 @@ impl NodeWrapper<'_> { if state.is_read_only_supported() && state.is_read_only_or_disabled() { atspi_state.insert(State::ReadOnly); - } else { + } + + // `Enabled` and `Sensitive` track the disabled flag, not the read-only + // branch above. A read-only control is still enabled, and a disabled + // control is not, whether or not its role supports read-only. + if !state.is_disabled() { atspi_state.insert(State::Enabled | State::Sensitive); } @@ -2029,3 +2034,70 @@ pub struct CacheNode { pub role: AtspiRole, pub states: StateSet, } + +#[cfg(test)] +mod tests { + use super::NodeWrapper; + use accesskit::{Node, NodeId, Role, TreeId, TreeInfo, TreeUpdate}; + use accesskit_consumer::Tree; + use atspi_common::{State, StateSet}; + + fn state_of(node: Node) -> StateSet { + let mut root = Node::new(Role::Window); + root.set_children(vec![NodeId(1)]); + let tree = Tree::new( + TreeUpdate { + nodes: vec![(NodeId(0), root), (NodeId(1), node)], + tree: Some(TreeInfo::new(NodeId(0))), + tree_id: TreeId::ROOT, + focus: NodeId(0), + }, + true, + ); + let state = tree.state(); + let node = state.root().children().next().unwrap(); + NodeWrapper(&node).state(true) + } + + #[test] + fn disabled_node_is_neither_enabled_nor_sensitive() { + // A role that doesn't support read-only, so the read-only branch + // can't stand in for the disabled flag. + for role in [Role::Button, Role::Link, Role::MenuItem, Role::Tab] { + let mut node = Node::new(role); + node.set_disabled(); + let state = state_of(node); + assert!(!state.contains(State::Enabled), "{role:?}"); + assert!(!state.contains(State::Sensitive), "{role:?}"); + } + } + + #[test] + fn enabled_node_is_enabled_and_sensitive() { + for role in [Role::Button, Role::Link, Role::MenuItem, Role::Tab] { + let state = state_of(Node::new(role)); + assert!(state.contains(State::Enabled), "{role:?}"); + assert!(state.contains(State::Sensitive), "{role:?}"); + } + } + + #[test] + fn read_only_text_input_is_still_enabled_and_sensitive() { + let mut node = Node::new(Role::TextInput); + node.set_read_only(); + let state = state_of(node); + assert!(state.contains(State::ReadOnly)); + assert!(state.contains(State::Enabled)); + assert!(state.contains(State::Sensitive)); + } + + #[test] + fn disabled_text_input_is_read_only_and_not_enabled() { + let mut node = Node::new(Role::TextInput); + node.set_disabled(); + let state = state_of(node); + assert!(state.contains(State::ReadOnly)); + assert!(!state.contains(State::Enabled)); + assert!(!state.contains(State::Sensitive)); + } +}