teksilo_core/widget_tree/pointer_cancel.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The cancel funnel: one queued path by which a pointer interaction is taken
5//! away, and the enumerated set of things allowed to take it.
6//!
7//! # Why a funnel
8//!
9//! Before this module the framework revoked interactions by *forgetting* them.
10//! A window that lost focus dropped every capture in the table, and a parked
11//! subtree simply stopped being hit-testable: the widget mid-drag was
12//! never told, so it kept its own half of the interaction — a latched
13//! selection, a grabbed divider, a highlighted drop target — with no event
14//! coming that could ever clear it. `PointerUp` cannot stand in, because "the
15//! user finished" and "the system took it away" call for opposite responses:
16//! an `Up` on a drag *drops*, and dropping a file because the window lost
17//! focus is a data-loss bug.
18//!
19//! So every revocation now goes through [`WidgetTree::cancel_pointer`], which
20//! tears the interaction down in one order and delivers exactly one
21//! [`WidgetEvent::PointerCancel`] carrying the [`CancelReason`] that names who
22//! took it.
23//!
24//! # Two granularities, and they are not the same act
25//!
26//! * [`cancel_pointer`](WidgetTree::cancel_pointer) revokes the **whole
27//! pointer**. The sequence dies, the capture is given back, the table entry
28//! goes (for a contact), and nothing more will be delivered for that
29//! pointer.
30//! * [`revoke_sequence_member`](WidgetTree::revoke_sequence_member) revokes
31//! **one competitor** of a sequence that is still alive. Its recognizers are
32//! cancelled and it is told so, and the pointer carries on — its winner
33//! keeps receiving moves and will still get its `Up`. This is what a peer
34//! claim does: exactly one member wins, and every other member is told it
35//! lost. Revoking a member is emphatically not cancelling the pointer, and
36//! confusing the two would make an ancestor's losing drag kill the tap that
37//! beat it.
38//!
39//! # Queued, always
40//!
41//! A cancel raised from inside a handler must not unwind the sample that
42//! handler is standing on. It therefore rides the same
43//! [`pending_dispatch`](WidgetTree::pending_dispatch) queue P07 built for
44//! nested dispatch, as a [`QueuedDispatch::Cancel`](super::pointer_router::QueuedDispatch::Cancel) entry rather than a second
45//! queue of its own: one queue means one order, and the cancel a handler
46//! raised lands after the event that provoked it rather than in the middle of
47//! it. At depth zero the queue is drained immediately, so a caller outside a
48//! dispatch — `set_window_active`, an overlay teardown — sees the cancel take
49//! effect before its own call returns.
50//!
51//! Reference: `docs/touch-and-pen.md` §3.3.
52
53use super::*;
54use crate::pointer::{CancelReason, PointerId};
55
56impl WidgetTree {
57 // -----------------------------------------------------------------
58 // The funnel
59 // -----------------------------------------------------------------
60
61 /// Revoke `pointer`'s interaction, for `reason`.
62 ///
63 /// **Always queued** behind the sample currently being dispatched, and a
64 /// **no-op if that interaction has already finished** by the time the
65 /// queue drains — a cancel raised from a handler must not fire against a
66 /// press the very same sample completed. "Finished" means the pointer is
67 /// no longer live, or holds no capture and has no sequence, or its
68 /// sequence is already inside its own terminal dispatch; all three say
69 /// there is nothing left to revoke.
70 ///
71 /// The teardown runs in one order, and the order is load-bearing: every
72 /// competitor's recognizer state goes first (so nothing can recognize on
73 /// the way out), then the arbitration, then the capture, then the drag
74 /// session, then the table entry, and the event is delivered last — to a
75 /// tree that has already forgotten the interaction, so a handler that
76 /// reacts by capturing or dragging starts from a clean slate rather than
77 /// racing the teardown.
78 ///
79 /// Two teardown steps named in the design are absent because their subject
80 /// does not exist yet: the framework press signal (P11) and the fling
81 /// driver (P13/P21) each clear at the marked point below.
82 pub fn cancel_pointer(
83 &mut self,
84 pointer: PointerId,
85 reason: CancelReason,
86 ops: &mut dyn crate::window::WindowOps,
87 ) {
88 self.cancel_pointer_to(pointer, reason, None, ops);
89 }
90
91 /// [`cancel_pointer`](Self::cancel_pointer), addressed to a widget the
92 /// caller names rather than to whoever the table says holds the pointer.
93 ///
94 /// For a producer whose own teardown has already given the capture back
95 /// before it raises the cancel — the OS-drag escalation hands the pointer
96 /// to the platform first — so the funnel would otherwise have nobody left
97 /// to tell. The named widget is used only while it is still there; a
98 /// recipient destroyed in the meantime falls back to the ordinary chain.
99 pub fn cancel_pointer_to(
100 &mut self,
101 pointer: PointerId,
102 reason: CancelReason,
103 recipient: Option<WidgetId>,
104 ops: &mut dyn crate::window::WindowOps,
105 ) {
106 self.enqueue_cancel(pointer, reason, recipient);
107 if self.dispatch_depth == 0 {
108 self.drain_pending_dispatch(ops);
109 }
110 }
111
112 /// Revoke every live pointer, for `reason`. The window went away under
113 /// them, a modal opened over them, the platform took the seat.
114 pub fn cancel_all_pointers(
115 &mut self,
116 reason: CancelReason,
117 ops: &mut dyn crate::window::WindowOps,
118 ) {
119 let live: Vec<PointerId> = self.pointers.iter().map(|e| e.info.id).collect();
120 for id in live {
121 self.enqueue_cancel(id, reason, None);
122 }
123 if self.dispatch_depth == 0 {
124 self.drain_pending_dispatch(ops);
125 }
126 }
127
128 /// Revoke every pointer whose interaction is **anchored inside** `root`.
129 ///
130 /// "Anchored inside" means the pointer's captor is `root` or a descendant
131 /// of it: that widget is the one about to stop existing, and the pointer
132 /// it holds would otherwise be stranded on it. A pointer merely passing
133 /// over the subtree is not anchored in it and is left alone.
134 ///
135 /// A pointer whose press is no longer revocable is skipped, which is what
136 /// makes the named exemption work: tapping a menu item whose own handler
137 /// closes its menu must complete the tap, not have it cancelled out from
138 /// under itself by the teardown it asked for.
139 pub fn cancel_pointers_in_subtree(
140 &mut self,
141 root: WidgetId,
142 reason: CancelReason,
143 ops: &mut dyn crate::window::WindowOps,
144 ) {
145 let anchored: Vec<PointerId> = self
146 .pointers
147 .iter()
148 .filter(|entry| {
149 entry
150 .captured_by
151 .is_some_and(|captor| captor == root || self.is_descendant_of(captor, root))
152 })
153 .map(|entry| entry.info.id)
154 .collect();
155 let mut queued = false;
156 for id in anchored {
157 if !self.press_is_revocable(id) {
158 crate::trace_input!(
159 Gestures,
160 "{id:?} is inside the parked subtree but its press has already ended: not cancelled"
161 );
162 continue;
163 }
164 self.enqueue_cancel(id, reason, None);
165 queued = true;
166 }
167 if queued && self.dispatch_depth == 0 {
168 self.drain_pending_dispatch(ops);
169 }
170 }
171
172 /// Park `root`'s subtree and cancel every pointer it was holding.
173 ///
174 /// The tree-level door onto [`WidgetArena::set_dormant`](crate::arena::WidgetArena::set_dormant):
175 /// parking is invisible to hit-testing and to dispatch, so a widget parked
176 /// mid-interaction would keep whatever the press latched and never receive
177 /// another event. Every caller that parks a subtree which could plausibly
178 /// contain a live pointer goes through here; the audit of the ones that do
179 /// not is in `docs/touch-and-pen.md` §3.3.
180 pub(crate) fn park_subtree(&mut self, root: WidgetId) {
181 let mut noop = crate::window::NoopWindowOps;
182 self.park_subtree_with_ops(root, &mut noop);
183 }
184
185 /// [`park_subtree`](Self::park_subtree) with the caller's
186 /// [`WindowOps`](crate::window::WindowOps).
187 ///
188 /// The cancel is raised **before** the subtree is parked, so the widget is
189 /// still active when it is told to let go — a `PointerCancel` delivered to
190 /// a node the dispatcher has just made dormant would be dropped, which is
191 /// precisely the silent teardown this replaces.
192 pub(crate) fn park_subtree_with_ops(
193 &mut self,
194 root: WidgetId,
195 ops: &mut dyn crate::window::WindowOps,
196 ) {
197 self.cancel_pointers_in_subtree(root, CancelReason::SubtreeParked, ops);
198 let _parked = self.arena.set_dormant(root);
199 }
200
201 /// Revoke one **member** of a live sequence, leaving the sequence, the
202 /// capture and the pointer itself alone.
203 ///
204 /// The member's recognizers are cancelled for this contact and the member
205 /// widget is told with a [`WidgetEvent::PointerCancel`]. Unlike
206 /// [`cancel_pointer`](Self::cancel_pointer) this is delivered **now**
207 /// rather than queued: it is raised from the arbitration itself, which
208 /// already runs at a point where the sequence is consistent, and a member
209 /// that lost must stop recognizing before the same sample reaches it
210 /// through the ordinary bubble.
211 ///
212 /// A member whose node has already been destroyed gets the recognizer
213 /// teardown and no event — there is nothing left to deliver to. A merely
214 /// *dormant* one still exists and is told directly, without a bubble,
215 /// exactly as a dormant node is told about a lost focus.
216 pub(super) fn revoke_sequence_member(
217 &mut self,
218 pointer: PointerId,
219 member: WidgetId,
220 reason: CancelReason,
221 ops: &mut dyn crate::window::WindowOps,
222 ) {
223 crate::trace_input!(
224 Gestures,
225 "member {member:?} of {pointer:?} revoked: {reason:?}"
226 );
227 self.cancel_member_arena(member, pointer);
228 if self.arena.get(member).is_none() {
229 return;
230 }
231 // The entry is still there on this path — a losing member's revoke does
232 // not end the pointer, only its own recognizers — so the event names the
233 // real device. A pointer that has gone anyway has no press left to
234 // announce, and the recognizer teardown above is the whole of what it
235 // needed.
236 let Some((info, at)) = self.pointers.get(pointer).map(|e| (e.info, e.position)) else {
237 return;
238 };
239 let event = Self::pointer_cancel_event(info, at, reason);
240 self.dispatch_to_widget_direct(member, &event, ops);
241 }
242
243 // -----------------------------------------------------------------
244 // Queue plumbing
245 // -----------------------------------------------------------------
246
247 /// Put a cancel on the shared dispatch queue, unless one for the same
248 /// pointer is already waiting there.
249 ///
250 /// The de-duplication is what keeps "exactly one cancel" true when two
251 /// producers fire on the same sample — an overlay dismissal that also
252 /// parks the subtree it lived in, say. The first reason wins, because it
253 /// is the one that describes what actually happened.
254 fn enqueue_cancel(
255 &mut self,
256 pointer: PointerId,
257 reason: CancelReason,
258 recipient: Option<WidgetId>,
259 ) {
260 if self.pending_dispatch.iter().any(|queued| {
261 matches!(queued, pointer_router::QueuedDispatch::Cancel { pointer: p, .. } if *p == pointer)
262 }) {
263 return;
264 }
265 crate::trace_input!(Samples, "cancel queued for {pointer:?}: {reason:?}");
266 // A revoked contact holds nothing, so its tree-owned hold is over. Done
267 // at *enqueue* rather than at dispatch: the queue can be drained a
268 // frame later, and a route that fires in between would be answering a
269 // press the system has already taken away.
270 self.cancel_touch_route(pointer);
271 self.pending_dispatch
272 .push_back(pointer_router::QueuedDispatch::Cancel {
273 pointer,
274 reason,
275 recipient,
276 });
277 }
278
279 /// Run one queued cancel, at dispatch depth zero.
280 pub(super) fn run_one_cancel(
281 &mut self,
282 pointer: PointerId,
283 reason: CancelReason,
284 recipient: Option<WidgetId>,
285 ops: &mut dyn crate::window::WindowOps,
286 ) {
287 if !self.press_is_revocable(pointer) {
288 crate::trace_input!(
289 Samples,
290 "cancel for {pointer:?} ({reason:?}) dropped: nothing left to revoke"
291 );
292 return;
293 }
294 crate::trace_input!(Samples, "cancelling {pointer:?}: {reason:?}");
295
296 // Read the pointer's identity ONCE, here, while its table entry is
297 // certainly present — `press_is_revocable` above answered false for an
298 // absent entry, so this cannot fail. Both the snapshot below and the
299 // `PointerCancel` delivered at the end of the teardown are built from
300 // this one read, which is what stops them disagreeing: step 6 of the
301 // teardown *removes* a contact's entry, so anything reading the table
302 // after it gets a fabricated answer.
303 let Some((info, at)) = self.pointers.get(pointer).map(|e| (e.info, e.position)) else {
304 return;
305 };
306
307 // Serve the teardown as *this* pointer's sample: every helper below
308 // that reads "the pointer being dispatched" — the recognizer context,
309 // the drag's capture release, `EventContext::pointer()` inside the
310 // handler — must answer with the pointer being cancelled and not with
311 // whatever the outer dispatch was serving. Restored on the way out, so
312 // a cancel drained after an outer sample leaves that sample's snapshot
313 // as it found it.
314 let snapshot = crate::pointer::InputSnapshot {
315 pointer: info,
316 position: Some(at),
317 ..Default::default()
318 };
319 let previous_input = std::mem::replace(&mut self.current_input, snapshot);
320 // Anything the cancel's own handler dispatches is queued behind it,
321 // exactly as it would be from inside an ordinary sample.
322 self.dispatch_depth += 1;
323 self.tear_down_cancelled_pointer(pointer, info, at, reason, recipient, ops);
324 self.dispatch_depth -= 1;
325 self.current_input = previous_input;
326 }
327
328 /// The ordered teardown itself, with `current_input` already serving
329 /// `pointer`. See [`cancel_pointer`](Self::cancel_pointer) for why the
330 /// order is what it is.
331 ///
332 /// `info` and `at` are the pointer's identity and last position, read by the
333 /// caller **before** any of this ran: step 6 removes a contact's table
334 /// entry, and the event delivered in step 7 has to name the pointer that
335 /// went away.
336 fn tear_down_cancelled_pointer(
337 &mut self,
338 pointer: PointerId,
339 info: crate::pointer::PointerInfo,
340 at: Point,
341 reason: CancelReason,
342 recipient: Option<WidgetId>,
343 ops: &mut dyn crate::window::WindowOps,
344 ) {
345 // 1. Every competitor's recognizer state, before anything else can
346 // recognize on the way out.
347 let members: Vec<WidgetId> = self
348 .pointers
349 .get(pointer)
350 .and_then(|e| e.sequence.as_ref())
351 .map(|s| s.members().iter().map(|m| m.id).collect())
352 .unwrap_or_default();
353 for member in members {
354 self.cancel_member_arena(member, pointer);
355 }
356 // The captor's own arena is not necessarily a member (a plain tap owner
357 // never enrols), and it is the node most likely to be holding
358 // recognizer state for this contact.
359 let captor = self.pointers.get(pointer).and_then(|e| e.captured_by);
360 if let Some(captor) = captor {
361 self.cancel_member_arena(captor, pointer);
362 }
363 // …and anything else that saw the press but is neither: an ancestor
364 // that took the capture off the node whose arena is still following the
365 // contact. See `release_arenas_following`.
366 self.release_arenas_following(pointer);
367
368 // 2. The arbitration. Whoever it decided for is about to be told the
369 // press it won has been taken away.
370 let recipient = recipient
371 .filter(|id| self.arena.get(*id).is_some())
372 .or_else(|| self.cancel_recipient(pointer));
373 if let Some(entry) = self.pointers.get_mut(pointer) {
374 entry.sequence = None;
375 }
376
377 // 3. The capture.
378 self.set_pointer_capture(pointer, None);
379
380 // 4. The drag session this pointer was driving, if it was driving one.
381 // `cancel_active_drag` is the same teardown Escape runs: the current
382 // drop target is told to clear its feedback and the source is told
383 // the drag ended as `Cancelled`, so nothing is left highlighted and
384 // no payload is silently dropped where the pointer happened to be.
385 if captor.is_some() && self.pointer_owns_active_drag_via(captor) {
386 self.cancel_active_drag(ops);
387 }
388
389 // 5. The touch-motion layer. The pan session goes without delivering a
390 // release — there is no velocity to hand on from an interaction that
391 // was taken away — and any coast the claimant chain is running stops
392 // with it. The palm watch is dropped rather than judged: a cancel is
393 // not a release, so there is nothing to be a palm *of*. The pinch is
394 // told, because a handler that has been zooming since `PinchStarted`
395 // must be given its `Cancelled` to unwind on. The framework press
396 // visual goes with them: a press that was taken away must not stay
397 // painted, and the node is never sent an `Up` to clear it from.
398 let pan_chain: Vec<WidgetId> = self.pan_chain_ids(pointer);
399 self.abandon_pan(pointer);
400 for id in pan_chain {
401 self.stop_fling(id);
402 }
403 self.forget_palm_watch(pointer);
404 self.end_press(pointer);
405 self.cancel_pinch(pointer, reason, ops);
406
407 // 6. The table entry, for a pointer that ceases to exist when it is
408 // taken away. A hovering-capable pointer does not: a mouse whose
409 // press was cancelled is still there, still hovering, and its entry
410 // is what every singular accessor reads — the same rule
411 // `dispatch_pointer_with_ops` applies to an `Up`.
412 let hovers = self
413 .pointers
414 .get(pointer)
415 .is_some_and(|e| e.info.kind.hovers());
416 if !hovers {
417 self.pointers.end(pointer);
418 } else if let Some(entry) = self.pointers.get_mut(pointer) {
419 // …and *hovering* is the whole of what it is now doing. The entry's
420 // button mask is what `PointerEntry::is_contacting` reads for a
421 // hovering-capable pointer, so leaving the press's mask on it
422 // leaves the pointer reading as held by an interaction the
423 // framework has just finished forgetting — for ever, since a Cancel
424 // sample never reaches `PointerTable::admit` and the next real
425 // sample is the earliest correction. The platform layer already
426 // builds its pen proximity-leave with an empty mask, intending
427 // exactly this; the intent was being discarded.
428 entry.info.buttons = crate::event::ButtonMask::NONE;
429 }
430
431 // A cancel is terminal: an `Up` that arrives for this pointer
432 // afterwards — a platform that sends both, a test that sends one by
433 // hand — must not complete the interaction that was taken away.
434 if !self.cancelled_pointers.contains(&pointer) {
435 self.cancelled_pointers.push(pointer);
436 }
437
438 // 7. The event, last, to a tree that has already let go — built from the
439 // identity the caller captured, since step 6 may have taken the
440 // entry away.
441 if let Some(recipient) = recipient {
442 let event = Self::pointer_cancel_event(info, at, reason);
443 self.dispatch_to_widget_direct(recipient, &event, ops);
444 }
445 }
446
447 // -----------------------------------------------------------------
448 // Predicates the producers and the funnel share
449 // -----------------------------------------------------------------
450
451 /// Whether `pointer` still has an interaction that can be taken away.
452 ///
453 /// Three ways the answer is no, and all three mean the same thing — there
454 /// is nothing left for a `PointerCancel` to revoke:
455 ///
456 /// * the pointer is not live at all (a contact that lifted);
457 /// * it holds no capture and has no sequence, so it is merely hovering;
458 /// * its sequence is inside its own terminal dispatch
459 /// ([`PointerSequence::is_terminating`](crate::gesture::PointerSequence::is_terminating)),
460 /// so the press has completed and only its epilogue is still running.
461 ///
462 /// The second case is what carries the design's named exemption. By the
463 /// time a menu item's `on_tap` runs, the release sweep has already closed
464 /// that pointer's sequence (`end_sequence` clears it before the `Up` is
465 /// delivered), so a cancel the handler's own overlay teardown queues finds
466 /// no press to revoke and the tap completes. The third case covers the
467 /// same window for any future producer that fires while a sequence is
468 /// installed but already terminating.
469 pub(super) fn press_is_revocable(&self, pointer: PointerId) -> bool {
470 let Some(entry) = self.pointers.get(pointer) else {
471 return false;
472 };
473 match entry.sequence.as_ref() {
474 Some(sequence) => !sequence.is_terminating(),
475 None => entry.captured_by.is_some(),
476 }
477 }
478
479 /// Who receives the `PointerCancel`: the widget holding the capture, and
480 /// failing that the last widget that accepted an event from this pointer.
481 ///
482 /// The first of the two that still *exists* wins. A captor destroyed by
483 /// the very rebuild that provoked the cancel cannot be told anything, and
484 /// falling through to the last acceptor is how the widget that was
485 /// actually interacting still hears about it.
486 fn cancel_recipient(&self, pointer: PointerId) -> Option<WidgetId> {
487 let entry = self.pointers.get(pointer)?;
488 [entry.captured_by, entry.last_accepted]
489 .into_iter()
490 .flatten()
491 .find(|id| self.arena.get(*id).is_some())
492 }
493
494 /// The `PointerCancel` announcing `pointer`, at the position it last
495 /// reported.
496 ///
497 /// Takes the identity rather than looking it up, and that is the point: the
498 /// teardown **removes a contact's table entry** before the event is
499 /// delivered (step 6, deliberately — a lifted or revoked contact stops
500 /// existing), so a builder that read the table here read the entry it had
501 /// just deleted and fell back to a fabricated mouse with no position. The
502 /// handler whose entire job is to know which pointer went away was told the
503 /// mouse cancelled a press only a finger had made. Every caller now reads
504 /// the entry while it is still there and hands the answer down, so the event
505 /// payload and `EventContext::pointer()` inside the handler cannot disagree
506 /// — which is how that defect stayed invisible, since the snapshot half was
507 /// always right.
508 fn pointer_cancel_event(
509 pointer: crate::pointer::PointerInfo,
510 window_position: Point,
511 reason: CancelReason,
512 ) -> WidgetEvent {
513 WidgetEvent::PointerCancel {
514 window_position: Some(window_position),
515 reason,
516 pointer,
517 }
518 }
519
520 /// Whether the in-flight drag belongs to the pointer whose capture is
521 /// `captor`.
522 ///
523 /// An internal drag captures the pointer it started from onto its own
524 /// source widget (`collect_from_ctx`'s drag-start arm), so holding that
525 /// capture *is* what it means to be driving the drag. An external (OS)
526 /// drag takes no capture and has no in-app source: it belongs to the
527 /// platform backend that began it, and no in-app pointer cancel may end
528 /// it.
529 fn pointer_owns_active_drag_via(&self, captor: Option<WidgetId>) -> bool {
530 self.active_drag
531 .as_ref()
532 .and_then(|drag| drag.source_widget)
533 .is_some_and(|source| captor == Some(source))
534 }
535}
536
537// -------------------------------------------------------------------------
538// P09: the cancel taxonomy — one funnel, an enumerated producer set
539// -------------------------------------------------------------------------
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use crate::event::{EventResponse, Modifiers, PointerButton, WidgetEvent};
545 use crate::pointer::{
546 BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase, PointerSample,
547 };
548 use crate::test_widgets::{FillWidget, StackWidget};
549 use crate::widget_builder::WidgetBuilder;
550 use std::cell::RefCell;
551 use std::rc::Rc;
552 use teksilo_canvas::{Point, SizeProposal};
553
554 /// Every cancel a test observed, in delivery order.
555 type Log = Rc<RefCell<Vec<(WidgetId, CancelReason)>>>;
556
557 fn log() -> Log {
558 Rc::new(RefCell::new(Vec::new()))
559 }
560
561 /// A widget that records the cancels it is told about.
562 fn recorder(log: &Log, id_slot: Rc<std::cell::Cell<Option<WidgetId>>>) -> impl Widget {
563 let log = log.clone();
564 FillWidget::new().on_pointer_cancel(move |_pointer, reason, _ctx| {
565 let id = id_slot.get().expect("the recorder's id was never recorded");
566 log.borrow_mut().push((id, reason));
567 })
568 }
569
570 fn press(tree: &mut WidgetTree, at: Point) {
571 tree.dispatch_event(WidgetEvent::pointer_down(
572 at,
573 PointerButton::Primary,
574 Modifiers::NONE,
575 ));
576 }
577
578 fn moved(tree: &mut WidgetTree, at: Point) {
579 tree.dispatch_event(WidgetEvent::pointer_move(at));
580 }
581
582 fn release(tree: &mut WidgetTree, at: Point) {
583 tree.dispatch_event(WidgetEvent::pointer_up(
584 at,
585 PointerButton::Primary,
586 Modifiers::NONE,
587 ));
588 }
589
590 /// A fresh contact: the platform mints a new id per press.
591 fn new_contact() -> PointerId {
592 use std::sync::atomic::{AtomicU64, Ordering};
593 static NEXT: AtomicU64 = AtomicU64::new(31_000);
594 PointerIdAllocator::global().begin(
595 BackendDeviceKey::DEFAULT,
596 NEXT.fetch_add(1, Ordering::Relaxed),
597 )
598 }
599
600 fn touch(id: PointerId, phase: PointerPhase, at: Point) -> PointerSample {
601 PointerSample {
602 pointer: PointerInfo::touch(id, EventTime::ZERO),
603 phase,
604 position: at,
605 button: None,
606 modifiers: Modifiers::NONE,
607 coalesced: Vec::new(),
608 }
609 }
610
611 /// A leaf that takes the pointer on press and drives itself from moves —
612 /// the splitter-handle / column-grip shape, and the simplest thing that
613 /// has an interaction a cancel can take away.
614 fn grip(log: &Log, id_slot: Rc<std::cell::Cell<Option<WidgetId>>>) -> impl Widget {
615 let log = log.clone();
616 let slot = id_slot.clone();
617 FillWidget::new()
618 .on_pointer_event(|event, ctx| {
619 if matches!(event, WidgetEvent::PointerDown { .. }) {
620 ctx.capture_pointer();
621 }
622 EventResponse::Ignored
623 })
624 .on_pointer_cancel(move |_pointer, reason, _ctx| {
625 let id = slot.get().expect("the grip's id was never recorded");
626 log.borrow_mut().push((id, reason));
627 })
628 }
629
630 /// Build a one-leaf tree whose leaf grips the pointer, and return it.
631 fn tree_with_grip(log: &Log) -> (WidgetTree, WidgetId) {
632 let slot = Rc::new(std::cell::Cell::new(None));
633 let mut tree = WidgetTree::new();
634 let id = tree.add(grip(log, slot.clone()));
635 slot.set(Some(id));
636 tree.layout(SizeProposal::exact(200.0, 100.0));
637 (tree, id)
638 }
639
640 // ---------------------------------------------------------------
641 // Producer: window deactivation
642 // ---------------------------------------------------------------
643
644 /// The producer that replaces the framework's oldest silent teardown.
645 /// Deactivating a window used to drop every capture without a word; now
646 /// the widget holding one is told, so it can let go of what the press
647 /// latched.
648 #[test]
649 fn deactivating_the_window_cancels_the_pointer_it_stranded() {
650 let log = log();
651 let (mut tree, id) = tree_with_grip(&log);
652
653 press(&mut tree, Point::new(20.0, 50.0));
654 assert_eq!(tree.captured_by(PointerId::MOUSE), Some(id));
655
656 tree.set_window_active(false);
657
658 assert_eq!(
659 *log.borrow(),
660 vec![(id, CancelReason::WindowDeactivated)],
661 "the widget that captured the pointer is told the window took it away"
662 );
663 assert_eq!(
664 tree.captured_by(PointerId::MOUSE),
665 None,
666 "and the capture is released, as it always was"
667 );
668 tree.assert_no_leaked_pointer_state();
669 }
670
671 /// A window deactivated with nothing going on cancels nothing: there is no
672 /// interaction to revoke, and firing a `PointerCancel` at a widget the
673 /// mouse merely rests over would be noise.
674 #[test]
675 fn deactivating_the_window_with_no_live_press_cancels_nothing() {
676 let log = log();
677 let (mut tree, _id) = tree_with_grip(&log);
678
679 moved(&mut tree, Point::new(20.0, 50.0));
680 tree.set_window_active(false);
681
682 assert!(log.borrow().is_empty());
683 tree.assert_no_leaked_pointer_state();
684 }
685
686 /// The platform itself revoked the contact — a `wl_touch.cancel`, a
687 /// `WM_POINTERCAPTURECHANGED`, a compositor grab. It reaches the funnel
688 /// directly from the sample door rather than being lowered onto an event,
689 /// because lowering would first *admit* the pointer the sample revokes.
690 #[test]
691 fn a_platform_cancel_sample_tears_the_contact_down() {
692 let log = log();
693 let slot = Rc::new(std::cell::Cell::new(None));
694 let mut tree = WidgetTree::new();
695 let leaf = tree.add(grip(&log, slot.clone()));
696 slot.set(Some(leaf));
697 tree.layout(SizeProposal::exact(200.0, 100.0));
698
699 let contact = new_contact();
700 tree.dispatch_pointer(touch(contact, PointerPhase::Down, Point::new(20.0, 50.0)));
701 assert_eq!(tree.captured_by(contact), Some(leaf));
702
703 tree.dispatch_pointer(touch(contact, PointerPhase::Cancel, Point::new(20.0, 50.0)));
704
705 assert_eq!(*log.borrow(), vec![(leaf, CancelReason::Platform)]);
706 assert!(
707 tree.pointers.get(contact).is_none(),
708 "a revoked contact leaves the table, as a lifted one does"
709 );
710 tree.assert_no_leaked_pointer_state();
711 }
712
713 // ---------------------------------------------------------------
714 // The delivered payload names the pointer that went away
715 // ---------------------------------------------------------------
716
717 /// What a `PointerCancel` handler was told, per delivery.
718 type PayloadLog = Rc<RefCell<Vec<(CancelReason, PointerInfo, Option<Point>)>>>;
719
720 /// A leaf that grips the pointer and records the whole `PointerCancel`
721 /// payload it is handed — not just the reason.
722 fn payload_grip(log: &PayloadLog) -> impl Widget {
723 let log = log.clone();
724 FillWidget::new()
725 .on_pointer_event(|event, ctx| {
726 if matches!(event, WidgetEvent::PointerDown { .. }) {
727 ctx.capture_pointer();
728 }
729 EventResponse::Ignored
730 })
731 .on_pointer_cancel(move |pointer, reason, ctx| {
732 log.borrow_mut()
733 .push((reason, *pointer, ctx.pointer_position()));
734 })
735 }
736
737 /// A cancelled **contact** is announced as that contact.
738 ///
739 /// The handler whose whole job is to know which pointer went away was being
740 /// handed a fabricated mouse: the teardown removes a non-hovering pointer's
741 /// table entry before the event is built, and the builder read the entry it
742 /// had just deleted, so an app branching on `pointer.kind` — releasing a
743 /// per-contact grip, dropping a stroke, un-highlighting one finger's row —
744 /// was told the mouse cancelled a press only a finger had made, and given no
745 /// position to do it at.
746 #[test]
747 fn a_cancelled_contact_is_announced_as_that_contact() {
748 let log: PayloadLog = Rc::new(RefCell::new(Vec::new()));
749 let mut tree = WidgetTree::new();
750 let leaf = tree.add(payload_grip(&log));
751 tree.layout(SizeProposal::exact(200.0, 100.0));
752
753 let contact = new_contact();
754 let at = Point::new(20.0, 50.0);
755 tree.dispatch_pointer(touch(contact, PointerPhase::Down, at));
756 assert_eq!(tree.captured_by(contact), Some(leaf));
757
758 tree.dispatch_pointer(touch(contact, PointerPhase::Cancel, at));
759
760 let seen = log.borrow();
761 assert_eq!(seen.len(), 1, "one cancel, delivered once");
762 let (reason, pointer, position) = seen[0];
763 assert_eq!(reason, CancelReason::Platform);
764 assert_eq!(
765 pointer.kind,
766 teksilo_tokens::PointerKind::Touch,
767 "the event must name the device that was cancelled",
768 );
769 assert_eq!(
770 pointer.id, contact,
771 "and its own id, not the mouse's reserved one",
772 );
773 assert_eq!(
774 position,
775 Some(at),
776 "with the position the contact was last at",
777 );
778 }
779
780 /// The same for a mouse, so the fix above is a *translation* of whatever the
781 /// entry held and not a hardcoded touch.
782 #[test]
783 fn a_cancelled_mouse_press_is_still_announced_as_the_mouse() {
784 let log: PayloadLog = Rc::new(RefCell::new(Vec::new()));
785 let mut tree = WidgetTree::new();
786 tree.add(payload_grip(&log));
787 tree.layout(SizeProposal::exact(200.0, 100.0));
788
789 let at = Point::new(30.0, 40.0);
790 press(&mut tree, at);
791 tree.cancel_all_pointers(CancelReason::ModalOpened, &mut crate::window::NoopWindowOps);
792
793 let seen = log.borrow();
794 assert_eq!(seen.len(), 1);
795 let (reason, pointer, position) = seen[0];
796 assert_eq!(reason, CancelReason::ModalOpened);
797 assert_eq!(pointer.kind, teksilo_tokens::PointerKind::Mouse);
798 assert_eq!(pointer.id, PointerId::MOUSE);
799 assert_eq!(position, Some(at));
800 }
801
802 /// A second producer, with a different shape: the subtree holding the press
803 /// is parked, so the cancel is queued by `set_dormant` rather than raised
804 /// from the sample door, and the position it announces is the one carried by
805 /// a *later* move than the press. The payload must read the same — the
806 /// fallback the defect lived in sits below all fifteen producers, so one
807 /// producer being right is not evidence about the others.
808 #[test]
809 fn a_parked_subtree_announces_the_contact_it_stranded() {
810 let log: PayloadLog = Rc::new(RefCell::new(Vec::new()));
811 let mut tree = WidgetTree::new();
812 let leaf = tree.add(payload_grip(&log));
813 let branch = tree.add(StackWidget::new().child(leaf));
814 tree.layout(SizeProposal::exact(200.0, 100.0));
815
816 let contact = new_contact();
817 tree.dispatch_pointer(touch(contact, PointerPhase::Down, Point::new(20.0, 50.0)));
818 assert_eq!(tree.captured_by(contact), Some(leaf));
819 let moved_to = Point::new(24.0, 52.0);
820 tree.dispatch_pointer(touch(contact, PointerPhase::Move, moved_to));
821
822 tree.set_dormant(branch);
823
824 let seen = log.borrow();
825 assert_eq!(seen.len(), 1, "the parked leaf was told once");
826 let (reason, pointer, position) = seen[0];
827 assert_eq!(reason, CancelReason::SubtreeParked);
828 assert_eq!(pointer.kind, teksilo_tokens::PointerKind::Touch);
829 assert_eq!(pointer.id, contact);
830 assert_eq!(
831 position,
832 Some(moved_to),
833 "the position is where the contact last was, not where it pressed",
834 );
835 }
836
837 /// A cancel sample for a pointer the tree never saw must not *create* one.
838 /// Admitting it would leave an entry behind that nothing can ever remove.
839 #[test]
840 fn a_cancel_for_an_unknown_pointer_admits_nothing() {
841 let mut tree = WidgetTree::new();
842 tree.add(FillWidget::new());
843 tree.layout(SizeProposal::exact(200.0, 100.0));
844
845 let ghost = new_contact();
846 tree.dispatch_pointer(touch(ghost, PointerPhase::Cancel, Point::new(20.0, 50.0)));
847
848 assert!(tree.pointers.get(ghost).is_none());
849 tree.assert_no_leaked_pointer_state();
850 }
851
852 /// The recipient of last resort. A press that took no capture still has a
853 /// widget that was interacting with it — the last one that answered
854 /// `Handled` — and that is who must be told the press was taken away.
855 #[test]
856 fn a_cancel_without_a_capture_reaches_the_last_widget_that_accepted() {
857 let log = log();
858 let slot = Rc::new(std::cell::Cell::new(None));
859
860 let mut tree = WidgetTree::new();
861 let leaf = {
862 let l = log.clone();
863 let s = slot.clone();
864 tree.add(
865 FillWidget::new()
866 // Handles the press without taking the pointer — the shape
867 // of a widget that paints a press state and nothing else.
868 .on_pointer_event(|event, _ctx| {
869 if matches!(event, WidgetEvent::PointerDown { .. }) {
870 return EventResponse::Handled;
871 }
872 EventResponse::Ignored
873 })
874 .on_pointer_cancel(move |_p, reason, _ctx| {
875 let id = s.get().expect("the leaf's id was never recorded");
876 l.borrow_mut().push((id, reason));
877 }),
878 )
879 };
880 slot.set(Some(leaf));
881 tree.layout(SizeProposal::exact(200.0, 100.0));
882
883 press(&mut tree, Point::new(20.0, 50.0));
884 assert_eq!(
885 tree.captured_by(PointerId::MOUSE),
886 None,
887 "the widget took the press without taking the pointer"
888 );
889
890 tree.set_window_active(false);
891
892 assert_eq!(*log.borrow(), vec![(leaf, CancelReason::WindowDeactivated)]);
893 tree.assert_no_leaked_pointer_state();
894 }
895
896 // ---------------------------------------------------------------
897 // Producer: a modal opening
898 // ---------------------------------------------------------------
899
900 /// A modal opens over the press: the surface being worked on is now behind
901 /// a scrim, and the `Up` that would have completed the press will land on
902 /// the modal instead.
903 #[test]
904 fn opening_a_modal_cancels_every_live_pointer() {
905 let log = log();
906 let (mut tree, id) = tree_with_grip(&log);
907
908 press(&mut tree, Point::new(20.0, 50.0));
909
910 let modal_content = tree.add(FillWidget::new());
911 tree.show_overlay(crate::overlay::OverlayRequest {
912 content_id: modal_content,
913 anchor: id,
914 placement: crate::overlay::OverlayPlacement::Centered,
915 dismiss: crate::overlay::DismissBehavior::Manual,
916 layer: crate::overlay::OverlayLayer::InTree,
917 parent_overlay: None,
918 on_dismiss: None,
919 fade_duration: None,
920 });
921
922 assert_eq!(*log.borrow(), vec![(id, CancelReason::ModalOpened)]);
923 assert_eq!(tree.captured_by(PointerId::MOUSE), None);
924 tree.assert_no_leaked_pointer_state();
925 }
926
927 /// Only a modal. A menu, a popover, a tooltip or a drag preview opens over
928 /// an interaction that legitimately continues.
929 #[test]
930 fn opening_a_non_modal_overlay_cancels_nothing() {
931 let log = log();
932 let (mut tree, id) = tree_with_grip(&log);
933
934 press(&mut tree, Point::new(20.0, 50.0));
935
936 let popover = tree.add(FillWidget::new());
937 tree.show_overlay(crate::overlay::OverlayRequest {
938 content_id: popover,
939 anchor: id,
940 placement: crate::overlay::OverlayPlacement::Below,
941 dismiss: crate::overlay::DismissBehavior::Manual,
942 layer: crate::overlay::OverlayLayer::InTree,
943 parent_overlay: None,
944 on_dismiss: None,
945 fade_duration: None,
946 });
947
948 assert!(log.borrow().is_empty(), "a popover is not a modal");
949 assert_eq!(tree.captured_by(PointerId::MOUSE), Some(id));
950 release(&mut tree, Point::new(20.0, 50.0));
951 tree.assert_no_leaked_pointer_state();
952 }
953
954 // ---------------------------------------------------------------
955 // Producer: a subtree going dormant
956 // ---------------------------------------------------------------
957
958 /// Parking a subtree is invisible to hit-testing and to dispatch, so a
959 /// widget parked mid-press would keep what the press latched with no event
960 /// left that could clear it.
961 #[test]
962 fn parking_a_subtree_cancels_the_pointer_inside_it() {
963 let log = log();
964 let slot = Rc::new(std::cell::Cell::new(None));
965 let mut tree = WidgetTree::new();
966 let leaf = tree.add(grip(&log, slot.clone()));
967 slot.set(Some(leaf));
968 let branch = tree.add(StackWidget::new().child(leaf));
969 tree.layout(SizeProposal::exact(200.0, 100.0));
970
971 press(&mut tree, Point::new(20.0, 50.0));
972 assert_eq!(tree.captured_by(PointerId::MOUSE), Some(leaf));
973
974 tree.set_dormant(branch);
975
976 assert_eq!(*log.borrow(), vec![(leaf, CancelReason::SubtreeParked)]);
977 assert_eq!(tree.captured_by(PointerId::MOUSE), None);
978 tree.assert_no_leaked_pointer_state();
979 }
980
981 /// The parked-ids plumbing is what makes the producer above possible: a
982 /// caller that cannot see which nodes went to sleep cannot cancel the
983 /// pointers holding them. `set_dormant` reports the whole subtree, its
984 /// root first, and reports it once per node however deep the nesting.
985 #[test]
986 fn set_dormant_reports_the_whole_parked_subtree() {
987 let mut tree = WidgetTree::new();
988 let leaf_a = tree.add(FillWidget::new());
989 let leaf_b = tree.add(FillWidget::new());
990 let inner = tree.add(StackWidget::new().child(leaf_a).child(leaf_b));
991 let outer = tree.add(StackWidget::new().child(inner));
992 tree.layout(SizeProposal::exact(200.0, 100.0));
993
994 let parked = tree.arena.set_dormant(outer);
995
996 assert_eq!(parked.first(), Some(&outer), "the root is reported first");
997 let mut sorted = parked.clone();
998 sorted.sort();
999 sorted.dedup();
1000 assert_eq!(
1001 sorted.len(),
1002 parked.len(),
1003 "each node is reported exactly once"
1004 );
1005 for id in [outer, inner, leaf_a, leaf_b] {
1006 assert!(
1007 parked.contains(&id),
1008 "{id:?} is missing from the parked set"
1009 );
1010 }
1011 }
1012
1013 /// The other way a subtree parks: a `visible_when` gate flipping false,
1014 /// resolved by the layout pass rather than by an explicit call. It is the
1015 /// busiest `set_dormant` caller in the framework, and it goes through the
1016 /// same door.
1017 #[test]
1018 fn a_visible_when_gate_closing_cancels_the_pointer_inside_it() {
1019 let log = log();
1020 let slot = Rc::new(std::cell::Cell::new(None));
1021 let shown = crate::signal::Signal::new(true);
1022
1023 let mut tree = WidgetTree::new();
1024 let leaf = tree.add(grip(&log, slot.clone()));
1025 slot.set(Some(leaf));
1026 let branch = tree.add(StackWidget::new().child(leaf).visible_when(shown.clone()));
1027 let _root = tree.add(StackWidget::new().child(branch));
1028 tree.layout(SizeProposal::exact(200.0, 100.0));
1029
1030 let at = tree.bounds(leaf).center();
1031 press(&mut tree, at);
1032 assert_eq!(tree.captured_by(PointerId::MOUSE), Some(leaf));
1033
1034 shown.set(false);
1035 tree.layout(SizeProposal::exact(200.0, 100.0));
1036
1037 assert_eq!(*log.borrow(), vec![(leaf, CancelReason::SubtreeParked)]);
1038 assert_eq!(tree.captured_by(PointerId::MOUSE), None);
1039 tree.assert_no_leaked_pointer_state();
1040 }
1041
1042 // ---------------------------------------------------------------
1043 // Producer: an overlay being dismissed, and its named exemption
1044 // ---------------------------------------------------------------
1045
1046 /// A pointer anchored inside an overlay that is torn down under it is
1047 /// stranded on a widget that no longer takes events.
1048 #[test]
1049 fn dismissing_an_overlay_cancels_a_pointer_anchored_inside_it() {
1050 let log = log();
1051 let slot = Rc::new(std::cell::Cell::new(None));
1052 let mut tree = WidgetTree::new();
1053 let anchor = tree.add(FillWidget::new());
1054 let content = tree.add(grip(&log, slot.clone()));
1055 slot.set(Some(content));
1056 tree.layout(SizeProposal::exact(200.0, 100.0));
1057
1058 let overlay = tree.show_overlay(crate::overlay::OverlayRequest {
1059 content_id: content,
1060 anchor,
1061 placement: crate::overlay::OverlayPlacement::Below,
1062 dismiss: crate::overlay::DismissBehavior::Manual,
1063 layer: crate::overlay::OverlayLayer::InTree,
1064 parent_overlay: None,
1065 on_dismiss: None,
1066 fade_duration: None,
1067 });
1068 tree.layout(SizeProposal::exact(200.0, 100.0));
1069
1070 // The press lands on the overlay's own content, which grips the
1071 // pointer — a scrollbar thumb inside a dropdown, say.
1072 tree.dispatch_event(WidgetEvent::pointer_down(
1073 tree.bounds(content).center(),
1074 PointerButton::Primary,
1075 Modifiers::NONE,
1076 ));
1077 assert_eq!(tree.captured_by(PointerId::MOUSE), Some(content));
1078
1079 tree.dismiss_overlay(overlay);
1080
1081 assert_eq!(
1082 *log.borrow(),
1083 vec![(content, CancelReason::OverlayDismissed)]
1084 );
1085 tree.assert_no_leaked_pointer_state();
1086 }
1087
1088 /// **The named exemption.** Tapping a menu item whose own handler closes
1089 /// its menu must complete the tap. The overlay teardown the handler asks
1090 /// for happens while that pointer's press is already over — the release
1091 /// sweep ran before the `Up` was delivered — so there is nothing left for a
1092 /// cancel to revoke, and the item is never told its own activation was
1093 /// taken away.
1094 #[test]
1095 fn tapping_a_menu_item_that_closes_its_own_menu_completes_the_tap() {
1096 for kind in ["mouse", "touch"] {
1097 let log = log();
1098 let tapped = Rc::new(std::cell::Cell::new(0_u32));
1099 let overlay_slot = Rc::new(std::cell::Cell::new(None));
1100
1101 let mut tree = WidgetTree::new();
1102 let anchor = tree.add(FillWidget::new());
1103 let item_slot = Rc::new(std::cell::Cell::new(None));
1104 let item = {
1105 let t = tapped.clone();
1106 let o = overlay_slot.clone();
1107 let l = log.clone();
1108 let s = item_slot.clone();
1109 tree.add(
1110 FillWidget::new()
1111 .on_tap(move |_e, ctx| {
1112 t.set(t.get() + 1);
1113 // The menu item closes its own menu, exactly as a
1114 // real one does.
1115 if let Some(id) = o.get() {
1116 ctx.dismiss_overlay(id);
1117 }
1118 })
1119 .on_pointer_cancel(move |_p, reason, _ctx| {
1120 let id = s.get().expect("the item's id was never recorded");
1121 l.borrow_mut().push((id, reason));
1122 }),
1123 )
1124 };
1125 item_slot.set(Some(item));
1126 tree.layout(SizeProposal::exact(200.0, 100.0));
1127
1128 let overlay = tree.show_overlay(crate::overlay::OverlayRequest {
1129 content_id: item,
1130 anchor,
1131 placement: crate::overlay::OverlayPlacement::Below,
1132 dismiss: crate::overlay::DismissBehavior::Manual,
1133 layer: crate::overlay::OverlayLayer::InTree,
1134 parent_overlay: None,
1135 on_dismiss: None,
1136 fade_duration: None,
1137 });
1138 overlay_slot.set(Some(overlay));
1139 tree.layout(SizeProposal::exact(200.0, 100.0));
1140 let at = tree.bounds(item).center();
1141
1142 if kind == "mouse" {
1143 press(&mut tree, at);
1144 release(&mut tree, at);
1145 } else {
1146 let contact = new_contact();
1147 tree.dispatch_pointer(touch(contact, PointerPhase::Down, at));
1148 tree.dispatch_pointer(touch(contact, PointerPhase::Up, at));
1149 }
1150
1151 assert_eq!(tapped.get(), 1, "the {kind} tap completed");
1152 assert!(
1153 log.borrow().is_empty(),
1154 "the {kind} tap must not be cancelled by the teardown it asked \
1155 for: {:?}",
1156 log.borrow()
1157 );
1158 tree.assert_no_leaked_pointer_state();
1159 }
1160 }
1161
1162 // ---------------------------------------------------------------
1163 // Producer: a peer claiming the sequence
1164 // ---------------------------------------------------------------
1165
1166 /// The member granularity, and the one that is emphatically *not* a
1167 /// pointer cancel: two nested drag-capable ancestors compete for a press a
1168 /// tapping descendant is holding, the inner one wins at the mouse latch,
1169 /// and the outer one is told it lost — **once**, on that sample, and never
1170 /// again however many more moves arrive.
1171 #[test]
1172 fn a_peer_claim_revokes_each_loser_exactly_once() {
1173 let log = log();
1174 let outer_slot = Rc::new(std::cell::Cell::new(None));
1175
1176 let mut tree = WidgetTree::new();
1177 // The tap owner: it takes the capture, which is what enrols the
1178 // drag-capable ancestors above it as competitors.
1179 let child = tree.add(FillWidget::new().on_tap(|_e, _c| {}));
1180 let inner = tree.add(StackWidget::new().child(child).on_drag(|_phase, _c| {}));
1181 let outer = tree.add(
1182 StackWidget::new()
1183 .child(inner)
1184 .on_drag(|_phase, _c| {})
1185 .on_pointer_cancel({
1186 let log = log.clone();
1187 let slot = outer_slot.clone();
1188 move |_p, reason, _ctx| {
1189 let id = slot.get().expect("the outer id was never recorded");
1190 log.borrow_mut().push((id, reason));
1191 }
1192 }),
1193 );
1194 outer_slot.set(Some(outer));
1195 tree.layout(SizeProposal::exact(400.0, 100.0));
1196
1197 press(&mut tree, Point::new(20.0, 50.0));
1198 // Ten samples well past the mouse drag latch: the inner drag wins on
1199 // the first one that clears 5 dp, and the outer must be revoked then
1200 // and only then.
1201 for step in 1..=10 {
1202 moved(&mut tree, Point::new(20.0 + step as f32 * 10.0, 50.0));
1203 }
1204
1205 assert_eq!(
1206 tree.sequence_winner(PointerId::MOUSE),
1207 Some(inner),
1208 "the innermost drag won the press"
1209 );
1210 assert_eq!(
1211 *log.borrow(),
1212 vec![(outer, CancelReason::PeerClaimed)],
1213 "the loser is revoked once, on the sample the peer won, and never again"
1214 );
1215 assert!(
1216 tree.pointers.get(PointerId::MOUSE).is_some(),
1217 "revoking a member is not cancelling the pointer: it is still live"
1218 );
1219
1220 release(&mut tree, Point::new(120.0, 50.0));
1221 assert_eq!(
1222 log.borrow().len(),
1223 1,
1224 "and the release adds nothing: the loser was already told"
1225 );
1226 tree.assert_no_leaked_pointer_state();
1227 }
1228
1229 // ---------------------------------------------------------------
1230 // Producer: rebuild / member death
1231 // ---------------------------------------------------------------
1232
1233 /// A competitor destroyed mid-press is revoked **individually**: the
1234 /// pointer and its winner are untouched.
1235 #[test]
1236 fn a_destroyed_member_is_revoked_alone() {
1237 let mut tree = WidgetTree::new();
1238 let child = tree.add(FillWidget::new().on_tap(|_e, _c| {}));
1239 let ancestor = tree.add(StackWidget::new().child(child).on_drag(|_phase, _c| {}));
1240 tree.layout(SizeProposal::exact(400.0, 100.0));
1241
1242 press(&mut tree, Point::new(20.0, 50.0));
1243 assert!(
1244 tree.sequence_members(PointerId::MOUSE)
1245 .iter()
1246 .any(|(id, _, _)| *id == ancestor),
1247 "the ancestor drag is enrolled through the child's capture"
1248 );
1249
1250 tree.destroy_subtree_for_testing(ancestor);
1251 moved(&mut tree, Point::new(21.0, 50.0));
1252
1253 assert!(
1254 tree.sequence_members(PointerId::MOUSE)
1255 .iter()
1256 .all(|(id, _, _)| *id != ancestor),
1257 "the dead member is dropped from the arbitration"
1258 );
1259 }
1260
1261 /// The captor dying is a different act: the press belonged to it, so the
1262 /// whole pointer is cancelled and the capture it left behind is given back.
1263 #[test]
1264 fn a_destroyed_captor_cancels_the_whole_pointer() {
1265 let log = log();
1266 let slot = Rc::new(std::cell::Cell::new(None));
1267 let mut tree = WidgetTree::new();
1268 let leaf = tree.add(grip(&log, slot.clone()));
1269 slot.set(Some(leaf));
1270 let host = tree.add(StackWidget::new().child(leaf));
1271 tree.layout(SizeProposal::exact(200.0, 100.0));
1272
1273 press(&mut tree, Point::new(20.0, 50.0));
1274 assert_eq!(tree.captured_by(PointerId::MOUSE), Some(leaf));
1275
1276 // Destroy the captor without a layout in between, so the sequence's own
1277 // revalidation is what notices — the mandated hook.
1278 tree.destroy_subtree_for_testing(host);
1279 moved(&mut tree, Point::new(21.0, 50.0));
1280
1281 assert_eq!(
1282 tree.captured_by(PointerId::MOUSE),
1283 None,
1284 "the orphaned capture is given back"
1285 );
1286 assert!(
1287 tree.sequence_members(PointerId::MOUSE).is_empty(),
1288 "and the arbitration is over"
1289 );
1290 tree.assert_no_leaked_pointer_state();
1291 }
1292
1293 // ---------------------------------------------------------------
1294 // Producer: an OS drag taking the pointer
1295 // ---------------------------------------------------------------
1296
1297 /// Escalating an in-app drag to the OS hands the pointer to the platform:
1298 /// this window sees no further move and no `Up`, so whatever the press
1299 /// still had going has to be revoked here.
1300 #[test]
1301 fn escalating_to_an_os_drag_cancels_the_source_pointer() {
1302 /// Stands in for the platform backend: accepts the hand-off and
1303 /// records that it did.
1304 struct AcceptingOps {
1305 began: std::cell::Cell<bool>,
1306 }
1307 impl crate::window::WindowOps for AcceptingOps {
1308 fn open_window(
1309 &mut self,
1310 _config: crate::window::WindowConfig,
1311 ) -> crate::window::TeksiloWindowId {
1312 panic!("not used in this test")
1313 }
1314 fn find_window(&self, _id: &str) -> Option<crate::window::TeksiloWindowId> {
1315 None
1316 }
1317 fn window_state(
1318 &self,
1319 _id: crate::window::TeksiloWindowId,
1320 ) -> Option<crate::window::WindowState> {
1321 None
1322 }
1323 fn windows(&self) -> Vec<crate::window::WindowState> {
1324 Vec::new()
1325 }
1326 fn focus_window(&mut self, _id: crate::window::TeksiloWindowId) {}
1327 fn close_window_by_id(&mut self, _id: crate::window::TeksiloWindowId) {}
1328 fn begin_os_drag(
1329 &mut self,
1330 _data: crate::drag_payload::OutboundDragData,
1331 _image: Option<crate::drag_payload::DragImageData>,
1332 _pointer: teksilo_tokens::PointerKind,
1333 ) -> bool {
1334 self.began.set(true);
1335 true
1336 }
1337 }
1338
1339 let log = log();
1340 let slot = Rc::new(std::cell::Cell::new(None));
1341 let mut tree = WidgetTree::new();
1342 let source = tree.add(recorder(&log, slot.clone()));
1343 slot.set(Some(source));
1344 tree.layout(SizeProposal::exact(200.0, 100.0));
1345
1346 let mut ops = AcceptingOps {
1347 began: std::cell::Cell::new(false),
1348 };
1349 press(&mut tree, Point::new(20.0, 50.0));
1350
1351 let mut ctx = crate::widget::EventContext::new();
1352 ctx.start_drag(
1353 source,
1354 crate::drag_payload::DragPayload::typed(7_u32).with_mime("text/plain", b"7".to_vec()),
1355 );
1356 tree.collect_from_ctx(ctx, source);
1357 assert!(tree.active_drag.is_some());
1358
1359 // Out of the window: the drag escalates.
1360 tree.dispatch_event_with_ops(WidgetEvent::pointer_move(Point::new(-40.0, 50.0)), &mut ops);
1361
1362 assert!(ops.began.get(), "the platform took the drag");
1363 assert_eq!(
1364 *log.borrow(),
1365 vec![(source, CancelReason::OsDragStarted)],
1366 "and the source is told the in-app half of the interaction is over"
1367 );
1368 tree.assert_no_leaked_pointer_state();
1369 }
1370
1371 // ---------------------------------------------------------------
1372 // The queue, and terminality
1373 // ---------------------------------------------------------------
1374
1375 /// A cancel raised from inside a handler is **queued**: the handler that
1376 /// raised it finishes on the state it started with, and the teardown runs
1377 /// afterwards — before the top-level dispatch returns.
1378 #[test]
1379 fn a_cancel_raised_from_a_handler_is_queued_not_reentrant() {
1380 let observed_capture = Rc::new(std::cell::Cell::new(None));
1381 let cancelled = Rc::new(std::cell::Cell::new(false));
1382
1383 let mut tree = WidgetTree::new();
1384 let leaf = {
1385 let seen = observed_capture.clone();
1386 let done = cancelled.clone();
1387 tree.add(
1388 FillWidget::new()
1389 .on_pointer_event(move |event, ctx| {
1390 if matches!(event, WidgetEvent::PointerDown { .. }) {
1391 ctx.capture_pointer();
1392 }
1393 if matches!(event, WidgetEvent::PointerMove { .. }) {
1394 ctx.cancel_pointer_sequence(CancelReason::Deactivated);
1395 // Still owned, right here: the cancel has not run.
1396 seen.set(Some(ctx.owns_pointer()));
1397 }
1398 EventResponse::Ignored
1399 })
1400 .on_pointer_cancel(move |_p, _reason, _ctx| done.set(true)),
1401 )
1402 };
1403 tree.layout(SizeProposal::exact(200.0, 100.0));
1404
1405 press(&mut tree, Point::new(20.0, 50.0));
1406 moved(&mut tree, Point::new(30.0, 50.0));
1407
1408 assert_eq!(
1409 observed_capture.get(),
1410 Some(true),
1411 "the handler that raised the cancel still owned the pointer when it returned"
1412 );
1413 assert!(
1414 cancelled.get(),
1415 "and the teardown ran once the sample finished"
1416 );
1417 assert!(
1418 !tree.has_pending_dispatch(),
1419 "the queue is empty again before the dispatch returns"
1420 );
1421 assert_eq!(tree.captured_by(PointerId::MOUSE), None);
1422 assert_eq!(tree.arena.get(leaf).map(|_| ()), Some(()));
1423 tree.assert_no_leaked_pointer_state();
1424 }
1425
1426 /// A cancel that a handler raises against a pointer which lifts in the very
1427 /// same sample must not fire: by the time the queue drains there is no
1428 /// interaction left to revoke.
1429 #[test]
1430 fn a_cancel_does_not_fire_if_the_pointer_lifted_in_the_same_sample() {
1431 let log = log();
1432 let slot = Rc::new(std::cell::Cell::new(None));
1433
1434 let mut tree = WidgetTree::new();
1435 let leaf = {
1436 let l = log.clone();
1437 let s = slot.clone();
1438 tree.add(
1439 FillWidget::new()
1440 .on_pointer_event(|event, ctx| {
1441 if matches!(event, WidgetEvent::PointerDown { .. }) {
1442 ctx.capture_pointer();
1443 }
1444 if matches!(event, WidgetEvent::PointerUp { .. }) {
1445 // The release itself asks for a cancel — the shape
1446 // a menu item's "close my menu" handler has.
1447 ctx.cancel_pointer_sequence(CancelReason::OverlayDismissed);
1448 }
1449 EventResponse::Ignored
1450 })
1451 .on_pointer_cancel(move |_p, reason, _ctx| {
1452 let id = s.get().expect("the leaf's id was never recorded");
1453 l.borrow_mut().push((id, reason));
1454 }),
1455 )
1456 };
1457 slot.set(Some(leaf));
1458 tree.layout(SizeProposal::exact(200.0, 100.0));
1459
1460 let contact = new_contact();
1461 tree.dispatch_pointer(touch(contact, PointerPhase::Down, Point::new(20.0, 50.0)));
1462 tree.dispatch_pointer(touch(contact, PointerPhase::Up, Point::new(20.0, 50.0)));
1463
1464 assert!(
1465 log.borrow().is_empty(),
1466 "the press was already over when the queued cancel drained: {:?}",
1467 log.borrow()
1468 );
1469 tree.assert_no_leaked_pointer_state();
1470 }
1471
1472 /// `PointerCancel` is terminal. An `Up` that arrives for a press the system
1473 /// already took away completes nothing — the widget has been told to let
1474 /// go, and handing it back the release would resurrect an interaction that
1475 /// no longer exists.
1476 #[test]
1477 fn no_pointer_up_follows_a_cancel() {
1478 let ups = Rc::new(std::cell::Cell::new(0_u32));
1479 let cancels = Rc::new(std::cell::Cell::new(0_u32));
1480
1481 let mut tree = WidgetTree::new();
1482 {
1483 let u = ups.clone();
1484 let c = cancels.clone();
1485 tree.add(
1486 FillWidget::new()
1487 .on_tap(move |_e, _ctx| u.set(u.get() + 1))
1488 .on_pointer_cancel(move |_p, _reason, _ctx| c.set(c.get() + 1)),
1489 );
1490 }
1491 tree.layout(SizeProposal::exact(200.0, 100.0));
1492
1493 press(&mut tree, Point::new(20.0, 50.0));
1494 tree.set_window_active(false);
1495 assert_eq!(cancels.get(), 1);
1496
1497 // The platform (or a confused caller) sends the release anyway.
1498 release(&mut tree, Point::new(20.0, 50.0));
1499 assert_eq!(ups.get(), 0, "the tap must not complete after its cancel");
1500
1501 // …and the next press is a fresh interaction, unaffected.
1502 tree.set_window_active(true);
1503 press(&mut tree, Point::new(20.0, 50.0));
1504 release(&mut tree, Point::new(20.0, 50.0));
1505 assert_eq!(ups.get(), 1, "the next press works normally");
1506 tree.assert_no_leaked_pointer_state();
1507 }
1508
1509 /// The whole-pointer and member-level granularities are different acts,
1510 /// and the difference has to be visible from outside: a member revocation
1511 /// leaves the pointer, its capture and its winner exactly where they were.
1512 #[test]
1513 fn revoking_a_member_leaves_the_pointer_alive() {
1514 let mut tree = WidgetTree::new();
1515 let child = tree.add(FillWidget::new().on_tap(|_e, _c| {}));
1516 let ancestor = tree.add(StackWidget::new().child(child).on_drag(|_phase, _c| {}));
1517 tree.layout(SizeProposal::exact(400.0, 100.0));
1518
1519 press(&mut tree, Point::new(20.0, 50.0));
1520 let mut noop = crate::window::NoopWindowOps;
1521 tree.revoke_sequence_member(
1522 PointerId::MOUSE,
1523 ancestor,
1524 CancelReason::PeerClaimed,
1525 &mut noop,
1526 );
1527
1528 assert_eq!(
1529 tree.captured_by(PointerId::MOUSE),
1530 Some(child),
1531 "the capture survives a member revocation"
1532 );
1533 assert!(
1534 tree.pointers.get(PointerId::MOUSE).is_some(),
1535 "and so does the pointer"
1536 );
1537
1538 release(&mut tree, Point::new(20.0, 50.0));
1539 tree.assert_no_leaked_pointer_state();
1540 }
1541
1542 /// A drag the cancelled pointer was driving ends as `Cancelled`, not as a
1543 /// drop: releasing the payload wherever the pointer happened to be is how
1544 /// a lost window focus turns into a data-loss bug.
1545 #[test]
1546 fn cancelling_the_pointer_driving_a_drag_cancels_the_drag() {
1547 let outcome = Rc::new(RefCell::new(None));
1548
1549 let mut tree = WidgetTree::new();
1550 let source = {
1551 let o = outcome.clone();
1552 tree.add(FillWidget::new().on_drag_ended(move |result, _ctx| {
1553 *o.borrow_mut() = Some(result);
1554 }))
1555 };
1556 tree.layout(SizeProposal::exact(200.0, 100.0));
1557
1558 press(&mut tree, Point::new(20.0, 50.0));
1559 let mut ctx = crate::widget::EventContext::new();
1560 ctx.start_drag(source, crate::drag_payload::DragPayload::typed(1_u8));
1561 tree.collect_from_ctx(ctx, source);
1562 assert!(tree.active_drag.is_some());
1563
1564 tree.set_window_active(false);
1565
1566 assert!(tree.active_drag.is_none(), "the drag session is torn down");
1567 assert_eq!(
1568 *outcome.borrow(),
1569 Some(crate::drag_payload::DropOutcome::Cancelled),
1570 "and its source is told it was cancelled, not dropped"
1571 );
1572 tree.assert_no_leaked_pointer_state();
1573 }
1574
1575 /// A drag mid-flight is exactly the case the funnel exists for. Its phases
1576 /// must not report an `Ended` — that is the `Up` story — and the widget
1577 /// must hear about the revocation instead.
1578 #[test]
1579 fn a_cancelled_drag_reports_no_ended_phase() {
1580 let phases = Rc::new(RefCell::new(Vec::new()));
1581 let cancels = Rc::new(std::cell::Cell::new(0_u32));
1582
1583 let mut tree = WidgetTree::new();
1584 {
1585 let p = phases.clone();
1586 let c = cancels.clone();
1587 tree.add(
1588 FillWidget::new()
1589 .on_drag(move |phase, _ctx| {
1590 p.borrow_mut().push(std::mem::discriminant(&phase));
1591 })
1592 .on_pointer_cancel(move |_p, _reason, _ctx| c.set(c.get() + 1)),
1593 );
1594 }
1595 tree.layout(SizeProposal::exact(400.0, 100.0));
1596
1597 press(&mut tree, Point::new(20.0, 50.0));
1598 for step in 1..=3 {
1599 moved(&mut tree, Point::new(20.0 + step as f32 * 10.0, 50.0));
1600 }
1601 let before = phases.borrow().len();
1602 assert!(before >= 2, "the drag started and moved");
1603
1604 tree.set_window_active(false);
1605
1606 assert_eq!(cancels.get(), 1, "the dragging widget is told");
1607 assert_eq!(
1608 phases.borrow().len(),
1609 before,
1610 "and no further drag phase — least of all an Ended — is reported"
1611 );
1612 tree.assert_no_leaked_pointer_state();
1613 }
1614
1615 /// A cancelled hovering pointer stops reading as held.
1616 ///
1617 /// A contact that is taken away leaves the table; a mouse or a pen does
1618 /// not, and the entry that stays behind is what every singular accessor
1619 /// reads. Its button mask is what `PointerEntry::is_contacting` answers
1620 /// from, and a `Cancel` sample never reaches `PointerTable::admit` — so
1621 /// before this was cleared, a stylus whose press was revoked by a modal, a
1622 /// window deactivation or an OS drag went on reporting a held tip until the
1623 /// next real sample, and `assert_no_leaked_pointer_state` said so.
1624 ///
1625 /// A **pen**, not a mouse, because the legacy `WidgetEvent::PointerDown`
1626 /// path admits `PointerInfo::mouse`, whose mask is empty — so a mouse press
1627 /// has never read as contacting in the first place and could not show this.
1628 /// The pen helpers build the sample the platform translator builds, mask
1629 /// included.
1630 #[test]
1631 fn a_cancelled_hovering_pointer_stops_reading_as_held() {
1632 let mut tree = WidgetTree::new();
1633 let id_slot = Rc::new(std::cell::Cell::new(None::<WidgetId>));
1634 let cancels = log();
1635 // Built in one chain rather than through `recorder`: a second
1636 // `WidgetBuilder` call on an already-wrapped `impl Widget` re-wraps
1637 // instead of merging, and the inner handler set would be dropped.
1638 let recorded = cancels.clone();
1639 let slot = id_slot.clone();
1640 let node = tree.add(
1641 FillWidget::new()
1642 .on_pointer_cancel(move |_pointer, reason, _ctx| {
1643 let id = slot.get().expect("the recorder's id was never recorded");
1644 recorded.borrow_mut().push((id, reason));
1645 })
1646 .on_tap(|_e, _c| {}),
1647 );
1648 id_slot.set(Some(node));
1649 tree.layout(SizeProposal::exact(100.0, 100.0));
1650
1651 tree.pen_down(Point::new(50.0, 50.0), 0.5, (0.0, 0.0));
1652 let pen = tree
1653 .live_pointers()
1654 .find(|p| matches!(p.kind, teksilo_tokens::PointerKind::Pen(_)))
1655 .map(|p| p.id)
1656 .expect("the pen was admitted");
1657 assert!(
1658 tree.pointers.get(pen).is_some_and(|e| e.is_contacting()),
1659 "the tip is on the surface"
1660 );
1661
1662 let mut noop = crate::window::NoopWindowOps;
1663 tree.cancel_pointer(pen, CancelReason::ModalOpened, &mut noop);
1664
1665 assert_eq!(
1666 cancels.borrow().as_slice(),
1667 &[(node, CancelReason::ModalOpened)],
1668 "the node was told"
1669 );
1670 assert!(
1671 tree.pointers.get(pen).is_some(),
1672 "a pen keeps its entry: it is still in proximity, still hovering"
1673 );
1674 assert!(
1675 !tree.pointers.get(pen).is_some_and(|e| e.is_contacting()),
1676 "…but it is no longer contacting anything"
1677 );
1678 tree.assert_no_leaked_pointer_state();
1679 }
1680}