Skip to main content

telar_ui_core/
styled_container.rs

1use geometry_core::{Rect, Transform};
2use layout_core::{LayoutError, LayoutStyle, NodeId};
3use platform_core::{Event, Key, NamedKey, PointerButton, PointerSource, ScrollDelta};
4use reactive_core::{Effect, RwSignal, effect, signal};
5use renderer_core::RectStyle;
6use ui_tree::{Component, EventResult, RenderNode};
7
8use crate::child_host::{ChildSlot, DynHost};
9use crate::context::{new_container, track_layout};
10use crate::drag::DragGesture;
11use crate::focus::{self, FocusId};
12use crate::layout_item::{LayoutItem, TrackedChildren, register_container};
13use crate::pointer::dispatch_container_event;
14use crate::press::PressGesture;
15
16pub struct StyledContainer {
17    node: NodeId,
18    rect: RwSignal<Rect>,
19    style: Box<dyn Fn(Rect) -> RectStyle>,
20    // Swapped in while the pointer is over the box (mouse only), mirroring `Button`'s rect/rect_hover.
21    hover_style: Option<Box<dyn Fn(Rect) -> RectStyle>>,
22    is_hovered: RwSignal<bool>,
23    // Swapped in while a primary pointer is held down inside the box (the pressed / CSS `:active` state),
24    // taking precedence over `hover_style`. Mouse and touch; cleared on release, leave, or drag-off.
25    active_style: Option<Box<dyn Fn(Rect) -> RectStyle>>,
26    is_active: RwSignal<bool>,
27    // A closure (not a plain f32) so `view()` re-reads it every run: a reactive opacity or a `transition:opacity` animation resolves to its current value on each re-render.
28    opacity: Box<dyn Fn() -> f32>,
29    // Resolved per `view()` (like `opacity`) so a `$signal`-driven transform re-reads its current value. Takes the laid-out `Rect` so rotate/scale can pivot on the box centre; `None` means identity (no wrapping node).
30    transform: Box<dyn Fn(Rect) -> Option<[f32; 6]>>,
31    children: TrackedChildren,
32    // Set when the box holds a reactive fragment: static + dynamic children route through the host so
33    // they interleave in this node (see `child_host`). `children` is empty in that case.
34    dyn_host: Option<DynHost>,
35    // Optional tap gesture so a styled box can itself be pressable (a clickable card); children still hit-test first.
36    press: PressGesture,
37    // Optional drag gesture (slider/reorder/resize): reports the pointer position on press and each move.
38    drag: DragGesture,
39    // Fires with `true`/`false` as the mouse enters/leaves the box (mouse only, like the hover style).
40    on_hover: Option<Box<dyn Fn(bool)>>,
41    // Fires with the wheel delta while the pointer is over the box. Scroll events carry no position, so the box's own hover state is what targets them — setting this therefore turns hover tracking on.
42    on_scroll: Option<Box<dyn Fn(f32, f32)>>,
43    // Fires on every key press. Key events carry no pointer position, so they are broadcast to every widget
44    // — this is a GLOBAL shortcut handler (there is no per-widget focus), not focused text input.
45    on_key: Option<Box<dyn Fn(&Key)>>,
46    // When set, the box is focusable: it joins the tab order, takes focus on tap, and handles Tab while
47    // focused. `on_focus` observes the transitions.
48    focus_id: Option<FocusId>,
49    // Watches focus transitions for `on_focus`; dropping it (with the box) tears the subscription down.
50    _focus_effect: Option<Effect>,
51}
52
53impl StyledContainer {
54    pub fn new(
55        layout_style: LayoutStyle,
56        style: impl Fn(Rect) -> RectStyle + 'static,
57        children: Vec<Box<dyn LayoutItem>>,
58    ) -> Result<Self, LayoutError> {
59        let (node, rect, children) = register_container(layout_style, children)?;
60        Ok(Self {
61            node,
62            rect,
63            style: Box::new(style),
64            hover_style: None,
65            is_hovered: signal(false),
66            active_style: None,
67            is_active: signal(false),
68            opacity: Box::new(|| 1.0),
69            transform: Box::new(|_| None),
70            children,
71            dyn_host: None,
72            press: PressGesture::default(),
73            drag: DragGesture::default(),
74            on_hover: None,
75            on_scroll: None,
76            on_key: None,
77            focus_id: None,
78            _focus_effect: None,
79        })
80    }
81
82    /// A styled box whose children are a mix of static widgets and reactive fragments (`ChildSlot`s),
83    /// reconciled into this box's own node so they inherit its flex direction/gap — the transparent
84    /// `box`-with-a-`for` path (see [`Container::from_slots`](crate::Container::from_slots)).
85    pub fn from_slots(
86        layout_style: LayoutStyle,
87        style: impl Fn(Rect) -> RectStyle + 'static,
88        slots: Vec<ChildSlot>,
89    ) -> Result<Self, LayoutError> {
90        let node = new_container(layout_style, &[])?;
91        let rect = track_layout(node).expect("new_container always registers a signal");
92        let dyn_host = DynHost::build(node, slots)?;
93        Ok(Self {
94            node,
95            rect,
96            style: Box::new(style),
97            hover_style: None,
98            is_hovered: signal(false),
99            active_style: None,
100            is_active: signal(false),
101            opacity: Box::new(|| 1.0),
102            transform: Box::new(|_| None),
103            children: Vec::new(),
104            dyn_host: Some(dyn_host),
105            press: PressGesture::default(),
106            drag: DragGesture::default(),
107            on_hover: None,
108            on_scroll: None,
109            on_key: None,
110            focus_id: None,
111            _focus_effect: None,
112        })
113    }
114
115    fn dispatch_children(&mut self, event: &Event) -> EventResult {
116        match &self.dyn_host {
117            Some(host) => host.dispatch(event),
118            None => dispatch_container_event(&mut self.children, event),
119        }
120    }
121
122    pub fn with_opacity(mut self, opacity: impl Fn() -> f32 + 'static) -> Self {
123        self.opacity = Box::new(opacity);
124        self
125    }
126
127    /// Apply an affine transform (rotate/scale/translate) to the whole box each `view()`. The closure
128    /// takes the laid-out rect and returns the 2×3 matrix, or `None` for identity.
129    pub fn with_transform(
130        mut self,
131        transform: impl Fn(Rect) -> Option<[f32; 6]> + 'static,
132    ) -> Self {
133        self.transform = Box::new(transform);
134        self
135    }
136
137    /// Paint the box with `f` while the mouse hovers it (a declarative style swap, like `Button`).
138    /// Hover is mouse-only; touch never sets it, so a tap leaves no stuck hover state.
139    pub fn on_hover_style(mut self, f: impl Fn(Rect) -> RectStyle + 'static) -> Self {
140        self.hover_style = Some(Box::new(f));
141        self
142    }
143
144    /// Paint the box with `f` while a primary pointer is held down inside it — the pressed / CSS `:active`
145    /// state, which takes precedence over `on_hover_style`. Unlike hover it tracks touch as well as mouse,
146    /// and it clears on release, on leaving the box, or once the press drags off, so it never sticks.
147    pub fn on_active_style(mut self, f: impl Fn(Rect) -> RectStyle + 'static) -> Self {
148        self.active_style = Some(Box::new(f));
149        self
150    }
151
152    /// Whether the box is currently pressed (a primary pointer is held down inside it). Set only when an
153    /// `active_style` is present; drives its paint swap and clears on release/leave/drag-off.
154    fn set_active(&self, active: bool) {
155        if self.active_style.is_some() && self.is_active.get() != active {
156            self.is_active.set(active);
157        }
158    }
159
160    /// Make the box itself pressable. The callback fires on a tap (release, not press) inside the box;
161    /// a child widget that handles the press wins, and a scroll gesture started on the box does not fire it.
162    pub fn on_press(mut self, f: impl Fn() + 'static) -> Self {
163        self.press.set(f);
164        self.mark_interactive();
165        self
166    }
167
168    /// Fire `f(button)` on a tap with a **non-primary** button — `Secondary` (right) or `Auxiliary` (middle).
169    /// Same tap-on-release semantics as [`Self::on_press`]: a child that handles the press wins, and travel
170    /// past the tap slop cancels it.
171    ///
172    /// Opt-in per box rather than folded into `on_press`, because a non-primary press otherwise falls through
173    /// to whatever is behind it — silently swallowing right-clicks on every pressable box would break that.
174    pub fn on_alt_press(mut self, f: impl Fn(PointerButton) + 'static) -> Self {
175        self.press.set_alt_press(f);
176        self.mark_interactive();
177        self
178    }
179
180    /// Fires once a press inside the box is held past ~500ms without moving past the tap slop, instead of
181    /// `on_press`'s tap-on-release. There is no dedicated timer in the gesture pipeline, so the threshold is
182    /// only checked on the next pointer event after the press (a move or the release) — it fires slightly
183    /// late, never at exactly 500ms, and a release before that next check-in is a normal tap.
184    pub fn on_long_press(mut self, f: impl Fn() + 'static) -> Self {
185        self.press.set_long_press(f);
186        self.mark_interactive();
187        self
188    }
189
190    /// Make the box draggable. The callback fires with the pointer position (layout space) on a press
191    /// inside the box and on every move until release — even after the pointer leaves the box. Map the
192    /// coordinate to a value (slider) or an offset (reorder/resize).
193    /// Fires once when a drag started on this box ends, with the position it finished at (layout space, local
194    /// to the box, same as [`on_drag`](Self::on_drag)).
195    ///
196    /// This is what makes a *threshold* gesture expressible: `on_drag` alone reports where the pointer is but
197    /// never that it let go, so a swipe-to-dismiss or a drag-to-open can be tracked and never decided. A drag
198    /// also ends when the pointer leaves the window or a child consumes the release; those carry no position,
199    /// so the last one the drag reached is reported instead — the gesture always ends exactly once.
200    pub fn on_drag_end(mut self, f: impl Fn(f32, f32) + 'static) -> Self {
201        self.drag.set_end(f);
202        self.mark_interactive();
203        self
204    }
205
206    pub fn on_drag(mut self, f: impl Fn(f32, f32) + 'static) -> Self {
207        self.drag.set(f);
208        self.mark_interactive();
209        self
210    }
211
212    /// Records this box as a pointer target in the per-surface interactive registry, so a surface that carves
213    /// its input region from its content (a click-through overlay) receives input over it. See
214    /// [`crate::interactive_rects`].
215    fn mark_interactive(&self) {
216        crate::input_region::register_interactive(self.node, self.rect.read_only());
217    }
218
219    /// Fire `f(true)` when the mouse enters the box and `f(false)` when it leaves (mouse only). Independent
220    /// of `on_hover_style`: a box can observe hover without swapping its paint.
221    ///
222    /// Registers the box as a pointer target, like [`on_scroll`](Self::on_scroll) does for the same reason: a
223    /// surface that carves its input region from its content (a click-through overlay) never receives a move
224    /// event over a box it left out of that region, so a hover it did not register is a hover it can't observe.
225    pub fn on_hover(mut self, f: impl Fn(bool) + 'static) -> Self {
226        self.on_hover = Some(Box::new(f));
227        self.mark_interactive();
228        self
229    }
230
231    /// Fire `f(dx, dy)` with the wheel delta while the mouse is over the box — scroll-to-adjust on a control
232    /// (a volume or brightness chip, a stepper). Deltas are normalised to pixels, matching
233    /// [`ScrollArea`](crate::ScrollArea): a line delta counts as 20px, so one wheel notch is roughly ±60.
234    ///
235    /// Scroll events carry no pointer position, so the box's hover state is what targets them; this enables
236    /// hover tracking on its own, without needing an `on_hover_style`. A scrollable child (a scroll area
237    /// inside the box) hit-tests first and keeps the event.
238    pub fn on_scroll(mut self, f: impl Fn(f32, f32) + 'static) -> Self {
239        self.on_scroll = Some(Box::new(f));
240        self.mark_interactive();
241        self
242    }
243
244    /// Fire `f(&key)` on every key press. This is a GLOBAL handler (key events reach every widget; there is
245    /// no per-widget focus), so it suits app-level shortcuts, not focused text entry.
246    pub fn on_key(mut self, f: impl Fn(&Key) + 'static) -> Self {
247        self.on_key = Some(Box::new(f));
248        self
249    }
250
251    /// Make the box focusable and fire `f(true)`/`f(false)` when it gains/loses keyboard focus. It joins
252    /// the tab order (Tab/Shift-Tab reach it) and takes focus on tap. Use it to drive a focus ring or to
253    /// build a custom focusable widget on top of a `box`.
254    pub fn on_focus(mut self, f: impl Fn(bool) + 'static) -> Self {
255        let id = *self.focus_id.get_or_insert_with(focus::next_id);
256        focus::register(id);
257        // An effect fires the callback only on an actual transition (its first run seeds `last`, no fire).
258        let last = std::rc::Rc::new(std::cell::Cell::new(focus::is_focused(id)));
259        self._focus_effect = Some(effect(move || {
260            let now = focus::is_focused(id);
261            if now != last.get() {
262                last.set(now);
263                f(now);
264            }
265        }));
266        self
267    }
268}
269
270impl LayoutItem for StyledContainer {
271    fn layout_node(&self) -> NodeId {
272        self.node
273    }
274}
275
276impl Component for StyledContainer {
277    fn view(&self) -> RenderNode {
278        let r = self.rect.get();
279        // Pressed wins over hover wins over base. Each `is_*` signal is only read when its style exists, so
280        // a plain box's view() stays inert and subscribes to neither.
281        let style = if let Some(active) = &self.active_style
282            && self.is_active.get()
283        {
284            active
285        } else if let Some(hover) = &self.hover_style
286            && self.is_hovered.get()
287        {
288            hover
289        } else {
290            &self.style
291        };
292        let background = RenderNode::rect(
293            Rect {
294                x: r.x,
295                y: r.y,
296                width: r.width,
297                height: r.height,
298            },
299            style(r),
300        );
301        let content = match &self.dyn_host {
302            Some(host) => {
303                RenderNode::group(std::iter::once(background).chain(host.child_boundaries()))
304            }
305            None => RenderNode::group(
306                std::iter::once(background)
307                    .chain(self.children.iter().map(|c| c.segment.boundary())),
308            ),
309        };
310        let opacity = (self.opacity)();
311        let composed = if opacity < 1.0 {
312            RenderNode::layer(opacity, 0.0, [content])
313        } else {
314            content
315        };
316        match (self.transform)(r) {
317            Some(matrix) => RenderNode::transform_with(matrix, [composed]),
318            None => composed,
319        }
320    }
321
322    fn on_event(&mut self, event: &Event) -> EventResult {
323        // No tap/drag handler, hover style, or event callbacks: behave exactly as a plain container (pure routing).
324        if !self.press.is_set()
325            && !self.drag.is_set()
326            && self.hover_style.is_none()
327            && self.active_style.is_none()
328            && self.on_hover.is_none()
329            && self.on_scroll.is_none()
330            && self.on_key.is_none()
331            && self.focus_id.is_none()
332        {
333            return self.dispatch_children(event);
334        }
335        let rect = self.rect.get();
336        match event {
337            // Moves are broadcast to all children (their hover) and also feed our own scroll-vs-tap and
338            // hover tracking. Hover is mouse-only: touch has no "pointer left", so a tap would otherwise
339            // leave the box stuck in its hover style.
340            Event::PointerMoved { x, y, source } => {
341                self.press.track_move(event);
342                let dragged = self.drag.moved(event, rect) == EventResult::Handled;
343                let child = self.dispatch_children(event);
344                let inside = rect.contains(*x as f32, *y as f32);
345                // Pressed clears once the pointer drags off the box (mouse or touch) so it never sticks.
346                if !inside {
347                    self.set_active(false);
348                }
349                let tracks_hover = self.hover_style.is_some()
350                    || self.on_hover.is_some()
351                    || self.on_scroll.is_some();
352                if tracks_hover
353                    && matches!(source, PointerSource::Mouse)
354                    && inside != self.is_hovered.get()
355                {
356                    self.is_hovered.set(inside);
357                    if let Some(cb) = &self.on_hover {
358                        cb(inside);
359                    }
360                    return EventResult::Handled;
361                }
362                if dragged { EventResult::Handled } else { child }
363            }
364            // A child (e.g. an inner button) hit-tests first and wins; only a press on the bare box arms our tap/drag.
365            Event::PointerPressed { x, y, button, .. } => {
366                // Pressed state, focus and drag are primary-only gestures. A box that asked for other buttons
367                // gets them routed to its press gesture; every other box lets them fall through untouched.
368                let primary = *button == PointerButton::Primary;
369                if !primary && !self.press.wants_alt() {
370                    return self.dispatch_children(event);
371                }
372                if self.dispatch_children(event) == EventResult::Handled {
373                    self.press.cancel();
374                    self.drag.end(None);
375                    return EventResult::Handled;
376                }
377                // A primary press inside the box enters the pressed state (purely visual; independent of on_press).
378                if primary && rect.contains(*x as f32, *y as f32) {
379                    self.set_active(true);
380                }
381                // A tap inside a focusable box takes focus (and consumes the press so focus sticks).
382                let focused = match self.focus_id {
383                    Some(id) if primary && rect.contains(*x as f32, *y as f32) => {
384                        focus::request(id);
385                        true
386                    }
387                    _ => false,
388                };
389                let tapped =
390                    self.press.is_set() && self.press.arm(event, rect) == EventResult::Handled;
391                let dragged = primary
392                    && self.drag.is_set()
393                    && self.drag.press(event, rect) == EventResult::Handled;
394                if tapped || dragged || focused {
395                    EventResult::Handled
396                } else {
397                    EventResult::Ignored
398                }
399            }
400            Event::PointerReleased { button, .. } => {
401                let primary = *button == PointerButton::Primary;
402                if !primary && !self.press.wants_alt() {
403                    return self.dispatch_children(event);
404                }
405                // A release always ends the pressed state, wherever it lands.
406                if primary {
407                    self.set_active(false);
408                }
409                if self.dispatch_children(event) == EventResult::Handled {
410                    self.press.cancel();
411                    self.drag.end(None);
412                    return EventResult::Handled;
413                }
414                // The release carries its own position, which is the one the gesture actually finished at —
415                // a drag can end past the last move the compositor delivered.
416                let released_at = match event {
417                    Event::PointerReleased { x, y, .. } => {
418                        Some((*x as f32 - rect.x, *y as f32 - rect.y))
419                    }
420                    _ => None,
421                };
422                let dragged = primary && self.drag.end(released_at);
423                let tapped =
424                    self.press.is_set() && self.press.release(event, rect) == EventResult::Handled;
425                if tapped || dragged {
426                    EventResult::Handled
427                } else {
428                    EventResult::Ignored
429                }
430            }
431            Event::CursorLeft => {
432                self.press.cancel();
433                self.drag.end(None);
434                self.set_active(false);
435                let tracks_hover = self.hover_style.is_some()
436                    || self.on_hover.is_some()
437                    || self.on_scroll.is_some();
438                if tracks_hover && self.is_hovered.get() {
439                    self.is_hovered.set(false);
440                    if let Some(cb) = &self.on_hover {
441                        cb(false);
442                    }
443                }
444                self.dispatch_children(event)
445            }
446            // Scroll carries no position, so the box's hover state targets it. Children (e.g. a nested scroll area) get first refusal; only then does an `on_scroll` box consume the wheel.
447            Event::Scrolled { delta } => {
448                if self.dispatch_children(event) == EventResult::Handled {
449                    return EventResult::Handled;
450                }
451                let Some(cb) = &self.on_scroll else {
452                    return EventResult::Ignored;
453                };
454                if !self.is_hovered.get() {
455                    return EventResult::Ignored;
456                }
457                let (dx, dy) = match delta {
458                    ScrollDelta::Lines { x, y } => (*x * 20.0, *y * 20.0),
459                    ScrollDelta::Pixels { x, y } => (*x, *y),
460                };
461                cb(dx, dy);
462                EventResult::Handled
463            }
464            // Broadcast (no pointer position): fire the global key handler, then keep routing to children.
465            Event::KeyPressed { key, modifiers } => {
466                // While this focusable box holds focus, Tab moves focus to the next/previous field.
467                if let Some(id) = self.focus_id
468                    && focus::is_focused(id)
469                    && matches!(key, Key::Named(NamedKey::Tab))
470                {
471                    if modifiers.is_shift {
472                        focus::focus_prev();
473                    } else {
474                        focus::focus_next();
475                    }
476                    return EventResult::Handled;
477                }
478                if let Some(cb) = &self.on_key {
479                    cb(key);
480                }
481                self.dispatch_children(event)
482            }
483            _ => self.dispatch_children(event),
484        }
485    }
486
487    fn debug_name(&self) -> &'static str {
488        "StyledContainer"
489    }
490}
491
492impl Drop for StyledContainer {
493    fn drop(&mut self) {
494        // Drop the focus watcher first so releasing focus below doesn't fire `on_focus` during teardown.
495        self._focus_effect.take();
496        if let Some(id) = self.focus_id {
497            focus::unregister(id);
498        }
499        crate::input_region::unregister_interactive(self.node);
500    }
501}
502
503/// Builds the affine matrix for a box's declarative `rotate`/`scale`/`translate` attributes, pivoting
504/// rotation and scale on the box centre. Returns `None` when every component is identity, so an untransformed
505/// box skips the extra transform node entirely.
506pub fn box_transform(
507    rect: Rect,
508    rotate_deg: f32,
509    scale_x: f32,
510    scale_y: f32,
511    translate_x: f32,
512    translate_y: f32,
513) -> Option<[f32; 6]> {
514    if rotate_deg == 0.0
515        && scale_x == 1.0
516        && scale_y == 1.0
517        && translate_x == 0.0
518        && translate_y == 0.0
519    {
520        return None;
521    }
522    let cx = rect.x + rect.width / 2.0;
523    let cy = rect.y + rect.height / 2.0;
524    let matrix = Transform::rotate_around(rotate_deg, cx, cy)
525        .then(Transform::scale_around(scale_x, scale_y, cx, cy))
526        .then(Transform::translate(translate_x, translate_y));
527    Some(matrix.to_array())
528}
529
530#[cfg(test)]
531mod tests {
532    use crate::context::reset_layout_runtime;
533    use std::cell::Cell;
534    use std::rc::Rc;
535
536    use layout_core::AvailableSpace;
537    use platform_core::{PointerButton, PointerSource};
538    use renderer_core::{Color, ShapeStyle};
539    use theme_core::{Theme, ThemeTokens, set_theme, use_theme};
540
541    use super::*;
542
543    #[test]
544    fn box_transform_identity_is_none() {
545        let r = Rect {
546            x: 0.0,
547            y: 0.0,
548            width: 10.0,
549            height: 10.0,
550        };
551        assert!(box_transform(r, 0.0, 1.0, 1.0, 0.0, 0.0).is_none());
552    }
553
554    #[test]
555    fn box_transform_scale_pivots_on_center() {
556        let r = Rect {
557            x: 0.0,
558            y: 0.0,
559            width: 100.0,
560            height: 100.0,
561        };
562        // scale_around(2, 2, 50, 50): pins the centre, so e = f = 50 - 2*50 = -50.
563        assert_eq!(
564            box_transform(r, 0.0, 2.0, 2.0, 0.0, 0.0).unwrap(),
565            [2.0, 0.0, 0.0, 2.0, -50.0, -50.0]
566        );
567    }
568
569    #[test]
570    fn box_transform_translate_offsets_origin() {
571        let r = Rect {
572            x: 0.0,
573            y: 0.0,
574            width: 10.0,
575            height: 10.0,
576        };
577        assert_eq!(
578            box_transform(r, 0.0, 1.0, 1.0, 8.0, -4.0).unwrap(),
579            [1.0, 0.0, 0.0, 1.0, 8.0, -4.0]
580        );
581    }
582    use crate::container::Container;
583    use crate::context::{compute_layout, track_layout};
584
585    fn press(x: f64, y: f64, source: PointerSource) -> Event {
586        Event::PointerPressed {
587            x,
588            y,
589            button: PointerButton::Primary,
590            source,
591        }
592    }
593    fn release(x: f64, y: f64, source: PointerSource) -> Event {
594        Event::PointerReleased {
595            x,
596            y,
597            button: PointerButton::Primary,
598            source,
599        }
600    }
601
602    #[test]
603    fn on_hover_fires_on_enter_and_leave() {
604        let seen: Rc<Cell<Option<bool>>> = Rc::new(Cell::new(None));
605        let sink = seen.clone();
606        reset_layout_runtime();
607        let inner = Container::new(LayoutStyle::new().width(100.0).height(100.0), vec![]).unwrap();
608        let mut card = StyledContainer::new(
609            LayoutStyle::new().flex_column().width(100.0).height(100.0),
610            |_r| RectStyle::default(),
611            vec![Box::new(inner)],
612        )
613        .unwrap()
614        .on_hover(move |h| sink.set(Some(h)));
615        let node = card.layout_node();
616        compute_layout(
617            node,
618            AvailableSpace::Definite(100.0),
619            AvailableSpace::Definite(100.0),
620        )
621        .unwrap();
622
623        card.on_event(&Event::PointerMoved {
624            x: 50.0,
625            y: 50.0,
626            source: PointerSource::Mouse,
627        });
628        assert_eq!(seen.get(), Some(true), "entering fires on_hover(true)");
629        card.on_event(&Event::CursorLeft);
630        assert_eq!(seen.get(), Some(false), "leaving fires on_hover(false)");
631    }
632
633    #[test]
634    fn on_scroll_fires_only_while_hovered_and_normalises_lines() {
635        let seen: Rc<Cell<(f32, f32)>> = Rc::new(Cell::new((0.0, 0.0)));
636        let sink = seen.clone();
637        reset_layout_runtime();
638        let inner = Container::new(LayoutStyle::new().width(100.0).height(100.0), vec![]).unwrap();
639        let mut card = StyledContainer::new(
640            LayoutStyle::new().flex_column().width(100.0).height(100.0),
641            |_r| RectStyle::default(),
642            vec![Box::new(inner)],
643        )
644        .unwrap()
645        .on_scroll(move |dx, dy| sink.set((dx, dy)));
646        let node = card.layout_node();
647        compute_layout(
648            node,
649            AvailableSpace::Definite(100.0),
650            AvailableSpace::Definite(100.0),
651        )
652        .unwrap();
653
654        // The pointer has never entered, so the wheel is not ours to consume.
655        assert_eq!(
656            card.on_event(&Event::Scrolled {
657                delta: ScrollDelta::Pixels { x: 0.0, y: -30.0 },
658            }),
659            EventResult::Ignored,
660            "a wheel event outside the box is ignored"
661        );
662        assert_eq!(seen.get(), (0.0, 0.0));
663
664        card.on_event(&Event::PointerMoved {
665            x: 50.0,
666            y: 50.0,
667            source: PointerSource::Mouse,
668        });
669        assert_eq!(
670            card.on_event(&Event::Scrolled {
671                delta: ScrollDelta::Pixels { x: 0.0, y: -30.0 },
672            }),
673            EventResult::Handled,
674            "hovering makes the wheel ours"
675        );
676        assert_eq!(seen.get(), (0.0, -30.0));
677
678        // Line deltas are normalised to pixels the same way ScrollArea does it.
679        card.on_event(&Event::Scrolled {
680            delta: ScrollDelta::Lines { x: 0.0, y: 3.0 },
681        });
682        assert_eq!(seen.get(), (0.0, 60.0));
683
684        // Leaving clears hover, so the wheel stops reaching the handler.
685        card.on_event(&Event::CursorLeft);
686        assert_eq!(
687            card.on_event(&Event::Scrolled {
688                delta: ScrollDelta::Pixels { x: 0.0, y: -10.0 },
689            }),
690            EventResult::Ignored,
691            "the wheel is released once the pointer leaves"
692        );
693        assert_eq!(seen.get(), (0.0, 60.0), "the handler did not fire again");
694    }
695
696    #[test]
697    fn on_key_fires_on_key_press() {
698        let count = Rc::new(Cell::new(0u32));
699        let sink = count.clone();
700        reset_layout_runtime();
701        let inner = Container::new(LayoutStyle::new().width(10.0).height(10.0), vec![]).unwrap();
702        let mut card = StyledContainer::new(
703            LayoutStyle::new().flex_column(),
704            |_r| RectStyle::default(),
705            vec![Box::new(inner)],
706        )
707        .unwrap()
708        .on_key(move |_k| sink.set(sink.get() + 1));
709        card.on_event(&Event::KeyPressed {
710            key: Key::Char('a'),
711            modifiers: platform_core::ModifiersState::default(),
712        });
713        assert_eq!(count.get(), 1, "a key press fires on_key");
714    }
715
716    #[derive(Clone)]
717    struct TestTheme(Color);
718    impl Theme for TestTheme {
719        fn as_any(&self) -> &dyn std::any::Any {
720            self
721        }
722    }
723    impl ThemeTokens for TestTheme {
724        fn primary(&self) -> Color {
725            self.0
726        }
727        fn on_primary(&self) -> Color {
728            Color::WHITE
729        }
730    }
731
732    // Clicking a theme button (which sets the global THEME) while a themed StyledContainer ancestor is on the dispatch stack must not re-enter that ancestor's render segment mid borrow_mut.
733    #[test]
734    fn theme_button_click_force_tick_no_panic() {
735        set_theme(TestTheme(Color::RED));
736
737        reset_layout_runtime();
738        // A pressable primitive stands in for the old high-level Button (now in ui-components).
739        let btn = StyledContainer::new(
740            LayoutStyle::new().width(50.0).height(30.0),
741            |_r| RectStyle::default(),
742            vec![],
743        )
744        .unwrap()
745        .on_press(move || set_theme(TestTheme(Color::GREEN)));
746        let btn_node = btn.layout_node();
747        let inner = Container::new(
748            LayoutStyle::new().flex_column().width(200.0).height(100.0),
749            vec![Box::new(btn)],
750        )
751        .unwrap();
752        let card = StyledContainer::new(
753            LayoutStyle::new().flex_column().width(200.0).height(100.0),
754            |_r| RectStyle::default().with_fill(use_theme::<TestTheme>().0),
755            vec![Box::new(inner)],
756        )
757        .unwrap();
758        let card_node = card.layout_node();
759        compute_layout(
760            card_node,
761            AvailableSpace::Definite(200.0),
762            AvailableSpace::Definite(100.0),
763        )
764        .unwrap();
765        let br = track_layout(btn_node).unwrap().get();
766
767        let mut tree = crate::ComponentList::new(card);
768        let _ = tree.commands();
769
770        reactive_core::begin_batch();
771        let handled = tree.on_event(&Event::PointerPressed {
772            x: (br.x + br.width / 2.0) as f64,
773            y: (br.y + br.height / 2.0) as f64,
774            button: PointerButton::Primary,
775            source: PointerSource::Mouse,
776        });
777        if handled == EventResult::Handled {
778            tree.bump_force_ticks();
779            reactive_core::end_batch();
780            reactive_core::begin_batch();
781        }
782        let _ = tree.commands();
783        reactive_core::end_batch();
784    }
785
786    // A pressable box fires its callback on release (a tap), never on press alone.
787    #[test]
788    fn on_press_fires_on_tap_not_press() {
789        let flag = Rc::new(Cell::new(false));
790        let f = flag.clone();
791        reset_layout_runtime();
792        let mut card = StyledContainer::new(
793            LayoutStyle::new().flex_column().width(200.0).height(100.0),
794            |_r| RectStyle::default(),
795            vec![],
796        )
797        .unwrap()
798        .on_press(move || f.set(true));
799        compute_layout(
800            card.layout_node(),
801            AvailableSpace::Definite(200.0),
802            AvailableSpace::Definite(100.0),
803        )
804        .unwrap();
805
806        assert_eq!(
807            card.on_event(&press(100.0, 50.0, PointerSource::Mouse)),
808            EventResult::Handled
809        );
810        assert!(!flag.get(), "press alone must not fire on_press");
811        assert_eq!(
812            card.on_event(&release(100.0, 50.0, PointerSource::Mouse)),
813            EventResult::Handled
814        );
815        assert!(flag.get(), "release inside the box fires on_press");
816    }
817
818    // Holding a press past the long-press threshold fires on_long_press on the next pointer event (there is
819    // no dedicated timer) and suppresses the tap; a quick release stays a normal tap and never fires on_long_press.
820    #[test]
821    fn on_long_press_fires_after_threshold_not_on_quick_release() {
822        let long_flag = Rc::new(Cell::new(false));
823        let tap_flag = Rc::new(Cell::new(false));
824        let lf = long_flag.clone();
825        let tf = tap_flag.clone();
826        reset_layout_runtime();
827        let mut card = StyledContainer::new(
828            LayoutStyle::new().flex_column().width(200.0).height(100.0),
829            |_r| RectStyle::default(),
830            vec![],
831        )
832        .unwrap()
833        .on_press(move || tf.set(true))
834        .on_long_press(move || lf.set(true));
835        compute_layout(
836            card.layout_node(),
837            AvailableSpace::Definite(200.0),
838            AvailableSpace::Definite(100.0),
839        )
840        .unwrap();
841
842        // A quick release (well under the threshold) stays a normal tap.
843        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
844        card.on_event(&release(100.0, 50.0, PointerSource::Mouse));
845        assert!(tap_flag.get(), "a quick release still fires on_press");
846        assert!(
847            !long_flag.get(),
848            "a quick release must not fire on_long_press"
849        );
850
851        // Holding past the threshold: the next event (the release here) fires on_long_press instead of on_press.
852        tap_flag.set(false);
853        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
854        std::thread::sleep(std::time::Duration::from_millis(550));
855        card.on_event(&release(100.0, 50.0, PointerSource::Mouse));
856        assert!(
857            long_flag.get(),
858            "a release after the threshold fires on_long_press"
859        );
860        assert!(!tap_flag.get(), "a long press must not also fire on_press");
861    }
862
863    fn press_with(x: f64, y: f64, button: PointerButton) -> Event {
864        Event::PointerPressed {
865            x,
866            y,
867            button,
868            source: PointerSource::Mouse,
869        }
870    }
871    fn release_with(x: f64, y: f64, button: PointerButton) -> Event {
872        Event::PointerReleased {
873            x,
874            y,
875            button,
876            source: PointerSource::Mouse,
877        }
878    }
879
880    fn laid_out_box() -> StyledContainer {
881        reset_layout_runtime();
882        StyledContainer::new(
883            LayoutStyle::new().width(100.0).height(100.0),
884            |_r| RectStyle::default(),
885            vec![],
886        )
887        .unwrap()
888    }
889
890    fn settle(card: &mut StyledContainer) {
891        compute_layout(
892            card.layout_node(),
893            AvailableSpace::Definite(100.0),
894            AvailableSpace::Definite(100.0),
895        )
896        .unwrap();
897    }
898
899    #[test]
900    fn on_alt_press_reports_which_non_primary_button_tapped() {
901        let seen: Rc<Cell<Option<PointerButton>>> = Rc::new(Cell::new(None));
902        let sink = seen.clone();
903        let mut card = laid_out_box().on_alt_press(move |b| sink.set(Some(b)));
904        settle(&mut card);
905
906        card.on_event(&press_with(50.0, 50.0, PointerButton::Secondary));
907        card.on_event(&release_with(50.0, 50.0, PointerButton::Secondary));
908        assert_eq!(seen.take(), Some(PointerButton::Secondary));
909
910        card.on_event(&press_with(50.0, 50.0, PointerButton::Auxiliary));
911        card.on_event(&release_with(50.0, 50.0, PointerButton::Auxiliary));
912        assert_eq!(seen.take(), Some(PointerButton::Auxiliary));
913    }
914
915    #[test]
916    fn a_box_wanting_only_alt_presses_leaves_the_primary_one_alone() {
917        let alt = Rc::new(Cell::new(false));
918        let sink = alt.clone();
919        let mut card = laid_out_box().on_alt_press(move |_| sink.set(true));
920        settle(&mut card);
921
922        assert_eq!(
923            card.on_event(&press_with(50.0, 50.0, PointerButton::Primary)),
924            EventResult::Ignored,
925            "a primary press must still fall through to whatever is behind the box"
926        );
927        card.on_event(&release_with(50.0, 50.0, PointerButton::Primary));
928        assert!(!alt.get(), "the primary button is not an alt press");
929    }
930
931    #[test]
932    fn a_plain_pressable_box_still_ignores_non_primary_buttons() {
933        let tapped = Rc::new(Cell::new(false));
934        let sink = tapped.clone();
935        let mut card = laid_out_box().on_press(move || sink.set(true));
936        settle(&mut card);
937
938        assert_eq!(
939            card.on_event(&press_with(50.0, 50.0, PointerButton::Secondary)),
940            EventResult::Ignored,
941            "right-click keeps passing through a box that never asked for it"
942        );
943        card.on_event(&release_with(50.0, 50.0, PointerButton::Secondary));
944        assert!(!tapped.get(), "on_press is a primary-button gesture");
945    }
946
947    #[test]
948    fn releasing_a_different_button_than_armed_completes_nothing() {
949        let seen: Rc<Cell<Option<PointerButton>>> = Rc::new(Cell::new(None));
950        let sink = seen.clone();
951        let tapped = Rc::new(Cell::new(false));
952        let tap_sink = tapped.clone();
953        let mut card = laid_out_box()
954            .on_press(move || tap_sink.set(true))
955            .on_alt_press(move |b| sink.set(Some(b)));
956        settle(&mut card);
957
958        card.on_event(&press_with(50.0, 50.0, PointerButton::Secondary));
959        card.on_event(&release_with(50.0, 50.0, PointerButton::Primary));
960        assert_eq!(
961            seen.take(),
962            None,
963            "the right button armed it, the left cannot complete it"
964        );
965        assert!(!tapped.get());
966    }
967
968    #[test]
969    fn dragging_off_the_box_cancels_an_alt_press() {
970        let seen: Rc<Cell<Option<PointerButton>>> = Rc::new(Cell::new(None));
971        let sink = seen.clone();
972        let mut card = laid_out_box().on_alt_press(move |b| sink.set(Some(b)));
973        settle(&mut card);
974
975        card.on_event(&press_with(50.0, 50.0, PointerButton::Secondary));
976        card.on_event(&Event::PointerMoved {
977            x: 95.0,
978            y: 95.0,
979            source: PointerSource::Mouse,
980        });
981        card.on_event(&release_with(95.0, 95.0, PointerButton::Secondary));
982        assert_eq!(
983            seen.take(),
984            None,
985            "travel past the tap slop cancels an alt press just as it cancels a tap"
986        );
987    }
988
989    // A child that handles the press (an inner button) wins; the box's own on_press must stay silent.
990    #[test]
991    fn inner_button_press_wins_over_box() {
992        let card_flag = Rc::new(Cell::new(false));
993        let btn_flag = Rc::new(Cell::new(false));
994        let cf = card_flag.clone();
995        let bf = btn_flag.clone();
996        reset_layout_runtime();
997        // A pressable primitive child stands in for the old high-level Button (now in ui-components).
998        let btn = StyledContainer::new(
999            LayoutStyle::new().width(50.0).height(30.0),
1000            |_r| RectStyle::default(),
1001            vec![],
1002        )
1003        .unwrap()
1004        .on_press(move || bf.set(true));
1005        let btn_node = btn.layout_node();
1006        let mut card = StyledContainer::new(
1007            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1008            |_r| RectStyle::default(),
1009            vec![Box::new(btn)],
1010        )
1011        .unwrap()
1012        .on_press(move || cf.set(true));
1013        compute_layout(
1014            card.layout_node(),
1015            AvailableSpace::Definite(200.0),
1016            AvailableSpace::Definite(100.0),
1017        )
1018        .unwrap();
1019
1020        let br = track_layout(btn_node).unwrap().get();
1021        let (cx, cy) = (
1022            (br.x + br.width / 2.0) as f64,
1023            (br.y + br.height / 2.0) as f64,
1024        );
1025        card.on_event(&press(cx, cy, PointerSource::Mouse));
1026        card.on_event(&release(cx, cy, PointerSource::Mouse));
1027        assert!(btn_flag.get(), "the inner button should fire");
1028        assert!(
1029            !card_flag.get(),
1030            "the box on_press must not fire when a child handled the press"
1031        );
1032    }
1033
1034    // A hover style swaps the box's fill while the mouse is over it (mouse only), and clears on leave.
1035    #[test]
1036    fn hover_style_swaps_on_mouse_move() {
1037        reset_layout_runtime();
1038        let mut card = StyledContainer::new(
1039            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1040            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
1041            vec![],
1042        )
1043        .unwrap()
1044        .on_hover_style(|_r| RectStyle::default().with_fill(Color::rgba(0.9, 0.9, 0.9, 1.0)));
1045        compute_layout(
1046            card.layout_node(),
1047            AvailableSpace::Definite(200.0),
1048            AvailableSpace::Definite(100.0),
1049        )
1050        .unwrap();
1051
1052        let normal = fill_color(&card.view());
1053        card.on_event(&Event::PointerMoved {
1054            x: 100.0,
1055            y: 50.0,
1056            source: PointerSource::Mouse,
1057        });
1058        let hovered = fill_color(&card.view());
1059        assert_ne!(normal, hovered, "hover should swap the fill");
1060
1061        card.on_event(&Event::PointerMoved {
1062            x: 9999.0,
1063            y: 9999.0,
1064            source: PointerSource::Mouse,
1065        });
1066        assert_eq!(
1067            fill_color(&card.view()),
1068            normal,
1069            "leaving the box restores the base fill"
1070        );
1071    }
1072
1073    // Touch never sets hover (no "pointer left" on touch), so a tap leaves no stuck hover style.
1074    #[test]
1075    fn touch_move_does_not_set_hover() {
1076        reset_layout_runtime();
1077        let mut card = StyledContainer::new(
1078            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1079            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
1080            vec![],
1081        )
1082        .unwrap()
1083        .on_hover_style(|_r| RectStyle::default().with_fill(Color::rgba(0.9, 0.9, 0.9, 1.0)));
1084        compute_layout(
1085            card.layout_node(),
1086            AvailableSpace::Definite(200.0),
1087            AvailableSpace::Definite(100.0),
1088        )
1089        .unwrap();
1090
1091        let normal = fill_color(&card.view());
1092        card.on_event(&Event::PointerMoved {
1093            x: 100.0,
1094            y: 50.0,
1095            source: PointerSource::Touch { id: 1 },
1096        });
1097        assert_eq!(
1098            fill_color(&card.view()),
1099            normal,
1100            "a touch move must not trigger hover"
1101        );
1102    }
1103
1104    // A press inside swaps to the active (pressed) fill; the release restores the base fill.
1105    #[test]
1106    fn active_style_swaps_on_press_and_clears_on_release() {
1107        reset_layout_runtime();
1108        let mut card = StyledContainer::new(
1109            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1110            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
1111            vec![],
1112        )
1113        .unwrap()
1114        .on_active_style(|_r| RectStyle::default().with_fill(Color::rgba(0.5, 0.5, 0.5, 1.0)));
1115        compute_layout(
1116            card.layout_node(),
1117            AvailableSpace::Definite(200.0),
1118            AvailableSpace::Definite(100.0),
1119        )
1120        .unwrap();
1121
1122        let normal = fill_color(&card.view());
1123        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
1124        assert_ne!(
1125            normal,
1126            fill_color(&card.view()),
1127            "press swaps to the active fill"
1128        );
1129        card.on_event(&release(100.0, 50.0, PointerSource::Mouse));
1130        assert_eq!(
1131            fill_color(&card.view()),
1132            normal,
1133            "release restores the base fill"
1134        );
1135    }
1136
1137    // Pressed wins over hover: pressing while hovering shows the active fill, and releasing (still inside)
1138    // falls back to the hover fill.
1139    #[test]
1140    fn active_style_takes_precedence_over_hover() {
1141        reset_layout_runtime();
1142        let hover = Color::rgba(0.9, 0.9, 0.9, 1.0);
1143        let active = Color::rgba(0.4, 0.4, 0.4, 1.0);
1144        let mut card = StyledContainer::new(
1145            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1146            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
1147            vec![],
1148        )
1149        .unwrap()
1150        .on_hover_style(move |_r| RectStyle::default().with_fill(hover))
1151        .on_active_style(move |_r| RectStyle::default().with_fill(active));
1152        compute_layout(
1153            card.layout_node(),
1154            AvailableSpace::Definite(200.0),
1155            AvailableSpace::Definite(100.0),
1156        )
1157        .unwrap();
1158
1159        card.on_event(&Event::PointerMoved {
1160            x: 100.0,
1161            y: 50.0,
1162            source: PointerSource::Mouse,
1163        });
1164        assert_eq!(
1165            fill_color(&card.view()),
1166            hover,
1167            "hovering shows the hover fill"
1168        );
1169        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
1170        assert_eq!(
1171            fill_color(&card.view()),
1172            active,
1173            "pressing while hovered shows the active fill (precedence)"
1174        );
1175        card.on_event(&release(100.0, 50.0, PointerSource::Mouse));
1176        assert_eq!(
1177            fill_color(&card.view()),
1178            hover,
1179            "releasing inside falls back to the hover fill"
1180        );
1181    }
1182
1183    // Dragging the press off the box clears the pressed state, so it never sticks.
1184    #[test]
1185    fn active_style_clears_when_press_drags_off() {
1186        reset_layout_runtime();
1187        let mut card = StyledContainer::new(
1188            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1189            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
1190            vec![],
1191        )
1192        .unwrap()
1193        .on_active_style(|_r| RectStyle::default().with_fill(Color::rgba(0.5, 0.5, 0.5, 1.0)));
1194        compute_layout(
1195            card.layout_node(),
1196            AvailableSpace::Definite(200.0),
1197            AvailableSpace::Definite(100.0),
1198        )
1199        .unwrap();
1200
1201        let normal = fill_color(&card.view());
1202        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
1203        assert_ne!(normal, fill_color(&card.view()), "press activates");
1204        card.on_event(&Event::PointerMoved {
1205            x: 9999.0,
1206            y: 9999.0,
1207            source: PointerSource::Mouse,
1208        });
1209        assert_eq!(
1210            fill_color(&card.view()),
1211            normal,
1212            "dragging off the box clears the pressed state"
1213        );
1214    }
1215
1216    fn fill_color(view: &RenderNode) -> Color {
1217        let group = match view {
1218            RenderNode::Group { children, .. } => children,
1219            _ => panic!("expected Group"),
1220        };
1221        if let RenderNode::Primitive(renderer_core::DrawCommand::Rect { style, .. }) = &group[0] {
1222            if let Some(renderer_core::Paint::Solid(c)) = style.fill {
1223                return c;
1224            }
1225        }
1226        panic!("expected a solid-fill background rect");
1227    }
1228
1229    // A scroll gesture that begins on the box (press then drag past the slop) must not press it.
1230    #[test]
1231    fn scroll_drag_does_not_press_box() {
1232        let flag = Rc::new(Cell::new(false));
1233        let f = flag.clone();
1234        reset_layout_runtime();
1235        let mut card = StyledContainer::new(
1236            LayoutStyle::new().flex_column().width(200.0).height(200.0),
1237            |_r| RectStyle::default(),
1238            vec![],
1239        )
1240        .unwrap()
1241        .on_press(move || f.set(true));
1242        compute_layout(
1243            card.layout_node(),
1244            AvailableSpace::Definite(200.0),
1245            AvailableSpace::Definite(200.0),
1246        )
1247        .unwrap();
1248
1249        let touch = PointerSource::Touch { id: 1 };
1250        card.on_event(&press(50.0, 20.0, touch.clone()));
1251        card.on_event(&Event::PointerMoved {
1252            x: 50.0,
1253            y: 120.0, // > TAP_SLOP away
1254            source: touch.clone(),
1255        });
1256        card.on_event(&release(50.0, 120.0, touch));
1257        assert!(!flag.get(), "a scroll drag over the box must not press it");
1258    }
1259
1260    // on_drag fires on a press inside, on every subsequent move (even once the pointer leaves the box),
1261    // then stops after release.
1262    #[test]
1263    fn on_drag_reports_press_then_moves_until_release() {
1264        use std::cell::RefCell;
1265        let seen: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
1266        let sink = seen.clone();
1267        reset_layout_runtime();
1268        let mut card = StyledContainer::new(
1269            LayoutStyle::new().flex_column().width(200.0).height(200.0),
1270            |_r| RectStyle::default(),
1271            vec![],
1272        )
1273        .unwrap()
1274        .on_drag(move |x, y| sink.borrow_mut().push((x, y)));
1275        compute_layout(
1276            card.layout_node(),
1277            AvailableSpace::Definite(200.0),
1278            AvailableSpace::Definite(200.0),
1279        )
1280        .unwrap();
1281
1282        let moved = |x: f64, y: f64| Event::PointerMoved {
1283            x,
1284            y,
1285            source: PointerSource::Mouse,
1286        };
1287        card.on_event(&press(40.0, 40.0, PointerSource::Mouse));
1288        card.on_event(&moved(80.0, 90.0));
1289        card.on_event(&moved(400.0, 400.0)); // outside the box: drag still tracks
1290        card.on_event(&release(400.0, 400.0, PointerSource::Mouse));
1291        card.on_event(&moved(10.0, 10.0)); // after release: no longer dragging
1292
1293        assert_eq!(
1294            *seen.borrow(),
1295            vec![(40.0, 40.0), (80.0, 90.0), (400.0, 400.0)],
1296            "drag reports the press point then each move until release"
1297        );
1298    }
1299
1300    // Regression: a drag released OUTSIDE the widget must still end. Dispatched through a parent (whose
1301    // release path position-filters presses) — the release must broadcast to the dragging child anyway,
1302    // else it stays stuck to the pointer (fires on_drag on later moves).
1303    #[test]
1304    fn drag_released_outside_bounds_ends_via_parent_dispatch() {
1305        use std::cell::RefCell;
1306        let seen: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
1307        let sink = seen.clone();
1308        reset_layout_runtime();
1309        let child = StyledContainer::new(
1310            LayoutStyle::new().width(100.0).height(100.0),
1311            |_r| RectStyle::default(),
1312            vec![],
1313        )
1314        .unwrap()
1315        .on_drag(move |x, y| sink.borrow_mut().push((x, y)));
1316        let mut parent = Container::new(
1317            LayoutStyle::new().flex_column().width(300.0).height(300.0),
1318            vec![Box::new(child)],
1319        )
1320        .unwrap();
1321        compute_layout(
1322            parent.layout_node(),
1323            AvailableSpace::Definite(300.0),
1324            AvailableSpace::Definite(300.0),
1325        )
1326        .unwrap();
1327
1328        let moved = |x: f64, y: f64| Event::PointerMoved {
1329            x,
1330            y,
1331            source: PointerSource::Mouse,
1332        };
1333        // child sits at (0,0) 100×100. Press inside, drag well outside, release outside.
1334        parent.on_event(&press(50.0, 50.0, PointerSource::Mouse));
1335        parent.on_event(&moved(250.0, 250.0));
1336        parent.on_event(&release(250.0, 250.0, PointerSource::Mouse));
1337        // After release the drag must be over: a later move fires nothing.
1338        parent.on_event(&moved(60.0, 60.0));
1339        assert_eq!(
1340            *seen.borrow(),
1341            vec![(50.0, 50.0), (250.0, 250.0)],
1342            "drag ended on the outside release; the post-release move must not fire"
1343        );
1344    }
1345
1346    // `on_drag_end` is what makes a threshold gesture (swipe-to-dismiss, drag-to-open) expressible: it fires
1347    // exactly once per drag, with the position it finished at.
1348    #[test]
1349    fn on_drag_end_fires_once_with_the_release_position() {
1350        use std::cell::RefCell;
1351        let ends: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
1352        let sink = ends.clone();
1353        reset_layout_runtime();
1354        let mut card = StyledContainer::new(
1355            LayoutStyle::new().width(100.0).height(100.0),
1356            |_r| RectStyle::default(),
1357            vec![],
1358        )
1359        .unwrap()
1360        .on_drag_end(move |x, y| sink.borrow_mut().push((x, y)));
1361        compute_layout(
1362            card.layout_node(),
1363            AvailableSpace::Definite(100.0),
1364            AvailableSpace::Definite(100.0),
1365        )
1366        .unwrap();
1367
1368        let moved = |x: f64, y: f64| Event::PointerMoved {
1369            x,
1370            y,
1371            source: PointerSource::Mouse,
1372        };
1373        card.on_event(&moved(10.0, 10.0));
1374        assert!(ends.borrow().is_empty(), "a move with no drag ends nothing");
1375
1376        card.on_event(&press(20.0, 20.0, PointerSource::Mouse));
1377        card.on_event(&moved(70.0, 30.0));
1378        assert!(ends.borrow().is_empty(), "still dragging");
1379        card.on_event(&release(90.0, 40.0, PointerSource::Mouse));
1380        assert_eq!(
1381            *ends.borrow(),
1382            vec![(90.0, 40.0)],
1383            "the release position, not the last move — a drag can end past it"
1384        );
1385
1386        // Exactly once: a second release with no drag in flight fires nothing.
1387        card.on_event(&release(95.0, 45.0, PointerSource::Mouse));
1388        assert_eq!(ends.borrow().len(), 1);
1389    }
1390
1391    // A drag also ends when the pointer leaves the window, which carries no position. It must still end —
1392    // otherwise the gesture is stuck armed — reporting the last place it reached.
1393    #[test]
1394    fn on_drag_end_still_fires_when_the_cursor_leaves() {
1395        use std::cell::RefCell;
1396        let ends: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
1397        let sink = ends.clone();
1398        reset_layout_runtime();
1399        let mut card = StyledContainer::new(
1400            LayoutStyle::new().width(100.0).height(100.0),
1401            |_r| RectStyle::default(),
1402            vec![],
1403        )
1404        .unwrap()
1405        .on_drag_end(move |x, y| sink.borrow_mut().push((x, y)));
1406        compute_layout(
1407            card.layout_node(),
1408            AvailableSpace::Definite(100.0),
1409            AvailableSpace::Definite(100.0),
1410        )
1411        .unwrap();
1412
1413        card.on_event(&press(20.0, 20.0, PointerSource::Mouse));
1414        card.on_event(&Event::PointerMoved {
1415            x: 60.0,
1416            y: 25.0,
1417            source: PointerSource::Mouse,
1418        });
1419        card.on_event(&Event::CursorLeft);
1420        assert_eq!(
1421            *ends.borrow(),
1422            vec![(60.0, 25.0)],
1423            "the last position the drag reached"
1424        );
1425    }
1426
1427    // `on_drag_end` alone is enough to make a box draggable: a gesture that only cares about the outcome
1428    // should not have to register a per-move callback it ignores.
1429    #[test]
1430    fn on_drag_end_works_without_an_on_drag() {
1431        use std::cell::RefCell;
1432        let ends: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
1433        let sink = ends.clone();
1434        reset_layout_runtime();
1435        let mut card = StyledContainer::new(
1436            LayoutStyle::new().width(100.0).height(100.0),
1437            |_r| RectStyle::default(),
1438            vec![],
1439        )
1440        .unwrap()
1441        .on_drag_end(move |x, y| sink.borrow_mut().push((x, y)));
1442        compute_layout(
1443            card.layout_node(),
1444            AvailableSpace::Definite(100.0),
1445            AvailableSpace::Definite(100.0),
1446        )
1447        .unwrap();
1448        card.on_event(&press(10.0, 10.0, PointerSource::Mouse));
1449        card.on_event(&release(80.0, 10.0, PointerSource::Mouse));
1450        assert_eq!(*ends.borrow(), vec![(80.0, 10.0)]);
1451    }
1452
1453    // A focusable box fires on_focus(true) when tapped and on_focus(false) when focus is cleared.
1454    #[test]
1455    fn on_focus_fires_on_gain_and_loss() {
1456        use std::cell::RefCell;
1457        let seen: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
1458        let sink = seen.clone();
1459        reset_layout_runtime();
1460        let mut card = StyledContainer::new(
1461            LayoutStyle::new().flex_column().width(100.0).height(100.0),
1462            |_r| RectStyle::default(),
1463            vec![],
1464        )
1465        .unwrap()
1466        .on_focus(move |f| sink.borrow_mut().push(f));
1467        compute_layout(
1468            card.layout_node(),
1469            AvailableSpace::Definite(100.0),
1470            AvailableSpace::Definite(100.0),
1471        )
1472        .unwrap();
1473
1474        card.on_event(&press(50.0, 50.0, PointerSource::Mouse)); // tap focuses → on_focus(true)
1475        crate::focus::clear(); // → on_focus(false)
1476        assert_eq!(
1477            *seen.borrow(),
1478            vec![true, false],
1479            "on_focus fires true on gain then false on loss"
1480        );
1481    }
1482
1483    // A pressable box publishes its laid-out rect to the interactive registry (so a carved-input-region surface
1484    // receives input over it), and withdraws it on drop.
1485    #[test]
1486    fn pressable_publishes_rect_to_interactive_registry_and_withdraws_on_drop() {
1487        use crate::interactive_rects;
1488        reset_layout_runtime();
1489        let baseline = interactive_rects().len();
1490        let card = StyledContainer::new(
1491            LayoutStyle::new().width(120.0).height(40.0),
1492            |_r| RectStyle::default(),
1493            vec![],
1494        )
1495        .unwrap()
1496        .on_press(|| {});
1497        let node = card.layout_node();
1498        // Zero-sized before layout, so it contributes nothing yet.
1499        assert_eq!(
1500            interactive_rects().len(),
1501            baseline,
1502            "an unlaid-out pressable contributes no rect"
1503        );
1504        compute_layout(
1505            node,
1506            AvailableSpace::Definite(120.0),
1507            AvailableSpace::Definite(40.0),
1508        )
1509        .unwrap();
1510        let rects = interactive_rects();
1511        assert_eq!(rects.len(), baseline + 1);
1512        assert!(
1513            rects.iter().any(|r| r.width == 120.0 && r.height == 40.0),
1514            "a laid-out pressable reports its rect"
1515        );
1516        drop(card);
1517        assert_eq!(
1518            interactive_rects().len(),
1519            baseline,
1520            "dropping the pressable withdraws its rect"
1521        );
1522    }
1523}