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