Skip to main content

cranpose_ui/
focus_manager.rs

1use cranpose_core::{CompositionLocal, NodeId};
2
3use crate::{
4    focus_dispatch,
5    focus_order::{FocusEntry, with_focus_order},
6    modifier::FocusDirection,
7};
8
9/// Moves focus between the focus targets the tree holds, the way
10/// `LocalFocusManager` does in Jetpack Compose.
11///
12/// ```ignore
13/// let focus = cranpose_ui::local_focus_manager().current();
14/// focus.move_focus(FocusDirection::Next);
15/// focus.clear_focus();
16/// ```
17///
18/// Tab and Shift+Tab reach this through the app shell, so an app that puts
19/// `Modifier::focusable()` on its controls gets keyboard traversal with no
20/// further code.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub struct FocusManager;
23
24impl FocusManager {
25    /// Moves focus one step in `direction`. Answers whether focus moved.
26    pub fn move_focus(&self, direction: FocusDirection) -> bool {
27        let Some(target) = next_target(direction) else {
28            return false;
29        };
30        focus_dispatch::request_focus_in_context(target)
31    }
32
33    /// Drops focus from whatever holds it, and answers whether one held it.
34    /// Compose takes a `force` flag here for targets that refuse to give focus
35    /// up; no target in Cranpose refuses, so this always clears.
36    pub fn clear_focus(&self) -> bool {
37        focus_dispatch::clear_active_focus()
38    }
39}
40
41/// CompositionLocal carrying the [`FocusManager`], as `LocalFocusManager` does
42/// in Compose. The same instance comes back on every call.
43pub fn local_focus_manager() -> CompositionLocal<FocusManager> {
44    crate::environment_locals::cached_local(
45        |locals| &locals.focus_manager,
46        || cranpose_core::compositionLocalOf(FocusManager::default),
47    )
48}
49
50/// Moves focus onto `node_id` when it holds a focus target. A platform's
51/// accessibility layer calls this when a screen reader lands on a control, so
52/// the app's focus follows the reader's.
53pub fn request_focus_from_platform(node_id: NodeId) -> bool {
54    focus_dispatch::request_focus_in_context(node_id)
55}
56
57fn next_target(direction: FocusDirection) -> Option<NodeId> {
58    with_focus_order(|order| {
59        if order.is_empty() {
60            return None;
61        }
62        let active = focus_dispatch::active_focus_target();
63        let current = active.and_then(|node_id| order.iter().position(|e| e.node_id == node_id));
64
65        match direction {
66            FocusDirection::Next | FocusDirection::Enter => Some(step(order, current, 1)),
67            FocusDirection::Previous => Some(step(order, current, -1)),
68            FocusDirection::Exit => None,
69            FocusDirection::Up
70            | FocusDirection::Down
71            | FocusDirection::Left
72            | FocusDirection::Right => current.and_then(|index| nearest(order, index, direction)),
73        }
74    })
75}
76
77fn step(order: &[FocusEntry], current: Option<usize>, delta: isize) -> NodeId {
78    let count = order.len() as isize;
79    let index = match current {
80        Some(index) => (index as isize + delta).rem_euclid(count),
81        None if delta > 0 => 0,
82        None => count - 1,
83    };
84    order[index as usize].node_id
85}
86
87fn nearest(order: &[FocusEntry], from: usize, direction: FocusDirection) -> Option<NodeId> {
88    let (from_x, from_y) = order[from].center();
89    let mut best: Option<(f32, NodeId)> = None;
90
91    for (index, entry) in order.iter().enumerate() {
92        if index == from {
93            continue;
94        }
95        let (x, y) = entry.center();
96        let (along, across) = match direction {
97            FocusDirection::Up => (from_y - y, (x - from_x).abs()),
98            FocusDirection::Down => (y - from_y, (x - from_x).abs()),
99            FocusDirection::Left => (from_x - x, (y - from_y).abs()),
100            FocusDirection::Right => (x - from_x, (y - from_y).abs()),
101            _ => continue,
102        };
103        if along <= 0.0 {
104            continue;
105        }
106        let cost = along + across * 2.0;
107        if best.is_none_or(|(best_cost, _)| cost < best_cost) {
108            best = Some((cost, entry.node_id));
109        }
110    }
111
112    best.map(|(_, node_id)| node_id)
113}
114
115#[cfg(test)]
116#[path = "tests/focus_manager_tests.rs"]
117mod tests;