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