Skip to main content

cranpose_app_shell/shell_input/
key_navigation.rs

1use std::fmt::Debug;
2
3use cranpose_core::{enter_event_handler_scope, run_in_mutable_snapshot};
4use cranpose_render_common::Renderer;
5use cranpose_ui::{FocusDirection, Key, KeyEvent, KeyEventType, request_render_invalidation};
6
7use super::key_activation::plain_key_down;
8use crate::SurfaceMut;
9
10#[expect(clippy::multiple_inherent_impl, reason = "one file per shell concern")]
11impl<R> SurfaceMut<'_, R>
12where
13    R: Renderer,
14    R::Error: Debug,
15{
16    pub(super) fn on_focus_key(&mut self, event: &KeyEvent) -> bool {
17        if !plain_key_down(event) || event.key() != Key::Tab {
18            return false;
19        }
20        self.on_group_navigation_key(event)
21    }
22
23    fn on_group_navigation_key(&mut self, event: &KeyEvent) -> bool {
24        let focused = cranpose_ui::active_focus_target();
25        let target = self.with_layout_tree(|tree| {
26            cranpose_ui::keyboard_focus_target(tree?, focused, event.key(), event.modifiers().shift)
27        });
28        let Some(target) = target else {
29            return false;
30        };
31        let moved =
32            run_in_mutable_snapshot(|| cranpose_ui::request_focus_from_platform(target.node_id))
33                .unwrap_or(false);
34        if moved {
35            self.mark_dirty();
36            self.note_focus_moved_by_keyboard(true);
37            if target.activate {
38                self.accessibility_activate(target.node_id, None);
39            }
40        }
41        moved
42    }
43
44    // Presses the focused control with the keys Compose's clickable takes:
45    // Enter, the keypad's Enter, the space bar and a D-pad's center. The
46    // press lands as the key goes down and clicks as it comes up; a held
47    // key presses once.
48    pub(super) fn on_activation_key(&mut self, event: &KeyEvent) -> bool {
49        if !matches!(
50            event.key(),
51            Key::Enter | Key::NumPadEnter | Key::Spacebar | Key::DirectionCenter
52        ) || cranpose_ui::text_field_focus::has_focused_field()
53        {
54            return false;
55        }
56        if event.type_() == KeyEventType::KeyUp {
57            return self.release_by_key(event.key());
58        }
59        if !plain_key_down(event) {
60            return false;
61        }
62        let Some(focused) = cranpose_ui::active_focus_target() else {
63            return false;
64        };
65        if event.native_key_event().repeat_count > 0 {
66            return true;
67        }
68        let pressed = self.press_by_key(focused, event.key());
69        self.note_focus_moved_by_keyboard(true);
70        pressed
71    }
72
73    // On a D-pad a focused text field leaves the arrows to focus navigation
74    // and opens its software keyboard on the center button, as Compose's
75    // text field does with a D-pad. An arrow with no target that way moves
76    // the caret. While the keyboard is open, the D-pad's keys go to it.
77    pub(super) fn on_field_dpad_key(&mut self, event: &KeyEvent) -> bool {
78        if !self.surface().dpad_navigation || !cranpose_ui::text_field_focus::has_focused_field() {
79            return false;
80        }
81        match event.key() {
82            Key::DirectionCenter => {
83                if event.type_() == KeyEventType::KeyDown
84                    && event.native_key_event().repeat_count == 0
85                {
86                    cranpose_ui::text_field_focus::show_keyboard();
87                }
88                true
89            }
90            Key::DirectionUp | Key::DirectionDown | Key::DirectionLeft | Key::DirectionRight => {
91                self.on_navigation_key(event)
92            }
93            _ => false,
94        }
95    }
96
97    // Takes a key the focused control and its ancestors left: a navigation
98    // key moves focus, and any other goes to the `UnhandledKeyEvents`
99    // handlers unless a text field takes typing.
100    pub(super) fn on_unconsumed_key(&mut self, event: &KeyEvent) -> bool {
101        if self.on_navigation_key(event) {
102            return true;
103        }
104        if cranpose_ui::text_field_focus::has_focused_field() {
105            return false;
106        }
107        let handled = run_in_mutable_snapshot(|| cranpose_ui::dispatch_unhandled_key_event(event))
108            .unwrap_or(false);
109        if handled {
110            self.mark_dirty();
111            self.shell.app.request_layout_pass();
112        }
113        handled
114    }
115
116    // Moves focus with an arrow, Home or End key that no handler took:
117    // inside a radio group, tab bar or menu by its own rules, else an arrow
118    // moves to the nearest target that way, first within the focused node's
119    // selectable group and then on the whole screen. A held key moves once a
120    // frame.
121    fn on_navigation_key(&mut self, event: &KeyEvent) -> bool {
122        let direction = match event.key() {
123            Key::DirectionLeft => Some(FocusDirection::Left),
124            Key::DirectionRight => Some(FocusDirection::Right),
125            Key::DirectionUp => Some(FocusDirection::Up),
126            Key::DirectionDown => Some(FocusDirection::Down),
127            Key::MoveHome | Key::MoveEnd => None,
128            _ => return false,
129        };
130        if !plain_key_down(event)
131            || (cranpose_ui::text_field_focus::has_focused_field()
132                && !self.surface().dpad_navigation)
133        {
134            return false;
135        }
136        let frame = Some(self.surface().frames);
137        if event.native_key_event().repeat_count > 0 && self.surface().key_moved_focus_at == frame {
138            return true;
139        }
140        let moved = self.on_group_navigation_key(event)
141            || direction.is_some_and(|direction| {
142                self.move_focus_by_arrow(direction) || self.scroll_by_arrow(direction)
143            });
144        if moved {
145            self.surface_mut().key_moved_focus_at = frame;
146        }
147        moved
148    }
149
150    // Scrolls the nearest scroll container around the focused control that
151    // can move the arrow's way by half its viewport, as Android's ScrollView
152    // does for an arrow that finds no target: a page of text with nothing to
153    // focus scrolls with the keys. Answers whether it moved.
154    fn scroll_by_arrow(&mut self, direction: FocusDirection) -> bool {
155        let Some(focused) = cranpose_ui::active_focus_target() else {
156            return false;
157        };
158        let vertical = matches!(direction, FocusDirection::Up | FocusDirection::Down);
159        let forward = matches!(direction, FocusDirection::Down | FocusDirection::Right);
160        let container = {
161            let (app, surface) = self.parts();
162            surface.semantics_tree_for_input(app).and_then(|tree| {
163                let root = tree.root();
164                crate::focus_reveal::scroll_ancestors(root, focused)?
165                    .into_iter()
166                    .find_map(|ancestor| {
167                        let node = crate::focus_reveal::semantics_of(root, ancestor)?;
168                        let details = node.details();
169                        let range = if vertical {
170                            details.vertical_scroll
171                        } else {
172                            details.horizontal_scroll
173                        }?;
174                        let can_move = if forward != range.reverse {
175                            range.can_scroll_forward()
176                        } else {
177                            range.can_scroll_backward()
178                        };
179                        let action = details.scroll_by.clone()?;
180                        can_move.then_some((ancestor, action, range.reverse))
181                    })
182            })
183        };
184        let Some((ancestor, action, reverse)) = container else {
185            return false;
186        };
187        let Some(viewport) =
188            self.with_layout_tree(|tree| crate::focus_reveal::bounds_of(tree?.root(), ancestor))
189        else {
190            return false;
191        };
192        let half = if vertical {
193            viewport.height
194        } else {
195            viewport.width
196        } * 0.5;
197        let delta = if forward != reverse { half } else { -half };
198        let (dx, dy) = if vertical { (0.0, delta) } else { (delta, 0.0) };
199        let moved = run_in_mutable_snapshot(|| action.invoke(dx, dy)).unwrap_or(false);
200        if moved {
201            self.mark_dirty();
202            self.shell.app.request_layout_pass();
203        }
204        moved
205    }
206
207    // Gives focus to a target when none holds it, as an arrow would: the
208    // target now where the last focused one was, or the first one. The
209    // search builds the whole layout tree, and with no target registered it
210    // can find none, so a screen with no target skips it after each layout.
211    pub(crate) fn keep_focus(&mut self) {
212        if cranpose_ui::active_focus_target().is_none() && cranpose_ui::has_focus_targets() {
213            self.move_focus_by_arrow(FocusDirection::Down);
214        }
215    }
216
217    // Out of line: a frame that keeps focus seldom moves it, and the frame's
218    // own code stays small.
219    #[inline(never)]
220    fn move_focus_by_arrow(&mut self, direction: FocusDirection) -> bool {
221        let target = self.with_layout_tree(|layout_tree| {
222            cranpose_ui::arrow_focus_target(layout_tree?, direction)
223        });
224        let Some(target) = target else {
225            return false;
226        };
227        let request = if self.surface().dpad_navigation {
228            cranpose_ui::request_focus_without_keyboard
229        } else {
230            cranpose_ui::request_focus_from_platform
231        };
232        let moved = run_in_mutable_snapshot(|| request(target)).unwrap_or(false);
233        if moved {
234            self.mark_dirty();
235            self.note_focus_moved_by_keyboard(true);
236        }
237        moved
238    }
239
240    pub(super) fn note_focus_moved_by_keyboard(&mut self, keyboard: bool) {
241        if cranpose_ui::set_keyboard_focus_visible(keyboard) {
242            request_render_invalidation();
243            self.mark_dirty();
244        }
245    }
246
247    pub(super) fn on_escape_key(&mut self, event: &KeyEvent) -> bool {
248        if event.type_() != KeyEventType::KeyDown || event.key() != Key::Escape {
249            return false;
250        }
251        self.dismiss_top_modal_in_context()
252    }
253
254    /// Asks the innermost open dialog or popup to close, the way the platform
255    /// back gesture does. Answers whether one was open to take the request.
256    pub fn dismiss_top_modal(&mut self) -> bool {
257        let _event_handler = enter_event_handler_scope();
258        let _app_context = self.shell.app.app_context.enter_scope();
259        self.dismiss_top_modal_in_context()
260    }
261
262    fn dismiss_top_modal_in_context(&mut self) -> bool {
263        let closed = run_in_mutable_snapshot(|| {
264            if cranpose_ui::modal_depth() > 0 {
265                cranpose_ui::dispatch_modal_back()
266            } else {
267                cranpose_ui::dismiss_top_popup()
268            }
269        })
270        .unwrap_or(false);
271        if closed {
272            self.mark_dirty();
273        }
274        closed
275    }
276
277    /// Publishes the focus order layout left behind and moves focus one step.
278    /// Answers whether focus moved.
279    pub fn move_focus_in_context(&mut self, direction: FocusDirection) -> bool {
280        let order = self.with_layout_tree(|layout_tree| {
281            layout_tree
282                .map(cranpose_ui::collect_focus_order)
283                .unwrap_or_default()
284        });
285        self.move_focus_in_order(order, direction)
286    }
287
288    fn move_focus_in_order(
289        &mut self,
290        order: Vec<cranpose_ui::FocusEntry>,
291        direction: FocusDirection,
292    ) -> bool {
293        cranpose_ui::set_focus_order(order);
294        let moved = run_in_mutable_snapshot(|| cranpose_ui::FocusManager.move_focus(direction))
295            .unwrap_or(false);
296        if moved {
297            self.mark_dirty();
298            self.note_focus_moved_by_keyboard(true);
299        }
300        moved
301    }
302}