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