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