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