Skip to main content

teksilo_core/widget_tree/
focus_impl.rs

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