Skip to main content

teksilo_widgets/
focus_scope.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `FocusScope` — a layout-transparent wrapper that declares a **traversal
5//! boundary** for Tab / Shift+Tab focus cycling.
6//!
7//! Descendants' `tab_index` values are scoped to the nearest enclosing
8//! `FocusScope`: two sibling scopes that both number their children `1, 2, 3`
9//! never interleave — each scope is an independent, ordered unit within its
10//! parent. The [`TraversalScopePolicy`] controls what Tab does at the scope's
11//! ends:
12//!
13//! - [`Continue`](TraversalScopePolicy::Continue) — Tab flows *out* of the
14//!   scope into the enclosing scope's next member (grouping only). Use for
15//!   logical regions in a continuous Tab order, e.g. dock panels.
16//! - [`Cycle`](TraversalScopePolicy::Cycle) — Tab *wraps* within the scope and
17//!   never leaves via keyboard. Use for modal dialogs.
18//!
19//! ```ignore
20//! // A modal dialog whose Tab order is confined to its own content:
21//! FocusScope::new(TraversalScopePolicy::Cycle).child(dialog_body)
22//! ```
23//!
24//! **Do not `Cycle`-wrap a popover, menu or dropdown panel.** Those are
25//! non-modal, and the framework dismisses a non-modal overlay when keyboard
26//! focus leaves it — which is what their ARIA patterns (Disclosure, Menu) ask
27//! for, and what keeps an open panel from sitting over the focus ring that
28//! left it. Trapping focus inside one prevents that dismissal from ever
29//! firing. A centered modal needs no wrapper at all: `cycle_focus` already
30//! roots traversal at the topmost centered overlay's content.
31//!
32//! ## Layout & accessibility
33//!
34//! `FocusScope` imposes no layout — it reports its child's natural size and
35//! places the child at its own bounds (like [`Fade`](crate::Fade)). It is a
36//! structural boundary, not an AT element: the wrapped child owns its own
37//! accessibility semantics. The scope node is never itself a Tab stop
38//! (`BuildContext::set_traversal_scope` forces it non-focusable).
39
40use teksilo_canvas::{Point, Rect, SizeProposal};
41use teksilo_core::accessibility::AccessNodeBuilder;
42use teksilo_core::build_context::BuildContext;
43use teksilo_core::focus::TraversalScopePolicy;
44use teksilo_core::widget::{LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement};
45use teksilo_core::widget_id::WidgetId;
46
47/// Wraps a child subtree and declares it a Tab traversal scope. See the
48/// [module documentation](self) for semantics.
49pub struct FocusScope {
50    policy: TraversalScopePolicy,
51    pending_child: Option<PendingChild>,
52    child_id: Option<WidgetId>,
53}
54
55impl FocusScope {
56    /// Create a traversal scope with the given boundary `policy`.
57    pub fn new(policy: TraversalScopePolicy) -> Self {
58        Self {
59            policy,
60            pending_child: None,
61            child_id: None,
62        }
63    }
64
65    /// Inline child widget (deferred insertion — the form `teksu!` lowers to).
66    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
67        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
68        self
69    }
70    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
71    ///
72    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
73    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
74    /// adds no arena node, so nothing is laid out, painted, or published to the
75    /// accessibility tree, and a stack applies no spacing around it.
76    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
77        match widget {
78            Some(w) => self.child(w),
79            None => self,
80        }
81    }
82}
83
84impl std::fmt::Debug for FocusScope {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("FocusScope")
87            .field("policy", &self.policy)
88            .finish()
89    }
90}
91
92impl Widget for FocusScope {
93    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
94        if let Some(pending) = self.pending_child.take() {
95            self.child_id = Some(match pending {
96                PendingChild::Id(id) => id,
97                PendingChild::Deferred(w) => ctx.add_boxed(w),
98            });
99        }
100        // Mark this node as a traversal-scope boundary. This also forces the
101        // node non-focusable: a scope is a boundary, never itself a Tab stop.
102        ctx.set_traversal_scope(self.policy);
103        self.child_id.into_iter().collect()
104    }
105
106    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
107        // Layout-transparent: report the child's natural size unchanged.
108        self.child_id
109            .and_then(|id| ctx.child_size(id, proposal))
110            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
111            .into()
112    }
113
114    fn place_children(
115        &self,
116        bounds: Rect,
117        _proposal: SizeProposal,
118        children: &mut [WidgetPlacement],
119        _ctx: &LayoutContext,
120    ) {
121        for child in children.iter_mut() {
122            child.origin = Point::new(bounds.x, bounds.y);
123            child.size = bounds.size();
124        }
125    }
126
127    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
128        // Structural boundary only — the wrapped subtree owns its semantics.
129    }
130
131    fn children(&self) -> Vec<WidgetId> {
132        self.child_id.into_iter().collect()
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::primitives::TextWidget;
140    use teksilo_core::widget_tree::WidgetTree;
141    use teksilo_i18n::lit;
142
143    #[test]
144    fn marks_node_with_its_policy() {
145        let mut tree = WidgetTree::new();
146        let scope = tree
147            .add(FocusScope::new(TraversalScopePolicy::Cycle).child(TextWidget::new(lit!("x"))));
148        tree.layout(SizeProposal::exact(100.0, 50.0));
149        assert_eq!(
150            tree.traversal_scope(scope),
151            Some(TraversalScopePolicy::Cycle),
152            "FocusScope::build must install its policy on its own node"
153        );
154    }
155
156    #[test]
157    fn is_layout_transparent() {
158        // The wrapper's bounds match the bare child's — it imposes no layout.
159        let mut tree = WidgetTree::new();
160        let bare = tree.add(TextWidget::new(lit!("hello")));
161        let scoped = tree.add(
162            FocusScope::new(TraversalScopePolicy::Continue).child(TextWidget::new(lit!("hello"))),
163        );
164        tree.layout(SizeProposal {
165            width: Some(300.0),
166            height: None,
167        });
168        assert_eq!(
169            tree.bounds(bare).size(),
170            tree.bounds(scoped).size(),
171            "FocusScope must report its child's natural size"
172        );
173    }
174}