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