Skip to main content

teksilo_core/widget_tree/
focus_impl.rs

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