Skip to main content

teksilo_core/widget_tree/
focus_impl.rs

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