Skip to main content

teksilo_core/widget_tree/
focus_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use super::*;
5
6impl WidgetTree {
7    /// Set focus to a specific widget with the given origin, invoking
8    /// `on_focus_lost` / `on_focus_gained` handlers through the
9    /// caller-supplied [`WindowOps`](crate::window::WindowOps) sink.
10    ///
11    /// `teksilo-app` drives in-dispatch focus changes through this method so
12    /// that focus-triggered handlers *can* synchronously call
13    /// `ctx.open_window(...)`. Standalone callers (programmatic focus from
14    /// framework code paths, tests) use
15    /// [`focus_with_origin`](Self::focus_with_origin) which wraps with
16    /// [`NoopWindowOps`](crate::window::NoopWindowOps).
17    ///
18    /// **WCAG 3.2.1 (On Focus).** The capability above is a footgun: an
19    /// `on_focus` handler that opens a window, navigates, or otherwise changes
20    /// context *merely because a control received focus* is a Success Criterion
21    /// 3.2.1 failure — keyboard users tabbing through the UI would trigger it
22    /// unexpectedly. `on_focus` should only update local visual/reactive state.
23    /// A debug-only guard ([`EventContext::open_window`]) warns if a synchronous
24    /// context change is attempted from inside focus dispatch.
25    pub fn focus_with_origin_ops(
26        &mut self,
27        id: WidgetId,
28        origin: crate::focus::FocusOrigin,
29        ops: &mut dyn crate::window::WindowOps,
30    ) {
31        // Every focus move passes through here, so this is where a deferred
32        // request for a dormant node is superseded: whatever the user or the
33        // app did since that request, it is newer. Before the early return,
34        // because naming the node that already has focus is a request too.
35        self.pending_focus_request = None;
36        if self.focused == Some(id) {
37            return;
38        }
39        // Debug-only WCAG 3.2.1 guard: flag that we're inside focus dispatch so
40        // `EventContext::open_window`/`focus_window` can warn if a focus handler
41        // synchronously changes context. RAII-cleared so a panicking handler
42        // (in tests) still resets the flag; the guard owns an `Rc` clone so it
43        // doesn't borrow `self`.
44        struct ClearOnDrop(std::rc::Rc<std::cell::Cell<bool>>);
45        impl Drop for ClearOnDrop {
46            fn drop(&mut self) {
47                self.0.set(false);
48            }
49        }
50        self.in_focus_dispatch.set(true);
51        let _focus_dispatch_guard = ClearOnDrop(self.in_focus_dispatch.clone());
52
53        let previously_focused = self.focused;
54        if let Some(old) = self.focused {
55            let old_overlay = self.overlay_ancestor_for_widget(old);
56            let new_overlay = self.overlay_ancestor_for_widget(id);
57            let moving_into_descendant_overlay = match (old_overlay, new_overlay) {
58                (Some(old_overlay), Some(new_overlay)) => self
59                    .overlay_manager
60                    .is_descendant_of(new_overlay, old_overlay),
61                _ => false,
62            };
63
64            if moving_into_descendant_overlay {
65                self.dispatch_to_widget_direct(old, &WidgetEvent::FocusLost, &mut *ops);
66            } else {
67                self.dispatch_to_widget(old, &WidgetEvent::FocusLost, &mut *ops);
68            }
69        }
70        self.set_focused(Some(id));
71        self.focus_origin = Some(origin);
72        // `:focus-visible` is one tree-level signal, so the assignment itself
73        // has to declare the modality: a direct pointer's focus lands on the
74        // release, an event kind the dispatch root's modality sniff does not
75        // name, and an assistive `Action::Focus` carries no input event at all.
76        // Keying the write on the origin makes "the ring moved" and "focus
77        // moved" the same event. `Programmatic` declares nothing — a scripted
78        // focus leaves the ring where the user's last real interaction left it,
79        // which is what `:focus-visible` does for `element.focus()`.
80        if let Some(visible) = origin.focus_visible()
81            && self.focus_visible.get() != visible
82        {
83            self.focus_visible.set(visible);
84        }
85        self.a11y_dirty = true;
86        self.update_focus_within_signals(previously_focused, Some(id));
87        self.update_view_focus_signals(previously_focused, Some(id));
88        self.dispatch_to_widget(id, &WidgetEvent::FocusGained { origin }, &mut *ops);
89        // Non-modal overlays do not contain focus — they follow it out. A menu,
90        // popover or dropdown panel the keyboard has walked out of closes here,
91        // rather than lingering over the focus ring that left it. Runs after
92        // `set_focused` on purpose: `dormant_dismissed_content` re-fires
93        // `FocusLost` and clears focus only for a widget *inside* the subtree it
94        // parks, and the rule below never dismisses the overlay the new target
95        // lives in — so the focus just installed is never disturbed. Running it
96        // before the tooltip pass also means that pass sees already-reset
97        // `self.tooltips` entries instead of chasing a dismissed overlay id.
98        self.dismiss_overlays_left_by_focus(previously_focused, Some(id), &mut *ops);
99        // Focus-driven tooltip machinery: close any previously-shown
100        // focus-promoted rich tooltip whose scope no longer contains
101        // the focus target, then immediately surface+sticky the rich
102        // tooltip (if any) attached to the new focus target. See
103        // `tooltip_focus_enter` / `tooltip_focus_leave_outside` for
104        // the full rationale.
105        self.tooltip_focus_leave_outside(Some(id), &mut *ops);
106        self.tooltip_focus_enter(id);
107        // A pointer press focuses a widget the user just clicked — it is already
108        // visible, so auto-scrolling is wrong: it yanks a tall, own-scroll-
109        // suppressed editor to its far end on the stale pre-click caret (the
110        // pointer sets the real caret *after* focus, and the widget's own
111        // caret-chase then keeps it visible). Reveal only for keyboard /
112        // programmatic focus, where the newly-focused target may be off-screen.
113        if !origin.is_pointer() {
114            self.scroll_focused_into_view(id, &mut *ops);
115        }
116    }
117
118    /// Set focus using [`NoopWindowOps`](crate::window::NoopWindowOps).
119    /// Programmatic / framework-internal callers. Handlers triggered
120    /// from this path cannot `ctx.open_window(...)`.
121    pub fn focus_with_origin(&mut self, id: WidgetId, origin: crate::focus::FocusOrigin) {
122        let mut noop = crate::window::NoopWindowOps;
123        self.focus_with_origin_ops(id, origin, &mut noop);
124    }
125
126    /// After setting focus, ensure the focused widget is visible inside
127    /// all ancestor scroll areas (clips_children containers).
128    fn scroll_focused_into_view(
129        &mut self,
130        focused_id: WidgetId,
131        ops: &mut dyn crate::window::WindowOps,
132    ) {
133        let focused_bounds = self.arena.bounds(focused_id);
134        // Let the focused widget nominate a sub-rectangle to reveal instead of
135        // its whole box (a caret line, a selected row). A tall widget — e.g. a
136        // `RichTextEditor` grown inside a page `ScrollArea` — would otherwise
137        // scroll the page to its bottom on a click that only placed the caret.
138        let reveal = self
139            .arena
140            .get(focused_id)
141            .and_then(|node| node.widget.focus_reveal_rect(focused_bounds))
142            .unwrap_or(focused_bounds);
143        self.scroll_rect_into_view(
144            focused_id,
145            reveal,
146            0.0,
147            crate::event::ScrollAlign::Minimal,
148            crate::event::ScrollMotion::Instant,
149            &mut *ops,
150        );
151    }
152
153    /// Reveal `rect` — stated in `from`'s own bounds space, which is window
154    /// coordinates everywhere except inside a content transform, where it is
155    /// that node's content space — inside every
156    /// `clips_children` scroll container above `from`, walking strictly
157    /// outward (`from` itself is excluded). For each such container whose
158    /// viewport does not already contain the margin-expanded rect, dispatch
159    /// [`WidgetEvent::ScrollIntoView`] so it adjusts its offset; nested scroll
160    /// areas each get a turn (outermost included).
161    ///
162    /// This is the shared engine behind both the focus follow
163    /// ([`scroll_focused_into_view`](Self::scroll_focused_into_view), which
164    /// passes the focused widget's own bounds and a zero margin) and the
165    /// caller-driven [`EventContext::ensure_visible`](crate::widget::EventContext::ensure_visible),
166    /// which passes an arbitrary interior rectangle (a caret, a virtualized
167    /// row, a scrolled-off tab header) queued from a handler and drained in
168    /// `collect_from_ctx`. Excluding `from` is deliberate: a scrollable widget
169    /// is responsible for revealing an interior rect inside its *own*
170    /// viewport, so `ensure_visible` only touches the containers enclosing it
171    /// — no double-scroll, no feedback loop with the widget's internal follow.
172    ///
173    /// **Nested scroll areas.** Each handling container reports how far it
174    /// scrolled through the `applied_scroll` back-channel on the
175    /// [`ScrollIntoView`](crate::event::WidgetEvent::ScrollIntoView) event; the
176    /// walk shifts `rect` by the negated delta before asking the next (outer)
177    /// container, so the outer targets where the child will land once the
178    /// inner's deferred scroll applies — not its pre-scroll position. A handler
179    /// that doesn't report a delta (leaves the cell zero) simply gets no
180    /// re-targeting, which is exact for the common single-enclosing-scroller
181    /// case.
182    ///
183    /// **Coordinate spaces.** The walk carries `rect` from one ancestor's
184    /// space into the next as it climbs, so each container is asked in the
185    /// space it can act in and compared against its viewport in the space that
186    /// viewport is stated in. Those two differ for a *content* transform (a
187    /// fixed window onto moving content, whose own bounds stay in its parent's
188    /// space) and coincide for a *self* transform and for the identity case
189    /// that is the rest of the tree. Without that bookkeeping a focused card
190    /// inside a panned `SceneView` tested as already visible however far the
191    /// camera had gone, and anything further out received a rectangle from a
192    /// coordinate system it had never heard of.
193    ///
194    /// **Alignment applies to the innermost scrolling ancestor only.** A
195    /// [`ScrollAlign::Fraction`] request names a height in *one* viewport; the
196    /// containers further out have their own, differently-sized viewports and no
197    /// claim on where the rect should sit inside them, so they fall back to
198    /// [`ScrollAlign::Minimal`] — their job is to bring the inner viewport on
199    /// screen. A `Fraction` request also bypasses the already-visible gate on
200    /// that innermost container: pinning is unconditional by definition, whereas
201    /// `Minimal` keeps the "don't scroll what's already visible" behaviour.
202    ///
203    /// "Scrolling" means a clipping ancestor with an `on_scroll` handler, the
204    /// only kind of node `ScrollIntoView` can reach at all. A clipper without
205    /// one (a `MaxSize` capping a text column, an animation wrapper) is deaf to
206    /// the event, so it neither holds the pin nor spends it: the pin passes
207    /// through to the scroller above it. Spending it there would degrade every
208    /// pin under such a wrapper to a plain reveal, with nothing reporting it.
209    ///
210    /// [`ScrollAlign::Fraction`]: crate::event::ScrollAlign::Fraction
211    /// [`ScrollAlign::Minimal`]: crate::event::ScrollAlign::Minimal
212    pub(super) fn scroll_rect_into_view(
213        &mut self,
214        from: WidgetId,
215        rect: Rect,
216        margin: f32,
217        align: crate::event::ScrollAlign,
218        motion: crate::event::ScrollMotion,
219        ops: &mut dyn crate::window::WindowOps,
220    ) {
221        // Shared back-channel: each handling scroll container reports how far it
222        // scrolled, so we can shift `rect` for the next (outer) ancestor to
223        // where the target will land once the inner's deferred scroll applies.
224        // `Arc<Mutex>` (not `Rc<Cell>`) keeps `WidgetEvent: Send`; always
225        // uncontended (single-threaded dispatch).
226        let applied = std::sync::Arc::new(std::sync::Mutex::new(Point::ZERO));
227        let mut rect = rect;
228        let mut current = self.arena.parent(from);
229        // Consumed by the first scrolling ancestor reached; every one after it
230        // reveals minimally.
231        let mut pending_align = align;
232        while let Some(ancestor_id) = current {
233            // The transform between this ancestor's children and its parent.
234            // Identity for the overwhelming majority of the tree; a `SceneView`
235            // (content) or a `Scale` / `Rotate` (self) makes it real.
236            //
237            // `rect` arrives in `ancestor_id`'s **content** space — the space
238            // its children's bounds are stated in — because that is the space
239            // the widget below it was measured in and every step of this walk
240            // restores the invariant on its way out. Without that bookkeeping a
241            // focused card inside a panned `SceneView` was compared, scene
242            // coordinates against window coordinates, with the view's own
243            // viewport: a card at scene (100, 100) under a −90 px pan tested as
244            // "already visible" and the reveal never fired, and any outer
245            // scroller then received a rectangle from a coordinate system it
246            // had never heard of.
247            let to_parent = self
248                .arena
249                .get(ancestor_id)
250                .and_then(|n| n.transform_prop.as_ref())
251                .map(|t| t.get())
252                .filter(|t| !t.is_identity());
253            let content_transform = self
254                .arena
255                .get(ancestor_id)
256                .is_some_and(|n| n.content_transform);
257
258            // Only a node with an `on_scroll` handler can act on
259            // `ScrollIntoView`, so a clipper without one is passed over rather
260            // than asked. Asking would reach no handler and change nothing, but
261            // it would spend the pin, and every scroller above would then be
262            // handed a plain reveal. That is how a `MaxSize` capping a text
263            // column turned typewriter scrolling into ordinary caret-following.
264            if let Some(node) = self.arena.get(ancestor_id)
265                && node.clips_children
266                && (node.handlers.on_scroll.is_some() || node.external_handlers.on_scroll.is_some())
267            {
268                let viewport = node.bounds;
269                // Which space the viewport is stated in decides which rectangle
270                // it is comparable with. A **content** transform is a fixed
271                // window onto moving content, so its own bounds stay in its
272                // parent's space and the target has to be projected to meet
273                // them. A **self** transform moves with its own bounds, so both
274                // are already in the same space.
275                let against = match (content_transform, to_parent.as_ref()) {
276                    (true, Some(t)) => t.apply_rect(rect),
277                    _ => rect,
278                };
279                let align =
280                    std::mem::replace(&mut pending_align, crate::event::ScrollAlign::Minimal);
281                // A pin must re-assert itself every time, so it never consults
282                // whether the target already happens to be on screen.
283                let needs_scroll = matches!(align, crate::event::ScrollAlign::Fraction(_))
284                    || against.y - margin < viewport.y
285                    || against.bottom() + margin > viewport.bottom()
286                    || against.x - margin < viewport.x
287                    || against.right() + margin > viewport.right();
288
289                if needs_scroll {
290                    *applied.lock().unwrap() = Point::ZERO;
291                    // Addressed, not bubbled. `dispatch_to_widget` previews the
292                    // event through the target's own ancestors first, which
293                    // hands every outer container the *inner* one's reveal —
294                    // out of order, before the inner has scrolled, and (now
295                    // that a content transform is in the picture) in a
296                    // coordinate system that is not the outer container's. This
297                    // walk already visits each clipping ancestor in turn, in
298                    // its own space and after re-targeting by what the one
299                    // inside it actually scrolled, so the preview pass was a
300                    // second, worse copy of the same job. Every widget in the
301                    // framework that handles `WidgetEvent::ScrollIntoView` —
302                    // `ScrollArea` and `SceneView` — clips, so each is reached
303                    // by the walk itself either way.
304                    self.dispatch_to_widget_direct(
305                        ancestor_id,
306                        &WidgetEvent::ScrollIntoView {
307                            // The handler's own content space, which is the
308                            // only one it can act in: a `ScrollArea` subtracts
309                            // its viewport origin from this, and a `SceneView`
310                            // hands it to `ensure_visible`, which is scene
311                            // coordinates by signature.
312                            target_bounds: rect,
313                            margin,
314                            align,
315                            motion,
316                            applied_scroll: Some(applied.clone()),
317                        },
318                        &mut *ops,
319                    );
320                    // The container scrolled its content by `+delta`, moving the
321                    // target `-delta` — in the same space the event was stated
322                    // in, since that is the space the handler worked in.
323                    let delta = *applied.lock().unwrap();
324                    if delta != Point::ZERO {
325                        rect =
326                            Rect::new(rect.x - delta.x, rect.y - delta.y, rect.width, rect.height);
327                    }
328                }
329            }
330            // Leaving this ancestor: restore the invariant for the next one up.
331            // Both kinds of transform map this node's content into its parent's
332            // space — they differ only in whether the node's *own* bounds went
333            // with it, which is what the comparison above had to know and this
334            // does not.
335            if let Some(t) = to_parent {
336                rect = t.apply_rect(rect);
337            }
338            current = self.arena.parent(ancestor_id);
339        }
340    }
341
342    /// Set focus to a specific widget (programmatic origin, no ops).
343    ///
344    /// **A dormant target is focused when it wakes, not now.** If `id` sits in
345    /// a parked `Switcher` page or a `visible_when(false)` branch, focus stays
346    /// where it is and the request is held until the end of the next
347    /// [`layout`](Self::layout): if that pass has made `id` active, focus moves
348    /// there; if `id` is still dormant, or gone, the request is dropped. Any
349    /// focus move in between, and [`clear_focus`](Self::clear_focus), cancel
350    /// it. This is what lets one handler show a page and focus a control on it.
351    pub fn focus(&mut self, id: WidgetId) {
352        let mut noop = crate::window::NoopWindowOps;
353        self.request_focus_with_ops(id, FocusRequestMode::Exact, &mut noop);
354    }
355
356    /// Drop keyboard focus: afterwards nothing is focused.
357    ///
358    /// It is a focus move to nothing, with everything a move carries: the
359    /// node that had focus receives `FocusLost`, every `focus_within` signal on
360    /// its ancestors turns `false`, a data view stops showing its selection as
361    /// focused, the focus ring is hidden (`focus_visible` resets to `false`),
362    /// a non-modal overlay that focus was inside or anchored at closes as it
363    /// would on Tab-away, and the accessibility tree is marked for a rebuild.
364    /// A focus request still waiting for a dormant target (see
365    /// [`focus`](Self::focus)) is cancelled.
366    ///
367    /// A no-op, apart from that cancellation, when nothing is focused.
368    ///
369    /// From a handler, use
370    /// [`EventContext::clear_focus`](crate::widget::EventContext::clear_focus),
371    /// which is applied after the handler returns like every other focus
372    /// request.
373    pub fn clear_focus(&mut self) {
374        let mut noop = crate::window::NoopWindowOps;
375        self.clear_focus_ops(&mut noop);
376    }
377
378    /// [`clear_focus`](Self::clear_focus), threading `ops` through to the
379    /// `FocusLost` handler and to any overlay the move dismisses.
380    pub(crate) fn clear_focus_ops(&mut self, ops: &mut dyn crate::window::WindowOps) {
381        self.pending_focus_request = None;
382        let Some(old) = self.focused else {
383            return;
384        };
385        // Same WCAG 3.2.1 guard as `focus_with_origin_ops`: a `FocusLost`
386        // handler is focus dispatch too.
387        struct ClearOnDrop(std::rc::Rc<std::cell::Cell<bool>>);
388        impl Drop for ClearOnDrop {
389            fn drop(&mut self) {
390                self.0.set(false);
391            }
392        }
393        self.in_focus_dispatch.set(true);
394        let _focus_dispatch_guard = ClearOnDrop(self.in_focus_dispatch.clone());
395
396        // The tree lets go first and tells the widget second, so a `FocusLost`
397        // handler that puts focus somewhere else makes an ordinary move from
398        // nothing, rather than re-entering a move away from itself.
399        self.set_focused(None);
400        self.focus_origin = None;
401        if self.focus_visible.get() {
402            self.focus_visible.set(false);
403        }
404        self.a11y_dirty = true;
405        self.update_focus_within_signals(Some(old), None);
406        self.update_view_focus_signals(Some(old), None);
407        self.dispatch_to_widget(old, &WidgetEvent::FocusLost, &mut *ops);
408        // Then what a move away from `old` closes, measured against wherever
409        // focus is now: nowhere, unless that handler redirected it.
410        let now = self.focused;
411        self.dismiss_overlays_left_by_focus(Some(old), now, &mut *ops);
412        self.tooltip_focus_leave_outside(now, &mut *ops);
413    }
414
415    /// Focus `id` the way `mode` says — or, when `id` exists but is dormant,
416    /// hold the request for the end of the next layout pass. See
417    /// [`focus`](Self::focus) for the deferred contract.
418    ///
419    /// The one door for the *requests* (`WidgetTree::focus`,
420    /// `BuildContext::focus` / `focus_into`, `EventContext::request_focus` /
421    /// `request_focus_into`). The framework's own focus restores go straight to
422    /// [`focus_ops`](Self::focus_ops): they already check that their target is
423    /// active, and a restore aimed at something dormant has nothing to wait for.
424    ///
425    /// An id that is not in the arena at all is passed through as before; only
426    /// a node that exists and is parked is deferred.
427    pub(crate) fn request_focus_with_ops(
428        &mut self,
429        id: WidgetId,
430        mode: FocusRequestMode,
431        ops: &mut dyn crate::window::WindowOps,
432    ) {
433        // A newer request replaces an older one, whether or not it moves focus.
434        self.pending_focus_request = None;
435        if self.arena.get(id).is_some() && !self.arena.is_active(id) {
436            self.pending_focus_request = Some(PendingFocusRequest { id, mode });
437            return;
438        }
439        self.resolve_focus_request(id, mode, ops);
440    }
441
442    /// Whether a focus request is waiting for a dormant target to wake.
443    #[cfg(test)]
444    pub(crate) fn has_pending_focus_request(&self) -> bool {
445        self.pending_focus_request.is_some()
446    }
447
448    /// Apply a focus request against an active (or absent) target.
449    fn resolve_focus_request(
450        &mut self,
451        id: WidgetId,
452        mode: FocusRequestMode,
453        ops: &mut dyn crate::window::WindowOps,
454    ) {
455        match mode {
456            FocusRequestMode::Exact => self.focus_ops(id, ops),
457            FocusRequestMode::SelfOrDescendant => {
458                let target = self.first_focusable_descendant(id).unwrap_or(id);
459                self.focus_ops(target, ops);
460            }
461            FocusRequestMode::Into => {
462                if let Some(target) = self.first_focusable_descendant(id) {
463                    self.focus_ops(target, ops);
464                }
465            }
466        }
467    }
468
469    /// Honour the focus request a dormant target deferred, now that the layout
470    /// pass has settled activation: focus it if it woke, drop it otherwise.
471    ///
472    /// Runs at the tail of `layout_with_ops` — after the culling parents have
473    /// woken what they wake and the bounds are fresh, so the focus-driven
474    /// scroll-into-view has real geometry — and before `pending_focus_restore`,
475    /// so a rebuild's restore never overrides an explicit request. Also runs on
476    /// the pass's idle early return, where activation is just as settled.
477    pub(super) fn settle_pending_focus_request(&mut self, ops: &mut dyn crate::window::WindowOps) {
478        let Some(request) = self.pending_focus_request.take() else {
479            return;
480        };
481        // A `*_into` / resolving request re-resolves its descendant here, so
482        // it lands on what the page holds *now*.
483        if self.arena.is_active(request.id) {
484            self.resolve_focus_request(request.id, request.mode, ops);
485        }
486    }
487
488    /// Set focus — the dispatch-path variant that threads `ops` through
489    /// to any on_focus_lost / on_focus_gained handlers.
490    pub fn focus_ops(&mut self, id: WidgetId, ops: &mut dyn crate::window::WindowOps) {
491        self.focus_with_origin_ops(id, crate::focus::FocusOrigin::Programmatic, ops);
492    }
493
494    /// Get the currently focused widget.
495    pub fn focused(&self) -> Option<WidgetId> {
496        self.focused
497    }
498
499    /// The OS-IME descriptor of the currently focused widget, if it is a
500    /// text-input surface. `None` when nothing is focused or the focused
501    /// node is not text-editing. The platform layer reads this at
502    /// focus-change time to enable/disable the OS input method and pick its
503    /// purpose. See [`crate::ime`].
504    pub fn ime_context_for_focused(&self) -> Option<crate::ime::ImeContext> {
505        self.focused.and_then(|id| self.arena.ime_context(id))
506    }
507
508    /// Find the first focusable widget within a subtree, in **traversal
509    /// order** — the widget Tab would land on first. Respects nested
510    /// `FocusScope`s and scoped `tab_index` (not merely raw DFS order), so a
511    /// modal's initial focus matches its Tab order.
512    pub fn first_focusable_descendant(&self, root: WidgetId) -> Option<WidgetId> {
513        if !self.arena.is_active(root) {
514            return None;
515        }
516        let mut entries = Vec::new();
517        self.collect_scope_entries(root, &mut entries);
518        sort_scope_entries(&mut entries);
519        let scope = ScopeNode {
520            policy: crate::focus::TraversalScopePolicy::Cycle,
521            entries,
522        };
523        enter_scope_edge(&scope, false)
524    }
525
526    /// The first widget within a subtree, in depth-first order, that is
527    /// declared focusable, whether or not it can take focus right now. See
528    /// [`BuildContext::first_focus_capable_descendant`](crate::build_context::BuildContext::first_focus_capable_descendant).
529    pub(crate) fn first_focus_capable_descendant(&self, root: WidgetId) -> Option<WidgetId> {
530        if !self.arena.is_active(root) {
531            return None;
532        }
533        let node = self.arena.get(root)?;
534        if self.is_node_focusable(node) {
535            return Some(root);
536        }
537        node.children
538            .iter()
539            .find_map(|&child| self.first_focus_capable_descendant(child))
540    }
541
542    /// Whether the given widget id currently exists and is active in the
543    /// tree (not dormant, not destroyed). Callers that need to validate a
544    /// user-supplied `WidgetId` before acting on it — e.g. the modal
545    /// presentation path validating `ModalRequest::focus_target` — use
546    /// this.
547    pub fn is_active(&self, id: WidgetId) -> bool {
548        self.arena.is_active(id)
549    }
550
551    /// Walk the subtree rooted at `id` in depth-first order, returning
552    /// the first widget-reported `initial_focus_hint` that resolves to
553    /// an active descendant of `id`.
554    ///
555    /// Used by the modal presentation pipeline to let a deferred-built
556    /// content widget (e.g. `MessageBox`) direct focus to a specific
557    /// descendant after build — even when wrapped in a surface widget
558    /// like `ModalContainer` that doesn't itself know the default
559    /// button's id. The framework walks in to find the first hint
560    /// under the content root, which is tighter than falling all the
561    /// way back to `first_focusable_descendant`.
562    ///
563    /// Hints pointing at inactive or out-of-subtree ids are ignored;
564    /// the walk continues so a shallow wrapper's stale hint doesn't
565    /// hide a deeper child's valid one.
566    pub fn widget_initial_focus_hint(&self, id: WidgetId) -> Option<WidgetId> {
567        if !self.arena.is_active(id) {
568            return None;
569        }
570        if let Some(node) = self.arena.get(id) {
571            if let Some(target) = node.widget.initial_focus_hint()
572                && self.arena.is_active(target)
573                && self.is_descendant_of(target, id)
574            {
575                return Some(target);
576            }
577            for &child in &node.children {
578                if let Some(found) = self.widget_initial_focus_hint(child) {
579                    return Some(found);
580                }
581            }
582        }
583        None
584    }
585
586    /// How the currently focused widget gained focus.
587    pub fn focus_origin(&self) -> Option<crate::focus::FocusOrigin> {
588        self.focus_origin
589    }
590
591    /// Input-modality "focus-visible" signal: `true` after keyboard input,
592    /// `false` after pointer input. Focus rings observe this so they show only
593    /// during keyboard navigation. See [`BuildContext::focus_visible`](crate::BuildContext::focus_visible).
594    pub fn focus_visible_signal(&self) -> crate::signal::Signal<bool> {
595        self.focus_visible.clone()
596    }
597
598    /// Cycle focus to the next/previous focusable widget (Tab/Shift-Tab),
599    /// honoring nested **traversal scopes** (`FocusScope`).
600    ///
601    /// Builds a scope tree on demand (depth-first; `tab_index` scoped per
602    /// scope), then walks it with [`navigate_scope`]. The root is an implicit
603    /// `Cycle` scope (whole-tree last↔first wrap). A centered modal overlay
604    /// folds into the same mechanism: its content subtree becomes the root
605    /// `Cycle` scope, so Tab is confined to the modal.
606    pub(super) fn cycle_focus(&mut self, reverse: bool, ops: &mut dyn crate::window::WindowOps) {
607        let mut entries = Vec::new();
608        if let Some(modal_overlay) = self.overlay_manager.topmost_centered() {
609            let content_id = modal_overlay.content_id;
610            self.collect_scope_entries(content_id, &mut entries);
611        } else {
612            let roots = self.arena.roots();
613            for root in roots {
614                // Tooltip surfaces are spliced in below, next to the anchor
615                // they belong to — never collected as bare roots. A tooltip's
616                // content is `ctx.add`-ed parentless, so collecting it here put
617                // it in the Tab cycle at whatever position it happened to be
618                // inserted at: `sort_scope_entries` orders by explicit
619                // `tab_index` only, and entries without one compare `Equal`,
620                // so a stable sort leaves insertion order to decide. That made
621                // a tooltip's slot in the cycle an emergent property of build
622                // order rather than of the control it describes.
623                if self.tooltip_content_root(root).is_some() {
624                    continue;
625                }
626                self.collect_scope_entries(root, &mut entries);
627            }
628        }
629        sort_scope_entries(&mut entries);
630        self.splice_sticky_tooltips_after_anchors(&mut entries);
631        let root_scope = ScopeNode {
632            policy: crate::focus::TraversalScopePolicy::Cycle,
633            entries,
634        };
635
636        if let StepResult::Found(next_id) = navigate_scope(&root_scope, self.focused, reverse, true)
637        {
638            self.focus_with_origin_ops(next_id, crate::focus::FocusOrigin::Keyboard, &mut *ops);
639        }
640        // `Escaped` only reaches here for an empty tree (the root Cycle scope
641        // wraps at its ends) — nothing to focus, so leave focus unchanged.
642    }
643
644    /// The tooltip entry whose content root is `id`, if any.
645    fn tooltip_content_root(&self, id: WidgetId) -> Option<usize> {
646        self.tooltips.iter().position(|e| e.content_id == id)
647    }
648
649    /// Place every **sticky** tooltip surface immediately after the entry
650    /// holding its anchor, and leave every non-sticky one out entirely.
651    ///
652    /// Two rules, one place:
653    ///
654    /// * An *unpromoted* tip is informational. It appeared because the pointer
655    ///   paused or focus arrived — not because the user asked to enter it — so
656    ///   it takes no Tab stop, matching the ARIA tooltip pattern (its text
657    ///   reaches assistive tech through the anchor's description instead).
658    /// * A *promoted* one was earned, by a 2 s dwell in either modality, and
659    ///   its whole point is that its content is reachable. It belongs directly
660    ///   after the control it describes, the way a disclosure's panel follows
661    ///   its button — never at some position decided by arena insertion order.
662    ///
663    /// Anchors nested inside a traversal scope are handled by locating the
664    /// top-level entry that *contains* the anchor, so the panel still lands
665    /// immediately after that whole group rather than being dropped.
666    fn splice_sticky_tooltips_after_anchors(&self, entries: &mut Vec<ScopeEntry>) {
667        let sticky: Vec<(WidgetId, WidgetId)> = self
668            .tooltips
669            .iter()
670            .filter(|e| e.is_sticky && e.overlay_id.is_some())
671            .map(|e| (e.anchor_id, e.content_id))
672            .collect();
673
674        for (anchor_id, content_id) in sticky {
675            let mut panel = Vec::new();
676            self.collect_scope_entries(content_id, &mut panel);
677            if panel.is_empty() {
678                continue;
679            }
680            sort_scope_entries(&mut panel);
681
682            // The anchor is often not itself the Tab stop: composing controls
683            // (`Button`) keep focus on their outer node and attach the tip to
684            // an inner body root, so resolve to the focusable that actually
685            // appears in the cycle before looking for its entry.
686            let stop = self
687                .find_focusable_at_or_above(anchor_id)
688                .unwrap_or(anchor_id);
689            let at = entries
690                .iter()
691                .position(|entry| scope_entry_contains(entry, stop))
692                // An anchor with no Tab stop above it at all (a plain container
693                // that merely carries a tip) has no entry to follow; put the
694                // panel at the end rather than dropping it, so its content
695                // stays reachable.
696                .map_or(entries.len(), |i| i + 1);
697
698            for (offset, item) in panel.into_iter().enumerate() {
699                entries.insert(at + offset, item);
700            }
701        }
702    }
703
704    /// Whether `id` participates in Tab traversal, honoring a `tab_stop`
705    /// flag set anywhere on its ancestor chain. Walks up to the nearest
706    /// node (including `id`) carrying an explicit `tab_stop` prop and
707    /// returns its current value; defaults to `true` when no ancestor
708    /// constrains it. This makes `set_tab_stop` on a composite control
709    /// (whose focusable node is an inner leaf) govern the whole subtree —
710    /// the basis of the roving-tabindex pattern in `Toolbar` / `TabBar`.
711    pub(super) fn tab_stop_effective(&self, id: WidgetId) -> bool {
712        let mut current = Some(id);
713        while let Some(cur) = current {
714            let Some(node) = self.arena.get(cur) else {
715                break;
716            };
717            if let Some(prop) = node.tab_stop.as_ref() {
718                return prop.get();
719            }
720            current = node.parent;
721        }
722        true
723    }
724
725    /// Check if a node is focusable (set via HandlerSet `.focusable(true)` in build).
726    pub(super) fn is_node_focusable(&self, node: &crate::arena::WidgetNode) -> bool {
727        node.node_focusable.unwrap_or(false)
728    }
729
730    /// Find the nearest focusable widget at or above the given ID.
731    pub(super) fn find_focusable_at_or_above(&self, id: WidgetId) -> Option<WidgetId> {
732        let mut current = Some(id);
733        while let Some(current_id) = current {
734            if let Some(node) = self.arena.get(current_id)
735                && self.is_node_focusable(node)
736            {
737                return Some(current_id);
738            }
739            current = self.arena.parent(current_id);
740        }
741        None
742    }
743
744    /// Collect the traversal entries of the subtree rooted at `id`, in
745    /// depth-first (document) order, into the current scope's `out` list.
746    ///
747    /// - A node carrying `node_traversal_scope` becomes a single
748    ///   [`ScopeEntryKind::Scope`] — its descendants are collected into a *nested*
749    ///   ordered list and the recursion does not flow past it at this level.
750    ///   (The scope node itself is never a focusable; the `FocusScope` wrapper
751    ///   forces `node_focusable = false`.)
752    /// - Any other node that is focusable and an effective Tab stop becomes a
753    ///   [`ScopeEntryKind::Focusable`].
754    ///
755    /// Disabled subtrees and dormant/destroyed nodes are skipped entirely, as
756    /// in the previous flat collector. The `tab_stop` ancestor-walk
757    /// (`tab_stop_effective`) is applied here at collection time rather than as
758    /// a post-pass `retain`.
759    fn collect_scope_entries(&self, id: WidgetId, out: &mut Vec<ScopeEntry>) {
760        if !self.arena.is_active(id) {
761            return;
762        }
763        let Some(node) = self.arena.get(id) else {
764            return;
765        };
766        if node
767            .enabled_state
768            .as_ref()
769            .map(|s| !s.get())
770            .unwrap_or(false)
771        {
772            return;
773        }
774
775        // A traversal-scope boundary: collect its subtree as an independent,
776        // internally-ordered group; do not descend past it into `out`.
777        if let Some(policy) = node.node_traversal_scope {
778            let mut child_entries = Vec::new();
779            for &child in &node.children {
780                self.collect_scope_entries(child, &mut child_entries);
781            }
782            sort_scope_entries(&mut child_entries);
783            out.push(ScopeEntry {
784                tab_index: node.node_tab_index,
785                kind: ScopeEntryKind::Scope(ScopeNode {
786                    policy,
787                    entries: child_entries,
788                }),
789            });
790            return;
791        }
792
793        // A normal node: a Tab stop iff focusable and not tab_stop-suppressed.
794        if self.is_node_focusable(node) && self.tab_stop_effective(id) {
795            out.push(ScopeEntry {
796                tab_index: node.node_tab_index,
797                kind: ScopeEntryKind::Focusable(id),
798            });
799        }
800        for &child in &node.children {
801            self.collect_scope_entries(child, out);
802        }
803    }
804
805    /// Build the chain of strict ancestors of `id` (i.e. starting at
806    /// the parent of `id`, walking up to a root). Returns an empty
807    /// vector when `id` is `None` or has no parent. Used by the
808    /// `focus_within` / `hover_within` chain-diff helpers.
809    pub(super) fn strict_ancestors_of(&self, id: Option<WidgetId>) -> Vec<WidgetId> {
810        let mut chain = Vec::new();
811        if let Some(start) = id {
812            let mut current = self.arena.parent(start);
813            while let Some(parent) = current {
814                chain.push(parent);
815                current = self.arena.parent(parent);
816            }
817        }
818        chain
819    }
820
821    /// Update every `focus_within_signal` whose owning node moved
822    /// in or out of the focused widget's strict-ancestor chain
823    /// between `old` and `new`. Strict ancestors only — the
824    /// focused widget's own signal (if any) is never written.
825    pub(crate) fn update_focus_within_signals(
826        &mut self,
827        old: Option<WidgetId>,
828        new: Option<WidgetId>,
829    ) {
830        let old_chain = self.strict_ancestors_of(old);
831        let new_chain = self.strict_ancestors_of(new);
832        // Nodes leaving the chain → false.
833        for &id in &old_chain {
834            if !new_chain.contains(&id)
835                && let Some(node) = self.arena.get(id)
836                && let Some(sig) = node.focus_within_signal.clone()
837            {
838                sig.set(false);
839            }
840        }
841        // Nodes entering the chain → true.
842        for &id in &new_chain {
843            if !old_chain.contains(&id)
844                && let Some(node) = self.arena.get(id)
845                && let Some(sig) = node.focus_within_signal.clone()
846            {
847                sig.set(true);
848            }
849        }
850    }
851
852    /// Inclusive ancestor chain of `id`: `id` itself, then its strict
853    /// ancestors. Empty when `id` is `None`.
854    pub(super) fn inclusive_ancestors_of(&self, id: Option<WidgetId>) -> Vec<WidgetId> {
855        let mut chain = Vec::new();
856        if let Some(start) = id {
857            chain.push(start);
858            chain.extend(self.strict_ancestors_of(Some(start)));
859        }
860        chain
861    }
862
863    /// Mirror of [`update_focus_within_signals`](Self::update_focus_within_signals)
864    /// for `view_focus_signal`, using *inclusive* ancestor chains — so a node
865    /// that is itself the focused widget (e.g. a data view holding focus
866    /// directly) sees its own scope signal flip `true`.
867    pub(crate) fn update_view_focus_signals(
868        &mut self,
869        old: Option<WidgetId>,
870        new: Option<WidgetId>,
871    ) {
872        let old_chain = self.inclusive_ancestors_of(old);
873        let new_chain = self.inclusive_ancestors_of(new);
874        for &id in &old_chain {
875            if !new_chain.contains(&id)
876                && let Some(node) = self.arena.get(id)
877                && let Some(sig) = node.view_focus_signal.clone()
878            {
879                sig.set(false);
880            }
881        }
882        for &id in &new_chain {
883            if !old_chain.contains(&id)
884                && let Some(node) = self.arena.get(id)
885                && let Some(sig) = node.view_focus_signal.clone()
886            {
887                sig.set(true);
888            }
889        }
890    }
891
892    /// Get-or-create the `view_focus_signal` on `node_id`, initialised to the
893    /// node's current focus-containment (`focused` is `node_id` or a descendant).
894    pub(crate) fn view_focus_signal_for(
895        &mut self,
896        node_id: WidgetId,
897    ) -> crate::signal::Signal<bool> {
898        if let Some(node) = self.arena.get(node_id)
899            && let Some(sig) = node.view_focus_signal.clone()
900        {
901            return sig;
902        }
903        let active = self.inclusive_ancestors_of(self.focused).contains(&node_id);
904        let sig = crate::signal::Signal::new(active);
905        if let Some(node) = self.arena.get_mut(node_id) {
906            node.view_focus_signal = Some(sig.clone());
907        }
908        sig
909    }
910
911    /// Reactive signal that is `true` when the nearest focusable ancestor of
912    /// `node_id` (its "focus scope" — e.g. the enclosing data view) holds
913    /// keyboard focus. With no focusable ancestor, returns a constant-`true`
914    /// signal so selection renders active (the legacy behaviour for items
915    /// outside any focus scope). Drives focus-aware selection in `StandardItem`.
916    pub fn view_focus_active_for(&mut self, node_id: WidgetId) -> crate::signal::Signal<bool> {
917        match self.find_focusable_at_or_above(node_id) {
918            Some(scope) => self.view_focus_signal_for(scope),
919            None => crate::signal::Signal::new(true),
920        }
921    }
922
923    /// Push `node_id`'s focus scope onto the build-time scope stack (creating its
924    /// `view_focus_signal` if absent) so descendants built before arena
925    /// parenting is wired (docked / virtualized rows) still read the correct
926    /// view focus. Pair with [`end_view_focus`](Self::end_view_focus).
927    pub fn begin_view_focus(&mut self, node_id: WidgetId) -> crate::signal::Signal<bool> {
928        let sig = self.view_focus_signal_for(node_id);
929        self.view_focus_stack.push(sig.clone());
930        sig
931    }
932
933    /// Pop the innermost focus scope pushed by [`begin_view_focus`](Self::begin_view_focus).
934    pub fn end_view_focus(&mut self) {
935        self.view_focus_stack.pop();
936    }
937
938    /// The innermost active build-time focus scope, if any.
939    pub fn current_view_focus(&self) -> Option<crate::signal::Signal<bool>> {
940        self.view_focus_stack.last().cloned()
941    }
942
943    /// Single point of mutation for the hovered widget. Writes it onto the
944    /// **hover owner**'s entry in the pointer table and updates the
945    /// externally-observable Signal so debug tooling (the inspector's hover
946    /// tooltip) doesn't have to poll.
947    ///
948    /// Hover is hover-owner-only, so this is a no-op on the table side when no
949    /// hovering-capable pointer is live — a touch-only device has nothing that
950    /// hovers, and writing a contact's target here would make every hover
951    /// affordance fire on tap. The signal is still kept honest.
952    ///
953    /// Does **not** call `update_hover_within_signals` — call sites remain in
954    /// charge of dispatching enter/leave because some sites (e.g. post-layout
955    /// hover recovery) intentionally skip it.
956    pub(crate) fn set_hovered(&mut self, value: Option<WidgetId>) {
957        if let Some(entry) = self.pointers.hover_owner_mut() {
958            entry.hovered = value;
959        }
960        if self.hovered_signal.get() != value {
961            self.hovered_signal.set(value);
962        }
963    }
964
965    /// Single point of mutation for `self.focused`. Mirror of
966    /// `set_hovered` for the focused chain. Drives the inspector's
967    /// Focus tab without requiring the tab to poll. Does not touch
968    /// `focus_origin` or focus-within signals — call sites remain
969    /// responsible for those (the bookkeeping varies by mutation
970    /// path, e.g. focus loss vs. arena destruction).
971    pub(crate) fn set_focused(&mut self, value: Option<WidgetId>) {
972        self.focused = value;
973        if self.focused_signal.get() != value {
974            self.focused_signal.set(value);
975        }
976    }
977
978    /// Mirror of [`update_focus_within_signals`](Self::update_focus_within_signals)
979    /// for the hovered chain.
980    pub(crate) fn update_hover_within_signals(
981        &mut self,
982        old: Option<WidgetId>,
983        new: Option<WidgetId>,
984    ) {
985        let old_chain = self.strict_ancestors_of(old);
986        let new_chain = self.strict_ancestors_of(new);
987        // Record the chain on the hover owner's entry: it is the pointer that
988        // put those signals to `true`, so it is the one whose teardown (and
989        // whose post-rebuild scrub) has to know about them.
990        if let Some(entry) = self.pointers.hover_owner_mut() {
991            entry.hover_within = new_chain.clone();
992        }
993        for &id in &old_chain {
994            if !new_chain.contains(&id)
995                && let Some(node) = self.arena.get(id)
996                && let Some(sig) = node.hover_within_signal.clone()
997            {
998                sig.set(false);
999            }
1000        }
1001        for &id in &new_chain {
1002            if !old_chain.contains(&id)
1003                && let Some(node) = self.arena.get(id)
1004                && let Some(sig) = node.hover_within_signal.clone()
1005            {
1006                sig.set(true);
1007            }
1008        }
1009    }
1010}
1011
1012/// How a focus request picks its target, kept with a request that had to wait
1013/// for a dormant node.
1014#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1015pub(crate) enum FocusRequestMode {
1016    /// The named node itself (`WidgetTree::focus`, `BuildContext::focus`).
1017    Exact,
1018    /// The named node's first focusable descendant, or the node itself when it
1019    /// has none (`EventContext::request_focus`).
1020    SelfOrDescendant,
1021    /// The first focusable descendant only; nothing when there is none
1022    /// (`EventContext::request_focus_into`, `BuildContext::focus_into`).
1023    Into,
1024}
1025
1026/// A focus request whose target was dormant when it was made. See
1027/// `WidgetTree::pending_focus_request`.
1028#[derive(Debug, Clone, Copy)]
1029pub(crate) struct PendingFocusRequest {
1030    id: WidgetId,
1031    mode: FocusRequestMode,
1032}
1033
1034// ─── Traversal scope tree ────────────────────────────────────────────────
1035//
1036// `cycle_focus` builds a transient tree of these on each Tab press. A
1037// `ScopeNode` is an ordered list of `ScopeEntry`s; an entry is either a
1038// focusable leaf or a nested `ScopeNode`. Within a node, entries are ordered
1039// by `tab_index` (scoped — only compared among siblings) then DFS order.
1040// `navigate_scope` walks this tree, applying each scope's `policy` at its ends.
1041
1042/// One member of a traversal scope: a focusable leaf or a nested scope, plus
1043/// the `tab_index` used to position it among its siblings (`None` sorts last,
1044/// preserving DFS order).
1045struct ScopeEntry {
1046    tab_index: Option<i32>,
1047    kind: ScopeEntryKind,
1048}
1049
1050enum ScopeEntryKind {
1051    Focusable(WidgetId),
1052    Scope(ScopeNode),
1053}
1054
1055/// An ordered group of entries with a boundary policy.
1056struct ScopeNode {
1057    policy: crate::focus::TraversalScopePolicy,
1058    entries: Vec<ScopeEntry>,
1059}
1060
1061/// Outcome of stepping within a scope.
1062enum StepResult {
1063    /// Focus should move to this widget.
1064    Found(WidgetId),
1065    /// Tab ran off this scope's end and the policy permits leaving — the
1066    /// caller (parent scope) should continue stepping from this scope's slot.
1067    /// Never produced by a `Cycle` scope or the root scope (they wrap).
1068    Escaped,
1069}
1070
1071/// Whether a scope wraps (vs. lets focus escape) when Tab hits its boundary.
1072/// The root scope always wraps; otherwise only `Cycle` scopes do.
1073fn scope_wraps(policy: crate::focus::TraversalScopePolicy, is_root: bool) -> bool {
1074    is_root || matches!(policy, crate::focus::TraversalScopePolicy::Cycle)
1075}
1076
1077/// Sort entries by scoped `tab_index`: `Some` before `None` (ascending);
1078/// stable, so the DFS order is preserved within each group.
1079fn sort_scope_entries(entries: &mut [ScopeEntry]) {
1080    entries.sort_by(|a, b| match (a.tab_index, b.tab_index) {
1081        (Some(ia), Some(ib)) => ia.cmp(&ib),
1082        (Some(_), None) => std::cmp::Ordering::Less,
1083        (None, Some(_)) => std::cmp::Ordering::Greater,
1084        (None, None) => std::cmp::Ordering::Equal,
1085    });
1086}
1087
1088/// Whether the focused widget `f` lies anywhere within `entry`.
1089fn scope_entry_contains(entry: &ScopeEntry, f: WidgetId) -> bool {
1090    match &entry.kind {
1091        ScopeEntryKind::Focusable(id) => *id == f,
1092        ScopeEntryKind::Scope(child) => child.entries.iter().any(|e| scope_entry_contains(e, f)),
1093    }
1094}
1095
1096/// First focusable when entering `scope` from its leading (forward) or
1097/// trailing (reverse) edge, descending into nested scopes and skipping empty
1098/// ones. `None` if the scope holds no focusable members.
1099fn enter_scope_edge(scope: &ScopeNode, reverse: bool) -> Option<WidgetId> {
1100    let n = scope.entries.len();
1101    for k in 0..n {
1102        let idx = if reverse { n - 1 - k } else { k };
1103        match &scope.entries[idx].kind {
1104            ScopeEntryKind::Focusable(id) => return Some(*id),
1105            ScopeEntryKind::Scope(child) => {
1106                if let Some(id) = enter_scope_edge(child, reverse) {
1107                    return Some(id);
1108                }
1109            }
1110        }
1111    }
1112    None
1113}
1114
1115/// Move focus to the next/previous focusable within `scope`, recursing into
1116/// the nested scope that currently holds focus before stepping to siblings.
1117/// `is_root` marks the implicit top scope (always wraps, never escapes).
1118fn navigate_scope(
1119    scope: &ScopeNode,
1120    focused: Option<WidgetId>,
1121    reverse: bool,
1122    is_root: bool,
1123) -> StepResult {
1124    if scope.entries.is_empty() {
1125        return StepResult::Escaped;
1126    }
1127
1128    let cur = focused.and_then(|f| {
1129        scope
1130            .entries
1131            .iter()
1132            .position(|e| scope_entry_contains(e, f))
1133    });
1134
1135    let from = match cur {
1136        None => {
1137            // Focus is not in this scope (root with nothing focused, or the
1138            // focused widget was destroyed): enter from the edge.
1139            return match enter_scope_edge(scope, reverse) {
1140                Some(id) => StepResult::Found(id),
1141                None => StepResult::Escaped,
1142            };
1143        }
1144        Some(i) => i,
1145    };
1146
1147    // Focus is inside a nested scope: try to advance within it first.
1148    // (Escaped — fall through and step to the sibling after this scope.)
1149    if let ScopeEntryKind::Scope(child) = &scope.entries[from].kind
1150        && let StepResult::Found(id) = navigate_scope(child, focused, reverse, false)
1151    {
1152        return StepResult::Found(id);
1153    }
1154
1155    step_to_sibling(scope, from, reverse, is_root)
1156}
1157
1158/// Step from entry `from` to the next/previous *non-empty* sibling, applying
1159/// the boundary policy (wrap vs. escape) and entering the chosen entry from
1160/// its near edge. Bounded to one full lap.
1161fn step_to_sibling(scope: &ScopeNode, from: usize, reverse: bool, is_root: bool) -> StepResult {
1162    let n = scope.entries.len();
1163    let mut idx = from;
1164    for _ in 0..n {
1165        let next = if reverse {
1166            if idx == 0 {
1167                if scope_wraps(scope.policy, is_root) {
1168                    n - 1
1169                } else {
1170                    return StepResult::Escaped;
1171                }
1172            } else {
1173                idx - 1
1174            }
1175        } else if idx + 1 >= n {
1176            if scope_wraps(scope.policy, is_root) {
1177                0
1178            } else {
1179                return StepResult::Escaped;
1180            }
1181        } else {
1182            idx + 1
1183        };
1184
1185        match &scope.entries[next].kind {
1186            ScopeEntryKind::Focusable(id) => return StepResult::Found(*id),
1187            ScopeEntryKind::Scope(child) => {
1188                if let Some(id) = enter_scope_edge(child, reverse) {
1189                    return StepResult::Found(id);
1190                }
1191                // Empty nested scope: keep stepping past it.
1192                idx = next;
1193            }
1194        }
1195    }
1196    StepResult::Escaped
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201    use super::*;
1202    use crate::test_widgets::FillWidget;
1203    use crate::widget_builder::WidgetBuilder;
1204
1205    /// **A reveal walks the rect's owner's ancestors, not the asker's.**
1206    ///
1207    /// The default — walk from whoever handled the event — is right whenever a widget
1208    /// reveals something inside itself, and silently wrong the moment the rect belongs
1209    /// elsewhere. A find banner's Next button sits *beside* the scrolling page, so a walk
1210    /// from the button climbs out through the banner and never meets the scroll container
1211    /// the match is in: the match is selected, the counter moves, and the viewport does
1212    /// not follow. Naming the owner is what fixes that, and this pins both halves.
1213    #[test]
1214    fn a_reveal_walks_the_ancestors_of_whoever_owns_the_rect() {
1215        use crate::event::{EventResponse, ScrollAlign, ScrollMotion};
1216        use std::cell::Cell;
1217        use std::rc::Rc;
1218
1219        let asked = Rc::new(Cell::new(0usize));
1220        let mut tree = WidgetTree::new();
1221        // A clipping container that records every reveal it is asked for.
1222        let viewport = {
1223            let asked = asked.clone();
1224            tree.add(
1225                // `on_scroll` first: `clips_children` is also a `Widget` trait *method*,
1226                // so calling it on the bare widget resolves to the getter.
1227                FillWidget::new()
1228                    .on_scroll(move |event, _ctx| {
1229                        if matches!(event, WidgetEvent::ScrollIntoView { .. }) {
1230                            asked.set(asked.get() + 1);
1231                        }
1232                        EventResponse::Ignored
1233                    })
1234                    .clips_children(true),
1235            )
1236        };
1237        let inside = tree.add_child(viewport, FillWidget::new());
1238        // The "button": a sibling of the viewport, not a descendant — the banner's shape.
1239        let button = tree.add(FillWidget::new());
1240        tree.layout(SizeProposal::exact(400.0, 300.0));
1241
1242        // Far below the viewport, so a container that hears about it must scroll.
1243        let target = Rect::new(0.0, 5_000.0, 10.0, 10.0);
1244        let mut ops = crate::window::NoopWindowOps;
1245
1246        tree.scroll_rect_into_view(
1247            button,
1248            target,
1249            0.0,
1250            ScrollAlign::Minimal,
1251            ScrollMotion::Instant,
1252            &mut ops,
1253        );
1254        assert_eq!(
1255            asked.get(),
1256            0,
1257            "walking from the asker never reaches a viewport it is not inside"
1258        );
1259
1260        tree.scroll_rect_into_view(
1261            inside,
1262            target,
1263            0.0,
1264            ScrollAlign::Minimal,
1265            ScrollMotion::Instant,
1266            &mut ops,
1267        );
1268        assert_eq!(
1269            asked.get(),
1270            1,
1271            "walking from the rect's owner reaches the viewport enclosing it"
1272        );
1273    }
1274
1275    /// A `clips_children` container around `child`, recording every reveal it
1276    /// is asked for and the rectangle it was asked with.
1277    fn recorder(
1278        tree: &mut WidgetTree,
1279        child: WidgetId,
1280        seen: std::rc::Rc<std::cell::RefCell<Vec<Rect>>>,
1281    ) -> WidgetId {
1282        use crate::event::EventResponse;
1283        use crate::test_widgets::StackWidget;
1284        tree.add(
1285            StackWidget::new()
1286                .child(child)
1287                .on_scroll(move |event, _ctx| {
1288                    if let WidgetEvent::ScrollIntoView { target_bounds, .. } = event {
1289                        seen.borrow_mut().push(*target_bounds);
1290                    }
1291                    EventResponse::Ignored
1292                })
1293                .clips_children(true),
1294        )
1295    }
1296
1297    /// **The reveal walk crosses a content transform.**
1298    ///
1299    /// A `SceneView` is a fixed viewport over content in a coordinate system of
1300    /// its own: a card at scene (100, 100) keeps arena bounds of (100, 100)
1301    /// however far the camera has panned. The walk used to compare that
1302    /// rectangle directly against the view's *window* viewport and conclude
1303    /// there was nothing to reveal — which is why focus-follow inside an
1304    /// embedded editor never fired at a non-zero pan, and why anything further
1305    /// out then received a rectangle from a coordinate system it had never
1306    /// heard of.
1307    #[test]
1308    fn a_reveal_projects_the_rect_as_it_climbs_out_of_a_content_transform() {
1309        use crate::event::{ScrollAlign, ScrollMotion};
1310        use std::cell::RefCell;
1311        use std::rc::Rc;
1312        use teksilo_canvas::Transform2D;
1313
1314        let outer_seen: Rc<RefCell<Vec<Rect>>> = Rc::new(RefCell::new(Vec::new()));
1315        let view_seen: Rc<RefCell<Vec<Rect>>> = Rc::new(RefCell::new(Vec::new()));
1316
1317        let mut tree = WidgetTree::new();
1318        let card = tree.add(FillWidget::new());
1319        let view = recorder(&mut tree, card, view_seen.clone());
1320        let _outer = recorder(&mut tree, view, outer_seen.clone());
1321        tree.layout(SizeProposal::exact(400.0, 300.0));
1322        // The camera: panned 90 px left, so scene x = 100 paints at window
1323        // x = 10 — inside the viewport, and not where the arena says it is.
1324        tree.set_content_transform(view, Transform2D::translate(-90.0, 0.0));
1325
1326        let card_rect = Rect::new(100.0, 100.0, 50.0, 50.0);
1327        let mut ops = crate::window::NoopWindowOps;
1328        tree.scroll_rect_into_view(
1329            card,
1330            card_rect,
1331            0.0,
1332            ScrollAlign::Minimal,
1333            ScrollMotion::Instant,
1334            &mut ops,
1335        );
1336
1337        assert!(
1338            view_seen.borrow().is_empty(),
1339            "the card paints at window x = 10, inside the 400×300 viewport — \
1340             comparing its scene rect against the viewport is what used to \
1341             produce a spurious verdict here, in either direction"
1342        );
1343        assert!(
1344            outer_seen.borrow().is_empty(),
1345            "and nothing outside it has a reason to scroll either"
1346        );
1347
1348        // Now pan so the card is genuinely off-screen to the left. Its arena
1349        // bounds have not moved — only the camera has — so a walk that ignores
1350        // the transform still sees a card at (100, 100) and does nothing.
1351        tree.set_content_transform(view, Transform2D::translate(-600.0, 0.0));
1352        tree.scroll_rect_into_view(
1353            card,
1354            card_rect,
1355            0.0,
1356            ScrollAlign::Minimal,
1357            ScrollMotion::Instant,
1358            &mut ops,
1359        );
1360        assert_eq!(
1361            view_seen.borrow().as_slice(),
1362            &[card_rect],
1363            "the view is asked to reveal the card, in the view's own content \
1364             space — the space `SceneView::ensure_visible` takes by signature"
1365        );
1366        assert_eq!(
1367            outer_seen.borrow().as_slice(),
1368            &[Rect::new(-500.0, 100.0, 50.0, 50.0)],
1369            "and everything further out is asked in window space, which is the \
1370             only space it can compare against its own viewport"
1371        );
1372    }
1373
1374    /// The control for the test above: with no transform in the chain the walk
1375    /// is unchanged, which is the case the whole rest of the framework is.
1376    #[test]
1377    fn a_reveal_with_no_transform_in_the_chain_passes_the_rect_through() {
1378        use crate::event::{ScrollAlign, ScrollMotion};
1379        use std::cell::RefCell;
1380        use std::rc::Rc;
1381
1382        let seen: Rc<RefCell<Vec<Rect>>> = Rc::new(RefCell::new(Vec::new()));
1383        let mut tree = WidgetTree::new();
1384        let inside = tree.add(FillWidget::new());
1385        let _viewport = recorder(&mut tree, inside, seen.clone());
1386        tree.layout(SizeProposal::exact(400.0, 300.0));
1387
1388        let target = Rect::new(0.0, 5_000.0, 10.0, 10.0);
1389        let mut ops = crate::window::NoopWindowOps;
1390        tree.scroll_rect_into_view(
1391            inside,
1392            target,
1393            0.0,
1394            ScrollAlign::Minimal,
1395            ScrollMotion::Instant,
1396            &mut ops,
1397        );
1398        assert_eq!(seen.borrow().as_slice(), &[target]);
1399    }
1400
1401    #[test]
1402    fn focus_widget() {
1403        let mut tree = WidgetTree::new();
1404        let widget = tree.add(FillWidget::new());
1405        tree.layout(SizeProposal::exact(100.0, 50.0));
1406        tree.focus(widget);
1407        assert_eq!(tree.focused(), Some(widget));
1408    }
1409
1410    #[test]
1411    fn focus_change() {
1412        let mut tree = WidgetTree::new();
1413        let a = tree.add(FillWidget::new());
1414        let b = tree.add(FillWidget::new());
1415        tree.layout(SizeProposal::exact(100.0, 50.0));
1416        tree.focus(a);
1417        assert_eq!(tree.focused(), Some(a));
1418        tree.focus(b);
1419        assert_eq!(tree.focused(), Some(b));
1420    }
1421
1422    /// A container that rebuilds its focusable children whenever `epoch` bumps
1423    /// — the shape of every data-driven view: a `ListView` on a model update, a
1424    /// popover re-scanning its content when it opens.
1425    #[derive(Debug)]
1426    struct RebuildingRows {
1427        epoch: crate::signal::Signal<u64>,
1428        rows: Vec<WidgetId>,
1429    }
1430
1431    impl Widget for RebuildingRows {
1432        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1433            let sid = ctx.self_id();
1434            let reg = ctx.binding_registry();
1435            self.epoch
1436                .bind_to(sid, reg, crate::binding::BindingLevel::Rebuild);
1437            self.rows = (0..3)
1438                .map(|_| ctx.add(FillWidget::new().focusable()))
1439                .collect();
1440            self.rows.clone()
1441        }
1442
1443        fn layout_response(
1444            &self,
1445            proposal: SizeProposal,
1446            _ctx: &LayoutContext,
1447        ) -> crate::widget::LayoutResponse {
1448            proposal.resolve(0.0, 0.0).into()
1449        }
1450
1451        fn children(&self) -> Vec<WidgetId> {
1452            self.rows.clone()
1453        }
1454    }
1455
1456    /// A rebuild must keep focus **inside the subtree that owned it**.
1457    ///
1458    /// A rebuild destroys its children and allocates fresh `WidgetId`s, so the
1459    /// focused node dies. Dropping focus to `None` there (what
1460    /// `revalidate_interaction_state` does with any dead focus) kicks the user
1461    /// clean out of the widget they were in — most visibly, a popover that
1462    /// refreshes its content when it opens would throw away the very row the
1463    /// popover had just focused, and the menu would come up with no keyboard
1464    /// focus at all: no arrow keys, no Enter.
1465    #[test]
1466    fn a_rebuild_keeps_focus_inside_the_subtree_that_had_it() {
1467        let mut tree = WidgetTree::new();
1468        let epoch = crate::signal::Signal::new(0u64);
1469        let root = tree.add(RebuildingRows {
1470            epoch: epoch.clone(),
1471            rows: Vec::new(),
1472        });
1473        tree.layout(SizeProposal::exact(100.0, 60.0));
1474
1475        let first_row = tree
1476            .first_focusable_descendant(root)
1477            .expect("the rows are focusable");
1478        tree.focus(first_row);
1479        assert_eq!(tree.focused(), Some(first_row));
1480
1481        // Rebuild: every row is destroyed and re-allocated.
1482        epoch.set(1);
1483        tree.layout(SizeProposal::exact(100.0, 60.0));
1484
1485        let focused = tree
1486            .focused()
1487            .expect("a rebuild must not drop focus out of the rebuilt subtree");
1488        assert_ne!(
1489            focused, first_row,
1490            "the old row is dead — focus must have moved to a freshly built one"
1491        );
1492        assert!(
1493            tree.is_descendant_of(focused, root),
1494            "focus must land back inside the rebuilt subtree"
1495        );
1496        assert!(tree.is_active(focused), "the focused node must be live");
1497    }
1498
1499    /// The restore is scoped: a rebuild that did *not* own focus must leave
1500    /// focus exactly where it was, rather than yanking it into the rebuilt
1501    /// subtree. Otherwise any background list update would steal the caret out
1502    /// of whatever the user was typing in.
1503    #[test]
1504    fn a_rebuild_elsewhere_does_not_steal_focus() {
1505        let mut tree = WidgetTree::new();
1506        let epoch = crate::signal::Signal::new(0u64);
1507        let outsider = tree.add(FillWidget::new().focusable());
1508        let _rows = tree.add(RebuildingRows {
1509            epoch: epoch.clone(),
1510            rows: Vec::new(),
1511        });
1512        tree.layout(SizeProposal::exact(100.0, 60.0));
1513
1514        tree.focus(outsider);
1515        assert_eq!(tree.focused(), Some(outsider));
1516
1517        epoch.set(1);
1518        tree.layout(SizeProposal::exact(100.0, 60.0));
1519
1520        assert_eq!(
1521            tree.focused(),
1522            Some(outsider),
1523            "a rebuild that never held focus must not pull focus into itself"
1524        );
1525    }
1526
1527    /// A reconciling container: it builds its groups once and re-attaches the
1528    /// same ones on every later build, the shape of a toast host keeping one
1529    /// surface per live toast, or a tab widget keeping its pages.
1530    #[derive(Debug)]
1531    struct KeepingGroups {
1532        epoch: crate::signal::Signal<u64>,
1533        group_epochs: Vec<crate::signal::Signal<u64>>,
1534        groups: Vec<WidgetId>,
1535    }
1536
1537    impl Widget for KeepingGroups {
1538        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1539            let sid = ctx.self_id();
1540            let reg = ctx.binding_registry();
1541            self.epoch
1542                .bind_to(sid, reg, crate::binding::BindingLevel::Rebuild);
1543            if self.groups.is_empty() {
1544                self.groups = self
1545                    .group_epochs
1546                    .iter()
1547                    .map(|epoch| {
1548                        ctx.add(RebuildingRows {
1549                            epoch: epoch.clone(),
1550                            rows: Vec::new(),
1551                        })
1552                    })
1553                    .collect();
1554            }
1555            self.groups.clone()
1556        }
1557
1558        fn layout_response(
1559            &self,
1560            proposal: SizeProposal,
1561            _ctx: &LayoutContext,
1562        ) -> crate::widget::LayoutResponse {
1563            proposal.resolve(0.0, 0.0).into()
1564        }
1565
1566        fn children(&self) -> Vec<WidgetId> {
1567            self.groups.clone()
1568        }
1569
1570        fn preserves_children_on_rebuild(&self) -> bool {
1571            true
1572        }
1573    }
1574
1575    /// When the focused node dies in a rebuild, focus goes back into the
1576    /// **innermost** ancestor that survived it, not into the outermost root
1577    /// that was rebuilt.
1578    ///
1579    /// A reconciling container keeps its children across its own rebuild, so
1580    /// the group that held focus is still there when one of its rows is
1581    /// replaced. Restoring into the container's first focusable descendant
1582    /// put a keyboard user in the *first* group whatever group they were in:
1583    /// in a toast stack that is the oldest toast, so a reader on the Cancel
1584    /// of a progress toast was thrown onto another toast at every step.
1585    #[test]
1586    fn a_rebuild_restores_focus_into_the_innermost_ancestor_that_survived() {
1587        let mut tree = WidgetTree::new();
1588        let epoch = crate::signal::Signal::new(0u64);
1589        let group_epochs = vec![
1590            crate::signal::Signal::new(0u64),
1591            crate::signal::Signal::new(0u64),
1592        ];
1593        let root = tree.add(KeepingGroups {
1594            epoch: epoch.clone(),
1595            group_epochs: group_epochs.clone(),
1596            groups: Vec::new(),
1597        });
1598        tree.layout(SizeProposal::exact(100.0, 60.0));
1599
1600        let groups = tree.children(root);
1601        let second_group = groups[1];
1602        let row = tree
1603            .first_focusable_descendant(second_group)
1604            .expect("the second group's rows are focusable");
1605        tree.focus(row);
1606
1607        // The container and the second group rebuild in the same pass: the
1608        // group survives (the container re-attaches it), the row does not.
1609        epoch.set(1);
1610        group_epochs[1].set(1);
1611        tree.layout(SizeProposal::exact(100.0, 60.0));
1612
1613        assert_eq!(
1614            tree.children(root),
1615            groups,
1616            "the container kept both groups (the premise of this test)"
1617        );
1618        let focused = tree.focused().expect("focus must not be dropped");
1619        assert_ne!(focused, row, "the old row is dead");
1620        assert!(
1621            tree.is_descendant_of(focused, second_group),
1622            "focus must land back in the group that held it, the innermost \
1623             ancestor that survived, not in the first group of the container"
1624        );
1625    }
1626
1627    #[test]
1628    fn first_focusable_descendant_prefers_first_focusable_child() {
1629        let mut tree = WidgetTree::new();
1630        let a = tree.add(FillWidget::new().focusable());
1631        let _not_focusable = tree.add(FillWidget::new());
1632        let b = tree.add(FillWidget::new().focusable());
1633        let root = tree.add(crate::test_widgets::StackWidget::new().child(a).child(b));
1634        tree.layout(SizeProposal::exact(100.0, 50.0));
1635
1636        assert_eq!(tree.first_focusable_descendant(root), Some(a));
1637    }
1638
1639    #[test]
1640    fn tab_cycles_through_focusable_widgets() {
1641        let mut tree = WidgetTree::new();
1642        let a = tree.add(FillWidget::new().focusable());
1643        let b = tree.add(FillWidget::new().focusable());
1644        let c = tree.add(FillWidget::new().focusable());
1645        tree.layout(SizeProposal::exact(100.0, 50.0));
1646
1647        assert_eq!(tree.focused(), None);
1648
1649        tree.press_key(Key::Tab, Modifiers::NONE);
1650        assert_eq!(tree.focused(), Some(a));
1651
1652        tree.press_key(Key::Tab, Modifiers::NONE);
1653        assert_eq!(tree.focused(), Some(b));
1654
1655        tree.press_key(Key::Tab, Modifiers::NONE);
1656        assert_eq!(tree.focused(), Some(c));
1657
1658        tree.press_key(Key::Tab, Modifiers::NONE);
1659        assert_eq!(tree.focused(), Some(a));
1660    }
1661
1662    #[test]
1663    fn tab_stop_on_composite_excludes_focusable_descendant() {
1664        // The roving-tabindex case: a composite control (here a StackWidget
1665        // standing in for ComboBox / IconButton) carries the `tab_stop` flag
1666        // on its composing node, but its real focusable node is an inner
1667        // leaf. Suppressing the composite must remove that leaf from Tab.
1668        let mut tree = WidgetTree::new();
1669        let leaf = tree.add(FillWidget::new().focusable());
1670        let composite = tree.add(crate::test_widgets::StackWidget::new().child(leaf));
1671        let other = tree.add(FillWidget::new().focusable());
1672        tree.layout(SizeProposal::exact(100.0, 50.0));
1673
1674        tree.set_tab_stop(composite, false);
1675
1676        tree.press_key(Key::Tab, Modifiers::NONE);
1677        assert_eq!(
1678            tree.focused(),
1679            Some(other),
1680            "Tab must skip the suppressed composite's inner leaf"
1681        );
1682        tree.press_key(Key::Tab, Modifiers::NONE);
1683        assert_eq!(
1684            tree.focused(),
1685            Some(other),
1686            "only the un-suppressed control participates in Tab"
1687        );
1688
1689        // Re-enabling the composite brings its inner leaf back into Tab.
1690        tree.set_tab_stop(composite, true);
1691        tree.set_focused(None);
1692        tree.press_key(Key::Tab, Modifiers::NONE);
1693        let first = tree.focused();
1694        tree.press_key(Key::Tab, Modifiers::NONE);
1695        let second = tree.focused();
1696        assert_ne!(
1697            first, second,
1698            "with the composite re-enabled, Tab visits both controls"
1699        );
1700    }
1701
1702    #[test]
1703    fn shift_tab_cycles_backwards() {
1704        let mut tree = WidgetTree::new();
1705        let a = tree.add(FillWidget::new().focusable());
1706        let b = tree.add(FillWidget::new().focusable());
1707        let c = tree.add(FillWidget::new().focusable());
1708        tree.layout(SizeProposal::exact(100.0, 50.0));
1709
1710        tree.press_key(Key::Tab, Modifiers::NONE);
1711        assert_eq!(tree.focused(), Some(a));
1712
1713        tree.press_key(Key::Tab, Modifiers::SHIFT);
1714        assert_eq!(tree.focused(), Some(c));
1715
1716        tree.press_key(Key::Tab, Modifiers::SHIFT);
1717        assert_eq!(tree.focused(), Some(b));
1718    }
1719
1720    #[test]
1721    fn tab_skips_non_focusable_widgets() {
1722        let mut tree = WidgetTree::new();
1723        let _not_focusable = tree.add(FillWidget::new());
1724        let a = tree.add(FillWidget::new().focusable());
1725        let _also_not = tree.add(FillWidget::new());
1726        let b = tree.add(FillWidget::new().focusable());
1727        tree.layout(SizeProposal::exact(100.0, 50.0));
1728
1729        tree.press_key(Key::Tab, Modifiers::NONE);
1730        assert_eq!(tree.focused(), Some(a));
1731
1732        tree.press_key(Key::Tab, Modifiers::NONE);
1733        assert_eq!(tree.focused(), Some(b));
1734    }
1735
1736    /// `focus` is a command, not a query: it moves focus to the node it is
1737    /// handed without asking whether traversal could ever land there.
1738    ///
1739    /// This is why a test claiming **keyboard reachability** has to read the
1740    /// traversal graph — `tab_stops_within` — rather than focus its subject and
1741    /// press a key. The latter is green for a control no keyboard user can
1742    /// reach, which is how a widget once lost `focusable(true)` with all six of
1743    /// its tests still passing. If this behaviour ever grows a guard, the
1744    /// reachability recipe in `docs/a11y/non-drag-alternatives.md` and the
1745    /// comments citing it are what to revisit.
1746    #[test]
1747    fn focus_does_not_check_that_the_node_is_focusable() {
1748        let mut tree = WidgetTree::new();
1749        let inert = tree.add(FillWidget::new()); // no `.focusable()`
1750        tree.layout(SizeProposal::exact(100.0, 50.0));
1751        assert!(
1752            tree.tab_stops_within(inert).is_empty(),
1753            "the subject has to be unreachable for the point to be made"
1754        );
1755
1756        tree.focus(inert);
1757        assert_eq!(
1758            tree.focused(),
1759            Some(inert),
1760            "focus lands on a node Tab traversal can never offer"
1761        );
1762    }
1763
1764    #[test]
1765    fn tab_focus_has_keyboard_origin() {
1766        let mut tree = WidgetTree::new();
1767        tree.add(FillWidget::new().focusable());
1768        tree.layout(SizeProposal::exact(100.0, 50.0));
1769
1770        tree.press_key(Key::Tab, Modifiers::NONE);
1771        assert_eq!(
1772            tree.focus_origin(),
1773            Some(crate::focus::FocusOrigin::Keyboard)
1774        );
1775    }
1776
1777    #[test]
1778    fn tab_skips_focusable_inside_disabled_ancestor() {
1779        use crate::signal::Signal;
1780        use crate::test_widgets::StackWidget;
1781
1782        let mut tree = WidgetTree::new();
1783        let a = tree.add(FillWidget::new().focusable());
1784        let inner = tree.add(FillWidget::new().focusable());
1785        let disabled_container = tree.add(StackWidget::new().child(inner));
1786        let c = tree.add(FillWidget::new().focusable());
1787        tree.enabled_when(disabled_container, Signal::new(false));
1788        tree.layout(SizeProposal::exact(200.0, 100.0));
1789
1790        tree.press_key(Key::Tab, Modifiers::NONE);
1791        assert_eq!(tree.focused(), Some(a));
1792
1793        tree.press_key(Key::Tab, Modifiers::NONE);
1794        assert_eq!(
1795            tree.focused(),
1796            Some(c),
1797            "tab should skip the focusable widget nested inside the disabled container"
1798        );
1799    }
1800
1801    #[test]
1802    fn dormant_widget_not_in_focus_cycle() {
1803        let mut tree = WidgetTree::new();
1804        let a = tree.add(FillWidget::new().focusable());
1805        let b = tree.add(FillWidget::new().focusable());
1806        let c = tree.add(FillWidget::new().focusable());
1807        tree.layout(SizeProposal::exact(200.0, 100.0));
1808
1809        tree.focus(a);
1810        tree.set_dormant(b);
1811
1812        tree.press_key(Key::Tab, Modifiers::NONE);
1813        assert_eq!(tree.focused(), Some(c));
1814    }
1815
1816    /// Parking a focused widget dormant (Switcher / `visible_when`) must
1817    /// deliver `FocusLost` so the widget clears local focus state. Without
1818    /// that, a rich-text editor keeps `has_focus` and schedules caret wakes
1819    /// forever — the multi-tab CPU creep Skribisto hit on rapid tab switches.
1820    #[test]
1821    fn revalidate_delivers_focus_lost_when_focused_widget_goes_dormant() {
1822        use std::cell::Cell;
1823        use std::rc::Rc;
1824
1825        let lost = Rc::new(Cell::new(0_u32));
1826        let gained = Rc::new(Cell::new(0_u32));
1827        let lost_c = lost.clone();
1828        let gained_c = gained.clone();
1829
1830        let mut tree = WidgetTree::new();
1831        let editor = tree.add(
1832            FillWidget::new()
1833                .focusable()
1834                .on_focus(move |is_gained, _ctx| {
1835                    if is_gained {
1836                        gained_c.set(gained_c.get() + 1);
1837                    } else {
1838                        lost_c.set(lost_c.get() + 1);
1839                    }
1840                }),
1841        );
1842        let _other = tree.add(FillWidget::new().focusable());
1843        tree.layout(SizeProposal::exact(200.0, 100.0));
1844
1845        tree.focus(editor);
1846        assert_eq!(tree.focused(), Some(editor));
1847        assert_eq!(gained.get(), 1, "focus() delivers FocusGained");
1848        assert_eq!(lost.get(), 0);
1849
1850        // Park the focused editor dormant — the Switcher / tab-switch path.
1851        tree.set_dormant(editor);
1852        // Revalidate is what layout runs after the visibility pass.
1853        let mut noop = crate::window::NoopWindowOps;
1854        tree.revalidate_interaction_state(&mut noop);
1855
1856        assert_eq!(
1857            tree.focused(),
1858            None,
1859            "tree focus must clear when the target is dormant"
1860        );
1861        assert_eq!(
1862            lost.get(),
1863            1,
1864            "FocusLost must reach the dormant widget so it can clear has_focus / caret blink"
1865        );
1866    }
1867
1868    #[test]
1869    fn tab_cycles_focus_in_tree_order() {
1870        let mut tree = WidgetTree::new();
1871        let a = tree.add(FillWidget::new().focusable());
1872        let b = tree.add(FillWidget::new().focusable());
1873        tree.layout(SizeProposal::exact(200.0, 80.0));
1874
1875        tree.focus(a);
1876        assert_eq!(tree.focused(), Some(a));
1877
1878        tree.press_key(Key::Tab, Modifiers::NONE);
1879        assert_eq!(tree.focused(), Some(b));
1880
1881        tree.press_key(Key::Tab, Modifiers::SHIFT);
1882        assert_eq!(tree.focused(), Some(a));
1883    }
1884
1885    #[test]
1886    fn focus_survives_theme_switch() {
1887        let mut tree = WidgetTree::new();
1888        let a = tree.add(FillWidget::new().focusable());
1889        let _b = tree.add(FillWidget::new().focusable());
1890        tree.layout(SizeProposal::exact(200.0, 80.0));
1891
1892        tree.focus(a);
1893        assert_eq!(tree.focused(), Some(a));
1894
1895        tree.set_theme(crate::presets::intui::dark());
1896        tree.layout(SizeProposal::exact(200.0, 80.0));
1897
1898        assert_eq!(
1899            tree.focused(),
1900            Some(a),
1901            "theme switch must not clobber focus"
1902        );
1903    }
1904
1905    #[test]
1906    fn focus_survives_locale_switch() {
1907        let mut tree = WidgetTree::new();
1908        let a = tree.add(FillWidget::new().focusable());
1909        tree.layout(SizeProposal::exact(200.0, 80.0));
1910
1911        tree.focus(a);
1912        tree.set_locale("fr-FR".to_string());
1913        tree.layout(SizeProposal::exact(200.0, 80.0));
1914
1915        assert_eq!(
1916            tree.focused(),
1917            Some(a),
1918            "locale switch must not clobber focus"
1919        );
1920    }
1921
1922    // ── focus_within / hover_within ─────────────────────────────
1923
1924    #[test]
1925    fn focus_within_flips_when_descendant_takes_focus() {
1926        use crate::signal::Signal;
1927        use crate::test_widgets::StackWidget;
1928        use crate::widget_builder::WidgetBuilder;
1929
1930        let halo = Signal::new(false);
1931
1932        let mut tree = WidgetTree::new();
1933        let leaf = tree.add(FillWidget::new().focusable());
1934        let mid = tree.add(StackWidget::new().child(leaf));
1935        let _outer = tree.add(StackWidget::new().child(mid).focus_within(halo.clone()));
1936
1937        tree.layout(SizeProposal::exact(100.0, 50.0));
1938        assert!(!halo.get(), "no focus yet → signal is false");
1939
1940        tree.focus(leaf);
1941        assert!(halo.get(), "leaf now focused, outer is its strict ancestor");
1942    }
1943
1944    #[test]
1945    fn focus_within_strict_ancestors_only() {
1946        // A widget that is itself focused must NOT see its own
1947        // focus_within signal flipped to true.
1948        use crate::signal::Signal;
1949        use crate::widget_builder::WidgetBuilder;
1950
1951        let halo = Signal::new(false);
1952
1953        let mut tree = WidgetTree::new();
1954        let widget = tree.add(FillWidget::new().focusable().focus_within(halo.clone()));
1955        tree.layout(SizeProposal::exact(100.0, 50.0));
1956        tree.focus(widget);
1957
1958        assert!(
1959            !halo.get(),
1960            "focusing the widget itself must not set its own focus_within"
1961        );
1962    }
1963
1964    #[test]
1965    fn view_focus_active_is_inclusive_for_the_view_itself() {
1966        // A non-focusable item inside a focusable "view" reads the view's scope
1967        // focus: true when the view OR a descendant holds focus (inclusive),
1968        // unlike `focus_within` (strict descendants only). This is what powers
1969        // focus-aware selection — the data view holds focus directly, yet its
1970        // selected rows must still render "active".
1971        use crate::test_widgets::StackWidget;
1972        use crate::widget_builder::WidgetBuilder;
1973
1974        let mut tree = WidgetTree::new();
1975        let item = tree.add(FillWidget::new()); // a row item — NOT focusable
1976        let view = tree.add(StackWidget::new().child(item).focusable(true));
1977        let outside = tree.add(FillWidget::new().focusable());
1978        tree.layout(SizeProposal::exact(100.0, 50.0));
1979
1980        let active = tree.view_focus_active_for(item);
1981        assert!(!active.get(), "no focus yet → scope inactive");
1982
1983        tree.focus(view);
1984        assert!(
1985            active.get(),
1986            "view focused directly → scope active (focus_within would be false here)"
1987        );
1988
1989        tree.focus(outside);
1990        assert!(
1991            !active.get(),
1992            "focus moved outside the view → scope inactive"
1993        );
1994    }
1995
1996    #[test]
1997    fn begin_view_focus_for_keys_on_the_view_root_not_the_building_pane() {
1998        // TableView / TreeTableView / GridView build their rows inside a
1999        // separate, non-focusable body *pane*; keyboard focus lands on the
2000        // focusable view *root* (the pane's ancestor). A row's focus scope must
2001        // therefore be keyed on the root via `begin_view_focus_for(root)`, so
2002        // its selection reads "active" when the view is focused. Keying on the
2003        // pane — which never holds focus and is not an ancestor of the focused
2004        // root — would read constant-false (the latent bug this fixes).
2005        use crate::test_widgets::StackWidget;
2006        use crate::widget_builder::WidgetBuilder;
2007
2008        let mut tree = WidgetTree::new();
2009        let row = tree.add(FillWidget::new()); // row item — NOT focusable
2010        let pane = tree.add(StackWidget::new().child(row)); // body pane — NOT focusable
2011        let root = tree.add(StackWidget::new().child(pane).focusable(true));
2012        tree.layout(SizeProposal::exact(100.0, 50.0));
2013
2014        // What the pane opens for its rows: a scope keyed on the root.
2015        let keyed_on_root = tree.begin_view_focus(root);
2016        tree.end_view_focus();
2017        // What keying on the pane itself would have produced (the bug).
2018        let keyed_on_pane = tree.view_focus_signal_for(pane);
2019
2020        assert!(!keyed_on_root.get(), "no focus yet → inactive");
2021        tree.focus(root);
2022        assert!(keyed_on_root.get(), "view root focused → row scope active");
2023        assert!(
2024            !keyed_on_pane.get(),
2025            "pane-keyed scope stays false: the focused root is the pane's ancestor, not its descendant",
2026        );
2027    }
2028
2029    #[test]
2030    fn focus_visible_tracks_input_modality() {
2031        // `:focus-visible` — keyboard input reveals focus rings, pointer input
2032        // hides them. The recipe gates the row focus ring on this signal.
2033        use crate::event::{Key, Modifiers};
2034        let mut tree = WidgetTree::new();
2035        let w = tree.add(FillWidget::new().focusable());
2036        tree.layout(SizeProposal::exact(100.0, 50.0));
2037        let vis = tree.focus_visible_signal();
2038        assert!(!vis.get(), "starts not focus-visible");
2039
2040        tree.press_key(Key::Tab, Modifiers::NONE);
2041        assert!(vis.get(), "keyboard input turns focus-visible ON");
2042
2043        tree.click(w);
2044        assert!(!vis.get(), "pointer input turns focus-visible OFF");
2045
2046        tree.press_key(Key::ArrowDown, Modifiers::NONE);
2047        assert!(vis.get(), "keyboard input turns it back ON");
2048    }
2049
2050    #[test]
2051    fn view_focus_active_without_focusable_ancestor_is_constant_true() {
2052        // An item with no focusable ancestor (e.g. a static list) always reads
2053        // active, so its selection chrome is never muted.
2054        let mut tree = WidgetTree::new();
2055        let item = tree.add(FillWidget::new());
2056        tree.layout(SizeProposal::exact(100.0, 50.0));
2057        assert!(tree.view_focus_active_for(item).get());
2058    }
2059
2060    #[test]
2061    fn focus_within_diff_across_siblings() {
2062        // Tree: root → mid_a [sig_a] → leaf_a, root → mid_b [sig_b] → leaf_b.
2063        // Move focus from leaf_a to leaf_b: sig_a → false, sig_b → true.
2064        use crate::signal::Signal;
2065        use crate::test_widgets::StackWidget;
2066        use crate::widget_builder::WidgetBuilder;
2067
2068        let sig_a = Signal::new(false);
2069        let sig_b = Signal::new(false);
2070
2071        let mut tree = WidgetTree::new();
2072        let leaf_a = tree.add(FillWidget::new().focusable());
2073        let leaf_b = tree.add(FillWidget::new().focusable());
2074        let mid_a = tree.add(StackWidget::new().child(leaf_a).focus_within(sig_a.clone()));
2075        let mid_b = tree.add(StackWidget::new().child(leaf_b).focus_within(sig_b.clone()));
2076        let _root = tree.add(StackWidget::new().child(mid_a).child(mid_b));
2077
2078        tree.layout(SizeProposal::exact(100.0, 50.0));
2079        tree.focus(leaf_a);
2080        assert!(sig_a.get(), "sig_a true after focusing leaf_a");
2081        assert!(!sig_b.get(), "sig_b false: leaf_a is not its descendant");
2082
2083        tree.focus(leaf_b);
2084        assert!(!sig_a.get(), "sig_a flipped to false on focus move");
2085        assert!(sig_b.get(), "sig_b flipped to true on focus move");
2086    }
2087
2088    #[test]
2089    fn focus_within_clears_when_focused_widget_destroyed() {
2090        use crate::signal::Signal;
2091        use crate::test_widgets::StackWidget;
2092        use crate::widget_builder::WidgetBuilder;
2093
2094        let halo = Signal::new(false);
2095
2096        let mut tree = WidgetTree::new();
2097        let leaf = tree.add(FillWidget::new().focusable());
2098        let _outer = tree.add(StackWidget::new().child(leaf).focus_within(halo.clone()));
2099
2100        tree.layout(SizeProposal::exact(100.0, 50.0));
2101        tree.focus(leaf);
2102        assert!(halo.get());
2103
2104        tree.destroy_subtree(leaf);
2105        assert!(
2106            !halo.get(),
2107            "destroying the focused widget must clear focus_within on its ancestors"
2108        );
2109    }
2110
2111    #[test]
2112    fn hover_within_flips_via_pointer_move() {
2113        use crate::signal::Signal;
2114        use crate::test_widgets::StackWidget;
2115        use crate::widget_builder::WidgetBuilder;
2116        use teksilo_canvas::Point;
2117
2118        let glow = Signal::new(false);
2119
2120        let mut tree = WidgetTree::new();
2121        let leaf = tree.add(FillWidget::new());
2122        let _outer = tree.add(StackWidget::new().child(leaf).hover_within(glow.clone()));
2123
2124        tree.layout(SizeProposal::exact(100.0, 50.0));
2125        assert!(!glow.get());
2126
2127        tree.pointer_move(Point::new(50.0, 25.0));
2128        assert!(glow.get(), "pointer over leaf → outer.hover_within = true");
2129
2130        tree.pointer_move(Point::new(500.0, 500.0));
2131        assert!(
2132            !glow.get(),
2133            "pointer moves outside the tree → hover_within clears"
2134        );
2135    }
2136
2137    #[test]
2138    fn hover_within_strict_ancestors_only() {
2139        use crate::signal::Signal;
2140        use crate::widget_builder::WidgetBuilder;
2141        use teksilo_canvas::Point;
2142
2143        let glow = Signal::new(false);
2144
2145        let mut tree = WidgetTree::new();
2146        let _w = tree.add(FillWidget::new().hover_within(glow.clone()));
2147        tree.layout(SizeProposal::exact(100.0, 50.0));
2148        tree.pointer_move(Point::new(50.0, 25.0));
2149
2150        assert!(
2151            !glow.get(),
2152            "hovering the widget itself must not set its own hover_within"
2153        );
2154    }
2155}
2156
2157#[cfg(test)]
2158mod tests_scope {
2159    //! Scope-aware Tab traversal (`FocusScope` / `set_traversal_scope`).
2160    //! These drive the scope marker directly via `WidgetTree::set_traversal_scope`
2161    //! so the algorithm is exercised with no dependency on the widgets crate.
2162
2163    use super::*;
2164    use crate::focus::TraversalScopePolicy::{Continue, Cycle};
2165    use crate::test_widgets::{FillWidget, StackWidget};
2166    use crate::widget_builder::WidgetBuilder;
2167
2168    /// A focusable leaf with an explicit scoped `tab_index`.
2169    fn indexed(tree: &mut WidgetTree, idx: i32) -> WidgetId {
2170        tree.add(FillWidget::new().focusable().tab_index(idx))
2171    }
2172
2173    fn tab(tree: &mut WidgetTree) {
2174        tree.press_key(Key::Tab, Modifiers::NONE);
2175    }
2176    fn shift_tab(tree: &mut WidgetTree) {
2177        tree.press_key(Key::Tab, Modifiers::SHIFT);
2178    }
2179
2180    #[test]
2181    fn overlapping_tab_index_in_sibling_scopes_does_not_interleave() {
2182        let mut tree = WidgetTree::new();
2183        let a1 = indexed(&mut tree, 1);
2184        let a2 = indexed(&mut tree, 2);
2185        let scope_a = tree.add(StackWidget::new().child(a1).child(a2));
2186        let b1 = indexed(&mut tree, 1);
2187        let b2 = indexed(&mut tree, 2);
2188        let scope_b = tree.add(StackWidget::new().child(b1).child(b2));
2189        let _root = tree.add(StackWidget::new().child(scope_a).child(scope_b));
2190        tree.set_traversal_scope(scope_a, Continue);
2191        tree.set_traversal_scope(scope_b, Continue);
2192        tree.layout(SizeProposal::exact(100.0, 100.0));
2193
2194        // Grouped: a1,a2 then b1,b2 — never a1,b1,a2,b2.
2195        for expected in [a1, a2, b1, b2, a1] {
2196            tab(&mut tree);
2197            assert_eq!(tree.focused(), Some(expected));
2198        }
2199    }
2200
2201    #[test]
2202    fn continue_scope_flows_out_at_the_ends() {
2203        // root[A, scopeC(Continue)[c1,c2], B] — no tab_index, DFS order.
2204        let mut tree = WidgetTree::new();
2205        let a = tree.add(FillWidget::new().focusable());
2206        let c1 = tree.add(FillWidget::new().focusable());
2207        let c2 = tree.add(FillWidget::new().focusable());
2208        let scope_c = tree.add(StackWidget::new().child(c1).child(c2));
2209        let b = tree.add(FillWidget::new().focusable());
2210        let _root = tree.add(StackWidget::new().child(a).child(scope_c).child(b));
2211        tree.set_traversal_scope(scope_c, Continue);
2212        tree.layout(SizeProposal::exact(100.0, 100.0));
2213
2214        for expected in [a, c1, c2, b, a] {
2215            tab(&mut tree);
2216            assert_eq!(tree.focused(), Some(expected));
2217        }
2218        // Reverse: from c1, Shift+Tab leaves the scope to A (not c2).
2219        tree.focus(c1);
2220        shift_tab(&mut tree);
2221        assert_eq!(tree.focused(), Some(a));
2222    }
2223
2224    #[test]
2225    fn cycle_scope_wraps_and_never_escapes() {
2226        // root[A, scopeD(Cycle)[d1,d2]].
2227        let mut tree = WidgetTree::new();
2228        let a = tree.add(FillWidget::new().focusable());
2229        let d1 = tree.add(FillWidget::new().focusable());
2230        let d2 = tree.add(FillWidget::new().focusable());
2231        let scope_d = tree.add(StackWidget::new().child(d1).child(d2));
2232        let _root = tree.add(StackWidget::new().child(a).child(scope_d));
2233        tree.set_traversal_scope(scope_d, Cycle);
2234        tree.layout(SizeProposal::exact(100.0, 100.0));
2235
2236        tree.focus(d1);
2237        for _ in 0..10 {
2238            tab(&mut tree);
2239            let f = tree.focused();
2240            assert!(
2241                f == Some(d1) || f == Some(d2),
2242                "Cycle scope must trap Tab inside {{d1,d2}}, got {f:?}"
2243            );
2244        }
2245        // Forward d1→d2→d1 and reverse d1→d2 (wrap at the start).
2246        tree.focus(d1);
2247        tab(&mut tree);
2248        assert_eq!(tree.focused(), Some(d2));
2249        shift_tab(&mut tree);
2250        assert_eq!(tree.focused(), Some(d1));
2251        shift_tab(&mut tree);
2252        assert_eq!(tree.focused(), Some(d2), "Shift+Tab at start wraps to last");
2253    }
2254
2255    #[test]
2256    fn empty_scope_is_skipped() {
2257        // root[A, emptyScope(Continue)[], B].
2258        let mut tree = WidgetTree::new();
2259        let a = tree.add(FillWidget::new().focusable());
2260        let empty = tree.add(StackWidget::new());
2261        let b = tree.add(FillWidget::new().focusable());
2262        let _root = tree.add(StackWidget::new().child(a).child(empty).child(b));
2263        tree.set_traversal_scope(empty, Continue);
2264        tree.layout(SizeProposal::exact(100.0, 100.0));
2265
2266        for expected in [a, b, a] {
2267            tab(&mut tree);
2268            assert_eq!(tree.focused(), Some(expected));
2269        }
2270    }
2271
2272    #[test]
2273    fn single_member_cycle_scope_stays_put() {
2274        let mut tree = WidgetTree::new();
2275        let e = tree.add(FillWidget::new().focusable());
2276        let scope_e = tree.add(StackWidget::new().child(e));
2277        tree.set_traversal_scope(scope_e, Cycle);
2278        tree.layout(SizeProposal::exact(100.0, 50.0));
2279
2280        tree.focus(e);
2281        tab(&mut tree);
2282        assert_eq!(tree.focused(), Some(e));
2283        shift_tab(&mut tree);
2284        assert_eq!(tree.focused(), Some(e));
2285    }
2286
2287    #[test]
2288    fn nested_continue_in_cycle_flows_out_to_outer() {
2289        // outer(Cycle)[X, inner(Continue)[i1,i2], Y] — outer is the only root.
2290        let mut tree = WidgetTree::new();
2291        let x = tree.add(FillWidget::new().focusable());
2292        let i1 = tree.add(FillWidget::new().focusable());
2293        let i2 = tree.add(FillWidget::new().focusable());
2294        let inner = tree.add(StackWidget::new().child(i1).child(i2));
2295        let y = tree.add(FillWidget::new().focusable());
2296        let outer = tree.add(StackWidget::new().child(x).child(inner).child(y));
2297        tree.set_traversal_scope(inner, Continue);
2298        tree.set_traversal_scope(outer, Cycle);
2299        tree.layout(SizeProposal::exact(100.0, 100.0));
2300
2301        for expected in [x, i1, i2, y, x] {
2302            tab(&mut tree);
2303            assert_eq!(tree.focused(), Some(expected));
2304        }
2305        // Shift+Tab from i1 escapes inner to X.
2306        tree.focus(i1);
2307        shift_tab(&mut tree);
2308        assert_eq!(tree.focused(), Some(x));
2309    }
2310
2311    #[test]
2312    fn nested_cycle_in_cycle_traps_in_the_inner_scope() {
2313        // outer(Cycle)[X, inner(Cycle)[i1,i2], Y]: once inside inner, Y is
2314        // unreachable via Tab.
2315        let mut tree = WidgetTree::new();
2316        let x = tree.add(FillWidget::new().focusable());
2317        let i1 = tree.add(FillWidget::new().focusable());
2318        let i2 = tree.add(FillWidget::new().focusable());
2319        let inner = tree.add(StackWidget::new().child(i1).child(i2));
2320        let y = tree.add(FillWidget::new().focusable());
2321        let outer = tree.add(StackWidget::new().child(x).child(inner).child(y));
2322        tree.set_traversal_scope(inner, Cycle);
2323        tree.set_traversal_scope(outer, Cycle);
2324        tree.layout(SizeProposal::exact(100.0, 100.0));
2325
2326        tree.focus(i1);
2327        for _ in 0..6 {
2328            tab(&mut tree);
2329            let f = tree.focused();
2330            assert!(
2331                f == Some(i1) || f == Some(i2),
2332                "inner Cycle must trap Tab; reached {f:?}"
2333            );
2334        }
2335    }
2336
2337    #[test]
2338    fn scoped_tab_index_orders_within_a_scope() {
2339        // scopeF(Cycle)[f3#3, f1#1, f2#2] added out of order → visited 1,2,3.
2340        let mut tree = WidgetTree::new();
2341        let f3 = indexed(&mut tree, 3);
2342        let f1 = indexed(&mut tree, 1);
2343        let f2 = indexed(&mut tree, 2);
2344        let scope_f = tree.add(StackWidget::new().child(f3).child(f1).child(f2));
2345        tree.set_traversal_scope(scope_f, Cycle);
2346        tree.layout(SizeProposal::exact(100.0, 100.0));
2347
2348        for expected in [f1, f2, f3, f1] {
2349            tab(&mut tree);
2350            assert_eq!(tree.focused(), Some(expected));
2351        }
2352    }
2353
2354    #[test]
2355    fn destroyed_focused_widget_re_enters_at_first() {
2356        let mut tree = WidgetTree::new();
2357        let a = tree.add(FillWidget::new().focusable());
2358        let b = tree.add(FillWidget::new().focusable());
2359        tree.layout(SizeProposal::exact(100.0, 50.0));
2360
2361        tree.focus(b);
2362        tree.destroy_subtree(b);
2363        tab(&mut tree);
2364        assert_eq!(tree.focused(), Some(a), "Tab after destroy enters at first");
2365    }
2366
2367    #[test]
2368    fn set_traversal_scope_forces_node_non_focusable() {
2369        // A scope marker on an otherwise-focusable node must drop it from Tab
2370        // (it is a boundary, not a stop).
2371        let mut tree = WidgetTree::new();
2372        let inner = tree.add(FillWidget::new().focusable());
2373        let scope = tree.add(StackWidget::new().child(inner).focusable(true));
2374        tree.set_traversal_scope(scope, Continue);
2375        tree.layout(SizeProposal::exact(100.0, 50.0));
2376
2377        // Only `inner` is a Tab stop; the scope node itself never gets focus.
2378        tab(&mut tree);
2379        assert_eq!(tree.focused(), Some(inner));
2380        tab(&mut tree);
2381        assert_eq!(tree.focused(), Some(inner));
2382    }
2383
2384    #[test]
2385    fn no_scopes_behaves_like_a_flat_cycle() {
2386        // Regression: without any scopes, traversal is a flat wrapping ring,
2387        // ordered by tab_index then DFS — identical to the pre-scope behavior.
2388        let mut tree = WidgetTree::new();
2389        let a = indexed(&mut tree, 2);
2390        let b = indexed(&mut tree, 1);
2391        let c = tree.add(FillWidget::new().focusable());
2392        tree.layout(SizeProposal::exact(100.0, 50.0));
2393
2394        // b(#1), a(#2), then c(no index) — wrapping.
2395        for expected in [b, a, c, b] {
2396            tab(&mut tree);
2397            assert_eq!(tree.focused(), Some(expected));
2398        }
2399    }
2400
2401    #[test]
2402    fn centered_modal_confines_tab_to_its_content() {
2403        // A centered overlay folds into the traversal model as an implicit
2404        // Cycle scope rooted at its content — Tab must stay inside it.
2405        let mut tree = WidgetTree::new();
2406        let outside1 = tree.add(FillWidget::new().focusable());
2407        let outside2 = tree.add(FillWidget::new().focusable());
2408        let m1 = tree.add(FillWidget::new().focusable());
2409        let m2 = tree.add(FillWidget::new().focusable());
2410        let content = tree.add(StackWidget::new().child(m1).child(m2));
2411        tree.layout(SizeProposal::exact(200.0, 100.0));
2412
2413        tree.show_overlay(crate::overlay::OverlayRequest::new(
2414            content,
2415            outside1,
2416            crate::overlay::OverlayPlacement::Centered,
2417            crate::overlay::DismissBehavior::Manual,
2418        ));
2419
2420        for _ in 0..6 {
2421            tab(&mut tree);
2422            let f = tree.focused();
2423            assert!(
2424                f == Some(m1) || f == Some(m2),
2425                "modal must trap Tab inside its content, reached {f:?}"
2426            );
2427        }
2428        assert_ne!(tree.focused(), Some(outside1));
2429        assert_ne!(tree.focused(), Some(outside2));
2430    }
2431}
2432
2433#[cfg(test)]
2434mod tests_focus_out_dismissal {
2435    //! Non-modal overlays follow focus out instead of trapping it.
2436    //!
2437    //! Driven against bare `OverlayRequest`s so the rule is exercised with no
2438    //! dependency on the widgets crate; the real `MenuBar` / `PopoverButton` /
2439    //! `ComboBox` behaviour is pinned over in `teksilo-widgets`.
2440
2441    use super::*;
2442    use crate::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
2443    use crate::test_widgets::{FillWidget, StackWidget};
2444
2445    fn tab(tree: &mut WidgetTree) {
2446        tree.press_key(Key::Tab, Modifiers::NONE);
2447    }
2448
2449    /// Show `content` anchored to `anchor`, the way a popover or menu does.
2450    fn show_anchored(
2451        tree: &mut WidgetTree,
2452        content: WidgetId,
2453        anchor: WidgetId,
2454        parent: Option<crate::overlay::OverlayId>,
2455    ) -> crate::overlay::OverlayId {
2456        let mut request = OverlayRequest::new(
2457            content,
2458            anchor,
2459            OverlayPlacement::Below,
2460            DismissBehavior::EscapeOrClickOutside,
2461        );
2462        request.parent_overlay = parent;
2463        tree.show_overlay(request)
2464    }
2465
2466    #[test]
2467    fn tab_out_of_a_non_modal_overlay_dismisses_it() {
2468        let mut tree = WidgetTree::new();
2469        let anchor = tree.add(FillWidget::new().focusable());
2470        let after = tree.add(FillWidget::new().focusable());
2471        let inner = tree.add(FillWidget::new().focusable());
2472        let content = tree.add(StackWidget::new().child(inner));
2473        tree.layout(SizeProposal::exact(200.0, 100.0));
2474        show_anchored(&mut tree, content, anchor, None);
2475
2476        tree.focus(inner);
2477        tab(&mut tree);
2478
2479        assert_ne!(tree.focused(), Some(inner), "focus genuinely left");
2480        assert!(
2481            tree.active_overlays().is_empty(),
2482            "an overlay must not stay open over the focus ring that left it"
2483        );
2484        assert!(tree.focused() == Some(after) || tree.focused() == Some(anchor));
2485    }
2486
2487    /// The centered modal keeps its trap — that pattern *does* contain focus.
2488    #[test]
2489    fn a_centered_modal_is_never_dismissed_by_focus_moving() {
2490        let mut tree = WidgetTree::new();
2491        let outside = tree.add(FillWidget::new().focusable());
2492        let m1 = tree.add(FillWidget::new().focusable());
2493        let content = tree.add(StackWidget::new().child(m1));
2494        tree.layout(SizeProposal::exact(200.0, 100.0));
2495        tree.show_overlay(OverlayRequest::new(
2496            content,
2497            outside,
2498            OverlayPlacement::Centered,
2499            DismissBehavior::Manual,
2500        ));
2501
2502        tree.focus(m1);
2503        // Force focus out programmatically. Tab could not do this, and an
2504        // AccessKit request behind the modal is refused (`is_behind_modal`),
2505        // but app code can, and the modal must survive it.
2506        tree.focus(outside);
2507
2508        assert_eq!(
2509            tree.active_overlays().len(),
2510            1,
2511            "a modal is the one overlay that legitimately contains focus"
2512        );
2513    }
2514
2515    /// The scrim is anchored to whatever opened the modal, so an anchor-aware
2516    /// rule could mistake it for a panel orbiting that widget. `FullViewport`
2517    /// is what tells it apart.
2518    #[test]
2519    fn the_modal_scrim_survives_focus_moving_inside_the_modal() {
2520        let mut tree = WidgetTree::new();
2521        let opener = tree.add(FillWidget::new().focusable());
2522        let m1 = tree.add(FillWidget::new().focusable());
2523        let m2 = tree.add(FillWidget::new().focusable());
2524        let scrim = tree.add(FillWidget::new());
2525        let content = tree.add(StackWidget::new().child(m1).child(m2));
2526        tree.layout(SizeProposal::exact(200.0, 100.0));
2527
2528        tree.show_overlay(OverlayRequest::new(
2529            scrim,
2530            opener,
2531            OverlayPlacement::FullViewport,
2532            DismissBehavior::Manual,
2533        ));
2534        tree.show_overlay(OverlayRequest::new(
2535            content,
2536            opener,
2537            OverlayPlacement::Centered,
2538            DismissBehavior::Manual,
2539        ));
2540
2541        tree.focus(opener);
2542        tree.focus(m1);
2543        tab(&mut tree);
2544
2545        assert_eq!(
2546            tree.active_overlays().len(),
2547            2,
2548            "scrim and modal both stand while focus moves within the modal"
2549        );
2550    }
2551
2552    /// A submenu is a *sibling* arena subtree linked only by `parent_overlay`.
2553    /// Ask the arena instead and the parent dies the moment its own child opens.
2554    #[test]
2555    fn focus_moving_into_a_child_overlay_keeps_the_parent_open() {
2556        let mut tree = WidgetTree::new();
2557        let anchor = tree.add(FillWidget::new().focusable());
2558        let parent_item = tree.add(FillWidget::new().focusable());
2559        let parent_content = tree.add(StackWidget::new().child(parent_item));
2560        let child_item = tree.add(FillWidget::new().focusable());
2561        let child_content = tree.add(StackWidget::new().child(child_item));
2562        tree.layout(SizeProposal::exact(200.0, 100.0));
2563
2564        let parent = show_anchored(&mut tree, parent_content, anchor, None);
2565        show_anchored(&mut tree, child_content, parent_item, Some(parent));
2566
2567        tree.focus(parent_item);
2568        tree.focus(child_item);
2569
2570        assert_eq!(
2571            tree.active_overlays().len(),
2572            2,
2573            "opening a submenu is not leaving the menu that owns it"
2574        );
2575    }
2576
2577    /// Backing out to a shallower level of the same cascade closes only what
2578    /// sits below it.
2579    #[test]
2580    fn focus_back_to_the_parent_overlay_closes_only_the_child() {
2581        let mut tree = WidgetTree::new();
2582        let anchor = tree.add(FillWidget::new().focusable());
2583        let parent_item = tree.add(FillWidget::new().focusable());
2584        let parent_content = tree.add(StackWidget::new().child(parent_item));
2585        let child_item = tree.add(FillWidget::new().focusable());
2586        let child_content = tree.add(StackWidget::new().child(child_item));
2587        tree.layout(SizeProposal::exact(200.0, 100.0));
2588
2589        let parent = show_anchored(&mut tree, parent_content, anchor, None);
2590        show_anchored(&mut tree, child_content, parent_item, Some(parent));
2591
2592        tree.focus(child_item);
2593        tree.focus(parent_item);
2594
2595        assert_eq!(
2596            tree.active_overlays(),
2597            vec![parent],
2598            "the submenu goes, the menu that owns it stays"
2599        );
2600    }
2601
2602    /// Leaving the whole cascade closes every level in one move — APG's
2603    /// "closes all menus and submenus", plural and unqualified.
2604    #[test]
2605    fn leaving_a_nested_cascade_closes_every_level() {
2606        let mut tree = WidgetTree::new();
2607        let anchor = tree.add(FillWidget::new().focusable());
2608        let away = tree.add(FillWidget::new().focusable());
2609        let parent_item = tree.add(FillWidget::new().focusable());
2610        let parent_content = tree.add(StackWidget::new().child(parent_item));
2611        let child_item = tree.add(FillWidget::new().focusable());
2612        let child_content = tree.add(StackWidget::new().child(child_item));
2613        tree.layout(SizeProposal::exact(200.0, 100.0));
2614
2615        let parent = show_anchored(&mut tree, parent_content, anchor, None);
2616        show_anchored(&mut tree, child_content, parent_item, Some(parent));
2617
2618        tree.focus(child_item);
2619        tree.focus(away);
2620
2621        assert!(
2622            tree.active_overlays().is_empty(),
2623            "one move out of the cascade must leave nothing behind"
2624        );
2625    }
2626
2627    /// A dropdown opened *inside a modal* still follows focus out.
2628    ///
2629    /// The regression this pins: a `ComboBox` keeps focus on its own trigger
2630    /// while its panel is up, so the rule has to find that panel through its
2631    /// **anchor**. But when the trigger lives inside a modal — Settings, say —
2632    /// the by-content lookup succeeds first and answers with the *modal*, whose
2633    /// whole point is that it does not follow focus out. That shadowed the
2634    /// anchor lookup entirely, and the dropdown was left open over the
2635    /// Settings pane after Tab had moved on.
2636    #[test]
2637    fn a_dropdown_inside_a_modal_still_follows_focus_out() {
2638        let mut tree = WidgetTree::new();
2639        let opener = tree.add(FillWidget::new().focusable());
2640        let trigger = tree.add(FillWidget::new().focusable());
2641        let next = tree.add(FillWidget::new().focusable());
2642        let modal_content = tree.add(StackWidget::new().child(trigger).child(next));
2643        let panel = tree.add(StackWidget::new());
2644        tree.layout(SizeProposal::exact(200.0, 100.0));
2645
2646        tree.show_overlay(OverlayRequest::new(
2647            modal_content,
2648            opener,
2649            OverlayPlacement::Centered,
2650            DismissBehavior::Manual,
2651        ));
2652        // The dropdown, anchored to a trigger that sits *within* the modal.
2653        show_anchored(&mut tree, panel, trigger, None);
2654
2655        tree.focus(trigger);
2656        assert_eq!(
2657            tree.active_overlays().len(),
2658            2,
2659            "precondition: modal + panel"
2660        );
2661
2662        tab(&mut tree);
2663
2664        assert_eq!(
2665            tree.focused(),
2666            Some(next),
2667            "Tab moves on within the modal, as it should"
2668        );
2669        assert_eq!(
2670            tree.active_overlays().len(),
2671            1,
2672            "the dropdown must go — only the modal hosting it stays"
2673        );
2674    }
2675
2676    /// A snackbar is shown *from* a focused button and leaves that button
2677    /// focused — so an anchor-aware rule would tear it down on the user's very
2678    /// next keystroke. Its lifetime belongs to its timer, not to the keyboard.
2679    ///
2680    /// This is why eligibility asks whether an overlay is positioned *at* its
2681    /// anchor rather than merely whether it has one: `BottomCenter` is placed
2682    /// against the viewport, and its anchor is bookkeeping.
2683    #[test]
2684    fn a_viewport_placed_notification_ignores_focus_moving() {
2685        let mut tree = WidgetTree::new();
2686        let trigger = tree.add(FillWidget::new().focusable());
2687        let elsewhere = tree.add(FillWidget::new().focusable());
2688        let snack = tree.add(StackWidget::new());
2689        tree.layout(SizeProposal::exact(200.0, 100.0));
2690        tree.show_overlay(OverlayRequest::new(
2691            snack,
2692            trigger,
2693            OverlayPlacement::BottomCenter,
2694            DismissBehavior::Manual,
2695        ));
2696
2697        tree.focus(trigger);
2698        tab(&mut tree);
2699
2700        assert_eq!(
2701            tree.active_overlays().len(),
2702            1,
2703            "a snackbar outlives the keystroke that moved focus off its trigger"
2704        );
2705        assert_ne!(tree.focused(), Some(trigger), "and focus did move");
2706        assert_eq!(tree.focused(), Some(elsewhere));
2707    }
2708
2709    /// A menu must never take its host down with it. The upward walk stops at
2710    /// a host surface — a hosting dialog, composite tooltip, or revealed
2711    /// menubar — so tabbing out of the inner menu closes the menu alone.
2712    #[test]
2713    fn leaving_a_hosted_menu_spares_the_host() {
2714        let mut tree = WidgetTree::new();
2715        let opener = tree.add(FillWidget::new().focusable());
2716        let away = tree.add(FillWidget::new().focusable());
2717        let modal_item = tree.add(FillWidget::new().focusable());
2718        let modal_content = tree.add(StackWidget::new().child(modal_item));
2719        let menu_item = tree.add(FillWidget::new().focusable());
2720        let menu_content = tree.add(StackWidget::new().child(menu_item));
2721        tree.layout(SizeProposal::exact(200.0, 100.0));
2722
2723        // A centered modal is unconditionally a host surface.
2724        let host = tree.show_overlay(OverlayRequest::new(
2725            modal_content,
2726            opener,
2727            OverlayPlacement::Centered,
2728            DismissBehavior::Manual,
2729        ));
2730        show_anchored(&mut tree, menu_content, modal_item, Some(host));
2731
2732        tree.focus(menu_item);
2733        tree.focus(away);
2734
2735        assert_eq!(
2736            tree.active_overlays(),
2737            vec![host],
2738            "the menu goes; the modal hosting it stays"
2739        );
2740    }
2741
2742    /// An overlay whose focus never enters it is still reachable through its
2743    /// **anchor** — the non-searchable `ComboBox` / `SearchField` shape, where
2744    /// focus stays on the trigger the whole time the panel is up.
2745    #[test]
2746    fn leaving_the_anchor_dismisses_a_panel_focus_never_entered() {
2747        let mut tree = WidgetTree::new();
2748        let trigger = tree.add(FillWidget::new().focusable());
2749        let after = tree.add(FillWidget::new().focusable());
2750        let content = tree.add(StackWidget::new());
2751        tree.layout(SizeProposal::exact(200.0, 100.0));
2752        show_anchored(&mut tree, content, trigger, None);
2753
2754        tree.focus(trigger);
2755        assert_eq!(tree.active_overlays().len(), 1, "precondition: panel is up");
2756
2757        tree.focus(after);
2758        assert!(
2759            tree.active_overlays().is_empty(),
2760            "leaving the trigger is leaving the dropdown it owns"
2761        );
2762    }
2763}