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::WithWidgetMut { id, dirty, apply } => {
2114                    // Run the typed mutation while `&mut arena` is live, then
2115                    // drop the borrow before dirty-marking (the `mark_*` calls
2116                    // re-borrow the arena). Only dirty-mark a live node so we
2117                    // never call `mark_ancestors_need_layout` on a destroyed id.
2118                    let existed = if let Some(any) =
2119                        self.arena.get_mut(id).and_then(|n| n.widget.as_any_mut())
2120                    {
2121                        apply(any);
2122                        true
2123                    } else {
2124                        false
2125                    };
2126                    if existed {
2127                        match dirty {
2128                            BindingLevel::RepaintOnly => self.arena.mark_needs_paint(id),
2129                            BindingLevel::SubtreeRepaint => self.arena.mark_subtree_needs_paint(id),
2130                            BindingLevel::Relayout => {
2131                                self.arena.mark_needs_layout(id);
2132                                self.arena.mark_ancestors_need_layout(id);
2133                            }
2134                            BindingLevel::Rebuild => {
2135                                self.arena.mark_needs_rebuild(id);
2136                                self.arena.mark_ancestors_need_layout(id);
2137                            }
2138                            BindingLevel::AccessibilityOnly => self.a11y_dirty = true,
2139                        }
2140                    }
2141                }
2142            }
2143        }
2144    }
2145
2146    pub fn hit_test(&self, point: Point) -> Option<WidgetId> {
2147        self.hit_test_excluding_overlay_and_widget(point, None, None)
2148    }
2149
2150    /// Hit-test at a point, excluding a specific overlay and widget from consideration.
2151    /// Used during drag-and-drop to exclude the preview overlay and its content widget,
2152    /// so they don't block hit-testing of the actual drop targets underneath.
2153    pub fn hit_test_excluding_overlay_and_widget(
2154        &self,
2155        point: Point,
2156        exclude_overlay: Option<crate::overlay::OverlayId>,
2157        exclude_widget: Option<WidgetId>,
2158    ) -> Option<WidgetId> {
2159        if let Some(overlay_id) = self.overlay_manager.hit_test(point) {
2160            if Some(overlay_id) == exclude_overlay {
2161                // Skip this excluded overlay, fall through to widget tree
2162            } else if let Some(overlay) = self.overlay_manager.overlay(overlay_id) {
2163                return self.arena.hit_test_in_subtree_excluding(
2164                    overlay.content_id,
2165                    point,
2166                    exclude_widget,
2167                );
2168            }
2169        }
2170
2171        if self.overlay_manager.topmost_centered().is_some() {
2172            return None;
2173        }
2174
2175        // Delegates to WidgetArena::hit_test_at, which honors
2176        // event_pass_through and clips_children correctly.
2177        self.arena.hit_test_at(point, exclude_widget)
2178    }
2179}
2180
2181/// Whether this keystroke is one of the chords that ask for a context menu.
2182///
2183/// Three routes, because no single one exists on every platform:
2184///
2185/// * **The dedicated key.** `VK_APPS` on Windows, `keysyms::Menu` on X11 and
2186///   Wayland. `winit-0.30.13`'s AppKit backend references
2187///   `NamedKey::ContextMenu` zero times, so macOS never produces it.
2188/// * **Shift+F10.** The convention Windows, GTK and Qt all honour, and the one
2189///   thing a Windows or Linux keyboard without a Menu key can still reach.
2190/// * **Ctrl+Shift+M on macOS.** Neither of the above is available there: Mac
2191///   keyboards have no Menu key, and F10 is a media key under the default
2192///   "Use F1, F2 etc. as standard function keys = off" setting, so Shift+F10
2193///   may never arrive as F10 at all. Kept off the other platforms, where
2194///   Ctrl+Shift+M is a plausible application binding.
2195///
2196/// Modifiers are matched exactly. Shift+F10 with Ctrl held is a different
2197/// gesture and must reach the application unchanged.
2198fn is_context_menu_chord(key: Key, modifiers: Modifiers) -> bool {
2199    match key {
2200        Key::ContextMenu => modifiers == Modifiers::NONE,
2201        Key::F10 => modifiers == Modifiers::SHIFT,
2202        #[cfg(target_os = "macos")]
2203        Key::M => modifiers == Modifiers::CTRL | Modifiers::SHIFT,
2204        _ => false,
2205    }
2206}
2207
2208#[cfg(test)]
2209mod tests {
2210    use super::*;
2211    use crate::test_widgets::FillWidget;
2212    use crate::widget::CursorIcon;
2213    use crate::widget_builder::WidgetBuilder;
2214
2215    #[test]
2216    fn pointer_enter_leave_synthesized() {
2217        let mut tree = WidgetTree::new();
2218        let widget = tree.add(FillWidget::new());
2219        tree.layout(SizeProposal::exact(100.0, 50.0));
2220        tree.pointer_move(Point::new(50.0, 25.0));
2221        assert_eq!(tree.hovered, Some(widget));
2222        tree.pointer_move(Point::new(200.0, 200.0));
2223        assert_eq!(tree.hovered, None);
2224    }
2225
2226    #[test]
2227    fn pointer_hover_updates_current_cursor() {
2228        let mut tree = WidgetTree::new();
2229        tree.add(FillWidget::new().cursor(CursorIcon::ColResize));
2230        tree.layout(SizeProposal::exact(100.0, 50.0));
2231
2232        tree.pointer_move(Point::new(50.0, 25.0));
2233        assert_eq!(tree.current_cursor(), CursorIcon::ColResize);
2234
2235        tree.pointer_move(Point::new(200.0, 200.0));
2236        assert_eq!(tree.current_cursor(), CursorIcon::Default);
2237    }
2238
2239    // A leaf that opts into typed introspection, so `with_widget_mut` /
2240    // `widget_as_any(_mut)` can reach it (the default `as_any_mut` is `None`).
2241    #[derive(Debug)]
2242    struct Bumpable {
2243        value: i32,
2244    }
2245
2246    impl crate::widget::Widget for Bumpable {
2247        fn layout_response(
2248            &self,
2249            proposal: SizeProposal,
2250            _ctx: &crate::widget::LayoutContext,
2251        ) -> crate::widget::LayoutResponse {
2252            proposal.resolve(10.0, 10.0).into()
2253        }
2254        fn as_any(&self) -> Option<&dyn std::any::Any> {
2255            Some(self)
2256        }
2257        fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
2258            Some(self)
2259        }
2260    }
2261
2262    #[test]
2263    fn with_widget_mut_applies_and_dirty_marks() {
2264        let mut tree = WidgetTree::new();
2265        let id = tree.add(Bumpable { value: 0 });
2266        tree.layout(SizeProposal::exact(100.0, 100.0));
2267
2268        let mut ctx = EventContext::new();
2269        ctx.with_widget_mut::<Bumpable>(id, crate::binding::BindingLevel::Relayout, |b| {
2270            b.value = 42;
2271        });
2272        tree.collect_from_ctx(ctx, id);
2273
2274        let value = tree
2275            .widget_as_any(id)
2276            .and_then(|a| a.downcast_ref::<Bumpable>())
2277            .map(|b| b.value);
2278        assert_eq!(
2279            value,
2280            Some(42),
2281            "the deferred closure must mutate the live widget"
2282        );
2283        assert!(
2284            tree.needs_layout(),
2285            "Relayout dirty level must mark the tree for relayout"
2286        );
2287    }
2288
2289    #[test]
2290    #[cfg(debug_assertions)]
2291    #[should_panic(expected = "not the requested type")]
2292    fn with_widget_mut_wrong_type_panics_in_debug() {
2293        struct Other;
2294        let mut tree = WidgetTree::new();
2295        let id = tree.add(Bumpable { value: 0 });
2296        let mut ctx = EventContext::new();
2297        ctx.with_widget_mut::<Other>(
2298            id,
2299            crate::binding::BindingLevel::RepaintOnly,
2300            |_o: &mut Other| {},
2301        );
2302        // Bumpable opts into as_any_mut, so the closure runs and the
2303        // wrong-type downcast trips the debug_assert.
2304        tree.collect_from_ctx(ctx, id);
2305    }
2306
2307    #[test]
2308    fn with_widget_mut_closure_may_fire_observed_signals() {
2309        // Reentrancy guard. The closure runs inside `apply_tree_mutations`
2310        // while the target arena node is mutably borrowed. If it fires a
2311        // `Signal` whose observer sets *another* signal — the exact
2312        // `SceneView` shape (`item_change_signal` → bump `reconcile_dirty`) —
2313        // nothing may double-borrow the arena. The arena borrow is scoped to
2314        // the closure call and dropped before dirty-marking; signal/observer
2315        // work touches the binding registry, not the arena.
2316        use crate::signal::Signal;
2317        let mut tree = WidgetTree::new();
2318        let id = tree.add(Bumpable { value: 0 });
2319        tree.layout(SizeProposal::exact(100.0, 100.0));
2320
2321        let trigger = Signal::new(0_u64);
2322        let echo = Signal::new(0_u64);
2323        let echo_for_obs = echo.clone();
2324        let _obs = trigger.observe(move |v| echo_for_obs.set(*v));
2325
2326        let trigger_in = trigger.clone();
2327        let mut ctx = EventContext::new();
2328        ctx.with_widget_mut::<Bumpable>(id, crate::binding::BindingLevel::RepaintOnly, move |b| {
2329            b.value = 7;
2330            // Fires `_obs` synchronously, mid-deferred-apply.
2331            trigger_in.set(99);
2332        });
2333        tree.collect_from_ctx(ctx, id); // must not panic / double-borrow
2334
2335        assert_eq!(
2336            echo.get(),
2337            99,
2338            "the observer ran during the deferred mutation"
2339        );
2340        let value = tree
2341            .widget_as_any(id)
2342            .and_then(|a| a.downcast_ref::<Bumpable>())
2343            .map(|b| b.value);
2344        assert_eq!(value, Some(7));
2345    }
2346
2347    #[test]
2348    fn request_accessibility_update_forces_rewalk() {
2349        let mut tree = WidgetTree::new();
2350        let id = tree.add(Bumpable { value: 0 });
2351        tree.layout(SizeProposal::exact(100.0, 100.0));
2352        let _ = tree.sync_accessibility(); // populate cache, clears a11y_dirty
2353        assert!(
2354            !tree.a11y_dirty,
2355            "sync_accessibility should clear the dirty flag"
2356        );
2357
2358        let mut ctx = EventContext::new();
2359        ctx.request_accessibility_update();
2360        tree.collect_from_ctx(ctx, id);
2361        assert!(
2362            tree.a11y_dirty,
2363            "request_accessibility_update must force an AT re-walk"
2364        );
2365    }
2366
2367    #[test]
2368    fn rebuild_dirties_accessibility_tree() {
2369        // Regression for audit Blocker G1: every `BindingLevel::Rebuild`
2370        // consumer (ListView / TreeView / TableView / ComboBox / Calendar /
2371        // DockingLayout / ...) tears down and re-creates its subtree on an
2372        // ordinary model change, allocating fresh WidgetIds and changing the
2373        // AccessKit tree shape. That pass must dirty the cached AT snapshot,
2374        // or screen readers keep reading the pre-mutation tree indefinitely.
2375        let mut tree = WidgetTree::new();
2376        let id = tree.add(Bumpable { value: 0 });
2377        tree.layout(SizeProposal::exact(100.0, 100.0));
2378        let _ = tree.sync_accessibility(); // populate cache, clears a11y_dirty
2379        assert!(
2380            !tree.a11y_dirty,
2381            "sync_accessibility should clear the dirty flag"
2382        );
2383
2384        // Marking for rebuild is exactly what a Rebuild-level binding does;
2385        // the following layout pass drains pending rebuilds.
2386        tree.arena_mark_needs_rebuild_for_testing(id);
2387        tree.layout(SizeProposal::exact(100.0, 100.0));
2388        assert!(
2389            tree.a11y_dirty,
2390            "a rebuild must dirty the AT tree so the next sync re-walks"
2391        );
2392    }
2393
2394    #[test]
2395    fn bound_access_label_change_dirties_accessibility_tree() {
2396        use crate::signal::Signal;
2397        use crate::test_widgets::FillWidget;
2398        use crate::widget_builder::WidgetBuilder;
2399
2400        // Regression for audit G15: a reactive `.access_label(signal)` (and
2401        // likewise description / value) must register at AccessibilityOnly so
2402        // changing the signal re-walks the AT tree and re-resolves the
2403        // announced name. Previously only `access_hidden` was registered, so
2404        // label / description / value updates were invisible to screen readers.
2405        let label = Signal::new("first".to_string());
2406        let mut tree = WidgetTree::new();
2407        let _id = tree.add(FillWidget::new().access_label(label.clone()));
2408        tree.layout(SizeProposal::exact(100.0, 100.0));
2409        let _ = tree.sync_accessibility(); // populate cache, clears a11y_dirty
2410        assert!(!tree.a11y_dirty, "sync_accessibility should clear the flag");
2411
2412        label.set("second".to_string());
2413        tree.layout(SizeProposal::exact(100.0, 100.0));
2414        assert!(
2415            tree.a11y_dirty,
2416            "changing a bound access_label must dirty the AT tree"
2417        );
2418    }
2419
2420    #[test]
2421    fn disabled_ancestor_blocks_event_to_descendant() {
2422        use crate::signal::Signal;
2423        use crate::test_widgets::StackWidget;
2424        use std::cell::Cell;
2425        use std::rc::Rc;
2426
2427        let tapped = Rc::new(Cell::new(false));
2428        let flag = tapped.clone();
2429        let enabled = Signal::new(true);
2430
2431        let mut tree = WidgetTree::new();
2432        let child = tree.add(FillWidget::new().on_tap(move |_pos, _ctx| {
2433            flag.set(true);
2434        }));
2435        let parent = tree.add(StackWidget::new().add_child(child));
2436        tree.enabled_when(parent, enabled.clone());
2437        tree.layout(SizeProposal::exact(100.0, 50.0));
2438
2439        enabled.set(false);
2440        tree.click(child);
2441        assert!(
2442            !tapped.get(),
2443            "disabled ancestor should block descendant tap"
2444        );
2445
2446        enabled.set(true);
2447        tree.click(child);
2448        assert!(tapped.get(), "re-enabling should restore dispatch");
2449    }
2450
2451    #[test]
2452    fn pointer_positions_are_widget_local_at_nonzero_origin() {
2453        use crate::event::{Modifiers, PointerButton};
2454        use crate::test_widgets::InsetWidget;
2455        use std::cell::Cell;
2456        use std::rc::Rc;
2457
2458        // A 20px inset places the child at window origin (20, 20).
2459        let tap_pos: Rc<Cell<Option<Point>>> = Rc::new(Cell::new(None));
2460        let down_pos: Rc<Cell<Option<Point>>> = Rc::new(Cell::new(None));
2461        let drag_pos: Rc<Cell<Option<Point>>> = Rc::new(Cell::new(None));
2462        let (tp, dp, gp) = (tap_pos.clone(), down_pos.clone(), drag_pos.clone());
2463
2464        let mut tree = WidgetTree::new();
2465        let child = tree.add(
2466            FillWidget::new()
2467                .on_tap(move |ev, _ctx| tp.set(Some(ev.position)))
2468                .on_pointer_event(move |ev, _ctx| {
2469                    if let WidgetEvent::PointerDown { position, .. } = ev {
2470                        dp.set(Some(*position));
2471                    }
2472                    crate::event::EventResponse::Ignored
2473                })
2474                .on_drag(move |phase, _ctx| {
2475                    use crate::gesture::DragPhase;
2476                    match phase {
2477                        DragPhase::Started { position, .. }
2478                        | DragPhase::Moved { position, .. }
2479                        | DragPhase::Ended { position } => gp.set(Some(position)),
2480                    }
2481                }),
2482        );
2483        let inset = tree.add(InsetWidget::new(20.0).set_child(child));
2484        let _ = inset;
2485        tree.layout(SizeProposal::exact(200.0, 200.0));
2486        assert_eq!(tree.bounds(child).origin(), Point::new(20.0, 20.0));
2487
2488        // A tap at window (50, 40) must reach the handler as local (30, 20).
2489        tree.dispatch_event(WidgetEvent::PointerDown {
2490            position: Point::new(50.0, 40.0),
2491            button: PointerButton::Primary,
2492            modifiers: Modifiers::NONE,
2493        });
2494        assert_eq!(
2495            down_pos.get(),
2496            Some(Point::new(30.0, 20.0)),
2497            "on_pointer_event PointerDown must be widget-local"
2498        );
2499        tree.dispatch_event(WidgetEvent::PointerUp {
2500            position: Point::new(50.0, 40.0),
2501            button: PointerButton::Primary,
2502            modifiers: Modifiers::NONE,
2503        });
2504        assert_eq!(
2505            tap_pos.get(),
2506            Some(Point::new(30.0, 20.0)),
2507            "on_tap position must be widget-local"
2508        );
2509
2510        // A drag (down then a move past the recognizer threshold) must
2511        // also deliver widget-local coordinates.
2512        tree.dispatch_event(WidgetEvent::PointerDown {
2513            position: Point::new(50.0, 40.0),
2514            button: PointerButton::Primary,
2515            modifiers: Modifiers::NONE,
2516        });
2517        // First move crosses the recognizer threshold (DragStarted);
2518        // the second reports a known DragMoved position.
2519        tree.dispatch_event(WidgetEvent::PointerMove {
2520            position: Point::new(65.0, 55.0),
2521        });
2522        tree.dispatch_event(WidgetEvent::PointerMove {
2523            position: Point::new(90.0, 70.0),
2524        });
2525        assert_eq!(
2526            drag_pos.get(),
2527            Some(Point::new(70.0, 50.0)),
2528            "on_drag position must be widget-local"
2529        );
2530    }
2531
2532    #[test]
2533    fn dormant_widget_not_hit_tested() {
2534        let mut tree = WidgetTree::new();
2535        let widget = tree.add(FillWidget::new());
2536        tree.layout(SizeProposal::exact(100.0, 50.0));
2537
2538        tree.pointer_move(Point::new(50.0, 25.0));
2539        assert_eq!(tree.hovered, Some(widget));
2540
2541        tree.set_dormant(widget);
2542        tree.pointer_move(Point::new(200.0, 200.0));
2543        tree.pointer_move(Point::new(50.0, 25.0));
2544        assert_eq!(tree.hovered, None);
2545    }
2546
2547    #[test]
2548    fn ancestor_pointer_handler_does_not_suppress_descendant_hover() {
2549        use crate::event::EventResponse;
2550        use crate::test_widgets::StackWidget;
2551        use std::cell::Cell;
2552        use std::rc::Rc;
2553
2554        // The child reports its own hover transitions via `on_hover`.
2555        let hovered = Rc::new(Cell::new(false));
2556        let h = hovered.clone();
2557
2558        let mut tree = WidgetTree::new();
2559        let child = tree.add(FillWidget::new().on_hover(move |entered, _ctx| h.set(entered)));
2560        // An ancestor whose `on_pointer_event` greedily claims everything it
2561        // previews — exactly the "drag-detecting ancestor" footgun. Before the
2562        // fix it consumed the descendant's `PointerEnter`/`Leave` in the
2563        // preview pass and the child's hover never fired.
2564        tree.add(
2565            StackWidget::new()
2566                .add_child(child)
2567                .on_pointer_event(|_event, _ctx| EventResponse::Handled),
2568        );
2569        tree.layout(SizeProposal::exact(100.0, 50.0));
2570
2571        tree.pointer_move(Point::new(50.0, 25.0));
2572        assert!(
2573            hovered.get(),
2574            "a greedy ancestor on_pointer_event must NOT swallow the child's PointerEnter"
2575        );
2576
2577        tree.pointer_move(Point::new(500.0, 500.0));
2578        assert!(
2579            !hovered.get(),
2580            "PointerLeave must likewise reach the child despite the ancestor"
2581        );
2582    }
2583
2584    /// **The other direction: a child must not swallow its ancestor's hover.**
2585    ///
2586    /// A row that reveals controls on hover puts interactive children inside
2587    /// itself, and the pointer leaves the row *through* one of them. The child's
2588    /// own `on_hover` used to handle the `PointerLeave` and stop the bubble there,
2589    /// so the row went on believing the pointer was still over it and kept its
2590    /// controls showing after the pointer had gone.
2591    #[test]
2592    fn a_child_hover_handler_does_not_swallow_its_ancestors() {
2593        use crate::test_widgets::StackWidget;
2594        use std::cell::Cell;
2595        use std::rc::Rc;
2596
2597        let (row, button) = (Rc::new(Cell::new(false)), Rc::new(Cell::new(false)));
2598        let (r, b) = (row.clone(), button.clone());
2599
2600        let mut tree = WidgetTree::new();
2601        let child = tree.add(FillWidget::new().on_hover(move |entered, _ctx| b.set(entered)));
2602        tree.add(
2603            StackWidget::new()
2604                .add_child(child)
2605                .on_hover(move |entered, _ctx| r.set(entered)),
2606        );
2607        tree.layout(SizeProposal::exact(100.0, 50.0));
2608
2609        tree.pointer_move(Point::new(50.0, 25.0));
2610        assert!(button.get(), "the child is hovered");
2611        assert!(row.get(), "and so is the row it is inside");
2612
2613        tree.pointer_move(Point::new(500.0, 500.0));
2614        assert!(!button.get(), "the child heard the leave");
2615        assert!(
2616            !row.get(),
2617            "and so did the row — a container is not still hovered because the \
2618             pointer left it through a button"
2619        );
2620    }
2621
2622    #[test]
2623    fn destroy_subtree_clears_dangling_pointer_capture() {
2624        use crate::event::{EventResponse, Modifiers, PointerButton};
2625        use crate::test_widgets::StackWidget;
2626
2627        let mut tree = WidgetTree::new();
2628        let child = tree.add(FillWidget::new().on_pointer_event(|event, ctx| {
2629            if matches!(event, WidgetEvent::PointerDown { .. }) {
2630                ctx.capture_pointer();
2631            }
2632            EventResponse::Ignored
2633        }));
2634        let parent = tree.add(StackWidget::new().add_child(child));
2635        tree.layout(SizeProposal::exact(100.0, 50.0));
2636
2637        // A press inside the child captures the pointer to it.
2638        tree.dispatch_event(WidgetEvent::PointerDown {
2639            position: Point::new(50.0, 25.0),
2640            button: PointerButton::Primary,
2641            modifiers: Modifiers::NONE,
2642        });
2643        assert_eq!(
2644            tree.pointer_captured_by,
2645            Some(child),
2646            "PointerDown handler should have captured the pointer"
2647        );
2648
2649        // Tearing down the capturing subtree (e.g. mid-gesture rebuild) must
2650        // release the capture eagerly rather than leaving a dangling id that
2651        // swallows every later Move/Up until the next layout pass heals it.
2652        tree.destroy_subtree(parent);
2653        assert_eq!(
2654            tree.pointer_captured_by, None,
2655            "destroy_subtree must clear a capture anchored at a destroyed widget"
2656        );
2657    }
2658
2659    // NOTE: legacy `shortcut_intercepts_before_widget` test removed with
2660    // the ShortcutMap dispatch path. The new shortcut→intent interception
2661    // is built on top of `ShortcutRegistry` + `Action`.
2662
2663    // ── on_key_preview ──────────────────────────────────────────
2664
2665    #[test]
2666    fn key_preview_consumes_before_focused_on_key() {
2667        // root → mid → leaf (focused). Root consumes Enter via
2668        // on_key_preview; the leaf's on_key must NOT fire.
2669        use crate::event::EventResponse;
2670        use crate::test_widgets::StackWidget;
2671        use std::cell::Cell;
2672        use std::rc::Rc;
2673
2674        let leaf_fired = Rc::new(Cell::new(false));
2675        let leaf_flag = leaf_fired.clone();
2676        let preview_fired = Rc::new(Cell::new(false));
2677        let preview_flag = preview_fired.clone();
2678
2679        let mut tree = WidgetTree::new();
2680        let leaf = tree.add(FillWidget::new().focusable().on_key(move |event, _c| {
2681            // Only count KeyDown so the trailing KeyUp from
2682            // press_key doesn't trigger us spuriously.
2683            if matches!(event, WidgetEvent::KeyDown { .. }) {
2684                leaf_flag.set(true);
2685            }
2686            EventResponse::Handled
2687        }));
2688        let mid = tree.add(StackWidget::new().add_child(leaf));
2689        let _root =
2690            tree.add(StackWidget::new().add_child(mid).on_key_preview(
2691                move |event, _c| match event {
2692                    WidgetEvent::KeyDown {
2693                        key: Key::Enter, ..
2694                    } => {
2695                        preview_flag.set(true);
2696                        EventResponse::Handled
2697                    }
2698                    _ => EventResponse::Ignored,
2699                },
2700            ));
2701
2702        tree.layout(SizeProposal::exact(100.0, 50.0));
2703        tree.focus(leaf);
2704        tree.press_key(Key::Enter, Modifiers::NONE);
2705
2706        assert!(
2707            preview_fired.get(),
2708            "ancestor on_key_preview must fire for KeyDown on a focused descendant"
2709        );
2710        assert!(
2711            !leaf_fired.get(),
2712            "consuming the event in preview must prevent the focused widget's on_key from running"
2713        );
2714    }
2715
2716    #[test]
2717    fn key_preview_falls_through_when_returning_ignored() {
2718        // Same shape; this time the preview returns Ignored, so
2719        // the leaf's on_key must still fire.
2720        use crate::event::EventResponse;
2721        use crate::test_widgets::StackWidget;
2722        use std::cell::Cell;
2723        use std::rc::Rc;
2724
2725        let leaf_fired = Rc::new(Cell::new(false));
2726        let leaf_flag = leaf_fired.clone();
2727        let preview_fired = Rc::new(Cell::new(false));
2728        let preview_flag = preview_fired.clone();
2729
2730        let mut tree = WidgetTree::new();
2731        let leaf = tree.add(FillWidget::new().focusable().on_key(move |_e, _c| {
2732            leaf_flag.set(true);
2733            EventResponse::Handled
2734        }));
2735        let mid = tree.add(StackWidget::new().add_child(leaf));
2736        let _root = tree.add(StackWidget::new().add_child(mid).on_key_preview(
2737            move |_event, _c| {
2738                preview_flag.set(true);
2739                EventResponse::Ignored
2740            },
2741        ));
2742
2743        tree.layout(SizeProposal::exact(100.0, 50.0));
2744        tree.focus(leaf);
2745        tree.press_key(Key::Enter, Modifiers::NONE);
2746
2747        assert!(preview_fired.get(), "preview must always be invoked");
2748        assert!(
2749            leaf_fired.get(),
2750            "preview returning Ignored must not block the focused widget's on_key"
2751        );
2752    }
2753
2754    #[test]
2755    fn key_preview_excludes_focused_target_itself() {
2756        // Strict-ancestors-only: the focused widget's own
2757        // on_key_preview must NOT fire — the preview pass walks
2758        // strict ancestors only.
2759        use crate::event::EventResponse;
2760        use std::cell::Cell;
2761        use std::rc::Rc;
2762
2763        let preview_on_target = Rc::new(Cell::new(false));
2764        let pf = preview_on_target.clone();
2765
2766        let mut tree = WidgetTree::new();
2767        let leaf = tree.add(FillWidget::new().focusable().on_key_preview(move |_e, _c| {
2768            pf.set(true);
2769            EventResponse::Handled
2770        }));
2771        tree.layout(SizeProposal::exact(100.0, 50.0));
2772        tree.focus(leaf);
2773        tree.press_key(Key::Enter, Modifiers::NONE);
2774
2775        assert!(
2776            !preview_on_target.get(),
2777            "the focused widget itself must not see its own on_key_preview"
2778        );
2779    }
2780
2781    #[test]
2782    fn key_preview_root_to_target_order() {
2783        // Two ancestors with on_key_preview attached. The outer
2784        // (root-side) one must fire first; the closer one (still
2785        // ancestor of the focused leaf) fires second.
2786        use crate::event::EventResponse;
2787        use crate::test_widgets::StackWidget;
2788        use std::cell::RefCell;
2789        use std::rc::Rc;
2790
2791        let order: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
2792        let outer_log = order.clone();
2793        let inner_log = order.clone();
2794
2795        let mut tree = WidgetTree::new();
2796        let leaf = tree.add(FillWidget::new().focusable());
2797        let inner = tree.add(StackWidget::new().add_child(leaf).on_key_preview(
2798            move |event, _c| {
2799                if matches!(event, WidgetEvent::KeyDown { .. }) {
2800                    inner_log.borrow_mut().push("inner");
2801                }
2802                EventResponse::Ignored
2803            },
2804        ));
2805        let _outer = tree.add(StackWidget::new().add_child(inner).on_key_preview(
2806            move |event, _c| {
2807                if matches!(event, WidgetEvent::KeyDown { .. }) {
2808                    outer_log.borrow_mut().push("outer");
2809                }
2810                EventResponse::Ignored
2811            },
2812        ));
2813
2814        tree.layout(SizeProposal::exact(100.0, 50.0));
2815        tree.focus(leaf);
2816        tree.dispatch_event(WidgetEvent::KeyDown {
2817            key: Key::Enter,
2818            modifiers: Modifiers::NONE,
2819            text: None,
2820        });
2821
2822        assert_eq!(
2823            *order.borrow(),
2824            vec!["outer", "inner"],
2825            "preview must walk root → parent-of-target"
2826        );
2827    }
2828
2829    #[test]
2830    fn access_action_routes_to_cursored_target_not_focus() {
2831        // VoiceOver's VO+Space targets the node under the AT cursor (`b`),
2832        // even when keyboard focus is on a different control (`a`). The action
2833        // must fire on `b`, never get redirected to the focused `a`.
2834        use crate::signal::Signal;
2835        let a_fired = Signal::new(false);
2836        let b_fired = Signal::new(false);
2837        let a_cb = a_fired.clone();
2838        let b_cb = b_fired.clone();
2839
2840        let mut tree = WidgetTree::new();
2841        let a = tree.add(
2842            FillWidget::new().access_action(accesskit::Action::Click, move |_ctx| a_cb.set(true)),
2843        );
2844        let b = tree.add(
2845            FillWidget::new().access_action(accesskit::Action::Click, move |_ctx| b_cb.set(true)),
2846        );
2847        tree.layout(SizeProposal::exact(200.0, 100.0));
2848
2849        tree.focus(a);
2850        tree.dispatch_event(WidgetEvent::AccessAction {
2851            action: accesskit::Action::Click,
2852            target: Some(b),
2853            target_node: crate::accessibility::widget_id_to_node_id(b),
2854            data: None,
2855        });
2856
2857        assert!(b_fired.get(), "the cursored target must receive the action");
2858        assert!(
2859            !a_fired.get(),
2860            "the keyboard-focused widget must NOT receive an action targeting another node"
2861        );
2862    }
2863
2864    #[test]
2865    fn access_action_without_target_is_dropped_not_redirected_to_focus() {
2866        // An action with no (or an inactive) target must be dropped — never
2867        // silently re-routed to whatever holds keyboard focus.
2868        use crate::signal::Signal;
2869        let fired = Signal::new(false);
2870        let cb = fired.clone();
2871
2872        let mut tree = WidgetTree::new();
2873        let widget = tree.add(
2874            FillWidget::new().access_action(accesskit::Action::Click, move |_ctx| cb.set(true)),
2875        );
2876        tree.layout(SizeProposal::exact(200.0, 100.0));
2877
2878        tree.focus(widget);
2879        tree.dispatch_event(WidgetEvent::AccessAction {
2880            action: accesskit::Action::Click,
2881            target: None,
2882            target_node: crate::accessibility::root_node_id(),
2883            data: None,
2884        });
2885
2886        assert!(
2887            !fired.get(),
2888            "a target-less action must not be redirected to the focused widget"
2889        );
2890    }
2891
2892    // NOTE: legacy `scoped_shortcut_fires_when_focused_in_subtree` test
2893    // removed along with the ShortcutMap dispatch path. Scope-aware
2894    // dispatch is handled by the new ShortcutRegistry.
2895
2896    // --- Intent / Action dispatch ------------------------------
2897
2898    #[test]
2899    fn shortcut_fires_matching_action_on_source_widget() {
2900        use crate::action::Action;
2901        use crate::shortcut::{KeyStroke, Shortcut};
2902        use std::cell::Cell;
2903        use std::rc::Rc;
2904
2905        let fired = Rc::new(Cell::new(false));
2906        let fired_flag = fired.clone();
2907
2908        let mut tree = WidgetTree::new();
2909        let widget = tree.add(FillWidget::new().focusable());
2910        tree.push_action(
2911            widget,
2912            Action::new("app.save").on_invoke(move |_intent, _ctx| {
2913                fired_flag.set(true);
2914            }),
2915        );
2916        tree.shortcut_registry_mut().register(
2917            Shortcut::new("app.save")
2918                .primary(KeyStroke::command(Key::S))
2919                .build(),
2920        );
2921
2922        tree.layout(SizeProposal::exact(100.0, 50.0));
2923        tree.focus(widget);
2924
2925        tree.press_key(Key::S, Modifiers::COMMAND);
2926        assert!(fired.get(), "matching action must fire on KeyDown");
2927    }
2928
2929    #[test]
2930    fn global_shortcut_fires_without_focused_widget() {
2931        use crate::action::Action;
2932        use crate::shortcut::{KeyStroke, Shortcut};
2933        use std::cell::Cell;
2934        use std::rc::Rc;
2935
2936        // Regression: a global shortcut must fire even when no widget
2937        // is focused. A root-registered action should still receive
2938        // the intent (anchored at the root as a fallback).
2939        let fired = Rc::new(Cell::new(false));
2940        let fired_flag = fired.clone();
2941
2942        let mut tree = WidgetTree::new();
2943        let root = tree.add(FillWidget::new());
2944        tree.push_action(
2945            root,
2946            Action::new("app.save").on_invoke(move |_intent, _ctx| {
2947                fired_flag.set(true);
2948            }),
2949        );
2950        tree.shortcut_registry_mut().register(
2951            Shortcut::new("app.save")
2952                .primary(KeyStroke::command(Key::S))
2953                .build(),
2954        );
2955
2956        tree.layout(SizeProposal::exact(100.0, 50.0));
2957        // Deliberately no focus() call.
2958
2959        tree.press_key(Key::S, Modifiers::COMMAND);
2960        assert!(
2961            fired.get(),
2962            "global shortcut must fire without a focused widget"
2963        );
2964    }
2965
2966    #[test]
2967    fn global_shortcut_fires_after_focused_widget_destroyed() {
2968        use crate::action::Action;
2969        use crate::shortcut::{KeyStroke, Shortcut};
2970        use std::cell::Cell;
2971        use std::rc::Rc;
2972
2973        // Regression: if the focused widget is destroyed (e.g. during a
2974        // rebuild after a settings-panel rebind), focus must be cleared
2975        // so the next global shortcut falls through to the root-anchor
2976        // path instead of dispatching from a stale, destroyed id.
2977        let fired = Rc::new(Cell::new(false));
2978        let fired_flag = fired.clone();
2979
2980        let mut tree = WidgetTree::new();
2981        let root = tree.add(FillWidget::new());
2982        let focusable = tree.add_child(root, FillWidget::new().focusable());
2983        tree.push_action(
2984            root,
2985            Action::new("app.save").on_invoke(move |_intent, _ctx| {
2986                fired_flag.set(true);
2987            }),
2988        );
2989        tree.shortcut_registry_mut().register(
2990            Shortcut::new("app.save")
2991                .primary(KeyStroke::command(Key::S))
2992                .build(),
2993        );
2994
2995        tree.layout(SizeProposal::exact(100.0, 50.0));
2996        tree.focus(focusable);
2997        assert_eq!(tree.focused(), Some(focusable));
2998
2999        // Destroy the focused subtree (simulates a rebuild that drops
3000        // the currently-focused Rebind button).
3001        tree.destroy_subtree(focusable);
3002        assert_eq!(tree.focused(), None, "focus must clear when destroyed");
3003
3004        tree.press_key(Key::S, Modifiers::COMMAND);
3005        assert!(
3006            fired.get(),
3007            "global shortcut must still fire after the focused widget is destroyed"
3008        );
3009    }
3010
3011    #[test]
3012    fn scoped_shortcut_matches_only_when_focus_in_scope() {
3013        use crate::action::Action;
3014        use crate::shortcut::{KeyStroke, Shortcut, ShortcutScope};
3015        use std::cell::Cell;
3016        use std::rc::Rc;
3017
3018        let fired = Rc::new(Cell::new(0));
3019        let fired_flag = fired.clone();
3020
3021        let mut tree = WidgetTree::new();
3022        let scope_root = tree.add(FillWidget::new().focusable());
3023        let inside = tree.add_child(scope_root, FillWidget::new().focusable());
3024        let outside = tree.add(FillWidget::new().focusable());
3025
3026        tree.push_action(
3027            scope_root,
3028            Action::new("editor.find").on_invoke(move |_i, _c| {
3029                fired_flag.set(fired_flag.get() + 1);
3030            }),
3031        );
3032        tree.shortcut_registry_mut().register(
3033            Shortcut::new("editor.find")
3034                .primary(KeyStroke::command(Key::F))
3035                .scope(ShortcutScope::Scoped(scope_root))
3036                .build(),
3037        );
3038
3039        tree.layout(SizeProposal::exact(200.0, 100.0));
3040
3041        // Focus outside the scope: the shortcut does NOT activate.
3042        tree.focus(outside);
3043        tree.press_key(Key::F, Modifiers::COMMAND);
3044        assert_eq!(
3045            fired.get(),
3046            0,
3047            "scoped shortcut must not fire outside scope"
3048        );
3049
3050        // Focus inside the scope: it fires.
3051        tree.focus(inside);
3052        tree.press_key(Key::F, Modifiers::COMMAND);
3053        assert_eq!(
3054            fired.get(),
3055            1,
3056            "scoped shortcut must fire when focus in scope"
3057        );
3058    }
3059
3060    #[test]
3061    fn same_chord_scoped_first_falls_back_to_global_when_focus_outside() {
3062        // Defect 1: a Scoped binding that sorts first by id must NOT
3063        // shadow the slot when focus is outside its subtree — the
3064        // applicable Global binding fires instead. (`editor.saveBlock`
3065        // < `zzz.global.save`, so the scoped one wins the id-order race
3066        // that `find_by_keystroke` used to settle on.)
3067        use crate::action::Action;
3068        use crate::shortcut::{KeyStroke, Shortcut, ShortcutScope};
3069        use std::cell::Cell;
3070        use std::rc::Rc;
3071
3072        let scoped_fired = Rc::new(Cell::new(0));
3073        let global_fired = Rc::new(Cell::new(0));
3074        let sf = scoped_fired.clone();
3075        let gf = global_fired.clone();
3076
3077        let mut tree = WidgetTree::new();
3078        let root = tree.add(FillWidget::new());
3079        let editor = tree.add_child(root, FillWidget::new().focusable());
3080        let _editor_inner = tree.add_child(editor, FillWidget::new().focusable());
3081        let sidebar = tree.add_child(root, FillWidget::new().focusable());
3082
3083        tree.push_action(
3084            editor,
3085            Action::new("editor.saveBlock").on_invoke(move |_i, _c| sf.set(sf.get() + 1)),
3086        );
3087        tree.push_action(
3088            root,
3089            Action::new("zzz.global.save").on_invoke(move |_i, _c| gf.set(gf.get() + 1)),
3090        );
3091        tree.shortcut_registry_mut().register(
3092            Shortcut::new("editor.saveBlock")
3093                .primary(KeyStroke::command(Key::S))
3094                .scope(ShortcutScope::Scoped(editor))
3095                .build(),
3096        );
3097        tree.shortcut_registry_mut().register(
3098            Shortcut::new("zzz.global.save")
3099                .primary(KeyStroke::command(Key::S))
3100                .build(),
3101        );
3102
3103        tree.layout(SizeProposal::exact(200.0, 100.0));
3104        tree.focus(sidebar);
3105        tree.press_key(Key::S, Modifiers::COMMAND);
3106
3107        assert_eq!(global_fired.get(), 1, "applicable global must fire");
3108        assert_eq!(
3109            scoped_fired.get(),
3110            0,
3111            "inapplicable scoped binding must not eat the chord"
3112        );
3113    }
3114
3115    #[test]
3116    fn same_chord_global_first_yields_to_scoped_when_focus_inside() {
3117        // Defect 2: a Global binding that sorts first by id must yield to
3118        // an in-focus Scoped binding (most-specific-scope wins), then
3119        // reclaim the chord once focus leaves the scope. (`app.save` <
3120        // `editor.saveBlock`, so the global one wins id order.)
3121        use crate::action::Action;
3122        use crate::shortcut::{KeyStroke, Shortcut, ShortcutScope};
3123        use std::cell::Cell;
3124        use std::rc::Rc;
3125
3126        let scoped_fired = Rc::new(Cell::new(0));
3127        let global_fired = Rc::new(Cell::new(0));
3128        let sf = scoped_fired.clone();
3129        let gf = global_fired.clone();
3130
3131        let mut tree = WidgetTree::new();
3132        let root = tree.add(FillWidget::new());
3133        let editor = tree.add_child(root, FillWidget::new().focusable());
3134        let editor_inner = tree.add_child(editor, FillWidget::new().focusable());
3135        let sidebar = tree.add_child(root, FillWidget::new().focusable());
3136
3137        tree.push_action(
3138            editor,
3139            Action::new("editor.saveBlock").on_invoke(move |_i, _c| sf.set(sf.get() + 1)),
3140        );
3141        tree.push_action(
3142            root,
3143            Action::new("app.save").on_invoke(move |_i, _c| gf.set(gf.get() + 1)),
3144        );
3145        tree.shortcut_registry_mut().register(
3146            Shortcut::new("app.save")
3147                .primary(KeyStroke::command(Key::S))
3148                .build(),
3149        );
3150        tree.shortcut_registry_mut().register(
3151            Shortcut::new("editor.saveBlock")
3152                .primary(KeyStroke::command(Key::S))
3153                .scope(ShortcutScope::Scoped(editor))
3154                .build(),
3155        );
3156
3157        tree.layout(SizeProposal::exact(200.0, 100.0));
3158
3159        // Focus inside the editor: the scoped binding wins over global.
3160        tree.focus(editor_inner);
3161        tree.press_key(Key::S, Modifiers::COMMAND);
3162        assert_eq!(
3163            scoped_fired.get(),
3164            1,
3165            "in-focus scoped must win over global"
3166        );
3167        assert_eq!(
3168            global_fired.get(),
3169            0,
3170            "global must yield to the scoped binding"
3171        );
3172
3173        // Focus outside the editor: global reclaims the chord.
3174        tree.focus(sidebar);
3175        tree.press_key(Key::S, Modifiers::COMMAND);
3176        assert_eq!(scoped_fired.get(), 1, "scoped stays put outside its scope");
3177        assert_eq!(
3178            global_fired.get(),
3179            1,
3180            "global fires when focus leaves the scope"
3181        );
3182    }
3183
3184    #[test]
3185    fn propagated_action_lets_ancestor_handle() {
3186        use crate::action::Action;
3187        use crate::intent::IntentResponse;
3188        use crate::shortcut::{KeyStroke, Shortcut};
3189        use std::cell::Cell;
3190        use std::rc::Rc;
3191
3192        let inner_seen = Rc::new(Cell::new(false));
3193        let outer_seen = Rc::new(Cell::new(false));
3194        let inner_flag = inner_seen.clone();
3195        let outer_flag = outer_seen.clone();
3196
3197        let mut tree = WidgetTree::new();
3198        let outer = tree.add(FillWidget::new().focusable());
3199        let inner = tree.add_child(outer, FillWidget::new().focusable());
3200
3201        // Inner observes then propagates; outer consumes.
3202        tree.push_action(
3203            inner,
3204            Action::new("app.save").on_invoke_with_response(move |_i, _c| {
3205                inner_flag.set(true);
3206                IntentResponse::Propagated
3207            }),
3208        );
3209        tree.push_action(
3210            outer,
3211            Action::new("app.save").on_invoke(move |_i, _c| {
3212                outer_flag.set(true);
3213            }),
3214        );
3215        tree.shortcut_registry_mut().register(
3216            Shortcut::new("app.save")
3217                .primary(KeyStroke::command(Key::S))
3218                .build(),
3219        );
3220
3221        tree.layout(SizeProposal::exact(100.0, 50.0));
3222        tree.focus(inner);
3223
3224        tree.press_key(Key::S, Modifiers::COMMAND);
3225        assert!(inner_seen.get(), "inner action observed the intent");
3226        assert!(outer_seen.get(), "outer action reached after Propagated");
3227    }
3228
3229    #[test]
3230    fn handled_action_stops_propagation() {
3231        use crate::action::Action;
3232        use crate::shortcut::{KeyStroke, Shortcut};
3233        use std::cell::Cell;
3234        use std::rc::Rc;
3235
3236        let inner_seen = Rc::new(Cell::new(false));
3237        let outer_seen = Rc::new(Cell::new(false));
3238        let inner_flag = inner_seen.clone();
3239        let outer_flag = outer_seen.clone();
3240
3241        let mut tree = WidgetTree::new();
3242        let outer = tree.add(FillWidget::new().focusable());
3243        let inner = tree.add_child(outer, FillWidget::new().focusable());
3244
3245        tree.push_action(
3246            inner,
3247            Action::new("app.save").on_invoke(move |_i, _c| {
3248                inner_flag.set(true);
3249            }),
3250        );
3251        tree.push_action(
3252            outer,
3253            Action::new("app.save").on_invoke(move |_i, _c| {
3254                outer_flag.set(true);
3255            }),
3256        );
3257        tree.shortcut_registry_mut().register(
3258            Shortcut::new("app.save")
3259                .primary(KeyStroke::command(Key::S))
3260                .build(),
3261        );
3262
3263        tree.layout(SizeProposal::exact(100.0, 50.0));
3264        tree.focus(inner);
3265
3266        tree.press_key(Key::S, Modifiers::COMMAND);
3267        assert!(inner_seen.get());
3268        assert!(!outer_seen.get(), "Handled at inner must stop propagation");
3269    }
3270
3271    #[test]
3272    fn disabled_action_propagates_by_default() {
3273        use crate::action::Action;
3274        use crate::shortcut::{KeyStroke, Shortcut};
3275        use crate::signal::Signal;
3276        use std::cell::Cell;
3277        use std::rc::Rc;
3278
3279        let inner_seen = Rc::new(Cell::new(false));
3280        let outer_seen = Rc::new(Cell::new(false));
3281        let inner_flag = inner_seen.clone();
3282        let outer_flag = outer_seen.clone();
3283
3284        let mut tree = WidgetTree::new();
3285        let outer = tree.add(FillWidget::new().focusable());
3286        let inner = tree.add_child(outer, FillWidget::new().focusable());
3287
3288        let enabled = Signal::new(false);
3289        tree.push_action(
3290            inner,
3291            Action::new("app.save")
3292                .enabled_when(enabled.clone())
3293                .on_invoke(move |_i, _c| {
3294                    inner_flag.set(true);
3295                }),
3296        );
3297        tree.push_action(
3298            outer,
3299            Action::new("app.save").on_invoke(move |_i, _c| {
3300                outer_flag.set(true);
3301            }),
3302        );
3303        tree.shortcut_registry_mut().register(
3304            Shortcut::new("app.save")
3305                .primary(KeyStroke::command(Key::S))
3306                .build(),
3307        );
3308
3309        tree.layout(SizeProposal::exact(100.0, 50.0));
3310        tree.focus(inner);
3311
3312        tree.press_key(Key::S, Modifiers::COMMAND);
3313        assert!(!inner_seen.get(), "disabled inner must not run");
3314        assert!(
3315            outer_seen.get(),
3316            "intent must propagate past disabled inner"
3317        );
3318    }
3319
3320    #[test]
3321    fn disabled_action_with_non_propagating_shortcut_consumes() {
3322        use crate::action::Action;
3323        use crate::shortcut::{KeyStroke, Shortcut};
3324        use crate::signal::Signal;
3325        use std::cell::Cell;
3326        use std::rc::Rc;
3327
3328        let inner_seen = Rc::new(Cell::new(false));
3329        let outer_seen = Rc::new(Cell::new(false));
3330        let inner_flag = inner_seen.clone();
3331        let outer_flag = outer_seen.clone();
3332
3333        let mut tree = WidgetTree::new();
3334        let outer = tree.add(FillWidget::new().focusable());
3335        let inner = tree.add_child(outer, FillWidget::new().focusable());
3336
3337        let enabled = Signal::new(false);
3338        tree.push_action(
3339            inner,
3340            Action::new("app.save")
3341                .enabled_when(enabled.clone())
3342                .on_invoke(move |_i, _c| {
3343                    inner_flag.set(true);
3344                }),
3345        );
3346        tree.push_action(
3347            outer,
3348            Action::new("app.save").on_invoke(move |_i, _c| {
3349                outer_flag.set(true);
3350            }),
3351        );
3352        tree.shortcut_registry_mut().register(
3353            Shortcut::new("app.save")
3354                .primary(KeyStroke::command(Key::S))
3355                .propagate_when_disabled(false)
3356                .build(),
3357        );
3358
3359        tree.layout(SizeProposal::exact(100.0, 50.0));
3360        tree.focus(inner);
3361
3362        tree.press_key(Key::S, Modifiers::COMMAND);
3363        assert!(!inner_seen.get(), "disabled inner still does not run");
3364        assert!(
3365            !outer_seen.get(),
3366            "intent must NOT propagate when shortcut disallows it"
3367        );
3368    }
3369
3370    #[test]
3371    fn send_intent_from_handler_reaches_ancestor_action() {
3372        use crate::action::Action;
3373        use crate::intent::Intent;
3374        use std::cell::Cell;
3375        use std::rc::Rc;
3376
3377        let save_seen = Rc::new(Cell::new(false));
3378        let save_flag = save_seen.clone();
3379
3380        let mut tree = WidgetTree::new();
3381        let root = tree.add(FillWidget::new());
3382        let button = tree.add_child(
3383            root,
3384            FillWidget::new().on_tap(|_pos, ctx| {
3385                ctx.send_intent(Intent::new("app.save"));
3386            }),
3387        );
3388        tree.push_action(
3389            root,
3390            Action::new("app.save").on_invoke(move |_i, _c| {
3391                save_flag.set(true);
3392            }),
3393        );
3394
3395        tree.layout(SizeProposal::exact(100.0, 50.0));
3396        tree.click(button);
3397        assert!(
3398            save_seen.get(),
3399            "ctx.send_intent must reach ancestor action"
3400        );
3401    }
3402
3403    #[test]
3404    fn widget_type_histogram_counts_distinct_types() {
3405        // The histogram surfaces concrete widget types
3406        // by std::any::type_name_of_val. Widgets become active
3407        // after the first layout pass, so we run that before
3408        // checking the histogram.
3409        let mut tree = WidgetTree::new();
3410        let _ = tree.add(FillWidget::new());
3411        let _ = tree.add(FillWidget::new());
3412        let _ = tree.add(FillWidget::new());
3413        tree.layout(SizeProposal::exact(100.0, 100.0));
3414        let histogram = tree.widget_type_histogram();
3415        let total: u32 = histogram.values().sum();
3416        assert!(
3417            total >= 3,
3418            "expected at least 3 active widgets, got {total}: {histogram:?}"
3419        );
3420        let fillwidget_entries: u32 = histogram
3421            .iter()
3422            .filter(|(k, _)| k.contains("FillWidget"))
3423            .map(|(_, v)| *v)
3424            .sum();
3425        assert!(
3426            fillwidget_entries >= 3,
3427            "expected ≥3 FillWidget instances; histogram = {histogram:?}"
3428        );
3429        assert_eq!(tree.active_widget_count() as u32, total);
3430    }
3431
3432    #[test]
3433    fn intent_source_tagged_handler_for_tap_activation() {
3434        // A tap-driven `ctx.send_intent` must surface as
3435        // `IntentSource::Handler` to ancestor actions, not the
3436        // `Programmatic` default of `Intent::new`.
3437        use crate::action::Action;
3438        use crate::intent::Intent;
3439        use crate::telemetry::IntentSource;
3440        use std::cell::Cell;
3441        use std::rc::Rc;
3442        let captured = Rc::new(Cell::new(IntentSource::Unknown));
3443        let captured_for_action = captured.clone();
3444
3445        let mut tree = WidgetTree::new();
3446        let root = tree.add(FillWidget::new());
3447        let button = tree.add_child(
3448            root,
3449            FillWidget::new().on_tap(|_pos, ctx| {
3450                ctx.send_intent(Intent::new("app.save"));
3451            }),
3452        );
3453        tree.push_action(
3454            root,
3455            Action::new("app.save").on_invoke(move |intent, _c| {
3456                captured_for_action.set(intent.source);
3457            }),
3458        );
3459
3460        tree.layout(SizeProposal::exact(100.0, 50.0));
3461        tree.click(button);
3462        assert_eq!(
3463            captured.get(),
3464            IntentSource::Handler,
3465            "tap-driven intent must tag IntentSource::Handler"
3466        );
3467    }
3468
3469    #[test]
3470    fn intent_source_programmatic_when_no_handler_active() {
3471        use crate::intent::Intent;
3472        use crate::telemetry::IntentSource;
3473        let intent = Intent::new("app.demo");
3474        assert_eq!(intent.source, IntentSource::Programmatic);
3475
3476        // ctx.send_intent without a handler scope keeps it Programmatic.
3477        let mut ctx = EventContext::new();
3478        ctx.send_intent(Intent::new("app.demo"));
3479        let queued = ctx.pending_intents.first().expect("intent queued");
3480        assert_eq!(queued.source, IntentSource::Programmatic);
3481    }
3482
3483    #[test]
3484    fn with_intent_source_overrides_for_managed_widgets() {
3485        use crate::intent::Intent;
3486        use crate::telemetry::IntentSource;
3487        let mut ctx = EventContext::new();
3488        ctx.with_intent_source(IntentSource::Menu, |ctx| {
3489            ctx.send_intent(Intent::new("app.demo"));
3490        });
3491        let queued = ctx.pending_intents.first().expect("intent queued");
3492        assert_eq!(
3493            queued.source,
3494            IntentSource::Menu,
3495            "with_intent_source(Menu) must tag the dispatched intent"
3496        );
3497
3498        // After the closure returns, current_source is restored —
3499        // a follow-up send_intent without a wrapping closure goes
3500        // back to the default (no override).
3501        ctx.send_intent(Intent::new("app.next"));
3502        let next = ctx.pending_intents.last().expect("second intent");
3503        assert_eq!(next.source, IntentSource::Programmatic);
3504    }
3505
3506    #[test]
3507    fn disabled_shortcut_falls_through_to_focused_widget() {
3508        use crate::action::Action;
3509        use crate::shortcut::{KeyStroke, Shortcut};
3510        use crate::signal::Signal;
3511        use std::cell::Cell;
3512        use std::rc::Rc;
3513
3514        let action_fired = Rc::new(Cell::new(false));
3515        let on_key_fired = Rc::new(Cell::new(false));
3516        let af = action_fired.clone();
3517        let kf = on_key_fired.clone();
3518
3519        let enabled = Signal::new(false);
3520
3521        let mut tree = WidgetTree::new();
3522        let widget = tree.add(FillWidget::new().focusable().on_key(move |event, _ctx| {
3523            if matches!(
3524                event,
3525                WidgetEvent::KeyDown {
3526                    key: Key::S,
3527                    modifiers,
3528                    ..
3529                } if modifiers.command()
3530            ) {
3531                kf.set(true);
3532                return EventResponse::Handled;
3533            }
3534            EventResponse::Ignored
3535        }));
3536        tree.push_action(
3537            widget,
3538            Action::new("app.save").on_invoke(move |_i, _c| af.set(true)),
3539        );
3540        tree.shortcut_registry_mut().register(
3541            Shortcut::new("app.save")
3542                .primary(KeyStroke::command(Key::S))
3543                .enabled_when(enabled.clone())
3544                .build(),
3545        );
3546
3547        tree.layout(SizeProposal::exact(100.0, 50.0));
3548        tree.focus(widget);
3549
3550        // Disabled: keystroke falls through to on_key.
3551        tree.press_key(Key::S, Modifiers::COMMAND);
3552        assert!(
3553            !action_fired.get(),
3554            "disabled shortcut must not invoke its action"
3555        );
3556        assert!(
3557            on_key_fired.get(),
3558            "disabled shortcut must let KeyDown reach the focused widget"
3559        );
3560
3561        // Re-enable → action fires, on_key does not.
3562        on_key_fired.set(false);
3563        enabled.set(true);
3564        tree.press_key(Key::S, Modifiers::COMMAND);
3565        assert!(action_fired.get(), "re-enabled shortcut must dispatch");
3566        assert!(
3567            !on_key_fired.get(),
3568            "enabled shortcut must consume the KeyDown"
3569        );
3570    }
3571
3572    #[test]
3573    fn keyboard_capture_bypasses_shortcut() {
3574        use crate::action::Action;
3575        use crate::shortcut::{KeyStroke, Shortcut};
3576        use std::cell::Cell;
3577        use std::rc::Rc;
3578
3579        // A focused keyboard-capture surface (e.g. a terminal) must receive
3580        // the accelerator chord itself (⌘S on macOS, Ctrl+S elsewhere), even
3581        // though an ENABLED global shortcut binds it — the whole point of
3582        // GAP 1. A non-capturing widget must yield to the shortcut (the
3583        // control case).
3584        fn run(capture: bool) -> (bool, bool) {
3585            let action_fired = Rc::new(Cell::new(false));
3586            let on_key_fired = Rc::new(Cell::new(false));
3587            let af = action_fired.clone();
3588            let kf = on_key_fired.clone();
3589
3590            let mut tree = WidgetTree::new();
3591            let widget = tree.add(
3592                FillWidget::new()
3593                    .focusable()
3594                    .keyboard_capture(capture)
3595                    .on_key(move |event, _ctx| {
3596                        if matches!(
3597                            event,
3598                            WidgetEvent::KeyDown { key: Key::S, modifiers, .. } if modifiers.command()
3599                        ) {
3600                            kf.set(true);
3601                            return EventResponse::Handled;
3602                        }
3603                        EventResponse::Ignored
3604                    }),
3605            );
3606            tree.push_action(
3607                widget,
3608                Action::new("app.save").on_invoke(move |_i, _c| af.set(true)),
3609            );
3610            tree.shortcut_registry_mut().register(
3611                Shortcut::new("app.save")
3612                    .primary(KeyStroke::command(Key::S))
3613                    .build(),
3614            );
3615
3616            tree.layout(SizeProposal::exact(100.0, 50.0));
3617            tree.focus(widget);
3618            tree.press_key(Key::S, Modifiers::COMMAND);
3619            (action_fired.get(), on_key_fired.get())
3620        }
3621
3622        // Capture on: the shortcut is bypassed, the widget sees the key.
3623        let (action, on_key) = run(true);
3624        assert!(
3625            !action,
3626            "keyboard_capture must suppress the shortcut action"
3627        );
3628        assert!(on_key, "keyboard_capture must deliver the raw KeyDown");
3629
3630        // Capture off (control): the shortcut consumes the key.
3631        let (action, on_key) = run(false);
3632        assert!(action, "without capture the shortcut must fire");
3633        assert!(!on_key, "without capture the widget must not see the key");
3634    }
3635
3636    #[test]
3637    fn ctrl_tab_always_escapes_a_keyboard_capture_surface() {
3638        use std::cell::Cell;
3639        use std::rc::Rc;
3640
3641        // WCAG 2.1.2. A capture surface answers `Handled` to every key —
3642        // that is what it is for — so the "cycle focus only when the focused
3643        // widget did not handle Tab" rule can never get focus out of one.
3644        // Ctrl+Tab / Ctrl+Shift+Tab are therefore reserved by the dispatcher
3645        // and never reach the widget at all.
3646        let saw_key = Rc::new(Cell::new(false));
3647        let sk = saw_key.clone();
3648
3649        let mut tree = WidgetTree::new();
3650        let capture = tree.add(
3651            FillWidget::new()
3652                .focusable()
3653                .keyboard_capture(true)
3654                // The greediest possible handler: everything is consumed.
3655                .on_key(move |_event, _ctx| {
3656                    sk.set(true);
3657                    EventResponse::Handled
3658                }),
3659        );
3660        let neighbour = tree.add(FillWidget::new().focusable());
3661        tree.layout(SizeProposal::exact(100.0, 50.0));
3662
3663        // Plain Tab stays inside: the widget consumed it (a terminal writes
3664        // it to the child as `\t`).
3665        tree.focus(capture);
3666        tree.press_key(Key::Tab, Modifiers::NONE);
3667        assert!(saw_key.get(), "plain Tab must reach the capture surface");
3668        assert_eq!(
3669            tree.focused(),
3670            Some(capture),
3671            "plain Tab must not move focus off a capture surface"
3672        );
3673
3674        // Ctrl+Tab escapes forward, without the widget ever seeing it.
3675        saw_key.set(false);
3676        tree.press_key(Key::Tab, Modifiers::CTRL);
3677        assert!(
3678            !saw_key.get(),
3679            "Ctrl+Tab is reserved and must not reach the capture surface"
3680        );
3681        assert_eq!(
3682            tree.focused(),
3683            Some(neighbour),
3684            "Ctrl+Tab must move focus out of a capture surface"
3685        );
3686
3687        // And backwards.
3688        tree.focus(capture);
3689        tree.press_key(Key::Tab, Modifiers::CTRL | Modifiers::SHIFT);
3690        assert_eq!(
3691            tree.focused(),
3692            Some(neighbour),
3693            "Ctrl+Shift+Tab must move focus out of a capture surface"
3694        );
3695    }
3696
3697    #[test]
3698    fn scope_mismatch_does_not_invoke_on_activate() {
3699        use crate::intent::Intent;
3700        use crate::shortcut::{KeyStroke, Shortcut, ShortcutScope};
3701        use std::cell::Cell;
3702        use std::rc::Rc;
3703
3704        // Regression: before the find/invoke split, `on_activate` ran
3705        // even when the focused widget was outside the shortcut's
3706        // scope, and any side effects on its ctx were silently
3707        // dropped. The closure must now only run when the scope
3708        // check has already passed.
3709        let activated = Rc::new(Cell::new(false));
3710        let activated_flag = activated.clone();
3711
3712        let mut tree = WidgetTree::new();
3713        let scope_root = tree.add(FillWidget::new().focusable());
3714        let outside = tree.add(FillWidget::new().focusable());
3715
3716        tree.shortcut_registry_mut().register(
3717            Shortcut::new("editor.find")
3718                .primary(KeyStroke::command(Key::F))
3719                .scope(ShortcutScope::Scoped(scope_root))
3720                .on_activate(move |_ks, _ctx| {
3721                    activated_flag.set(true);
3722                    Intent::new("editor.find")
3723                })
3724                .build(),
3725        );
3726
3727        tree.layout(SizeProposal::exact(200.0, 100.0));
3728        tree.focus(outside);
3729
3730        tree.press_key(Key::F, Modifiers::COMMAND);
3731        assert!(
3732            !activated.get(),
3733            "on_activate must not run when focus is outside the shortcut's scope"
3734        );
3735    }
3736
3737    #[test]
3738    fn key_capture_runs_callback_and_bypasses_registry() {
3739        use crate::action::Action;
3740        use crate::shortcut::{KeyStroke, Shortcut};
3741        use std::cell::Cell;
3742        use std::rc::Rc;
3743
3744        let action_fired = Rc::new(Cell::new(false));
3745        let af = action_fired.clone();
3746        let captured = Rc::new(Cell::new(None));
3747        let cf = captured.clone();
3748
3749        let mut tree = WidgetTree::new();
3750        let widget = tree.add(FillWidget::new().focusable());
3751        tree.push_action(
3752            widget,
3753            Action::new("app.save").on_invoke(move |_i, _c| af.set(true)),
3754        );
3755        tree.shortcut_registry_mut().register(
3756            Shortcut::new("app.save")
3757                .primary(KeyStroke::command(Key::S))
3758                .build(),
3759        );
3760
3761        tree.layout(SizeProposal::exact(100.0, 50.0));
3762        tree.focus(widget);
3763
3764        let handle = tree.begin_key_capture(move |ks, _reg, _ctx| cf.set(Some(ks)));
3765        assert!(tree.is_capturing_keys());
3766
3767        tree.press_key(Key::S, Modifiers::COMMAND);
3768        assert_eq!(
3769            captured.get(),
3770            Some(KeyStroke::command(Key::S)),
3771            "capture callback must receive the chord"
3772        );
3773        assert!(
3774            !action_fired.get(),
3775            "shortcut action must not fire while capture is armed"
3776        );
3777        assert!(
3778            !tree.is_capturing_keys(),
3779            "capture is one-shot; next KeyDown flows normally"
3780        );
3781        drop(handle);
3782    }
3783
3784    #[test]
3785    fn key_capture_can_rebind_through_registry() {
3786        use crate::shortcut::{KeyStroke, Shortcut};
3787
3788        let mut tree = WidgetTree::new();
3789        let widget = tree.add(FillWidget::new().focusable());
3790        tree.shortcut_registry_mut().register(
3791            Shortcut::new("app.save")
3792                .primary(KeyStroke::command(Key::S))
3793                .build(),
3794        );
3795
3796        tree.layout(SizeProposal::exact(100.0, 50.0));
3797        tree.focus(widget);
3798
3799        // Arm capture: whatever chord comes next, rebind app.save to it.
3800        let _h = tree.begin_key_capture(|ks, reg, _ctx| {
3801            reg.rebind_primary("app.save", Some(ks));
3802        });
3803
3804        tree.press_key(Key::B, Modifiers::COMMAND | Modifiers::SHIFT);
3805        assert_eq!(
3806            tree.shortcut_registry()
3807                .effective("app.save")
3808                .unwrap()
3809                .primary,
3810            Some(KeyStroke::command_shift(Key::B))
3811        );
3812    }
3813
3814    #[test]
3815    fn dropping_capture_handle_cancels_capture() {
3816        use crate::shortcut::{KeyStroke, Shortcut};
3817        use std::cell::Cell;
3818        use std::rc::Rc;
3819
3820        let action_fired = Rc::new(Cell::new(false));
3821        let af = action_fired.clone();
3822        let capture_fired = Rc::new(Cell::new(false));
3823        let cf = capture_fired.clone();
3824
3825        let mut tree = WidgetTree::new();
3826        let widget = tree.add(FillWidget::new().focusable());
3827        tree.push_action(
3828            widget,
3829            crate::action::Action::new("app.save").on_invoke(move |_i, _c| af.set(true)),
3830        );
3831        tree.shortcut_registry_mut().register(
3832            Shortcut::new("app.save")
3833                .primary(KeyStroke::command(Key::S))
3834                .build(),
3835        );
3836        tree.layout(SizeProposal::exact(100.0, 50.0));
3837        tree.focus(widget);
3838
3839        // Arm capture in a scope, then drop the handle before any key
3840        // is pressed. The next KeyDown must fall through to the normal
3841        // shortcut path, firing the action — not the cancelled capture.
3842        {
3843            let _h = tree.begin_key_capture(move |_ks, _reg, _ctx| cf.set(true));
3844            assert!(tree.is_capturing_keys());
3845            // `_h` drops here → cancel.
3846        }
3847        assert!(
3848            !tree.is_capturing_keys(),
3849            "dropping the handle must cancel the capture"
3850        );
3851
3852        tree.press_key(Key::S, Modifiers::COMMAND);
3853        assert!(!capture_fired.get(), "cancelled capture must not fire");
3854        assert!(
3855            action_fired.get(),
3856            "shortcut action runs after capture was cancelled"
3857        );
3858    }
3859
3860    #[test]
3861    fn second_begin_key_capture_does_not_racecancel_first() {
3862        use std::cell::Cell;
3863        use std::rc::Rc;
3864
3865        let first = Rc::new(Cell::new(false));
3866        let second = Rc::new(Cell::new(false));
3867        let f = first.clone();
3868        let s = second.clone();
3869
3870        let mut tree = WidgetTree::new();
3871        let widget = tree.add(FillWidget::new().focusable());
3872        tree.layout(SizeProposal::exact(100.0, 50.0));
3873        tree.focus(widget);
3874
3875        // Arm #1 then replace with #2. #1's handle is later dropped,
3876        // which would have cancelled the active capture under the old
3877        // `Option<Box<FnOnce>>` design — CaptureHandle now ties each
3878        // session to its own slot, so the drop only clears #1's
3879        // (orphaned) slot, not #2.
3880        let h1 = tree.begin_key_capture(move |_ks, _reg, _ctx| f.set(true));
3881        let _h2 = tree.begin_key_capture(move |_ks, _reg, _ctx| s.set(true));
3882        drop(h1);
3883
3884        assert!(
3885            tree.is_capturing_keys(),
3886            "dropping the older handle must not cancel the active capture"
3887        );
3888        tree.press_key(Key::K, Modifiers::COMMAND);
3889        assert!(!first.get());
3890        assert!(second.get(), "newest capture wins");
3891    }
3892
3893    #[test]
3894    fn capture_callback_can_send_intent() {
3895        use crate::action::Action;
3896        use crate::intent::Intent;
3897
3898        use std::cell::Cell;
3899        use std::rc::Rc;
3900
3901        let ran = Rc::new(Cell::new(false));
3902        let flag = ran.clone();
3903
3904        let mut tree = WidgetTree::new();
3905        let widget = tree.add(FillWidget::new().focusable());
3906        tree.push_action(
3907            widget,
3908            Action::new("app.save").on_invoke(move |_i, _c| flag.set(true)),
3909        );
3910        tree.layout(SizeProposal::exact(100.0, 50.0));
3911        tree.focus(widget);
3912
3913        let _h = tree.begin_key_capture(|_ks, _reg, ctx| {
3914            ctx.send_intent(Intent::new("app.save"));
3915        });
3916        tree.press_key(Key::X, Modifiers::COMMAND);
3917        assert!(
3918            ran.get(),
3919            "intent queued from capture callback must dispatch"
3920        );
3921    }
3922
3923    #[test]
3924    fn binding_registry_does_not_accumulate_across_rebuilds() {
3925        use crate::binding::BindingLevel;
3926        use crate::signal::Signal;
3927
3928        #[derive(Debug)]
3929        struct BoundLeaf {
3930            tick: Signal<u64>,
3931        }
3932        impl crate::widget::Widget for BoundLeaf {
3933            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
3934                self.tick.bind_to(
3935                    ctx.self_id(),
3936                    ctx.binding_registry(),
3937                    BindingLevel::Relayout,
3938                );
3939                Vec::new()
3940            }
3941            fn layout_response(
3942                &self,
3943                proposal: SizeProposal,
3944                _ctx: &crate::widget::LayoutContext,
3945            ) -> crate::widget::LayoutResponse {
3946                proposal.resolve(10.0, 10.0).into()
3947            }
3948        }
3949
3950        let mut tree = WidgetTree::new();
3951        let tick = Signal::new(0_u64);
3952        let widget = tree.add(BoundLeaf { tick: tick.clone() });
3953        tree.layout(SizeProposal::exact(200.0, 200.0));
3954        let after_first_build = tree.binding_registry().len();
3955        assert!(after_first_build >= 1);
3956
3957        // Force rebuild a handful of times and verify the binding
3958        // count does not keep growing. Pre-fix: each rebuild pushed
3959        // a new entry for the same (widget, signal) pair.
3960        for _ in 0..5 {
3961            tree.arena.mark_needs_rebuild(widget);
3962            tree.layout(SizeProposal::exact(200.0, 200.0));
3963        }
3964        assert_eq!(
3965            tree.binding_registry().len(),
3966            after_first_build,
3967            "bindings must be cleared on rebuild"
3968        );
3969
3970        tree.destroy_subtree(widget);
3971        assert_eq!(
3972            tree.binding_registry().len(),
3973            0,
3974            "bindings must be cleared on destroy"
3975        );
3976        // Silence unused-variable warning for the signal.
3977        let _ = tick;
3978    }
3979
3980    #[test]
3981    fn ctx_destroy_cancels_animations_and_bindings_via_deferred_path() {
3982        // Regression: `EventContext::destroy` queues
3983        // `TreeMutation::Destroy`, which used to be applied with the
3984        // bare `arena.destroy` — unlinking the node but leaking the
3985        // animation-scheduler entry (it holds a strong `Signal<f32>`
3986        // clone, so the widget kept animating after destruction) and
3987        // the widget's bindings. It must route through
3988        // `destroy_subtree` like every other destroy path does.
3989        use crate::binding::BindingLevel;
3990        use crate::signal::Signal;
3991        use std::time::{Duration, Instant};
3992        use teksilo_tokens::Easing;
3993
3994        #[derive(Debug)]
3995        struct BoundLeaf {
3996            tick: Signal<u64>,
3997        }
3998        impl crate::widget::Widget for BoundLeaf {
3999            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4000                self.tick.bind_to(
4001                    ctx.self_id(),
4002                    ctx.binding_registry(),
4003                    BindingLevel::Relayout,
4004                );
4005                Vec::new()
4006            }
4007            fn layout_response(
4008                &self,
4009                proposal: SizeProposal,
4010                _ctx: &crate::widget::LayoutContext,
4011            ) -> crate::widget::LayoutResponse {
4012                proposal.resolve(10.0, 10.0).into()
4013            }
4014        }
4015
4016        let mut tree = WidgetTree::new();
4017        let widget = tree.add(BoundLeaf {
4018            tick: Signal::new(0_u64),
4019        });
4020        tree.layout(SizeProposal::exact(200.0, 200.0));
4021        assert!(tree.binding_registry().len() >= 1);
4022
4023        // Seed an animation owned by the widget — exactly the strong
4024        // `Signal<f32>` clone the scheduler outlives the widget with.
4025        let anim = Signal::<f32>::new_animated(0.0);
4026        tree.animation_scheduler.animate(
4027            &anim,
4028            widget,
4029            1.0,
4030            Duration::from_secs(10),
4031            Easing::Linear,
4032            Instant::now(),
4033        );
4034        assert_eq!(tree.animation_scheduler.active_count(), 1);
4035
4036        // Destroy via the deferred handler-time path.
4037        let mut noop = crate::window::NoopWindowOps;
4038        tree.run_with_event_context(&mut noop, |ctx| ctx.destroy(widget));
4039
4040        assert_eq!(
4041            tree.animation_scheduler.active_count(),
4042            0,
4043            "ctx.destroy must cancel animations owned by the destroyed widget"
4044        );
4045        assert_eq!(
4046            tree.binding_registry().len(),
4047            0,
4048            "ctx.destroy must unregister the destroyed widget's bindings"
4049        );
4050        assert!(
4051            tree.arena.get(widget).is_none(),
4052            "node must be removed from the arena"
4053        );
4054    }
4055
4056    #[test]
4057    fn clear_shortcut_override_via_event_context_restores_default() {
4058        use crate::shortcut::{KeyStroke, Shortcut};
4059
4060        let mut tree = WidgetTree::new();
4061        tree.shortcut_registry_mut().register(
4062            Shortcut::new("app.save")
4063                .primary(KeyStroke::command(Key::S))
4064                .build(),
4065        );
4066        tree.shortcut_registry_mut()
4067            .rebind_primary("app.save", Some(KeyStroke::alt(Key::S)));
4068
4069        let source = tree.add(FillWidget::new());
4070        let mut ctx = EventContext::new();
4071        ctx.clear_shortcut_override("app.save");
4072        tree.collect_from_ctx(ctx, source);
4073
4074        assert_eq!(
4075            tree.shortcut_registry()
4076                .effective("app.save")
4077                .unwrap()
4078                .primary,
4079            Some(KeyStroke::command(Key::S))
4080        );
4081    }
4082
4083    #[test]
4084    fn rebind_shortcut_primary_via_event_context() {
4085        use crate::shortcut::{KeyStroke, Shortcut};
4086
4087        let mut tree = WidgetTree::new();
4088        tree.shortcut_registry_mut().register(
4089            Shortcut::new("app.save")
4090                .primary(KeyStroke::command(Key::S))
4091                .build(),
4092        );
4093        let source = tree.add(FillWidget::new());
4094
4095        let mut ctx = EventContext::new();
4096        ctx.rebind_shortcut_primary("app.save", Some(KeyStroke::alt(Key::S)));
4097        tree.collect_from_ctx(ctx, source);
4098
4099        assert_eq!(
4100            tree.shortcut_registry()
4101                .effective("app.save")
4102                .unwrap()
4103                .primary,
4104            Some(KeyStroke::alt(Key::S))
4105        );
4106    }
4107
4108    #[test]
4109    fn unregister_all_for_owner_called_on_destroy() {
4110        use crate::shortcut::{KeyStroke, Shortcut};
4111
4112        let mut tree = WidgetTree::new();
4113        let widget = tree.add(FillWidget::new());
4114        let widget_owner = widget;
4115        tree.shortcut_registry_mut().register_owned(
4116            Shortcut::new("scoped.thing")
4117                .primary(KeyStroke::command(Key::K))
4118                .build(),
4119            widget_owner,
4120        );
4121        assert!(
4122            tree.shortcut_registry()
4123                .get_default("scoped.thing")
4124                .is_some()
4125        );
4126
4127        tree.destroy_subtree(widget);
4128        assert!(
4129            tree.shortcut_registry()
4130                .get_default("scoped.thing")
4131                .is_none(),
4132            "destroying the owner must unregister its shortcut"
4133        );
4134    }
4135
4136    /// A global action fires for an intent dispatched from a widget in a
4137    /// completely unrelated subtree — proving it is a position-independent
4138    /// fallback (the menu-bar-vs-content case).
4139    #[test]
4140    fn global_action_reached_from_unrelated_source() {
4141        use crate::action::Action;
4142        use crate::intent::Intent;
4143        use std::cell::Cell;
4144        use std::rc::Rc;
4145
4146        #[derive(Debug)]
4147        struct Registrar(Rc<Cell<bool>>);
4148        impl crate::widget::Widget for Registrar {
4149            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4150                let flag = self.0.clone();
4151                ctx.register_action_global(
4152                    Action::new("test.global").on_invoke(move |_i, _c| flag.set(true)),
4153                );
4154                vec![]
4155            }
4156            fn layout_response(
4157                &self,
4158                _p: teksilo_canvas::SizeProposal,
4159                _c: &crate::widget::LayoutContext,
4160            ) -> crate::widget::LayoutResponse {
4161                teksilo_canvas::Size::new(0.0, 0.0).into()
4162            }
4163        }
4164
4165        let mut tree = WidgetTree::new();
4166        let fired = Rc::new(Cell::new(false));
4167        let registrar = tree.add(Registrar(fired.clone()));
4168        let source = tree.add(FillWidget::new()); // unrelated sibling root
4169        let mut ops = crate::window::NoopWindowOps;
4170
4171        tree.dispatch_intent(source, Intent::new("test.global"), true, &mut ops);
4172        assert!(
4173            fired.get(),
4174            "global action must fire from an unrelated source"
4175        );
4176
4177        // And it is torn down with its owner.
4178        fired.set(false);
4179        tree.destroy_subtree(registrar);
4180        tree.dispatch_intent(source, Intent::new("test.global"), true, &mut ops);
4181        assert!(
4182            !fired.get(),
4183            "destroying the owner must remove its global action"
4184        );
4185    }
4186
4187    // --- Transform-aware hit-testing -------------------------------------
4188    //
4189    // `set_transform` scopes are paint-only: the renderer pushes the
4190    // transform around the subtree, so the visually-displayed area is
4191    // shifted relative to `arena.bounds(id)`. Hit-testing must inverse-
4192    // transform the screen-space input point as it descends through each
4193    // transform scope so that a click on the visually-rendered area lands
4194    // on the correct widget. Pre-fix, screen-space `bounds.contains(point)`
4195    // returned the *pre-transform* widget for in-bounds-pre-transform
4196    // points and missed the visually-shifted hit area entirely.
4197
4198    #[test]
4199    fn hit_test_through_translate_scope() {
4200        use crate::test_widgets::StackWidget;
4201        let mut tree = WidgetTree::new();
4202        let child = tree.add(FillWidget::new());
4203        let parent = tree.add(StackWidget::new().add_child(child));
4204        // Visually shift the entire subtree right by 100px.
4205        tree.set_transform(parent, teksilo_canvas::Transform2D::translate(100.0, 0.0));
4206        tree.layout(SizeProposal::exact(100.0, 50.0));
4207
4208        // (50, 25) is inside the *pre-transform* bounds but the widget is
4209        // visually painted at x=100..200; a click at (50, 25) lands on
4210        // empty space.
4211        assert_eq!(
4212            tree.hit_test(Point::new(50.0, 25.0)),
4213            None,
4214            "pre-transform area is not visually populated and must not hit"
4215        );
4216        // (150, 25) is inside the visually-rendered area (post-translate).
4217        assert_eq!(
4218            tree.hit_test(Point::new(150.0, 25.0)),
4219            Some(child),
4220            "visually-rendered area must hit the child"
4221        );
4222        // Off everything.
4223        assert_eq!(tree.hit_test(Point::new(250.0, 25.0)), None);
4224    }
4225
4226    #[test]
4227    fn hit_test_through_scale_scope() {
4228        use crate::test_widgets::StackWidget;
4229        let mut tree = WidgetTree::new();
4230        let child = tree.add(FillWidget::new());
4231        let parent = tree.add(StackWidget::new().add_child(child));
4232        // Halve the visual size: pre-transform bounds (0,0,100,50) →
4233        // visually (0,0,50,25).
4234        tree.set_transform(parent, teksilo_canvas::Transform2D::scale(0.5, 0.5));
4235        tree.layout(SizeProposal::exact(100.0, 50.0));
4236
4237        // Inside the visual area.
4238        assert_eq!(tree.hit_test(Point::new(25.0, 12.0)), Some(child));
4239        // Outside the visual area but inside the pre-transform bounds.
4240        // Without the fix this would (incorrectly) hit the child.
4241        assert_eq!(
4242            tree.hit_test(Point::new(75.0, 25.0)),
4243            None,
4244            "scaled-out region must not hit"
4245        );
4246    }
4247
4248    #[test]
4249    fn hit_test_through_nested_transforms_compose() {
4250        use crate::test_widgets::StackWidget;
4251        let mut tree = WidgetTree::new();
4252        let leaf = tree.add(FillWidget::new());
4253        let inner = tree.add(StackWidget::new().add_child(leaf));
4254        let outer = tree.add(StackWidget::new().add_child(inner));
4255        // Outer translates by (100, 0); inner additionally scales by 2.
4256        // Effective at leaf = scale(2,2).then(translate(100,0)) — the
4257        // renderer composes deepest-first (see `effective_transform`).
4258        tree.set_transform(outer, teksilo_canvas::Transform2D::translate(100.0, 0.0));
4259        tree.set_transform(inner, teksilo_canvas::Transform2D::scale(2.0, 2.0));
4260        tree.layout(SizeProposal::exact(50.0, 25.0));
4261
4262        // Leaf-local (0, 0) → scale → (0, 0) → translate → (100, 0).
4263        // Leaf-local (50, 25) → scale → (100, 50) → translate → (200, 50).
4264        // So the visual hit area is x in [100, 200], y in [0, 50].
4265        assert_eq!(tree.hit_test(Point::new(150.0, 25.0)), Some(leaf));
4266        assert_eq!(tree.hit_test(Point::new(50.0, 25.0)), None);
4267        assert_eq!(tree.hit_test(Point::new(250.0, 25.0)), None);
4268    }
4269
4270    #[test]
4271    fn hit_test_identity_transform_unchanged() {
4272        // Sanity: an identity transform must not perturb the existing
4273        // hit-test behavior. Guards against accidental over-application
4274        // of inversion on the hot path.
4275        let mut tree = WidgetTree::new();
4276        let widget = tree.add(FillWidget::new());
4277        tree.set_transform(widget, teksilo_canvas::Transform2D::IDENTITY);
4278        tree.layout(SizeProposal::exact(100.0, 50.0));
4279        assert_eq!(tree.hit_test(Point::new(50.0, 25.0)), Some(widget));
4280    }
4281
4282    #[test]
4283    fn arena_effective_transform_composes_ancestors() {
4284        // `arena.effective_transform(id)` must equal the renderer's
4285        // transform-stack top by the time it begins painting `id` —
4286        // i.e. mapping `id`'s pre-transform local point to screen space.
4287        // The renderer's `PushTransform` handler composes as
4288        // `device_t.then(prev_top)` (see `teksilo-render/src/renderer.rs`),
4289        // so the *innermost* transform applies first to a local point.
4290        // For ancestors [outer, inner] both with transforms, this means
4291        // effective = inner.then(outer), NOT outer.then(inner).
4292        // teksilo-scene relies on this to project scene-coord bounds to
4293        // screen space when emitting AT nodes for view-transformed items.
4294        use crate::test_widgets::StackWidget;
4295        let mut tree = WidgetTree::new();
4296        let leaf = tree.add(FillWidget::new());
4297        let inner = tree.add(StackWidget::new().add_child(leaf));
4298        let outer = tree.add(StackWidget::new().add_child(inner));
4299        tree.set_transform(outer, teksilo_canvas::Transform2D::translate(100.0, 0.0));
4300        tree.set_transform(inner, teksilo_canvas::Transform2D::scale(2.0, 2.0));
4301        tree.layout(SizeProposal::exact(50.0, 25.0));
4302
4303        let eff = tree.arena.effective_transform(leaf);
4304        let expected = teksilo_canvas::Transform2D::scale(2.0, 2.0)
4305            .then(&teksilo_canvas::Transform2D::translate(100.0, 0.0));
4306        for (a, b) in eff.m.iter().zip(expected.m.iter()) {
4307            assert!(
4308                (a - b).abs() < 1e-5,
4309                "effective_transform mismatch: got {:?}, want {:?}",
4310                eff.m,
4311                expected.m
4312            );
4313        }
4314
4315        // Concrete-point check that pins the composition order without
4316        // relying on matrix equality alone: a leaf-local point at the
4317        // bounds origin (0, 0) should land at screen (100, 0) — scale
4318        // first (still (0,0)), then translate by 100 in x. With the
4319        // wrong composition order it would land at (200, 0).
4320        let screen_origin = eff.apply_point(Point::new(0.0, 0.0));
4321        assert!((screen_origin.x - 100.0).abs() < 1e-5);
4322        assert!((screen_origin.y - 0.0).abs() < 1e-5);
4323        // Far corner: leaf-local (50, 25) → scale → (100, 50) → translate
4324        // by 100 in x → (200, 50).
4325        let screen_corner = eff.apply_point(Point::new(50.0, 25.0));
4326        assert!((screen_corner.x - 200.0).abs() < 1e-5);
4327        assert!((screen_corner.y - 50.0).abs() < 1e-5);
4328    }
4329
4330    // ─── Context-menu factory: position, ctx, None fall-through ─────────
4331
4332    /// A throwaway content widget the factory mounts. We never paint
4333    /// it — the test only checks that it lands in the overlay manager.
4334    #[derive(Debug)]
4335    struct StubMenu;
4336    impl crate::widget::Widget for StubMenu {
4337        fn layout_response(
4338            &self,
4339            _proposal: SizeProposal,
4340            _ctx: &crate::widget::LayoutContext,
4341        ) -> crate::widget::LayoutResponse {
4342            teksilo_canvas::Size::new(100.0, 40.0).into()
4343        }
4344    }
4345
4346    // The keyboard route to a context menu.
4347    //
4348    // Until this existed there was none at all: no `Key::ContextMenu`, no
4349    // Shift+F10, and `Action::ShowContextMenu` appears in zero of the three
4350    // AccessKit adapters, so the assistive-technology route is dead on every
4351    // platform too. A menu reachable only by right-click is a menu a keyboard
4352    // user does not have.
4353
4354    /// A widget that hands the keyboard a different target than itself, the way
4355    /// every data view does: the container has focus, the row is what the menu
4356    /// is about.
4357    #[derive(Debug)]
4358    struct NominatingWidget {
4359        row: std::cell::Cell<Option<WidgetId>>,
4360    }
4361
4362    impl crate::widget::Widget for NominatingWidget {
4363        fn layout_response(
4364            &self,
4365            proposal: SizeProposal,
4366            _ctx: &LayoutContext,
4367        ) -> crate::widget::LayoutResponse {
4368            proposal.resolve(50.0, 20.0).into()
4369        }
4370
4371        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4372            ctx.apply_self_handlers(crate::widget_builder::HandlerSet::new().focusable(true));
4373            Vec::new()
4374        }
4375
4376        fn context_menu_key_target(&self) -> Option<WidgetId> {
4377            self.row.get()
4378        }
4379    }
4380
4381    fn press(tree: &mut WidgetTree, key: Key, modifiers: Modifiers) {
4382        tree.dispatch_event(WidgetEvent::KeyDown {
4383            key,
4384            modifiers,
4385            text: None,
4386        });
4387    }
4388
4389    #[test]
4390    fn the_context_menu_key_opens_the_focused_widget_menu() {
4391        use std::cell::Cell;
4392        use std::rc::Rc;
4393
4394        let opened = Rc::new(Cell::new(false));
4395        let flag = opened.clone();
4396        let mut tree = WidgetTree::new();
4397        let widget = tree.add(
4398            FillWidget::new()
4399                .focusable()
4400                .context_menu(move |_pos, _ctx| {
4401                    flag.set(true);
4402                    Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
4403                }),
4404        );
4405        tree.layout(SizeProposal::exact(200.0, 100.0));
4406        tree.focus(widget);
4407
4408        press(&mut tree, Key::ContextMenu, Modifiers::NONE);
4409        assert!(opened.get(), "the dedicated Menu key must open the menu");
4410    }
4411
4412    /// The chord every Windows and Linux keyboard can reach, including the many
4413    /// that have no dedicated Menu key at all.
4414    #[test]
4415    fn shift_f10_opens_the_focused_widget_menu() {
4416        use std::cell::Cell;
4417        use std::rc::Rc;
4418
4419        let opened = Rc::new(Cell::new(false));
4420        let flag = opened.clone();
4421        let mut tree = WidgetTree::new();
4422        let widget = tree.add(
4423            FillWidget::new()
4424                .focusable()
4425                .context_menu(move |_pos, _ctx| {
4426                    flag.set(true);
4427                    Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
4428                }),
4429        );
4430        tree.layout(SizeProposal::exact(200.0, 100.0));
4431        tree.focus(widget);
4432
4433        press(&mut tree, Key::F10, Modifiers::SHIFT);
4434        assert!(opened.get(), "Shift+F10 must open the menu");
4435    }
4436
4437    /// Modifiers are matched exactly. Ctrl+Shift+F10 is a different gesture and
4438    /// belongs to the application.
4439    #[test]
4440    fn a_near_miss_chord_is_not_a_context_menu_request() {
4441        use std::cell::Cell;
4442        use std::rc::Rc;
4443
4444        let opened = Rc::new(Cell::new(false));
4445        let flag = opened.clone();
4446        let mut tree = WidgetTree::new();
4447        let widget = tree.add(
4448            FillWidget::new()
4449                .focusable()
4450                .context_menu(move |_pos, _ctx| {
4451                    flag.set(true);
4452                    Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
4453                }),
4454        );
4455        tree.layout(SizeProposal::exact(200.0, 100.0));
4456        tree.focus(widget);
4457
4458        press(&mut tree, Key::F10, Modifiers::SHIFT | Modifiers::CTRL);
4459        press(&mut tree, Key::F10, Modifiers::NONE);
4460        assert!(!opened.get(), "only Shift+F10 exactly asks for a menu");
4461    }
4462
4463    /// The correction the design needed. A data view is focusable and its rows
4464    /// are not, so "the focused widget" is the list, and the menu a user asked
4465    /// for on row 4 would have been the list's own.
4466    #[test]
4467    fn the_keyboard_target_can_be_a_row_rather_than_the_focused_container() {
4468        use std::cell::Cell;
4469        use std::rc::Rc;
4470
4471        let menu_owner = Rc::new(Cell::new(None::<&'static str>));
4472
4473        let row_flag = menu_owner.clone();
4474        let container_flag = menu_owner.clone();
4475
4476        let mut tree = WidgetTree::new();
4477        let row = tree.add(FillWidget::new().context_menu(move |_pos, _ctx| {
4478            row_flag.set(Some("row"));
4479            Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
4480        }));
4481        let container = tree.add(
4482            crate::test_widgets::StackWidget::new()
4483                .add_child(row)
4484                .context_menu(move |_pos, _ctx| {
4485                    container_flag.set(Some("container"));
4486                    Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
4487                }),
4488        );
4489        tree.layout(SizeProposal::exact(200.0, 100.0));
4490
4491        // The container is focused, and nominates the row.
4492        let nominator = tree.add(NominatingWidget {
4493            row: std::cell::Cell::new(Some(row)),
4494        });
4495        tree.layout(SizeProposal::exact(200.0, 100.0));
4496        tree.focus(nominator);
4497        let _ = container;
4498
4499        press(&mut tree, Key::ContextMenu, Modifiers::NONE);
4500        assert_eq!(
4501            menu_owner.get(),
4502            Some("row"),
4503            "the nominated row's factory must be the one that runs"
4504        );
4505    }
4506
4507    /// Nothing on the chain owns a factory, so the framework must not swallow
4508    /// the key: a widget that wants to handle Shift+F10 itself still can.
4509    #[test]
4510    fn the_chord_falls_through_when_there_is_no_menu_to_show() {
4511        use std::cell::Cell;
4512        use std::rc::Rc;
4513
4514        let saw_key = Rc::new(Cell::new(false));
4515        let flag = saw_key.clone();
4516        let mut tree = WidgetTree::new();
4517        let widget = tree.add(FillWidget::new().focusable().on_key(move |ev, _ctx| {
4518            if matches!(ev, WidgetEvent::KeyDown { key: Key::F10, .. }) {
4519                flag.set(true);
4520            }
4521            crate::event::EventResponse::Ignored
4522        }));
4523        tree.layout(SizeProposal::exact(200.0, 100.0));
4524        tree.focus(widget);
4525
4526        press(&mut tree, Key::F10, Modifiers::SHIFT);
4527        assert!(
4528            saw_key.get(),
4529            "with no factory anywhere, the key must reach the widget"
4530        );
4531    }
4532
4533    #[test]
4534    fn context_menu_factory_receives_click_position() {
4535        use crate::event::{Modifiers, PointerButton};
4536        use std::cell::Cell;
4537        use std::rc::Rc;
4538
4539        let captured_position = Rc::new(Cell::new(None::<Point>));
4540        let cap = captured_position.clone();
4541        let mut tree = WidgetTree::new();
4542        let widget = tree.add(FillWidget::new().context_menu(move |pos, _ctx| {
4543            cap.set(Some(pos));
4544            Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
4545        }));
4546        tree.layout(SizeProposal::exact(200.0, 100.0));
4547
4548        let click = Point::new(73.0, 42.0);
4549        tree.dispatch_event(WidgetEvent::PointerDown {
4550            position: click,
4551            button: PointerButton::Secondary,
4552            modifiers: Modifiers::NONE,
4553        });
4554
4555        let got = captured_position.get();
4556        assert_eq!(
4557            got,
4558            Some(click),
4559            "factory must receive the click position; got {:?}",
4560            got
4561        );
4562        let _ = widget;
4563    }
4564
4565    #[test]
4566    fn context_menu_factory_returning_none_falls_through_to_parent() {
4567        use crate::event::{Modifiers, PointerButton};
4568        use crate::test_widgets::StackWidget;
4569        use std::cell::Cell;
4570        use std::rc::Rc;
4571
4572        // Outer factory always returns Some(StubMenu); inner factory
4573        // returns None. Right-click should walk past the inner and
4574        // mount the outer's menu.
4575        let outer_called = Rc::new(Cell::new(0_u32));
4576        let outer_flag = outer_called.clone();
4577        let mut tree = WidgetTree::new();
4578        let inner = tree.add(FillWidget::new().context_menu(|_pos, _ctx| None));
4579        let _outer = tree.add(StackWidget::new().add_child(inner).context_menu(
4580            move |_pos, _ctx| {
4581                outer_flag.set(outer_flag.get() + 1);
4582                Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
4583            },
4584        ));
4585        tree.layout(SizeProposal::exact(200.0, 100.0));
4586
4587        tree.dispatch_event(WidgetEvent::PointerDown {
4588            position: Point::new(50.0, 25.0),
4589            button: PointerButton::Secondary,
4590            modifiers: Modifiers::NONE,
4591        });
4592
4593        assert_eq!(
4594            outer_called.get(),
4595            1,
4596            "inner returning None must fall through to the outer factory"
4597        );
4598    }
4599
4600    #[test]
4601    fn context_menu_factory_none_throughout_chain_does_not_show_overlay() {
4602        use crate::event::{Modifiers, PointerButton};
4603
4604        // Single factory returning None → no overlay shown, no panic.
4605        let mut tree = WidgetTree::new();
4606        tree.add(FillWidget::new().context_menu(|_pos, _ctx| None));
4607        tree.layout(SizeProposal::exact(200.0, 100.0));
4608
4609        let overlay_count_before = tree.overlay_manager.len();
4610        tree.dispatch_event(WidgetEvent::PointerDown {
4611            position: Point::new(50.0, 25.0),
4612            button: PointerButton::Secondary,
4613            modifiers: Modifiers::NONE,
4614        });
4615        let overlay_count_after = tree.overlay_manager.len();
4616        assert_eq!(
4617            overlay_count_before, overlay_count_after,
4618            "a factory returning None must not mount any overlay"
4619        );
4620    }
4621
4622    // ---- Reconcile-on-rebuild (`preserves_children_on_rebuild`) ----------
4623    //
4624    // These pin the contract that the preserve path RECONCILES: it keeps the
4625    // children a rebuild re-attaches (and any subtree re-parented into the new
4626    // tree) while reaping the ones it drops — so memoizing widgets are both
4627    // stateful and leak-free. Regression guard for the orphan-leak the old
4628    // "preserve = destroy nothing" behaviour caused.
4629
4630    /// `build()` mints a fresh child every time and returns only it, abandoning
4631    /// the previous one. Used to prove dropped children are reaped, not leaked.
4632    #[derive(Debug)]
4633    struct FreshChildHost {
4634        preserve: bool,
4635    }
4636    impl Widget for FreshChildHost {
4637        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4638            vec![ctx.add(FillWidget::new())]
4639        }
4640        fn layout_response(
4641            &self,
4642            p: SizeProposal,
4643            _c: &LayoutContext,
4644        ) -> crate::widget::LayoutResponse {
4645            p.resolve(10.0, 10.0).into()
4646        }
4647        fn preserves_children_on_rebuild(&self) -> bool {
4648            self.preserve
4649        }
4650    }
4651
4652    #[test]
4653    fn reconcile_reaps_dropped_children_no_leak() {
4654        // preserve=false (destroy-all) and preserve=true (reconcile) must BOTH
4655        // keep the arena bounded when a rebuild drops its old child. Before the
4656        // reconcile fix, preserve=true grew the arena (and the active set) by
4657        // one stranded orphan per rebuild.
4658        for preserve in [false, true] {
4659            let mut tree = WidgetTree::new();
4660            let host = tree.add(FreshChildHost { preserve });
4661            tree.layout(SizeProposal::exact(100.0, 100.0));
4662            let total0 = tree.arena.len();
4663            let active0 = tree.active_widget_count();
4664            for _ in 0..5 {
4665                tree.arena_mark_needs_rebuild_for_testing(host);
4666                tree.layout(SizeProposal::exact(100.0, 100.0));
4667            }
4668            assert_eq!(
4669                tree.arena.len(),
4670                total0,
4671                "preserve={preserve}: dropped children must be reaped, not leaked"
4672            );
4673            assert_eq!(
4674                tree.active_widget_count(),
4675                active0,
4676                "preserve={preserve}: no stranded still-active orphans"
4677            );
4678        }
4679    }
4680
4681    /// `build()` mints one **detached** node every time — the shape of every
4682    /// pre-built popup in the widget crate (a dropdown, a calendar, a
4683    /// tooltip's cascade children): parked dormant, shown later through an
4684    /// overlay, and deliberately not a child, since activation and paint both
4685    /// descend through `children`.
4686    #[derive(Debug)]
4687    struct DetachedContentHost {
4688        preserve: bool,
4689    }
4690    impl Widget for DetachedContentHost {
4691        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4692            let popup = ctx.add_detached(FillWidget::new());
4693            ctx.set_dormant(popup);
4694            vec![ctx.add(FillWidget::new())]
4695        }
4696        fn layout_response(
4697            &self,
4698            p: SizeProposal,
4699            _c: &LayoutContext,
4700        ) -> crate::widget::LayoutResponse {
4701            p.resolve(10.0, 10.0).into()
4702        }
4703        fn preserves_children_on_rebuild(&self) -> bool {
4704            self.preserve
4705        }
4706    }
4707
4708    #[test]
4709    fn rebuilding_reaps_detached_content_no_leak() {
4710        // A parentless node is reachable from no walk at all — not the child
4711        // teardown, not the accessibility tree, not `active_widget_count`. Held
4712        // by a bare `ctx.add` it simply accumulated: one stranded popup per
4713        // rebuild, for the lifetime of the process. `add_detached` records the
4714        // ownership edge that makes it reapable.
4715        for preserve in [false, true] {
4716            let mut tree = WidgetTree::new();
4717            let host = tree.add(DetachedContentHost { preserve });
4718            tree.layout(SizeProposal::exact(100.0, 100.0));
4719            let total0 = tree.arena.len();
4720            for _ in 0..5 {
4721                tree.arena_mark_needs_rebuild_for_testing(host);
4722                tree.layout(SizeProposal::exact(100.0, 100.0));
4723            }
4724            assert_eq!(
4725                tree.arena.len(),
4726                total0,
4727                "preserve={preserve}: the previous build's detached content must be reaped"
4728            );
4729        }
4730    }
4731
4732    #[test]
4733    fn destroying_a_host_reaps_its_detached_content() {
4734        let mut tree = WidgetTree::new();
4735        let outer = tree.add(FillWidget::new());
4736        tree.layout(SizeProposal::exact(100.0, 100.0));
4737        let empty = tree.arena.len();
4738
4739        let host = tree.add_child(outer, DetachedContentHost { preserve: false });
4740        tree.layout(SizeProposal::exact(100.0, 100.0));
4741        assert!(tree.arena.len() > empty);
4742
4743        tree.destroy_subtree(host);
4744        assert_eq!(
4745            tree.arena.len(),
4746            empty,
4747            "the popup must die with the widget that built it"
4748        );
4749    }
4750
4751    /// Memoizes one child and re-attaches the same id every build.
4752    #[derive(Debug)]
4753    struct StableChildHost {
4754        child: Option<WidgetId>,
4755        probe: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
4756    }
4757    impl Widget for StableChildHost {
4758        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4759            let id = match self.child {
4760                Some(id) => id,
4761                None => {
4762                    let id = ctx.add(FillWidget::new());
4763                    self.child = Some(id);
4764                    self.probe.set(Some(id));
4765                    id
4766                }
4767            };
4768            vec![id]
4769        }
4770        fn layout_response(
4771            &self,
4772            p: SizeProposal,
4773            _c: &LayoutContext,
4774        ) -> crate::widget::LayoutResponse {
4775            p.resolve(10.0, 10.0).into()
4776        }
4777        fn preserves_children_on_rebuild(&self) -> bool {
4778            true
4779        }
4780    }
4781
4782    #[test]
4783    fn reconcile_preserves_reattached_child() {
4784        let probe = std::rc::Rc::new(std::cell::Cell::new(None));
4785        let mut tree = WidgetTree::new();
4786        let host = tree.add(StableChildHost {
4787            child: None,
4788            probe: probe.clone(),
4789        });
4790        tree.layout(SizeProposal::exact(100.0, 100.0));
4791        let child = probe.get().expect("child mounted");
4792        let total0 = tree.arena.len();
4793        for _ in 0..5 {
4794            tree.arena_mark_needs_rebuild_for_testing(host);
4795            tree.layout(SizeProposal::exact(100.0, 100.0));
4796        }
4797        assert!(
4798            tree.arena.is_active(child),
4799            "the re-attached child must survive every rebuild"
4800        );
4801        assert_eq!(tree.arena.len(), total0, "no growth — same child reused");
4802    }
4803
4804    /// Re-homes a node returned from its `build()` under itself.
4805    #[derive(Debug)]
4806    struct Wrapper {
4807        child: WidgetId,
4808    }
4809    impl Widget for Wrapper {
4810        fn build(&mut self, _ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4811            vec![self.child]
4812        }
4813        fn layout_response(
4814            &self,
4815            p: SizeProposal,
4816            _c: &LayoutContext,
4817        ) -> crate::widget::LayoutResponse {
4818            p.resolve(10.0, 10.0).into()
4819        }
4820    }
4821
4822    /// Memoizes a body, then wraps it in a FRESH `Wrapper` each build —
4823    /// re-parenting the body out of the previous (now dropped) wrapper. This is
4824    /// the TabWidget / CompositeTooltip pattern in miniature.
4825    #[derive(Debug)]
4826    struct ReparentHost {
4827        body: Option<WidgetId>,
4828        probe: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
4829    }
4830    impl Widget for ReparentHost {
4831        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
4832            let body = match self.body {
4833                Some(id) => id,
4834                None => {
4835                    let id = ctx.add(FillWidget::new());
4836                    self.body = Some(id);
4837                    self.probe.set(Some(id));
4838                    id
4839                }
4840            };
4841            vec![ctx.add(Wrapper { child: body })]
4842        }
4843        fn layout_response(
4844            &self,
4845            p: SizeProposal,
4846            _c: &LayoutContext,
4847        ) -> crate::widget::LayoutResponse {
4848            p.resolve(10.0, 10.0).into()
4849        }
4850        fn preserves_children_on_rebuild(&self) -> bool {
4851            true
4852        }
4853    }
4854
4855    #[test]
4856    fn reconcile_spares_reparented_survivor() {
4857        // The memoized body is re-parented into a fresh wrapper each rebuild;
4858        // the old wrapper is dropped. The body must survive (it is re-homed),
4859        // and the old wrappers must be reaped (no leak). This is the exact
4860        // failure that destroyed TabWidget's static panel before the fix: the
4861        // parent-authoritative recursion + single-node arena removal spare the
4862        // re-homed body while still reaping the dropped wrapper subtree.
4863        let probe = std::rc::Rc::new(std::cell::Cell::new(None));
4864        let mut tree = WidgetTree::new();
4865        let host = tree.add(ReparentHost {
4866            body: None,
4867            probe: probe.clone(),
4868        });
4869        tree.layout(SizeProposal::exact(100.0, 100.0));
4870        let body = probe.get().expect("body mounted");
4871        let total0 = tree.arena.len();
4872        for _ in 0..5 {
4873            tree.arena_mark_needs_rebuild_for_testing(host);
4874            tree.layout(SizeProposal::exact(100.0, 100.0));
4875        }
4876        assert!(
4877            tree.arena.is_active(body),
4878            "the re-parented body must survive — it was moved into the new tree, \
4879             not swept with the dropped wrapper"
4880        );
4881        assert_eq!(
4882            tree.arena.len(),
4883            total0,
4884            "dropped wrappers reaped — no per-rebuild leak"
4885        );
4886    }
4887
4888    // -----------------------------------------------------------------
4889    // EventContext::ensure_visible / ensure_widget_visible — the
4890    // rect/id-based outer-scroll chase drained in `collect_from_ctx`.
4891    // -----------------------------------------------------------------
4892
4893    /// A `clips_children` container that places its single child at a fixed
4894    /// vertical offset — used to give a child arena bounds *outside* the
4895    /// container's viewport so the id-based `ensure_widget_visible` walk has a
4896    /// reason to dispatch `ScrollIntoView`.
4897    #[derive(Debug)]
4898    struct BelowContainer {
4899        child: Option<WidgetId>,
4900        offset: f32,
4901    }
4902
4903    impl crate::widget::Widget for BelowContainer {
4904        fn layout_response(
4905            &self,
4906            proposal: SizeProposal,
4907            _ctx: &crate::widget::LayoutContext,
4908        ) -> crate::widget::LayoutResponse {
4909            proposal.resolve(0.0, 0.0).into()
4910        }
4911        fn place_children(
4912            &self,
4913            bounds: Rect,
4914            _proposal: SizeProposal,
4915            children: &mut [crate::widget::WidgetPlacement],
4916            _ctx: &crate::widget::LayoutContext,
4917        ) {
4918            for c in children.iter_mut() {
4919                c.origin = Point::new(bounds.x, bounds.y + self.offset);
4920                c.size = bounds.size();
4921            }
4922        }
4923        fn children(&self) -> Vec<WidgetId> {
4924            self.child.into_iter().collect()
4925        }
4926    }
4927
4928    /// A `clips_children` container that records the `ScrollIntoView` it
4929    /// receives, so a test can assert what the framework dispatched to it.
4930    fn recording_scroll_container(
4931        tree: &mut WidgetTree,
4932        child: WidgetId,
4933        recorded: std::rc::Rc<std::cell::Cell<Option<Rect>>>,
4934    ) -> WidgetId {
4935        use crate::test_widgets::StackWidget;
4936        tree.add(
4937            StackWidget::new()
4938                .add_child(child)
4939                .on_scroll(move |ev, _ctx| match ev {
4940                    WidgetEvent::ScrollIntoView { target_bounds, .. } => {
4941                        recorded.set(Some(*target_bounds));
4942                        EventResponse::Handled
4943                    }
4944                    _ => EventResponse::Ignored,
4945                })
4946                .clips_children(true),
4947        )
4948    }
4949
4950    #[test]
4951    fn ensure_visible_dispatches_scroll_into_view_to_clipping_ancestor() {
4952        use std::cell::Cell;
4953        use std::rc::Rc;
4954        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
4955        let mut tree = WidgetTree::new();
4956        let actor = tree.add(FillWidget::new());
4957        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
4958        tree.layout(SizeProposal::exact(100.0, 100.0));
4959
4960        // A rect well below the 100px viewport — the container must be asked to
4961        // reveal it.
4962        let target = Rect::new(10.0, 500.0, 20.0, 15.0);
4963        let mut ctx = EventContext::new();
4964        ctx.ensure_visible(target);
4965        tree.collect_from_ctx(ctx, actor);
4966
4967        assert_eq!(
4968            recorded.get(),
4969            Some(target),
4970            "ensure_visible(rect) must dispatch ScrollIntoView with the exact rect \
4971             to the clips_children ancestor"
4972        );
4973    }
4974
4975    #[test]
4976    fn ensure_visible_is_noop_when_rect_already_visible() {
4977        use std::cell::Cell;
4978        use std::rc::Rc;
4979        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
4980        let mut tree = WidgetTree::new();
4981        let actor = tree.add(FillWidget::new());
4982        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
4983        tree.layout(SizeProposal::exact(100.0, 100.0));
4984
4985        // Fully inside the viewport → the ancestor already shows it, so no
4986        // ScrollIntoView is dispatched.
4987        let mut ctx = EventContext::new();
4988        ctx.ensure_visible(Rect::new(10.0, 10.0, 20.0, 15.0));
4989        tree.collect_from_ctx(ctx, actor);
4990
4991        assert_eq!(
4992            recorded.get(),
4993            None,
4994            "a rect already inside the viewport must not trigger a scroll"
4995        );
4996    }
4997
4998    #[test]
4999    fn ensure_visible_margin_forces_scroll_near_edge() {
5000        use std::cell::Cell;
5001        use std::rc::Rc;
5002        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
5003        let mut tree = WidgetTree::new();
5004        let actor = tree.add(FillWidget::new());
5005        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
5006        tree.layout(SizeProposal::exact(100.0, 100.0));
5007
5008        // Rect at y=95..99 is visible at margin 0, but with a 10px margin its
5009        // padded bottom (109) spills past the 100px viewport → scroll.
5010        let rect = Rect::new(10.0, 95.0, 20.0, 4.0);
5011        let mut ctx = EventContext::new();
5012        ctx.ensure_visible_with_margin(rect, 10.0);
5013        tree.collect_from_ctx(ctx, actor);
5014
5015        assert_eq!(
5016            recorded.get(),
5017            Some(rect),
5018            "the margin must widen the visibility test so a near-edge rect scrolls"
5019        );
5020    }
5021
5022    /// A `clips_children` container that records the alignment and motion of the
5023    /// `ScrollIntoView` it receives.
5024    fn recording_align_container(
5025        tree: &mut WidgetTree,
5026        child: WidgetId,
5027        recorded: std::rc::Rc<
5028            std::cell::Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>,
5029        >,
5030    ) -> WidgetId {
5031        use crate::test_widgets::StackWidget;
5032        tree.add(
5033            StackWidget::new()
5034                .add_child(child)
5035                .on_scroll(move |ev, _ctx| match ev {
5036                    WidgetEvent::ScrollIntoView { align, motion, .. } => {
5037                        recorded.set(Some((*align, *motion)));
5038                        EventResponse::Handled
5039                    }
5040                    _ => EventResponse::Ignored,
5041                })
5042                .clips_children(true),
5043        )
5044    }
5045
5046    #[test]
5047    fn ensure_visible_aligned_scrolls_even_when_already_visible() {
5048        use std::cell::Cell;
5049        use std::rc::Rc;
5050        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
5051        let mut tree = WidgetTree::new();
5052        let actor = tree.add(FillWidget::new());
5053        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
5054        tree.layout(SizeProposal::exact(100.0, 100.0));
5055
5056        // Comfortably inside the viewport — a *minimal* reveal would decline
5057        // (see `ensure_visible_is_noop_when_rect_already_visible`). A pin must
5058        // still fire: re-asserting unconditionally is the whole difference
5059        // between "keep it on screen" and "hold it at this height".
5060        let target = Rect::new(10.0, 10.0, 20.0, 15.0);
5061        let mut ctx = EventContext::new();
5062        ctx.ensure_visible_aligned(target, 0.5, crate::event::ScrollMotion::Instant);
5063        tree.collect_from_ctx(ctx, actor);
5064
5065        assert_eq!(
5066            recorded.get(),
5067            Some(target),
5068            "an aligned reveal must dispatch even when the rect is already visible"
5069        );
5070    }
5071
5072    #[test]
5073    fn ensure_visible_aligned_forwards_fraction_and_motion() {
5074        use std::cell::Cell;
5075        use std::rc::Rc;
5076        let recorded: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
5077            Rc::new(Cell::new(None));
5078        let mut tree = WidgetTree::new();
5079        let actor = tree.add(FillWidget::new());
5080        let _container = recording_align_container(&mut tree, actor, recorded.clone());
5081        tree.layout(SizeProposal::exact(100.0, 100.0));
5082
5083        let mut ctx = EventContext::new();
5084        ctx.ensure_visible_aligned(
5085            Rect::new(10.0, 10.0, 20.0, 15.0),
5086            0.25,
5087            crate::event::ScrollMotion::Smooth,
5088        );
5089        tree.collect_from_ctx(ctx, actor);
5090
5091        assert_eq!(
5092            recorded.get(),
5093            Some((
5094                crate::event::ScrollAlign::Fraction(0.25),
5095                crate::event::ScrollMotion::Smooth
5096            )),
5097            "the container must receive the requested fraction and motion verbatim"
5098        );
5099    }
5100
5101    #[test]
5102    fn ensure_visible_aligned_clamps_the_fraction() {
5103        use std::cell::Cell;
5104        use std::rc::Rc;
5105        let recorded: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
5106            Rc::new(Cell::new(None));
5107        let mut tree = WidgetTree::new();
5108        let actor = tree.add(FillWidget::new());
5109        let _container = recording_align_container(&mut tree, actor, recorded.clone());
5110        tree.layout(SizeProposal::exact(100.0, 100.0));
5111
5112        let mut ctx = EventContext::new();
5113        ctx.ensure_visible_aligned(
5114            Rect::new(10.0, 10.0, 20.0, 15.0),
5115            4.2,
5116            crate::event::ScrollMotion::Instant,
5117        );
5118        tree.collect_from_ctx(ctx, actor);
5119
5120        assert_eq!(
5121            recorded.get().map(|(a, _)| a),
5122            Some(crate::event::ScrollAlign::Fraction(1.0)),
5123            "an out-of-range fraction must clamp rather than aim the pin off-screen"
5124        );
5125    }
5126
5127    #[test]
5128    fn plain_ensure_visible_requests_minimal_alignment() {
5129        use std::cell::Cell;
5130        use std::rc::Rc;
5131        let recorded: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
5132            Rc::new(Cell::new(None));
5133        let mut tree = WidgetTree::new();
5134        let actor = tree.add(FillWidget::new());
5135        let _container = recording_align_container(&mut tree, actor, recorded.clone());
5136        tree.layout(SizeProposal::exact(100.0, 100.0));
5137
5138        let mut ctx = EventContext::new();
5139        ctx.ensure_visible(Rect::new(10.0, 500.0, 20.0, 15.0));
5140        tree.collect_from_ctx(ctx, actor);
5141
5142        assert_eq!(
5143            recorded.get(),
5144            Some((
5145                crate::event::ScrollAlign::Minimal,
5146                crate::event::ScrollMotion::Instant
5147            )),
5148            "the pre-existing reveal API must keep its exact semantics"
5149        );
5150    }
5151
5152    #[test]
5153    fn only_the_innermost_container_aligns() {
5154        use std::cell::Cell;
5155        use std::rc::Rc;
5156        let inner_rec: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
5157            Rc::new(Cell::new(None));
5158        let outer_rec: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
5159            Rc::new(Cell::new(None));
5160
5161        let mut tree = WidgetTree::new();
5162        let actor = tree.add(FillWidget::new());
5163        let inner = recording_align_container(&mut tree, actor, inner_rec.clone());
5164        let _outer = recording_align_container(&mut tree, inner, outer_rec.clone());
5165        tree.layout(SizeProposal::exact(100.0, 100.0));
5166
5167        // Off-screen, so the outer container is asked too (a `Minimal` request
5168        // is gated on visibility).
5169        let mut ctx = EventContext::new();
5170        ctx.ensure_visible_aligned(
5171            Rect::new(10.0, 500.0, 20.0, 15.0),
5172            0.5,
5173            crate::event::ScrollMotion::Instant,
5174        );
5175        tree.collect_from_ctx(ctx, actor);
5176
5177        assert_eq!(
5178            inner_rec.get().map(|(a, _)| a),
5179            Some(crate::event::ScrollAlign::Fraction(0.5)),
5180            "the innermost clipping ancestor owns the pin"
5181        );
5182        assert_eq!(
5183            outer_rec.get().map(|(a, _)| a),
5184            Some(crate::event::ScrollAlign::Minimal),
5185            "an outer container must only bring the inner viewport into view — a \
5186             fraction names a height in one viewport, not in every ancestor's"
5187        );
5188    }
5189
5190    #[test]
5191    fn ensure_widget_visible_uses_target_arena_bounds() {
5192        use std::cell::Cell;
5193        use std::rc::Rc;
5194        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
5195        let mut tree = WidgetTree::new();
5196        // Target lives 500px below the container's top — off the viewport.
5197        let target = tree.add(FillWidget::new());
5198        let rec = recorded.clone();
5199        let container = tree.add(
5200            BelowContainer {
5201                child: Some(target),
5202                offset: 500.0,
5203            }
5204            .on_scroll(move |ev, _ctx| match ev {
5205                WidgetEvent::ScrollIntoView { target_bounds, .. } => {
5206                    rec.set(Some(*target_bounds));
5207                    EventResponse::Handled
5208                }
5209                _ => EventResponse::Ignored,
5210            })
5211            .clips_children(true),
5212        );
5213        tree.layout(SizeProposal::exact(100.0, 100.0));
5214
5215        let expected = tree.bounds(target);
5216        assert!(
5217            expected.y > 100.0,
5218            "fixture sanity: the target must sit below the viewport (y={})",
5219            expected.y
5220        );
5221
5222        // The source widget is irrelevant for the id-based walk — it starts
5223        // from the *target's* parent — so pass the container itself.
5224        let mut ctx = EventContext::new();
5225        ctx.ensure_widget_visible(target);
5226        tree.collect_from_ctx(ctx, container);
5227
5228        assert_eq!(
5229            recorded.get(),
5230            Some(expected),
5231            "ensure_widget_visible(id) must dispatch ScrollIntoView with the \
5232             target's current arena bounds"
5233        );
5234    }
5235
5236    #[test]
5237    fn ensure_widget_visible_ignores_missing_widget() {
5238        // A never-mounted id must neither panic nor dispatch a spurious scroll.
5239        use std::cell::Cell;
5240        use std::rc::Rc;
5241        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
5242        let mut tree = WidgetTree::new();
5243        let actor = tree.add(FillWidget::new());
5244        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
5245        tree.layout(SizeProposal::exact(100.0, 100.0));
5246
5247        let mut ctx = EventContext::new();
5248        ctx.ensure_widget_visible(WidgetId::default());
5249        tree.collect_from_ctx(ctx, actor); // must not panic
5250
5251        assert_eq!(
5252            recorded.get(),
5253            None,
5254            "an unmounted id must not trigger a scroll"
5255        );
5256    }
5257
5258    #[test]
5259    fn context_menu_inside_a_modal_keeps_the_modal() {
5260        // Regression: right-clicking a widget that lives inside an open modal must
5261        // open its context menu WITHOUT tearing down the modal. `show_context_menu_for`
5262        // used to `dismiss_all()`, which closed the very overlay hosting the editor.
5263        use crate::event::{Modifiers, PointerButton, WidgetEvent};
5264        use crate::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
5265        use crate::test_widgets::{FillWidget, StackWidget};
5266
5267        let mut tree = WidgetTree::new();
5268        // A container standing in for the modal's content subtree, with the editor
5269        // (a right-clickable widget) inside it.
5270        let modal_content = tree.add(StackWidget::new());
5271        let _editor = tree.add_child(
5272            modal_content,
5273            FillWidget::new()
5274                .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
5275        );
5276        tree.layout(SizeProposal::exact(200.0, 100.0));
5277
5278        let modal = tree.overlay_manager.show(OverlayRequest {
5279            content_id: modal_content,
5280            anchor: modal_content,
5281            placement: OverlayPlacement::Centered,
5282            dismiss: DismissBehavior::EscapeKey,
5283            layer: OverlayLayer::InTree,
5284            parent_overlay: None,
5285            on_dismiss: None,
5286            fade_duration: None,
5287        });
5288        // Give the overlay real bounds so the right-click hit-tests inside it.
5289        tree.overlay_manager
5290            .stack
5291            .iter_mut()
5292            .find(|o| o.id == modal)
5293            .unwrap()
5294            .bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
5295        assert_eq!(tree.overlay_manager.len(), 1);
5296
5297        // Right-click the editor inside the modal.
5298        tree.dispatch_event(WidgetEvent::PointerDown {
5299            position: Point::new(50.0, 25.0),
5300            button: PointerButton::Secondary,
5301            modifiers: Modifiers::NONE,
5302        });
5303
5304        assert!(
5305            tree.overlay_manager.active_ids().contains(&modal),
5306            "the modal must survive opening a context menu inside it"
5307        );
5308        assert_eq!(
5309            tree.overlay_manager.len(),
5310            2,
5311            "the context menu should now be open on top of the surviving modal"
5312        );
5313    }
5314}