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 {
913 content_id: modal_content,
914 anchor: id,
915 placement: crate::overlay::OverlayPlacement::Centered,
916 dismiss: crate::overlay::DismissBehavior::Manual,
917 layer: crate::overlay::OverlayLayer::InTree,
918 parent_overlay: None,
919 on_dismiss: None,
920 fade_duration: None,
921 });
922
923 assert_eq!(*log.borrow(), vec![(id, CancelReason::ModalOpened)]);
924 assert_eq!(tree.captured_by(PointerId::MOUSE), None);
925 tree.assert_no_leaked_pointer_state();
926 }
927
928 /// Only a modal. A menu, a popover, a tooltip or a drag preview opens over
929 /// an interaction that legitimately continues.
930 #[test]
931 fn opening_a_non_modal_overlay_cancels_nothing() {
932 let log = log();
933 let (mut tree, id) = tree_with_grip(&log);
934
935 press(&mut tree, Point::new(20.0, 50.0));
936
937 let popover = tree.add(FillWidget::new());
938 tree.show_overlay(crate::overlay::OverlayRequest {
939 content_id: popover,
940 anchor: id,
941 placement: crate::overlay::OverlayPlacement::Below,
942 dismiss: crate::overlay::DismissBehavior::Manual,
943 layer: crate::overlay::OverlayLayer::InTree,
944 parent_overlay: None,
945 on_dismiss: None,
946 fade_duration: None,
947 });
948
949 assert!(log.borrow().is_empty(), "a popover is not a modal");
950 assert_eq!(tree.captured_by(PointerId::MOUSE), Some(id));
951 release(&mut tree, Point::new(20.0, 50.0));
952 tree.assert_no_leaked_pointer_state();
953 }
954
955 // ---------------------------------------------------------------
956 // Producer: a subtree going dormant
957 // ---------------------------------------------------------------
958
959 /// Parking a subtree is invisible to hit-testing and to dispatch, so a
960 /// widget parked mid-press would keep what the press latched with no event
961 /// left that could clear it.
962 #[test]
963 fn parking_a_subtree_cancels_the_pointer_inside_it() {
964 let log = log();
965 let slot = Rc::new(std::cell::Cell::new(None));
966 let mut tree = WidgetTree::new();
967 let leaf = tree.add(grip(&log, slot.clone()));
968 slot.set(Some(leaf));
969 let branch = tree.add(StackWidget::new().child(leaf));
970 tree.layout(SizeProposal::exact(200.0, 100.0));
971
972 press(&mut tree, Point::new(20.0, 50.0));
973 assert_eq!(tree.captured_by(PointerId::MOUSE), Some(leaf));
974
975 tree.set_dormant(branch);
976
977 assert_eq!(*log.borrow(), vec![(leaf, CancelReason::SubtreeParked)]);
978 assert_eq!(tree.captured_by(PointerId::MOUSE), None);
979 tree.assert_no_leaked_pointer_state();
980 }
981
982 /// The parked-ids plumbing is what makes the producer above possible: a
983 /// caller that cannot see which nodes went to sleep cannot cancel the
984 /// pointers holding them. `set_dormant` reports the whole subtree, its
985 /// root first, and reports it once per node however deep the nesting.
986 #[test]
987 fn set_dormant_reports_the_whole_parked_subtree() {
988 let mut tree = WidgetTree::new();
989 let leaf_a = tree.add(FillWidget::new());
990 let leaf_b = tree.add(FillWidget::new());
991 let inner = tree.add(StackWidget::new().child(leaf_a).child(leaf_b));
992 let outer = tree.add(StackWidget::new().child(inner));
993 tree.layout(SizeProposal::exact(200.0, 100.0));
994
995 let parked = tree.arena.set_dormant(outer);
996
997 assert_eq!(parked.first(), Some(&outer), "the root is reported first");
998 let mut sorted = parked.clone();
999 sorted.sort();
1000 sorted.dedup();
1001 assert_eq!(
1002 sorted.len(),
1003 parked.len(),
1004 "each node is reported exactly once"
1005 );
1006 for id in [outer, inner, leaf_a, leaf_b] {
1007 assert!(
1008 parked.contains(&id),
1009 "{id:?} is missing from the parked set"
1010 );
1011 }
1012 }
1013
1014 /// The other way a subtree parks: a `visible_when` gate flipping false,
1015 /// resolved by the layout pass rather than by an explicit call. It is the
1016 /// busiest `set_dormant` caller in the framework, and it goes through the
1017 /// same door.
1018 #[test]
1019 fn a_visible_when_gate_closing_cancels_the_pointer_inside_it() {
1020 let log = log();
1021 let slot = Rc::new(std::cell::Cell::new(None));
1022 let shown = crate::signal::Signal::new(true);
1023
1024 let mut tree = WidgetTree::new();
1025 let leaf = tree.add(grip(&log, slot.clone()));
1026 slot.set(Some(leaf));
1027 let branch = tree.add(StackWidget::new().child(leaf).visible_when(shown.clone()));
1028 let _root = tree.add(StackWidget::new().child(branch));
1029 tree.layout(SizeProposal::exact(200.0, 100.0));
1030
1031 let at = tree.bounds(leaf).center();
1032 press(&mut tree, at);
1033 assert_eq!(tree.captured_by(PointerId::MOUSE), Some(leaf));
1034
1035 shown.set(false);
1036 tree.layout(SizeProposal::exact(200.0, 100.0));
1037
1038 assert_eq!(*log.borrow(), vec![(leaf, CancelReason::SubtreeParked)]);
1039 assert_eq!(tree.captured_by(PointerId::MOUSE), None);
1040 tree.assert_no_leaked_pointer_state();
1041 }
1042
1043 // ---------------------------------------------------------------
1044 // Producer: an overlay being dismissed, and its named exemption
1045 // ---------------------------------------------------------------
1046
1047 /// A pointer anchored inside an overlay that is torn down under it is
1048 /// stranded on a widget that no longer takes events.
1049 #[test]
1050 fn dismissing_an_overlay_cancels_a_pointer_anchored_inside_it() {
1051 let log = log();
1052 let slot = Rc::new(std::cell::Cell::new(None));
1053 let mut tree = WidgetTree::new();
1054 let anchor = tree.add(FillWidget::new());
1055 let content = tree.add(grip(&log, slot.clone()));
1056 slot.set(Some(content));
1057 tree.layout(SizeProposal::exact(200.0, 100.0));
1058
1059 let overlay = tree.show_overlay(crate::overlay::OverlayRequest {
1060 content_id: content,
1061 anchor,
1062 placement: crate::overlay::OverlayPlacement::Below,
1063 dismiss: crate::overlay::DismissBehavior::Manual,
1064 layer: crate::overlay::OverlayLayer::InTree,
1065 parent_overlay: None,
1066 on_dismiss: None,
1067 fade_duration: None,
1068 });
1069 tree.layout(SizeProposal::exact(200.0, 100.0));
1070
1071 // The press lands on the overlay's own content, which grips the
1072 // pointer — a scrollbar thumb inside a dropdown, say.
1073 tree.dispatch_event(WidgetEvent::pointer_down(
1074 tree.bounds(content).center(),
1075 PointerButton::Primary,
1076 Modifiers::NONE,
1077 ));
1078 assert_eq!(tree.captured_by(PointerId::MOUSE), Some(content));
1079
1080 tree.dismiss_overlay(overlay);
1081
1082 assert_eq!(
1083 *log.borrow(),
1084 vec![(content, CancelReason::OverlayDismissed)]
1085 );
1086 tree.assert_no_leaked_pointer_state();
1087 }
1088
1089 /// **The named exemption.** Tapping a menu item whose own handler closes
1090 /// its menu must complete the tap. The overlay teardown the handler asks
1091 /// for happens while that pointer's press is already over — the release
1092 /// sweep ran before the `Up` was delivered — so there is nothing left for a
1093 /// cancel to revoke, and the item is never told its own activation was
1094 /// taken away.
1095 #[test]
1096 fn tapping_a_menu_item_that_closes_its_own_menu_completes_the_tap() {
1097 for kind in ["mouse", "touch"] {
1098 let log = log();
1099 let tapped = Rc::new(std::cell::Cell::new(0_u32));
1100 let overlay_slot = Rc::new(std::cell::Cell::new(None));
1101
1102 let mut tree = WidgetTree::new();
1103 let anchor = tree.add(FillWidget::new());
1104 let item_slot = Rc::new(std::cell::Cell::new(None));
1105 let item = {
1106 let t = tapped.clone();
1107 let o = overlay_slot.clone();
1108 let l = log.clone();
1109 let s = item_slot.clone();
1110 tree.add(
1111 FillWidget::new()
1112 .on_tap(move |_e, ctx| {
1113 t.set(t.get() + 1);
1114 // The menu item closes its own menu, exactly as a
1115 // real one does.
1116 if let Some(id) = o.get() {
1117 ctx.dismiss_overlay(id);
1118 }
1119 })
1120 .on_pointer_cancel(move |_p, reason, _ctx| {
1121 let id = s.get().expect("the item's id was never recorded");
1122 l.borrow_mut().push((id, reason));
1123 }),
1124 )
1125 };
1126 item_slot.set(Some(item));
1127 tree.layout(SizeProposal::exact(200.0, 100.0));
1128
1129 let overlay = tree.show_overlay(crate::overlay::OverlayRequest {
1130 content_id: item,
1131 anchor,
1132 placement: crate::overlay::OverlayPlacement::Below,
1133 dismiss: crate::overlay::DismissBehavior::Manual,
1134 layer: crate::overlay::OverlayLayer::InTree,
1135 parent_overlay: None,
1136 on_dismiss: None,
1137 fade_duration: None,
1138 });
1139 overlay_slot.set(Some(overlay));
1140 tree.layout(SizeProposal::exact(200.0, 100.0));
1141 let at = tree.bounds(item).center();
1142
1143 if kind == "mouse" {
1144 press(&mut tree, at);
1145 release(&mut tree, at);
1146 } else {
1147 let contact = new_contact();
1148 tree.dispatch_pointer(touch(contact, PointerPhase::Down, at));
1149 tree.dispatch_pointer(touch(contact, PointerPhase::Up, at));
1150 }
1151
1152 assert_eq!(tapped.get(), 1, "the {kind} tap completed");
1153 assert!(
1154 log.borrow().is_empty(),
1155 "the {kind} tap must not be cancelled by the teardown it asked \
1156 for: {:?}",
1157 log.borrow()
1158 );
1159 tree.assert_no_leaked_pointer_state();
1160 }
1161 }
1162
1163 // ---------------------------------------------------------------
1164 // Producer: a peer claiming the sequence
1165 // ---------------------------------------------------------------
1166
1167 /// The member granularity, and the one that is emphatically *not* a
1168 /// pointer cancel: two nested drag-capable ancestors compete for a press a
1169 /// tapping descendant is holding, the inner one wins at the mouse latch,
1170 /// and the outer one is told it lost — **once**, on that sample, and never
1171 /// again however many more moves arrive.
1172 #[test]
1173 fn a_peer_claim_revokes_each_loser_exactly_once() {
1174 let log = log();
1175 let outer_slot = Rc::new(std::cell::Cell::new(None));
1176
1177 let mut tree = WidgetTree::new();
1178 // The tap owner: it takes the capture, which is what enrols the
1179 // drag-capable ancestors above it as competitors.
1180 let child = tree.add(FillWidget::new().on_tap(|_e, _c| {}));
1181 let inner = tree.add(StackWidget::new().child(child).on_drag(|_phase, _c| {}));
1182 let outer = tree.add(
1183 StackWidget::new()
1184 .child(inner)
1185 .on_drag(|_phase, _c| {})
1186 .on_pointer_cancel({
1187 let log = log.clone();
1188 let slot = outer_slot.clone();
1189 move |_p, reason, _ctx| {
1190 let id = slot.get().expect("the outer id was never recorded");
1191 log.borrow_mut().push((id, reason));
1192 }
1193 }),
1194 );
1195 outer_slot.set(Some(outer));
1196 tree.layout(SizeProposal::exact(400.0, 100.0));
1197
1198 press(&mut tree, Point::new(20.0, 50.0));
1199 // Ten samples well past the mouse drag latch: the inner drag wins on
1200 // the first one that clears 5 dp, and the outer must be revoked then
1201 // and only then.
1202 for step in 1..=10 {
1203 moved(&mut tree, Point::new(20.0 + step as f32 * 10.0, 50.0));
1204 }
1205
1206 assert_eq!(
1207 tree.sequence_winner(PointerId::MOUSE),
1208 Some(inner),
1209 "the innermost drag won the press"
1210 );
1211 assert_eq!(
1212 *log.borrow(),
1213 vec![(outer, CancelReason::PeerClaimed)],
1214 "the loser is revoked once, on the sample the peer won, and never again"
1215 );
1216 assert!(
1217 tree.pointers.get(PointerId::MOUSE).is_some(),
1218 "revoking a member is not cancelling the pointer: it is still live"
1219 );
1220
1221 release(&mut tree, Point::new(120.0, 50.0));
1222 assert_eq!(
1223 log.borrow().len(),
1224 1,
1225 "and the release adds nothing: the loser was already told"
1226 );
1227 tree.assert_no_leaked_pointer_state();
1228 }
1229
1230 // ---------------------------------------------------------------
1231 // Producer: rebuild / member death
1232 // ---------------------------------------------------------------
1233
1234 /// A competitor destroyed mid-press is revoked **individually**: the
1235 /// pointer and its winner are untouched.
1236 #[test]
1237 fn a_destroyed_member_is_revoked_alone() {
1238 let mut tree = WidgetTree::new();
1239 let child = tree.add(FillWidget::new().on_tap(|_e, _c| {}));
1240 let ancestor = tree.add(StackWidget::new().child(child).on_drag(|_phase, _c| {}));
1241 tree.layout(SizeProposal::exact(400.0, 100.0));
1242
1243 press(&mut tree, Point::new(20.0, 50.0));
1244 assert!(
1245 tree.sequence_members(PointerId::MOUSE)
1246 .iter()
1247 .any(|(id, _, _)| *id == ancestor),
1248 "the ancestor drag is enrolled through the child's capture"
1249 );
1250
1251 tree.destroy_subtree_for_testing(ancestor);
1252 moved(&mut tree, Point::new(21.0, 50.0));
1253
1254 assert!(
1255 tree.sequence_members(PointerId::MOUSE)
1256 .iter()
1257 .all(|(id, _, _)| *id != ancestor),
1258 "the dead member is dropped from the arbitration"
1259 );
1260 }
1261
1262 /// The captor dying is a different act: the press belonged to it, so the
1263 /// whole pointer is cancelled and the capture it left behind is given back.
1264 #[test]
1265 fn a_destroyed_captor_cancels_the_whole_pointer() {
1266 let log = log();
1267 let slot = Rc::new(std::cell::Cell::new(None));
1268 let mut tree = WidgetTree::new();
1269 let leaf = tree.add(grip(&log, slot.clone()));
1270 slot.set(Some(leaf));
1271 let host = tree.add(StackWidget::new().child(leaf));
1272 tree.layout(SizeProposal::exact(200.0, 100.0));
1273
1274 press(&mut tree, Point::new(20.0, 50.0));
1275 assert_eq!(tree.captured_by(PointerId::MOUSE), Some(leaf));
1276
1277 // Destroy the captor without a layout in between, so the sequence's own
1278 // revalidation is what notices — the mandated hook.
1279 tree.destroy_subtree_for_testing(host);
1280 moved(&mut tree, Point::new(21.0, 50.0));
1281
1282 assert_eq!(
1283 tree.captured_by(PointerId::MOUSE),
1284 None,
1285 "the orphaned capture is given back"
1286 );
1287 assert!(
1288 tree.sequence_members(PointerId::MOUSE).is_empty(),
1289 "and the arbitration is over"
1290 );
1291 tree.assert_no_leaked_pointer_state();
1292 }
1293
1294 // ---------------------------------------------------------------
1295 // Producer: an OS drag taking the pointer
1296 // ---------------------------------------------------------------
1297
1298 /// Escalating an in-app drag to the OS hands the pointer to the platform:
1299 /// this window sees no further move and no `Up`, so whatever the press
1300 /// still had going has to be revoked here.
1301 #[test]
1302 fn escalating_to_an_os_drag_cancels_the_source_pointer() {
1303 /// Stands in for the platform backend: accepts the hand-off and
1304 /// records that it did.
1305 struct AcceptingOps {
1306 began: std::cell::Cell<bool>,
1307 }
1308 impl crate::window::WindowOps for AcceptingOps {
1309 fn open_window(
1310 &mut self,
1311 _config: crate::window::WindowConfig,
1312 ) -> crate::window::TeksiloWindowId {
1313 panic!("not used in this test")
1314 }
1315 fn find_window(&self, _id: &str) -> Option<crate::window::TeksiloWindowId> {
1316 None
1317 }
1318 fn window_state(
1319 &self,
1320 _id: crate::window::TeksiloWindowId,
1321 ) -> Option<crate::window::WindowState> {
1322 None
1323 }
1324 fn windows(&self) -> Vec<crate::window::WindowState> {
1325 Vec::new()
1326 }
1327 fn focus_window(&mut self, _id: crate::window::TeksiloWindowId) {}
1328 fn close_window_by_id(&mut self, _id: crate::window::TeksiloWindowId) {}
1329 fn begin_os_drag(
1330 &mut self,
1331 _data: crate::drag_payload::OutboundDragData,
1332 _image: Option<crate::drag_payload::DragImageData>,
1333 _pointer: teksilo_tokens::PointerKind,
1334 ) -> bool {
1335 self.began.set(true);
1336 true
1337 }
1338 }
1339
1340 let log = log();
1341 let slot = Rc::new(std::cell::Cell::new(None));
1342 let mut tree = WidgetTree::new();
1343 let source = tree.add(recorder(&log, slot.clone()));
1344 slot.set(Some(source));
1345 tree.layout(SizeProposal::exact(200.0, 100.0));
1346
1347 let mut ops = AcceptingOps {
1348 began: std::cell::Cell::new(false),
1349 };
1350 press(&mut tree, Point::new(20.0, 50.0));
1351
1352 let mut ctx = crate::widget::EventContext::new();
1353 ctx.start_drag(
1354 source,
1355 crate::drag_payload::DragPayload::typed(7_u32).with_mime("text/plain", b"7".to_vec()),
1356 );
1357 tree.collect_from_ctx(ctx, source);
1358 assert!(tree.active_drag.is_some());
1359
1360 // Out of the window: the drag escalates.
1361 tree.dispatch_event_with_ops(WidgetEvent::pointer_move(Point::new(-40.0, 50.0)), &mut ops);
1362
1363 assert!(ops.began.get(), "the platform took the drag");
1364 assert_eq!(
1365 *log.borrow(),
1366 vec![(source, CancelReason::OsDragStarted)],
1367 "and the source is told the in-app half of the interaction is over"
1368 );
1369 tree.assert_no_leaked_pointer_state();
1370 }
1371
1372 // ---------------------------------------------------------------
1373 // The queue, and terminality
1374 // ---------------------------------------------------------------
1375
1376 /// A cancel raised from inside a handler is **queued**: the handler that
1377 /// raised it finishes on the state it started with, and the teardown runs
1378 /// afterwards — before the top-level dispatch returns.
1379 #[test]
1380 fn a_cancel_raised_from_a_handler_is_queued_not_reentrant() {
1381 let observed_capture = Rc::new(std::cell::Cell::new(None));
1382 let cancelled = Rc::new(std::cell::Cell::new(false));
1383
1384 let mut tree = WidgetTree::new();
1385 let leaf = {
1386 let seen = observed_capture.clone();
1387 let done = cancelled.clone();
1388 tree.add(
1389 FillWidget::new()
1390 .on_pointer_event(move |event, ctx| {
1391 if matches!(event, WidgetEvent::PointerDown { .. }) {
1392 ctx.capture_pointer();
1393 }
1394 if matches!(event, WidgetEvent::PointerMove { .. }) {
1395 ctx.cancel_pointer_sequence(CancelReason::Deactivated);
1396 // Still owned, right here: the cancel has not run.
1397 seen.set(Some(ctx.owns_pointer()));
1398 }
1399 EventResponse::Ignored
1400 })
1401 .on_pointer_cancel(move |_p, _reason, _ctx| done.set(true)),
1402 )
1403 };
1404 tree.layout(SizeProposal::exact(200.0, 100.0));
1405
1406 press(&mut tree, Point::new(20.0, 50.0));
1407 moved(&mut tree, Point::new(30.0, 50.0));
1408
1409 assert_eq!(
1410 observed_capture.get(),
1411 Some(true),
1412 "the handler that raised the cancel still owned the pointer when it returned"
1413 );
1414 assert!(
1415 cancelled.get(),
1416 "and the teardown ran once the sample finished"
1417 );
1418 assert!(
1419 !tree.has_pending_dispatch(),
1420 "the queue is empty again before the dispatch returns"
1421 );
1422 assert_eq!(tree.captured_by(PointerId::MOUSE), None);
1423 assert_eq!(tree.arena.get(leaf).map(|_| ()), Some(()));
1424 tree.assert_no_leaked_pointer_state();
1425 }
1426
1427 /// A cancel that a handler raises against a pointer which lifts in the very
1428 /// same sample must not fire: by the time the queue drains there is no
1429 /// interaction left to revoke.
1430 #[test]
1431 fn a_cancel_does_not_fire_if_the_pointer_lifted_in_the_same_sample() {
1432 let log = log();
1433 let slot = Rc::new(std::cell::Cell::new(None));
1434
1435 let mut tree = WidgetTree::new();
1436 let leaf = {
1437 let l = log.clone();
1438 let s = slot.clone();
1439 tree.add(
1440 FillWidget::new()
1441 .on_pointer_event(|event, ctx| {
1442 if matches!(event, WidgetEvent::PointerDown { .. }) {
1443 ctx.capture_pointer();
1444 }
1445 if matches!(event, WidgetEvent::PointerUp { .. }) {
1446 // The release itself asks for a cancel — the shape
1447 // a menu item's "close my menu" handler has.
1448 ctx.cancel_pointer_sequence(CancelReason::OverlayDismissed);
1449 }
1450 EventResponse::Ignored
1451 })
1452 .on_pointer_cancel(move |_p, reason, _ctx| {
1453 let id = s.get().expect("the leaf's id was never recorded");
1454 l.borrow_mut().push((id, reason));
1455 }),
1456 )
1457 };
1458 slot.set(Some(leaf));
1459 tree.layout(SizeProposal::exact(200.0, 100.0));
1460
1461 let contact = new_contact();
1462 tree.dispatch_pointer(touch(contact, PointerPhase::Down, Point::new(20.0, 50.0)));
1463 tree.dispatch_pointer(touch(contact, PointerPhase::Up, Point::new(20.0, 50.0)));
1464
1465 assert!(
1466 log.borrow().is_empty(),
1467 "the press was already over when the queued cancel drained: {:?}",
1468 log.borrow()
1469 );
1470 tree.assert_no_leaked_pointer_state();
1471 }
1472
1473 /// `PointerCancel` is terminal. An `Up` that arrives for a press the system
1474 /// already took away completes nothing — the widget has been told to let
1475 /// go, and handing it back the release would resurrect an interaction that
1476 /// no longer exists.
1477 #[test]
1478 fn no_pointer_up_follows_a_cancel() {
1479 let ups = Rc::new(std::cell::Cell::new(0_u32));
1480 let cancels = Rc::new(std::cell::Cell::new(0_u32));
1481
1482 let mut tree = WidgetTree::new();
1483 {
1484 let u = ups.clone();
1485 let c = cancels.clone();
1486 tree.add(
1487 FillWidget::new()
1488 .on_tap(move |_e, _ctx| u.set(u.get() + 1))
1489 .on_pointer_cancel(move |_p, _reason, _ctx| c.set(c.get() + 1)),
1490 );
1491 }
1492 tree.layout(SizeProposal::exact(200.0, 100.0));
1493
1494 press(&mut tree, Point::new(20.0, 50.0));
1495 tree.set_window_active(false);
1496 assert_eq!(cancels.get(), 1);
1497
1498 // The platform (or a confused caller) sends the release anyway.
1499 release(&mut tree, Point::new(20.0, 50.0));
1500 assert_eq!(ups.get(), 0, "the tap must not complete after its cancel");
1501
1502 // …and the next press is a fresh interaction, unaffected.
1503 tree.set_window_active(true);
1504 press(&mut tree, Point::new(20.0, 50.0));
1505 release(&mut tree, Point::new(20.0, 50.0));
1506 assert_eq!(ups.get(), 1, "the next press works normally");
1507 tree.assert_no_leaked_pointer_state();
1508 }
1509
1510 /// The whole-pointer and member-level granularities are different acts,
1511 /// and the difference has to be visible from outside: a member revocation
1512 /// leaves the pointer, its capture and its winner exactly where they were.
1513 #[test]
1514 fn revoking_a_member_leaves_the_pointer_alive() {
1515 let mut tree = WidgetTree::new();
1516 let child = tree.add(FillWidget::new().on_tap(|_e, _c| {}));
1517 let ancestor = tree.add(StackWidget::new().child(child).on_drag(|_phase, _c| {}));
1518 tree.layout(SizeProposal::exact(400.0, 100.0));
1519
1520 press(&mut tree, Point::new(20.0, 50.0));
1521 let mut noop = crate::window::NoopWindowOps;
1522 tree.revoke_sequence_member(
1523 PointerId::MOUSE,
1524 ancestor,
1525 CancelReason::PeerClaimed,
1526 &mut noop,
1527 );
1528
1529 assert_eq!(
1530 tree.captured_by(PointerId::MOUSE),
1531 Some(child),
1532 "the capture survives a member revocation"
1533 );
1534 assert!(
1535 tree.pointers.get(PointerId::MOUSE).is_some(),
1536 "and so does the pointer"
1537 );
1538
1539 release(&mut tree, Point::new(20.0, 50.0));
1540 tree.assert_no_leaked_pointer_state();
1541 }
1542
1543 /// A drag the cancelled pointer was driving ends as `Cancelled`, not as a
1544 /// drop: releasing the payload wherever the pointer happened to be is how
1545 /// a lost window focus turns into a data-loss bug.
1546 #[test]
1547 fn cancelling_the_pointer_driving_a_drag_cancels_the_drag() {
1548 let outcome = Rc::new(RefCell::new(None));
1549
1550 let mut tree = WidgetTree::new();
1551 let source = {
1552 let o = outcome.clone();
1553 tree.add(FillWidget::new().on_drag_ended(move |result, _ctx| {
1554 *o.borrow_mut() = Some(result);
1555 }))
1556 };
1557 tree.layout(SizeProposal::exact(200.0, 100.0));
1558
1559 press(&mut tree, Point::new(20.0, 50.0));
1560 let mut ctx = crate::widget::EventContext::new();
1561 ctx.start_drag(source, crate::drag_payload::DragPayload::typed(1_u8));
1562 tree.collect_from_ctx(ctx, source);
1563 assert!(tree.active_drag.is_some());
1564
1565 tree.set_window_active(false);
1566
1567 assert!(tree.active_drag.is_none(), "the drag session is torn down");
1568 assert_eq!(
1569 *outcome.borrow(),
1570 Some(crate::drag_payload::DropOutcome::Cancelled),
1571 "and its source is told it was cancelled, not dropped"
1572 );
1573 tree.assert_no_leaked_pointer_state();
1574 }
1575
1576 /// A drag mid-flight is exactly the case the funnel exists for. Its phases
1577 /// must not report an `Ended` — that is the `Up` story — and the widget
1578 /// must hear about the revocation instead.
1579 #[test]
1580 fn a_cancelled_drag_reports_no_ended_phase() {
1581 let phases = Rc::new(RefCell::new(Vec::new()));
1582 let cancels = Rc::new(std::cell::Cell::new(0_u32));
1583
1584 let mut tree = WidgetTree::new();
1585 {
1586 let p = phases.clone();
1587 let c = cancels.clone();
1588 tree.add(
1589 FillWidget::new()
1590 .on_drag(move |phase, _ctx| {
1591 p.borrow_mut().push(std::mem::discriminant(&phase));
1592 })
1593 .on_pointer_cancel(move |_p, _reason, _ctx| c.set(c.get() + 1)),
1594 );
1595 }
1596 tree.layout(SizeProposal::exact(400.0, 100.0));
1597
1598 press(&mut tree, Point::new(20.0, 50.0));
1599 for step in 1..=3 {
1600 moved(&mut tree, Point::new(20.0 + step as f32 * 10.0, 50.0));
1601 }
1602 let before = phases.borrow().len();
1603 assert!(before >= 2, "the drag started and moved");
1604
1605 tree.set_window_active(false);
1606
1607 assert_eq!(cancels.get(), 1, "the dragging widget is told");
1608 assert_eq!(
1609 phases.borrow().len(),
1610 before,
1611 "and no further drag phase — least of all an Ended — is reported"
1612 );
1613 tree.assert_no_leaked_pointer_state();
1614 }
1615
1616 /// A cancelled hovering pointer stops reading as held.
1617 ///
1618 /// A contact that is taken away leaves the table; a mouse or a pen does
1619 /// not, and the entry that stays behind is what every singular accessor
1620 /// reads. Its button mask is what `PointerEntry::is_contacting` answers
1621 /// from, and a `Cancel` sample never reaches `PointerTable::admit` — so
1622 /// before this was cleared, a stylus whose press was revoked by a modal, a
1623 /// window deactivation or an OS drag went on reporting a held tip until the
1624 /// next real sample, and `assert_no_leaked_pointer_state` said so.
1625 ///
1626 /// A **pen**, not a mouse, because the legacy `WidgetEvent::PointerDown`
1627 /// path admits `PointerInfo::mouse`, whose mask is empty — so a mouse press
1628 /// has never read as contacting in the first place and could not show this.
1629 /// The pen helpers build the sample the platform translator builds, mask
1630 /// included.
1631 #[test]
1632 fn a_cancelled_hovering_pointer_stops_reading_as_held() {
1633 let mut tree = WidgetTree::new();
1634 let id_slot = Rc::new(std::cell::Cell::new(None::<WidgetId>));
1635 let cancels = log();
1636 // Built in one chain rather than through `recorder`: a second
1637 // `WidgetBuilder` call on an already-wrapped `impl Widget` re-wraps
1638 // rather than adding to the set it already has, and one wrapper is
1639 // cheaper than two. The inner set is no longer lost either way —
1640 // `Widget::take_handler_set` recurses and merges.
1641 let recorded = cancels.clone();
1642 let slot = id_slot.clone();
1643 let node = tree.add(
1644 FillWidget::new()
1645 .on_pointer_cancel(move |_pointer, reason, _ctx| {
1646 let id = slot.get().expect("the recorder's id was never recorded");
1647 recorded.borrow_mut().push((id, reason));
1648 })
1649 .on_tap(|_e, _c| {}),
1650 );
1651 id_slot.set(Some(node));
1652 tree.layout(SizeProposal::exact(100.0, 100.0));
1653
1654 tree.pen_down(Point::new(50.0, 50.0), 0.5, (0.0, 0.0));
1655 let pen = tree
1656 .live_pointers()
1657 .find(|p| matches!(p.kind, teksilo_tokens::PointerKind::Pen(_)))
1658 .map(|p| p.id)
1659 .expect("the pen was admitted");
1660 assert!(
1661 tree.pointers.get(pen).is_some_and(|e| e.is_contacting()),
1662 "the tip is on the surface"
1663 );
1664
1665 let mut noop = crate::window::NoopWindowOps;
1666 tree.cancel_pointer(pen, CancelReason::ModalOpened, &mut noop);
1667
1668 assert_eq!(
1669 cancels.borrow().as_slice(),
1670 &[(node, CancelReason::ModalOpened)],
1671 "the node was told"
1672 );
1673 assert!(
1674 tree.pointers.get(pen).is_some(),
1675 "a pen keeps its entry: it is still in proximity, still hovering"
1676 );
1677 assert!(
1678 !tree.pointers.get(pen).is_some_and(|e| e.is_contacting()),
1679 "…but it is no longer contacting anything"
1680 );
1681 tree.assert_no_leaked_pointer_state();
1682 }
1683}