diff --git a/Cargo.lock b/Cargo.lock index a8defc9531..b269eb81be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,96 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "accesskit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" + +[[package]] +name = "accesskit_atspi_common" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_macos" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle 0.6.2", + "winit", +] + [[package]] name = "adler2" version = "2.0.1" @@ -394,6 +484,56 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-connection" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus", +] + +[[package]] +name = "atspi-proxies" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -2458,6 +2598,7 @@ dependencies = [ name = "iced_core" version = "0.14.0-dev" dependencies = [ + "accesskit", "bitflags 2.10.0", "bytes", "glam", @@ -2557,6 +2698,7 @@ dependencies = [ name = "iced_runtime" version = "0.14.0-dev" dependencies = [ + "accesskit", "bytes", "iced_core", "iced_futures", @@ -2654,6 +2796,8 @@ dependencies = [ name = "iced_winit" version = "0.14.0-dev" dependencies = [ + "accesskit", + "accesskit_winit", "iced_debug", "iced_program", "log", @@ -4639,6 +4783,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.37.5" @@ -7152,16 +7306,38 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", + "windows-collections 0.3.2", "windows-core 0.62.2", - "windows-future", - "windows-numerics", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -7198,6 +7374,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -7211,6 +7400,17 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + [[package]] name = "windows-future" version = "0.3.2" @@ -7219,7 +7419,7 @@ checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core 0.62.2", "windows-link 0.2.1", - "windows-threading", + "windows-threading 0.2.1", ] [[package]] @@ -7300,6 +7500,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -7502,6 +7712,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" @@ -7910,6 +8129,30 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-lockstep" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29e96e38ded30eeab90b6ba88cb888d70aef4e7489b6cd212c5e5b5ec38045b6" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6821851fa840b708b4cbbaf6241868cabc85a2dc22f426361b0292bfc0b836" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + [[package]] name = "zbus_macros" version = "5.12.0" @@ -7937,6 +8180,19 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus_xml" +version = "5.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589e9a02bfafb9754bb2340a9e3b38f389772684c63d9637e76b1870377bec29" +dependencies = [ + "quick-xml 0.36.2", + "serde", + "static_assertions", + "zbus_names", + "zvariant", +] + [[package]] name = "zeno" version = "0.3.3" diff --git a/Cargo.toml b/Cargo.toml index 7d2d6f3fa6..0a39ed74a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -231,6 +231,14 @@ wgpu = "27.0" window_clipboard = "0.4.1" winit = { git = "https://github.com/iced-rs/winit.git", rev = "05b8ff17a06562f0a10bb46e6eaacbe2a95cb5ed" } +# Accessibility +accesskit = "0.21.1" +accesskit_winit = "0.29.2" + +[patch.crates-io] +# Redirect accesskit_winit's winit dependency to iced's fork +winit = { git = "https://github.com/iced-rs/winit.git", rev = "05b8ff17a06562f0a10bb46e6eaacbe2a95cb5ed" } + [workspace.lints.rust] rust_2018_idioms = { level = "deny", priority = -1 } missing_docs = "deny" diff --git a/core/Cargo.toml b/core/Cargo.toml index 3129644637..f603df630b 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -20,6 +20,7 @@ basic-shaping = [] advanced-shaping = [] [dependencies] +accesskit.workspace = true bitflags.workspace = true bytes.workspace = true glam.workspace = true diff --git a/core/src/accessibility/mod.rs b/core/src/accessibility/mod.rs new file mode 100644 index 0000000000..917970878a --- /dev/null +++ b/core/src/accessibility/mod.rs @@ -0,0 +1,39 @@ +//! Build and display accessibility information for widgets. +//! +//! This module provides types for describing widget semantics to +//! assistive technologies like screen readers. + +use crate::widget::Id; + +mod node; + +pub use node::AccessibilityNode; + +// Re-export commonly used AccessKit types +pub use accesskit::{Action, NodeId, Role}; + +/// Convert a widget::Id to a stable AccessKit NodeId. +/// +/// This implementation ensures that the same widget ID always produces +/// the same NodeId, which is essential for stable accessibility trees. +/// +/// # Example +/// ``` +/// use iced_core::accessibility::NodeId; +/// use iced_core::widget::Id; +/// +/// let widget_id = Id::new("my-button"); +/// let node_id: NodeId = (&widget_id).into(); +/// // The same widget_id always produces the same node_id +/// assert_eq!(node_id, NodeId::from(&widget_id)); +/// ``` +impl From<&Id> for NodeId { + fn from(id: &Id) -> Self { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + id.hash(&mut hasher); + NodeId(hasher.finish()) + } +} diff --git a/core/src/accessibility/node.rs b/core/src/accessibility/node.rs new file mode 100644 index 0000000000..28e28dd095 --- /dev/null +++ b/core/src/accessibility/node.rs @@ -0,0 +1,246 @@ +//! Accessibility node for describing widget semantics. + +use crate::Rectangle; +use crate::widget::Id; +use std::any::Any; + +pub use accesskit::Role; + +/// An accessibility node describing a widget's semantics. +/// +/// This is a simplified wrapper around AccessKit's node types, +/// providing a builder API for common accessibility properties. +pub struct AccessibilityNode { + /// The bounding box of the widget in screen coordinates + pub bounds: Rectangle, + + /// The semantic role of this widget + pub role: Option, + + /// The accessible label/name for this widget + pub label: Option, + + /// The current value (for inputs, sliders, etc.) + pub value: Option, + + /// Whether the widget is enabled for interaction + pub enabled: bool, + + /// Whether the widget can receive keyboard focus + pub focusable: bool, + + /// Optional widget ID for maximum accessibility tree stability. + /// + /// When provided, this ID is used to generate stable AccessKit NodeIds + /// that remain consistent even when the widget tree structure changes. + /// This is particularly useful for dynamic lists or frequently changing UIs. + /// + /// If not provided, a path-based ID is generated automatically. + pub widget_id: Option, + + /// Whether this widget is a leaf node in the accessibility tree. + /// + /// When `true`, child widgets will not have their accessibility nodes + /// included in the tree. This is useful for widgets like buttons that + /// want to present themselves as a single accessible element with their + /// content embedded in the label, rather than as a container with children. + /// + /// Default: `false` + pub is_leaf_node: bool, + + /// Optional action callback that will be invoked when an accessibility action occurs. + /// + /// This is a closure that produces a type-erased Message when called. + /// It will be invoked when an accessibility action (like Click) is performed on this node. + /// TreeBuilder will extract this and store it in the action callback map. + /// + /// Using a closure avoids requiring Clone on the Message type, following iced's pattern. + pub on_action: Option Box + Send>>, +} + +impl AccessibilityNode { + /// Creates a new [`AccessibilityNode`] with the given bounds. + /// + /// By default, the node has no role, label, or value, and is enabled + /// but not focusable. + /// + /// # Example + /// ``` + /// use iced_core::accessibility::AccessibilityNode; + /// use iced_core::Rectangle; + /// + /// let node = AccessibilityNode::new(Rectangle { + /// x: 0.0, + /// y: 0.0, + /// width: 100.0, + /// height: 50.0, + /// }); + /// ``` + pub fn new(bounds: Rectangle) -> Self { + Self { + bounds, + role: None, + label: None, + value: None, + enabled: true, + focusable: false, + widget_id: None, + is_leaf_node: false, + on_action: None, + } + } + + /// Sets the semantic role of this widget. + /// + /// # Example + /// ``` + /// use iced_core::accessibility::{AccessibilityNode, Role}; + /// use iced_core::Rectangle; + /// + /// let node = AccessibilityNode::new(Rectangle::default()) + /// .role(Role::Button); + /// ``` + pub fn role(mut self, role: Role) -> Self { + self.role = Some(role); + self + } + + /// Sets the accessible label for this widget. + /// + /// # Example + /// ``` + /// use iced_core::accessibility::AccessibilityNode; + /// use iced_core::Rectangle; + /// + /// let node = AccessibilityNode::new(Rectangle::default()) + /// .label("Click me"); + /// ``` + pub fn label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Sets the current value for this widget. + /// + /// Typically used for text inputs, sliders, or other widgets with state. + /// + /// # Example + /// ``` + /// use iced_core::accessibility::AccessibilityNode; + /// use iced_core::Rectangle; + /// + /// let node = AccessibilityNode::new(Rectangle::default()) + /// .value("Hello, world!"); + /// ``` + pub fn value(mut self, value: impl Into) -> Self { + self.value = Some(value.into()); + self + } + + /// Sets whether this widget is enabled for interaction. + /// + /// Disabled widgets are typically grayed out and don't respond to input. + /// + /// # Example + /// ``` + /// use iced_core::accessibility::AccessibilityNode; + /// use iced_core::Rectangle; + /// + /// let node = AccessibilityNode::new(Rectangle::default()) + /// .enabled(false); + /// ``` + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Sets whether this widget can receive keyboard focus. + /// + /// # Example + /// ``` + /// use iced_core::accessibility::AccessibilityNode; + /// use iced_core::Rectangle; + /// + /// let node = AccessibilityNode::new(Rectangle::default()) + /// .focusable(true); + /// ``` + pub fn focusable(mut self, focusable: bool) -> Self { + self.focusable = focusable; + self + } + + /// Sets an optional widget ID for maximum accessibility tree stability. + /// + /// When a widget ID is provided, it's used to generate a stable AccessKit + /// NodeId that remains consistent across UI updates, even when the widget + /// tree structure changes. This is particularly important for dynamic lists + /// and frequently changing UIs. + /// + /// If no widget ID is provided, a path-based ID is generated automatically + /// based on the widget's position in the tree. + /// + /// # Example + /// ``` + /// use iced_core::accessibility::AccessibilityNode; + /// use iced_core::widget::Id; + /// use iced_core::Rectangle; + /// + /// let node = AccessibilityNode::new(Rectangle::default()) + /// .widget_id(Some(Id::new("my-button"))); + /// ``` + pub fn widget_id(mut self, id: Option) -> Self { + self.widget_id = id; + self + } + + /// Sets whether this widget is a leaf node in the accessibility tree. + /// + /// When set to `true`, child widgets will not have their accessibility + /// nodes included in the tree. This is useful for widgets like buttons + /// that want to present themselves as a single accessible element rather + /// than as a container with children. + /// + /// # Example + /// ``` + /// use iced_core::accessibility::{AccessibilityNode, Role}; + /// use iced_core::Rectangle; + /// + /// let node = AccessibilityNode::new(Rectangle::default()) + /// .role(Role::Button) + /// .label("Click me") + /// .is_leaf_node(true); + /// ``` + pub fn is_leaf_node(mut self, is_leaf: bool) -> Self { + self.is_leaf_node = is_leaf; + self + } + + /// Sets the action callback for this widget. + /// + /// When an accessibility action (like Click) is performed on this node, + /// the provided message will be published to the application. + /// + /// The message is captured in a closure to avoid requiring Clone on the Message type. + /// + /// # Example + /// ```ignore + /// use iced_core::accessibility::{AccessibilityNode, Role}; + /// use iced_core::Rectangle; + /// + /// #[derive(Clone)] + /// enum Message { + /// ButtonPressed, + /// } + /// + /// let node = AccessibilityNode::new(Rectangle::default()) + /// .role(Role::Button) + /// .on_action(Message::ButtonPressed); + /// ``` + pub fn on_action(mut self, message: M) -> Self + where + M: 'static + Clone + Send, + { + self.on_action = Some(Box::new(move || Box::new(message.clone()))); + self + } +} diff --git a/core/src/event.rs b/core/src/event.rs index 7f0ab91438..3a0364ee77 100644 --- a/core/src/event.rs +++ b/core/src/event.rs @@ -5,6 +5,8 @@ use crate::mouse; use crate::touch; use crate::window; +use accesskit::ActionRequest; + /// A user interface event. /// /// _**Note:** This type is largely incomplete! If you need to track @@ -27,6 +29,9 @@ pub enum Event { /// An input method event InputMethod(input_method::Event), + + /// An accessibility event + AccessKit(ActionRequest), } /// The status of an [`Event`] after being processed. diff --git a/core/src/lib.rs b/core/src/lib.rs index e75ef2a7e5..4a2ae80029 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -9,6 +9,7 @@ #![doc( html_logo_url = "https://raw.githubusercontent.com/iced-rs/iced/9ab6923e943f784985e9ef9ca28b10278297225d/docs/logo.svg" )] +pub mod accessibility; pub mod alignment; pub mod animation; pub mod border; diff --git a/core/src/widget.rs b/core/src/widget.rs index 3b39ffcc18..e9989feb8a 100644 --- a/core/src/widget.rs +++ b/core/src/widget.rs @@ -151,4 +151,61 @@ where ) -> Option> { None } + + /// Returns accessibility information for this [`Widget`]. + /// + /// By default, returns `None`, making the widget invisible to + /// accessibility tools. Widgets should override this to provide + /// meaningful accessibility information. + /// + /// # Return Value + /// - `Some(node)`: This widget should appear in the accessibility tree + /// - `None`: This widget is transparent to accessibility (layout-only) + /// + /// # When to return `None` + /// Return `None` for: + /// - Pure layout containers (Container, Column, Row) + /// - Spacing widgets (Space) + /// - Decorative elements with no semantic meaning + /// + /// # When to return `Some` + /// Return `Some(node)` for: + /// - Interactive widgets (Button, TextInput, Checkbox) + /// - Informational content (Text, Image with alt text) + /// - Semantic containers (List, Table, Group) + /// + /// # Example + /// ``` + /// use iced_core::accessibility::{AccessibilityNode, Role}; + /// use iced_core::widget::{Tree, Widget}; + /// use iced_core::{Layout, Rectangle}; + /// + /// # struct MyButton; + /// # impl Widget for MyButton { + /// # fn size(&self) -> iced_core::Size { iced_core::Size::new(iced_core::Length::Shrink, iced_core::Length::Shrink) } + /// # fn layout(&mut self, _: &mut Tree, _: &Renderer, _: &iced_core::layout::Limits) -> iced_core::layout::Node { + /// # iced_core::layout::Node::new(iced_core::Size::ZERO) + /// # } + /// # fn draw(&self, _: &Tree, _: &mut Renderer, _: &Theme, _: &iced_core::renderer::Style, _: Layout<'_>, _: iced_core::mouse::Cursor, _: &Rectangle) {} + /// fn accessibility( + /// &self, + /// _state: &Tree, + /// layout: Layout<'_>, + /// ) -> Option { + /// Some( + /// AccessibilityNode::new(layout.bounds()) + /// .role(Role::Button) + /// .label("Click me") + /// .focusable(true) + /// ) + /// } + /// # } + /// ``` + fn accessibility( + &self, + _state: &Tree, + _layout: Layout<'_>, + ) -> Option { + None + } } diff --git a/core/src/widget/operation.rs b/core/src/widget/operation.rs index 9e7b0d340d..a541d826ef 100644 --- a/core/src/widget/operation.rs +++ b/core/src/widget/operation.rs @@ -70,6 +70,18 @@ pub trait Operation: Send { ) { } + /// Collects accessibility information from a widget. + /// + /// This is called by widgets that provide accessibility information + /// via the [`Widget::accessibility`] method. + /// + /// [`Widget::accessibility`]: crate::Widget::accessibility + fn accessibility( + &mut self, + _node: Option, + ) { + } + /// Finishes the [`Operation`] and returns its [`Outcome`]. fn finish(&self) -> Outcome { Outcome::None @@ -136,6 +148,13 @@ where self.as_mut().custom(id, bounds, state); } + fn accessibility( + &mut self, + node: Option, + ) { + self.as_mut().accessibility(node); + } + fn finish(&self) -> Outcome { self.as_ref().finish() } @@ -239,6 +258,13 @@ where self.operation.custom(id, bounds, state); } + fn accessibility( + &mut self, + node: Option, + ) { + self.operation.accessibility(node); + } + fn finish(&self) -> Outcome { Outcome::None } @@ -340,6 +366,13 @@ where ) { self.operation.custom(id, bounds, state); } + + fn accessibility( + &mut self, + node: Option, + ) { + self.operation.accessibility(node); + } } self.operation.traverse(&mut |operation| { @@ -399,6 +432,13 @@ where self.operation.custom(id, bounds, state); } + fn accessibility( + &mut self, + node: Option, + ) { + self.operation.accessibility(node); + } + fn finish(&self) -> Outcome { match self.operation.finish() { Outcome::None => Outcome::None, @@ -503,6 +543,13 @@ where self.operation.custom(id, bounds, state); } + fn accessibility( + &mut self, + node: Option, + ) { + self.operation.accessibility(node); + } + fn finish(&self) -> Outcome { match self.operation.finish() { Outcome::None => Outcome::None, diff --git a/core/src/widget/text.rs b/core/src/widget/text.rs index 72cb4319cd..75b3e283ce 100644 --- a/core/src/widget/text.rs +++ b/core/src/widget/text.rs @@ -179,6 +179,17 @@ where self.class = class.into(); self } + + fn accessibility_noce( + &self, + layout: Layout<'_>, + ) -> crate::accessibility::AccessibilityNode { + use crate::accessibility::{AccessibilityNode, Role}; + + AccessibilityNode::new(layout.bounds()) + .role(Role::Label) + .label(self.fragment.as_ref()) + } } /// The internal state of a [`Text`] widget. @@ -243,6 +254,14 @@ where ); } + fn accessibility( + &self, + _state: &Tree, + layout: Layout<'_>, + ) -> Option { + Some(self.accessibility_noce(layout)) + } + fn operate( &mut self, _state: &mut Tree, @@ -250,6 +269,7 @@ where _renderer: &Renderer, operation: &mut dyn super::Operation, ) { + operation.accessibility(Some(self.accessibility_noce(layout))); operation.text(None, layout.bounds(), &self.fragment); } } diff --git a/examples/editor/src/main.rs b/examples/editor/src/main.rs index b854275fec..9adb4d3f76 100644 --- a/examples/editor/src/main.rs +++ b/examples/editor/src/main.rs @@ -291,7 +291,7 @@ async fn save_file( Ok(path) } -fn action<'a, Message: Clone + 'a>( +fn action<'a, Message: Clone + Send + 'static>( content: impl Into>, label: &'a str, on_press: Option, diff --git a/examples/toast/src/main.rs b/examples/toast/src/main.rs index cca0e78997..728a2deb67 100644 --- a/examples/toast/src/main.rs +++ b/examples/toast/src/main.rs @@ -222,7 +222,7 @@ mod toast { impl<'a, Message> Manager<'a, Message> where - Message: 'a + Clone, + Message: 'a + Clone + Send + 'static, { pub fn new( content: impl Into>, diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 26b094d442..3ab5825074 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -17,6 +17,7 @@ selector = ["dep:iced_selector"] workspace = true [dependencies] +accesskit.workspace = true bytes.workspace = true iced_core.workspace = true iced_futures.workspace = true diff --git a/runtime/src/accessibility.rs b/runtime/src/accessibility.rs new file mode 100644 index 0000000000..4f94ed65a3 --- /dev/null +++ b/runtime/src/accessibility.rs @@ -0,0 +1,371 @@ +//! Accessibility tree construction and management. + +use crate::core::Rectangle; +use crate::core::widget::{Id, Operation, operation}; +use crate::user_interface::UserInterface; + +use accesskit::{ + ActionRequest, Node, NodeId, Role, Tree as AccessKitTree, TreeUpdate, +}; + +use std::collections::HashMap; + +/// An accessibility action to be performed by the runtime. +#[derive(Debug, Clone)] +pub enum Action { + /// An action was requested by an assistive technology. + ActionRequested(ActionRequest), + /// Accessibility was deactivated. + Deactivated, +} + +/// Builds an accessibility tree from a UserInterface. +/// +/// This traverses the widget tree and collects accessibility information. +/// Returns the TreeUpdate, bounds mapping, and action callbacks (as closures) for routing. +pub fn build_tree_from_ui( + ui: &mut UserInterface<'_, Message, Theme, Renderer>, + renderer: &Renderer, +) -> ( + TreeUpdate, + HashMap, + HashMap Message + Send>>, +) +where + Message: Send + 'static, + Renderer: crate::core::Renderer, +{ + let mut builder = TreeBuilder::new(); + ui.operate(renderer, &mut builder); + builder.build() +} + +/// Helper struct for building the accessibility tree via Operation pattern. +pub struct TreeBuilder { + nodes: HashMap, + children: Vec, + /// Mapping of NodeId to bounds for action routing + node_bounds: HashMap, + /// Mapping of NodeId to action callbacks (closures that produce messages) + action_callbacks: HashMap Message + Send>>, + /// Path stack for generating stable IDs based on widget tree position + path_stack: Vec, + /// Counter for each widget type at current level + type_counters: HashMap, + /// Whether we're inside a leaf node (children should not create accessibility nodes) + inside_leaf_node: bool, +} + +impl TreeBuilder { + fn new() -> Self { + Self { + nodes: HashMap::new(), + children: Vec::new(), + node_bounds: HashMap::new(), + action_callbacks: HashMap::new(), + path_stack: vec!["window".to_string()], + type_counters: HashMap::new(), + inside_leaf_node: false, + } + } + + /// Generate a stable NodeId based on widget::Id (preferred) or tree position (fallback) + /// + /// Priority: + /// 1. If widget_id is provided, use it for maximum stability + /// 2. Otherwise, fall back to path-based hashing + fn generate_stable_id( + &mut self, + widget_type: &str, + widget_id: Option<&Id>, + ) -> NodeId { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + match widget_id { + Some(id) => { + // Convert widget::Id to NodeId via deterministic hashing + NodeId::from(id) + } + + None => { + let counter = self + .type_counters + .entry(widget_type.to_string()) + .or_insert(0); + let index = *counter; + *counter += 1; + + // Build path string like "window/column/button[0]" + let mut path = self.path_stack.join("/"); + path.push_str(&format!("/{}[{}]", widget_type, index)); + + // Hash the path to get stable u64 ID + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + NodeId(hasher.finish()) + } + } + } + + fn build( + mut self, + ) -> ( + TreeUpdate, + HashMap, + HashMap Message + Send>>, + ) { + // Create root node and add all collected nodes as children + let mut root = Node::new(Role::Window); + root.set_label("Iced Application".to_string()); + root.set_children(self.children); + + let _ = self.nodes.insert(NodeId(0), root); + + let tree_update = TreeUpdate { + nodes: self.nodes.into_iter().collect(), + tree: Some(AccessKitTree::new(NodeId(0))), + focus: NodeId(0), + }; + + (tree_update, self.node_bounds, self.action_callbacks) + } +} + +impl TreeBuilder { + /// Register an action callback for a node. + /// This allows widgets to specify what closure should be called when an accessibility action occurs. + pub fn register_action( + &mut self, + node_id: NodeId, + callback: Box Message + Send>, + ) { + let _ = self.action_callbacks.insert(node_id, callback); + } +} + +impl Operation for TreeBuilder { + fn accessibility( + &mut self, + accessibility_node: Option< + crate::core::accessibility::AccessibilityNode, + >, + ) { + // If we're inside a leaf node, don't add child nodes to the tree + if self.inside_leaf_node { + return; + } + + if let Some(a11y_node) = accessibility_node { + // Store whether this widget is a leaf node + let is_leaf = a11y_node.is_leaf_node; + + // Role is required for AccessKit nodes + if let Some(role) = a11y_node.role { + // Generate stable ID based on role type and position + let widget_type = match role { + Role::Button => "button", + Role::Label => "label", + Role::TextInput => "textinput", + Role::CheckBox => "checkbox", + Role::Slider => "slider", + Role::Image => "image", + Role::Link => "link", + _ => "widget", + }; + // NEW: Pass widget_id to generate_stable_id for hybrid approach + let node_id = self.generate_stable_id( + widget_type, + a11y_node.widget_id.as_ref(), + ); + + // Extract and store action callback if present + if let Some(action_closure) = a11y_node.on_action { + // The closure produces Box, we need to downcast and create a new closure + let callback = Box::new(move || { + let any_box = action_closure(); + // Downcast the Any box to the concrete Message type + *any_box + .downcast::() + .expect("Message type mismatch") + }); + let _ = self.action_callbacks.insert(node_id, callback); + } + + // Convert iced AccessibilityNode to AccessKit Node + let mut node = Node::new(role); + + // Set bounds + node.set_bounds(accesskit::Rect { + x0: a11y_node.bounds.x as f64, + y0: a11y_node.bounds.y as f64, + x1: (a11y_node.bounds.x + a11y_node.bounds.width) as f64, + y1: (a11y_node.bounds.y + a11y_node.bounds.height) as f64, + }); + + // Set label if present + if let Some(label) = a11y_node.label { + node.set_label(label); + } + + // Set value if present + if let Some(value) = a11y_node.value { + node.set_value(value); + } + + // Set other properties + if a11y_node.focusable { + node.add_action(accesskit::Action::Focus); + } + + // Add Click action for buttons + if role == Role::Button && a11y_node.enabled { + node.add_action(accesskit::Action::Click); + } + + if a11y_node.enabled { + // Enabled state is implicit; we mark disabled state + } else { + node.set_disabled(); + } + + self.children.push(node_id); + let _ = self.nodes.insert(node_id, node); + + // Store bounds for action routing + let _ = self.node_bounds.insert(node_id, a11y_node.bounds); + } + + // If this is a leaf node, set the flag so children won't be added + // This will affect the subsequent traverse() call + if is_leaf { + self.inside_leaf_node = true; + } + } + } + + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + // NEW: Pass widget ID through to generate_stable_id + let node_id = self.generate_stable_id("container", id); + + let mut node = Node::new(Role::GenericContainer); + node.set_bounds(accesskit::Rect { + x0: bounds.x as f64, + y0: bounds.y as f64, + x1: (bounds.x + bounds.width) as f64, + y1: (bounds.y + bounds.height) as f64, + }); + + if let Some(id) = id { + node.set_label(format!("Container {:?}", id)); + } + + self.children.push(node_id); + let _ = self.nodes.insert(node_id, node); + } + + fn focusable( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + _state: &mut dyn operation::Focusable, + ) { + // NEW: Pass widget ID through to generate_stable_id + let node_id = self.generate_stable_id("focusable", id); + + let mut node = Node::new(Role::Button); + node.set_bounds(accesskit::Rect { + x0: bounds.x as f64, + y0: bounds.y as f64, + x1: (bounds.x + bounds.width) as f64, + y1: (bounds.y + bounds.height) as f64, + }); + + if let Some(id) = id { + node.set_label(format!("Focusable {:?}", id)); + } + + self.children.push(node_id); + let _ = self.nodes.insert(node_id, node); + } + + fn text_input( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + _state: &mut dyn operation::TextInput, + ) { + // NEW: Pass widget ID through to generate_stable_id + let node_id = self.generate_stable_id("textinput", id); + + let mut node = Node::new(Role::TextInput); + node.set_bounds(accesskit::Rect { + x0: bounds.x as f64, + y0: bounds.y as f64, + x1: (bounds.x + bounds.width) as f64, + y1: (bounds.y + bounds.height) as f64, + }); + + if let Some(id) = id { + node.set_label(format!("TextInput {:?}", id)); + } + + self.children.push(node_id); + let _ = self.nodes.insert(node_id, node); + } + + fn text(&mut self, id: Option<&Id>, bounds: Rectangle, text: &str) { + // NEW: Pass widget ID through to generate_stable_id (though text rarely has IDs) + let node_id = self.generate_stable_id("text", id); + + let mut node = Node::new(Role::TextRun); + node.set_bounds(accesskit::Rect { + x0: bounds.x as f64, + y0: bounds.y as f64, + x1: (bounds.x + bounds.width) as f64, + y1: (bounds.y + bounds.height) as f64, + }); + node.set_label(text.to_string()); + + self.children.push(node_id); + let _ = self.nodes.insert(node_id, node); + } + + fn scrollable( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + _content_bounds: Rectangle, + _translation: crate::core::Vector, + _state: &mut dyn operation::Scrollable, + ) { + // NEW: Pass widget ID through to generate_stable_id + let node_id = self.generate_stable_id("scrollable", id); + + let mut node = Node::new(Role::ScrollView); + node.set_bounds(accesskit::Rect { + x0: bounds.x as f64, + y0: bounds.y as f64, + x1: (bounds.x + bounds.width) as f64, + y1: (bounds.y + bounds.height) as f64, + }); + + if let Some(id) = id { + node.set_label(format!("Scrollable {:?}", id)); + } + + self.children.push(node_id); + let _ = self.nodes.insert(node_id, node); + } + + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + // Perform the traversal + // If inside_leaf_node is true, child nodes won't be added to the tree + operate(self); + + // Reset the flag after traversing + // This ensures the flag only affects immediate children of the leaf node, + // not siblings or other widgets at the same level + self.inside_leaf_node = false; + } +} diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 8b78801a7c..91e4e9fcdb 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -9,6 +9,7 @@ html_logo_url = "https://raw.githubusercontent.com/iced-rs/iced/9ab6923e943f784985e9ef9ca28b10278297225d/docs/logo.svg" )] #![cfg_attr(docsrs, feature(doc_cfg))] +pub mod accessibility; pub mod clipboard; pub mod font; pub mod image; @@ -58,6 +59,9 @@ pub enum Action { /// An image action. Image(image::Action), + /// An accessibility action. + Accessibility(accessibility::Action), + /// Recreate all user interfaces and redraw all windows. Reload, @@ -85,6 +89,7 @@ impl Action { Action::Window(action) => Err(Action::Window(action)), Action::System(action) => Err(Action::System(action)), Action::Image(action) => Err(Action::Image(action)), + Action::Accessibility(action) => Err(Action::Accessibility(action)), Action::Reload => Err(Action::Reload), Action::Exit => Err(Action::Exit), } @@ -110,6 +115,9 @@ where Action::Window(_) => write!(f, "Action::Window"), Action::System(action) => write!(f, "Action::System({action:?})"), Action::Image(_) => write!(f, "Action::Image"), + Action::Accessibility(action) => { + write!(f, "Action::Accessibility({action:?})") + } Action::Reload => write!(f, "Action::Reload"), Action::Exit => write!(f, "Action::Exit"), } diff --git a/runtime/src/user_interface.rs b/runtime/src/user_interface.rs index a40ab22199..a019351e7f 100644 --- a/runtime/src/user_interface.rs +++ b/runtime/src/user_interface.rs @@ -577,6 +577,32 @@ where } } + /// Builds an accessibility tree from the current widget tree. + /// + /// This traverses the widget tree using an [`Operation`] and collects + /// accessibility information to create an AccessKit [`TreeUpdate`] and + /// a mapping of NodeId to bounds for action routing. + /// + /// [`TreeUpdate`]: https://docs.rs/accesskit/latest/accesskit/struct.TreeUpdate.html + pub fn accessibility( + &mut self, + renderer: &Renderer, + ) -> ( + accesskit::TreeUpdate, + std::collections::HashMap, + std::collections::HashMap< + accesskit::NodeId, + Box Message + Send>, + >, + ) + where + Message: Send + 'static, + { + use crate::accessibility; + + accessibility::build_tree_from_ui(self, renderer) + } + /// Relayouts and returns a new [`UserInterface`] using the provided /// bounds. pub fn relayout(self, bounds: Size, renderer: &mut Renderer) -> Self { diff --git a/runtime/tests/accessibility_test.rs b/runtime/tests/accessibility_test.rs new file mode 100644 index 0000000000..5af183e48c --- /dev/null +++ b/runtime/tests/accessibility_test.rs @@ -0,0 +1,35 @@ +//! Test accessibility action routing + +use accesskit::{ActionRequest, NodeId}; +use iced_runtime::accessibility; + +#[test] +fn test_action_request_structure() { + // Verify that ActionRequest contains the fields we expect + let request = ActionRequest { + action: accesskit::Action::Click, + target: NodeId(123), + data: None, + }; + + assert_eq!(request.action, accesskit::Action::Click); + assert_eq!(request.target, NodeId(123)); + assert!(request.data.is_none()); +} + +#[test] +fn test_accessibility_action_enum() { + // Verify our accessibility Action enum works + let action = accessibility::Action::ActionRequested(ActionRequest { + action: accesskit::Action::Click, + target: NodeId(456), + data: None, + }); + + match action { + accessibility::Action::ActionRequested(req) => { + assert_eq!(req.target, NodeId(456)); + } + _ => panic!("Expected ActionRequested variant"), + } +} diff --git a/src/lib.rs b/src/lib.rs index b39336700a..a4a2700953 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -439,14 +439,14 @@ //! } //! } //! } else { -//! Task::none() +//! Task::none() //! } //! } //! Message::Conversation(message) => { //! if let Screen::Conversation(conversation) = &mut state.screen { //! conversation.update(message).map(Message::Conversation) //! } else { -//! Task::none() +//! Task::none() //! } //! } //! } diff --git a/test/src/emulator.rs b/test/src/emulator.rs index cc013a4592..aa9c850dad 100644 --- a/test/src/emulator.rs +++ b/test/src/emulator.rs @@ -267,6 +267,7 @@ impl Emulator

{ runtime::Action::Reload => { // TODO } + runtime::Action::Accessibility(_) => todo!(), }, } } diff --git a/widget/src/button.rs b/widget/src/button.rs index 060762a2c1..1fcbd7bfe8 100644 --- a/widget/src/button.rs +++ b/widget/src/button.rs @@ -81,6 +81,8 @@ where clip: bool, class: Theme::Class<'a>, status: Option, + id: Option, + accessibility_label: Option, } enum OnPress<'a, Message> { @@ -118,6 +120,8 @@ where clip: false, class: Theme::default(), status: None, + id: None, + accessibility_label: None, } } @@ -196,6 +200,24 @@ where self.class = class.into(); self } + + /// Sets the ID of the [`Button`]. + /// + /// This is useful for providing stable accessibility tree node IDs, + /// especially in dynamic UIs where widgets may be reordered or inserted. + pub fn id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Sets the accessibility label for the [`Button`]. + /// + /// This label will be used by screen readers and other assistive technologies. + /// If not set, a default "Button" label will be used. + pub fn accessibility_label(mut self, label: impl Into) -> Self { + self.accessibility_label = Some(label.into()); + self + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -206,7 +228,7 @@ struct State { impl<'a, Message, Theme, Renderer> Widget for Button<'a, Message, Theme, Renderer> where - Message: 'a + Clone, + Message: 'a + Clone + Send + 'static, Renderer: 'a + crate::core::Renderer, Theme: Catalog, { @@ -261,7 +283,30 @@ where renderer: &Renderer, operation: &mut dyn Operation, ) { - operation.container(None, layout.bounds()); + // Collect text from children if no explicit accessibility label is set + let collected_text = if self.accessibility_label.is_none() { + extract_text_from_children( + &mut self.content, + &mut tree.children[0], + layout.children().next().unwrap(), + renderer, + ) + } else { + None + }; + + // Provide accessibility information for this button + let accessibility_node = Self::build_accessibility_node( + &self.accessibility_label, + self.on_press.is_some(), + &self.id, + layout.bounds(), + collected_text, + &self.on_press, + ); + operation.accessibility(accessibility_node); + + // Continue traversal to children operation.traverse(&mut |operation| { self.content.as_widget_mut().operate( &mut tree.children[0], @@ -444,12 +489,121 @@ where translation, ) } + + fn accessibility( + &self, + _state: &Tree, + layout: Layout<'_>, + ) -> Option { + // This is called from Widget trait, but we don't have collected text here + // Use the helper with no collected text + Self::build_accessibility_node( + &self.accessibility_label, + self.on_press.is_some(), + &self.id, + layout.bounds(), + None, + &self.on_press, + ) + } +} + +impl<'a, Message, Theme, Renderer> Button<'a, Message, Theme, Renderer> +where + Renderer: crate::core::Renderer, + Theme: Catalog, +{ + /// Helper to build accessibility node with optional collected text + fn build_accessibility_node( + accessibility_label: &Option, + enabled: bool, + id: &Option, + bounds: crate::core::Rectangle, + collected_text: Option, + on_press: &Option>, + ) -> Option + where + Message: Clone + Send + 'static, + { + use crate::core::accessibility::{AccessibilityNode, Role}; + + // Priority: explicit label > collected text > default "Button" + let label = accessibility_label + .clone() + .or(collected_text) + .unwrap_or_else(|| "Button".to_string()); + + let mut node = AccessibilityNode::new(bounds) + .role(Role::Button) + .label(label) + .enabled(enabled) + .focusable(true) + .widget_id(id.clone()) + .is_leaf_node(true); // Button is a leaf node - don't include children in accessibility tree + + // Register the on_press callback for accessibility actions + if let Some(on_press) = on_press { + node = node.on_action(on_press.get()); + } + + Some(node) + } +} + +/// Helper to extract text content from button's children for accessibility labels. +/// +/// Uses the Operation pattern to traverse child widgets and collect text. +fn extract_text_from_children( + element: &mut Element<'_, Message, Theme, Renderer>, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, +) -> Option +where + Renderer: crate::core::Renderer, +{ + use crate::core::Rectangle; + use crate::core::widget::Id; + use crate::core::widget::Operation; + + /// Operation to collect text from widgets + struct TextCollector { + text: Vec, + } + + impl TextCollector { + fn new() -> Self { + Self { text: Vec::new() } + } + } + + impl Operation for TextCollector { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn Operation)) { + operate(self); + } + + fn text(&mut self, _id: Option<&Id>, _bounds: Rectangle, text: &str) { + self.text.push(text.to_string()); + } + } + + let mut collector = TextCollector::new(); + element + .as_widget_mut() + .operate(tree, layout, renderer, &mut collector); + + if collector.text.is_empty() { + None + } else { + // Join multiple text segments with spaces + Some(collector.text.join(" ")) + } } impl<'a, Message, Theme, Renderer> From> for Element<'a, Message, Theme, Renderer> where - Message: Clone + 'a, + Message: Clone + Send + 'static, Theme: Catalog + 'a, Renderer: crate::core::Renderer + 'a, { @@ -539,11 +693,11 @@ impl Default for Style { /// /// impl Catalog for MyTheme { /// type Class<'a> = ButtonClass; -/// +/// /// fn default<'a>() -> Self::Class<'a> { /// ButtonClass::default() /// } -/// +/// /// /// fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { /// let mut style = Style::default(); diff --git a/winit/Cargo.toml b/winit/Cargo.toml index ebac340b82..68ceed0a1d 100644 --- a/winit/Cargo.toml +++ b/winit/Cargo.toml @@ -25,6 +25,8 @@ unconditional-rendering = [] linux-theme-detection = ["dep:mundy", "mundy/async-io", "mundy/color-scheme"] [dependencies] +accesskit.workspace = true +accesskit_winit.workspace = true iced_debug.workspace = true iced_program.workspace = true diff --git a/winit/src/accessibility.rs b/winit/src/accessibility.rs new file mode 100644 index 0000000000..eea0a31f77 --- /dev/null +++ b/winit/src/accessibility.rs @@ -0,0 +1,76 @@ +//! Accessibility adapter handlers for accesskit_winit integration. + +use crate::Proxy; +use crate::runtime::Action; +use crate::runtime::accessibility; + +use accesskit::{ + ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler, + TreeUpdate, +}; + +use std::sync::{Arc, Mutex}; + +/// Handler for accessibility activation events. +pub struct IcedActivationHandler { + tree_state: Arc>>, +} + +impl IcedActivationHandler { + pub fn new(tree_state: Arc>>) -> Self { + Self { tree_state } + } +} + +impl ActivationHandler for IcedActivationHandler { + fn request_initial_tree(&mut self) -> Option { + // Return the current tree if available + if let Ok(tree) = self.tree_state.lock() { + tree.clone() + } else { + None + } + } +} + +/// Handler for accessibility action requests. +pub struct IcedActionHandler { + proxy: Proxy, +} + +impl IcedActionHandler { + pub fn new(proxy: Proxy) -> Self { + Self { proxy } + } +} + +impl ActionHandler for IcedActionHandler { + fn do_action(&mut self, request: ActionRequest) { + // Send the action request to the main event loop + self.proxy.send_action(Action::Accessibility( + accessibility::Action::ActionRequested(request), + )); + } +} + +/// Handler for accessibility deactivation events. +pub struct IcedDeactivationHandler { + proxy: Proxy, +} + +impl IcedDeactivationHandler { + pub fn new(proxy: Proxy) -> Self { + Self { proxy } + } +} + +impl DeactivationHandler + for IcedDeactivationHandler +{ + fn deactivate_accessibility(&mut self) { + // Notify the main event loop that accessibility was deactivated + self.proxy.send_action(Action::Accessibility( + accessibility::Action::Deactivated, + )); + } +} diff --git a/winit/src/lib.rs b/winit/src/lib.rs index be819d3ed5..4b034a85b6 100644 --- a/winit/src/lib.rs +++ b/winit/src/lib.rs @@ -29,6 +29,7 @@ pub use winit; pub mod clipboard; pub mod conversion; +mod accessibility; mod error; mod proxy; mod window; @@ -147,6 +148,7 @@ where receiver: mpsc::UnboundedReceiver, error: Option, system_theme: Option>, + proxy: Proxy, #[cfg(target_arch = "wasm32")] canvas: Option, @@ -160,6 +162,7 @@ where receiver: control_receiver, error: None, system_theme: Some(system_theme_sender), + proxy: proxy.clone(), #[cfg(target_arch = "wasm32")] canvas: None, @@ -171,6 +174,7 @@ where for Runner where F: Future, + Message: Send, { fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) { if let Some(sender) = self.system_theme.take() { @@ -276,6 +280,7 @@ where impl Runner where F: Future, + Message: Send, { fn process_event( &mut self, @@ -415,6 +420,41 @@ where }; } + // Create accessibility adapter + let accessibility_adapter = { + use crate::accessibility::{ + IcedActionHandler, + IcedActivationHandler, + IcedDeactivationHandler, + }; + use std::sync::{Arc, Mutex}; + + // Create shared state for the initial tree + let tree_state = Arc::new(Mutex::new(None)); + + // Create handlers + let activation_handler = + IcedActivationHandler::new(Arc::clone( + &tree_state, + )); + let action_handler = IcedActionHandler::new( + self.proxy.clone(), + ); + let deactivation_handler = + IcedDeactivationHandler::new( + self.proxy.clone(), + ); + + // Create the adapter with direct handlers + Some(accesskit_winit::Adapter::with_direct_handlers( + event_loop, + &window, + activation_handler, + action_handler, + deactivation_handler, + )) + }; + self.process_event( event_loop, Event::WindowCreated { @@ -423,6 +463,7 @@ where exit_on_close_request, make_visible: visible, on_open, + accessibility_adapter, }, ); } @@ -466,7 +507,6 @@ where } } -#[derive(Debug)] enum Event { WindowCreated { id: window::Id, @@ -474,11 +514,26 @@ enum Event { exit_on_close_request: bool, make_visible: bool, on_open: oneshot::Sender, + accessibility_adapter: Option, }, EventLoopAwakened(winit::event::Event), Exit, } +impl std::fmt::Debug for Event { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Event::WindowCreated { id, .. } => { + write!(f, "Event::WindowCreated {{ id: {:?}, ... }}", id) + } + Event::EventLoopAwakened(_) => { + write!(f, "Event::EventLoopAwakened") + } + Event::Exit => write!(f, "Event::Exit"), + } + } +} + #[derive(Debug)] enum Control { ChangeFlow(winit::event_loop::ControlFlow), @@ -577,6 +632,7 @@ async fn run_instance

( exit_on_close_request, make_visible, on_open, + accessibility_adapter, } => { if compositor.is_none() { let (compositor_sender, compositor_receiver) = @@ -661,6 +717,7 @@ async fn run_instance

( .expect("Compositor must be initialized"), exit_on_close_request, system_theme, + accessibility_adapter, ); window.raw.set_theme(conversion::window_theme( @@ -966,6 +1023,59 @@ async fn run_instance

( ); draw_span.finish(); + // Update accessibility tree + if let Some(adapter) = &mut window.accessibility { + let (tree_update, node_bounds, action_callbacks) = + interface.accessibility(&window.renderer); + + // Store node bounds for action routing + window.accessibility_nodes = node_bounds; + + // Store action callbacks for message publishing + window.accessibility_actions = action_callbacks; + + // Write tree to file for debugging (only first time) + static ONCE: std::sync::Once = + std::sync::Once::new(); + ONCE.call_once(|| { + use std::io::Write; + if let Ok(mut file) = std::fs::File::create("/tmp/iced_accessibility_tree.txt") { + let _ = writeln!(file, "Accessibility Tree Snapshot:"); + let _ = writeln!(file, "Total nodes: {}", tree_update.nodes.len()); + let _ = writeln!(file, "\nTree structure:"); + if let Some(tree) = &tree_update.tree { + let _ = writeln!(file, " Root: {:?}", tree.root); + } + let _ = writeln!(file, "\nNodes:"); + for (node_id, node) in &tree_update.nodes { + let _ = writeln!(file, " Node {:?}:", node_id); + let _ = writeln!(file, " Role: {:?}", node.role()); + let _ = writeln!(file, " Children: {} ({:?})", node.children().len(), node.children()); + if let Some(label) = node.label() { + let _ = writeln!(file, " Label: {}", label); + } + if let Some(bounds) = node.bounds() { + let _ = writeln!(file, " Bounds: {:?}", bounds); + } + // Check for common actions + let mut actions = Vec::new(); + if node.supports_action(accesskit::Action::Click) { + actions.push("Click"); + } + if node.supports_action(accesskit::Action::Focus) { + actions.push("Focus"); + } + if !actions.is_empty() { + let _ = writeln!(file, " Actions: {:?}", actions); + } + } + eprintln!("Accessibility tree written to /tmp/iced_accessibility_tree.txt"); + } + }); + + adapter.update_if_active(|| tree_update); + } + if let user_interface::State::Updated { redraw_request, input_method, @@ -1045,6 +1155,11 @@ async fn run_instance

( continue; }; + // Process accessibility events + if let Some(adapter) = &mut window.accessibility { + adapter.process_event(&window.raw, &window_event); + } + match window_event { winit::event::WindowEvent::Resized(_) => { window.raw.request_redraw(); @@ -1781,6 +1896,41 @@ fn run_action<'a, P, C>( .start_send(Control::Exit) .expect("Send control action"); } + Action::Accessibility(action) => { + match action { + runtime::accessibility::Action::ActionRequested(request) => { + log::debug!( + "Accessibility action requested: {:?}", + request + ); + + // Only handle Click actions, not Focus or other actions + if request.action == accesskit::Action::Click { + // Find the window that has this node and publish the message + for (window_id, window) in window_manager.iter_mut() { + if let Some(callback) = window + .accessibility_actions + .get(&request.target) + { + log::debug!( + "Publishing message for Click action on node {:?} in window {:?}", + request.target, + window_id + ); + + // Call the closure to produce the message - pure Elm architecture! + messages.push(callback()); + + break; + } + } + } + } + runtime::accessibility::Action::Deactivated => { + log::debug!("Accessibility deactivated"); + } + } + } } } diff --git a/winit/src/window.rs b/winit/src/window.rs index 101556f887..df0733ee4e 100644 --- a/winit/src/window.rs +++ b/winit/src/window.rs @@ -55,6 +55,7 @@ where compositor: &mut C, exit_on_close_request: bool, system_theme: theme::Mode, + accessibility_adapter: Option, ) -> &mut Window { let state = State::new(program, id, &window, system_theme); let surface_size = state.physical_size(); @@ -81,6 +82,9 @@ where redraw_at: None, preedit: None, ime_state: None, + accessibility: accessibility_adapter, + accessibility_nodes: std::collections::HashMap::new(), + accessibility_actions: std::collections::HashMap::new(), }, ); @@ -172,6 +176,13 @@ where pub redraw_at: Option, preedit: Option>, ime_state: Option<(Point, input_method::Purpose)>, + pub accessibility: Option, + pub accessibility_nodes: + std::collections::HashMap, + pub accessibility_actions: std::collections::HashMap< + accesskit::NodeId, + Box P::Message + Send>, + >, } impl Window