Skip to main content

cranpose_app_shell/
focus_reveal.rs

1use cranpose_core::{NodeId, run_in_mutable_snapshot};
2use cranpose_foundation::{ScrollAxisRange, SemanticsScrollBy};
3use cranpose_render_common::Renderer;
4use cranpose_ui::{LayoutBox, SemanticsNode, layout::can_skip_scroll_reveal_from_applier};
5use cranpose_ui_graphics::Rect;
6
7use crate::AppShell;
8
9impl<R: Renderer> AppShell<R>
10where
11    R::Error: std::fmt::Debug,
12{
13    /// Reveals a screen reader's target without changing keyboard focus.
14    ///
15    /// Hidden, disabled, removed and modal-background targets are rejected.
16    /// Returns whether a scroll action reports movement.
17    pub fn accessibility_reveal(&mut self, node_id: NodeId) -> bool {
18        let _app_context = self.app.app_context.enter_scope();
19        self.reveal_node_in_context(node_id)
20    }
21
22    pub(crate) fn reveal_new_focus(&mut self) {
23        let focused = self.app.app_context.active_focus_target();
24        if focused == self.app.revealed_focus {
25            return;
26        }
27        self.app.revealed_focus = focused;
28        let Some(focused) = focused else { return };
29        self.reveal_node_with(focused, true);
30    }
31
32    fn reveal_node_in_context(&mut self, focused: NodeId) -> bool {
33        self.reveal_node_with(focused, false)
34    }
35
36    /// Out of line: a frame checks for a newly focused node and seldom finds
37    /// one, so the frame's own code stays small.
38    #[inline(never)]
39    fn reveal_node_with(&mut self, focused: NodeId, remeasure_containers: bool) -> bool {
40        let mut revealed = false;
41        for index in 0..self.surfaces.len() {
42            let surface = &self.surfaces[index];
43            if surface.layout_tree.is_none() && surface.semantics_tree.is_none() {
44                self.app.flush_semantics_invalidations();
45                if let Some(root) = surface.root_node(&self.app)
46                    && can_skip_scroll_reveal_from_applier(
47                        &mut self.app.composition.applier_mut(),
48                        root,
49                        focused,
50                    )
51                    .unwrap_or(false)
52                {
53                    continue;
54                }
55            }
56            let ancestors = self.surfaces[index]
57                .semantics_tree_for_input(&mut self.app)
58                .and_then(|tree| scroll_ancestors(tree.root(), focused))
59                .unwrap_or_default();
60            if remeasure_containers && !ancestors.is_empty() {
61                for &ancestor in &ancestors {
62                    cranpose_ui::schedule_measure_repass(ancestor);
63                }
64                self.app.request_layout_pass();
65                self.run_layout_phase_in_context();
66            }
67            for ancestor in ancestors {
68                revealed |= self.reveal_within_container(index, focused, ancestor);
69            }
70        }
71        revealed
72    }
73
74    fn reveal_within_container(&mut self, index: usize, focused: NodeId, ancestor: NodeId) -> bool {
75        let mut revealed = false;
76        let mut previous_distance = f32::INFINITY;
77        loop {
78            let surface = &mut self.surfaces[index];
79            let request = surface
80                .layout_tree_in_context(&mut self.app)
81                .and_then(|tree| {
82                    Some((
83                        bounds_of(tree.root(), focused)?,
84                        bounds_of(tree.root(), ancestor)?,
85                    ))
86                })
87                .and_then(|(target, viewport)| {
88                    let tree = surface.semantics_tree_for_input(&mut self.app)?;
89                    scroll_request(semantics_of(tree.root(), ancestor)?, target, viewport)
90                });
91            let Some((action, dx, dy)) = request else {
92                break;
93            };
94            let distance = dx.abs() + dy.abs();
95            if distance >= previous_distance {
96                break;
97            }
98            previous_distance = distance;
99            if !run_in_mutable_snapshot(|| action.invoke(dx, dy)).unwrap_or(false) {
100                break;
101            }
102            revealed = true;
103            self.app.request_layout_pass();
104            self.run_layout_phase_in_context();
105        }
106        revealed
107    }
108}
109
110pub(crate) fn scroll_ancestors(node: &SemanticsNode, target: NodeId) -> Option<Vec<NodeId>> {
111    if node.hidden || !node.enabled {
112        return None;
113    }
114    if node.node_id == target {
115        return Some(Vec::new());
116    }
117    let mut path = node
118        .children
119        .iter()
120        .find_map(|node| scroll_ancestors(node, target))?;
121    if node.details().scroll_by.is_some() {
122        path.push(node.node_id);
123    }
124    Some(path)
125}
126
127pub(crate) fn semantics_of(node: &SemanticsNode, target: NodeId) -> Option<&SemanticsNode> {
128    if node.node_id == target {
129        return Some(node);
130    }
131    node.children
132        .iter()
133        .find_map(|node| semantics_of(node, target))
134}
135
136pub(crate) fn bounds_of(node: &LayoutBox, target: NodeId) -> Option<Rect> {
137    if node.node_id == target {
138        return Some(node.rect);
139    }
140    node.children
141        .iter()
142        .find_map(|node| bounds_of(node, target))
143}
144
145fn scroll_request(
146    node: &SemanticsNode,
147    target: Rect,
148    viewport: Rect,
149) -> Option<(SemanticsScrollBy, f32, f32)> {
150    let details = node.details();
151    let action = details.scroll_by.clone()?;
152    let dx = axis_delta(
153        target.x,
154        target.width,
155        viewport.x,
156        viewport.width,
157        details.horizontal_scroll,
158    );
159    let dy = axis_delta(
160        target.y,
161        target.height,
162        viewport.y,
163        viewport.height,
164        details.vertical_scroll,
165    );
166    (dx != 0.0 || dy != 0.0).then_some((action, dx, dy))
167}
168
169fn axis_delta(
170    start: f32,
171    size: f32,
172    viewport_start: f32,
173    viewport_size: f32,
174    range: Option<ScrollAxisRange>,
175) -> f32 {
176    let Some(range) = range else { return 0.0 };
177    if ![start, size, viewport_start, viewport_size]
178        .iter()
179        .all(|value| value.is_finite())
180        || size <= 0.0
181        || viewport_size <= 0.0
182    {
183        return 0.0;
184    }
185    let inset_start = range.content_padding_start.max(0.0);
186    let inset_end = range.content_padding_end.max(0.0);
187    let (viewport_start, viewport_size) = if viewport_size - inset_start - inset_end >= size {
188        (
189            viewport_start + inset_start,
190            viewport_size - inset_start - inset_end,
191        )
192    } else {
193        (viewport_start, viewport_size)
194    };
195    let delta = range.bring_into_view_spec.calculate_scroll_distance(
196        start - viewport_start,
197        size,
198        viewport_size,
199    );
200    let delta = if range.reverse { -delta } else { delta };
201    if (delta > 0.0 && range.can_scroll_forward()) || (delta < 0.0 && range.can_scroll_backward()) {
202        delta
203    } else {
204        0.0
205    }
206}
207
208#[cfg(test)]
209#[path = "tests/focus_reveal_tests.rs"]
210mod tests;