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    /// Every widget within a subtree, `root` included, that can take focus
527    /// now, in depth-first tree order: active, under no disabled ancestor,
528    /// declared focusable. Tab-stop flags are not consulted, so a node that
529    /// holds focus without being a Tab stop (a roving-focus item) still has
530    /// a position here.
531    ///
532    /// Read on both sides of a rebuild to put focus back at the position it
533    /// had (see `pending_focus_restore`), which needs only the two readings to
534    /// agree, not to match Tab order.
535    pub(crate) fn focusable_descendants(&self, root: WidgetId) -> Vec<WidgetId> {
536        fn walk(tree: &WidgetTree, id: WidgetId, out: &mut Vec<WidgetId>) {
537            if !tree.arena.is_active(id) {
538                return;
539            }
540            let Some(node) = tree.arena.get(id) else {
541                return;
542            };
543            if node.enabled_state.as_ref().is_some_and(|s| !s.get()) {
544                return;
545            }
546            if tree.is_node_focusable(node) {
547                out.push(id);
548            }
549            for &child in tree.arena.children(id) {
550                walk(tree, child, out);
551            }
552        }
553        let mut out = Vec::new();
554        walk(self, root, &mut out);
555        out
556    }
557
558    /// The first widget within a subtree, in depth-first order, that is
559    /// declared focusable, whether or not it can take focus right now. See
560    /// [`BuildContext::first_focus_capable_descendant`](crate::build_context::BuildContext::first_focus_capable_descendant).
561    pub(crate) fn first_focus_capable_descendant(&self, root: WidgetId) -> Option<WidgetId> {
562        if !self.arena.is_active(root) {
563            return None;
564        }
565        let node = self.arena.get(root)?;
566        if self.is_node_focusable(node) {
567            return Some(root);
568        }
569        node.children
570            .iter()
571            .find_map(|&child| self.first_focus_capable_descendant(child))
572    }
573
574    /// Whether the given widget id currently exists and is active in the
575    /// tree (not dormant, not destroyed). Callers that need to validate a
576    /// user-supplied `WidgetId` before acting on it — e.g. the modal
577    /// presentation path validating `ModalRequest::focus_target` — use
578    /// this.
579    pub fn is_active(&self, id: WidgetId) -> bool {
580        self.arena.is_active(id)
581    }
582
583    /// Walk the subtree rooted at `id` in depth-first order, returning
584    /// the first widget-reported `initial_focus_hint` that resolves to
585    /// an active descendant of `id`.
586    ///
587    /// Used by the modal presentation pipeline to let a deferred-built
588    /// content widget (e.g. `MessageBox`) direct focus to a specific
589    /// descendant after build — even when wrapped in a surface widget
590    /// like `ModalContainer` that doesn't itself know the default
591    /// button's id. The framework walks in to find the first hint
592    /// under the content root, which is tighter than falling all the
593    /// way back to `first_focusable_descendant`.
594    ///
595    /// Hints pointing at inactive or out-of-subtree ids are ignored;
596    /// the walk continues so a shallow wrapper's stale hint doesn't
597    /// hide a deeper child's valid one.
598    pub fn widget_initial_focus_hint(&self, id: WidgetId) -> Option<WidgetId> {
599        if !self.arena.is_active(id) {
600            return None;
601        }
602        if let Some(node) = self.arena.get(id) {
603            if let Some(target) = node.widget.initial_focus_hint()
604                && self.arena.is_active(target)
605                && self.is_descendant_of(target, id)
606            {
607                return Some(target);
608            }
609            for &child in &node.children {
610                if let Some(found) = self.widget_initial_focus_hint(child) {
611                    return Some(found);
612                }
613            }
614        }
615        None
616    }
617
618    /// How the currently focused widget gained focus.
619    pub fn focus_origin(&self) -> Option<crate::focus::FocusOrigin> {
620        self.focus_origin
621    }
622
623    /// Input-modality "focus-visible" signal: `true` after keyboard input,
624    /// `false` after pointer input. Focus rings observe this so they show only
625    /// during keyboard navigation. See [`BuildContext::focus_visible`](crate::BuildContext::focus_visible).
626    pub fn focus_visible_signal(&self) -> crate::signal::Signal<bool> {
627        self.focus_visible.clone()
628    }
629
630    /// Cycle focus to the next/previous focusable widget (Tab/Shift-Tab),
631    /// honoring nested **traversal scopes** (`FocusScope`).
632    ///
633    /// Builds a scope tree on demand (depth-first; `tab_index` scoped per
634    /// scope), then walks it with [`navigate_scope`]. The root is an implicit
635    /// `Cycle` scope (whole-tree last↔first wrap). A centered modal overlay
636    /// folds into the same mechanism: its content subtree becomes the root
637    /// `Cycle` scope, so Tab is confined to the modal.
638    pub(super) fn cycle_focus(&mut self, reverse: bool, ops: &mut dyn crate::window::WindowOps) {
639        let mut entries = Vec::new();
640        if let Some(modal_overlay) = self.overlay_manager.topmost_modal() {
641            let content_id = modal_overlay.content_id;
642            self.collect_scope_entries(content_id, &mut entries);
643        } else {
644            let roots = self.arena.roots();
645            for root in roots {
646                // Tooltip surfaces are spliced in below, next to the anchor
647                // they belong to — never collected as bare roots. A tooltip's
648                // content is `ctx.add`-ed parentless, so collecting it here put
649                // it in the Tab cycle at whatever position it happened to be
650                // inserted at: `sort_scope_entries` orders by explicit
651                // `tab_index` only, and entries without one compare `Equal`,
652                // so a stable sort leaves insertion order to decide. That made
653                // a tooltip's slot in the cycle an emergent property of build
654                // order rather than of the control it describes.
655                if self.tooltip_content_root(root).is_some() {
656                    continue;
657                }
658                self.collect_scope_entries(root, &mut entries);
659            }
660        }
661        sort_scope_entries(&mut entries);
662        self.splice_sticky_tooltips_after_anchors(&mut entries);
663        let root_scope = ScopeNode {
664            policy: crate::focus::TraversalScopePolicy::Cycle,
665            entries,
666        };
667
668        if let StepResult::Found(next_id) = navigate_scope(&root_scope, self.focused, reverse, true)
669        {
670            self.focus_with_origin_ops(next_id, crate::focus::FocusOrigin::Keyboard, &mut *ops);
671        }
672        // `Escaped` only reaches here for an empty tree (the root Cycle scope
673        // wraps at its ends) — nothing to focus, so leave focus unchanged.
674    }
675
676    /// The tooltip entry whose content root is `id`, if any.
677    fn tooltip_content_root(&self, id: WidgetId) -> Option<usize> {
678        self.tooltips.iter().position(|e| e.content_id == id)
679    }
680
681    /// Place every **sticky** tooltip surface immediately after the entry
682    /// holding its anchor, and leave every non-sticky one out entirely.
683    ///
684    /// Two rules, one place:
685    ///
686    /// * An *unpromoted* tip is informational. It appeared because the pointer
687    ///   paused or focus arrived — not because the user asked to enter it — so
688    ///   it takes no Tab stop, matching the ARIA tooltip pattern (its text
689    ///   reaches assistive tech through the anchor's description instead).
690    /// * A *promoted* one was earned, by a 2 s dwell in either modality, and
691    ///   its whole point is that its content is reachable. It belongs directly
692    ///   after the control it describes, the way a disclosure's panel follows
693    ///   its button — never at some position decided by arena insertion order.
694    ///
695    /// Anchors nested inside a traversal scope are handled by locating the
696    /// top-level entry that *contains* the anchor, so the panel still lands
697    /// immediately after that whole group rather than being dropped.
698    fn splice_sticky_tooltips_after_anchors(&self, entries: &mut Vec<ScopeEntry>) {
699        let sticky: Vec<(WidgetId, WidgetId)> = self
700            .tooltips
701            .iter()
702            .filter(|e| e.is_sticky && e.overlay_id.is_some())
703            .map(|e| (e.anchor_id, e.content_id))
704            .collect();
705
706        for (anchor_id, content_id) in sticky {
707            let mut panel = Vec::new();
708            self.collect_scope_entries(content_id, &mut panel);
709            if panel.is_empty() {
710                continue;
711            }
712            sort_scope_entries(&mut panel);
713
714            // The anchor is often not itself the Tab stop: composing controls
715            // (`Button`) keep focus on their outer node and attach the tip to
716            // an inner body root, so resolve to the focusable that actually
717            // appears in the cycle before looking for its entry.
718            let stop = self
719                .find_focusable_at_or_above(anchor_id)
720                .unwrap_or(anchor_id);
721            let at = entries
722                .iter()
723                .position(|entry| scope_entry_contains(entry, stop))
724                // An anchor with no Tab stop above it at all (a plain container
725                // that merely carries a tip) has no entry to follow; put the
726                // panel at the end rather than dropping it, so its content
727                // stays reachable.
728                .map_or(entries.len(), |i| i + 1);
729
730            for (offset, item) in panel.into_iter().enumerate() {
731                entries.insert(at + offset, item);
732            }
733        }
734    }
735
736    /// Whether `id` participates in Tab traversal, honoring a `tab_stop`
737    /// flag set anywhere on its ancestor chain. Walks up to the nearest
738    /// node (including `id`) carrying an explicit `tab_stop` prop and
739    /// returns its current value; defaults to `true` when no ancestor
740    /// constrains it. This makes `set_tab_stop` on a composite control
741    /// (whose focusable node is an inner leaf) govern the whole subtree —
742    /// the basis of the roving-tabindex pattern in `Toolbar` / `TabBar`.
743    pub(super) fn tab_stop_effective(&self, id: WidgetId) -> bool {
744        let mut current = Some(id);
745        while let Some(cur) = current {
746            let Some(node) = self.arena.get(cur) else {
747                break;
748            };
749            if let Some(prop) = node.tab_stop.as_ref() {
750                return prop.get();
751            }
752            current = node.parent;
753        }
754        true
755    }
756
757    /// Check if a node is focusable (set via HandlerSet `.focusable(true)` in build).
758    pub(super) fn is_node_focusable(&self, node: &crate::arena::WidgetNode) -> bool {
759        node.node_focusable.unwrap_or(false)
760    }
761
762    /// Find the nearest focusable widget at or above the given ID.
763    pub(super) fn find_focusable_at_or_above(&self, id: WidgetId) -> Option<WidgetId> {
764        let mut current = Some(id);
765        while let Some(current_id) = current {
766            if let Some(node) = self.arena.get(current_id)
767                && self.is_node_focusable(node)
768            {
769                return Some(current_id);
770            }
771            current = self.arena.parent(current_id);
772        }
773        None
774    }
775
776    /// Collect the traversal entries of the subtree rooted at `id`, in
777    /// depth-first (document) order, into the current scope's `out` list.
778    ///
779    /// - A node carrying `node_traversal_scope` becomes a single
780    ///   [`ScopeEntryKind::Scope`] — its descendants are collected into a *nested*
781    ///   ordered list and the recursion does not flow past it at this level.
782    ///   (The scope node itself is never a focusable; the `FocusScope` wrapper
783    ///   forces `node_focusable = false`.)
784    /// - Any other node that is focusable and an effective Tab stop becomes a
785    ///   [`ScopeEntryKind::Focusable`].
786    ///
787    /// Disabled subtrees and dormant/destroyed nodes are skipped entirely, as
788    /// in the previous flat collector. The `tab_stop` ancestor-walk
789    /// (`tab_stop_effective`) is applied here at collection time rather than as
790    /// a post-pass `retain`.
791    fn collect_scope_entries(&self, id: WidgetId, out: &mut Vec<ScopeEntry>) {
792        if !self.arena.is_active(id) {
793            return;
794        }
795        let Some(node) = self.arena.get(id) else {
796            return;
797        };
798        if node
799            .enabled_state
800            .as_ref()
801            .map(|s| !s.get())
802            .unwrap_or(false)
803        {
804            return;
805        }
806
807        // A traversal-scope boundary: collect its subtree as an independent,
808        // internally-ordered group; do not descend past it into `out`.
809        if let Some(policy) = node.node_traversal_scope {
810            let mut child_entries = Vec::new();
811            for &child in &node.children {
812                self.collect_scope_entries(child, &mut child_entries);
813            }
814            sort_scope_entries(&mut child_entries);
815            out.push(ScopeEntry {
816                tab_index: node.node_tab_index,
817                kind: ScopeEntryKind::Scope(ScopeNode {
818                    policy,
819                    entries: child_entries,
820                }),
821            });
822            return;
823        }
824
825        // A normal node: a Tab stop iff focusable and not tab_stop-suppressed.
826        if self.is_node_focusable(node) && self.tab_stop_effective(id) {
827            out.push(ScopeEntry {
828                tab_index: node.node_tab_index,
829                kind: ScopeEntryKind::Focusable(id),
830            });
831        }
832        for &child in &node.children {
833            self.collect_scope_entries(child, out);
834        }
835    }
836
837    /// Build the chain of strict ancestors of `id` (i.e. starting at
838    /// the parent of `id`, walking up to a root). Returns an empty
839    /// vector when `id` is `None` or has no parent. Used by the
840    /// `focus_within` / `hover_within` chain-diff helpers.
841    pub(super) fn strict_ancestors_of(&self, id: Option<WidgetId>) -> Vec<WidgetId> {
842        let mut chain = Vec::new();
843        if let Some(start) = id {
844            let mut current = self.arena.parent(start);
845            while let Some(parent) = current {
846                chain.push(parent);
847                current = self.arena.parent(parent);
848            }
849        }
850        chain
851    }
852
853    /// Update every `focus_within_signal` whose owning node moved
854    /// in or out of the focused widget's strict-ancestor chain
855    /// between `old` and `new`. Strict ancestors only — the
856    /// focused widget's own signal (if any) is never written.
857    pub(crate) fn update_focus_within_signals(
858        &mut self,
859        old: Option<WidgetId>,
860        new: Option<WidgetId>,
861    ) {
862        let old_chain = self.strict_ancestors_of(old);
863        let new_chain = self.strict_ancestors_of(new);
864        // Nodes leaving the chain → false.
865        for &id in &old_chain {
866            if !new_chain.contains(&id)
867                && let Some(node) = self.arena.get(id)
868                && let Some(sig) = node.focus_within_signal.clone()
869            {
870                sig.set(false);
871            }
872        }
873        // Nodes entering the chain → true.
874        for &id in &new_chain {
875            if !old_chain.contains(&id)
876                && let Some(node) = self.arena.get(id)
877                && let Some(sig) = node.focus_within_signal.clone()
878            {
879                sig.set(true);
880            }
881        }
882    }
883
884    /// Inclusive ancestor chain of `id`: `id` itself, then its strict
885    /// ancestors. Empty when `id` is `None`.
886    pub(super) fn inclusive_ancestors_of(&self, id: Option<WidgetId>) -> Vec<WidgetId> {
887        let mut chain = Vec::new();
888        if let Some(start) = id {
889            chain.push(start);
890            chain.extend(self.strict_ancestors_of(Some(start)));
891        }
892        chain
893    }
894
895    /// Mirror of [`update_focus_within_signals`](Self::update_focus_within_signals)
896    /// for `view_focus_signal`, using *inclusive* ancestor chains — so a node
897    /// that is itself the focused widget (e.g. a data view holding focus
898    /// directly) sees its own scope signal flip `true`.
899    pub(crate) fn update_view_focus_signals(
900        &mut self,
901        old: Option<WidgetId>,
902        new: Option<WidgetId>,
903    ) {
904        let old_chain = self.inclusive_ancestors_of(old);
905        let new_chain = self.inclusive_ancestors_of(new);
906        for &id in &old_chain {
907            if !new_chain.contains(&id)
908                && let Some(node) = self.arena.get(id)
909                && let Some(sig) = node.view_focus_signal.clone()
910            {
911                sig.set(false);
912            }
913        }
914        for &id in &new_chain {
915            if !old_chain.contains(&id)
916                && let Some(node) = self.arena.get(id)
917                && let Some(sig) = node.view_focus_signal.clone()
918            {
919                sig.set(true);
920            }
921        }
922    }
923
924    /// Get-or-create the `view_focus_signal` on `node_id`, initialised to the
925    /// node's current focus-containment (`focused` is `node_id` or a descendant).
926    pub(crate) fn view_focus_signal_for(
927        &mut self,
928        node_id: WidgetId,
929    ) -> crate::signal::Signal<bool> {
930        if let Some(node) = self.arena.get(node_id)
931            && let Some(sig) = node.view_focus_signal.clone()
932        {
933            return sig;
934        }
935        let active = self.inclusive_ancestors_of(self.focused).contains(&node_id);
936        let sig = crate::signal::Signal::new(active);
937        if let Some(node) = self.arena.get_mut(node_id) {
938            node.view_focus_signal = Some(sig.clone());
939        }
940        sig
941    }
942
943    /// Reactive signal that is `true` when the nearest focusable ancestor of
944    /// `node_id` (its "focus scope" — e.g. the enclosing data view) holds
945    /// keyboard focus. With no focusable ancestor, returns a constant-`true`
946    /// signal so selection renders active (the legacy behaviour for items
947    /// outside any focus scope). Drives focus-aware selection in `StandardItem`.
948    pub fn view_focus_active_for(&mut self, node_id: WidgetId) -> crate::signal::Signal<bool> {
949        match self.find_focusable_at_or_above(node_id) {
950            Some(scope) => self.view_focus_signal_for(scope),
951            None => crate::signal::Signal::new(true),
952        }
953    }
954
955    /// Push `node_id`'s focus scope onto the build-time scope stack (creating its
956    /// `view_focus_signal` if absent) so descendants built before arena
957    /// parenting is wired (docked / virtualized rows) still read the correct
958    /// view focus. Pair with [`end_view_focus`](Self::end_view_focus).
959    pub fn begin_view_focus(&mut self, node_id: WidgetId) -> crate::signal::Signal<bool> {
960        let sig = self.view_focus_signal_for(node_id);
961        self.view_focus_stack.push(sig.clone());
962        sig
963    }
964
965    /// Pop the innermost focus scope pushed by [`begin_view_focus`](Self::begin_view_focus).
966    pub fn end_view_focus(&mut self) {
967        self.view_focus_stack.pop();
968    }
969
970    /// The innermost active build-time focus scope, if any.
971    pub fn current_view_focus(&self) -> Option<crate::signal::Signal<bool>> {
972        self.view_focus_stack.last().cloned()
973    }
974
975    /// Single point of mutation for the hovered widget. Writes it onto the
976    /// **hover owner**'s entry in the pointer table and updates the
977    /// externally-observable Signal so debug tooling (the inspector's hover
978    /// tooltip) doesn't have to poll.
979    ///
980    /// Hover is hover-owner-only, so this is a no-op on the table side when no
981    /// hovering-capable pointer is live — a touch-only device has nothing that
982    /// hovers, and writing a contact's target here would make every hover
983    /// affordance fire on tap. The signal is still kept honest.
984    ///
985    /// Does **not** call `update_hover_within_signals` — call sites remain in
986    /// charge of dispatching enter/leave because some sites (e.g. post-layout
987    /// hover recovery) intentionally skip it.
988    pub(crate) fn set_hovered(&mut self, value: Option<WidgetId>) {
989        if let Some(entry) = self.pointers.hover_owner_mut() {
990            entry.hovered = value;
991        }
992        if self.hovered_signal.get() != value {
993            self.hovered_signal.set(value);
994        }
995    }
996
997    /// Single point of mutation for `self.focused`. Mirror of
998    /// `set_hovered` for the focused chain. Drives the inspector's
999    /// Focus tab without requiring the tab to poll. Does not touch
1000    /// `focus_origin` or focus-within signals — call sites remain
1001    /// responsible for those (the bookkeeping varies by mutation
1002    /// path, e.g. focus loss vs. arena destruction).
1003    pub(crate) fn set_focused(&mut self, value: Option<WidgetId>) {
1004        self.focused = value;
1005        if self.focused_signal.get() != value {
1006            self.focused_signal.set(value);
1007        }
1008    }
1009
1010    /// Mirror of [`update_focus_within_signals`](Self::update_focus_within_signals)
1011    /// for the hovered chain.
1012    pub(crate) fn update_hover_within_signals(
1013        &mut self,
1014        old: Option<WidgetId>,
1015        new: Option<WidgetId>,
1016    ) {
1017        let old_chain = self.strict_ancestors_of(old);
1018        let new_chain = self.strict_ancestors_of(new);
1019        // Record the chain on the hover owner's entry: it is the pointer that
1020        // put those signals to `true`, so it is the one whose teardown (and
1021        // whose post-rebuild scrub) has to know about them.
1022        if let Some(entry) = self.pointers.hover_owner_mut() {
1023            entry.hover_within = new_chain.clone();
1024        }
1025        for &id in &old_chain {
1026            if !new_chain.contains(&id)
1027                && let Some(node) = self.arena.get(id)
1028                && let Some(sig) = node.hover_within_signal.clone()
1029            {
1030                sig.set(false);
1031            }
1032        }
1033        for &id in &new_chain {
1034            if !old_chain.contains(&id)
1035                && let Some(node) = self.arena.get(id)
1036                && let Some(sig) = node.hover_within_signal.clone()
1037            {
1038                sig.set(true);
1039            }
1040        }
1041    }
1042}
1043
1044/// How a focus request picks its target, kept with a request that had to wait
1045/// for a dormant node.
1046#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1047pub(crate) enum FocusRequestMode {
1048    /// The named node itself (`WidgetTree::focus`, `BuildContext::focus`).
1049    Exact,
1050    /// The named node's first focusable descendant, or the node itself when it
1051    /// has none (`EventContext::request_focus`).
1052    SelfOrDescendant,
1053    /// The first focusable descendant only; nothing when there is none
1054    /// (`EventContext::request_focus_into`, `BuildContext::focus_into`).
1055    Into,
1056}
1057
1058/// A focus request whose target was dormant when it was made. See
1059/// `WidgetTree::pending_focus_request`.
1060#[derive(Debug, Clone, Copy)]
1061pub(crate) struct PendingFocusRequest {
1062    id: WidgetId,
1063    mode: FocusRequestMode,
1064}
1065
1066// ─── Traversal scope tree ────────────────────────────────────────────────
1067//
1068// `cycle_focus` builds a transient tree of these on each Tab press. A
1069// `ScopeNode` is an ordered list of `ScopeEntry`s; an entry is either a
1070// focusable leaf or a nested `ScopeNode`. Within a node, entries are ordered
1071// by `tab_index` (scoped — only compared among siblings) then DFS order.
1072// `navigate_scope` walks this tree, applying each scope's `policy` at its ends.
1073
1074/// One member of a traversal scope: a focusable leaf or a nested scope, plus
1075/// the `tab_index` used to position it among its siblings (`None` sorts last,
1076/// preserving DFS order).
1077struct ScopeEntry {
1078    tab_index: Option<i32>,
1079    kind: ScopeEntryKind,
1080}
1081
1082enum ScopeEntryKind {
1083    Focusable(WidgetId),
1084    Scope(ScopeNode),
1085}
1086
1087/// An ordered group of entries with a boundary policy.
1088struct ScopeNode {
1089    policy: crate::focus::TraversalScopePolicy,
1090    entries: Vec<ScopeEntry>,
1091}
1092
1093/// Outcome of stepping within a scope.
1094enum StepResult {
1095    /// Focus should move to this widget.
1096    Found(WidgetId),
1097    /// Tab ran off this scope's end and the policy permits leaving — the
1098    /// caller (parent scope) should continue stepping from this scope's slot.
1099    /// Never produced by a `Cycle` scope or the root scope (they wrap).
1100    Escaped,
1101}
1102
1103/// Whether a scope wraps (vs. lets focus escape) when Tab hits its boundary.
1104/// The root scope always wraps; otherwise only `Cycle` scopes do.
1105fn scope_wraps(policy: crate::focus::TraversalScopePolicy, is_root: bool) -> bool {
1106    is_root || matches!(policy, crate::focus::TraversalScopePolicy::Cycle)
1107}
1108
1109/// Sort entries by scoped `tab_index`: `Some` before `None` (ascending);
1110/// stable, so the DFS order is preserved within each group.
1111fn sort_scope_entries(entries: &mut [ScopeEntry]) {
1112    entries.sort_by(|a, b| match (a.tab_index, b.tab_index) {
1113        (Some(ia), Some(ib)) => ia.cmp(&ib),
1114        (Some(_), None) => std::cmp::Ordering::Less,
1115        (None, Some(_)) => std::cmp::Ordering::Greater,
1116        (None, None) => std::cmp::Ordering::Equal,
1117    });
1118}
1119
1120/// Whether the focused widget `f` lies anywhere within `entry`.
1121fn scope_entry_contains(entry: &ScopeEntry, f: WidgetId) -> bool {
1122    match &entry.kind {
1123        ScopeEntryKind::Focusable(id) => *id == f,
1124        ScopeEntryKind::Scope(child) => child.entries.iter().any(|e| scope_entry_contains(e, f)),
1125    }
1126}
1127
1128/// First focusable when entering `scope` from its leading (forward) or
1129/// trailing (reverse) edge, descending into nested scopes and skipping empty
1130/// ones. `None` if the scope holds no focusable members.
1131fn enter_scope_edge(scope: &ScopeNode, reverse: bool) -> Option<WidgetId> {
1132    let n = scope.entries.len();
1133    for k in 0..n {
1134        let idx = if reverse { n - 1 - k } else { k };
1135        match &scope.entries[idx].kind {
1136            ScopeEntryKind::Focusable(id) => return Some(*id),
1137            ScopeEntryKind::Scope(child) => {
1138                if let Some(id) = enter_scope_edge(child, reverse) {
1139                    return Some(id);
1140                }
1141            }
1142        }
1143    }
1144    None
1145}
1146
1147/// Move focus to the next/previous focusable within `scope`, recursing into
1148/// the nested scope that currently holds focus before stepping to siblings.
1149/// `is_root` marks the implicit top scope (always wraps, never escapes).
1150fn navigate_scope(
1151    scope: &ScopeNode,
1152    focused: Option<WidgetId>,
1153    reverse: bool,
1154    is_root: bool,
1155) -> StepResult {
1156    if scope.entries.is_empty() {
1157        return StepResult::Escaped;
1158    }
1159
1160    let cur = focused.and_then(|f| {
1161        scope
1162            .entries
1163            .iter()
1164            .position(|e| scope_entry_contains(e, f))
1165    });
1166
1167    let from = match cur {
1168        None => {
1169            // Focus is not in this scope (root with nothing focused, or the
1170            // focused widget was destroyed): enter from the edge.
1171            return match enter_scope_edge(scope, reverse) {
1172                Some(id) => StepResult::Found(id),
1173                None => StepResult::Escaped,
1174            };
1175        }
1176        Some(i) => i,
1177    };
1178
1179    // Focus is inside a nested scope: try to advance within it first.
1180    // (Escaped — fall through and step to the sibling after this scope.)
1181    if let ScopeEntryKind::Scope(child) = &scope.entries[from].kind
1182        && let StepResult::Found(id) = navigate_scope(child, focused, reverse, false)
1183    {
1184        return StepResult::Found(id);
1185    }
1186
1187    step_to_sibling(scope, from, reverse, is_root)
1188}
1189
1190/// Step from entry `from` to the next/previous *non-empty* sibling, applying
1191/// the boundary policy (wrap vs. escape) and entering the chosen entry from
1192/// its near edge. Bounded to one full lap.
1193fn step_to_sibling(scope: &ScopeNode, from: usize, reverse: bool, is_root: bool) -> StepResult {
1194    let n = scope.entries.len();
1195    let mut idx = from;
1196    for _ in 0..n {
1197        let next = if reverse {
1198            if idx == 0 {
1199                if scope_wraps(scope.policy, is_root) {
1200                    n - 1
1201                } else {
1202                    return StepResult::Escaped;
1203                }
1204            } else {
1205                idx - 1
1206            }
1207        } else if idx + 1 >= n {
1208            if scope_wraps(scope.policy, is_root) {
1209                0
1210            } else {
1211                return StepResult::Escaped;
1212            }
1213        } else {
1214            idx + 1
1215        };
1216
1217        match &scope.entries[next].kind {
1218            ScopeEntryKind::Focusable(id) => return StepResult::Found(*id),
1219            ScopeEntryKind::Scope(child) => {
1220                if let Some(id) = enter_scope_edge(child, reverse) {
1221                    return StepResult::Found(id);
1222                }
1223                // Empty nested scope: keep stepping past it.
1224                idx = next;
1225            }
1226        }
1227    }
1228    StepResult::Escaped
1229}
1230
1231#[cfg(test)]
1232mod tests {
1233    use super::*;
1234    use crate::test_widgets::FillWidget;
1235    use crate::widget_builder::WidgetBuilder;
1236
1237    /// **A reveal walks the rect's owner's ancestors, not the asker's.**
1238    ///
1239    /// The default — walk from whoever handled the event — is right whenever a widget
1240    /// reveals something inside itself, and silently wrong the moment the rect belongs
1241    /// elsewhere. A find banner's Next button sits *beside* the scrolling page, so a walk
1242    /// from the button climbs out through the banner and never meets the scroll container
1243    /// the match is in: the match is selected, the counter moves, and the viewport does
1244    /// not follow. Naming the owner is what fixes that, and this pins both halves.
1245    #[test]
1246    fn a_reveal_walks_the_ancestors_of_whoever_owns_the_rect() {
1247        use crate::event::{EventResponse, ScrollAlign, ScrollMotion};
1248        use std::cell::Cell;
1249        use std::rc::Rc;
1250
1251        let asked = Rc::new(Cell::new(0usize));
1252        let mut tree = WidgetTree::new();
1253        // A clipping container that records every reveal it is asked for.
1254        let viewport = {
1255            let asked = asked.clone();
1256            tree.add(
1257                // `on_scroll` first: `clips_children` is also a `Widget` trait *method*,
1258                // so calling it on the bare widget resolves to the getter.
1259                FillWidget::new()
1260                    .on_scroll(move |event, _ctx| {
1261                        if matches!(event, WidgetEvent::ScrollIntoView { .. }) {
1262                            asked.set(asked.get() + 1);
1263                        }
1264                        EventResponse::Ignored
1265                    })
1266                    .clips_children(true),
1267            )
1268        };
1269        let inside = tree.add_child(viewport, FillWidget::new());
1270        // The "button": a sibling of the viewport, not a descendant — the banner's shape.
1271        let button = tree.add(FillWidget::new());
1272        tree.layout(SizeProposal::exact(400.0, 300.0));
1273
1274        // Far below the viewport, so a container that hears about it must scroll.
1275        let target = Rect::new(0.0, 5_000.0, 10.0, 10.0);
1276        let mut ops = crate::window::NoopWindowOps;
1277
1278        tree.scroll_rect_into_view(
1279            button,
1280            target,
1281            0.0,
1282            ScrollAlign::Minimal,
1283            ScrollMotion::Instant,
1284            &mut ops,
1285        );
1286        assert_eq!(
1287            asked.get(),
1288            0,
1289            "walking from the asker never reaches a viewport it is not inside"
1290        );
1291
1292        tree.scroll_rect_into_view(
1293            inside,
1294            target,
1295            0.0,
1296            ScrollAlign::Minimal,
1297            ScrollMotion::Instant,
1298            &mut ops,
1299        );
1300        assert_eq!(
1301            asked.get(),
1302            1,
1303            "walking from the rect's owner reaches the viewport enclosing it"
1304        );
1305    }
1306
1307    /// A `clips_children` container around `child`, recording every reveal it
1308    /// is asked for and the rectangle it was asked with.
1309    fn recorder(
1310        tree: &mut WidgetTree,
1311        child: WidgetId,
1312        seen: std::rc::Rc<std::cell::RefCell<Vec<Rect>>>,
1313    ) -> WidgetId {
1314        use crate::event::EventResponse;
1315        use crate::test_widgets::StackWidget;
1316        tree.add(
1317            StackWidget::new()
1318                .child(child)
1319                .on_scroll(move |event, _ctx| {
1320                    if let WidgetEvent::ScrollIntoView { target_bounds, .. } = event {
1321                        seen.borrow_mut().push(*target_bounds);
1322                    }
1323                    EventResponse::Ignored
1324                })
1325                .clips_children(true),
1326        )
1327    }
1328
1329    /// **The reveal walk crosses a content transform.**
1330    ///
1331    /// A `SceneView` is a fixed viewport over content in a coordinate system of
1332    /// its own: a card at scene (100, 100) keeps arena bounds of (100, 100)
1333    /// however far the camera has panned. The walk used to compare that
1334    /// rectangle directly against the view's *window* viewport and conclude
1335    /// there was nothing to reveal — which is why focus-follow inside an
1336    /// embedded editor never fired at a non-zero pan, and why anything further
1337    /// out then received a rectangle from a coordinate system it had never
1338    /// heard of.
1339    #[test]
1340    fn a_reveal_projects_the_rect_as_it_climbs_out_of_a_content_transform() {
1341        use crate::event::{ScrollAlign, ScrollMotion};
1342        use std::cell::RefCell;
1343        use std::rc::Rc;
1344        use teksilo_canvas::Transform2D;
1345
1346        let outer_seen: Rc<RefCell<Vec<Rect>>> = Rc::new(RefCell::new(Vec::new()));
1347        let view_seen: Rc<RefCell<Vec<Rect>>> = Rc::new(RefCell::new(Vec::new()));
1348
1349        let mut tree = WidgetTree::new();
1350        let card = tree.add(FillWidget::new());
1351        let view = recorder(&mut tree, card, view_seen.clone());
1352        let _outer = recorder(&mut tree, view, outer_seen.clone());
1353        tree.layout(SizeProposal::exact(400.0, 300.0));
1354        // The camera: panned 90 px left, so scene x = 100 paints at window
1355        // x = 10 — inside the viewport, and not where the arena says it is.
1356        tree.set_content_transform(view, Transform2D::translate(-90.0, 0.0));
1357
1358        let card_rect = Rect::new(100.0, 100.0, 50.0, 50.0);
1359        let mut ops = crate::window::NoopWindowOps;
1360        tree.scroll_rect_into_view(
1361            card,
1362            card_rect,
1363            0.0,
1364            ScrollAlign::Minimal,
1365            ScrollMotion::Instant,
1366            &mut ops,
1367        );
1368
1369        assert!(
1370            view_seen.borrow().is_empty(),
1371            "the card paints at window x = 10, inside the 400×300 viewport — \
1372             comparing its scene rect against the viewport is what used to \
1373             produce a spurious verdict here, in either direction"
1374        );
1375        assert!(
1376            outer_seen.borrow().is_empty(),
1377            "and nothing outside it has a reason to scroll either"
1378        );
1379
1380        // Now pan so the card is genuinely off-screen to the left. Its arena
1381        // bounds have not moved — only the camera has — so a walk that ignores
1382        // the transform still sees a card at (100, 100) and does nothing.
1383        tree.set_content_transform(view, Transform2D::translate(-600.0, 0.0));
1384        tree.scroll_rect_into_view(
1385            card,
1386            card_rect,
1387            0.0,
1388            ScrollAlign::Minimal,
1389            ScrollMotion::Instant,
1390            &mut ops,
1391        );
1392        assert_eq!(
1393            view_seen.borrow().as_slice(),
1394            &[card_rect],
1395            "the view is asked to reveal the card, in the view's own content \
1396             space — the space `SceneView::ensure_visible` takes by signature"
1397        );
1398        assert_eq!(
1399            outer_seen.borrow().as_slice(),
1400            &[Rect::new(-500.0, 100.0, 50.0, 50.0)],
1401            "and everything further out is asked in window space, which is the \
1402             only space it can compare against its own viewport"
1403        );
1404    }
1405
1406    /// The control for the test above: with no transform in the chain the walk
1407    /// is unchanged, which is the case the whole rest of the framework is.
1408    #[test]
1409    fn a_reveal_with_no_transform_in_the_chain_passes_the_rect_through() {
1410        use crate::event::{ScrollAlign, ScrollMotion};
1411        use std::cell::RefCell;
1412        use std::rc::Rc;
1413
1414        let seen: Rc<RefCell<Vec<Rect>>> = Rc::new(RefCell::new(Vec::new()));
1415        let mut tree = WidgetTree::new();
1416        let inside = tree.add(FillWidget::new());
1417        let _viewport = recorder(&mut tree, inside, seen.clone());
1418        tree.layout(SizeProposal::exact(400.0, 300.0));
1419
1420        let target = Rect::new(0.0, 5_000.0, 10.0, 10.0);
1421        let mut ops = crate::window::NoopWindowOps;
1422        tree.scroll_rect_into_view(
1423            inside,
1424            target,
1425            0.0,
1426            ScrollAlign::Minimal,
1427            ScrollMotion::Instant,
1428            &mut ops,
1429        );
1430        assert_eq!(seen.borrow().as_slice(), &[target]);
1431    }
1432
1433    #[test]
1434    fn focus_widget() {
1435        let mut tree = WidgetTree::new();
1436        let widget = tree.add(FillWidget::new());
1437        tree.layout(SizeProposal::exact(100.0, 50.0));
1438        tree.focus(widget);
1439        assert_eq!(tree.focused(), Some(widget));
1440    }
1441
1442    #[test]
1443    fn focus_change() {
1444        let mut tree = WidgetTree::new();
1445        let a = tree.add(FillWidget::new());
1446        let b = tree.add(FillWidget::new());
1447        tree.layout(SizeProposal::exact(100.0, 50.0));
1448        tree.focus(a);
1449        assert_eq!(tree.focused(), Some(a));
1450        tree.focus(b);
1451        assert_eq!(tree.focused(), Some(b));
1452    }
1453
1454    /// A container that rebuilds its focusable children whenever `epoch` bumps
1455    /// — the shape of every data-driven view: a `ListView` on a model update, a
1456    /// popover re-scanning its content when it opens.
1457    #[derive(Debug)]
1458    struct RebuildingRows {
1459        epoch: crate::signal::Signal<u64>,
1460        rows: Vec<WidgetId>,
1461    }
1462
1463    impl Widget for RebuildingRows {
1464        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1465            let sid = ctx.self_id();
1466            let reg = ctx.binding_registry();
1467            self.epoch
1468                .bind_to(sid, reg, crate::binding::BindingLevel::Rebuild);
1469            self.rows = (0..3)
1470                .map(|_| ctx.add(FillWidget::new().focusable()))
1471                .collect();
1472            self.rows.clone()
1473        }
1474
1475        fn layout_response(
1476            &self,
1477            proposal: SizeProposal,
1478            _ctx: &LayoutContext,
1479        ) -> crate::widget::LayoutResponse {
1480            proposal.resolve(0.0, 0.0).into()
1481        }
1482
1483        fn children(&self) -> Vec<WidgetId> {
1484            self.rows.clone()
1485        }
1486    }
1487
1488    /// A rebuild must keep focus **inside the subtree that owned it**.
1489    ///
1490    /// A rebuild destroys its children and allocates fresh `WidgetId`s, so the
1491    /// focused node dies. Dropping focus to `None` there (what
1492    /// `revalidate_interaction_state` does with any dead focus) kicks the user
1493    /// clean out of the widget they were in — most visibly, a popover that
1494    /// refreshes its content when it opens would throw away the very row the
1495    /// popover had just focused, and the menu would come up with no keyboard
1496    /// focus at all: no arrow keys, no Enter.
1497    #[test]
1498    fn a_rebuild_keeps_focus_inside_the_subtree_that_had_it() {
1499        let mut tree = WidgetTree::new();
1500        let epoch = crate::signal::Signal::new(0u64);
1501        let root = tree.add(RebuildingRows {
1502            epoch: epoch.clone(),
1503            rows: Vec::new(),
1504        });
1505        tree.layout(SizeProposal::exact(100.0, 60.0));
1506
1507        let first_row = tree
1508            .first_focusable_descendant(root)
1509            .expect("the rows are focusable");
1510        tree.focus(first_row);
1511        assert_eq!(tree.focused(), Some(first_row));
1512
1513        // Rebuild: every row is destroyed and re-allocated.
1514        epoch.set(1);
1515        tree.layout(SizeProposal::exact(100.0, 60.0));
1516
1517        let focused = tree
1518            .focused()
1519            .expect("a rebuild must not drop focus out of the rebuilt subtree");
1520        assert_ne!(
1521            focused, first_row,
1522            "the old row is dead — focus must have moved to a freshly built one"
1523        );
1524        assert!(
1525            tree.is_descendant_of(focused, root),
1526            "focus must land back inside the rebuilt subtree"
1527        );
1528        assert!(tree.is_active(focused), "the focused node must be live");
1529    }
1530
1531    /// The restore lands where focus was, not on the first focusable: focus on
1532    /// the third row comes back to the third row, and the widget hears that it
1533    /// was put back (`Restored`), not that something moved it.
1534    ///
1535    /// It used to land on the rebuilt subtree's first focusable whatever the
1536    /// user had been in, so typing after a rebuild of a form went on in its
1537    /// first field.
1538    #[test]
1539    fn a_rebuild_restores_focus_to_the_position_it_had() {
1540        let mut tree = WidgetTree::new();
1541        let epoch = crate::signal::Signal::new(0u64);
1542        let root = tree.add(RebuildingRows {
1543            epoch: epoch.clone(),
1544            rows: Vec::new(),
1545        });
1546        tree.layout(SizeProposal::exact(100.0, 60.0));
1547        let third = tree.children(root)[2];
1548        tree.focus(third);
1549
1550        epoch.set(1);
1551        tree.layout(SizeProposal::exact(100.0, 60.0));
1552
1553        let rows = tree.children(root);
1554        assert_ne!(rows[2], third, "precondition: the rows were rebuilt");
1555        assert_eq!(tree.focused(), Some(rows[2]), "the third row again");
1556        assert_eq!(
1557            tree.focus_origin(),
1558            Some(crate::focus::FocusOrigin::Restored)
1559        );
1560    }
1561
1562    /// The restore is scoped: a rebuild that did *not* own focus must leave
1563    /// focus exactly where it was, rather than yanking it into the rebuilt
1564    /// subtree. Otherwise any background list update would steal the caret out
1565    /// of whatever the user was typing in.
1566    #[test]
1567    fn a_rebuild_elsewhere_does_not_steal_focus() {
1568        let mut tree = WidgetTree::new();
1569        let epoch = crate::signal::Signal::new(0u64);
1570        let outsider = tree.add(FillWidget::new().focusable());
1571        let _rows = tree.add(RebuildingRows {
1572            epoch: epoch.clone(),
1573            rows: Vec::new(),
1574        });
1575        tree.layout(SizeProposal::exact(100.0, 60.0));
1576
1577        tree.focus(outsider);
1578        assert_eq!(tree.focused(), Some(outsider));
1579
1580        epoch.set(1);
1581        tree.layout(SizeProposal::exact(100.0, 60.0));
1582
1583        assert_eq!(
1584            tree.focused(),
1585            Some(outsider),
1586            "a rebuild that never held focus must not pull focus into itself"
1587        );
1588    }
1589
1590    /// A reconciling container: it builds its groups once and re-attaches the
1591    /// same ones on every later build, the shape of a toast host keeping one
1592    /// surface per live toast, or a tab widget keeping its pages.
1593    #[derive(Debug)]
1594    struct KeepingGroups {
1595        epoch: crate::signal::Signal<u64>,
1596        group_epochs: Vec<crate::signal::Signal<u64>>,
1597        groups: Vec<WidgetId>,
1598    }
1599
1600    impl Widget for KeepingGroups {
1601        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1602            let sid = ctx.self_id();
1603            let reg = ctx.binding_registry();
1604            self.epoch
1605                .bind_to(sid, reg, crate::binding::BindingLevel::Rebuild);
1606            if self.groups.is_empty() {
1607                self.groups = self
1608                    .group_epochs
1609                    .iter()
1610                    .map(|epoch| {
1611                        ctx.add(RebuildingRows {
1612                            epoch: epoch.clone(),
1613                            rows: Vec::new(),
1614                        })
1615                    })
1616                    .collect();
1617            }
1618            self.groups.clone()
1619        }
1620
1621        fn layout_response(
1622            &self,
1623            proposal: SizeProposal,
1624            _ctx: &LayoutContext,
1625        ) -> crate::widget::LayoutResponse {
1626            proposal.resolve(0.0, 0.0).into()
1627        }
1628
1629        fn children(&self) -> Vec<WidgetId> {
1630            self.groups.clone()
1631        }
1632
1633        fn preserves_children_on_rebuild(&self) -> bool {
1634            true
1635        }
1636    }
1637
1638    /// When the focused node dies in a rebuild, focus goes back into the
1639    /// **innermost** ancestor that survived it, not into the outermost root
1640    /// that was rebuilt.
1641    ///
1642    /// A reconciling container keeps its children across its own rebuild, so
1643    /// the group that held focus is still there when one of its rows is
1644    /// replaced. Restoring into the container's first focusable descendant
1645    /// put a keyboard user in the *first* group whatever group they were in:
1646    /// in a toast stack that is the oldest toast, so a reader on the Cancel
1647    /// of a progress toast was thrown onto another toast at every step.
1648    #[test]
1649    fn a_rebuild_restores_focus_into_the_innermost_ancestor_that_survived() {
1650        let mut tree = WidgetTree::new();
1651        let epoch = crate::signal::Signal::new(0u64);
1652        let group_epochs = vec![
1653            crate::signal::Signal::new(0u64),
1654            crate::signal::Signal::new(0u64),
1655        ];
1656        let root = tree.add(KeepingGroups {
1657            epoch: epoch.clone(),
1658            group_epochs: group_epochs.clone(),
1659            groups: Vec::new(),
1660        });
1661        tree.layout(SizeProposal::exact(100.0, 60.0));
1662
1663        let groups = tree.children(root);
1664        let second_group = groups[1];
1665        let row = tree
1666            .first_focusable_descendant(second_group)
1667            .expect("the second group's rows are focusable");
1668        tree.focus(row);
1669
1670        // The container and the second group rebuild in the same pass: the
1671        // group survives (the container re-attaches it), the row does not.
1672        epoch.set(1);
1673        group_epochs[1].set(1);
1674        tree.layout(SizeProposal::exact(100.0, 60.0));
1675
1676        assert_eq!(
1677            tree.children(root),
1678            groups,
1679            "the container kept both groups (the premise of this test)"
1680        );
1681        let focused = tree.focused().expect("focus must not be dropped");
1682        assert_ne!(focused, row, "the old row is dead");
1683        assert!(
1684            tree.is_descendant_of(focused, second_group),
1685            "focus must land back in the group that held it, the innermost \
1686             ancestor that survived, not in the first group of the container"
1687        );
1688    }
1689
1690    #[test]
1691    fn first_focusable_descendant_prefers_first_focusable_child() {
1692        let mut tree = WidgetTree::new();
1693        let a = tree.add(FillWidget::new().focusable());
1694        let _not_focusable = tree.add(FillWidget::new());
1695        let b = tree.add(FillWidget::new().focusable());
1696        let root = tree.add(crate::test_widgets::StackWidget::new().child(a).child(b));
1697        tree.layout(SizeProposal::exact(100.0, 50.0));
1698
1699        assert_eq!(tree.first_focusable_descendant(root), Some(a));
1700    }
1701
1702    #[test]
1703    fn tab_cycles_through_focusable_widgets() {
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        assert_eq!(tree.focused(), None);
1711
1712        tree.press_key(Key::Tab, Modifiers::NONE);
1713        assert_eq!(tree.focused(), Some(a));
1714
1715        tree.press_key(Key::Tab, Modifiers::NONE);
1716        assert_eq!(tree.focused(), Some(b));
1717
1718        tree.press_key(Key::Tab, Modifiers::NONE);
1719        assert_eq!(tree.focused(), Some(c));
1720
1721        tree.press_key(Key::Tab, Modifiers::NONE);
1722        assert_eq!(tree.focused(), Some(a));
1723    }
1724
1725    #[test]
1726    fn tab_stop_on_composite_excludes_focusable_descendant() {
1727        // The roving-tabindex case: a composite control (here a StackWidget
1728        // standing in for ComboBox / IconButton) carries the `tab_stop` flag
1729        // on its composing node, but its real focusable node is an inner
1730        // leaf. Suppressing the composite must remove that leaf from Tab.
1731        let mut tree = WidgetTree::new();
1732        let leaf = tree.add(FillWidget::new().focusable());
1733        let composite = tree.add(crate::test_widgets::StackWidget::new().child(leaf));
1734        let other = tree.add(FillWidget::new().focusable());
1735        tree.layout(SizeProposal::exact(100.0, 50.0));
1736
1737        tree.set_tab_stop(composite, false);
1738
1739        tree.press_key(Key::Tab, Modifiers::NONE);
1740        assert_eq!(
1741            tree.focused(),
1742            Some(other),
1743            "Tab must skip the suppressed composite's inner leaf"
1744        );
1745        tree.press_key(Key::Tab, Modifiers::NONE);
1746        assert_eq!(
1747            tree.focused(),
1748            Some(other),
1749            "only the un-suppressed control participates in Tab"
1750        );
1751
1752        // Re-enabling the composite brings its inner leaf back into Tab.
1753        tree.set_tab_stop(composite, true);
1754        tree.set_focused(None);
1755        tree.press_key(Key::Tab, Modifiers::NONE);
1756        let first = tree.focused();
1757        tree.press_key(Key::Tab, Modifiers::NONE);
1758        let second = tree.focused();
1759        assert_ne!(
1760            first, second,
1761            "with the composite re-enabled, Tab visits both controls"
1762        );
1763    }
1764
1765    #[test]
1766    fn shift_tab_cycles_backwards() {
1767        let mut tree = WidgetTree::new();
1768        let a = tree.add(FillWidget::new().focusable());
1769        let b = tree.add(FillWidget::new().focusable());
1770        let c = tree.add(FillWidget::new().focusable());
1771        tree.layout(SizeProposal::exact(100.0, 50.0));
1772
1773        tree.press_key(Key::Tab, Modifiers::NONE);
1774        assert_eq!(tree.focused(), Some(a));
1775
1776        tree.press_key(Key::Tab, Modifiers::SHIFT);
1777        assert_eq!(tree.focused(), Some(c));
1778
1779        tree.press_key(Key::Tab, Modifiers::SHIFT);
1780        assert_eq!(tree.focused(), Some(b));
1781    }
1782
1783    #[test]
1784    fn tab_skips_non_focusable_widgets() {
1785        let mut tree = WidgetTree::new();
1786        let _not_focusable = tree.add(FillWidget::new());
1787        let a = tree.add(FillWidget::new().focusable());
1788        let _also_not = tree.add(FillWidget::new());
1789        let b = tree.add(FillWidget::new().focusable());
1790        tree.layout(SizeProposal::exact(100.0, 50.0));
1791
1792        tree.press_key(Key::Tab, Modifiers::NONE);
1793        assert_eq!(tree.focused(), Some(a));
1794
1795        tree.press_key(Key::Tab, Modifiers::NONE);
1796        assert_eq!(tree.focused(), Some(b));
1797    }
1798
1799    /// `focus` is a command, not a query: it moves focus to the node it is
1800    /// handed without asking whether traversal could ever land there.
1801    ///
1802    /// This is why a test claiming **keyboard reachability** has to read the
1803    /// traversal graph — `tab_stops_within` — rather than focus its subject and
1804    /// press a key. The latter is green for a control no keyboard user can
1805    /// reach, which is how a widget once lost `focusable(true)` with all six of
1806    /// its tests still passing. If this behaviour ever grows a guard, the
1807    /// reachability recipe in `docs/a11y/non-drag-alternatives.md` and the
1808    /// comments citing it are what to revisit.
1809    #[test]
1810    fn focus_does_not_check_that_the_node_is_focusable() {
1811        let mut tree = WidgetTree::new();
1812        let inert = tree.add(FillWidget::new()); // no `.focusable()`
1813        tree.layout(SizeProposal::exact(100.0, 50.0));
1814        assert!(
1815            tree.tab_stops_within(inert).is_empty(),
1816            "the subject has to be unreachable for the point to be made"
1817        );
1818
1819        tree.focus(inert);
1820        assert_eq!(
1821            tree.focused(),
1822            Some(inert),
1823            "focus lands on a node Tab traversal can never offer"
1824        );
1825    }
1826
1827    #[test]
1828    fn tab_focus_has_keyboard_origin() {
1829        let mut tree = WidgetTree::new();
1830        tree.add(FillWidget::new().focusable());
1831        tree.layout(SizeProposal::exact(100.0, 50.0));
1832
1833        tree.press_key(Key::Tab, Modifiers::NONE);
1834        assert_eq!(
1835            tree.focus_origin(),
1836            Some(crate::focus::FocusOrigin::Keyboard)
1837        );
1838    }
1839
1840    #[test]
1841    fn tab_skips_focusable_inside_disabled_ancestor() {
1842        use crate::signal::Signal;
1843        use crate::test_widgets::StackWidget;
1844
1845        let mut tree = WidgetTree::new();
1846        let a = tree.add(FillWidget::new().focusable());
1847        let inner = tree.add(FillWidget::new().focusable());
1848        let disabled_container = tree.add(StackWidget::new().child(inner));
1849        let c = tree.add(FillWidget::new().focusable());
1850        tree.enabled_when(disabled_container, Signal::new(false));
1851        tree.layout(SizeProposal::exact(200.0, 100.0));
1852
1853        tree.press_key(Key::Tab, Modifiers::NONE);
1854        assert_eq!(tree.focused(), Some(a));
1855
1856        tree.press_key(Key::Tab, Modifiers::NONE);
1857        assert_eq!(
1858            tree.focused(),
1859            Some(c),
1860            "tab should skip the focusable widget nested inside the disabled container"
1861        );
1862    }
1863
1864    #[test]
1865    fn dormant_widget_not_in_focus_cycle() {
1866        let mut tree = WidgetTree::new();
1867        let a = tree.add(FillWidget::new().focusable());
1868        let b = tree.add(FillWidget::new().focusable());
1869        let c = tree.add(FillWidget::new().focusable());
1870        tree.layout(SizeProposal::exact(200.0, 100.0));
1871
1872        tree.focus(a);
1873        tree.set_dormant(b);
1874
1875        tree.press_key(Key::Tab, Modifiers::NONE);
1876        assert_eq!(tree.focused(), Some(c));
1877    }
1878
1879    /// Parking a focused widget dormant (Switcher / `visible_when`) must
1880    /// deliver `FocusLost` so the widget clears local focus state. Without
1881    /// that, a rich-text editor keeps `has_focus` and schedules caret wakes
1882    /// forever — the multi-tab CPU creep Skribisto hit on rapid tab switches.
1883    #[test]
1884    fn revalidate_delivers_focus_lost_when_focused_widget_goes_dormant() {
1885        use std::cell::Cell;
1886        use std::rc::Rc;
1887
1888        let lost = Rc::new(Cell::new(0_u32));
1889        let gained = Rc::new(Cell::new(0_u32));
1890        let lost_c = lost.clone();
1891        let gained_c = gained.clone();
1892
1893        let mut tree = WidgetTree::new();
1894        let editor = tree.add(
1895            FillWidget::new()
1896                .focusable()
1897                .on_focus(move |is_gained, _ctx| {
1898                    if is_gained {
1899                        gained_c.set(gained_c.get() + 1);
1900                    } else {
1901                        lost_c.set(lost_c.get() + 1);
1902                    }
1903                }),
1904        );
1905        let _other = tree.add(FillWidget::new().focusable());
1906        tree.layout(SizeProposal::exact(200.0, 100.0));
1907
1908        tree.focus(editor);
1909        assert_eq!(tree.focused(), Some(editor));
1910        assert_eq!(gained.get(), 1, "focus() delivers FocusGained");
1911        assert_eq!(lost.get(), 0);
1912
1913        // Park the focused editor dormant — the Switcher / tab-switch path.
1914        tree.set_dormant(editor);
1915        // Revalidate is what layout runs after the visibility pass.
1916        let mut noop = crate::window::NoopWindowOps;
1917        tree.revalidate_interaction_state(&mut noop);
1918
1919        assert_eq!(
1920            tree.focused(),
1921            None,
1922            "tree focus must clear when the target is dormant"
1923        );
1924        assert_eq!(
1925            lost.get(),
1926            1,
1927            "FocusLost must reach the dormant widget so it can clear has_focus / caret blink"
1928        );
1929    }
1930
1931    #[test]
1932    fn tab_cycles_focus_in_tree_order() {
1933        let mut tree = WidgetTree::new();
1934        let a = tree.add(FillWidget::new().focusable());
1935        let b = tree.add(FillWidget::new().focusable());
1936        tree.layout(SizeProposal::exact(200.0, 80.0));
1937
1938        tree.focus(a);
1939        assert_eq!(tree.focused(), Some(a));
1940
1941        tree.press_key(Key::Tab, Modifiers::NONE);
1942        assert_eq!(tree.focused(), Some(b));
1943
1944        tree.press_key(Key::Tab, Modifiers::SHIFT);
1945        assert_eq!(tree.focused(), Some(a));
1946    }
1947
1948    #[test]
1949    fn focus_survives_theme_switch() {
1950        let mut tree = WidgetTree::new();
1951        let a = tree.add(FillWidget::new().focusable());
1952        let _b = tree.add(FillWidget::new().focusable());
1953        tree.layout(SizeProposal::exact(200.0, 80.0));
1954
1955        tree.focus(a);
1956        assert_eq!(tree.focused(), Some(a));
1957
1958        tree.set_theme(crate::presets::intui::dark());
1959        tree.layout(SizeProposal::exact(200.0, 80.0));
1960
1961        assert_eq!(
1962            tree.focused(),
1963            Some(a),
1964            "theme switch must not clobber focus"
1965        );
1966    }
1967
1968    #[test]
1969    fn focus_survives_locale_switch() {
1970        let mut tree = WidgetTree::new();
1971        let a = tree.add(FillWidget::new().focusable());
1972        tree.layout(SizeProposal::exact(200.0, 80.0));
1973
1974        tree.focus(a);
1975        tree.set_locale("fr-FR".to_string());
1976        tree.layout(SizeProposal::exact(200.0, 80.0));
1977
1978        assert_eq!(
1979            tree.focused(),
1980            Some(a),
1981            "locale switch must not clobber focus"
1982        );
1983    }
1984
1985    // ── focus_within / hover_within ─────────────────────────────
1986
1987    #[test]
1988    fn focus_within_flips_when_descendant_takes_focus() {
1989        use crate::signal::Signal;
1990        use crate::test_widgets::StackWidget;
1991        use crate::widget_builder::WidgetBuilder;
1992
1993        let halo = Signal::new(false);
1994
1995        let mut tree = WidgetTree::new();
1996        let leaf = tree.add(FillWidget::new().focusable());
1997        let mid = tree.add(StackWidget::new().child(leaf));
1998        let _outer = tree.add(StackWidget::new().child(mid).focus_within(halo.clone()));
1999
2000        tree.layout(SizeProposal::exact(100.0, 50.0));
2001        assert!(!halo.get(), "no focus yet → signal is false");
2002
2003        tree.focus(leaf);
2004        assert!(halo.get(), "leaf now focused, outer is its strict ancestor");
2005    }
2006
2007    #[test]
2008    fn focus_within_strict_ancestors_only() {
2009        // A widget that is itself focused must NOT see its own
2010        // focus_within signal flipped to true.
2011        use crate::signal::Signal;
2012        use crate::widget_builder::WidgetBuilder;
2013
2014        let halo = Signal::new(false);
2015
2016        let mut tree = WidgetTree::new();
2017        let widget = tree.add(FillWidget::new().focusable().focus_within(halo.clone()));
2018        tree.layout(SizeProposal::exact(100.0, 50.0));
2019        tree.focus(widget);
2020
2021        assert!(
2022            !halo.get(),
2023            "focusing the widget itself must not set its own focus_within"
2024        );
2025    }
2026
2027    #[test]
2028    fn view_focus_active_is_inclusive_for_the_view_itself() {
2029        // A non-focusable item inside a focusable "view" reads the view's scope
2030        // focus: true when the view OR a descendant holds focus (inclusive),
2031        // unlike `focus_within` (strict descendants only). This is what powers
2032        // focus-aware selection — the data view holds focus directly, yet its
2033        // selected rows must still render "active".
2034        use crate::test_widgets::StackWidget;
2035        use crate::widget_builder::WidgetBuilder;
2036
2037        let mut tree = WidgetTree::new();
2038        let item = tree.add(FillWidget::new()); // a row item — NOT focusable
2039        let view = tree.add(StackWidget::new().child(item).focusable(true));
2040        let outside = tree.add(FillWidget::new().focusable());
2041        tree.layout(SizeProposal::exact(100.0, 50.0));
2042
2043        let active = tree.view_focus_active_for(item);
2044        assert!(!active.get(), "no focus yet → scope inactive");
2045
2046        tree.focus(view);
2047        assert!(
2048            active.get(),
2049            "view focused directly → scope active (focus_within would be false here)"
2050        );
2051
2052        tree.focus(outside);
2053        assert!(
2054            !active.get(),
2055            "focus moved outside the view → scope inactive"
2056        );
2057    }
2058
2059    #[test]
2060    fn begin_view_focus_for_keys_on_the_view_root_not_the_building_pane() {
2061        // TableView / TreeTableView / GridView build their rows inside a
2062        // separate, non-focusable body *pane*; keyboard focus lands on the
2063        // focusable view *root* (the pane's ancestor). A row's focus scope must
2064        // therefore be keyed on the root via `begin_view_focus_for(root)`, so
2065        // its selection reads "active" when the view is focused. Keying on the
2066        // pane — which never holds focus and is not an ancestor of the focused
2067        // root — would read constant-false (the latent bug this fixes).
2068        use crate::test_widgets::StackWidget;
2069        use crate::widget_builder::WidgetBuilder;
2070
2071        let mut tree = WidgetTree::new();
2072        let row = tree.add(FillWidget::new()); // row item — NOT focusable
2073        let pane = tree.add(StackWidget::new().child(row)); // body pane — NOT focusable
2074        let root = tree.add(StackWidget::new().child(pane).focusable(true));
2075        tree.layout(SizeProposal::exact(100.0, 50.0));
2076
2077        // What the pane opens for its rows: a scope keyed on the root.
2078        let keyed_on_root = tree.begin_view_focus(root);
2079        tree.end_view_focus();
2080        // What keying on the pane itself would have produced (the bug).
2081        let keyed_on_pane = tree.view_focus_signal_for(pane);
2082
2083        assert!(!keyed_on_root.get(), "no focus yet → inactive");
2084        tree.focus(root);
2085        assert!(keyed_on_root.get(), "view root focused → row scope active");
2086        assert!(
2087            !keyed_on_pane.get(),
2088            "pane-keyed scope stays false: the focused root is the pane's ancestor, not its descendant",
2089        );
2090    }
2091
2092    #[test]
2093    fn focus_visible_tracks_input_modality() {
2094        // `:focus-visible` — keyboard input reveals focus rings, pointer input
2095        // hides them. The recipe gates the row focus ring on this signal.
2096        use crate::event::{Key, Modifiers};
2097        let mut tree = WidgetTree::new();
2098        let w = tree.add(FillWidget::new().focusable());
2099        tree.layout(SizeProposal::exact(100.0, 50.0));
2100        let vis = tree.focus_visible_signal();
2101        assert!(!vis.get(), "starts not focus-visible");
2102
2103        tree.press_key(Key::Tab, Modifiers::NONE);
2104        assert!(vis.get(), "keyboard input turns focus-visible ON");
2105
2106        tree.click(w);
2107        assert!(!vis.get(), "pointer input turns focus-visible OFF");
2108
2109        tree.press_key(Key::ArrowDown, Modifiers::NONE);
2110        assert!(vis.get(), "keyboard input turns it back ON");
2111    }
2112
2113    #[test]
2114    fn view_focus_active_without_focusable_ancestor_is_constant_true() {
2115        // An item with no focusable ancestor (e.g. a static list) always reads
2116        // active, so its selection chrome is never muted.
2117        let mut tree = WidgetTree::new();
2118        let item = tree.add(FillWidget::new());
2119        tree.layout(SizeProposal::exact(100.0, 50.0));
2120        assert!(tree.view_focus_active_for(item).get());
2121    }
2122
2123    #[test]
2124    fn focus_within_diff_across_siblings() {
2125        // Tree: root → mid_a [sig_a] → leaf_a, root → mid_b [sig_b] → leaf_b.
2126        // Move focus from leaf_a to leaf_b: sig_a → false, sig_b → true.
2127        use crate::signal::Signal;
2128        use crate::test_widgets::StackWidget;
2129        use crate::widget_builder::WidgetBuilder;
2130
2131        let sig_a = Signal::new(false);
2132        let sig_b = Signal::new(false);
2133
2134        let mut tree = WidgetTree::new();
2135        let leaf_a = tree.add(FillWidget::new().focusable());
2136        let leaf_b = tree.add(FillWidget::new().focusable());
2137        let mid_a = tree.add(StackWidget::new().child(leaf_a).focus_within(sig_a.clone()));
2138        let mid_b = tree.add(StackWidget::new().child(leaf_b).focus_within(sig_b.clone()));
2139        let _root = tree.add(StackWidget::new().child(mid_a).child(mid_b));
2140
2141        tree.layout(SizeProposal::exact(100.0, 50.0));
2142        tree.focus(leaf_a);
2143        assert!(sig_a.get(), "sig_a true after focusing leaf_a");
2144        assert!(!sig_b.get(), "sig_b false: leaf_a is not its descendant");
2145
2146        tree.focus(leaf_b);
2147        assert!(!sig_a.get(), "sig_a flipped to false on focus move");
2148        assert!(sig_b.get(), "sig_b flipped to true on focus move");
2149    }
2150
2151    #[test]
2152    fn focus_within_clears_when_focused_widget_destroyed() {
2153        use crate::signal::Signal;
2154        use crate::test_widgets::StackWidget;
2155        use crate::widget_builder::WidgetBuilder;
2156
2157        let halo = Signal::new(false);
2158
2159        let mut tree = WidgetTree::new();
2160        let leaf = tree.add(FillWidget::new().focusable());
2161        let _outer = tree.add(StackWidget::new().child(leaf).focus_within(halo.clone()));
2162
2163        tree.layout(SizeProposal::exact(100.0, 50.0));
2164        tree.focus(leaf);
2165        assert!(halo.get());
2166
2167        tree.destroy_subtree(leaf);
2168        assert!(
2169            !halo.get(),
2170            "destroying the focused widget must clear focus_within on its ancestors"
2171        );
2172    }
2173
2174    #[test]
2175    fn hover_within_flips_via_pointer_move() {
2176        use crate::signal::Signal;
2177        use crate::test_widgets::StackWidget;
2178        use crate::widget_builder::WidgetBuilder;
2179        use teksilo_canvas::Point;
2180
2181        let glow = Signal::new(false);
2182
2183        let mut tree = WidgetTree::new();
2184        let leaf = tree.add(FillWidget::new());
2185        let _outer = tree.add(StackWidget::new().child(leaf).hover_within(glow.clone()));
2186
2187        tree.layout(SizeProposal::exact(100.0, 50.0));
2188        assert!(!glow.get());
2189
2190        tree.pointer_move(Point::new(50.0, 25.0));
2191        assert!(glow.get(), "pointer over leaf → outer.hover_within = true");
2192
2193        tree.pointer_move(Point::new(500.0, 500.0));
2194        assert!(
2195            !glow.get(),
2196            "pointer moves outside the tree → hover_within clears"
2197        );
2198    }
2199
2200    #[test]
2201    fn hover_within_strict_ancestors_only() {
2202        use crate::signal::Signal;
2203        use crate::widget_builder::WidgetBuilder;
2204        use teksilo_canvas::Point;
2205
2206        let glow = Signal::new(false);
2207
2208        let mut tree = WidgetTree::new();
2209        let _w = tree.add(FillWidget::new().hover_within(glow.clone()));
2210        tree.layout(SizeProposal::exact(100.0, 50.0));
2211        tree.pointer_move(Point::new(50.0, 25.0));
2212
2213        assert!(
2214            !glow.get(),
2215            "hovering the widget itself must not set its own hover_within"
2216        );
2217    }
2218}
2219
2220#[cfg(test)]
2221mod tests_scope {
2222    //! Scope-aware Tab traversal (`FocusScope` / `set_traversal_scope`).
2223    //! These drive the scope marker directly via `WidgetTree::set_traversal_scope`
2224    //! so the algorithm is exercised with no dependency on the widgets crate.
2225
2226    use super::*;
2227    use crate::focus::TraversalScopePolicy::{Continue, Cycle};
2228    use crate::test_widgets::{FillWidget, StackWidget};
2229    use crate::widget_builder::WidgetBuilder;
2230
2231    /// A focusable leaf with an explicit scoped `tab_index`.
2232    fn indexed(tree: &mut WidgetTree, idx: i32) -> WidgetId {
2233        tree.add(FillWidget::new().focusable().tab_index(idx))
2234    }
2235
2236    fn tab(tree: &mut WidgetTree) {
2237        tree.press_key(Key::Tab, Modifiers::NONE);
2238    }
2239    fn shift_tab(tree: &mut WidgetTree) {
2240        tree.press_key(Key::Tab, Modifiers::SHIFT);
2241    }
2242
2243    #[test]
2244    fn overlapping_tab_index_in_sibling_scopes_does_not_interleave() {
2245        let mut tree = WidgetTree::new();
2246        let a1 = indexed(&mut tree, 1);
2247        let a2 = indexed(&mut tree, 2);
2248        let scope_a = tree.add(StackWidget::new().child(a1).child(a2));
2249        let b1 = indexed(&mut tree, 1);
2250        let b2 = indexed(&mut tree, 2);
2251        let scope_b = tree.add(StackWidget::new().child(b1).child(b2));
2252        let _root = tree.add(StackWidget::new().child(scope_a).child(scope_b));
2253        tree.set_traversal_scope(scope_a, Continue);
2254        tree.set_traversal_scope(scope_b, Continue);
2255        tree.layout(SizeProposal::exact(100.0, 100.0));
2256
2257        // Grouped: a1,a2 then b1,b2 — never a1,b1,a2,b2.
2258        for expected in [a1, a2, b1, b2, a1] {
2259            tab(&mut tree);
2260            assert_eq!(tree.focused(), Some(expected));
2261        }
2262    }
2263
2264    #[test]
2265    fn continue_scope_flows_out_at_the_ends() {
2266        // root[A, scopeC(Continue)[c1,c2], B] — no tab_index, DFS order.
2267        let mut tree = WidgetTree::new();
2268        let a = tree.add(FillWidget::new().focusable());
2269        let c1 = tree.add(FillWidget::new().focusable());
2270        let c2 = tree.add(FillWidget::new().focusable());
2271        let scope_c = tree.add(StackWidget::new().child(c1).child(c2));
2272        let b = tree.add(FillWidget::new().focusable());
2273        let _root = tree.add(StackWidget::new().child(a).child(scope_c).child(b));
2274        tree.set_traversal_scope(scope_c, Continue);
2275        tree.layout(SizeProposal::exact(100.0, 100.0));
2276
2277        for expected in [a, c1, c2, b, a] {
2278            tab(&mut tree);
2279            assert_eq!(tree.focused(), Some(expected));
2280        }
2281        // Reverse: from c1, Shift+Tab leaves the scope to A (not c2).
2282        tree.focus(c1);
2283        shift_tab(&mut tree);
2284        assert_eq!(tree.focused(), Some(a));
2285    }
2286
2287    #[test]
2288    fn cycle_scope_wraps_and_never_escapes() {
2289        // root[A, scopeD(Cycle)[d1,d2]].
2290        let mut tree = WidgetTree::new();
2291        let a = tree.add(FillWidget::new().focusable());
2292        let d1 = tree.add(FillWidget::new().focusable());
2293        let d2 = tree.add(FillWidget::new().focusable());
2294        let scope_d = tree.add(StackWidget::new().child(d1).child(d2));
2295        let _root = tree.add(StackWidget::new().child(a).child(scope_d));
2296        tree.set_traversal_scope(scope_d, Cycle);
2297        tree.layout(SizeProposal::exact(100.0, 100.0));
2298
2299        tree.focus(d1);
2300        for _ in 0..10 {
2301            tab(&mut tree);
2302            let f = tree.focused();
2303            assert!(
2304                f == Some(d1) || f == Some(d2),
2305                "Cycle scope must trap Tab inside {{d1,d2}}, got {f:?}"
2306            );
2307        }
2308        // Forward d1→d2→d1 and reverse d1→d2 (wrap at the start).
2309        tree.focus(d1);
2310        tab(&mut tree);
2311        assert_eq!(tree.focused(), Some(d2));
2312        shift_tab(&mut tree);
2313        assert_eq!(tree.focused(), Some(d1));
2314        shift_tab(&mut tree);
2315        assert_eq!(tree.focused(), Some(d2), "Shift+Tab at start wraps to last");
2316    }
2317
2318    #[test]
2319    fn empty_scope_is_skipped() {
2320        // root[A, emptyScope(Continue)[], B].
2321        let mut tree = WidgetTree::new();
2322        let a = tree.add(FillWidget::new().focusable());
2323        let empty = tree.add(StackWidget::new());
2324        let b = tree.add(FillWidget::new().focusable());
2325        let _root = tree.add(StackWidget::new().child(a).child(empty).child(b));
2326        tree.set_traversal_scope(empty, Continue);
2327        tree.layout(SizeProposal::exact(100.0, 100.0));
2328
2329        for expected in [a, b, a] {
2330            tab(&mut tree);
2331            assert_eq!(tree.focused(), Some(expected));
2332        }
2333    }
2334
2335    #[test]
2336    fn single_member_cycle_scope_stays_put() {
2337        let mut tree = WidgetTree::new();
2338        let e = tree.add(FillWidget::new().focusable());
2339        let scope_e = tree.add(StackWidget::new().child(e));
2340        tree.set_traversal_scope(scope_e, Cycle);
2341        tree.layout(SizeProposal::exact(100.0, 50.0));
2342
2343        tree.focus(e);
2344        tab(&mut tree);
2345        assert_eq!(tree.focused(), Some(e));
2346        shift_tab(&mut tree);
2347        assert_eq!(tree.focused(), Some(e));
2348    }
2349
2350    #[test]
2351    fn nested_continue_in_cycle_flows_out_to_outer() {
2352        // outer(Cycle)[X, inner(Continue)[i1,i2], Y] — outer is the only root.
2353        let mut tree = WidgetTree::new();
2354        let x = tree.add(FillWidget::new().focusable());
2355        let i1 = tree.add(FillWidget::new().focusable());
2356        let i2 = tree.add(FillWidget::new().focusable());
2357        let inner = tree.add(StackWidget::new().child(i1).child(i2));
2358        let y = tree.add(FillWidget::new().focusable());
2359        let outer = tree.add(StackWidget::new().child(x).child(inner).child(y));
2360        tree.set_traversal_scope(inner, Continue);
2361        tree.set_traversal_scope(outer, Cycle);
2362        tree.layout(SizeProposal::exact(100.0, 100.0));
2363
2364        for expected in [x, i1, i2, y, x] {
2365            tab(&mut tree);
2366            assert_eq!(tree.focused(), Some(expected));
2367        }
2368        // Shift+Tab from i1 escapes inner to X.
2369        tree.focus(i1);
2370        shift_tab(&mut tree);
2371        assert_eq!(tree.focused(), Some(x));
2372    }
2373
2374    #[test]
2375    fn nested_cycle_in_cycle_traps_in_the_inner_scope() {
2376        // outer(Cycle)[X, inner(Cycle)[i1,i2], Y]: once inside inner, Y is
2377        // unreachable via Tab.
2378        let mut tree = WidgetTree::new();
2379        let x = tree.add(FillWidget::new().focusable());
2380        let i1 = tree.add(FillWidget::new().focusable());
2381        let i2 = tree.add(FillWidget::new().focusable());
2382        let inner = tree.add(StackWidget::new().child(i1).child(i2));
2383        let y = tree.add(FillWidget::new().focusable());
2384        let outer = tree.add(StackWidget::new().child(x).child(inner).child(y));
2385        tree.set_traversal_scope(inner, Cycle);
2386        tree.set_traversal_scope(outer, Cycle);
2387        tree.layout(SizeProposal::exact(100.0, 100.0));
2388
2389        tree.focus(i1);
2390        for _ in 0..6 {
2391            tab(&mut tree);
2392            let f = tree.focused();
2393            assert!(
2394                f == Some(i1) || f == Some(i2),
2395                "inner Cycle must trap Tab; reached {f:?}"
2396            );
2397        }
2398    }
2399
2400    #[test]
2401    fn scoped_tab_index_orders_within_a_scope() {
2402        // scopeF(Cycle)[f3#3, f1#1, f2#2] added out of order → visited 1,2,3.
2403        let mut tree = WidgetTree::new();
2404        let f3 = indexed(&mut tree, 3);
2405        let f1 = indexed(&mut tree, 1);
2406        let f2 = indexed(&mut tree, 2);
2407        let scope_f = tree.add(StackWidget::new().child(f3).child(f1).child(f2));
2408        tree.set_traversal_scope(scope_f, Cycle);
2409        tree.layout(SizeProposal::exact(100.0, 100.0));
2410
2411        for expected in [f1, f2, f3, f1] {
2412            tab(&mut tree);
2413            assert_eq!(tree.focused(), Some(expected));
2414        }
2415    }
2416
2417    #[test]
2418    fn destroyed_focused_widget_re_enters_at_first() {
2419        let mut tree = WidgetTree::new();
2420        let a = tree.add(FillWidget::new().focusable());
2421        let b = tree.add(FillWidget::new().focusable());
2422        tree.layout(SizeProposal::exact(100.0, 50.0));
2423
2424        tree.focus(b);
2425        tree.destroy_subtree(b);
2426        tab(&mut tree);
2427        assert_eq!(tree.focused(), Some(a), "Tab after destroy enters at first");
2428    }
2429
2430    #[test]
2431    fn set_traversal_scope_forces_node_non_focusable() {
2432        // A scope marker on an otherwise-focusable node must drop it from Tab
2433        // (it is a boundary, not a stop).
2434        let mut tree = WidgetTree::new();
2435        let inner = tree.add(FillWidget::new().focusable());
2436        let scope = tree.add(StackWidget::new().child(inner).focusable(true));
2437        tree.set_traversal_scope(scope, Continue);
2438        tree.layout(SizeProposal::exact(100.0, 50.0));
2439
2440        // Only `inner` is a Tab stop; the scope node itself never gets focus.
2441        tab(&mut tree);
2442        assert_eq!(tree.focused(), Some(inner));
2443        tab(&mut tree);
2444        assert_eq!(tree.focused(), Some(inner));
2445    }
2446
2447    #[test]
2448    fn no_scopes_behaves_like_a_flat_cycle() {
2449        // Regression: without any scopes, traversal is a flat wrapping ring,
2450        // ordered by tab_index then DFS — identical to the pre-scope behavior.
2451        let mut tree = WidgetTree::new();
2452        let a = indexed(&mut tree, 2);
2453        let b = indexed(&mut tree, 1);
2454        let c = tree.add(FillWidget::new().focusable());
2455        tree.layout(SizeProposal::exact(100.0, 50.0));
2456
2457        // b(#1), a(#2), then c(no index) — wrapping.
2458        for expected in [b, a, c, b] {
2459            tab(&mut tree);
2460            assert_eq!(tree.focused(), Some(expected));
2461        }
2462    }
2463
2464    #[test]
2465    fn centered_modal_confines_tab_to_its_content() {
2466        // A centered overlay folds into the traversal model as an implicit
2467        // Cycle scope rooted at its content — Tab must stay inside it.
2468        let mut tree = WidgetTree::new();
2469        let outside1 = tree.add(FillWidget::new().focusable());
2470        let outside2 = tree.add(FillWidget::new().focusable());
2471        let m1 = tree.add(FillWidget::new().focusable());
2472        let m2 = tree.add(FillWidget::new().focusable());
2473        let content = tree.add(StackWidget::new().child(m1).child(m2));
2474        tree.layout(SizeProposal::exact(200.0, 100.0));
2475
2476        tree.show_overlay(crate::overlay::OverlayRequest::new(
2477            content,
2478            outside1,
2479            crate::overlay::OverlayPlacement::Centered,
2480            crate::overlay::DismissBehavior::Manual,
2481        ));
2482
2483        for _ in 0..6 {
2484            tab(&mut tree);
2485            let f = tree.focused();
2486            assert!(
2487                f == Some(m1) || f == Some(m2),
2488                "modal must trap Tab inside its content, reached {f:?}"
2489            );
2490        }
2491        assert_ne!(tree.focused(), Some(outside1));
2492        assert_ne!(tree.focused(), Some(outside2));
2493    }
2494}
2495
2496#[cfg(test)]
2497mod tests_focus_out_dismissal {
2498    //! Non-modal overlays follow focus out instead of trapping it.
2499    //!
2500    //! Driven against bare `OverlayRequest`s so the rule is exercised with no
2501    //! dependency on the widgets crate; the real `MenuBar` / `PopoverButton` /
2502    //! `ComboBox` behaviour is pinned over in `teksilo-widgets`.
2503
2504    use super::*;
2505    use crate::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
2506    use crate::test_widgets::{FillWidget, StackWidget};
2507
2508    fn tab(tree: &mut WidgetTree) {
2509        tree.press_key(Key::Tab, Modifiers::NONE);
2510    }
2511
2512    /// Show `content` anchored to `anchor`, the way a popover or menu does.
2513    fn show_anchored(
2514        tree: &mut WidgetTree,
2515        content: WidgetId,
2516        anchor: WidgetId,
2517        parent: Option<crate::overlay::OverlayId>,
2518    ) -> crate::overlay::OverlayId {
2519        let mut request = OverlayRequest::new(
2520            content,
2521            anchor,
2522            OverlayPlacement::Below,
2523            DismissBehavior::EscapeOrClickOutside,
2524        );
2525        request.parent_overlay = parent;
2526        tree.show_overlay(request)
2527    }
2528
2529    #[test]
2530    fn tab_out_of_a_non_modal_overlay_dismisses_it() {
2531        let mut tree = WidgetTree::new();
2532        let anchor = tree.add(FillWidget::new().focusable());
2533        let after = tree.add(FillWidget::new().focusable());
2534        let inner = tree.add(FillWidget::new().focusable());
2535        let content = tree.add(StackWidget::new().child(inner));
2536        tree.layout(SizeProposal::exact(200.0, 100.0));
2537        show_anchored(&mut tree, content, anchor, None);
2538
2539        tree.focus(inner);
2540        tab(&mut tree);
2541
2542        assert_ne!(tree.focused(), Some(inner), "focus genuinely left");
2543        assert!(
2544            tree.active_overlays().is_empty(),
2545            "an overlay must not stay open over the focus ring that left it"
2546        );
2547        assert!(tree.focused() == Some(after) || tree.focused() == Some(anchor));
2548    }
2549
2550    /// The centered modal keeps its trap — that pattern *does* contain focus.
2551    #[test]
2552    fn a_centered_modal_is_never_dismissed_by_focus_moving() {
2553        let mut tree = WidgetTree::new();
2554        let outside = tree.add(FillWidget::new().focusable());
2555        let m1 = tree.add(FillWidget::new().focusable());
2556        let content = tree.add(StackWidget::new().child(m1));
2557        tree.layout(SizeProposal::exact(200.0, 100.0));
2558        tree.show_overlay(OverlayRequest::new(
2559            content,
2560            outside,
2561            OverlayPlacement::Centered,
2562            DismissBehavior::Manual,
2563        ));
2564
2565        tree.focus(m1);
2566        // Force focus out programmatically. Tab could not do this, and an
2567        // AccessKit request behind the modal is refused (`is_behind_modal`),
2568        // but app code can, and the modal must survive it.
2569        tree.focus(outside);
2570
2571        assert_eq!(
2572            tree.active_overlays().len(),
2573            1,
2574            "a modal is the one overlay that legitimately contains focus"
2575        );
2576    }
2577
2578    /// The scrim is anchored to whatever opened the modal, so an anchor-aware
2579    /// rule could mistake it for a panel orbiting that widget. `FullViewport`
2580    /// is what tells it apart.
2581    #[test]
2582    fn the_modal_scrim_survives_focus_moving_inside_the_modal() {
2583        let mut tree = WidgetTree::new();
2584        let opener = tree.add(FillWidget::new().focusable());
2585        let m1 = tree.add(FillWidget::new().focusable());
2586        let m2 = tree.add(FillWidget::new().focusable());
2587        let scrim = tree.add(FillWidget::new());
2588        let content = tree.add(StackWidget::new().child(m1).child(m2));
2589        tree.layout(SizeProposal::exact(200.0, 100.0));
2590
2591        tree.show_overlay(OverlayRequest::new(
2592            scrim,
2593            opener,
2594            OverlayPlacement::FullViewport,
2595            DismissBehavior::Manual,
2596        ));
2597        tree.show_overlay(OverlayRequest::new(
2598            content,
2599            opener,
2600            OverlayPlacement::Centered,
2601            DismissBehavior::Manual,
2602        ));
2603
2604        tree.focus(opener);
2605        tree.focus(m1);
2606        tab(&mut tree);
2607
2608        assert_eq!(
2609            tree.active_overlays().len(),
2610            2,
2611            "scrim and modal both stand while focus moves within the modal"
2612        );
2613    }
2614
2615    /// A submenu is a *sibling* arena subtree linked only by `parent_overlay`.
2616    /// Ask the arena instead and the parent dies the moment its own child opens.
2617    #[test]
2618    fn focus_moving_into_a_child_overlay_keeps_the_parent_open() {
2619        let mut tree = WidgetTree::new();
2620        let anchor = tree.add(FillWidget::new().focusable());
2621        let parent_item = tree.add(FillWidget::new().focusable());
2622        let parent_content = tree.add(StackWidget::new().child(parent_item));
2623        let child_item = tree.add(FillWidget::new().focusable());
2624        let child_content = tree.add(StackWidget::new().child(child_item));
2625        tree.layout(SizeProposal::exact(200.0, 100.0));
2626
2627        let parent = show_anchored(&mut tree, parent_content, anchor, None);
2628        show_anchored(&mut tree, child_content, parent_item, Some(parent));
2629
2630        tree.focus(parent_item);
2631        tree.focus(child_item);
2632
2633        assert_eq!(
2634            tree.active_overlays().len(),
2635            2,
2636            "opening a submenu is not leaving the menu that owns it"
2637        );
2638    }
2639
2640    /// Backing out to a shallower level of the same cascade closes only what
2641    /// sits below it.
2642    #[test]
2643    fn focus_back_to_the_parent_overlay_closes_only_the_child() {
2644        let mut tree = WidgetTree::new();
2645        let anchor = tree.add(FillWidget::new().focusable());
2646        let parent_item = tree.add(FillWidget::new().focusable());
2647        let parent_content = tree.add(StackWidget::new().child(parent_item));
2648        let child_item = tree.add(FillWidget::new().focusable());
2649        let child_content = tree.add(StackWidget::new().child(child_item));
2650        tree.layout(SizeProposal::exact(200.0, 100.0));
2651
2652        let parent = show_anchored(&mut tree, parent_content, anchor, None);
2653        show_anchored(&mut tree, child_content, parent_item, Some(parent));
2654
2655        tree.focus(child_item);
2656        tree.focus(parent_item);
2657
2658        assert_eq!(
2659            tree.active_overlays(),
2660            vec![parent],
2661            "the submenu goes, the menu that owns it stays"
2662        );
2663    }
2664
2665    /// Leaving the whole cascade closes every level in one move — APG's
2666    /// "closes all menus and submenus", plural and unqualified.
2667    #[test]
2668    fn leaving_a_nested_cascade_closes_every_level() {
2669        let mut tree = WidgetTree::new();
2670        let anchor = tree.add(FillWidget::new().focusable());
2671        let away = tree.add(FillWidget::new().focusable());
2672        let parent_item = tree.add(FillWidget::new().focusable());
2673        let parent_content = tree.add(StackWidget::new().child(parent_item));
2674        let child_item = tree.add(FillWidget::new().focusable());
2675        let child_content = tree.add(StackWidget::new().child(child_item));
2676        tree.layout(SizeProposal::exact(200.0, 100.0));
2677
2678        let parent = show_anchored(&mut tree, parent_content, anchor, None);
2679        show_anchored(&mut tree, child_content, parent_item, Some(parent));
2680
2681        tree.focus(child_item);
2682        tree.focus(away);
2683
2684        assert!(
2685            tree.active_overlays().is_empty(),
2686            "one move out of the cascade must leave nothing behind"
2687        );
2688    }
2689
2690    /// A dropdown opened *inside a modal* still follows focus out.
2691    ///
2692    /// The regression this pins: a `ComboBox` keeps focus on its own trigger
2693    /// while its panel is up, so the rule has to find that panel through its
2694    /// **anchor**. But when the trigger lives inside a modal — Settings, say —
2695    /// the by-content lookup succeeds first and answers with the *modal*, whose
2696    /// whole point is that it does not follow focus out. That shadowed the
2697    /// anchor lookup entirely, and the dropdown was left open over the
2698    /// Settings pane after Tab had moved on.
2699    #[test]
2700    fn a_dropdown_inside_a_modal_still_follows_focus_out() {
2701        let mut tree = WidgetTree::new();
2702        let opener = tree.add(FillWidget::new().focusable());
2703        let trigger = tree.add(FillWidget::new().focusable());
2704        let next = tree.add(FillWidget::new().focusable());
2705        let modal_content = tree.add(StackWidget::new().child(trigger).child(next));
2706        let panel = tree.add(StackWidget::new());
2707        tree.layout(SizeProposal::exact(200.0, 100.0));
2708
2709        tree.show_overlay(OverlayRequest::new(
2710            modal_content,
2711            opener,
2712            OverlayPlacement::Centered,
2713            DismissBehavior::Manual,
2714        ));
2715        // The dropdown, anchored to a trigger that sits *within* the modal.
2716        show_anchored(&mut tree, panel, trigger, None);
2717
2718        tree.focus(trigger);
2719        assert_eq!(
2720            tree.active_overlays().len(),
2721            2,
2722            "precondition: modal + panel"
2723        );
2724
2725        tab(&mut tree);
2726
2727        assert_eq!(
2728            tree.focused(),
2729            Some(next),
2730            "Tab moves on within the modal, as it should"
2731        );
2732        assert_eq!(
2733            tree.active_overlays().len(),
2734            1,
2735            "the dropdown must go — only the modal hosting it stays"
2736        );
2737    }
2738
2739    /// A snackbar is shown *from* a focused button and leaves that button
2740    /// focused — so an anchor-aware rule would tear it down on the user's very
2741    /// next keystroke. Its lifetime belongs to its timer, not to the keyboard.
2742    ///
2743    /// This is why eligibility asks whether an overlay is positioned *at* its
2744    /// anchor rather than merely whether it has one: `BottomCenter` is placed
2745    /// against the viewport, and its anchor is bookkeeping.
2746    #[test]
2747    fn a_viewport_placed_notification_ignores_focus_moving() {
2748        let mut tree = WidgetTree::new();
2749        let trigger = tree.add(FillWidget::new().focusable());
2750        let elsewhere = tree.add(FillWidget::new().focusable());
2751        let snack = tree.add(StackWidget::new());
2752        tree.layout(SizeProposal::exact(200.0, 100.0));
2753        tree.show_overlay(OverlayRequest::new(
2754            snack,
2755            trigger,
2756            OverlayPlacement::BottomCenter,
2757            DismissBehavior::Manual,
2758        ));
2759
2760        tree.focus(trigger);
2761        tab(&mut tree);
2762
2763        assert_eq!(
2764            tree.active_overlays().len(),
2765            1,
2766            "a snackbar outlives the keystroke that moved focus off its trigger"
2767        );
2768        assert_ne!(tree.focused(), Some(trigger), "and focus did move");
2769        assert_eq!(tree.focused(), Some(elsewhere));
2770    }
2771
2772    /// A menu must never take its host down with it. The upward walk stops at
2773    /// a host surface — a hosting dialog, composite tooltip, or revealed
2774    /// menubar — so tabbing out of the inner menu closes the menu alone.
2775    #[test]
2776    fn leaving_a_hosted_menu_spares_the_host() {
2777        let mut tree = WidgetTree::new();
2778        let opener = tree.add(FillWidget::new().focusable());
2779        let away = tree.add(FillWidget::new().focusable());
2780        let modal_item = tree.add(FillWidget::new().focusable());
2781        let modal_content = tree.add(StackWidget::new().child(modal_item));
2782        let menu_item = tree.add(FillWidget::new().focusable());
2783        let menu_content = tree.add(StackWidget::new().child(menu_item));
2784        tree.layout(SizeProposal::exact(200.0, 100.0));
2785
2786        // A centered modal is unconditionally a host surface.
2787        let host = tree.show_overlay(OverlayRequest::new(
2788            modal_content,
2789            opener,
2790            OverlayPlacement::Centered,
2791            DismissBehavior::Manual,
2792        ));
2793        show_anchored(&mut tree, menu_content, modal_item, Some(host));
2794
2795        tree.focus(menu_item);
2796        tree.focus(away);
2797
2798        assert_eq!(
2799            tree.active_overlays(),
2800            vec![host],
2801            "the menu goes; the modal hosting it stays"
2802        );
2803    }
2804
2805    /// An overlay whose focus never enters it is still reachable through its
2806    /// **anchor** — the non-searchable `ComboBox` / `SearchField` shape, where
2807    /// focus stays on the trigger the whole time the panel is up.
2808    #[test]
2809    fn leaving_the_anchor_dismisses_a_panel_focus_never_entered() {
2810        let mut tree = WidgetTree::new();
2811        let trigger = tree.add(FillWidget::new().focusable());
2812        let after = tree.add(FillWidget::new().focusable());
2813        let content = tree.add(StackWidget::new());
2814        tree.layout(SizeProposal::exact(200.0, 100.0));
2815        show_anchored(&mut tree, content, trigger, None);
2816
2817        tree.focus(trigger);
2818        assert_eq!(tree.active_overlays().len(), 1, "precondition: panel is up");
2819
2820        tree.focus(after);
2821        assert!(
2822            tree.active_overlays().is_empty(),
2823            "leaving the trigger is leaving the dropdown it owns"
2824        );
2825    }
2826}