diff --git a/examples/accessibility.rs b/examples/accessibility.rs new file mode 100644 index 00000000..3ffa387f --- /dev/null +++ b/examples/accessibility.rs @@ -0,0 +1,20 @@ +use dioxus::prelude::*; + +fn main() { + dioxus_blitz::launch(app); +} + +fn app() -> Element { + rsx! { + body { + App {} + } + } +} + +#[component] +fn App() -> Element { + rsx! { + div { "Dioxus for all" } + } +} diff --git a/packages/blitz/Cargo.toml b/packages/blitz/Cargo.toml index 1d01a462..2235901b 100644 --- a/packages/blitz/Cargo.toml +++ b/packages/blitz/Cargo.toml @@ -3,8 +3,6 @@ name = "blitz" version = "0.1.0" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] slab = "0.4.9" style = { workspace = true, features = ["servo"] } diff --git a/packages/dioxus-blitz/Cargo.toml b/packages/dioxus-blitz/Cargo.toml index dff82f40..90ee37c8 100644 --- a/packages/dioxus-blitz/Cargo.toml +++ b/packages/dioxus-blitz/Cargo.toml @@ -4,11 +4,14 @@ version = "0.0.0" edition = "2021" [features] -default = ["hot-reload", "menu"] +accessibility = ["dep:accesskit", "dep:accesskit_winit"] hot-reload = ["dep:dioxus-cli-config", "dep:dioxus-hot-reload"] menu = ["dep:muda"] +default = ["accessibility", "menu"] [dependencies] +accesskit = { version = "0.15.0", optional = true } +accesskit_winit = { version = "0.21.1", optional = true } winit = { version = "0.30.2", features = ["rwh_06"] } muda = { version = "0.11.5", features = ["serde"], optional = true } tokio = { workspace = true, features = ["full"] } diff --git a/packages/dioxus-blitz/src/accessibility.rs b/packages/dioxus-blitz/src/accessibility.rs new file mode 100644 index 00000000..16e4c2be --- /dev/null +++ b/packages/dioxus-blitz/src/accessibility.rs @@ -0,0 +1,92 @@ +use crate::waker::UserEvent; +use accesskit::{NodeBuilder, NodeId, Role, Tree, TreeUpdate}; +use blitz_dom::{local_name, Document, Node}; +use winit::{event_loop::EventLoopProxy, window::Window}; + +/// State of the accessibility node tree and platform adapter. +pub struct AccessibilityState { + /// Adapter to connect to the [`EventLoop`](`winit::event_loop::EventLoop`). + adapter: accesskit_winit::Adapter, + + /// Next ID to assign an an [`accesskit::Node`]. + next_id: u64, +} + +impl AccessibilityState { + pub fn new(window: &Window, proxy: EventLoopProxy) -> Self { + Self { + adapter: accesskit_winit::Adapter::with_event_loop_proxy(window, proxy.clone()), + next_id: 1, + } + } + pub fn build_tree(&mut self, doc: &Document) { + let mut nodes = std::collections::HashMap::new(); + let mut window = NodeBuilder::new(Role::Window); + + doc.visit(|node_id, node| { + let parent = node + .parent + .and_then(|parent_id| nodes.get_mut(&parent_id)) + .map(|(_, parent)| parent) + .unwrap_or(&mut window); + let (id, node_builder) = self.build_node(node, parent); + + nodes.insert(node_id, (id, node_builder)); + }); + + let mut nodes: Vec<_> = nodes + .into_iter() + .map(|(_, (id, node))| (id, node.build())) + .collect(); + nodes.push((NodeId(0), window.build())); + + let tree = Tree::new(NodeId(0)); + let tree_update = TreeUpdate { + nodes, + tree: Some(tree), + focus: NodeId(0), + }; + + self.adapter.update_if_active(|| tree_update) + } + + fn build_node(&mut self, node: &Node, parent: &mut NodeBuilder) -> (NodeId, NodeBuilder) { + let mut node_builder = NodeBuilder::default(); + + let id = NodeId(self.next_id); + self.next_id += 1; + + if let Some(element_data) = node.element_data() { + let name = element_data.name.local.to_string(); + + // TODO match more roles + let role = match &*name { + "button" => Role::Button, + "div" => Role::GenericContainer, + "header" => Role::Header, + "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => Role::Heading, + "p" => Role::Paragraph, + "section" => Role::Section, + "input" => { + let ty = element_data.attr(local_name!("type")).unwrap_or("text"); + match ty { + "number" => Role::NumberInput, + _ => Role::TextInput, + } + } + _ => Role::Unknown, + }; + + node_builder.set_role(role); + node_builder.set_html_tag(name); + } else if node.is_text_node() { + node_builder.set_role(Role::StaticText); + node_builder.set_name(node.text_content()); + parent.push_labelled_by(id) + } + + parent.push_child(id); + + (id, node_builder) + } +} diff --git a/packages/dioxus-blitz/src/lib.rs b/packages/dioxus-blitz/src/lib.rs index 2ea767c3..c137b56d 100644 --- a/packages/dioxus-blitz/src/lib.rs +++ b/packages/dioxus-blitz/src/lib.rs @@ -4,6 +4,9 @@ mod documents; mod waker; mod window; +#[cfg(feature = "accessibility")] +mod accessibility; + use crate::waker::{EventData, UserEvent}; use crate::{documents::HtmlDocument, window::View}; @@ -187,45 +190,52 @@ fn launch_with_window(window: View<'static, Doc>) { }; } - Event::UserEvent(UserEvent::Window { - data: EventData::Poll, - window_id: id, - }) => { - if let Some(view) = windows.get_mut(&id) { - if view.poll() { - view.request_redraw(); + Event::UserEvent(user_event) => match user_event { + UserEvent::Window { + data: EventData::Poll, + window_id: id, + } => { + if let Some(view) = windows.get_mut(&id) { + if view.poll() { + view.request_redraw(); + } + }; + } + #[cfg(feature = "accessibility")] + UserEvent::Accessibility(accessibility_event) => { + if let Some(window) = windows.get_mut(&accessibility_event.window_id) { + window.handle_accessibility_event(&accessibility_event.window_event); } - }; - } - - #[cfg(all( - feature = "hot-reload", - debug_assertions, - not(target_os = "android"), - not(target_os = "ios") - ))] - Event::UserEvent(UserEvent::HotReloadEvent(msg)) => match msg { - dioxus_hot_reload::HotReloadMsg::UpdateTemplate(template) => { - for window in windows.values_mut() { - if let Some(dx_doc) = window - .renderer - .dom - .as_any_mut() - .downcast_mut::() - { - dx_doc.vdom.replace_template(template); - - if window.poll() { - window.request_redraw(); + } + #[cfg(all( + feature = "hot-reload", + debug_assertions, + not(target_os = "android"), + not(target_os = "ios") + ))] + UserEvent::HotReloadEvent(msg) => match msg { + dioxus_hot_reload::HotReloadMsg::UpdateTemplate(template) => { + for window in windows.values_mut() { + if let Some(dx_doc) = window + .renderer + .dom + .as_any_mut() + .downcast_mut::() + { + dx_doc.vdom.replace_template(template); + + if window.poll() { + window.request_redraw(); + } } } } - } - dioxus_hot_reload::HotReloadMsg::Shutdown => event_loop.exit(), - dioxus_hot_reload::HotReloadMsg::UpdateAsset(asset) => { - // TODO dioxus-desktop seems to handle this by forcing a reload of all stylesheets. - dbg!("Update asset {:?}", asset); - } + dioxus_hot_reload::HotReloadMsg::Shutdown => event_loop.exit(), + dioxus_hot_reload::HotReloadMsg::UpdateAsset(asset) => { + // TODO dioxus-desktop seems to handle this by forcing a reload of all stylesheets. + dbg!("Update asset {:?}", asset); + } + }, }, // Event::UserEvent(_redraw) => { @@ -255,7 +265,7 @@ fn launch_with_window(window: View<'static, Doc>) { } => { if let Some(window) = windows.get_mut(&window_id) { window.handle_window_event(event); - }; + } } _ => (), diff --git a/packages/dioxus-blitz/src/waker.rs b/packages/dioxus-blitz/src/waker.rs index 2c20983e..962fe039 100644 --- a/packages/dioxus-blitz/src/waker.rs +++ b/packages/dioxus-blitz/src/waker.rs @@ -2,13 +2,21 @@ use futures_util::task::ArcWake; use std::sync::Arc; use winit::{event_loop::EventLoopProxy, window::WindowId}; +#[cfg(feature = "accessibility")] +use accesskit_winit::Event as AccessibilityEvent; + #[derive(Debug, Clone)] pub enum UserEvent { Window { window_id: WindowId, data: EventData, }, - /// Handle a hotreload event, basically telling us to update our templates + + /// An accessibility event from `accesskit`. + #[cfg(feature = "accessibility")] + Accessibility(Arc), + + /// A hotreload event, basically telling us to update our templates. #[cfg(all( feature = "hot-reload", debug_assertions, @@ -18,6 +26,13 @@ pub enum UserEvent { HotReloadEvent(dioxus_hot_reload::HotReloadMsg), } +#[cfg(feature = "accessibility")] +impl From for UserEvent { + fn from(value: AccessibilityEvent) -> Self { + Self::Accessibility(Arc::new(value)) + } +} + #[derive(Debug, Clone)] pub enum EventData { Poll, diff --git a/packages/dioxus-blitz/src/window.rs b/packages/dioxus-blitz/src/window.rs index a541ed8e..29add76a 100644 --- a/packages/dioxus-blitz/src/window.rs +++ b/packages/dioxus-blitz/src/window.rs @@ -14,6 +14,16 @@ use winit::event::{ElementState, MouseButton}; use winit::event_loop::{ActiveEventLoop, EventLoopProxy}; use winit::{event::WindowEvent, keyboard::KeyCode, keyboard::ModifiersState, window::Window}; +struct State { + #[cfg(feature = "accessibility")] + /// Accessibility adapter for `accesskit`. + accessibility: crate::accessibility::AccessibilityState, + + /// Main menu bar of this view's window. + #[cfg(feature = "menu")] + _menu: muda::Menu, +} + pub(crate) struct View<'s, Doc: DocumentLike> { pub(crate) renderer: Renderer<'s, Window, Doc>, pub(crate) scene: Scene, @@ -22,9 +32,8 @@ pub(crate) struct View<'s, Doc: DocumentLike> { /// need to store them in order to have access to them when processing keypress events keyboard_modifiers: ModifiersState, - /// Main menu bar of this view's window. - #[cfg(all(feature = "menu", not(any(target_os = "android", target_os = "ios"))))] - menu: Option, + #[cfg(any(feature = "accessibility", feature = "menu"))] + state: Option, } impl<'a, Doc: DocumentLike> View<'a, Doc> { @@ -34,8 +43,8 @@ impl<'a, Doc: DocumentLike> View<'a, Doc> { scene: Scene::new(), waker: None, keyboard_modifiers: Default::default(), - #[cfg(all(feature = "menu", not(any(target_os = "android", target_os = "ios"))))] - menu: None, + #[cfg(any(feature = "accessibility", feature = "menu"))] + state: None, } } } @@ -46,7 +55,22 @@ impl<'a, Doc: DocumentLike> View<'a, Doc> { None => false, Some(waker) => { let cx = std::task::Context::from_waker(waker); - self.renderer.poll(cx) + if self.renderer.poll(cx) { + #[cfg(feature = "accessibility")] + { + if let Some(ref mut state) = self.state { + // TODO send fine grained accessibility tree updates. + let changed = std::mem::take(&mut self.renderer.dom.as_mut().changed); + if !changed.is_empty() { + state.accessibility.build_tree(self.renderer.dom.as_ref()); + } + } + } + + true + } else { + false + } } } } @@ -274,6 +298,23 @@ impl<'a, Doc: DocumentLike> View<'a, Doc> { } } + #[cfg(feature = "accessibility")] + pub fn handle_accessibility_event(&mut self, event: &accesskit_winit::WindowEvent) { + match event { + accesskit_winit::WindowEvent::InitialTreeRequested => { + if let Some(ref mut state) = self.state { + state.accessibility.build_tree(self.renderer.dom.as_ref()); + } + } + accesskit_winit::WindowEvent::AccessibilityDeactivated => { + // TODO + } + accesskit_winit::WindowEvent::ActionRequested(_req) => { + // TODO + } + } + } + pub fn resume( &mut self, event_loop: &ActiveEventLoop, @@ -288,13 +329,23 @@ impl<'a, Doc: DocumentLike> View<'a, Doc> { })) .unwrap(); - #[cfg(all(feature = "menu", not(any(target_os = "android", target_os = "ios"))))] + // Initialize the accessibility and menu bar state. + #[cfg(any(feature = "accessibility", feature = "menu"))] { - self.menu = Some(init_menu( - #[cfg(target_os = "windows")] - &window, - )); + self.state = Some(State { + #[cfg(feature = "accessibility")] + accessibility: crate::accessibility::AccessibilityState::new( + &window, + proxy.clone(), + ), + #[cfg(feature = "menu")] + _menu: init_menu( + #[cfg(target_os = "windows")] + &window, + ), + }); } + let size: winit::dpi::PhysicalSize = window.inner_size(); let mut viewport = Viewport::new((size.width, size.height)); viewport.set_hidpi_scale(window.scale_factor() as _); diff --git a/packages/dom/src/document.rs b/packages/dom/src/document.rs index 158072a2..16939379 100644 --- a/packages/dom/src/document.rs +++ b/packages/dom/src/document.rs @@ -5,7 +5,7 @@ use crate::{Node, NodeData, TextNodeData}; use selectors::{matching::QuirksMode, Element}; use slab::Slab; use std::any::Any; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, VecDeque}; use style::invalidation::element::restyle_hints::RestyleHint; use style::selector_parser::ServoElementSnapshot; use style::servo::media_queries::FontMetricsProvider; @@ -92,6 +92,8 @@ pub struct Document { pub(crate) layout_ctx: parley::LayoutContext, pub(crate) hover_node_id: Option, + + pub changed: HashSet, } impl Document { @@ -123,6 +125,7 @@ impl Document { layout_ctx: parley::LayoutContext::new(), hover_node_id: None, + changed: HashSet::new(), }; // Initialise document with root Document node @@ -185,6 +188,9 @@ impl Document { // id as usize, // ); + // Mark the new node as changed. + self.changed.insert(id); + id } @@ -227,6 +233,9 @@ impl Document { let parent_id = node.parent.unwrap(); let parent = &mut self.nodes[parent_id]; + // Mark the node's parent as changed. + self.changed.insert(parent_id); + let mut children = std::mem::take(&mut parent.children); children.splice( node_child_idx..node_child_idx, @@ -534,6 +543,23 @@ impl Document { pub fn set_document(&mut self, _content: String) {} pub fn add_element(&mut self) {} + + pub fn visit(&self, mut visit: F) + where + F: FnMut(usize, &Node), + { + let mut stack = VecDeque::new(); + stack.push_front(0); + + while let Some(node_key) = stack.pop_back() { + let node = &self.nodes[node_key]; + visit(node_key, node); + + for &child_key in &node.children { + stack.push_front(child_key); + } + } + } } impl AsRef for Document {