Skip to main content

teksilo_core/widget_tree/
pointer_router.rs

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