From fd587f0595542396e35e781022f017ef8f0809c2 Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Thu, 26 Dec 2024 18:58:10 +0100 Subject: [PATCH 1/3] feat: Expose combo boxes in consumer and atspi-common --- consumer/src/node.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/consumer/src/node.rs b/consumer/src/node.rs index 5154ac12..06104924 100644 --- a/consumer/src/node.rs +++ b/consumer/src/node.rs @@ -9,8 +9,7 @@ // found in the LICENSE.chromium file. use accesskit::{ - Action, Affine, Live, Node as NodeData, NodeId, Orientation, Point, Rect, Role, TextSelection, - Toggled, + Action, Affine, HasPopup, Live, Node as NodeData, NodeId, Orientation, Point, Rect, Role, TextSelection, Toggled }; use alloc::{ string::{String, ToString}, @@ -703,6 +702,14 @@ impl<'a> Node<'a> { .map(move |id| state.node_by_id(*id).unwrap()) } + pub fn is_expanded(&self) -> Option { + self.data().is_expanded() + } + + pub fn has_popup(&self) -> Option { + self.data().has_popup() + } + pub fn raw_text_selection(&self) -> Option<&TextSelection> { self.data().text_selection() } From 8eb29363e9eb6e397c38823c178a1d3caca6d3fc Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Thu, 26 Dec 2024 19:30:59 +0100 Subject: [PATCH 2/3] fix: Expose combo boxes in the platform adapters --- platforms/atspi-common/src/node.rs | 6 +++ platforms/macos/src/node.rs | 24 ++++++++++ platforms/windows/src/node.rs | 76 ++++++++++++++++++++++++++++-- platforms/windows/src/util.rs | 6 +++ 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/platforms/atspi-common/src/node.rs b/platforms/atspi-common/src/node.rs index 2e47aa23..2ae23c64 100644 --- a/platforms/atspi-common/src/node.rs +++ b/platforms/atspi-common/src/node.rs @@ -287,6 +287,12 @@ impl NodeWrapper<'_> { if state.parent_id().is_none() && state.role() == Role::Window && is_window_focused { atspi_state.insert(State::Active); } + if let Some(expanded) = state.is_expanded() { + atspi_state.insert(State::Expandable); + if expanded { + atspi_state.insert(State::Expanded); + } + } if state.is_text_input() && !state.is_read_only() { atspi_state.insert(State::Editable); } diff --git a/platforms/macos/src/node.rs b/platforms/macos/src/node.rs index 13e1f0f8..91b78af6 100644 --- a/platforms/macos/src/node.rs +++ b/platforms/macos/src/node.rs @@ -961,6 +961,26 @@ declare_class!( .flatten() } + #[method(isAccessibilityExpanded)] + fn is_expanded(&self) -> bool { + self.resolve(|node| node.is_expanded()).flatten().unwrap_or(false) + } + + #[method(setAccessibilityExpanded:)] + fn set_expanded(&self, expanded: bool) { + self.resolve_with_context(|node, context| { + let Some(currently_expanded) = node.is_expanded() else { return }; + if expanded == currently_expanded { + return; + } + context.do_action(ActionRequest { + action: if expanded { Action::Expand } else { Action::Collapse }, + target: node.id(), + data: None + }); + }); + } + #[method(isAccessibilitySelectorAllowed:)] fn is_selector_allowed(&self, selector: Sel) -> bool { self.resolve(|node| { @@ -1018,6 +1038,10 @@ declare_class!( if selector == sel!(accessibilityTabs) { return node.role() == Role::TabList; } + if selector == sel!(isAccessibilityExpanded) + || selector == sel!(setAccessibilityExpanded:) { + return node.supports_expand_collapse(); + } selector == sel!(accessibilityParent) || selector == sel!(accessibilityChildren) || selector == sel!(accessibilityChildrenInNavigationOrder) diff --git a/platforms/windows/src/node.rs b/platforms/windows/src/node.rs index e2ff22bd..fd561f95 100644 --- a/platforms/windows/src/node.rs +++ b/platforms/windows/src/node.rs @@ -11,11 +11,10 @@ #![allow(non_upper_case_globals)] use accesskit::{ - Action, ActionData, ActionRequest, Live, NodeId, NodeIdContent, Orientation, Point, Role, - Toggled, + Action, ActionData, ActionRequest, HasPopup, Live, NodeId, NodeIdContent, Orientation, Point, Role, Toggled }; use accesskit_consumer::{FilterResult, Node, TreeState}; -use std::sync::{atomic::Ordering, Arc, Weak}; +use std::{fmt::Write, sync::{atomic::Ordering, Arc, Weak}}; use windows::{ core::*, Win32::{ @@ -443,6 +442,34 @@ impl NodeWrapper<'_> { self.0.role() == Role::PasswordInput } + fn is_expand_collapse_pattern_supported(&self) -> bool { + self.0.supports_expand_collapse() + } + + fn expand_collapse_state(&self) -> ExpandCollapseState { + // TODO: menus (#27) + match self.0.is_expanded() { + Some(true) => ExpandCollapseState_Expanded, + Some(false) => ExpandCollapseState_Collapsed, + _ => ExpandCollapseState_LeafNode, + } + } + + fn aria_properties(&self) -> Option { + let mut result = WideString::default(); + if let Some(has_popup) = self.0.has_popup() { + result.write_str("haspopup=").ok()?; + result.write_str(match has_popup { + HasPopup::Menu => "menu", + HasPopup::Listbox => "listbox", + HasPopup::Tree => "tree", + HasPopup::Grid => "grid", + HasPopup::Dialog => "dialog", + }).ok()?; + } + Some(result) + } + pub(crate) fn enqueue_property_changes( &self, queue: &mut Vec, @@ -501,7 +528,8 @@ impl NodeWrapper<'_> { IRangeValueProvider, ISelectionItemProvider, ISelectionProvider, - ITextProvider + ITextProvider, + IExpandCollapseProvider )] pub(crate) struct PlatformNode { pub(crate) context: Weak, @@ -963,7 +991,8 @@ properties! { (UIA_IsRequiredForFormPropertyId, is_required), (UIA_IsPasswordPropertyId, is_password), (UIA_PositionInSetPropertyId, position_in_set), - (UIA_SizeOfSetPropertyId, size_of_set) + (UIA_SizeOfSetPropertyId, size_of_set), + (UIA_AriaPropertiesPropertyId, aria_properties) } patterns! { @@ -1103,6 +1132,43 @@ patterns! { } }) } + )), + (UIA_ExpandCollapsePatternId, IExpandCollapseProvider, IExpandCollapseProvider_Impl, is_expand_collapse_pattern_supported, ( + (UIA_ExpandCollapseExpandCollapseStatePropertyId, ExpandCollapseState, expand_collapse_state, ExpandCollapseState) + ), ( + fn Collapse(&self) -> Result<()> { + self.resolve_with_context(|node, context| { + if node.is_disabled() { + return Err(element_not_enabled()); + } + if node.is_expanded() == Some(false) { + return Err(invalid_operation()); + } + context.do_action(ActionRequest { + action: Action::Collapse, + target: node.id(), + data: None, + }); + Ok(()) + }) + }, + + fn Expand(&self) -> Result<()> { + self.resolve_with_context(|node, context| { + if node.is_disabled() { + return Err(element_not_enabled()); + } + if node.is_expanded() == Some(true) { + return Err(invalid_operation()); + } + context.do_action(ActionRequest { + action: Action::Expand, + target: node.id(), + data: None, + }); + Ok(()) + }) + } )) } diff --git a/platforms/windows/src/util.rs b/platforms/windows/src/util.rs index a1260ceb..3d6cf1ae 100644 --- a/platforms/windows/src/util.rs +++ b/platforms/windows/src/util.rs @@ -168,6 +168,12 @@ impl From> for Variant { } } +impl From for Variant { + fn from(value: ExpandCollapseState) -> Self { + Self(value.0.into()) + } +} + fn safe_array_from_primitive_slice(vt: VARENUM, slice: &[T]) -> *mut SAFEARRAY { let sa = unsafe { SafeArrayCreateVector(VARENUM(vt.0), 0, slice.len().try_into().unwrap()) }; if sa.is_null() { From 015ba8e3e94022ef23d1bc8c9c2e2f72a14f3caf Mon Sep 17 00:00:00 2001 From: Arnold Loubriat Date: Wed, 19 Mar 2025 21:14:44 +0100 Subject: [PATCH 3/3] fix: Add an example --- platforms/winit/examples/combobox.rs | 386 +++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 platforms/winit/examples/combobox.rs diff --git a/platforms/winit/examples/combobox.rs b/platforms/winit/examples/combobox.rs new file mode 100644 index 00000000..51894426 --- /dev/null +++ b/platforms/winit/examples/combobox.rs @@ -0,0 +1,386 @@ +use accesskit::{Action, ActionRequest, Node, NodeId, Rect, Role, Tree, TreeUpdate}; +use accesskit_winit::{Adapter, Event as AccessKitEvent, WindowEvent as AccessKitWindowEvent}; +use std::error::Error; +use winit::{ + application::ApplicationHandler, + dpi::LogicalSize, + event::{ElementState, KeyEvent, WindowEvent}, + event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy}, + keyboard::Key, + window::{Window, WindowId}, +}; + +const WINDOW_TITLE: &str = "ComboBox example"; + +const WINDOW_ID: NodeId = NodeId(0); +const COMBOBOX_ID: NodeId = NodeId(1); +const POPUP_ID: NodeId = NodeId(2); +const INITIAL_FOCUS: NodeId = COMBOBOX_ID; + +const LANGUAGES: &[(&str, NodeId)] = &[ + ("English", NodeId(3)), + ("Esperanto", NodeId(4)), + ("French", NodeId(5)), + ("Spanish", NodeId(6)), +]; + +const ITEM_HEIGHT: f64 = 40.0; + +const COMBOBOX_RECT: Rect = Rect { + x0: 100.0, + y0: 20.0, + x1: 300.0, + y1: 20.0 + ITEM_HEIGHT, +}; + +const POPUP_RECT: Rect = Rect { + x0: COMBOBOX_RECT.x0, + y0: COMBOBOX_RECT.y0, + x1: COMBOBOX_RECT.x1, + y1: COMBOBOX_RECT.y0 + ITEM_HEIGHT * (LANGUAGES.len() as f64), +}; + +fn build_combobox(expanded: bool) -> Node { + let mut node = Node::new(Role::ComboBox); + node.set_bounds(COMBOBOX_RECT); + node.set_children([POPUP_ID]); + node.set_expanded(expanded); + node.set_label("Select your language"); + node.add_action(Action::Click); + node.add_action(Action::Focus); + node.add_action(if expanded { + Action::Collapse + } else { + Action::Expand + }); + node +} + +fn build_popup() -> Node { + let mut node = Node::new(Role::MenuListPopup); + node.set_bounds(POPUP_RECT); + node.set_children( + LANGUAGES + .iter() + .map(|(_, id)| id) + .copied() + .collect::>(), + ); + node.set_size_of_set(LANGUAGES.len()); + node +} + +fn build_option(index: usize, selected: bool) -> Node { + let mut node = Node::new(Role::MenuListOption); + node.set_bounds(Rect { + x0: POPUP_RECT.x0, + y0: POPUP_RECT.y0 + (index as f64) * ITEM_HEIGHT, + x1: POPUP_RECT.x1, + y1: POPUP_RECT.y0 + (index as f64) * ITEM_HEIGHT + ITEM_HEIGHT, + }); + node.set_label(LANGUAGES[index].0); + node.set_position_in_set(index); + node.set_selected(selected); + node.add_action(Action::Click); + node.add_action(Action::Focus); + node +} + +struct UiState { + focus: NodeId, + selected_index: usize, + previous_selected_index: Option, +} + +impl UiState { + fn new() -> Self { + Self { + focus: INITIAL_FOCUS, + selected_index: 0, + previous_selected_index: None, + } + } + + fn build_root(&mut self) -> Node { + let mut node = Node::new(Role::Window); + node.set_children([COMBOBOX_ID]); + node.set_label(WINDOW_TITLE); + node + } + + fn is_expanded(&self) -> bool { + self.previous_selected_index.is_some() + } + + fn build_initial_tree(&mut self) -> TreeUpdate { + let root = self.build_root(); + let combobox = build_combobox(self.is_expanded()); + let popup = build_popup(); + let tree = Tree::new(WINDOW_ID); + let mut update = TreeUpdate { + nodes: vec![ + (WINDOW_ID, root), + (COMBOBOX_ID, combobox), + (POPUP_ID, popup), + ], + tree: Some(tree), + focus: self.focus, + }; + for (i, (_, id)) in LANGUAGES.iter().enumerate() { + let is_selected = i == self.selected_index; + update.nodes.push((*id, build_option(i, is_selected))); + } + update + } + + fn set_focus(&mut self, adapter: &mut Adapter, id: NodeId) { + self.focus = id; + adapter.update_if_active(|| TreeUpdate { + nodes: vec![], + tree: None, + focus: self.focus, + }); + } + + fn expand(&mut self, adapter: &mut Adapter) { + self.previous_selected_index = Some(self.selected_index); + self.focus = LANGUAGES[self.selected_index].1; + adapter.update_if_active(|| { + let combobox = build_combobox(self.is_expanded()); + TreeUpdate { + nodes: vec![(COMBOBOX_ID, combobox)], + tree: None, + focus: self.focus, + } + }); + } + + fn confirm_selection_and_collapse(&mut self, adapter: &mut Adapter) { + self.previous_selected_index = None; + self.focus = COMBOBOX_ID; + adapter.update_if_active(|| { + let combobox = build_combobox(self.is_expanded()); + TreeUpdate { + nodes: vec![(COMBOBOX_ID, combobox)], + tree: None, + focus: self.focus, + } + }); + } + + fn discard_selection_and_collapse(&mut self, adapter: &mut Adapter) { + let previous_selection = self.previous_selected_index.take(); + if let Some(previous_selection) = previous_selection { + self.selected_index = previous_selection; + } + self.focus = COMBOBOX_ID; + adapter.update_if_active(|| { + let combobox = build_combobox(self.is_expanded()); + let selected_option = build_option(self.selected_index, true); + let mut update = TreeUpdate { + nodes: vec![ + (COMBOBOX_ID, combobox), + (LANGUAGES[self.selected_index].1, selected_option), + ], + tree: None, + focus: self.focus, + }; + if let Some(previous_selection) = previous_selection + .filter(|previous_selection| *previous_selection != self.selected_index) + { + update.nodes.push(( + LANGUAGES[previous_selection].1, + build_option(previous_selection, false), + )); + } + update + }); + } + + fn update_selection(&mut self, adapter: &mut Adapter, index: usize) { + let previous_selection = self.selected_index; + self.selected_index = index; + if self.is_expanded() { + self.focus = LANGUAGES[index].1; + } + adapter.update_if_active(|| TreeUpdate { + nodes: vec![ + (LANGUAGES[index].1, build_option(index, true)), + ( + LANGUAGES[previous_selection].1, + build_option(previous_selection, false), + ), + ], + tree: None, + focus: self.focus, + }); + } +} + +struct WindowState { + window: Window, + adapter: Adapter, + ui: UiState, +} + +impl WindowState { + fn new(window: Window, adapter: Adapter, ui: UiState) -> Self { + Self { + window, + adapter, + ui, + } + } +} + +struct Application { + event_loop_proxy: EventLoopProxy, + window: Option, +} + +impl Application { + fn new(event_loop_proxy: EventLoopProxy) -> Self { + Self { + event_loop_proxy, + window: None, + } + } + + fn create_window(&mut self, event_loop: &ActiveEventLoop) -> Result<(), Box> { + let window_attributes = Window::default_attributes() + .with_title(WINDOW_TITLE) + .with_visible(false) + .with_inner_size(LogicalSize::new(400, 300)); + + let window = event_loop.create_window(window_attributes)?; + let adapter = + Adapter::with_event_loop_proxy(event_loop, &window, self.event_loop_proxy.clone()); + window.set_visible(true); + + self.window = Some(WindowState::new(window, adapter, UiState::new())); + Ok(()) + } +} + +impl ApplicationHandler for Application { + fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, event: WindowEvent) { + let window = match &mut self.window { + Some(window) => window, + None => return, + }; + let adapter = &mut window.adapter; + let state = &mut window.ui; + + adapter.process_event(&window.window, &event); + match event { + WindowEvent::CloseRequested => { + self.window = None; + } + WindowEvent::KeyboardInput { + event: + KeyEvent { + logical_key: virtual_code, + state: ElementState::Pressed, + .. + }, + .. + } => match virtual_code { + Key::Named(winit::keyboard::NamedKey::ArrowDown) => { + if state.selected_index < LANGUAGES.len() - 1 { + state.update_selection(adapter, state.selected_index + 1); + } + } + Key::Named(winit::keyboard::NamedKey::ArrowUp) => { + if state.selected_index > 0 { + state.update_selection(adapter, state.selected_index - 1); + } + } + Key::Named(winit::keyboard::NamedKey::Space) if state.is_expanded() => { + state.confirm_selection_and_collapse(adapter); + } + Key::Named(winit::keyboard::NamedKey::Space) => { + state.expand(adapter); + } + Key::Named(winit::keyboard::NamedKey::Escape) if state.is_expanded() => { + state.discard_selection_and_collapse(adapter); + } + _ => (), + }, + _ => (), + } + } + + fn user_event(&mut self, _: &ActiveEventLoop, user_event: AccessKitEvent) { + let window = match &mut self.window { + Some(window) => window, + None => return, + }; + let adapter = &mut window.adapter; + let state = &mut window.ui; + + match user_event.window_event { + AccessKitWindowEvent::InitialTreeRequested => { + adapter.update_if_active(|| state.build_initial_tree()); + } + AccessKitWindowEvent::ActionRequested(ActionRequest { action, target, .. }) => { + if target == COMBOBOX_ID { + if (action == Action::Click || action == Action::Expand) && !state.is_expanded() + { + state.expand(adapter); + } else if (action == Action::Click || action == Action::Collapse) + && state.is_expanded() + { + state.discard_selection_and_collapse(adapter); + } else if action == Action::Focus { + state.set_focus(adapter, COMBOBOX_ID); + } + } else if action == Action::Click && state.is_expanded() { + if let Some(selection) = LANGUAGES.iter().position(|(_, id)| *id == target) { + state.selected_index = selection; + state.confirm_selection_and_collapse(adapter); + } + } else if action == Action::Focus && state.is_expanded() { + if LANGUAGES.iter().any(|(_, id)| *id == target) { + state.set_focus(adapter, target); + } + } + } + AccessKitWindowEvent::AccessibilityDeactivated => (), + } + } + + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + self.create_window(event_loop) + .expect("failed to create initial window"); + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_none() { + event_loop.exit(); + } + } +} + +fn main() -> Result<(), Box> { + println!("This example has no visible GUI, and a keyboard interface:"); + println!("- [Space] expands the combobox."); + println!("- [Up] and [Down] arrow keys update the selection."); + println!("- [Escape] collapses the combobox, discarding the selection."); + #[cfg(target_os = "windows")] + println!("Enable Narrator with [Win]+[Ctrl]+[Enter] (or [Win]+[Enter] on older versions of Windows)."); + #[cfg(all( + feature = "accesskit_unix", + any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + target_os = "openbsd" + ) + ))] + println!("Enable Orca with [Super]+[Alt]+[S]."); + + let event_loop = EventLoop::with_user_event().build()?; + let mut state = Application::new(event_loop.create_proxy()); + event_loop.run_app(&mut state).map_err(Into::into) +}