From 058b7148bb8ebcdca3b870cbbf9ebb6e69d913c0 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Sat, 21 Dec 2024 19:35:24 +0100 Subject: [PATCH 1/3] feat: Add list box support to the `consumer` and `atspi-common` crates --- consumer/src/node.rs | 88 +++++++++++ platforms/atspi-common/src/adapter.rs | 55 ++++++- platforms/atspi-common/src/events.rs | 1 + platforms/atspi-common/src/node.rs | 180 ++++++++++++++++++++++- platforms/atspi-common/src/simplified.rs | 72 +++++++++ 5 files changed, 385 insertions(+), 11 deletions(-) diff --git a/consumer/src/node.rs b/consumer/src/node.rs index a9c2c1a5e..45f0ff363 100644 --- a/consumer/src/node.rs +++ b/consumer/src/node.rs @@ -421,6 +421,33 @@ impl<'a> Node<'a> { self.supports_action(Action::Click) } + pub fn is_selectable(&self) -> bool { + // It's selectable if it has the attribute, whether it's true or false. + self.is_selected().is_some() && !self.is_disabled() + } + + pub fn is_multiselectable(&self) -> bool { + self.data().is_multiselectable() + } + + pub fn size_of_set_from_container( + &self, + filter: &impl Fn(&Node) -> FilterResult, + ) -> Option { + self.selection_container(filter) + .and_then(|c| c.size_of_set()) + } + + pub fn size_of_set(&self) -> Option { + // TODO: compute this if it is not provided (#9). + self.data().size_of_set() + } + + pub fn position_in_set(&self) -> Option { + // TODO: compute this if it is not provided (#9). + self.data().position_in_set() + } + pub fn supports_toggle(&self) -> bool { self.toggled().is_some() } @@ -623,6 +650,44 @@ impl<'a> Node<'a> { self.data().is_selected() } + pub fn is_item_like(&self) -> bool { + matches!( + self.role(), + Role::Article + | Role::Comment + | Role::ListItem + | Role::MenuItem + | Role::MenuItemRadio + | Role::Tab + | Role::MenuItemCheckBox + | Role::TreeItem + | Role::ListBoxOption + | Role::MenuListOption + | Role::RadioButton + | Role::DescriptionListTerm + | Role::Term + ) + } + + pub fn is_container_with_selectable_children(&self) -> bool { + matches!( + self.role(), + Role::ComboBox + | Role::EditableComboBox + | Role::Grid + | Role::ListBox + | Role::ListGrid + | Role::Menu + | Role::MenuBar + | Role::MenuListPopup + | Role::RadioGroup + | Role::TabList + | Role::Toolbar + | Role::Tree + | Role::TreeGrid + ) + } + pub fn raw_text_selection(&self) -> Option<&TextSelection> { self.data().text_selection() } @@ -690,6 +755,29 @@ impl<'a> Node<'a> { } None } + + pub fn selection_container(&self, filter: &impl Fn(&Node) -> FilterResult) -> Option> { + self.filtered_parent(&|parent| { + if parent.is_container_with_selectable_children() { + filter(parent) + } else { + FilterResult::ExcludeNode + } + }) + } + + pub fn items( + &self, + filter: impl Fn(&Node) -> FilterResult + 'a, + ) -> impl DoubleEndedIterator> + FusedIterator> + 'a { + self.filtered_children(move |child| { + if child.is_item_like() { + filter(child) + } else { + FilterResult::ExcludeNode + } + }) + } } struct SpacePrefixingWriter { diff --git a/platforms/atspi-common/src/adapter.rs b/platforms/atspi-common/src/adapter.rs index cdd79aef5..07b0de5d5 100644 --- a/platforms/atspi-common/src/adapter.rs +++ b/platforms/atspi-common/src/adapter.rs @@ -32,6 +32,7 @@ struct AdapterChangeHandler<'a> { added_nodes: HashSet, removed_nodes: HashSet, checked_text_change: HashSet, + selection_changed: HashSet, } impl<'a> AdapterChangeHandler<'a> { @@ -41,6 +42,7 @@ impl<'a> AdapterChangeHandler<'a> { added_nodes: HashSet::new(), removed_nodes: HashSet::new(), checked_text_change: HashSet::new(), + selection_changed: HashSet::new(), } } @@ -53,8 +55,8 @@ impl<'a> AdapterChangeHandler<'a> { let role = node.role(); let is_root = node.is_root(); - let node = NodeWrapper(node); - let interfaces = node.interfaces(); + let wrapper = NodeWrapper(node); + let interfaces = wrapper.interfaces(); self.adapter.register_interfaces(node.id(), interfaces); if is_root && role == Role::Window { let adapter_index = self @@ -66,13 +68,16 @@ impl<'a> AdapterChangeHandler<'a> { self.adapter.window_created(adapter_index, node.id()); } - let live = node.live(); + let live = wrapper.live(); if live != Live::None { - if let Some(name) = node.name() { + if let Some(name) = wrapper.name() { self.adapter .emit_object_event(node.id(), ObjectEvent::Announcement(name, live)); } } + if let Some(true) = node.is_selected() { + self.enqueue_selection_changed_if_needed(node); + } } fn add_subtree(&mut self, node: &Node) { @@ -91,14 +96,17 @@ impl<'a> AdapterChangeHandler<'a> { let role = node.role(); let is_root = node.is_root(); - let node = NodeWrapper(node); + let wrapper = NodeWrapper(node); if is_root && role == Role::Window { self.adapter.window_destroyed(node.id()); } self.adapter .emit_object_event(node.id(), ObjectEvent::StateChanged(State::Defunct, true)); self.adapter - .unregister_interfaces(node.id(), node.interfaces()); + .unregister_interfaces(node.id(), wrapper.interfaces()); + if let Some(true) = node.is_selected() { + self.enqueue_selection_changed_if_needed(node); + } } fn remove_subtree(&mut self, node: &Node) { @@ -235,6 +243,36 @@ impl<'a> AdapterChangeHandler<'a> { } } } + + fn enqueue_selection_changed_if_needed_parent(&mut self, node: Node) { + if !node.is_container_with_selectable_children() { + return; + } + let id = node.id(); + if self.selection_changed.contains(&id) { + return; + } + self.selection_changed.insert(id); + } + + fn enqueue_selection_changed_if_needed(&mut self, node: &Node) { + if !node.is_item_like() { + return; + } + if let Some(node) = node.selection_container(&filter) { + self.enqueue_selection_changed_if_needed_parent(node); + } + } + + fn emit_selection_changed(&mut self) { + for id in self.selection_changed.iter() { + if self.removed_nodes.contains(id) { + continue; + } + self.adapter + .emit_object_event(*id, ObjectEvent::SelectionChanged); + } + } } impl TreeChangeHandler for AdapterChangeHandler<'_> { @@ -275,6 +313,9 @@ impl TreeChangeHandler for AdapterChangeHandler<'_> { let bounds = *self.adapter.context.read_root_window_bounds(); new_wrapper.notify_changes(&bounds, self.adapter, &old_wrapper); self.emit_text_selection_change(Some(old_node), new_node); + if new_node.is_selected() != old_node.is_selected() { + self.enqueue_selection_changed_if_needed(new_node); + } } } @@ -466,6 +507,8 @@ impl Adapter { let mut handler = AdapterChangeHandler::new(self); let mut tree = self.context.tree.write().unwrap(); tree.update_and_process_changes(update, &mut handler); + drop(tree); + handler.emit_selection_changed(); } pub fn update_window_focus_state(&mut self, is_focused: bool) { diff --git a/platforms/atspi-common/src/events.rs b/platforms/atspi-common/src/events.rs index 6c95ab37b..e17769bbd 100644 --- a/platforms/atspi-common/src/events.rs +++ b/platforms/atspi-common/src/events.rs @@ -40,6 +40,7 @@ pub enum ObjectEvent { ChildAdded(usize, NodeId), ChildRemoved(NodeId), PropertyChanged(Property), + SelectionChanged, StateChanged(State, bool), TextInserted { start_index: i32, diff --git a/platforms/atspi-common/src/node.rs b/platforms/atspi-common/src/node.rs index 5a0979d28..4487e9240 100644 --- a/platforms/atspi-common/src/node.rs +++ b/platforms/atspi-common/src/node.rs @@ -294,9 +294,16 @@ impl NodeWrapper<'_> { if state.is_focusable() { atspi_state.insert(State::Focusable); } + let filter_result = filter(self.0); + if filter_result == FilterResult::Include { + atspi_state.insert(State::Visible | State::Showing); + } if state.is_required() { atspi_state.insert(State::Required); } + if state.is_multiselectable() { + atspi_state.insert(State::Multiselectable); + } if let Some(orientation) = state.orientation() { atspi_state.insert(if orientation == Orientation::Horizontal { State::Horizontal @@ -304,10 +311,6 @@ impl NodeWrapper<'_> { State::Vertical }); } - let filter_result = filter(self.0); - if filter_result == FilterResult::Include { - atspi_state.insert(State::Visible | State::Showing); - } if atspi_role != AtspiRole::ToggleButton && state.toggled().is_some() { atspi_state.insert(State::Checkable); } @@ -355,11 +358,32 @@ impl NodeWrapper<'_> { atspi_state } + fn placeholder(&self) -> Option<&str> { + self.0.placeholder() + } + + fn position_in_set(&self) -> Option { + self.0.position_in_set().map(|p| (p + 1).to_string()) + } + + fn size_of_set(&self) -> Option { + self.0 + .size_of_set_from_container(&filter) + .map(|s| s.to_string()) + } + fn attributes(&self) -> HashMap<&'static str, String> { let mut attributes = HashMap::new(); - if let Some(placeholder) = self.0.placeholder() { + if let Some(placeholder) = self.placeholder() { attributes.insert("placeholder-text", placeholder.to_string()); } + if let Some(position_in_set) = self.position_in_set() { + attributes.insert("posinset", position_in_set); + } + if let Some(size_of_set) = self.size_of_set() { + attributes.insert("setsize", size_of_set); + } + attributes } @@ -375,6 +399,10 @@ impl NodeWrapper<'_> { self.0.raw_bounds().is_some() || self.is_root() } + fn supports_selection(&self) -> bool { + self.0.is_container_with_selectable_children() + } + fn supports_text(&self) -> bool { self.0.supports_text_ranges() } @@ -391,6 +419,9 @@ impl NodeWrapper<'_> { if self.supports_component() { interfaces.insert(Interface::Component); } + if self.supports_selection() { + interfaces.insert(Interface::Selection); + } if self.supports_text() { interfaces.insert(Interface::Text); } @@ -626,6 +657,20 @@ impl PlatformNode { }) } + fn resolve_for_selection_with_context(&self, f: F) -> Result + where + for<'a> F: FnOnce(Node<'a>, &Context) -> Result, + { + self.resolve_with_context(|node, context| { + let wrapper = NodeWrapper(&node); + if wrapper.supports_selection() { + f(node, context) + } else { + Err(Error::UnsupportedInterface) + } + }) + } + fn resolve_for_text_with_context(&self, f: F) -> Result where for<'a> F: FnOnce(Node<'a>, &Context) -> Result, @@ -647,6 +692,20 @@ impl PlatformNode { self.resolve_with_context(|node, _| f(node)) } + fn resolve_for_selection(&self, f: F) -> Result + where + for<'a> F: FnOnce(Node<'a>) -> Result, + { + self.resolve(|node| { + let wrapper = NodeWrapper(&node); + if wrapper.supports_selection() { + f(node) + } else { + Err(Error::UnsupportedInterface) + } + }) + } + fn resolve_for_text(&self, f: F) -> Result where for<'a> F: FnOnce(Node<'a>) -> Result, @@ -810,6 +869,13 @@ impl PlatformNode { }) } + pub fn supports_selection(&self) -> Result { + self.resolve(|node| { + let wrapper = NodeWrapper(&node); + Ok(wrapper.supports_selection()) + }) + } + pub fn supports_text(&self) -> Result { self.resolve(|node| { let wrapper = NodeWrapper(&node); @@ -951,6 +1017,110 @@ impl PlatformNode { Ok(true) } + pub fn n_selected_children(&self) -> Result { + self.resolve_for_selection(|node| { + node.items(filter) + .filter(|item| item.is_selected() == Some(true)) + .count() + .try_into() + .map_err(|_| Error::TooManyChildren) + }) + } + + pub fn selected_child(&self, selected_child_index: usize) -> Result> { + self.resolve_for_selection(|node| { + Ok(node + .items(filter) + .filter(|item| item.is_selected() == Some(true)) + .nth(selected_child_index) + .map(|node| node.id())) + }) + } + + pub fn select_child(&self, child_index: usize) -> Result { + self.resolve_for_selection_with_context(|node, context| { + if let Some(child) = node.filtered_children(filter).nth(child_index) { + if let Some(true) = child.is_selected() { + Ok(true) + } else if child.is_selectable() && child.is_clickable() { + context.do_action(ActionRequest { + action: Action::Click, + target: child.id(), + data: None, + }); + Ok(true) + } else { + Ok(false) + } + } else { + Err(Error::Defunct) + } + }) + } + + pub fn deselect_selected_child(&self, selected_child_index: usize) -> Result { + self.resolve_for_selection_with_context(|node, context| { + if let Some(child) = node + .items(filter) + .filter(|c| c.is_selected() == Some(true)) + .nth(selected_child_index) + { + if child.is_clickable() { + context.do_action(ActionRequest { + action: Action::Click, + target: child.id(), + data: None, + }); + Ok(true) + } else { + Ok(false) + } + } else { + Err(Error::Defunct) + } + }) + } + + pub fn is_child_selected(&self, child_index: usize) -> Result { + self.resolve_for_selection(|node| { + node.filtered_children(filter) + .nth(child_index) + .map(|child| child.is_item_like() && child.is_selected() == Some(true)) + .ok_or(Error::Defunct) + }) + } + + pub fn select_all(&self) -> Result { + // We don't support selecting all children at once. + Ok(false) + } + + pub fn clear_selection(&self) -> Result { + // We don't support deselecting all children at once. + Ok(false) + } + + pub fn deselect_child(&self, child_index: usize) -> Result { + self.resolve_for_selection_with_context(|node, context| { + if let Some(child) = node.filtered_children(filter).nth(child_index) { + if let Some(false) = child.is_selected() { + Ok(true) + } else if child.is_selectable() && child.is_clickable() { + context.do_action(ActionRequest { + action: Action::Click, + target: child.id(), + data: None, + }); + Ok(true) + } else { + Ok(false) + } + } else { + Err(Error::Defunct) + } + }) + } + pub fn character_count(&self) -> Result { self.resolve_for_text(|node| { node.document_range() diff --git a/platforms/atspi-common/src/simplified.rs b/platforms/atspi-common/src/simplified.rs index b2ee51e61..8c914b72f 100644 --- a/platforms/atspi-common/src/simplified.rs +++ b/platforms/atspi-common/src/simplified.rs @@ -222,6 +222,71 @@ impl Accessible { } } + pub fn supports_selection(&self) -> Result { + match self { + Self::Node(node) => node.supports_selection(), + Self::Root(_) => Ok(false), + } + } + + pub fn n_selected_children(&self) -> Result { + match self { + Self::Node(node) => node.n_selected_children(), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn selected_child(&self, selected_child_index: usize) -> Result> { + match self { + Self::Node(node) => node + .selected_child(selected_child_index) + .map(|id| id.map(|id| Self::Node(node.relative(id)))), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn select_child(&self, child_index: usize) -> Result { + match self { + Self::Node(node) => node.select_child(child_index), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn deselect_selected_child(&self, selected_child_index: usize) -> Result { + match self { + Self::Node(node) => node.deselect_selected_child(selected_child_index), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn is_child_selected(&self, child_index: usize) -> Result { + match self { + Self::Node(node) => node.is_child_selected(child_index), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn select_all(&self) -> Result { + match self { + Self::Node(node) => node.select_all(), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn clear_selection(&self) -> Result { + match self { + Self::Node(node) => node.clear_selection(), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + + pub fn deselect_child(&self, child_index: usize) -> Result { + match self { + Self::Node(node) => node.deselect_child(child_index), + Self::Root(_) => Err(Error::UnsupportedInterface), + } + } + pub fn supports_text(&self) -> Result { match self { Self::Node(node) => node.supports_text(), @@ -545,6 +610,13 @@ impl Event { Property::Value(value) => EventData::F64(value), }), }, + ObjectEvent::SelectionChanged => Self { + kind: "object:selection-changed".into(), + source, + detail1: 0, + detail2: 0, + data: None, + }, ObjectEvent::StateChanged(state, value) => Self { kind: format!("object:state-changed:{}", String::from(state)), source, From 0b7e65c432cc65392eb4d8cc48e780041925fc0e Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Sat, 21 Dec 2024 19:36:42 +0100 Subject: [PATCH 2/3] fix: Add list box support to the platform adapters --- platforms/macos/src/event.rs | 47 +++++- platforms/macos/src/node.rs | 97 +++++++++++ platforms/unix/src/atspi/bus.rs | 27 ++++ platforms/unix/src/atspi/interfaces/mod.rs | 2 + .../unix/src/atspi/interfaces/selection.rs | 82 ++++++++++ platforms/windows/src/adapter.rs | 150 +++++++++++++++++- platforms/windows/src/node.rs | 103 +++++++++--- platforms/windows/src/util.rs | 4 + 8 files changed, 485 insertions(+), 27 deletions(-) create mode 100644 platforms/unix/src/atspi/interfaces/selection.rs diff --git a/platforms/macos/src/event.rs b/platforms/macos/src/event.rs index 2bb851ea5..13ea288a9 100644 --- a/platforms/macos/src/event.rs +++ b/platforms/macos/src/event.rs @@ -137,6 +137,7 @@ pub(crate) struct EventGenerator { context: Rc, events: Vec, text_changed: HashSet, + selected_rows_changed: HashSet, } impl EventGenerator { @@ -145,6 +146,7 @@ impl EventGenerator { context, events: Vec::new(), text_changed: HashSet::new(), + selected_rows_changed: HashSet::new(), } } @@ -181,6 +183,30 @@ impl EventGenerator { self.insert_text_change_if_needed_parent(node); } } + + fn enqueue_selected_rows_change_if_needed_parent(&mut self, node: Node) { + if !node.is_container_with_selectable_children() { + return; + } + let id = node.id(); + if self.selected_rows_changed.contains(&id) { + return; + } + self.events.push(QueuedEvent::Generic { + node_id: id, + notification: unsafe { NSAccessibilitySelectedRowsChangedNotification }, + }); + self.selected_rows_changed.insert(id); + } + + fn enqueue_selected_rows_change_if_needed(&mut self, node: &Node) { + if !node.is_item_like() { + return; + } + if let Some(node) = node.selection_container(&filter) { + self.enqueue_selected_rows_change_if_needed_parent(node); + } + } } impl TreeChangeHandler for EventGenerator { @@ -189,6 +215,9 @@ impl TreeChangeHandler for EventGenerator { if filter(node) != FilterResult::Include { return; } + if let Some(true) = node.is_selected() { + self.enqueue_selected_rows_change_if_needed(node); + } if node.value().is_some() && node.live() != Live::Off { self.events .push(QueuedEvent::live_region_announcement(node)); @@ -199,7 +228,14 @@ impl TreeChangeHandler for EventGenerator { if old_node.raw_value() != new_node.raw_value() { self.insert_text_change_if_needed(new_node); } + let old_node_was_filtered_out = filter(old_node) != FilterResult::Include; if filter(new_node) != FilterResult::Include { + if !old_node_was_filtered_out + && old_node.is_item_like() + && old_node.is_selected() == Some(true) + { + self.enqueue_selected_rows_change_if_needed(old_node); + } return; } let node_id = new_node.id(); @@ -230,11 +266,17 @@ impl TreeChangeHandler for EventGenerator { && new_node.live() != Live::Off && (new_node.value() != old_node.value() || new_node.live() != old_node.live() - || filter(old_node) != FilterResult::Include) + || old_node_was_filtered_out) { self.events .push(QueuedEvent::live_region_announcement(new_node)); } + if new_node.is_item_like() + && (new_node.is_selected() != old_node.is_selected() + || (old_node_was_filtered_out && new_node.is_selected() == Some(true))) + { + self.enqueue_selected_rows_change_if_needed(new_node); + } } fn focus_moved(&mut self, _old_node: Option<&Node>, new_node: Option<&Node>) { @@ -248,6 +290,9 @@ impl TreeChangeHandler for EventGenerator { fn node_removed(&mut self, node: &Node) { self.insert_text_change_if_needed(node); + if let Some(true) = node.is_selected() { + self.enqueue_selected_rows_change_if_needed(node); + } self.events.push(QueuedEvent::NodeDestroyed(node.id())); } } diff --git a/platforms/macos/src/node.rs b/platforms/macos/src/node.rs index 6c3364323..3a8de1e3c 100644 --- a/platforms/macos/src/node.rs +++ b/platforms/macos/src/node.rs @@ -410,6 +410,22 @@ declare_class!( self.children_internal() } + #[method_id(accessibilitySelectedChildren)] + fn selected_children(&self) -> Option>> { + self.resolve_with_context(|node, context| { + if !node.is_container_with_selectable_children() { + return None; + } + let platform_nodes = node + .items(filter) + .filter(|item| item.is_selected() == Some(true)) + .map(|child| context.get_or_create_platform_node(child.id())) + .collect::>>(); + Some(NSArray::from_vec(platform_nodes)) + }) + .flatten() + } + #[method(accessibilityFrame)] fn frame(&self) -> NSRect { self.resolve_with_context(|node, context| { @@ -813,6 +829,75 @@ declare_class!( .unwrap_or(false) } + #[method(isAccessibilitySelected)] + fn is_selected(&self) -> bool { + self.resolve(|node| node.is_selected()).flatten().unwrap_or(false) + } + + #[method(setAccessibilitySelected:)] + fn set_selected(&self, selected: bool) { + self.resolve_with_context(|node, context| { + if !node.is_clickable() || !node.is_selectable() { + return; + } + if node.is_selected() == Some(selected) { + return; + } + context.do_action(ActionRequest { + action: Action::Click, + target: node.id(), + data: None, + }); + }); + } + + #[method_id(accessibilityRows)] + fn rows(&self) -> Option>> { + self.resolve_with_context(|node, context| { + if !node.is_container_with_selectable_children() { + return None; + } + let platform_nodes = node + .items(filter) + .map(|child| context.get_or_create_platform_node(child.id())) + .collect::>>(); + Some(NSArray::from_vec(platform_nodes)) + }) + .flatten() + } + + #[method_id(accessibilitySelectedRows)] + fn selected_rows(&self) -> Option>> { + self.resolve_with_context(|node, context| { + if !node.is_container_with_selectable_children() { + return None; + } + let platform_nodes = node + .items(filter) + .filter(|item| item.is_selected() == Some(true)) + .map(|child| context.get_or_create_platform_node(child.id())) + .collect::>>(); + Some(NSArray::from_vec(platform_nodes)) + }) + .flatten() + } + + #[method(accessibilityPerformPick)] + fn pick(&self) -> bool { + self.resolve_with_context(|node, context| { + let selectable = node.is_clickable() && node.is_selectable(); + if selectable { + context.do_action(ActionRequest { + action: Action::Click, + target: node.id(), + data: None, + }); + } + selectable + }) + .unwrap_or(false) + } + #[method(isAccessibilitySelectorAllowed:)] fn is_selector_allowed(&self, selector: Sel) -> bool { self.resolve(|node| { @@ -849,9 +934,21 @@ declare_class!( // the expected VoiceOver behavior. return node.supports_text_ranges() && !node.is_read_only(); } + if selector == sel!(isAccessibilitySelected) { + return node.is_selectable(); + } + if selector == sel!(accessibilityRows) + || selector == sel!(accessibilitySelectedRows) + { + return node.is_container_with_selectable_children() + } + if selector == sel!(accessibilityPerformPick) { + return node.is_clickable() && node.is_selectable(); + } selector == sel!(accessibilityParent) || selector == sel!(accessibilityChildren) || selector == sel!(accessibilityChildrenInNavigationOrder) + || selector == sel!(accessibilitySelectedChildren) || selector == sel!(accessibilityFrame) || selector == sel!(accessibilityRole) || selector == sel!(accessibilitySubrole) diff --git a/platforms/unix/src/atspi/bus.rs b/platforms/unix/src/atspi/bus.rs index 9b289d2a5..015767b85 100644 --- a/platforms/unix/src/atspi/bus.rs +++ b/platforms/unix/src/atspi/bus.rs @@ -119,6 +119,13 @@ impl Bus { ) .await?; } + if new_interfaces.contains(Interface::Selection) { + self.register_interface( + &path, + SelectionInterface::new(bus_name.clone(), node.clone()), + ) + .await?; + } if new_interfaces.contains(Interface::Text) { self.register_interface(&path, TextInterface::new(node.clone())) .await?; @@ -164,6 +171,10 @@ impl Bus { self.unregister_interface::(&path) .await?; } + if old_interfaces.contains(Interface::Selection) { + self.unregister_interface::(&path) + .await?; + } if old_interfaces.contains(Interface::Text) { self.unregister_interface::(&path).await?; } @@ -206,6 +217,7 @@ impl Bus { ObjectEvent::CaretMoved(_) => "TextCaretMoved", ObjectEvent::ChildAdded(_, _) | ObjectEvent::ChildRemoved(_) => "ChildrenChanged", ObjectEvent::PropertyChanged(_) => "PropertyChange", + ObjectEvent::SelectionChanged => "SelectionChanged", ObjectEvent::StateChanged(_, _) => "StateChanged", ObjectEvent::TextInserted { .. } | ObjectEvent::TextRemoved { .. } => "TextChanged", ObjectEvent::TextSelectionChanged => "TextSelectionChanged", @@ -350,6 +362,21 @@ impl Bus { ) .await } + ObjectEvent::SelectionChanged => { + self.emit_event( + target, + interface, + signal, + EventBody { + kind: "", + detail1: 0, + detail2: 0, + any_data: 0i32.into(), + properties, + }, + ) + .await + } ObjectEvent::StateChanged(state, value) => { self.emit_event( target, diff --git a/platforms/unix/src/atspi/interfaces/mod.rs b/platforms/unix/src/atspi/interfaces/mod.rs index 607c4f8fe..1cb4c910b 100644 --- a/platforms/unix/src/atspi/interfaces/mod.rs +++ b/platforms/unix/src/atspi/interfaces/mod.rs @@ -7,6 +7,7 @@ mod accessible; mod action; mod application; mod component; +mod selection; mod text; mod value; @@ -31,5 +32,6 @@ pub(crate) use accessible::*; pub(crate) use action::*; pub(crate) use application::*; pub(crate) use component::*; +pub(crate) use selection::*; pub(crate) use text::*; pub(crate) use value::*; diff --git a/platforms/unix/src/atspi/interfaces/selection.rs b/platforms/unix/src/atspi/interfaces/selection.rs new file mode 100644 index 000000000..b311a905d --- /dev/null +++ b/platforms/unix/src/atspi/interfaces/selection.rs @@ -0,0 +1,82 @@ +// Copyright 2024 The AccessKit Authors. All rights reserved. +// Licensed under the Apache License, Version 2.0 (found in +// the LICENSE-APACHE file) or the MIT license (found in +// the LICENSE-MIT file), at your option. + +use accesskit_atspi_common::PlatformNode; +use zbus::{fdo, interface, names::OwnedUniqueName}; + +use crate::atspi::{ObjectId, OwnedObjectAddress}; + +pub(crate) struct SelectionInterface { + bus_name: OwnedUniqueName, + node: PlatformNode, +} + +impl SelectionInterface { + pub fn new(bus_name: OwnedUniqueName, node: PlatformNode) -> Self { + Self { bus_name, node } + } + + fn map_error(&self) -> impl '_ + FnOnce(accesskit_atspi_common::Error) -> fdo::Error { + |error| crate::util::map_error_from_node(&self.node, error) + } +} + +#[interface(name = "org.a11y.atspi.Selection")] +impl SelectionInterface { + #[zbus(property)] + fn n_selected_children(&self) -> fdo::Result { + self.node.n_selected_children().map_err(self.map_error()) + } + + fn get_selected_child(&self, selected_child_index: i32) -> fdo::Result<(OwnedObjectAddress,)> { + let child = self + .node + .selected_child(map_child_index(selected_child_index)?) + .map_err(self.map_error())? + .map(|child| ObjectId::Node { + adapter: self.node.adapter_id(), + node: child, + }); + Ok(super::optional_object_address(&self.bus_name, child)) + } + + fn select_child(&self, child_index: i32) -> fdo::Result { + self.node + .select_child(map_child_index(child_index)?) + .map_err(self.map_error()) + } + + fn deselect_selected_child(&self, selected_child_index: i32) -> fdo::Result { + self.node + .deselect_selected_child(map_child_index(selected_child_index)?) + .map_err(self.map_error()) + } + + fn is_child_selected(&self, child_index: i32) -> fdo::Result { + self.node + .is_child_selected(map_child_index(child_index)?) + .map_err(self.map_error()) + } + + fn select_all(&self) -> fdo::Result { + self.node.select_all().map_err(self.map_error()) + } + + fn clear_selection(&self) -> fdo::Result { + self.node.clear_selection().map_err(self.map_error()) + } + + fn deselect_child(&self, child_index: i32) -> fdo::Result { + self.node + .deselect_child(map_child_index(child_index)?) + .map_err(self.map_error()) + } +} + +fn map_child_index(index: i32) -> fdo::Result { + index + .try_into() + .map_err(|_| fdo::Error::InvalidArgs("Index can't be negative.".into())) +} diff --git a/platforms/windows/src/adapter.rs b/platforms/windows/src/adapter.rs index 8ce5a2b79..212f4efc5 100644 --- a/platforms/windows/src/adapter.rs +++ b/platforms/windows/src/adapter.rs @@ -8,7 +8,7 @@ use accesskit::{ TreeUpdate, }; use accesskit_consumer::{FilterResult, Node, Tree, TreeChangeHandler}; -use hashbrown::HashSet; +use hashbrown::{HashMap, HashSet}; use std::sync::{atomic::Ordering, Arc}; use windows::Win32::{ Foundation::*, @@ -36,6 +36,7 @@ struct AdapterChangeHandler<'a> { context: &'a Arc, queue: Vec, text_changed: HashSet, + selection_changed: HashMap, } impl<'a> AdapterChangeHandler<'a> { @@ -44,6 +45,7 @@ impl<'a> AdapterChangeHandler<'a> { context, queue: Vec::new(), text_changed: HashSet::new(), + selection_changed: HashMap::new(), } } } @@ -80,6 +82,126 @@ impl AdapterChangeHandler<'_> { self.insert_text_change_if_needed_parent(node); } } + + fn handle_selection_state_change(&mut self, node: &Node, is_selected: bool) { + // If `node` belongs to a selection container, then map the events with the + // selection container as the key because |FinalizeSelectionEvents| needs to + // determine whether or not there is only one element selected in order to + // optimize what platform events are sent. + let key = if let Some(container) = node.selection_container(&filter) { + container.id() + } else { + node.id() + }; + + let changes = self + .selection_changed + .entry(key) + .or_insert_with(|| SelectionChanges { + added_items: HashSet::new(), + removed_items: HashSet::new(), + }); + if is_selected { + changes.added_items.insert(node.id()); + } else { + changes.removed_items.insert(node.id()); + } + } + + fn enqueue_selection_changes(&mut self, tree: &Tree) { + let tree_state = tree.state(); + for (id, changes) in self.selection_changed.iter() { + let node = tree_state.node_by_id(*id).unwrap(); + // Determine if `node` is a selection container with one selected child in + // order to optimize what platform events are sent. + let mut container = None; + let mut only_selected_child = None; + if node.is_container_with_selectable_children() { + container = Some(node); + for child in node.filtered_children(filter) { + if let Some(true) = child.is_selected() { + if only_selected_child.is_none() { + only_selected_child = Some(child); + } else { + only_selected_child = None; + break; + } + } + } + } + + if let Some(only_selected_child) = only_selected_child { + self.queue.push(QueuedEvent::Simple { + element: PlatformNode::new(self.context, only_selected_child.id()).into(), + event_id: UIA_SelectionItem_ElementSelectedEventId, + }); + self.queue.push(QueuedEvent::PropertyChanged { + element: PlatformNode::new(self.context, only_selected_child.id()).into(), + property_id: UIA_SelectionItemIsSelectedPropertyId, + old_value: false.into(), + new_value: true.into(), + }); + for child_id in changes.removed_items.iter() { + let platform_node = PlatformNode::new(self.context, *child_id); + self.queue.push(QueuedEvent::PropertyChanged { + element: platform_node.into(), + property_id: UIA_SelectionItemIsSelectedPropertyId, + old_value: true.into(), + new_value: false.into(), + }); + } + } else { + // Per UIA documentation, beyond the "invalidate limit" we're supposed to + // fire a 'SelectionInvalidated' event. The exact value isn't specified, + // but System.Windows.Automation.Provider uses a value of 20. + const INVALIDATE_LIMIT: usize = 20; + if let Some(container) = container.filter(|_| { + changes.added_items.len() + changes.removed_items.len() > INVALIDATE_LIMIT + }) { + let platform_node = PlatformNode::new(self.context, container.id()); + self.queue.push(QueuedEvent::Simple { + element: platform_node.into(), + event_id: UIA_Selection_InvalidatedEventId, + }); + } else { + let container_is_multiselectable = + container.is_some_and(|c| c.is_multiselectable()); + for added_id in changes.added_items.iter() { + self.queue.push(QueuedEvent::Simple { + element: PlatformNode::new(self.context, *added_id).into(), + event_id: match container_is_multiselectable { + true => UIA_SelectionItem_ElementAddedToSelectionEventId, + false => UIA_SelectionItem_ElementSelectedEventId, + }, + }); + self.queue.push(QueuedEvent::PropertyChanged { + element: PlatformNode::new(self.context, *added_id).into(), + property_id: UIA_SelectionItemIsSelectedPropertyId, + old_value: false.into(), + new_value: true.into(), + }); + } + for removed_id in changes.removed_items.iter() { + self.queue.push(QueuedEvent::Simple { + element: PlatformNode::new(self.context, *removed_id).into(), + event_id: UIA_SelectionItem_ElementRemovedFromSelectionEventId, + }); + self.queue.push(QueuedEvent::PropertyChanged { + element: PlatformNode::new(self.context, *removed_id).into(), + property_id: UIA_SelectionItemIsSelectedPropertyId, + old_value: true.into(), + new_value: false.into(), + }); + } + } + } + } + } +} + +struct SelectionChanges { + added_items: HashSet, + removed_items: HashSet, } impl TreeChangeHandler for AdapterChangeHandler<'_> { @@ -97,13 +219,23 @@ impl TreeChangeHandler for AdapterChangeHandler<'_> { event_id: UIA_LiveRegionChangedEventId, }); } + if wrapper.is_selection_item_pattern_supported() && wrapper.is_selected() { + self.handle_selection_state_change(node, true); + } } fn node_updated(&mut self, old_node: &Node, new_node: &Node) { if old_node.raw_value() != new_node.raw_value() { self.insert_text_change_if_needed(new_node); } + let old_node_was_filtered_out = filter(old_node) != FilterResult::Include; if filter(new_node) != FilterResult::Include { + if !old_node_was_filtered_out { + let old_wrapper = NodeWrapper(old_node); + if old_wrapper.is_selection_item_pattern_supported() && old_wrapper.is_selected() { + self.handle_selection_state_change(old_node, false); + } + } return; } let platform_node = PlatformNode::new(self.context, new_node.id()); @@ -115,7 +247,7 @@ impl TreeChangeHandler for AdapterChangeHandler<'_> { if new_name.is_some() && new_node.live() != Live::Off && (new_node.live() != old_node.live() - || filter(old_node) != FilterResult::Include + || old_node_was_filtered_out || new_name != old_wrapper.name()) { self.queue.push(QueuedEvent::Simple { @@ -123,6 +255,12 @@ impl TreeChangeHandler for AdapterChangeHandler<'_> { event_id: UIA_LiveRegionChangedEventId, }); } + if new_wrapper.is_selection_item_pattern_supported() + && (new_wrapper.is_selected() != old_wrapper.is_selected() + || (old_node_was_filtered_out && new_wrapper.is_selected())) + { + self.handle_selection_state_change(new_node, new_wrapper.is_selected()); + } } fn focus_moved(&mut self, _old_node: Option<&Node>, new_node: Option<&Node>) { @@ -133,6 +271,13 @@ impl TreeChangeHandler for AdapterChangeHandler<'_> { fn node_removed(&mut self, node: &Node) { self.insert_text_change_if_needed(node); + if filter(node) != FilterResult::Include { + return; + } + let wrapper = NodeWrapper(node); + if wrapper.is_selection_item_pattern_supported() { + self.handle_selection_state_change(node, false); + } } // TODO: handle other events (#20) @@ -230,6 +375,7 @@ impl Adapter { let mut handler = AdapterChangeHandler::new(context); let mut tree = context.tree.write().unwrap(); tree.update_and_process_changes(update_factory(), &mut handler); + handler.enqueue_selection_changes(&tree); Some(QueuedEvents(handler.queue)) } } diff --git a/platforms/windows/src/node.rs b/platforms/windows/src/node.rs index 524f23637..0e9f128c9 100644 --- a/platforms/windows/src/node.rs +++ b/platforms/windows/src/node.rs @@ -378,7 +378,7 @@ impl NodeWrapper<'_> { self.0.is_required() } - fn is_selection_item_pattern_supported(&self) -> bool { + pub(crate) fn is_selection_item_pattern_supported(&self) -> bool { match self.0.role() { // TODO: tables (#29) // https://www.w3.org/TR/core-aam-1.1/#mapping_state-property_table @@ -398,7 +398,7 @@ impl NodeWrapper<'_> { } } - fn is_selected(&self) -> bool { + pub(crate) fn is_selected(&self) -> bool { match self.0.role() { // https://www.w3.org/TR/core-aam-1.1/#mapping_state-property_table // SelectionItem.IsSelected is set according to the True or False @@ -411,6 +411,27 @@ impl NodeWrapper<'_> { } } + fn position_in_set(&self) -> Option { + self.0 + .position_in_set() + .and_then(|p| p.try_into().ok()) + .map(|p: i32| p + 1) + } + + fn size_of_set(&self) -> Option { + self.0 + .size_of_set_from_container(&filter) + .and_then(|s| s.try_into().ok()) + } + + fn is_selection_pattern_supported(&self) -> bool { + self.0.is_container_with_selectable_children() + } + + fn is_multiselectable(&self) -> bool { + self.0.is_multiselectable() + } + fn is_text_pattern_supported(&self) -> bool { self.0.supports_text_ranges() } @@ -432,15 +453,6 @@ impl NodeWrapper<'_> { element: &IRawElementProviderSimple, old: &NodeWrapper, ) { - if self.is_selection_item_pattern_supported() - && self.is_selected() - && !(old.is_selection_item_pattern_supported() && old.is_selected()) - { - queue.push(QueuedEvent::Simple { - element: element.clone(), - event_id: UIA_SelectionItem_ElementSelectedEventId, - }); - } if self.is_text_pattern_supported() && old.is_text_pattern_supported() && self.0.raw_text_selection() != old.0.raw_text_selection() @@ -480,6 +492,7 @@ impl NodeWrapper<'_> { IValueProvider, IRangeValueProvider, ISelectionItemProvider, + ISelectionProvider, ITextProvider )] pub(crate) struct PlatformNode { @@ -614,6 +627,23 @@ impl PlatformNode { self.do_action(|| (Action::Click, None)) } + fn set_selected(&self, selected: bool) -> Result<()> { + self.resolve_with_context(|node, context| { + if node.is_disabled() { + return Err(element_not_enabled()); + } + let wrapper = NodeWrapper(&node); + if selected != wrapper.is_selected() { + context.do_action(ActionRequest { + action: Action::Click, + target: node.id(), + data: None, + }); + } + Ok(()) + }) + } + fn relative(&self, node_id: NodeId) -> Self { Self { context: self.context.clone(), @@ -890,7 +920,9 @@ properties! { (AutomationId, automation_id), (ClassName, class_name), (Orientation, orientation), - (IsRequiredForForm, is_required) + (IsRequiredForForm, is_required), + (PositionInSet, position_in_set), + (SizeOfSet, size_of_set) } patterns! { @@ -931,28 +963,51 @@ patterns! { }) } )), - (SelectionItem, is_selection_item_pattern_supported, ( - (IsSelected, is_selected, BOOL) - ), ( + (SelectionItem, is_selection_item_pattern_supported, (), ( + fn IsSelected(&self) -> Result { + self.resolve(|node| { + let wrapper = NodeWrapper(&node); + Ok(wrapper.is_selected().into()) + }) + }, + fn Select(&self) -> Result<()> { - self.click() + self.set_selected(true) }, fn AddToSelection(&self) -> Result<()> { - // TODO: implement when we work on list boxes (#23) - Err(not_implemented()) + self.set_selected(true) }, fn RemoveFromSelection(&self) -> Result<()> { - // TODO: implement when we work on list boxes (#23) - Err(not_implemented()) + self.set_selected(false) }, fn SelectionContainer(&self) -> Result { - // TODO: implement when we work on list boxes (#23) - // We return E_FAIL here because that's what Chromium does - // if it can't find a container. - Err(E_FAIL.into()) + self.resolve(|node| { + if let Some(container) = node.selection_container(&filter) { + Ok(self.relative(container.id()).into()) + } else { + Err(E_FAIL.into()) + } + }) + } + )), + (Selection, is_selection_pattern_supported, ( + (CanSelectMultiple, is_multiselectable, BOOL), + (IsSelectionRequired, is_required, BOOL) + ), ( + fn GetSelection(&self) -> Result<*mut SAFEARRAY> { + self.resolve(|node| { + let selection: Vec<_> = node + .items(&filter) + .filter(|item| item.is_selected() == Some(true)) + .map(|item| self.relative(item.id())) + .map(IRawElementProviderSimple::from) + .filter_map(|item| item.cast::().ok()) + .collect(); + Ok(safe_array_from_com_slice(&selection)) + }) } )), (Text, is_text_pattern_supported, (), ( diff --git a/platforms/windows/src/util.rs b/platforms/windows/src/util.rs index e674ba3b1..bb746ee82 100644 --- a/platforms/windows/src/util.rs +++ b/platforms/windows/src/util.rs @@ -207,6 +207,10 @@ pub(crate) fn element_not_available() -> Error { HRESULT(UIA_E_ELEMENTNOTAVAILABLE as _).into() } +pub(crate) fn element_not_enabled() -> Error { + HRESULT(UIA_E_ELEMENTNOTENABLED as _).into() +} + pub(crate) fn invalid_operation() -> Error { HRESULT(UIA_E_INVALIDOPERATION as _).into() } From 716fa44851aeb2fbff8cd9bfe6d8e43211c9f1b1 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Sun, 29 Dec 2024 12:21:32 +0100 Subject: [PATCH 3/3] Don't emit selection events on Windows if the node was already removed --- platforms/windows/src/adapter.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/platforms/windows/src/adapter.rs b/platforms/windows/src/adapter.rs index 212f4efc5..666c9ef8e 100644 --- a/platforms/windows/src/adapter.rs +++ b/platforms/windows/src/adapter.rs @@ -111,7 +111,9 @@ impl AdapterChangeHandler<'_> { fn enqueue_selection_changes(&mut self, tree: &Tree) { let tree_state = tree.state(); for (id, changes) in self.selection_changed.iter() { - let node = tree_state.node_by_id(*id).unwrap(); + let Some(node) = tree_state.node_by_id(*id) else { + continue; + }; // Determine if `node` is a selection container with one selected child in // order to optimize what platform events are sent. let mut container = None;