diff --git a/Cargo.lock b/Cargo.lock index fb5eeeab8df..48a3436c770 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8056,6 +8056,7 @@ dependencies = [ name = "servo-layout" version = "0.1.0" dependencies = [ + "accesskit", "app_units", "atomic_refcell", "bitflags 2.11.0", @@ -8074,6 +8075,7 @@ dependencies = [ "rayon", "rustc-hash 2.1.2", "selectors", + "serde", "servo-base", "servo-config", "servo-embedder-traits", diff --git a/components/constellation/constellation.rs b/components/constellation/constellation.rs index 20a32e31303..18b6d50f71e 100644 --- a/components/constellation/constellation.rs +++ b/components/constellation/constellation.rs @@ -1164,7 +1164,7 @@ where inherited_secure_context: Option, throttled: bool, ) { - debug!("{}: Creating new browsing context", browsing_context_id); + debug!("{browsing_context_id}: Creating new browsing context"); let bc_group_id = match self .browsing_context_group_set .iter_mut() @@ -3160,23 +3160,19 @@ where }; webview.accessibility_active = active; - self.constellation_to_embedder_proxy.send( - ConstellationToEmbedderMsg::DocumentAccessibilityTreeIdChanged( - webview_id, - webview.active_top_level_pipeline_id.into(), - ), + let Some(pipeline_id) = webview.active_top_level_pipeline_id else { + return; + }; + let epoch = webview.active_top_level_pipeline_epoch; + // Forward the activation to the webview’s active top-level pipeline, if any. For inactive + // pipelines (documents in bfcache), we only need to forward the activation if and when they + // become active (see set_frame_tree_for_webview()). + // There are two sites like this; this is the a11y activation site. + self.send_message_to_pipeline( + pipeline_id, + ScriptThreadMessage::SetAccessibilityActive(pipeline_id, active, epoch), + "Set accessibility active after closure", ); - - // Forward the activation to the webview’s active pipelines (of those that represent - // documents). For inactive pipelines (documents in bfcache), we only need to forward the - // activation if and when they become active (see set_frame_tree_for_webview()). - for pipeline_id in self.active_pipelines_for_webview(webview_id) { - self.send_message_to_pipeline( - pipeline_id, - ScriptThreadMessage::SetAccessibilityActive(pipeline_id, active), - "Set accessibility active after closure", - ); - } } fn forward_input_event( @@ -3253,13 +3249,7 @@ where // its focused browsing context to be itself. self.webviews.insert( webview_id, - ConstellationWebView::new( - &self.constellation_to_embedder_proxy, - webview_id, - pipeline_id, - browsing_context_id, - user_content_manager_id, - ), + ConstellationWebView::new(webview_id, browsing_context_id, user_content_manager_id), ); // https://html.spec.whatwg.org/multipage/#creating-a-new-browsing-context-group @@ -3649,9 +3639,7 @@ where self.webviews.insert( new_webview_id, ConstellationWebView::new( - &self.constellation_to_embedder_proxy, new_webview_id, - new_pipeline_id, new_browsing_context_id, user_content_manager_id, ), @@ -5773,23 +5761,6 @@ where }) } - /// Convert a webview to a flat list of active pipeline ids, for activating accessibility. - fn active_pipelines_for_webview(&self, webview_id: WebViewId) -> Vec { - let mut result = vec![]; - let mut browsing_context_ids = vec![BrowsingContextId::from(webview_id)]; - while let Some(browsing_context_id) = browsing_context_ids.pop() { - let Some(browsing_context) = self.browsing_contexts.get(&browsing_context_id) else { - continue; - }; - let Some(pipeline) = self.pipelines.get(&browsing_context.pipeline_id) else { - continue; - }; - result.push(browsing_context.pipeline_id); - browsing_context_ids.extend(pipeline.children.iter().copied()); - } - result - } - /// Send the frame tree for the given webview to `Paint`. #[servo_tracing::instrument(skip_all)] fn set_frame_tree_for_webview(&mut self, webview_id: WebViewId) { @@ -5797,35 +5768,55 @@ where // avoiding this panic would require a mechanism for dealing // with low-resource scenarios. let browsing_context_id = BrowsingContextId::from(webview_id); - if let Some(frame_tree) = self.browsing_context_to_sendable(browsing_context_id) { - if let Some(webview) = self.webviews.get_mut(&webview_id) { - if frame_tree.pipeline.id != webview.active_top_level_pipeline_id { - webview.active_top_level_pipeline_id = frame_tree.pipeline.id; - self.constellation_to_embedder_proxy.send( - ConstellationToEmbedderMsg::DocumentAccessibilityTreeIdChanged( - webview_id, - webview.active_top_level_pipeline_id.into(), - ), - ); - } - } - - debug!("{}: Sending frame tree", browsing_context_id); - self.paint_proxy - .send(PaintMessage::SetFrameTreeForWebView(webview_id, frame_tree)); - } - - let Some(webview) = self.webviews.get(&webview_id) else { + let Some(frame_tree) = self.browsing_context_to_sendable(browsing_context_id) else { return; }; - let active = webview.accessibility_active; - for pipeline_id in self.active_pipelines_for_webview(webview_id) { + + let new_pipeline_id = frame_tree.pipeline.id; + + debug!("{}: Sending frame tree", browsing_context_id); + self.paint_proxy + .send(PaintMessage::SetFrameTreeForWebView(webview_id, frame_tree)); + + let Some(webview) = self.webviews.get_mut(&webview_id) else { + return; + }; + if webview.active_top_level_pipeline_id == Some(new_pipeline_id) { + return; + } + + let old_pipeline_id = webview.active_top_level_pipeline_id; + let old_epoch = webview.active_top_level_pipeline_epoch; + let new_epoch = old_epoch.next(); + + let accessibility_active = webview.accessibility_active; + + webview.active_top_level_pipeline_id = Some(new_pipeline_id); + webview.active_top_level_pipeline_epoch = new_epoch; + + // Deactivate accessibility in the now-inactive top-level document in the WebView. + // This ensures that the document stops sending tree updates, since they will be + // discarded in libservo anyway, and also ensures that when accessibility is + // reactivated, the document sends the whole accessibility tree from scratch. + if let Some(old_pipeline_id) = old_pipeline_id { self.send_message_to_pipeline( - pipeline_id, - ScriptThreadMessage::SetAccessibilityActive(pipeline_id, active), + old_pipeline_id, + ScriptThreadMessage::SetAccessibilityActive(old_pipeline_id, false, old_epoch), "Set accessibility active after closure", ); } + + // Forward activation to layout for the active top-level document in the WebView. + // There are two sites like this; this is the navigation (or bfcache traversal) site. + self.send_message_to_pipeline( + new_pipeline_id, + ScriptThreadMessage::SetAccessibilityActive( + new_pipeline_id, + accessibility_active, + new_epoch, + ), + "Set accessibility active after closure", + ); } #[servo_tracing::instrument(skip_all)] diff --git a/components/constellation/constellation_webview.rs b/components/constellation/constellation_webview.rs index 2b0a8ee58f1..44496b0d4de 100644 --- a/components/constellation/constellation_webview.rs +++ b/components/constellation/constellation_webview.rs @@ -3,15 +3,15 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ use embedder_traits::user_contents::UserContentManagerId; -use embedder_traits::{GenericEmbedderProxy, InputEvent, MouseLeftViewportEvent, Theme}; +use embedder_traits::{InputEvent, MouseLeftViewportEvent, Theme}; use euclid::Point2D; use log::warn; use rustc_hash::FxHashMap; use script_traits::{ConstellationInputEvent, ScriptThreadMessage}; +use servo_base::Epoch; use servo_base::id::{BrowsingContextId, PipelineId, WebViewId}; use style_traits::CSSPixel; -use super::embedder::ConstellationToEmbedderMsg; use crate::browsingcontext::BrowsingContext; use crate::pipeline::Pipeline; use crate::session_history::JointSessionHistory; @@ -23,7 +23,9 @@ pub(crate) struct ConstellationWebView { webview_id: WebViewId, /// The [`PipelineId`] of the currently active pipeline at the top level of this WebView. - pub active_top_level_pipeline_id: PipelineId, + pub active_top_level_pipeline_id: Option, + /// A counter for changes to [`Self::active_top_level_pipeline_id`]. + pub active_top_level_pipeline_epoch: Epoch, /// The currently focused browsing context in this webview for key events. /// The focused pipeline is the current entry of the focused browsing @@ -61,23 +63,15 @@ pub(crate) struct ConstellationWebView { impl ConstellationWebView { pub(crate) fn new( - embedder_proxy: &GenericEmbedderProxy, webview_id: WebViewId, - active_top_level_pipeline_id: PipelineId, focused_browsing_context_id: BrowsingContextId, user_content_manager_id: Option, ) -> Self { - embedder_proxy.send( - ConstellationToEmbedderMsg::DocumentAccessibilityTreeIdChanged( - webview_id, - active_top_level_pipeline_id.into(), - ), - ); - Self { webview_id, user_content_manager_id, - active_top_level_pipeline_id, + active_top_level_pipeline_id: None, + active_top_level_pipeline_epoch: Epoch::default(), focused_browsing_context_id, hovered_browsing_context_id: None, last_mouse_move_point: Default::default(), diff --git a/components/constellation/embedder.rs b/components/constellation/embedder.rs index e98746f1061..99f96dda4ad 100644 --- a/components/constellation/embedder.rs +++ b/components/constellation/embedder.rs @@ -2,7 +2,6 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -use accesskit::TreeId; use embedder_traits::{ InputEventOutcome, JSValue, JavaScriptEvaluationError, JavaScriptEvaluationId, MediaSessionEvent, NewWebViewDetails, TraversalId, @@ -48,7 +47,4 @@ pub enum ConstellationToEmbedderMsg { AllowNavigationRequest(WebViewId, PipelineId, ServoUrl), /// The history state has changed. HistoryChanged(WebViewId, Vec, usize), - /// Notifies the embedder that the AccessKit [`TreeId`] for the top-level document in this - /// WebView has been changed (or initially set). - DocumentAccessibilityTreeIdChanged(WebViewId, TreeId), } diff --git a/components/layout/Cargo.toml b/components/layout/Cargo.toml index bfe8fb5ebc9..4e54b06d2bd 100644 --- a/components/layout/Cargo.toml +++ b/components/layout/Cargo.toml @@ -19,6 +19,7 @@ doctest = false tracing = ["dep:tracing"] [dependencies] +accesskit = { workspace = true } app_units = { workspace = true } atomic_refcell = { workspace = true } bitflags = { workspace = true } @@ -46,6 +47,7 @@ rustc-hash = { workspace = true } script = { workspace = true } script_traits = { workspace = true } selectors = { workspace = true } +serde = { workspace = true } servo_arc = { workspace = true } servo-base = { workspace = true } servo-config = { workspace = true } diff --git a/components/layout/accessibility_tree.rs b/components/layout/accessibility_tree.rs new file mode 100644 index 00000000000..45a90684727 --- /dev/null +++ b/components/layout/accessibility_tree.rs @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +use accesskit::Role; +use layout_api::LayoutNode; +use log::trace; +use rustc_hash::FxHashMap; +use script::layout_dom::ServoLayoutNode; +use servo_base::Epoch; +use style::dom::{NodeInfo, OpaqueNode}; + +struct AccessibilityUpdate { + accesskit_update: accesskit::TreeUpdate, +} + +#[derive(Debug)] +struct AccessibilityNode { + id: accesskit::NodeId, + accesskit_node: accesskit::Node, +} + +#[derive(Debug)] +pub struct AccessibilityTree { + nodes: FxHashMap, + accesskit_tree: accesskit::Tree, + tree_id: accesskit::TreeId, + epoch: Epoch, +} + +impl AccessibilityUpdate { + fn new(tree: accesskit::Tree, tree_id: accesskit::TreeId) -> Self { + Self { + accesskit_update: accesskit::TreeUpdate { + nodes: Default::default(), + tree: Some(tree), + focus: accesskit::NodeId(1), + tree_id, + }, + } + } + + fn add(&mut self, node: &AccessibilityNode) { + self.accesskit_update + .nodes + .push((node.id, node.accesskit_node.clone())); + } +} + +impl AccessibilityTree { + const ROOT_NODE_ID: accesskit::NodeId = accesskit::NodeId(0); + + pub(super) fn new(tree_id: accesskit::TreeId, epoch: Epoch) -> Self { + // The root node doesn't correspond to a DOM node, but contains the root DOM node. + let mut root_node = AccessibilityNode::new(AccessibilityTree::ROOT_NODE_ID); + root_node + .accesskit_node + .set_role(accesskit::Role::RootWebArea); + root_node + .accesskit_node + .add_action(accesskit::Action::Focus); + + let mut tree = Self { + nodes: Default::default(), + accesskit_tree: accesskit::Tree::new(root_node.id), + tree_id, + epoch, + }; + tree.nodes.insert(root_node.id, root_node); + + tree + } + + pub(super) fn update_tree( + &mut self, + root_dom_node: &ServoLayoutNode<'_>, + ) -> Option { + let mut tree_update = AccessibilityUpdate::new(self.accesskit_tree.clone(), self.tree_id); + + let root_dom_node_id = Self::to_accesskit_id(&root_dom_node.opaque()); + let root_node = self + .nodes + .get_mut(&AccessibilityTree::ROOT_NODE_ID) + .unwrap(); + root_node + .accesskit_node + .set_children(vec![root_dom_node_id]); + + tree_update.add(root_node); + + self.update_node_and_children(root_dom_node, &mut tree_update); + Some(tree_update.accesskit_update) + } + + fn update_node_and_children( + &mut self, + dom_node: &ServoLayoutNode<'_>, + tree_update: &mut AccessibilityUpdate, + ) { + // TODO: read accessibility damage from dom_node (right now, assume damage is complete) + + let node = self.get_or_create_node_mut(dom_node); + let accesskit_node = &mut node.accesskit_node; + + let mut new_children: Vec = vec![]; + for dom_child in dom_node.flat_tree_children() { + let child_id = Self::to_accesskit_id(&dom_child.opaque()); + new_children.push(child_id); + } + if new_children != accesskit_node.children() { + accesskit_node.set_children(new_children); + } + + if dom_node.is_text_node() { + accesskit_node.set_role(Role::TextRun); + let text_content = dom_node.text_content(); + trace!("node text content = {text_content:?}"); + // FIXME: this should take into account editing selection units (grapheme clusters?) + accesskit_node.set_value(&*text_content); + } else if dom_node.as_element().is_some() { + accesskit_node.set_role(Role::GenericContainer); + } + + tree_update.add(node); + + for dom_child in dom_node.flat_tree_children() { + self.update_node_and_children(&dom_child, tree_update); + } + } + + fn get_or_create_node_mut(&mut self, dom_node: &ServoLayoutNode<'_>) -> &mut AccessibilityNode { + let id = Self::to_accesskit_id(&dom_node.opaque()); + + self.nodes + .entry(id) + .or_insert_with(|| AccessibilityNode::new(id)) + } + + fn to_accesskit_id(opaque: &OpaqueNode) -> accesskit::NodeId { + accesskit::NodeId(opaque.0 as u64) + } + + pub(crate) fn epoch(&self) -> Epoch { + self.epoch + } +} + +impl AccessibilityNode { + fn new(id: accesskit::NodeId) -> Self { + Self { + id, + accesskit_node: accesskit::Node::new(Role::Unknown), + } + } +} diff --git a/components/layout/layout_impl.rs b/components/layout/layout_impl.rs index d5018cd8e30..871951daaaf 100644 --- a/components/layout/layout_impl.rs +++ b/components/layout/layout_impl.rs @@ -42,6 +42,7 @@ use script::layout_dom::{ }; use script_traits::{DrawAPaintImageResult, PaintWorkletError, Painter, ScriptThreadMessage}; use servo_arc::Arc as ServoArc; +use servo_base::Epoch; use servo_base::generic_channel::GenericSender; use servo_base::id::{PipelineId, WebViewId}; use servo_config::opts::{self, DiagnosticsLogging}; @@ -80,6 +81,7 @@ use url::Url; use webrender_api::ExternalScrollId; use webrender_api::units::{DevicePixel, LayoutVector2D}; +use crate::accessibility_tree::AccessibilityTree; use crate::context::{CachedImageOrError, ImageResolver, LayoutContext}; use crate::display_list::{DisplayListBuilder, HitTest, PaintTimingHandler, StackingContextTree}; use crate::query::{ @@ -205,9 +207,12 @@ pub struct LayoutThread { /// Handler for all Paint Timings paint_timing_handler: RefCell>, - /// Whether accessibility is active in this layout. - /// (Note: this is a temporary field which will be replaced with an optional accessibility tree member.) - accessibility_active: Cell, + /// Layout's internal representation of its accessibility tree. + /// This is `None` if accessibility is not active. + accessibility_tree: RefCell>, + + /// See [Layout::needs_accessibility_update()]. + needs_accessibility_update: Cell, } pub struct LayoutFactoryImpl(); @@ -669,12 +674,25 @@ impl Layout for LayoutThread { &mut self.stylist } - fn set_accessibility_active(&self, active: bool) { - if !(pref!(accessibility_enabled)) { + fn set_accessibility_active(&self, active: bool, epoch: Epoch) { + if !active { + self.accessibility_tree.replace(None); return; } + self.set_needs_accessibility_update(); + let mut accessibility_tree = self.accessibility_tree.borrow_mut(); + if accessibility_tree.is_some() { + return; + } + *accessibility_tree = Some(AccessibilityTree::new(self.id.into(), epoch)); + } - self.accessibility_active.replace(active); + fn needs_accessibility_update(&self) -> bool { + self.needs_accessibility_update.get() + } + + fn set_needs_accessibility_update(&self) { + self.needs_accessibility_update.set(true); } } @@ -731,7 +749,8 @@ impl LayoutThread { previously_highlighted_dom_node: Cell::new(None), paint_timing_handler: Default::default(), user_stylesheets: config.user_stylesheets, - accessibility_active: Cell::new(false), + accessibility_tree: Default::default(), + needs_accessibility_update: Cell::new(false), } } @@ -799,6 +818,10 @@ impl LayoutThread { if self.fragment_tree.borrow().is_none() { return false; } + // If accessibility was just activated, we need reflow to build the accessibility tree. + if self.needs_accessibility_update() { + return false; + } // If we have a fragment tree and it's up-to-date and this reflow // doesn't need more reflow results, we can skip the rest of layout. @@ -851,6 +874,33 @@ impl LayoutThread { } } + fn handle_accessibility_tree_update(&self, root_element: &ServoLayoutNode) -> bool { + if !self.needs_accessibility_update() { + return false; + } + let mut accessibility_tree = self.accessibility_tree.borrow_mut(); + let Some(accessibility_tree) = accessibility_tree.as_mut() else { + return false; + }; + + let accessibility_tree = &mut *accessibility_tree; + if let Some(tree_update) = accessibility_tree.update_tree(root_element) { + // TODO(#4344): send directly to embedder over the pipeline_to_embedder_sender cloned from ScriptThread. + // FIXME: Handle send error. Could have a method on accessibility tree to + // finalise after sending, removing accessibility damage? On fail, retain damage + // for next reflow, as well as retaining document.needs_accessibility_update. + let _ = self + .script_chan + .send(ScriptThreadMessage::AccessibilityTreeUpdate( + self.webview_id, + tree_update, + accessibility_tree.epoch(), + )); + } + self.needs_accessibility_update.set(false); + true + } + /// The high-level routine that performs layout. #[servo_tracing::instrument(skip_all)] fn handle_reflow(&mut self, mut reflow_request: ReflowRequest) -> Option { @@ -905,6 +955,9 @@ impl LayoutThread { if self.handle_update_scroll_node_request(&reflow_request) { reflow_phases_run.insert(ReflowPhasesRun::UpdatedScrollNodeOffset); } + if self.handle_accessibility_tree_update(&root_element.as_node()) { + reflow_phases_run.insert(ReflowPhasesRun::UpdatedAccessibilityTree); + } let pending_images = std::mem::take(&mut *image_resolver.pending_images.lock()); let pending_rasterization_images = diff --git a/components/layout/lib.rs b/components/layout/lib.rs index 94490ca428f..f960a26dbe1 100644 --- a/components/layout/lib.rs +++ b/components/layout/lib.rs @@ -7,6 +7,7 @@ //! Layout. Performs layout on the DOM, builds display lists and sends them to be //! painted. +mod accessibility_tree; mod cell; mod context; mod display_list; diff --git a/components/script/dom/document/document.rs b/components/script/dom/document/document.rs index 18943e992d8..8c8d73fc026 100644 --- a/components/script/dom/document/document.rs +++ b/components/script/dom/document/document.rs @@ -2840,7 +2840,8 @@ impl Document { } if !self.window().layout_blocked() && (!self.restyle_reason().is_empty() || - self.window().layout().needs_new_display_list()) + self.window().layout().needs_new_display_list() || + self.window().layout().needs_accessibility_update()) { return true; } diff --git a/components/script/script_thread.rs b/components/script/script_thread.rs index c21266b0bfb..bae4965ed5a 100644 --- a/components/script/script_thread.rs +++ b/components/script/script_thread.rs @@ -80,12 +80,12 @@ use script_traits::{ }; use servo_arc::Arc as ServoArc; use servo_base::cross_process_instant::CrossProcessInstant; -use servo_base::generic_channel; use servo_base::generic_channel::GenericSender; use servo_base::id::{ BrowsingContextId, HistoryStateId, PipelineId, PipelineNamespace, ScriptEventLoopId, TEST_WEBVIEW_ID, WebViewId, }; +use servo_base::{Epoch, generic_channel}; use servo_canvas_traits::webgl::WebGLPipeline; use servo_config::{opts, pref, prefs}; use servo_constellation_traits::{ @@ -1969,16 +1969,16 @@ impl ScriptThread { .borrow_mut() .remove(&user_content_manager_id); }, - ScriptThreadMessage::AccessibilityTreeUpdate(webview_id, tree_update) => { + ScriptThreadMessage::AccessibilityTreeUpdate(webview_id, tree_update, epoch) => { let _ = self.senders.pipeline_to_embedder_sender.send( - EmbedderMsg::AccessibilityTreeUpdate(webview_id, tree_update), + EmbedderMsg::AccessibilityTreeUpdate(webview_id, tree_update, epoch), ); }, ScriptThreadMessage::UpdatePinchZoomInfos(id, pinch_zoom_infos) => { self.handle_update_pinch_zoom_infos(id, pinch_zoom_infos, CanGc::from_cx(cx)); }, - ScriptThreadMessage::SetAccessibilityActive(pipeline_id, active) => { - self.set_accessibility_active(pipeline_id, active); + ScriptThreadMessage::SetAccessibilityActive(pipeline_id, active, epoch) => { + self.set_accessibility_active(pipeline_id, active, epoch); }, ScriptThreadMessage::TriggerGarbageCollection => unsafe { JS_GC(*GlobalScope::get_cx(), GCReason::API); @@ -3703,7 +3703,7 @@ impl ScriptThread { } /// See the docs for [`ScriptThreadMessage::SetAccessibilityActive`]. - fn set_accessibility_active(&self, pipeline_id: PipelineId, active: bool) { + fn set_accessibility_active(&self, pipeline_id: PipelineId, active: bool, epoch: Epoch) { if !(pref!(accessibility_enabled)) { return; } @@ -3713,7 +3713,10 @@ impl ScriptThread { .borrow() .find_document(pipeline_id) .expect("Got pipeline_id from self.documents"); - document.window().layout().set_accessibility_active(active); + document + .window() + .layout() + .set_accessibility_active(active, epoch); } /// Handle a "navigate an iframe" message from the constellation. diff --git a/components/servo/servo.rs b/components/servo/servo.rs index 813992cd446..bc169f3b96a 100644 --- a/components/servo/servo.rs +++ b/components/servo/servo.rs @@ -669,11 +669,9 @@ impl ServoInner { warn!("Failed to respond to GetScreenMetrics: {error}"); } }, - EmbedderMsg::AccessibilityTreeUpdate(webview_id, tree_update) => { + EmbedderMsg::AccessibilityTreeUpdate(webview_id, tree_update, epoch) => { if let Some(webview) = self.get_webview_handle(webview_id) { - webview - .delegate() - .notify_accessibility_tree_update(webview, tree_update); + webview.process_accessibility_tree_update(tree_update, epoch); } }, } @@ -781,11 +779,6 @@ impl ServoInner { .notify_media_session_event(webview, media_session_event); } }, - ConstellationToEmbedderMsg::DocumentAccessibilityTreeIdChanged(webview_id, tree_id) => { - if let Some(webview) = self.get_webview_handle(webview_id) { - webview.notify_document_accessibility_tree_id(tree_id); - } - }, } } } diff --git a/components/servo/tests/accessibility.rs b/components/servo/tests/accessibility.rs index 016c4a9137c..0ecaea1154c 100644 --- a/components/servo/tests/accessibility.rs +++ b/components/servo/tests/accessibility.rs @@ -48,18 +48,159 @@ fn test_basic_accessibility_update() { let load_webview = webview.clone(); servo_test.spin(move || load_webview.load_status() != LoadStatus::Complete); - let updates = wait_for_min_updates(&servo_test, delegate.clone(), 1); + let updates = wait_for_min_updates(&servo_test, delegate.clone(), 2); let tree = build_tree(updates); + let _ = assert_tree_structure_and_get_root_web_area(&tree); +} + +#[test] +fn test_activate_accessibility_after_layout() { + let servo_test = ServoTest::new_with_builder(|builder| { + let mut preferences = Preferences::default(); + preferences.accessibility_enabled = true; + builder.preferences(preferences) + }); + + let delegate = Rc::new(WebViewDelegateImpl::default()); + let webview = WebViewBuilder::new(servo_test.servo(), servo_test.rendering_context.clone()) + .delegate(delegate.clone()) + .url(Url::parse("data:text/html,").unwrap()) + .build(); + + let load_webview = webview.clone(); + servo_test.spin(move || load_webview.load_status() != LoadStatus::Complete); + + webview.set_accessibility_active(true); + + let updates = wait_for_min_updates(&servo_test, delegate.clone(), 2); + let tree = build_tree(updates); + let _ = assert_tree_structure_and_get_root_web_area(&tree); +} + +#[test] +fn test_navigate_creates_new_accessibility_update() { + let servo_test = ServoTest::new_with_builder(|builder| { + let mut preferences = Preferences::default(); + preferences.accessibility_enabled = true; + builder.preferences(preferences) + }); + + let page_1_url = Url::parse("data:text/html, page 1").unwrap(); + let page_2_url = Url::parse("data:text/html, page 2").unwrap(); + + let delegate = Rc::new(WebViewDelegateImpl::default()); + let webview = WebViewBuilder::new(servo_test.servo(), servo_test.rendering_context.clone()) + .delegate(delegate.clone()) + .url(page_1_url) + .build(); + webview.set_accessibility_active(true); + + let load_webview = webview.clone(); + servo_test.spin(move || load_webview.load_status() != LoadStatus::Complete); + + let updates = wait_for_min_updates(&servo_test, delegate.clone(), 2); + let mut tree = build_tree(updates); + + let root_web_area = assert_tree_structure_and_get_root_web_area(&tree); + + let result = find_first_matching_node(root_web_area, |node| { + node.role() == accesskit::Role::TextRun + }); + let text_node = result.expect("Should be exactly one TextRun in the tree"); + + assert_eq!(text_node.value().as_deref(), Some("page 1")); + + let load_webview = webview.clone(); + webview.load(page_2_url.clone()); + servo_test.spin(move || load_webview.url() != Some(page_2_url.clone())); + + let new_updates = wait_for_min_updates(&servo_test, delegate.clone(), 2); + for tree_update in new_updates { + tree.update_and_process_changes(tree_update, &mut NoOpChangeHandler); + } let root_node = tree.state().root(); - let scroll_view = find_first_matching_node(root_node, |node| node.role() == Role::ScrollView) - .expect("Tree should include a scroll view corresponding to the WebView."); - let scroll_view_children = scroll_view.children().collect::>(); - assert_eq!(scroll_view_children.len(), 1); - let graft_node = scroll_view_children[0]; - assert!(graft_node.is_graft()); + let result = + find_first_matching_node(root_node, |node| node.role() == accesskit::Role::TextRun); + let text_node = result.expect("Should be exactly one TextRun in the tree"); + + assert_eq!(text_node.value().as_deref(), Some("page 2")); } +// FIXME(accessibility): when clicking back and forward, we currently rely on +// layout and the accessibility tree being rebuilt from scratch, so that the full +// a11y tree can be resent. +// But if bfcache navigations stop redoing layout, or we implement incremental +// a11y tree building, this test will break. +#[test] +fn test_accessibility_after_navigate_and_back() { + let servo_test = ServoTest::new_with_builder(|builder| { + let mut preferences = Preferences::default(); + preferences.accessibility_enabled = true; + builder.preferences(preferences) + }); + + let page_1_url = Url::parse("data:text/html, page 1").unwrap(); + let page_2_url = Url::parse("data:text/html, page 2").unwrap(); + + let delegate = Rc::new(WebViewDelegateImpl::default()); + let webview = WebViewBuilder::new(servo_test.servo(), servo_test.rendering_context.clone()) + .delegate(delegate.clone()) + .url(page_1_url.clone()) + .build(); + webview.set_accessibility_active(true); + + let load_webview = webview.clone(); + servo_test.spin(move || load_webview.load_status() != LoadStatus::Complete); + + let updates = wait_for_min_updates(&servo_test, delegate.clone(), 2); + let mut tree = build_tree(updates); + + let root_web_area = assert_tree_structure_and_get_root_web_area(&tree); + + let result = find_all_matching_nodes(root_web_area, |node| { + node.role() == accesskit::Role::TextRun + }); + assert_eq!(result.len(), 1); + let text_node = result[0]; + + assert_eq!(text_node.value().as_deref(), Some("page 1")); + + let load_webview = webview.clone(); + webview.load(page_2_url.clone()); + servo_test.spin(move || load_webview.url() != Some(page_2_url.clone())); + + let new_updates = wait_for_min_updates(&servo_test, delegate.clone(), 2); + for tree_update in new_updates { + tree.update_and_process_changes(tree_update, &mut NoOpChangeHandler); + } + + let root_node = tree.state().root(); + let result = find_all_matching_nodes(root_node, |node| node.role() == accesskit::Role::TextRun); + assert_eq!(result.len(), 1); + let text_node = result[0]; + + assert_eq!(text_node.value().as_deref(), Some("page 2")); + + let back_webview = webview.clone(); + webview.go_back(1); + servo_test.spin(move || back_webview.url() != Some(page_1_url.clone())); + + let new_updates = wait_for_min_updates(&servo_test, delegate.clone(), 2); + for tree_update in new_updates { + tree.update_and_process_changes(tree_update, &mut NoOpChangeHandler); + } + + let root_node = tree.state().root(); + let result = find_all_matching_nodes(root_node, |node| node.role() == accesskit::Role::TextRun); + assert_eq!(result.len(), 1); + let text_node = result[0]; + + assert_eq!(text_node.value().as_deref(), Some("page 1")); +} + +// TODO(accessibility): write test for resend a11y tree when clicking back or forward + fn wait_for_min_updates( servo_test: &ServoTest, delegate: Rc, @@ -114,16 +255,51 @@ fn build_tree(tree_updates: Vec) -> accesskit_consumer::Tree { tree } +fn assert_tree_structure_and_get_root_web_area<'tree>( + tree: &'tree accesskit_consumer::Tree, +) -> accesskit_consumer::Node<'tree> { + let root_node = tree.state().root(); + let scroll_view = find_first_matching_node(root_node, |node| node.role() == Role::ScrollView) + .expect("Tree should include a scroll view corresponding to the WebView."); + let scroll_view_children: Vec> = scroll_view.children().collect(); + assert_eq!(scroll_view_children.len(), 1); + let graft_node = scroll_view_children[0]; + assert!(graft_node.is_graft()); + + let graft_node_children: Vec> = graft_node.children().collect(); + assert_eq!(graft_node_children.len(), 1); + + let root_web_area = graft_node_children[0]; + assert_eq!(root_web_area.role(), Role::RootWebArea); + + root_web_area +} + fn find_first_matching_node( root_node: accesskit_consumer::Node<'_>, mut pred: impl FnMut(&accesskit_consumer::Node) -> bool, ) -> Option> { let mut children = root_node.children().collect::>(); - let mut result: Option = None; while let Some(candidate) = children.pop_front() { if pred(&candidate) { - result = Some(candidate); - break; + return Some(candidate); + } + for child in candidate.children() { + children.push_back(child); + } + } + None +} + +fn find_all_matching_nodes( + root_node: accesskit_consumer::Node<'_>, + mut pred: impl FnMut(&accesskit_consumer::Node) -> bool, +) -> Vec> { + let mut children = root_node.children().collect::>(); + let mut result = vec![]; + while let Some(candidate) = children.pop_front() { + if pred(&candidate) { + result.push(candidate); } for child in candidate.children() { children.push_back(child); diff --git a/components/servo/webview.rs b/components/servo/webview.rs index 358d9e5f1c6..19e773fbb5d 100644 --- a/components/servo/webview.rs +++ b/components/servo/webview.rs @@ -19,8 +19,10 @@ use embedder_traits::{ }; use euclid::{Scale, Size2D}; use image::RgbaImage; +use log::debug; use paint_api::WebViewTrait; use paint_api::rendering_context::RenderingContext; +use servo_base::Epoch; use servo_base::generic_channel::GenericSender; use servo_base::id::WebViewId; use servo_config::pref; @@ -106,6 +108,9 @@ pub(crate) struct WebViewInner { /// [`TreeId`] of the web contents of this [`WebView`]’s active top-level pipeline, /// which is grafted into the tree for this [`WebView`]. pub(crate) grafted_accesskit_tree_id: Option, + /// A counter for changes to the grafted accesskit tree for this webview. + /// See [`Self::grafted_accesskit_tree_id`]. + grafted_accesskit_tree_epoch: Option, rendering_context: Rc, user_content_manager: Option>, @@ -156,6 +161,7 @@ impl WebView { .unwrap_or_else(|| Rc::new(DefaultGamepadDelegate)), accesskit_tree_id: None, grafted_accesskit_tree_id: None, + grafted_accesskit_tree_epoch: None, hidpi_scale_factor: builder.hidpi_scale_factor, load_status: LoadStatus::Started, status_text: None, @@ -820,6 +826,8 @@ impl WebView { self.inner_mut().accesskit_tree_id = Some(accesskit_tree_id); } else { self.inner_mut().accesskit_tree_id = None; + self.inner_mut().grafted_accesskit_tree_id = None; + self.inner_mut().grafted_accesskit_tree_epoch = None; } self.inner().servo.constellation_proxy().send( @@ -837,7 +845,8 @@ impl WebView { .inner_mut() .grafted_accesskit_tree_id .replace(grafted_tree_id); - // TODO(accessibility): try to avoid duplicate notifications in the first place? + // TODO(#4344): try to avoid duplicate notifications in the first place? + // (see ConstellationWebView::new for more details) if old_grafted_tree_id == Some(grafted_tree_id) { return; } @@ -861,6 +870,30 @@ impl WebView { }, ); } + + pub(crate) fn process_accessibility_tree_update(&self, tree_update: TreeUpdate, epoch: Epoch) { + if self + .inner() + .grafted_accesskit_tree_epoch + .is_some_and(|current| epoch < current) + { + // We expect this to happen occasionally when the constellation navigates, because + // deactivating accessibility happens asynchronously, so the script thread of the + // previously active document may continue sending updates for a short period of time. + debug!("Ignoring stale tree update for {:?}", tree_update.tree_id); + return; + } + if self + .inner() + .grafted_accesskit_tree_epoch + .is_none_or(|current| epoch > current) + { + self.notify_document_accessibility_tree_id(tree_update.tree_id); + self.inner_mut().grafted_accesskit_tree_epoch = Some(epoch); + } + self.delegate() + .notify_accessibility_tree_update(self.clone(), tree_update); + } } /// A structure used to expose a view of the [`WebView`] to the Servo diff --git a/components/shared/embedder/lib.rs b/components/shared/embedder/lib.rs index 5107577ac16..ffa244c41be 100644 --- a/components/shared/embedder/lib.rs +++ b/components/shared/embedder/lib.rs @@ -30,6 +30,7 @@ use malloc_size_of::malloc_size_of_is_0; use malloc_size_of_derive::MallocSizeOf; use pixels::SharedRasterImage; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use servo_base::Epoch; use servo_base::generic_channel::{ GenericCallback, GenericSender, GenericSharedMemory, SendResult, }; @@ -508,7 +509,7 @@ pub enum EmbedderMsg { /// and the embedder can continue processing it, if necessary. InputEventsHandled(WebViewId, Vec), /// Send the embedder an accessibility tree update. - AccessibilityTreeUpdate(WebViewId, TreeUpdate), + AccessibilityTreeUpdate(WebViewId, TreeUpdate, Epoch), } impl Debug for EmbedderMsg { diff --git a/components/shared/layout/lib.rs b/components/shared/layout/lib.rs index 325cb3a95b1..d60eab49533 100644 --- a/components/shared/layout/lib.rs +++ b/components/shared/layout/lib.rs @@ -387,7 +387,24 @@ pub trait Layout { fn query_effective_overflow(&self, node: TrustedNodeAddress) -> Option; fn stylist_mut(&mut self) -> &mut Stylist; - fn set_accessibility_active(&self, active: bool); + /// Set whether the accessibility tree should be constructed for this Layout. + /// This should be called by the embedder when accessibility is requested by the user. + fn set_accessibility_active(&self, enabled: bool, epoch: Epoch); + + /// Whether the accessibility tree needs updating. This is set to true when + /// - accessibility is activated; or + /// - a page is loaded after accesibility is activated. + /// + /// In future, this should be set to true if DOM or style have changed in a way that + /// impacts the accessibility tree. + /// + /// Checked in can_skip_reflow_request_entirely(), as a dirty accessibility tree + /// should force a reflow, and handle_reflow() to determine whether to update the + /// accessibility tree during reflow. + fn needs_accessibility_update(&self) -> bool; + + /// See [Self::needs_accessibility_update()]. + fn set_needs_accessibility_update(&self); } /// This trait is part of `layout_api` because it depends on both `script_traits` @@ -593,6 +610,7 @@ bitflags! { /// updating style or layout. This is used when updating canvas contents and /// progressing to a new animated image frame. const UpdatedImageData = 1 << 5; + const UpdatedAccessibilityTree = 1 << 6; } } diff --git a/components/shared/script/lib.rs b/components/shared/script/lib.rs index b52f5ff9b78..ec4c8e3c92c 100644 --- a/components/shared/script/lib.rs +++ b/components/shared/script/lib.rs @@ -30,6 +30,7 @@ use pixels::PixelFormat; use profile_traits::mem; use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; +use servo_base::Epoch; use servo_base::cross_process_instant::CrossProcessInstant; use servo_base::generic_channel::{GenericCallback, GenericReceiver, GenericSender}; use servo_base::id::{ @@ -312,7 +313,7 @@ pub enum ScriptThreadMessage { /// `user_contents_for_manager_id` map. DestroyUserContentManager(UserContentManagerId), /// Send the embedder an accessibility tree update. - AccessibilityTreeUpdate(WebViewId, accesskit::TreeUpdate), + AccessibilityTreeUpdate(WebViewId, accesskit::TreeUpdate, Epoch), /// Update the pinch zoom details of a pipeline. Each `Window` stores a `VisualViewport` DOM /// instance that gets updated according to the changes from the `Compositor``. UpdatePinchZoomInfos(PipelineId, PinchZoomInfos), @@ -324,7 +325,7 @@ pub enum ScriptThreadMessage { /// those pipelines run in script threads, which complicates things: the pipelines in a webview /// may be split across multiple script threads, and the pipelines in a script thread may belong /// to multiple webviews. So the simplest approach is to activate it for one pipeline at a time. - SetAccessibilityActive(PipelineId, bool), + SetAccessibilityActive(PipelineId, bool, Epoch), /// Force a garbage collection in this script thread. TriggerGarbageCollection, } diff --git a/ports/servoshell/running_app_state.rs b/ports/servoshell/running_app_state.rs index 67ace2ebf4f..03415d3231b 100644 --- a/ports/servoshell/running_app_state.rs +++ b/ports/servoshell/running_app_state.rs @@ -678,6 +678,8 @@ impl RunningAppState { for window in self.windows().values() { for (_, webview) in window.webviews() { + // Activate accessibility in the WebView. + // There are two sites like this; this is the a11y activation site. webview.set_accessibility_active(active); } } diff --git a/ports/servoshell/window.rs b/ports/servoshell/window.rs index afa881fde7f..4ecc1d59eb2 100644 --- a/ports/servoshell/window.rs +++ b/ports/servoshell/window.rs @@ -107,6 +107,8 @@ impl ServoShellWindow { self.add_webview(webview.clone()); // If `self` is not in `state.windows`, our notify_accessibility_tree_update() will panic. if state.accessibility_active() { + // Activate accessibility in the WebView. + // There are two sites like this; this is the WebView creation site. webview.set_accessibility_active(true); } webview