Skip to main content

teksilo_core/widget_tree/
pointer_router.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Pointer, keyboard and accessibility event routing: the dispatch
5//! pipeline, hit-testing, the preview/bubble handler passes and the
6//! context-menu entry points.
7
8use super::*;
9
10use crate::gesture::{GestureEvent, RawPointerEvent, TapEvent};
11
12/// Fire an `EventResponse`-returning handler from BOTH the external
13/// and own slots (in that order). Returns `Handled` if either did,
14/// `Ignored` otherwise. `None` slots are skipped.
15fn fire_event_handler_both(
16    external: &mut Option<Box<dyn FnMut(&WidgetEvent, &mut EventContext) -> EventResponse>>,
17    own: &mut Option<Box<dyn FnMut(&WidgetEvent, &mut EventContext) -> EventResponse>>,
18    event: &WidgetEvent,
19    ctx: &mut EventContext,
20) -> EventResponse {
21    let r1 = external
22        .as_mut()
23        .map(|h| h(event, ctx))
24        .unwrap_or(EventResponse::Ignored);
25    let r2 = own
26        .as_mut()
27        .map(|h| h(event, ctx))
28        .unwrap_or(EventResponse::Ignored);
29    if r1 == EventResponse::Handled || r2 == EventResponse::Handled {
30        EventResponse::Handled
31    } else {
32        EventResponse::Ignored
33    }
34}
35
36/// A dispatch deferred because another was in flight.
37///
38/// Carries everything needed to replay it faithfully: the event, and the input
39/// snapshot that says which pointer produced it. `ops` is not carried — the
40/// drain runs inside the same top-level call, so the caller's sink is still in
41/// hand.
42pub(super) enum QueuedDispatch {
43    /// A nested `dispatch_*` call, replayed verbatim — event *and* input
44    /// snapshot — once the outer sample completes.
45    ///
46    /// The snapshot is boxed: it carries the packet's coalesced positions, so
47    /// it is by some way the largest thing either variant holds, and a queue
48    /// entry that is mostly padding would be paid for on the cancel path too.
49    Event {
50        event: WidgetEvent,
51        snapshot: Box<crate::pointer::InputSnapshot>,
52    },
53    /// A revocation raised through
54    /// [`WidgetTree::cancel_pointer`](crate::WidgetTree::cancel_pointer).
55    ///
56    /// It rides this queue rather than one of its own so that a cancel and the
57    /// dispatch that provoked it cannot be reordered relative to each other:
58    /// one queue is one order. It carries the reason rather than a
59    /// pre-built `PointerCancel`, because the event's position and
60    /// `PointerInfo` must be read from the table at *drain* time — by then the
61    /// pointer may have moved, or ceased to exist, and a snapshot taken at
62    /// queue time would describe a state the widget is no longer in.
63    Cancel {
64        pointer: crate::pointer::PointerId,
65        reason: crate::pointer::CancelReason,
66        /// Who to tell, when the producer knows better than the table does.
67        ///
68        /// The funnel normally addresses the cancel to whoever holds the
69        /// capture. A producer that has *already* taken the capture back as
70        /// part of its own teardown — the OS-drag escalation hands the pointer
71        /// to the platform before it raises the cancel — would leave the
72        /// funnel with nobody to tell, so it names the widget itself.
73        recipient: Option<WidgetId>,
74    },
75}
76
77/// The window-logical position a pointer event happened at, if it carries one.
78///
79/// The pointer table is fed from here: every event with a position refreshes
80/// its pointer's entry, and everything else (keys, IME, focus, AT actions)
81/// leaves the table alone.
82fn pointer_event_position(event: &WidgetEvent) -> Option<Point> {
83    match event {
84        WidgetEvent::PointerDown { position, .. }
85        | WidgetEvent::PointerUp { position, .. }
86        | WidgetEvent::PointerMove { position, .. } => Some(*position),
87        // Deliberately not `PointerCancel`. A revocation must never *create*
88        // a pointer: admitting one here would resurrect a contact the funnel
89        // is in the middle of forgetting, and leave its entry behind for good.
90        _ => None,
91    }
92}
93
94/// What the bubble pass is allowed to run on one node.
95///
96/// Two independent gates, kept together because they answer the same
97/// question — "how much of this node takes part in *this* dispatch".
98#[derive(Copy, Clone)]
99struct BubbleGates {
100    /// Gates the pre-gesture `on_pointer_event` intercept. `true` for the
101    /// bubble target (the widget the event was dispatched at) and `false`
102    /// for every ancestor, because ancestors already fired their
103    /// `on_pointer_event` during the preview pass — firing it again in
104    /// bubble was the source of double-toggle / double-select bugs when a
105    /// wrapper widget (e.g. `ListItemWrapper`) held the handler and a child
106    /// leaf was the hit target.
107    fire_on_pointer_event: bool,
108    /// This node lost the pointer's arbitration, so its **recognizers** stay
109    /// out of the event and it bubbles on as if it carried none. Its own
110    /// handlers still run: losing an arbitration is not the same as being
111    /// removed from the tree. See `WidgetTree::sequence_blocks_arena`.
112    arena_blocked: bool,
113}
114
115impl WidgetTree {
116    /// Hops from `focus` up to `scope_id` (0 when equal), or `None` when
117    /// `scope_id` is not an ancestor-or-self of `focus`. Fewer hops means
118    /// the scope sits closer to focus — i.e. a more specific binding.
119    fn scope_distance_from_focus(&self, focus: WidgetId, scope_id: WidgetId) -> Option<usize> {
120        let mut hops = 0usize;
121        let mut current = Some(focus);
122        while let Some(c) = current {
123            if c == scope_id {
124                return Some(hops);
125            }
126            current = self.arena.parent(c);
127            hops += 1;
128        }
129        None
130    }
131
132    /// From every same-chord shortcut candidate, choose the one whose
133    /// scope applies to the current focus, preferring the most specific
134    /// scope: a `Scoped` binding whose subtree contains focus beats a
135    /// `Global` one, and among nested applicable scopes the one closest
136    /// to focus (fewest hops) wins. Equal-specificity ties keep the
137    /// deterministic `(category, id)` order `candidates` arrives in (the
138    /// first such candidate wins). Returns `None` when no candidate
139    /// applies — every match is a scoped binding outside the focused
140    /// subtree — so the caller falls through to normal KeyDown dispatch.
141    fn select_shortcut_for_focus(
142        &self,
143        candidates: &[(&'static str, crate::shortcut::ShortcutScope, bool)],
144    ) -> Option<(&'static str, crate::shortcut::ShortcutScope, bool)> {
145        use crate::shortcut::ShortcutScope;
146        let mut best: Option<(usize, (&'static str, ShortcutScope, bool))> = None;
147        for &(id, scope, propagate) in candidates {
148            // Specificity score, higher = more specific. Global is the
149            // least-specific fallback (0); any applicable scoped binding
150            // outranks it (`usize::MAX - hops`, so fewer hops = deeper
151            // scope = higher score). Tree depth is tiny, so no overflow.
152            let score = match scope {
153                ShortcutScope::Global => Some(0usize),
154                ShortcutScope::Scoped(scope_id) => self
155                    .focused
156                    .and_then(|f| self.scope_distance_from_focus(f, scope_id))
157                    .map(|hops| usize::MAX - hops),
158            };
159            let Some(score) = score else { continue };
160            // Strictly-greater keeps the first candidate on a tie, so the
161            // existing `(category, id)` precedence holds within a scope.
162            if best.as_ref().is_none_or(|(b, _)| score > *b) {
163                best = Some((score, (id, scope, propagate)));
164            }
165        }
166        best.map(|(_, c)| c)
167    }
168
169    /// Dispatch an event into the widget tree.
170    ///
171    /// Routing rules:
172    /// - Pointer events -> hit testing against layout tree
173    /// - Keyboard/IME events -> focused widget
174    /// - AccessKit actions -> target widget directly
175    /// - Scroll events -> hit testing (scroll target under pointer)
176    ///
177    /// Dispatch an event with the caller-supplied app-level
178    /// [`WindowOps`](crate::window::WindowOps) sink. `teksilo-app` calls
179    /// this variant; handlers can reach the multi-window API
180    /// synchronously (`open_window` creates the winit window inside
181    /// the same call before returning).
182    pub fn dispatch_event_with_ops(
183        &mut self,
184        event: WidgetEvent,
185        ops: &mut dyn crate::window::WindowOps,
186    ) {
187        // A legacy event names no pointer, so it is the mouse at the epoch —
188        // which is exactly what it has always meant.
189        let mut snapshot = crate::pointer::InputSnapshot::from_event(&event);
190        // A legacy `WidgetEvent` carries no timestamp of its own, so stamp it
191        // from the tree clock. Without this the gesture layer would see every
192        // hand-built event at the epoch and no interval — a double tap, a long
193        // press and a swipe would all be undecidable.
194        if snapshot.pointer.time == crate::pointer::EventTime::ZERO {
195            snapshot.pointer.time = self.input_now();
196        }
197        self.dispatch_with_input_snapshot(event, snapshot, ops)
198    }
199
200    /// Dispatch an event on a standalone tree (tests, headless
201    /// scenarios). Handler code that calls `ctx.open_window(...)`
202    /// from within this dispatch will panic — by design. See
203    /// [`dispatch_event_with_ops`](Self::dispatch_event_with_ops)
204    /// for the app-facing variant.
205    pub fn dispatch_event(&mut self, event: WidgetEvent) {
206        let mut noop = crate::window::NoopWindowOps;
207        self.dispatch_event_with_ops(event, &mut noop);
208    }
209
210    // The three ingress doors
211
212    /// Deliver one pointer sample.
213    ///
214    /// This and [`dispatch_scroll`](Self::dispatch_scroll) are the real input
215    /// doors: a backend produces [`PointerSample`](crate::pointer::PointerSample)s
216    /// and [`ScrollSample`](crate::pointer::ScrollSample)s, and everything
217    /// Teksilo knows about *who* is pointing — identity, kind, pressure,
218    /// timestamp, coalesced history — reaches the tree through them.
219    ///
220    /// For now a sample is **lowered** onto the legacy `WidgetEvent` it
221    /// describes and takes the existing route, so a mouse behaves bit for bit
222    /// as it did before the doors existed. What changes here is only that the
223    /// door exists and that the sample's
224    /// [`PointerInfo`](crate::pointer::PointerInfo) is visible to handlers
225    /// through [`EventContext::pointer`](crate::widget::EventContext::pointer).
226    pub fn dispatch_pointer(&mut self, sample: crate::pointer::PointerSample) {
227        let mut noop = crate::window::NoopWindowOps;
228        self.dispatch_pointer_with_ops(sample, &mut noop);
229    }
230
231    /// [`dispatch_pointer`](Self::dispatch_pointer) with the caller's
232    /// app-level [`WindowOps`](crate::window::WindowOps) sink, so handlers can
233    /// reach the multi-window API synchronously.
234    pub fn dispatch_pointer_with_ops(
235        &mut self,
236        sample: crate::pointer::PointerSample,
237        ops: &mut dyn crate::window::WindowOps,
238    ) {
239        use crate::pointer::PointerPhase;
240
241        crate::trace_input!(
242            Samples,
243            "{:?} {:?} at {:?} buttons={:?} t={:?}",
244            sample.phase,
245            sample.pointer.id,
246            sample.position,
247            sample.pointer.buttons,
248            sample.pointer.time
249        );
250
251        // The button a Down/Up is *about*. A direct-pointer contact reports no
252        // button at all, and the widget layer has always been told
253        // `Primary` for a press — that is what a tap is.
254        let button = sample
255            .button
256            .unwrap_or(crate::event::PointerButton::Primary);
257        let event = match sample.phase {
258            PointerPhase::Down => WidgetEvent::PointerDown {
259                position: sample.position,
260                button,
261                modifiers: sample.modifiers,
262                pointer: sample.pointer,
263            },
264            PointerPhase::Move => WidgetEvent::PointerMove {
265                position: sample.position,
266                modifiers: sample.modifiers,
267                pointer: sample.pointer,
268            },
269            PointerPhase::Up => WidgetEvent::PointerUp {
270                position: sample.position,
271                button,
272                modifiers: sample.modifiers,
273                pointer: sample.pointer,
274            },
275            // The platform revoked the contact (a `wl_touch.cancel`, a
276            // `WM_POINTERCAPTURECHANGED`, a compositor grab). It takes the
277            // cancel funnel directly rather than being lowered onto an event:
278            // the funnel owns the teardown order, and lowering would first
279            // *admit* the pointer the sample is revoking.
280            PointerPhase::Cancel => {
281                // The dismissal this contact had armed goes with it. The funnel
282                // below returns early for a pointer with nothing revocable, and
283                // a suppressed arming `Down` leaves exactly that shape — no
284                // sequence, no capture — so the abort cannot ride on it.
285                self.overlay_manager.abort_dismiss(sample.pointer.id);
286                self.cancel_pointer(
287                    sample.pointer.id,
288                    crate::pointer::CancelReason::Platform,
289                    ops,
290                );
291                // A revoked contact ceases to exist, exactly as a lifted one
292                // does — the `ends_pointer` rule below, which this arm returns
293                // before reaching. The funnel ends the entry itself *when it
294                // runs*; for a pointer that had no press to revoke it returns
295                // first, and the entry would outlive the finger. Idempotent, so
296                // the ordinary path is unaffected.
297                if !sample.pointer.kind.hovers() {
298                    self.pointers.end(sample.pointer.id);
299                }
300                return;
301            }
302        };
303
304        // Admit the pointer before anything is dispatched. A palm, or an
305        // eleventh simultaneous contact, is refused here and produces no event
306        // at all — the alternative (evicting a live contact to make room) turns
307        // a pinch into a fling, and letting a resting palm through turns a hand
308        // on a tablet into a stream of taps.
309        if !self.pointers.would_admit(&sample.pointer) {
310            return;
311        }
312        // A contact ceases to exist when it lifts; a hovering-capable pointer
313        // does not — a mouse that releases a button is still there, still
314        // hovering, and its entry is what every legacy singular accessor reads.
315        let ends_pointer = matches!(sample.phase, PointerPhase::Up | PointerPhase::Cancel)
316            && !sample.pointer.kind.hovers();
317        let pointer_id = sample.pointer.id;
318
319        let snapshot = crate::pointer::InputSnapshot::from_pointer_sample(&sample);
320        self.dispatch_with_input_snapshot(event, snapshot, ops);
321
322        if ends_pointer {
323            self.pointers.end(pointer_id);
324        }
325    }
326
327    /// Deliver one scroll sample.
328    ///
329    /// Routing follows [`ScrollSample::position`](crate::pointer::ScrollSample::position):
330    /// `Some` hit-tests it, `None` falls back to the hovered (else focused)
331    /// widget, which is what every scroll did before. A mouse wheel carries no
332    /// position, so this is a no-op for a mouse; a pan synthesised from a
333    /// direct pointer must carry one, because a contact never writes hover.
334    pub fn dispatch_scroll(&mut self, sample: crate::pointer::ScrollSample) {
335        let mut noop = crate::window::NoopWindowOps;
336        self.dispatch_scroll_with_ops(sample, &mut noop);
337    }
338
339    /// [`dispatch_scroll`](Self::dispatch_scroll) with the caller's app-level
340    /// [`WindowOps`](crate::window::WindowOps) sink.
341    pub fn dispatch_scroll_with_ops(
342        &mut self,
343        sample: crate::pointer::ScrollSample,
344        ops: &mut dyn crate::window::WindowOps,
345    ) {
346        crate::trace_input!(
347            Samples,
348            "scroll {:?} {:?}/{:?} at {:?}",
349            sample.delta,
350            sample.phase,
351            sample.source,
352            sample.position
353        );
354
355        let event = WidgetEvent::Scroll {
356            delta: sample.delta,
357            modifiers: sample.modifiers,
358            window_position: sample.position,
359            phase: sample.phase,
360            pointer: sample.pointer,
361        };
362        let snapshot = crate::pointer::InputSnapshot::from_scroll_sample(&sample);
363        self.dispatch_with_input_snapshot(event, snapshot, ops);
364    }
365
366    /// The pointer left the window.
367    ///
368    /// The third ingress door, and the only one that carries no sample: the OS
369    /// says the cursor crossed the window boundary and nothing else. It exists
370    /// because hover is otherwise cleared *only* by a move that lands
371    /// elsewhere — so a mouse that leaves through an edge would leave the last
372    /// widget hovered for as long as it stays away, with its hover chrome
373    /// painted, its `hover_within` signal true and its tooltip still counting
374    /// down.
375    ///
376    /// Clears hover the way a move to an empty spot does: a `PointerLeave` to
377    /// the hovered widget, the tooltip dwell cancelled, the `hover_within`
378    /// chain updated. It touches nothing else — no pointer is cancelled, no
379    /// capture released, no table entry ended. A mouse that leaves the window
380    /// is still a mouse, and a *captured* pointer is deliberately exempt: a
381    /// drag whose pointer wanders off the window keeps its target, which is
382    /// what makes a drag past the edge (and the OS-drag escalation built on
383    /// it) work at all.
384    ///
385    /// There is no matching `pointer_entered_window`, and that is not an
386    /// omission: the enter carries no position either, and a position only
387    /// ever arrives with a `CursorMoved` — which re-arms hover through the
388    /// ordinary path. A door that could only say "somewhere" would have
389    /// nothing to hit-test.
390    pub fn pointer_left_window(&mut self, ops: &mut dyn crate::window::WindowOps) {
391        // The pointer that owns hover is the one that just left; if it holds a
392        // capture, the interaction it is in the middle of outranks the
393        // boundary crossing.
394        if self
395            .pointers
396            .hover_owner_id()
397            .and_then(|id| self.captured_by(id))
398            .is_some()
399        {
400            return;
401        }
402        let Some(hovered) = self.hovered_id() else {
403            return;
404        };
405        // The hover owner is the pointer that just left — read from the table
406        // because this door carries no sample of its own.
407        let leave = WidgetEvent::PointerLeave {
408            pointer: self.hover_transition_pointer(),
409        };
410        self.dispatch_to_widget(hovered, &leave, &mut *ops);
411        self.tooltip_pointer_leave(hovered, &mut *ops);
412        self.set_hovered(None);
413        self.update_hover_within_signals(Some(hovered), None);
414    }
415
416    /// The pointer a hover transition is credited to.
417    ///
418    /// [`PointerEnter`](WidgetEvent::PointerEnter) /
419    /// [`PointerLeave`](WidgetEvent::PointerLeave) are hover-owner events by
420    /// construction — a contact never writes hover — so the answer is the hover
421    /// owner's own [`PointerInfo`](crate::pointer::PointerInfo), read from the
422    /// table rather than from the in-flight sample: a transition can be raised
423    /// by something that is not a pointer sample at all (a relayout that moves
424    /// a widget out from under the cursor, the window-leave door), and a
425    /// hover-incapable pointer must never be credited with hover.
426    ///
427    /// Falls back to a mouse at the current tree time when the table has no
428    /// hover owner, which is the state a synthesized `dispatch_event` leaves it
429    /// in. Either way this is the tree's own view rather than a producer's — see
430    /// the rule stated on
431    /// [`handle_pointer_move`](Self::handle_pointer_move).
432    pub(super) fn hover_transition_pointer(&self) -> crate::pointer::PointerInfo {
433        self.pointers
434            .hover_owner()
435            .map(|entry| entry.info)
436            .unwrap_or_else(|| crate::pointer::PointerInfo::mouse(self.input_now()))
437    }
438
439    /// Run one dispatch with `snapshot` installed as the tree's view of the
440    /// in-flight sample, restoring the previous value afterwards.
441    ///
442    /// Save-and-restore rather than reset-to-default so a nested dispatch (a
443    /// synthetic click queued by a handler, a scroll-into-view walk) leaves the
444    /// outer sample's snapshot intact for the rest of the outer dispatch.
445    fn dispatch_with_input_snapshot(
446        &mut self,
447        event: WidgetEvent,
448        snapshot: crate::pointer::InputSnapshot,
449        ops: &mut dyn crate::window::WindowOps,
450    ) {
451        // A dispatch reached from inside a dispatch — a handler's synthetic
452        // click, an assistive-technology action re-entering the door — is
453        // **queued**, not run inline. Running it inline would let it unwind the
454        // pointer state the outer sample is still standing on: the outer
455        // handler would return to a tree whose hover, capture and table entries
456        // had all moved under it. Queued, the outer dispatch finishes on the
457        // state it started with and the nested one replays immediately
458        // afterwards, so from a caller's side nothing changed — the queue is
459        // empty again before the top-level call returns.
460        self.pending_dispatch.push_back(QueuedDispatch::Event {
461            event,
462            snapshot: Box::new(snapshot),
463        });
464        if self.dispatch_depth > 0 {
465            return;
466        }
467        self.drain_pending_dispatch(&mut *ops);
468    }
469
470    /// Run everything the queue holds, in order, each at depth zero.
471    ///
472    /// `pop_front` in a loop rather than `drain`: a replayed dispatch — or a
473    /// cancel's own `PointerCancel` handler — may queue another entry, and
474    /// each must in turn run at depth zero.
475    pub(super) fn drain_pending_dispatch(&mut self, ops: &mut dyn crate::window::WindowOps) {
476        while let Some(queued) = self.pending_dispatch.pop_front() {
477            match queued {
478                QueuedDispatch::Event { event, snapshot } => {
479                    self.run_one_dispatch(event, *snapshot, &mut *ops);
480                }
481                QueuedDispatch::Cancel {
482                    pointer,
483                    reason,
484                    recipient,
485                } => {
486                    self.run_one_cancel(pointer, reason, recipient, &mut *ops);
487                }
488            }
489        }
490    }
491
492    /// One dispatch at depth zero, with `snapshot` installed as the tree's view
493    /// of the in-flight sample and the previous value restored afterwards.
494    ///
495    /// Save-and-restore rather than reset-to-default: the restore is what stops
496    /// one queued dispatch's snapshot standing as the tree's view of the world
497    /// once it has returned, while the drain replays the next entry.
498    fn run_one_dispatch(
499        &mut self,
500        event: WidgetEvent,
501        snapshot: crate::pointer::InputSnapshot,
502        ops: &mut dyn crate::window::WindowOps,
503    ) {
504        let previous = std::mem::replace(&mut self.current_input, snapshot);
505        self.dispatch_depth += 1;
506        self.dispatch_event_impl(event, ops);
507        self.dispatch_depth -= 1;
508        self.current_input = previous;
509    }
510
511    fn dispatch_event_impl(&mut self, event: WidgetEvent, ops: &mut dyn crate::window::WindowOps) {
512        // Admit the pointer this event belongs to into the table before
513        // anything routes. A legacy `WidgetEvent` names no pointer, so
514        // `current_input` reports the mouse and this creates (or refreshes) the
515        // one mouse entry — which is what every singular accessor then reads,
516        // so a mouse-only tree behaves exactly as it did before the table.
517        if let Some(position) = pointer_event_position(&event) {
518            let is_down = matches!(event, WidgetEvent::PointerDown { .. });
519            let is_move = matches!(event, WidgetEvent::PointerMove { .. });
520            if !self.admit_current_pointer(position, is_down, is_move) {
521                return;
522            }
523            // A hovering-capable pointer takes the hover-owner role by pointing:
524            // the later sample wins, and whoever held it is sent a leave. A
525            // contact is refused the role outright — it has no hover to give.
526            if self.current_input.pointer.kind.hovers() {
527                self.claim_hover_owner_for_current(&mut *ops);
528            }
529        }
530
531        // Track input modality for `:focus-visible`: keyboard input reveals
532        // focus rings, pointer input hides them. Updated at the dispatch root so
533        // every handler (and the next paint) observes the current modality.
534        match &event {
535            WidgetEvent::KeyDown { .. } if !self.focus_visible.get() => {
536                self.focus_visible.set(true);
537            }
538            WidgetEvent::PointerDown { .. } if self.focus_visible.get() => {
539                self.focus_visible.set(false);
540            }
541            _ => {}
542        }
543
544        // The "back toward the parent overlay" key closes the top nested
545        // overlay (e.g. an open submenu over its parent menu). It is the
546        // inline-start arrow: ArrowLeft under LTR, ArrowRight under RTL.
547        // Without the RTL flip, ArrowLeft would navigate *into* a submenu
548        // in RTL menus yet still dismiss it here.
549        let overlay_back_key = match self.layout_direction {
550            crate::environment::LayoutDirection::RightToLeft => Key::ArrowRight,
551            crate::environment::LayoutDirection::LeftToRight => Key::ArrowLeft,
552        };
553        if let WidgetEvent::KeyDown { key, .. } = &event
554            && *key == overlay_back_key
555        {
556            // Count menu-level (non-host) overlays. A revealed collapsible
557            // `MenuBar` is itself a *host* overlay (Role::MenuBar), so a
558            // single open top-level menu sitting over it must NOT be treated
559            // as a nested submenu — otherwise the back key would close the
560            // menu instead of letting the menubar navigate to the previous
561            // one. Only when ≥2 non-host overlays are stacked (a submenu over
562            // its parent menu) does the back key dismiss the top overlay.
563            //
564            // **Menus only**, which is what the band says. Every mounted text
565            // editor keeps one full-viewport affordance host alive in the
566            // [`TextAffordance`](crate::overlay::OverlayBand::TextAffordance)
567            // band for its selection handles, so counting bands alike made two
568            // editors on one page read as a menu cascade: the back key then
569            // tore down an affordance host and returned, and ArrowLeft stopped
570            // reaching *any* editor in that window for as long as a second one
571            // was mounted. A text affordance is not a cascade level, the same
572            // reason `OverlayBand::dismissed_by_outside_press` already excludes
573            // it from press dismissal.
574            //
575            // `dismiss_top` below stays correct because the stack is
576            // band-ordered (`OverlayManager::show_with_auto_dismiss` inserts,
577            // it does not push): a `Standard` overlay always sits above every
578            // `TextAffordance` one, so whenever this count exceeds one the top
579            // of the stack is the menu this key means.
580            let nested_menu_overlays = {
581                let ids: Vec<_> = self
582                    .overlay_manager
583                    .stack
584                    .iter()
585                    .filter(|o| o.band == crate::overlay::OverlayBand::Standard)
586                    .map(|o| o.id)
587                    .collect();
588                ids.into_iter()
589                    .filter(|&id| !self.overlay_is_host_surface(id))
590                    .count()
591            };
592            // The back key only navigates *menu* cascades; it must never close a
593            // dialog / alert / modal that happens to sit on top. Each modal is a
594            // scrim+panel overlay pair and the (non-host) scrims inflate the count
595            // above, so also require the *topmost* overlay to be back-navigable —
596            // i.e. a non-host (menu) surface — before dismissing it.
597            let top_id = self.overlay_manager.stack.last().map(|o| o.id);
598            let top_is_back_navigable = top_id.is_some_and(|id| !self.overlay_is_host_surface(id));
599            if nested_menu_overlays > 1 && top_is_back_navigable {
600                if let Some((_id, content_ids, focus_restore)) = self
601                    .overlay_manager
602                    .dismiss_top_because(crate::overlay::DismissReason::Escape)
603                {
604                    self.dormant_dismissed_content(&content_ids, &mut *ops);
605                    if let Some(restore_id) = focus_restore
606                        && self.arena.is_active(restore_id)
607                    {
608                        self.focus_ops(restore_id, &mut *ops);
609                    }
610                }
611                return;
612            }
613        }
614
615        // Escape retires any shown tooltip first, and does **not** stop there —
616        // see `tooltip_escape_pressed`. Ordered before the stack walk below so
617        // that walk can no longer pick a tooltip as the thing to dismiss, which
618        // is what used to spend the key on a tip nobody was reading while the
619        // editor / menu / dialog the user meant stayed open.
620        if let WidgetEvent::KeyDown {
621            key: Key::Escape, ..
622        } = &event
623        {
624            self.tooltip_escape_pressed();
625        }
626
627        if let WidgetEvent::KeyDown {
628            key: Key::Escape, ..
629        } = &event
630            && !self.overlay_manager.is_empty()
631            && let Some((_id, content_ids, focus_restore)) =
632                self.overlay_manager.try_dismiss_top_on_escape()
633        {
634            self.dormant_dismissed_content(&content_ids, &mut *ops);
635            if let Some(restore_id) = focus_restore
636                && self.arena.is_active(restore_id)
637            {
638                self.focus_ops(restore_id, &mut *ops);
639            }
640            return;
641        }
642
643        // Outside-press overlay dismissal. Two shapes, chosen by the pointer:
644        // an indirect one dismisses on the press and falls through, exactly as
645        // it always has; a direct one *arms* on the press and commits on the
646        // release. See `arm_outside_press_dismissal`.
647        match &event {
648            WidgetEvent::PointerDown {
649                position, button, ..
650            } => {
651                if self.arm_outside_press_dismissal(*position, *button, &mut *ops) {
652                    return;
653                }
654            }
655            WidgetEvent::PointerUp { position, .. } => {
656                if self.commit_outside_press_dismissal(*position, &mut *ops) {
657                    return;
658                }
659            }
660            WidgetEvent::PointerCancel { pointer, .. } => {
661                // A revoked press dismisses nothing. The arming Down was never
662                // delivered beneath either, so the whole gesture leaves no
663                // trace — which is the point of deferring to the release.
664                self.overlay_manager.abort_dismiss(pointer.id);
665            }
666            _ => {}
667        }
668
669        // Key-capture mode: if a callback is armed (via
670        // `WidgetTree::begin_key_capture`), the next KeyDown bypasses
671        // shortcut resolution entirely and runs the callback with
672        // mutable access to the registry AND an `EventContext` so
673        // rebind handlers can also emit commands, send intents,
674        // dismiss overlays, etc. The capture is one-shot; its slot
675        // is emptied before the callback runs so a re-entrant
676        // `begin_key_capture` call from inside the callback arms a
677        // fresh session (rather than competing with the in-flight
678        // one).
679        if let WidgetEvent::KeyDown { key, modifiers, .. } = &event
680            && let Some(callback) = self.take_key_capture()
681        {
682            let keystroke = crate::shortcut::KeyStroke::new(*key, *modifiers);
683            let mut cap_ctx = self.make_event_context(&mut *ops);
684            callback(keystroke, self.shortcut_registry_mut(), &mut cap_ctx);
685            // Route side effects of the callback through the
686            // focused widget (or an arbitrary root if no focus).
687            let anchor = self.focused.or_else(|| self.arena.roots().first().copied());
688            if let Some(anchor_id) = anchor {
689                self.collect_from_ctx(cap_ctx, anchor_id);
690                self.drain_pending_intents(&mut *ops);
691            }
692            return;
693        }
694
695        // Keyboard-capture surfaces (a terminal, a game viewport) opt out
696        // of shortcut resolution entirely while focused: they want every
697        // keystroke delivered raw so a host-app `Ctrl+C` shortcut can't
698        // steal the SIGINT the child process needs. The Escape / overlay
699        // back-navigation handled above still runs first, so an open
700        // overlay is still dismissable. Only a KeyDown is affected; KeyUp
701        // and IME already bypass the shortcut path.
702        let focus_captures_keys = matches!(&event, WidgetEvent::KeyDown { .. })
703            && self.focused.is_some_and(|f| self.is_keyboard_capture(f));
704
705        // Shortcut → intent → action dispatch. A KeyDown whose chord
706        // matches a registered enabled `Shortcut` whose scope contains
707        // the focused widget is consumed here: the shortcut's
708        // `on_activate` runs (producing an `Intent`), its ctx side
709        // effects are collected, and the intent walks source-widget →
710        // root firing any matching `Action`. Otherwise the focused
711        // widget sees the raw KeyDown below.
712        //
713        // Two-phase: the registry is inspected first (immutable read)
714        // to resolve `id / scope / propagate_when_disabled`. Only if
715        // scope matches the current focus do we take a mutable borrow
716        // to invoke `on_activate` — this way a scope mismatch cannot
717        // drop side effects the closure put into its ctx, because
718        // the closure never runs.
719        if !focus_captures_keys && let WidgetEvent::KeyDown { key, modifiers, .. } = &event {
720            let keystroke = crate::shortcut::KeyStroke::new(*key, *modifiers);
721            // Gather every same-chord candidate (owned fields) before any
722            // mutable borrow of the registry, then pick the one whose scope
723            // actually applies to the current focus. `find_by_keystroke`
724            // alone yields only the first by `(category, id)` order, which
725            // can be a `Scoped` binding outside focus shadowing an
726            // applicable `Global` one — or a `Global` binding that should
727            // yield to an in-focus `Scoped` one. Selection needs focus +
728            // the tree, so it happens here, not in the registry.
729            let candidates: Vec<(&'static str, crate::shortcut::ShortcutScope, bool)> = self
730                .shortcut_registry
731                .matches_by_keystroke(keystroke)
732                .map(|eff| {
733                    (
734                        eff.shortcut.id,
735                        eff.shortcut.scope,
736                        eff.shortcut.propagate_when_disabled,
737                    )
738                })
739                .collect();
740            let lookup = self.select_shortcut_for_focus(&candidates);
741            if let Some((id, scope, propagate_when_disabled)) = lookup {
742                let anchor = match scope {
743                    // Global shortcuts fire regardless of focus. If no
744                    // widget is currently focused, anchor the intent
745                    // walk at an arbitrary root so actions registered
746                    // at the top of the tree still see the intent.
747                    crate::shortcut::ShortcutScope::Global => {
748                        self.focused.or_else(|| self.arena.roots().first().copied())
749                    }
750                    crate::shortcut::ShortcutScope::Scoped(scope_id) => {
751                        self.focused.filter(|f| self.is_descendant_of(*f, scope_id))
752                    }
753                };
754                if let Some(anchor_id) = anchor {
755                    let mut act_ctx = self.make_event_context(&mut *ops);
756                    if let Some(intent) =
757                        self.shortcut_registry
758                            .invoke_on_activate(id, keystroke, &mut act_ctx)
759                    {
760                        self.collect_from_ctx(act_ctx, anchor_id);
761                        // Tag shortcut origin so analytics can
762                        // distinguish keyboard-driven activations from
763                        // button / menu / programmatic ones.
764                        let intent = intent.with_source(crate::telemetry::IntentSource::Shortcut);
765                        self.enqueue_intent(anchor_id, intent, propagate_when_disabled);
766                        self.drain_pending_intents(&mut *ops);
767                        return;
768                    }
769                }
770                // Chosen candidate had no anchor after all (e.g. a Global
771                // match while nothing is focused and the tree has no
772                // roots) — fall through to normal KeyDown dispatch.
773                // `on_activate` was never called, so nothing to clean up.
774            }
775            // `lookup` is `None` when every same-chord candidate was a
776            // scoped binding outside the focused subtree — fall through.
777        }
778
779        // Escape during an OS drag we escalated. There is no `active_drag`
780        // any more — `try_escalate_to_os_drag` took it when the platform
781        // accepted the hand-off — so this cannot live in the block below, but
782        // it is the same user gesture and belongs on the same path rather than
783        // being special-cased in the event loop of whichever backend needs it.
784        if self.outbound_drag_source.is_some()
785            && let WidgetEvent::KeyDown {
786                key: Key::Escape, ..
787            } = &event
788        {
789            ops.cancel_os_drag();
790            // Deliberately no `return`: the backend answers asynchronously with
791            // a terminal `DragEnded`, which is what actually tears the session
792            // down via `handle_os_drag_ended`. Swallowing the key here would
793            // also stop Escape from closing whatever else is open.
794        }
795
796        // --- Active drag session handling ---
797        if self.active_drag.is_some() {
798            match &event {
799                WidgetEvent::PointerMove { position, .. } => {
800                    self.handle_drag_move(*position, &mut *ops);
801                    return;
802                }
803                WidgetEvent::PointerUp { position, .. } => {
804                    self.handle_drag_drop(*position, &mut *ops);
805                    return;
806                }
807                WidgetEvent::KeyDown {
808                    key: Key::Escape, ..
809                } => {
810                    self.cancel_active_drag(&mut *ops);
811                    return;
812                }
813                WidgetEvent::Scroll { .. } => {
814                    // Route the wheel to the current drop target so users
815                    // can scroll the list/tree beneath the drag. Then
816                    // synthesise a hover at the stationary pointer so
817                    // feedback, drop-index math and the preview overlay
818                    // all reflect the new scroll offset.
819                    let target_and_pos = self
820                        .active_drag
821                        .as_ref()
822                        .and_then(|d| d.current_target.map(|t| (t, d.current_position)));
823                    if let Some((target, _pos)) = target_and_pos {
824                        self.dispatch_to_widget(target, &event, &mut *ops);
825                    }
826                    if let Some((_, pos)) = target_and_pos
827                        && self.active_drag.is_some()
828                    {
829                        self.handle_drag_move(pos, &mut *ops);
830                    }
831                    return;
832                }
833                _ => {}
834            }
835        }
836
837        // A keyboard route to the context menu, reserved at the dispatcher so
838        // every widget with a `.context_menu(..)` gets one without opting in.
839        //
840        // It has to be here rather than in a widget, and it cannot be a
841        // `Shortcut`: shortcut resolution runs above this point, so a global
842        // binding would fire while the user was typing in a modal. Sitting
843        // below it means an application that deliberately binds Shift+F10 to
844        // something else still wins.
845        if let WidgetEvent::KeyDown { key, modifiers, .. } = &event
846            && is_context_menu_chord(*key, *modifiers)
847            && self.open_context_menu_from_keyboard(&mut *ops)
848        {
849            return;
850        }
851
852        match &event {
853            WidgetEvent::PointerMove { position, .. } => {
854                // Every sample: re-check the sequence's members against the
855                // arena, and record where the pointer now is so each threshold
856                // reads one number.
857                self.note_sequence_position(*position);
858                self.revalidate_sequence(&mut *ops);
859                // Timers before positional thresholds, and before the move
860                // reaches any recognizer — see `tick_sequence_timers`.
861                self.tick_sequence_timers();
862                // The multi-contact and palm layers see every sample, decided
863                // or not: a pinch is arbitrated by contact count rather than by
864                // the press arbitration, and the palm watch has to know whether
865                // this contact ever moved.
866                self.note_palm_sample(*position);
867                self.feed_pinch(super::pan_arbiter::PinchFeed::Move, *position, &mut *ops);
868                if let Some(captured) = self.current_pointer_capture() {
869                    self.dispatch_to_widget(captured, &event, &mut *ops);
870                    // Advance the arbitration so an ancestor drag can still
871                    // begin while a descendant tap holds the capture. Once a
872                    // drag latches, `active_drag` takes over and the capture
873                    // branch above is bypassed.
874                    if self.active_drag.is_none() {
875                        self.advance_sequence(&event, &mut *ops);
876                    }
877                } else {
878                    self.handle_pointer_move(&event, *position, &mut *ops);
879                    // No capture: for a mouse there is nothing enrolled (a
880                    // gesture member is only enrolled *through* a capture), so
881                    // this is a no-op. A contact panning from empty space has
882                    // its pan claimants here.
883                    if self.active_drag.is_none() {
884                        self.advance_sequence(&event, &mut *ops);
885                    }
886                }
887                // After the arbitration, so a claim taken on *this* sample
888                // already delivers its own movement rather than waiting a frame.
889                self.advance_pan(*position, &mut *ops);
890                // …and after that, so the press visual answers to a claim taken
891                // on this very sample rather than surviving it by one move.
892                self.update_press(*position);
893                // A tree-owned hold survives only while the contact holds
894                // still; past the tap boundary this is a pan or a drag.
895                self.touch_route_moved(self.current_pointer_id());
896                // Hover-owner-only, exactly as `handle_pointer_move` is. The
897                // `DismissBehavior::PointerLeave` grace is a *hover* dismissal:
898                // it asks "has the pointer left this overlay and its trigger",
899                // a question only a pointer that hovers is entitled to answer.
900                // Ungated, any contact's move anywhere started the 150 ms grace
901                // on every such overlay — so a submenu a finger had just tapped
902                // open was closed by the frame pass with no further input, and
903                // no mouse test could see it because a mouse *is* the hover
904                // owner.
905                if self.pointers.hover_owner_id() == Some(self.current_pointer_id()) {
906                    self.update_pointer_leave_overlays(*position, &mut *ops);
907                }
908            }
909            WidgetEvent::PointerDown {
910                position, button, ..
911            } => {
912                // A new press is a new interaction: whatever took the previous
913                // one away has nothing to say about this one.
914                let pressed = self.current_pointer_id();
915                self.cancelled_pointers.retain(|p| *p != pressed);
916                // The user has acted — a tooltip that has not yet appeared is
917                // now answering a question nobody is asking any more, and one
918                // already up is covering the thing being clicked. Cancel the
919                // pending dwell and retire any shown non-sticky tip, the way
920                // Windows and GTK both do. Runs before hit-testing so it fires
921                // even for a press that lands on nothing.
922                self.tooltip_pointer_press(Some(*position));
923                // Routed for the pointer that is actually pressing: a finger
924                // gets its grip outsets and its miss-only slop, a mouse gets the
925                // exact test it has always had.
926                let pressing = self.current_input.pointer;
927                if let Some(target) = self.hit_test_for(*position, &pressing) {
928                    if *button == PointerButton::Secondary
929                        && self.show_context_menu_for(
930                            target,
931                            *position,
932                            crate::widget_builder::ContextMenuTrigger::Pointer,
933                            &mut *ops,
934                        )
935                    {
936                        return;
937                    }
938                    // Open the arbitration BEFORE any handler runs: the frozen
939                    // `TouchAction` has to be readable from `ctx.touch_action()`
940                    // inside the press handler, and an explicit
941                    // `capture_pointer()` made there needs a sequence to enrol
942                    // into.
943                    self.begin_sequence(target, *position);
944                    // Straight after the sequence, so the frozen `TouchAction`
945                    // and the enrolled pan members are already in hand — and so
946                    // a press on a coasting list catches it before anything
947                    // else runs.
948                    let modifiers = match &event {
949                        WidgetEvent::PointerDown { modifiers, .. } => *modifiers,
950                        _ => crate::event::Modifiers::NONE,
951                    };
952                    self.begin_pan(target, *position, modifiers);
953                    self.begin_palm_watch(*position);
954                    self.feed_pinch(super::pan_arbiter::PinchFeed::Down, *position, &mut *ops);
955                    // Open the press record before the dispatch, so a handler
956                    // asking `ctx.press_pending()` on its own `PointerDown`
957                    // gets the answer the router already knows.
958                    let focusable = self.find_focusable_at_or_above(target);
959                    self.begin_press(focusable);
960                    // An indirect pointer focuses on press, as it always has.
961                    // A direct one waits for the release: a finger that lands
962                    // on a control and slides away has not chosen it, and
963                    // moving focus at touch-down would leave the ring — and
964                    // the caret — on a control the user never activated. See
965                    // `focus_on_release`.
966                    if !pressing.kind.is_direct()
967                        && let Some(focusable) = focusable
968                    {
969                        self.focus_with_origin_ops(
970                            focusable,
971                            crate::focus::FocusOrigin::Pointer(pressing.kind),
972                            &mut *ops,
973                        );
974                    }
975                    self.dispatch_to_widget(target, &event, &mut *ops);
976                    // Enrol the competitors that only become knowable once the
977                    // press has been dispatched: the drag-capable ancestors of
978                    // whoever took the capture (tap-vs-drag across the hit
979                    // path).
980                    if self.active_drag.is_none() {
981                        self.enrol_sequence_members(&event, &mut *ops);
982                    }
983                    // The arena has now claimed the press, so the node whose
984                    // visual this record drives is known — and whether the
985                    // button that opened it is one that node can act on.
986                    self.adopt_press_owner(*button);
987                    // Last: the tree-owned hold. It has to see the enrolment
988                    // (a deferred grab spends the hold) and it has to see the
989                    // handlers a press-time build may have installed, so it is
990                    // resolved after both. See `super::touch_route`.
991                    self.arm_touch_route(target, *position);
992                }
993            }
994            WidgetEvent::PointerUp { position, .. } => {
995                // A `PointerCancel` is terminal. If this pointer's press was
996                // revoked, the `Up` that follows completes nothing — the widget
997                // has already been told to let go, and delivering the release
998                // would hand it back an interaction the system took away. Drop
999                // it, and forget the cancel: the pointer is free again.
1000                let released = self.current_pointer_id();
1001                // The hold is over whatever else this release does, and it is
1002                // cleared before any of it so a handler that runs below cannot
1003                // see a route that will never fire.
1004                self.cancel_touch_route(released);
1005                if let Some(index) = self.cancelled_pointers.iter().position(|p| *p == released) {
1006                    self.cancelled_pointers.swap_remove(index);
1007                    crate::trace_input!(
1008                        Samples,
1009                        "swallowing the Up for {released:?}: its press was cancelled"
1010                    );
1011                    return;
1012                }
1013                // The palm verdict, before anything else acts on the release:
1014                // a contact the heuristic rejects must fire no tap at all, and
1015                // the only way to guarantee that is to take the cancel funnel
1016                // instead of the release path. Judged on the `Up` and never
1017                // earlier — a contact is not revoked while the user might still
1018                // be doing something with it.
1019                self.feed_pinch(super::pan_arbiter::PinchFeed::Up, *position, &mut *ops);
1020                if self.take_palm_verdict(released) {
1021                    crate::trace_input!(
1022                        Samples,
1023                        "{released:?} released as a palm: large, and it never moved"
1024                    );
1025                    self.cancel_pointer(
1026                        released,
1027                        crate::pointer::CancelReason::PalmRejected,
1028                        &mut *ops,
1029                    );
1030                    return;
1031                }
1032                // A pan hands its release velocity to the fling driver here,
1033                // and closes its session either way.
1034                self.end_pan(*position, &mut *ops);
1035                // The pointer sequence ends here — the release sweep feeds the
1036                // `Up` to every member still following the press so its
1037                // recognizer clears the press origin it recorded. Without this,
1038                // a press that an interactive descendant captured (a card's
1039                // editor, a row's button) leaves the ancestor's DragRecognizer
1040                // armed, and the next hover move starts a phantom drag.
1041                self.note_sequence_position(*position);
1042                self.end_sequence(&event, &mut *ops);
1043                // A direct pointer's focus lands here, before the release is
1044                // dispatched, so a handler activating on the `Up` runs with the
1045                // focus its own press earned.
1046                self.focus_on_release(*position, &mut *ops);
1047                if let Some(captured) = self.current_pointer_capture() {
1048                    self.dispatch_to_widget(captured, &event, &mut *ops);
1049                    // Per pointer: this Up releases *this* pointer's capture and
1050                    // leaves every other contact's alone.
1051                    self.set_current_pointer_capture(None);
1052                } else {
1053                    let releasing = self.current_input.pointer;
1054                    if let Some(target) = self.hit_test_for(*position, &releasing) {
1055                        self.dispatch_to_widget(target, &event, &mut *ops);
1056                    }
1057                }
1058                // The press is over. Any arena still following this contact saw
1059                // its `Down` but not its `Up` — see `release_arenas_following`.
1060                let released = self.current_pointer_id();
1061                self.release_arenas_following(released);
1062                // The visual goes with it. After the dispatch, so a release
1063                // handler reading `ctx.is_pressed()` still sees the press it is
1064                // completing.
1065                self.end_press(released);
1066            }
1067            WidgetEvent::PointerCancel {
1068                reason, pointer, ..
1069            } => {
1070                // A hand-built `PointerCancel` — a caller reaching the legacy
1071                // door with one, a test — means the same thing a producer does,
1072                // so it takes the same funnel rather than a second teardown of
1073                // its own. Queued behind this dispatch, like every cancel.
1074                let (reason, pointer) = (*reason, pointer.id);
1075                self.cancel_pointer(pointer, reason, &mut *ops);
1076            }
1077            WidgetEvent::Scroll {
1078                window_position: position,
1079                ..
1080            } => {
1081                use super::pan_arbiter::ScrollDelivery;
1082                match ScrollDelivery::for_source(self.current_input.scroll_source) {
1083                    // A synthesised pan (and the coast that follows it) walks
1084                    // the pan claimants and nothing else — see
1085                    // `widget_tree::pan_arbiter` for why the generic bubble
1086                    // would be wrong here.
1087                    ScrollDelivery::ClaimantChain => {
1088                        let position = *position;
1089                        self.route_scroll_along_chain(&event, position, &mut *ops);
1090                    }
1091                    // A positioned scroll routes by hit test; a positionless one
1092                    // keeps the historical hover-then-focus fallback. A mouse wheel
1093                    // is positionless, so this is a no-op for it — the change
1094                    // exists for a pan synthesised from a direct pointer, which
1095                    // never writes hover and would otherwise route nowhere.
1096                    ScrollDelivery::Bubble => {
1097                        let scrolling = self.current_input.pointer;
1098                        let target = match position {
1099                            Some(p) => self.hit_test_for(*p, &scrolling),
1100                            None => self.hovered_id().or(self.focused),
1101                        };
1102                        if let Some(target) = target {
1103                            self.dispatch_to_widget(target, &event, &mut *ops);
1104                        }
1105                    }
1106                }
1107            }
1108            WidgetEvent::KeyDown { key, modifiers, .. } => {
1109                if *key == Key::Tab {
1110                    // Ctrl+Tab / Ctrl+Shift+Tab always leave a keyboard-capture
1111                    // surface (WCAG 2.1.2). A capture node exists precisely to
1112                    // swallow every keystroke — a terminal encodes Tab as `\t`
1113                    // and Shift+Tab as CSI Z — so the ordinary "dispatch first,
1114                    // cycle only when unhandled" rule below can never move focus
1115                    // out of one. Reserving this one chord at the dispatcher, not
1116                    // in each capture widget, is what makes the escape a property
1117                    // of `keyboard_capture` itself rather than a promise every
1118                    // future capture-surface author has to remember to keep.
1119                    //
1120                    // Literal `ctrl()`, not `command()`: Ctrl+Tab is Ctrl+Tab on
1121                    // macOS too — ⌘⇥ is the application switcher and never
1122                    // reaches an app at all. Same reading as `TableView`'s
1123                    // cell-grid escape and `RichTextEditor`'s `tab_escape`.
1124                    let captured_focus = self
1125                        .focused
1126                        .is_some_and(|focused| self.is_keyboard_capture(focused));
1127                    if captured_focus && modifiers.ctrl() {
1128                        self.cycle_focus(modifiers.shift(), &mut *ops);
1129                        return;
1130                    }
1131                    // Dispatch Tab to the focused widget first so
1132                    // ancestors (e.g. an open overlay that wants to
1133                    // close instead of moving focus out through its
1134                    // content) get a chance to intercept. Fall back to
1135                    // built-in focus cycling only when no handler
1136                    // returns `EventResponse::Handled`.
1137                    let handled = self
1138                        .focused
1139                        .map(|focused| {
1140                            self.dispatch_to_widget_returning_handled(focused, &event, &mut *ops)
1141                        })
1142                        .unwrap_or(false);
1143                    if !handled {
1144                        self.cycle_focus(modifiers.shift(), &mut *ops);
1145                    }
1146                } else if let Some(focused) = self.focused {
1147                    self.dispatch_to_widget(focused, &event, &mut *ops);
1148                }
1149            }
1150            WidgetEvent::KeyUp { .. }
1151            | WidgetEvent::ImeComposition { .. }
1152            | WidgetEvent::ImeCommit { .. } => {
1153                if let Some(focused) = self.focused {
1154                    self.dispatch_to_widget(focused, &event, &mut *ops);
1155                }
1156            }
1157            WidgetEvent::AccessAction { target, action, .. } => {
1158                // An AT action (e.g. VoiceOver's VO+Space → `Action::Click`)
1159                // always names the node it targets — the element under the
1160                // assistive-technology cursor. It must be delivered to THAT
1161                // node, never to whatever happens to hold keyboard focus.
1162                // Falling back to `self.focused` would make VO+Space fire the
1163                // focused control instead of the cursored one, and would mask
1164                // a stale/inactive target by silently activating something
1165                // else. If the target is missing or no longer active, drop the
1166                // action rather than redirecting it.
1167                //
1168                // Drop it too when a modal is up and the target is behind it
1169                // (`is_behind_modal`). The scrim keeps the pointer off the page
1170                // and the Tab cycle stays in the modal, but the page stays in
1171                // the tree a screen reader walks, and its request names a node
1172                // there directly. Delivered, a click behind "Save changes?"
1173                // opened a second box over it, and a focus request put the
1174                // keyboard on a control the box covers.
1175                if let Some(id) =
1176                    target.filter(|id| self.arena.is_active(*id) && !self.is_behind_modal(*id))
1177                {
1178                    if *action == accesskit::Action::Focus {
1179                        // Land where the keys go. A composite publishes one AT
1180                        // node on a root that is not itself focusable (a
1181                        // `ComboBox` or a `DateEdit` keeps focus on an inner
1182                        // leaf), and `ctx.request_focus` has always walked
1183                        // into the subtree for exactly that reason. The AT path
1184                        // must too: focusing the root parks `self.focused` on a
1185                        // node that takes no keystrokes, and because
1186                        // `on_key_preview` fires only on *strict* ancestors of
1187                        // the focused node, it also disarms the composite's own
1188                        // stepping keys. `first_focusable_descendant` returns the
1189                        // node itself when it is focusable, so every leaf control
1190                        // is unchanged.
1191                        //
1192                        // The walk is gated on the node actually offering
1193                        // `Action::Focus`, which is what makes the sentence
1194                        // above true of composites and only of them. Walking
1195                        // from *any* non-focusable node meant an AT `Focus` on a
1196                        // `Panel`, a `GroupBox`, a landmark or a label moved the
1197                        // keyboard onto the first control inside it — a node the
1198                        // assistive technology could have named itself and did
1199                        // not — and reported success. A node that offers no
1200                        // `Focus` now reports the action unhandled instead,
1201                        // which is the honest answer.
1202                        //
1203                        // A node that stands for a focusable composite (an
1204                        // editor's text body, see `Widget::accessibility_proxy`)
1205                        // is the composite to the assistive technology, so the
1206                        // keyboard goes to the composite.
1207                        if self.advertises_focus_action(id) {
1208                            let target = self
1209                                .focusable_composite_behind(id)
1210                                .or_else(|| self.first_focusable_descendant(id))
1211                                .unwrap_or(id);
1212                            // An assistive move, not a scripted one: the user is
1213                            // navigating, so the focus ring appears exactly as it
1214                            // would for a Tab. `Programmatic` — what this used to
1215                            // pass — declares no modality and left a screen-reader
1216                            // user with an invisible focus after any click.
1217                            self.focus_with_origin_ops(
1218                                target,
1219                                crate::focus::FocusOrigin::Accessibility,
1220                                &mut *ops,
1221                            );
1222                            // Focus is serviced here rather than by the widget,
1223                            // so "handled" means the focus actually landed.
1224                            self.access_action_handled = self.focused == Some(target);
1225                        } else {
1226                            self.access_action_handled = false;
1227                        }
1228                    } else if *action == accesskit::Action::ShowContextMenu {
1229                        // A "show context menu" AT action — a screen reader's
1230                        // menu key, or an automation `right_click` /
1231                        // `invoke_action(node, "show_context_menu")` — first
1232                        // offers itself to the node's own `on_access_action`
1233                        // handlers. If none consume it, fall through to the very
1234                        // same machinery a Secondary `PointerDown` drives, so a
1235                        // widget's `.context_menu(..)` factory opens without the
1236                        // caller having to synthesise a right-click. The AT
1237                        // action carries no point, so anchor the menu at the
1238                        // node's centre. Without this, the AT action was a silent
1239                        // no-op for every widget that wires its menu through the
1240                        // factory (i.e. all of them) — see `show_context_menu_for`.
1241                        // Handled = the widget consumed it, or the factory
1242                        // fallback actually opened a menu. A node with neither
1243                        // reports unhandled rather than a silent success.
1244                        self.access_action_handled =
1245                            if self.dispatch_to_widget_returning_handled(id, &event, &mut *ops) {
1246                                true
1247                            } else {
1248                                let position = self.arena.bounds(id).center();
1249                                self.show_context_menu_for(
1250                                    id,
1251                                    position,
1252                                    crate::widget_builder::ContextMenuTrigger::Accessibility,
1253                                    &mut *ops,
1254                                )
1255                            };
1256                    } else {
1257                        self.access_action_handled =
1258                            self.dispatch_to_widget_returning_handled(id, &event, &mut *ops);
1259                    }
1260                }
1261            }
1262            WidgetEvent::Gesture { .. } => {
1263                if let Some(target) = self.hovered_id().or(self.focused) {
1264                    self.dispatch_to_widget(target, &event, &mut *ops);
1265                }
1266            }
1267            WidgetEvent::ScrollIntoView { .. }
1268            | WidgetEvent::PointerEnter { .. }
1269            | WidgetEvent::PointerLeave { .. }
1270            | WidgetEvent::FocusGained { .. }
1271            | WidgetEvent::FocusLost => {}
1272        }
1273        // Any intents queued by handlers via `ctx.send_intent(...)`
1274        // are dispatched after the raw event has been handled but
1275        // before commands are flushed, so commands emitted from
1276        // action handlers land on the same tick.
1277        self.drain_pending_intents(&mut *ops);
1278    }
1279
1280    /// Open the context menu the keyboard just asked for, and report whether
1281    /// one appeared.
1282    ///
1283    /// Targets the focused widget, or whatever its
1284    /// [`context_menu_key_target`](crate::widget::Widget::context_menu_key_target)
1285    /// nominates instead — for a data view, the selected row. Anchors the menu
1286    /// at the target's own bounds rather than at the last pointer position,
1287    /// which may be anywhere on screen or nowhere at all.
1288    ///
1289    /// Returns `false` when nothing on the ancestor chain owns a factory, so
1290    /// the key falls through to normal dispatch and a widget that wants to
1291    /// handle it itself still can.
1292    fn open_context_menu_from_keyboard(&mut self, ops: &mut dyn crate::window::WindowOps) -> bool {
1293        let Some(focused) = self.focused else {
1294            return false;
1295        };
1296        let target = self
1297            .arena
1298            .get(focused)
1299            .and_then(|node| node.widget.context_menu_key_target())
1300            .filter(|id| self.arena.is_active(*id))
1301            .unwrap_or(focused);
1302
1303        // The menu belongs where the thing it is about is. A keyboard user has
1304        // no pointer position, and the stale one is worse than useless: it
1305        // would put the menu over an unrelated part of the window.
1306        let bounds = self.bounds(target);
1307        let anchor = Point {
1308            x: bounds.x + bounds.width / 2.0,
1309            y: bounds.y + bounds.height / 2.0,
1310        };
1311        self.show_context_menu_for(
1312            target,
1313            anchor,
1314            crate::widget_builder::ContextMenuTrigger::Keyboard,
1315            ops,
1316        )
1317    }
1318
1319    /// Ask the factories from `target` up for a menu, and mount the first one
1320    /// offered at `position`.
1321    ///
1322    /// `trigger` reaches each factory through
1323    /// [`EventContext::context_menu_trigger`](crate::widget::EventContext::context_menu_trigger):
1324    /// only a pointer's `position` is a place the user chose, and a factory
1325    /// that moves a caret to it on a keyboard request writes the menu's Paste
1326    /// into the middle of the text.
1327    pub(super) fn show_context_menu_for(
1328        &mut self,
1329        target: WidgetId,
1330        position: Point,
1331        trigger: crate::widget_builder::ContextMenuTrigger,
1332        ops: &mut dyn crate::window::WindowOps,
1333    ) -> bool {
1334        // Walks up the parent chain calling each factory in turn. A
1335        // factory returning `Some(menu)` claims the click and mounts;
1336        // a factory returning `None` declines and the walk continues.
1337        // No factory anywhere on the chain → fall through to whatever
1338        // the caller does with the unconsumed PointerDown.
1339        let mut ctx = self.make_event_context(&mut *ops);
1340        ctx.context_menu_trigger = Some(trigger);
1341        let mut walker = Some(target);
1342        let menu_decision: Option<(WidgetId, Box<dyn Widget>)> = loop {
1343            // Walk to the next ancestor (including `walker` itself)
1344            // that owns a factory.
1345            let owner_id = {
1346                let mut probe = walker;
1347                loop {
1348                    match probe {
1349                        None => break None,
1350                        Some(id) => {
1351                            if self
1352                                .arena
1353                                .get(id)
1354                                .is_some_and(|node| node.context_menu_factory.is_some())
1355                            {
1356                                break Some(id);
1357                            }
1358                            probe = self.arena.get(id).and_then(|node| node.parent);
1359                        }
1360                    }
1361                }
1362            };
1363            let Some(owner_id) = owner_id else {
1364                break None;
1365            };
1366            // Invoke the factory with the click position and a real
1367            // EventContext. The factory is `Fn` (not FnMut), so we
1368            // can call it through an immutable borrow on the node.
1369            // `ctx` is a local — its `&mut WindowOps` lifetime is
1370            // disjoint from `self.arena`, so the immutable arena
1371            // borrow doesn't conflict with the mutable ctx borrow.
1372            let outcome: Option<Box<dyn Widget>> = {
1373                let node = self
1374                    .arena
1375                    .get(owner_id)
1376                    .expect("owner_id from active arena walk");
1377                let factory = node
1378                    .context_menu_factory
1379                    .as_ref()
1380                    .expect("owner_id only set when factory present");
1381                factory(position, &mut ctx)
1382            };
1383            match outcome {
1384                Some(menu) => break Some((owner_id, menu)),
1385                None => {
1386                    // Decline → keep walking up from the parent.
1387                    walker = self.arena.get(owner_id).and_then(|n| n.parent);
1388                }
1389            }
1390        };
1391
1392        // Drain ctx side effects regardless of whether a menu showed —
1393        // a factory that returns `None` may still have queued intents,
1394        // updated signals, or requested a frame.
1395        let drain_anchor = menu_decision
1396            .as_ref()
1397            .map(|(id, _)| *id)
1398            .or_else(|| self.arena.roots().first().copied())
1399            .unwrap_or(target);
1400        self.collect_from_ctx(ctx, drain_anchor);
1401
1402        let Some((owner_id, menu_widget)) = menu_decision else {
1403            return false;
1404        };
1405
1406        // Clear stale transient overlays (other menus / popovers) before mounting
1407        // the new menu, but KEEP any overlay that *contains* the right-clicked
1408        // widget — otherwise a right-click inside a modal editor would tear down
1409        // the modal it lives in (dismiss_all did exactly that). The context menu
1410        // then mounts on top of its host overlay.
1411        let keep: std::collections::HashSet<WidgetId> = self
1412            .overlay_manager
1413            .stack
1414            .iter()
1415            .map(|o| o.content_id)
1416            .filter(|&content_id| self.is_descendant_of(owner_id, content_id))
1417            .collect();
1418        let dismissed = self.overlay_manager.dismiss_except(&keep);
1419        self.dormant_dismissed_content(&dismissed, &mut *ops);
1420
1421        let content_id = self.add_boxed(menu_widget);
1422        let prev_focus = self.focused;
1423        // One branch, every menu: a coarse pointer gets a placement that keeps
1424        // clear of its own contact patch, everything else the historical
1425        // `AtPointer`. A point-anchored panel that puts its own corner under
1426        // the finger is the defect this exists to fix, and it is invisible from
1427        // a mouse — which is why the decision lives in
1428        // `OverlayPlacement::at_pointer_for` rather than at each call site.
1429        let placement =
1430            crate::overlay::OverlayPlacement::at_pointer_for(position, &self.current_input.pointer);
1431        self.overlay_manager.show(crate::overlay::OverlayRequest {
1432            content_id,
1433            anchor: owner_id,
1434            placement,
1435            dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
1436            layer: crate::overlay::OverlayLayer::InTree,
1437            parent_overlay: None,
1438            on_dismiss: None,
1439            fade_duration: None,
1440        });
1441        if let Some(focus_id) = prev_focus {
1442            self.overlay_manager.set_top_focus_restore(focus_id);
1443        }
1444        self.focus_ops(content_id, &mut *ops);
1445        // Flush intents the factory queued so they take effect on the
1446        // same dispatch tick as the menu mount. The caller's
1447        // PointerDown handler returns after we return `true`, skipping
1448        // its own drain — fire ours here.
1449        self.drain_pending_intents(&mut *ops);
1450        true
1451    }
1452
1453    // -----------------------------------------------------------------
1454    // Outside-press overlay dismissal
1455    // -----------------------------------------------------------------
1456
1457    /// Handle a press that lands outside one or more dismissable overlays.
1458    ///
1459    /// Returns `true` when the press is consumed and must not reach the tree.
1460    ///
1461    /// **An indirect pointer is unchanged.** It dismisses on the press and
1462    /// falls through, so one click still closes a menu and actuates the control
1463    /// beneath — deliberate behaviour for a cursor, which names one pixel that
1464    /// the user could see the whole time they were aiming at it.
1465    ///
1466    /// **A direct pointer arms instead.** A finger covers what it is about to
1467    /// actuate: the menu is the only thing the user was looking at, and the
1468    /// control underneath is one they never saw. So the `Down` is withheld from
1469    /// the tree, the dismissal waits for the release, and a press that is
1470    /// cancelled — or slid onto the very overlay it would have closed — leaves
1471    /// nothing behind at all. Both the arming `Down` and the committing `Up`
1472    /// are consumed, so nothing beneath ever sees half a press.
1473    fn arm_outside_press_dismissal(
1474        &mut self,
1475        position: Point,
1476        button: PointerButton,
1477        ops: &mut dyn crate::window::WindowOps,
1478    ) -> bool {
1479        self.prune_stale_dismiss_arms();
1480        let pressing = self.current_input.pointer;
1481        let busy = self.busy_press_points(pressing.id);
1482
1483        if pressing.kind.is_direct() {
1484            return self
1485                .overlay_manager
1486                .arm_dismiss(pressing.id, position, &busy)
1487                .suppress_beneath;
1488        }
1489
1490        let (dismissed, focus_restore, toggle_anchors) =
1491            self.overlay_manager.dismiss_outside_press(position, &busy);
1492        if dismissed.is_empty() {
1493            return false;
1494        }
1495        self.dormant_dismissed_content(&dismissed, &mut *ops);
1496        if let Some(restore_id) = focus_restore
1497            && self.arena.is_active(restore_id)
1498        {
1499            self.focus_ops(restore_id, &mut *ops);
1500        }
1501        // The press dismissed one or more overlays. By default it
1502        // now ALSO falls through to the widget under the cursor,
1503        // so a single click both closes the menu/popover and
1504        // activates the control beneath — the behaviour a
1505        // secondary press already had. The one case still
1506        // swallowed: a primary press on the anchor of a
1507        // click-opened overlay, because the anchor's own tap
1508        // handler would otherwise reopen the overlay this very
1509        // press just dismissed (click-the-trigger-to-close).
1510        button == PointerButton::Primary
1511            && toggle_anchors.iter().any(|&anchor| {
1512                self.arena.is_active(anchor) && self.arena.bounds(anchor).contains(position)
1513            })
1514    }
1515
1516    /// Complete a direct pointer's armed dismissal on its release.
1517    ///
1518    /// Returns `true` when this pointer held an arm — in which case the `Up` is
1519    /// consumed whether or not anything actually closed. Nothing beneath saw
1520    /// the `Down`, so delivering the `Up` alone would hand a widget the second
1521    /// half of a press it never started.
1522    fn commit_outside_press_dismissal(
1523        &mut self,
1524        position: Point,
1525        ops: &mut dyn crate::window::WindowOps,
1526    ) -> bool {
1527        let pointer = self.current_pointer_id();
1528        if !self.overlay_manager.has_armed_dismiss(pointer) {
1529            return false;
1530        }
1531        let (dismissed, focus_restore, _anchors) =
1532            self.overlay_manager.commit_dismiss(pointer, position);
1533        if !dismissed.is_empty() {
1534            self.dormant_dismissed_content(&dismissed, &mut *ops);
1535            if let Some(restore_id) = focus_restore
1536                && self.arena.is_active(restore_id)
1537            {
1538                self.focus_ops(restore_id, &mut *ops);
1539            }
1540        }
1541        true
1542    }
1543
1544    /// Where every *other* live pointer is holding a press.
1545    ///
1546    /// A press is only "outside" relative to the overlays nobody else is
1547    /// working in — see
1548    /// [`OverlayManager::dismiss_outside_press`](crate::overlay::OverlayManager::dismiss_outside_press).
1549    /// Liveness is judged with
1550    /// [`press_is_revocable`](Self::press_is_revocable), the predicate the
1551    /// cancel funnel already uses, and for the same reason: a pointer with
1552    /// neither a sequence nor a capture has no interaction that could be taken
1553    /// away, so it has none to protect either. That also exempts a pointer
1554    /// inside its terminal `Up` — its sequence is still installed but already
1555    /// terminating — which is what lets a menu item's own handler close its
1556    /// menu while a second contact rests elsewhere.
1557    ///
1558    /// The *press* position, not the current one: a contact that grabbed a
1559    /// menu and dragged past its edge is still manipulating that menu.
1560    fn busy_press_points(&self, exclude: crate::pointer::PointerId) -> Vec<Point> {
1561        self.pointers
1562            .iter()
1563            .filter(|entry| entry.info.id != exclude)
1564            .filter(|entry| self.press_is_revocable(entry.info.id))
1565            .map(|entry| entry.down_position)
1566            .collect()
1567    }
1568
1569    /// Drop arms whose contact is gone.
1570    ///
1571    /// An arm is normally retired by its own `Up` or `Cancel`. A contact that
1572    /// disappears without either — a table sweep, a window losing its input —
1573    /// would otherwise leave one behind, and a later contact minted with the
1574    /// same id would inherit a dismissal it never asked for.
1575    fn prune_stale_dismiss_arms(&mut self) {
1576        let stale: Vec<crate::pointer::PointerId> = self
1577            .overlay_manager
1578            .armed_pointers()
1579            .into_iter()
1580            .filter(|id| self.pointers.get(*id).is_none())
1581            .collect();
1582        for id in stale {
1583            self.overlay_manager.abort_dismiss(id);
1584        }
1585    }
1586
1587    // -----------------------------------------------------------------
1588    // Press state
1589    // -----------------------------------------------------------------
1590
1591    /// Open the press record for the contact being dispatched.
1592    ///
1593    /// Called from the `PointerDown` arm before the press is dispatched, with
1594    /// the focusable the press landed on (which a direct pointer will hold
1595    /// until its release). The node whose visual the record drives is not
1596    /// known yet — the arena claims the press during the dispatch — so
1597    /// [`adopt_press_owner`](Self::adopt_press_owner) finishes the record
1598    /// afterwards.
1599    ///
1600    /// The press-feedback delay applies **only inside a pan claimant**: a
1601    /// finger resting on a list row must not flash the row before the pan has
1602    /// been ruled out, while a button that nothing can scroll has no ambiguity
1603    /// to wait out and highlights at once. `begin_pan` has already decided
1604    /// whether this press is inside one — a session exists only when a claimant
1605    /// along the hit path accepts this pointer kind — so this reads its answer
1606    /// rather than re-deriving it.
1607    fn begin_press(&mut self, focusable: Option<WidgetId>) {
1608        let pointer = self.current_pointer_id();
1609        let now = self.sequence_now();
1610        let delay = self
1611            .pan_session_open(pointer)
1612            .then(|| self.current_profile().press_feedback_delay)
1613            .filter(|d| !d.is_zero());
1614        self.presses.press(pointer, focusable, now, delay);
1615    }
1616
1617    /// Record the node whose gesture arena took this press, and publish its
1618    /// signal.
1619    ///
1620    /// The owner is the sequence's `pressed_owner` — the node holding the
1621    /// pointer capture once the `Down` has been dispatched, which is exactly
1622    /// the node whose `on_tap` would fire. A press no arena took owns no
1623    /// visual, and its record stays for the focus deferral alone.
1624    ///
1625    /// So does a press on a **button the owner cannot act on**. A press visual
1626    /// says "release here and this control acts", so it has to answer to the
1627    /// same buttons the activation does: the router opens a record for every
1628    /// button, but only [`press_buttons`](Self::press_buttons) — the union of
1629    /// the owner's own click-style [`ButtonMask`](crate::event::ButtonMask)s,
1630    /// `PRIMARY` unless the widget widened it — decides which of them may light
1631    /// the control up. Without the gate a middle-click, or a right-click on a
1632    /// node with no context menu, would raise a press that can never complete;
1633    /// with it, the visual and the activation agree by construction, exactly as
1634    /// they do at the tap boundary where one predicate fails the tap, fires
1635    /// `cancel_taps` and clears the visual.
1636    ///
1637    /// The record itself is untouched either way, so the focus a direct
1638    /// pointer deferred to its release is still there to be assigned.
1639    fn adopt_press_owner(&mut self, button: PointerButton) {
1640        let pointer = self.current_pointer_id();
1641        let Some(owner) = self.current_sequence().and_then(|s| s.pressed_owner()) else {
1642            return;
1643        };
1644        if !self.press_buttons(owner).contains(button) {
1645            crate::trace_input!(
1646                Samples,
1647                "no press visual for {owner:?}: {button:?} is not one of its accepted buttons"
1648            );
1649            return;
1650        }
1651        self.presses.set_owner(pointer, owner);
1652        self.publish_pressed(owner);
1653    }
1654
1655    /// Re-evaluate the press being dispatched against `position`.
1656    ///
1657    /// Three ways a press visual changes without a release:
1658    ///
1659    /// * the pointer left the press's [`TapBoundary`](crate::gesture::TapBoundary)
1660    ///   — the same predicate that fails the tap and fires `cancel_taps`, so
1661    ///   the visual and the activation are abandoned together;
1662    /// * it came back inside, which restores the visual: WCAG 2.2 SC 2.5.2's
1663    ///   abort gesture is reversible right up to the release;
1664    /// * the arbitration decided for somebody else — a pan claimant, an
1665    ///   ancestor drag — and the pressed control has lost the press without
1666    ///   ever seeing a release.
1667    fn update_press(&mut self, position: Point) {
1668        let pointer = self.current_pointer_id();
1669        if self.presses.get(pointer).is_none() {
1670            return;
1671        }
1672        // A peer claim ends the press outright: the node was never told, and
1673        // leaving its visual up would advertise an interaction it has lost.
1674        let claimed_elsewhere = self.current_sequence().is_some_and(|sequence| {
1675            sequence
1676                .winner()
1677                .is_some_and(|winner| Some(winner) != sequence.pressed_owner())
1678        });
1679        if claimed_elsewhere {
1680            self.end_press(pointer);
1681            return;
1682        }
1683        let profile = self.current_profile();
1684        let origin = self.current_sequence().map(|s| s.press_origin());
1685        let owner = self.presses.get(pointer).and_then(|p| p.owner);
1686        let inside = match (origin, owner) {
1687            (Some(origin), Some(owner)) => {
1688                let bounds = self
1689                    .arena
1690                    .is_active(owner)
1691                    .then(|| self.arena.bounds(owner));
1692                !crate::gesture::TapBoundary::for_pointer(&self.current_input.pointer, &profile)
1693                    .left(origin, position, bounds, &profile)
1694            }
1695            // No sequence to measure from, or no owner to measure against:
1696            // there is no visual either way, so the flag is moot.
1697            _ => true,
1698        };
1699        let Some(press) = self.presses.get_mut(pointer) else {
1700            return;
1701        };
1702        if press.inside == inside {
1703            return;
1704        }
1705        press.inside = inside;
1706        if let Some(owner) = owner {
1707            self.publish_pressed(owner);
1708        }
1709    }
1710
1711    /// Close the press held by `pointer` and republish the node it drove.
1712    ///
1713    /// The one exit: a release, a cancel, and a peer claim all come through
1714    /// here, so a node can never be left painted as pressed by a path that
1715    /// forgot to clear it.
1716    pub(crate) fn end_press(&mut self, pointer: crate::pointer::PointerId) {
1717        if let Some(owner) = self.presses.release(pointer) {
1718            self.publish_pressed(owner);
1719        }
1720    }
1721
1722    /// Resolve every elapsed press-feedback delay and publish the visuals that
1723    /// just appeared. Driven by the same tick that advances the long press.
1724    pub(crate) fn resolve_press_delays(&mut self, now: crate::pointer::EventTime) {
1725        if self.presses.is_empty() {
1726            return;
1727        }
1728        for id in self.presses.resolve_delays(now) {
1729            self.publish_pressed(id);
1730            self.arena.mark_needs_paint(id);
1731        }
1732    }
1733
1734    /// The earliest instant a pending press wants the event loop back, so a
1735    /// finger that lands and does not move still gets its highlight.
1736    pub(crate) fn next_press_deadline(&self) -> Option<std::time::Instant> {
1737        self.presses.next_deadline().map(|t| self.instant_for(t))
1738    }
1739
1740    /// Assign the focus a direct pointer's press deferred, if the release
1741    /// earned it.
1742    ///
1743    /// The guard is "the release landed on the same focusable as the press".
1744    /// A finger that presses a button, slides onto its neighbour and lifts has
1745    /// activated nothing and must move focus nowhere — the same rule the tap
1746    /// recognizer applies to activation, applied to focus so the two cannot
1747    /// disagree. A press that found no focusable defers nothing.
1748    ///
1749    /// A no-op for an indirect pointer, which focused at press.
1750    fn focus_on_release(&mut self, position: Point, ops: &mut dyn crate::window::WindowOps) {
1751        let releasing = self.current_input.pointer;
1752        if !releasing.kind.is_direct() {
1753            return;
1754        }
1755        let pointer = self.current_pointer_id();
1756        let Some(pressed) = self.presses.get(pointer).and_then(|p| p.focusable) else {
1757            return;
1758        };
1759        let released_on = self
1760            .hit_test_for(position, &releasing)
1761            .and_then(|target| self.find_focusable_at_or_above(target));
1762        if released_on == Some(pressed) {
1763            self.focus_with_origin_ops(
1764                pressed,
1765                crate::focus::FocusOrigin::Pointer(releasing.kind),
1766                &mut *ops,
1767            );
1768        }
1769    }
1770
1771    /// The hover walk for one move: hit-test, run the enter/leave transitions,
1772    /// then deliver `event` to whatever the pointer is now over.
1773    ///
1774    /// `event` is the `PointerMove` being dispatched and is forwarded
1775    /// **verbatim** — its `modifiers` (a Shift or Ctrl pressed mid-drag) and its
1776    /// `pointer` are the producer's own, not a copy assembled here. `position`
1777    /// is that event's position, passed separately only because every step below
1778    /// needs it.
1779    ///
1780    /// Which is the rule for the whole router: a producer's event reaches the
1781    /// widget carrying what the producer said, while an event the *tree*
1782    /// synthesizes — the enter/leave pair below — carries the tree's own view of
1783    /// who is pointing, read from the pointer table. The two differ only for a
1784    /// hand-built legacy event, which names no time and is stamped from the tree
1785    /// clock into the snapshot [`EventContext::pointer`] reports (see
1786    /// [`dispatch_event_with_ops`](Self::dispatch_event_with_ops)) without that
1787    /// stamp being written back onto the event.
1788    ///
1789    /// [`EventContext::pointer`]: crate::widget::EventContext::pointer
1790    fn handle_pointer_move(
1791        &mut self,
1792        event: &WidgetEvent,
1793        position: Point,
1794        ops: &mut dyn crate::window::WindowOps,
1795    ) {
1796        // A contact routes its move by hit test but writes **no hover**: a
1797        // finger has no hover state, so a second finger arriving beside a
1798        // hovering mouse must leave enter/leave, the cursor, tooltip dwell and
1799        // every `on_hover` handler exactly where they were.
1800        let moving = self.current_input.pointer;
1801        if self.pointers.hover_owner_id() != Some(self.current_pointer_id()) {
1802            if let Some(target) = self.hit_test_for(position, &moving) {
1803                self.dispatch_to_widget(target, event, &mut *ops);
1804            }
1805            return;
1806        }
1807        let target = self.hit_test_for(position, &moving);
1808
1809        if target != self.hovered_id() {
1810            // Past the gate above, so the mover *is* the hover owner.
1811            let hover = self.hover_transition_pointer();
1812            let previously_hovered = self.hovered_id();
1813            if let Some(old) = previously_hovered {
1814                self.dispatch_to_widget(
1815                    old,
1816                    &WidgetEvent::PointerLeave { pointer: hover },
1817                    &mut *ops,
1818                );
1819                self.tooltip_pointer_leave(old, &mut *ops);
1820            }
1821            if let Some(new) = target {
1822                self.dispatch_to_widget(
1823                    new,
1824                    &WidgetEvent::PointerEnter { pointer: hover },
1825                    &mut *ops,
1826                );
1827                self.tooltip_pointer_enter(new);
1828            }
1829            self.set_hovered(target);
1830            self.update_hover_within_signals(previously_hovered, target);
1831        } else if let Some(target) = target {
1832            // Same hover target — restart pending tooltip timers if the
1833            // pointer is still moving beyond the stationary slop.
1834            self.tooltip_pointer_moved(target, position);
1835        }
1836
1837        if let Some(target) = target {
1838            self.dispatch_to_widget(target, event, &mut *ops);
1839        }
1840    }
1841
1842    pub(super) fn dispatch_to_widget(
1843        &mut self,
1844        target: WidgetId,
1845        event: &WidgetEvent,
1846        ops: &mut dyn crate::window::WindowOps,
1847    ) {
1848        self.dispatch_to_widget_returning_handled(target, event, ops);
1849    }
1850
1851    /// Rebuild `event` with any pointer position converted into `id`'s
1852    /// **widget-local** space. Returns `None` for events that carry no
1853    /// position, so the caller keeps the original event.
1854    ///
1855    /// This is the single point where the framework localizes pointer
1856    /// coordinates. It runs once per node in both the preview and bubble
1857    /// passes, and because both `on_pointer_event` and the gesture arena
1858    /// read the position out of `event`, localizing it here makes
1859    /// `on_tap` / `on_double_tap` / `on_long_press` / `on_drag` and
1860    /// `on_pointer_event` all receive widget-local coordinates uniformly.
1861    /// See [`WidgetArena::local_pointer_position`].
1862    pub(super) fn localize_event(&self, id: WidgetId, event: &WidgetEvent) -> Option<WidgetEvent> {
1863        match event {
1864            WidgetEvent::PointerDown {
1865                position,
1866                button,
1867                modifiers,
1868                pointer,
1869            } => Some(WidgetEvent::PointerDown {
1870                position: self.arena.local_pointer_position(id, *position),
1871                button: *button,
1872                modifiers: *modifiers,
1873                pointer: *pointer,
1874            }),
1875            WidgetEvent::PointerUp {
1876                position,
1877                button,
1878                modifiers,
1879                pointer,
1880            } => Some(WidgetEvent::PointerUp {
1881                position: self.arena.local_pointer_position(id, *position),
1882                button: *button,
1883                modifiers: *modifiers,
1884                pointer: *pointer,
1885            }),
1886            WidgetEvent::PointerMove {
1887                position,
1888                modifiers,
1889                pointer,
1890            } => Some(WidgetEvent::PointerMove {
1891                position: self.arena.local_pointer_position(id, *position),
1892                modifiers: *modifiers,
1893                pointer: *pointer,
1894            }),
1895            WidgetEvent::Gesture { gesture } => Some(WidgetEvent::Gesture {
1896                gesture: self.localize_gesture(id, gesture),
1897            }),
1898            // Deliberately no `Scroll` or `PointerCancel` arm. Both name their
1899            // position `window_position` precisely because it stays in window
1900            // space — see the fields' own docs for why routing and velocity
1901            // need it there — and adding an arm here would silently change the
1902            // frame every reader of those two fields works in.
1903            _ => None,
1904        }
1905    }
1906
1907    /// Convert every position / center field of a pre-recognized
1908    /// [`GestureEvent`] into `id`'s widget-local space (`DragMoved.delta`
1909    /// is relative and left untouched). Used for the platform gesture
1910    /// path; arena-recognized gestures are already local because the
1911    /// `RawPointerEvent` feeding the arena was localized by
1912    /// [`Self::localize_event`].
1913    fn localize_gesture(&self, id: WidgetId, gesture: &GestureEvent) -> GestureEvent {
1914        let loc = |p: teksilo_canvas::Point| self.arena.local_pointer_position(id, p);
1915        let tap = |t: &TapEvent| {
1916            TapEvent::new(loc(t.position), t.button, t.modifiers).with_pointer(t.pointer)
1917        };
1918        match gesture {
1919            GestureEvent::Tap(t) => GestureEvent::Tap(tap(t)),
1920            GestureEvent::DoubleTap(t) => GestureEvent::DoubleTap(tap(t)),
1921            GestureEvent::TripleTap(t) => GestureEvent::TripleTap(tap(t)),
1922            GestureEvent::LongPress(t) => GestureEvent::LongPress(tap(t)),
1923            GestureEvent::DragStarted {
1924                position,
1925                button,
1926                pointer,
1927            } => GestureEvent::DragStarted {
1928                position: loc(*position),
1929                button: *button,
1930                pointer: *pointer,
1931            },
1932            GestureEvent::DragMoved {
1933                position,
1934                delta,
1935                pointer,
1936            } => GestureEvent::DragMoved {
1937                position: loc(*position),
1938                delta: *delta,
1939                pointer: *pointer,
1940            },
1941            GestureEvent::DragEnded { position, pointer } => GestureEvent::DragEnded {
1942                position: loc(*position),
1943                pointer: *pointer,
1944            },
1945            GestureEvent::DragCancelled {
1946                position,
1947                pointer,
1948                reason,
1949            } => GestureEvent::DragCancelled {
1950                position: loc(*position),
1951                pointer: *pointer,
1952                reason: *reason,
1953            },
1954            GestureEvent::PinchStarted { center } => GestureEvent::PinchStarted {
1955                center: loc(*center),
1956            },
1957            GestureEvent::PinchChanged {
1958                center,
1959                scale,
1960                rotation,
1961            } => GestureEvent::PinchChanged {
1962                center: loc(*center),
1963                scale: *scale,
1964                rotation: *rotation,
1965            },
1966            GestureEvent::PinchEnded => GestureEvent::PinchEnded,
1967            GestureEvent::PinchCancelled { reason } => {
1968                GestureEvent::PinchCancelled { reason: *reason }
1969            }
1970            GestureEvent::Swipe {
1971                direction,
1972                velocity,
1973            } => GestureEvent::Swipe {
1974                direction: *direction,
1975                velocity: *velocity,
1976            },
1977        }
1978    }
1979
1980    /// Same as `dispatch_to_widget` but returns `true` when any
1981    /// preview or bubble handler consumed the event. Used for keyboard
1982    /// events the framework wants to consume by default (Tab focus
1983    /// navigation): callers can dispatch first, then fall back to
1984    /// built-in behavior only when no widget claimed it.
1985    pub(super) fn dispatch_to_widget_returning_handled(
1986        &mut self,
1987        target: WidgetId,
1988        event: &WidgetEvent,
1989        ops: &mut dyn crate::window::WindowOps,
1990    ) -> bool {
1991        if !self.arena.is_enabled(target) {
1992            return false;
1993        }
1994
1995        let mut ancestors = Vec::new();
1996        let mut current = self.arena.parent(target);
1997        while let Some(id) = current {
1998            ancestors.push(id);
1999            current = self.arena.parent(id);
2000        }
2001        ancestors.reverse();
2002
2003        // For a pointer press, find the innermost tap-owning node at-or-above
2004        // the hit target (a chevron / checkbox / inline button). A row or
2005        // container that selects on press consults
2006        // `ctx.press_claimed_by_interactive_child()` to skip selecting when this
2007        // owner is a strict descendant of it — the press belongs to the inner
2008        // control, not the row. Tap-like handlers only; drag/swipe are excluded
2009        // so a draggable row still selects itself on press.
2010        //
2011        // **`on_tap` / `on_long_press` only — never `on_double_tap` alone.**
2012        // The question this answers is "does a descendant own *this press*",
2013        // and a widget that wired only a multi-tap handler does not: the first
2014        // click of a double-click is not its business. Counting it meant a
2015        // table cell could not carry double-click-to-edit without also
2016        // silently stopping its row from selecting on a plain click — while
2017        // every file manager selects a row on the first click of the
2018        // double-click that opens it. A node that wants the press still has
2019        // `on_tap` (a real `Button`, a checkbox), and those are unaffected.
2020        let tap_owner: Option<WidgetId> = if matches!(
2021            event,
2022            WidgetEvent::PointerDown { .. } | WidgetEvent::PointerUp { .. }
2023        ) {
2024            let mut owner = None;
2025            let mut cur = Some(target);
2026            while let Some(id) = cur {
2027                if self.arena.get(id).is_some_and(|n| {
2028                    n.any_handler(|h| h.on_tap.is_some() || h.on_long_press.is_some())
2029                }) {
2030                    owner = Some(id);
2031                    break;
2032                }
2033                cur = self.arena.parent(id);
2034            }
2035            owner
2036        } else {
2037            None
2038        };
2039
2040        for &id in &ancestors {
2041            let mut ctx = self
2042                .make_event_context(&mut *ops)
2043                .with_dispatch_node(id)
2044                .with_dispatch_target(target);
2045            ctx.press_claimed_by_interactive_child =
2046                tap_owner.is_some_and(|owner| owner != id && self.is_descendant_of(owner, id));
2047            // Convert any pointer position into this node's widget-local
2048            // space before its handlers see it (see `localize_event`).
2049            let localized = self.localize_event(id, event);
2050            let event = localized.as_ref().unwrap_or(event);
2051            let response = if let Some(node) = self.arena.get_mut(id) {
2052                Self::try_handler_preview(node, event, &mut ctx).unwrap_or(EventResponse::Ignored)
2053            } else {
2054                EventResponse::Ignored
2055            };
2056            self.collect_from_ctx(ctx, id);
2057            if response == EventResponse::Handled {
2058                self.arena.mark_needs_paint(id);
2059                // Step 1 of the decision procedure: the raw-preview pass runs
2060                // FIRST and keeps its root-first order, and the first `Handled`
2061                // claims the press. Deliberately not folded into the
2062                // innermost-first member order — `rich_text/mouse.rs` documents
2063                // relying on an outer wrapper seeing a press before an inner
2064                // one, and reordering it would silently invert a precedence
2065                // real widgets depend on.
2066                if matches!(event, WidgetEvent::PointerDown { .. }) {
2067                    self.note_preview_claim(id);
2068                }
2069                return true;
2070            }
2071        }
2072
2073        let needs_layout_on_handle = matches!(
2074            event,
2075            WidgetEvent::Scroll { .. } | WidgetEvent::ScrollIntoView { .. }
2076        );
2077        let mut current = Some(target);
2078        let mut is_target = true;
2079        while let Some(id) = current {
2080            let mut ctx = self
2081                .make_event_context(&mut *ops)
2082                .with_dispatch_node(id)
2083                .with_dispatch_target(target);
2084            ctx.press_claimed_by_interactive_child =
2085                tap_owner.is_some_and(|owner| owner != id && self.is_descendant_of(owner, id));
2086            // Convert any pointer position into this node's widget-local
2087            // space before its handlers (and its gesture arena) see it.
2088            let localized = self.localize_event(id, event);
2089            let gesture_cx = self.recognizer_context(id);
2090            // A member that lost the arbitration keeps its handlers and loses
2091            // only its recognizers — see `sequence_blocks_arena`.
2092            let arena_blocked = self.sequence_blocks_arena(id);
2093            let WidgetTree {
2094                arena,
2095                gesture_owners,
2096                ..
2097            } = self;
2098            let event = localized.as_ref().unwrap_or(event);
2099            let response = if let Some(node) = arena.get_mut(id) {
2100                Self::try_handler_bubble(
2101                    node,
2102                    event,
2103                    &mut ctx,
2104                    BubbleGates {
2105                        fire_on_pointer_event: is_target,
2106                        arena_blocked,
2107                    },
2108                    id,
2109                    gesture_owners,
2110                    gesture_cx,
2111                )
2112                .unwrap_or(EventResponse::Ignored)
2113            } else {
2114                EventResponse::Ignored
2115            };
2116            self.collect_from_ctx(ctx, id);
2117            if response == EventResponse::Handled {
2118                if needs_layout_on_handle {
2119                    self.arena.mark_needs_layout(id);
2120                } else {
2121                    self.arena.mark_needs_paint(id);
2122                }
2123                self.note_pointer_acceptance(id, event);
2124                // **Hover transitions are notifications, and every ancestor is
2125                // entitled to one.** Stopping the bubble here left a container
2126                // stuck hovered whenever the pointer left it *through* an
2127                // interactive child: the child's own `on_hover` handled the
2128                // `PointerLeave`, the bubble stopped, and the row went on believing
2129                // the pointer was still over it. A search result whose controls
2130                // appear on hover then kept them after the pointer had gone.
2131                //
2132                // The preview pass already refuses to let an ancestor swallow a
2133                // descendant's Enter/Leave; this is that rule in the other
2134                // direction, and it is what makes a container's hover mean "the
2135                // pointer is somewhere inside me" rather than "the pointer is on my
2136                // own background". Every other event still stops at its handler,
2137                // which is what makes handling one mean anything.
2138                if !matches!(
2139                    event,
2140                    WidgetEvent::PointerEnter { .. } | WidgetEvent::PointerLeave { .. }
2141                ) {
2142                    return true;
2143                }
2144            }
2145            is_target = false;
2146            current = self.arena.parent(id);
2147        }
2148        false
2149    }
2150
2151    pub(super) fn dispatch_to_widget_direct(
2152        &mut self,
2153        target: WidgetId,
2154        event: &WidgetEvent,
2155        ops: &mut dyn crate::window::WindowOps,
2156    ) {
2157        self.dispatch_to_widget_direct_returning_handled(target, event, ops);
2158    }
2159
2160    /// [`dispatch_to_widget_direct`](Self::dispatch_to_widget_direct), reporting
2161    /// whether the node consumed the event.
2162    ///
2163    /// The claimant chain needs the answer: `Handled` means the container
2164    /// absorbed some of the delta and the walk stops, `Ignored` means it is at
2165    /// a boundary and the same whole event goes to the next container outward.
2166    /// Addressed rather than bubbled, which is exactly what a claimant chain is
2167    /// — a list of named recipients, like the one the cancel funnel delivers
2168    /// to.
2169    pub(super) fn dispatch_to_widget_direct_returning_handled(
2170        &mut self,
2171        target: WidgetId,
2172        event: &WidgetEvent,
2173        ops: &mut dyn crate::window::WindowOps,
2174    ) -> bool {
2175        if !self.arena.is_enabled(target) {
2176            return false;
2177        }
2178
2179        let mut ctx = self
2180            .make_event_context(&mut *ops)
2181            .with_dispatch_node(target)
2182            .with_dispatch_target(target);
2183        let gesture_cx = self.recognizer_context(target);
2184        let arena_blocked = self.sequence_blocks_arena(target);
2185        let WidgetTree {
2186            arena,
2187            gesture_owners,
2188            ..
2189        } = self;
2190        let response = if let Some(node) = arena.get_mut(target) {
2191            Self::try_handler_bubble(
2192                node,
2193                event,
2194                &mut ctx,
2195                BubbleGates {
2196                    fire_on_pointer_event: true,
2197                    arena_blocked,
2198                },
2199                target,
2200                gesture_owners,
2201                gesture_cx,
2202            )
2203            .unwrap_or(EventResponse::Ignored)
2204        } else {
2205            EventResponse::Ignored
2206        };
2207        self.collect_from_ctx(ctx, target);
2208
2209        if response == EventResponse::Handled {
2210            // A scroll changes geometry, so it earns a layout pass rather than
2211            // a repaint — the same distinction the bubble path makes.
2212            if matches!(
2213                event,
2214                WidgetEvent::Scroll { .. } | WidgetEvent::ScrollIntoView { .. }
2215            ) {
2216                self.arena.mark_needs_layout(target);
2217            } else {
2218                self.arena.mark_needs_paint(target);
2219            }
2220            self.note_pointer_acceptance(target, event);
2221        }
2222        response == EventResponse::Handled
2223    }
2224
2225    /// Remember that `target` answered `Handled` to one of the current
2226    /// pointer's positional events.
2227    ///
2228    /// Read only by the cancel funnel, as the recipient of last resort when a
2229    /// revoked pointer holds no capture. Restricted to the three positional
2230    /// phases: a key, an accessibility action or a focus change is not "an
2231    /// event from this pointer", and letting one of those set the anchor would
2232    /// address the cancel to a widget the pointer never touched.
2233    pub(super) fn note_pointer_acceptance(&mut self, target: WidgetId, event: &WidgetEvent) {
2234        if !matches!(
2235            event,
2236            WidgetEvent::PointerDown { .. }
2237                | WidgetEvent::PointerMove { .. }
2238                | WidgetEvent::PointerUp { .. }
2239        ) {
2240            return;
2241        }
2242        let pointer = self.current_pointer_id();
2243        if let Some(entry) = self.pointers.get_mut(pointer) {
2244            entry.last_accepted = Some(target);
2245        }
2246    }
2247
2248    fn try_handler_preview(
2249        node: &mut crate::arena::WidgetNode,
2250        event: &WidgetEvent,
2251        ctx: &mut EventContext,
2252    ) -> Option<EventResponse> {
2253        match event {
2254            // Key + IME events fire `on_key_preview` on each strict
2255            // ancestor of the focused widget (root → parent-of-target).
2256            // Mirrors how `on_pointer_event` previews on the pointer
2257            // side; the focused widget itself does NOT see its own
2258            // `on_key_preview` (the dispatch loop builds an ancestors
2259            // list that excludes the target, so this is enforced by
2260            // the caller, not here).
2261            WidgetEvent::KeyDown { .. }
2262            | WidgetEvent::KeyUp { .. }
2263            | WidgetEvent::ImeComposition { .. }
2264            | WidgetEvent::ImeCommit { .. } => {
2265                let has = node.external_handlers.on_key_preview.is_some()
2266                    || node.handlers.on_key_preview.is_some();
2267                if !has {
2268                    return None;
2269                }
2270                Some(fire_event_handler_both(
2271                    &mut node.external_handlers.on_key_preview,
2272                    &mut node.handlers.on_key_preview,
2273                    event,
2274                    ctx,
2275                ))
2276            }
2277            // `PointerEnter` / `PointerLeave` are per-node hover transitions
2278            // synthesized by `handle_pointer_move`, not part of the raw pointer
2279            // stream. Running them through the ancestor preview pass would let
2280            // a drag-detecting ancestor whose `on_pointer_event` returns
2281            // `Handled` silently swallow a descendant's hover (its cursor and
2282            // `on_hover` would never fire). They are delivered to their target
2283            // directly via the bubble pass (where Enter/Leave fire `on_hover`),
2284            // so they have no business in preview. `PointerMove`/`Down`/`Up`
2285            // and `Scroll` still preview through the catch-all below — the
2286            // tab-bar wheel-remap (`tab_widget/bar.rs`) and the split-view /
2287            // rich-text drag guards depend on that.
2288            WidgetEvent::PointerEnter { .. } | WidgetEvent::PointerLeave { .. } => None,
2289            _ => {
2290                let has = node.external_handlers.on_pointer_event.is_some()
2291                    || node.handlers.on_pointer_event.is_some();
2292                if !has {
2293                    return None;
2294                }
2295                Some(fire_event_handler_both(
2296                    &mut node.external_handlers.on_pointer_event,
2297                    &mut node.handlers.on_pointer_event,
2298                    event,
2299                    ctx,
2300                ))
2301            }
2302        }
2303    }
2304
2305    fn try_handler_bubble(
2306        node: &mut crate::arena::WidgetNode,
2307        event: &WidgetEvent,
2308        ctx: &mut EventContext,
2309        gates: BubbleGates,
2310        node_id: WidgetId,
2311        gesture_owners: &mut std::collections::HashSet<WidgetId>,
2312        gesture_cx: crate::gesture::RecognizerContext<'_>,
2313    ) -> Option<EventResponse> {
2314        let BubbleGates {
2315            fire_on_pointer_event,
2316            arena_blocked,
2317        } = gates;
2318        match event {
2319            WidgetEvent::PointerEnter { .. } => {
2320                if let Some(cursor) = node.node_cursor {
2321                    // The declared channel, not `set_cursor`: a handler
2322                    // overriding the cursor in the same dispatch must not
2323                    // erase the tree's record of what the node asked for —
2324                    // that record is what `release_cursor` hands back to.
2325                    ctx.declared_cursor_request = Some(cursor);
2326                }
2327                let mut fired = false;
2328                if let Some(h) = node.external_handlers.on_hover.as_mut() {
2329                    h(true, ctx);
2330                    fired = true;
2331                }
2332                if let Some(h) = node.handlers.on_hover.as_mut() {
2333                    h(true, ctx);
2334                    fired = true;
2335                }
2336                if fired {
2337                    Some(EventResponse::Handled)
2338                } else {
2339                    node.node_cursor.map(|_| EventResponse::Handled)
2340                }
2341            }
2342            WidgetEvent::PointerLeave { .. } => {
2343                if node.node_cursor.is_some() {
2344                    ctx.declared_cursor_request = Some(crate::widget::CursorIcon::Default);
2345                }
2346                let mut fired = false;
2347                if let Some(h) = node.external_handlers.on_hover.as_mut() {
2348                    h(false, ctx);
2349                    fired = true;
2350                }
2351                if let Some(h) = node.handlers.on_hover.as_mut() {
2352                    h(false, ctx);
2353                    fired = true;
2354                }
2355                if fired {
2356                    Some(EventResponse::Handled)
2357                } else {
2358                    node.node_cursor.map(|_| EventResponse::Handled)
2359                }
2360            }
2361            WidgetEvent::FocusGained { .. } => {
2362                let mut fired = false;
2363                if let Some(h) = node.external_handlers.on_focus.as_mut() {
2364                    h(true, ctx);
2365                    fired = true;
2366                }
2367                if let Some(h) = node.handlers.on_focus.as_mut() {
2368                    h(true, ctx);
2369                    fired = true;
2370                }
2371                fired.then_some(EventResponse::Handled)
2372            }
2373            WidgetEvent::FocusLost => {
2374                let mut fired = false;
2375                if let Some(h) = node.external_handlers.on_focus.as_mut() {
2376                    h(false, ctx);
2377                    fired = true;
2378                }
2379                if let Some(h) = node.handlers.on_focus.as_mut() {
2380                    h(false, ctx);
2381                    fired = true;
2382                }
2383                fired.then_some(EventResponse::Handled)
2384            }
2385            WidgetEvent::KeyDown { .. }
2386            | WidgetEvent::KeyUp { .. }
2387            | WidgetEvent::ImeComposition { .. }
2388            | WidgetEvent::ImeCommit { .. } => {
2389                if node.external_handlers.on_key.is_some() || node.handlers.on_key.is_some() {
2390                    Some(fire_event_handler_both(
2391                        &mut node.external_handlers.on_key,
2392                        &mut node.handlers.on_key,
2393                        event,
2394                        ctx,
2395                    ))
2396                } else {
2397                    None
2398                }
2399            }
2400            WidgetEvent::Scroll { .. } | WidgetEvent::ScrollIntoView { .. } => {
2401                if node.external_handlers.on_scroll.is_some() || node.handlers.on_scroll.is_some() {
2402                    Some(fire_event_handler_both(
2403                        &mut node.external_handlers.on_scroll,
2404                        &mut node.handlers.on_scroll,
2405                        event,
2406                        ctx,
2407                    ))
2408                } else {
2409                    None
2410                }
2411            }
2412            WidgetEvent::AccessAction {
2413                action,
2414                target_node,
2415                data,
2416                ..
2417            } => {
2418                // Every installed slot fires — both payload shapes, and
2419                // within each shape both the external (app-installed
2420                // `.on_access_action*`) and the widget's own. Button (own)
2421                // and Dialog (external) layered together rely on that for a
2422                // single accesskit click.
2423                //
2424                // The two shapes are layered, not alternatives, because they
2425                // have different owners: `on_access_action_request` is what a
2426                // widget reaches for when it needs `target_node` or `data`
2427                // (`Slider`, `SpinBox`, `TextInputField`, `CodeEditor`,
2428                // `TabBar`), while `.on_access_action(..)` is the app's
2429                // builder-level hook. Preferring the payload shape when it was
2430                // set therefore did not choose between two handlers for the
2431                // same job — it silently disabled the app's handler on exactly
2432                // the widgets that had migrated, with nothing at the call site
2433                // to say so.
2434                //
2435                // Assistive-tech action paths run under the `Accessibility`
2436                // source label. Restored after the block.
2437                let saved_a11y_source = ctx
2438                    .current_source
2439                    .replace(crate::telemetry::IntentSource::Accessibility);
2440                let mut any_slot = false;
2441                let mut any_handled = false;
2442                if let Some(h) = node.external_handlers.on_access_action_request.as_mut() {
2443                    any_slot = true;
2444                    any_handled |=
2445                        h(*action, *target_node, data.clone(), ctx) == EventResponse::Handled;
2446                }
2447                if let Some(h) = node.handlers.on_access_action_request.as_mut() {
2448                    any_slot = true;
2449                    any_handled |=
2450                        h(*action, *target_node, data.clone(), ctx) == EventResponse::Handled;
2451                }
2452                if let Some(h) = node.external_handlers.on_access_action.as_mut() {
2453                    any_slot = true;
2454                    any_handled |= h(*action, ctx) == EventResponse::Handled;
2455                }
2456                if let Some(h) = node.handlers.on_access_action.as_mut() {
2457                    any_slot = true;
2458                    any_handled |= h(*action, ctx) == EventResponse::Handled;
2459                }
2460                let user_handled = any_slot.then_some(if any_handled {
2461                    EventResponse::Handled
2462                } else {
2463                    EventResponse::Ignored
2464                });
2465
2466                // Builder-level access_action / access_custom_action
2467                // callbacks. These layer on top of any user-installed
2468                // on_access_action / on_access_action_request — both
2469                // fire for the same dispatched event. Drives the
2470                // SwiftUI `.accessibilityAction(...)` parity.
2471                let mut override_handled = false;
2472                if let Some(ov) = node.access_overrides.as_deref_mut() {
2473                    if matches!(action, accesskit::Action::CustomAction) {
2474                        if let Some(accesskit::ActionData::CustomAction(idx)) = data
2475                            && let Some((_, cb)) = ov.custom_actions.get_mut(*idx as usize)
2476                        {
2477                            cb(ctx);
2478                            override_handled = true;
2479                        }
2480                    } else {
2481                        for (a, cb) in ov.actions.iter_mut() {
2482                            if *a == *action {
2483                                cb(ctx);
2484                                override_handled = true;
2485                            }
2486                        }
2487                    }
2488                }
2489
2490                ctx.current_source = saved_a11y_source;
2491                match (user_handled, override_handled) {
2492                    (Some(EventResponse::Handled), _) | (_, true) => Some(EventResponse::Handled),
2493                    (Some(EventResponse::Ignored), false) => Some(EventResponse::Ignored),
2494                    (None, false) => None,
2495                }
2496            }
2497            WidgetEvent::Gesture { gesture } => {
2498                // Pre-recognized gestures from the platform (OS trackpad
2499                // pinch/rotation, double-tap, …) bypass the gesture arena
2500                // and go straight to the matching handler. See §10.
2501                let matched = matches!(
2502                    gesture,
2503                    GestureEvent::PinchStarted { .. }
2504                        | GestureEvent::PinchChanged { .. }
2505                        | GestureEvent::PinchEnded
2506                        | GestureEvent::Swipe { .. }
2507                        | GestureEvent::DoubleTap { .. }
2508                        | GestureEvent::TripleTap { .. }
2509                ) && {
2510                    let has_handler = match gesture {
2511                        GestureEvent::PinchStarted { .. }
2512                        | GestureEvent::PinchChanged { .. }
2513                        | GestureEvent::PinchEnded => node.any_handler(|h| h.on_pinch.is_some()),
2514                        GestureEvent::Swipe { .. } => node.any_handler(|h| h.on_swipe.is_some()),
2515                        GestureEvent::DoubleTap { .. } => {
2516                            node.any_handler(|h| h.on_double_tap.is_some())
2517                        }
2518                        GestureEvent::TripleTap { .. } => {
2519                            node.any_handler(|h| h.on_triple_tap.is_some())
2520                        }
2521                        _ => false,
2522                    };
2523                    if has_handler {
2524                        Self::dispatch_recognized_gesture(node, *gesture, ctx);
2525                    }
2526                    has_handler
2527                };
2528                if matched {
2529                    Some(EventResponse::Handled)
2530                } else {
2531                    None
2532                }
2533            }
2534            WidgetEvent::PointerDown {
2535                position,
2536                button,
2537                modifiers,
2538                ..
2539            } => {
2540                // Raw pointer handler runs first so widgets can intercept
2541                // events that the gesture recognizers won't catch (e.g.
2542                // right-click → context menu). If it returns Handled the
2543                // gesture arena is skipped; otherwise we fall through.
2544                // Only fire for the target — ancestors already fired
2545                // on_pointer_event during the preview pass.
2546                if fire_on_pointer_event {
2547                    let r = fire_event_handler_both(
2548                        &mut node.external_handlers.on_pointer_event,
2549                        &mut node.handlers.on_pointer_event,
2550                        event,
2551                        ctx,
2552                    );
2553                    if r == EventResponse::Handled {
2554                        return Some(EventResponse::Handled);
2555                    }
2556                }
2557                if arena_blocked {
2558                    // This node lost the arbitration for the press: its
2559                    // recognizers stay out of it, and the event goes on
2560                    // bubbling as if the node carried none.
2561                    return None;
2562                }
2563                Self::ensure_gesture_arena(node, node_id, gesture_owners);
2564                if let Some(arena) = node.handlers.gesture_arena.as_mut() {
2565                    let cx = gesture_cx;
2566                    // Implicit capture for the Down..Up sequence so that
2567                    // moves leaving the widget bounds still reach the
2568                    // arena. Without this, a drag that starts inside the
2569                    // widget but crosses its edge before the recognizer
2570                    // latches would be hit-tested to another widget and
2571                    // the press-origin arena would never see a `Move`.
2572                    // Released unconditionally by the `PointerUp` branch
2573                    // in `dispatch_event`.
2574                    //
2575                    // **Implicit**: this is plumbing, not a claim. Routing it
2576                    // through the public `capture_pointer` would enrol every
2577                    // arena-bearing node as a `RawDrag` competitor and decide
2578                    // every mouse sequence at press. See
2579                    // `EventContext::capture_pointer_implicit`.
2580                    ctx.capture_pointer_implicit();
2581                    let result = arena.process(
2582                        &RawPointerEvent::Down {
2583                            position: *position,
2584                            button: *button,
2585                            modifiers: *modifiers,
2586                            pointer: cx.pointer,
2587                            time: cx.now,
2588                        },
2589                        &cx,
2590                    );
2591                    if let Some(gesture) = result {
2592                        Self::dispatch_recognized_gesture(node, gesture, ctx);
2593                    }
2594                    return Some(EventResponse::Handled);
2595                }
2596                None
2597            }
2598            WidgetEvent::PointerUp {
2599                position,
2600                button,
2601                modifiers,
2602                ..
2603            } => {
2604                if fire_on_pointer_event {
2605                    let r = fire_event_handler_both(
2606                        &mut node.external_handlers.on_pointer_event,
2607                        &mut node.handlers.on_pointer_event,
2608                        event,
2609                        ctx,
2610                    );
2611                    if r == EventResponse::Handled {
2612                        return Some(EventResponse::Handled);
2613                    }
2614                }
2615                if arena_blocked {
2616                    return None;
2617                }
2618                if let Some(arena) = node.handlers.gesture_arena.as_mut() {
2619                    let cx = gesture_cx;
2620                    let result = arena.process(
2621                        &RawPointerEvent::Up {
2622                            position: *position,
2623                            button: *button,
2624                            modifiers: *modifiers,
2625                            pointer: cx.pointer,
2626                            time: cx.now,
2627                        },
2628                        &cx,
2629                    );
2630                    if let Some(gesture) = result {
2631                        Self::dispatch_recognized_gesture(node, gesture, ctx);
2632                    }
2633                    return Some(EventResponse::Handled);
2634                }
2635                None
2636            }
2637            WidgetEvent::PointerMove { position, .. } => {
2638                if fire_on_pointer_event {
2639                    let r = fire_event_handler_both(
2640                        &mut node.external_handlers.on_pointer_event,
2641                        &mut node.handlers.on_pointer_event,
2642                        event,
2643                        ctx,
2644                    );
2645                    if r == EventResponse::Handled {
2646                        return Some(EventResponse::Handled);
2647                    }
2648                }
2649                if arena_blocked {
2650                    return None;
2651                }
2652                if let Some(arena) = node.handlers.gesture_arena.as_mut() {
2653                    let cx = gesture_cx;
2654                    let result = arena.process(
2655                        &RawPointerEvent::Move {
2656                            position: *position,
2657                            pointer: cx.pointer,
2658                            time: cx.now,
2659                        },
2660                        &cx,
2661                    );
2662                    if let Some(gesture) = result {
2663                        Self::dispatch_recognized_gesture(node, gesture, ctx);
2664                        // A recognized gesture (DragStarted / DragMoved / …)
2665                        // almost always changes visible state — return
2666                        // `Handled` so the bubble loop marks this widget
2667                        // `needs_paint`, which in turn makes
2668                        // `WidgetTree::needs_redraw()` return true and
2669                        // triggers a `request_redraw` for the next frame.
2670                        // Without this, state updates via bound signals are
2671                        // only observed on the *next* layout/render pass,
2672                        // which in turn is never scheduled because
2673                        // `teksilo-app::update_control_flow` only wakes up when
2674                        // `needs_redraw()` is true.
2675                        return Some(EventResponse::Handled);
2676                    }
2677                    return Some(EventResponse::Ignored);
2678                }
2679                None
2680            }
2681            WidgetEvent::PointerCancel {
2682                reason, pointer, ..
2683            } => {
2684                // Two hooks, and the dedicated one always runs. `on_pointer_cancel`
2685                // is a notification, not a route: a widget releasing what its
2686                // press latched has nothing to consume, and letting it report
2687                // `Handled` would make releasing state look like claiming the
2688                // event. The raw `on_pointer_event` hook keeps its ordinary
2689                // consuming semantics for widgets that drive the whole pointer
2690                // stream themselves.
2691                for slot in [
2692                    &mut node.external_handlers.on_pointer_cancel,
2693                    &mut node.handlers.on_pointer_cancel,
2694                ] {
2695                    if let Some(handler) = slot.as_mut() {
2696                        handler(pointer, *reason, ctx);
2697                    }
2698                }
2699                if fire_on_pointer_event {
2700                    let r = fire_event_handler_both(
2701                        &mut node.external_handlers.on_pointer_event,
2702                        &mut node.handlers.on_pointer_event,
2703                        event,
2704                        ctx,
2705                    );
2706                    if r == EventResponse::Handled {
2707                        return Some(EventResponse::Handled);
2708                    }
2709                }
2710                None
2711            }
2712        }
2713    }
2714
2715    pub(super) fn collect_from_ctx<'ops>(
2716        &mut self,
2717        mut ctx: EventContext<'ops>,
2718        source_widget: WidgetId,
2719    ) {
2720        // Take the ops handle out of ctx up front so we can freely
2721        // reborrow it inside the method without fighting the 'ops
2722        // lifetime propagation when other fields of `ctx` are moved.
2723        // When no ops is set (standalone trees / tests), fall back to
2724        // a stack NoopWindowOps.
2725        let local_ops = ctx.window_ops.take();
2726        let mut noop = crate::window::NoopWindowOps;
2727        let ops: &mut dyn crate::window::WindowOps = match local_ops {
2728            Some(o) => o,
2729            None => &mut noop,
2730        };
2731        if ctx.frame_requested {
2732            self.request_frame();
2733        }
2734        // Declared first, handler second — the order the two used to occur in
2735        // when both wrote the same slot, so a handler that speaks during an
2736        // enter still outranks the node it entered.
2737        if let Some(declared) = ctx.declared_cursor_request {
2738            self.node_declared_cursor = declared;
2739            self.current_cursor = declared;
2740        }
2741        match ctx.cursor_request {
2742            Some(crate::widget::CursorRequest::Set(cursor)) => {
2743                self.current_cursor = cursor;
2744            }
2745            // A withdrawal restores the node-declared cursor rather than
2746            // resetting to `Default`: the handler is stepping back, not
2747            // claiming the cursor is nothing.
2748            Some(crate::widget::CursorRequest::Release) => {
2749                self.current_cursor = self.node_declared_cursor;
2750            }
2751            None => {}
2752        }
2753        // Intents queued through `ctx.send_intent` are anchored at
2754        // the originating widget. Programmatic sends default to
2755        // `propagate_when_disabled = true` — there is no shortcut to
2756        // consult, and propagation is the safe, least-surprising
2757        // default.
2758        for intent in ctx.pending_intents {
2759            self.enqueue_intent(source_widget, intent, true);
2760        }
2761        // Key capture: process cancel before arm, matching the
2762        // handler's call order (the handler sets `cancel_key_capture`
2763        // when it calls `ctx.cancel_key_capture()`, and separately
2764        // stores `pending_key_capture` when it calls
2765        // `ctx.begin_key_capture(...)`). If the handler did both,
2766        // arm wins (whichever was called last on the ctx has
2767        // already overwritten the other field's effect via the
2768        // setter logic).
2769        if ctx.cancel_key_capture {
2770            self.cancel_key_capture();
2771        }
2772        if let Some(slot) = ctx.pending_key_capture {
2773            self.key_capture = Some(slot);
2774        }
2775        // Registry mutations queued by settings-UI buttons.
2776        for mutation in ctx.pending_shortcut_mutations {
2777            match mutation {
2778                crate::widget::ShortcutMutation::RebindPrimary { id, keystroke } => {
2779                    self.shortcut_registry.rebind_primary(id, keystroke);
2780                }
2781                crate::widget::ShortcutMutation::RebindSecondary { id, keystroke } => {
2782                    self.shortcut_registry.rebind_secondary(id, keystroke);
2783                }
2784                crate::widget::ShortcutMutation::ClearOverride { id } => {
2785                    self.shortcut_registry.clear_override(&id);
2786                }
2787            }
2788        }
2789        if ctx.close_window_requested {
2790            self.close_window_requested = true;
2791        }
2792        if ctx.force_close_requested {
2793            self.force_close_requested = true;
2794        }
2795        self.pending_modal_requests
2796            .extend(ctx.modal_requests.into_iter().map(|request| {
2797                crate::modal::QueuedModalRequest {
2798                    source_widget,
2799                    request,
2800                }
2801            }));
2802        if ctx.dismiss_modal && !self.dismiss_modal_for_source(source_widget, &mut *ops) {
2803            self.pending_modal_dismissal = true;
2804        }
2805        for callback in ctx.idle_callbacks {
2806            self.idle_queue.push_boxed(callback);
2807        }
2808        match ctx.dismiss_scope {
2809            Some(crate::widget::DismissScope::All) => {
2810                let dismissed = self.overlay_manager.dismiss_all();
2811                self.dormant_dismissed_content(&dismissed, &mut *ops);
2812            }
2813            Some(crate::widget::DismissScope::AllExceptHosts) => {
2814                self.dismiss_all_overlays_except_hosts(&mut *ops);
2815            }
2816            Some(crate::widget::DismissScope::SelfChain) => {
2817                self.dismiss_self_overlay_chain_for_source(source_widget, &mut *ops);
2818            }
2819            Some(crate::widget::DismissScope::Top) => {
2820                if let Some((_id, content_ids, focus_restore)) = self.overlay_manager.dismiss_top()
2821                {
2822                    self.dormant_dismissed_content(&content_ids, &mut *ops);
2823                    if let Some(restore_id) = focus_restore
2824                        && self.arena.is_active(restore_id)
2825                    {
2826                        self.focus_ops(restore_id, &mut *ops);
2827                    }
2828                }
2829            }
2830            None => {
2831                for id in ctx.overlay_dismissals {
2832                    let dismissed = self.overlay_manager.dismiss(id);
2833                    self.dormant_dismissed_content(&dismissed, &mut *ops);
2834                }
2835            }
2836        }
2837        // Content-keyed dismissals (`dismiss_overlay_by_content`). Drained
2838        // unconditionally — independent of `dismiss_scope` and of the
2839        // pending delayed-overlay list — so a handler can retract a shown
2840        // reusable overlay it identifies only by content. Resolving the
2841        // id here (not at call time) is what lets the caller skip
2842        // tracking the `OverlayId`.
2843        for content_id in ctx.overlay_content_dismissals {
2844            if let Some(overlay_id) = self.overlay_manager.find_by_content(content_id) {
2845                let dismissed = self.overlay_manager.dismiss(overlay_id);
2846                self.dormant_dismissed_content(&dismissed, &mut *ops);
2847            }
2848        }
2849        // Apply pause/resume queue (ToastHost hover-pause). Drained
2850        // here so the handler-side `ctx.pause_overlay_auto_dismiss(id)`
2851        // is order-independent with `dismiss_overlay(id)` and the
2852        // scope-based dismissals: pause/resume on an overlay that
2853        // was concurrently dismissed is silently dropped (the find
2854        // inside the OverlayManager methods misses on the gone id).
2855        for (id, pause) in ctx.overlay_pause_requests {
2856            if pause {
2857                self.overlay_manager.pause_auto_dismiss(id);
2858            } else {
2859                self.overlay_manager.resume_auto_dismiss(id);
2860            }
2861        }
2862        for preserve_content in ctx.dismiss_descendant_overlays {
2863            self.dismiss_child_overlays_for_source(source_widget, preserve_content, &mut *ops);
2864        }
2865        let deferred_row_activations =
2866            self.apply_tree_mutations(std::mem::take(&mut ctx.tree_mutations));
2867        if ctx.request_a11y_update {
2868            self.a11y_dirty = true;
2869        }
2870        if let Some(visible) = ctx.soft_keyboard_request.take() {
2871            self.request_soft_keyboard(visible);
2872        }
2873        // Handed to the tree's own live regions, which schedule the two
2874        // accessibility syncs each message needs. See `crate::announcer`.
2875        for (message, politeness) in std::mem::take(&mut ctx.announcements) {
2876            self.announce_with(message, politeness);
2877        }
2878        for mut req in ctx.overlay_requests {
2879            if req.parent_overlay.is_none() {
2880                req.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
2881            }
2882            if self
2883                .overlay_manager
2884                .find_by_content(req.content_id)
2885                .is_some()
2886            {
2887                continue;
2888            }
2889            let current_focus = self.focused;
2890            self.overlay_manager.show(req);
2891            // Overlay show changes the AT tree shape — mirror the
2892            // `WidgetTree::show_overlay` path. The dismissal sibling
2893            // (`dismiss_overlay_with_ops`) already flips this.
2894            self.a11y_dirty = true;
2895            if let Some(focus_id) = current_focus {
2896                self.overlay_manager.set_top_focus_restore(focus_id);
2897            }
2898        }
2899        for (mut req, band) in ctx.overlay_band_requests {
2900            if req.parent_overlay.is_none() {
2901                req.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
2902            }
2903            if self
2904                .overlay_manager
2905                .find_by_content(req.content_id)
2906                .is_some()
2907            {
2908                continue;
2909            }
2910            let content_id = req.content_id;
2911            self.overlay_manager.show_in_band(req, band);
2912            self.arena.activate(content_id);
2913            self.a11y_dirty = true;
2914            // Deliberately no `set_top_focus_restore`: the text-affordance band
2915            // never takes focus from the anchor, so there is nothing to give
2916            // back when it goes.
2917        }
2918        // After the shows, so a handler may raise an overlay and place it in
2919        // the same dispatch.
2920        for (content_id, placement) in ctx.overlay_placement_updates {
2921            if let Some(overlay_id) = self.overlay_manager.find_by_content(content_id) {
2922                self.overlay_manager.update_placement(overlay_id, placement);
2923            }
2924        }
2925        for (mut req, duration) in ctx.timed_overlay_requests {
2926            if req.parent_overlay.is_none() {
2927                req.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
2928            }
2929            if self
2930                .overlay_manager
2931                .find_by_content(req.content_id)
2932                .is_some()
2933            {
2934                continue;
2935            }
2936            let current_focus = self.focused;
2937            let overlay_id = self.overlay_manager.show_for(req, duration);
2938            self.overlay_manager
2939                .set_shown_at_sim(overlay_id, self.sim_clock);
2940            self.a11y_dirty = true;
2941            if let Some(focus_id) = current_focus {
2942                self.overlay_manager.set_top_focus_restore(focus_id);
2943            }
2944        }
2945        for (mut req, progress, duration) in ctx.reveal_overlay_requests {
2946            if req.parent_overlay.is_none() {
2947                req.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
2948            }
2949            if self
2950                .overlay_manager
2951                .find_by_content(req.content_id)
2952                .is_some()
2953            {
2954                continue;
2955            }
2956            let content_id = req.content_id;
2957            let current_focus = self.focused;
2958            let overlay_id = self.overlay_manager.show(req);
2959            self.overlay_manager
2960                .set_shown_at_sim(overlay_id, self.sim_clock);
2961            self.a11y_dirty = true;
2962            if let Some(focus_id) = current_focus {
2963                self.overlay_manager.set_top_focus_restore(focus_id);
2964            }
2965            // Drive the caller's progress signal 0 → 1, and register it
2966            // as the overlay's fade-state signal so every dismiss path
2967            // tweens it 1 → 0 and defers removal until it completes — the
2968            // same deferral machinery as `with_fade`, minus `set_opacity`
2969            // (the caller owns how `progress` paints).
2970            self.register_animated_signal(&progress, content_id);
2971            let _ = progress.try_animate_with_options(crate::animation::AnimationRequest {
2972                target: 1.0,
2973                duration,
2974                easing: teksilo_tokens::Easing::EaseOut,
2975                frame_interval: None,
2976                looping: false,
2977                epsilon: 0.0,
2978                max_duration: None,
2979            });
2980            self.overlay_manager
2981                .attach_fade(overlay_id, progress, duration);
2982        }
2983        if let Some((pointer, capture)) = ctx.pointer_capture {
2984            // Per pointer, and by default the pointer whose sample the handler
2985            // was serving — so a mouse call site means exactly what it meant
2986            // before, and two contacts on two widgets hold two captures.
2987            let named = pointer;
2988            let pointer = pointer.unwrap_or_else(|| self.current_pointer_id());
2989            self.set_pointer_capture(pointer, capture.then_some(source_widget));
2990            // An explicit `capture_pointer()` from a handler is an arbitration
2991            // act; the arena's and the drag pipeline's own captures are
2992            // plumbing and route through `capture_pointer_implicit`.
2993            if capture && ctx.explicit_capture && named.is_none() {
2994                self.note_explicit_capture(source_widget);
2995            }
2996        }
2997        if let Some(activation) = ctx.drag_activation_override.take() {
2998            // A press handler chose this node's drag activation for this press.
2999            // Onto the sequence, where it dies with the press — see
3000            // `EventContext::set_drag_activation`.
3001            self.note_drag_activation_override(source_widget, activation);
3002        }
3003        if ctx.recognized_owning_gesture {
3004            // A drag or a swipe recognized on this node owns the rest of the
3005            // press, however the recognizer was reached.
3006            self.note_gesture_recognized(source_widget);
3007        }
3008        if !ctx.gesture_acts.is_empty() {
3009            let acts = std::mem::take(&mut ctx.gesture_acts);
3010            self.apply_gesture_acts(&acts, source_widget);
3011        }
3012        if let Some(reason) = ctx.cancel_pointer_request {
3013            let pointer = self.current_pointer_id();
3014            self.cancel_pointer(pointer, reason, &mut *ops);
3015        }
3016        for (mut request, delay, focus_target, replace_siblings) in ctx.delayed_overlay_requests {
3017            if request.parent_overlay.is_none() {
3018                request.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
3019            }
3020            if self
3021                .overlay_manager
3022                .find_by_content(request.content_id)
3023                .is_some()
3024            {
3025                continue;
3026            }
3027            let content_id = request.content_id;
3028            self.pending_delayed_overlays
3029                .retain(|pending| pending.request.content_id != content_id);
3030            self.pending_delayed_overlays.push(PendingDelayedOverlay {
3031                request,
3032                delay,
3033                focus_target,
3034                replace_siblings,
3035                real_requested_at: std::time::Instant::now(),
3036                sim_requested_at: self.sim_clock,
3037            });
3038            self.arena.mark_needs_paint(source_widget);
3039        }
3040        for content_id in ctx.cancel_delayed_overlays {
3041            self.pending_delayed_overlays
3042                .retain(|pending| pending.request.content_id != content_id);
3043        }
3044        // Apex = the last sample that was still over the anchor, which
3045        // for the intended caller (the anchor's own hover-leave) is the
3046        // point the diagonal starts from. Ops are applied per node as
3047        // its handler returns, so this lands before the next widget's
3048        // hover-enter and before the move's own pointer-leave
3049        // bookkeeping.
3050        for content_id in ctx.safe_region_arm_requests {
3051            if let Some(apex) = self
3052                .previous_pointer_position
3053                .or_else(|| self.hover_owner_position())
3054            {
3055                self.overlay_manager.arm_safe_region(
3056                    content_id,
3057                    apex,
3058                    std::time::Instant::now(),
3059                    self.sim_clock,
3060                );
3061            }
3062        }
3063        for id in ctx.repaint_requests {
3064            self.arena.mark_needs_paint(id);
3065        }
3066        for id in ctx.synthetic_clicks {
3067            // Over the caller's ops, never a standalone dispatch: the
3068            // tapped widget's own handler runs inside this nested
3069            // dispatch, so a standalone one would deny it the
3070            // multi-window API this dispatch already has in hand.
3071            self.synthesise_tap_with_ops(id, &mut *ops);
3072        }
3073        if let Some(&id) = ctx.focus_requests.last() {
3074            // If the requested widget is itself not focusable (e.g. a
3075            // composite like `TextInput` whose focus-handling lives on
3076            // an inner leaf), walk into the subtree and land on the
3077            // first focusable descendant in document order. This makes
3078            // `ctx.request_focus(some_composite)` Do The Right Thing
3079            // without every caller having to reach into private inner
3080            // ids. `first_focusable_descendant` returns the node itself
3081            // when it's focusable, so the usual leaf-target case is
3082            // still a no-op lookup.
3083            let target = self.first_focusable_descendant(id).unwrap_or(id);
3084            self.focus_ops(target, &mut *ops);
3085        }
3086        if let Some(&id) = ctx.focus_into_requests.last() {
3087            // "Focus into" semantics: land on the first focusable descendant
3088            // and — unlike `focus_requests` above — do NOT fall back to the
3089            // container itself. A region with no focusable content (and not
3090            // focusable in its own right) leaves focus untouched rather than
3091            // trapping it on a non-interactive node. Drives Enter-on-a-tab →
3092            // into the tab panel.
3093            if let Some(target) = self.first_focusable_descendant(id) {
3094                self.focus_ops(target, &mut *ops);
3095            }
3096        }
3097
3098        // Rect-based "scroll this into view" requests (`ctx.ensure_visible`).
3099        // Walk outward from the widget whose handler queued the request and
3100        // reveal the rect inside every enclosing scroll container. Run after
3101        // focus so that if the same handler also moved focus, both follows
3102        // settle against the same (pre-relayout) bounds; each dispatch is
3103        // gated on the container not already showing the rect, so ordering is
3104        // harmless. The source widget itself is excluded from the walk — it
3105        // owns revealing an interior rect inside its own viewport.
3106        for req in ctx.scroll_into_view_requests {
3107            self.scroll_rect_into_view(
3108                // Whoever the rect belongs to — the source widget unless the caller
3109                // named another. See `EventContext::ensure_visible_from`.
3110                req.from.unwrap_or(source_widget),
3111                req.rect,
3112                req.margin,
3113                req.align,
3114                req.motion,
3115                &mut *ops,
3116            );
3117        }
3118        // Id-based `ctx.ensure_widget_visible`: resolve to the target's current
3119        // absolute bounds and walk *its* ancestors (skip if it was destroyed
3120        // before the drain). Walking from the target — not `source_widget` —
3121        // means the request reveals that widget wherever it sits, even when the
3122        // handler runs on a different node (a group's roving-key handler
3123        // revealing the child tile it just selected).
3124        for (id, margin) in ctx.scroll_widget_into_view_requests {
3125            if self.arena.get(id).is_some() {
3126                let bounds = self.arena.bounds(id);
3127                self.scroll_rect_into_view(
3128                    id,
3129                    bounds,
3130                    margin,
3131                    crate::event::ScrollAlign::Minimal,
3132                    crate::event::ScrollMotion::Instant,
3133                    &mut *ops,
3134                );
3135            }
3136        }
3137
3138        // Keyboard-highlight tooltip: surface the highlighted (menu) item's
3139        // tooltip immediately and dismiss the previously-highlighted one. Keyed
3140        // on the item id, NOT real focus (which stays on the menu panel for key
3141        // handling). Only the last request per handler is honoured.
3142        if let Some(&id) = ctx.highlight_tooltip_requests.last() {
3143            self.show_highlight_tooltip(id, &mut *ops);
3144        }
3145
3146        // --- Drag and drop ---
3147        if let Some((source_widget, payload, preview_widget)) = ctx.drag_start_request {
3148            let (preview_content_id, preview_overlay_id) = if let Some(preview) = preview_widget {
3149                // `add_boxed` — NOT `arena.insert` — runs the widget's
3150                // `build()` so composite previews (our `DragPreview`
3151                // wrapper in teksilo-widgets, or anything a user supplies)
3152                // actually instantiate their child subtree. Plain
3153                // `arena.insert` stops at the root node, leaves build
3154                // un-fired, and the overlay renders an empty widget.
3155                let content_id = self.add_boxed(preview);
3156                let overlay_id = self.overlay_manager.show(crate::overlay::OverlayRequest {
3157                    content_id,
3158                    anchor: source_widget,
3159                    placement: crate::overlay::OverlayPlacement::AtPointer(
3160                        teksilo_canvas::Point::ZERO,
3161                    ),
3162                    dismiss: crate::overlay::DismissBehavior::Manual,
3163                    layer: crate::overlay::OverlayLayer::InTree,
3164                    parent_overlay: None,
3165                    on_dismiss: None,
3166                    fade_duration: None,
3167                });
3168                // Force the next layout pass to run `position_overlays`
3169                // and `set_content_bounds` — otherwise the preview sits
3170                // at its initial (0, 0) placement forever.
3171                self.arena.mark_needs_layout(content_id);
3172                (Some(content_id), Some(overlay_id))
3173            } else {
3174                (None, None)
3175            };
3176            self.active_drag = Some(crate::drag_state::DragSession {
3177                payload,
3178                // The pointer that armed the drag. `start_drag` is always
3179                // reached from a handler serving a real sample, which is the
3180                // only place this is knowable — from here on the drag runs
3181                // through layout ticks and platform threads that have no
3182                // sample of their own. See `DragSession::pointer`.
3183                pointer: self.current_input.pointer,
3184                source_widget: Some(source_widget),
3185                is_external: false,
3186                current_position: teksilo_canvas::Point::ZERO,
3187                current_target: None,
3188                feedback: crate::drag_state::DropFeedback::NoFeedback,
3189                preview_content_id,
3190                preview_overlay_id,
3191            });
3192            self.set_current_pointer_capture(Some(source_widget));
3193            // Grabbing-hand cursor while the drag is in flight. Reset on
3194            // drop / cancel / source-destroyed below.
3195            self.current_cursor = crate::widget::CursorIcon::Grabbing;
3196        }
3197        if ctx.cancel_drag {
3198            self.cancel_active_drag(&mut *ops);
3199        }
3200
3201        // --- Environment changes (architecture §9.5) ---
3202        if let Some(theme) = ctx.theme_request {
3203            // Stored, not applied: the app layer routes this through
3204            // `WindowManager::set_theme` so every window re-themes, matching
3205            // the app-wide `set_locale` path below. Applying
3206            // `WidgetTree::set_theme` inline would re-theme only the
3207            // originating window.
3208            self.pending_theme_request = Some(theme);
3209        }
3210        if ctx.follow_system_request {
3211            // Stored, not applied: the app layer switches to
3212            // `ThemeMode::Native` and recomputes the theme from the current
3213            // OS colours, fanning it to every window.
3214            self.pending_follow_system_request = true;
3215        }
3216        if let Some(locale) = ctx.locale_request {
3217            // Stored, not applied: the app layer must route this through
3218            // `WindowManager::set_locale` so the `I18nManager`'s active
3219            // locale and direction stay in sync. Applying via
3220            // `WidgetTree::set_locale` alone would leave `tr!` bindings
3221            // reading the old translations.
3222            self.pending_locale_request = Some(locale);
3223        }
3224        if let Some(scale) = ctx.text_scale_request {
3225            // Stored, not applied: the app layer routes this through
3226            // `WindowManager::set_text_scale` so every window re-scales its
3227            // text. Applying `WidgetTree::set_user_text_scale` inline would
3228            // grow only the originating window.
3229            self.pending_text_scale_request = Some(scale);
3230        }
3231        // Last, and with a context of their own: `Space` on a data view's
3232        // focused row runs the row's published toggle, and a checkbox's toggle
3233        // fires the app's `on_change`, which may send an intent or open a
3234        // window. Running them here rather than inside the mutation drain is
3235        // what gives them an `EventContext`; the drain resolved which action to
3236        // run against the live tree and handed it back.
3237        if !deferred_row_activations.is_empty() {
3238            self.run_with_event_context(&mut *ops, move |ctx| {
3239                for action in deferred_row_activations {
3240                    action(ctx);
3241                }
3242            });
3243        }
3244    }
3245
3246    /// Returns the row activations it resolved but could not run: they need an
3247    /// [`EventContext`], and this method has no `ops` to build one from. The
3248    /// caller runs them once the drain is finished, the way
3249    /// [`WidgetTree::run_mount_actions`](crate::WidgetTree::run_mount_actions)
3250    /// does.
3251    #[must_use]
3252    fn apply_tree_mutations(
3253        &mut self,
3254        mutations: Vec<crate::widget::TreeMutation>,
3255    ) -> Vec<std::rc::Rc<dyn Fn(&mut crate::widget::EventContext)>> {
3256        let mut deferred_row_activations = Vec::new();
3257        use crate::binding::BindingLevel;
3258        use crate::widget::TreeMutation;
3259
3260        for mutation in mutations {
3261            match mutation {
3262                TreeMutation::SetDormant(id) => {
3263                    self.park_subtree(id);
3264                }
3265                TreeMutation::Activate(id) => self.arena.activate(id),
3266                TreeMutation::Destroy(id) => {
3267                    // Route through `destroy_subtree`, NOT the bare
3268                    // `arena.destroy`: the latter only unlinks nodes from
3269                    // the slotmap and leaks everything the widget owned —
3270                    // animation-scheduler entries (which hold strong
3271                    // `Signal<f32>` clones, so the widget keeps animating
3272                    // after it's gone), animated-quad slots, event-source
3273                    // subscriptions, registered shortcuts, bindings, and
3274                    // gesture ownership — and leaves `focused`/`hovered`
3275                    // dangling at a removed id. This mirrors the build-time
3276                    // `BuildContext::destroy_subtree`, including dismissing
3277                    // any overlay that still references the subtree so the
3278                    // manager doesn't retain a stale content reference.
3279                    if let Some(overlay_id) = self.overlay_manager().find_by_content(id) {
3280                        self.dismiss_overlay(overlay_id);
3281                    }
3282                    self.destroy_subtree(id);
3283                }
3284                // Build now, not next frame: the same handler is about to show
3285                // an overlay over this node and move focus into it, and both
3286                // read the subtree. See `EventContext::materialize_now`.
3287                TreeMutation::MaterializeNow(id) => {
3288                    if self.arena.get(id).is_some() {
3289                        self.rebuild_single_widget(id);
3290                    }
3291                }
3292                TreeMutation::RowSpaceActivate { row, fallback } => {
3293                    // Resolve against the *live* tree: a data view rebuilds its
3294                    // rows as they realize, so the row that was focused when
3295                    // the key arrived may have been rebuilt since.
3296                    //
3297                    // Resolve here, run later. The action carries an
3298                    // `EventContext` so a row's checkbox fires its `on_change`
3299                    // on this path exactly as it does under the pointer; this
3300                    // method has no `ops` to build one from, so the caller runs
3301                    // it after the drain.
3302                    deferred_row_activations.push(self.keyboard_toggle_in(row).unwrap_or(fallback));
3303                }
3304                TreeMutation::WithWidgetMut { id, dirty, apply } => {
3305                    // Run the typed mutation while `&mut arena` is live, then
3306                    // drop the borrow before dirty-marking (the `mark_*` calls
3307                    // re-borrow the arena). Only dirty-mark a live node so we
3308                    // never call `mark_ancestors_need_layout` on a destroyed id.
3309                    let existed = if let Some(any) =
3310                        self.arena.get_mut(id).and_then(|n| n.widget.as_any_mut())
3311                    {
3312                        apply(any);
3313                        true
3314                    } else {
3315                        false
3316                    };
3317                    if existed {
3318                        match dirty {
3319                            BindingLevel::RepaintOnly => self.arena.mark_needs_paint(id),
3320                            BindingLevel::SubtreeRepaint => self.arena.mark_subtree_needs_paint(id),
3321                            BindingLevel::Relayout => {
3322                                self.arena.mark_needs_layout(id);
3323                                self.arena.mark_ancestors_need_layout(id);
3324                            }
3325                            BindingLevel::Rebuild => {
3326                                self.arena.mark_needs_rebuild(id);
3327                                self.arena.mark_ancestors_need_layout(id);
3328                            }
3329                            BindingLevel::AccessibilityOnly => self.a11y_dirty = true,
3330                        }
3331                    }
3332                }
3333            }
3334        }
3335        deferred_row_activations
3336    }
3337
3338    /// Hit-test at a point for the **mouse, exactly** — the meaning this door
3339    /// has always had, and keeps.
3340    ///
3341    /// A mouse cursor's hot-spot is exact, so neither hit-targeting mechanism
3342    /// applies to it: the outset pre-pass sees zero insets and the miss-only
3343    /// slop pass short-circuits on a zero radius. A caller that holds a pointer
3344    /// should use [`hit_test_for`](Self::hit_test_for) instead, which is the
3345    /// same test for a mouse and the widened one for a finger or a stylus.
3346    pub fn hit_test(&self, point: Point) -> Option<WidgetId> {
3347        self.hit_test_excluding_overlay_and_widget(point, None, None)
3348    }
3349
3350    /// Hit-test at a point on behalf of a named pointer.
3351    ///
3352    /// Runs the exact pass with that pointer's `Widget::hit_outset`, then — only
3353    /// if the exact pass found nothing eligible — the miss-only slop pass. For
3354    /// [`PointerKind::Mouse`](teksilo_tokens::PointerKind::Mouse) this is
3355    /// exactly [`hit_test`](Self::hit_test).
3356    ///
3357    /// Candidates are restricted to the **topmost overlay layer the exact pass
3358    /// entered**: a press inside an open menu can be re-attributed to a menu
3359    /// row, never to a control on the page behind it.
3360    pub fn hit_test_for(
3361        &self,
3362        point: Point,
3363        pointer: &crate::pointer::PointerInfo,
3364    ) -> Option<WidgetId> {
3365        self.hit_test_for_excluding(point, pointer, None, None)
3366    }
3367
3368    /// [`hit_test_for`](Self::hit_test_for) with the drag-and-drop exclusions of
3369    /// [`hit_test_excluding_overlay_and_widget`](Self::hit_test_excluding_overlay_and_widget).
3370    pub fn hit_test_for_excluding(
3371        &self,
3372        point: Point,
3373        pointer: &crate::pointer::PointerInfo,
3374        exclude_overlay: Option<crate::overlay::OverlayId>,
3375        exclude_widget: Option<WidgetId>,
3376    ) -> Option<WidgetId> {
3377        let surfaces = self.text_surfaces();
3378        let read_only = |id: WidgetId| surfaces.is_read_only(id);
3379        let hit =
3380            crate::pointer::hit_slop::HitContext::new(pointer.kind, &self.effective_theme.input)
3381                .direction(self.layout_direction)
3382                .read_only_probe(&read_only);
3383        self.hit_test_with(point, exclude_overlay, exclude_widget, &hit)
3384    }
3385
3386    /// Hit-test at a point, excluding a specific overlay and widget from consideration.
3387    /// Used during drag-and-drop to exclude the preview overlay and its content widget,
3388    /// so they don't block hit-testing of the actual drop targets underneath.
3389    ///
3390    /// **Mouse, exact** — the pointer-aware twin is
3391    /// [`hit_test_for_excluding`](Self::hit_test_for_excluding).
3392    pub fn hit_test_excluding_overlay_and_widget(
3393        &self,
3394        point: Point,
3395        exclude_overlay: Option<crate::overlay::OverlayId>,
3396        exclude_widget: Option<WidgetId>,
3397    ) -> Option<WidgetId> {
3398        self.hit_test_with(
3399            point,
3400            exclude_overlay,
3401            exclude_widget,
3402            &crate::pointer::hit_slop::HitContext::mouse(),
3403        )
3404    }
3405
3406    /// The one hit-test body: overlay first, then the arena, under whichever
3407    /// [`HitContext`](crate::pointer::hit_slop::HitContext) the caller built.
3408    pub(crate) fn hit_test_with(
3409        &self,
3410        point: Point,
3411        exclude_overlay: Option<crate::overlay::OverlayId>,
3412        exclude_widget: Option<WidgetId>,
3413        hit: &crate::pointer::hit_slop::HitContext<'_>,
3414    ) -> Option<WidgetId> {
3415        if let Some(overlay_id) = self.overlay_manager.hit_test(point) {
3416            if Some(overlay_id) == exclude_overlay {
3417                // Skip this excluded overlay, fall through to widget tree
3418            } else if let Some(overlay) = self.overlay_manager.overlay(overlay_id) {
3419                // Scoped to the overlay's content: this is what restricts the
3420                // slop pass's candidates to the topmost layer the exact pass
3421                // entered.
3422                let content_id = overlay.content_id;
3423                if let Some(found) =
3424                    self.arena
3425                        .hit_test_in_subtree_with_slop(content_id, point, exclude_widget, hit)
3426                {
3427                    return Some(found);
3428                }
3429                // The overlay was chosen by its **bounds** and its content
3430                // claimed nothing at this point. For every ordinary overlay
3431                // that is the end of the search — answering `None` is what
3432                // keeps the slop pass inside the one layer the exact pass
3433                // entered, so a near-miss on a menu row cannot be beaten by a
3434                // control behind the menu.
3435                //
3436                // An overlay whose content root declares `event_pass_through`
3437                // is the exception, because that flag already means "what I did
3438                // not claim belongs to whatever is behind me": the tree's
3439                // walker honours it for an ordinary node *after* its children
3440                // miss, and an overlay root is not an exception to it. Without
3441                // this, a viewport-sized pass-through layer — the placement the
3442                // text-affordance band was written for — stops the surface
3443                // under it taking presses at all. The widening is confined to
3444                // that flag: an overlay that does not set it still returns
3445                // here, so no ordinary overlay's candidate set changes.
3446                if !self
3447                    .arena
3448                    .get(content_id)
3449                    .is_some_and(|node| node.event_pass_through)
3450                {
3451                    return None;
3452                }
3453            }
3454        }
3455
3456        if self.overlay_manager.topmost_centered().is_some() {
3457            return None;
3458        }
3459
3460        // Delegates to WidgetArena::hit_test_at_with_slop, which honors
3461        // event_pass_through and clips_children correctly.
3462        self.arena.hit_test_at_with_slop(point, exclude_widget, hit)
3463    }
3464}
3465
3466/// Whether this keystroke is one of the chords that ask for a context menu.
3467///
3468/// Three routes, because no single one exists on every platform:
3469///
3470/// * **The dedicated key.** `VK_APPS` on Windows, `keysyms::Menu` on X11 and
3471///   Wayland. `winit-0.30.13`'s AppKit backend references
3472///   `NamedKey::ContextMenu` zero times, so macOS never produces it.
3473/// * **Shift+F10.** The convention Windows, GTK and Qt all honour, and the one
3474///   thing a Windows or Linux keyboard without a Menu key can still reach.
3475/// * **Ctrl+Shift+M on macOS.** Neither of the above is available there: Mac
3476///   keyboards have no Menu key, and F10 is a media key under the default
3477///   "Use F1, F2 etc. as standard function keys = off" setting, so Shift+F10
3478///   may never arrive as F10 at all. Kept off the other platforms, where
3479///   Ctrl+Shift+M is a plausible application binding.
3480///
3481/// Modifiers are matched exactly. Shift+F10 with Ctrl held is a different
3482/// gesture and must reach the application unchanged.
3483fn is_context_menu_chord(key: Key, modifiers: Modifiers) -> bool {
3484    match key {
3485        Key::ContextMenu => modifiers == Modifiers::NONE,
3486        Key::F10 => modifiers == Modifiers::SHIFT,
3487        #[cfg(target_os = "macos")]
3488        Key::M => modifiers == Modifiers::CTRL | Modifiers::SHIFT,
3489        _ => false,
3490    }
3491}
3492
3493#[cfg(test)]
3494mod tests {
3495    use super::*;
3496    use crate::test_widgets::FillWidget;
3497    use crate::widget::CursorIcon;
3498    use crate::widget_builder::WidgetBuilder;
3499
3500    #[test]
3501    fn pointer_enter_leave_synthesized() {
3502        let mut tree = WidgetTree::new();
3503        let widget = tree.add(FillWidget::new());
3504        tree.layout(SizeProposal::exact(100.0, 50.0));
3505        tree.pointer_move(Point::new(50.0, 25.0));
3506        assert_eq!(tree.hovered(), Some(widget));
3507        tree.pointer_move(Point::new(200.0, 200.0));
3508        assert_eq!(tree.hovered(), None);
3509    }
3510
3511    #[test]
3512    fn pointer_hover_updates_current_cursor() {
3513        let mut tree = WidgetTree::new();
3514        tree.add(FillWidget::new().cursor(CursorIcon::ColResize));
3515        tree.layout(SizeProposal::exact(100.0, 50.0));
3516
3517        tree.pointer_move(Point::new(50.0, 25.0));
3518        assert_eq!(tree.current_cursor(), CursorIcon::ColResize);
3519
3520        tree.pointer_move(Point::new(200.0, 200.0));
3521        assert_eq!(tree.current_cursor(), CursorIcon::Default);
3522    }
3523
3524    /// A handler's `set_cursor` outlives its dispatch, so a handler that
3525    /// re-decides the cursor on every move needs a way to stop deciding.
3526    ///
3527    /// Going quiet does not do it: the cursor moves only when something writes
3528    /// to it, and the node-declared cursor is written on `PointerEnter` /
3529    /// `PointerLeave` alone — so while the pointer stays inside one node there
3530    /// is no second writer, and the handler's last word simply stands. That is
3531    /// what `release_cursor` withdraws, and it withdraws *to the node's own
3532    /// declaration*, not to `Default`.
3533    #[test]
3534    fn release_cursor_hands_the_cursor_back_to_the_node_that_declared_it() {
3535        // Overrides itself to `Crosshair` on the left third of the widget and
3536        // withdraws everywhere else — the shape of any handler that arbitrates
3537        // its own affordance against the node it sits on.
3538        let mut tree = WidgetTree::new();
3539        tree.add(
3540            FillWidget::new()
3541                .cursor(CursorIcon::ColResize)
3542                .on_pointer_event(|event, ctx| {
3543                    if let WidgetEvent::PointerMove { position, .. } = event {
3544                        if position.x < 30.0 {
3545                            ctx.set_cursor(CursorIcon::Crosshair);
3546                        } else {
3547                            ctx.release_cursor();
3548                        }
3549                    }
3550                    EventResponse::Ignored
3551                }),
3552        );
3553        tree.layout(SizeProposal::exact(100.0, 50.0));
3554
3555        tree.pointer_move(Point::new(10.0, 25.0));
3556        assert_eq!(
3557            tree.current_cursor(),
3558            CursorIcon::Crosshair,
3559            "the handler outranks the node it is attached to",
3560        );
3561        tree.pointer_move(Point::new(60.0, 25.0));
3562        assert_eq!(
3563            tree.current_cursor(),
3564            CursorIcon::ColResize,
3565            "and hands it back to the node, not to Default — no enter/leave \
3566             fires on a move within one node, so nothing else could",
3567        );
3568        tree.pointer_move(Point::new(10.0, 25.0));
3569        assert_eq!(
3570            tree.current_cursor(),
3571            CursorIcon::Crosshair,
3572            "and can take it again"
3573        );
3574        tree.pointer_move(Point::new(200.0, 200.0));
3575        assert_eq!(
3576            tree.current_cursor(),
3577            CursorIcon::Default,
3578            "leaving the node clears both the override and the declaration",
3579        );
3580    }
3581
3582    /// The withdrawal is a no-op for a handler that never spoke, and resolves
3583    /// to `Default` when the chain declares nothing — so a handler may call it
3584    /// unconditionally without having to remember whether it once set a cursor.
3585    #[test]
3586    fn release_cursor_is_a_no_op_with_nothing_to_undo() {
3587        let mut tree = WidgetTree::new();
3588        tree.add(
3589            FillWidget::new()
3590                .cursor(CursorIcon::ColResize)
3591                .on_pointer_event(|event, ctx| {
3592                    if matches!(event, WidgetEvent::PointerMove { .. }) {
3593                        ctx.release_cursor();
3594                    }
3595                    EventResponse::Ignored
3596                }),
3597        );
3598        tree.layout(SizeProposal::exact(100.0, 50.0));
3599        tree.pointer_move(Point::new(50.0, 25.0));
3600        assert_eq!(tree.current_cursor(), CursorIcon::ColResize);
3601
3602        // Same handler over a node that declares nothing.
3603        let mut tree = WidgetTree::new();
3604        tree.add(FillWidget::new().on_pointer_event(|event, ctx| {
3605            if let WidgetEvent::PointerMove { position, .. } = event {
3606                if position.x < 30.0 {
3607                    ctx.set_cursor(CursorIcon::Crosshair);
3608                } else {
3609                    ctx.release_cursor();
3610                }
3611            }
3612            EventResponse::Ignored
3613        }));
3614        tree.layout(SizeProposal::exact(100.0, 50.0));
3615        tree.pointer_move(Point::new(10.0, 25.0));
3616        assert_eq!(tree.current_cursor(), CursorIcon::Crosshair);
3617        tree.pointer_move(Point::new(60.0, 25.0));
3618        assert_eq!(
3619            tree.current_cursor(),
3620            CursorIcon::Default,
3621            "nothing declared a cursor for this chain, so the hand-back \
3622             resolves to Default",
3623        );
3624    }
3625
3626    /// A handler that speaks during the `PointerEnter` itself still outranks
3627    /// the node it entered — and the node's declaration is remembered anyway,
3628    /// so the hand-back has somewhere to go.
3629    ///
3630    /// The two used to share one slot, where the handler's later write erased
3631    /// the declaration outright; they are separate channels now precisely so
3632    /// this case keeps both.
3633    #[test]
3634    fn an_on_hover_override_does_not_erase_the_declaration_it_outranks() {
3635        let mut tree = WidgetTree::new();
3636        tree.add(
3637            FillWidget::new()
3638                .cursor(CursorIcon::ColResize)
3639                .on_hover(|entered, ctx| {
3640                    if entered {
3641                        ctx.set_cursor(CursorIcon::Crosshair);
3642                    }
3643                })
3644                .on_pointer_event(|event, ctx| {
3645                    if let WidgetEvent::PointerMove { position, .. } = event
3646                        && position.x >= 30.0
3647                    {
3648                        ctx.release_cursor();
3649                    }
3650                    EventResponse::Ignored
3651                }),
3652        );
3653        tree.layout(SizeProposal::exact(100.0, 50.0));
3654
3655        tree.pointer_move(Point::new(10.0, 25.0));
3656        assert_eq!(
3657            tree.current_cursor(),
3658            CursorIcon::Crosshair,
3659            "on_hover runs after the node cursor is applied, so it wins",
3660        );
3661        tree.pointer_move(Point::new(60.0, 25.0));
3662        assert_eq!(
3663            tree.current_cursor(),
3664            CursorIcon::ColResize,
3665            "and the node's declaration survived being overridden",
3666        );
3667    }
3668
3669    // A leaf that opts into typed introspection, so `with_widget_mut` /
3670    // `widget_as_any(_mut)` can reach it (the default `as_any_mut` is `None`).
3671    #[derive(Debug)]
3672    struct Bumpable {
3673        value: i32,
3674    }
3675
3676    impl crate::widget::Widget for Bumpable {
3677        fn layout_response(
3678            &self,
3679            proposal: SizeProposal,
3680            _ctx: &crate::widget::LayoutContext,
3681        ) -> crate::widget::LayoutResponse {
3682            proposal.resolve(10.0, 10.0).into()
3683        }
3684        fn as_any(&self) -> Option<&dyn std::any::Any> {
3685            Some(self)
3686        }
3687        fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
3688            Some(self)
3689        }
3690    }
3691
3692    #[test]
3693    fn with_widget_mut_applies_and_dirty_marks() {
3694        let mut tree = WidgetTree::new();
3695        let id = tree.add(Bumpable { value: 0 });
3696        tree.layout(SizeProposal::exact(100.0, 100.0));
3697
3698        let mut ctx = EventContext::new();
3699        ctx.with_widget_mut::<Bumpable>(id, crate::binding::BindingLevel::Relayout, |b| {
3700            b.value = 42;
3701        });
3702        tree.collect_from_ctx(ctx, id);
3703
3704        let value = tree
3705            .widget_as_any(id)
3706            .and_then(|a| a.downcast_ref::<Bumpable>())
3707            .map(|b| b.value);
3708        assert_eq!(
3709            value,
3710            Some(42),
3711            "the deferred closure must mutate the live widget"
3712        );
3713        assert!(
3714            tree.needs_layout(),
3715            "Relayout dirty level must mark the tree for relayout"
3716        );
3717    }
3718
3719    #[test]
3720    #[cfg(debug_assertions)]
3721    #[should_panic(expected = "not the requested type")]
3722    fn with_widget_mut_wrong_type_panics_in_debug() {
3723        struct Other;
3724        let mut tree = WidgetTree::new();
3725        let id = tree.add(Bumpable { value: 0 });
3726        let mut ctx = EventContext::new();
3727        ctx.with_widget_mut::<Other>(
3728            id,
3729            crate::binding::BindingLevel::RepaintOnly,
3730            |_o: &mut Other| {},
3731        );
3732        // Bumpable opts into as_any_mut, so the closure runs and the
3733        // wrong-type downcast trips the debug_assert.
3734        tree.collect_from_ctx(ctx, id);
3735    }
3736
3737    #[test]
3738    fn with_widget_mut_closure_may_fire_observed_signals() {
3739        // Reentrancy guard. The closure runs inside `apply_tree_mutations`
3740        // while the target arena node is mutably borrowed. If it fires a
3741        // `Signal` whose observer sets *another* signal — the exact
3742        // `SceneView` shape (`item_change_signal` → bump `reconcile_dirty`) —
3743        // nothing may double-borrow the arena. The arena borrow is scoped to
3744        // the closure call and dropped before dirty-marking; signal/observer
3745        // work touches the binding registry, not the arena.
3746        use crate::signal::Signal;
3747        let mut tree = WidgetTree::new();
3748        let id = tree.add(Bumpable { value: 0 });
3749        tree.layout(SizeProposal::exact(100.0, 100.0));
3750
3751        let trigger = Signal::new(0_u64);
3752        let echo = Signal::new(0_u64);
3753        let echo_for_obs = echo.clone();
3754        let _obs = trigger.observe(move |v| echo_for_obs.set(*v));
3755
3756        let trigger_in = trigger.clone();
3757        let mut ctx = EventContext::new();
3758        ctx.with_widget_mut::<Bumpable>(id, crate::binding::BindingLevel::RepaintOnly, move |b| {
3759            b.value = 7;
3760            // Fires `_obs` synchronously, mid-deferred-apply.
3761            trigger_in.set(99);
3762        });
3763        tree.collect_from_ctx(ctx, id); // must not panic / double-borrow
3764
3765        assert_eq!(
3766            echo.get(),
3767            99,
3768            "the observer ran during the deferred mutation"
3769        );
3770        let value = tree
3771            .widget_as_any(id)
3772            .and_then(|a| a.downcast_ref::<Bumpable>())
3773            .map(|b| b.value);
3774        assert_eq!(value, Some(7));
3775    }
3776
3777    #[test]
3778    fn request_accessibility_update_forces_rewalk() {
3779        let mut tree = WidgetTree::new();
3780        let id = tree.add(Bumpable { value: 0 });
3781        tree.layout(SizeProposal::exact(100.0, 100.0));
3782        let _ = tree.sync_accessibility(); // populate cache, clears a11y_dirty
3783        assert!(
3784            !tree.a11y_dirty,
3785            "sync_accessibility should clear the dirty flag"
3786        );
3787
3788        let mut ctx = EventContext::new();
3789        ctx.request_accessibility_update();
3790        tree.collect_from_ctx(ctx, id);
3791        assert!(
3792            tree.a11y_dirty,
3793            "request_accessibility_update must force an AT re-walk"
3794        );
3795    }
3796
3797    #[test]
3798    fn rebuild_dirties_accessibility_tree() {
3799        // Regression for audit Blocker G1: every `BindingLevel::Rebuild`
3800        // consumer (ListView / TreeView / TableView / ComboBox / Calendar /
3801        // DockingLayout / ...) tears down and re-creates its subtree on an
3802        // ordinary model change, allocating fresh WidgetIds and changing the
3803        // AccessKit tree shape. That pass must dirty the cached AT snapshot,
3804        // or screen readers keep reading the pre-mutation tree indefinitely.
3805        let mut tree = WidgetTree::new();
3806        let id = tree.add(Bumpable { value: 0 });
3807        tree.layout(SizeProposal::exact(100.0, 100.0));
3808        let _ = tree.sync_accessibility(); // populate cache, clears a11y_dirty
3809        assert!(
3810            !tree.a11y_dirty,
3811            "sync_accessibility should clear the dirty flag"
3812        );
3813
3814        // Marking for rebuild is exactly what a Rebuild-level binding does;
3815        // the following layout pass drains pending rebuilds.
3816        tree.arena_mark_needs_rebuild_for_testing(id);
3817        tree.layout(SizeProposal::exact(100.0, 100.0));
3818        assert!(
3819            tree.a11y_dirty,
3820            "a rebuild must dirty the AT tree so the next sync re-walks"
3821        );
3822    }
3823
3824    #[test]
3825    fn bound_access_label_change_dirties_accessibility_tree() {
3826        use crate::signal::Signal;
3827        use crate::test_widgets::FillWidget;
3828        use crate::widget_builder::WidgetBuilder;
3829
3830        // Regression for audit G15: a reactive `.access_label(signal)` (and
3831        // likewise description / value) must register at AccessibilityOnly so
3832        // changing the signal re-walks the AT tree and re-resolves the
3833        // announced name. Previously only `access_hidden` was registered, so
3834        // label / description / value updates were invisible to screen readers.
3835        let label = Signal::new("first".to_string());
3836        let mut tree = WidgetTree::new();
3837        let _id = tree.add(FillWidget::new().access_label(label.clone()));
3838        tree.layout(SizeProposal::exact(100.0, 100.0));
3839        let _ = tree.sync_accessibility(); // populate cache, clears a11y_dirty
3840        assert!(!tree.a11y_dirty, "sync_accessibility should clear the flag");
3841
3842        label.set("second".to_string());
3843        tree.layout(SizeProposal::exact(100.0, 100.0));
3844        assert!(
3845            tree.a11y_dirty,
3846            "changing a bound access_label must dirty the AT tree"
3847        );
3848    }
3849
3850    #[test]
3851    fn disabled_ancestor_blocks_event_to_descendant() {
3852        use crate::signal::Signal;
3853        use crate::test_widgets::StackWidget;
3854        use std::cell::Cell;
3855        use std::rc::Rc;
3856
3857        let tapped = Rc::new(Cell::new(false));
3858        let flag = tapped.clone();
3859        let enabled = Signal::new(true);
3860
3861        let mut tree = WidgetTree::new();
3862        let child = tree.add(FillWidget::new().on_tap(move |_pos, _ctx| {
3863            flag.set(true);
3864        }));
3865        let parent = tree.add(StackWidget::new().child(child));
3866        tree.enabled_when(parent, enabled.clone());
3867        tree.layout(SizeProposal::exact(100.0, 50.0));
3868
3869        enabled.set(false);
3870        tree.click(child);
3871        assert!(
3872            !tapped.get(),
3873            "disabled ancestor should block descendant tap"
3874        );
3875
3876        enabled.set(true);
3877        tree.click(child);
3878        assert!(tapped.get(), "re-enabling should restore dispatch");
3879    }
3880
3881    #[test]
3882    fn pointer_positions_are_widget_local_at_nonzero_origin() {
3883        use crate::event::{Modifiers, PointerButton};
3884        use crate::test_widgets::InsetWidget;
3885        use std::cell::Cell;
3886        use std::rc::Rc;
3887
3888        // A 20px inset places the child at window origin (20, 20).
3889        let tap_pos: Rc<Cell<Option<Point>>> = Rc::new(Cell::new(None));
3890        let down_pos: Rc<Cell<Option<Point>>> = Rc::new(Cell::new(None));
3891        let drag_pos: Rc<Cell<Option<Point>>> = Rc::new(Cell::new(None));
3892        let (tp, dp, gp) = (tap_pos.clone(), down_pos.clone(), drag_pos.clone());
3893
3894        let mut tree = WidgetTree::new();
3895        let child = tree.add(
3896            FillWidget::new()
3897                .on_tap(move |ev, _ctx| tp.set(Some(ev.position)))
3898                .on_pointer_event(move |ev, _ctx| {
3899                    if let WidgetEvent::PointerDown { position, .. } = ev {
3900                        dp.set(Some(*position));
3901                    }
3902                    crate::event::EventResponse::Ignored
3903                })
3904                .on_drag(move |phase, _ctx| {
3905                    use crate::gesture::DragPhase;
3906                    match phase {
3907                        DragPhase::Started { position, .. }
3908                        | DragPhase::Moved { position, .. }
3909                        | DragPhase::Ended { position, .. } => gp.set(Some(position)),
3910                        _ => {}
3911                    }
3912                }),
3913        );
3914        let inset = tree.add(InsetWidget::new(20.0).set_child(child));
3915        let _ = inset;
3916        tree.layout(SizeProposal::exact(200.0, 200.0));
3917        assert_eq!(tree.bounds(child).origin(), Point::new(20.0, 20.0));
3918
3919        // A tap at window (50, 40) must reach the handler as local (30, 20).
3920        tree.dispatch_event(WidgetEvent::pointer_down(
3921            Point::new(50.0, 40.0),
3922            PointerButton::Primary,
3923            Modifiers::NONE,
3924        ));
3925        assert_eq!(
3926            down_pos.get(),
3927            Some(Point::new(30.0, 20.0)),
3928            "on_pointer_event PointerDown must be widget-local"
3929        );
3930        tree.dispatch_event(WidgetEvent::pointer_up(
3931            Point::new(50.0, 40.0),
3932            PointerButton::Primary,
3933            Modifiers::NONE,
3934        ));
3935        assert_eq!(
3936            tap_pos.get(),
3937            Some(Point::new(30.0, 20.0)),
3938            "on_tap position must be widget-local"
3939        );
3940
3941        // A drag (down then a move past the recognizer threshold) must
3942        // also deliver widget-local coordinates.
3943        tree.dispatch_event(WidgetEvent::pointer_down(
3944            Point::new(50.0, 40.0),
3945            PointerButton::Primary,
3946            Modifiers::NONE,
3947        ));
3948        // First move crosses the recognizer threshold (DragStarted);
3949        // the second reports a known DragMoved position.
3950        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(65.0, 55.0)));
3951        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(90.0, 70.0)));
3952        assert_eq!(
3953            drag_pos.get(),
3954            Some(Point::new(70.0, 50.0)),
3955            "on_drag position must be widget-local"
3956        );
3957    }
3958
3959    #[test]
3960    fn dormant_widget_not_hit_tested() {
3961        let mut tree = WidgetTree::new();
3962        let widget = tree.add(FillWidget::new());
3963        tree.layout(SizeProposal::exact(100.0, 50.0));
3964
3965        tree.pointer_move(Point::new(50.0, 25.0));
3966        assert_eq!(tree.hovered(), Some(widget));
3967
3968        tree.set_dormant(widget);
3969        tree.pointer_move(Point::new(200.0, 200.0));
3970        tree.pointer_move(Point::new(50.0, 25.0));
3971        assert_eq!(tree.hovered(), None);
3972    }
3973
3974    #[test]
3975    fn ancestor_pointer_handler_does_not_suppress_descendant_hover() {
3976        use crate::event::EventResponse;
3977        use crate::test_widgets::StackWidget;
3978        use std::cell::Cell;
3979        use std::rc::Rc;
3980
3981        // The child reports its own hover transitions via `on_hover`.
3982        let hovered = Rc::new(Cell::new(false));
3983        let h = hovered.clone();
3984
3985        let mut tree = WidgetTree::new();
3986        let child = tree.add(FillWidget::new().on_hover(move |entered, _ctx| h.set(entered)));
3987        // An ancestor whose `on_pointer_event` greedily claims everything it
3988        // previews — exactly the "drag-detecting ancestor" footgun. Before the
3989        // fix it consumed the descendant's `PointerEnter`/`Leave` in the
3990        // preview pass and the child's hover never fired.
3991        tree.add(
3992            StackWidget::new()
3993                .child(child)
3994                .on_pointer_event(|_event, _ctx| EventResponse::Handled),
3995        );
3996        tree.layout(SizeProposal::exact(100.0, 50.0));
3997
3998        tree.pointer_move(Point::new(50.0, 25.0));
3999        assert!(
4000            hovered.get(),
4001            "a greedy ancestor on_pointer_event must NOT swallow the child's PointerEnter"
4002        );
4003
4004        tree.pointer_move(Point::new(500.0, 500.0));
4005        assert!(
4006            !hovered.get(),
4007            "PointerLeave must likewise reach the child despite the ancestor"
4008        );
4009    }
4010
4011    /// **The other direction: a child must not swallow its ancestor's hover.**
4012    ///
4013    /// A row that reveals controls on hover puts interactive children inside
4014    /// itself, and the pointer leaves the row *through* one of them. The child's
4015    /// own `on_hover` used to handle the `PointerLeave` and stop the bubble there,
4016    /// so the row went on believing the pointer was still over it and kept its
4017    /// controls showing after the pointer had gone.
4018    #[test]
4019    fn a_child_hover_handler_does_not_swallow_its_ancestors() {
4020        use crate::test_widgets::StackWidget;
4021        use std::cell::Cell;
4022        use std::rc::Rc;
4023
4024        let (row, button) = (Rc::new(Cell::new(false)), Rc::new(Cell::new(false)));
4025        let (r, b) = (row.clone(), button.clone());
4026
4027        let mut tree = WidgetTree::new();
4028        let child = tree.add(FillWidget::new().on_hover(move |entered, _ctx| b.set(entered)));
4029        tree.add(
4030            StackWidget::new()
4031                .child(child)
4032                .on_hover(move |entered, _ctx| r.set(entered)),
4033        );
4034        tree.layout(SizeProposal::exact(100.0, 50.0));
4035
4036        tree.pointer_move(Point::new(50.0, 25.0));
4037        assert!(button.get(), "the child is hovered");
4038        assert!(row.get(), "and so is the row it is inside");
4039
4040        tree.pointer_move(Point::new(500.0, 500.0));
4041        assert!(!button.get(), "the child heard the leave");
4042        assert!(
4043            !row.get(),
4044            "and so did the row — a container is not still hovered because the \
4045             pointer left it through a button"
4046        );
4047    }
4048
4049    // NOTE: legacy `shortcut_intercepts_before_widget` test removed with
4050    // the ShortcutMap dispatch path. The new shortcut→intent interception
4051    // is built on top of `ShortcutRegistry` + `Action`.
4052
4053    // ── on_key_preview ──────────────────────────────────────────
4054
4055    #[test]
4056    fn key_preview_consumes_before_focused_on_key() {
4057        // root → mid → leaf (focused). Root consumes Enter via
4058        // on_key_preview; the leaf's on_key must NOT fire.
4059        use crate::event::EventResponse;
4060        use crate::test_widgets::StackWidget;
4061        use std::cell::Cell;
4062        use std::rc::Rc;
4063
4064        let leaf_fired = Rc::new(Cell::new(false));
4065        let leaf_flag = leaf_fired.clone();
4066        let preview_fired = Rc::new(Cell::new(false));
4067        let preview_flag = preview_fired.clone();
4068
4069        let mut tree = WidgetTree::new();
4070        let leaf = tree.add(FillWidget::new().focusable().on_key(move |event, _c| {
4071            // Only count KeyDown so the trailing KeyUp from
4072            // press_key doesn't trigger us spuriously.
4073            if matches!(event, WidgetEvent::KeyDown { .. }) {
4074                leaf_flag.set(true);
4075            }
4076            EventResponse::Handled
4077        }));
4078        let mid = tree.add(StackWidget::new().child(leaf));
4079        let _root = tree.add(
4080            StackWidget::new()
4081                .child(mid)
4082                .on_key_preview(move |event, _c| match event {
4083                    WidgetEvent::KeyDown {
4084                        key: Key::Enter, ..
4085                    } => {
4086                        preview_flag.set(true);
4087                        EventResponse::Handled
4088                    }
4089                    _ => EventResponse::Ignored,
4090                }),
4091        );
4092
4093        tree.layout(SizeProposal::exact(100.0, 50.0));
4094        tree.focus(leaf);
4095        tree.press_key(Key::Enter, Modifiers::NONE);
4096
4097        assert!(
4098            preview_fired.get(),
4099            "ancestor on_key_preview must fire for KeyDown on a focused descendant"
4100        );
4101        assert!(
4102            !leaf_fired.get(),
4103            "consuming the event in preview must prevent the focused widget's on_key from running"
4104        );
4105    }
4106
4107    #[test]
4108    fn key_preview_falls_through_when_returning_ignored() {
4109        // Same shape; this time the preview returns Ignored, so
4110        // the leaf's on_key must still fire.
4111        use crate::event::EventResponse;
4112        use crate::test_widgets::StackWidget;
4113        use std::cell::Cell;
4114        use std::rc::Rc;
4115
4116        let leaf_fired = Rc::new(Cell::new(false));
4117        let leaf_flag = leaf_fired.clone();
4118        let preview_fired = Rc::new(Cell::new(false));
4119        let preview_flag = preview_fired.clone();
4120
4121        let mut tree = WidgetTree::new();
4122        let leaf = tree.add(FillWidget::new().focusable().on_key(move |_e, _c| {
4123            leaf_flag.set(true);
4124            EventResponse::Handled
4125        }));
4126        let mid = tree.add(StackWidget::new().child(leaf));
4127        let _root = tree.add(
4128            StackWidget::new()
4129                .child(mid)
4130                .on_key_preview(move |_event, _c| {
4131                    preview_flag.set(true);
4132                    EventResponse::Ignored
4133                }),
4134        );
4135
4136        tree.layout(SizeProposal::exact(100.0, 50.0));
4137        tree.focus(leaf);
4138        tree.press_key(Key::Enter, Modifiers::NONE);
4139
4140        assert!(preview_fired.get(), "preview must always be invoked");
4141        assert!(
4142            leaf_fired.get(),
4143            "preview returning Ignored must not block the focused widget's on_key"
4144        );
4145    }
4146
4147    #[test]
4148    fn key_preview_excludes_focused_target_itself() {
4149        // Strict-ancestors-only: the focused widget's own
4150        // on_key_preview must NOT fire — the preview pass walks
4151        // strict ancestors only.
4152        use crate::event::EventResponse;
4153        use std::cell::Cell;
4154        use std::rc::Rc;
4155
4156        let preview_on_target = Rc::new(Cell::new(false));
4157        let pf = preview_on_target.clone();
4158
4159        let mut tree = WidgetTree::new();
4160        let leaf = tree.add(FillWidget::new().focusable().on_key_preview(move |_e, _c| {
4161            pf.set(true);
4162            EventResponse::Handled
4163        }));
4164        tree.layout(SizeProposal::exact(100.0, 50.0));
4165        tree.focus(leaf);
4166        tree.press_key(Key::Enter, Modifiers::NONE);
4167
4168        assert!(
4169            !preview_on_target.get(),
4170            "the focused widget itself must not see its own on_key_preview"
4171        );
4172    }
4173
4174    #[test]
4175    fn key_preview_root_to_target_order() {
4176        // Two ancestors with on_key_preview attached. The outer
4177        // (root-side) one must fire first; the closer one (still
4178        // ancestor of the focused leaf) fires second.
4179        use crate::event::EventResponse;
4180        use crate::test_widgets::StackWidget;
4181        use std::cell::RefCell;
4182        use std::rc::Rc;
4183
4184        let order: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
4185        let outer_log = order.clone();
4186        let inner_log = order.clone();
4187
4188        let mut tree = WidgetTree::new();
4189        let leaf = tree.add(FillWidget::new().focusable());
4190        let inner = tree.add(
4191            StackWidget::new()
4192                .child(leaf)
4193                .on_key_preview(move |event, _c| {
4194                    if matches!(event, WidgetEvent::KeyDown { .. }) {
4195                        inner_log.borrow_mut().push("inner");
4196                    }
4197                    EventResponse::Ignored
4198                }),
4199        );
4200        let _outer = tree.add(
4201            StackWidget::new()
4202                .child(inner)
4203                .on_key_preview(move |event, _c| {
4204                    if matches!(event, WidgetEvent::KeyDown { .. }) {
4205                        outer_log.borrow_mut().push("outer");
4206                    }
4207                    EventResponse::Ignored
4208                }),
4209        );
4210
4211        tree.layout(SizeProposal::exact(100.0, 50.0));
4212        tree.focus(leaf);
4213        tree.dispatch_event(WidgetEvent::KeyDown {
4214            key: Key::Enter,
4215            modifiers: Modifiers::NONE,
4216            text: None,
4217        });
4218
4219        assert_eq!(
4220            *order.borrow(),
4221            vec!["outer", "inner"],
4222            "preview must walk root → parent-of-target"
4223        );
4224    }
4225
4226    #[test]
4227    fn access_action_routes_to_cursored_target_not_focus() {
4228        // VoiceOver's VO+Space targets the node under the AT cursor (`b`),
4229        // even when keyboard focus is on a different control (`a`). The action
4230        // must fire on `b`, never get redirected to the focused `a`.
4231        use crate::signal::Signal;
4232        let a_fired = Signal::new(false);
4233        let b_fired = Signal::new(false);
4234        let a_cb = a_fired.clone();
4235        let b_cb = b_fired.clone();
4236
4237        let mut tree = WidgetTree::new();
4238        let a = tree.add(
4239            FillWidget::new().access_action(accesskit::Action::Click, move |_ctx| a_cb.set(true)),
4240        );
4241        let b = tree.add(
4242            FillWidget::new().access_action(accesskit::Action::Click, move |_ctx| b_cb.set(true)),
4243        );
4244        tree.layout(SizeProposal::exact(200.0, 100.0));
4245
4246        tree.focus(a);
4247        tree.dispatch_event(WidgetEvent::AccessAction {
4248            action: accesskit::Action::Click,
4249            target: Some(b),
4250            target_node: crate::accessibility::widget_id_to_node_id(b),
4251            data: None,
4252        });
4253
4254        assert!(b_fired.get(), "the cursored target must receive the action");
4255        assert!(
4256            !a_fired.get(),
4257            "the keyboard-focused widget must NOT receive an action targeting another node"
4258        );
4259    }
4260
4261    #[test]
4262    fn access_action_without_target_is_dropped_not_redirected_to_focus() {
4263        // An action with no (or an inactive) target must be dropped — never
4264        // silently re-routed to whatever holds keyboard focus.
4265        use crate::signal::Signal;
4266        let fired = Signal::new(false);
4267        let cb = fired.clone();
4268
4269        let mut tree = WidgetTree::new();
4270        let widget = tree.add(
4271            FillWidget::new().access_action(accesskit::Action::Click, move |_ctx| cb.set(true)),
4272        );
4273        tree.layout(SizeProposal::exact(200.0, 100.0));
4274
4275        tree.focus(widget);
4276        tree.dispatch_event(WidgetEvent::AccessAction {
4277            action: accesskit::Action::Click,
4278            target: None,
4279            target_node: crate::accessibility::root_node_id(),
4280            data: None,
4281        });
4282
4283        assert!(
4284            !fired.get(),
4285            "a target-less action must not be redirected to the focused widget"
4286        );
4287    }
4288
4289    // NOTE: legacy `scoped_shortcut_fires_when_focused_in_subtree` test
4290    // removed along with the ShortcutMap dispatch path. Scope-aware
4291    // dispatch is handled by the new ShortcutRegistry.
4292
4293    // --- Intent / Action dispatch ------------------------------
4294
4295    #[test]
4296    fn shortcut_fires_matching_action_on_source_widget() {
4297        use crate::action::Action;
4298        use crate::shortcut::{KeyStroke, Shortcut};
4299        use std::cell::Cell;
4300        use std::rc::Rc;
4301
4302        let fired = Rc::new(Cell::new(false));
4303        let fired_flag = fired.clone();
4304
4305        let mut tree = WidgetTree::new();
4306        let widget = tree.add(FillWidget::new().focusable());
4307        tree.push_action(
4308            widget,
4309            Action::new("app.save").on_invoke(move |_intent, _ctx| {
4310                fired_flag.set(true);
4311            }),
4312        );
4313        tree.shortcut_registry_mut().register(
4314            Shortcut::new("app.save")
4315                .primary(KeyStroke::command(Key::S))
4316                .build(),
4317        );
4318
4319        tree.layout(SizeProposal::exact(100.0, 50.0));
4320        tree.focus(widget);
4321
4322        tree.press_key(Key::S, Modifiers::COMMAND);
4323        assert!(fired.get(), "matching action must fire on KeyDown");
4324    }
4325
4326    #[test]
4327    fn global_shortcut_fires_without_focused_widget() {
4328        use crate::action::Action;
4329        use crate::shortcut::{KeyStroke, Shortcut};
4330        use std::cell::Cell;
4331        use std::rc::Rc;
4332
4333        // Regression: a global shortcut must fire even when no widget
4334        // is focused. A root-registered action should still receive
4335        // the intent (anchored at the root as a fallback).
4336        let fired = Rc::new(Cell::new(false));
4337        let fired_flag = fired.clone();
4338
4339        let mut tree = WidgetTree::new();
4340        let root = tree.add(FillWidget::new());
4341        tree.push_action(
4342            root,
4343            Action::new("app.save").on_invoke(move |_intent, _ctx| {
4344                fired_flag.set(true);
4345            }),
4346        );
4347        tree.shortcut_registry_mut().register(
4348            Shortcut::new("app.save")
4349                .primary(KeyStroke::command(Key::S))
4350                .build(),
4351        );
4352
4353        tree.layout(SizeProposal::exact(100.0, 50.0));
4354        // Deliberately no focus() call.
4355
4356        tree.press_key(Key::S, Modifiers::COMMAND);
4357        assert!(
4358            fired.get(),
4359            "global shortcut must fire without a focused widget"
4360        );
4361    }
4362
4363    #[test]
4364    fn global_shortcut_fires_after_focused_widget_destroyed() {
4365        use crate::action::Action;
4366        use crate::shortcut::{KeyStroke, Shortcut};
4367        use std::cell::Cell;
4368        use std::rc::Rc;
4369
4370        // Regression: if the focused widget is destroyed (e.g. during a
4371        // rebuild after a settings-panel rebind), focus must be cleared
4372        // so the next global shortcut falls through to the root-anchor
4373        // path instead of dispatching from a stale, destroyed id.
4374        let fired = Rc::new(Cell::new(false));
4375        let fired_flag = fired.clone();
4376
4377        let mut tree = WidgetTree::new();
4378        let root = tree.add(FillWidget::new());
4379        let focusable = tree.add_child(root, FillWidget::new().focusable());
4380        tree.push_action(
4381            root,
4382            Action::new("app.save").on_invoke(move |_intent, _ctx| {
4383                fired_flag.set(true);
4384            }),
4385        );
4386        tree.shortcut_registry_mut().register(
4387            Shortcut::new("app.save")
4388                .primary(KeyStroke::command(Key::S))
4389                .build(),
4390        );
4391
4392        tree.layout(SizeProposal::exact(100.0, 50.0));
4393        tree.focus(focusable);
4394        assert_eq!(tree.focused(), Some(focusable));
4395
4396        // Destroy the focused subtree (simulates a rebuild that drops
4397        // the currently-focused Rebind button).
4398        tree.destroy_subtree(focusable);
4399        assert_eq!(tree.focused(), None, "focus must clear when destroyed");
4400
4401        tree.press_key(Key::S, Modifiers::COMMAND);
4402        assert!(
4403            fired.get(),
4404            "global shortcut must still fire after the focused widget is destroyed"
4405        );
4406    }
4407
4408    #[test]
4409    fn scoped_shortcut_matches_only_when_focus_in_scope() {
4410        use crate::action::Action;
4411        use crate::shortcut::{KeyStroke, Shortcut, ShortcutScope};
4412        use std::cell::Cell;
4413        use std::rc::Rc;
4414
4415        let fired = Rc::new(Cell::new(0));
4416        let fired_flag = fired.clone();
4417
4418        let mut tree = WidgetTree::new();
4419        let scope_root = tree.add(FillWidget::new().focusable());
4420        let inside = tree.add_child(scope_root, FillWidget::new().focusable());
4421        let outside = tree.add(FillWidget::new().focusable());
4422
4423        tree.push_action(
4424            scope_root,
4425            Action::new("editor.find").on_invoke(move |_i, _c| {
4426                fired_flag.set(fired_flag.get() + 1);
4427            }),
4428        );
4429        tree.shortcut_registry_mut().register(
4430            Shortcut::new("editor.find")
4431                .primary(KeyStroke::command(Key::F))
4432                .scope(ShortcutScope::Scoped(scope_root))
4433                .build(),
4434        );
4435
4436        tree.layout(SizeProposal::exact(200.0, 100.0));
4437
4438        // Focus outside the scope: the shortcut does NOT activate.
4439        tree.focus(outside);
4440        tree.press_key(Key::F, Modifiers::COMMAND);
4441        assert_eq!(
4442            fired.get(),
4443            0,
4444            "scoped shortcut must not fire outside scope"
4445        );
4446
4447        // Focus inside the scope: it fires.
4448        tree.focus(inside);
4449        tree.press_key(Key::F, Modifiers::COMMAND);
4450        assert_eq!(
4451            fired.get(),
4452            1,
4453            "scoped shortcut must fire when focus in scope"
4454        );
4455    }
4456
4457    #[test]
4458    fn same_chord_scoped_first_falls_back_to_global_when_focus_outside() {
4459        // Defect 1: a Scoped binding that sorts first by id must NOT
4460        // shadow the slot when focus is outside its subtree — the
4461        // applicable Global binding fires instead. (`editor.saveBlock`
4462        // < `zzz.global.save`, so the scoped one wins the id-order race
4463        // that `find_by_keystroke` used to settle on.)
4464        use crate::action::Action;
4465        use crate::shortcut::{KeyStroke, Shortcut, ShortcutScope};
4466        use std::cell::Cell;
4467        use std::rc::Rc;
4468
4469        let scoped_fired = Rc::new(Cell::new(0));
4470        let global_fired = Rc::new(Cell::new(0));
4471        let sf = scoped_fired.clone();
4472        let gf = global_fired.clone();
4473
4474        let mut tree = WidgetTree::new();
4475        let root = tree.add(FillWidget::new());
4476        let editor = tree.add_child(root, FillWidget::new().focusable());
4477        let _editor_inner = tree.add_child(editor, FillWidget::new().focusable());
4478        let sidebar = tree.add_child(root, FillWidget::new().focusable());
4479
4480        tree.push_action(
4481            editor,
4482            Action::new("editor.saveBlock").on_invoke(move |_i, _c| sf.set(sf.get() + 1)),
4483        );
4484        tree.push_action(
4485            root,
4486            Action::new("zzz.global.save").on_invoke(move |_i, _c| gf.set(gf.get() + 1)),
4487        );
4488        tree.shortcut_registry_mut().register(
4489            Shortcut::new("editor.saveBlock")
4490                .primary(KeyStroke::command(Key::S))
4491                .scope(ShortcutScope::Scoped(editor))
4492                .build(),
4493        );
4494        tree.shortcut_registry_mut().register(
4495            Shortcut::new("zzz.global.save")
4496                .primary(KeyStroke::command(Key::S))
4497                .build(),
4498        );
4499
4500        tree.layout(SizeProposal::exact(200.0, 100.0));
4501        tree.focus(sidebar);
4502        tree.press_key(Key::S, Modifiers::COMMAND);
4503
4504        assert_eq!(global_fired.get(), 1, "applicable global must fire");
4505        assert_eq!(
4506            scoped_fired.get(),
4507            0,
4508            "inapplicable scoped binding must not eat the chord"
4509        );
4510    }
4511
4512    #[test]
4513    fn same_chord_global_first_yields_to_scoped_when_focus_inside() {
4514        // Defect 2: a Global binding that sorts first by id must yield to
4515        // an in-focus Scoped binding (most-specific-scope wins), then
4516        // reclaim the chord once focus leaves the scope. (`app.save` <
4517        // `editor.saveBlock`, so the global one wins id order.)
4518        use crate::action::Action;
4519        use crate::shortcut::{KeyStroke, Shortcut, ShortcutScope};
4520        use std::cell::Cell;
4521        use std::rc::Rc;
4522
4523        let scoped_fired = Rc::new(Cell::new(0));
4524        let global_fired = Rc::new(Cell::new(0));
4525        let sf = scoped_fired.clone();
4526        let gf = global_fired.clone();
4527
4528        let mut tree = WidgetTree::new();
4529        let root = tree.add(FillWidget::new());
4530        let editor = tree.add_child(root, FillWidget::new().focusable());
4531        let editor_inner = tree.add_child(editor, FillWidget::new().focusable());
4532        let sidebar = tree.add_child(root, FillWidget::new().focusable());
4533
4534        tree.push_action(
4535            editor,
4536            Action::new("editor.saveBlock").on_invoke(move |_i, _c| sf.set(sf.get() + 1)),
4537        );
4538        tree.push_action(
4539            root,
4540            Action::new("app.save").on_invoke(move |_i, _c| gf.set(gf.get() + 1)),
4541        );
4542        tree.shortcut_registry_mut().register(
4543            Shortcut::new("app.save")
4544                .primary(KeyStroke::command(Key::S))
4545                .build(),
4546        );
4547        tree.shortcut_registry_mut().register(
4548            Shortcut::new("editor.saveBlock")
4549                .primary(KeyStroke::command(Key::S))
4550                .scope(ShortcutScope::Scoped(editor))
4551                .build(),
4552        );
4553
4554        tree.layout(SizeProposal::exact(200.0, 100.0));
4555
4556        // Focus inside the editor: the scoped binding wins over global.
4557        tree.focus(editor_inner);
4558        tree.press_key(Key::S, Modifiers::COMMAND);
4559        assert_eq!(
4560            scoped_fired.get(),
4561            1,
4562            "in-focus scoped must win over global"
4563        );
4564        assert_eq!(
4565            global_fired.get(),
4566            0,
4567            "global must yield to the scoped binding"
4568        );
4569
4570        // Focus outside the editor: global reclaims the chord.
4571        tree.focus(sidebar);
4572        tree.press_key(Key::S, Modifiers::COMMAND);
4573        assert_eq!(scoped_fired.get(), 1, "scoped stays put outside its scope");
4574        assert_eq!(
4575            global_fired.get(),
4576            1,
4577            "global fires when focus leaves the scope"
4578        );
4579    }
4580
4581    #[test]
4582    fn propagated_action_lets_ancestor_handle() {
4583        use crate::action::Action;
4584        use crate::intent::IntentResponse;
4585        use crate::shortcut::{KeyStroke, Shortcut};
4586        use std::cell::Cell;
4587        use std::rc::Rc;
4588
4589        let inner_seen = Rc::new(Cell::new(false));
4590        let outer_seen = Rc::new(Cell::new(false));
4591        let inner_flag = inner_seen.clone();
4592        let outer_flag = outer_seen.clone();
4593
4594        let mut tree = WidgetTree::new();
4595        let outer = tree.add(FillWidget::new().focusable());
4596        let inner = tree.add_child(outer, FillWidget::new().focusable());
4597
4598        // Inner observes then propagates; outer consumes.
4599        tree.push_action(
4600            inner,
4601            Action::new("app.save").on_invoke_with_response(move |_i, _c| {
4602                inner_flag.set(true);
4603                IntentResponse::Propagated
4604            }),
4605        );
4606        tree.push_action(
4607            outer,
4608            Action::new("app.save").on_invoke(move |_i, _c| {
4609                outer_flag.set(true);
4610            }),
4611        );
4612        tree.shortcut_registry_mut().register(
4613            Shortcut::new("app.save")
4614                .primary(KeyStroke::command(Key::S))
4615                .build(),
4616        );
4617
4618        tree.layout(SizeProposal::exact(100.0, 50.0));
4619        tree.focus(inner);
4620
4621        tree.press_key(Key::S, Modifiers::COMMAND);
4622        assert!(inner_seen.get(), "inner action observed the intent");
4623        assert!(outer_seen.get(), "outer action reached after Propagated");
4624    }
4625
4626    #[test]
4627    fn handled_action_stops_propagation() {
4628        use crate::action::Action;
4629        use crate::shortcut::{KeyStroke, Shortcut};
4630        use std::cell::Cell;
4631        use std::rc::Rc;
4632
4633        let inner_seen = Rc::new(Cell::new(false));
4634        let outer_seen = Rc::new(Cell::new(false));
4635        let inner_flag = inner_seen.clone();
4636        let outer_flag = outer_seen.clone();
4637
4638        let mut tree = WidgetTree::new();
4639        let outer = tree.add(FillWidget::new().focusable());
4640        let inner = tree.add_child(outer, FillWidget::new().focusable());
4641
4642        tree.push_action(
4643            inner,
4644            Action::new("app.save").on_invoke(move |_i, _c| {
4645                inner_flag.set(true);
4646            }),
4647        );
4648        tree.push_action(
4649            outer,
4650            Action::new("app.save").on_invoke(move |_i, _c| {
4651                outer_flag.set(true);
4652            }),
4653        );
4654        tree.shortcut_registry_mut().register(
4655            Shortcut::new("app.save")
4656                .primary(KeyStroke::command(Key::S))
4657                .build(),
4658        );
4659
4660        tree.layout(SizeProposal::exact(100.0, 50.0));
4661        tree.focus(inner);
4662
4663        tree.press_key(Key::S, Modifiers::COMMAND);
4664        assert!(inner_seen.get());
4665        assert!(!outer_seen.get(), "Handled at inner must stop propagation");
4666    }
4667
4668    #[test]
4669    fn disabled_action_propagates_by_default() {
4670        use crate::action::Action;
4671        use crate::shortcut::{KeyStroke, Shortcut};
4672        use crate::signal::Signal;
4673        use std::cell::Cell;
4674        use std::rc::Rc;
4675
4676        let inner_seen = Rc::new(Cell::new(false));
4677        let outer_seen = Rc::new(Cell::new(false));
4678        let inner_flag = inner_seen.clone();
4679        let outer_flag = outer_seen.clone();
4680
4681        let mut tree = WidgetTree::new();
4682        let outer = tree.add(FillWidget::new().focusable());
4683        let inner = tree.add_child(outer, FillWidget::new().focusable());
4684
4685        let enabled = Signal::new(false);
4686        tree.push_action(
4687            inner,
4688            Action::new("app.save")
4689                .enabled_when(enabled.clone())
4690                .on_invoke(move |_i, _c| {
4691                    inner_flag.set(true);
4692                }),
4693        );
4694        tree.push_action(
4695            outer,
4696            Action::new("app.save").on_invoke(move |_i, _c| {
4697                outer_flag.set(true);
4698            }),
4699        );
4700        tree.shortcut_registry_mut().register(
4701            Shortcut::new("app.save")
4702                .primary(KeyStroke::command(Key::S))
4703                .build(),
4704        );
4705
4706        tree.layout(SizeProposal::exact(100.0, 50.0));
4707        tree.focus(inner);
4708
4709        tree.press_key(Key::S, Modifiers::COMMAND);
4710        assert!(!inner_seen.get(), "disabled inner must not run");
4711        assert!(
4712            outer_seen.get(),
4713            "intent must propagate past disabled inner"
4714        );
4715    }
4716
4717    #[test]
4718    fn disabled_action_with_non_propagating_shortcut_consumes() {
4719        use crate::action::Action;
4720        use crate::shortcut::{KeyStroke, Shortcut};
4721        use crate::signal::Signal;
4722        use std::cell::Cell;
4723        use std::rc::Rc;
4724
4725        let inner_seen = Rc::new(Cell::new(false));
4726        let outer_seen = Rc::new(Cell::new(false));
4727        let inner_flag = inner_seen.clone();
4728        let outer_flag = outer_seen.clone();
4729
4730        let mut tree = WidgetTree::new();
4731        let outer = tree.add(FillWidget::new().focusable());
4732        let inner = tree.add_child(outer, FillWidget::new().focusable());
4733
4734        let enabled = Signal::new(false);
4735        tree.push_action(
4736            inner,
4737            Action::new("app.save")
4738                .enabled_when(enabled.clone())
4739                .on_invoke(move |_i, _c| {
4740                    inner_flag.set(true);
4741                }),
4742        );
4743        tree.push_action(
4744            outer,
4745            Action::new("app.save").on_invoke(move |_i, _c| {
4746                outer_flag.set(true);
4747            }),
4748        );
4749        tree.shortcut_registry_mut().register(
4750            Shortcut::new("app.save")
4751                .primary(KeyStroke::command(Key::S))
4752                .propagate_when_disabled(false)
4753                .build(),
4754        );
4755
4756        tree.layout(SizeProposal::exact(100.0, 50.0));
4757        tree.focus(inner);
4758
4759        tree.press_key(Key::S, Modifiers::COMMAND);
4760        assert!(!inner_seen.get(), "disabled inner still does not run");
4761        assert!(
4762            !outer_seen.get(),
4763            "intent must NOT propagate when shortcut disallows it"
4764        );
4765    }
4766
4767    #[test]
4768    fn send_intent_from_handler_reaches_ancestor_action() {
4769        use crate::action::Action;
4770        use crate::intent::Intent;
4771        use std::cell::Cell;
4772        use std::rc::Rc;
4773
4774        let save_seen = Rc::new(Cell::new(false));
4775        let save_flag = save_seen.clone();
4776
4777        let mut tree = WidgetTree::new();
4778        let root = tree.add(FillWidget::new());
4779        let button = tree.add_child(
4780            root,
4781            FillWidget::new().on_tap(|_pos, ctx| {
4782                ctx.send_intent(Intent::new("app.save"));
4783            }),
4784        );
4785        tree.push_action(
4786            root,
4787            Action::new("app.save").on_invoke(move |_i, _c| {
4788                save_flag.set(true);
4789            }),
4790        );
4791
4792        tree.layout(SizeProposal::exact(100.0, 50.0));
4793        tree.click(button);
4794        assert!(
4795            save_seen.get(),
4796            "ctx.send_intent must reach ancestor action"
4797        );
4798    }
4799
4800    #[test]
4801    fn widget_type_histogram_counts_distinct_types() {
4802        // The histogram surfaces concrete widget types
4803        // by std::any::type_name_of_val. Widgets become active
4804        // after the first layout pass, so we run that before
4805        // checking the histogram.
4806        let mut tree = WidgetTree::new();
4807        let _ = tree.add(FillWidget::new());
4808        let _ = tree.add(FillWidget::new());
4809        let _ = tree.add(FillWidget::new());
4810        tree.layout(SizeProposal::exact(100.0, 100.0));
4811        let histogram = tree.widget_type_histogram();
4812        let total: u32 = histogram.values().sum();
4813        assert!(
4814            total >= 3,
4815            "expected at least 3 active widgets, got {total}: {histogram:?}"
4816        );
4817        let fillwidget_entries: u32 = histogram
4818            .iter()
4819            .filter(|(k, _)| k.contains("FillWidget"))
4820            .map(|(_, v)| *v)
4821            .sum();
4822        assert!(
4823            fillwidget_entries >= 3,
4824            "expected ≥3 FillWidget instances; histogram = {histogram:?}"
4825        );
4826        assert_eq!(tree.active_widget_count() as u32, total);
4827    }
4828
4829    #[test]
4830    fn intent_source_tagged_handler_for_tap_activation() {
4831        // A tap-driven `ctx.send_intent` must surface as
4832        // `IntentSource::Handler` to ancestor actions, not the
4833        // `Programmatic` default of `Intent::new`.
4834        use crate::action::Action;
4835        use crate::intent::Intent;
4836        use crate::telemetry::IntentSource;
4837        use std::cell::Cell;
4838        use std::rc::Rc;
4839        let captured = Rc::new(Cell::new(IntentSource::Unknown));
4840        let captured_for_action = captured.clone();
4841
4842        let mut tree = WidgetTree::new();
4843        let root = tree.add(FillWidget::new());
4844        let button = tree.add_child(
4845            root,
4846            FillWidget::new().on_tap(|_pos, ctx| {
4847                ctx.send_intent(Intent::new("app.save"));
4848            }),
4849        );
4850        tree.push_action(
4851            root,
4852            Action::new("app.save").on_invoke(move |intent, _c| {
4853                captured_for_action.set(intent.source);
4854            }),
4855        );
4856
4857        tree.layout(SizeProposal::exact(100.0, 50.0));
4858        tree.click(button);
4859        assert_eq!(
4860            captured.get(),
4861            IntentSource::Handler,
4862            "tap-driven intent must tag IntentSource::Handler"
4863        );
4864    }
4865
4866    #[test]
4867    fn intent_source_programmatic_when_no_handler_active() {
4868        use crate::intent::Intent;
4869        use crate::telemetry::IntentSource;
4870        let intent = Intent::new("app.demo");
4871        assert_eq!(intent.source, IntentSource::Programmatic);
4872
4873        // ctx.send_intent without a handler scope keeps it Programmatic.
4874        let mut ctx = EventContext::new();
4875        ctx.send_intent(Intent::new("app.demo"));
4876        let queued = ctx.pending_intents.first().expect("intent queued");
4877        assert_eq!(queued.source, IntentSource::Programmatic);
4878    }
4879
4880    #[test]
4881    fn with_intent_source_overrides_for_managed_widgets() {
4882        use crate::intent::Intent;
4883        use crate::telemetry::IntentSource;
4884        let mut ctx = EventContext::new();
4885        ctx.with_intent_source(IntentSource::Menu, |ctx| {
4886            ctx.send_intent(Intent::new("app.demo"));
4887        });
4888        let queued = ctx.pending_intents.first().expect("intent queued");
4889        assert_eq!(
4890            queued.source,
4891            IntentSource::Menu,
4892            "with_intent_source(Menu) must tag the dispatched intent"
4893        );
4894
4895        // After the closure returns, current_source is restored —
4896        // a follow-up send_intent without a wrapping closure goes
4897        // back to the default (no override).
4898        ctx.send_intent(Intent::new("app.next"));
4899        let next = ctx.pending_intents.last().expect("second intent");
4900        assert_eq!(next.source, IntentSource::Programmatic);
4901    }
4902
4903    #[test]
4904    fn disabled_shortcut_falls_through_to_focused_widget() {
4905        use crate::action::Action;
4906        use crate::shortcut::{KeyStroke, Shortcut};
4907        use crate::signal::Signal;
4908        use std::cell::Cell;
4909        use std::rc::Rc;
4910
4911        let action_fired = Rc::new(Cell::new(false));
4912        let on_key_fired = Rc::new(Cell::new(false));
4913        let af = action_fired.clone();
4914        let kf = on_key_fired.clone();
4915
4916        let enabled = Signal::new(false);
4917
4918        let mut tree = WidgetTree::new();
4919        let widget = tree.add(FillWidget::new().focusable().on_key(move |event, _ctx| {
4920            if matches!(
4921                event,
4922                WidgetEvent::KeyDown {
4923                    key: Key::S,
4924                    modifiers,
4925                    ..
4926                } if modifiers.command()
4927            ) {
4928                kf.set(true);
4929                return EventResponse::Handled;
4930            }
4931            EventResponse::Ignored
4932        }));
4933        tree.push_action(
4934            widget,
4935            Action::new("app.save").on_invoke(move |_i, _c| af.set(true)),
4936        );
4937        tree.shortcut_registry_mut().register(
4938            Shortcut::new("app.save")
4939                .primary(KeyStroke::command(Key::S))
4940                .enabled_when(enabled.clone())
4941                .build(),
4942        );
4943
4944        tree.layout(SizeProposal::exact(100.0, 50.0));
4945        tree.focus(widget);
4946
4947        // Disabled: keystroke falls through to on_key.
4948        tree.press_key(Key::S, Modifiers::COMMAND);
4949        assert!(
4950            !action_fired.get(),
4951            "disabled shortcut must not invoke its action"
4952        );
4953        assert!(
4954            on_key_fired.get(),
4955            "disabled shortcut must let KeyDown reach the focused widget"
4956        );
4957
4958        // Re-enable → action fires, on_key does not.
4959        on_key_fired.set(false);
4960        enabled.set(true);
4961        tree.press_key(Key::S, Modifiers::COMMAND);
4962        assert!(action_fired.get(), "re-enabled shortcut must dispatch");
4963        assert!(
4964            !on_key_fired.get(),
4965            "enabled shortcut must consume the KeyDown"
4966        );
4967    }
4968
4969    #[test]
4970    fn keyboard_capture_bypasses_shortcut() {
4971        use crate::action::Action;
4972        use crate::shortcut::{KeyStroke, Shortcut};
4973        use std::cell::Cell;
4974        use std::rc::Rc;
4975
4976        // A focused keyboard-capture surface (e.g. a terminal) must receive
4977        // the accelerator chord itself (⌘S on macOS, Ctrl+S elsewhere), even
4978        // though an ENABLED global shortcut binds it — the whole point of
4979        // GAP 1. A non-capturing widget must yield to the shortcut (the
4980        // control case).
4981        fn run(capture: bool) -> (bool, bool) {
4982            let action_fired = Rc::new(Cell::new(false));
4983            let on_key_fired = Rc::new(Cell::new(false));
4984            let af = action_fired.clone();
4985            let kf = on_key_fired.clone();
4986
4987            let mut tree = WidgetTree::new();
4988            let widget = tree.add(
4989                FillWidget::new()
4990                    .focusable()
4991                    .keyboard_capture(capture)
4992                    .on_key(move |event, _ctx| {
4993                        if matches!(
4994                            event,
4995                            WidgetEvent::KeyDown { key: Key::S, modifiers, .. } if modifiers.command()
4996                        ) {
4997                            kf.set(true);
4998                            return EventResponse::Handled;
4999                        }
5000                        EventResponse::Ignored
5001                    }),
5002            );
5003            tree.push_action(
5004                widget,
5005                Action::new("app.save").on_invoke(move |_i, _c| af.set(true)),
5006            );
5007            tree.shortcut_registry_mut().register(
5008                Shortcut::new("app.save")
5009                    .primary(KeyStroke::command(Key::S))
5010                    .build(),
5011            );
5012
5013            tree.layout(SizeProposal::exact(100.0, 50.0));
5014            tree.focus(widget);
5015            tree.press_key(Key::S, Modifiers::COMMAND);
5016            (action_fired.get(), on_key_fired.get())
5017        }
5018
5019        // Capture on: the shortcut is bypassed, the widget sees the key.
5020        let (action, on_key) = run(true);
5021        assert!(
5022            !action,
5023            "keyboard_capture must suppress the shortcut action"
5024        );
5025        assert!(on_key, "keyboard_capture must deliver the raw KeyDown");
5026
5027        // Capture off (control): the shortcut consumes the key.
5028        let (action, on_key) = run(false);
5029        assert!(action, "without capture the shortcut must fire");
5030        assert!(!on_key, "without capture the widget must not see the key");
5031    }
5032
5033    #[test]
5034    fn ctrl_tab_always_escapes_a_keyboard_capture_surface() {
5035        use std::cell::Cell;
5036        use std::rc::Rc;
5037
5038        // WCAG 2.1.2. A capture surface answers `Handled` to every key —
5039        // that is what it is for — so the "cycle focus only when the focused
5040        // widget did not handle Tab" rule can never get focus out of one.
5041        // Ctrl+Tab / Ctrl+Shift+Tab are therefore reserved by the dispatcher
5042        // and never reach the widget at all.
5043        let saw_key = Rc::new(Cell::new(false));
5044        let sk = saw_key.clone();
5045
5046        let mut tree = WidgetTree::new();
5047        let capture = tree.add(
5048            FillWidget::new()
5049                .focusable()
5050                .keyboard_capture(true)
5051                // The greediest possible handler: everything is consumed.
5052                .on_key(move |_event, _ctx| {
5053                    sk.set(true);
5054                    EventResponse::Handled
5055                }),
5056        );
5057        let neighbour = tree.add(FillWidget::new().focusable());
5058        tree.layout(SizeProposal::exact(100.0, 50.0));
5059
5060        // Plain Tab stays inside: the widget consumed it (a terminal writes
5061        // it to the child as `\t`).
5062        tree.focus(capture);
5063        tree.press_key(Key::Tab, Modifiers::NONE);
5064        assert!(saw_key.get(), "plain Tab must reach the capture surface");
5065        assert_eq!(
5066            tree.focused(),
5067            Some(capture),
5068            "plain Tab must not move focus off a capture surface"
5069        );
5070
5071        // Ctrl+Tab escapes forward, without the widget ever seeing it.
5072        saw_key.set(false);
5073        tree.press_key(Key::Tab, Modifiers::CTRL);
5074        assert!(
5075            !saw_key.get(),
5076            "Ctrl+Tab is reserved and must not reach the capture surface"
5077        );
5078        assert_eq!(
5079            tree.focused(),
5080            Some(neighbour),
5081            "Ctrl+Tab must move focus out of a capture surface"
5082        );
5083
5084        // And backwards.
5085        tree.focus(capture);
5086        tree.press_key(Key::Tab, Modifiers::CTRL | Modifiers::SHIFT);
5087        assert_eq!(
5088            tree.focused(),
5089            Some(neighbour),
5090            "Ctrl+Shift+Tab must move focus out of a capture surface"
5091        );
5092    }
5093
5094    #[test]
5095    fn scope_mismatch_does_not_invoke_on_activate() {
5096        use crate::intent::Intent;
5097        use crate::shortcut::{KeyStroke, Shortcut, ShortcutScope};
5098        use std::cell::Cell;
5099        use std::rc::Rc;
5100
5101        // Regression: before the find/invoke split, `on_activate` ran
5102        // even when the focused widget was outside the shortcut's
5103        // scope, and any side effects on its ctx were silently
5104        // dropped. The closure must now only run when the scope
5105        // check has already passed.
5106        let activated = Rc::new(Cell::new(false));
5107        let activated_flag = activated.clone();
5108
5109        let mut tree = WidgetTree::new();
5110        let scope_root = tree.add(FillWidget::new().focusable());
5111        let outside = tree.add(FillWidget::new().focusable());
5112
5113        tree.shortcut_registry_mut().register(
5114            Shortcut::new("editor.find")
5115                .primary(KeyStroke::command(Key::F))
5116                .scope(ShortcutScope::Scoped(scope_root))
5117                .on_activate(move |_ks, _ctx| {
5118                    activated_flag.set(true);
5119                    Intent::new("editor.find")
5120                })
5121                .build(),
5122        );
5123
5124        tree.layout(SizeProposal::exact(200.0, 100.0));
5125        tree.focus(outside);
5126
5127        tree.press_key(Key::F, Modifiers::COMMAND);
5128        assert!(
5129            !activated.get(),
5130            "on_activate must not run when focus is outside the shortcut's scope"
5131        );
5132    }
5133
5134    #[test]
5135    fn key_capture_runs_callback_and_bypasses_registry() {
5136        use crate::action::Action;
5137        use crate::shortcut::{KeyStroke, Shortcut};
5138        use std::cell::Cell;
5139        use std::rc::Rc;
5140
5141        let action_fired = Rc::new(Cell::new(false));
5142        let af = action_fired.clone();
5143        let captured = Rc::new(Cell::new(None));
5144        let cf = captured.clone();
5145
5146        let mut tree = WidgetTree::new();
5147        let widget = tree.add(FillWidget::new().focusable());
5148        tree.push_action(
5149            widget,
5150            Action::new("app.save").on_invoke(move |_i, _c| af.set(true)),
5151        );
5152        tree.shortcut_registry_mut().register(
5153            Shortcut::new("app.save")
5154                .primary(KeyStroke::command(Key::S))
5155                .build(),
5156        );
5157
5158        tree.layout(SizeProposal::exact(100.0, 50.0));
5159        tree.focus(widget);
5160
5161        let handle = tree.begin_key_capture(move |ks, _reg, _ctx| cf.set(Some(ks)));
5162        assert!(tree.is_capturing_keys());
5163
5164        tree.press_key(Key::S, Modifiers::COMMAND);
5165        assert_eq!(
5166            captured.get(),
5167            Some(KeyStroke::command(Key::S)),
5168            "capture callback must receive the chord"
5169        );
5170        assert!(
5171            !action_fired.get(),
5172            "shortcut action must not fire while capture is armed"
5173        );
5174        assert!(
5175            !tree.is_capturing_keys(),
5176            "capture is one-shot; next KeyDown flows normally"
5177        );
5178        drop(handle);
5179    }
5180
5181    #[test]
5182    fn key_capture_can_rebind_through_registry() {
5183        use crate::shortcut::{KeyStroke, Shortcut};
5184
5185        let mut tree = WidgetTree::new();
5186        let widget = tree.add(FillWidget::new().focusable());
5187        tree.shortcut_registry_mut().register(
5188            Shortcut::new("app.save")
5189                .primary(KeyStroke::command(Key::S))
5190                .build(),
5191        );
5192
5193        tree.layout(SizeProposal::exact(100.0, 50.0));
5194        tree.focus(widget);
5195
5196        // Arm capture: whatever chord comes next, rebind app.save to it.
5197        let _h = tree.begin_key_capture(|ks, reg, _ctx| {
5198            reg.rebind_primary("app.save", Some(ks));
5199        });
5200
5201        tree.press_key(Key::B, Modifiers::COMMAND | Modifiers::SHIFT);
5202        assert_eq!(
5203            tree.shortcut_registry()
5204                .effective("app.save")
5205                .unwrap()
5206                .primary,
5207            Some(KeyStroke::command_shift(Key::B))
5208        );
5209    }
5210
5211    #[test]
5212    fn dropping_capture_handle_cancels_capture() {
5213        use crate::shortcut::{KeyStroke, Shortcut};
5214        use std::cell::Cell;
5215        use std::rc::Rc;
5216
5217        let action_fired = Rc::new(Cell::new(false));
5218        let af = action_fired.clone();
5219        let capture_fired = Rc::new(Cell::new(false));
5220        let cf = capture_fired.clone();
5221
5222        let mut tree = WidgetTree::new();
5223        let widget = tree.add(FillWidget::new().focusable());
5224        tree.push_action(
5225            widget,
5226            crate::action::Action::new("app.save").on_invoke(move |_i, _c| af.set(true)),
5227        );
5228        tree.shortcut_registry_mut().register(
5229            Shortcut::new("app.save")
5230                .primary(KeyStroke::command(Key::S))
5231                .build(),
5232        );
5233        tree.layout(SizeProposal::exact(100.0, 50.0));
5234        tree.focus(widget);
5235
5236        // Arm capture in a scope, then drop the handle before any key
5237        // is pressed. The next KeyDown must fall through to the normal
5238        // shortcut path, firing the action — not the cancelled capture.
5239        {
5240            let _h = tree.begin_key_capture(move |_ks, _reg, _ctx| cf.set(true));
5241            assert!(tree.is_capturing_keys());
5242            // `_h` drops here → cancel.
5243        }
5244        assert!(
5245            !tree.is_capturing_keys(),
5246            "dropping the handle must cancel the capture"
5247        );
5248
5249        tree.press_key(Key::S, Modifiers::COMMAND);
5250        assert!(!capture_fired.get(), "cancelled capture must not fire");
5251        assert!(
5252            action_fired.get(),
5253            "shortcut action runs after capture was cancelled"
5254        );
5255    }
5256
5257    #[test]
5258    fn second_begin_key_capture_does_not_racecancel_first() {
5259        use std::cell::Cell;
5260        use std::rc::Rc;
5261
5262        let first = Rc::new(Cell::new(false));
5263        let second = Rc::new(Cell::new(false));
5264        let f = first.clone();
5265        let s = second.clone();
5266
5267        let mut tree = WidgetTree::new();
5268        let widget = tree.add(FillWidget::new().focusable());
5269        tree.layout(SizeProposal::exact(100.0, 50.0));
5270        tree.focus(widget);
5271
5272        // Arm #1 then replace with #2. #1's handle is later dropped,
5273        // which would have cancelled the active capture under the old
5274        // `Option<Box<FnOnce>>` design — CaptureHandle now ties each
5275        // session to its own slot, so the drop only clears #1's
5276        // (orphaned) slot, not #2.
5277        let h1 = tree.begin_key_capture(move |_ks, _reg, _ctx| f.set(true));
5278        let _h2 = tree.begin_key_capture(move |_ks, _reg, _ctx| s.set(true));
5279        drop(h1);
5280
5281        assert!(
5282            tree.is_capturing_keys(),
5283            "dropping the older handle must not cancel the active capture"
5284        );
5285        tree.press_key(Key::K, Modifiers::COMMAND);
5286        assert!(!first.get());
5287        assert!(second.get(), "newest capture wins");
5288    }
5289
5290    #[test]
5291    fn capture_callback_can_send_intent() {
5292        use crate::action::Action;
5293        use crate::intent::Intent;
5294
5295        use std::cell::Cell;
5296        use std::rc::Rc;
5297
5298        let ran = Rc::new(Cell::new(false));
5299        let flag = ran.clone();
5300
5301        let mut tree = WidgetTree::new();
5302        let widget = tree.add(FillWidget::new().focusable());
5303        tree.push_action(
5304            widget,
5305            Action::new("app.save").on_invoke(move |_i, _c| flag.set(true)),
5306        );
5307        tree.layout(SizeProposal::exact(100.0, 50.0));
5308        tree.focus(widget);
5309
5310        let _h = tree.begin_key_capture(|_ks, _reg, ctx| {
5311            ctx.send_intent(Intent::new("app.save"));
5312        });
5313        tree.press_key(Key::X, Modifiers::COMMAND);
5314        assert!(
5315            ran.get(),
5316            "intent queued from capture callback must dispatch"
5317        );
5318    }
5319
5320    #[test]
5321    fn binding_registry_does_not_accumulate_across_rebuilds() {
5322        use crate::binding::BindingLevel;
5323        use crate::signal::Signal;
5324
5325        #[derive(Debug)]
5326        struct BoundLeaf {
5327            tick: Signal<u64>,
5328        }
5329        impl crate::widget::Widget for BoundLeaf {
5330            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
5331                self.tick.bind_to(
5332                    ctx.self_id(),
5333                    ctx.binding_registry(),
5334                    BindingLevel::Relayout,
5335                );
5336                Vec::new()
5337            }
5338            fn layout_response(
5339                &self,
5340                proposal: SizeProposal,
5341                _ctx: &crate::widget::LayoutContext,
5342            ) -> crate::widget::LayoutResponse {
5343                proposal.resolve(10.0, 10.0).into()
5344            }
5345        }
5346
5347        let mut tree = WidgetTree::new();
5348        let tick = Signal::new(0_u64);
5349        let widget = tree.add(BoundLeaf { tick: tick.clone() });
5350        tree.layout(SizeProposal::exact(200.0, 200.0));
5351        let after_first_build = tree.binding_registry().len();
5352        assert!(after_first_build >= 1);
5353
5354        // Force rebuild a handful of times and verify the binding
5355        // count does not keep growing. Pre-fix: each rebuild pushed
5356        // a new entry for the same (widget, signal) pair.
5357        for _ in 0..5 {
5358            tree.arena.mark_needs_rebuild(widget);
5359            tree.layout(SizeProposal::exact(200.0, 200.0));
5360        }
5361        assert_eq!(
5362            tree.binding_registry().len(),
5363            after_first_build,
5364            "bindings must be cleared on rebuild"
5365        );
5366
5367        tree.destroy_subtree(widget);
5368        assert_eq!(
5369            tree.binding_registry().len(),
5370            0,
5371            "bindings must be cleared on destroy"
5372        );
5373        // Silence unused-variable warning for the signal.
5374        let _ = tick;
5375    }
5376
5377    #[test]
5378    fn ctx_destroy_cancels_animations_and_bindings_via_deferred_path() {
5379        // Regression: `EventContext::destroy` queues
5380        // `TreeMutation::Destroy`, which used to be applied with the
5381        // bare `arena.destroy` — unlinking the node but leaking the
5382        // animation-scheduler entry (it holds a strong `Signal<f32>`
5383        // clone, so the widget kept animating after destruction) and
5384        // the widget's bindings. It must route through
5385        // `destroy_subtree` like every other destroy path does.
5386        use crate::binding::BindingLevel;
5387        use crate::signal::Signal;
5388        use std::time::{Duration, Instant};
5389        use teksilo_tokens::Easing;
5390
5391        #[derive(Debug)]
5392        struct BoundLeaf {
5393            tick: Signal<u64>,
5394        }
5395        impl crate::widget::Widget for BoundLeaf {
5396            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
5397                self.tick.bind_to(
5398                    ctx.self_id(),
5399                    ctx.binding_registry(),
5400                    BindingLevel::Relayout,
5401                );
5402                Vec::new()
5403            }
5404            fn layout_response(
5405                &self,
5406                proposal: SizeProposal,
5407                _ctx: &crate::widget::LayoutContext,
5408            ) -> crate::widget::LayoutResponse {
5409                proposal.resolve(10.0, 10.0).into()
5410            }
5411        }
5412
5413        let mut tree = WidgetTree::new();
5414        let widget = tree.add(BoundLeaf {
5415            tick: Signal::new(0_u64),
5416        });
5417        tree.layout(SizeProposal::exact(200.0, 200.0));
5418        assert!(tree.binding_registry().len() >= 1);
5419
5420        // Seed an animation owned by the widget — exactly the strong
5421        // `Signal<f32>` clone the scheduler outlives the widget with.
5422        let anim = Signal::<f32>::new_animated(0.0);
5423        tree.animation_scheduler.animate(
5424            &anim,
5425            widget,
5426            1.0,
5427            Duration::from_secs(10),
5428            Easing::Linear,
5429            Instant::now(),
5430        );
5431        assert_eq!(tree.animation_scheduler.active_count(), 1);
5432
5433        // Destroy via the deferred handler-time path.
5434        let mut noop = crate::window::NoopWindowOps;
5435        tree.run_with_event_context(&mut noop, |ctx| ctx.destroy(widget));
5436
5437        assert_eq!(
5438            tree.animation_scheduler.active_count(),
5439            0,
5440            "ctx.destroy must cancel animations owned by the destroyed widget"
5441        );
5442        assert_eq!(
5443            tree.binding_registry().len(),
5444            0,
5445            "ctx.destroy must unregister the destroyed widget's bindings"
5446        );
5447        assert!(
5448            tree.arena.get(widget).is_none(),
5449            "node must be removed from the arena"
5450        );
5451    }
5452
5453    #[test]
5454    fn clear_shortcut_override_via_event_context_restores_default() {
5455        use crate::shortcut::{KeyStroke, Shortcut};
5456
5457        let mut tree = WidgetTree::new();
5458        tree.shortcut_registry_mut().register(
5459            Shortcut::new("app.save")
5460                .primary(KeyStroke::command(Key::S))
5461                .build(),
5462        );
5463        tree.shortcut_registry_mut()
5464            .rebind_primary("app.save", Some(KeyStroke::alt(Key::S)));
5465
5466        let source = tree.add(FillWidget::new());
5467        let mut ctx = EventContext::new();
5468        ctx.clear_shortcut_override("app.save");
5469        tree.collect_from_ctx(ctx, source);
5470
5471        assert_eq!(
5472            tree.shortcut_registry()
5473                .effective("app.save")
5474                .unwrap()
5475                .primary,
5476            Some(KeyStroke::command(Key::S))
5477        );
5478    }
5479
5480    #[test]
5481    fn rebind_shortcut_primary_via_event_context() {
5482        use crate::shortcut::{KeyStroke, Shortcut};
5483
5484        let mut tree = WidgetTree::new();
5485        tree.shortcut_registry_mut().register(
5486            Shortcut::new("app.save")
5487                .primary(KeyStroke::command(Key::S))
5488                .build(),
5489        );
5490        let source = tree.add(FillWidget::new());
5491
5492        let mut ctx = EventContext::new();
5493        ctx.rebind_shortcut_primary("app.save", Some(KeyStroke::alt(Key::S)));
5494        tree.collect_from_ctx(ctx, source);
5495
5496        assert_eq!(
5497            tree.shortcut_registry()
5498                .effective("app.save")
5499                .unwrap()
5500                .primary,
5501            Some(KeyStroke::alt(Key::S))
5502        );
5503    }
5504
5505    #[test]
5506    fn unregister_all_for_owner_called_on_destroy() {
5507        use crate::shortcut::{KeyStroke, Shortcut};
5508
5509        let mut tree = WidgetTree::new();
5510        let widget = tree.add(FillWidget::new());
5511        let widget_owner = widget;
5512        tree.shortcut_registry_mut().register_owned(
5513            Shortcut::new("scoped.thing")
5514                .primary(KeyStroke::command(Key::K))
5515                .build(),
5516            widget_owner,
5517        );
5518        assert!(
5519            tree.shortcut_registry()
5520                .get_default("scoped.thing")
5521                .is_some()
5522        );
5523
5524        tree.destroy_subtree(widget);
5525        assert!(
5526            tree.shortcut_registry()
5527                .get_default("scoped.thing")
5528                .is_none(),
5529            "destroying the owner must unregister its shortcut"
5530        );
5531    }
5532
5533    /// A global action fires for an intent dispatched from a widget in a
5534    /// completely unrelated subtree — proving it is a position-independent
5535    /// fallback (the menu-bar-vs-content case).
5536    #[test]
5537    fn global_action_reached_from_unrelated_source() {
5538        use crate::action::Action;
5539        use crate::intent::Intent;
5540        use std::cell::Cell;
5541        use std::rc::Rc;
5542
5543        #[derive(Debug)]
5544        struct Registrar(Rc<Cell<bool>>);
5545        impl crate::widget::Widget for Registrar {
5546            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
5547                let flag = self.0.clone();
5548                ctx.register_action_global(
5549                    Action::new("test.global").on_invoke(move |_i, _c| flag.set(true)),
5550                );
5551                vec![]
5552            }
5553            fn layout_response(
5554                &self,
5555                _p: teksilo_canvas::SizeProposal,
5556                _c: &crate::widget::LayoutContext,
5557            ) -> crate::widget::LayoutResponse {
5558                teksilo_canvas::Size::new(0.0, 0.0).into()
5559            }
5560        }
5561
5562        let mut tree = WidgetTree::new();
5563        let fired = Rc::new(Cell::new(false));
5564        let registrar = tree.add(Registrar(fired.clone()));
5565        let source = tree.add(FillWidget::new()); // unrelated sibling root
5566        let mut ops = crate::window::NoopWindowOps;
5567
5568        tree.dispatch_intent(source, Intent::new("test.global"), true, &mut ops);
5569        assert!(
5570            fired.get(),
5571            "global action must fire from an unrelated source"
5572        );
5573
5574        // And it is torn down with its owner.
5575        fired.set(false);
5576        tree.destroy_subtree(registrar);
5577        tree.dispatch_intent(source, Intent::new("test.global"), true, &mut ops);
5578        assert!(
5579            !fired.get(),
5580            "destroying the owner must remove its global action"
5581        );
5582    }
5583
5584    // --- Transform-aware hit-testing -------------------------------------
5585    //
5586    // `set_transform` scopes are paint-only: the renderer pushes the
5587    // transform around the subtree, so the visually-displayed area is
5588    // shifted relative to `arena.bounds(id)`. Hit-testing must inverse-
5589    // transform the screen-space input point as it descends through each
5590    // transform scope so that a click on the visually-rendered area lands
5591    // on the correct widget. Pre-fix, screen-space `bounds.contains(point)`
5592    // returned the *pre-transform* widget for in-bounds-pre-transform
5593    // points and missed the visually-shifted hit area entirely.
5594
5595    #[test]
5596    fn hit_test_through_translate_scope() {
5597        use crate::test_widgets::StackWidget;
5598        let mut tree = WidgetTree::new();
5599        let child = tree.add(FillWidget::new());
5600        let parent = tree.add(StackWidget::new().child(child));
5601        // Visually shift the entire subtree right by 100px.
5602        tree.set_transform(parent, teksilo_canvas::Transform2D::translate(100.0, 0.0));
5603        tree.layout(SizeProposal::exact(100.0, 50.0));
5604
5605        // (50, 25) is inside the *pre-transform* bounds but the widget is
5606        // visually painted at x=100..200; a click at (50, 25) lands on
5607        // empty space.
5608        assert_eq!(
5609            tree.hit_test(Point::new(50.0, 25.0)),
5610            None,
5611            "pre-transform area is not visually populated and must not hit"
5612        );
5613        // (150, 25) is inside the visually-rendered area (post-translate).
5614        assert_eq!(
5615            tree.hit_test(Point::new(150.0, 25.0)),
5616            Some(child),
5617            "visually-rendered area must hit the child"
5618        );
5619        // Off everything.
5620        assert_eq!(tree.hit_test(Point::new(250.0, 25.0)), None);
5621    }
5622
5623    #[test]
5624    fn hit_test_through_scale_scope() {
5625        use crate::test_widgets::StackWidget;
5626        let mut tree = WidgetTree::new();
5627        let child = tree.add(FillWidget::new());
5628        let parent = tree.add(StackWidget::new().child(child));
5629        // Halve the visual size: pre-transform bounds (0,0,100,50) →
5630        // visually (0,0,50,25).
5631        tree.set_transform(parent, teksilo_canvas::Transform2D::scale(0.5, 0.5));
5632        tree.layout(SizeProposal::exact(100.0, 50.0));
5633
5634        // Inside the visual area.
5635        assert_eq!(tree.hit_test(Point::new(25.0, 12.0)), Some(child));
5636        // Outside the visual area but inside the pre-transform bounds.
5637        // Without the fix this would (incorrectly) hit the child.
5638        assert_eq!(
5639            tree.hit_test(Point::new(75.0, 25.0)),
5640            None,
5641            "scaled-out region must not hit"
5642        );
5643    }
5644
5645    #[test]
5646    fn hit_test_through_nested_transforms_compose() {
5647        use crate::test_widgets::StackWidget;
5648        let mut tree = WidgetTree::new();
5649        let leaf = tree.add(FillWidget::new());
5650        let inner = tree.add(StackWidget::new().child(leaf));
5651        let outer = tree.add(StackWidget::new().child(inner));
5652        // Outer translates by (100, 0); inner additionally scales by 2.
5653        // Effective at leaf = scale(2,2).then(translate(100,0)) — the
5654        // renderer composes deepest-first (see `effective_transform`).
5655        tree.set_transform(outer, teksilo_canvas::Transform2D::translate(100.0, 0.0));
5656        tree.set_transform(inner, teksilo_canvas::Transform2D::scale(2.0, 2.0));
5657        tree.layout(SizeProposal::exact(50.0, 25.0));
5658
5659        // Leaf-local (0, 0) → scale → (0, 0) → translate → (100, 0).
5660        // Leaf-local (50, 25) → scale → (100, 50) → translate → (200, 50).
5661        // So the visual hit area is x in [100, 200], y in [0, 50].
5662        assert_eq!(tree.hit_test(Point::new(150.0, 25.0)), Some(leaf));
5663        assert_eq!(tree.hit_test(Point::new(50.0, 25.0)), None);
5664        assert_eq!(tree.hit_test(Point::new(250.0, 25.0)), None);
5665    }
5666
5667    #[test]
5668    fn hit_test_identity_transform_unchanged() {
5669        // Sanity: an identity transform must not perturb the existing
5670        // hit-test behavior. Guards against accidental over-application
5671        // of inversion on the hot path.
5672        let mut tree = WidgetTree::new();
5673        let widget = tree.add(FillWidget::new());
5674        tree.set_transform(widget, teksilo_canvas::Transform2D::IDENTITY);
5675        tree.layout(SizeProposal::exact(100.0, 50.0));
5676        assert_eq!(tree.hit_test(Point::new(50.0, 25.0)), Some(widget));
5677    }
5678
5679    #[test]
5680    fn arena_effective_transform_composes_ancestors() {
5681        // `arena.effective_transform(id)` must equal the renderer's
5682        // transform-stack top by the time it begins painting `id` —
5683        // i.e. mapping `id`'s pre-transform local point to screen space.
5684        // The renderer's `PushTransform` handler composes as
5685        // `device_t.then(prev_top)` (see `teksilo-render/src/renderer.rs`),
5686        // so the *innermost* transform applies first to a local point.
5687        // For ancestors [outer, inner] both with transforms, this means
5688        // effective = inner.then(outer), NOT outer.then(inner).
5689        // teksilo-scene relies on this to project scene-coord bounds to
5690        // screen space when emitting AT nodes for view-transformed items.
5691        use crate::test_widgets::StackWidget;
5692        let mut tree = WidgetTree::new();
5693        let leaf = tree.add(FillWidget::new());
5694        let inner = tree.add(StackWidget::new().child(leaf));
5695        let outer = tree.add(StackWidget::new().child(inner));
5696        tree.set_transform(outer, teksilo_canvas::Transform2D::translate(100.0, 0.0));
5697        tree.set_transform(inner, teksilo_canvas::Transform2D::scale(2.0, 2.0));
5698        tree.layout(SizeProposal::exact(50.0, 25.0));
5699
5700        let eff = tree.arena.effective_transform(leaf);
5701        let expected = teksilo_canvas::Transform2D::scale(2.0, 2.0)
5702            .then(&teksilo_canvas::Transform2D::translate(100.0, 0.0));
5703        for (a, b) in eff.m.iter().zip(expected.m.iter()) {
5704            assert!(
5705                (a - b).abs() < 1e-5,
5706                "effective_transform mismatch: got {:?}, want {:?}",
5707                eff.m,
5708                expected.m
5709            );
5710        }
5711
5712        // Concrete-point check that pins the composition order without
5713        // relying on matrix equality alone: a leaf-local point at the
5714        // bounds origin (0, 0) should land at screen (100, 0) — scale
5715        // first (still (0,0)), then translate by 100 in x. With the
5716        // wrong composition order it would land at (200, 0).
5717        let screen_origin = eff.apply_point(Point::new(0.0, 0.0));
5718        assert!((screen_origin.x - 100.0).abs() < 1e-5);
5719        assert!((screen_origin.y - 0.0).abs() < 1e-5);
5720        // Far corner: leaf-local (50, 25) → scale → (100, 50) → translate
5721        // by 100 in x → (200, 50).
5722        let screen_corner = eff.apply_point(Point::new(50.0, 25.0));
5723        assert!((screen_corner.x - 200.0).abs() < 1e-5);
5724        assert!((screen_corner.y - 50.0).abs() < 1e-5);
5725    }
5726
5727    // ─── Context-menu factory: position, ctx, None fall-through ─────────
5728
5729    /// A throwaway content widget the factory mounts. We never paint
5730    /// it — the test only checks that it lands in the overlay manager.
5731    #[derive(Debug)]
5732    struct StubMenu;
5733    impl crate::widget::Widget for StubMenu {
5734        fn layout_response(
5735            &self,
5736            _proposal: SizeProposal,
5737            _ctx: &crate::widget::LayoutContext,
5738        ) -> crate::widget::LayoutResponse {
5739            teksilo_canvas::Size::new(100.0, 40.0).into()
5740        }
5741    }
5742
5743    // The keyboard route to a context menu.
5744    //
5745    // Until this existed there was none at all: no `Key::ContextMenu`, no
5746    // Shift+F10, and `Action::ShowContextMenu` appears in zero of the three
5747    // AccessKit adapters, so the assistive-technology route is dead on every
5748    // platform too. A menu reachable only by right-click is a menu a keyboard
5749    // user does not have.
5750
5751    /// A widget that hands the keyboard a different target than itself, the way
5752    /// every data view does: the container has focus, the row is what the menu
5753    /// is about.
5754    #[derive(Debug)]
5755    struct NominatingWidget {
5756        row: std::cell::Cell<Option<WidgetId>>,
5757    }
5758
5759    impl crate::widget::Widget for NominatingWidget {
5760        fn layout_response(
5761            &self,
5762            proposal: SizeProposal,
5763            _ctx: &LayoutContext,
5764        ) -> crate::widget::LayoutResponse {
5765            proposal.resolve(50.0, 20.0).into()
5766        }
5767
5768        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
5769            ctx.apply_self_handlers(crate::widget_builder::HandlerSet::new().focusable(true));
5770            Vec::new()
5771        }
5772
5773        fn context_menu_key_target(&self) -> Option<WidgetId> {
5774            self.row.get()
5775        }
5776    }
5777
5778    fn press(tree: &mut WidgetTree, key: Key, modifiers: Modifiers) {
5779        tree.dispatch_event(WidgetEvent::KeyDown {
5780            key,
5781            modifiers,
5782            text: None,
5783        });
5784    }
5785
5786    #[test]
5787    fn the_context_menu_key_opens_the_focused_widget_menu() {
5788        use std::cell::Cell;
5789        use std::rc::Rc;
5790
5791        let opened = Rc::new(Cell::new(false));
5792        let flag = opened.clone();
5793        let mut tree = WidgetTree::new();
5794        let widget = tree.add(
5795            FillWidget::new()
5796                .focusable()
5797                .context_menu(move |_pos, _ctx| {
5798                    flag.set(true);
5799                    Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
5800                }),
5801        );
5802        tree.layout(SizeProposal::exact(200.0, 100.0));
5803        tree.focus(widget);
5804
5805        press(&mut tree, Key::ContextMenu, Modifiers::NONE);
5806        assert!(opened.get(), "the dedicated Menu key must open the menu");
5807    }
5808
5809    /// The chord every Windows and Linux keyboard can reach, including the many
5810    /// that have no dedicated Menu key at all.
5811    #[test]
5812    fn shift_f10_opens_the_focused_widget_menu() {
5813        use std::cell::Cell;
5814        use std::rc::Rc;
5815
5816        let opened = Rc::new(Cell::new(false));
5817        let flag = opened.clone();
5818        let mut tree = WidgetTree::new();
5819        let widget = tree.add(
5820            FillWidget::new()
5821                .focusable()
5822                .context_menu(move |_pos, _ctx| {
5823                    flag.set(true);
5824                    Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
5825                }),
5826        );
5827        tree.layout(SizeProposal::exact(200.0, 100.0));
5828        tree.focus(widget);
5829
5830        press(&mut tree, Key::F10, Modifiers::SHIFT);
5831        assert!(opened.get(), "Shift+F10 must open the menu");
5832    }
5833
5834    /// Modifiers are matched exactly. Ctrl+Shift+F10 is a different gesture and
5835    /// belongs to the application.
5836    #[test]
5837    fn a_near_miss_chord_is_not_a_context_menu_request() {
5838        use std::cell::Cell;
5839        use std::rc::Rc;
5840
5841        let opened = Rc::new(Cell::new(false));
5842        let flag = opened.clone();
5843        let mut tree = WidgetTree::new();
5844        let widget = tree.add(
5845            FillWidget::new()
5846                .focusable()
5847                .context_menu(move |_pos, _ctx| {
5848                    flag.set(true);
5849                    Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
5850                }),
5851        );
5852        tree.layout(SizeProposal::exact(200.0, 100.0));
5853        tree.focus(widget);
5854
5855        press(&mut tree, Key::F10, Modifiers::SHIFT | Modifiers::CTRL);
5856        press(&mut tree, Key::F10, Modifiers::NONE);
5857        assert!(!opened.get(), "only Shift+F10 exactly asks for a menu");
5858    }
5859
5860    /// The correction the design needed. A data view is focusable and its rows
5861    /// are not, so "the focused widget" is the list, and the menu a user asked
5862    /// for on row 4 would have been the list's own.
5863    #[test]
5864    fn the_keyboard_target_can_be_a_row_rather_than_the_focused_container() {
5865        use std::cell::Cell;
5866        use std::rc::Rc;
5867
5868        let menu_owner = Rc::new(Cell::new(None::<&'static str>));
5869
5870        let row_flag = menu_owner.clone();
5871        let container_flag = menu_owner.clone();
5872
5873        let mut tree = WidgetTree::new();
5874        let row = tree.add(FillWidget::new().context_menu(move |_pos, _ctx| {
5875            row_flag.set(Some("row"));
5876            Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
5877        }));
5878        let container = tree.add(
5879            crate::test_widgets::StackWidget::new()
5880                .child(row)
5881                .context_menu(move |_pos, _ctx| {
5882                    container_flag.set(Some("container"));
5883                    Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
5884                }),
5885        );
5886        tree.layout(SizeProposal::exact(200.0, 100.0));
5887
5888        // The container is focused, and nominates the row.
5889        let nominator = tree.add(NominatingWidget {
5890            row: std::cell::Cell::new(Some(row)),
5891        });
5892        tree.layout(SizeProposal::exact(200.0, 100.0));
5893        tree.focus(nominator);
5894        let _ = container;
5895
5896        press(&mut tree, Key::ContextMenu, Modifiers::NONE);
5897        assert_eq!(
5898            menu_owner.get(),
5899            Some("row"),
5900            "the nominated row's factory must be the one that runs"
5901        );
5902    }
5903
5904    /// Nothing on the chain owns a factory, so the framework must not swallow
5905    /// the key: a widget that wants to handle Shift+F10 itself still can.
5906    #[test]
5907    fn the_chord_falls_through_when_there_is_no_menu_to_show() {
5908        use std::cell::Cell;
5909        use std::rc::Rc;
5910
5911        let saw_key = Rc::new(Cell::new(false));
5912        let flag = saw_key.clone();
5913        let mut tree = WidgetTree::new();
5914        let widget = tree.add(FillWidget::new().focusable().on_key(move |ev, _ctx| {
5915            if matches!(ev, WidgetEvent::KeyDown { key: Key::F10, .. }) {
5916                flag.set(true);
5917            }
5918            crate::event::EventResponse::Ignored
5919        }));
5920        tree.layout(SizeProposal::exact(200.0, 100.0));
5921        tree.focus(widget);
5922
5923        press(&mut tree, Key::F10, Modifiers::SHIFT);
5924        assert!(
5925            saw_key.get(),
5926            "with no factory anywhere, the key must reach the widget"
5927        );
5928    }
5929
5930    /// Every route to a menu tells the factory what asked for it, because only
5931    /// a pointer's point is a place the user chose. A text field's factory
5932    /// moves the caret to its point for a right-click; told nothing, it did the
5933    /// same for Shift+F10, and the menu's Paste wrote into the middle of the
5934    /// field.
5935    #[test]
5936    fn each_route_to_a_menu_tells_the_factory_what_asked_for_it() {
5937        use crate::widget_builder::ContextMenuTrigger;
5938        use std::cell::RefCell;
5939        use std::rc::Rc;
5940
5941        let seen = Rc::new(RefCell::new(Vec::new()));
5942        let log = seen.clone();
5943        let mut tree = WidgetTree::new();
5944        let widget = tree.add(
5945            FillWidget::new()
5946                .focusable()
5947                .context_menu(move |_pos, ctx| {
5948                    log.borrow_mut().push(ctx.context_menu_trigger());
5949                    Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
5950                }),
5951        );
5952        tree.layout(SizeProposal::exact(200.0, 100.0));
5953        tree.focus(widget);
5954        let inside = tree.bounds(widget).center();
5955
5956        press(&mut tree, Key::F10, Modifiers::SHIFT);
5957        press(&mut tree, Key::Escape, Modifiers::NONE);
5958        press(&mut tree, Key::ContextMenu, Modifiers::NONE);
5959        press(&mut tree, Key::Escape, Modifiers::NONE);
5960        tree.dispatch_event(WidgetEvent::pointer_down(
5961            inside,
5962            crate::event::PointerButton::Secondary,
5963            Modifiers::NONE,
5964        ));
5965        tree.dispatch_event(WidgetEvent::pointer_up(
5966            inside,
5967            crate::event::PointerButton::Secondary,
5968            Modifiers::NONE,
5969        ));
5970        press(&mut tree, Key::Escape, Modifiers::NONE);
5971        tree.dispatch_event(WidgetEvent::AccessAction {
5972            action: accesskit::Action::ShowContextMenu,
5973            target: Some(widget),
5974            target_node: crate::accessibility::widget_id_to_node_id(widget),
5975            data: None,
5976        });
5977        press(&mut tree, Key::Escape, Modifiers::NONE);
5978        tree.long_press_at(teksilo_tokens::PointerKind::Touch, inside);
5979
5980        assert_eq!(
5981            *seen.borrow(),
5982            vec![
5983                Some(ContextMenuTrigger::Keyboard),
5984                Some(ContextMenuTrigger::Keyboard),
5985                Some(ContextMenuTrigger::Pointer),
5986                Some(ContextMenuTrigger::Accessibility),
5987                Some(ContextMenuTrigger::Pointer),
5988            ],
5989            "Shift+F10, the Menu key, a right-click, the assistive action and a \
5990             hold, in that order"
5991        );
5992    }
5993
5994    #[test]
5995    fn context_menu_factory_receives_click_position() {
5996        use crate::event::{Modifiers, PointerButton};
5997        use std::cell::Cell;
5998        use std::rc::Rc;
5999
6000        let captured_position = Rc::new(Cell::new(None::<Point>));
6001        let cap = captured_position.clone();
6002        let mut tree = WidgetTree::new();
6003        let widget = tree.add(FillWidget::new().context_menu(move |pos, _ctx| {
6004            cap.set(Some(pos));
6005            Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
6006        }));
6007        tree.layout(SizeProposal::exact(200.0, 100.0));
6008
6009        let click = Point::new(73.0, 42.0);
6010        tree.dispatch_event(WidgetEvent::pointer_down(
6011            click,
6012            PointerButton::Secondary,
6013            Modifiers::NONE,
6014        ));
6015
6016        let got = captured_position.get();
6017        assert_eq!(
6018            got,
6019            Some(click),
6020            "factory must receive the click position; got {:?}",
6021            got
6022        );
6023        let _ = widget;
6024    }
6025
6026    #[test]
6027    fn context_menu_factory_returning_none_falls_through_to_parent() {
6028        use crate::event::{Modifiers, PointerButton};
6029        use crate::test_widgets::StackWidget;
6030        use std::cell::Cell;
6031        use std::rc::Rc;
6032
6033        // Outer factory always returns Some(StubMenu); inner factory
6034        // returns None. Right-click should walk past the inner and
6035        // mount the outer's menu.
6036        let outer_called = Rc::new(Cell::new(0_u32));
6037        let outer_flag = outer_called.clone();
6038        let mut tree = WidgetTree::new();
6039        let inner = tree.add(FillWidget::new().context_menu(|_pos, _ctx| None));
6040        let _outer = tree.add(
6041            StackWidget::new()
6042                .child(inner)
6043                .context_menu(move |_pos, _ctx| {
6044                    outer_flag.set(outer_flag.get() + 1);
6045                    Some(Box::new(StubMenu) as Box<dyn crate::widget::Widget>)
6046                }),
6047        );
6048        tree.layout(SizeProposal::exact(200.0, 100.0));
6049
6050        tree.dispatch_event(WidgetEvent::pointer_down(
6051            Point::new(50.0, 25.0),
6052            PointerButton::Secondary,
6053            Modifiers::NONE,
6054        ));
6055
6056        assert_eq!(
6057            outer_called.get(),
6058            1,
6059            "inner returning None must fall through to the outer factory"
6060        );
6061    }
6062
6063    #[test]
6064    fn context_menu_factory_none_throughout_chain_does_not_show_overlay() {
6065        use crate::event::{Modifiers, PointerButton};
6066
6067        // Single factory returning None → no overlay shown, no panic.
6068        let mut tree = WidgetTree::new();
6069        tree.add(FillWidget::new().context_menu(|_pos, _ctx| None));
6070        tree.layout(SizeProposal::exact(200.0, 100.0));
6071
6072        let overlay_count_before = tree.overlay_manager.len();
6073        tree.dispatch_event(WidgetEvent::pointer_down(
6074            Point::new(50.0, 25.0),
6075            PointerButton::Secondary,
6076            Modifiers::NONE,
6077        ));
6078        let overlay_count_after = tree.overlay_manager.len();
6079        assert_eq!(
6080            overlay_count_before, overlay_count_after,
6081            "a factory returning None must not mount any overlay"
6082        );
6083    }
6084
6085    // ---- Reconcile-on-rebuild (`preserves_children_on_rebuild`) ----------
6086    //
6087    // These pin the contract that the preserve path RECONCILES: it keeps the
6088    // children a rebuild re-attaches (and any subtree re-parented into the new
6089    // tree) while reaping the ones it drops — so memoizing widgets are both
6090    // stateful and leak-free. Regression guard for the orphan-leak the old
6091    // "preserve = destroy nothing" behaviour caused.
6092
6093    /// `build()` mints a fresh child every time and returns only it, abandoning
6094    /// the previous one. Used to prove dropped children are reaped, not leaked.
6095    #[derive(Debug)]
6096    struct FreshChildHost {
6097        preserve: bool,
6098    }
6099    impl Widget for FreshChildHost {
6100        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
6101            vec![ctx.add(FillWidget::new())]
6102        }
6103        fn layout_response(
6104            &self,
6105            p: SizeProposal,
6106            _c: &LayoutContext,
6107        ) -> crate::widget::LayoutResponse {
6108            p.resolve(10.0, 10.0).into()
6109        }
6110        fn preserves_children_on_rebuild(&self) -> bool {
6111            self.preserve
6112        }
6113    }
6114
6115    #[test]
6116    fn reconcile_reaps_dropped_children_no_leak() {
6117        // preserve=false (destroy-all) and preserve=true (reconcile) must BOTH
6118        // keep the arena bounded when a rebuild drops its old child. Before the
6119        // reconcile fix, preserve=true grew the arena (and the active set) by
6120        // one stranded orphan per rebuild.
6121        for preserve in [false, true] {
6122            let mut tree = WidgetTree::new();
6123            let host = tree.add(FreshChildHost { preserve });
6124            tree.layout(SizeProposal::exact(100.0, 100.0));
6125            let total0 = tree.arena.len();
6126            let active0 = tree.active_widget_count();
6127            for _ in 0..5 {
6128                tree.arena_mark_needs_rebuild_for_testing(host);
6129                tree.layout(SizeProposal::exact(100.0, 100.0));
6130            }
6131            assert_eq!(
6132                tree.arena.len(),
6133                total0,
6134                "preserve={preserve}: dropped children must be reaped, not leaked"
6135            );
6136            assert_eq!(
6137                tree.active_widget_count(),
6138                active0,
6139                "preserve={preserve}: no stranded still-active orphans"
6140            );
6141        }
6142    }
6143
6144    /// `build()` mints one **detached** node every time — the shape of every
6145    /// pre-built popup in the widget crate (a dropdown, a calendar, a
6146    /// tooltip's cascade children): parked dormant, shown later through an
6147    /// overlay, and deliberately not a child, since activation and paint both
6148    /// descend through `children`.
6149    #[derive(Debug)]
6150    struct DetachedContentHost {
6151        preserve: bool,
6152    }
6153    impl Widget for DetachedContentHost {
6154        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
6155            let popup = ctx.add_detached(FillWidget::new());
6156            ctx.set_dormant(popup);
6157            vec![ctx.add(FillWidget::new())]
6158        }
6159        fn layout_response(
6160            &self,
6161            p: SizeProposal,
6162            _c: &LayoutContext,
6163        ) -> crate::widget::LayoutResponse {
6164            p.resolve(10.0, 10.0).into()
6165        }
6166        fn preserves_children_on_rebuild(&self) -> bool {
6167            self.preserve
6168        }
6169    }
6170
6171    #[test]
6172    fn rebuilding_reaps_detached_content_no_leak() {
6173        // A parentless node is reachable from no walk at all — not the child
6174        // teardown, not the accessibility tree, not `active_widget_count`. Held
6175        // by a bare `ctx.add` it simply accumulated: one stranded popup per
6176        // rebuild, for the lifetime of the process. `add_detached` records the
6177        // ownership edge that makes it reapable.
6178        for preserve in [false, true] {
6179            let mut tree = WidgetTree::new();
6180            let host = tree.add(DetachedContentHost { preserve });
6181            tree.layout(SizeProposal::exact(100.0, 100.0));
6182            let total0 = tree.arena.len();
6183            for _ in 0..5 {
6184                tree.arena_mark_needs_rebuild_for_testing(host);
6185                tree.layout(SizeProposal::exact(100.0, 100.0));
6186            }
6187            assert_eq!(
6188                tree.arena.len(),
6189                total0,
6190                "preserve={preserve}: the previous build's detached content must be reaped"
6191            );
6192        }
6193    }
6194
6195    #[test]
6196    fn destroying_a_host_reaps_its_detached_content() {
6197        let mut tree = WidgetTree::new();
6198        let outer = tree.add(FillWidget::new());
6199        tree.layout(SizeProposal::exact(100.0, 100.0));
6200        let empty = tree.arena.len();
6201
6202        let host = tree.add_child(outer, DetachedContentHost { preserve: false });
6203        tree.layout(SizeProposal::exact(100.0, 100.0));
6204        assert!(tree.arena.len() > empty);
6205
6206        tree.destroy_subtree(host);
6207        assert_eq!(
6208            tree.arena.len(),
6209            empty,
6210            "the popup must die with the widget that built it"
6211        );
6212    }
6213
6214    /// Memoizes one child and re-attaches the same id every build.
6215    #[derive(Debug)]
6216    struct StableChildHost {
6217        child: Option<WidgetId>,
6218        probe: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
6219    }
6220    impl Widget for StableChildHost {
6221        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
6222            let id = match self.child {
6223                Some(id) => id,
6224                None => {
6225                    let id = ctx.add(FillWidget::new());
6226                    self.child = Some(id);
6227                    self.probe.set(Some(id));
6228                    id
6229                }
6230            };
6231            vec![id]
6232        }
6233        fn layout_response(
6234            &self,
6235            p: SizeProposal,
6236            _c: &LayoutContext,
6237        ) -> crate::widget::LayoutResponse {
6238            p.resolve(10.0, 10.0).into()
6239        }
6240        fn preserves_children_on_rebuild(&self) -> bool {
6241            true
6242        }
6243    }
6244
6245    #[test]
6246    fn reconcile_preserves_reattached_child() {
6247        let probe = std::rc::Rc::new(std::cell::Cell::new(None));
6248        let mut tree = WidgetTree::new();
6249        let host = tree.add(StableChildHost {
6250            child: None,
6251            probe: probe.clone(),
6252        });
6253        tree.layout(SizeProposal::exact(100.0, 100.0));
6254        let child = probe.get().expect("child mounted");
6255        let total0 = tree.arena.len();
6256        for _ in 0..5 {
6257            tree.arena_mark_needs_rebuild_for_testing(host);
6258            tree.layout(SizeProposal::exact(100.0, 100.0));
6259        }
6260        assert!(
6261            tree.arena.is_active(child),
6262            "the re-attached child must survive every rebuild"
6263        );
6264        assert_eq!(tree.arena.len(), total0, "no growth — same child reused");
6265    }
6266
6267    /// Re-homes a node returned from its `build()` under itself.
6268    #[derive(Debug)]
6269    struct Wrapper {
6270        child: WidgetId,
6271    }
6272    impl Widget for Wrapper {
6273        fn build(&mut self, _ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
6274            vec![self.child]
6275        }
6276        fn layout_response(
6277            &self,
6278            p: SizeProposal,
6279            _c: &LayoutContext,
6280        ) -> crate::widget::LayoutResponse {
6281            p.resolve(10.0, 10.0).into()
6282        }
6283    }
6284
6285    /// Memoizes a body, then wraps it in a FRESH `Wrapper` each build —
6286    /// re-parenting the body out of the previous (now dropped) wrapper. This is
6287    /// the TabWidget / CompositeTooltip pattern in miniature.
6288    #[derive(Debug)]
6289    struct ReparentHost {
6290        body: Option<WidgetId>,
6291        probe: std::rc::Rc<std::cell::Cell<Option<WidgetId>>>,
6292    }
6293    impl Widget for ReparentHost {
6294        fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
6295            let body = match self.body {
6296                Some(id) => id,
6297                None => {
6298                    let id = ctx.add(FillWidget::new());
6299                    self.body = Some(id);
6300                    self.probe.set(Some(id));
6301                    id
6302                }
6303            };
6304            vec![ctx.add(Wrapper { child: body })]
6305        }
6306        fn layout_response(
6307            &self,
6308            p: SizeProposal,
6309            _c: &LayoutContext,
6310        ) -> crate::widget::LayoutResponse {
6311            p.resolve(10.0, 10.0).into()
6312        }
6313        fn preserves_children_on_rebuild(&self) -> bool {
6314            true
6315        }
6316    }
6317
6318    #[test]
6319    fn reconcile_spares_reparented_survivor() {
6320        // The memoized body is re-parented into a fresh wrapper each rebuild;
6321        // the old wrapper is dropped. The body must survive (it is re-homed),
6322        // and the old wrappers must be reaped (no leak). This is the exact
6323        // failure that destroyed TabWidget's static panel before the fix: the
6324        // parent-authoritative recursion + single-node arena removal spare the
6325        // re-homed body while still reaping the dropped wrapper subtree.
6326        let probe = std::rc::Rc::new(std::cell::Cell::new(None));
6327        let mut tree = WidgetTree::new();
6328        let host = tree.add(ReparentHost {
6329            body: None,
6330            probe: probe.clone(),
6331        });
6332        tree.layout(SizeProposal::exact(100.0, 100.0));
6333        let body = probe.get().expect("body mounted");
6334        let total0 = tree.arena.len();
6335        for _ in 0..5 {
6336            tree.arena_mark_needs_rebuild_for_testing(host);
6337            tree.layout(SizeProposal::exact(100.0, 100.0));
6338        }
6339        assert!(
6340            tree.arena.is_active(body),
6341            "the re-parented body must survive — it was moved into the new tree, \
6342             not swept with the dropped wrapper"
6343        );
6344        assert_eq!(
6345            tree.arena.len(),
6346            total0,
6347            "dropped wrappers reaped — no per-rebuild leak"
6348        );
6349    }
6350
6351    // -----------------------------------------------------------------
6352    // EventContext::ensure_visible / ensure_widget_visible — the
6353    // rect/id-based outer-scroll chase drained in `collect_from_ctx`.
6354    // -----------------------------------------------------------------
6355
6356    /// A `clips_children` container that places its single child at a fixed
6357    /// vertical offset — used to give a child arena bounds *outside* the
6358    /// container's viewport so the id-based `ensure_widget_visible` walk has a
6359    /// reason to dispatch `ScrollIntoView`.
6360    #[derive(Debug)]
6361    struct BelowContainer {
6362        child: Option<WidgetId>,
6363        offset: f32,
6364    }
6365
6366    impl crate::widget::Widget for BelowContainer {
6367        fn layout_response(
6368            &self,
6369            proposal: SizeProposal,
6370            _ctx: &crate::widget::LayoutContext,
6371        ) -> crate::widget::LayoutResponse {
6372            proposal.resolve(0.0, 0.0).into()
6373        }
6374        fn place_children(
6375            &self,
6376            bounds: Rect,
6377            _proposal: SizeProposal,
6378            children: &mut [crate::widget::WidgetPlacement],
6379            _ctx: &crate::widget::LayoutContext,
6380        ) {
6381            for c in children.iter_mut() {
6382                c.origin = Point::new(bounds.x, bounds.y + self.offset);
6383                c.size = bounds.size();
6384            }
6385        }
6386        fn children(&self) -> Vec<WidgetId> {
6387            self.child.into_iter().collect()
6388        }
6389    }
6390
6391    /// A `clips_children` container that records the `ScrollIntoView` it
6392    /// receives, so a test can assert what the framework dispatched to it.
6393    fn recording_scroll_container(
6394        tree: &mut WidgetTree,
6395        child: WidgetId,
6396        recorded: std::rc::Rc<std::cell::Cell<Option<Rect>>>,
6397    ) -> WidgetId {
6398        use crate::test_widgets::StackWidget;
6399        tree.add(
6400            StackWidget::new()
6401                .child(child)
6402                .on_scroll(move |ev, _ctx| match ev {
6403                    WidgetEvent::ScrollIntoView { target_bounds, .. } => {
6404                        recorded.set(Some(*target_bounds));
6405                        EventResponse::Handled
6406                    }
6407                    _ => EventResponse::Ignored,
6408                })
6409                .clips_children(true),
6410        )
6411    }
6412
6413    #[test]
6414    fn ensure_visible_dispatches_scroll_into_view_to_clipping_ancestor() {
6415        use std::cell::Cell;
6416        use std::rc::Rc;
6417        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
6418        let mut tree = WidgetTree::new();
6419        let actor = tree.add(FillWidget::new());
6420        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
6421        tree.layout(SizeProposal::exact(100.0, 100.0));
6422
6423        // A rect well below the 100px viewport — the container must be asked to
6424        // reveal it.
6425        let target = Rect::new(10.0, 500.0, 20.0, 15.0);
6426        let mut ctx = EventContext::new();
6427        ctx.ensure_visible(target);
6428        tree.collect_from_ctx(ctx, actor);
6429
6430        assert_eq!(
6431            recorded.get(),
6432            Some(target),
6433            "ensure_visible(rect) must dispatch ScrollIntoView with the exact rect \
6434             to the clips_children ancestor"
6435        );
6436    }
6437
6438    #[test]
6439    fn ensure_visible_is_noop_when_rect_already_visible() {
6440        use std::cell::Cell;
6441        use std::rc::Rc;
6442        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
6443        let mut tree = WidgetTree::new();
6444        let actor = tree.add(FillWidget::new());
6445        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
6446        tree.layout(SizeProposal::exact(100.0, 100.0));
6447
6448        // Fully inside the viewport → the ancestor already shows it, so no
6449        // ScrollIntoView is dispatched.
6450        let mut ctx = EventContext::new();
6451        ctx.ensure_visible(Rect::new(10.0, 10.0, 20.0, 15.0));
6452        tree.collect_from_ctx(ctx, actor);
6453
6454        assert_eq!(
6455            recorded.get(),
6456            None,
6457            "a rect already inside the viewport must not trigger a scroll"
6458        );
6459    }
6460
6461    #[test]
6462    fn ensure_visible_margin_forces_scroll_near_edge() {
6463        use std::cell::Cell;
6464        use std::rc::Rc;
6465        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
6466        let mut tree = WidgetTree::new();
6467        let actor = tree.add(FillWidget::new());
6468        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
6469        tree.layout(SizeProposal::exact(100.0, 100.0));
6470
6471        // Rect at y=95..99 is visible at margin 0, but with a 10px margin its
6472        // padded bottom (109) spills past the 100px viewport → scroll.
6473        let rect = Rect::new(10.0, 95.0, 20.0, 4.0);
6474        let mut ctx = EventContext::new();
6475        ctx.ensure_visible_with_margin(rect, 10.0);
6476        tree.collect_from_ctx(ctx, actor);
6477
6478        assert_eq!(
6479            recorded.get(),
6480            Some(rect),
6481            "the margin must widen the visibility test so a near-edge rect scrolls"
6482        );
6483    }
6484
6485    /// A `clips_children` container that records the alignment and motion of the
6486    /// `ScrollIntoView` it receives.
6487    fn recording_align_container(
6488        tree: &mut WidgetTree,
6489        child: WidgetId,
6490        recorded: std::rc::Rc<
6491            std::cell::Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>,
6492        >,
6493    ) -> WidgetId {
6494        use crate::test_widgets::StackWidget;
6495        tree.add(
6496            StackWidget::new()
6497                .child(child)
6498                .on_scroll(move |ev, _ctx| match ev {
6499                    WidgetEvent::ScrollIntoView { align, motion, .. } => {
6500                        recorded.set(Some((*align, *motion)));
6501                        EventResponse::Handled
6502                    }
6503                    _ => EventResponse::Ignored,
6504                })
6505                .clips_children(true),
6506        )
6507    }
6508
6509    #[test]
6510    fn ensure_visible_aligned_scrolls_even_when_already_visible() {
6511        use std::cell::Cell;
6512        use std::rc::Rc;
6513        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
6514        let mut tree = WidgetTree::new();
6515        let actor = tree.add(FillWidget::new());
6516        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
6517        tree.layout(SizeProposal::exact(100.0, 100.0));
6518
6519        // Comfortably inside the viewport — a *minimal* reveal would decline
6520        // (see `ensure_visible_is_noop_when_rect_already_visible`). A pin must
6521        // still fire: re-asserting unconditionally is the whole difference
6522        // between "keep it on screen" and "hold it at this height".
6523        let target = Rect::new(10.0, 10.0, 20.0, 15.0);
6524        let mut ctx = EventContext::new();
6525        ctx.ensure_visible_aligned(target, 0.5, crate::event::ScrollMotion::Instant);
6526        tree.collect_from_ctx(ctx, actor);
6527
6528        assert_eq!(
6529            recorded.get(),
6530            Some(target),
6531            "an aligned reveal must dispatch even when the rect is already visible"
6532        );
6533    }
6534
6535    #[test]
6536    fn ensure_visible_aligned_forwards_fraction_and_motion() {
6537        use std::cell::Cell;
6538        use std::rc::Rc;
6539        let recorded: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
6540            Rc::new(Cell::new(None));
6541        let mut tree = WidgetTree::new();
6542        let actor = tree.add(FillWidget::new());
6543        let _container = recording_align_container(&mut tree, actor, recorded.clone());
6544        tree.layout(SizeProposal::exact(100.0, 100.0));
6545
6546        let mut ctx = EventContext::new();
6547        ctx.ensure_visible_aligned(
6548            Rect::new(10.0, 10.0, 20.0, 15.0),
6549            0.25,
6550            crate::event::ScrollMotion::Smooth,
6551        );
6552        tree.collect_from_ctx(ctx, actor);
6553
6554        assert_eq!(
6555            recorded.get(),
6556            Some((
6557                crate::event::ScrollAlign::Fraction(0.25),
6558                crate::event::ScrollMotion::Smooth
6559            )),
6560            "the container must receive the requested fraction and motion verbatim"
6561        );
6562    }
6563
6564    #[test]
6565    fn ensure_visible_aligned_clamps_the_fraction() {
6566        use std::cell::Cell;
6567        use std::rc::Rc;
6568        let recorded: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
6569            Rc::new(Cell::new(None));
6570        let mut tree = WidgetTree::new();
6571        let actor = tree.add(FillWidget::new());
6572        let _container = recording_align_container(&mut tree, actor, recorded.clone());
6573        tree.layout(SizeProposal::exact(100.0, 100.0));
6574
6575        let mut ctx = EventContext::new();
6576        ctx.ensure_visible_aligned(
6577            Rect::new(10.0, 10.0, 20.0, 15.0),
6578            4.2,
6579            crate::event::ScrollMotion::Instant,
6580        );
6581        tree.collect_from_ctx(ctx, actor);
6582
6583        assert_eq!(
6584            recorded.get().map(|(a, _)| a),
6585            Some(crate::event::ScrollAlign::Fraction(1.0)),
6586            "an out-of-range fraction must clamp rather than aim the pin off-screen"
6587        );
6588    }
6589
6590    #[test]
6591    fn plain_ensure_visible_requests_minimal_alignment() {
6592        use std::cell::Cell;
6593        use std::rc::Rc;
6594        let recorded: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
6595            Rc::new(Cell::new(None));
6596        let mut tree = WidgetTree::new();
6597        let actor = tree.add(FillWidget::new());
6598        let _container = recording_align_container(&mut tree, actor, recorded.clone());
6599        tree.layout(SizeProposal::exact(100.0, 100.0));
6600
6601        let mut ctx = EventContext::new();
6602        ctx.ensure_visible(Rect::new(10.0, 500.0, 20.0, 15.0));
6603        tree.collect_from_ctx(ctx, actor);
6604
6605        assert_eq!(
6606            recorded.get(),
6607            Some((
6608                crate::event::ScrollAlign::Minimal,
6609                crate::event::ScrollMotion::Instant
6610            )),
6611            "the pre-existing reveal API must keep its exact semantics"
6612        );
6613    }
6614
6615    #[test]
6616    fn only_the_innermost_container_aligns() {
6617        use std::cell::Cell;
6618        use std::rc::Rc;
6619        let inner_rec: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
6620            Rc::new(Cell::new(None));
6621        let outer_rec: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
6622            Rc::new(Cell::new(None));
6623
6624        let mut tree = WidgetTree::new();
6625        let actor = tree.add(FillWidget::new());
6626        let inner = recording_align_container(&mut tree, actor, inner_rec.clone());
6627        let _outer = recording_align_container(&mut tree, inner, outer_rec.clone());
6628        tree.layout(SizeProposal::exact(100.0, 100.0));
6629
6630        // Off-screen, so the outer container is asked too (a `Minimal` request
6631        // is gated on visibility).
6632        let mut ctx = EventContext::new();
6633        ctx.ensure_visible_aligned(
6634            Rect::new(10.0, 500.0, 20.0, 15.0),
6635            0.5,
6636            crate::event::ScrollMotion::Instant,
6637        );
6638        tree.collect_from_ctx(ctx, actor);
6639
6640        assert_eq!(
6641            inner_rec.get().map(|(a, _)| a),
6642            Some(crate::event::ScrollAlign::Fraction(0.5)),
6643            "the innermost clipping ancestor owns the pin"
6644        );
6645        assert_eq!(
6646            outer_rec.get().map(|(a, _)| a),
6647            Some(crate::event::ScrollAlign::Minimal),
6648            "an outer container must only bring the inner viewport into view — a \
6649             fraction names a height in one viewport, not in every ancestor's"
6650        );
6651    }
6652
6653    #[test]
6654    fn a_clipper_that_does_not_scroll_passes_the_pin_on() {
6655        // The shape of every capped text column: the editor inside a `MaxSize`
6656        // (which clips once a cap is set, and has no `on_scroll`) inside the
6657        // page's scroll area. The wrapper cannot act on a `ScrollIntoView`, so
6658        // it must not be the one the pin is spent on. Otherwise the scroll
6659        // area is handed a plain reveal and typewriter scrolling silently
6660        // becomes ordinary caret-following.
6661        use crate::test_widgets::StackWidget;
6662        use std::cell::Cell;
6663        use std::rc::Rc;
6664        let outer_rec: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
6665            Rc::new(Cell::new(None));
6666
6667        let mut tree = WidgetTree::new();
6668        let actor = tree.add(FillWidget::new());
6669        let clipper = tree.add(StackWidget::new().child(actor).clips_children_on(true));
6670        let _scroller = recording_align_container(&mut tree, clipper, outer_rec.clone());
6671        tree.layout(SizeProposal::exact(100.0, 100.0));
6672
6673        // Inside the viewport, so only a pin (never a minimal reveal) reaches
6674        // the scroller at all.
6675        let mut ctx = EventContext::new();
6676        ctx.ensure_visible_aligned(
6677            Rect::new(10.0, 10.0, 20.0, 15.0),
6678            0.5,
6679            crate::event::ScrollMotion::Instant,
6680        );
6681        tree.collect_from_ctx(ctx, actor);
6682
6683        assert_eq!(
6684            outer_rec.get().map(|(a, _)| a),
6685            Some(crate::event::ScrollAlign::Fraction(0.5)),
6686            "a clipping wrapper with no scroll handler must pass the pin through \
6687             to the scroll container above it"
6688        );
6689    }
6690
6691    #[test]
6692    fn a_scroller_that_declines_to_move_still_holds_the_pin() {
6693        // What the scroller does with the pin is its own business: a `SceneView`
6694        // already holding the target where asked answers `Ignored`, since it
6695        // moved nothing. That is not an invitation for the next container out
6696        // to align a rectangle inside a viewport it does not own.
6697        use crate::test_widgets::StackWidget;
6698        use std::cell::Cell;
6699        use std::rc::Rc;
6700        let inner_rec: Rc<Cell<Option<crate::event::ScrollAlign>>> = Rc::new(Cell::new(None));
6701        let outer_rec: Rc<Cell<Option<(crate::event::ScrollAlign, crate::event::ScrollMotion)>>> =
6702            Rc::new(Cell::new(None));
6703
6704        let mut tree = WidgetTree::new();
6705        let actor = tree.add(FillWidget::new());
6706        let rec = inner_rec.clone();
6707        let inner = tree.add(
6708            StackWidget::new()
6709                .child(actor)
6710                .on_scroll(move |ev, _ctx| match ev {
6711                    WidgetEvent::ScrollIntoView { align, .. } => {
6712                        rec.set(Some(*align));
6713                        EventResponse::Ignored
6714                    }
6715                    _ => EventResponse::Ignored,
6716                })
6717                .clips_children(true),
6718        );
6719        let _outer = recording_align_container(&mut tree, inner, outer_rec.clone());
6720        tree.layout(SizeProposal::exact(100.0, 100.0));
6721
6722        // Off-screen, so the outer container is asked too.
6723        let mut ctx = EventContext::new();
6724        ctx.ensure_visible_aligned(
6725            Rect::new(10.0, 500.0, 20.0, 15.0),
6726            0.5,
6727            crate::event::ScrollMotion::Instant,
6728        );
6729        tree.collect_from_ctx(ctx, actor);
6730
6731        assert_eq!(
6732            inner_rec.get(),
6733            Some(crate::event::ScrollAlign::Fraction(0.5)),
6734            "the innermost scroller is offered the pin"
6735        );
6736        assert_eq!(
6737            outer_rec.get().map(|(a, _)| a),
6738            Some(crate::event::ScrollAlign::Minimal),
6739            "declining to move must not pass the pin outward"
6740        );
6741    }
6742
6743    #[test]
6744    fn ensure_widget_visible_uses_target_arena_bounds() {
6745        use std::cell::Cell;
6746        use std::rc::Rc;
6747        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
6748        let mut tree = WidgetTree::new();
6749        // Target lives 500px below the container's top — off the viewport.
6750        let target = tree.add(FillWidget::new());
6751        let rec = recorded.clone();
6752        let container = tree.add(
6753            BelowContainer {
6754                child: Some(target),
6755                offset: 500.0,
6756            }
6757            .on_scroll(move |ev, _ctx| match ev {
6758                WidgetEvent::ScrollIntoView { target_bounds, .. } => {
6759                    rec.set(Some(*target_bounds));
6760                    EventResponse::Handled
6761                }
6762                _ => EventResponse::Ignored,
6763            })
6764            .clips_children(true),
6765        );
6766        tree.layout(SizeProposal::exact(100.0, 100.0));
6767
6768        let expected = tree.bounds(target);
6769        assert!(
6770            expected.y > 100.0,
6771            "fixture sanity: the target must sit below the viewport (y={})",
6772            expected.y
6773        );
6774
6775        // The source widget is irrelevant for the id-based walk — it starts
6776        // from the *target's* parent — so pass the container itself.
6777        let mut ctx = EventContext::new();
6778        ctx.ensure_widget_visible(target);
6779        tree.collect_from_ctx(ctx, container);
6780
6781        assert_eq!(
6782            recorded.get(),
6783            Some(expected),
6784            "ensure_widget_visible(id) must dispatch ScrollIntoView with the \
6785             target's current arena bounds"
6786        );
6787    }
6788
6789    #[test]
6790    fn ensure_widget_visible_ignores_missing_widget() {
6791        // A never-mounted id must neither panic nor dispatch a spurious scroll.
6792        use std::cell::Cell;
6793        use std::rc::Rc;
6794        let recorded: Rc<Cell<Option<Rect>>> = Rc::new(Cell::new(None));
6795        let mut tree = WidgetTree::new();
6796        let actor = tree.add(FillWidget::new());
6797        let _container = recording_scroll_container(&mut tree, actor, recorded.clone());
6798        tree.layout(SizeProposal::exact(100.0, 100.0));
6799
6800        let mut ctx = EventContext::new();
6801        ctx.ensure_widget_visible(WidgetId::default());
6802        tree.collect_from_ctx(ctx, actor); // must not panic
6803
6804        assert_eq!(
6805            recorded.get(),
6806            None,
6807            "an unmounted id must not trigger a scroll"
6808        );
6809    }
6810
6811    #[test]
6812    fn context_menu_inside_a_modal_keeps_the_modal() {
6813        // Regression: right-clicking a widget that lives inside an open modal must
6814        // open its context menu WITHOUT tearing down the modal. `show_context_menu_for`
6815        // used to `dismiss_all()`, which closed the very overlay hosting the editor.
6816        use crate::event::{Modifiers, PointerButton, WidgetEvent};
6817        use crate::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
6818        use crate::test_widgets::{FillWidget, StackWidget};
6819
6820        let mut tree = WidgetTree::new();
6821        // A container standing in for the modal's content subtree, with the editor
6822        // (a right-clickable widget) inside it.
6823        let modal_content = tree.add(StackWidget::new());
6824        let _editor = tree.add_child(
6825            modal_content,
6826            FillWidget::new()
6827                .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
6828        );
6829        tree.layout(SizeProposal::exact(200.0, 100.0));
6830
6831        let modal = tree.overlay_manager.show(OverlayRequest {
6832            content_id: modal_content,
6833            anchor: modal_content,
6834            placement: OverlayPlacement::Centered,
6835            dismiss: DismissBehavior::EscapeKey,
6836            layer: OverlayLayer::InTree,
6837            parent_overlay: None,
6838            on_dismiss: None,
6839            fade_duration: None,
6840        });
6841        // Give the overlay real bounds so the right-click hit-tests inside it.
6842        tree.overlay_manager
6843            .stack
6844            .iter_mut()
6845            .find(|o| o.id == modal)
6846            .unwrap()
6847            .bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
6848        assert_eq!(tree.overlay_manager.len(), 1);
6849
6850        // Right-click the editor inside the modal.
6851        tree.dispatch_event(WidgetEvent::pointer_down(
6852            Point::new(50.0, 25.0),
6853            PointerButton::Secondary,
6854            Modifiers::NONE,
6855        ));
6856
6857        assert!(
6858            tree.overlay_manager.active_ids().contains(&modal),
6859            "the modal must survive opening a context menu inside it"
6860        );
6861        assert_eq!(
6862            tree.overlay_manager.len(),
6863            2,
6864            "the context menu should now be open on top of the surviving modal"
6865        );
6866    }
6867}
6868
6869/// The stage-1 input ingress: the two sample doors, the scroll routing rule,
6870/// and the guarantee that a mouse still behaves exactly as it did.
6871#[cfg(test)]
6872mod input_ingress_tests {
6873    use super::*;
6874    use crate::event::{EventResponse, Modifiers, PointerButton, ScrollDelta};
6875    use crate::pointer::{
6876        EventTime, PointerId, PointerInfo, PointerPhase, PointerSample, ScrollPhase, ScrollSample,
6877        ScrollSource,
6878    };
6879    use crate::test_widgets::FillWidget;
6880    use crate::widget::{LayoutContext, WidgetPlacement};
6881    use crate::widget_builder::WidgetBuilder;
6882    use std::cell::RefCell;
6883    use std::rc::Rc;
6884
6885    /// A container that lays children out side by side across its bounds, so a
6886    /// hit test at a given x picks a specific child.
6887    ///
6888    /// Local rather than shared: the common `StackWidget` deliberately stacks
6889    /// its children at one origin, which is the opposite of what a routing test
6890    /// needs.
6891    #[derive(Debug)]
6892    struct RowWidget {
6893        children: Vec<WidgetId>,
6894    }
6895
6896    impl crate::widget::Widget for RowWidget {
6897        fn layout_response(
6898            &self,
6899            proposal: SizeProposal,
6900            _ctx: &LayoutContext,
6901        ) -> crate::widget::LayoutResponse {
6902            proposal.resolve(0.0, 0.0).into()
6903        }
6904
6905        fn place_children(
6906            &self,
6907            bounds: Rect,
6908            _proposal: SizeProposal,
6909            children: &mut [WidgetPlacement],
6910            _ctx: &LayoutContext,
6911        ) {
6912            let n = children.len().max(1) as f32;
6913            let w = bounds.width / n;
6914            for (i, child) in children.iter_mut().enumerate() {
6915                child.origin = Point::new(bounds.x + w * i as f32, bounds.y);
6916                child.size = teksilo_canvas::Size::new(w, bounds.height);
6917            }
6918        }
6919
6920        fn children(&self) -> Vec<WidgetId> {
6921            self.children.clone()
6922        }
6923    }
6924
6925    /// Everything a widget observes of a pointer interaction, as text, so two
6926    /// runs can be compared for exact equality rather than field by field.
6927    fn record(drive: impl FnOnce(&mut WidgetTree)) -> Vec<String> {
6928        let log: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
6929
6930        let mut tree = WidgetTree::new();
6931        let pointer_log = log.clone();
6932        let hover_log = log.clone();
6933        tree.add(
6934            FillWidget::new()
6935                .on_pointer_event(move |event, _ctx| {
6936                    pointer_log.borrow_mut().push(format!("{event:?}"));
6937                    EventResponse::Ignored
6938                })
6939                .on_hover(move |entered, _ctx| {
6940                    hover_log.borrow_mut().push(format!("hover({entered})"));
6941                }),
6942        );
6943        tree.layout(SizeProposal::exact(100.0, 100.0));
6944
6945        drive(&mut tree);
6946        log.borrow().clone()
6947    }
6948
6949    /// The load-bearing compatibility claim of this package: lowering a mouse
6950    /// `PointerSample` produces the *same* `WidgetEvent` stream, in the same
6951    /// order, as writing the events out by hand. If this ever diverges, the
6952    /// door has started meaning something different from the events it lowers
6953    /// to.
6954    #[test]
6955    fn a_mouse_sample_reproduces_todays_event_stream() {
6956        let inside = Point::new(40.0, 40.0);
6957        let moved = Point::new(60.0, 55.0);
6958        let outside = Point::new(400.0, 400.0);
6959
6960        let legacy = record(|tree| {
6961            tree.dispatch_event(WidgetEvent::pointer_move(inside));
6962            tree.dispatch_event(WidgetEvent::pointer_down(
6963                inside,
6964                PointerButton::Primary,
6965                Modifiers::NONE,
6966            ));
6967            tree.dispatch_event(WidgetEvent::pointer_move(moved));
6968            tree.dispatch_event(WidgetEvent::pointer_up(
6969                moved,
6970                PointerButton::Primary,
6971                Modifiers::NONE,
6972            ));
6973            tree.dispatch_event(WidgetEvent::pointer_move(outside));
6974        });
6975
6976        let sampled = record(|tree| {
6977            let t = EventTime::ZERO;
6978            tree.dispatch_pointer(PointerSample::mouse(PointerPhase::Move, inside, t));
6979            tree.dispatch_pointer(
6980                PointerSample::mouse(PointerPhase::Down, inside, t)
6981                    .with_button(PointerButton::Primary),
6982            );
6983            tree.dispatch_pointer(PointerSample::mouse(PointerPhase::Move, moved, t));
6984            tree.dispatch_pointer(
6985                PointerSample::mouse(PointerPhase::Up, moved, t)
6986                    .with_button(PointerButton::Primary),
6987            );
6988            tree.dispatch_pointer(PointerSample::mouse(PointerPhase::Move, outside, t));
6989        });
6990
6991        assert_eq!(legacy, sampled);
6992        assert!(
6993            legacy.contains(&"hover(true)".to_string())
6994                && legacy.contains(&"hover(false)".to_string()),
6995            "the fixture must actually exercise enter and leave: {legacy:?}"
6996        );
6997    }
6998
6999    /// A press with no button — what a bare direct-pointer contact reports —
7000    /// still reads as the primary press, because that is what a tap has always
7001    /// been.
7002    #[test]
7003    fn a_buttonless_press_lowers_to_primary() {
7004        let events = record(|tree| {
7005            tree.dispatch_pointer(PointerSample::mouse(
7006                PointerPhase::Down,
7007                Point::new(20.0, 20.0),
7008                EventTime::ZERO,
7009            ));
7010        });
7011        assert!(
7012            events.iter().any(|e| e.contains("button: Primary")),
7013            "{events:?}"
7014        );
7015    }
7016
7017    // --- scroll routing --------------------------------------------------
7018
7019    /// Two leaves side by side across a 200-wide tree: `top` owns x < 100,
7020    /// `bottom` owns x >= 100.
7021    fn scroll_fixture() -> (WidgetTree, Rc<RefCell<Vec<&'static str>>>) {
7022        let hits: Rc<RefCell<Vec<&'static str>>> = Rc::new(RefCell::new(Vec::new()));
7023        let mut tree = WidgetTree::new();
7024
7025        let leading_hits = hits.clone();
7026        let trailing_hits = hits.clone();
7027        let leading = tree.add(FillWidget::new().on_scroll(move |_event, _ctx| {
7028            leading_hits.borrow_mut().push("leading");
7029            EventResponse::Handled
7030        }));
7031        let trailing = tree.add(FillWidget::new().on_scroll(move |_event, _ctx| {
7032            trailing_hits.borrow_mut().push("trailing");
7033            EventResponse::Handled
7034        }));
7035        tree.add(RowWidget {
7036            children: vec![leading, trailing],
7037        });
7038        tree.layout(SizeProposal::exact(200.0, 100.0));
7039        (tree, hits)
7040    }
7041
7042    fn notch() -> ScrollDelta {
7043        ScrollDelta::Lines { x: 0.0, y: -1.0 }
7044    }
7045
7046    /// The mouse path: a wheel notch carries no position, so it routes by
7047    /// hover exactly as it always has. This is the no-op claim.
7048    #[test]
7049    fn a_positionless_scroll_routes_by_hover() {
7050        let (mut tree, hits) = scroll_fixture();
7051
7052        tree.pointer_move(Point::new(150.0, 50.0)); // hover the trailing leaf
7053        tree.dispatch_event(WidgetEvent::scroll(notch(), Modifiers::NONE));
7054        assert_eq!(*hits.borrow(), vec!["trailing"]);
7055
7056        tree.pointer_move(Point::new(50.0, 50.0)); // hover the leading leaf
7057        tree.dispatch_scroll(ScrollSample::wheel(
7058            notch(),
7059            Modifiers::NONE,
7060            EventTime::ZERO,
7061        ));
7062        assert_eq!(*hits.borrow(), vec!["trailing", "leading"]);
7063    }
7064
7065    /// A positioned scroll routes by hit test, *against* the hover. This is
7066    /// the only thing that makes a synthesised touch pan routable at all: a
7067    /// contact never writes hover, so hover would send the pan to whatever the
7068    /// mouse last touched — or nowhere.
7069    #[test]
7070    fn a_positioned_scroll_routes_by_hit_test() {
7071        let (mut tree, hits) = scroll_fixture();
7072        tree.pointer_move(Point::new(150.0, 50.0)); // hover the TRAILING leaf
7073
7074        tree.dispatch_event(WidgetEvent::scroll_at(
7075            notch(),
7076            Modifiers::NONE,
7077            Point::new(50.0, 50.0), // …but scroll over the LEADING one
7078        ));
7079        assert_eq!(*hits.borrow(), vec!["leading"]);
7080
7081        tree.dispatch_scroll(
7082            ScrollSample::wheel(notch(), Modifiers::NONE, EventTime::ZERO)
7083                .at(Point::new(150.0, 50.0)),
7084        );
7085        assert_eq!(*hits.borrow(), vec!["leading", "trailing"]);
7086    }
7087
7088    /// With nothing hovered and nothing focused a positionless scroll goes
7089    /// nowhere — the pre-existing behaviour, pinned rather than left
7090    /// incidental.
7091    #[test]
7092    fn a_positionless_scroll_with_no_hover_goes_nowhere() {
7093        let (mut tree, hits) = scroll_fixture();
7094        tree.dispatch_event(WidgetEvent::scroll(notch(), Modifiers::NONE));
7095        assert!(hits.borrow().is_empty());
7096    }
7097
7098    // --- the per-dispatch snapshot ---------------------------------------
7099
7100    /// A handler can ask which pointer it is serving, and what phase and
7101    /// source a scroll had.
7102    #[test]
7103    fn a_handler_sees_the_sample_it_is_serving() {
7104        let seen: Rc<RefCell<Vec<(ScrollPhase, ScrollSource, Option<Point>)>>> =
7105            Rc::new(RefCell::new(Vec::new()));
7106        let sink = seen.clone();
7107
7108        let mut tree = WidgetTree::new();
7109        // A `ScrollSource::TouchPan` sample is delivered along the pan
7110        // claimants and nowhere else (see `widget_tree::pan_arbiter`), so the
7111        // fixture has to be a pan surface for the sample below to reach it at
7112        // all. Nothing about what this test *asserts* changes — only that the
7113        // widget it asserts against is now the kind of widget a touch pan is
7114        // addressed to.
7115        tree.add(
7116            FillWidget::new()
7117                .scroll_container(crate::pointer::touch_action::PanAxes::BOTH)
7118                .on_scroll(move |_event, ctx| {
7119                    sink.borrow_mut().push((
7120                        ctx.scroll_phase(),
7121                        ctx.scroll_source(),
7122                        ctx.pointer_position(),
7123                    ));
7124                    EventResponse::Handled
7125                }),
7126        );
7127        tree.layout(SizeProposal::exact(100.0, 100.0));
7128
7129        tree.dispatch_scroll(ScrollSample {
7130            delta: notch(),
7131            position: Some(Point::new(50.0, 50.0)),
7132            phase: ScrollPhase::Momentum,
7133            source: ScrollSource::TouchPan,
7134            pointer: PointerInfo::mouse(EventTime::from_millis(12)),
7135            modifiers: Modifiers::NONE,
7136        });
7137
7138        assert_eq!(
7139            *seen.borrow(),
7140            vec![(
7141                ScrollPhase::Momentum,
7142                ScrollSource::TouchPan,
7143                Some(Point::new(50.0, 50.0))
7144            )]
7145        );
7146    }
7147
7148    /// Outside a pointer dispatch a handler sees the default mouse — the same
7149    /// answer every such handler got before pointers were distinguishable.
7150    #[test]
7151    fn a_legacy_event_reports_the_mouse_at_the_epoch() {
7152        let seen: Rc<RefCell<Option<(PointerId, ScrollPhase)>>> = Rc::new(RefCell::new(None));
7153        let sink = seen.clone();
7154
7155        let mut tree = WidgetTree::new();
7156        tree.add(FillWidget::new().on_pointer_event(move |_event, ctx| {
7157            *sink.borrow_mut() = Some((ctx.pointer().id, ctx.scroll_phase()));
7158            EventResponse::Ignored
7159        }));
7160        tree.layout(SizeProposal::exact(100.0, 100.0));
7161        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(50.0, 50.0)));
7162
7163        assert_eq!(
7164            *seen.borrow(),
7165            Some((PointerId::MOUSE, ScrollPhase::Discrete))
7166        );
7167    }
7168
7169    /// The snapshot is saved and restored around a dispatch, so a nested one
7170    /// (a synthetic click, a scroll-into-view walk) does not strand the outer
7171    /// sample's view of the world.
7172    #[test]
7173    fn the_snapshot_is_restored_after_a_dispatch() {
7174        let mut tree = WidgetTree::new();
7175        tree.add(FillWidget::new());
7176        tree.layout(SizeProposal::exact(100.0, 100.0));
7177
7178        let before = tree.current_input.clone();
7179        tree.dispatch_scroll(
7180            ScrollSample::wheel(notch(), Modifiers::NONE, EventTime::ZERO)
7181                .at(Point::new(10.0, 10.0)),
7182        );
7183        assert_eq!(tree.current_input, before);
7184    }
7185}
7186
7187#[cfg(test)]
7188mod press_and_focus_tests {
7189    //! The framework press, focus-on-release for direct pointers, and the one
7190    //! `focus_visible` signal — driven through the real ingress doors against
7191    //! real trees.
7192
7193    use std::cell::{Cell, RefCell};
7194    use std::rc::Rc;
7195    use teksilo_canvas::{Point, SizeProposal};
7196
7197    use crate::WidgetId;
7198    use crate::event::{EventResponse, Key, Modifiers, PointerButton, WidgetEvent};
7199    use crate::focus::FocusOrigin;
7200    use crate::pointer::clock::ManualClock;
7201    use crate::pointer::touch_action::PanClaim;
7202    use crate::pointer::{
7203        BackendDeviceKey, EventTime, PointerId, PointerIdAllocator, PointerInfo, PointerPhase,
7204        PointerSample,
7205    };
7206    use crate::test_widgets::{FillWidget, StackWidget};
7207    use crate::widget_builder::WidgetBuilder;
7208    use crate::widget_tree::WidgetTree;
7209
7210    // -----------------------------------------------------------------
7211    // Fixtures
7212    // -----------------------------------------------------------------
7213
7214    /// A fresh contact identity, minted through the real allocator.
7215    fn contact_id() -> PointerId {
7216        use std::sync::atomic::{AtomicU64, Ordering};
7217        static NEXT: AtomicU64 = AtomicU64::new(1);
7218        PointerIdAllocator::global().begin(
7219            BackendDeviceKey::new(0x0B11),
7220            NEXT.fetch_add(1, Ordering::Relaxed),
7221        )
7222    }
7223
7224    fn contact(id: PointerId, phase: PointerPhase, at: Point, t: EventTime) -> PointerSample {
7225        PointerSample {
7226            pointer: PointerInfo::touch(id, t),
7227            phase,
7228            position: at,
7229            button: None,
7230            modifiers: Modifiers::NONE,
7231            coalesced: Vec::new(),
7232        }
7233    }
7234
7235    /// A tree on a clock the test drives, so every deadline in these tests is
7236    /// virtual.
7237    fn tree_on_a_clock() -> (WidgetTree, Rc<ManualClock>) {
7238        let mut tree = WidgetTree::new();
7239        let clock = Rc::new(ManualClock::new(EventTime::ZERO));
7240        tree.set_input_clock(clock.clone());
7241        (tree, clock)
7242    }
7243
7244    /// A tappable leaf with a framework press signal installed on it, plus the
7245    /// signal itself.
7246    ///
7247    /// `on_tap` is what gives the node a gesture arena, which is what makes it
7248    /// the press owner — the same thing a `Button` gets from its own tap
7249    /// handler.
7250    fn tappable(tree: &mut WidgetTree) -> (WidgetId, crate::signal::Signal<bool>) {
7251        let id = tree.add(FillWidget::new().focusable().on_tap(|_e, _c| {}));
7252        let signal = tree.pressed_signal(id);
7253        (id, signal)
7254    }
7255
7256    // -----------------------------------------------------------------
7257    // The enum
7258    // -----------------------------------------------------------------
7259
7260    /// `FocusOrigin` now names the device behind a pointer focus, and the two
7261    /// accessors that replaced `== FocusOrigin::Pointer` answer for every arm.
7262    #[test]
7263    fn a_pointer_origin_names_its_device() {
7264        use teksilo_tokens::{PenKind, PointerKind};
7265
7266        assert!(FocusOrigin::Pointer(PointerKind::Touch).is_pointer());
7267        assert!(FocusOrigin::Pointer(PointerKind::Pen(PenKind::Pen)).is_pointer());
7268        assert!(FocusOrigin::POINTER.is_pointer());
7269        assert!(!FocusOrigin::Keyboard.is_pointer());
7270        assert!(!FocusOrigin::Programmatic.is_pointer());
7271        assert!(!FocusOrigin::Accessibility.is_pointer());
7272
7273        assert_eq!(
7274            FocusOrigin::Pointer(PointerKind::Touch).pointer_kind(),
7275            Some(PointerKind::Touch),
7276        );
7277        assert_eq!(FocusOrigin::Keyboard.pointer_kind(), None);
7278        assert_eq!(
7279            FocusOrigin::POINTER.pointer_kind(),
7280            Some(PointerKind::Unknown),
7281            "a widget deriving its own origin says so rather than naming a device it never saw",
7282        );
7283    }
7284
7285    /// The `:focus-visible` verdict, per origin. `Programmatic` abstains — a
7286    /// scripted focus declares no modality, so the ring stays where the user's
7287    /// last real interaction left it.
7288    #[test]
7289    fn only_a_real_navigation_declares_a_modality() {
7290        use teksilo_tokens::PointerKind;
7291
7292        assert_eq!(FocusOrigin::Keyboard.focus_visible(), Some(true));
7293        assert_eq!(FocusOrigin::Accessibility.focus_visible(), Some(true));
7294        assert_eq!(
7295            FocusOrigin::Pointer(PointerKind::Touch).focus_visible(),
7296            Some(false),
7297        );
7298        assert_eq!(FocusOrigin::Programmatic.focus_visible(), None);
7299    }
7300
7301    /// The three Tier-3 configs still carry `Signal<Option<FocusOrigin>>`, and
7302    /// a style reading one still gets what it was written against.
7303    #[test]
7304    fn the_tier_three_configs_are_unchanged() {
7305        let origin: crate::signal::Signal<Option<FocusOrigin>> =
7306            crate::signal::Signal::new(Some(FocusOrigin::Keyboard));
7307        let slider: crate::signal::Signal<Option<FocusOrigin>> = origin.clone();
7308        let splitter: crate::signal::Signal<Option<FocusOrigin>> = origin.clone();
7309        let segmented: crate::signal::Signal<Option<FocusOrigin>> = origin.clone();
7310        for field in [slider, splitter, segmented] {
7311            assert_eq!(field.get(), Some(FocusOrigin::Keyboard));
7312        }
7313        origin.set(Some(FocusOrigin::POINTER));
7314        assert_ne!(
7315            origin.get(),
7316            Some(FocusOrigin::Keyboard),
7317            "the `== Some(Keyboard)` test every consumer makes still discriminates",
7318        );
7319    }
7320
7321    // -----------------------------------------------------------------
7322    // focus-visible per kind
7323    // -----------------------------------------------------------------
7324
7325    /// Keyboard focus, then a touch tap, leaves no ring.
7326    ///
7327    /// The behaviour predates this package; what is new is that the focus the
7328    /// tap installs lands on the **release**, so this pins that moving the
7329    /// assignment did not leave a keyboard ring standing over a control the
7330    /// finger just took.
7331    #[test]
7332    fn a_touch_tap_after_keyboard_focus_leaves_no_ring() {
7333        let (mut tree, _clock) = tree_on_a_clock();
7334        let a = tree.add(FillWidget::new().focusable());
7335        let b = tree.add(FillWidget::new().focusable());
7336        let root = tree.add(SideBySide {
7337            children: vec![a, b],
7338        });
7339        tree.layout(SizeProposal::exact(200.0, 100.0));
7340        let _ = root;
7341
7342        let visible = tree.focus_visible_signal();
7343        tree.press_key(Key::Tab, Modifiers::NONE);
7344        assert_eq!(tree.focused(), Some(a));
7345        assert!(visible.get(), "keyboard navigation reveals the ring");
7346
7347        let id = contact_id();
7348        let at = tree.bounds(b).center();
7349        tree.dispatch_pointer(contact(id, PointerPhase::Down, at, EventTime::ZERO));
7350        tree.dispatch_pointer(contact(
7351            id,
7352            PointerPhase::Up,
7353            at,
7354            EventTime::from_millis(30),
7355        ));
7356
7357        assert_eq!(tree.focused(), Some(b), "the release moved focus");
7358        assert!(!visible.get(), "and the ring did not come with it");
7359        assert_eq!(
7360            tree.focus_origin(),
7361            Some(FocusOrigin::Pointer(teksilo_tokens::PointerKind::Touch)),
7362        );
7363    }
7364
7365    /// An assistive `Action::Focus` reveals the ring. The user is navigating —
7366    /// they are simply not doing it with a key — and this used to route through
7367    /// `Programmatic`, which declares nothing and left a screen-reader user
7368    /// with an invisible focus after any click.
7369    #[test]
7370    fn an_assistive_focus_reveals_the_ring() {
7371        let mut tree = WidgetTree::new();
7372        let a = tree.add(FillWidget::new().focusable());
7373        let b = tree.add(FillWidget::new().focusable());
7374        let root = tree.add(SideBySide {
7375            children: vec![a, b],
7376        });
7377        tree.layout(SizeProposal::exact(200.0, 50.0));
7378        let _ = root;
7379        let visible = tree.focus_visible_signal();
7380
7381        tree.click(a);
7382        assert_eq!(tree.focused(), Some(a));
7383        assert!(!visible.get(), "the click hid it");
7384
7385        tree.dispatch_event(WidgetEvent::AccessAction {
7386            target: Some(b),
7387            action: accesskit::Action::Focus,
7388            target_node: crate::accessibility::widget_id_to_node_id(b),
7389            data: None,
7390        });
7391        assert_eq!(tree.focused(), Some(b));
7392        assert!(visible.get(), "an assistive move is a navigation");
7393        assert_eq!(tree.focus_origin(), Some(FocusOrigin::Accessibility));
7394    }
7395
7396    /// A programmatic focus abstains: it declares no modality, so the ring
7397    /// stays exactly where the last real interaction left it. This is what
7398    /// `Button` / `Checkbox`'s pre-existing focus-ring tests pin, and what
7399    /// browsers do for `element.focus()`.
7400    #[test]
7401    fn a_programmatic_focus_leaves_the_modality_alone() {
7402        let mut tree = WidgetTree::new();
7403        let a = tree.add(FillWidget::new().focusable());
7404        let b = tree.add(FillWidget::new().focusable());
7405        let root = tree.add(SideBySide {
7406            children: vec![a, b],
7407        });
7408        tree.layout(SizeProposal::exact(200.0, 100.0));
7409        let _ = root;
7410        let visible = tree.focus_visible_signal();
7411
7412        tree.press_key(Key::Tab, Modifiers::NONE);
7413        assert_eq!(tree.focused(), Some(a));
7414        assert!(visible.get());
7415        tree.focus(b);
7416        assert!(
7417            visible.get(),
7418            "a scripted focus does not hide a keyboard ring"
7419        );
7420
7421        tree.click(a);
7422        assert!(!visible.get());
7423        tree.focus(b);
7424        assert!(
7425            !visible.get(),
7426            "and does not reveal one after a click either",
7427        );
7428    }
7429
7430    // -----------------------------------------------------------------
7431    // Activation on release
7432    // -----------------------------------------------------------------
7433
7434    /// A mouse focuses on press, exactly as it always has.
7435    #[test]
7436    fn a_mouse_still_focuses_on_press() {
7437        let mut tree = WidgetTree::new();
7438        let w = tree.add(FillWidget::new().focusable());
7439        tree.layout(SizeProposal::exact(100.0, 50.0));
7440        let center = tree.bounds(w).center();
7441
7442        tree.dispatch_event(WidgetEvent::pointer_down(
7443            center,
7444            PointerButton::Primary,
7445            Modifiers::NONE,
7446        ));
7447        assert_eq!(
7448            tree.focused(),
7449            Some(w),
7450            "focus lands on the press for an indirect pointer",
7451        );
7452    }
7453
7454    /// A finger's focus waits for the release.
7455    #[test]
7456    fn a_finger_focuses_on_release() {
7457        let (mut tree, _clock) = tree_on_a_clock();
7458        let w = tree.add(FillWidget::new().focusable());
7459        tree.layout(SizeProposal::exact(100.0, 50.0));
7460        let center = tree.bounds(w).center();
7461
7462        let id = contact_id();
7463        tree.dispatch_pointer(contact(id, PointerPhase::Down, center, EventTime::ZERO));
7464        assert_eq!(
7465            tree.focused(),
7466            None,
7467            "a finger that has only landed has chosen nothing yet",
7468        );
7469
7470        tree.dispatch_pointer(contact(
7471            id,
7472            PointerPhase::Up,
7473            center,
7474            EventTime::from_millis(40),
7475        ));
7476        assert_eq!(tree.focused(), Some(w));
7477        assert_eq!(
7478            tree.focus_origin().and_then(FocusOrigin::pointer_kind),
7479            Some(teksilo_tokens::PointerKind::Touch),
7480            "the origin names the device that delivered it",
7481        );
7482    }
7483
7484    /// …and only when the release lands back on the same focusable. A finger
7485    /// that presses one control, slides onto its neighbour and lifts has
7486    /// activated nothing and must move focus nowhere.
7487    #[test]
7488    fn a_release_on_a_different_focusable_moves_no_focus() {
7489        let (mut tree, _clock) = tree_on_a_clock();
7490        let a = tree.add(FillWidget::new().focusable());
7491        let b = tree.add(FillWidget::new().focusable());
7492        let root = tree.add(SideBySide {
7493            children: vec![a, b],
7494        });
7495        tree.layout(SizeProposal::exact(200.0, 50.0));
7496        let _ = root;
7497
7498        let on_a = tree.bounds(a).center();
7499        let on_b = tree.bounds(b).center();
7500        let id = contact_id();
7501        tree.dispatch_pointer(contact(id, PointerPhase::Down, on_a, EventTime::ZERO));
7502        tree.dispatch_pointer(contact(
7503            id,
7504            PointerPhase::Move,
7505            on_b,
7506            EventTime::from_millis(20),
7507        ));
7508        tree.dispatch_pointer(contact(
7509            id,
7510            PointerPhase::Up,
7511            on_b,
7512            EventTime::from_millis(40),
7513        ));
7514
7515        assert_eq!(
7516            tree.focused(),
7517            None,
7518            "the guard is `the release landed on the same focusable as the press`",
7519        );
7520    }
7521
7522    // -----------------------------------------------------------------
7523    // The press visual
7524    // -----------------------------------------------------------------
7525
7526    /// A mouse press lights the visual at once and the release clears it —
7527    /// the pre-touch rule, and no press-feedback delay anywhere near it.
7528    #[test]
7529    fn a_mouse_press_visual_is_unchanged() {
7530        let mut tree = WidgetTree::new();
7531        let (w, pressed) = tappable(&mut tree);
7532        tree.layout(SizeProposal::exact(100.0, 50.0));
7533        let center = tree.bounds(w).center();
7534
7535        assert!(!pressed.get(), "not pressed at rest");
7536        tree.dispatch_event(WidgetEvent::pointer_down(
7537            center,
7538            PointerButton::Primary,
7539            Modifiers::NONE,
7540        ));
7541        assert!(pressed.get(), "an indirect pointer lights up on the press");
7542        assert!(
7543            !tree.press_pending(w),
7544            "and never waits: a mouse opens no pan session, so there is no \
7545             ambiguity to wait out",
7546        );
7547        assert_eq!(tree.pressed_by(w), Some(PointerId::MOUSE));
7548
7549        tree.dispatch_event(WidgetEvent::pointer_up(
7550            center,
7551            PointerButton::Primary,
7552            Modifiers::NONE,
7553        ));
7554        assert!(!pressed.get(), "the release clears it");
7555        assert_eq!(tree.pressed_by(w), None);
7556    }
7557
7558    /// A slide off the target clears the visual, and sliding back on restores
7559    /// it. WCAG 2.2 SC 2.5.2's abort gesture, and reversible right up to the
7560    /// release.
7561    #[test]
7562    fn a_slide_off_clears_the_visual_and_re_entry_restores_it() {
7563        let (mut tree, _clock) = tree_on_a_clock();
7564        let (w, pressed) = tappable(&mut tree);
7565        tree.layout(SizeProposal::exact(100.0, 200.0));
7566        let inside = tree.bounds(w).center();
7567        let outside = Point::new(inside.x, inside.y + 400.0);
7568
7569        let id = contact_id();
7570        tree.dispatch_pointer(contact(id, PointerPhase::Down, inside, EventTime::ZERO));
7571        assert!(
7572            pressed.get(),
7573            "nothing here claims a pan, so no delay applies"
7574        );
7575
7576        tree.dispatch_pointer(contact(
7577            id,
7578            PointerPhase::Move,
7579            outside,
7580            EventTime::from_millis(20),
7581        ));
7582        assert!(!pressed.get(), "the press has left its target");
7583        assert!(!tree.press_is_inside(w));
7584        assert_eq!(
7585            tree.pressed_by(w),
7586            Some(id),
7587            "the contact still holds the press — it is the *visual* that is off",
7588        );
7589
7590        tree.dispatch_pointer(contact(
7591            id,
7592            PointerPhase::Move,
7593            inside,
7594            EventTime::from_millis(40),
7595        ));
7596        assert!(pressed.get(), "sliding back on restores it");
7597
7598        tree.dispatch_pointer(contact(
7599            id,
7600            PointerPhase::Up,
7601            inside,
7602            EventTime::from_millis(60),
7603        ));
7604        assert!(!pressed.get());
7605    }
7606
7607    /// A second contact cannot clear the first's visual. Its own release
7608    /// removes only the press it owns.
7609    #[test]
7610    fn a_second_contact_cannot_clear_the_first_visual() {
7611        let (mut tree, _clock) = tree_on_a_clock();
7612        let (w, pressed) = tappable(&mut tree);
7613        tree.layout(SizeProposal::exact(100.0, 50.0));
7614        let bounds = tree.bounds(w);
7615        let first_at = Point::new(bounds.x + 20.0, bounds.y + 25.0);
7616        let second_at = Point::new(bounds.x + 70.0, bounds.y + 25.0);
7617
7618        let first = contact_id();
7619        let second = contact_id();
7620        tree.dispatch_pointer(contact(
7621            first,
7622            PointerPhase::Down,
7623            first_at,
7624            EventTime::ZERO,
7625        ));
7626        assert!(pressed.get());
7627        assert_eq!(tree.pressed_by(w), Some(first));
7628
7629        tree.dispatch_pointer(contact(
7630            second,
7631            PointerPhase::Down,
7632            second_at,
7633            EventTime::from_millis(10),
7634        ));
7635        assert_eq!(
7636            tree.pressed_by(w),
7637            Some(first),
7638            "under `MultiContact::First` the second contact is terminated before \
7639             it reaches the arena at all",
7640        );
7641
7642        tree.dispatch_pointer(contact(
7643            second,
7644            PointerPhase::Up,
7645            second_at,
7646            EventTime::from_millis(20),
7647        ));
7648        assert!(
7649            pressed.get(),
7650            "so the second contact's release cannot clear a visual it never owned",
7651        );
7652        assert_eq!(tree.pressed_by(w), Some(first));
7653
7654        tree.dispatch_pointer(contact(
7655            first,
7656            PointerPhase::Up,
7657            first_at,
7658            EventTime::from_millis(30),
7659        ));
7660        assert!(!pressed.get(), "the owner's release does clear it");
7661    }
7662
7663    /// A cancel clears the visual. The node is never sent an `Up` to clear it
7664    /// from, so nothing else could.
7665    #[test]
7666    fn a_cancel_clears_the_visual() {
7667        let (mut tree, _clock) = tree_on_a_clock();
7668        let (w, pressed) = tappable(&mut tree);
7669        tree.layout(SizeProposal::exact(100.0, 50.0));
7670        let center = tree.bounds(w).center();
7671
7672        let id = contact_id();
7673        tree.dispatch_pointer(contact(id, PointerPhase::Down, center, EventTime::ZERO));
7674        assert!(pressed.get());
7675
7676        tree.dispatch_pointer(contact(
7677            id,
7678            PointerPhase::Cancel,
7679            center,
7680            EventTime::from_millis(20),
7681        ));
7682        assert!(!pressed.get(), "a press that was taken away is not painted");
7683        assert_eq!(tree.pressed_by(w), None);
7684    }
7685
7686    /// A peer winning the arbitration clears the visual. The pressed control is
7687    /// never told; only the router knows it has lost the press.
7688    #[test]
7689    fn a_pan_claim_clears_the_visual() {
7690        let (mut tree, clock) = tree_on_a_clock();
7691        let (row, pressed) = tappable(&mut tree);
7692        let list = tree.add(
7693            StackWidget::new()
7694                .child(row)
7695                .pan_claim(PanClaim::vertical())
7696                .on_scroll(|_e, _c| EventResponse::Handled),
7697        );
7698        tree.layout(SizeProposal::exact(200.0, 400.0));
7699        let _ = list;
7700        let start = Point::new(100.0, 200.0);
7701
7702        let id = contact_id();
7703        tree.dispatch_pointer(contact(id, PointerPhase::Down, start, EventTime::ZERO));
7704        // Inside a claimant, so the visual waits.
7705        assert!(tree.press_pending(row));
7706        clock.set(EventTime::from_millis(100));
7707        tree.tick_gestures(std::time::Instant::now());
7708        assert!(pressed.get(), "…and appears once the delay has elapsed");
7709
7710        // Past the touch profile's 36 dp pan slop: the list claims.
7711        tree.dispatch_pointer(contact(
7712            id,
7713            PointerPhase::Move,
7714            Point::new(100.0, 260.0),
7715            EventTime::from_millis(120),
7716        ));
7717        assert!(
7718            !pressed.get(),
7719            "the row lost the press to the list and must stop advertising it",
7720        );
7721        assert_eq!(tree.pressed_by(row), None);
7722    }
7723
7724    // -----------------------------------------------------------------
7725    // Which button may raise the visual
7726    // -----------------------------------------------------------------
7727
7728    /// The visual answers to the same buttons the activation does. A node
7729    /// carrying a plain `on_tap` accepts `PRIMARY` and nothing else, so a
7730    /// middle, back or forward press must light nothing up — the press it
7731    /// would be advertising can never complete.
7732    #[test]
7733    fn a_button_the_control_cannot_act_on_raises_no_visual() {
7734        let taps = Rc::new(Cell::new(0u32));
7735        let count = taps.clone();
7736        let mut tree = WidgetTree::new();
7737        let w = tree.add(FillWidget::new().focusable().on_tap(move |_e, _c| {
7738            count.set(count.get() + 1);
7739        }));
7740        let pressed = tree.pressed_signal(w);
7741        tree.layout(SizeProposal::exact(100.0, 50.0));
7742        let center = tree.bounds(w).center();
7743
7744        for button in [
7745            PointerButton::Middle,
7746            PointerButton::Back,
7747            PointerButton::Forward,
7748        ] {
7749            tree.dispatch_event(WidgetEvent::pointer_down(center, button, Modifiers::NONE));
7750            assert!(
7751                !pressed.get(),
7752                "{button:?} cannot activate an `on_tap` node, so it must not light one up",
7753            );
7754            assert_eq!(tree.pressed_by(w), None, "and owns no visual to clear");
7755            tree.dispatch_event(WidgetEvent::pointer_up(center, button, Modifiers::NONE));
7756            assert_eq!(
7757                taps.get(),
7758                0,
7759                "the tap recognizer refuses {button:?} too — that is the point",
7760            );
7761        }
7762
7763        // …and the button it does act on is untouched.
7764        tree.dispatch_event(WidgetEvent::pointer_down(
7765            center,
7766            PointerButton::Primary,
7767            Modifiers::NONE,
7768        ));
7769        assert!(pressed.get(), "a primary press lights up as it always has");
7770        assert_eq!(tree.pressed_by(w), Some(PointerId::MOUSE));
7771        tree.dispatch_event(WidgetEvent::pointer_up(
7772            center,
7773            PointerButton::Primary,
7774            Modifiers::NONE,
7775        ));
7776        assert!(!pressed.get());
7777        assert_eq!(taps.get(), 1);
7778    }
7779
7780    /// The case a real user hits: a right-click on a control with no context
7781    /// menu. The secondary arm finds nothing to open and falls through to the
7782    /// ordinary press path, which must still raise nothing.
7783    #[test]
7784    fn a_secondary_press_with_no_context_menu_raises_no_visual() {
7785        let mut tree = WidgetTree::new();
7786        let (w, pressed) = tappable(&mut tree);
7787        tree.layout(SizeProposal::exact(100.0, 50.0));
7788        let center = tree.bounds(w).center();
7789
7790        tree.dispatch_event(WidgetEvent::pointer_down(
7791            center,
7792            PointerButton::Secondary,
7793            Modifiers::NONE,
7794        ));
7795        assert!(
7796            !pressed.get(),
7797            "nothing opened, and nothing may look pressed either",
7798        );
7799        assert_eq!(tree.pressed_by(w), None);
7800        assert!(!tree.press_is_inside(w));
7801    }
7802
7803    /// A widget that widened its own mask keeps the visual on the buttons it
7804    /// widened to: the gate reads the node's declared acceptance, it does not
7805    /// hardcode `PRIMARY`.
7806    #[test]
7807    fn a_widened_mask_widens_the_visual_with_it() {
7808        use crate::event::ButtonMask;
7809
7810        let mut tree = WidgetTree::new();
7811        let w = tree.add(
7812            FillWidget::new()
7813                .on_tap(|_e, _c| {})
7814                .accept_tap_buttons(ButtonMask::PRIMARY | ButtonMask::MIDDLE),
7815        );
7816        let pressed = tree.pressed_signal(w);
7817        tree.layout(SizeProposal::exact(100.0, 50.0));
7818        let center = tree.bounds(w).center();
7819
7820        tree.dispatch_event(WidgetEvent::pointer_down(
7821            center,
7822            PointerButton::Middle,
7823            Modifiers::NONE,
7824        ));
7825        assert!(pressed.get(), "this node really does act on a middle-click");
7826
7827        tree.dispatch_event(WidgetEvent::pointer_up(
7828            center,
7829            PointerButton::Middle,
7830            Modifiers::NONE,
7831        ));
7832        assert!(!pressed.get());
7833    }
7834
7835    /// A press that raises no visual still records the focus a direct pointer
7836    /// defers to its release. A stylus barrel button reports `Secondary`, and
7837    /// a pen that presses a control and lifts on it has chosen that control
7838    /// whether or not the button lit it up — the record exists for the focus,
7839    /// not only for the visual.
7840    #[test]
7841    fn a_press_that_raises_no_visual_still_defers_its_focus() {
7842        use teksilo_tokens::{PenKind, PointerKind};
7843
7844        let (mut tree, _clock) = tree_on_a_clock();
7845        let (w, pressed) = tappable(&mut tree);
7846        tree.layout(SizeProposal::exact(100.0, 50.0));
7847        let center = tree.bounds(w).center();
7848
7849        let id = contact_id();
7850        let barrel = |phase, t| PointerSample {
7851            pointer: PointerInfo {
7852                kind: PointerKind::Pen(PenKind::Pen),
7853                ..PointerInfo::touch(id, t)
7854            },
7855            phase,
7856            position: center,
7857            button: Some(PointerButton::Secondary),
7858            modifiers: Modifiers::NONE,
7859            coalesced: Vec::new(),
7860        };
7861
7862        tree.dispatch_pointer(barrel(PointerPhase::Down, EventTime::ZERO));
7863        assert!(!pressed.get(), "the barrel button activates nothing here");
7864        assert_eq!(tree.pressed_by(w), None);
7865        assert_eq!(
7866            tree.focused(),
7867            None,
7868            "a direct pointer has chosen nothing until it lifts",
7869        );
7870
7871        tree.dispatch_pointer(barrel(PointerPhase::Up, EventTime::from_millis(40)));
7872        assert_eq!(
7873            tree.focused(),
7874            Some(w),
7875            "the deferral is not button-gated: the release landed where the press did",
7876        );
7877        assert_eq!(
7878            tree.focus_origin().and_then(FocusOrigin::pointer_kind),
7879            Some(PointerKind::Pen(PenKind::Pen)),
7880        );
7881    }
7882
7883    // -----------------------------------------------------------------
7884    // The press-feedback delay
7885    // -----------------------------------------------------------------
7886
7887    /// The delay applies only inside a pan claimant — a control nothing can
7888    /// scroll out from under has no ambiguity to wait out.
7889    #[test]
7890    fn the_feedback_delay_applies_only_inside_a_claimant() {
7891        // Outside a claimant: immediate.
7892        let (mut tree, _clock) = tree_on_a_clock();
7893        let (w, pressed) = tappable(&mut tree);
7894        tree.layout(SizeProposal::exact(200.0, 400.0));
7895        let id = contact_id();
7896        let at = tree.bounds(w).center();
7897        tree.dispatch_pointer(contact(id, PointerPhase::Down, at, EventTime::ZERO));
7898        assert!(
7899            !tree.press_pending(w),
7900            "no claimant above it, so nothing to rule out",
7901        );
7902        assert!(pressed.get());
7903
7904        // Inside one: withheld, then released by the delay.
7905        let (mut tree, clock) = tree_on_a_clock();
7906        let (row, pressed) = tappable(&mut tree);
7907        let list = tree.add(
7908            StackWidget::new()
7909                .child(row)
7910                .pan_claim(PanClaim::vertical())
7911                .on_scroll(|_e, _c| EventResponse::Handled),
7912        );
7913        tree.layout(SizeProposal::exact(200.0, 400.0));
7914        let _ = list;
7915        let id = contact_id();
7916        let at = Point::new(100.0, 200.0);
7917        tree.dispatch_pointer(contact(id, PointerPhase::Down, at, EventTime::ZERO));
7918        assert!(tree.press_pending(row), "a finger might be about to scroll");
7919        assert!(!pressed.get(), "so the row does not flash");
7920        assert!(
7921            tree.press_is_inside(row),
7922            "the press is real; only its visual is being withheld",
7923        );
7924
7925        clock.set(EventTime::from_millis(99));
7926        tree.tick_gestures(std::time::Instant::now());
7927        assert!(!pressed.get(), "99 ms is under the 100 ms delay");
7928
7929        clock.set(EventTime::from_millis(100));
7930        tree.tick_gestures(std::time::Instant::now());
7931        assert!(pressed.get(), "and 100 ms is it");
7932        assert!(!tree.press_pending(row));
7933    }
7934
7935    /// A mouse pressing inside the very same claimant waits for nothing: an
7936    /// indirect pointer opens no pan session, so the delay never reaches it.
7937    #[test]
7938    fn a_mouse_inside_a_claimant_never_waits() {
7939        let mut tree = WidgetTree::new();
7940        let (row, pressed) = tappable(&mut tree);
7941        let list = tree.add(
7942            StackWidget::new()
7943                .child(row)
7944                .pan_claim(PanClaim::vertical())
7945                .on_scroll(|_e, _c| EventResponse::Handled),
7946        );
7947        tree.layout(SizeProposal::exact(200.0, 400.0));
7948        let _ = list;
7949
7950        tree.dispatch_event(WidgetEvent::pointer_down(
7951            Point::new(100.0, 200.0),
7952            PointerButton::Primary,
7953            Modifiers::NONE,
7954        ));
7955        assert!(!tree.press_pending(row));
7956        assert!(pressed.get(), "Compact with a mouse is exactly as it was");
7957    }
7958
7959    // -----------------------------------------------------------------
7960    // The EventContext queries
7961    // -----------------------------------------------------------------
7962
7963    /// The three queries `teksilo-widgets`' `common/interaction.rs` consumes,
7964    /// read from inside a real handler.
7965    #[test]
7966    fn a_handler_can_read_the_press_it_is_inside() {
7967        let (mut tree, _clock) = tree_on_a_clock();
7968        let seen: Rc<RefCell<Vec<(bool, bool, bool)>>> = Rc::new(RefCell::new(Vec::new()));
7969        let log = seen.clone();
7970        let row = tree.add(FillWidget::new().on_tap(|_e, _c| {}).on_pointer_event(
7971            move |event, ctx| {
7972                if matches!(event, WidgetEvent::PointerUp { .. }) {
7973                    log.borrow_mut().push((
7974                        ctx.is_pressed(),
7975                        ctx.press_is_inside(),
7976                        ctx.press_pending(),
7977                    ));
7978                }
7979                EventResponse::Ignored
7980            },
7981        ));
7982        let list = tree.add(
7983            StackWidget::new()
7984                .child(row)
7985                .pan_claim(PanClaim::vertical())
7986                .on_scroll(|_e, _c| EventResponse::Handled),
7987        );
7988        tree.layout(SizeProposal::exact(200.0, 400.0));
7989        let _ = list;
7990
7991        let id = contact_id();
7992        let at = Point::new(100.0, 200.0);
7993        tree.dispatch_pointer(contact(id, PointerPhase::Down, at, EventTime::ZERO));
7994        tree.dispatch_pointer(contact(
7995            id,
7996            PointerPhase::Up,
7997            at,
7998            EventTime::from_millis(30),
7999        ));
8000
8001        assert_eq!(
8002            seen.borrow().as_slice(),
8003            &[(false, true, true)],
8004            "released inside the claimant before the delay elapsed: inside, \
8005             pending, and therefore not yet showing",
8006        );
8007    }
8008
8009    /// A handler outside any press reads three falses rather than a panic or a
8010    /// stale answer.
8011    #[test]
8012    fn a_handler_outside_a_press_reads_nothing() {
8013        let mut tree = WidgetTree::new();
8014        let asked = Rc::new(Cell::new(false));
8015        let flag = asked.clone();
8016        let w = tree.add(FillWidget::new().focusable().on_key(move |_e, ctx| {
8017            flag.set(ctx.is_pressed() || ctx.press_is_inside() || ctx.press_pending());
8018            EventResponse::Ignored
8019        }));
8020        tree.layout(SizeProposal::exact(100.0, 50.0));
8021        tree.focus(w);
8022        tree.press_key(Key::ArrowDown, Modifiers::NONE);
8023        assert!(!asked.get());
8024    }
8025
8026    /// A completed interaction leaves no press behind, and the leak detector
8027    /// says so — it now reads the press table, which it could not before this
8028    /// package landed one.
8029    #[test]
8030    fn a_completed_press_leaves_the_detector_clean() {
8031        let (mut tree, _clock) = tree_on_a_clock();
8032        let (w, pressed) = tappable(&mut tree);
8033        tree.layout(SizeProposal::exact(100.0, 50.0));
8034        let at = tree.bounds(w).center();
8035
8036        let id = contact_id();
8037        tree.dispatch_pointer(contact(id, PointerPhase::Down, at, EventTime::ZERO));
8038        assert!(pressed.get());
8039        tree.dispatch_pointer(contact(
8040            id,
8041            PointerPhase::Up,
8042            at,
8043            EventTime::from_millis(30),
8044        ));
8045        tree.assert_no_leaked_pointer_state();
8046    }
8047
8048    /// …and it would have caught the opposite. Driven by holding the press open
8049    /// rather than by faking state, so the assertion is about the real exit
8050    /// path.
8051    #[test]
8052    fn the_detector_reports_a_press_still_held() {
8053        let (mut tree, _clock) = tree_on_a_clock();
8054        let (w, _pressed) = tappable(&mut tree);
8055        tree.layout(SizeProposal::exact(100.0, 50.0));
8056        let at = tree.bounds(w).center();
8057
8058        let id = contact_id();
8059        tree.dispatch_pointer(contact(id, PointerPhase::Down, at, EventTime::ZERO));
8060        let held = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
8061            tree.assert_no_leaked_pointer_state()
8062        }));
8063        let message = *held
8064            .expect_err("a live press is leaked state")
8065            .downcast::<String>()
8066            .expect("the detector panics with a message");
8067        assert!(
8068            message.contains("is still pressed by"),
8069            "the press is named in the report, got: {message}",
8070        );
8071    }
8072
8073    /// A container that lays its children out side by side, so a hit test at a
8074    /// given x picks a specific child. `StackWidget` deliberately stacks at one
8075    /// origin, which is the opposite of what a release-elsewhere test needs.
8076    #[derive(Debug)]
8077    struct SideBySide {
8078        children: Vec<WidgetId>,
8079    }
8080
8081    impl crate::widget::Widget for SideBySide {
8082        fn layout_response(
8083            &self,
8084            proposal: SizeProposal,
8085            _ctx: &crate::widget::LayoutContext,
8086        ) -> crate::widget::LayoutResponse {
8087            proposal.resolve(0.0, 0.0).into()
8088        }
8089
8090        fn place_children(
8091            &self,
8092            bounds: teksilo_canvas::Rect,
8093            _proposal: SizeProposal,
8094            children: &mut [crate::widget::WidgetPlacement],
8095            _ctx: &crate::widget::LayoutContext,
8096        ) {
8097            let n = children.len().max(1) as f32;
8098            let w = bounds.width / n;
8099            for (i, child) in children.iter_mut().enumerate() {
8100                child.origin = Point::new(bounds.x + w * i as f32, bounds.y);
8101                child.size = teksilo_canvas::Size::new(w, bounds.height);
8102            }
8103        }
8104
8105        fn children(&self) -> Vec<WidgetId> {
8106            self.children.clone()
8107        }
8108    }
8109}
8110
8111#[cfg(test)]
8112mod overlay_release_dismissal_tests {
8113    //! Outside-press overlay dismissal, driven through the real ingress door.
8114    //!
8115    //! The defect: `handle_click_outside` ran on the `PointerDown` and then
8116    //! *fell through*, so one press both closed a menu and actuated whatever
8117    //! the menu was covering. With a cursor that is defensible — the user aimed
8118    //! at a pixel they could see the whole time. With a finger it is not: the
8119    //! menu is the only thing they were looking at, and the control underneath
8120    //! is one they never saw.
8121
8122    use std::cell::Cell;
8123    use std::rc::Rc;
8124
8125    use teksilo_canvas::{Point, Rect, Size, SizeProposal};
8126
8127    use crate::WidgetId;
8128    use crate::event::{Modifiers, PointerButton, WidgetEvent};
8129    use crate::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
8130    use crate::pointer::{
8131        BackendDeviceKey, EventTime, PointerId, PointerIdAllocator, PointerInfo, PointerPhase,
8132        PointerSample,
8133    };
8134    use crate::test_widgets::FillWidget;
8135    use crate::widget::{LayoutContext, LayoutResponse, Widget};
8136    use crate::widget_builder::WidgetBuilder;
8137    use crate::widget_tree::WidgetTree;
8138
8139    // -----------------------------------------------------------------
8140    // Fixtures
8141    // -----------------------------------------------------------------
8142
8143    /// A leaf with an intrinsic size, so an overlay hung off it gets real
8144    /// bounds out of `position_overlays` instead of a zero rect.
8145    #[derive(Debug)]
8146    struct Panel(Size);
8147
8148    impl Widget for Panel {
8149        fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
8150            self.0.into()
8151        }
8152    }
8153
8154    /// A root that puts its first child over the whole window and its second in
8155    /// a 10 dp corner.
8156    ///
8157    /// Two bare roots would both be laid out at the window's full size — the
8158    /// trigger would then contain every press point, and the pre-existing
8159    /// "a press on a click-opened overlay's own anchor is consumed" rule would
8160    /// swallow the very presses these tests are about.
8161    #[derive(Debug)]
8162    struct PageAndTrigger {
8163        children: Vec<WidgetId>,
8164    }
8165
8166    impl Widget for PageAndTrigger {
8167        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
8168            proposal.resolve(0.0, 0.0).into()
8169        }
8170
8171        fn place_children(
8172            &self,
8173            bounds: Rect,
8174            _proposal: SizeProposal,
8175            children: &mut [crate::widget::WidgetPlacement],
8176            _ctx: &LayoutContext,
8177        ) {
8178            if let Some(page) = children.get_mut(0) {
8179                page.origin = bounds.origin();
8180                page.size = bounds.size();
8181            }
8182            if let Some(trigger) = children.get_mut(1) {
8183                trigger.origin = bounds.origin();
8184                trigger.size = Size::new(10.0, 10.0);
8185            }
8186        }
8187
8188        fn children(&self) -> Vec<WidgetId> {
8189            self.children.clone()
8190        }
8191    }
8192
8193    fn contact_id(n: u64) -> PointerId {
8194        PointerIdAllocator::global().begin(BackendDeviceKey::new(0x0FA2), n)
8195    }
8196
8197    fn touch(id: PointerId, phase: PointerPhase, at: Point) -> PointerSample {
8198        PointerSample {
8199            pointer: PointerInfo::touch(id, EventTime::ZERO),
8200            phase,
8201            position: at,
8202            button: None,
8203            modifiers: Modifiers::NONE,
8204            coalesced: Vec::new(),
8205        }
8206    }
8207
8208    /// The scene every test below shares: a tappable page filling the window,
8209    /// and a menu overlay floating over part of it at (100, 100, 200, 200).
8210    ///
8211    /// The menu's content is a separate root, so a press outside it lands on
8212    /// the page and a press inside it lands on the menu — which is exactly the
8213    /// arrangement the dismissal rule is about.
8214    fn page_with_a_menu() -> (WidgetTree, WidgetId, Rc<Cell<u32>>) {
8215        let mut tree = WidgetTree::new();
8216        let taps = Rc::new(Cell::new(0u32));
8217        let counter = taps.clone();
8218        let page = tree.add(
8219            FillWidget::new()
8220                .focusable()
8221                .on_tap(move |_e, _c| counter.set(counter.get() + 1)),
8222        );
8223        // A small trigger in the corner, well clear of both press points — see
8224        // `PageAndTrigger` for why it cannot simply be a second root.
8225        let trigger = tree.add(Panel(Size::new(10.0, 10.0)));
8226        let _root = tree.add(PageAndTrigger {
8227            children: vec![page, trigger],
8228        });
8229        let menu = tree.add(Panel(Size::new(200.0, 200.0)));
8230        tree.show_overlay(OverlayRequest {
8231            content_id: menu,
8232            anchor: trigger,
8233            placement: OverlayPlacement::AtPointer(Point::new(100.0, 100.0)),
8234            dismiss: DismissBehavior::ClickOutside,
8235            layer: OverlayLayer::InTree,
8236            parent_overlay: None,
8237            on_dismiss: None,
8238            fade_duration: None,
8239        });
8240        tree.layout(SizeProposal::exact(800.0, 600.0));
8241        assert_eq!(tree.active_overlays().len(), 1, "the menu is open");
8242        (tree, page, taps)
8243    }
8244
8245    fn outside() -> Point {
8246        Point::new(600.0, 500.0)
8247    }
8248
8249    fn inside_menu() -> Point {
8250        Point::new(150.0, 150.0)
8251    }
8252
8253    // -----------------------------------------------------------------
8254    // The direct-pointer contract
8255    // -----------------------------------------------------------------
8256
8257    /// The arming press reaches nothing: no press record, no capture, and the
8258    /// menu is still up because the decision belongs to the release.
8259    #[test]
8260    fn the_suppressed_down_leaves_nothing_pressed_or_captured_beneath() {
8261        let (mut tree, page, taps) = page_with_a_menu();
8262        let finger = contact_id(1);
8263        tree.dispatch_pointer(touch(finger, PointerPhase::Down, outside()));
8264
8265        assert_eq!(
8266            tree.active_overlays().len(),
8267            1,
8268            "the press does not close the menu; the release does"
8269        );
8270        assert_eq!(tree.pressed_by(page), None, "nothing beneath is pressed");
8271        let entry = tree.pointers.get(finger).expect("the contact is live");
8272        assert_eq!(entry.captured_by, None, "nothing beneath captured it");
8273        assert!(entry.sequence.is_none(), "no arbitration was opened");
8274        assert_eq!(taps.get(), 0);
8275    }
8276
8277    /// …and the release closes the menu without actuating what it covered.
8278    #[test]
8279    fn a_touch_tap_outside_a_menu_closes_it_and_actuates_nothing() {
8280        let (mut tree, page, taps) = page_with_a_menu();
8281        let finger = contact_id(2);
8282        tree.dispatch_pointer(touch(finger, PointerPhase::Down, outside()));
8283        tree.dispatch_pointer(touch(finger, PointerPhase::Up, outside()));
8284
8285        assert!(tree.active_overlays().is_empty(), "the menu closed");
8286        assert_eq!(taps.get(), 0, "the page beneath was never tapped");
8287        assert_eq!(tree.pressed_by(page), None);
8288        tree.assert_no_leaked_pointer_state();
8289    }
8290
8291    /// A press that never completes delivers nothing at all — neither the
8292    /// dismissal it armed nor the press it withheld.
8293    #[test]
8294    fn a_cancelled_press_aborts_the_arm() {
8295        let (mut tree, page, taps) = page_with_a_menu();
8296        let finger = contact_id(3);
8297        tree.dispatch_pointer(touch(finger, PointerPhase::Down, outside()));
8298        tree.dispatch_pointer(touch(finger, PointerPhase::Cancel, outside()));
8299
8300        assert_eq!(tree.active_overlays().len(), 1, "the menu survives");
8301        assert_eq!(taps.get(), 0);
8302        assert_eq!(tree.pressed_by(page), None);
8303        tree.assert_no_leaked_pointer_state();
8304    }
8305
8306    /// Land beside the menu, drag onto it, lift there. The finger changed its
8307    /// mind: the menu stays, and the page under the arming press was never
8308    /// touched either.
8309    #[test]
8310    fn a_press_slid_onto_the_menu_dismisses_nothing() {
8311        let (mut tree, _page, taps) = page_with_a_menu();
8312        let finger = contact_id(4);
8313        tree.dispatch_pointer(touch(finger, PointerPhase::Down, outside()));
8314        tree.dispatch_pointer(touch(finger, PointerPhase::Move, inside_menu()));
8315        tree.dispatch_pointer(touch(finger, PointerPhase::Up, inside_menu()));
8316
8317        assert_eq!(tree.active_overlays().len(), 1, "the menu survives");
8318        assert_eq!(taps.get(), 0);
8319        tree.assert_no_leaked_pointer_state();
8320    }
8321
8322    /// A finger working *inside* the menu is an interaction, and a second one
8323    /// landing on the page is not a reason to take it away mid-flight.
8324    #[test]
8325    fn a_second_contact_cannot_dismiss_what_the_first_is_manipulating() {
8326        let (mut tree, _page, _taps) = page_with_a_menu();
8327        let first = contact_id(5);
8328        let second = contact_id(6);
8329
8330        tree.dispatch_pointer(touch(first, PointerPhase::Down, inside_menu()));
8331        assert!(
8332            tree.pointers
8333                .get(first)
8334                .is_some_and(|e| e.sequence.is_some()),
8335            "the first contact holds a live press inside the menu"
8336        );
8337
8338        tree.dispatch_pointer(touch(second, PointerPhase::Down, outside()));
8339        tree.dispatch_pointer(touch(second, PointerPhase::Up, outside()));
8340        assert_eq!(
8341            tree.active_overlays().len(),
8342            1,
8343            "the menu the first finger is holding must not close under it"
8344        );
8345
8346        // Once the first contact's press is over there is nothing left to
8347        // revoke, so it protects nothing and the same tap closes the menu.
8348        tree.dispatch_pointer(touch(first, PointerPhase::Up, inside_menu()));
8349        let third = contact_id(7);
8350        tree.dispatch_pointer(touch(third, PointerPhase::Down, outside()));
8351        tree.dispatch_pointer(touch(third, PointerPhase::Up, outside()));
8352        assert!(tree.active_overlays().is_empty());
8353        tree.assert_no_leaked_pointer_state();
8354    }
8355
8356    /// A contact that is merely *holding an arm* has no sequence and no
8357    /// capture, so `press_is_revocable` says it has no press — and it must not
8358    /// block a second contact's dismissal the way a real press does.
8359    #[test]
8360    fn an_arm_is_not_itself_a_press_that_blocks_another_contact() {
8361        let (mut tree, _page, _taps) = page_with_a_menu();
8362        let first = contact_id(8);
8363        let second = contact_id(9);
8364
8365        tree.dispatch_pointer(touch(first, PointerPhase::Down, outside()));
8366        assert!(
8367            tree.overlay_manager().has_armed_dismiss(first),
8368            "the first contact armed"
8369        );
8370        // The first contact is live but holds nothing revocable.
8371        assert!(tree.busy_press_points(second).is_empty());
8372
8373        tree.dispatch_pointer(touch(second, PointerPhase::Down, outside()));
8374        assert!(tree.overlay_manager().has_armed_dismiss(second));
8375        tree.dispatch_pointer(touch(second, PointerPhase::Up, outside()));
8376        assert!(tree.active_overlays().is_empty());
8377
8378        // The first contact's arm now names an overlay that is gone; its own
8379        // release must be a quiet no-op rather than a panic.
8380        tree.dispatch_pointer(touch(first, PointerPhase::Up, outside()));
8381        tree.assert_no_leaked_pointer_state();
8382    }
8383
8384    /// A tap *inside* the menu is not an outside press, so nothing is armed and
8385    /// the menu's own content handles the press exactly as before.
8386    #[test]
8387    fn a_touch_inside_the_menu_arms_nothing() {
8388        let (mut tree, _page, _taps) = page_with_a_menu();
8389        let finger = contact_id(10);
8390        tree.dispatch_pointer(touch(finger, PointerPhase::Down, inside_menu()));
8391        assert!(!tree.overlay_manager().has_armed_dismiss(finger));
8392        tree.dispatch_pointer(touch(finger, PointerPhase::Up, inside_menu()));
8393        assert_eq!(tree.active_overlays().len(), 1);
8394        tree.assert_no_leaked_pointer_state();
8395    }
8396
8397    /// A contact whose press was never opened — it hit nothing, or its `Down`
8398    /// was suppressed by an arm — still ceases to exist when the platform
8399    /// revokes it.
8400    ///
8401    /// The cancel funnel returns early for a pointer with nothing revocable and
8402    /// so never reaches the step that drops the table entry. Before the release
8403    /// dismissal that shape was rare (a press on bare background); the arm makes
8404    /// it the ordinary case, so the ingress door applies the same
8405    /// contact-ceases-to-exist rule it applies to an `Up`.
8406    #[test]
8407    fn a_contact_with_no_press_still_ends_when_the_platform_revokes_it() {
8408        let mut tree = WidgetTree::new();
8409        tree.layout(SizeProposal::exact(800.0, 600.0));
8410        let finger = contact_id(99);
8411        tree.dispatch_pointer(touch(finger, PointerPhase::Down, outside()));
8412        tree.dispatch_pointer(touch(finger, PointerPhase::Cancel, outside()));
8413        tree.assert_no_leaked_pointer_state();
8414    }
8415
8416    // -----------------------------------------------------------------
8417    // The mouse, unchanged
8418    // -----------------------------------------------------------------
8419
8420    /// The press dismisses and falls through, exactly as before: one click both
8421    /// closes the menu and actuates the control beneath.
8422    #[test]
8423    fn a_mouse_click_outside_a_menu_dismisses_on_the_press_and_falls_through() {
8424        let (mut tree, _page, taps) = page_with_a_menu();
8425        tree.dispatch_event(WidgetEvent::pointer_down(
8426            outside(),
8427            PointerButton::Primary,
8428            Modifiers::NONE,
8429        ));
8430        assert!(
8431            tree.active_overlays().is_empty(),
8432            "the mouse still dismisses on the press"
8433        );
8434        assert!(
8435            tree.pointers
8436                .get(PointerId::MOUSE)
8437                .is_some_and(|e| e.sequence.is_some()),
8438            "and the press still reaches the page beneath"
8439        );
8440        tree.dispatch_event(WidgetEvent::pointer_up(
8441            outside(),
8442            PointerButton::Primary,
8443            Modifiers::NONE,
8444        ));
8445        assert_eq!(taps.get(), 1, "the control beneath activated");
8446        assert!(
8447            !tree.overlay_manager().has_armed_dismiss(PointerId::MOUSE),
8448            "a mouse never arms"
8449        );
8450        tree.assert_no_leaked_pointer_state();
8451    }
8452
8453    // -----------------------------------------------------------------
8454    // Contact avoidance
8455    // -----------------------------------------------------------------
8456
8457    /// A context menu raised by a finger keeps clear of the contact patch; the
8458    /// same menu raised by a mouse lands on the pixel, as it always has.
8459    #[test]
8460    fn a_context_menu_avoids_the_contact_that_opened_it() {
8461        fn menu_bounds(pointer: PointerInfo, at: Point) -> Rect {
8462            let mut tree = WidgetTree::new();
8463            let page = tree.add(
8464                FillWidget::new()
8465                    .focusable()
8466                    .context_menu(|_p, _ctx| Some(Box::new(Panel(Size::new(200.0, 160.0))))),
8467            );
8468            let _ = page;
8469            tree.layout(SizeProposal::exact(800.0, 600.0));
8470            tree.dispatch_pointer(PointerSample {
8471                pointer,
8472                phase: PointerPhase::Down,
8473                position: at,
8474                button: Some(PointerButton::Secondary),
8475                modifiers: Modifiers::NONE,
8476                coalesced: Vec::new(),
8477            });
8478            tree.layout(SizeProposal::exact(800.0, 600.0));
8479            let id = *tree
8480                .active_overlays()
8481                .first()
8482                .expect("the context menu opened");
8483            tree.overlay_manager().bounds_for(id).expect("bounds")
8484        }
8485
8486        let at = Point::new(400.0, 300.0);
8487        let finger = menu_bounds(PointerInfo::touch(contact_id(11), EventTime::ZERO), at);
8488        let contact = crate::overlay::rect_centred_on(at, crate::overlay::ASSUMED_CONTACT_PATCH);
8489        assert!(
8490            finger.x < contact.x && finger.right() <= contact.x,
8491            "a touch menu clears the contact patch: {finger:?} vs {contact:?}"
8492        );
8493
8494        let mouse = menu_bounds(PointerInfo::mouse(EventTime::ZERO), at);
8495        assert_eq!(
8496            (mouse.x, mouse.y),
8497            (at.x, at.y),
8498            "a mouse menu still opens with its corner on the pointer"
8499        );
8500    }
8501}
8502
8503/// An overlay is chosen by its **bounds**, and what happens when its content
8504/// then claims nothing at the point.
8505///
8506/// Two answers, and the flag on the content root is what picks between them.
8507/// An ordinary overlay ends the search inside itself — that is what keeps the
8508/// miss-only slop pass confined to the one layer the exact pass entered, so a
8509/// near-miss on a menu row can never be re-attributed to a control on the page
8510/// behind the menu. An overlay whose content root declares `event_pass_through`
8511/// is the exception: the flag already means "what I did not claim belongs to
8512/// whatever is behind me", and an overlay root is not an exception to it.
8513///
8514/// This is not a hypothetical. The touch text affordances were specified as a
8515/// viewport-sized pass-through layer, and under the first answer applied to
8516/// both, mounting one made the editor beneath stop taking presses entirely —
8517/// measured, as `hit_test` returning `None` at a point inside the field.
8518#[cfg(test)]
8519mod pass_through_overlay_tests {
8520    use teksilo_canvas::{Point, Rect, SizeProposal};
8521
8522    use crate::WidgetId;
8523    use crate::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
8524    use crate::test_widgets::FillWidget;
8525    use crate::widget::{LayoutContext, LayoutResponse, Widget};
8526    use crate::widget_builder::WidgetBuilder;
8527    use crate::widget_tree::WidgetTree;
8528
8529    /// The affordance layer's shape: fills whatever it is given, and puts its
8530    /// one child — a selection handle — on a fixed rectangle inside it.
8531    #[derive(Debug)]
8532    struct HandleLayer {
8533        handle: WidgetId,
8534        at: Rect,
8535    }
8536
8537    impl Widget for HandleLayer {
8538        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
8539            proposal.resolve(0.0, 0.0).into()
8540        }
8541
8542        fn place_children(
8543            &self,
8544            _bounds: Rect,
8545            _proposal: SizeProposal,
8546            children: &mut [crate::widget::WidgetPlacement],
8547            _ctx: &LayoutContext,
8548        ) {
8549            if let Some(child) = children.get_mut(0) {
8550                child.origin = self.at.origin();
8551                child.size = self.at.size();
8552            }
8553        }
8554
8555        fn children(&self) -> Vec<WidgetId> {
8556            vec![self.handle]
8557        }
8558    }
8559
8560    const HANDLE: Rect = Rect {
8561        x: 10.0,
8562        y: 10.0,
8563        width: 20.0,
8564        height: 20.0,
8565    };
8566
8567    /// Which flag the overlay's content root carries.
8568    #[derive(Clone, Copy, PartialEq)]
8569    enum RootFlag {
8570        /// The affordance layer's: not a target itself, children are.
8571        PassThrough,
8572        /// Neither the root nor its children are targets. Stronger, and
8573        /// deliberately **not** what the fall-through is gated on.
8574        HitTransparent,
8575    }
8576
8577    /// A field under a viewport-sized overlay carrying one handle.
8578    ///
8579    /// Both flags make the subtree answer `None` for a point no handle covers,
8580    /// which is the state the fall-through decides — so the two arms differ in
8581    /// nothing but the flag the router reads.
8582    fn field_under_a_layer(flag: RootFlag) -> (WidgetTree, WidgetId, WidgetId) {
8583        let mut tree = WidgetTree::new();
8584        let field = tree.add(FillWidget::new());
8585        let handle = tree.add(FillWidget::new());
8586        let layer = HandleLayer { handle, at: HANDLE };
8587        let layer = match flag {
8588            RootFlag::PassThrough => tree.add(layer.event_pass_through(true)),
8589            RootFlag::HitTransparent => tree.add(layer.hit_transparent(true)),
8590        };
8591        tree.show_overlay(OverlayRequest {
8592            content_id: layer,
8593            anchor: field,
8594            placement: OverlayPlacement::FullViewport,
8595            dismiss: DismissBehavior::Manual,
8596            layer: OverlayLayer::InTree,
8597            parent_overlay: None,
8598            on_dismiss: None,
8599            fade_duration: None,
8600        });
8601        tree.layout(SizeProposal::exact(400.0, 200.0));
8602        assert_eq!(tree.active_overlays().len(), 1, "the layer is up");
8603        (tree, field, handle)
8604    }
8605
8606    /// The door: the surface under a pass-through layer goes on taking presses.
8607    #[test]
8608    fn a_pass_through_layer_hands_back_what_it_did_not_claim() {
8609        let (tree, field, handle) = field_under_a_layer(RootFlag::PassThrough);
8610
8611        assert_eq!(
8612            tree.hit_test(Point::new(15.0, 15.0)),
8613            Some(handle),
8614            "the layer must still win the point its own child covers"
8615        );
8616        assert_eq!(
8617            tree.hit_test(Point::new(200.0, 100.0)),
8618            Some(field),
8619            "a press the layer did not claim must reach the surface beneath it"
8620        );
8621    }
8622
8623    /// `event_pass_through` removes a node from **hit-testing**, not from the
8624    /// **bubble path** of a descendant that was hit.
8625    ///
8626    /// The distinction is what lets a host mount its affordances at the full
8627    /// viewport and still answer the one press neither the layer nor the surface
8628    /// beneath can: the pass-through root is skipped when a point belongs to
8629    /// nobody in its subtree, and is still told about a press that landed on one
8630    /// of its children. The single-line text stack's `AffordanceHost` is exactly
8631    /// this — a cursor's click on a selection handle, which the handle refuses and
8632    /// the editor never sees.
8633    #[test]
8634    fn a_pass_through_root_still_hears_a_press_that_landed_on_its_child() {
8635        use std::cell::Cell;
8636        use std::rc::Rc;
8637
8638        let seen = Rc::new(Cell::new(0u32));
8639        let counter = seen.clone();
8640        let mut tree = WidgetTree::new();
8641        let field = tree.add(FillWidget::new());
8642        let handle = tree.add(FillWidget::new().on_tap(|_e, _c| {}));
8643        let layer = tree.add(
8644            HandleLayer { handle, at: HANDLE }
8645                .event_pass_through(true)
8646                .on_pointer_event(move |_event, _ctx| {
8647                    counter.set(counter.get() + 1);
8648                    crate::event::EventResponse::Ignored
8649                }),
8650        );
8651        tree.show_overlay(OverlayRequest {
8652            content_id: layer,
8653            anchor: field,
8654            placement: OverlayPlacement::FullViewport,
8655            dismiss: DismissBehavior::Manual,
8656            layer: OverlayLayer::InTree,
8657            parent_overlay: None,
8658            on_dismiss: None,
8659            fade_duration: None,
8660        });
8661        tree.layout(SizeProposal::exact(400.0, 200.0));
8662
8663        tree.pointer_down_button(Point::new(15.0, 15.0), crate::event::PointerButton::Primary);
8664        assert!(
8665            seen.get() > 0,
8666            "a pass-through root heard nothing about a press on its own child"
8667        );
8668
8669        let after_child = seen.get();
8670        tree.pointer_up_button(Point::new(15.0, 15.0), crate::event::PointerButton::Primary);
8671        // …and a press it did not contain a target for never arrives, because it
8672        // was never the hit.
8673        tree.pointer_down_button(
8674            Point::new(200.0, 100.0),
8675            crate::event::PointerButton::Primary,
8676        );
8677        assert_eq!(
8678            seen.get(),
8679            after_child + 1,
8680            "the press-and-lift on the child accounts for the count, and the \
8681             press on the surface beneath added nothing"
8682        );
8683    }
8684
8685    /// …and the widening stops at that one flag.
8686    ///
8687    /// `hit_transparent` is the discriminating case, and the only one available:
8688    /// it is the other way for an overlay's content to claim nothing at a point,
8689    /// so it is the fixture in which the gate — rather than the subtree's own
8690    /// answer — is what decides. A gate that read "the subtree claimed nothing"
8691    /// alone would fall through here too, and with it past every overlay whose
8692    /// content happens to miss, which is what confines the miss-only slop pass to
8693    /// the layer the exact pass entered.
8694    ///
8695    /// The exclusion is deliberate rather than an oversight: the one overlay that
8696    /// must be seen past regardless is the drag preview, and the hit-test already
8697    /// takes an explicit `exclude_overlay` for it.
8698    #[test]
8699    fn a_hit_transparent_root_does_not_get_the_fall_through() {
8700        let (tree, field, _handle) = field_under_a_layer(RootFlag::HitTransparent);
8701
8702        // Nothing in the overlay is a target, not even the handle.
8703        assert_eq!(
8704            tree.hit_test(Point::new(15.0, 15.0)),
8705            None,
8706            "hit_transparent must exclude the subtree, or this fixture is not \
8707             testing the gate"
8708        );
8709        assert_eq!(
8710            tree.hit_test(Point::new(200.0, 100.0)),
8711            None,
8712            "the fall-through reached past an overlay that did not ask for it"
8713        );
8714        let _ = field;
8715    }
8716}