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