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 std::rc::Rc;
12
13use layout_core::NodeId;
14use platform_core::{Key, ModifiersState, NamedKey, NumericValue};
15use reactive_core::{RwSignal, signal};
16use rustc_hash::FxHashSet;
17
18/// An opaque focus identity, one per focusable widget. Allocate with [`next_id`].
19pub type FocusId = u64;
20
21/// What kind of widget a focusable is, as far as the keyboard is concerned.
22///
23/// The distinction exists for one question: whether the keys arriving now are *text*. Key events are
24/// broadcast, so an app-level shortcut handler and a focused field see the same press, and without this the
25/// `3` typed into a dimension field also fires the app's `3` shortcut.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum FocusKind {
28    /// Takes keys as commands: a button, a tab, a slider.
29    Widget,
30    /// Takes keys as text: a field, an editor.
31    TextEntry,
32}
33
34/// What a focusable *is*, for the reader that has to say it out loud — a separate question from [`FocusKind`],
35/// which asks what the widget does with a key.
36///
37/// Defined in `platform-core` because it is the vocabulary the UI and the platform share, the same way
38/// [`Key`] is. Re-exported here because this is where it is *authored*: a widget declares its role at the
39/// moment it declares itself focusable, and the two are one call.
40pub use platform_core::Role;
41
42/// A cheap, `Copy` handle to a focusable widget's identity, so a caller that has moved the widget into a
43/// container (and no longer holds a reference to it) can still drive its focus — e.g. autofocus a hosted
44/// editor when its tab activates. Obtain one from the widget (see [`crate::TextArea::focus_handle`]).
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct FocusHandle(FocusId);
47
48impl FocusHandle {
49    /// Gives focus to the handle's widget.
50    pub fn request(self) {
51        request(self.0);
52    }
53
54    /// Removes focus from the handle's widget, only if it currently holds it.
55    pub fn release(self) {
56        release(self.0);
57    }
58
59    /// Whether the handle's widget currently holds focus.
60    pub fn is_focused(self) -> bool {
61        is_focused(self.0)
62    }
63}
64
65/// Wraps a raw [`FocusId`] in a [`FocusHandle`]. A focusable widget hands out a handle to its own id.
66pub fn handle(id: FocusId) -> FocusHandle {
67    FocusHandle(id)
68}
69
70/// Identifies one registered [`Scope`], so a closing overlay can withdraw exactly its own.
71#[derive(Clone, Copy, PartialEq, Eq, Debug)]
72pub struct ScopeId(u64);
73
74/// A region of the tree whose focusables are only reachable while it is showing.
75///
76/// Declared by whatever can hide content without taking it out of the tree — an [`Overlay`](crate::Overlay)
77/// kept mounted across a close, today. It names a *node*, not a set of ids, and that is the whole trick: an
78/// overlay's children are built before the overlay that will host them, so it never learns which focusables
79/// are its own. Ancestry answers instead.
80struct Scope {
81    id: ScopeId,
82    node: NodeId,
83    showing: Rc<dyn Fn() -> bool>,
84    /// Whether the scope holds focus in while it shows — a modal, as against a tooltip layer.
85    traps: bool,
86    reason: ScopeReason,
87}
88
89/// Why a scope's focusables are out of reach, which the keyboard does not care about and a screen reader does.
90///
91/// Tab treats the two the same — neither is a stop — but they are opposite things to say out loud. A control
92/// inside a closed dialog is *not there*; a disabled one is there and unavailable, and a reader that omitted
93/// it would leave the user wondering where the button went.
94#[derive(Clone, Copy, PartialEq, Eq, Debug)]
95pub enum ScopeReason {
96    NotShowing,
97    Disabled,
98}
99
100/// One entry in the tab order.
101struct Entry {
102    id: FocusId,
103    /// The widget's layout node, which is what makes reachability answerable. `None` for a focus id that
104    /// belongs to no widget (the dismiss stack takes one as a token).
105    node: Option<NodeId>,
106    role: Role,
107    /// Whether Tab stops here. `false` for a control that is driven some other way and still has to be
108    /// announced: the rows of a menu answer to arrow keys, and putting each one in the tab order would make
109    /// Tab walk a list the user opened precisely so as not to.
110    tabbable: bool,
111    /// A checked state, for the controls that have one. A closure and not a flag, for the same reason
112    /// "reachable" is one: a checkbox toggles without being rebuilt, and a reader asking a moment later has to
113    /// get the answer that is true then.
114    toggled: Option<Rc<dyn Fn() -> bool>>,
115    value: Option<Rc<dyn Fn() -> NumericValue>>,
116}
117
118/// Per-surface keyboard-focus state: the id allocator, the focused-widget signal, and the tab order.
119struct FocusState {
120    next_id: FocusId,
121    next_scope: u64,
122    focused: RwSignal<Option<FocusId>>,
123    // Whether the focus held now was taken by a pointer. A signal, not a plain flag: Tab onto the widget you just clicked moves it without moving `focused`, and a ring that missed that would be stale exactly when the keyboard took over.
124    pointer_focus: RwSignal<bool>,
125    // Registered focusables in tab order (registration order ≈ document order). Drives Tab/Shift-Tab.
126    order: Vec<Entry>,
127    // Regions that can hide their contents without unregistering them; consulted when stepping.
128    scopes: Vec<Scope>,
129    // The subset of `order` that takes keys as text. A set rather than a field on each entry because it is
130    // the minority and the only kind anyone asks about.
131    text_entries: FxHashSet<FocusId>,
132}
133
134impl FocusState {
135    fn new() -> Self {
136        Self {
137            next_id: 1,
138            next_scope: 1,
139            focused: signal(None),
140            pointer_focus: signal(false),
141            order: Vec::new(),
142            scopes: Vec::new(),
143            text_entries: FxHashSet::default(),
144        }
145    }
146}
147
148reactive_core::surface_local! {
149    /// Per-surface focus state. The runner activates each surface's [`FocusContext`] around its
150    /// build/event/frame, so focus never crosses windows.
151    slot FOCUS: FocusState = FocusState::new();
152    access with_focus, with_focus_ref;
153    context FocusContext, FocusGuard;
154}
155
156/// The active surface's focused-widget signal, cloned out of the slot so callers never hold the slot borrow
157/// across a `.set()` — its flush re-enters the slot when an effect reads [`current`].
158fn focused_signal() -> RwSignal<Option<FocusId>> {
159    with_focus_ref(|s| s.focused.clone())
160}
161
162/// Allocates a fresh focus id for a focusable widget.
163pub fn next_id() -> FocusId {
164    with_focus(|s| {
165        let id = s.next_id;
166        s.next_id += 1;
167        id
168    })
169}
170
171/// The currently focused widget, or `None`. Reactive: reading this inside a `view()` re-renders the
172/// caller when focus changes.
173pub fn current() -> Option<FocusId> {
174    focused_signal().get()
175}
176
177/// Whether `id` currently holds focus.
178pub fn is_focused(id: FocusId) -> bool {
179    current() == Some(id)
180}
181
182// The three commands below `peek` the signal they write, and it matters: a command is a thing an *effect* may
183// well issue ("while this row is the selected one, focus its field"), and a reactive read there would
184// subscribe that effect to the focus it sets — so the next focus change anywhere would re-run it and it would
185// take the focus straight back. Same rule, and the same bug, as `ScrollViewport::reveal`.
186
187/// Gives focus to `id` (a no-op if it already holds it).
188pub fn request(id: FocusId) {
189    set_pointer_focus(false);
190    let focused = focused_signal();
191    if focused.peek() != Some(id) {
192        focused.set(Some(id));
193    }
194}
195
196/// [`request`] for focus a *tap* is giving, which is the one case that should not draw a focus ring.
197///
198/// The distinction CSS spent years arriving at as `:focus-visible`. A ring on every click is noise — the user
199/// already knows where they clicked — and the ring drawn anyway is why so many stylesheets used to turn
200/// outlines off altogether, taking the keyboard's only cue with them. Focus taken any other way (Tab, or an
201/// application focusing something itself) shows it.
202pub fn request_from_pointer(id: FocusId) {
203    set_pointer_focus(true);
204    let focused = focused_signal();
205    if focused.peek() != Some(id) {
206        focused.set(Some(id));
207    }
208}
209
210/// Whether `id` holds focus *and* should show it. Reactive, like [`current`].
211pub fn is_focus_visible(id: FocusId) -> bool {
212    is_focused(id) && !pointer_focus_signal().get()
213}
214
215fn pointer_focus_signal() -> RwSignal<bool> {
216    with_focus_ref(|s| s.pointer_focus.clone())
217}
218
219fn set_pointer_focus(from_pointer: bool) {
220    let flag = pointer_focus_signal();
221    if flag.peek() != from_pointer {
222        flag.set(from_pointer);
223    }
224}
225
226/// Removes focus from `id`, but only if it currently holds it — so a widget blurring itself never steals
227/// focus away from another.
228pub fn release(id: FocusId) {
229    let focused = focused_signal();
230    if focused.peek() == Some(id) {
231        focused.set(None);
232    }
233}
234
235/// Clears focus entirely, whoever holds it.
236pub fn clear() {
237    let focused = focused_signal();
238    if focused.peek().is_some() {
239        focused.set(None);
240    }
241}
242
243/// Adds `id` to the tab order (at the end), if not already present, as a widget that says what it does with
244/// the keyboard — a text field registers as [`FocusKind::TextEntry`], which is what makes
245/// [`text_entry_focused`] answerable. A focusable widget calls this on creation; registration order is the
246/// traversal order.
247pub fn register_as(id: FocusId, kind: FocusKind) {
248    register_node(id, kind, None, default_role(kind), true);
249}
250
251/// [`register_as`] for a widget that can say which layout node it is, which is what lets Tab skip it while it
252/// is not on screen. Every focusable widget should use this; the node-less forms remain for a focus id that
253/// stands for something other than a widget.
254pub fn register_at(id: FocusId, kind: FocusKind, node: NodeId) {
255    register_node(id, kind, Some(node), default_role(kind), true);
256}
257
258/// [`register_at`] for a widget that is not simply "a thing you activate" — a checkbox, a tab, a slider. The
259/// role is what a screen reader says this is; see [`Role`].
260pub fn register_with_role(id: FocusId, kind: FocusKind, node: NodeId, role: Role) {
261    register_node(id, kind, Some(node), role, true);
262}
263
264/// Registers a control that is announced but is not a Tab stop, because something else drives it.
265///
266/// The rows of a menu are the case: they answer to arrow keys and type-ahead, and a reader that could not see
267/// them would be handed an open menu it could not describe — while a Tab order containing every row would
268/// walk the user through a list they opened in order to *avoid* walking it.
269pub fn register_presented(id: FocusId, node: NodeId, role: Role) {
270    register_node(id, FocusKind::Widget, Some(node), role, false);
271}
272
273/// What a widget is taken to be when it has not said: the reading that matches what the keyboard does with it.
274fn default_role(kind: FocusKind) -> Role {
275    match kind {
276        FocusKind::Widget => Role::Button,
277        FocusKind::TextEntry => Role::TextInput,
278    }
279}
280
281fn register_node(id: FocusId, kind: FocusKind, node: Option<NodeId>, role: Role, tabbable: bool) {
282    with_focus(|s| {
283        match s.order.iter_mut().find(|e| e.id == id) {
284            // Re-registering only ever adds knowledge: a widget that learns its node later keeps its place.
285            Some(existing) => {
286                existing.node = existing.node.or(node);
287                if role != Role::default() {
288                    existing.role = role;
289                }
290                existing.tabbable &= tabbable;
291            }
292            None => s.order.push(Entry {
293                id,
294                node,
295                role,
296                tabbable,
297                toggled: None,
298                value: None,
299            }),
300        }
301        if kind == FocusKind::TextEntry {
302            s.text_entries.insert(id);
303        }
304    });
305}
306
307/// Declares a region whose focusables are only reachable while `showing` reads true, and — when `traps` — that
308/// holds focus inside itself while it is up.
309///
310/// The counterpart of the pointer barrier an overlay already puts up. Without it the tab order is a list built
311/// when widgets were *constructed*, which says nothing about what is on screen now: a dialog kept mounted
312/// across a close leaves its fields as Tab stops, and one that is open does not stop Tab walking out behind it.
313pub fn register_scope(node: NodeId, showing: impl Fn() -> bool + 'static, traps: bool) -> ScopeId {
314    register_scope_because(node, showing, traps, ScopeReason::NotShowing)
315}
316
317/// [`register_scope`] for a region that says *why* its focusables are out of reach. See [`ScopeReason`].
318pub fn register_scope_because(
319    node: NodeId,
320    showing: impl Fn() -> bool + 'static,
321    traps: bool,
322    reason: ScopeReason,
323) -> ScopeId {
324    with_focus(|s| {
325        let id = ScopeId(s.next_scope);
326        s.next_scope += 1;
327        s.scopes.push(Scope {
328            id,
329            node,
330            showing: Rc::new(showing),
331            traps,
332            reason,
333        });
334        id
335    })
336}
337
338/// Withdraws a scope registered with [`register_scope`].
339pub fn unregister_scope(id: ScopeId) {
340    with_focus(|s| s.scopes.retain(|scope| scope.id != id));
341}
342
343/// Removes `id` from the tab order and drops its focus if it held it. A focusable widget calls this on
344/// drop, so a destroyed widget never lingers in traversal or as the focused id.
345pub fn unregister(id: FocusId) {
346    with_focus(|s| {
347        s.order.retain(|e| e.id != id);
348        s.text_entries.remove(&id);
349    });
350    release(id);
351}
352
353/// Whether the focused widget takes keys as text. Reactive, like [`current`].
354///
355/// The guard an app-level shortcut table needs: without it, typing into a field also runs the shortcuts
356/// that share its letters. Prefer [`text_entry_takes_key`], which lets through the presses no editor wants.
357pub fn text_entry_focused() -> bool {
358    match current() {
359        Some(id) => with_focus_ref(|s| s.text_entries.contains(&id)),
360        None => false,
361    }
362}
363
364/// Whether a focused text entry would take this press as text — the guard for a global shortcut handler.
365///
366/// Narrower than [`text_entry_focused`] on purpose: a field claims the letters and the caret keys, and
367/// nothing else. `⌘S` still saves while the caret sits in a field, and so do the function keys, because no
368/// editor here does anything with them. The list mirrors what [`crate::Input`] and [`crate::TextArea`]
369/// actually consume, and their own tests hold it to that.
370pub fn text_entry_takes_key(key: &Key, modifiers: ModifiersState) -> bool {
371    text_entry_focused() && edits_text(key, modifiers)
372}
373
374fn edits_text(key: &Key, modifiers: ModifiersState) -> bool {
375    match key {
376        // A chord is a command, not text: the editors ignore it too.
377        Key::Char(_) if modifiers.is_ctrl || modifiers.is_meta => false,
378        Key::Char(c) => !c.is_control(),
379        Key::Named(named) => matches!(
380            named,
381            NamedKey::Space
382                | NamedKey::Backspace
383                | NamedKey::Delete
384                | NamedKey::ArrowLeft
385                | NamedKey::ArrowRight
386                | NamedKey::ArrowUp
387                | NamedKey::ArrowDown
388                | NamedKey::Home
389                | NamedKey::End
390                | NamedKey::Enter
391                | NamedKey::Escape
392                | NamedKey::Tab
393        ),
394    }
395}
396
397/// Moves focus to the next registered focusable in tab order (wrapping); with nothing focused, focuses
398/// the first. A no-op when nothing is registered.
399pub fn focus_next() {
400    step(1);
401}
402
403/// Like [`focus_next`] but backwards (Shift+Tab).
404pub fn focus_prev() {
405    step(-1);
406}
407
408/// A [`Scope`] as [`step`] reads it, once copied out from under the slot borrow: where it is, whether it is
409/// showing, and whether it holds focus inside itself.
410type ScopeView = (NodeId, Rc<dyn Fn() -> bool>, bool);
411
412/// Whether Tab should be able to land on a focusable at `node`, given the scopes registered right now.
413///
414/// Three ways to be out of reach, and they are genuinely different mechanisms rather than one seen from three
415/// angles — which is why a rule aimed at any single one of them leaves the others open:
416/// - out of layout flow, by its own `display:none` or an ancestor's, which leaves the rect it last had;
417/// - inside a region kept mounted while not showing, which leaves the rect *and* the layout intact;
418/// - outside the modal that is currently up, which is about nothing on the node itself.
419fn reachable(node: Option<NodeId>, scopes: &[ScopeView]) -> bool {
420    // A focus id that stands for no widget has no way to be off screen.
421    let Some(node) = node else { return true };
422    if layout_reactive::is_hidden(node) {
423        return false;
424    }
425    if scopes
426        .iter()
427        .any(|(scope, showing, _)| !showing() && layout_reactive::is_descendant_of(node, *scope))
428    {
429        return false;
430    }
431    // The topmost trapping scope that is up holds focus inside itself.
432    match scopes
433        .iter()
434        .rev()
435        .find(|(_, showing, traps)| *traps && showing())
436    {
437        Some((scope, _, _)) => layout_reactive::is_descendant_of(node, *scope),
438        None => true,
439    }
440}
441
442/// The tab order and the scopes, copied out from under the slot borrow — see [`step`] for why that matters.
443fn snapshot() -> (Vec<(FocusId, Option<NodeId>)>, Vec<ScopeView>) {
444    with_focus_ref(|s| {
445        let order: Vec<(FocusId, Option<NodeId>)> = s
446            .order
447            .iter()
448            .filter(|e| e.tabbable)
449            .map(|e| (e.id, e.node))
450            .collect();
451        let scopes: Vec<ScopeView> = s
452            .scopes
453            .iter()
454            .map(|sc| (sc.node, sc.showing.clone(), sc.traps))
455            .collect();
456        (order, scopes)
457    })
458}
459
460/// Declares that `id` carries a checked state, and how to read it now.
461///
462/// Separate from registering the control because the two are known at different moments: a box declares what
463/// it *is* as it is built, and what it is *bound to* when the caller hands it a signal.
464pub fn set_toggled(id: FocusId, state: impl Fn() -> bool + 'static) {
465    let state: Rc<dyn Fn() -> bool> = Rc::new(state);
466    with_focus(|s| {
467        if let Some(entry) = s.order.iter_mut().find(|e| e.id == id) {
468            entry.toggled = Some(state);
469        }
470    });
471}
472
473/// Declares that `id` carries a number, and how to read it now. The counterpart of [`set_toggled`] for the
474/// roles whose state is a value rather than a flag.
475pub fn set_value(id: FocusId, read: impl Fn() -> NumericValue + 'static) {
476    let read: Rc<dyn Fn() -> NumericValue> = Rc::new(read);
477    with_focus(|s| {
478        if let Some(entry) = s.order.iter_mut().find(|e| e.id == id) {
479            entry.value = Some(read);
480        }
481    });
482}
483
484/// One focusable as the accessibility layer sees it: where it is, what it is, and whether it is available.
485pub struct Exposed {
486    pub id: FocusId,
487    pub node: NodeId,
488    pub role: Role,
489    /// Available to be activated. `false` is *announced*, not hidden — see [`ScopeReason`].
490    pub enabled: bool,
491    /// Its checked state, for the controls that carry one.
492    pub toggled: Option<bool>,
493    /// Its numeric reading, for the controls that carry one.
494    pub value: Option<NumericValue>,
495}
496
497/// The focusables a screen reader should be told about, in tab order.
498///
499/// Deliberately the same [`reachable`] the keyboard walks, so the two can never disagree about what is on
500/// screen — with one distinction Tab has no use for: a control kept out of reach by being *disabled* is
501/// reported as present and unavailable, where one inside a closed dialog is not reported at all.
502pub fn exposed() -> Vec<Exposed> {
503    let (order, scopes) = with_focus_ref(|s| {
504        // The state closures come out with everything else and are called after the borrow drops: reading one
505        // can read a signal, and reading a signal can flush effects back through this very slot.
506        type Row = (
507            FocusId,
508            Option<NodeId>,
509            Role,
510            Option<Rc<dyn Fn() -> bool>>,
511            Option<Rc<dyn Fn() -> NumericValue>>,
512        );
513        let order: Vec<Row> = s
514            .order
515            .iter()
516            .map(|e| (e.id, e.node, e.role, e.toggled.clone(), e.value.clone()))
517            .collect();
518        let scopes: Vec<(NodeId, Rc<dyn Fn() -> bool>, bool, ScopeReason)> = s
519            .scopes
520            .iter()
521            .map(|sc| (sc.node, sc.showing.clone(), sc.traps, sc.reason))
522            .collect();
523        (order, scopes)
524    });
525    let hiding: Vec<ScopeView> = scopes
526        .iter()
527        .filter(|(_, _, _, reason)| *reason == ScopeReason::NotShowing)
528        .map(|(node, showing, traps, _)| (*node, showing.clone(), *traps))
529        .collect();
530
531    order
532        .into_iter()
533        .filter_map(|(id, node, role, toggled, value)| {
534            let node = node?;
535            reachable(Some(node), &hiding).then(|| Exposed {
536                id,
537                node,
538                role,
539                enabled: !scopes.iter().any(|(scope, showing, _, reason)| {
540                    *reason == ScopeReason::Disabled
541                        && !showing()
542                        && layout_reactive::is_descendant_of(node, *scope)
543                }),
544                toggled: toggled.as_ref().map(|read| read()),
545                value: value.as_ref().map(|read| read()),
546            })
547        })
548        .collect()
549}
550
551/// Moves focus to the first reachable focusable inside `node`, reporting whether it found one.
552///
553/// What a dialog needs on open: the keyboard has to arrive somewhere inside it, or the user is left tabbing
554/// from wherever they were — which, now that a modal traps focus, means tabbing nowhere at all.
555pub fn focus_first_in(node: NodeId) -> bool {
556    let (order, scopes) = snapshot();
557    let found = order.into_iter().find(|(_, widget)| {
558        widget.is_some_and(|widget| layout_reactive::is_descendant_of(widget, node))
559            && reachable(*widget, &scopes)
560    });
561    match found {
562        Some((id, _)) => {
563            request(id);
564            true
565        }
566        None => false,
567    }
568}
569
570/// Whether `id` is still registered, so a caller restoring remembered focus does not aim at a widget that has
571/// since been dropped.
572pub fn is_registered(id: FocusId) -> bool {
573    with_focus_ref(|s| s.order.iter().any(|e| e.id == id))
574}
575
576fn step(dir: isize) {
577    // Snapshot, then release the slot borrow: `showing` is the author's closure and the reachability queries borrow the layout runtime, and neither may run under this one — nor may `request`, which flushes.
578    let (order, scopes) = snapshot();
579    let order: Vec<FocusId> = order
580        .into_iter()
581        .filter(|(_, node)| reachable(*node, &scopes))
582        .map(|(id, _)| id)
583        .collect();
584    if order.is_empty() {
585        return;
586    }
587    let n = order.len() as isize;
588    let next = match current().and_then(|c| order.iter().position(|&x| x == c)) {
589        Some(i) => order[((i as isize + dir).rem_euclid(n)) as usize],
590        None => {
591            if dir > 0 {
592                order[0]
593            } else {
594                order[order.len() - 1]
595            }
596        }
597    };
598    request(next);
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn request_release_and_ids_are_unique() {
607        clear();
608        let a = next_id();
609        let b = next_id();
610        assert_ne!(a, b, "ids must be unique");
611
612        assert!(!is_focused(a));
613        request(a);
614        assert!(is_focused(a) && current() == Some(a));
615
616        // Requesting b moves focus off a.
617        request(b);
618        assert!(is_focused(b) && !is_focused(a));
619
620        // Releasing a (which is not focused) leaves b focused.
621        release(a);
622        assert!(is_focused(b));
623
624        // Releasing the focused one clears it.
625        release(b);
626        assert!(current().is_none());
627    }
628
629    /// A control the application has disabled is not a Tab stop — in HTML a `disabled` element is skipped
630    /// outright, and a keyboard user made to walk through controls that do nothing is being told less about
631    /// the interface than a mouse user, who at least sees them dimmed.
632    ///
633    /// Rides the same scope mechanism a hidden overlay uses rather than a second one, which is what gives a
634    /// disabled *wrapper* the `fieldset` reading for the keyboard as well as for the pointer.
635    #[test]
636    fn tab_skips_a_disabled_box() {
637        use crate::context::{compute_layout, reset_layout_runtime};
638        use crate::{LayoutItem, StyledContainer};
639        use layout_core::{AvailableSpace, LayoutStyle};
640
641        reset_layout_runtime();
642        let base = next_id();
643        register_as(base, FocusKind::Widget);
644
645        let below = next_id();
646        let off = StyledContainer::new(
647            LayoutStyle::new().width(50.0).height(20.0),
648            |_r| renderer_core::RectStyle::default(),
649            vec![],
650        )
651        .unwrap()
652        .on_focus(|_| {})
653        .disabled(|| true);
654        let above = next_id();
655        compute_layout(
656            off.layout_node(),
657            AvailableSpace::Definite(50.0),
658            AvailableSpace::Definite(20.0),
659        )
660        .unwrap();
661
662        request(base);
663        focus_next();
664        let landed = current().expect("something took focus");
665        assert!(
666            !(landed > below && landed < above),
667            "Tab landed on a box the application had disabled"
668        );
669    }
670
671    /// The case that showed an overlay-shaped fix would only ever be half of one: `display:none` hides content
672    /// without any overlay involved, and left it in the tab order just the same. The two mechanisms leave
673    /// opposite traces — a hidden overlay keeps its children's rects and stops painting, a `display:none`
674    /// subtree collapses to zero and keeps its place in the walk — so neither a paint test nor a rect test
675    /// catches both. Ancestry does.
676    #[test]
677    fn tab_skips_a_focusable_taken_out_of_layout_flow() {
678        use crate::context::{compute_layout, reset_layout_runtime, set_display};
679        use crate::{LayoutItem, StyledContainer};
680        use layout_core::{AvailableSpace, LayoutStyle};
681
682        reset_layout_runtime();
683        let base = next_id();
684        register_as(base, FocusKind::Widget);
685
686        let below = next_id();
687        let hidden = StyledContainer::new(
688            LayoutStyle::new().width(50.0).height(20.0),
689            |_r| renderer_core::RectStyle::default(),
690            vec![],
691        )
692        .unwrap()
693        .on_focus(|_| {});
694        let node = hidden.layout_node();
695        let root = StyledContainer::new(
696            LayoutStyle::new().width(100.0).height(100.0),
697            |_r| renderer_core::RectStyle::default(),
698            vec![Box::new(hidden)],
699        )
700        .unwrap();
701        let above = next_id();
702
703        set_display(node, false);
704        compute_layout(
705            root.layout_node(),
706            AvailableSpace::Definite(100.0),
707            AvailableSpace::Definite(100.0),
708        )
709        .unwrap();
710
711        request(base);
712        focus_next();
713        let landed = current().expect("something took focus");
714        assert!(
715            !(landed > below && landed < above),
716            "Tab landed on a focusable that is out of layout flow"
717        );
718    }
719
720    #[test]
721    fn tab_order_steps_forward_and_back() {
722        // Register three contiguous ids at the end of the order and step within that block (robust to any
723        // ids other tests registered earlier on this thread).
724        let (a, b, c) = (next_id(), next_id(), next_id());
725        register_as(a, FocusKind::Widget);
726        register_as(b, FocusKind::Widget);
727        register_as(c, FocusKind::Widget);
728
729        request(a);
730        focus_next();
731        assert_eq!(current(), Some(b));
732        focus_next();
733        assert_eq!(current(), Some(c));
734        focus_prev();
735        assert_eq!(current(), Some(b));
736
737        // Unregistering the focused one drops focus and removes it from traversal.
738        unregister(b);
739        assert!(current().is_none());
740        unregister(a);
741        unregister(c);
742    }
743}