Skip to main content

cranpose_ui/
focus_manager.rs

1use std::cell::RefCell;
2
3use cranpose_core::{CompositionLocal, NodeId};
4
5use crate::{
6    focus_dispatch,
7    focus_order::{FocusEntry, with_focus_order},
8    modifier::FocusDirection,
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        focus_dispatch::request_focus_in_context(target)
33    }
34
35    /// Drops focus from whatever holds it, and answers whether one held it.
36    /// Compose takes a `force` flag here for targets that refuse to give focus
37    /// up; no target in Cranpose refuses, so this always clears.
38    pub fn clear_focus(&self) -> bool {
39        focus_dispatch::clear_active_focus()
40    }
41}
42
43/// CompositionLocal carrying the [`FocusManager`], as `LocalFocusManager` does
44/// in Compose. The same instance comes back on every call.
45pub fn local_focus_manager() -> CompositionLocal<FocusManager> {
46    thread_local! {
47        static LOCAL_FOCUS_MANAGER: RefCell<Option<CompositionLocal<FocusManager>>> =
48            const { RefCell::new(None) };
49    }
50
51    LOCAL_FOCUS_MANAGER.with(|cell| {
52        cell.borrow_mut()
53            .get_or_insert_with(|| cranpose_core::compositionLocalOf(FocusManager::default))
54            .clone()
55    })
56}
57
58/// Moves focus onto `node_id` when it holds a focus target. A platform's
59/// accessibility layer calls this when a screen reader lands on a control, so
60/// the app's focus follows the reader's.
61pub fn request_focus_from_platform(node_id: NodeId) -> bool {
62    focus_dispatch::request_focus_in_context(node_id)
63}
64
65fn next_target(direction: FocusDirection) -> Option<NodeId> {
66    with_focus_order(|order| {
67        if order.is_empty() {
68            return None;
69        }
70        let active = focus_dispatch::active_focus_target();
71        let current = active.and_then(|node_id| order.iter().position(|e| e.node_id == node_id));
72
73        match direction {
74            FocusDirection::Next | FocusDirection::Enter => Some(step(order, current, 1)),
75            FocusDirection::Previous => Some(step(order, current, -1)),
76            FocusDirection::Exit => None,
77            FocusDirection::Up
78            | FocusDirection::Down
79            | FocusDirection::Left
80            | FocusDirection::Right => current.and_then(|index| nearest(order, index, direction)),
81        }
82    })
83}
84
85fn step(order: &[FocusEntry], current: Option<usize>, delta: isize) -> NodeId {
86    let count = order.len() as isize;
87    let index = match current {
88        Some(index) => (index as isize + delta).rem_euclid(count),
89        None if delta > 0 => 0,
90        None => count - 1,
91    };
92    order[index as usize].node_id
93}
94
95fn nearest(order: &[FocusEntry], from: usize, direction: FocusDirection) -> Option<NodeId> {
96    let (from_x, from_y) = order[from].center();
97    let mut best: Option<(f32, NodeId)> = None;
98
99    for (index, entry) in order.iter().enumerate() {
100        if index == from {
101            continue;
102        }
103        let (x, y) = entry.center();
104        let (along, across) = match direction {
105            FocusDirection::Up => (from_y - y, (x - from_x).abs()),
106            FocusDirection::Down => (y - from_y, (x - from_x).abs()),
107            FocusDirection::Left => (from_x - x, (y - from_y).abs()),
108            FocusDirection::Right => (x - from_x, (y - from_y).abs()),
109            _ => continue,
110        };
111        if along <= 0.0 {
112            continue;
113        }
114        let cost = along + across * 2.0;
115        if best.is_none_or(|(best_cost, _)| cost < best_cost) {
116            best = Some((cost, entry.node_id));
117        }
118    }
119
120    best.map(|(_, node_id)| node_id)
121}
122
123#[cfg(test)]
124#[path = "tests/focus_manager_tests.rs"]
125mod tests;