Skip to main content

frust_widgets/
canvas.rs

1//! The `CanvasView`/`CanvasWidget` leaf: declarative custom painting over the
2//! [`PaintScene`] trait object, for an app that wants to paint custom graphics
3//! (a chart, a node-and-edge graph, a game board) without hand-rolling its own
4//! `View`/`Widget` pair.
5//!
6//! [`canvas`] takes a paint closure, `Fn(&mut dyn PaintScene, Size, &PaintCtx)`,
7//! that runs every time this widget actually paints. The widget pushes a
8//! translation onto the scene's transform stack (its own absolute origin) and a
9//! clip to its laid-out bounds before invoking the closure, then pops both —
10//! so the closure paints entirely in **local space**: `(0, 0)` is this widget's
11//! own top-left corner, regardless of where it sits in the tree, and a draw
12//! call past its own `size` is simply clipped rather than bleeding into a
13//! sibling.
14//!
15//! `.size(Size)`/`.expand()` choose the sizing policy — `.expand()` is the
16//! default, mirroring [`crate::platform_view`]'s builder, so an app that
17//! forgets to call either still gets a sensible full-bleed canvas rather than
18//! a degenerate zero-size one. `.on_hit(..)` narrows pointer hit-testing to an
19//! arbitrary shape (a circular node, a hex cell) — defaulted to the ordinary
20//! "inside the rectangular bounds" test every other baseline leaf uses (see
21//! `button.rs`'s own `inside` precedent) — consulted on `Down` (to decide
22//! whether this canvas claims the gesture at all, so a press outside a narrow
23//! hit-shape falls through to whatever sits beneath it) and again on `Up` (so
24//! a drag that left the shape resolves as no-op, matching the "fire on
25//! up-inside only" convention [`crate::Button`] already uses). `.on_tap(..)`/
26//! `.on_pointer(..)` are the two optional callbacks [`CanvasView::on_hit`]
27//! gates: `on_tap` fires once per up-inside release; `on_pointer` fires with
28//! the raw [`frust_core::PointerEvent`] on every phase the hit-test admits,
29//! for a caller that wants the full gesture (drag included) rather than just
30//! the tap summary.
31//!
32//! # Repaint-on-rebuild only, by default
33//!
34//! `CanvasWidget` requests no frame and no redraw of its own; it repaints
35//! exactly when the ordinary `View` diff already says so (a changed `.size`,
36//! or an ancestor's own dirtying). A paint closure's *captured* data can
37//! change from one rebuild to the next with nothing about the `CanvasView`
38//! itself comparably different — closures aren't comparable, so the widget
39//! can't detect that on its own. [`CanvasView::repaint_key`] is the escape
40//! hatch: pass any [`Hash`] fingerprint of whatever the closure reads (a tick
41//! counter, a node list's generation), and a changed hash across a rebuild
42//! requests [`ChangeFlags::PAINT`] even though nothing else about the view
43//! changed. Leaving it unset means the canvas repaints only when something
44//! else already triggers one (e.g. the first build, or a `.size` change).
45//!
46//! ```
47//! use frust_core::{PaintCtx, PaintScene, View};
48//! use frust_widgets::canvas;
49//! use kurbo::{Point, Size};
50//! use peniko::Color;
51//!
52//! struct State {
53//!     tick: u32,
54//! }
55//!
56//! fn graph(state: &State) -> impl View<State> + use<> {
57//!     let tick = state.tick;
58//!     canvas(move |scene: &mut dyn PaintScene, size: Size, _ctx: &PaintCtx| {
59//!         scene.fill_rect(Point::ZERO, size, Color::from_rgb8(0x20, 0x20, 0x20));
60//!     })
61//!     .expand()
62//!     .repaint_key(tick)
63//! }
64//! # let _ = graph;
65//! ```
66
67use std::hash::{Hash, Hasher};
68use std::rc::Rc;
69
70use frust_core::{
71    BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
72    PaintScene, PointerEvent, PointerPhase, View, Widget,
73};
74use kurbo::{Affine, Point, Size};
75
76use crate::authoring::presses;
77
78/// How a [`CanvasView`] resolves its layout size — the same shape
79/// [`crate::platform_view::PlatformViewView`]'s `SlotSize` uses, with
80/// `Expand` as the default for the same reason: a canvas most often wants to
81/// fill whatever space its container gives it.
82#[derive(Clone, Copy, Debug, PartialEq)]
83enum CanvasSize {
84    /// Fill the incoming constraints' maximum.
85    Expand,
86    /// A fixed size, clamped into the incoming constraints.
87    Explicit(Size),
88}
89
90/// The paint closure a [`CanvasView`] carries: renders into `&mut dyn
91/// PaintScene` (local space — see the [module docs](self)), given the
92/// widget's laid-out `Size` and the pass's own `&PaintCtx` (frame time, theme,
93/// and the rest of the paint-time context a themed/animated canvas needs).
94type CanvasPaint = Rc<dyn Fn(&mut dyn PaintScene, Size, &PaintCtx)>;
95
96/// An optional custom hit-test: `Fn(local_point, widget_size) -> bool`. See
97/// [`CanvasView::on_hit`].
98type HitTest = Rc<dyn Fn(Point, Size) -> bool>;
99
100/// A view-held tap callback (erased on build) — see [`CanvasView::on_tap`].
101type TapCallback<State> = Rc<dyn Fn(&mut State)>;
102
103/// A view-held pointer callback carrying the raw [`PointerEvent`] (erased on
104/// build) — see [`CanvasView::on_pointer`].
105type PointerCallback<State> = Rc<dyn Fn(&mut State, PointerEvent)>;
106
107/// A declarative custom-painting canvas. See the [module docs](self).
108pub struct CanvasView<State: 'static> {
109    paint: CanvasPaint,
110    hit: Option<HitTest>,
111    on_tap: Option<TapCallback<State>>,
112    on_pointer: Option<PointerCallback<State>>,
113    size: CanvasSize,
114    /// A hashed fingerprint of whatever external data the paint closure
115    /// reads — see [`CanvasView::repaint_key`].
116    repaint_key: Option<u64>,
117}
118
119/// Create a canvas that paints by calling `paint(scene, size, ctx)` every time
120/// it actually repaints — full-bleed by default (see [`CanvasView::expand`]).
121/// See the [module docs](self) for the local-space/clip contract and the
122/// builder methods that round out the declaration.
123pub fn canvas<State: 'static, F>(paint: F) -> CanvasView<State>
124where
125    F: Fn(&mut dyn PaintScene, Size, &PaintCtx) + 'static,
126{
127    CanvasView {
128        paint: Rc::new(paint),
129        hit: None,
130        on_tap: None,
131        on_pointer: None,
132        size: CanvasSize::Expand,
133        repaint_key: None,
134    }
135}
136
137impl<State: 'static> CanvasView<State> {
138    /// Force an explicit size, clamped into whatever constraints this
139    /// widget's parent hands it. Overrides [`CanvasView::expand`] when called
140    /// after it (last builder call wins, matching the crate's other
141    /// size-mode builders — e.g. [`crate::platform_view`]).
142    pub fn size(mut self, size: Size) -> Self {
143        self.size = CanvasSize::Explicit(size);
144        self
145    }
146
147    /// Fill the incoming constraints' maximum — the default (see the
148    /// [module docs](self)), provided as an explicit builder call for
149    /// readability at call sites that want to make the choice visible.
150    pub fn expand(mut self) -> Self {
151        self.size = CanvasSize::Expand;
152        self
153    }
154
155    /// Narrow pointer hit-testing to `hit(local_point, size) -> bool`,
156    /// consulted before [`CanvasView::on_tap`]/[`CanvasView::on_pointer`]
157    /// dispatch (see the [module docs](self)). Unset, hit-testing falls back
158    /// to the ordinary "inside the rectangular bounds" test every other
159    /// baseline leaf uses.
160    pub fn on_hit<F: Fn(Point, Size) -> bool + 'static>(mut self, hit: F) -> Self {
161        self.hit = Some(Rc::new(hit));
162        self
163    }
164
165    /// Fire `on_tap(state)` on an up-inside release (gated by
166    /// [`CanvasView::on_hit`], see the [module docs](self)).
167    pub fn on_tap<F: Fn(&mut State) + 'static>(mut self, on_tap: F) -> Self {
168        self.on_tap = Some(Rc::new(on_tap));
169        self
170    }
171
172    /// Fire `on_pointer(state, event)` with the raw [`PointerEvent`] on every
173    /// phase the hit-test admits (gated by [`CanvasView::on_hit`], see the
174    /// [module docs](self)) — the escape hatch for a caller that wants the
175    /// full gesture (drag included) rather than just the tap summary.
176    pub fn on_pointer<F: Fn(&mut State, PointerEvent) + 'static>(mut self, on_pointer: F) -> Self {
177        self.on_pointer = Some(Rc::new(on_pointer));
178        self
179    }
180
181    /// Opt into paint-only dirtying driven by a dependency outside the
182    /// ordinary `View` diff: hash any [`Hash`] fingerprint of the data the
183    /// paint closure reads, and a changed hash across a rebuild requests
184    /// [`ChangeFlags::PAINT`] even when nothing else about the view changed.
185    /// See the [module docs](self#repaint-on-rebuild-only-by-default).
186    pub fn repaint_key(mut self, key: impl Hash) -> Self {
187        let mut hasher = std::collections::hash_map::DefaultHasher::new();
188        key.hash(&mut hasher);
189        self.repaint_key = Some(hasher.finish());
190        self
191    }
192}
193
194/// `true` when `pos` falls within `size`'s rectangular bounds — the default
195/// hit test when [`CanvasView::on_hit`] is unset (mirrors `button.rs`'s
196/// `inside` helper, the same convention).
197fn inside(pos: Point, size: Size) -> bool {
198    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
199}
200
201/// The retained widget for a [`CanvasView`]. See the [module docs](self).
202pub struct CanvasWidget {
203    paint: CanvasPaint,
204    hit: Option<HitTest>,
205    on_tap: Option<crate::authoring::ErasedCallback>,
206    on_pointer: Option<crate::authoring::ErasedArgCallback<PointerEvent>>,
207    size: CanvasSize,
208    repaint_key: Option<u64>,
209    /// Armed by a hit-passing `Down` (alongside `capture_pointer`), cleared
210    /// on `Up`/`Cancel` — mirrors [`crate::Slider`]/[`crate::Button`]'s own
211    /// `captured` flag.
212    captured: bool,
213}
214
215impl CanvasWidget {
216    /// Consult [`Self::hit`] if set, else fall back to [`inside`].
217    fn hit_test(&self, point: Point, size: Size) -> bool {
218        match &self.hit {
219            Some(hit) => hit(point, size),
220            None => inside(point, size),
221        }
222    }
223}
224
225impl<State: 'static> View<State> for CanvasView<State> {
226    type Element = CanvasWidget;
227
228    fn build(&self, _ctx: &mut BuildCtx<'_>) -> CanvasWidget {
229        CanvasWidget {
230            paint: self.paint.clone(),
231            hit: self.hit.clone(),
232            on_tap: self.on_tap.as_ref().map(crate::authoring::erase_callback),
233            on_pointer: self
234                .on_pointer
235                .as_ref()
236                .map(crate::authoring::erase_callback_arg),
237            size: self.size,
238            repaint_key: self.repaint_key,
239            captured: false,
240        }
241    }
242
243    fn rebuild(
244        &self,
245        prev: &Self,
246        element: &mut CanvasWidget,
247        _ctx: &mut BuildCtx<'_>,
248    ) -> ChangeFlags {
249        let mut flags = ChangeFlags::NONE;
250
251        // The closure/hit/callbacks are reinstalled unconditionally every
252        // rebuild — closures aren't comparable, matching every other
253        // interactive widget's `erase_callback*` convention (see
254        // `authoring::erase_callback`'s own doc comment).
255        element.paint = self.paint.clone();
256        element.hit = self.hit.clone();
257        element.on_tap = self.on_tap.as_ref().map(crate::authoring::erase_callback);
258        element.on_pointer = self
259            .on_pointer
260            .as_ref()
261            .map(crate::authoring::erase_callback_arg);
262
263        if prev.size != self.size {
264            element.size = self.size;
265            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
266        }
267
268        // See the module docs' "Repaint-on-rebuild only" section: a changed
269        // (or newly set/cleared) repaint_key is the only signal that content
270        // behind the otherwise-incomparable paint closure actually changed.
271        if prev.repaint_key != self.repaint_key {
272            flags |= ChangeFlags::PAINT;
273        }
274        element.repaint_key = self.repaint_key;
275
276        flags
277    }
278}
279
280impl Widget for CanvasWidget {
281    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
282        match self.size {
283            CanvasSize::Explicit(size) => bc.constrain(size),
284            CanvasSize::Expand => bc.max(),
285        }
286    }
287
288    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
289        let origin = ctx.origin();
290        let size = ctx.size();
291        // Local-space contract (see the module docs): translate so the
292        // closure's own (0, 0) is this widget's top-left corner, and clip to
293        // its bounds so an over-painting closure is clipped, not a bug to
294        // chase.
295        scene.push_transform(Affine::translate(origin.to_vec2()));
296        scene.push_clip(Point::ZERO, size);
297        (self.paint)(scene, size, ctx);
298        scene.pop_clip();
299        scene.pop_transform();
300    }
301
302    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
303        let InputEvent::Pointer(p) = event else {
304            return EventResult::Ignored;
305        };
306        match p.phase {
307            PointerPhase::Down => {
308                // A hit-failing Down claims nothing: it falls through so
309                // whatever sits beneath a narrow hit-shape (a sibling in a
310                // `Stack`, a `PanZoomView` ancestor) still sees it.
311                if !presses(p) || !self.hit_test(p.position, ctx.size()) {
312                    return EventResult::Ignored;
313                }
314                self.captured = true;
315                ctx.capture_pointer();
316                if let Some(cb) = self.on_pointer.as_mut() {
317                    cb(ctx, *p);
318                }
319                ctx.request_redraw();
320                EventResult::Handled
321            }
322            PointerPhase::Move => {
323                if !self.captured {
324                    return EventResult::Ignored;
325                }
326                if let Some(cb) = self.on_pointer.as_mut() {
327                    cb(ctx, *p);
328                    ctx.request_redraw();
329                }
330                EventResult::Handled
331            }
332            PointerPhase::Up => {
333                if !self.captured {
334                    return EventResult::Ignored;
335                }
336                self.captured = false;
337                // Fire on up-inside only (the same masonry semantics
338                // `crate::Button` uses), "inside" being whatever hit-test is
339                // active.
340                if self.hit_test(p.position, ctx.size()) {
341                    if let Some(cb) = self.on_pointer.as_mut() {
342                        cb(ctx, *p);
343                    }
344                    if let Some(cb) = self.on_tap.as_mut() {
345                        cb(ctx);
346                    }
347                }
348                ctx.request_redraw();
349                EventResult::Handled
350            }
351            PointerPhase::Cancel => {
352                if !self.captured {
353                    return EventResult::Ignored;
354                }
355                // Cancel only clears internal flags — never touches app
356                // state (no callback), per the interaction-semantics
357                // convention.
358                self.captured = false;
359                EventResult::Handled
360            }
361        }
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use frust_core::{BuildCtx, PointerButton};
369    use peniko::Color;
370    use std::any::Any;
371    use std::cell::RefCell;
372
373    fn build<State: 'static>(view: &CanvasView<State>) -> CanvasWidget {
374        let mut counter = 0u64;
375        View::<State>::build(view, &mut BuildCtx::new(&mut counter))
376    }
377
378    fn rebuild<State: 'static>(
379        prev: &CanvasView<State>,
380        next: &CanvasView<State>,
381        element: &mut CanvasWidget,
382    ) -> ChangeFlags {
383        let mut counter = 0u64;
384        View::<State>::rebuild(next, prev, element, &mut BuildCtx::new(&mut counter))
385    }
386
387    /// Records every `PaintScene` call relevant to the tests below, in order.
388    #[derive(Default)]
389    struct Recorder {
390        ops: Vec<Op>,
391    }
392
393    #[derive(Debug, PartialEq)]
394    enum Op {
395        Transform(Affine),
396        PopTransform,
397        Clip(Point, Size),
398        PopClip,
399        Fill(Point, Size, Color),
400    }
401
402    impl PaintScene for Recorder {
403        fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
404            self.ops.push(Op::Fill(origin, size, color));
405        }
406        fn draw_text(&mut self, _origin: Point, _text: &str) {}
407        fn push_transform(&mut self, transform: Affine) {
408            self.ops.push(Op::Transform(transform));
409        }
410        fn pop_transform(&mut self) {
411            self.ops.push(Op::PopTransform);
412        }
413        fn push_clip(&mut self, origin: Point, size: Size) {
414            self.ops.push(Op::Clip(origin, size));
415        }
416        fn pop_clip(&mut self) {
417            self.ops.push(Op::PopClip);
418        }
419    }
420
421    fn pointer_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
422        InputEvent::Pointer(PointerEvent {
423            phase,
424            position: Point::new(x, y),
425            button: PointerButton::Primary,
426        })
427    }
428
429    // -- layout -------------------------------------------------------------
430
431    #[test]
432    fn expand_fills_the_incoming_constraints_max() {
433        let view = canvas::<(), _>(|_scene, _size, _ctx| {});
434        let mut w = build(&view);
435        let mut lctx = LayoutCtx::new();
436        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(300.0, 200.0)));
437        assert_eq!(size, Size::new(300.0, 200.0));
438    }
439
440    #[test]
441    fn explicit_size_is_clamped_into_constraints() {
442        let view = canvas::<(), _>(|_scene, _size, _ctx| {}).size(Size::new(1000.0, 1000.0));
443        let mut w = build(&view);
444        let mut lctx = LayoutCtx::new();
445        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(300.0, 200.0)));
446        assert_eq!(size, Size::new(300.0, 200.0));
447    }
448
449    // -- paint: local space + laid-out size + clip ---------------------------
450
451    #[test]
452    fn paint_runs_the_closure_with_the_laid_out_size_translated_and_clipped() {
453        let seen_size = Rc::new(RefCell::new(None));
454        let seen_size_cb = seen_size.clone();
455        let view = canvas::<(), _>(move |scene, size, _ctx| {
456            *seen_size_cb.borrow_mut() = Some(size);
457            scene.fill_rect(Point::new(1.0, 2.0), Size::new(3.0, 4.0), Color::BLACK);
458        });
459        let mut w = build(&view);
460        let mut lctx = LayoutCtx::new();
461        let laid_out = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(80.0, 60.0)));
462        assert_eq!(laid_out, Size::new(80.0, 60.0));
463
464        let mut rec = Recorder::default();
465        let mut pctx = PaintCtx::new(Point::new(10.0, 20.0), Size::new(80.0, 60.0));
466        w.paint(&mut pctx, &mut rec);
467
468        assert_eq!(
469            *seen_size.borrow(),
470            Some(Size::new(80.0, 60.0)),
471            "the closure sees the laid-out size"
472        );
473        assert_eq!(
474            rec.ops,
475            vec![
476                Op::Transform(Affine::translate((10.0, 20.0))),
477                Op::Clip(Point::ZERO, Size::new(80.0, 60.0)),
478                Op::Fill(Point::new(1.0, 2.0), Size::new(3.0, 4.0), Color::BLACK),
479                Op::PopClip,
480                Op::PopTransform,
481            ],
482            "the widget pushes its absolute origin as a transform and clips to \
483             its bounds before the closure paints, then pops both"
484        );
485    }
486
487    // -- repaint_key ----------------------------------------------------------
488
489    #[test]
490    fn unset_repaint_key_requests_no_paint_flag_on_an_otherwise_unchanged_rebuild() {
491        let prev = canvas::<(), _>(|_scene, _size, _ctx| {});
492        let mut w = build(&prev);
493        let next = canvas::<(), _>(|_scene, _size, _ctx| {});
494        let flags = rebuild(&prev, &next, &mut w);
495        assert_eq!(
496            flags,
497            ChangeFlags::NONE,
498            "no repaint_key set: an otherwise-unchanged rebuild requests nothing"
499        );
500    }
501
502    #[test]
503    fn a_changed_repaint_key_requests_paint() {
504        let prev = canvas::<(), _>(|_scene, _size, _ctx| {}).repaint_key(1u32);
505        let mut w = build(&prev);
506        let next = canvas::<(), _>(|_scene, _size, _ctx| {}).repaint_key(2u32);
507        let flags = rebuild(&prev, &next, &mut w);
508        assert!(flags.needs_paint(), "a changed repaint_key requests PAINT");
509    }
510
511    #[test]
512    fn an_unchanged_repaint_key_requests_no_paint() {
513        let prev = canvas::<(), _>(|_scene, _size, _ctx| {}).repaint_key(7u32);
514        let mut w = build(&prev);
515        let next = canvas::<(), _>(|_scene, _size, _ctx| {}).repaint_key(7u32);
516        let flags = rebuild(&prev, &next, &mut w);
517        assert_eq!(
518            flags,
519            ChangeFlags::NONE,
520            "an unchanged repaint_key requests nothing"
521        );
522    }
523
524    // -- on_hit gates the tap callback ----------------------------------------
525
526    #[derive(Default)]
527    struct TapState {
528        taps: u32,
529        pointers: u32,
530    }
531
532    fn dispatch(w: &mut CanvasWidget, state: &mut TapState, event: &InputEvent, size: Size) {
533        let state_any: &mut dyn Any = state;
534        let mut ctx = EventCtx::new(state_any, Point::ZERO, size);
535        w.event(&mut ctx, event);
536    }
537
538    #[test]
539    fn with_no_on_hit_a_tap_inside_the_rect_fires() {
540        let view = canvas::<TapState, _>(|_scene, _size, _ctx| {}).on_tap(|s: &mut TapState| {
541            s.taps += 1;
542        });
543        let mut w = build(&view);
544        let mut state = TapState::default();
545        let size = Size::new(100.0, 100.0);
546        dispatch(
547            &mut w,
548            &mut state,
549            &pointer_ev(PointerPhase::Down, 10.0, 10.0),
550            size,
551        );
552        dispatch(
553            &mut w,
554            &mut state,
555            &pointer_ev(PointerPhase::Up, 10.0, 10.0),
556            size,
557        );
558        assert_eq!(state.taps, 1, "the default rectangular hit-test admits it");
559    }
560
561    #[test]
562    fn on_hit_rejects_a_down_outside_its_shape_never_capturing() {
563        // A circular hit-shape, radius 10, centered in a 100x100 box: a Down
564        // at a corner (well outside the circle but inside the rect) must be
565        // rejected, and no capture should ever open — the event is left for
566        // whatever sits beneath this canvas.
567        let view = canvas::<TapState, _>(|_scene, _size, _ctx| {})
568            .on_hit(|p: Point, size: Size| {
569                let center = Point::new(size.width / 2.0, size.height / 2.0);
570                (p - center).hypot() <= 10.0
571            })
572            .on_tap(|s: &mut TapState| s.taps += 1)
573            .on_pointer(|s: &mut TapState, _ev| s.pointers += 1);
574        let mut w = build(&view);
575        let mut state = TapState::default();
576        let size = Size::new(100.0, 100.0);
577
578        dispatch(
579            &mut w,
580            &mut state,
581            &pointer_ev(PointerPhase::Down, 1.0, 1.0),
582            size,
583        );
584        assert!(!w.captured, "a hit-failing Down must not open a capture");
585        assert_eq!(state.pointers, 0);
586
587        // A follow-up Up (as if this canvas somehow still saw it) is a no-op
588        // too, since nothing captured.
589        dispatch(
590            &mut w,
591            &mut state,
592            &pointer_ev(PointerPhase::Up, 1.0, 1.0),
593            size,
594        );
595        assert_eq!(state.taps, 0);
596        assert_eq!(state.pointers, 0);
597    }
598
599    #[test]
600    fn on_hit_admits_a_down_inside_its_shape_and_the_tap_fires_on_up() {
601        let view = canvas::<TapState, _>(|_scene, _size, _ctx| {})
602            .on_hit(|p: Point, size: Size| {
603                let center = Point::new(size.width / 2.0, size.height / 2.0);
604                (p - center).hypot() <= 10.0
605            })
606            .on_tap(|s: &mut TapState| s.taps += 1)
607            .on_pointer(|s: &mut TapState, _ev| s.pointers += 1);
608        let mut w = build(&view);
609        let mut state = TapState::default();
610        let size = Size::new(100.0, 100.0);
611        let center = (50.0, 50.0);
612
613        dispatch(
614            &mut w,
615            &mut state,
616            &pointer_ev(PointerPhase::Down, center.0, center.1),
617            size,
618        );
619        assert!(w.captured, "a hit-passing Down opens a capture");
620        dispatch(
621            &mut w,
622            &mut state,
623            &pointer_ev(PointerPhase::Up, center.0, center.1),
624            size,
625        );
626        assert_eq!(state.taps, 1, "the up-inside release fires the tap");
627        assert_eq!(state.pointers, 2, "on_pointer rides along on Down and Up");
628    }
629
630    #[test]
631    fn a_captured_press_released_outside_the_hit_shape_does_not_tap() {
632        // Down inside the circle (captures), drag/release outside it: the
633        // "fire on up-inside only" convention means no tap fires, mirroring
634        // `crate::Button`'s own up-inside semantics.
635        let view = canvas::<TapState, _>(|_scene, _size, _ctx| {})
636            .on_hit(|p: Point, size: Size| {
637                let center = Point::new(size.width / 2.0, size.height / 2.0);
638                (p - center).hypot() <= 10.0
639            })
640            .on_tap(|s: &mut TapState| s.taps += 1);
641        let mut w = build(&view);
642        let mut state = TapState::default();
643        let size = Size::new(100.0, 100.0);
644
645        dispatch(
646            &mut w,
647            &mut state,
648            &pointer_ev(PointerPhase::Down, 50.0, 50.0),
649            size,
650        );
651        assert!(w.captured);
652        dispatch(
653            &mut w,
654            &mut state,
655            &pointer_ev(PointerPhase::Move, 90.0, 90.0),
656            size,
657        );
658        dispatch(
659            &mut w,
660            &mut state,
661            &pointer_ev(PointerPhase::Up, 90.0, 90.0),
662            size,
663        );
664        assert_eq!(state.taps, 0, "a release outside the hit-shape never taps");
665        assert!(!w.captured, "capture still clears on Up regardless");
666    }
667
668    #[test]
669    fn cancel_clears_captured_state_and_fires_nothing() {
670        let view = canvas::<TapState, _>(|_scene, _size, _ctx| {}).on_tap(|s: &mut TapState| {
671            s.taps += 1;
672        });
673        let mut w = build(&view);
674        let mut state = TapState::default();
675        let size = Size::new(100.0, 100.0);
676        dispatch(
677            &mut w,
678            &mut state,
679            &pointer_ev(PointerPhase::Down, 10.0, 10.0),
680            size,
681        );
682        assert!(w.captured);
683        dispatch(
684            &mut w,
685            &mut state,
686            &pointer_ev(PointerPhase::Cancel, 10.0, 10.0),
687            size,
688        );
689        assert!(!w.captured, "Cancel disarms the press");
690        dispatch(
691            &mut w,
692            &mut state,
693            &pointer_ev(PointerPhase::Up, 10.0, 10.0),
694            size,
695        );
696        assert_eq!(
697            state.taps, 0,
698            "an Up after Cancel is a no-op (not captured)"
699        );
700    }
701}