teksilo_core/focus.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use teksilo_tokens::PointerKind;
5
6/// How focus was acquired.
7///
8/// Read by `:focus-visible` — a focus ring belongs to keyboard and assistive
9/// navigation, not to a click — and by anything that has to treat a finger
10/// differently from a mouse. The pointer arm carries the device that delivered
11/// the focus, because "a pointer focused this" is not one behaviour: a mouse
12/// focuses on press, a finger and a pen focus on release, and only a release
13/// that lands back on the same focusable counts.
14///
15/// `#[non_exhaustive]`: matches need a wildcard arm. Most call sites want
16/// [`is_pointer`](Self::is_pointer) rather than a match at all.
17#[non_exhaustive]
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum FocusOrigin {
20 /// Focus gained via Tab/Shift-Tab keyboard navigation, or by any other
21 /// keystroke a widget routes into focus.
22 Keyboard,
23 /// Focus gained by pointing at the widget, with the device that did it.
24 Pointer(PointerKind),
25 /// Focus set programmatically by the application. Carries no input
26 /// modality of its own: a scripted focus leaves the focus ring exactly as
27 /// the user's last real interaction left it, which is what
28 /// `:focus-visible` does for `element.focus()`.
29 Programmatic,
30 /// Focus moved by assistive technology — a screen reader's
31 /// [`Action::Focus`](accesskit::Action::Focus), or an automation client
32 /// standing in for one. Reveals the focus ring: the user is navigating,
33 /// they are simply not doing it with a key.
34 Accessibility,
35 /// Focus put back by the tree after a rebuild destroyed the widget that
36 /// held it: the rebuilt subtree's successor takes the focus its
37 /// predecessor had. Nobody moved focus, so a widget must treat this as
38 /// *continuity*, not arrival.
39 ///
40 /// The case it exists for is a text field whose own value rebuilds an
41 /// ancestor, as a filtering search box does: every keystroke recreates
42 /// the field, and a field that selected its whole text on what it took
43 /// for a fresh keyboard focus made the next keystroke replace everything
44 /// typed so far, leaving only the last character visible. Declares no
45 /// modality, like [`Programmatic`](Self::Programmatic): the ring stays
46 /// wherever the rebuilt widget's predecessor had it.
47 Restored,
48}
49
50impl FocusOrigin {
51 /// Focus arrived by pointer, from a site that cannot know which device
52 /// delivered it.
53 ///
54 /// A control deriving its own origin from hover or from the tree's
55 /// input-modality signal — rather than from the
56 /// [`FocusGained`](crate::event::WidgetEvent::FocusGained) it was handed —
57 /// knows only that the keyboard was not involved. It says so with
58 /// [`PointerKind::Unknown`] instead of naming a device it never saw, so a
59 /// consumer reading [`pointer_kind`](Self::pointer_kind) is never told a
60 /// finger was a mouse.
61 pub const POINTER: Self = Self::Pointer(PointerKind::Unknown);
62
63 /// Whether focus arrived by pointing at the widget.
64 ///
65 /// The predicate that replaced `== FocusOrigin::Pointer`: a pointer origin
66 /// now names its device, so equality against a bare variant no longer
67 /// compiles and equality against one device would silently exclude the
68 /// others.
69 pub const fn is_pointer(self) -> bool {
70 matches!(self, Self::Pointer(_))
71 }
72
73 /// The device that delivered a pointer focus, or `None` for every other
74 /// origin. [`PointerKind::Unknown`] for a widget-side derivation — see
75 /// [`POINTER`](Self::POINTER).
76 pub const fn pointer_kind(self) -> Option<PointerKind> {
77 match self {
78 Self::Pointer(kind) => Some(kind),
79 _ => None,
80 }
81 }
82
83 /// Whether this origin reveals the focus ring — the `:focus-visible`
84 /// question, answered in one place so the tree's modality signal and any
85 /// widget consulting the origin cannot disagree.
86 ///
87 /// Keyboard and assistive navigation reveal it; a pointer hides it.
88 /// [`Programmatic`](Self::Programmatic) answers neither: a scripted focus
89 /// declares no modality, so the tree leaves the signal where the last real
90 /// interaction put it.
91 pub const fn focus_visible(self) -> Option<bool> {
92 match self {
93 Self::Keyboard | Self::Accessibility => Some(true),
94 Self::Pointer(_) => Some(false),
95 Self::Programmatic | Self::Restored => None,
96 }
97 }
98}
99
100/// Policy for a focus **traversal scope**, declared via the `FocusScope`
101/// wrapper widget. Controls what Tab / Shift+Tab does when it reaches the
102/// scope's ends.
103///
104/// A scope groups + scopes the `tab_index` numbering of its descendants:
105/// two sibling scopes that both number their children `1, 2, 3` never
106/// interleave — each scope is an independent, ordered unit within its
107/// parent. This is Teksilo's analogue of Flutter `FocusTraversalGroup` /
108/// WPF `KeyboardNavigation.TabNavigation`.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum TraversalScopePolicy {
111 /// Tab flows *out* of the scope at its ends into the enclosing scope's
112 /// next member. The scope groups `tab_index` numbering without trapping
113 /// focus — use for logical regions in a continuous Tab order (e.g. dock
114 /// panels, where each panel numbers its own controls without colliding
115 /// with sibling panels).
116 Continue,
117 /// Tab *wraps* within the scope and never exits via keyboard navigation.
118 /// Use for modal dialogs — the one surface whose pattern (ARIA's Dialog
119 /// (Modal)) actually calls for containing focus.
120 ///
121 /// **Not for popovers or menus.** Those implement Disclosure and Menu,
122 /// which mandate the opposite: Tab is an exit gesture there, and the
123 /// framework already answers it by dismissing the overlay focus leaves
124 /// rather than by trapping focus inside it. Wrapping such an overlay in a
125 /// `Cycle` scope defeats that — focus can no longer leave, so the
126 /// dismissal never fires and the panel becomes keyboard-inescapable except
127 /// via Escape.
128 Cycle,
129}