Skip to main content

cranpose_ui/
focus_manager.rs

1use cranpose_core::{CompositionLocal, NodeId};
2use cranpose_ui_graphics::Rect;
3
4use crate::{
5    focus_dispatch,
6    focus_order::{FocusEntry, with_focus_order},
7    modifier::FocusDirection,
8    two_dimensional_focus_search::{Arrow, entry_point, find_best_candidate},
9};
10
11/// Moves focus between the focus targets the tree holds, the way
12/// `LocalFocusManager` does in Jetpack Compose.
13///
14/// ```ignore
15/// let focus = cranpose_ui::local_focus_manager().current();
16/// focus.move_focus(FocusDirection::Next);
17/// focus.clear_focus();
18/// ```
19///
20/// Tab and Shift+Tab reach this through the app shell, so an app that puts
21/// `Modifier::focusable()` on its controls gets keyboard traversal with no
22/// further code.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub struct FocusManager;
25
26impl FocusManager {
27    /// Moves focus one step in `direction`. Answers whether focus moved.
28    pub fn move_focus(&self, direction: FocusDirection) -> bool {
29        let Some(target) = next_target(direction) else {
30            return false;
31        };
32        let moved = focus_dispatch::request_focus_in_context(target);
33        if moved {
34            focus_dispatch::note_focus_rect(target);
35        }
36        moved
37    }
38
39    /// Drops focus from whatever holds it, and answers whether one held it.
40    /// Compose takes a `force` flag here for targets that refuse to give focus
41    /// up; no target in Cranpose refuses, so this always clears.
42    pub fn clear_focus(&self) -> bool {
43        focus_dispatch::clear_active_focus()
44    }
45}
46
47/// CompositionLocal carrying the [`FocusManager`], as `LocalFocusManager` does
48/// in Compose. The same instance comes back on every call.
49pub fn local_focus_manager() -> CompositionLocal<FocusManager> {
50    crate::environment_locals::cached_local(
51        |locals| &locals.focus_manager,
52        || cranpose_core::compositionLocalOf(FocusManager::default),
53    )
54}
55
56/// Moves focus onto `node_id` when it holds a focus target. A platform's
57/// accessibility layer calls this when a screen reader lands on a control, so
58/// the app's focus follows the reader's.
59pub fn request_focus_from_platform(node_id: NodeId) -> bool {
60    focus_dispatch::request_focus_in_context(node_id)
61}
62
63/// Moves focus onto `node_id` as a D-pad does: a text field that takes it
64/// leaves the software keyboard closed until [`show_keyboard`] opens it.
65///
66/// [`show_keyboard`]: crate::text_field_focus::show_keyboard
67#[doc(hidden)]
68pub fn request_focus_without_keyboard(node_id: NodeId) -> bool {
69    crate::text_input_session::without_keyboard(|| {
70        focus_dispatch::request_focus_in_context(node_id)
71    })
72}
73
74fn next_target(direction: FocusDirection) -> Option<NodeId> {
75    with_focus_order(|order| {
76        if order.is_empty() {
77            return None;
78        }
79        let active = focus_dispatch::active_focus_target();
80        let current = active.and_then(|node_id| order.iter().position(|e| e.node_id == node_id));
81
82        if let Some(arrow) = Arrow::of(direction) {
83            return arrow_target(order, active, arrow);
84        }
85        match direction {
86            FocusDirection::Next | FocusDirection::Enter => Some(step(order, current, 1)),
87            FocusDirection::Previous => Some(step(order, current, -1)),
88            FocusDirection::Exit
89            | FocusDirection::Up
90            | FocusDirection::Down
91            | FocusDirection::Left
92            | FocusDirection::Right => None,
93        }
94    })
95}
96
97// The target an arrow reaches in `order` from the focused target `active`.
98// With the focused target gone, the arrow goes to the target that took its
99// place, as a list gives focus to the item that moved into the removed one's
100// row, or else searches from that place. With nothing focused before, it
101// enters from the arrow's edge.
102pub(crate) fn arrow_target(
103    order: &[FocusEntry],
104    active: Option<NodeId>,
105    arrow: Arrow,
106) -> Option<NodeId> {
107    if let Some(focused) = order.iter().find(|entry| Some(entry.node_id) == active) {
108        let candidates = order.iter().filter(|entry| Some(entry.node_id) != active);
109        return find_best_candidate(candidates, focused.rect, arrow);
110    }
111    if let Some(last) = focus_dispatch::last_focus_rect() {
112        return order
113            .iter()
114            .find(|entry| covers_center(entry.rect, last))
115            .map(|entry| entry.node_id)
116            .or_else(|| find_best_candidate(order, last, arrow));
117    }
118    find_best_candidate(order, entry_point(order, arrow)?, arrow)
119}
120
121fn covers_center(rect: Rect, of: Rect) -> bool {
122    let (x, y) = (of.x + of.width * 0.5, of.y + of.height * 0.5);
123    x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
124}
125
126fn step(order: &[FocusEntry], current: Option<usize>, delta: isize) -> NodeId {
127    let count = order.len() as isize;
128    let index = match current {
129        Some(index) => (index as isize + delta).rem_euclid(count),
130        None if delta > 0 => 0,
131        None => count - 1,
132    };
133    order[index as usize].node_id
134}
135
136#[cfg(test)]
137#[path = "tests/focus_manager_tests.rs"]
138mod tests;