Skip to main content

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 tree cannot follow a widget across a rebuild, which
37    /// allocates fresh ids, so it focuses the node at the same position: the
38    /// same index among the focusable descendants of the innermost ancestor
39    /// the rebuild left alive (the last one, when there are fewer now). Nobody
40    /// moved focus, so a widget treats this as the same interaction going on,
41    /// not as an arrival: a text field that would select all on a Tab puts
42    /// its caret at the end instead.
43    ///
44    /// Declares no modality, like [`Programmatic`](Self::Programmatic): the
45    /// ring stays wherever the predecessor had it.
46    Restored,
47}
48
49impl FocusOrigin {
50    /// Focus arrived by pointer, from a site that cannot know which device
51    /// delivered it.
52    ///
53    /// A control deriving its own origin from hover or from the tree's
54    /// input-modality signal — rather than from the
55    /// [`FocusGained`](crate::event::WidgetEvent::FocusGained) it was handed —
56    /// knows only that the keyboard was not involved. It says so with
57    /// [`PointerKind::Unknown`] instead of naming a device it never saw, so a
58    /// consumer reading [`pointer_kind`](Self::pointer_kind) is never told a
59    /// finger was a mouse.
60    pub const POINTER: Self = Self::Pointer(PointerKind::Unknown);
61
62    /// Whether focus arrived by pointing at the widget.
63    ///
64    /// The predicate that replaced `== FocusOrigin::Pointer`: a pointer origin
65    /// now names its device, so equality against a bare variant no longer
66    /// compiles and equality against one device would silently exclude the
67    /// others.
68    pub const fn is_pointer(self) -> bool {
69        matches!(self, Self::Pointer(_))
70    }
71
72    /// The device that delivered a pointer focus, or `None` for every other
73    /// origin. [`PointerKind::Unknown`] for a widget-side derivation — see
74    /// [`POINTER`](Self::POINTER).
75    pub const fn pointer_kind(self) -> Option<PointerKind> {
76        match self {
77            Self::Pointer(kind) => Some(kind),
78            _ => None,
79        }
80    }
81
82    /// Whether this origin reveals the focus ring — the `:focus-visible`
83    /// question, answered in one place so the tree's modality signal and any
84    /// widget consulting the origin cannot disagree.
85    ///
86    /// Keyboard and assistive navigation reveal it; a pointer hides it.
87    /// [`Programmatic`](Self::Programmatic) answers neither: a scripted focus
88    /// declares no modality, so the tree leaves the signal where the last real
89    /// interaction put it.
90    pub const fn focus_visible(self) -> Option<bool> {
91        match self {
92            Self::Keyboard | Self::Accessibility => Some(true),
93            Self::Pointer(_) => Some(false),
94            Self::Programmatic | Self::Restored => None,
95        }
96    }
97}
98
99/// Policy for a focus **traversal scope**, declared via the `FocusScope`
100/// wrapper widget. Controls what Tab / Shift+Tab does when it reaches the
101/// scope's ends.
102///
103/// A scope groups + scopes the `tab_index` numbering of its descendants:
104/// two sibling scopes that both number their children `1, 2, 3` never
105/// interleave — each scope is an independent, ordered unit within its
106/// parent. This is Teksilo's analogue of Flutter `FocusTraversalGroup` /
107/// WPF `KeyboardNavigation.TabNavigation`.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum TraversalScopePolicy {
110    /// Tab flows *out* of the scope at its ends into the enclosing scope's
111    /// next member. The scope groups `tab_index` numbering without trapping
112    /// focus — use for logical regions in a continuous Tab order (e.g. dock
113    /// panels, where each panel numbers its own controls without colliding
114    /// with sibling panels).
115    Continue,
116    /// Tab *wraps* within the scope and never exits via keyboard navigation.
117    /// Use for modal dialogs, whose pattern (ARIA's Dialog (Modal)) calls for
118    /// containing focus.
119    ///
120    /// **Not for popovers or menus.** Those implement Disclosure and Menu,
121    /// which mandate the opposite: Tab is an exit gesture there, and the
122    /// framework already answers it by dismissing the overlay focus leaves
123    /// rather than by trapping focus inside it. Wrapping such an overlay in a
124    /// `Cycle` scope defeats that — focus can no longer leave, so the
125    /// dismissal never fires and the panel becomes keyboard-inescapable except
126    /// via Escape.
127    ///
128    /// The other fit is an **embedded edit scope** with an exit key it
129    /// advertises: a list row the user stepped into (`ListView` in action
130    /// mode), whose controls Tab cycles through until Escape returns to the
131    /// list. That is not an overlay that leaving focus must dismiss, and WCAG
132    /// 2.1.2 allows a keyboard trap whose way out the user is told.
133    Cycle,
134}