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