Skip to main content

telar_ui_core/
focus.rs

1//! Keyboard focus: which widget receives key events. A base primitive with no styling of its own — a
2//! focusable widget (e.g. [`crate::Input`]) requests focus on tap and consults it in `on_event`/`view`.
3//!
4//! Key events are broadcast to every widget (see `dispatch_container_event`), so focus is *self-filtering*:
5//! a widget handles a key only when [`is_focused`] holds for its id — there is no central router. Focus is
6//! a reactive signal, so a widget that reads [`current`]/[`is_focused`] inside its `view()` re-renders when
7//! focus moves (e.g. to show or hide its caret). State is per-surface (each surface owns its own focus via
8//! [`FocusContext`], activated by the runner), so focus never crosses windows; preserving focus across a
9//! hot-reload dylib swap is out of scope.
10
11use reactive_core::{RwSignal, signal};
12
13/// An opaque focus identity, one per focusable widget. Allocate with [`next_id`].
14pub type FocusId = u64;
15
16/// A cheap, `Copy` handle to a focusable widget's identity, so a caller that has moved the widget into a
17/// container (and no longer holds a reference to it) can still drive its focus — e.g. autofocus a hosted
18/// editor when its tab activates. Obtain one from the widget (see [`crate::TextArea::focus_handle`]).
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct FocusHandle(FocusId);
21
22impl FocusHandle {
23    /// Gives focus to the handle's widget.
24    pub fn request(self) {
25        request(self.0);
26    }
27
28    /// Removes focus from the handle's widget, only if it currently holds it.
29    pub fn release(self) {
30        release(self.0);
31    }
32
33    /// Whether the handle's widget currently holds focus.
34    pub fn is_focused(self) -> bool {
35        is_focused(self.0)
36    }
37}
38
39/// Wraps a raw [`FocusId`] in a [`FocusHandle`]. A focusable widget hands out a handle to its own id.
40pub fn handle(id: FocusId) -> FocusHandle {
41    FocusHandle(id)
42}
43
44/// Per-surface keyboard-focus state: the id allocator, the focused-widget signal, and the tab order.
45struct FocusState {
46    next_id: FocusId,
47    focused: RwSignal<Option<FocusId>>,
48    // Registered focusables in tab order (registration order ≈ document order). Drives Tab/Shift-Tab.
49    order: Vec<FocusId>,
50}
51
52impl FocusState {
53    fn new() -> Self {
54        Self {
55            next_id: 1,
56            focused: signal(None),
57            order: Vec::new(),
58        }
59    }
60}
61
62reactive_core::surface_local! {
63    /// Per-surface focus state. The runner activates each surface's [`FocusContext`] around its
64    /// build/event/frame, so focus never crosses windows.
65    slot FOCUS: FocusState = FocusState::new();
66    access with_focus, with_focus_ref;
67    context FocusContext, FocusGuard;
68}
69
70/// The active surface's focused-widget signal, cloned out of the slot so callers never hold the slot borrow
71/// across a `.set()` — its flush re-enters the slot when an effect reads [`current`].
72fn focused_signal() -> RwSignal<Option<FocusId>> {
73    with_focus_ref(|s| s.focused.clone())
74}
75
76/// Allocates a fresh focus id for a focusable widget.
77pub fn next_id() -> FocusId {
78    with_focus(|s| {
79        let id = s.next_id;
80        s.next_id += 1;
81        id
82    })
83}
84
85/// The currently focused widget, or `None`. Reactive: reading this inside a `view()` re-renders the
86/// caller when focus changes.
87pub fn current() -> Option<FocusId> {
88    focused_signal().get()
89}
90
91/// Whether `id` currently holds focus.
92pub fn is_focused(id: FocusId) -> bool {
93    current() == Some(id)
94}
95
96// The three commands below `peek` the signal they write, and it matters: a command is a thing an *effect* may
97// well issue ("while this row is the selected one, focus its field"), and a reactive read there would
98// subscribe that effect to the focus it sets — so the next focus change anywhere would re-run it and it would
99// take the focus straight back. Same rule, and the same bug, as `ScrollViewport::reveal`.
100
101/// Gives focus to `id` (a no-op if it already holds it).
102pub fn request(id: FocusId) {
103    let focused = focused_signal();
104    if focused.peek() != Some(id) {
105        focused.set(Some(id));
106    }
107}
108
109/// Removes focus from `id`, but only if it currently holds it — so a widget blurring itself never steals
110/// focus away from another.
111pub fn release(id: FocusId) {
112    let focused = focused_signal();
113    if focused.peek() == Some(id) {
114        focused.set(None);
115    }
116}
117
118/// Clears focus entirely, whoever holds it.
119pub fn clear() {
120    let focused = focused_signal();
121    if focused.peek().is_some() {
122        focused.set(None);
123    }
124}
125
126/// Adds `id` to the tab order (at the end), if not already present. A focusable widget calls this on
127/// creation; registration order is the traversal order.
128pub fn register(id: FocusId) {
129    with_focus(|s| {
130        if !s.order.contains(&id) {
131            s.order.push(id);
132        }
133    });
134}
135
136/// Removes `id` from the tab order and drops its focus if it held it. A focusable widget calls this on
137/// drop, so a destroyed widget never lingers in traversal or as the focused id.
138pub fn unregister(id: FocusId) {
139    with_focus(|s| s.order.retain(|&x| x != id));
140    release(id);
141}
142
143/// Moves focus to the next registered focusable in tab order (wrapping); with nothing focused, focuses
144/// the first. A no-op when nothing is registered.
145pub fn focus_next() {
146    step(1);
147}
148
149/// Like [`focus_next`] but backwards (Shift+Tab).
150pub fn focus_prev() {
151    step(-1);
152}
153
154fn step(dir: isize) {
155    // Snapshot the tab order and release the slot borrow before `request` (which flushes) re-enters it.
156    let order = with_focus_ref(|s| s.order.clone());
157    if order.is_empty() {
158        return;
159    }
160    let n = order.len() as isize;
161    let next = match current().and_then(|c| order.iter().position(|&x| x == c)) {
162        Some(i) => order[((i as isize + dir).rem_euclid(n)) as usize],
163        None => {
164            if dir > 0 {
165                order[0]
166            } else {
167                order[order.len() - 1]
168            }
169        }
170    };
171    request(next);
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn request_release_and_ids_are_unique() {
180        clear();
181        let a = next_id();
182        let b = next_id();
183        assert_ne!(a, b, "ids must be unique");
184
185        assert!(!is_focused(a));
186        request(a);
187        assert!(is_focused(a) && current() == Some(a));
188
189        // Requesting b moves focus off a.
190        request(b);
191        assert!(is_focused(b) && !is_focused(a));
192
193        // Releasing a (which is not focused) leaves b focused.
194        release(a);
195        assert!(is_focused(b));
196
197        // Releasing the focused one clears it.
198        release(b);
199        assert!(current().is_none());
200    }
201
202    #[test]
203    fn tab_order_steps_forward_and_back() {
204        // Register three contiguous ids at the end of the order and step within that block (robust to any
205        // ids other tests registered earlier on this thread).
206        let (a, b, c) = (next_id(), next_id(), next_id());
207        register(a);
208        register(b);
209        register(c);
210
211        request(a);
212        focus_next();
213        assert_eq!(current(), Some(b));
214        focus_next();
215        assert_eq!(current(), Some(c));
216        focus_prev();
217        assert_eq!(current(), Some(b));
218
219        // Unregistering the focused one drops focus and removes it from traversal.
220        unregister(b);
221        assert!(current().is_none());
222        unregister(a);
223        unregister(c);
224    }
225}