Skip to main content

teksilo_core/widget_tree/
event_dispatch_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use super::*;
5
6use crate::gesture::{GestureEvent, RawPointerEvent, TapEvent};
7
8/// Fire an `EventResponse`-returning handler from BOTH the external
9/// and own slots (in that order). Returns `Handled` if either did,
10/// `Ignored` otherwise. `None` slots are skipped.
11fn fire_event_handler_both(
12    external: &mut Option<Box<dyn FnMut(&WidgetEvent, &mut EventContext) -> EventResponse>>,
13    own: &mut Option<Box<dyn FnMut(&WidgetEvent, &mut EventContext) -> EventResponse>>,
14    event: &WidgetEvent,
15    ctx: &mut EventContext,
16) -> EventResponse {
17    let r1 = external
18        .as_mut()
19        .map(|h| h(event, ctx))
20        .unwrap_or(EventResponse::Ignored);
21    let r2 = own
22        .as_mut()
23        .map(|h| h(event, ctx))
24        .unwrap_or(EventResponse::Ignored);
25    if r1 == EventResponse::Handled || r2 == EventResponse::Handled {
26        EventResponse::Handled
27    } else {
28        EventResponse::Ignored
29    }
30}
31
32impl WidgetTree {
33    /// Hops from `focus` up to `scope_id` (0 when equal), or `None` when
34    /// `scope_id` is not an ancestor-or-self of `focus`. Fewer hops means
35    /// the scope sits closer to focus — i.e. a more specific binding.
36    fn scope_distance_from_focus(&self, focus: WidgetId, scope_id: WidgetId) -> Option<usize> {
37        let mut hops = 0usize;
38        let mut current = Some(focus);
39        while let Some(c) = current {
40            if c == scope_id {
41                return Some(hops);
42            }
43            current = self.arena.parent(c);
44            hops += 1;
45        }
46        None
47    }
48
49    /// From every same-chord shortcut candidate, choose the one whose
50    /// scope applies to the current focus, preferring the most specific
51    /// scope: a `Scoped` binding whose subtree contains focus beats a
52    /// `Global` one, and among nested applicable scopes the one closest
53    /// to focus (fewest hops) wins. Equal-specificity ties keep the
54    /// deterministic `(category, id)` order `candidates` arrives in (the
55    /// first such candidate wins). Returns `None` when no candidate
56    /// applies — every match is a scoped binding outside the focused
57    /// subtree — so the caller falls through to normal KeyDown dispatch.
58    fn select_shortcut_for_focus(
59        &self,
60        candidates: &[(&'static str, crate::shortcut::ShortcutScope, bool)],
61    ) -> Option<(&'static str, crate::shortcut::ShortcutScope, bool)> {
62        use crate::shortcut::ShortcutScope;
63        let mut best: Option<(usize, (&'static str, ShortcutScope, bool))> = None;
64        for &(id, scope, propagate) in candidates {
65            // Specificity score, higher = more specific. Global is the
66            // least-specific fallback (0); any applicable scoped binding
67            // outranks it (`usize::MAX - hops`, so fewer hops = deeper
68            // scope = higher score). Tree depth is tiny, so no overflow.
69            let score = match scope {
70                ShortcutScope::Global => Some(0usize),
71                ShortcutScope::Scoped(scope_id) => self
72                    .focused
73                    .and_then(|f| self.scope_distance_from_focus(f, scope_id))
74                    .map(|hops| usize::MAX - hops),
75            };
76            let Some(score) = score else { continue };
77            // Strictly-greater keeps the first candidate on a tie, so the
78            // existing `(category, id)` precedence holds within a scope.
79            if best.as_ref().is_none_or(|(b, _)| score > *b) {
80                best = Some((score, (id, scope, propagate)));
81            }
82        }
83        best.map(|(_, c)| c)
84    }
85
86    /// Dispatch an event into the widget tree.
87    ///
88    /// Routing rules:
89    /// - Pointer events -> hit testing against layout tree
90    /// - Keyboard/IME events -> focused widget
91    /// - AccessKit actions -> target widget directly
92    /// - Scroll events -> hit testing (scroll target under pointer)
93    ///
94    /// Dispatch an event with the caller-supplied app-level
95    /// [`WindowOps`](crate::window::WindowOps) sink. `teksilo-app` calls
96    /// this variant; handlers can reach the multi-window API
97    /// synchronously (`open_window` creates the winit window inside
98    /// the same call before returning).
99    pub fn dispatch_event_with_ops(
100        &mut self,
101        event: WidgetEvent,
102        ops: &mut dyn crate::window::WindowOps,
103    ) {
104        self.dispatch_event_impl(event, ops)
105    }
106
107    /// Dispatch an event on a standalone tree (tests, headless
108    /// scenarios). Handler code that calls `ctx.open_window(...)`
109    /// from within this dispatch will panic — by design. See
110    /// [`dispatch_event_with_ops`](Self::dispatch_event_with_ops)
111    /// for the app-facing variant.
112    pub fn dispatch_event(&mut self, event: WidgetEvent) {
113        let mut noop = crate::window::NoopWindowOps;
114        self.dispatch_event_impl(event, &mut noop);
115    }
116
117    fn dispatch_event_impl(&mut self, event: WidgetEvent, ops: &mut dyn crate::window::WindowOps) {
118        // Track input modality for `:focus-visible`: keyboard input reveals
119        // focus rings, pointer input hides them. Updated at the dispatch root so
120        // every handler (and the next paint) observes the current modality.
121        match &event {
122            WidgetEvent::KeyDown { .. } if !self.focus_visible.get() => {
123                self.focus_visible.set(true);
124            }
125            WidgetEvent::PointerDown { .. } if self.focus_visible.get() => {
126                self.focus_visible.set(false);
127            }
128            _ => {}
129        }
130
131        // The "back toward the parent overlay" key closes the top nested
132        // overlay (e.g. an open submenu over its parent menu). It is the
133        // inline-start arrow: ArrowLeft under LTR, ArrowRight under RTL.
134        // Without the RTL flip, ArrowLeft would navigate *into* a submenu
135        // in RTL menus yet still dismiss it here.
136        let overlay_back_key = match self.layout_direction {
137            crate::environment::LayoutDirection::RightToLeft => Key::ArrowRight,
138            crate::environment::LayoutDirection::LeftToRight => Key::ArrowLeft,
139        };
140        if let WidgetEvent::KeyDown { key, .. } = &event
141            && *key == overlay_back_key
142        {
143            // Count menu-level (non-host) overlays. A revealed collapsible
144            // `MenuBar` is itself a *host* overlay (Role::MenuBar), so a
145            // single open top-level menu sitting over it must NOT be treated
146            // as a nested submenu — otherwise the back key would close the
147            // menu instead of letting the menubar navigate to the previous
148            // one. Only when ≥2 non-host overlays are stacked (a submenu over
149            // its parent menu) does the back key dismiss the top overlay.
150            let nested_menu_overlays = {
151                let ids: Vec<_> = self.overlay_manager.stack.iter().map(|o| o.id).collect();
152                ids.into_iter()
153                    .filter(|&id| !self.overlay_is_host_surface(id))
154                    .count()
155            };
156            // The back key only navigates *menu* cascades; it must never close a
157            // dialog / alert / modal that happens to sit on top. Each modal is a
158            // scrim+panel overlay pair and the (non-host) scrims inflate the count
159            // above, so also require the *topmost* overlay to be back-navigable —
160            // i.e. a non-host (menu) surface — before dismissing it.
161            let top_id = self.overlay_manager.stack.last().map(|o| o.id);
162            let top_is_back_navigable = top_id.is_some_and(|id| !self.overlay_is_host_surface(id));
163            if nested_menu_overlays > 1 && top_is_back_navigable {
164                if let Some((_id, content_ids, focus_restore)) = self.overlay_manager.dismiss_top()
165                {
166                    self.dormant_dismissed_content(&content_ids, &mut *ops);
167                    if let Some(restore_id) = focus_restore
168                        && self.arena.is_active(restore_id)
169                    {
170                        self.focus_ops(restore_id, &mut *ops);
171                    }
172                }
173                return;
174            }
175        }
176
177        // Escape retires any shown tooltip first, and does **not** stop there —
178        // see `tooltip_escape_pressed`. Ordered before the stack walk below so
179        // that walk can no longer pick a tooltip as the thing to dismiss, which
180        // is what used to spend the key on a tip nobody was reading while the
181        // editor / menu / dialog the user meant stayed open.
182        if let WidgetEvent::KeyDown {
183            key: Key::Escape, ..
184        } = &event
185        {
186            self.tooltip_escape_pressed();
187        }
188
189        if let WidgetEvent::KeyDown {
190            key: Key::Escape, ..
191        } = &event
192            && !self.overlay_manager.is_empty()
193            && let Some((_id, content_ids, focus_restore)) =
194                self.overlay_manager.try_dismiss_top_on_escape()
195        {
196            self.dormant_dismissed_content(&content_ids, &mut *ops);
197            if let Some(restore_id) = focus_restore
198                && self.arena.is_active(restore_id)
199            {
200                self.focus_ops(restore_id, &mut *ops);
201            }
202            return;
203        }
204
205        if let WidgetEvent::PointerDown {
206            position, button, ..
207        } = &event
208        {
209            let (dismissed, focus_restore, toggle_anchors) =
210                self.overlay_manager.handle_click_outside(*position);
211            if !dismissed.is_empty() {
212                self.dormant_dismissed_content(&dismissed, &mut *ops);
213                if let Some(restore_id) = focus_restore
214                    && self.arena.is_active(restore_id)
215                {
216                    self.focus_ops(restore_id, &mut *ops);
217                }
218                // The press dismissed one or more overlays. By default it
219                // now ALSO falls through to the widget under the cursor,
220                // so a single click both closes the menu/popover and
221                // activates the control beneath — the behaviour a
222                // secondary press already had. The one case still
223                // swallowed: a primary press on the anchor of a
224                // click-opened overlay, because the anchor's own tap
225                // handler would otherwise reopen the overlay this very
226                // press just dismissed (click-the-trigger-to-close).
227                let on_toggle_anchor = *button == PointerButton::Primary
228                    && toggle_anchors.iter().any(|&anchor| {
229                        self.arena.is_active(anchor)
230                            && self.arena.bounds(anchor).contains(*position)
231                    });
232                if on_toggle_anchor {
233                    return;
234                }
235            }
236        }
237
238        // Key-capture mode: if a callback is armed (via
239        // `WidgetTree::begin_key_capture`), the next KeyDown bypasses
240        // shortcut resolution entirely and runs the callback with
241        // mutable access to the registry AND an `EventContext` so
242        // rebind handlers can also emit commands, send intents,
243        // dismiss overlays, etc. The capture is one-shot; its slot
244        // is emptied before the callback runs so a re-entrant
245        // `begin_key_capture` call from inside the callback arms a
246        // fresh session (rather than competing with the in-flight
247        // one).
248        if let WidgetEvent::KeyDown { key, modifiers, .. } = &event
249            && let Some(callback) = self.take_key_capture()
250        {
251            let keystroke = crate::shortcut::KeyStroke::new(*key, *modifiers);
252            let mut cap_ctx = self.make_event_context(&mut *ops);
253            callback(keystroke, self.shortcut_registry_mut(), &mut cap_ctx);
254            // Route side effects of the callback through the
255            // focused widget (or an arbitrary root if no focus).
256            let anchor = self.focused.or_else(|| self.arena.roots().first().copied());
257            if let Some(anchor_id) = anchor {
258                self.collect_from_ctx(cap_ctx, anchor_id);
259                self.drain_pending_intents(&mut *ops);
260            }
261            return;
262        }
263
264        // Keyboard-capture surfaces (a terminal, a game viewport) opt out
265        // of shortcut resolution entirely while focused: they want every
266        // keystroke delivered raw so a host-app `Ctrl+C` shortcut can't
267        // steal the SIGINT the child process needs. The Escape / overlay
268        // back-navigation handled above still runs first, so an open
269        // overlay is still dismissable. Only a KeyDown is affected; KeyUp
270        // and IME already bypass the shortcut path.
271        let focus_captures_keys = matches!(&event, WidgetEvent::KeyDown { .. })
272            && self.focused.is_some_and(|f| self.is_keyboard_capture(f));
273
274        // Shortcut → intent → action dispatch. A KeyDown whose chord
275        // matches a registered enabled `Shortcut` whose scope contains
276        // the focused widget is consumed here: the shortcut's
277        // `on_activate` runs (producing an `Intent`), its ctx side
278        // effects are collected, and the intent walks source-widget →
279        // root firing any matching `Action`. Otherwise the focused
280        // widget sees the raw KeyDown below.
281        //
282        // Two-phase: the registry is inspected first (immutable read)
283        // to resolve `id / scope / propagate_when_disabled`. Only if
284        // scope matches the current focus do we take a mutable borrow
285        // to invoke `on_activate` — this way a scope mismatch cannot
286        // drop side effects the closure put into its ctx, because
287        // the closure never runs.
288        if !focus_captures_keys && let WidgetEvent::KeyDown { key, modifiers, .. } = &event {
289            let keystroke = crate::shortcut::KeyStroke::new(*key, *modifiers);
290            // Gather every same-chord candidate (owned fields) before any
291            // mutable borrow of the registry, then pick the one whose scope
292            // actually applies to the current focus. `find_by_keystroke`
293            // alone yields only the first by `(category, id)` order, which
294            // can be a `Scoped` binding outside focus shadowing an
295            // applicable `Global` one — or a `Global` binding that should
296            // yield to an in-focus `Scoped` one. Selection needs focus +
297            // the tree, so it happens here, not in the registry.
298            let candidates: Vec<(&'static str, crate::shortcut::ShortcutScope, bool)> = self
299                .shortcut_registry
300                .matches_by_keystroke(keystroke)
301                .map(|eff| {
302                    (
303                        eff.shortcut.id,
304                        eff.shortcut.scope,
305                        eff.shortcut.propagate_when_disabled,
306                    )
307                })
308                .collect();
309            let lookup = self.select_shortcut_for_focus(&candidates);
310            if let Some((id, scope, propagate_when_disabled)) = lookup {
311                let anchor = match scope {
312                    // Global shortcuts fire regardless of focus. If no
313                    // widget is currently focused, anchor the intent
314                    // walk at an arbitrary root so actions registered
315                    // at the top of the tree still see the intent.
316                    crate::shortcut::ShortcutScope::Global => {
317                        self.focused.or_else(|| self.arena.roots().first().copied())
318                    }
319                    crate::shortcut::ShortcutScope::Scoped(scope_id) => {
320                        self.focused.filter(|f| self.is_descendant_of(*f, scope_id))
321                    }
322                };
323                if let Some(anchor_id) = anchor {
324                    let mut act_ctx = self.make_event_context(&mut *ops);
325                    if let Some(intent) =
326                        self.shortcut_registry
327                            .invoke_on_activate(id, keystroke, &mut act_ctx)
328                    {
329                        self.collect_from_ctx(act_ctx, anchor_id);
330                        // Tag shortcut origin so analytics can
331                        // distinguish keyboard-driven activations from
332                        // button / menu / programmatic ones.
333                        let intent = intent.with_source(crate::telemetry::IntentSource::Shortcut);
334                        self.enqueue_intent(anchor_id, intent, propagate_when_disabled);
335                        self.drain_pending_intents(&mut *ops);
336                        return;
337                    }
338                }
339                // Chosen candidate had no anchor after all (e.g. a Global
340                // match while nothing is focused and the tree has no
341                // roots) — fall through to normal KeyDown dispatch.
342                // `on_activate` was never called, so nothing to clean up.
343            }
344            // `lookup` is `None` when every same-chord candidate was a
345            // scoped binding outside the focused subtree — fall through.
346        }
347
348        // Escape during an OS drag we escalated. There is no `active_drag`
349        // any more — `try_escalate_to_os_drag` took it when the platform
350        // accepted the hand-off — so this cannot live in the block below, but
351        // it is the same user gesture and belongs on the same path rather than
352        // being special-cased in the event loop of whichever backend needs it.
353        if self.outbound_drag_source.is_some()
354            && let WidgetEvent::KeyDown {
355                key: Key::Escape, ..
356            } = &event
357        {
358            ops.cancel_os_drag();
359            // Deliberately no `return`: the backend answers asynchronously with
360            // a terminal `DragEnded`, which is what actually tears the session
361            // down via `handle_os_drag_ended`. Swallowing the key here would
362            // also stop Escape from closing whatever else is open.
363        }
364
365        // --- Active drag session handling ---
366        if self.active_drag.is_some() {
367            match &event {
368                WidgetEvent::PointerMove { position } => {
369                    self.handle_drag_move(*position, &mut *ops);
370                    return;
371                }
372                WidgetEvent::PointerUp { position, .. } => {
373                    self.handle_drag_drop(*position, &mut *ops);
374                    return;
375                }
376                WidgetEvent::KeyDown {
377                    key: Key::Escape, ..
378                } => {
379                    self.cancel_active_drag(&mut *ops);
380                    return;
381                }
382                WidgetEvent::Scroll { .. } => {
383                    // Route the wheel to the current drop target so users
384                    // can scroll the list/tree beneath the drag. Then
385                    // synthesise a hover at the stationary pointer so
386                    // feedback, drop-index math and the preview overlay
387                    // all reflect the new scroll offset.
388                    let target_and_pos = self
389                        .active_drag
390                        .as_ref()
391                        .and_then(|d| d.current_target.map(|t| (t, d.current_position)));
392                    if let Some((target, _pos)) = target_and_pos {
393                        self.dispatch_to_widget(target, &event, &mut *ops);
394                    }
395                    if let Some((_, pos)) = target_and_pos
396                        && self.active_drag.is_some()
397                    {
398                        self.handle_drag_move(pos, &mut *ops);
399                    }
400                    return;
401                }
402                _ => {}
403            }
404        }
405
406        match &event {
407            WidgetEvent::PointerMove { position } => {
408                if let Some(captured) = self.pointer_captured_by {
409                    self.dispatch_to_widget(
410                        captured,
411                        &WidgetEvent::PointerMove {
412                            position: *position,
413                        },
414                        &mut *ops,
415                    );
416                    // Let armed ancestor drag recognizers observe the move so
417                    // an ancestor drag can start while a descendant tap holds
418                    // the capture. Once a drag latches, `active_drag` takes
419                    // over and the capture branch above is bypassed.
420                    if self.active_drag.is_none() {
421                        self.advance_drag_observers(
422                            &WidgetEvent::PointerMove {
423                                position: *position,
424                            },
425                            &mut *ops,
426                        );
427                    }
428                } else {
429                    self.handle_pointer_move(*position, &mut *ops);
430                }
431                self.update_pointer_leave_overlays(*position, &mut *ops);
432            }
433            WidgetEvent::PointerDown {
434                position, button, ..
435            } => {
436                // The user has acted — a tooltip that has not yet appeared is
437                // now answering a question nobody is asking any more, and one
438                // already up is covering the thing being clicked. Cancel the
439                // pending dwell and retire any shown non-sticky tip, the way
440                // Windows and GTK both do. Runs before hit-testing so it fires
441                // even for a press that lands on nothing.
442                self.tooltip_pointer_press(Some(*position));
443                if let Some(target) = self.hit_test(*position) {
444                    if *button == PointerButton::Secondary
445                        && self.show_context_menu_for(target, *position, &mut *ops)
446                    {
447                        return;
448                    }
449                    if let Some(focusable) = self.find_focusable_at_or_above(target) {
450                        self.focus_with_origin_ops(
451                            focusable,
452                            crate::focus::FocusOrigin::Pointer,
453                            &mut *ops,
454                        );
455                    }
456                    self.dispatch_to_widget(target, &event, &mut *ops);
457                    // If a descendant captured the pointer for a tap (no drag
458                    // started), arm ancestor drag recognizers so an ancestor
459                    // drag can still begin on move (tap-vs-drag across the
460                    // hit-path).
461                    if self.active_drag.is_none()
462                        && let Some(captured) = self.pointer_captured_by
463                    {
464                        self.arm_drag_observers(captured, &event, &mut *ops);
465                    }
466                }
467            }
468            WidgetEvent::PointerUp { position, .. } => {
469                // The pointer sequence ends here — feed the `Up` to any armed
470                // ancestor drag observers so their recognizer clears the press
471                // origin it recorded on the press. Without this, a press that
472                // an interactive descendant captured (a card's editor, a row's
473                // button) leaves the ancestor's DragRecognizer armed, and the
474                // next hover move starts a phantom drag. Also discards the list.
475                self.release_drag_observers(&event, &mut *ops);
476                if let Some(captured) = self.pointer_captured_by {
477                    self.dispatch_to_widget(captured, &event, &mut *ops);
478                    self.pointer_captured_by = None;
479                } else if let Some(target) = self.hit_test(*position) {
480                    self.dispatch_to_widget(target, &event, &mut *ops);
481                }
482            }
483            WidgetEvent::Scroll { .. } => {
484                if let Some(target) = self.hovered.or(self.focused) {
485                    self.dispatch_to_widget(target, &event, &mut *ops);
486                }
487            }
488            WidgetEvent::KeyDown { key, modifiers, .. } => {
489                if *key == Key::Tab {
490                    // Ctrl+Tab / Ctrl+Shift+Tab always leave a keyboard-capture
491                    // surface (WCAG 2.1.2). A capture node exists precisely to
492                    // swallow every keystroke — a terminal encodes Tab as `\t`
493                    // and Shift+Tab as CSI Z — so the ordinary "dispatch first,
494                    // cycle only when unhandled" rule below can never move focus
495                    // out of one. Reserving this one chord at the dispatcher, not
496                    // in each capture widget, is what makes the escape a property
497                    // of `keyboard_capture` itself rather than a promise every
498                    // future capture-surface author has to remember to keep.
499                    //
500                    // Literal `ctrl()`, not `command()`: Ctrl+Tab is Ctrl+Tab on
501                    // macOS too — ⌘⇥ is the application switcher and never
502                    // reaches an app at all. Same reading as `TableView`'s
503                    // cell-grid escape and `RichTextEditor`'s `tab_escape`.
504                    let captured_focus = self
505                        .focused
506                        .is_some_and(|focused| self.is_keyboard_capture(focused));
507                    if captured_focus && modifiers.ctrl() {
508                        self.cycle_focus(modifiers.shift(), &mut *ops);
509                        return;
510                    }
511                    // Dispatch Tab to the focused widget first so
512                    // ancestors (e.g. an open overlay that wants to
513                    // close instead of moving focus out through its
514                    // content) get a chance to intercept. Fall back to
515                    // built-in focus cycling only when no handler
516                    // returns `EventResponse::Handled`.
517                    let handled = self
518                        .focused
519                        .map(|focused| {
520                            self.dispatch_to_widget_returning_handled(focused, &event, &mut *ops)
521                        })
522                        .unwrap_or(false);
523                    if !handled {
524                        self.cycle_focus(modifiers.shift(), &mut *ops);
525                    }
526                } else if let Some(focused) = self.focused {
527                    self.dispatch_to_widget(focused, &event, &mut *ops);
528                }
529            }
530            WidgetEvent::KeyUp { .. }
531            | WidgetEvent::ImeComposition { .. }
532            | WidgetEvent::ImeCommit { .. } => {
533                if let Some(focused) = self.focused {
534                    self.dispatch_to_widget(focused, &event, &mut *ops);
535                }
536            }
537            WidgetEvent::AccessAction { target, action, .. } => {
538                // An AT action (e.g. VoiceOver's VO+Space → `Action::Click`)
539                // always names the node it targets — the element under the
540                // assistive-technology cursor. It must be delivered to THAT
541                // node, never to whatever happens to hold keyboard focus.
542                // Falling back to `self.focused` would make VO+Space fire the
543                // focused control instead of the cursored one, and would mask
544                // a stale/inactive target by silently activating something
545                // else. If the target is missing or no longer active, drop the
546                // action rather than redirecting it.
547                if let Some(id) = target.filter(|id| self.arena.is_active(*id)) {
548                    if *action == accesskit::Action::Focus {
549                        self.focus_with_origin_ops(
550                            id,
551                            crate::focus::FocusOrigin::Programmatic,
552                            &mut *ops,
553                        );
554                        // Focus is serviced here rather than by the widget, so
555                        // "handled" means the focus actually landed.
556                        self.access_action_handled = self.focused == Some(id);
557                    } else if *action == accesskit::Action::ShowContextMenu {
558                        // A "show context menu" AT action — a screen reader's
559                        // menu key, or an automation `right_click` /
560                        // `invoke_action(node, "show_context_menu")` — first
561                        // offers itself to the node's own `on_access_action`
562                        // handlers. If none consume it, fall through to the very
563                        // same machinery a Secondary `PointerDown` drives, so a
564                        // widget's `.context_menu(..)` factory opens without the
565                        // caller having to synthesise a right-click. The AT
566                        // action carries no point, so anchor the menu at the
567                        // node's centre. Without this, the AT action was a silent
568                        // no-op for every widget that wires its menu through the
569                        // factory (i.e. all of them) — see `show_context_menu_for`.
570                        // Handled = the widget consumed it, or the factory
571                        // fallback actually opened a menu. A node with neither
572                        // reports unhandled rather than a silent success.
573                        self.access_action_handled =
574                            if self.dispatch_to_widget_returning_handled(id, &event, &mut *ops) {
575                                true
576                            } else {
577                                let position = self.arena.bounds(id).center();
578                                self.show_context_menu_for(id, position, &mut *ops)
579                            };
580                    } else {
581                        self.access_action_handled =
582                            self.dispatch_to_widget_returning_handled(id, &event, &mut *ops);
583                    }
584                }
585            }
586            WidgetEvent::Gesture { .. } => {
587                if let Some(target) = self.hovered.or(self.focused) {
588                    self.dispatch_to_widget(target, &event, &mut *ops);
589                }
590            }
591            WidgetEvent::ScrollIntoView { .. }
592            | WidgetEvent::PointerEnter
593            | WidgetEvent::PointerLeave
594            | WidgetEvent::FocusGained { .. }
595            | WidgetEvent::FocusLost => {}
596        }
597        // Any intents queued by handlers via `ctx.send_intent(...)`
598        // are dispatched after the raw event has been handled but
599        // before commands are flushed, so commands emitted from
600        // action handlers land on the same tick.
601        self.drain_pending_intents(&mut *ops);
602    }
603
604    fn show_context_menu_for(
605        &mut self,
606        target: WidgetId,
607        position: Point,
608        ops: &mut dyn crate::window::WindowOps,
609    ) -> bool {
610        // Walks up the parent chain calling each factory in turn. A
611        // factory returning `Some(menu)` claims the click and mounts;
612        // a factory returning `None` declines and the walk continues.
613        // No factory anywhere on the chain → fall through to whatever
614        // the caller does with the unconsumed PointerDown.
615        let mut ctx = self.make_event_context(&mut *ops);
616        let mut walker = Some(target);
617        let menu_decision: Option<(WidgetId, Box<dyn Widget>)> = loop {
618            // Walk to the next ancestor (including `walker` itself)
619            // that owns a factory.
620            let owner_id = {
621                let mut probe = walker;
622                loop {
623                    match probe {
624                        None => break None,
625                        Some(id) => {
626                            if self
627                                .arena
628                                .get(id)
629                                .is_some_and(|node| node.context_menu_factory.is_some())
630                            {
631                                break Some(id);
632                            }
633                            probe = self.arena.get(id).and_then(|node| node.parent);
634                        }
635                    }
636                }
637            };
638            let Some(owner_id) = owner_id else {
639                break None;
640            };
641            // Invoke the factory with the click position and a real
642            // EventContext. The factory is `Fn` (not FnMut), so we
643            // can call it through an immutable borrow on the node.
644            // `ctx` is a local — its `&mut WindowOps` lifetime is
645            // disjoint from `self.arena`, so the immutable arena
646            // borrow doesn't conflict with the mutable ctx borrow.
647            let outcome: Option<Box<dyn Widget>> = {
648                let node = self
649                    .arena
650                    .get(owner_id)
651                    .expect("owner_id from active arena walk");
652                let factory = node
653                    .context_menu_factory
654                    .as_ref()
655                    .expect("owner_id only set when factory present");
656                factory(position, &mut ctx)
657            };
658            match outcome {
659                Some(menu) => break Some((owner_id, menu)),
660                None => {
661                    // Decline → keep walking up from the parent.
662                    walker = self.arena.get(owner_id).and_then(|n| n.parent);
663                }
664            }
665        };
666
667        // Drain ctx side effects regardless of whether a menu showed —
668        // a factory that returns `None` may still have queued intents,
669        // updated signals, or requested a frame.
670        let drain_anchor = menu_decision
671            .as_ref()
672            .map(|(id, _)| *id)
673            .or_else(|| self.arena.roots().first().copied())
674            .unwrap_or(target);
675        self.collect_from_ctx(ctx, drain_anchor);
676
677        let Some((owner_id, menu_widget)) = menu_decision else {
678            return false;
679        };
680
681        // Clear stale transient overlays (other menus / popovers) before mounting
682        // the new menu, but KEEP any overlay that *contains* the right-clicked
683        // widget — otherwise a right-click inside a modal editor would tear down
684        // the modal it lives in (dismiss_all did exactly that). The context menu
685        // then mounts on top of its host overlay.
686        let keep: std::collections::HashSet<WidgetId> = self
687            .overlay_manager
688            .stack
689            .iter()
690            .map(|o| o.content_id)
691            .filter(|&content_id| self.is_descendant_of(owner_id, content_id))
692            .collect();
693        let dismissed = self.overlay_manager.dismiss_except(&keep);
694        self.dormant_dismissed_content(&dismissed, &mut *ops);
695
696        let content_id = self.add_boxed(menu_widget);
697        let prev_focus = self.focused;
698        self.overlay_manager.show(crate::overlay::OverlayRequest {
699            content_id,
700            anchor: owner_id,
701            placement: crate::overlay::OverlayPlacement::AtPointer(position),
702            dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
703            layer: crate::overlay::OverlayLayer::InTree,
704            parent_overlay: None,
705            on_dismiss: None,
706            fade_duration: None,
707        });
708        if let Some(focus_id) = prev_focus {
709            self.overlay_manager.set_top_focus_restore(focus_id);
710        }
711        self.focus_ops(content_id, &mut *ops);
712        // Flush intents the factory queued so they take effect on the
713        // same dispatch tick as the menu mount. The caller's
714        // PointerDown handler returns after we return `true`, skipping
715        // its own drain — fire ours here.
716        self.drain_pending_intents(&mut *ops);
717        true
718    }
719
720    fn handle_pointer_move(&mut self, position: Point, ops: &mut dyn crate::window::WindowOps) {
721        self.last_pointer_position = Some(position);
722        let target = self.hit_test(position);
723
724        if target != self.hovered {
725            let previously_hovered = self.hovered;
726            if let Some(old) = self.hovered {
727                self.dispatch_to_widget(old, &WidgetEvent::PointerLeave, &mut *ops);
728                self.tooltip_pointer_leave(old, &mut *ops);
729            }
730            if let Some(new) = target {
731                self.dispatch_to_widget(new, &WidgetEvent::PointerEnter, &mut *ops);
732                self.tooltip_pointer_enter(new);
733            }
734            self.set_hovered(target);
735            self.update_hover_within_signals(previously_hovered, target);
736        } else if let Some(target) = target {
737            // Same hover target — restart pending tooltip timers if the
738            // pointer is still moving beyond the stationary slop.
739            self.tooltip_pointer_moved(target, position);
740        }
741
742        if let Some(target) = target {
743            self.dispatch_to_widget(target, &WidgetEvent::PointerMove { position }, &mut *ops);
744        }
745    }
746
747    pub(super) fn dispatch_to_widget(
748        &mut self,
749        target: WidgetId,
750        event: &WidgetEvent,
751        ops: &mut dyn crate::window::WindowOps,
752    ) {
753        self.dispatch_to_widget_returning_handled(target, event, ops);
754    }
755
756    /// Whether `id` carries a drag or swipe handler (hence gets a drag/swipe
757    /// recognizer once its arena is built).
758    fn widget_has_drag(&self, id: WidgetId) -> bool {
759        self.arena
760            .get(id)
761            .map(|n| n.any_handler(|h| h.on_drag.is_some() || h.on_swipe.is_some()))
762            .unwrap_or(false)
763    }
764
765    /// Whether `id` is a gesture dead-zone boundary — a press inside its
766    /// subtree must not arm a drag/swipe on any ancestor above it. See
767    /// [`WidgetNode::gesture_dead_zone`](crate::arena::WidgetNode::gesture_dead_zone).
768    fn is_gesture_dead_zone(&self, id: WidgetId) -> bool {
769        self.arena
770            .get(id)
771            .map(|n| n.gesture_dead_zone)
772            .unwrap_or(false)
773    }
774
775    /// Whether `id` is a keyboard-capture surface — while focused it
776    /// receives every `KeyDown` raw, bypassing shortcut resolution. See
777    /// [`WidgetNode::keyboard_capture`](crate::arena::WidgetNode::keyboard_capture).
778    fn is_keyboard_capture(&self, id: WidgetId) -> bool {
779        self.arena
780            .get(id)
781            .map(|n| n.keyboard_capture)
782            .unwrap_or(false)
783    }
784
785    /// On `PointerDown`, when a descendant has captured the pointer for a
786    /// non-drag gesture (a tap / long-press), arm every strict ancestor that
787    /// carries a drag/swipe recognizer so an ancestor drag can still begin
788    /// once the pointer moves past threshold — the tap-vs-drag disambiguation
789    /// across the hit-path. Without this a descendant `on_tap` permanently
790    /// shadows an ancestor `on_drag` (the bubble stops + capture routes every
791    /// move to the descendant alone).
792    ///
793    /// Skipped when the captured widget can itself drag: the innermost drag
794    /// owns the gesture, so no ancestor observation.
795    pub(super) fn arm_drag_observers(
796        &mut self,
797        captured: WidgetId,
798        down_event: &WidgetEvent,
799        ops: &mut dyn crate::window::WindowOps,
800    ) {
801        self.drag_observers.clear();
802        if self.widget_has_drag(captured) {
803            return;
804        }
805        // The press is inside a gesture dead zone (the captured control *is* the
806        // dead zone) → arm no ancestor drag at all.
807        if self.is_gesture_dead_zone(captured) {
808            return;
809        }
810        let mut observers = Vec::new();
811        let mut current = self.arena.parent(captured);
812        while let Some(id) = current {
813            // A dead-zone boundary stops the walk: ancestors AT or ABOVE it are
814            // never armed, so a control inside the dead zone can never start the
815            // ancestor's drag (the robust fix for "clicking a header button +
816            // a few px of jitter drags the whole panel").
817            if self.is_gesture_dead_zone(id) {
818                break;
819            }
820            if self.widget_has_drag(id) {
821                // Build the arena (the bubble never reached this ancestor) and
822                // feed it the press so its DragRecognizer records the origin.
823                {
824                    let WidgetTree {
825                        arena,
826                        gesture_owners,
827                        ..
828                    } = self;
829                    if let Some(node) = arena.get_mut(id) {
830                        Self::ensure_gesture_arena(node, id, gesture_owners);
831                    }
832                }
833                self.observe_drag_on_ancestor(id, down_event, ops);
834                observers.push(id);
835            }
836            current = self.arena.parent(id);
837        }
838        self.drag_observers = observers;
839    }
840
841    /// The pointer sequence ended (a tap / plain release) WITHOUT the armed
842    /// ancestor drag latching. Feed the terminating `Up` to each armed ancestor
843    /// so its `DragRecognizer` clears the press origin it recorded when it was
844    /// armed on `PointerDown` — otherwise a later *hover* move would cross the
845    /// drag threshold and start a phantom drag. This matters because the press
846    /// was captured by an interactive descendant (e.g. a card's read-only
847    /// `RichTextEditor`), so the ancestor's own arena never saw this `Up` on
848    /// its own and its recognizer would stay armed indefinitely. Also discards
849    /// the observer list.
850    pub(super) fn release_drag_observers(
851        &mut self,
852        up_event: &WidgetEvent,
853        ops: &mut dyn crate::window::WindowOps,
854    ) {
855        if self.drag_observers.is_empty() {
856            return;
857        }
858        let observers = std::mem::take(&mut self.drag_observers);
859        for id in &observers {
860            // An `Up` while the recognizer is not mid-drag resolves it to
861            // `Failed` and clears `down_position` — no gesture is produced, so
862            // this only tidies recognizer state.
863            self.observe_drag_on_ancestor(*id, up_event, ops);
864        }
865    }
866
867    /// On a captured `PointerMove`, feed the move to each armed ancestor drag
868    /// observer (innermost first). If one latches a drag, it has already called
869    /// `start_drag` (so `active_drag` now owns the pointer) — stop observing.
870    pub(super) fn advance_drag_observers(
871        &mut self,
872        move_event: &WidgetEvent,
873        ops: &mut dyn crate::window::WindowOps,
874    ) {
875        if self.drag_observers.is_empty() {
876            return;
877        }
878        let observers = std::mem::take(&mut self.drag_observers);
879        for id in &observers {
880            let recognized = self.observe_drag_on_ancestor(*id, move_event, ops);
881            if recognized || self.active_drag.is_some() {
882                // A drag latched on this ancestor — it now owns the pointer.
883                return;
884            }
885        }
886        // No drag yet — keep observing on the next move.
887        self.drag_observers = observers;
888    }
889
890    /// Feed one raw pointer event to `id`'s gesture arena WITHOUT firing its
891    /// `on_pointer_event` or taking the implicit capture (the descendant
892    /// already holds it). Returns `true` if the arena recognized a gesture
893    /// (a drag/swipe latched), in which case it is dispatched so the
894    /// `on_drag` handler's `start_drag` runs and `active_drag` takes over.
895    fn observe_drag_on_ancestor(
896        &mut self,
897        id: WidgetId,
898        event: &WidgetEvent,
899        ops: &mut dyn crate::window::WindowOps,
900    ) -> bool {
901        let localized = self.localize_event(id, event);
902        let event = localized.as_ref().unwrap_or(event);
903        let raw = match event {
904            WidgetEvent::PointerDown {
905                position,
906                button,
907                modifiers,
908            } => crate::gesture::RawPointerEvent::Down {
909                position: *position,
910                button: *button,
911                modifiers: *modifiers,
912            },
913            WidgetEvent::PointerMove { position } => crate::gesture::RawPointerEvent::Move {
914                position: *position,
915            },
916            WidgetEvent::PointerUp {
917                position,
918                button,
919                modifiers,
920            } => crate::gesture::RawPointerEvent::Up {
921                position: *position,
922                button: *button,
923                modifiers: *modifiers,
924            },
925            _ => return false,
926        };
927        let mut ctx = self.make_event_context(&mut *ops);
928        let WidgetTree { arena, .. } = self;
929        let recognized = if let Some(node) = arena.get_mut(id) {
930            if let Some(arena_ref) = node.handlers.gesture_arena.as_mut() {
931                if let Some(gesture) = arena_ref.process(&raw) {
932                    Self::dispatch_recognized_gesture(node, gesture, &mut ctx);
933                    true
934                } else {
935                    false
936                }
937            } else {
938                false
939            }
940        } else {
941            false
942        };
943        self.collect_from_ctx(ctx, id);
944        recognized
945    }
946
947    /// Rebuild `event` with any pointer position converted into `id`'s
948    /// **widget-local** space. Returns `None` for events that carry no
949    /// position, so the caller keeps the original event.
950    ///
951    /// This is the single point where the framework localizes pointer
952    /// coordinates. It runs once per node in both the preview and bubble
953    /// passes, and because both `on_pointer_event` and the gesture arena
954    /// read the position out of `event`, localizing it here makes
955    /// `on_tap` / `on_double_tap` / `on_long_press` / `on_drag` and
956    /// `on_pointer_event` all receive widget-local coordinates uniformly.
957    /// See [`WidgetArena::local_pointer_position`].
958    fn localize_event(&self, id: WidgetId, event: &WidgetEvent) -> Option<WidgetEvent> {
959        match event {
960            WidgetEvent::PointerDown {
961                position,
962                button,
963                modifiers,
964            } => Some(WidgetEvent::PointerDown {
965                position: self.arena.local_pointer_position(id, *position),
966                button: *button,
967                modifiers: *modifiers,
968            }),
969            WidgetEvent::PointerUp {
970                position,
971                button,
972                modifiers,
973            } => Some(WidgetEvent::PointerUp {
974                position: self.arena.local_pointer_position(id, *position),
975                button: *button,
976                modifiers: *modifiers,
977            }),
978            WidgetEvent::PointerMove { position } => Some(WidgetEvent::PointerMove {
979                position: self.arena.local_pointer_position(id, *position),
980            }),
981            WidgetEvent::Gesture { gesture } => Some(WidgetEvent::Gesture {
982                gesture: self.localize_gesture(id, gesture),
983            }),
984            _ => None,
985        }
986    }
987
988    /// Convert every position / center field of a pre-recognized
989    /// [`GestureEvent`] into `id`'s widget-local space (`DragMoved.delta`
990    /// is relative and left untouched). Used for the platform gesture
991    /// path; arena-recognized gestures are already local because the
992    /// `RawPointerEvent` feeding the arena was localized by
993    /// [`Self::localize_event`].
994    fn localize_gesture(&self, id: WidgetId, gesture: &GestureEvent) -> GestureEvent {
995        let loc = |p: teksilo_canvas::Point| self.arena.local_pointer_position(id, p);
996        let tap = |t: &TapEvent| TapEvent::new(loc(t.position), t.button, t.modifiers);
997        match gesture {
998            GestureEvent::Tap(t) => GestureEvent::Tap(tap(t)),
999            GestureEvent::DoubleTap(t) => GestureEvent::DoubleTap(tap(t)),
1000            GestureEvent::TripleTap(t) => GestureEvent::TripleTap(tap(t)),
1001            GestureEvent::LongPress(t) => GestureEvent::LongPress(tap(t)),
1002            GestureEvent::DragStarted { position, button } => GestureEvent::DragStarted {
1003                position: loc(*position),
1004                button: *button,
1005            },
1006            GestureEvent::DragMoved { position, delta } => GestureEvent::DragMoved {
1007                position: loc(*position),
1008                delta: *delta,
1009            },
1010            GestureEvent::DragEnded { position } => GestureEvent::DragEnded {
1011                position: loc(*position),
1012            },
1013            GestureEvent::PinchStarted { center } => GestureEvent::PinchStarted {
1014                center: loc(*center),
1015            },
1016            GestureEvent::PinchChanged {
1017                center,
1018                scale,
1019                rotation,
1020            } => GestureEvent::PinchChanged {
1021                center: loc(*center),
1022                scale: *scale,
1023                rotation: *rotation,
1024            },
1025            GestureEvent::PinchEnded => GestureEvent::PinchEnded,
1026            GestureEvent::Swipe {
1027                direction,
1028                velocity,
1029            } => GestureEvent::Swipe {
1030                direction: *direction,
1031                velocity: *velocity,
1032            },
1033        }
1034    }
1035
1036    /// Same as `dispatch_to_widget` but returns `true` when any
1037    /// preview or bubble handler consumed the event. Used for keyboard
1038    /// events the framework wants to consume by default (Tab focus
1039    /// navigation): callers can dispatch first, then fall back to
1040    /// built-in behavior only when no widget claimed it.
1041    pub(super) fn dispatch_to_widget_returning_handled(
1042        &mut self,
1043        target: WidgetId,
1044        event: &WidgetEvent,
1045        ops: &mut dyn crate::window::WindowOps,
1046    ) -> bool {
1047        if !self.arena.is_enabled(target) {
1048            return false;
1049        }
1050
1051        let mut ancestors = Vec::new();
1052        let mut current = self.arena.parent(target);
1053        while let Some(id) = current {
1054            ancestors.push(id);
1055            current = self.arena.parent(id);
1056        }
1057        ancestors.reverse();
1058
1059        // For a pointer press, find the innermost tap-owning node at-or-above
1060        // the hit target (a chevron / checkbox / inline button). A row or
1061        // container that selects on press consults
1062        // `ctx.press_claimed_by_interactive_child()` to skip selecting when this
1063        // owner is a strict descendant of it — the press belongs to the inner
1064        // control, not the row. Tap-like handlers only; drag/swipe are excluded
1065        // so a draggable row still selects itself on press.
1066        //
1067        // **`on_tap` / `on_long_press` only — never `on_double_tap` alone.**
1068        // The question this answers is "does a descendant own *this press*",
1069        // and a widget that wired only a multi-tap handler does not: the first
1070        // click of a double-click is not its business. Counting it meant a
1071        // table cell could not carry double-click-to-edit without also
1072        // silently stopping its row from selecting on a plain click — while
1073        // every file manager selects a row on the first click of the
1074        // double-click that opens it. A node that wants the press still has
1075        // `on_tap` (a real `Button`, a checkbox), and those are unaffected.
1076        let tap_owner: Option<WidgetId> = if matches!(
1077            event,
1078            WidgetEvent::PointerDown { .. } | WidgetEvent::PointerUp { .. }
1079        ) {
1080            let mut owner = None;
1081            let mut cur = Some(target);
1082            while let Some(id) = cur {
1083                if self.arena.get(id).is_some_and(|n| {
1084                    n.any_handler(|h| h.on_tap.is_some() || h.on_long_press.is_some())
1085                }) {
1086                    owner = Some(id);
1087                    break;
1088                }
1089                cur = self.arena.parent(id);
1090            }
1091            owner
1092        } else {
1093            None
1094        };
1095
1096        for &id in &ancestors {
1097            let mut ctx = self.make_event_context(&mut *ops);
1098            ctx.press_claimed_by_interactive_child =
1099                tap_owner.is_some_and(|owner| owner != id && self.is_descendant_of(owner, id));
1100            // Convert any pointer position into this node's widget-local
1101            // space before its handlers see it (see `localize_event`).
1102            let localized = self.localize_event(id, event);
1103            let event = localized.as_ref().unwrap_or(event);
1104            let response = if let Some(node) = self.arena.get_mut(id) {
1105                Self::try_handler_preview(node, event, &mut ctx).unwrap_or(EventResponse::Ignored)
1106            } else {
1107                EventResponse::Ignored
1108            };
1109            self.collect_from_ctx(ctx, id);
1110            if response == EventResponse::Handled {
1111                self.arena.mark_needs_paint(id);
1112                return true;
1113            }
1114        }
1115
1116        let needs_layout_on_handle = matches!(
1117            event,
1118            WidgetEvent::Scroll { .. } | WidgetEvent::ScrollIntoView { .. }
1119        );
1120        let mut current = Some(target);
1121        let mut is_target = true;
1122        while let Some(id) = current {
1123            let mut ctx = self.make_event_context(&mut *ops);
1124            ctx.press_claimed_by_interactive_child =
1125                tap_owner.is_some_and(|owner| owner != id && self.is_descendant_of(owner, id));
1126            // Convert any pointer position into this node's widget-local
1127            // space before its handlers (and its gesture arena) see it.
1128            let localized = self.localize_event(id, event);
1129            let WidgetTree {
1130                arena,
1131                gesture_owners,
1132                ..
1133            } = self;
1134            let event = localized.as_ref().unwrap_or(event);
1135            let response = if let Some(node) = arena.get_mut(id) {
1136                Self::try_handler_bubble(node, event, &mut ctx, is_target, id, gesture_owners)
1137                    .unwrap_or(EventResponse::Ignored)
1138            } else {
1139                EventResponse::Ignored
1140            };
1141            self.collect_from_ctx(ctx, id);
1142            if response == EventResponse::Handled {
1143                if needs_layout_on_handle {
1144                    self.arena.mark_needs_layout(id);
1145                } else {
1146                    self.arena.mark_needs_paint(id);
1147                }
1148                // **Hover transitions are notifications, and every ancestor is
1149                // entitled to one.** Stopping the bubble here left a container
1150                // stuck hovered whenever the pointer left it *through* an
1151                // interactive child: the child's own `on_hover` handled the
1152                // `PointerLeave`, the bubble stopped, and the row went on believing
1153                // the pointer was still over it. A search result whose controls
1154                // appear on hover then kept them after the pointer had gone.
1155                //
1156                // The preview pass already refuses to let an ancestor swallow a
1157                // descendant's Enter/Leave; this is that rule in the other
1158                // direction, and it is what makes a container's hover mean "the
1159                // pointer is somewhere inside me" rather than "the pointer is on my
1160                // own background". Every other event still stops at its handler,
1161                // which is what makes handling one mean anything.
1162                if !matches!(event, WidgetEvent::PointerEnter | WidgetEvent::PointerLeave) {
1163                    return true;
1164                }
1165            }
1166            is_target = false;
1167            current = self.arena.parent(id);
1168        }
1169        false
1170    }
1171
1172    pub(super) fn dispatch_to_widget_direct(
1173        &mut self,
1174        target: WidgetId,
1175        event: &WidgetEvent,
1176        ops: &mut dyn crate::window::WindowOps,
1177    ) {
1178        if !self.arena.is_enabled(target) {
1179            return;
1180        }
1181
1182        let mut ctx = self.make_event_context(&mut *ops);
1183        let WidgetTree {
1184            arena,
1185            gesture_owners,
1186            ..
1187        } = self;
1188        let response = if let Some(node) = arena.get_mut(target) {
1189            Self::try_handler_bubble(node, event, &mut ctx, true, target, gesture_owners)
1190                .unwrap_or(EventResponse::Ignored)
1191        } else {
1192            EventResponse::Ignored
1193        };
1194        self.collect_from_ctx(ctx, target);
1195
1196        if response == EventResponse::Handled {
1197            self.arena.mark_needs_paint(target);
1198        }
1199    }
1200
1201    fn try_handler_preview(
1202        node: &mut crate::arena::WidgetNode,
1203        event: &WidgetEvent,
1204        ctx: &mut EventContext,
1205    ) -> Option<EventResponse> {
1206        match event {
1207            // Key + IME events fire `on_key_preview` on each strict
1208            // ancestor of the focused widget (root → parent-of-target).
1209            // Mirrors how `on_pointer_event` previews on the pointer
1210            // side; the focused widget itself does NOT see its own
1211            // `on_key_preview` (the dispatch loop builds an ancestors
1212            // list that excludes the target, so this is enforced by
1213            // the caller, not here).
1214            WidgetEvent::KeyDown { .. }
1215            | WidgetEvent::KeyUp { .. }
1216            | WidgetEvent::ImeComposition { .. }
1217            | WidgetEvent::ImeCommit { .. } => {
1218                let has = node.external_handlers.on_key_preview.is_some()
1219                    || node.handlers.on_key_preview.is_some();
1220                if !has {
1221                    return None;
1222                }
1223                Some(fire_event_handler_both(
1224                    &mut node.external_handlers.on_key_preview,
1225                    &mut node.handlers.on_key_preview,
1226                    event,
1227                    ctx,
1228                ))
1229            }
1230            // `PointerEnter` / `PointerLeave` are per-node hover transitions
1231            // synthesized by `handle_pointer_move`, not part of the raw pointer
1232            // stream. Running them through the ancestor preview pass would let
1233            // a drag-detecting ancestor whose `on_pointer_event` returns
1234            // `Handled` silently swallow a descendant's hover (its cursor and
1235            // `on_hover` would never fire). They are delivered to their target
1236            // directly via the bubble pass (where Enter/Leave fire `on_hover`),
1237            // so they have no business in preview. `PointerMove`/`Down`/`Up`
1238            // and `Scroll` still preview through the catch-all below — the
1239            // tab-bar wheel-remap (`tab_widget/bar.rs`) and the split-view /
1240            // rich-text drag guards depend on that.
1241            WidgetEvent::PointerEnter | WidgetEvent::PointerLeave => None,
1242            _ => {
1243                let has = node.external_handlers.on_pointer_event.is_some()
1244                    || node.handlers.on_pointer_event.is_some();
1245                if !has {
1246                    return None;
1247                }
1248                Some(fire_event_handler_both(
1249                    &mut node.external_handlers.on_pointer_event,
1250                    &mut node.handlers.on_pointer_event,
1251                    event,
1252                    ctx,
1253                ))
1254            }
1255        }
1256    }
1257
1258    /// `fire_on_pointer_event` gates the pre-gesture `on_pointer_event`
1259    /// intercept. Set it to `true` for the bubble target (the widget the
1260    /// event was dispatched at) and `false` for every ancestor, because
1261    /// ancestors already fired their `on_pointer_event` during the
1262    /// preview pass — firing it again in bubble was the source of
1263    /// double-toggle / double-select bugs when a wrapper widget (e.g.
1264    /// `ListItemWrapper`) held the handler and a child leaf was the hit
1265    /// target.
1266    fn try_handler_bubble(
1267        node: &mut crate::arena::WidgetNode,
1268        event: &WidgetEvent,
1269        ctx: &mut EventContext,
1270        fire_on_pointer_event: bool,
1271        node_id: WidgetId,
1272        gesture_owners: &mut std::collections::HashSet<WidgetId>,
1273    ) -> Option<EventResponse> {
1274        match event {
1275            WidgetEvent::PointerEnter => {
1276                if let Some(cursor) = node.node_cursor {
1277                    ctx.set_cursor(cursor);
1278                }
1279                let mut fired = false;
1280                if let Some(h) = node.external_handlers.on_hover.as_mut() {
1281                    h(true, ctx);
1282                    fired = true;
1283                }
1284                if let Some(h) = node.handlers.on_hover.as_mut() {
1285                    h(true, ctx);
1286                    fired = true;
1287                }
1288                if fired {
1289                    Some(EventResponse::Handled)
1290                } else {
1291                    node.node_cursor.map(|_| EventResponse::Handled)
1292                }
1293            }
1294            WidgetEvent::PointerLeave => {
1295                if node.node_cursor.is_some() {
1296                    ctx.set_cursor(crate::widget::CursorIcon::Default);
1297                }
1298                let mut fired = false;
1299                if let Some(h) = node.external_handlers.on_hover.as_mut() {
1300                    h(false, ctx);
1301                    fired = true;
1302                }
1303                if let Some(h) = node.handlers.on_hover.as_mut() {
1304                    h(false, ctx);
1305                    fired = true;
1306                }
1307                if fired {
1308                    Some(EventResponse::Handled)
1309                } else {
1310                    node.node_cursor.map(|_| EventResponse::Handled)
1311                }
1312            }
1313            WidgetEvent::FocusGained { .. } => {
1314                let mut fired = false;
1315                if let Some(h) = node.external_handlers.on_focus.as_mut() {
1316                    h(true, ctx);
1317                    fired = true;
1318                }
1319                if let Some(h) = node.handlers.on_focus.as_mut() {
1320                    h(true, ctx);
1321                    fired = true;
1322                }
1323                fired.then_some(EventResponse::Handled)
1324            }
1325            WidgetEvent::FocusLost => {
1326                let mut fired = false;
1327                if let Some(h) = node.external_handlers.on_focus.as_mut() {
1328                    h(false, ctx);
1329                    fired = true;
1330                }
1331                if let Some(h) = node.handlers.on_focus.as_mut() {
1332                    h(false, ctx);
1333                    fired = true;
1334                }
1335                fired.then_some(EventResponse::Handled)
1336            }
1337            WidgetEvent::KeyDown { .. }
1338            | WidgetEvent::KeyUp { .. }
1339            | WidgetEvent::ImeComposition { .. }
1340            | WidgetEvent::ImeCommit { .. } => {
1341                if node.external_handlers.on_key.is_some() || node.handlers.on_key.is_some() {
1342                    Some(fire_event_handler_both(
1343                        &mut node.external_handlers.on_key,
1344                        &mut node.handlers.on_key,
1345                        event,
1346                        ctx,
1347                    ))
1348                } else {
1349                    None
1350                }
1351            }
1352            WidgetEvent::Scroll { .. } | WidgetEvent::ScrollIntoView { .. } => {
1353                if node.external_handlers.on_scroll.is_some() || node.handlers.on_scroll.is_some() {
1354                    Some(fire_event_handler_both(
1355                        &mut node.external_handlers.on_scroll,
1356                        &mut node.handlers.on_scroll,
1357                        event,
1358                        ctx,
1359                    ))
1360                } else {
1361                    None
1362                }
1363            }
1364            WidgetEvent::AccessAction {
1365                action,
1366                target_node,
1367                data,
1368                ..
1369            } => {
1370                // Prefer the full-payload handler when the widget has
1371                // opted in; it's the one that receives `target_node` and
1372                // `data`. Within each payload variant, fire BOTH external
1373                // and own handlers — Button (own) and Dialog (external)
1374                // layered together rely on both firing for a single
1375                // accesskit click.
1376                // Assistive-tech action paths run under
1377                // the `Accessibility` source label. Restored after
1378                // the inner if/else.
1379                let saved_a11y_source = ctx
1380                    .current_source
1381                    .replace(crate::telemetry::IntentSource::Accessibility);
1382                let request_is_set = node.handlers.on_access_action_request.is_some()
1383                    || node.external_handlers.on_access_action_request.is_some();
1384                let user_handled = if request_is_set {
1385                    let r1 = node
1386                        .external_handlers
1387                        .on_access_action_request
1388                        .as_mut()
1389                        .map(|h| h(*action, *target_node, data.clone(), ctx))
1390                        .unwrap_or(EventResponse::Ignored);
1391                    let r2 = node
1392                        .handlers
1393                        .on_access_action_request
1394                        .as_mut()
1395                        .map(|h| h(*action, *target_node, data.clone(), ctx))
1396                        .unwrap_or(EventResponse::Ignored);
1397                    Some(
1398                        if r1 == EventResponse::Handled || r2 == EventResponse::Handled {
1399                            EventResponse::Handled
1400                        } else {
1401                            EventResponse::Ignored
1402                        },
1403                    )
1404                } else if node.handlers.on_access_action.is_some()
1405                    || node.external_handlers.on_access_action.is_some()
1406                {
1407                    let r1 = node
1408                        .external_handlers
1409                        .on_access_action
1410                        .as_mut()
1411                        .map(|h| h(*action, ctx))
1412                        .unwrap_or(EventResponse::Ignored);
1413                    let r2 = node
1414                        .handlers
1415                        .on_access_action
1416                        .as_mut()
1417                        .map(|h| h(*action, ctx))
1418                        .unwrap_or(EventResponse::Ignored);
1419                    Some(
1420                        if r1 == EventResponse::Handled || r2 == EventResponse::Handled {
1421                            EventResponse::Handled
1422                        } else {
1423                            EventResponse::Ignored
1424                        },
1425                    )
1426                } else {
1427                    None
1428                };
1429
1430                // Builder-level access_action / access_custom_action
1431                // callbacks. These layer on top of any user-installed
1432                // on_access_action / on_access_action_request — both
1433                // fire for the same dispatched event. Drives the
1434                // SwiftUI `.accessibilityAction(...)` parity.
1435                let mut override_handled = false;
1436                if let Some(ov) = node.access_overrides.as_deref_mut() {
1437                    if matches!(action, accesskit::Action::CustomAction) {
1438                        if let Some(accesskit::ActionData::CustomAction(idx)) = data
1439                            && let Some((_, cb)) = ov.custom_actions.get_mut(*idx as usize)
1440                        {
1441                            cb(ctx);
1442                            override_handled = true;
1443                        }
1444                    } else {
1445                        for (a, cb) in ov.actions.iter_mut() {
1446                            if *a == *action {
1447                                cb(ctx);
1448                                override_handled = true;
1449                            }
1450                        }
1451                    }
1452                }
1453
1454                ctx.current_source = saved_a11y_source;
1455                match (user_handled, override_handled) {
1456                    (Some(EventResponse::Handled), _) | (_, true) => Some(EventResponse::Handled),
1457                    (Some(EventResponse::Ignored), false) => Some(EventResponse::Ignored),
1458                    (None, false) => None,
1459                }
1460            }
1461            WidgetEvent::Gesture { gesture } => {
1462                // Pre-recognized gestures from the platform (OS trackpad
1463                // pinch/rotation, double-tap, …) bypass the gesture arena
1464                // and go straight to the matching handler. See §10.
1465                let matched = matches!(
1466                    gesture,
1467                    GestureEvent::PinchStarted { .. }
1468                        | GestureEvent::PinchChanged { .. }
1469                        | GestureEvent::PinchEnded
1470                        | GestureEvent::Swipe { .. }
1471                        | GestureEvent::DoubleTap { .. }
1472                        | GestureEvent::TripleTap { .. }
1473                ) && {
1474                    let has_handler = match gesture {
1475                        GestureEvent::PinchStarted { .. }
1476                        | GestureEvent::PinchChanged { .. }
1477                        | GestureEvent::PinchEnded => node.any_handler(|h| h.on_pinch.is_some()),
1478                        GestureEvent::Swipe { .. } => node.any_handler(|h| h.on_swipe.is_some()),
1479                        GestureEvent::DoubleTap { .. } => {
1480                            node.any_handler(|h| h.on_double_tap.is_some())
1481                        }
1482                        GestureEvent::TripleTap { .. } => {
1483                            node.any_handler(|h| h.on_triple_tap.is_some())
1484                        }
1485                        _ => false,
1486                    };
1487                    if has_handler {
1488                        Self::dispatch_recognized_gesture(node, *gesture, ctx);
1489                    }
1490                    has_handler
1491                };
1492                if matched {
1493                    Some(EventResponse::Handled)
1494                } else {
1495                    None
1496                }
1497            }
1498            WidgetEvent::PointerDown {
1499                position,
1500                button,
1501                modifiers,
1502            } => {
1503                // Raw pointer handler runs first so widgets can intercept
1504                // events that the gesture recognizers won't catch (e.g.
1505                // right-click → context menu). If it returns Handled the
1506                // gesture arena is skipped; otherwise we fall through.
1507                // Only fire for the target — ancestors already fired
1508                // on_pointer_event during the preview pass.
1509                if fire_on_pointer_event {
1510                    let r = fire_event_handler_both(
1511                        &mut node.external_handlers.on_pointer_event,
1512                        &mut node.handlers.on_pointer_event,
1513                        event,
1514                        ctx,
1515                    );
1516                    if r == EventResponse::Handled {
1517                        return Some(EventResponse::Handled);
1518                    }
1519                }
1520                Self::ensure_gesture_arena(node, node_id, gesture_owners);
1521                if let Some(arena) = node.handlers.gesture_arena.as_mut() {
1522                    // Implicit capture for the Down..Up sequence so that
1523                    // moves leaving the widget bounds still reach the
1524                    // arena. Without this, a drag that starts inside the
1525                    // widget but crosses its edge before the recognizer
1526                    // latches would be hit-tested to another widget and
1527                    // the press-origin arena would never see a `Move`.
1528                    // Released unconditionally by the `PointerUp` branch
1529                    // in `dispatch_event`.
1530                    ctx.capture_pointer();
1531                    let result = arena.process(&RawPointerEvent::Down {
1532                        position: *position,
1533                        button: *button,
1534                        modifiers: *modifiers,
1535                    });
1536                    if let Some(gesture) = result {
1537                        Self::dispatch_recognized_gesture(node, gesture, ctx);
1538                    }
1539                    return Some(EventResponse::Handled);
1540                }
1541                None
1542            }
1543            WidgetEvent::PointerUp {
1544                position,
1545                button,
1546                modifiers,
1547            } => {
1548                if fire_on_pointer_event {
1549                    let r = fire_event_handler_both(
1550                        &mut node.external_handlers.on_pointer_event,
1551                        &mut node.handlers.on_pointer_event,
1552                        event,
1553                        ctx,
1554                    );
1555                    if r == EventResponse::Handled {
1556                        return Some(EventResponse::Handled);
1557                    }
1558                }
1559                if let Some(arena) = node.handlers.gesture_arena.as_mut() {
1560                    let result = arena.process(&RawPointerEvent::Up {
1561                        position: *position,
1562                        button: *button,
1563                        modifiers: *modifiers,
1564                    });
1565                    if let Some(gesture) = result {
1566                        Self::dispatch_recognized_gesture(node, gesture, ctx);
1567                    }
1568                    return Some(EventResponse::Handled);
1569                }
1570                None
1571            }
1572            WidgetEvent::PointerMove { position } => {
1573                if fire_on_pointer_event {
1574                    let r = fire_event_handler_both(
1575                        &mut node.external_handlers.on_pointer_event,
1576                        &mut node.handlers.on_pointer_event,
1577                        event,
1578                        ctx,
1579                    );
1580                    if r == EventResponse::Handled {
1581                        return Some(EventResponse::Handled);
1582                    }
1583                }
1584                if let Some(arena) = node.handlers.gesture_arena.as_mut() {
1585                    let result = arena.process(&RawPointerEvent::Move {
1586                        position: *position,
1587                    });
1588                    if let Some(gesture) = result {
1589                        Self::dispatch_recognized_gesture(node, gesture, ctx);
1590                        // A recognized gesture (DragStarted / DragMoved / …)
1591                        // almost always changes visible state — return
1592                        // `Handled` so the bubble loop marks this widget
1593                        // `needs_paint`, which in turn makes
1594                        // `WidgetTree::needs_redraw()` return true and
1595                        // triggers a `request_redraw` for the next frame.
1596                        // Without this, state updates via bound signals are
1597                        // only observed on the *next* layout/render pass,
1598                        // which in turn is never scheduled because
1599                        // `teksilo-app::update_control_flow` only wakes up when
1600                        // `needs_redraw()` is true.
1601                        return Some(EventResponse::Handled);
1602                    }
1603                    return Some(EventResponse::Ignored);
1604                }
1605                None
1606            }
1607        }
1608    }
1609
1610    pub(super) fn collect_from_ctx<'ops>(
1611        &mut self,
1612        mut ctx: EventContext<'ops>,
1613        source_widget: WidgetId,
1614    ) {
1615        // Take the ops handle out of ctx up front so we can freely
1616        // reborrow it inside the method without fighting the 'ops
1617        // lifetime propagation when other fields of `ctx` are moved.
1618        // When no ops is set (standalone trees / tests), fall back to
1619        // a stack NoopWindowOps.
1620        let local_ops = ctx.window_ops.take();
1621        let mut noop = crate::window::NoopWindowOps;
1622        let ops: &mut dyn crate::window::WindowOps = match local_ops {
1623            Some(o) => o,
1624            None => &mut noop,
1625        };
1626        if ctx.frame_requested {
1627            self.request_frame();
1628        }
1629        if let Some(cursor) = ctx.cursor_request {
1630            self.current_cursor = cursor;
1631        }
1632        // Intents queued through `ctx.send_intent` are anchored at
1633        // the originating widget. Programmatic sends default to
1634        // `propagate_when_disabled = true` — there is no shortcut to
1635        // consult, and propagation is the safe, least-surprising
1636        // default.
1637        for intent in ctx.pending_intents {
1638            self.enqueue_intent(source_widget, intent, true);
1639        }
1640        // Key capture: process cancel before arm, matching the
1641        // handler's call order (the handler sets `cancel_key_capture`
1642        // when it calls `ctx.cancel_key_capture()`, and separately
1643        // stores `pending_key_capture` when it calls
1644        // `ctx.begin_key_capture(...)`). If the handler did both,
1645        // arm wins (whichever was called last on the ctx has
1646        // already overwritten the other field's effect via the
1647        // setter logic).
1648        if ctx.cancel_key_capture {
1649            self.cancel_key_capture();
1650        }
1651        if let Some(slot) = ctx.pending_key_capture {
1652            self.key_capture = Some(slot);
1653        }
1654        // Registry mutations queued by settings-UI buttons.
1655        for mutation in ctx.pending_shortcut_mutations {
1656            match mutation {
1657                crate::widget::ShortcutMutation::RebindPrimary { id, keystroke } => {
1658                    self.shortcut_registry.rebind_primary(id, keystroke);
1659                }
1660                crate::widget::ShortcutMutation::RebindSecondary { id, keystroke } => {
1661                    self.shortcut_registry.rebind_secondary(id, keystroke);
1662                }
1663                crate::widget::ShortcutMutation::ClearOverride { id } => {
1664                    self.shortcut_registry.clear_override(&id);
1665                }
1666            }
1667        }
1668        if ctx.close_window_requested {
1669            self.close_window_requested = true;
1670        }
1671        if ctx.force_close_requested {
1672            self.force_close_requested = true;
1673        }
1674        self.pending_modal_requests
1675            .extend(ctx.modal_requests.into_iter().map(|request| {
1676                crate::modal::QueuedModalRequest {
1677                    source_widget,
1678                    request,
1679                }
1680            }));
1681        if ctx.dismiss_modal && !self.dismiss_modal_for_source(source_widget, &mut *ops) {
1682            self.pending_modal_dismissal = true;
1683        }
1684        for callback in ctx.idle_callbacks {
1685            self.idle_queue.push_boxed(callback);
1686        }
1687        match ctx.dismiss_scope {
1688            Some(crate::widget::DismissScope::All) => {
1689                let dismissed = self.overlay_manager.dismiss_all();
1690                self.dormant_dismissed_content(&dismissed, &mut *ops);
1691            }
1692            Some(crate::widget::DismissScope::AllExceptHosts) => {
1693                self.dismiss_all_overlays_except_hosts(&mut *ops);
1694            }
1695            Some(crate::widget::DismissScope::SelfChain) => {
1696                self.dismiss_self_overlay_chain_for_source(source_widget, &mut *ops);
1697            }
1698            Some(crate::widget::DismissScope::Top) => {
1699                if let Some((_id, content_ids, focus_restore)) = self.overlay_manager.dismiss_top()
1700                {
1701                    self.dormant_dismissed_content(&content_ids, &mut *ops);
1702                    if let Some(restore_id) = focus_restore
1703                        && self.arena.is_active(restore_id)
1704                    {
1705                        self.focus_ops(restore_id, &mut *ops);
1706                    }
1707                }
1708            }
1709            None => {
1710                for id in ctx.overlay_dismissals {
1711                    let dismissed = self.overlay_manager.dismiss(id);
1712                    self.dormant_dismissed_content(&dismissed, &mut *ops);
1713                }
1714            }
1715        }
1716        // Content-keyed dismissals (`dismiss_overlay_by_content`). Drained
1717        // unconditionally — independent of `dismiss_scope` and of the
1718        // pending delayed-overlay list — so a handler can retract a shown
1719        // reusable overlay it identifies only by content. Resolving the
1720        // id here (not at call time) is what lets the caller skip
1721        // tracking the `OverlayId`.
1722        for content_id in ctx.overlay_content_dismissals {
1723            if let Some(overlay_id) = self.overlay_manager.find_by_content(content_id) {
1724                let dismissed = self.overlay_manager.dismiss(overlay_id);
1725                self.dormant_dismissed_content(&dismissed, &mut *ops);
1726            }
1727        }
1728        // Apply pause/resume queue (ToastHost hover-pause). Drained
1729        // here so the handler-side `ctx.pause_overlay_auto_dismiss(id)`
1730        // is order-independent with `dismiss_overlay(id)` and the
1731        // scope-based dismissals: pause/resume on an overlay that
1732        // was concurrently dismissed is silently dropped (the find
1733        // inside the OverlayManager methods misses on the gone id).
1734        for (id, pause) in ctx.overlay_pause_requests {
1735            if pause {
1736                self.overlay_manager.pause_auto_dismiss(id);
1737            } else {
1738                self.overlay_manager.resume_auto_dismiss(id);
1739            }
1740        }
1741        for preserve_content in ctx.dismiss_descendant_overlays {
1742            self.dismiss_child_overlays_for_source(source_widget, preserve_content, &mut *ops);
1743        }
1744        self.apply_tree_mutations(std::mem::take(&mut ctx.tree_mutations));
1745        if ctx.request_a11y_update {
1746            self.a11y_dirty = true;
1747        }
1748        for mut req in ctx.overlay_requests {
1749            if req.parent_overlay.is_none() {
1750                req.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
1751            }
1752            if self
1753                .overlay_manager
1754                .find_by_content(req.content_id)
1755                .is_some()
1756            {
1757                continue;
1758            }
1759            let current_focus = self.focused;
1760            self.overlay_manager.show(req);
1761            // Overlay show changes the AT tree shape — mirror the
1762            // `WidgetTree::show_overlay` path. The dismissal sibling
1763            // (`dismiss_overlay_with_ops`) already flips this.
1764            self.a11y_dirty = true;
1765            if let Some(focus_id) = current_focus {
1766                self.overlay_manager.set_top_focus_restore(focus_id);
1767            }
1768        }
1769        for (mut req, duration) in ctx.timed_overlay_requests {
1770            if req.parent_overlay.is_none() {
1771                req.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
1772            }
1773            if self
1774                .overlay_manager
1775                .find_by_content(req.content_id)
1776                .is_some()
1777            {
1778                continue;
1779            }
1780            let current_focus = self.focused;
1781            let overlay_id = self.overlay_manager.show_for(req, duration);
1782            self.overlay_manager
1783                .set_shown_at_sim(overlay_id, self.sim_clock);
1784            self.a11y_dirty = true;
1785            if let Some(focus_id) = current_focus {
1786                self.overlay_manager.set_top_focus_restore(focus_id);
1787            }
1788        }
1789        for (mut req, progress, duration) in ctx.reveal_overlay_requests {
1790            if req.parent_overlay.is_none() {
1791                req.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
1792            }
1793            if self
1794                .overlay_manager
1795                .find_by_content(req.content_id)
1796                .is_some()
1797            {
1798                continue;
1799            }
1800            let content_id = req.content_id;
1801            let current_focus = self.focused;
1802            let overlay_id = self.overlay_manager.show(req);
1803            self.overlay_manager
1804                .set_shown_at_sim(overlay_id, self.sim_clock);
1805            self.a11y_dirty = true;
1806            if let Some(focus_id) = current_focus {
1807                self.overlay_manager.set_top_focus_restore(focus_id);
1808            }
1809            // Drive the caller's progress signal 0 → 1, and register it
1810            // as the overlay's fade-state signal so every dismiss path
1811            // tweens it 1 → 0 and defers removal until it completes — the
1812            // same deferral machinery as `with_fade`, minus `set_opacity`
1813            // (the caller owns how `progress` paints).
1814            self.register_animated_signal(&progress, content_id);
1815            let _ = progress.try_animate_with_options(crate::animation::AnimationRequest {
1816                target: 1.0,
1817                duration,
1818                easing: teksilo_tokens::Easing::EaseOut,
1819                frame_interval: None,
1820                looping: false,
1821                epsilon: 0.0,
1822                max_duration: None,
1823            });
1824            self.overlay_manager
1825                .attach_fade(overlay_id, progress, duration);
1826        }
1827        if let Some(capture) = ctx.pointer_capture {
1828            if capture {
1829                self.pointer_captured_by = Some(source_widget);
1830            } else {
1831                self.pointer_captured_by = None;
1832            }
1833        }
1834        for (mut request, delay, focus_target) in ctx.delayed_overlay_requests {
1835            if request.parent_overlay.is_none() {
1836                request.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
1837            }
1838            if self
1839                .overlay_manager
1840                .find_by_content(request.content_id)
1841                .is_some()
1842            {
1843                continue;
1844            }
1845            let content_id = request.content_id;
1846            self.pending_delayed_overlays
1847                .retain(|pending| pending.request.content_id != content_id);
1848            self.pending_delayed_overlays.push(PendingDelayedOverlay {
1849                request,
1850                delay,
1851                focus_target,
1852                real_requested_at: std::time::Instant::now(),
1853                sim_requested_at: self.sim_clock,
1854            });
1855            self.arena.mark_needs_paint(source_widget);
1856        }
1857        for content_id in ctx.cancel_delayed_overlays {
1858            self.pending_delayed_overlays
1859                .retain(|pending| pending.request.content_id != content_id);
1860        }
1861        for id in ctx.repaint_requests {
1862            self.arena.mark_needs_paint(id);
1863        }
1864        for id in ctx.synthetic_clicks {
1865            // Over the caller's ops, never a standalone dispatch: the
1866            // tapped widget's own handler runs inside this nested
1867            // dispatch, so a standalone one would deny it the
1868            // multi-window API this dispatch already has in hand.
1869            self.synthesise_tap_with_ops(id, &mut *ops);
1870        }
1871        if let Some(&id) = ctx.focus_requests.last() {
1872            // If the requested widget is itself not focusable (e.g. a
1873            // composite like `TextInput` whose focus-handling lives on
1874            // an inner leaf), walk into the subtree and land on the
1875            // first focusable descendant in document order. This makes
1876            // `ctx.request_focus(some_composite)` Do The Right Thing
1877            // without every caller having to reach into private inner
1878            // ids. `first_focusable_descendant` returns the node itself
1879            // when it's focusable, so the usual leaf-target case is
1880            // still a no-op lookup.
1881            let target = self.first_focusable_descendant(id).unwrap_or(id);
1882            self.focus_ops(target, &mut *ops);
1883        }
1884        if let Some(&id) = ctx.focus_into_requests.last() {
1885            // "Focus into" semantics: land on the first focusable descendant
1886            // and — unlike `focus_requests` above — do NOT fall back to the
1887            // container itself. A region with no focusable content (and not
1888            // focusable in its own right) leaves focus untouched rather than
1889            // trapping it on a non-interactive node. Drives Enter-on-a-tab →
1890            // into the tab panel.
1891            if let Some(target) = self.first_focusable_descendant(id) {
1892                self.focus_ops(target, &mut *ops);
1893            }
1894        }
1895
1896        // Rect-based "scroll this into view" requests (`ctx.ensure_visible`).
1897        // Walk outward from the widget whose handler queued the request and
1898        // reveal the rect inside every enclosing scroll container. Run after
1899        // focus so that if the same handler also moved focus, both follows
1900        // settle against the same (pre-relayout) bounds; each dispatch is
1901        // gated on the container not already showing the rect, so ordering is
1902        // harmless. The source widget itself is excluded from the walk — it
1903        // owns revealing an interior rect inside its own viewport.
1904        for req in ctx.scroll_into_view_requests {
1905            self.scroll_rect_into_view(
1906                // Whoever the rect belongs to — the source widget unless the caller
1907                // named another. See `EventContext::ensure_visible_from`.
1908                req.from.unwrap_or(source_widget),
1909                req.rect,
1910                req.margin,
1911                req.align,
1912                req.motion,
1913                &mut *ops,
1914            );
1915        }
1916        // Id-based `ctx.ensure_widget_visible`: resolve to the target's current
1917        // absolute bounds and walk *its* ancestors (skip if it was destroyed
1918        // before the drain). Walking from the target — not `source_widget` —
1919        // means the request reveals that widget wherever it sits, even when the
1920        // handler runs on a different node (a group's roving-key handler
1921        // revealing the child tile it just selected).
1922        for (id, margin) in ctx.scroll_widget_into_view_requests {
1923            if self.arena.get(id).is_some() {
1924                let bounds = self.arena.bounds(id);
1925                self.scroll_rect_into_view(
1926                    id,
1927                    bounds,
1928                    margin,
1929                    crate::event::ScrollAlign::Minimal,
1930                    crate::event::ScrollMotion::Instant,
1931                    &mut *ops,
1932                );
1933            }
1934        }
1935
1936        // Keyboard-highlight tooltip: surface the highlighted (menu) item's
1937        // tooltip immediately and dismiss the previously-highlighted one. Keyed
1938        // on the item id, NOT real focus (which stays on the menu panel for key
1939        // handling). Only the last request per handler is honoured.
1940        if let Some(&id) = ctx.highlight_tooltip_requests.last() {
1941            self.show_highlight_tooltip(id, &mut *ops);
1942        }
1943
1944        // --- Drag and drop ---
1945        if let Some((source_widget, payload, preview_widget)) = ctx.drag_start_request {
1946            let (preview_content_id, preview_overlay_id) = if let Some(preview) = preview_widget {
1947                // `add_boxed` — NOT `arena.insert` — runs the widget's
1948                // `build()` so composite previews (our `DragPreview`
1949                // wrapper in teksilo-widgets, or anything a user supplies)
1950                // actually instantiate their child subtree. Plain
1951                // `arena.insert` stops at the root node, leaves build
1952                // un-fired, and the overlay renders an empty widget.
1953                let content_id = self.add_boxed(preview);
1954                let overlay_id = self.overlay_manager.show(crate::overlay::OverlayRequest {
1955                    content_id,
1956                    anchor: source_widget,
1957                    placement: crate::overlay::OverlayPlacement::AtPointer(
1958                        teksilo_canvas::Point::ZERO,
1959                    ),
1960                    dismiss: crate::overlay::DismissBehavior::Manual,
1961                    layer: crate::overlay::OverlayLayer::InTree,
1962                    parent_overlay: None,
1963                    on_dismiss: None,
1964                    fade_duration: None,
1965                });
1966                // Force the next layout pass to run `position_overlays`
1967                // and `set_content_bounds` — otherwise the preview sits
1968                // at its initial (0, 0) placement forever.
1969                self.arena.mark_needs_layout(content_id);
1970                (Some(content_id), Some(overlay_id))
1971            } else {
1972                (None, None)
1973            };
1974            self.active_drag = Some(crate::drag_state::DragSession {
1975                payload,
1976                source_widget: Some(source_widget),
1977                is_external: false,
1978                current_position: teksilo_canvas::Point::ZERO,
1979                current_target: None,
1980                feedback: crate::drag_state::DropFeedback::NoFeedback,
1981                preview_content_id,
1982                preview_overlay_id,
1983            });
1984            self.pointer_captured_by = Some(source_widget);
1985            // Grabbing-hand cursor while the drag is in flight. Reset on
1986            // drop / cancel / source-destroyed below.
1987            self.current_cursor = crate::widget::CursorIcon::Grabbing;
1988        }
1989        if ctx.cancel_drag {
1990            self.cancel_active_drag(&mut *ops);
1991        }
1992
1993        // --- Environment changes (architecture §9.5) ---
1994        if let Some(theme) = ctx.theme_request {
1995            // Stored, not applied: the app layer routes this through
1996            // `WindowManager::set_theme` so every window re-themes, matching
1997            // the app-wide `set_locale` path below. Applying
1998            // `WidgetTree::set_theme` inline would re-theme only the
1999            // originating window.
2000            self.pending_theme_request = Some(theme);
2001        }
2002        if ctx.follow_system_request {
2003            // Stored, not applied: the app layer switches to
2004            // `ThemeMode::Native` and recomputes the theme from the current
2005            // OS colours, fanning it to every window.
2006            self.pending_follow_system_request = true;
2007        }
2008        if let Some(locale) = ctx.locale_request {
2009            // Stored, not applied: the app layer must route this through
2010            // `WindowManager::set_locale` so the `I18nManager`'s active
2011            // locale and direction stay in sync. Applying via
2012            // `WidgetTree::set_locale` alone would leave `tr!` bindings
2013            // reading the old translations.
2014            self.pending_locale_request = Some(locale);
2015        }
2016        if let Some(scale) = ctx.text_scale_request {
2017            // Stored, not applied: the app layer routes this through
2018            // `WindowManager::set_text_scale` so every window re-scales its
2019            // text. Applying `WidgetTree::set_user_text_scale` inline would
2020            // grow only the originating window.
2021            self.pending_text_scale_request = Some(scale);
2022        }
2023    }
2024
2025    fn apply_tree_mutations(&mut self, mutations: Vec<crate::widget::TreeMutation>) {
2026        use crate::binding::BindingLevel;
2027        use crate::widget::TreeMutation;
2028
2029        for mutation in mutations {
2030            match mutation {
2031                TreeMutation::SetDormant(id) => self.arena.set_dormant(id),
2032                TreeMutation::Activate(id) => self.arena.activate(id),
2033                TreeMutation::Destroy(id) => {
2034                    // Route through `destroy_subtree`, NOT the bare
2035                    // `arena.destroy`: the latter only unlinks nodes from
2036                    // the slotmap and leaks everything the widget owned —
2037                    // animation-scheduler entries (which hold strong
2038                    // `Signal<f32>` clones, so the widget keeps animating
2039                    // after it's gone), animated-quad slots, event-source
2040                    // subscriptions, registered shortcuts, bindings, and
2041                    // gesture ownership — and leaves `focused`/`hovered`
2042                    // dangling at a removed id. This mirrors the build-time
2043                    // `BuildContext::destroy_subtree`, including dismissing
2044                    // any overlay that still references the subtree so the
2045                    // manager doesn't retain a stale content reference.
2046                    if let Some(overlay_id) = self.overlay_manager().find_by_content(id) {
2047                        self.dismiss_overlay(overlay_id);
2048                    }
2049                    self.destroy_subtree(id);
2050                }
2051                // Build now, not next frame: the same handler is about to show
2052                // an overlay over this node and move focus into it, and both
2053                // read the subtree. See `EventContext::materialize_now`.
2054                TreeMutation::MaterializeNow(id) => {
2055                    if self.arena.get(id).is_some() {
2056                        self.rebuild_single_widget(id);
2057                    }
2058                }
2059                TreeMutation::WithWidgetMut { id, dirty, apply } => {
2060                    // Run the typed mutation while `&mut arena` is live, then
2061                    // drop the borrow before dirty-marking (the `mark_*` calls
2062                    // re-borrow the arena). Only dirty-mark a live node so we
2063                    // never call `mark_ancestors_need_layout` on a destroyed id.
2064                    let existed = if let Some(any) =
2065                        self.arena.get_mut(id).and_then(|n| n.widget.as_any_mut())
2066                    {
2067                        apply(any);
2068                        true
2069                    } else {
2070                        false
2071                    };
2072                    if existed {
2073                        match dirty {
2074                            BindingLevel::RepaintOnly => self.arena.mark_needs_paint(id),
2075                            BindingLevel::SubtreeRepaint => self.arena.mark_subtree_needs_paint(id),
2076                            BindingLevel::Relayout => {
2077                                self.arena.mark_needs_layout(id);
2078                                self.arena.mark_ancestors_need_layout(id);
2079                            }
2080                            BindingLevel::Rebuild => {
2081                                self.arena.mark_needs_rebuild(id);
2082                                self.arena.mark_ancestors_need_layout(id);
2083                            }
2084                            BindingLevel::AccessibilityOnly => self.a11y_dirty = true,
2085                        }
2086                    }
2087                }
2088            }
2089        }
2090    }
2091
2092    pub fn hit_test(&self, point: Point) -> Option<WidgetId> {
2093        self.hit_test_excluding_overlay_and_widget(point, None, None)
2094    }
2095
2096    /// Hit-test at a point, excluding a specific overlay and widget from consideration.
2097    /// Used during drag-and-drop to exclude the preview overlay and its content widget,
2098    /// so they don't block hit-testing of the actual drop targets underneath.
2099    pub fn hit_test_excluding_overlay_and_widget(
2100        &self,
2101        point: Point,
2102        exclude_overlay: Option<crate::overlay::OverlayId>,
2103        exclude_widget: Option<WidgetId>,
2104    ) -> Option<WidgetId> {
2105        if let Some(overlay_id) = self.overlay_manager.hit_test(point) {
2106            if Some(overlay_id) == exclude_overlay {
2107                // Skip this excluded overlay, fall through to widget tree
2108            } else if let Some(overlay) = self.overlay_manager.overlay(overlay_id) {
2109                return self.arena.hit_test_in_subtree_excluding(
2110                    overlay.content_id,
2111                    point,
2112                    exclude_widget,
2113                );
2114            }
2115        }
2116
2117        if self.overlay_manager.topmost_centered().is_some() {
2118            return None;
2119        }
2120
2121        // Delegates to WidgetArena::hit_test_at, which honors
2122        // event_pass_through and clips_children correctly.
2123        self.arena.hit_test_at(point, exclude_widget)
2124    }
2125}
2126
2127#[cfg(test)]
2128mod tests {
2129    use super::*;
2130    use crate::test_widgets::FillWidget;
2131    use crate::widget::CursorIcon;
2132    use crate::widget_builder::WidgetBuilder;
2133
2134    #[test]
2135    fn pointer_enter_leave_synthesized() {
2136        let mut tree = WidgetTree::new();
2137        let widget = tree.add(FillWidget::new());
2138        tree.layout(SizeProposal::exact(100.0, 50.0));
2139        tree.pointer_move(Point::new(50.0, 25.0));
2140        assert_eq!(tree.hovered, Some(widget));
2141        tree.pointer_move(Point::new(200.0, 200.0));
2142        assert_eq!(tree.hovered, None);
2143    }
2144
2145    #[test]
2146    fn pointer_hover_updates_current_cursor() {
2147        let mut tree = WidgetTree::new();
2148        tree.add(FillWidget::new().cursor(CursorIcon::ColResize));
2149        tree.layout(SizeProposal::exact(100.0, 50.0));
2150
2151        tree.pointer_move(Point::new(50.0, 25.0));
2152        assert_eq!(tree.current_cursor(), CursorIcon::ColResize);
2153
2154        tree.pointer_move(Point::new(200.0, 200.0));
2155        assert_eq!(tree.current_cursor(), CursorIcon::Default);
2156    }
2157
2158    // A leaf that opts into typed introspection, so `with_widget_mut` /
2159    // `widget_as_any(_mut)` can reach it (the default `as_any_mut` is `None`).
2160    #[derive(Debug)]
2161    struct Bumpable {
2162        value: i32,
2163    }
2164
2165    impl crate::widget::Widget for Bumpable {
2166        fn layout_response(
2167            &self,
2168            proposal: SizeProposal,
2169            _ctx: &crate::widget::LayoutContext,
2170        ) -> crate::widget::LayoutResponse {
2171            proposal.resolve(10.0, 10.0).into()
2172        }
2173        fn as_any(&self) -> Option<&dyn std::any::Any> {
2174            Some(self)
2175        }
2176        fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
2177            Some(self)
2178        }
2179    }
2180
2181    #[test]
2182    fn with_widget_mut_applies_and_dirty_marks() {
2183        let mut tree = WidgetTree::new();
2184        let id = tree.add(Bumpable { value: 0 });
2185        tree.layout(SizeProposal::exact(100.0, 100.0));
2186
2187        let mut ctx = EventContext::new();
2188        ctx.with_widget_mut::<Bumpable>(id, crate::binding::BindingLevel::Relayout, |b| {
2189            b.value = 42;
2190        });
2191        tree.collect_from_ctx(ctx, id);
2192
2193        let value = tree
2194            .widget_as_any(id)
2195            .and_then(|a| a.downcast_ref::<Bumpable>())
2196            .map(|b| b.value);
2197        assert_eq!(
2198            value,
2199            Some(42),
2200            "the deferred closure must mutate the live widget"
2201        );
2202        assert!(
2203            tree.needs_layout(),
2204            "Relayout dirty level must mark the tree for relayout"
2205        );
2206    }
2207
2208    #[test]
2209    #[cfg(debug_assertions)]
2210    #[should_panic(expected = "not the requested type")]
2211    fn with_widget_mut_wrong_type_panics_in_debug() {
2212        struct Other;
2213        let mut tree = WidgetTree::new();
2214        let id = tree.add(Bumpable { value: 0 });
2215        let mut ctx = EventContext::new();
2216        ctx.with_widget_mut::<Other>(
2217            id,
2218            crate::binding::BindingLevel::RepaintOnly,
2219            |_o: &mut Other| {},
2220        );
2221        // Bumpable opts into as_any_mut, so the closure runs and the
2222        // wrong-type downcast trips the debug_assert.
2223        tree.collect_from_ctx(ctx, id);
2224    }
2225
2226    #[test]
2227    fn with_widget_mut_closure_may_fire_observed_signals() {
2228        // Reentrancy guard. The closure runs inside `apply_tree_mutations`
2229        // while the target arena node is mutably borrowed. If it fires a
2230        // `Signal` whose observer sets *another* signal — the exact
2231        // `SceneView` shape (`item_change_signal` → bump `reconcile_dirty`) —
2232        // nothing may double-borrow the arena. The arena borrow is scoped to
2233        // the closure call and dropped before dirty-marking; signal/observer
2234        // work touches the binding registry, not the arena.
2235        use crate::signal::Signal;
2236        let mut tree = WidgetTree::new();
2237        let id = tree.add(Bumpable { value: 0 });
2238        tree.layout(SizeProposal::exact(100.0, 100.0));
2239
2240        let trigger = Signal::new(0_u64);
2241        let echo = Signal::new(0_u64);
2242        let echo_for_obs = echo.clone();
2243        let _obs = trigger.observe(move |v| echo_for_obs.set(*v));
2244
2245        let trigger_in = trigger.clone();
2246        let mut ctx = EventContext::new();
2247        ctx.with_widget_mut::<Bumpable>(id, crate::binding::BindingLevel::RepaintOnly, move |b| {
2248            b.value = 7;
2249            // Fires `_obs` synchronously, mid-deferred-apply.
2250            trigger_in.set(99);
2251        });
2252        tree.collect_from_ctx(ctx, id); // must not panic / double-borrow
2253
2254        assert_eq!(
2255            echo.get(),
2256            99,
2257            "the observer ran during the deferred mutation"
2258        );
2259        let value = tree
2260            .widget_as_any(id)
2261            .and_then(|a| a.downcast_ref::<Bumpable>())
2262            .map(|b| b.value);
2263        assert_eq!(value, Some(7));
2264    }
2265
2266    #[test]
2267    fn request_accessibility_update_forces_rewalk() {
2268        let mut tree = WidgetTree::new();
2269        let id = tree.add(Bumpable { value: 0 });
2270        tree.layout(SizeProposal::exact(100.0, 100.0));
2271        let _ = tree.sync_accessibility(); // populate cache, clears a11y_dirty
2272        assert!(
2273            !tree.a11y_dirty,
2274            "sync_accessibility should clear the dirty flag"
2275        );
2276
2277        let mut ctx = EventContext::new();
2278        ctx.request_accessibility_update();
2279        tree.collect_from_ctx(ctx, id);
2280        assert!(
2281            tree.a11y_dirty,
2282            "request_accessibility_update must force an AT re-walk"
2283        );
2284    }
2285
2286    #[test]
2287    fn rebuild_dirties_accessibility_tree() {
2288        // Regression for audit Blocker G1: every `BindingLevel::Rebuild`
2289        // consumer (ListView / TreeView / TableView / ComboBox / Calendar /
2290        // DockingLayout / ...) tears down and re-creates its subtree on an
2291        // ordinary model change, allocating fresh WidgetIds and changing the
2292        // AccessKit tree shape. That pass must dirty the cached AT snapshot,
2293        // or screen readers keep reading the pre-mutation tree indefinitely.
2294        let mut tree = WidgetTree::new();
2295        let id = tree.add(Bumpable { value: 0 });
2296        tree.layout(SizeProposal::exact(100.0, 100.0));
2297        let _ = tree.sync_accessibility(); // populate cache, clears a11y_dirty
2298        assert!(
2299            !tree.a11y_dirty,
2300            "sync_accessibility should clear the dirty flag"
2301        );
2302
2303        // Marking for rebuild is exactly what a Rebuild-level binding does;
2304        // the following layout pass drains pending rebuilds.
2305        tree.arena_mark_needs_rebuild_for_testing(id);
2306        tree.layout(SizeProposal::exact(100.0, 100.0));
2307        assert!(
2308            tree.a11y_dirty,
2309            "a rebuild must dirty the AT tree so the next sync re-walks"
2310        );
2311    }
2312
2313    #[test]
2314    fn bound_access_label_change_dirties_accessibility_tree() {
2315        use crate::signal::Signal;
2316        use crate::test_widgets::FillWidget;
2317        use crate::widget_builder::WidgetBuilder;
2318
2319        // Regression for audit G15: a reactive `.access_label(signal)` (and
2320        // likewise description / value) must register at AccessibilityOnly so
2321        // changing the signal re-walks the AT tree and re-resolves the
2322        // announced name. Previously only `access_hidden` was registered, so
2323        // label / description / value updates were invisible to screen readers.
2324        let label = Signal::new("first".to_string());
2325        let mut tree = WidgetTree::new();
2326        let _id = tree.add(FillWidget::new().access_label(label.clone()));
2327        tree.layout(SizeProposal::exact(100.0, 100.0));
2328        let _ = tree.sync_accessibility(); // populate cache, clears a11y_dirty
2329        assert!(!tree.a11y_dirty, "sync_accessibility should clear the flag");
2330
2331        label.set("second".to_string());
2332        tree.layout(SizeProposal::exact(100.0, 100.0));
2333        assert!(
2334            tree.a11y_dirty,
2335            "changing a bound access_label must dirty the AT tree"
2336        );
2337    }
2338
2339    #[test]
2340    fn disabled_ancestor_blocks_event_to_descendant() {
2341        use crate::signal::Signal;
2342        use crate::test_widgets::StackWidget;
2343        use std::cell::Cell;
2344        use std::rc::Rc;
2345
2346        let tapped = Rc::new(Cell::new(false));
2347        let flag = tapped.clone();
2348        let enabled = Signal::new(true);
2349
2350        let mut tree = WidgetTree::new();
2351        let child = tree.add(FillWidget::new().on_tap(move |_pos, _ctx| {
2352            flag.set(true);
2353        }));
2354        let parent = tree.add(StackWidget::new().add_child(child));
2355        tree.enabled_when(parent, enabled.clone());
2356        tree.layout(SizeProposal::exact(100.0, 50.0));
2357
2358        enabled.set(false);
2359        tree.click(child);
2360        assert!(
2361            !tapped.get(),
2362            "disabled ancestor should block descendant tap"
2363        );
2364
2365        enabled.set(true);
2366        tree.click(child);
2367        assert!(tapped.get(), "re-enabling should restore dispatch");
2368    }
2369
2370    #[test]
2371    fn pointer_positions_are_widget_local_at_nonzero_origin() {
2372        use crate::event::{Modifiers, PointerButton};
2373        use crate::test_widgets::InsetWidget;
2374        use std::cell::Cell;
2375        use std::rc::Rc;
2376
2377        // A 20px inset places the child at window origin (20, 20).
2378        let tap_pos: Rc<Cell<Option<Point>>> = Rc::new(Cell::new(None));
2379        let down_pos: Rc<Cell<Option<Point>>> = Rc::new(Cell::new(None));
2380        let drag_pos: Rc<Cell<Option<Point>>> = Rc::new(Cell::new(None));
2381        let (tp, dp, gp) = (tap_pos.clone(), down_pos.clone(), drag_pos.clone());
2382
2383        let mut tree = WidgetTree::new();
2384        let child = tree.add(
2385            FillWidget::new()
2386                .on_tap(move |ev, _ctx| tp.set(Some(ev.position)))
2387                .on_pointer_event(move |ev, _ctx| {
2388                    if let WidgetEvent::PointerDown { position, .. } = ev {
2389                        dp.set(Some(*position));
2390                    }
2391                    crate::event::EventResponse::Ignored
2392                })
2393                .on_drag(move |phase, _ctx| {
2394                    use crate::gesture::DragPhase;
2395                    match phase {
2396                        DragPhase::Started { position, .. }
2397                        | DragPhase::Moved { position, .. }
2398                        | DragPhase::Ended { position } => gp.set(Some(position)),
2399                    }
2400                }),
2401        );
2402        let inset = tree.add(InsetWidget::new(20.0).set_child(child));
2403        let _ = inset;
2404        tree.layout(SizeProposal::exact(200.0, 200.0));
2405        assert_eq!(tree.bounds(child).origin(), Point::new(20.0, 20.0));
2406
2407        // A tap at window (50, 40) must reach the handler as local (30, 20).
2408        tree.dispatch_event(WidgetEvent::PointerDown {
2409            position: Point::new(50.0, 40.0),
2410            button: PointerButton::Primary,
2411            modifiers: Modifiers::NONE,
2412        });
2413        assert_eq!(
2414            down_pos.get(),
2415            Some(Point::new(30.0, 20.0)),
2416            "on_pointer_event PointerDown must be widget-local"
2417        );
2418        tree.dispatch_event(WidgetEvent::PointerUp {
2419            position: Point::new(50.0, 40.0),
2420            button: PointerButton::Primary,
2421            modifiers: Modifiers::NONE,
2422        });
2423        assert_eq!(
2424            tap_pos.get(),
2425            Some(Point::new(30.0, 20.0)),
2426            "on_tap position must be widget-local"
2427        );
2428
2429        // A drag (down then a move past the recognizer threshold) must
2430        // also deliver widget-local coordinates.
2431        tree.dispatch_event(WidgetEvent::PointerDown {
2432            position: Point::new(50.0, 40.0),
2433            button: PointerButton::Primary,
2434            modifiers: Modifiers::NONE,
2435        });
2436        // First move crosses the recognizer threshold (DragStarted);
2437        // the second reports a known DragMoved position.
2438        tree.dispatch_event(WidgetEvent::PointerMove {
2439            position: Point::new(65.0, 55.0),
2440        });
2441        tree.dispatch_event(WidgetEvent::PointerMove {
2442            position: Point::new(90.0, 70.0),
2443        });
2444        assert_eq!(
2445            drag_pos.get(),
2446            Some(Point::new(70.0, 50.0)),
2447            "on_drag position must be widget-local"
2448        );
2449    }
2450
2451    #[test]
2452    fn dormant_widget_not_hit_tested() {
2453        let mut tree = WidgetTree::new();
2454        let widget = tree.add(FillWidget::new());
2455        tree.layout(SizeProposal::exact(100.0, 50.0));
2456
2457        tree.pointer_move(Point::new(50.0, 25.0));
2458        assert_eq!(tree.hovered, Some(widget));
2459
2460        tree.set_dormant(widget);
2461        tree.pointer_move(Point::new(200.0, 200.0));
2462        tree.pointer_move(Point::new(50.0, 25.0));
2463        assert_eq!(tree.hovered, None);
2464    }
2465
2466    #[test]
2467    fn ancestor_pointer_handler_does_not_suppress_descendant_hover() {
2468        use crate::event::EventResponse;
2469        use crate::test_widgets::StackWidget;
2470        use std::cell::Cell;
2471        use std::rc::Rc;
2472
2473        // The child reports its own hover transitions via `on_hover`.
2474        let hovered = Rc::new(Cell::new(false));
2475        let h = hovered.clone();
2476
2477        let mut tree = WidgetTree::new();
2478        let child = tree.add(FillWidget::new().on_hover(move |entered, _ctx| h.set(entered)));
2479        // An ancestor whose `on_pointer_event` greedily claims everything it
2480        // previews — exactly the "drag-detecting ancestor" footgun. Before the
2481        // fix it consumed the descendant's `PointerEnter`/`Leave` in the
2482        // preview pass and the child's hover never fired.
2483        tree.add(
2484            StackWidget::new()
2485                .add_child(child)
2486                .on_pointer_event(|_event, _ctx| EventResponse::Handled),
2487        );
2488        tree.layout(SizeProposal::exact(100.0, 50.0));
2489
2490        tree.pointer_move(Point::new(50.0, 25.0));
2491        assert!(
2492            hovered.get(),
2493            "a greedy ancestor on_pointer_event must NOT swallow the child's PointerEnter"
2494        );
2495
2496        tree.pointer_move(Point::new(500.0, 500.0));
2497        assert!(
2498            !hovered.get(),
2499            "PointerLeave must likewise reach the child despite the ancestor"
2500        );
2501    }
2502
2503    /// **The other direction: a child must not swallow its ancestor's hover.**
2504    ///
2505    /// A row that reveals controls on hover puts interactive children inside
2506    /// itself, and the pointer leaves the row *through* one of them. The child's
2507    /// own `on_hover` used to handle the `PointerLeave` and stop the bubble there,
2508    /// so the row went on believing the pointer was still over it and kept its
2509    /// controls showing after the pointer had gone.
2510    #[test]
2511    fn a_child_hover_handler_does_not_swallow_its_ancestors() {
2512        use crate::test_widgets::StackWidget;
2513        use std::cell::Cell;
2514        use std::rc::Rc;
2515
2516        let (row, button) = (Rc::new(Cell::new(false)), Rc::new(Cell::new(false)));
2517        let (r, b) = (row.clone(), button.clone());
2518
2519        let mut tree = WidgetTree::new();
2520        let child = tree.add(FillWidget::new().on_hover(move |entered, _ctx| b.set(entered)));
2521        tree.add(
2522            StackWidget::new()
2523                .add_child(child)
2524                .on_hover(move |entered, _ctx| r.set(entered)),
2525        );
2526        tree.layout(SizeProposal::exact(100.0, 50.0));
2527
2528        tree.pointer_move(Point::new(50.0, 25.0));
2529        assert!(button.get(), "the child is hovered");
2530        assert!(row.get(), "and so is the row it is inside");
2531
2532        tree.pointer_move(Point::new(500.0, 500.0));
2533        assert!(!button.get(), "the child heard the leave");
2534        assert!(
2535            !row.get(),
2536            "and so did the row — a container is not still hovered because the \
2537             pointer left it through a button"
2538        );
2539    }
2540
2541    #[test]
2542    fn destroy_subtree_clears_dangling_pointer_capture() {
2543        use crate::event::{EventResponse, Modifiers, PointerButton};
2544        use crate::test_widgets::StackWidget;
2545
2546        let mut tree = WidgetTree::new();
2547        let child = tree.add(FillWidget::new().on_pointer_event(|event, ctx| {
2548            if matches!(event, WidgetEvent::PointerDown { .. }) {
2549                ctx.capture_pointer();
2550            }
2551            EventResponse::Ignored
2552        }));
2553        let parent = tree.add(StackWidget::new().add_child(child));
2554        tree.layout(SizeProposal::exact(100.0, 50.0));
2555
2556        // A press inside the child captures the pointer to it.
2557        tree.dispatch_event(WidgetEvent::PointerDown {
2558            position: Point::new(50.0, 25.0),
2559            button: PointerButton::Primary,
2560            modifiers: Modifiers::NONE,
2561        });
2562        assert_eq!(
2563            tree.pointer_captured_by,
2564            Some(child),
2565            "PointerDown handler should have captured the pointer"
2566        );
2567
2568        // Tearing down the capturing subtree (e.g. mid-gesture rebuild) must
2569        // release the capture eagerly rather than leaving a dangling id that
2570        // swallows every later Move/Up until the next layout pass heals it.
2571        tree.destroy_subtree(parent);
2572        assert_eq!(
2573            tree.pointer_captured_by, None,
2574            "destroy_subtree must clear a capture anchored at a destroyed widget"
2575        );
2576    }
2577
2578    // NOTE: legacy `shortcut_intercepts_before_widget` test removed with
2579    // the ShortcutMap dispatch path. The new shortcut→intent interception
2580    // is built on top of `ShortcutRegistry` + `Action`.
2581
2582    // ── on_key_preview ──────────────────────────────────────────
2583
2584    #[test]
2585    fn key_preview_consumes_before_focused_on_key() {
2586        // root → mid → leaf (focused). Root consumes Enter via
2587        // on_key_preview; the leaf's on_key must NOT fire.
2588        use crate::event::EventResponse;
2589        use crate::test_widgets::StackWidget;
2590        use std::cell::Cell;
2591        use std::rc::Rc;
2592
2593        let leaf_fired = Rc::new(Cell::new(false));
2594        let leaf_flag = leaf_fired.clone();
2595        let preview_fired = Rc::new(Cell::new(false));
2596        let preview_flag = preview_fired.clone();
2597
2598        let mut tree = WidgetTree::new();
2599        let leaf = tree.add(FillWidget::new().focusable().on_key(move |event, _c| {
2600            // Only count KeyDown so the trailing KeyUp from
2601            // press_key doesn't trigger us spuriously.
2602            if matches!(event, WidgetEvent::KeyDown { .. }) {
2603                leaf_flag.set(true);
2604            }
2605            EventResponse::Handled
2606        }));
2607        let mid = tree.add(StackWidget::new().add_child(leaf));
2608        let _root =
2609            tree.add(StackWidget::new().add_child(mid).on_key_preview(
2610                move |event, _c| match event {
2611                    WidgetEvent::KeyDown {
2612                        key: Key::Enter, ..
2613                    } => {
2614                        preview_flag.set(true);
2615                        EventResponse::Handled
2616                    }
2617                    _ => EventResponse::Ignored,
2618                },
2619            ));
2620
2621        tree.layout(SizeProposal::exact(100.0, 50.0));
2622        tree.focus(leaf);
2623        tree.press_key(Key::Enter, Modifiers::NONE);
2624
2625        assert!(
2626            preview_fired.get(),
2627            "ancestor on_key_preview must fire for KeyDown on a focused descendant"
2628        );
2629        assert!(
2630            !leaf_fired.get(),
2631            "consuming the event in preview must prevent the focused widget's on_key from running"
2632        );
2633    }
2634
2635    #[test]
2636    fn key_preview_falls_through_when_returning_ignored() {
2637        // Same shape; this time the preview returns Ignored, so
2638        // the leaf's on_key must still fire.
2639        use crate::event::EventResponse;
2640        use crate::test_widgets::StackWidget;
2641        use std::cell::Cell;
2642        use std::rc::Rc;
2643
2644        let leaf_fired = Rc::new(Cell::new(false));
2645        let leaf_flag = leaf_fired.clone();
2646        let preview_fired = Rc::new(Cell::new(false));
2647        let preview_flag = preview_fired.clone();
2648
2649        let mut tree = WidgetTree::new();
2650        let leaf = tree.add(FillWidget::new().focusable().on_key(move |_e, _c| {
2651            leaf_flag.set(true);
2652            EventResponse::Handled
2653        }));
2654        let mid = tree.add(StackWidget::new().add_child(leaf));
2655        let _root = tree.add(StackWidget::new().add_child(mid).on_key_preview(
2656            move |_event, _c| {
2657                preview_flag.set(true);
2658                EventResponse::Ignored
2659            },
2660        ));
2661
2662        tree.layout(SizeProposal::exact(100.0, 50.0));
2663        tree.focus(leaf);
2664        tree.press_key(Key::Enter, Modifiers::NONE);
2665
2666        assert!(preview_fired.get(), "preview must always be invoked");
2667        assert!(
2668            leaf_fired.get(),
2669            "preview returning Ignored must not block the focused widget's on_key"
2670        );
2671    }
2672
2673    #[test]
2674    fn key_preview_excludes_focused_target_itself() {
2675        // Strict-ancestors-only: the focused widget's own
2676        // on_key_preview must NOT fire — the preview pass walks
2677        // strict ancestors only.
2678        use crate::event::EventResponse;
2679        use std::cell::Cell;
2680        use std::rc::Rc;
2681
2682        let preview_on_target = Rc::new(Cell::new(false));
2683        let pf = preview_on_target.clone();
2684
2685        let mut tree = WidgetTree::new();
2686        let leaf = tree.add(FillWidget::new().focusable().on_key_preview(move |_e, _c| {
2687            pf.set(true);
2688            EventResponse::Handled
2689        }));
2690        tree.layout(SizeProposal::exact(100.0, 50.0));
2691        tree.focus(leaf);
2692        tree.press_key(Key::Enter, Modifiers::NONE);
2693
2694        assert!(
2695            !preview_on_target.get(),
2696            "the focused widget itself must not see its own on_key_preview"
2697        );
2698    }
2699
2700    #[test]
2701    fn key_preview_root_to_target_order() {
2702        // Two ancestors with on_key_preview attached. The outer
2703        // (root-side) one must fire first; the closer one (still
2704        // ancestor of the focused leaf) fires second.
2705        use crate::event::EventResponse;
2706        use crate::test_widgets::StackWidget;
2707        use std::cell::RefCell;
2708        use std::rc::Rc;
2709
2710        let order: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
2711        let outer_log = order.clone();
2712        let inner_log = order.clone();
2713
2714        let mut tree = WidgetTree::new();
2715        let leaf = tree.add(FillWidget::new().focusable());
2716        let inner = tree.add(StackWidget::new().add_child(leaf).on_key_preview(
2717            move |event, _c| {
2718                if matches!(event, WidgetEvent::KeyDown { .. }) {
2719                    inner_log.borrow_mut().push("inner");
2720                }
2721                EventResponse::Ignored
2722            },
2723        ));
2724        let _outer = tree.add(StackWidget::new().add_child(inner).on_key_preview(
2725            move |event, _c| {
2726                if matches!(event, WidgetEvent::KeyDown { .. }) {
2727                    outer_log.borrow_mut().push("outer");
2728                }
2729                EventResponse::Ignored
2730            },
2731        ));
2732
2733        tree.layout(SizeProposal::exact(100.0, 50.0));
2734        tree.focus(leaf);
2735        tree.dispatch_event(WidgetEvent::KeyDown {
2736            key: Key::Enter,
2737            modifiers: Modifiers::NONE,
2738            text: None,
2739        });
2740
2741        assert_eq!(
2742            *order.borrow(),
2743            vec!["outer", "inner"],
2744            "preview must walk root → parent-of-target"
2745        );
2746    }
2747
2748    #[test]
2749    fn access_action_routes_to_cursored_target_not_focus() {
2750        // VoiceOver's VO+Space targets the node under the AT cursor (`b`),
2751        // even when keyboard focus is on a different control (`a`). The action
2752        // must fire on `b`, never get redirected to the focused `a`.
2753        use crate::signal::Signal;
2754        let a_fired = Signal::new(false);
2755        let b_fired = Signal::new(false);
2756        let a_cb = a_fired.clone();
2757        let b_cb = b_fired.clone();
2758
2759        let mut tree = WidgetTree::new();
2760        let a = tree.add(
2761            FillWidget::new().access_action(accesskit::Action::Click, move |_ctx| a_cb.set(true)),
2762        );
2763        let b = tree.add(
2764            FillWidget::new().access_action(accesskit::Action::Click, move |_ctx| b_cb.set(true)),
2765        );
2766        tree.layout(SizeProposal::exact(200.0, 100.0));
2767
2768        tree.focus(a);
2769        tree.dispatch_event(WidgetEvent::AccessAction {
2770            action: accesskit::Action::Click,
2771            target: Some(b),
2772            target_node: crate::accessibility::widget_id_to_node_id(b),
2773            data: None,
2774        });
2775
2776        assert!(b_fired.get(), "the cursored target must receive the action");
2777        assert!(
2778            !a_fired.get(),
2779            "the keyboard-focused widget must NOT receive an action targeting another node"
2780        );
2781    }
2782
2783    #[test]
2784    fn access_action_without_target_is_dropped_not_redirected_to_focus() {
2785        // An action with no (or an inactive) target must be dropped — never
2786        // silently re-routed to whatever holds keyboard focus.
2787        use crate::signal::Signal;
2788        let fired = Signal::new(false);
2789        let cb = fired.clone();
2790
2791        let mut tree = WidgetTree::new();
2792        let widget = tree.add(
2793            FillWidget::new().access_action(accesskit::Action::Click, move |_ctx| cb.set(true)),
2794        );
2795        tree.layout(SizeProposal::exact(200.0, 100.0));
2796
2797        tree.focus(widget);
2798        tree.dispatch_event(WidgetEvent::AccessAction {
2799            action: accesskit::Action::Click,
2800            target: None,
2801            target_node: crate::accessibility::root_node_id(),
2802            data: None,
2803        });
2804
2805        assert!(
2806            !fired.get(),
2807            "a target-less action must not be redirected to the focused widget"
2808        );
2809    }
2810
2811    // NOTE: legacy `scoped_shortcut_fires_when_focused_in_subtree` test
2812    // removed along with the ShortcutMap dispatch path. Scope-aware
2813    // dispatch is handled by the new ShortcutRegistry.
2814
2815    // --- Intent / Action dispatch ------------------------------
2816
2817    #[test]
2818    fn shortcut_fires_matching_action_on_source_widget() {
2819        use crate::action::Action;
2820        use crate::shortcut::{KeyStroke, Shortcut};
2821        use std::cell::Cell;
2822        use std::rc::Rc;
2823
2824        let fired = Rc::new(Cell::new(false));
2825        let fired_flag = fired.clone();
2826
2827        let mut tree = WidgetTree::new();
2828        let widget = tree.add(FillWidget::new().focusable());
2829        tree.push_action(
2830            widget,
2831            Action::new("app.save").on_invoke(move |_intent, _ctx| {
2832                fired_flag.set(true);
2833            }),
2834        );
2835        tree.shortcut_registry_mut().register(
2836            Shortcut::new("app.save")
2837                .primary(KeyStroke::command(Key::S))
2838                .build(),
2839        );
2840
2841        tree.layout(SizeProposal::exact(100.0, 50.0));
2842        tree.focus(widget);
2843
2844        tree.press_key(Key::S, Modifiers::COMMAND);
2845        assert!(fired.get(), "matching action must fire on KeyDown");
2846    }
2847
2848    #[test]
2849    fn global_shortcut_fires_without_focused_widget() {
2850        use crate::action::Action;
2851        use crate::shortcut::{KeyStroke, Shortcut};
2852        use std::cell::Cell;
2853        use std::rc::Rc;
2854
2855        // Regression: a global shortcut must fire even when no widget
2856        // is focused. A root-registered action should still receive
2857        // the intent (anchored at the root as a fallback).
2858        let fired = Rc::new(Cell::new(false));
2859        let fired_flag = fired.clone();
2860
2861        let mut tree = WidgetTree::new();
2862        let root = tree.add(FillWidget::new());
2863        tree.push_action(
2864            root,
2865            Action::new("app.save").on_invoke(move |_intent, _ctx| {
2866                fired_flag.set(true);
2867            }),
2868        );
2869        tree.shortcut_registry_mut().register(
2870            Shortcut::new("app.save")
2871                .primary(KeyStroke::command(Key::S))
2872                .build(),
2873        );
2874
2875        tree.layout(SizeProposal::exact(100.0, 50.0));
2876        // Deliberately no focus() call.
2877
2878        tree.press_key(Key::S, Modifiers::COMMAND);
2879        assert!(
2880            fired.get(),
2881            "global shortcut must fire without a focused widget"
2882        );
2883    }
2884
2885    #[test]
2886    fn global_shortcut_fires_after_focused_widget_destroyed() {
2887        use crate::action::Action;
2888        use crate::shortcut::{KeyStroke, Shortcut};
2889        use std::cell::Cell;
2890        use std::rc::Rc;
2891
2892        // Regression: if the focused widget is destroyed (e.g. during a
2893        // rebuild after a settings-panel rebind), focus must be cleared
2894        // so the next global shortcut falls through to the root-anchor
2895        // path instead of dispatching from a stale, destroyed id.
2896        let fired = Rc::new(Cell::new(false));
2897        let fired_flag = fired.clone();
2898
2899        let mut tree = WidgetTree::new();
2900        let root = tree.add(FillWidget::new());
2901        let focusable = tree.add_child(root, FillWidget::new().focusable());
2902        tree.push_action(
2903            root,
2904            Action::new("app.save").on_invoke(move |_intent, _ctx| {
2905                fired_flag.set(true);
2906            }),
2907        );
2908        tree.shortcut_registry_mut().register(
2909            Shortcut::new("app.save")
2910                .primary(KeyStroke::command(Key::S))
2911                .build(),
2912        );
2913
2914        tree.layout(SizeProposal::exact(100.0, 50.0));
2915        tree.focus(focusable);
2916        assert_eq!(tree.focused(), Some(focusable));
2917
2918        // Destroy the focused subtree (simulates a rebuild that drops
2919        // the currently-focused Rebind button).
2920        tree.destroy_subtree(focusable);
2921        assert_eq!(tree.focused(), None, "focus must clear when destroyed");
2922
2923        tree.press_key(Key::S, Modifiers::COMMAND);
2924        assert!(
2925            fired.get(),
2926            "global shortcut must still fire after the focused widget is destroyed"
2927        );
2928    }
2929
2930    #[test]
2931    fn scoped_shortcut_matches_only_when_focus_in_scope() {
2932        use crate::action::Action;
2933        use crate::shortcut::{KeyStroke, Shortcut, ShortcutScope};
2934        use std::cell::Cell;
2935        use std::rc::Rc;
2936
2937        let fired = Rc::new(Cell::new(0));
2938        let fired_flag = fired.clone();
2939
2940        let mut tree = WidgetTree::new();
2941        let scope_root = tree.add(FillWidget::new().focusable());
2942        let inside = tree.add_child(scope_root, FillWidget::new().focusable());
2943        let outside = tree.add(FillWidget::new().focusable());
2944
2945        tree.push_action(
2946            scope_root,
2947            Action::new("editor.find").on_invoke(move |_i, _c| {
2948                fired_flag.set(fired_flag.get() + 1);
2949            }),
2950        );
2951        tree.shortcut_registry_mut().register(
2952            Shortcut::new("editor.find")
2953                .primary(KeyStroke::command(Key::F))
2954                .scope(ShortcutScope::Scoped(scope_root))
2955                .build(),
2956        );
2957
2958        tree.layout(SizeProposal::exact(200.0, 100.0));
2959
2960        // Focus outside the scope: the shortcut does NOT activate.
2961        tree.focus(outside);
2962        tree.press_key(Key::F, Modifiers::COMMAND);
2963        assert_eq!(
2964            fired.get(),
2965            0,
2966            "scoped shortcut must not fire outside scope"
2967        );
2968
2969        // Focus inside the scope: it fires.
2970        tree.focus(inside);
2971        tree.press_key(Key::F, Modifiers::COMMAND);
2972        assert_eq!(
2973            fired.get(),
2974            1,
2975            "scoped shortcut must fire when focus in scope"
2976        );
2977    }
2978
2979    #[test]
2980    fn same_chord_scoped_first_falls_back_to_global_when_focus_outside() {
2981        // Defect 1: a Scoped binding that sorts first by id must NOT
2982        // shadow the slot when focus is outside its subtree — the
2983        // applicable Global binding fires instead. (`editor.saveBlock`
2984        // < `zzz.global.save`, so the scoped one wins the id-order race
2985        // that `find_by_keystroke` used to settle on.)
2986        use crate::action::Action;
2987        use crate::shortcut::{KeyStroke, Shortcut, ShortcutScope};
2988        use std::cell::Cell;
2989        use std::rc::Rc;
2990
2991        let scoped_fired = Rc::new(Cell::new(0));
2992        let global_fired = Rc::new(Cell::new(0));
2993        let sf = scoped_fired.clone();
2994        let gf = global_fired.clone();
2995
2996        let mut tree = WidgetTree::new();
2997        let root = tree.add(FillWidget::new());
2998        let editor = tree.add_child(root, FillWidget::new().focusable());
2999        let _editor_inner = tree.add_child(editor, FillWidget::new().focusable());
3000        let sidebar = tree.add_child(root, FillWidget::new().focusable());
3001
3002        tree.push_action(
3003            editor,
3004            Action::new("editor.saveBlock").on_invoke(move |_i, _c| sf.set(sf.get() + 1)),
3005        );
3006        tree.push_action(
3007            root,
3008            Action::new("zzz.global.save").on_invoke(move |_i, _c| gf.set(gf.get() + 1)),
3009        );
3010        tree.shortcut_registry_mut().register(
3011            Shortcut::new("editor.saveBlock")
3012                .primary(KeyStroke::command(Key::S))
3013                .scope(ShortcutScope::Scoped(editor))
3014                .build(),
3015        );
3016        tree.shortcut_registry_mut().register(
3017            Shortcut::new("zzz.global.save")
3018                .primary(KeyStroke::command(Key::S))
3019                .build(),
3020        );
3021
3022        tree.layout(SizeProposal::exact(200.0, 100.0));
3023        tree.focus(sidebar);
3024        tree.press_key(Key::S, Modifiers::COMMAND);
3025
3026        assert_eq!(global_fired.get(), 1, "applicable global must fire");
3027        assert_eq!(
3028            scoped_fired.get(),
3029            0,
3030            "inapplicable scoped binding must not eat the chord"
3031        );
3032    }
3033
3034    #[test]
3035    fn same_chord_global_first_yields_to_scoped_when_focus_inside() {
3036        // Defect 2: a Global binding that sorts first by id must yield to
3037        // an in-focus Scoped binding (most-specific-scope wins), then
3038        // reclaim the chord once focus leaves the scope. (`app.save` <
3039        // `editor.saveBlock`, so the global one wins id order.)
3040        use crate::action::Action;
3041        use crate::shortcut::{KeyStroke, Shortcut, ShortcutScope};
3042        use std::cell::Cell;
3043        use std::rc::Rc;
3044
3045        let scoped_fired = Rc::new(Cell::new(0));
3046        let global_fired = Rc::new(Cell::new(0));
3047        let sf = scoped_fired.clone();
3048        let gf = global_fired.clone();
3049
3050        let mut tree = WidgetTree::new();
3051        let root = tree.add(FillWidget::new());
3052        let editor = tree.add_child(root, FillWidget::new().focusable());
3053        let editor_inner = tree.add_child(editor, FillWidget::new().focusable());
3054        let sidebar = tree.add_child(root, FillWidget::new().focusable());
3055
3056        tree.push_action(
3057            editor,
3058            Action::new("editor.saveBlock").on_invoke(move |_i, _c| sf.set(sf.get() + 1)),
3059        );
3060        tree.push_action(
3061            root,
3062            Action::new("app.save").on_invoke(move |_i, _c| gf.set(gf.get() + 1)),
3063        );
3064        tree.shortcut_registry_mut().register(
3065            Shortcut::new("app.save")
3066                .primary(KeyStroke::command(Key::S))
3067                .build(),
3068        );
3069        tree.shortcut_registry_mut().register(
3070            Shortcut::new("editor.saveBlock")
3071                .primary(KeyStroke::command(Key::S))
3072                .scope(ShortcutScope::Scoped(editor))
3073                .build(),
3074        );
3075
3076        tree.layout(SizeProposal::exact(200.0, 100.0));
3077
3078        // Focus inside the editor: the scoped binding wins over global.
3079        tree.focus(editor_inner);
3080        tree.press_key(Key::S, Modifiers::COMMAND);
3081        assert_eq!(
3082            scoped_fired.get(),
3083            1,
3084            "in-focus scoped must win over global"
3085        );
3086        assert_eq!(
3087            global_fired.get(),
3088            0,
3089            "global must yield to the scoped binding"
3090        );
3091
3092        // Focus outside the editor: global reclaims the chord.
3093        tree.focus(sidebar);
3094        tree.press_key(Key::S, Modifiers::COMMAND);
3095        assert_eq!(scoped_fired.get(), 1, "scoped stays put outside its scope");
3096        assert_eq!(
3097            global_fired.get(),
3098            1,
3099            "global fires when focus leaves the scope"
3100        );
3101    }
3102
3103    #[test]
3104    fn propagated_action_lets_ancestor_handle() {
3105        use crate::action::Action;
3106        use crate::intent::IntentResponse;
3107        use crate::shortcut::{KeyStroke, Shortcut};
3108        use std::cell::Cell;
3109        use std::rc::Rc;
3110
3111        let inner_seen = Rc::new(Cell::new(false));
3112        let outer_seen = Rc::new(Cell::new(false));
3113        let inner_flag = inner_seen.clone();
3114        let outer_flag = outer_seen.clone();
3115
3116        let mut tree = WidgetTree::new();
3117        let outer = tree.add(FillWidget::new().focusable());
3118        let inner = tree.add_child(outer, FillWidget::new().focusable());
3119
3120        // Inner observes then propagates; outer consumes.
3121        tree.push_action(
3122            inner,
3123            Action::new("app.save").on_invoke_with_response(move |_i, _c| {
3124                inner_flag.set(true);
3125                IntentResponse::Propagated
3126            }),
3127        );
3128        tree.push_action(
3129            outer,
3130            Action::new("app.save").on_invoke(move |_i, _c| {
3131                outer_flag.set(true);
3132            }),
3133        );
3134        tree.shortcut_registry_mut().register(
3135            Shortcut::new("app.save")
3136                .primary(KeyStroke::command(Key::S))
3137                .build(),
3138        );
3139
3140        tree.layout(SizeProposal::exact(100.0, 50.0));
3141        tree.focus(inner);
3142
3143        tree.press_key(Key::S, Modifiers::COMMAND);
3144        assert!(inner_seen.get(), "inner action observed the intent");
3145        assert!(outer_seen.get(), "outer action reached after Propagated");
3146    }
3147
3148    #[test]
3149    fn handled_action_stops_propagation() {
3150        use crate::action::Action;
3151        use crate::shortcut::{KeyStroke, Shortcut};
3152        use std::cell::Cell;
3153        use std::rc::Rc;
3154
3155        let inner_seen = Rc::new(Cell::new(false));
3156        let outer_seen = Rc::new(Cell::new(false));
3157        let inner_flag = inner_seen.clone();
3158        let outer_flag = outer_seen.clone();
3159
3160        let mut tree = WidgetTree::new();
3161        let outer = tree.add(FillWidget::new().focusable());
3162        let inner = tree.add_child(outer, FillWidget::new().focusable());
3163
3164        tree.push_action(
3165            inner,
3166            Action::new("app.save").on_invoke(move |_i, _c| {
3167                inner_flag.set(true);
3168            }),
3169        );
3170        tree.push_action(
3171            outer,
3172            Action::new("app.save").on_invoke(move |_i, _c| {
3173                outer_flag.set(true);
3174            }),
3175        );
3176        tree.shortcut_registry_mut().register(
3177            Shortcut::new("app.save")
3178                .primary(KeyStroke::command(Key::S))
3179                .build(),
3180        );
3181
3182        tree.layout(SizeProposal::exact(100.0, 50.0));
3183        tree.focus(inner);
3184
3185        tree.press_key(Key::S, Modifiers::COMMAND);
3186        assert!(inner_seen.get());
3187        assert!(!outer_seen.get(), "Handled at inner must stop propagation");
3188    }
3189
3190    #[test]
3191    fn disabled_action_propagates_by_default() {
3192        use crate::action::Action;
3193        use crate::shortcut::{KeyStroke, Shortcut};
3194        use crate::signal::Signal;
3195        use std::cell::Cell;
3196        use std::rc::Rc;
3197
3198        let inner_seen = Rc::new(Cell::new(false));
3199        let outer_seen = Rc::new(Cell::new(false));
3200        let inner_flag = inner_seen.clone();
3201        let outer_flag = outer_seen.clone();
3202
3203        let mut tree = WidgetTree::new();
3204        let outer = tree.add(FillWidget::new().focusable());
3205        let inner = tree.add_child(outer, FillWidget::new().focusable());
3206
3207        let enabled = Signal::new(false);
3208        tree.push_action(
3209            inner,
3210            Action::new("app.save")
3211                .enabled_when(enabled.clone())
3212                .on_invoke(move |_i, _c| {
3213                    inner_flag.set(true);
3214                }),
3215        );
3216        tree.push_action(
3217            outer,
3218            Action::new("app.save").on_invoke(move |_i, _c| {
3219                outer_flag.set(true);
3220            }),
3221        );
3222        tree.shortcut_registry_mut().register(
3223            Shortcut::new("app.save")
3224                .primary(KeyStroke::command(Key::S))
3225                .build(),
3226        );
3227
3228        tree.layout(SizeProposal::exact(100.0, 50.0));
3229        tree.focus(inner);
3230
3231        tree.press_key(Key::S, Modifiers::COMMAND);
3232        assert!(!inner_seen.get(), "disabled inner must not run");
3233        assert!(
3234            outer_seen.get(),
3235            "intent must propagate past disabled inner"
3236        );
3237    }
3238
3239    #[test]
3240    fn disabled_action_with_non_propagating_shortcut_consumes() {
3241        use crate::action::Action;
3242        use crate::shortcut::{KeyStroke, Shortcut};
3243        use crate::signal::Signal;
3244        use std::cell::Cell;
3245        use std::rc::Rc;
3246
3247        let inner_seen = Rc::new(Cell::new(false));
3248        let outer_seen = Rc::new(Cell::new(false));
3249        let inner_flag = inner_seen.clone();
3250        let outer_flag = outer_seen.clone();
3251
3252        let mut tree = WidgetTree::new();
3253        let outer = tree.add(FillWidget::new().focusable());
3254        let inner = tree.add_child(outer, FillWidget::new().focusable());
3255
3256        let enabled = Signal::new(false);
3257        tree.push_action(
3258            inner,
3259            Action::new("app.save")
3260                .enabled_when(enabled.clone())
3261                .on_invoke(move |_i, _c| {
3262                    inner_flag.set(true);
3263                }),
3264        );
3265        tree.push_action(
3266            outer,
3267            Action::new("app.save").on_invoke(move |_i, _c| {
3268                outer_flag.set(true);
3269            }),
3270        );
3271        tree.shortcut_registry_mut().register(
3272            Shortcut::new("app.save")
3273                .primary(KeyStroke::command(Key::S))
3274                .propagate_when_disabled(false)
3275                .build(),
3276        );
3277
3278        tree.layout(SizeProposal::exact(100.0, 50.0));
3279        tree.focus(inner);
3280
3281        tree.press_key(Key::S, Modifiers::COMMAND);
3282        assert!(!inner_seen.get(), "disabled inner still does not run");
3283        assert!(
3284            !outer_seen.get(),
3285            "intent must NOT propagate when shortcut disallows it"
3286        );
3287    }
3288
3289    #[test]
3290    fn send_intent_from_handler_reaches_ancestor_action() {
3291        use crate::action::Action;
3292        use crate::intent::Intent;
3293        use std::cell::Cell;
3294        use std::rc::Rc;
3295
3296        let save_seen = Rc::new(Cell::new(false));
3297        let save_flag = save_seen.clone();
3298
3299        let mut tree = WidgetTree::new();
3300        let root = tree.add(FillWidget::new());
3301        let button = tree.add_child(
3302            root,
3303            FillWidget::new().on_tap(|_pos, ctx| {
3304                ctx.send_intent(Intent::new("app.save"));
3305            }),
3306        );
3307        tree.push_action(
3308            root,
3309            Action::new("app.save").on_invoke(move |_i, _c| {
3310                save_flag.set(true);
3311            }),
3312        );
3313
3314        tree.layout(SizeProposal::exact(100.0, 50.0));
3315        tree.click(button);
3316        assert!(
3317            save_seen.get(),
3318            "ctx.send_intent must reach ancestor action"
3319        );
3320    }
3321
3322    #[test]
3323    fn widget_type_histogram_counts_distinct_types() {
3324        // The histogram surfaces concrete widget types
3325        // by std::any::type_name_of_val. Widgets become active
3326        // after the first layout pass, so we run that before
3327        // checking the histogram.
3328        let mut tree = WidgetTree::new();
3329        let _ = tree.add(FillWidget::new());
3330        let _ = tree.add(FillWidget::new());
3331        let _ = tree.add(FillWidget::new());
3332        tree.layout(SizeProposal::exact(100.0, 100.0));
3333        let histogram = tree.widget_type_histogram();
3334        let total: u32 = histogram.values().sum();
3335        assert!(
3336            total >= 3,
3337            "expected at least 3 active widgets, got {total}: {histogram:?}"
3338        );
3339        let fillwidget_entries: u32 = histogram
3340            .iter()
3341            .filter(|(k, _)| k.contains("FillWidget"))
3342            .map(|(_, v)| *v)
3343            .sum();
3344        assert!(
3345            fillwidget_entries >= 3,
3346            "expected ≥3 FillWidget instances; histogram = {histogram:?}"
3347        );
3348        assert_eq!(tree.active_widget_count() as u32, total);
3349    }
3350
3351    #[test]
3352    fn intent_source_tagged_handler_for_tap_activation() {
3353        // A tap-driven `ctx.send_intent` must surface as
3354        // `IntentSource::Handler` to ancestor actions, not the
3355        // `Programmatic` default of `Intent::new`.
3356        use crate::action::Action;
3357        use crate::intent::Intent;
3358        use crate::telemetry::IntentSource;
3359        use std::cell::Cell;
3360        use std::rc::Rc;
3361        let captured = Rc::new(Cell::new(IntentSource::Unknown));
3362        let captured_for_action = captured.clone();
3363
3364        let mut tree = WidgetTree::new();
3365        let root = tree.add(FillWidget::new());
3366        let button = tree.add_child(
3367            root,
3368            FillWidget::new().on_tap(|_pos, ctx| {
3369                ctx.send_intent(Intent::new("app.save"));
3370            }),
3371        );
3372        tree.push_action(
3373            root,
3374            Action::new("app.save").on_invoke(move |intent, _c| {
3375                captured_for_action.set(intent.source);
3376            }),
3377        );
3378
3379        tree.layout(SizeProposal::exact(100.0, 50.0));
3380        tree.click(button);
3381        assert_eq!(
3382            captured.get(),
3383            IntentSource::Handler,
3384            "tap-driven intent must tag IntentSource::Handler"
3385        );
3386    }
3387
3388    #[test]
3389    fn intent_source_programmatic_when_no_handler_active() {
3390        use crate::intent::Intent;
3391        use crate::telemetry::IntentSource;
3392        let intent = Intent::new("app.demo");
3393        assert_eq!(intent.source, IntentSource::Programmatic);
3394
3395        // ctx.send_intent without a handler scope keeps it Programmatic.
3396        let mut ctx = EventContext::new();
3397        ctx.send_intent(Intent::new("app.demo"));
3398        let queued = ctx.pending_intents.first().expect("intent queued");
3399        assert_eq!(queued.source, IntentSource::Programmatic);
3400    }
3401
3402    #[test]
3403    fn with_intent_source_overrides_for_managed_widgets() {
3404        use crate::intent::Intent;
3405        use crate::telemetry::IntentSource;
3406        let mut ctx = EventContext::new();
3407        ctx.with_intent_source(IntentSource::Menu, |ctx| {
3408            ctx.send_intent(Intent::new("app.demo"));
3409        });
3410        let queued = ctx.pending_intents.first().expect("intent queued");
3411        assert_eq!(
3412            queued.source,
3413            IntentSource::Menu,
3414            "with_intent_source(Menu) must tag the dispatched intent"
3415        );
3416
3417        // After the closure returns, current_source is restored —
3418        // a follow-up send_intent without a wrapping closure goes
3419        // back to the default (no override).
3420        ctx.send_intent(Intent::new("app.next"));
3421        let next = ctx.pending_intents.last().expect("second intent");
3422        assert_eq!(next.source, IntentSource::Programmatic);
3423    }
3424
3425    #[test]
3426    fn disabled_shortcut_falls_through_to_focused_widget() {
3427        use crate::action::Action;
3428        use crate::shortcut::{KeyStroke, Shortcut};
3429        use crate::signal::Signal;
3430        use std::cell::Cell;
3431        use std::rc::Rc;
3432
3433        let action_fired = Rc::new(Cell::new(false));
3434        let on_key_fired = Rc::new(Cell::new(false));
3435        let af = action_fired.clone();
3436        let kf = on_key_fired.clone();
3437
3438        let enabled = Signal::new(false);
3439
3440        let mut tree = WidgetTree::new();
3441        let widget = tree.add(FillWidget::new().focusable().on_key(move |event, _ctx| {
3442            if matches!(
3443                event,
3444                WidgetEvent::KeyDown {
3445                    key: Key::S,
3446                    modifiers,
3447                    ..
3448                } if modifiers.ctrl()
3449            ) {
3450                kf.set(true);
3451                return EventResponse::Handled;
3452            }
3453            EventResponse::Ignored
3454        }));
3455        tree.push_action(
3456            widget,
3457            Action::new("app.save").on_invoke(move |_i, _c| af.set(true)),
3458        );
3459        tree.shortcut_registry_mut().register(
3460            Shortcut::new("app.save")
3461                .primary(KeyStroke::command(Key::S))
3462                .enabled_when(enabled.clone())
3463                .build(),
3464        );
3465
3466        tree.layout(SizeProposal::exact(100.0, 50.0));
3467        tree.focus(widget);
3468
3469        // Disabled: keystroke falls through to on_key.
3470        tree.press_key(Key::S, Modifiers::COMMAND);
3471        assert!(
3472            !action_fired.get(),
3473            "disabled shortcut must not invoke its action"
3474        );
3475        assert!(
3476            on_key_fired.get(),
3477            "disabled shortcut must let KeyDown reach the focused widget"
3478        );
3479
3480        // Re-enable → action fires, on_key does not.
3481        on_key_fired.set(false);
3482        enabled.set(true);
3483        tree.press_key(Key::S, Modifiers::COMMAND);
3484        assert!(action_fired.get(), "re-enabled shortcut must dispatch");
3485        assert!(
3486            !on_key_fired.get(),
3487            "enabled shortcut must consume the KeyDown"
3488        );
3489    }
3490
3491    #[test]
3492    fn keyboard_capture_bypasses_shortcut() {
3493        use crate::action::Action;
3494        use crate::shortcut::{KeyStroke, Shortcut};
3495        use std::cell::Cell;
3496        use std::rc::Rc;
3497
3498        // A focused keyboard-capture surface (e.g. a terminal) must receive
3499        // Ctrl+S itself, even though an ENABLED global shortcut binds it — the
3500        // whole point of GAP 1. A non-capturing widget must yield to the
3501        // shortcut (the control case).
3502        fn run(capture: bool) -> (bool, bool) {
3503            let action_fired = Rc::new(Cell::new(false));
3504            let on_key_fired = Rc::new(Cell::new(false));
3505            let af = action_fired.clone();
3506            let kf = on_key_fired.clone();
3507
3508            let mut tree = WidgetTree::new();
3509            let widget = tree.add(
3510                FillWidget::new()
3511                    .focusable()
3512                    .keyboard_capture(capture)
3513                    .on_key(move |event, _ctx| {
3514                        if matches!(
3515                            event,
3516                            WidgetEvent::KeyDown { key: Key::S, modifiers, .. } if modifiers.ctrl()
3517                        ) {
3518                            kf.set(true);
3519                            return EventResponse::Handled;
3520                        }
3521                        EventResponse::Ignored
3522                    }),
3523            );
3524            tree.push_action(
3525                widget,
3526                Action::new("app.save").on_invoke(move |_i, _c| af.set(true)),
3527            );
3528            tree.shortcut_registry_mut().register(
3529                Shortcut::new("app.save")
3530                    .primary(KeyStroke::command(Key::S))
3531                    .build(),
3532            );
3533
3534            tree.layout(SizeProposal::exact(100.0, 50.0));
3535            tree.focus(widget);
3536            tree.press_key(Key::S, Modifiers::COMMAND);
3537            (action_fired.get(), on_key_fired.get())
3538        }
3539
3540        // Capture on: the shortcut is bypassed, the widget sees the key.
3541        let (action, on_key) = run(true);
3542        assert!(
3543            !action,
3544            "keyboard_capture must suppress the shortcut action"
3545        );
3546        assert!(on_key, "keyboard_capture must deliver the raw KeyDown");
3547
3548        // Capture off (control): the shortcut consumes the key.
3549        let (action, on_key) = run(false);
3550        assert!(action, "without capture the shortcut must fire");
3551        assert!(!on_key, "without capture the widget must not see the key");
3552    }
3553
3554    #[test]
3555    fn ctrl_tab_always_escapes_a_keyboard_capture_surface() {
3556        use std::cell::Cell;
3557        use std::rc::Rc;
3558
3559        // WCAG 2.1.2. A capture surface answers `Handled` to every key —
3560        // that is what it is for — so the "cycle focus only when the focused
3561        // widget did not handle Tab" rule can never get focus out of one.
3562        // Ctrl+Tab / Ctrl+Shift+Tab are therefore reserved by the dispatcher
3563        // and never reach the widget at all.
3564        let saw_key = Rc::new(Cell::new(false));
3565        let sk = saw_key.clone();
3566
3567        let mut tree = WidgetTree::new();
3568        let capture = tree.add(
3569            FillWidget::new()
3570                .focusable()
3571                .keyboard_capture(true)
3572                // The greediest possible handler: everything is consumed.
3573                .on_key(move |_event, _ctx| {
3574                    sk.set(true);
3575                    EventResponse::Handled
3576                }),
3577        );
3578        let neighbour = tree.add(FillWidget::new().focusable());
3579        tree.layout(SizeProposal::exact(100.0, 50.0));
3580
3581        // Plain Tab stays inside: the widget consumed it (a terminal writes
3582        // it to the child as `\t`).
3583        tree.focus(capture);
3584        tree.press_key(Key::Tab, Modifiers::NONE);
3585        assert!(saw_key.get(), "plain Tab must reach the capture surface");
3586        assert_eq!(
3587            tree.focused(),
3588            Some(capture),
3589            "plain Tab must not move focus off a capture surface"
3590        );
3591
3592        // Ctrl+Tab escapes forward, without the widget ever seeing it.
3593        saw_key.set(false);
3594        tree.press_key(Key::Tab, Modifiers::CTRL);
3595        assert!(
3596            !saw_key.get(),
3597            "Ctrl+Tab is reserved and must not reach the capture surface"
3598        );
3599        assert_eq!(
3600            tree.focused(),
3601            Some(neighbour),
3602            "Ctrl+Tab must move focus out of a capture surface"
3603        );
3604
3605        // And backwards.
3606        tree.focus(capture);
3607        tree.press_key(Key::Tab, Modifiers::CTRL | Modifiers::SHIFT);
3608        assert_eq!(
3609            tree.focused(),
3610            Some(neighbour),
3611            "Ctrl+Shift+Tab must move focus out of a capture surface"
3612        );
3613    }
3614
3615    #[test]
3616    fn scope_mismatch_does_not_invoke_on_activate() {
3617        use crate::intent::Intent;
3618        use crate::shortcut::{KeyStroke, Shortcut, ShortcutScope};
3619        use std::cell::Cell;
3620        use std::rc::Rc;
3621
3622        // Regression: before the find/invoke split, `on_activate` ran
3623        // even when the focused widget was outside the shortcut's
3624        // scope, and any side effects on its ctx were silently
3625        // dropped. The closure must now only run when the scope
3626        // check has already passed.
3627        let activated = Rc::new(Cell::new(false));
3628        let activated_flag = activated.clone();
3629
3630        let mut tree = WidgetTree::new();
3631        let scope_root = tree.add(FillWidget::new().focusable());
3632        let outside = tree.add(FillWidget::new().focusable());
3633
3634        tree.shortcut_registry_mut().register(
3635            Shortcut::new("editor.find")
3636                .primary(KeyStroke::command(Key::F))
3637                .scope(ShortcutScope::Scoped(scope_root))
3638                .on_activate(move |_ks, _ctx| {
3639                    activated_flag.set(true);
3640                    Intent::new("editor.find")
3641                })
3642                .build(),
3643        );
3644
3645        tree.layout(SizeProposal::exact(200.0, 100.0));
3646        tree.focus(outside);
3647
3648        tree.press_key(Key::F, Modifiers::COMMAND);
3649        assert!(
3650            !activated.get(),
3651            "on_activate must not run when focus is outside the shortcut's scope"
3652        );
3653    }
3654
3655    #[test]
3656    fn key_capture_runs_callback_and_bypasses_registry() {
3657        use crate::action::Action;
3658        use crate::shortcut::{KeyStroke, Shortcut};
3659        use std::cell::Cell;
3660        use std::rc::Rc;
3661
3662        let action_fired = Rc::new(Cell::new(false));
3663        let af = action_fired.clone();
3664        let captured = Rc::new(Cell::new(None));
3665        let cf = captured.clone();
3666
3667        let mut tree = WidgetTree::new();
3668        let widget = tree.add(FillWidget::new().focusable());
3669        tree.push_action(
3670            widget,
3671            Action::new("app.save").on_invoke(move |_i, _c| af.set(true)),
3672        );
3673        tree.shortcut_registry_mut().register(
3674            Shortcut::new("app.save")
3675                .primary(KeyStroke::command(Key::S))
3676                .build(),
3677        );
3678
3679        tree.layout(SizeProposal::exact(100.0, 50.0));
3680        tree.focus(widget);
3681
3682        let handle = tree.begin_key_capture(move |ks, _reg, _ctx| cf.set(Some(ks)));
3683        assert!(tree.is_capturing_keys());
3684
3685        tree.press_key(Key::S, Modifiers::COMMAND);
3686        assert_eq!(
3687            captured.get(),
3688            Some(KeyStroke::command(Key::S)),
3689            "capture callback must receive the chord"
3690        );
3691        assert!(
3692            !action_fired.get(),
3693            "shortcut action must not fire while capture is armed"
3694        );
3695        assert!(
3696            !tree.is_capturing_keys(),
3697            "capture is one-shot; next KeyDown flows normally"
3698        );
3699        drop(handle);
3700    }
3701
3702    #[test]
3703    fn key_capture_can_rebind_through_registry() {
3704        use crate::shortcut::{KeyStroke, Shortcut};
3705
3706        let mut tree = WidgetTree::new();
3707        let widget = tree.add(FillWidget::new().focusable());
3708        tree.shortcut_registry_mut().register(
3709            Shortcut::new("app.save")
3710                .primary(KeyStroke::command(Key::S))
3711                .build(),
3712        );
3713
3714        tree.layout(SizeProposal::exact(100.0, 50.0));
3715        tree.focus(widget);
3716
3717        // Arm capture: whatever chord comes next, rebind app.save to it.
3718        let _h = tree.begin_key_capture(|ks, reg, _ctx| {
3719            reg.rebind_primary("app.save", Some(ks));
3720        });
3721
3722        tree.press_key(Key::B, Modifiers::COMMAND | Modifiers::SHIFT);
3723        assert_eq!(
3724            tree.shortcut_registry()
3725                .effective("app.save")
3726                .unwrap()
3727                .primary,
3728            Some(KeyStroke::command_shift(Key::B))
3729        );
3730    }
3731
3732    #[test]
3733    fn dropping_capture_handle_cancels_capture() {
3734        use crate::shortcut::{KeyStroke, Shortcut};
3735        use std::cell::Cell;
3736        use std::rc::Rc;
3737
3738        let action_fired = Rc::new(Cell::new(false));
3739        let af = action_fired.clone();
3740        let capture_fired = Rc::new(Cell::new(false));
3741        let cf = capture_fired.clone();
3742
3743        let mut tree = WidgetTree::new();
3744        let widget = tree.add(FillWidget::new().focusable());
3745        tree.push_action(
3746            widget,
3747            crate::action::Action::new("app.save").on_invoke(move |_i, _c| af.set(true)),
3748        );
3749        tree.shortcut_registry_mut().register(
3750            Shortcut::new("app.save")
3751                .primary(KeyStroke::command(Key::S))
3752                .build(),
3753        );
3754        tree.layout(SizeProposal::exact(100.0, 50.0));
3755        tree.focus(widget);
3756
3757        // Arm capture in a scope, then drop the handle before any key
3758        // is pressed. The next KeyDown must fall through to the normal
3759        // shortcut path, firing the action — not the cancelled capture.
3760        {
3761            let _h = tree.begin_key_capture(move |_ks, _reg, _ctx| cf.set(true));
3762            assert!(tree.is_capturing_keys());
3763            // `_h` drops here → cancel.
3764        }
3765        assert!(
3766            !tree.is_capturing_keys(),
3767            "dropping the handle must cancel the capture"
3768        );
3769
3770        tree.press_key(Key::S, Modifiers::COMMAND);
3771        assert!(!capture_fired.get(), "cancelled capture must not fire");
3772        assert!(
3773            action_fired.get(),
3774            "shortcut action runs after capture was cancelled"
3775        );
3776    }
3777
3778    #[test]
3779    fn second_begin_key_capture_does_not_racecancel_first() {
3780        use std::cell::Cell;
3781        use std::rc::Rc;
3782
3783        let first = Rc::new(Cell::new(false));
3784        let second = Rc::new(Cell::new(false));
3785        let f = first.clone();
3786        let s = second.clone();
3787
3788        let mut tree = WidgetTree::new();
3789        let widget = tree.add(FillWidget::new().focusable());
3790        tree.layout(SizeProposal::exact(100.0, 50.0));
3791        tree.focus(widget);
3792
3793        // Arm #1 then replace with #2. #1's handle is later dropped,
3794        // which would have cancelled the active capture under the old
3795        // `Option<Box<FnOnce>>` design — CaptureHandle now ties each
3796        // session to its own slot, so the drop only clears #1's
3797        // (orphaned) slot, not #2.
3798        let h1 = tree.begin_key_capture(move |_ks, _reg, _ctx| f.set(true));
3799        let _h2 = tree.begin_key_capture(move |_ks, _reg, _ctx| s.set(true));
3800        drop(h1);
3801
3802        assert!(
3803            tree.is_capturing_keys(),
3804            "dropping the older handle must not cancel the active capture"
3805        );
3806        tree.press_key(Key::K, Modifiers::COMMAND);
3807        assert!(!first.get());
3808        assert!(second.get(), "newest capture wins");
3809    }
3810
3811    #[test]
3812    fn capture_callback_can_send_intent() {
3813        use crate::action::Action;
3814        use crate::intent::Intent;
3815
3816        use std::cell::Cell;
3817        use std::rc::Rc;
3818
3819        let ran = Rc::new(Cell::new(false));
3820        let flag = ran.clone();
3821
3822        let mut tree = WidgetTree::new();
3823        let widget = tree.add(FillWidget::new().focusable());
3824        tree.push_action(
3825            widget,
3826            Action::new("app.save").on_invoke(move |_i, _c| flag.set(true)),
3827        );
3828        tree.layout(SizeProposal::exact(100.0, 50.0));
3829        tree.focus(widget);
3830
3831        let _h = tree.begin_key_capture(|_ks, _reg, ctx| {
3832            ctx.send_intent(Intent::new("app.save"));
3833        });
3834        tree.press_key(Key::X, Modifiers::COMMAND);
3835        assert!(
3836            ran.get(),
3837            "intent queued from capture callback must dispatch"
3838        );
3839    }
3840
3841    #[test]
3842    fn binding_registry_does_not_accumulate_across_rebuilds() {
3843        use crate::binding::BindingLevel;
3844        use crate::signal::Signal;
3845
3846        #[derive(Debug)]
3847        struct BoundLeaf {
3848            tick: Signal<u64>,
3849        }
3850        impl crate::widget::Widget for BoundLeaf {
3851            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
3852                self.tick.bind_to(
3853                    ctx.self_id(),
3854                    ctx.binding_registry(),
3855                    BindingLevel::Relayout,
3856                );
3857                Vec::new()
3858            }
3859            fn layout_response(
3860                &self,
3861                proposal: SizeProposal,
3862                _ctx: &crate::widget::LayoutContext,
3863            ) -> crate::widget::LayoutResponse {
3864                proposal.resolve(10.0, 10.0).into()
3865            }
3866        }
3867
3868        let mut tree = WidgetTree::new();
3869        let tick = Signal::new(0_u64);
3870        let widget = tree.add(BoundLeaf { tick: tick.clone() });
3871        tree.layout(SizeProposal::exact(200.0, 200.0));
3872        let after_first_build = tree.binding_registry().len();
3873        assert!(after_first_build >= 1);
3874
3875        // Force rebuild a handful of times and verify the binding
3876        // count does not keep growing. Pre-fix: each rebuild pushed
3877        // a new entry for the same (widget, signal) pair.
3878        for _ in 0..5 {
3879            tree.arena.mark_needs_rebuild(widget);
3880            tree.layout(SizeProposal::exact(200.0, 200.0));
3881        }
3882        assert_eq!(
3883            tree.binding_registry().len(),
3884            after_first_build,
3885            "bindings must be cleared on rebuild"
3886        );
3887
3888        tree.destroy_subtree(widget);
3889        assert_eq!(
3890            tree.binding_registry().len(),
3891            0,
3892            "bindings must be cleared on destroy"
3893        );
3894        // Silence unused-variable warning for the signal.
3895        let _ = tick;
3896    }
3897
3898    #[test]
3899    fn ctx_destroy_cancels_animations_and_bindings_via_deferred_path() {
3900        // Regression: `EventContext::destroy` queues
3901        // `TreeMutation::Destroy`, which used to be applied with the
3902        // bare `arena.destroy` — unlinking the node but leaking the
3903        // animation-scheduler entry (it holds a strong `Signal<f32>`
3904        // clone, so the widget kept animating after destruction) and
3905        // the widget's bindings. It must route through
3906        // `destroy_subtree` like every other destroy path does.
3907        use crate::binding::BindingLevel;
3908        use crate::signal::Signal;
3909        use std::time::{Duration, Instant};
3910        use teksilo_tokens::Easing;
3911
3912        #[derive(Debug)]
3913        struct BoundLeaf {
3914            tick: Signal<u64>,
3915        }
3916        impl crate::widget::Widget for BoundLeaf {
3917            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
3918                self.tick.bind_to(
3919                    ctx.self_id(),
3920                    ctx.binding_registry(),
3921                    BindingLevel::Relayout,
3922                );
3923                Vec::new()
3924            }
3925            fn layout_response(
3926                &self,
3927                proposal: SizeProposal,
3928                _ctx: &crate::widget::LayoutContext,
3929            ) -> crate::widget::LayoutResponse {
3930                proposal.resolve(10.0, 10.0).into()
3931            }
3932        }
3933
3934        let mut tree = WidgetTree::new();
3935        let widget = tree.add(BoundLeaf {
3936            tick: Signal::new(0_u64),
3937        });
3938        tree.layout(SizeProposal::exact(200.0, 200.0));
3939        assert!(tree.binding_registry().len() >= 1);
3940
3941        // Seed an animation owned by the widget — exactly the strong
3942        // `Signal<f32>` clone the scheduler outlives the widget with.
3943        let anim = Signal::<f32>::new_animated(0.0);
3944        tree.animation_scheduler.animate(
3945            &anim,
3946            widget,
3947            1.0,
3948            Duration::from_secs(10),
3949            Easing::Linear,
3950            Instant::now(),
3951        );
3952        assert_eq!(tree.animation_scheduler.active_count(), 1);
3953
3954        // Destroy via the deferred handler-time path.
3955        let mut noop = crate::window::NoopWindowOps;
3956        tree.run_with_event_context(&mut noop, |ctx| ctx.destroy(widget));
3957
3958        assert_eq!(
3959            tree.animation_scheduler.active_count(),
3960            0,
3961            "ctx.destroy must cancel animations owned by the destroyed widget"
3962        );
3963        assert_eq!(
3964            tree.binding_registry().len(),
3965            0,
3966            "ctx.destroy must unregister the destroyed widget's bindings"
3967        );
3968        assert!(
3969            tree.arena.get(widget).is_none(),
3970            "node must be removed from the arena"
3971        );
3972    }
3973
3974    #[test]
3975    fn clear_shortcut_override_via_event_context_restores_default() {
3976        use crate::shortcut::{KeyStroke, Shortcut};
3977
3978        let mut tree = WidgetTree::new();
3979        tree.shortcut_registry_mut().register(
3980            Shortcut::new("app.save")
3981                .primary(KeyStroke::command(Key::S))
3982                .build(),
3983        );
3984        tree.shortcut_registry_mut()
3985            .rebind_primary("app.save", Some(KeyStroke::alt(Key::S)));
3986
3987        let source = tree.add(FillWidget::new());
3988        let mut ctx = EventContext::new();
3989        ctx.clear_shortcut_override("app.save");
3990        tree.collect_from_ctx(ctx, source);
3991
3992        assert_eq!(
3993            tree.shortcut_registry()
3994                .effective("app.save")
3995                .unwrap()
3996                .primary,
3997            Some(KeyStroke::command(Key::S))
3998        );
3999    }
4000
4001    #[test]
4002    fn rebind_shortcut_primary_via_event_context() {
4003        use crate::shortcut::{KeyStroke, Shortcut};
4004
4005        let mut tree = WidgetTree::new();
4006        tree.shortcut_registry_mut().register(
4007            Shortcut::new("app.save")
4008                .primary(KeyStroke::command(Key::S))
4009                .build(),
4010        );
4011        let source = tree.add(FillWidget::new());
4012
4013        let mut ctx = EventContext::new();
4014        ctx.rebind_shortcut_primary("app.save", Some(KeyStroke::alt(Key::S)));
4015        tree.collect_from_ctx(ctx, source);
4016
4017        assert_eq!(
4018            tree.shortcut_registry()
4019                .effective("app.save")
4020                .unwrap()
4021                .primary,
4022            Some(KeyStroke::alt(Key::S))
4023        );
4024    }
4025
4026    #[test]
4027    fn unregister_all_for_owner_called_on_destroy() {
4028        use crate::shortcut::{KeyStroke, Shortcut};
4029
4030        let mut tree = WidgetTree::new();
4031        let widget = tree.add(FillWidget::new());
4032        let widget_owner = widget;
4033        tree.shortcut_registry_mut().register_owned(
4034            Shortcut::new("scoped.thing")
4035                .primary(KeyStroke::command(Key::K))
4036                .build(),
4037            widget_owner,
4038        );
4039        assert!(
4040            tree.shortcut_registry()
4041                .get_default("scoped.thing")
4042                .is_some()
4043        );
4044
4045        tree.destroy_subtree(widget);
4046        assert!(
4047            tree.shortcut_registry()
4048                .get_default("scoped.thing")
4049                .is_none(),
4050            "destroying the owner must unregister its shortcut"
4051        );
4052    }
4053
4054    /// A global action fires for an intent dispatched from a widget in a
4055    /// completely unrelated subtree — proving it is a position-independent
4056    /// fallback (the menu-bar-vs-content case).
4057    #[test]
4058    fn global_action_reached_from_unrelated_source() {
4059        use crate::action::Action;
4060        use crate::intent::Intent;
4061        use std::cell::Cell;
4062        use std::rc::Rc;
4063
4064        #[derive(Debug)]
4065        struct Registrar(Rc<Cell<bool>>);
4066        impl crate::widget::Widget for Registrar {
4067            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4068                let flag = self.0.clone();
4069                ctx.register_action_global(
4070                    Action::new("test.global").on_invoke(move |_i, _c| flag.set(true)),
4071                );
4072                vec![]
4073            }
4074            fn layout_response(
4075                &self,
4076                _p: teksilo_canvas::SizeProposal,
4077                _c: &crate::widget::LayoutContext,
4078            ) -> crate::widget::LayoutResponse {
4079                teksilo_canvas::Size::new(0.0, 0.0).into()
4080            }
4081        }
4082
4083        let mut tree = WidgetTree::new();
4084        let fired = Rc::new(Cell::new(false));
4085        let registrar = tree.add(Registrar(fired.clone()));
4086        let source = tree.add(FillWidget::new()); // unrelated sibling root
4087        let mut ops = crate::window::NoopWindowOps;
4088
4089        tree.dispatch_intent(source, Intent::new("test.global"), true, &mut ops);
4090        assert!(
4091            fired.get(),
4092            "global action must fire from an unrelated source"
4093        );
4094
4095        // And it is torn down with its owner.
4096        fired.set(false);
4097        tree.destroy_subtree(registrar);
4098        tree.dispatch_intent(source, Intent::new("test.global"), true, &mut ops);
4099        assert!(
4100            !fired.get(),
4101            "destroying the owner must remove its global action"
4102        );
4103    }
4104
4105    // --- Transform-aware hit-testing -------------------------------------
4106    //
4107    // `set_transform` scopes are paint-only: the renderer pushes the
4108    // transform around the subtree, so the visually-displayed area is
4109    // shifted relative to `arena.bounds(id)`. Hit-testing must inverse-
4110    // transform the screen-space input point as it descends through each
4111    // transform scope so that a click on the visually-rendered area lands
4112    // on the correct widget. Pre-fix, screen-space `bounds.contains(point)`
4113    // returned the *pre-transform* widget for in-bounds-pre-transform
4114    // points and missed the visually-shifted hit area entirely.
4115
4116    #[test]
4117    fn hit_test_through_translate_scope() {
4118        use crate::test_widgets::StackWidget;
4119        let mut tree = WidgetTree::new();
4120        let child = tree.add(FillWidget::new());
4121        let parent = tree.add(StackWidget::new().add_child(child));
4122        // Visually shift the entire subtree right by 100px.
4123        tree.set_transform(parent, teksilo_canvas::Transform2D::translate(100.0, 0.0));
4124        tree.layout(SizeProposal::exact(100.0, 50.0));
4125
4126        // (50, 25) is inside the *pre-transform* bounds but the widget is
4127        // visually painted at x=100..200; a click at (50, 25) lands on
4128        // empty space.
4129        assert_eq!(
4130            tree.hit_test(Point::new(50.0, 25.0)),
4131            None,
4132            "pre-transform area is not visually populated and must not hit"
4133        );
4134        // (150, 25) is inside the visually-rendered area (post-translate).
4135        assert_eq!(
4136            tree.hit_test(Point::new(150.0, 25.0)),
4137            Some(child),
4138            "visually-rendered area must hit the child"
4139        );
4140        // Off everything.
4141        assert_eq!(tree.hit_test(Point::new(250.0, 25.0)), None);
4142    }
4143
4144    #[test]
4145    fn hit_test_through_scale_scope() {
4146        use crate::test_widgets::StackWidget;
4147        let mut tree = WidgetTree::new();
4148        let child = tree.add(FillWidget::new());
4149        let parent = tree.add(StackWidget::new().add_child(child));
4150        // Halve the visual size: pre-transform bounds (0,0,100,50) →
4151        // visually (0,0,50,25).
4152        tree.set_transform(parent, teksilo_canvas::Transform2D::scale(0.5, 0.5));
4153        tree.layout(SizeProposal::exact(100.0, 50.0));
4154
4155        // Inside the visual area.
4156        assert_eq!(tree.hit_test(Point::new(25.0, 12.0)), Some(child));
4157        // Outside the visual area but inside the pre-transform bounds.
4158        // Without the fix this would (incorrectly) hit the child.
4159        assert_eq!(
4160            tree.hit_test(Point::new(75.0, 25.0)),
4161            None,
4162            "scaled-out region must not hit"
4163        );
4164    }
4165
4166    #[test]
4167    fn hit_test_through_nested_transforms_compose() {
4168        use crate::test_widgets::StackWidget;
4169        let mut tree = WidgetTree::new();
4170        let leaf = tree.add(FillWidget::new());
4171        let inner = tree.add(StackWidget::new().add_child(leaf));
4172        let outer = tree.add(StackWidget::new().add_child(inner));
4173        // Outer translates by (100, 0); inner additionally scales by 2.
4174        // Effective at leaf = scale(2,2).then(translate(100,0)) — the
4175        // renderer composes deepest-first (see `effective_transform`).
4176        tree.set_transform(outer, teksilo_canvas::Transform2D::translate(100.0, 0.0));
4177        tree.set_transform(inner, teksilo_canvas::Transform2D::scale(2.0, 2.0));
4178        tree.layout(SizeProposal::exact(50.0, 25.0));
4179
4180        // Leaf-local (0, 0) → scale → (0, 0) → translate → (100, 0).
4181        // Leaf-local (50, 25) → scale → (100, 50) → translate → (200, 50).
4182        // So the visual hit area is x in [100, 200], y in [0, 50].
4183        assert_eq!(tree.hit_test(Point::new(150.0, 25.0)), Some(leaf));
4184        assert_eq!(tree.hit_test(Point::new(50.0, 25.0)), None);
4185        assert_eq!(tree.hit_test(Point::new(250.0, 25.0)), None);
4186    }
4187
4188    #[test]
4189    fn hit_test_identity_transform_unchanged() {
4190        // Sanity: an identity transform must not perturb the existing
4191        // hit-test behavior. Guards against accidental over-application
4192        // of inversion on the hot path.
4193        let mut tree = WidgetTree::new();
4194        let widget = tree.add(FillWidget::new());
4195        tree.set_transform(widget, teksilo_canvas::Transform2D::IDENTITY);
4196        tree.layout(SizeProposal::exact(100.0, 50.0));
4197        assert_eq!(tree.hit_test(Point::new(50.0, 25.0)), Some(widget));
4198    }
4199
4200    #[test]
4201    fn arena_effective_transform_composes_ancestors() {
4202        // `arena.effective_transform(id)` must equal the renderer's
4203        // transform-stack top by the time it begins painting `id` —
4204        // i.e. mapping `id`'s pre-transform local point to screen space.
4205        // The renderer's `PushTransform` handler composes as
4206        // `device_t.then(prev_top)` (see `teksilo-render/src/renderer.rs`),
4207        // so the *innermost* transform applies first to a local point.
4208        // For ancestors [outer, inner] both with transforms, this means
4209        // effective = inner.then(outer), NOT outer.then(inner).
4210        // teksilo-scene relies on this to project scene-coord bounds to
4211        // screen space when emitting AT nodes for view-transformed items.
4212        use crate::test_widgets::StackWidget;
4213        let mut tree = WidgetTree::new();
4214        let leaf = tree.add(FillWidget::new());
4215        let inner = tree.add(StackWidget::new().add_child(leaf));
4216        let outer = tree.add(StackWidget::new().add_child(inner));
4217        tree.set_transform(outer, teksilo_canvas::Transform2D::translate(100.0, 0.0));
4218        tree.set_transform(inner, teksilo_canvas::Transform2D::scale(2.0, 2.0));
4219        tree.layout(SizeProposal::exact(50.0, 25.0));
4220
4221        let eff = tree.arena.effective_transform(leaf);
4222        let expected = teksilo_canvas::Transform2D::scale(2.0, 2.0)
4223            .then(&teksilo_canvas::Transform2D::translate(100.0, 0.0));
4224        for (a, b) in eff.m.iter().zip(expected.m.iter()) {
4225            assert!(
4226                (a - b).abs() < 1e-5,
4227                "effective_transform mismatch: got {:?}, want {:?}",
4228                eff.m,
4229                expected.m
4230            );
4231        }
4232
4233        // Concrete-point check that pins the composition order without
4234        // relying on matrix equality alone: a leaf-local point at the
4235        // bounds origin (0, 0) should land at screen (100, 0) — scale
4236        // first (still (0,0)), then translate by 100 in x. With the
4237        // wrong composition order it would land at (200, 0).
4238        let screen_origin = eff.apply_point(Point::new(0.0, 0.0));
4239        assert!((screen_origin.x - 100.0).abs() < 1e-5);
4240        assert!((screen_origin.y - 0.0).abs() < 1e-5);
4241        // Far corner: leaf-local (50, 25) → scale → (100, 50) → translate
4242        // by 100 in x → (200, 50).
4243        let screen_corner = eff.apply_point(Point::new(50.0, 25.0));
4244        assert!((screen_corner.x - 200.0).abs() < 1e-5);
4245        assert!((screen_corner.y - 50.0).abs() < 1e-5);
4246    }
4247
4248    // ─── Context-menu factory: position, ctx, None fall-through ─────────
4249
4250    /// A throwaway content widget the factory mounts. We never paint
4251    /// it — the test only checks that it lands in the overlay manager.
4252    #[derive(Debug)]
4253    struct StubMenu;
4254    impl crate::widget::Widget for StubMenu {
4255        fn layout_response(
4256            &self,
4257            _proposal: SizeProposal,
4258            _ctx: &crate::widget::LayoutContext,
4259        ) -> crate::widget::LayoutResponse {
4260            teksilo_canvas::Size::new(100.0, 40.0).into()
4261        }
4262    }
4263
4264    #[test]
4265    fn context_menu_factory_receives_click_position() {
4266        use crate::event::{Modifiers, PointerButton};
4267        use std::cell::Cell;
4268        use std::rc::Rc;
4269
4270        let captured_position = Rc::new(Cell::new(None::<Point>));
4271        let cap = captured_position.clone();
4272        let mut tree = WidgetTree::new();
4273        let widget = tree.add(FillWidget::new().context_menu(move |pos, _ctx| {
4274            cap.set(Some(pos));
4275            Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
4276        }));
4277        tree.layout(SizeProposal::exact(200.0, 100.0));
4278
4279        let click = Point::new(73.0, 42.0);
4280        tree.dispatch_event(WidgetEvent::PointerDown {
4281            position: click,
4282            button: PointerButton::Secondary,
4283            modifiers: Modifiers::NONE,
4284        });
4285
4286        let got = captured_position.get();
4287        assert_eq!(
4288            got,
4289            Some(click),
4290            "factory must receive the click position; got {:?}",
4291            got
4292        );
4293        let _ = widget;
4294    }
4295
4296    #[test]
4297    fn context_menu_factory_returning_none_falls_through_to_parent() {
4298        use crate::event::{Modifiers, PointerButton};
4299        use crate::test_widgets::StackWidget;
4300        use std::cell::Cell;
4301        use std::rc::Rc;
4302
4303        // Outer factory always returns Some(StubMenu); inner factory
4304        // returns None. Right-click should walk past the inner and
4305        // mount the outer's menu.
4306        let outer_called = Rc::new(Cell::new(0_u32));
4307        let outer_flag = outer_called.clone();
4308        let mut tree = WidgetTree::new();
4309        let inner = tree.add(FillWidget::new().context_menu(|_pos, _ctx| None));
4310        let _outer = tree.add(StackWidget::new().add_child(inner).context_menu(
4311            move |_pos, _ctx| {
4312                outer_flag.set(outer_flag.get() + 1);
4313                Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
4314            },
4315        ));
4316        tree.layout(SizeProposal::exact(200.0, 100.0));
4317
4318        tree.dispatch_event(WidgetEvent::PointerDown {
4319            position: Point::new(50.0, 25.0),
4320            button: PointerButton::Secondary,
4321            modifiers: Modifiers::NONE,
4322        });
4323
4324        assert_eq!(
4325            outer_called.get(),
4326            1,
4327            "inner returning None must fall through to the outer factory"
4328        );
4329    }
4330
4331    #[test]
4332    fn context_menu_factory_none_throughout_chain_does_not_show_overlay() {
4333        use crate::event::{Modifiers, PointerButton};
4334
4335        // Single factory returning None → no overlay shown, no panic.
4336        let mut tree = WidgetTree::new();
4337        tree.add(FillWidget::new().context_menu(|_pos, _ctx| None));
4338        tree.layout(SizeProposal::exact(200.0, 100.0));
4339
4340        let overlay_count_before = tree.overlay_manager.len();
4341        tree.dispatch_event(WidgetEvent::PointerDown {
4342            position: Point::new(50.0, 25.0),
4343            button: PointerButton::Secondary,
4344            modifiers: Modifiers::NONE,
4345        });
4346        let overlay_count_after = tree.overlay_manager.len();
4347        assert_eq!(
4348            overlay_count_before, overlay_count_after,
4349            "a factory returning None must not mount any overlay"
4350        );
4351    }
4352
4353    // ---- Reconcile-on-rebuild (`preserves_children_on_rebuild`) ----------
4354    //
4355    // These pin the contract that the preserve path RECONCILES: it keeps the
4356    // children a rebuild re-attaches (and any subtree re-parented into the new
4357    // tree) while reaping the ones it drops — so memoizing widgets are both
4358    // stateful and leak-free. Regression guard for the orphan-leak the old
4359    // "preserve = destroy nothing" behaviour caused.
4360
4361    /// `build()` mints a fresh child every time and returns only it, abandoning
4362    /// the previous one. Used to prove dropped children are reaped, not leaked.
4363    #[derive(Debug)]
4364    struct FreshChildHost {
4365        preserve: bool,
4366    }
4367    impl Widget for FreshChildHost {
4368        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4369            vec![ctx.add(FillWidget::new())]
4370        }
4371        fn layout_response(
4372            &self,
4373            p: SizeProposal,
4374            _c: &LayoutContext,
4375        ) -> crate::widget::LayoutResponse {
4376            p.resolve(10.0, 10.0).into()
4377        }
4378        fn preserves_children_on_rebuild(&self) -> bool {
4379            self.preserve
4380        }
4381    }
4382
4383    #[test]
4384    fn reconcile_reaps_dropped_children_no_leak() {
4385        // preserve=false (destroy-all) and preserve=true (reconcile) must BOTH
4386        // keep the arena bounded when a rebuild drops its old child. Before the
4387        // reconcile fix, preserve=true grew the arena (and the active set) by
4388        // one stranded orphan per rebuild.
4389        for preserve in [false, true] {
4390            let mut tree = WidgetTree::new();
4391            let host = tree.add(FreshChildHost { preserve });
4392            tree.layout(SizeProposal::exact(100.0, 100.0));
4393            let total0 = tree.arena.len();
4394            let active0 = tree.active_widget_count();
4395            for _ in 0..5 {
4396                tree.arena_mark_needs_rebuild_for_testing(host);
4397                tree.layout(SizeProposal::exact(100.0, 100.0));
4398            }
4399            assert_eq!(
4400                tree.arena.len(),
4401                total0,
4402                "preserve={preserve}: dropped children must be reaped, not leaked"
4403            );
4404            assert_eq!(
4405                tree.active_widget_count(),
4406                active0,
4407                "preserve={preserve}: no stranded still-active orphans"
4408            );
4409        }
4410    }
4411
4412    /// `build()` mints one **detached** node every time — the shape of every
4413    /// pre-built popup in the widget crate (a dropdown, a calendar, a
4414    /// tooltip's cascade children): parked dormant, shown later through an
4415    /// overlay, and deliberately not a child, since activation and paint both
4416    /// descend through `children`.
4417    #[derive(Debug)]
4418    struct DetachedContentHost {
4419        preserve: bool,
4420    }
4421    impl Widget for DetachedContentHost {
4422        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4423            let popup = ctx.add_detached(FillWidget::new());
4424            ctx.set_dormant(popup);
4425            vec![ctx.add(FillWidget::new())]
4426        }
4427        fn layout_response(
4428            &self,
4429            p: SizeProposal,
4430            _c: &LayoutContext,
4431        ) -> crate::widget::LayoutResponse {
4432            p.resolve(10.0, 10.0).into()
4433        }
4434        fn preserves_children_on_rebuild(&self) -> bool {
4435            self.preserve
4436        }
4437    }
4438
4439    #[test]
4440    fn rebuilding_reaps_detached_content_no_leak() {
4441        // A parentless node is reachable from no walk at all — not the child
4442        // teardown, not the accessibility tree, not `active_widget_count`. Held
4443        // by a bare `ctx.add` it simply accumulated: one stranded popup per
4444        // rebuild, for the lifetime of the process. `add_detached` records the
4445        // ownership edge that makes it reapable.
4446        for preserve in [false, true] {
4447            let mut tree = WidgetTree::new();
4448            let host = tree.add(DetachedContentHost { preserve });
4449            tree.layout(SizeProposal::exact(100.0, 100.0));
4450            let total0 = tree.arena.len();
4451            for _ in 0..5 {
4452                tree.arena_mark_needs_rebuild_for_testing(host);
4453                tree.layout(SizeProposal::exact(100.0, 100.0));
4454            }
4455            assert_eq!(
4456                tree.arena.len(),
4457                total0,
4458                "preserve={preserve}: the previous build's detached content must be reaped"
4459            );
4460        }
4461    }
4462
4463    #[test]
4464    fn destroying_a_host_reaps_its_detached_content() {
4465        let mut tree = WidgetTree::new();
4466        let outer = tree.add(FillWidget::new());
4467        tree.layout(SizeProposal::exact(100.0, 100.0));
4468        let empty = tree.arena.len();
4469
4470        let host = tree.add_child(outer, DetachedContentHost { preserve: false });
4471        tree.layout(SizeProposal::exact(100.0, 100.0));
4472        assert!(tree.arena.len() > empty);
4473
4474        tree.destroy_subtree(host);
4475        assert_eq!(
4476            tree.arena.len(),
4477            empty,
4478            "the popup must die with the widget that built it"
4479        );
4480    }
4481
4482    /// Memoizes one child and re-attaches the same id every build.
4483    #[derive(Debug)]
4484    struct StableChildHost {
4485        child: Option<WidgetId>,
4486        probe: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
4487    }
4488    impl Widget for StableChildHost {
4489        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4490            let id = match self.child {
4491                Some(id) => id,
4492                None => {
4493                    let id = ctx.add(FillWidget::new());
4494                    self.child = Some(id);
4495                    self.probe.set(Some(id));
4496                    id
4497                }
4498            };
4499            vec![id]
4500        }
4501        fn layout_response(
4502            &self,
4503            p: SizeProposal,
4504            _c: &LayoutContext,
4505        ) -> crate::widget::LayoutResponse {
4506            p.resolve(10.0, 10.0).into()
4507        }
4508        fn preserves_children_on_rebuild(&self) -> bool {
4509            true
4510        }
4511    }
4512
4513    #[test]
4514    fn reconcile_preserves_reattached_child() {
4515        let probe = std::rc::Rc::new(std::cell::Cell::new(None));
4516        let mut tree = WidgetTree::new();
4517        let host = tree.add(StableChildHost {
4518            child: None,
4519            probe: probe.clone(),
4520        });
4521        tree.layout(SizeProposal::exact(100.0, 100.0));
4522        let child = probe.get().expect("child mounted");
4523        let total0 = tree.arena.len();
4524        for _ in 0..5 {
4525            tree.arena_mark_needs_rebuild_for_testing(host);
4526            tree.layout(SizeProposal::exact(100.0, 100.0));
4527        }
4528        assert!(
4529            tree.arena.is_active(child),
4530            "the re-attached child must survive every rebuild"
4531        );
4532        assert_eq!(tree.arena.len(), total0, "no growth — same child reused");
4533    }
4534
4535    /// Re-homes a node returned from its `build()` under itself.
4536    #[derive(Debug)]
4537    struct Wrapper {
4538        child: WidgetId,
4539    }
4540    impl Widget for Wrapper {
4541        fn build(&mut self, _ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4542            vec![self.child]
4543        }
4544        fn layout_response(
4545            &self,
4546            p: SizeProposal,
4547            _c: &LayoutContext,
4548        ) -> crate::widget::LayoutResponse {
4549            p.resolve(10.0, 10.0).into()
4550        }
4551    }
4552
4553    /// Memoizes a body, then wraps it in a FRESH `Wrapper` each build —
4554    /// re-parenting the body out of the previous (now dropped) wrapper. This is
4555    /// the TabWidget / CompositeTooltip pattern in miniature.
4556    #[derive(Debug)]
4557    struct ReparentHost {
4558        body: Option<WidgetId>,
4559        probe: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
4560    }
4561    impl Widget for ReparentHost {
4562        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4563            let body = match self.body {
4564                Some(id) => id,
4565                None => {
4566                    let id = ctx.add(FillWidget::new());
4567                    self.body = Some(id);
4568                    self.probe.set(Some(id));
4569                    id
4570                }
4571            };
4572            vec![ctx.add(Wrapper { child: body })]
4573        }
4574        fn layout_response(
4575            &self,
4576            p: SizeProposal,
4577            _c: &LayoutContext,
4578        ) -> crate::widget::LayoutResponse {
4579            p.resolve(10.0, 10.0).into()
4580        }
4581        fn preserves_children_on_rebuild(&self) -> bool {
4582            true
4583        }
4584    }
4585
4586    #[test]
4587    fn reconcile_spares_reparented_survivor() {
4588        // The memoized body is re-parented into a fresh wrapper each rebuild;
4589        // the old wrapper is dropped. The body must survive (it is re-homed),
4590        // and the old wrappers must be reaped (no leak). This is the exact
4591        // failure that destroyed TabWidget's static panel before the fix: the
4592        // parent-authoritative recursion + single-node arena removal spare the
4593        // re-homed body while still reaping the dropped wrapper subtree.
4594        let probe = std::rc::Rc::new(std::cell::Cell::new(None));
4595        let mut tree = WidgetTree::new();
4596        let host = tree.add(ReparentHost {
4597            body: None,
4598            probe: probe.clone(),
4599        });
4600        tree.layout(SizeProposal::exact(100.0, 100.0));
4601        let body = probe.get().expect("body mounted");
4602        let total0 = tree.arena.len();
4603        for _ in 0..5 {
4604            tree.arena_mark_needs_rebuild_for_testing(host);
4605            tree.layout(SizeProposal::exact(100.0, 100.0));
4606        }
4607        assert!(
4608            tree.arena.is_active(body),
4609            "the re-parented body must survive — it was moved into the new tree, \
4610             not swept with the dropped wrapper"
4611        );
4612        assert_eq!(
4613            tree.arena.len(),
4614            total0,
4615            "dropped wrappers reaped — no per-rebuild leak"
4616        );
4617    }
4618
4619    // -----------------------------------------------------------------
4620    // EventContext::ensure_visible / ensure_widget_visible — the
4621    // rect/id-based outer-scroll chase drained in `collect_from_ctx`.
4622    // -----------------------------------------------------------------
4623
4624    /// A `clips_children` container that places its single child at a fixed
4625    /// vertical offset — used to give a child arena bounds *outside* the
4626    /// container's viewport so the id-based `ensure_widget_visible` walk has a
4627    /// reason to dispatch `ScrollIntoView`.
4628    #[derive(Debug)]
4629    struct BelowContainer {
4630        child: Option<WidgetId>,
4631        offset: f32,
4632    }
4633
4634    impl crate::widget::Widget for BelowContainer {
4635        fn layout_response(
4636            &self,
4637            proposal: SizeProposal,
4638            _ctx: &crate::widget::LayoutContext,
4639        ) -> crate::widget::LayoutResponse {
4640            proposal.resolve(0.0, 0.0).into()
4641        }
4642        fn place_children(
4643            &self,
4644            bounds: Rect,
4645            _proposal: SizeProposal,
4646            children: &mut [crate::widget::WidgetPlacement],
4647            _ctx: &crate::widget::LayoutContext,
4648        ) {
4649            for c in children.iter_mut() {
4650                c.origin = Point::new(bounds.x, bounds.y + self.offset);
4651                c.size = bounds.size();
4652            }
4653        }
4654        fn children(&self) -> Vec<WidgetId> {
4655            self.child.into_iter().collect()
4656        }
4657    }
4658
4659    /// A `clips_children` container that records the `ScrollIntoView` it
4660    /// receives, so a test can assert what the framework dispatched to it.
4661    fn recording_scroll_container(
4662        tree: &mut WidgetTree,
4663        child: WidgetId,
4664        recorded: std::rc::Rc<std::cell::Cell<Option<Rect>>>,
4665    ) -> WidgetId {
4666        use crate::test_widgets::StackWidget;
4667        tree.add(
4668            StackWidget::new()
4669                .add_child(child)
4670                .on_scroll(move |ev, _ctx| match ev {
4671                    WidgetEvent::ScrollIntoView { target_bounds, .. } => {
4672                        recorded.set(Some(*target_bounds));
4673                        EventResponse::Handled
4674                    }
4675                    _ => EventResponse::Ignored,
4676                })
4677                .clips_children(true),
4678        )
4679    }
4680
4681    #[test]
4682    fn ensure_visible_dispatches_scroll_into_view_to_clipping_ancestor() {
4683        use std::cell::Cell;
4684        use std::rc::Rc;
4685        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
4686        let mut tree = WidgetTree::new();
4687        let actor = tree.add(FillWidget::new());
4688        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
4689        tree.layout(SizeProposal::exact(100.0, 100.0));
4690
4691        // A rect well below the 100px viewport — the container must be asked to
4692        // reveal it.
4693        let target = Rect::new(10.0, 500.0, 20.0, 15.0);
4694        let mut ctx = EventContext::new();
4695        ctx.ensure_visible(target);
4696        tree.collect_from_ctx(ctx, actor);
4697
4698        assert_eq!(
4699            recorded.get(),
4700            Some(target),
4701            "ensure_visible(rect) must dispatch ScrollIntoView with the exact rect \
4702             to the clips_children ancestor"
4703        );
4704    }
4705
4706    #[test]
4707    fn ensure_visible_is_noop_when_rect_already_visible() {
4708        use std::cell::Cell;
4709        use std::rc::Rc;
4710        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
4711        let mut tree = WidgetTree::new();
4712        let actor = tree.add(FillWidget::new());
4713        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
4714        tree.layout(SizeProposal::exact(100.0, 100.0));
4715
4716        // Fully inside the viewport → the ancestor already shows it, so no
4717        // ScrollIntoView is dispatched.
4718        let mut ctx = EventContext::new();
4719        ctx.ensure_visible(Rect::new(10.0, 10.0, 20.0, 15.0));
4720        tree.collect_from_ctx(ctx, actor);
4721
4722        assert_eq!(
4723            recorded.get(),
4724            None,
4725            "a rect already inside the viewport must not trigger a scroll"
4726        );
4727    }
4728
4729    #[test]
4730    fn ensure_visible_margin_forces_scroll_near_edge() {
4731        use std::cell::Cell;
4732        use std::rc::Rc;
4733        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
4734        let mut tree = WidgetTree::new();
4735        let actor = tree.add(FillWidget::new());
4736        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
4737        tree.layout(SizeProposal::exact(100.0, 100.0));
4738
4739        // Rect at y=95..99 is visible at margin 0, but with a 10px margin its
4740        // padded bottom (109) spills past the 100px viewport → scroll.
4741        let rect = Rect::new(10.0, 95.0, 20.0, 4.0);
4742        let mut ctx = EventContext::new();
4743        ctx.ensure_visible_with_margin(rect, 10.0);
4744        tree.collect_from_ctx(ctx, actor);
4745
4746        assert_eq!(
4747            recorded.get(),
4748            Some(rect),
4749            "the margin must widen the visibility test so a near-edge rect scrolls"
4750        );
4751    }
4752
4753    /// A `clips_children` container that records the alignment and motion of the
4754    /// `ScrollIntoView` it receives.
4755    fn recording_align_container(
4756        tree: &mut WidgetTree,
4757        child: WidgetId,
4758        recorded: std::rc::Rc<
4759            std::cell::Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>,
4760        >,
4761    ) -> WidgetId {
4762        use crate::test_widgets::StackWidget;
4763        tree.add(
4764            StackWidget::new()
4765                .add_child(child)
4766                .on_scroll(move |ev, _ctx| match ev {
4767                    WidgetEvent::ScrollIntoView { align, motion, .. } => {
4768                        recorded.set(Some((*align, *motion)));
4769                        EventResponse::Handled
4770                    }
4771                    _ => EventResponse::Ignored,
4772                })
4773                .clips_children(true),
4774        )
4775    }
4776
4777    #[test]
4778    fn ensure_visible_aligned_scrolls_even_when_already_visible() {
4779        use std::cell::Cell;
4780        use std::rc::Rc;
4781        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
4782        let mut tree = WidgetTree::new();
4783        let actor = tree.add(FillWidget::new());
4784        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
4785        tree.layout(SizeProposal::exact(100.0, 100.0));
4786
4787        // Comfortably inside the viewport — a *minimal* reveal would decline
4788        // (see `ensure_visible_is_noop_when_rect_already_visible`). A pin must
4789        // still fire: re-asserting unconditionally is the whole difference
4790        // between "keep it on screen" and "hold it at this height".
4791        let target = Rect::new(10.0, 10.0, 20.0, 15.0);
4792        let mut ctx = EventContext::new();
4793        ctx.ensure_visible_aligned(target, 0.5, crate::event::ScrollMotion::Instant);
4794        tree.collect_from_ctx(ctx, actor);
4795
4796        assert_eq!(
4797            recorded.get(),
4798            Some(target),
4799            "an aligned reveal must dispatch even when the rect is already visible"
4800        );
4801    }
4802
4803    #[test]
4804    fn ensure_visible_aligned_forwards_fraction_and_motion() {
4805        use std::cell::Cell;
4806        use std::rc::Rc;
4807        let recorded: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
4808            Rc::new(Cell::new(None));
4809        let mut tree = WidgetTree::new();
4810        let actor = tree.add(FillWidget::new());
4811        let _container = recording_align_container(&mut tree, actor, recorded.clone());
4812        tree.layout(SizeProposal::exact(100.0, 100.0));
4813
4814        let mut ctx = EventContext::new();
4815        ctx.ensure_visible_aligned(
4816            Rect::new(10.0, 10.0, 20.0, 15.0),
4817            0.25,
4818            crate::event::ScrollMotion::Smooth,
4819        );
4820        tree.collect_from_ctx(ctx, actor);
4821
4822        assert_eq!(
4823            recorded.get(),
4824            Some((
4825                crate::event::ScrollAlign::Fraction(0.25),
4826                crate::event::ScrollMotion::Smooth
4827            )),
4828            "the container must receive the requested fraction and motion verbatim"
4829        );
4830    }
4831
4832    #[test]
4833    fn ensure_visible_aligned_clamps_the_fraction() {
4834        use std::cell::Cell;
4835        use std::rc::Rc;
4836        let recorded: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
4837            Rc::new(Cell::new(None));
4838        let mut tree = WidgetTree::new();
4839        let actor = tree.add(FillWidget::new());
4840        let _container = recording_align_container(&mut tree, actor, recorded.clone());
4841        tree.layout(SizeProposal::exact(100.0, 100.0));
4842
4843        let mut ctx = EventContext::new();
4844        ctx.ensure_visible_aligned(
4845            Rect::new(10.0, 10.0, 20.0, 15.0),
4846            4.2,
4847            crate::event::ScrollMotion::Instant,
4848        );
4849        tree.collect_from_ctx(ctx, actor);
4850
4851        assert_eq!(
4852            recorded.get().map(|(a, _)| a),
4853            Some(crate::event::ScrollAlign::Fraction(1.0)),
4854            "an out-of-range fraction must clamp rather than aim the pin off-screen"
4855        );
4856    }
4857
4858    #[test]
4859    fn plain_ensure_visible_requests_minimal_alignment() {
4860        use std::cell::Cell;
4861        use std::rc::Rc;
4862        let recorded: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
4863            Rc::new(Cell::new(None));
4864        let mut tree = WidgetTree::new();
4865        let actor = tree.add(FillWidget::new());
4866        let _container = recording_align_container(&mut tree, actor, recorded.clone());
4867        tree.layout(SizeProposal::exact(100.0, 100.0));
4868
4869        let mut ctx = EventContext::new();
4870        ctx.ensure_visible(Rect::new(10.0, 500.0, 20.0, 15.0));
4871        tree.collect_from_ctx(ctx, actor);
4872
4873        assert_eq!(
4874            recorded.get(),
4875            Some((
4876                crate::event::ScrollAlign::Minimal,
4877                crate::event::ScrollMotion::Instant
4878            )),
4879            "the pre-existing reveal API must keep its exact semantics"
4880        );
4881    }
4882
4883    #[test]
4884    fn only_the_innermost_container_aligns() {
4885        use std::cell::Cell;
4886        use std::rc::Rc;
4887        let inner_rec: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
4888            Rc::new(Cell::new(None));
4889        let outer_rec: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
4890            Rc::new(Cell::new(None));
4891
4892        let mut tree = WidgetTree::new();
4893        let actor = tree.add(FillWidget::new());
4894        let inner = recording_align_container(&mut tree, actor, inner_rec.clone());
4895        let _outer = recording_align_container(&mut tree, inner, outer_rec.clone());
4896        tree.layout(SizeProposal::exact(100.0, 100.0));
4897
4898        // Off-screen, so the outer container is asked too (a `Minimal` request
4899        // is gated on visibility).
4900        let mut ctx = EventContext::new();
4901        ctx.ensure_visible_aligned(
4902            Rect::new(10.0, 500.0, 20.0, 15.0),
4903            0.5,
4904            crate::event::ScrollMotion::Instant,
4905        );
4906        tree.collect_from_ctx(ctx, actor);
4907
4908        assert_eq!(
4909            inner_rec.get().map(|(a, _)| a),
4910            Some(crate::event::ScrollAlign::Fraction(0.5)),
4911            "the innermost clipping ancestor owns the pin"
4912        );
4913        assert_eq!(
4914            outer_rec.get().map(|(a, _)| a),
4915            Some(crate::event::ScrollAlign::Minimal),
4916            "an outer container must only bring the inner viewport into view — a \
4917             fraction names a height in one viewport, not in every ancestor's"
4918        );
4919    }
4920
4921    #[test]
4922    fn ensure_widget_visible_uses_target_arena_bounds() {
4923        use std::cell::Cell;
4924        use std::rc::Rc;
4925        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
4926        let mut tree = WidgetTree::new();
4927        // Target lives 500px below the container's top — off the viewport.
4928        let target = tree.add(FillWidget::new());
4929        let rec = recorded.clone();
4930        let container = tree.add(
4931            BelowContainer {
4932                child: Some(target),
4933                offset: 500.0,
4934            }
4935            .on_scroll(move |ev, _ctx| match ev {
4936                WidgetEvent::ScrollIntoView { target_bounds, .. } => {
4937                    rec.set(Some(*target_bounds));
4938                    EventResponse::Handled
4939                }
4940                _ => EventResponse::Ignored,
4941            })
4942            .clips_children(true),
4943        );
4944        tree.layout(SizeProposal::exact(100.0, 100.0));
4945
4946        let expected = tree.bounds(target);
4947        assert!(
4948            expected.y > 100.0,
4949            "fixture sanity: the target must sit below the viewport (y={})",
4950            expected.y
4951        );
4952
4953        // The source widget is irrelevant for the id-based walk — it starts
4954        // from the *target's* parent — so pass the container itself.
4955        let mut ctx = EventContext::new();
4956        ctx.ensure_widget_visible(target);
4957        tree.collect_from_ctx(ctx, container);
4958
4959        assert_eq!(
4960            recorded.get(),
4961            Some(expected),
4962            "ensure_widget_visible(id) must dispatch ScrollIntoView with the \
4963             target's current arena bounds"
4964        );
4965    }
4966
4967    #[test]
4968    fn ensure_widget_visible_ignores_missing_widget() {
4969        // A never-mounted id must neither panic nor dispatch a spurious scroll.
4970        use std::cell::Cell;
4971        use std::rc::Rc;
4972        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
4973        let mut tree = WidgetTree::new();
4974        let actor = tree.add(FillWidget::new());
4975        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
4976        tree.layout(SizeProposal::exact(100.0, 100.0));
4977
4978        let mut ctx = EventContext::new();
4979        ctx.ensure_widget_visible(WidgetId::default());
4980        tree.collect_from_ctx(ctx, actor); // must not panic
4981
4982        assert_eq!(
4983            recorded.get(),
4984            None,
4985            "an unmounted id must not trigger a scroll"
4986        );
4987    }
4988
4989    #[test]
4990    fn context_menu_inside_a_modal_keeps_the_modal() {
4991        // Regression: right-clicking a widget that lives inside an open modal must
4992        // open its context menu WITHOUT tearing down the modal. `show_context_menu_for`
4993        // used to `dismiss_all()`, which closed the very overlay hosting the editor.
4994        use crate::event::{Modifiers, PointerButton, WidgetEvent};
4995        use crate::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
4996        use crate::test_widgets::{FillWidget, StackWidget};
4997
4998        let mut tree = WidgetTree::new();
4999        // A container standing in for the modal's content subtree, with the editor
5000        // (a right-clickable widget) inside it.
5001        let modal_content = tree.add(StackWidget::new());
5002        let _editor = tree.add_child(
5003            modal_content,
5004            FillWidget::new()
5005                .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
5006        );
5007        tree.layout(SizeProposal::exact(200.0, 100.0));
5008
5009        let modal = tree.overlay_manager.show(OverlayRequest {
5010            content_id: modal_content,
5011            anchor: modal_content,
5012            placement: OverlayPlacement::Centered,
5013            dismiss: DismissBehavior::EscapeKey,
5014            layer: OverlayLayer::InTree,
5015            parent_overlay: None,
5016            on_dismiss: None,
5017            fade_duration: None,
5018        });
5019        // Give the overlay real bounds so the right-click hit-tests inside it.
5020        tree.overlay_manager
5021            .stack
5022            .iter_mut()
5023            .find(|o| o.id == modal)
5024            .unwrap()
5025            .bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
5026        assert_eq!(tree.overlay_manager.len(), 1);
5027
5028        // Right-click the editor inside the modal.
5029        tree.dispatch_event(WidgetEvent::PointerDown {
5030            position: Point::new(50.0, 25.0),
5031            button: PointerButton::Secondary,
5032            modifiers: Modifiers::NONE,
5033        });
5034
5035        assert!(
5036            tree.overlay_manager.active_ids().contains(&modal),
5037            "the modal must survive opening a context menu inside it"
5038        );
5039        assert_eq!(
5040            tree.overlay_manager.len(),
5041            2,
5042            "the context menu should now be open on top of the surviving modal"
5043        );
5044    }
5045}