Skip to main content

telar_ui_core/
styled_container.rs

1use geometry_core::{Rect, Transform};
2use layout_core::{LayoutError, LayoutStyle, NodeId};
3use platform_core::{
4    Cursor, Event, Key, NamedKey, NumericValue, PointerButton, PointerSource, WindowCommand,
5};
6use reactive_core::{Effect, RwSignal, effect, signal};
7use renderer_core::{Color, RectStyle, ShapeStyle, Stroke};
8use theme_core::use_theme_tokens;
9use ui_tree::{Component, EventResult, RenderNode};
10
11use crate::child_host::{ChildSlot, DynHost};
12use crate::context::{new_container, track_layout};
13use crate::drag::DragGesture;
14use crate::focus::{self, FocusId};
15use crate::layout_item::{LayoutItem, TrackedChildren, register_container};
16use crate::pointer::dispatch_container_event;
17use crate::press::PressGesture;
18
19/// Re-resolves `node`'s layout style whenever the reactive state `style` reads changes, and once now.
20///
21/// The general form of [`StyledContainer::styled_by`], for a widget that is not a container — a text leaf sized
22/// off the theme's `font_size`, a slider thumb sized off its `spacing`. The returned [`Effect`] must be held for
23/// as long as the node lives, which for a leaf means its owning container `keeping` it.
24pub fn style_follows(node: NodeId, style: impl Fn() -> LayoutStyle + 'static) -> Effect {
25    effect(move || {
26        let _ = crate::context::set_layout_style(node, style());
27    })
28}
29
30/// The paint a box swaps in per state, and the state itself.
31///
32/// One value so "does this box repaint on a pointer transition" is a question with an owner, instead of a
33/// term someone has to remember to add to a disjunction spelled out at the top of `on_event`.
34struct StateStyle {
35    // Swapped in while the pointer is over the box (mouse only), mirroring `Button`'s rect/rect_hover.
36    hover: Option<Box<dyn Fn(Rect) -> RectStyle>>,
37    is_hovered: RwSignal<bool>,
38    // Swapped in while a primary pointer is held down inside the box (the pressed / CSS `:active` state),
39    // taking precedence over `hover`. Mouse and touch; cleared on release, leave, or drag-off.
40    active: Option<Box<dyn Fn(Rect) -> RectStyle>>,
41    is_active: RwSignal<bool>,
42    // Swapped in ahead of every other state: a control that cannot be used must not also look pressable.
43    disabled: Option<Box<dyn Fn(Rect) -> RectStyle>>,
44    // Laid *over* whichever state won, not instead of it — see `focus_style`.
45    focus: Option<Box<dyn Fn(Rect) -> RectStyle>>,
46}
47
48impl Default for StateStyle {
49    fn default() -> Self {
50        Self {
51            hover: None,
52            is_hovered: signal(false),
53            active: None,
54            is_active: signal(false),
55            disabled: None,
56            focus: None,
57        }
58    }
59}
60
61impl StateStyle {
62    /// Whether a pointer transition changes how the box looks, and so has to be tracked at all.
63    fn repaints_on_pointer(&self) -> bool {
64        self.hover.is_some() || self.active.is_some()
65    }
66}
67
68/// What the box wants told about the pointer, beyond press and drag.
69#[derive(Default)]
70struct PointerHooks {
71    // Fires with `true`/`false` as the mouse enters/leaves the box (mouse only, like the hover style).
72    hover: Option<Box<dyn Fn(bool)>>,
73    // Fires with the pointer position, local to the box, on every move over it. The continuous half of `hover`, which only reports the crossings.
74    moved: Option<Box<dyn Fn(f32, f32)>>,
75    // Fires with the wheel delta while the pointer is over the box.
76    scroll: Option<Box<dyn Fn(f32, f32)>>,
77    // Pointer shape while the box is hovered; restored to the default on leave. Set from `cursor:` in the DSL.
78    cursor: Option<Cursor>,
79}
80
81impl PointerHooks {
82    /// Whether anything here needs the pointer's moves. `cursor` counts: a box whose only claim is a shape
83    /// still has to see the crossings that set and clear it.
84    fn is_set(&self) -> bool {
85        self.hover.is_some()
86            || self.moved.is_some()
87            || self.scroll.is_some()
88            || self.cursor.is_some()
89    }
90}
91
92/// The box's place in the focus order, when it has one.
93#[derive(Default)]
94struct Focusable {
95    // When set, the box is focusable: it joins the tab order, takes focus on tap, and handles Tab while
96    // focused. `on_focus` observes the transitions.
97    id: Option<FocusId>,
98    // Watches focus transitions for `on_focus`; dropping it (with the box) tears the subscription down.
99    _effect: Option<Effect>,
100    // Whether Enter and Space fire the press the way a tap does. Set by `control`, because a control that
101    // answers a mouse and not a keyboard is the failure this whole path exists to make unspellable.
102    activates: bool,
103    // Registered when the box is given a `disabled` source; withdrawn on drop.
104    scope: Option<focus::ScopeId>,
105}
106
107pub struct StyledContainer {
108    node: NodeId,
109    rect: RwSignal<Rect>,
110    style: Box<dyn Fn(Rect) -> RectStyle>,
111    state: StateStyle,
112    // A closure (like `opacity`) so `view()` and the pointer path both re-read it: whether a control is usable is state that moves. `None` is the common case and skips the call on the pointer-move path.
113    disabled_source: Option<Box<dyn Fn() -> bool>>,
114    // 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. `None` means fully opaque.
115    opacity: Option<Box<dyn Fn() -> f32>>,
116    // 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).
117    transform: Option<Box<dyn Fn(Rect) -> Option<[f32; 6]>>>,
118    children: TrackedChildren,
119    // Set when the box holds a reactive fragment: static + dynamic children route through the host so
120    // they interleave in this node (see `child_host`). `children` is empty in that case.
121    dyn_host: Option<DynHost>,
122    // Optional tap gesture so a styled box can itself be pressable (a clickable card); children still hit-test first.
123    press: PressGesture,
124    // 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.
125    kept_effects: Vec<Effect>,
126    // Optional drag gesture (slider/reorder/resize): reports the pointer position on press and each move.
127    drag: DragGesture,
128    pointer: PointerHooks,
129    // Fires on every key press. Key events carry no pointer position, so they are broadcast to every widget
130    // — this is a GLOBAL shortcut handler (there is no per-widget focus), not focused text input.
131    on_key: Option<Box<dyn Fn(&Key)>>,
132    focusable: Focusable,
133    // Whether the box declines to shadow what it is drawn over (`pointer-events: none`). Set from
134    // `click_through` in the DSL; see `LayoutItem::pointer_opaque`.
135    click_through: bool,
136}
137
138impl StyledContainer {
139    pub fn new(
140        layout_style: LayoutStyle,
141        style: impl Fn(Rect) -> RectStyle + 'static,
142        children: Vec<Box<dyn LayoutItem>>,
143    ) -> Result<Self, LayoutError> {
144        let (node, rect, children) = register_container(layout_style, children)?;
145        Ok(Self::assemble(node, rect, Box::new(style), children, None))
146    }
147
148    /// Everything a fresh box holds before any builder touches it; the two constructors differ only in
149    /// where their children live.
150    fn assemble(
151        node: NodeId,
152        rect: RwSignal<Rect>,
153        style: Box<dyn Fn(Rect) -> RectStyle>,
154        children: TrackedChildren,
155        dyn_host: Option<DynHost>,
156    ) -> Self {
157        Self {
158            node,
159            rect,
160            style,
161            state: StateStyle::default(),
162            disabled_source: None,
163            opacity: None,
164            transform: None,
165            children,
166            dyn_host,
167            press: PressGesture::default(),
168            kept_effects: Vec::new(),
169            drag: DragGesture::default(),
170            pointer: PointerHooks::default(),
171            on_key: None,
172            focusable: Focusable::default(),
173            click_through: false,
174        }
175    }
176
177    /// A styled box whose children are a mix of static widgets and reactive fragments (`ChildSlot`s),
178    /// reconciled into this box's own node so they inherit its flex direction/gap — the transparent
179    /// `box`-with-a-`for` path (see [`Container::from_slots`](crate::Container::from_slots)).
180    pub fn from_slots(
181        layout_style: LayoutStyle,
182        style: impl Fn(Rect) -> RectStyle + 'static,
183        slots: Vec<ChildSlot>,
184    ) -> Result<Self, LayoutError> {
185        let node = new_container(layout_style, &[])?;
186        let rect = track_layout(node).expect("new_container always registers a signal");
187        let dyn_host = DynHost::build(node, slots)?;
188        Ok(Self::assemble(
189            node,
190            rect,
191            Box::new(style),
192            Vec::new(),
193            Some(dyn_host),
194        ))
195    }
196
197    /// Whether the box is currently refusing input. `None` — the common case — answers without a dyn call on
198    /// the pointer-move broadcast path, which every box in the tree pays.
199    fn is_disabled(&self) -> bool {
200        self.disabled_source.as_ref().is_some_and(|f| f())
201    }
202
203    /// Whether the box wants nothing from an event and can route it straight to its children, exactly as a
204    /// plain container would.
205    ///
206    /// One question per group rather than one term per field: this predicate was a ten-term disjunction
207    /// amended in ten commits, two of them fixing the omission the shape invites — a box whose only claim
208    /// was a cursor, and one whose only claim was `on_key`, each silently lost its events.
209    fn is_inert(&self) -> bool {
210        !self.press.is_set()
211            && !self.drag.is_set()
212            && !self.state.repaints_on_pointer()
213            && !self.pointer.is_set()
214            && self.on_key.is_none()
215            && self.focusable.id.is_none()
216    }
217
218    fn dispatch_children(&mut self, event: &Event) -> EventResult {
219        match &self.dyn_host {
220            Some(host) => host.dispatch(event),
221            None => dispatch_container_event(&mut self.children, event),
222        }
223    }
224
225    pub fn with_opacity(mut self, opacity: impl Fn() -> f32 + 'static) -> Self {
226        self.opacity = Some(Box::new(opacity));
227        self
228    }
229
230    /// Apply an affine transform (rotate/scale/translate) to the whole box each `view()`. The closure
231    /// takes the laid-out rect and returns the 2×3 matrix, or `None` for identity.
232    pub fn with_transform(
233        mut self,
234        transform: impl Fn(Rect) -> Option<[f32; 6]> + 'static,
235    ) -> Self {
236        self.transform = Some(Box::new(transform));
237        self
238    }
239
240    /// Paint the box with `f` while the mouse hovers it (a declarative style swap, like `Button`).
241    /// Hover is mouse-only; touch never sets it, so a tap leaves no stuck hover state.
242    pub fn hover_style(mut self, f: impl Fn(Rect) -> RectStyle + 'static) -> Self {
243        self.state.hover = Some(Box::new(f));
244        self
245    }
246
247    /// Paint the box with `f` while a primary pointer is held down inside it — the pressed / CSS `:active`
248    /// state, which takes precedence over `hover_style`. Unlike hover it tracks touch as well as mouse,
249    /// and it clears on release, on leaving the box, or once the press drags off, so it never sticks.
250    pub fn active_style(mut self, f: impl Fn(Rect) -> RectStyle + 'static) -> Self {
251        self.state.active = Some(Box::new(f));
252        self
253    }
254
255    /// Marks the box unusable while `f` reads true: it stops taking the pointer, stops tracking hover and
256    /// the pressed state, stops showing its [`cursor`](Self::cursor), and paints its
257    /// [`disabled_style`](Self::disabled_style) ahead of every other state.
258    ///
259    /// Closed here rather than left to each widget because the web never asks anyone to write it: `disabled`
260    /// is platform semantics that a selector and the hit-tester read for free, and a catalogue that made every
261    /// component re-implement it would get a different subset right in each one. The failure it prevents is
262    /// small and immediate — a control the application has already disabled still lighting up under the
263    /// pointer and still showing a hand cursor, which says "press me" about something that will do nothing.
264    pub fn disabled(mut self, f: impl Fn() -> bool + 'static) -> Self {
265        let f = std::rc::Rc::new(f);
266        self.disabled_source = Some({
267            let f = f.clone();
268            Box::new(move || f())
269        });
270        // Tab is the question the pointer already answers here, so it goes through the same mechanism a hidden overlay uses — which gives a disabled *wrapper* the `fieldset` reading for the keyboard too, rather than shielding the mouse and leaving Tab a way in.
271        self.focusable.scope = Some(focus::register_scope_because(
272            self.node,
273            move || !f(),
274            false,
275            focus::ScopeReason::Disabled,
276        ));
277        self
278    }
279
280    /// The paint for the disabled state, which wins over the pressed and hover ones.
281    pub fn disabled_style(mut self, f: impl Fn(Rect) -> RectStyle + 'static) -> Self {
282        self.state.disabled = Some(Box::new(f));
283        self
284    }
285
286    /// The focus ring: drawn over whichever state won, while the box holds focus *and* should show it.
287    ///
288    /// Composed rather than swapped, unlike the other three, and the difference is the point. Hover, pressed
289    /// and disabled are answers to "what is this box doing", so one of them replaces the rest. A ring answers
290    /// a different question — where the keyboard is going — and a hovered box that lost its ring would hide
291    /// that answer at the exact moment the user reached for the mouse. Which is why CSS gives focus its own
292    /// property (`outline`) rather than another background.
293    ///
294    /// Only the properties the ring names are applied; `radius` always comes from the box, since a ring sits
295    /// on a shape it does not get to reshape. Shown on [`focus::is_focus_visible`](crate::focus), so a tap
296    /// takes focus without drawing one.
297    /// Declares this box a control, and is the way to build one.
298    ///
299    /// One call because the three halves are one fact, and each alone is a control that does not work: a box
300    /// that takes a tap but never a key, a ring on something Tab cannot reach, a thing announced to a screen
301    /// reader that cannot say what it is. Splitting them across three optional builders is how nine catalogue
302    /// components shipped answering the mouse and nothing else — every one of them compiled, and looked right.
303    ///
304    /// It joins the tab order at this node, answers Enter and Space the way it answers a tap, draws the
305    /// theme's focus ring while the keyboard is what reached it (see
306    /// [`focus::is_focus_visible`](crate::focus::is_focus_visible)), and reports `role` outwards. A caller
307    /// that wants a ring of its own still says so with [`focus_style`](Self::focus_style); this only
308    /// supplies one when nothing else has.
309    ///
310    /// Deliberately not implied by [`on_press`](Self::on_press): a scrim, a click-away backdrop and a drag
311    /// surface all take presses and none of them is a place the keyboard should stop.
312    pub fn control(mut self, role: focus::Role) -> Self {
313        let id = *self.focusable.id.get_or_insert_with(focus::next_id);
314        focus::register_with_role(id, focus::FocusKind::Widget, self.node, role);
315        self.focusable.activates = true;
316        if self.state.focus.is_none() {
317            self.state.focus = Some(Box::new(|_r| default_focus_ring()));
318        }
319        self.mark_interactive();
320        self
321    }
322
323    /// Declares that this control carries a checked state, and how to read it.
324    ///
325    /// Only meaningful after [`control`](Self::control), and only for the roles that have one. Without it a
326    /// reader announces "checkbox" and stops — and a default of "unticked" would be worse, since it would be
327    /// confidently wrong for half of them.
328    pub fn toggled(self, state: impl Fn() -> bool + 'static) -> Self {
329        if let Some(id) = self.focusable.id {
330            focus::set_toggled(id, state);
331        }
332        self
333    }
334
335    /// Declares the number this box carries, so a reader says where a slider stands and not only that it is
336    /// one. The counterpart of [`toggled`](Self::toggled) for a control whose state is a value.
337    pub fn valued(self, read: impl Fn() -> NumericValue + 'static) -> Self {
338        if let Some(id) = self.focusable.id {
339            focus::set_value(id, read);
340        }
341        self
342    }
343
344    pub fn focus_style(mut self, f: impl Fn(Rect) -> RectStyle + 'static) -> Self {
345        // Declaring a ring is declaring the box focusable, or it would join no tab order and the ring would be a style nothing could satisfy.
346        let id = *self.focusable.id.get_or_insert_with(focus::next_id);
347        focus::register_at(id, focus::FocusKind::Widget, self.node);
348        self.state.focus = Some(Box::new(f));
349        self
350    }
351
352    /// Whether the box is currently pressed (a primary pointer is held down inside it). Set only when an
353    /// `active_style` is present; drives its paint swap and clears on release/leave/drag-off.
354    fn set_active(&self, active: bool) {
355        if self.state.active.is_some() && self.state.is_active.get() != active {
356            self.state.is_active.set(active);
357        }
358    }
359
360    /// Drops everything that meant *the pointer is inside this box*: hover, the pressed look, and a tap
361    /// still waiting for a release within the bounds. Deliberately not the drag — that one is measured from
362    /// the press and does not care where the pointer has wandered to.
363    fn end_containment(&mut self) {
364        self.press.cancel();
365        self.set_active(false);
366        let tracks_hover = self.state.hover.is_some()
367            || self.pointer.hover.is_some()
368            || self.pointer.cursor.is_some();
369        if tracks_hover && self.state.is_hovered.get() {
370            self.state.is_hovered.set(false);
371            // The shape was this box's claim about what a press would do here, and nothing else restores it while the pointer is still inside the window.
372            if self.pointer.cursor.is_some() {
373                platform_core::push_window_command(WindowCommand::SetCursor(Cursor::Default));
374            }
375            if let Some(cb) = &self.pointer.hover {
376                cb(false);
377            }
378        }
379    }
380
381    /// Shows `cursor` while the pointer is over this box, and restores the default when it leaves.
382    ///
383    /// The shape is the app's statement of what the next press will do — orbit, resize a panel, place a
384    /// point — so it belongs to the widget that would handle that press, not to a mode the app tracks.
385    pub fn cursor(mut self, cursor: Cursor) -> Self {
386        self.pointer.cursor = Some(cursor);
387        self
388    }
389
390    /// Declares that this box does not stand between the pointer and whatever it is drawn over — CSS's
391    /// `pointer-events: none`, and the second consumer of the hook [`Overlay`] opened.
392    ///
393    /// A box covers what is behind it: since the hit-test walks in paint order, the topmost child under the
394    /// pointer takes the event whether or not it wants it. That is right for a panel and wrong for a *label*
395    /// — a readout floating over a canvas, a badge over a photo, a drag ghost — which is drawn on top
396    /// precisely so it can be read, and whose whole contract is that the thing underneath still works. A
397    /// modeller's transform readout sits across the top of the viewport it reports on; without this, moving
398    /// the pointer under it stops the operation it is describing.
399    ///
400    /// It is a property of *this* box only. Children still hit-test normally, so a click-through bar can
401    /// hold a real button — the same split CSS makes with `pointer-events: auto` on a child.
402    pub fn click_through(mut self, through: bool) -> Self {
403        self.click_through = through;
404        self
405    }
406
407    /// Give this widget ownership of an [`Effect`], so it runs for exactly as long as the widget exists.
408    ///
409    /// The reactive runtime scopes an effect to the *surface* it was registered on, which is the right span for
410    /// a shell-wide subscription and far too coarse for one row of a list: the row goes, the effect stays, and
411    /// it keeps firing at a node that is gone. Dropping the handle instead is the opposite failure — the effect
412    /// deregisters, runs once, and stops, with nothing to say so. This is the third answer, and the one an
413    /// effect that belongs to a widget wants.
414    ///
415    /// Chainable, so several effects can be kept without nesting anything.
416    pub fn keeping(mut self, subscription: Effect) -> Self {
417        self.kept_effects.push(subscription);
418        self
419    }
420
421    /// Keeps the box's *layout* style in step with the reactive state it was built from — the theme's metric
422    /// tokens, today. `style` runs now, and again whenever a signal it read changes; the node is restyled in
423    /// place, so a live theme switch re-spaces the box as well as re-colouring it.
424    ///
425    /// Paint needs nothing like this: a rect or text style is a closure the renderer re-runs every frame, so a
426    /// token read inside one is already live. A layout style is a *value*, handed to the layout tree once when
427    /// the node is made — which is why the reactive read has to be arranged here rather than coming for free.
428    ///
429    /// Give [`new`](Self::new) the same builder, so the node starts at the style it will settle on:
430    /// `StyledContainer::new(shell(), paint, kids)?.styled_by(shell)`.
431    pub fn styled_by(self, style: impl Fn() -> LayoutStyle + 'static) -> Self {
432        let node = self.node;
433        self.keeping(style_follows(node, style))
434    }
435
436    /// Make the box itself pressable. The callback fires on a tap (release, not press) inside the box;
437    /// a child widget that handles the press wins, and a scroll gesture started on the box does not fire it.
438    pub fn on_press(self, f: impl Fn() + 'static) -> Self {
439        self.maybe_on_press(Some(f))
440    }
441
442    /// [`on_press`](Self::on_press) for a handler the caller may not have supplied.
443    ///
444    /// What a wrapper component needs to forward an optional callback. A box whose press handler is a no-op
445    /// still reports the tap `Handled`, so "no handler" would become "swallows the click" — a display-only chip
446    /// eating a press instead of letting it through. `None` leaves the box exactly as it was; the `maybe_*`
447    /// pairs below say the same for every other event whose absence the box can observe.
448    pub fn maybe_on_press(mut self, f: Option<impl Fn() + 'static>) -> Self {
449        let Some(f) = f else { return self };
450        self.press.set(f);
451        self.mark_interactive();
452        self
453    }
454
455    /// Fire `f(button)` on a tap with a **non-primary** button — `Secondary` (right) or `Auxiliary` (middle).
456    /// Same tap-on-release semantics as [`Self::on_press`]: a child that handles the press wins, and travel
457    /// past the tap slop cancels it.
458    ///
459    /// Opt-in per box rather than folded into `on_press`, because a non-primary press otherwise falls through
460    /// to whatever is behind it — silently swallowing right-clicks on every pressable box would break that.
461    pub fn on_alt_press(self, f: impl Fn(PointerButton) + 'static) -> Self {
462        self.maybe_on_alt_press(Some(f))
463    }
464
465    /// [`on_alt_press`](Self::on_alt_press) for a handler the caller may not have supplied.
466    pub fn maybe_on_alt_press(mut self, f: Option<impl Fn(PointerButton) + 'static>) -> Self {
467        let Some(f) = f else { return self };
468        self.press.set_alt_press(f);
469        self.mark_interactive();
470        self
471    }
472
473    /// Fires once a press inside the box is held past ~500ms without moving past the tap slop, instead of
474    /// `on_press`'s tap-on-release. There is no dedicated timer in the gesture pipeline, so the threshold is
475    /// only checked on the next pointer event after the press (a move or the release) — it fires slightly
476    /// late, never at exactly 500ms, and a release before that next check-in is a normal tap.
477    pub fn on_long_press(self, f: impl Fn() + 'static) -> Self {
478        self.maybe_on_long_press(Some(f))
479    }
480
481    /// [`on_long_press`](Self::on_long_press) for a handler the caller may not have supplied.
482    pub fn maybe_on_long_press(mut self, f: Option<impl Fn() + 'static>) -> Self {
483        let Some(f) = f else { return self };
484        self.press.set_long_press(f);
485        self.mark_interactive();
486        self
487    }
488
489    /// Make the box draggable. The callback fires with the pointer position (layout space) on a press
490    /// inside the box and on every move until release — even after the pointer leaves the box. Map the
491    /// coordinate to a value (slider) or an offset (reorder/resize).
492    /// Fires once when a drag started on this box ends, with the position it finished at (layout space, local
493    /// to the box, same as [`on_drag`](Self::on_drag)).
494    ///
495    /// This is what makes a *threshold* gesture expressible: `on_drag` alone reports where the pointer is but
496    /// never that it let go, so a swipe-to-dismiss or a drag-to-open can be tracked and never decided. A drag
497    /// also ends when the pointer leaves the window or a child consumes the release; those carry no position,
498    /// so the last one the drag reached is reported instead — the gesture always ends exactly once.
499    pub fn on_drag_end(self, f: impl Fn(f32, f32) + 'static) -> Self {
500        self.maybe_on_drag_end(Some(f))
501    }
502
503    /// [`on_drag_end`](Self::on_drag_end) for a handler the caller may not have supplied.
504    pub fn maybe_on_drag_end(mut self, f: Option<impl Fn(f32, f32) + 'static>) -> Self {
505        let Some(f) = f else { return self };
506        self.drag.set_end(f);
507        self.mark_interactive();
508        self
509    }
510
511    pub fn on_drag(self, f: impl Fn(f32, f32) + 'static) -> Self {
512        self.maybe_on_drag(Some(f))
513    }
514
515    /// [`on_drag`](Self::on_drag) for a handler the caller may not have supplied.
516    pub fn maybe_on_drag(mut self, f: Option<impl Fn(f32, f32) + 'static>) -> Self {
517        let Some(f) = f else { return self };
518        self.drag.set(f);
519        self.mark_interactive();
520        self
521    }
522
523    /// Let `button` start the drag too, on top of the primary one that always does.
524    ///
525    /// A slider or a splitter wants exactly one button and gets it by default. A surface with more than one
526    /// thing to drag needs the others: a modeller orbits with the primary button and pans with the secondary,
527    /// which is what the OS and every 3D application call those gestures. The handler is the same one — read
528    /// [`crate::pointer_buttons`] inside it to tell which button is doing the dragging.
529    pub fn drag_button(mut self, button: platform_core::PointerButton) -> Self {
530        self.drag.arm_with(&button);
531        self
532    }
533
534    /// How far the pointer must travel before this box counts as being dragged.
535    ///
536    /// Without it a press *is* a drag from its first instant, which is right for a slider — pressing the track
537    /// is how you set the value — and wrong for anything where a click and a drag mean different things on the
538    /// same button. A viewport is the case: a click picks what is under it, a drag orbits, and telling them
539    /// apart is the difference between selecting something and nudging the camera by a pixel.
540    ///
541    /// Set it and the two stop overlapping: a stroke that never travels this far fires only
542    /// [`on_press`](Self::on_press), one that does fires only the drag handlers, and neither fires both.
543    pub fn drag_threshold(mut self, px: f32) -> Self {
544        self.drag.set_threshold(px);
545        self
546    }
547
548    /// Records this box as a pointer target in the per-surface interactive registry, so a surface that carves
549    /// its input region from its content (a click-through overlay) receives input over it. See
550    /// [`crate::interactive_rects`].
551    fn mark_interactive(&self) {
552        crate::input_region::register_interactive(self.node, self.rect.read_only());
553    }
554
555    /// Fire `f(true)` when the mouse enters the box and `f(false)` when it leaves (mouse only). Independent
556    /// of `hover_style`: a box can observe hover without swapping its paint.
557    ///
558    /// Registers the box as a pointer target, like [`on_scroll`](Self::on_scroll) does for the same reason: a
559    /// surface that carves its input region from its content (a click-through overlay) never receives a move
560    /// event over a box it left out of that region, so a hover it did not register is a hover it can't observe.
561    pub fn on_hover(self, f: impl Fn(bool) + 'static) -> Self {
562        self.maybe_on_hover(Some(f))
563    }
564
565    /// [`on_hover`](Self::on_hover) for a handler the caller may not have supplied.
566    pub fn maybe_on_hover(mut self, f: Option<impl Fn(bool) + 'static>) -> Self {
567        let Some(f) = f else { return self };
568        self.pointer.hover = Some(Box::new(f));
569        self.mark_interactive();
570        self
571    }
572
573    /// Fire `f(x, y)` with the pointer position — local to the box, as [`on_drag`](Self::on_drag) reports it —
574    /// on every move over it.
575    ///
576    /// The continuous half of [`on_hover`](Self::on_hover), which reports only the crossings. It is what a
577    /// surface that answers to *where* the pointer is needs: highlighting the face under the cursor, previewing
578    /// a snap, stretching a dimension line. Fires for touch as well as mouse, since a drag on a touchscreen
579    /// asks the same question.
580    pub fn on_pointer_move(self, f: impl Fn(f32, f32) + 'static) -> Self {
581        self.maybe_on_pointer_move(Some(f))
582    }
583
584    /// [`on_pointer_move`](Self::on_pointer_move) for a handler the caller may not have supplied.
585    pub fn maybe_on_pointer_move(mut self, f: Option<impl Fn(f32, f32) + 'static>) -> Self {
586        let Some(f) = f else { return self };
587        self.pointer.moved = Some(Box::new(f));
588        self.mark_interactive();
589        self
590    }
591
592    /// Fire `f(dx, dy)` with the wheel delta while the pointer is over the box — scroll-to-adjust on a control
593    /// (a volume or brightness chip, a stepper), or zoom on a viewport. Deltas are normalised to pixels,
594    /// matching [`ScrollArea`](crate::ScrollArea): a line delta counts as 20px, so one wheel notch is roughly
595    /// ±60.
596    ///
597    /// Targeted by hit-testing the wheel's own position, so it answers a wheel that arrives before the pointer
598    /// has moved at all. A scrollable child (a scroll area inside the box) gets first refusal and keeps it.
599    pub fn on_scroll(self, f: impl Fn(f32, f32) + 'static) -> Self {
600        self.maybe_on_scroll(Some(f))
601    }
602
603    /// [`on_scroll`](Self::on_scroll) for a handler the caller may not have supplied.
604    pub fn maybe_on_scroll(mut self, f: Option<impl Fn(f32, f32) + 'static>) -> Self {
605        let Some(f) = f else { return self };
606        self.pointer.scroll = Some(Box::new(f));
607        self.mark_interactive();
608        self
609    }
610
611    /// Fire `f(&key)` on every key press. This is a GLOBAL handler (key events reach every widget; there is
612    /// no per-widget focus), so it suits app-level shortcuts, not focused text entry.
613    ///
614    /// It stands aside while a text entry holds focus and the press is text it would take
615    /// ([`focus::text_entry_takes_key`]) — so a shortcut on `3` does not also fire when the user types `3`
616    /// into a field, while `⌘S` still reaches it. Read the modifiers with [`crate::modifiers`]: key events
617    /// carry them, but pointer events do not, so the state registry is the one answer that works everywhere.
618    pub fn on_key(self, f: impl Fn(&Key) + 'static) -> Self {
619        self.maybe_on_key(Some(f))
620    }
621
622    /// [`on_key`](Self::on_key) for a handler the caller may not have supplied.
623    pub fn maybe_on_key(mut self, f: Option<impl Fn(&Key) + 'static>) -> Self {
624        let Some(f) = f else { return self };
625        self.on_key = Some(Box::new(f));
626        self
627    }
628
629    /// Make the box focusable and fire `f(true)`/`f(false)` when it gains/loses keyboard focus. It joins
630    /// the tab order (Tab/Shift-Tab reach it) and takes focus on tap. Use it to drive a focus ring or to
631    /// build a custom focusable widget on top of a `box`.
632    pub fn on_focus(self, f: impl Fn(bool) + 'static) -> Self {
633        self.maybe_on_focus(Some(f))
634    }
635
636    /// [`on_focus`](Self::on_focus) for a handler the caller may not have supplied.
637    pub fn maybe_on_focus(mut self, f: Option<impl Fn(bool) + 'static>) -> Self {
638        let Some(f) = f else { return self };
639        let id = *self.focusable.id.get_or_insert_with(focus::next_id);
640        focus::register_at(id, focus::FocusKind::Widget, self.node);
641        // An effect fires the callback only on an actual transition (its first run seeds `last`, no fire).
642        let last = std::rc::Rc::new(std::cell::Cell::new(focus::is_focused(id)));
643        self.focusable._effect = Some(effect(move || {
644            let now = focus::is_focused(id);
645            if now != last.get() {
646                last.set(now);
647                f(now);
648            }
649        }));
650        self
651    }
652}
653
654impl LayoutItem for StyledContainer {
655    fn layout_node(&self) -> NodeId {
656        self.node
657    }
658
659    fn pointer_opaque(&self) -> bool {
660        !self.click_through
661    }
662}
663
664impl Component for StyledContainer {
665    fn view(&self) -> RenderNode {
666        let r = self.rect.get();
667        // Disabled wins over pressed wins over hover wins over base. Each state is only read when its style exists, so a plain box's view() stays inert and subscribes to none of them.
668        let style = if let Some(disabled) = &self.state.disabled
669            && self.is_disabled()
670        {
671            disabled
672        } else if let Some(active) = &self.state.active
673            && self.state.is_active.get()
674        {
675            active
676        } else if let Some(hover) = &self.state.hover
677            && self.state.is_hovered.get()
678        {
679            hover
680        } else {
681            &self.style
682        };
683        let painted = match (&self.state.focus, self.focusable.id) {
684            (Some(ring), Some(id)) if focus::is_focus_visible(id) => {
685                let base = style(r);
686                let ring = ring(r);
687                RectStyle {
688                    fill: ring.fill.or(base.fill),
689                    stroke: ring.stroke.or(base.stroke),
690                    shadow: ring.shadow.or(base.shadow),
691                    // The ring sits on the box's shape rather than choosing one of its own.
692                    radius: base.radius,
693                    // Sides travel with whichever stroke won, and a ring goes all the way round: a box
694                    // wearing its border on one edge must not lend that edge to the ring and leave the
695                    // keyboard pointing at three sides of nothing.
696                    border_widths: if ring.stroke.is_some() {
697                        ring.border_widths
698                    } else {
699                        base.border_widths
700                    },
701                }
702            }
703            _ => style(r),
704        };
705        let background = RenderNode::rect(
706            Rect {
707                x: r.x,
708                y: r.y,
709                width: r.width,
710                height: r.height,
711            },
712            painted,
713        );
714        let content = match &self.dyn_host {
715            Some(host) => {
716                RenderNode::group(std::iter::once(background).chain(host.child_boundaries()))
717            }
718            None => RenderNode::group(
719                std::iter::once(background)
720                    .chain(self.children.iter().map(|c| c.segment.boundary())),
721            ),
722        };
723        let opacity = self.opacity.as_ref().map_or(1.0, |o| o());
724        let composed = if opacity < 1.0 {
725            RenderNode::layer(opacity, 0.0, [content])
726        } else {
727            content
728        };
729        match self.transform.as_ref().and_then(|t| t(r)) {
730            Some(matrix) => RenderNode::transform_with(matrix, [composed]),
731            None => composed,
732        }
733    }
734
735    fn on_event(&mut self, event: &Event) -> EventResult {
736        // `disabled` on a region means the region, as a `fieldset` does — hence ahead of the pure-routing bail below, which a wrapper with no handlers of its own would otherwise take.
737        // The state it was showing goes with it, or a box disabled mid-hover keeps the highlight and the hand cursor it can no longer honour.
738        if self.is_disabled() {
739            return match event {
740                Event::PointerMoved { .. }
741                | Event::PointerPressed { .. }
742                | Event::PointerReleased { .. }
743                | Event::Scrolled { .. } => {
744                    self.end_containment();
745                    self.drag.end(None);
746                    EventResult::Ignored
747                }
748                _ => self.dispatch_children(event),
749            };
750        }
751        if self.is_inert() {
752            return self.dispatch_children(event);
753        }
754        let rect = self.rect.get();
755        match event {
756            // Moves are broadcast to all children (their hover) and also feed our own scroll-vs-tap and
757            // hover tracking. Hover is mouse-only: touch has no "pointer left", so a tap would otherwise
758            // leave the box stuck in its hover style.
759            Event::PointerMoved { x, y, source } => {
760                self.press.track_move(event);
761                let dragged = self.drag.moved(event, rect) == EventResult::Handled;
762                // A stroke that has cleared its drag threshold has committed to being a drag, so it is no
763                // longer a tap. Only for a box that set one: without a threshold the two have always both
764                // fired, and a slider that also takes a press is entitled to keep that.
765                if self.drag.has_threshold() && self.drag.has_started() {
766                    self.press.cancel();
767                }
768                let child = self.dispatch_children(event);
769                // Inside the box AND nothing drawn in front of it there: a move is broadcast for the sake of
770                // gestures already running, and only the topmost box under the pointer is *hovered*.
771                let inside =
772                    rect.contains(*x as f32, *y as f32) && !crate::pointer::pointer_occluded();
773                // Pressed clears once the pointer drags off the box (mouse or touch) so it never sticks.
774                if !inside {
775                    self.set_active(false);
776                }
777                if inside && let Some(cb) = &self.pointer.moved {
778                    cb(*x as f32 - rect.x, *y as f32 - rect.y);
779                }
780                let tracks_hover = self.state.hover.is_some()
781                    || self.pointer.hover.is_some()
782                    || self.pointer.cursor.is_some();
783                if tracks_hover
784                    && matches!(source, PointerSource::Mouse)
785                    && inside != self.state.is_hovered.get()
786                {
787                    self.state.is_hovered.set(inside);
788                    if let Some(cursor) = self.pointer.cursor {
789                        platform_core::push_window_command(WindowCommand::SetCursor(if inside {
790                            cursor
791                        } else {
792                            Cursor::Default
793                        }));
794                    }
795                    if let Some(cb) = &self.pointer.hover {
796                        cb(inside);
797                    }
798                    return EventResult::Handled;
799                }
800                if dragged { EventResult::Handled } else { child }
801            }
802            // A child (e.g. an inner button) hit-tests first and wins; only a press on the bare box arms our tap/drag.
803            Event::PointerPressed { x, y, button, .. } => {
804                // Pressed state and focus are primary-only. A box that asked for other buttons gets them
805                // routed to its press or drag gesture; every other box lets them fall through untouched.
806                let primary = *button == PointerButton::Primary;
807                if !primary && !self.press.wants_alt() && !self.drag.arms(button) {
808                    return self.dispatch_children(event);
809                }
810                if self.dispatch_children(event) == EventResult::Handled {
811                    self.press.cancel();
812                    self.drag.end(None);
813                    return EventResult::Handled;
814                }
815                // A primary press inside the box enters the pressed state (purely visual; independent of on_press).
816                if primary && rect.contains(*x as f32, *y as f32) {
817                    self.set_active(true);
818                }
819                // A tap inside a focusable box takes focus (and consumes the press so focus sticks).
820                let focused = match self.focusable.id {
821                    Some(id) if primary && rect.contains(*x as f32, *y as f32) => {
822                        focus::request_from_pointer(id);
823                        true
824                    }
825                    _ => false,
826                };
827                let tapped =
828                    self.press.is_set() && self.press.arm(event, rect) == EventResult::Handled;
829                let dragged =
830                    self.drag.is_set() && self.drag.press(event, rect) == EventResult::Handled;
831                if tapped || dragged || focused {
832                    EventResult::Handled
833                } else {
834                    EventResult::Ignored
835                }
836            }
837            Event::PointerReleased { button, .. } => {
838                let primary = *button == PointerButton::Primary;
839                if !primary && !self.press.wants_alt() && !self.drag.arms(button) {
840                    return self.dispatch_children(event);
841                }
842                // A release always ends the pressed state, wherever it lands.
843                if primary {
844                    self.set_active(false);
845                }
846                if self.dispatch_children(event) == EventResult::Handled {
847                    self.press.cancel();
848                    self.drag.end(None);
849                    return EventResult::Handled;
850                }
851                // The release carries its own position, which is the one the gesture actually finished at —
852                // a drag can end past the last move the compositor delivered.
853                let released_at = match event {
854                    Event::PointerReleased { x, y, .. } => {
855                        Some((*x as f32 - rect.x, *y as f32 - rect.y))
856                    }
857                    _ => None,
858                };
859                let dragged = self.drag.arms(button) && self.drag.end(released_at);
860                let tapped =
861                    self.press.is_set() && self.press.release(event, rect) == EventResult::Handled;
862                if tapped || dragged {
863                    EventResult::Handled
864                } else {
865                    EventResult::Ignored
866                }
867            }
868            // Crossing the window border says nothing about a gesture in flight: a drag is measured from the press, and ending it here cuts an orbit short at the edge of a full-window viewport. What leaving does invalidate is containment.
869            Event::CursorLeft => {
870                self.end_containment();
871                self.dispatch_children(event)
872            }
873            // Where a live gesture really does have to end: a window that loses focus never sends the release for what was held, and Alt-Tab never crosses the border.
874            Event::FocusChanged { is_focused: false } => {
875                self.end_containment();
876                self.drag.end(None);
877                self.dispatch_children(event)
878            }
879            // Children (e.g. a nested scroll area) get first refusal; only then does an `on_scroll` box under the wheel consume it.
880            Event::Scrolled { delta, x, y } => {
881                if self.dispatch_children(event) == EventResult::Handled {
882                    return EventResult::Handled;
883                }
884                let Some(cb) = &self.pointer.scroll else {
885                    return EventResult::Ignored;
886                };
887                if !rect.contains(*x as f32, *y as f32) {
888                    return EventResult::Ignored;
889                }
890                let (dx, dy) = delta.pixels();
891                cb(dx, dy);
892                EventResult::Handled
893            }
894            // Broadcast (no pointer position): fire the global key handler, then keep routing to children.
895            Event::KeyPressed { key, modifiers } => {
896                // While this focusable box holds focus, Tab moves focus to the next/previous field.
897                if let Some(id) = self.focusable.id
898                    && focus::is_focused(id)
899                    && matches!(key, Key::Named(NamedKey::Tab))
900                {
901                    if modifiers.is_shift {
902                        focus::focus_prev();
903                    } else {
904                        focus::focus_next();
905                    }
906                    return EventResult::Handled;
907                }
908                // The keyboard half of a tap. Consumed only when there was something to fire, so Space on a
909                // focused box with no press handler still reaches whatever else wanted it — and skipped
910                // entirely for a box that handles its own keys, which is not second-guessed: a dropdown
911                // trigger answers Enter by confirming a highlighted row, not by re-opening itself.
912                if self.focusable.activates
913                    && self.on_key.is_none()
914                    && let Some(id) = self.focusable.id
915                    && focus::is_focused(id)
916                    && matches!(key, Key::Named(NamedKey::Enter | NamedKey::Space))
917                    && self.press.activate()
918                {
919                    return EventResult::Handled;
920                }
921                // Not while a field has the caret: the press is the user typing, and this handler is the app's
922                // shortcut table, which would otherwise fire on every letter of what they type.
923                if let Some(cb) = &self.on_key
924                    && !focus::text_entry_takes_key(key, *modifiers)
925                {
926                    cb(key);
927                }
928                self.dispatch_children(event)
929            }
930            _ => self.dispatch_children(event),
931        }
932    }
933
934    fn debug_name(&self) -> &'static str {
935        "StyledContainer"
936    }
937}
938
939impl Drop for StyledContainer {
940    fn drop(&mut self) {
941        // Drop the focus watcher first so releasing focus below doesn't fire `on_focus` during teardown.
942        self.focusable._effect.take();
943        if let Some(id) = self.focusable.id {
944            focus::unregister(id);
945        }
946        if let Some(scope) = self.focusable.scope {
947            focus::unregister_scope(scope);
948        }
949        crate::input_region::unregister_interactive(self.node);
950    }
951}
952
953/// The ring a control wears when the keyboard is what reached it, unless it asked for one of its own.
954///
955/// A ring and not a fill, because it answers a different question from hover or pressed — *where the keys are
956/// going*, not what the box is doing — and has to survive being layered over whichever of those won. Its
957/// radius is deliberately absent: the compositing path takes that from the box, since a ring sits on a shape
958/// it does not get to reshape.
959fn default_focus_ring() -> RectStyle {
960    let accent = use_theme_tokens()
961        .map(|t| t.primary())
962        .unwrap_or(Color::rgba(0.26, 0.38, 0.93, 1.0));
963    RectStyle::default().with_stroke(Stroke::new(accent, 2.0))
964}
965
966/// Builds the affine matrix for a box's declarative `rotate`/`scale`/`translate` attributes, pivoting
967/// rotation and scale on the box centre. Returns `None` when every component is identity, so an untransformed
968/// box skips the extra transform node entirely.
969pub fn box_transform(
970    rect: Rect,
971    rotate_deg: f32,
972    scale_x: f32,
973    scale_y: f32,
974    translate_x: f32,
975    translate_y: f32,
976) -> Option<[f32; 6]> {
977    if rotate_deg == 0.0
978        && scale_x == 1.0
979        && scale_y == 1.0
980        && translate_x == 0.0
981        && translate_y == 0.0
982    {
983        return None;
984    }
985    let cx = rect.x + rect.width / 2.0;
986    let cy = rect.y + rect.height / 2.0;
987    let matrix = Transform::rotate_around(rotate_deg, cx, cy)
988        .then(Transform::scale_around(scale_x, scale_y, cx, cy))
989        .then(Transform::translate(translate_x, translate_y));
990    Some(matrix.to_array())
991}
992
993#[cfg(test)]
994mod tests {
995    use crate::context::reset_layout_runtime;
996    use std::cell::Cell;
997    use std::rc::Rc;
998
999    use layout_core::AvailableSpace;
1000    use platform_core::{PointerButton, PointerSource};
1001    use renderer_core::{Color, ShapeStyle};
1002    use theme_core::{Theme, ThemeTokens, set_theme, use_theme};
1003
1004    use super::*;
1005    use platform_core::ScrollDelta;
1006
1007    #[test]
1008    fn box_transform_identity_is_none() {
1009        let r = Rect {
1010            x: 0.0,
1011            y: 0.0,
1012            width: 10.0,
1013            height: 10.0,
1014        };
1015        assert!(box_transform(r, 0.0, 1.0, 1.0, 0.0, 0.0).is_none());
1016    }
1017
1018    #[test]
1019    fn box_transform_scale_pivots_on_center() {
1020        let r = Rect {
1021            x: 0.0,
1022            y: 0.0,
1023            width: 100.0,
1024            height: 100.0,
1025        };
1026        // scale_around(2, 2, 50, 50): pins the centre, so e = f = 50 - 2*50 = -50.
1027        assert_eq!(
1028            box_transform(r, 0.0, 2.0, 2.0, 0.0, 0.0).unwrap(),
1029            [2.0, 0.0, 0.0, 2.0, -50.0, -50.0]
1030        );
1031    }
1032
1033    #[test]
1034    fn box_transform_translate_offsets_origin() {
1035        let r = Rect {
1036            x: 0.0,
1037            y: 0.0,
1038            width: 10.0,
1039            height: 10.0,
1040        };
1041        assert_eq!(
1042            box_transform(r, 0.0, 1.0, 1.0, 8.0, -4.0).unwrap(),
1043            [1.0, 0.0, 0.0, 1.0, 8.0, -4.0]
1044        );
1045    }
1046    use crate::container::Container;
1047    use crate::context::{compute_layout, track_layout};
1048
1049    /// The three halves a control is made of, asserted together because that is the whole reason they are one
1050    /// call: Tab reaches it, Enter fires it, and it says what it is. Nine catalogue components had none of the
1051    /// three while compiling and looking correct, which is what a split API buys you.
1052    #[test]
1053    fn a_control_joins_the_tab_order_answers_enter_and_says_what_it_is() {
1054        use std::cell::Cell;
1055
1056        reset_layout_runtime();
1057        focus::clear();
1058        let fired: Rc<Cell<u32>> = Rc::new(Cell::new(0));
1059        let sink = fired.clone();
1060        let mut card = StyledContainer::new(
1061            LayoutStyle::new().width(80.0).height(30.0),
1062            |_r| RectStyle::default(),
1063            vec![],
1064        )
1065        .unwrap()
1066        .control(focus::Role::CheckBox)
1067        .on_press(move || sink.set(sink.get() + 1));
1068        compute_layout(
1069            card.layout_node(),
1070            AvailableSpace::Definite(80.0),
1071            AvailableSpace::Definite(30.0),
1072        )
1073        .unwrap();
1074
1075        focus::focus_next();
1076        assert!(focus::current().is_some(), "Tab reaches it");
1077
1078        let key = |named| Event::KeyPressed {
1079            key: Key::Named(named),
1080            modifiers: platform_core::ModifiersState::default(),
1081        };
1082        assert_eq!(card.on_event(&key(NamedKey::Enter)), EventResult::Handled);
1083        assert_eq!(card.on_event(&key(NamedKey::Space)), EventResult::Handled);
1084        assert_eq!(fired.get(), 2, "Enter and Space each fire the press");
1085
1086        let exposed = focus::exposed();
1087        assert_eq!(exposed.len(), 1);
1088        assert_eq!(exposed[0].role, focus::Role::CheckBox);
1089        assert!(exposed[0].enabled);
1090    }
1091
1092    /// And what it must *not* do. A scrim, a click-away backdrop and a drag surface all take presses, and none
1093    /// of them is a place the keyboard should stop — so a press on its own still buys nothing.
1094    #[test]
1095    fn a_press_handler_alone_is_not_a_control() {
1096        reset_layout_runtime();
1097        focus::clear();
1098        let mut card = StyledContainer::new(
1099            LayoutStyle::new().width(80.0).height(30.0),
1100            |_r| RectStyle::default(),
1101            vec![],
1102        )
1103        .unwrap()
1104        .on_press(|| {});
1105        compute_layout(
1106            card.layout_node(),
1107            AvailableSpace::Definite(80.0),
1108            AvailableSpace::Definite(30.0),
1109        )
1110        .unwrap();
1111
1112        focus::focus_next();
1113        assert!(focus::exposed().is_empty(), "it is not a tab stop");
1114        assert_eq!(
1115            card.on_event(&Event::KeyPressed {
1116                key: Key::Named(NamedKey::Enter),
1117                modifiers: platform_core::ModifiersState::default(),
1118            }),
1119            EventResult::Ignored,
1120            "and Enter is left for whoever else wanted it"
1121        );
1122    }
1123
1124    fn press(x: f64, y: f64, source: PointerSource) -> Event {
1125        Event::PointerPressed {
1126            x,
1127            y,
1128            button: PointerButton::Primary,
1129            source,
1130        }
1131    }
1132    fn release(x: f64, y: f64, source: PointerSource) -> Event {
1133        Event::PointerReleased {
1134            x,
1135            y,
1136            button: PointerButton::Primary,
1137            source,
1138        }
1139    }
1140
1141    #[test]
1142    fn on_hover_fires_on_enter_and_leave() {
1143        let seen: Rc<Cell<Option<bool>>> = Rc::new(Cell::new(None));
1144        let sink = seen.clone();
1145        reset_layout_runtime();
1146        let inner = Container::new(LayoutStyle::new().width(100.0).height(100.0), vec![]).unwrap();
1147        let mut card = StyledContainer::new(
1148            LayoutStyle::new().flex_column().width(100.0).height(100.0),
1149            |_r| RectStyle::default(),
1150            vec![Box::new(inner)],
1151        )
1152        .unwrap()
1153        .on_hover(move |h| sink.set(Some(h)));
1154        let node = card.layout_node();
1155        compute_layout(
1156            node,
1157            AvailableSpace::Definite(100.0),
1158            AvailableSpace::Definite(100.0),
1159        )
1160        .unwrap();
1161
1162        card.on_event(&Event::PointerMoved {
1163            x: 50.0,
1164            y: 50.0,
1165            source: PointerSource::Mouse,
1166        });
1167        assert_eq!(seen.get(), Some(true), "entering fires on_hover(true)");
1168        card.on_event(&Event::CursorLeft);
1169        assert_eq!(seen.get(), Some(false), "leaving fires on_hover(false)");
1170    }
1171
1172    /// The wheel is targeted by where it happened, not by a hover the box had to have seen first — so the
1173    /// very first wheel over a box lands, and one over a sibling never does.
1174    #[test]
1175    fn on_scroll_targets_by_position_and_normalises_lines() {
1176        let seen: Rc<Cell<(f32, f32)>> = Rc::new(Cell::new((0.0, 0.0)));
1177        let sink = seen.clone();
1178        reset_layout_runtime();
1179        let inner = Container::new(LayoutStyle::new().width(100.0).height(100.0), vec![]).unwrap();
1180        let mut card = StyledContainer::new(
1181            LayoutStyle::new().flex_column().width(100.0).height(100.0),
1182            |_r| RectStyle::default(),
1183            vec![Box::new(inner)],
1184        )
1185        .unwrap()
1186        .on_scroll(move |dx, dy| sink.set((dx, dy)));
1187        let node = card.layout_node();
1188        compute_layout(
1189            node,
1190            AvailableSpace::Definite(100.0),
1191            AvailableSpace::Definite(100.0),
1192        )
1193        .unwrap();
1194
1195        assert_eq!(
1196            card.on_event(&Event::Scrolled {
1197                delta: ScrollDelta::Pixels { x: 0.0, y: -30.0 },
1198                x: 300.0,
1199                y: 300.0,
1200            }),
1201            EventResult::Ignored,
1202            "a wheel event outside the box is ignored"
1203        );
1204        assert_eq!(seen.get(), (0.0, 0.0));
1205
1206        assert_eq!(
1207            card.on_event(&Event::Scrolled {
1208                delta: ScrollDelta::Pixels { x: 0.0, y: -30.0 },
1209                x: 50.0,
1210                y: 50.0,
1211            }),
1212            EventResult::Handled,
1213            "a wheel over the box is ours, with no move having preceded it"
1214        );
1215        assert_eq!(seen.get(), (0.0, -30.0));
1216
1217        // Line deltas are normalised to pixels the same way ScrollArea does it.
1218        card.on_event(&Event::Scrolled {
1219            delta: ScrollDelta::Lines { x: 0.0, y: 3.0 },
1220            x: 50.0,
1221            y: 50.0,
1222        });
1223        assert_eq!(seen.get(), (0.0, 60.0));
1224    }
1225
1226    #[test]
1227    fn on_key_fires_on_key_press() {
1228        let count = Rc::new(Cell::new(0u32));
1229        let sink = count.clone();
1230        reset_layout_runtime();
1231        let inner = Container::new(LayoutStyle::new().width(10.0).height(10.0), vec![]).unwrap();
1232        let mut card = StyledContainer::new(
1233            LayoutStyle::new().flex_column(),
1234            |_r| RectStyle::default(),
1235            vec![Box::new(inner)],
1236        )
1237        .unwrap()
1238        .on_key(move |_k| sink.set(sink.get() + 1));
1239        card.on_event(&Event::KeyPressed {
1240            key: Key::Char('a'),
1241            modifiers: platform_core::ModifiersState::default(),
1242        });
1243        assert_eq!(count.get(), 1, "a key press fires on_key");
1244    }
1245
1246    /// The bug this closes: an app-level shortcut table sharing letters with what the user types. `3` selects
1247    /// a mode until a field has the caret, and `⌘S` saves either way because no editor here wants it.
1248    #[test]
1249    fn a_global_key_handler_stands_aside_while_a_field_has_the_caret() {
1250        let count = Rc::new(Cell::new(0u32));
1251        let sink = count.clone();
1252        reset_layout_runtime();
1253        focus::clear();
1254        let mut card = StyledContainer::new(
1255            LayoutStyle::new().flex_column(),
1256            |_r| RectStyle::default(),
1257            vec![],
1258        )
1259        .unwrap()
1260        .on_key(move |_k| sink.set(sink.get() + 1));
1261        let press = |key, modifiers| Event::KeyPressed { key, modifiers };
1262        let plain = platform_core::ModifiersState::default();
1263        let meta = platform_core::ModifiersState {
1264            is_meta: true,
1265            ..Default::default()
1266        };
1267
1268        card.on_event(&press(Key::Char('3'), plain));
1269        assert_eq!(count.get(), 1, "with nothing focused the shortcut fires");
1270
1271        let field = focus::next_id();
1272        focus::register_as(field, focus::FocusKind::TextEntry);
1273        focus::request(field);
1274        card.on_event(&press(Key::Char('3'), plain));
1275        assert_eq!(count.get(), 1, "typing into a field is not a shortcut");
1276        card.on_event(&press(Key::Char('s'), meta));
1277        assert_eq!(count.get(), 2, "a chord is a command, not text");
1278        card.on_event(&press(Key::Named(NamedKey::F5), plain));
1279        assert_eq!(count.get(), 3, "no editor takes F5");
1280
1281        focus::unregister(field);
1282        card.on_event(&press(Key::Char('3'), plain));
1283        assert_eq!(count.get(), 4, "the caret left, the shortcut is back");
1284    }
1285
1286    /// A focusable that is not a text entry — a button, a tab — leaves the shortcut table alone.
1287    #[test]
1288    fn a_focused_button_does_not_swallow_shortcuts() {
1289        let count = Rc::new(Cell::new(0u32));
1290        let sink = count.clone();
1291        reset_layout_runtime();
1292        focus::clear();
1293        let mut card = StyledContainer::new(
1294            LayoutStyle::new().flex_column(),
1295            |_r| RectStyle::default(),
1296            vec![],
1297        )
1298        .unwrap()
1299        .on_key(move |_k| sink.set(sink.get() + 1));
1300        let button = focus::next_id();
1301        focus::register_as(button, focus::FocusKind::Widget);
1302        focus::request(button);
1303        card.on_event(&Event::KeyPressed {
1304            key: Key::Char('3'),
1305            modifiers: platform_core::ModifiersState::default(),
1306        });
1307        assert_eq!(count.get(), 1);
1308        focus::unregister(button);
1309    }
1310
1311    /// A box drags from the primary button and no other, until it says otherwise — a slider must not slide
1312    /// on a right-click. A surface with more than one thing to drag opts the others in, and tells them apart
1313    /// through the button registry rather than through a wider callback.
1314    #[test]
1315    fn a_drag_starts_only_from_the_buttons_the_box_asked_for() {
1316        let seen = Rc::new(Cell::new(0u32));
1317        let sink = seen.clone();
1318        reset_layout_runtime();
1319        let mut plain = StyledContainer::new(
1320            LayoutStyle::new().width(100.0).height(100.0),
1321            |_r| RectStyle::default(),
1322            vec![],
1323        )
1324        .unwrap()
1325        .on_drag(move |_x, _y| sink.set(sink.get() + 1));
1326        compute_layout(
1327            plain.layout_node(),
1328            AvailableSpace::Definite(100.0),
1329            AvailableSpace::Definite(100.0),
1330        )
1331        .unwrap();
1332
1333        let press = |button: PointerButton| Event::PointerPressed {
1334            x: 50.0,
1335            y: 50.0,
1336            button,
1337            source: PointerSource::Mouse,
1338        };
1339        plain.on_event(&press(PointerButton::Secondary));
1340        assert_eq!(seen.get(), 0, "a secondary press is not this box's drag");
1341        plain.on_event(&press(PointerButton::Primary));
1342        assert_eq!(seen.get(), 1, "the primary one always is");
1343
1344        let count = Rc::new(Cell::new(0u32));
1345        let sink = count.clone();
1346        reset_layout_runtime();
1347        let mut viewport = StyledContainer::new(
1348            LayoutStyle::new().width(100.0).height(100.0),
1349            |_r| RectStyle::default(),
1350            vec![],
1351        )
1352        .unwrap()
1353        .on_drag(move |_x, _y| sink.set(sink.get() + 1))
1354        .drag_button(PointerButton::Secondary);
1355        compute_layout(
1356            viewport.layout_node(),
1357            AvailableSpace::Definite(100.0),
1358            AvailableSpace::Definite(100.0),
1359        )
1360        .unwrap();
1361        assert_eq!(
1362            viewport.on_event(&press(PointerButton::Secondary)),
1363            EventResult::Handled
1364        );
1365        assert_eq!(count.get(), 1, "the box asked for this button");
1366        // And the drag it started ends on that button's release, or it would stay armed for ever.
1367        assert_eq!(
1368            viewport.on_event(&Event::PointerReleased {
1369                x: 60.0,
1370                y: 60.0,
1371                button: PointerButton::Secondary,
1372                source: PointerSource::Mouse,
1373            }),
1374            EventResult::Handled
1375        );
1376    }
1377
1378    /// The registry that tells the buttons apart, since the drag callback reports where the pointer is and
1379    /// not what pressed it.
1380    #[test]
1381    fn the_button_registry_holds_what_is_down() {
1382        crate::reset_pointer();
1383        assert!(!crate::pointer_buttons().any());
1384        crate::observe_pointer(&Event::PointerPressed {
1385            x: 0.0,
1386            y: 0.0,
1387            button: PointerButton::Secondary,
1388            source: PointerSource::Mouse,
1389        });
1390        assert!(crate::pointer_buttons().secondary);
1391        assert!(!crate::pointer_buttons().primary);
1392        // Losing the focus is where a reconstructed state goes wrong: the release never comes.
1393        crate::observe_pointer(&Event::FocusChanged { is_focused: false });
1394        assert!(!crate::pointer_buttons().any());
1395    }
1396
1397    #[test]
1398    fn on_pointer_move_reports_the_position_local_to_the_box() {
1399        let seen: Rc<Cell<Option<(f32, f32)>>> = Rc::new(Cell::new(None));
1400        let sink = seen.clone();
1401        reset_layout_runtime();
1402        // A 20px spacer above the box, so its own origin is not the window's and a raw position would show.
1403        let spacer = Container::new(LayoutStyle::new().width(100.0).height(20.0), vec![]).unwrap();
1404        let card = StyledContainer::new(
1405            LayoutStyle::new().flex_column().width(100.0).height(100.0),
1406            |_r| RectStyle::default(),
1407            vec![],
1408        )
1409        .unwrap()
1410        .on_pointer_move(move |x, y| sink.set(Some((x, y))));
1411        let mut root = Container::new(
1412            LayoutStyle::new().flex_column().width(100.0).height(120.0),
1413            vec![Box::new(spacer), Box::new(card)],
1414        )
1415        .unwrap();
1416        compute_layout(
1417            root.layout_node(),
1418            AvailableSpace::Definite(100.0),
1419            AvailableSpace::Definite(120.0),
1420        )
1421        .unwrap();
1422
1423        root.on_event(&Event::PointerMoved {
1424            x: 30.0,
1425            y: 50.0,
1426            source: PointerSource::Mouse,
1427        });
1428        assert_eq!(
1429            seen.get(),
1430            Some((30.0, 30.0)),
1431            "the box starts 20px down, so the y arrives 20 less — as on_drag reports it"
1432        );
1433
1434        seen.set(None);
1435        root.on_event(&Event::PointerMoved {
1436            x: 300.0,
1437            y: 300.0,
1438            source: PointerSource::Mouse,
1439        });
1440        assert_eq!(seen.get(), None, "a move outside the box is not its move");
1441    }
1442
1443    #[derive(Clone)]
1444    struct TestTheme(Color);
1445    impl Theme for TestTheme {
1446        fn as_any(&self) -> &dyn std::any::Any {
1447            self
1448        }
1449    }
1450    impl ThemeTokens for TestTheme {
1451        fn primary(&self) -> Color {
1452            self.0
1453        }
1454        fn on_primary(&self) -> Color {
1455            Color::WHITE
1456        }
1457    }
1458
1459    // 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.
1460    #[test]
1461    fn theme_button_click_force_tick_no_panic() {
1462        set_theme(TestTheme(Color::RED));
1463
1464        reset_layout_runtime();
1465        // A pressable primitive stands in for the old high-level Button (now in ui-components).
1466        let btn = StyledContainer::new(
1467            LayoutStyle::new().width(50.0).height(30.0),
1468            |_r| RectStyle::default(),
1469            vec![],
1470        )
1471        .unwrap()
1472        .on_press(move || set_theme(TestTheme(Color::GREEN)));
1473        let btn_node = btn.layout_node();
1474        let inner = Container::new(
1475            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1476            vec![Box::new(btn)],
1477        )
1478        .unwrap();
1479        let card = StyledContainer::new(
1480            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1481            |_r| RectStyle::default().with_fill(use_theme::<TestTheme>().0),
1482            vec![Box::new(inner)],
1483        )
1484        .unwrap();
1485        let card_node = card.layout_node();
1486        compute_layout(
1487            card_node,
1488            AvailableSpace::Definite(200.0),
1489            AvailableSpace::Definite(100.0),
1490        )
1491        .unwrap();
1492        let br = track_layout(btn_node).unwrap().get();
1493
1494        let mut tree = crate::ComponentList::new(card);
1495        let _ = tree.commands();
1496
1497        reactive_core::begin_batch();
1498        let handled = tree.on_event(&Event::PointerPressed {
1499            x: (br.x + br.width / 2.0) as f64,
1500            y: (br.y + br.height / 2.0) as f64,
1501            button: PointerButton::Primary,
1502            source: PointerSource::Mouse,
1503        });
1504        if handled == EventResult::Handled {
1505            tree.bump_force_ticks();
1506            reactive_core::end_batch();
1507            reactive_core::begin_batch();
1508        }
1509        let _ = tree.commands();
1510        reactive_core::end_batch();
1511    }
1512
1513    // A pressable box fires its callback on release (a tap), never on press alone.
1514    #[test]
1515    fn on_press_fires_on_tap_not_press() {
1516        let flag = Rc::new(Cell::new(false));
1517        let f = flag.clone();
1518        reset_layout_runtime();
1519        let mut card = StyledContainer::new(
1520            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1521            |_r| RectStyle::default(),
1522            vec![],
1523        )
1524        .unwrap()
1525        .on_press(move || f.set(true));
1526        compute_layout(
1527            card.layout_node(),
1528            AvailableSpace::Definite(200.0),
1529            AvailableSpace::Definite(100.0),
1530        )
1531        .unwrap();
1532
1533        assert_eq!(
1534            card.on_event(&press(100.0, 50.0, PointerSource::Mouse)),
1535            EventResult::Handled
1536        );
1537        assert!(!flag.get(), "press alone must not fire on_press");
1538        assert_eq!(
1539            card.on_event(&release(100.0, 50.0, PointerSource::Mouse)),
1540            EventResult::Handled
1541        );
1542        assert!(flag.get(), "release inside the box fires on_press");
1543    }
1544
1545    // Holding a press past the long-press threshold fires on_long_press on the next pointer event (there is
1546    // no dedicated timer) and suppresses the tap; a quick release stays a normal tap and never fires on_long_press.
1547    #[test]
1548    fn on_long_press_fires_after_threshold_not_on_quick_release() {
1549        let long_flag = Rc::new(Cell::new(false));
1550        let tap_flag = Rc::new(Cell::new(false));
1551        let lf = long_flag.clone();
1552        let tf = tap_flag.clone();
1553        reset_layout_runtime();
1554        let mut card = StyledContainer::new(
1555            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1556            |_r| RectStyle::default(),
1557            vec![],
1558        )
1559        .unwrap()
1560        .on_press(move || tf.set(true))
1561        .on_long_press(move || lf.set(true));
1562        compute_layout(
1563            card.layout_node(),
1564            AvailableSpace::Definite(200.0),
1565            AvailableSpace::Definite(100.0),
1566        )
1567        .unwrap();
1568
1569        // A quick release (well under the threshold) stays a normal tap.
1570        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
1571        card.on_event(&release(100.0, 50.0, PointerSource::Mouse));
1572        assert!(tap_flag.get(), "a quick release still fires on_press");
1573        assert!(
1574            !long_flag.get(),
1575            "a quick release must not fire on_long_press"
1576        );
1577
1578        // Holding past the threshold: the next event (the release here) fires on_long_press instead of on_press.
1579        tap_flag.set(false);
1580        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
1581        std::thread::sleep(std::time::Duration::from_millis(550));
1582        card.on_event(&release(100.0, 50.0, PointerSource::Mouse));
1583        assert!(
1584            long_flag.get(),
1585            "a release after the threshold fires on_long_press"
1586        );
1587        assert!(!tap_flag.get(), "a long press must not also fire on_press");
1588    }
1589
1590    fn press_with(x: f64, y: f64, button: PointerButton) -> Event {
1591        Event::PointerPressed {
1592            x,
1593            y,
1594            button,
1595            source: PointerSource::Mouse,
1596        }
1597    }
1598    fn release_with(x: f64, y: f64, button: PointerButton) -> Event {
1599        Event::PointerReleased {
1600            x,
1601            y,
1602            button,
1603            source: PointerSource::Mouse,
1604        }
1605    }
1606
1607    fn laid_out_box() -> StyledContainer {
1608        reset_layout_runtime();
1609        StyledContainer::new(
1610            LayoutStyle::new().width(100.0).height(100.0),
1611            |_r| RectStyle::default(),
1612            vec![],
1613        )
1614        .unwrap()
1615    }
1616
1617    fn settle(card: &mut StyledContainer) {
1618        compute_layout(
1619            card.layout_node(),
1620            AvailableSpace::Definite(100.0),
1621            AvailableSpace::Definite(100.0),
1622        )
1623        .unwrap();
1624    }
1625
1626    #[test]
1627    fn on_alt_press_reports_which_non_primary_button_tapped() {
1628        let seen: Rc<Cell<Option<PointerButton>>> = Rc::new(Cell::new(None));
1629        let sink = seen.clone();
1630        let mut card = laid_out_box().on_alt_press(move |b| sink.set(Some(b)));
1631        settle(&mut card);
1632
1633        card.on_event(&press_with(50.0, 50.0, PointerButton::Secondary));
1634        card.on_event(&release_with(50.0, 50.0, PointerButton::Secondary));
1635        assert_eq!(seen.take(), Some(PointerButton::Secondary));
1636
1637        card.on_event(&press_with(50.0, 50.0, PointerButton::Auxiliary));
1638        card.on_event(&release_with(50.0, 50.0, PointerButton::Auxiliary));
1639        assert_eq!(seen.take(), Some(PointerButton::Auxiliary));
1640    }
1641
1642    #[test]
1643    fn a_box_wanting_only_alt_presses_leaves_the_primary_one_alone() {
1644        let alt = Rc::new(Cell::new(false));
1645        let sink = alt.clone();
1646        let mut card = laid_out_box().on_alt_press(move |_| sink.set(true));
1647        settle(&mut card);
1648
1649        assert_eq!(
1650            card.on_event(&press_with(50.0, 50.0, PointerButton::Primary)),
1651            EventResult::Ignored,
1652            "a primary press must still fall through to whatever is behind the box"
1653        );
1654        card.on_event(&release_with(50.0, 50.0, PointerButton::Primary));
1655        assert!(!alt.get(), "the primary button is not an alt press");
1656    }
1657
1658    // What a wrapper forwarding an optional `on_alt_press` needs: `None` must leave the box transparent to a right-click, not turn it into one that reports the press handled and swallows the context menu behind it.
1659    #[test]
1660    fn maybe_on_alt_press_of_none_lets_a_secondary_press_fall_through() {
1661        let mut card = laid_out_box().maybe_on_alt_press(None::<fn(PointerButton)>);
1662        settle(&mut card);
1663
1664        assert_eq!(
1665            card.on_event(&press_with(50.0, 50.0, PointerButton::Secondary)),
1666            EventResult::Ignored
1667        );
1668    }
1669
1670    #[test]
1671    fn a_plain_pressable_box_still_ignores_non_primary_buttons() {
1672        let tapped = Rc::new(Cell::new(false));
1673        let sink = tapped.clone();
1674        let mut card = laid_out_box().on_press(move || sink.set(true));
1675        settle(&mut card);
1676
1677        assert_eq!(
1678            card.on_event(&press_with(50.0, 50.0, PointerButton::Secondary)),
1679            EventResult::Ignored,
1680            "right-click keeps passing through a box that never asked for it"
1681        );
1682        card.on_event(&release_with(50.0, 50.0, PointerButton::Secondary));
1683        assert!(!tapped.get(), "on_press is a primary-button gesture");
1684    }
1685
1686    #[test]
1687    fn releasing_a_different_button_than_armed_completes_nothing() {
1688        let seen: Rc<Cell<Option<PointerButton>>> = Rc::new(Cell::new(None));
1689        let sink = seen.clone();
1690        let tapped = Rc::new(Cell::new(false));
1691        let tap_sink = tapped.clone();
1692        let mut card = laid_out_box()
1693            .on_press(move || tap_sink.set(true))
1694            .on_alt_press(move |b| sink.set(Some(b)));
1695        settle(&mut card);
1696
1697        card.on_event(&press_with(50.0, 50.0, PointerButton::Secondary));
1698        card.on_event(&release_with(50.0, 50.0, PointerButton::Primary));
1699        assert_eq!(
1700            seen.take(),
1701            None,
1702            "the right button armed it, the left cannot complete it"
1703        );
1704        assert!(!tapped.get());
1705    }
1706
1707    #[test]
1708    fn dragging_off_the_box_cancels_an_alt_press() {
1709        let seen: Rc<Cell<Option<PointerButton>>> = Rc::new(Cell::new(None));
1710        let sink = seen.clone();
1711        let mut card = laid_out_box().on_alt_press(move |b| sink.set(Some(b)));
1712        settle(&mut card);
1713
1714        card.on_event(&press_with(50.0, 50.0, PointerButton::Secondary));
1715        card.on_event(&Event::PointerMoved {
1716            x: 95.0,
1717            y: 95.0,
1718            source: PointerSource::Mouse,
1719        });
1720        card.on_event(&release_with(95.0, 95.0, PointerButton::Secondary));
1721        assert_eq!(
1722            seen.take(),
1723            None,
1724            "travel past the tap slop cancels an alt press just as it cancels a tap"
1725        );
1726    }
1727
1728    // A child that handles the press (an inner button) wins; the box's own on_press must stay silent.
1729    #[test]
1730    fn inner_button_press_wins_over_box() {
1731        let card_flag = Rc::new(Cell::new(false));
1732        let btn_flag = Rc::new(Cell::new(false));
1733        let cf = card_flag.clone();
1734        let bf = btn_flag.clone();
1735        reset_layout_runtime();
1736        // A pressable primitive child stands in for the old high-level Button (now in ui-components).
1737        let btn = StyledContainer::new(
1738            LayoutStyle::new().width(50.0).height(30.0),
1739            |_r| RectStyle::default(),
1740            vec![],
1741        )
1742        .unwrap()
1743        .on_press(move || bf.set(true));
1744        let btn_node = btn.layout_node();
1745        let mut card = StyledContainer::new(
1746            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1747            |_r| RectStyle::default(),
1748            vec![Box::new(btn)],
1749        )
1750        .unwrap()
1751        .on_press(move || cf.set(true));
1752        compute_layout(
1753            card.layout_node(),
1754            AvailableSpace::Definite(200.0),
1755            AvailableSpace::Definite(100.0),
1756        )
1757        .unwrap();
1758
1759        let br = track_layout(btn_node).unwrap().get();
1760        let (cx, cy) = (
1761            (br.x + br.width / 2.0) as f64,
1762            (br.y + br.height / 2.0) as f64,
1763        );
1764        card.on_event(&press(cx, cy, PointerSource::Mouse));
1765        card.on_event(&release(cx, cy, PointerSource::Mouse));
1766        assert!(btn_flag.get(), "the inner button should fire");
1767        assert!(
1768            !card_flag.get(),
1769            "the box on_press must not fire when a child handled the press"
1770        );
1771    }
1772
1773    /// A ring is not another state but a different question — where the keyboard is going — so it composes
1774    /// with whichever state won instead of replacing it. A hovered box that lost its ring would hide that
1775    /// answer exactly when the user reached for the mouse.
1776    #[test]
1777    fn a_focus_ring_is_drawn_over_the_state_that_won_not_instead_of_it() {
1778        reset_layout_runtime();
1779        let hover_fill = Color::rgba(0.9, 0.9, 0.9, 1.0);
1780        let ring = renderer_core::Stroke::new(Color::rgba(0.0, 0.4, 1.0, 1.0), 2.0);
1781        let mut card = StyledContainer::new(
1782            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1783            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
1784            vec![],
1785        )
1786        .unwrap()
1787        .hover_style(move |_r| RectStyle::default().with_fill(hover_fill))
1788        .focus_style(move |_r| RectStyle {
1789            stroke: Some(ring),
1790            ..RectStyle::default()
1791        });
1792        compute_layout(
1793            card.layout_node(),
1794            AvailableSpace::Definite(200.0),
1795            AvailableSpace::Definite(100.0),
1796        )
1797        .unwrap();
1798
1799        let id = card.focusable.id.expect("a ring makes the box focusable");
1800        focus::request(id);
1801        card.on_event(&Event::PointerMoved {
1802            x: 100.0,
1803            y: 50.0,
1804            source: PointerSource::Mouse,
1805        });
1806
1807        let painted = rect_style(&card.view()).expect("the box paints a rect");
1808        assert_eq!(
1809            painted.fill,
1810            Some(renderer_core::Paint::Solid(hover_fill)),
1811            "the hover fill survives the ring"
1812        );
1813        assert_eq!(painted.stroke, Some(ring), "and the ring is drawn over it");
1814        focus::release(id);
1815    }
1816
1817    /// `:focus-visible`, which CSS spent years arriving at: a ring on every click is noise, and the ring drawn
1818    /// anyway is why so many stylesheets turned outlines off altogether and took the keyboard's only cue with
1819    /// them. Focus taken by a tap shows none; focus taken any other way does.
1820    #[test]
1821    fn a_tap_takes_focus_without_drawing_a_ring() {
1822        reset_layout_runtime();
1823        let ring = renderer_core::Stroke::new(Color::rgba(0.0, 0.4, 1.0, 1.0), 2.0);
1824        let mut card = StyledContainer::new(
1825            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1826            |_r| RectStyle::default(),
1827            vec![],
1828        )
1829        .unwrap()
1830        .focus_style(move |_r| RectStyle {
1831            stroke: Some(ring),
1832            ..RectStyle::default()
1833        });
1834        compute_layout(
1835            card.layout_node(),
1836            AvailableSpace::Definite(200.0),
1837            AvailableSpace::Definite(100.0),
1838        )
1839        .unwrap();
1840        let id = card.focusable.id.expect("a ring makes the box focusable");
1841
1842        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
1843        assert!(focus::is_focused(id), "the tap did take focus");
1844        assert_eq!(
1845            rect_style(&card.view()).and_then(|s| s.stroke),
1846            None,
1847            "but drew no ring for it"
1848        );
1849
1850        // Reached with the keyboard instead, and the ring is exactly what says so.
1851        focus::request(id);
1852        assert_eq!(rect_style(&card.view()).and_then(|s| s.stroke), Some(ring));
1853        focus::release(id);
1854    }
1855
1856    /// The bug this exists to make unwritable, taken from a real port: a control the application had already
1857    /// disabled still lit up with the accent under the pointer and still showed a hand cursor, because the
1858    /// author remembered to guard the callback and the tint but not the hover and the cursor. Three places to
1859    /// remember is three places to get wrong, so the box reads one flag and closes all of them.
1860    #[test]
1861    fn a_disabled_box_neither_lights_up_nor_fires() {
1862        reset_layout_runtime();
1863        let presses = Rc::new(Cell::new(0u32));
1864        let sink = presses.clone();
1865        let enabled = signal(false);
1866        let flag = enabled.clone();
1867        let mut card = StyledContainer::new(
1868            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1869            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
1870            vec![],
1871        )
1872        .unwrap()
1873        .hover_style(|_r| RectStyle::default().with_fill(Color::rgba(0.9, 0.9, 0.9, 1.0)))
1874        .on_press(move || sink.set(sink.get() + 1))
1875        .disabled(move || !flag.get());
1876        compute_layout(
1877            card.layout_node(),
1878            AvailableSpace::Definite(200.0),
1879            AvailableSpace::Definite(100.0),
1880        )
1881        .unwrap();
1882
1883        let base = fill_color(&card.view());
1884        card.on_event(&Event::PointerMoved {
1885            x: 100.0,
1886            y: 50.0,
1887            source: PointerSource::Mouse,
1888        });
1889        assert_eq!(
1890            fill_color(&card.view()),
1891            base,
1892            "a disabled box does not take the hover paint"
1893        );
1894        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
1895        card.on_event(&release(100.0, 50.0, PointerSource::Mouse));
1896        assert_eq!(presses.get(), 0, "and its press callback never fires");
1897
1898        // Enabling it needs nothing else: the flag is re-read, not sampled at construction.
1899        enabled.set(true);
1900        card.on_event(&Event::PointerMoved {
1901            x: 100.0,
1902            y: 50.0,
1903            source: PointerSource::Mouse,
1904        });
1905        assert_ne!(fill_color(&card.view()), base, "now it hovers");
1906        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
1907        card.on_event(&release(100.0, 50.0, PointerSource::Mouse));
1908        assert_eq!(presses.get(), 1);
1909    }
1910
1911    /// The shape a `surface_local!` world was supposed to be unable to survive: a style closure reading the
1912    /// very rect the layout pass that runs it is about to write. `styled_by` makes it reachable from any
1913    /// widget, since `style()` is an arbitrary closure the author wrote.
1914    ///
1915    /// It settles instead of panicking, and the reason is worth pinning: `compute_layout` collects the
1916    /// `(signal, rect)` updates *while* holding the layout-runtime borrow and applies them only after
1917    /// releasing it, so the flush that re-runs this closure never re-enters a live borrow. The remaining
1918    /// failure mode of this shape is a re-layout cycle, which has its own named assert.
1919    #[test]
1920    fn a_style_effect_that_reads_the_rect_its_own_layout_pass_just_wrote_settles_instead_of_panicking()
1921     {
1922        reset_layout_runtime();
1923        let card = StyledContainer::new(
1924            LayoutStyle::new().width(200.0).height(100.0),
1925            |_r| RectStyle::default(),
1926            vec![],
1927        )
1928        .unwrap();
1929        let node = card.layout_node();
1930        let seen = track_layout(node).expect("the container registers a rect signal");
1931        let settled = seen.clone();
1932        let runs = Rc::new(Cell::new(0u32));
1933        let counted = runs.clone();
1934        // Half of its own laid-out width — the port's shape, and unlike deriving height from height it has a fixed point worth asserting.
1935        let card = card.styled_by(move || {
1936            counted.set(counted.get() + 1);
1937            let width = seen.get().width;
1938            LayoutStyle::new()
1939                .width(200.0)
1940                .height((width * 0.5).max(1.0))
1941        });
1942
1943        compute_layout(
1944            card.layout_node(),
1945            AvailableSpace::Definite(200.0),
1946            AvailableSpace::Definite(100.0),
1947        )
1948        .unwrap();
1949        compute_layout(
1950            card.layout_node(),
1951            AvailableSpace::Definite(200.0),
1952            AvailableSpace::Definite(100.0),
1953        )
1954        .unwrap();
1955
1956        assert!(runs.get() >= 1, "the style closure ran");
1957        assert_eq!(
1958            settled.peek().height,
1959            100.0,
1960            "and the rect it derives itself from came to rest instead of running away"
1961        );
1962    }
1963
1964    /// The other half of the port's bug, and the one that used to be a wall rather than an oversight:
1965    /// `cursor:` compiles from a literal and never passed through the signal path, so `cursor:$enabled` was
1966    /// not expressible at all. It does not need to be — the box suppresses the shape while disabled, so the
1967    /// attribute stays a literal and the framework answers the question.
1968    #[test]
1969    fn a_disabled_box_does_not_claim_the_pointer_shape() {
1970        use platform_core::take_window_commands;
1971
1972        reset_layout_runtime();
1973        let enabled = signal(false);
1974        let flag = enabled.clone();
1975        let mut card = StyledContainer::new(
1976            LayoutStyle::new().flex_column().width(200.0).height(100.0),
1977            |_r| RectStyle::default(),
1978            vec![],
1979        )
1980        .unwrap()
1981        .cursor(Cursor::Pointer)
1982        .disabled(move || !flag.get());
1983        compute_layout(
1984            card.layout_node(),
1985            AvailableSpace::Definite(200.0),
1986            AvailableSpace::Definite(100.0),
1987        )
1988        .unwrap();
1989
1990        let over = Event::PointerMoved {
1991            x: 100.0,
1992            y: 50.0,
1993            source: PointerSource::Mouse,
1994        };
1995        let _ = take_window_commands();
1996        card.on_event(&over);
1997        assert!(
1998            take_window_commands().is_empty(),
1999            "a disabled box asks for no cursor at all"
2000        );
2001
2002        enabled.set(true);
2003        card.on_event(&over);
2004        assert!(
2005            take_window_commands()
2006                .iter()
2007                .any(|c| matches!(c, WindowCommand::SetCursor(Cursor::Pointer))),
2008            "and asks for it again once it can be used"
2009        );
2010
2011        // Disabled again with the pointer still inside: nothing else hands the shape back while it never leaves.
2012        enabled.set(false);
2013        card.on_event(&over);
2014        assert!(
2015            take_window_commands()
2016                .iter()
2017                .any(|c| matches!(c, WindowCommand::SetCursor(Cursor::Default))),
2018            "the shape is given back when the box stops accepting the pointer"
2019        );
2020    }
2021
2022    /// Disabling a box while the pointer is inside it has to take back what it was already showing — nothing
2023    /// else will, since the pointer never leaves and the box stops accepting the moves that would settle it.
2024    #[test]
2025    fn disabling_a_hovered_box_takes_the_hover_back() {
2026        reset_layout_runtime();
2027        let enabled = signal(true);
2028        let flag = enabled.clone();
2029        let mut card = StyledContainer::new(
2030            LayoutStyle::new().flex_column().width(200.0).height(100.0),
2031            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
2032            vec![],
2033        )
2034        .unwrap()
2035        .hover_style(|_r| RectStyle::default().with_fill(Color::rgba(0.9, 0.9, 0.9, 1.0)))
2036        .disabled(move || !flag.get());
2037        compute_layout(
2038            card.layout_node(),
2039            AvailableSpace::Definite(200.0),
2040            AvailableSpace::Definite(100.0),
2041        )
2042        .unwrap();
2043
2044        let base = fill_color(&card.view());
2045        let moved = Event::PointerMoved {
2046            x: 100.0,
2047            y: 50.0,
2048            source: PointerSource::Mouse,
2049        };
2050        card.on_event(&moved);
2051        assert_ne!(fill_color(&card.view()), base, "hovered while enabled");
2052
2053        enabled.set(false);
2054        card.on_event(&moved);
2055        assert_eq!(
2056            fill_color(&card.view()),
2057            base,
2058            "the highlight goes with the ability to act on it"
2059        );
2060    }
2061
2062    /// The disabled paint wins over the pressed one, which already won over hover — so a box cannot be shown
2063    /// mid-press and unusable at the same time.
2064    #[test]
2065    fn the_disabled_paint_wins_over_every_other_state() {
2066        reset_layout_runtime();
2067        let off = Color::rgba(0.5, 0.5, 0.5, 1.0);
2068        let mut card = StyledContainer::new(
2069            LayoutStyle::new().flex_column().width(200.0).height(100.0),
2070            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
2071            vec![],
2072        )
2073        .unwrap()
2074        .hover_style(|_r| RectStyle::default().with_fill(Color::rgba(0.9, 0.9, 0.9, 1.0)))
2075        .active_style(|_r| RectStyle::default().with_fill(Color::rgba(0.7, 0.7, 0.7, 1.0)))
2076        .disabled_style(move |_r| RectStyle::default().with_fill(off))
2077        .disabled(|| true);
2078        compute_layout(
2079            card.layout_node(),
2080            AvailableSpace::Definite(200.0),
2081            AvailableSpace::Definite(100.0),
2082        )
2083        .unwrap();
2084
2085        card.on_event(&Event::PointerMoved {
2086            x: 100.0,
2087            y: 50.0,
2088            source: PointerSource::Mouse,
2089        });
2090        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
2091        assert_eq!(fill_color(&card.view()), off);
2092    }
2093
2094    /// `disabled` on a region means the region, as an HTML `fieldset` does: a wrapper with no handlers of its
2095    /// own still has to stop the pointer reaching what is inside it, or a disabled panel is disabled only in
2096    /// the places nobody put a control.
2097    #[test]
2098    fn a_disabled_wrapper_shields_its_children() {
2099        reset_layout_runtime();
2100        let presses = Rc::new(Cell::new(0u32));
2101        let sink = presses.clone();
2102        let inner = StyledContainer::new(
2103            LayoutStyle::new().width(200.0).height(100.0),
2104            |_r| RectStyle::default(),
2105            vec![],
2106        )
2107        .unwrap()
2108        .on_press(move || sink.set(sink.get() + 1));
2109        let mut wrapper = StyledContainer::new(
2110            LayoutStyle::new().flex_column().width(200.0).height(100.0),
2111            |_r| RectStyle::default(),
2112            vec![Box::new(inner)],
2113        )
2114        .unwrap()
2115        .disabled(|| true);
2116        compute_layout(
2117            wrapper.layout_node(),
2118            AvailableSpace::Definite(200.0),
2119            AvailableSpace::Definite(100.0),
2120        )
2121        .unwrap();
2122
2123        wrapper.on_event(&press(100.0, 50.0, PointerSource::Mouse));
2124        wrapper.on_event(&release(100.0, 50.0, PointerSource::Mouse));
2125        assert_eq!(presses.get(), 0);
2126    }
2127
2128    // A hover style swaps the box's fill while the mouse is over it (mouse only), and clears on leave.
2129    #[test]
2130    fn hover_style_swaps_on_mouse_move() {
2131        reset_layout_runtime();
2132        let mut card = StyledContainer::new(
2133            LayoutStyle::new().flex_column().width(200.0).height(100.0),
2134            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
2135            vec![],
2136        )
2137        .unwrap()
2138        .hover_style(|_r| RectStyle::default().with_fill(Color::rgba(0.9, 0.9, 0.9, 1.0)));
2139        compute_layout(
2140            card.layout_node(),
2141            AvailableSpace::Definite(200.0),
2142            AvailableSpace::Definite(100.0),
2143        )
2144        .unwrap();
2145
2146        let normal = fill_color(&card.view());
2147        card.on_event(&Event::PointerMoved {
2148            x: 100.0,
2149            y: 50.0,
2150            source: PointerSource::Mouse,
2151        });
2152        let hovered = fill_color(&card.view());
2153        assert_ne!(normal, hovered, "hover should swap the fill");
2154
2155        card.on_event(&Event::PointerMoved {
2156            x: 9999.0,
2157            y: 9999.0,
2158            source: PointerSource::Mouse,
2159        });
2160        assert_eq!(
2161            fill_color(&card.view()),
2162            normal,
2163            "leaving the box restores the base fill"
2164        );
2165    }
2166
2167    // Touch never sets hover (no "pointer left" on touch), so a tap leaves no stuck hover style.
2168    #[test]
2169    fn touch_move_does_not_set_hover() {
2170        reset_layout_runtime();
2171        let mut card = StyledContainer::new(
2172            LayoutStyle::new().flex_column().width(200.0).height(100.0),
2173            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
2174            vec![],
2175        )
2176        .unwrap()
2177        .hover_style(|_r| RectStyle::default().with_fill(Color::rgba(0.9, 0.9, 0.9, 1.0)));
2178        compute_layout(
2179            card.layout_node(),
2180            AvailableSpace::Definite(200.0),
2181            AvailableSpace::Definite(100.0),
2182        )
2183        .unwrap();
2184
2185        let normal = fill_color(&card.view());
2186        card.on_event(&Event::PointerMoved {
2187            x: 100.0,
2188            y: 50.0,
2189            source: PointerSource::Touch { id: 1 },
2190        });
2191        assert_eq!(
2192            fill_color(&card.view()),
2193            normal,
2194            "a touch move must not trigger hover"
2195        );
2196    }
2197
2198    // A press inside swaps to the active (pressed) fill; the release restores the base fill.
2199    #[test]
2200    fn active_style_swaps_on_press_and_clears_on_release() {
2201        reset_layout_runtime();
2202        let mut card = StyledContainer::new(
2203            LayoutStyle::new().flex_column().width(200.0).height(100.0),
2204            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
2205            vec![],
2206        )
2207        .unwrap()
2208        .active_style(|_r| RectStyle::default().with_fill(Color::rgba(0.5, 0.5, 0.5, 1.0)));
2209        compute_layout(
2210            card.layout_node(),
2211            AvailableSpace::Definite(200.0),
2212            AvailableSpace::Definite(100.0),
2213        )
2214        .unwrap();
2215
2216        let normal = fill_color(&card.view());
2217        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
2218        assert_ne!(
2219            normal,
2220            fill_color(&card.view()),
2221            "press swaps to the active fill"
2222        );
2223        card.on_event(&release(100.0, 50.0, PointerSource::Mouse));
2224        assert_eq!(
2225            fill_color(&card.view()),
2226            normal,
2227            "release restores the base fill"
2228        );
2229    }
2230
2231    // Pressed wins over hover: pressing while hovering shows the active fill, and releasing (still inside)
2232    // falls back to the hover fill.
2233    #[test]
2234    fn active_style_takes_precedence_over_hover() {
2235        reset_layout_runtime();
2236        let hover = Color::rgba(0.9, 0.9, 0.9, 1.0);
2237        let active = Color::rgba(0.4, 0.4, 0.4, 1.0);
2238        let mut card = StyledContainer::new(
2239            LayoutStyle::new().flex_column().width(200.0).height(100.0),
2240            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
2241            vec![],
2242        )
2243        .unwrap()
2244        .hover_style(move |_r| RectStyle::default().with_fill(hover))
2245        .active_style(move |_r| RectStyle::default().with_fill(active));
2246        compute_layout(
2247            card.layout_node(),
2248            AvailableSpace::Definite(200.0),
2249            AvailableSpace::Definite(100.0),
2250        )
2251        .unwrap();
2252
2253        card.on_event(&Event::PointerMoved {
2254            x: 100.0,
2255            y: 50.0,
2256            source: PointerSource::Mouse,
2257        });
2258        assert_eq!(
2259            fill_color(&card.view()),
2260            hover,
2261            "hovering shows the hover fill"
2262        );
2263        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
2264        assert_eq!(
2265            fill_color(&card.view()),
2266            active,
2267            "pressing while hovered shows the active fill (precedence)"
2268        );
2269        card.on_event(&release(100.0, 50.0, PointerSource::Mouse));
2270        assert_eq!(
2271            fill_color(&card.view()),
2272            hover,
2273            "releasing inside falls back to the hover fill"
2274        );
2275    }
2276
2277    // Dragging the press off the box clears the pressed state, so it never sticks.
2278    #[test]
2279    fn active_style_clears_when_press_drags_off() {
2280        reset_layout_runtime();
2281        let mut card = StyledContainer::new(
2282            LayoutStyle::new().flex_column().width(200.0).height(100.0),
2283            |_r| RectStyle::default().with_fill(Color::rgba(0.1, 0.1, 0.1, 1.0)),
2284            vec![],
2285        )
2286        .unwrap()
2287        .active_style(|_r| RectStyle::default().with_fill(Color::rgba(0.5, 0.5, 0.5, 1.0)));
2288        compute_layout(
2289            card.layout_node(),
2290            AvailableSpace::Definite(200.0),
2291            AvailableSpace::Definite(100.0),
2292        )
2293        .unwrap();
2294
2295        let normal = fill_color(&card.view());
2296        card.on_event(&press(100.0, 50.0, PointerSource::Mouse));
2297        assert_ne!(normal, fill_color(&card.view()), "press activates");
2298        card.on_event(&Event::PointerMoved {
2299            x: 9999.0,
2300            y: 9999.0,
2301            source: PointerSource::Mouse,
2302        });
2303        assert_eq!(
2304            fill_color(&card.view()),
2305            normal,
2306            "dragging off the box clears the pressed state"
2307        );
2308    }
2309
2310    // The background rect's whole style, for assertions about how the states composed rather than about one field.
2311    fn rect_style(view: &RenderNode) -> Option<RectStyle> {
2312        let RenderNode::Group { children, .. } = view else {
2313            return None;
2314        };
2315        match children.first() {
2316            Some(RenderNode::Primitive(renderer_core::DrawCommand::Rect { style, .. })) => {
2317                Some(**style)
2318            }
2319            _ => None,
2320        }
2321    }
2322
2323    fn fill_color(view: &RenderNode) -> Color {
2324        let group = match view {
2325            RenderNode::Group { children, .. } => children,
2326            _ => panic!("expected Group"),
2327        };
2328        if let RenderNode::Primitive(renderer_core::DrawCommand::Rect { style, .. }) = &group[0] {
2329            if let Some(renderer_core::Paint::Solid(c)) = style.fill {
2330                return c;
2331            }
2332        }
2333        panic!("expected a solid-fill background rect");
2334    }
2335
2336    // A scroll gesture that begins on the box (press then drag past the slop) must not press it.
2337    #[test]
2338    fn scroll_drag_does_not_press_box() {
2339        let flag = Rc::new(Cell::new(false));
2340        let f = flag.clone();
2341        reset_layout_runtime();
2342        let mut card = StyledContainer::new(
2343            LayoutStyle::new().flex_column().width(200.0).height(200.0),
2344            |_r| RectStyle::default(),
2345            vec![],
2346        )
2347        .unwrap()
2348        .on_press(move || f.set(true));
2349        compute_layout(
2350            card.layout_node(),
2351            AvailableSpace::Definite(200.0),
2352            AvailableSpace::Definite(200.0),
2353        )
2354        .unwrap();
2355
2356        let touch = PointerSource::Touch { id: 1 };
2357        card.on_event(&press(50.0, 20.0, touch.clone()));
2358        card.on_event(&Event::PointerMoved {
2359            x: 50.0,
2360            y: 120.0, // > TAP_SLOP away
2361            source: touch.clone(),
2362        });
2363        card.on_event(&release(50.0, 120.0, touch));
2364        assert!(!flag.get(), "a scroll drag over the box must not press it");
2365    }
2366
2367    // on_drag fires on a press inside, on every subsequent move (even once the pointer leaves the box),
2368    // then stops after release.
2369    #[test]
2370    fn on_drag_reports_press_then_moves_until_release() {
2371        use std::cell::RefCell;
2372        let seen: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
2373        let sink = seen.clone();
2374        reset_layout_runtime();
2375        let mut card = StyledContainer::new(
2376            LayoutStyle::new().flex_column().width(200.0).height(200.0),
2377            |_r| RectStyle::default(),
2378            vec![],
2379        )
2380        .unwrap()
2381        .on_drag(move |x, y| sink.borrow_mut().push((x, y)));
2382        compute_layout(
2383            card.layout_node(),
2384            AvailableSpace::Definite(200.0),
2385            AvailableSpace::Definite(200.0),
2386        )
2387        .unwrap();
2388
2389        let moved = |x: f64, y: f64| Event::PointerMoved {
2390            x,
2391            y,
2392            source: PointerSource::Mouse,
2393        };
2394        card.on_event(&press(40.0, 40.0, PointerSource::Mouse));
2395        card.on_event(&moved(80.0, 90.0));
2396        card.on_event(&moved(400.0, 400.0)); // outside the box: drag still tracks
2397        card.on_event(&release(400.0, 400.0, PointerSource::Mouse));
2398        card.on_event(&moved(10.0, 10.0)); // after release: no longer dragging
2399
2400        assert_eq!(
2401            *seen.borrow(),
2402            vec![(40.0, 40.0), (80.0, 90.0), (400.0, 400.0)],
2403            "drag reports the press point then each move until release"
2404        );
2405    }
2406
2407    /// A click and a drag on the same button stop overlapping once a threshold is set: below it the stroke is
2408    /// only a press, above it only a drag. A viewport is the case — a click picks what is under it and a drag
2409    /// orbits — and without this a one-pixel wobble did both.
2410    #[test]
2411    fn a_threshold_splits_a_click_from_a_drag_on_the_same_button() {
2412        use std::cell::Cell;
2413        use std::cell::RefCell;
2414
2415        let build = || {
2416            let clicks: Rc<Cell<u32>> = Rc::new(Cell::new(0));
2417            let drags: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
2418            reset_layout_runtime();
2419            let (c, d) = (clicks.clone(), drags.clone());
2420            let card = StyledContainer::new(
2421                LayoutStyle::new().flex_column().width(200.0).height(200.0),
2422                |_r| RectStyle::default(),
2423                vec![],
2424            )
2425            .unwrap()
2426            .drag_threshold(4.0)
2427            .on_press(move || c.set(c.get() + 1))
2428            .on_drag(move |x, y| d.borrow_mut().push((x, y)));
2429            compute_layout(
2430                card.layout_node(),
2431                AvailableSpace::Definite(200.0),
2432                AvailableSpace::Definite(200.0),
2433            )
2434            .unwrap();
2435            (card, clicks, drags)
2436        };
2437        let moved = |x: f64, y: f64| Event::PointerMoved {
2438            x,
2439            y,
2440            source: PointerSource::Mouse,
2441        };
2442
2443        // A hand that shifts a pixel between press and release still meant to click.
2444        let (mut card, clicks, drags) = build();
2445        card.on_event(&press(40.0, 40.0, PointerSource::Mouse));
2446        card.on_event(&moved(41.0, 40.0));
2447        card.on_event(&release(41.0, 40.0, PointerSource::Mouse));
2448        assert_eq!(clicks.get(), 1, "the click survives the wobble");
2449        assert!(drags.borrow().is_empty(), "and nothing was dragged");
2450
2451        // And one that travels is a drag, which is no longer also a click.
2452        let (mut card, clicks, drags) = build();
2453        card.on_event(&press(40.0, 40.0, PointerSource::Mouse));
2454        card.on_event(&moved(90.0, 40.0));
2455        card.on_event(&release(90.0, 40.0, PointerSource::Mouse));
2456        assert_eq!(clicks.get(), 0, "a drag is not also a click");
2457        assert_eq!(*drags.borrow(), vec![(90.0, 40.0)]);
2458    }
2459
2460    // Regression: a drag released OUTSIDE the widget must still end. Dispatched through a parent (whose
2461    // release path position-filters presses) — the release must broadcast to the dragging child anyway,
2462    // else it stays stuck to the pointer (fires on_drag on later moves).
2463    #[test]
2464    fn drag_released_outside_bounds_ends_via_parent_dispatch() {
2465        use std::cell::RefCell;
2466        let seen: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
2467        let sink = seen.clone();
2468        reset_layout_runtime();
2469        let child = StyledContainer::new(
2470            LayoutStyle::new().width(100.0).height(100.0),
2471            |_r| RectStyle::default(),
2472            vec![],
2473        )
2474        .unwrap()
2475        .on_drag(move |x, y| sink.borrow_mut().push((x, y)));
2476        let mut parent = Container::new(
2477            LayoutStyle::new().flex_column().width(300.0).height(300.0),
2478            vec![Box::new(child)],
2479        )
2480        .unwrap();
2481        compute_layout(
2482            parent.layout_node(),
2483            AvailableSpace::Definite(300.0),
2484            AvailableSpace::Definite(300.0),
2485        )
2486        .unwrap();
2487
2488        let moved = |x: f64, y: f64| Event::PointerMoved {
2489            x,
2490            y,
2491            source: PointerSource::Mouse,
2492        };
2493        // child sits at (0,0) 100×100. Press inside, drag well outside, release outside.
2494        parent.on_event(&press(50.0, 50.0, PointerSource::Mouse));
2495        parent.on_event(&moved(250.0, 250.0));
2496        parent.on_event(&release(250.0, 250.0, PointerSource::Mouse));
2497        // After release the drag must be over: a later move fires nothing.
2498        parent.on_event(&moved(60.0, 60.0));
2499        assert_eq!(
2500            *seen.borrow(),
2501            vec![(50.0, 50.0), (250.0, 250.0)],
2502            "drag ended on the outside release; the post-release move must not fire"
2503        );
2504    }
2505
2506    // `on_drag_end` is what makes a threshold gesture (swipe-to-dismiss, drag-to-open) expressible: it fires
2507    // exactly once per drag, with the position it finished at.
2508    #[test]
2509    fn on_drag_end_fires_once_with_the_release_position() {
2510        use std::cell::RefCell;
2511        let ends: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
2512        let sink = ends.clone();
2513        reset_layout_runtime();
2514        let mut card = StyledContainer::new(
2515            LayoutStyle::new().width(100.0).height(100.0),
2516            |_r| RectStyle::default(),
2517            vec![],
2518        )
2519        .unwrap()
2520        .on_drag_end(move |x, y| sink.borrow_mut().push((x, y)));
2521        compute_layout(
2522            card.layout_node(),
2523            AvailableSpace::Definite(100.0),
2524            AvailableSpace::Definite(100.0),
2525        )
2526        .unwrap();
2527
2528        let moved = |x: f64, y: f64| Event::PointerMoved {
2529            x,
2530            y,
2531            source: PointerSource::Mouse,
2532        };
2533        card.on_event(&moved(10.0, 10.0));
2534        assert!(ends.borrow().is_empty(), "a move with no drag ends nothing");
2535
2536        card.on_event(&press(20.0, 20.0, PointerSource::Mouse));
2537        card.on_event(&moved(70.0, 30.0));
2538        assert!(ends.borrow().is_empty(), "still dragging");
2539        card.on_event(&release(90.0, 40.0, PointerSource::Mouse));
2540        assert_eq!(
2541            *ends.borrow(),
2542            vec![(90.0, 40.0)],
2543            "the release position, not the last move — a drag can end past it"
2544        );
2545
2546        // Exactly once: a second release with no drag in flight fires nothing.
2547        card.on_event(&release(95.0, 45.0, PointerSource::Mouse));
2548        assert_eq!(ends.borrow().len(), 1);
2549    }
2550
2551    /// The pointer reaching the edge of the window is not the end of the gesture, and treating it as one is
2552    /// what makes an orbit stop dead against the border of a viewport that fills its window. The drag was
2553    /// armed by a press this widget took; it ends when that press is released, or when the window loses the
2554    /// focus that would have carried the release ([`losing_window_focus_ends_a_live_drag`]).
2555    #[test]
2556    fn a_drag_survives_the_cursor_leaving_the_window() {
2557        use std::cell::RefCell;
2558        let moves: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
2559        let ends: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
2560        let move_sink = moves.clone();
2561        let end_sink = ends.clone();
2562        reset_layout_runtime();
2563        let mut card = StyledContainer::new(
2564            LayoutStyle::new().width(100.0).height(100.0),
2565            |_r| RectStyle::default(),
2566            vec![],
2567        )
2568        .unwrap()
2569        .on_drag(move |x, y| move_sink.borrow_mut().push((x, y)))
2570        .on_drag_end(move |x, y| end_sink.borrow_mut().push((x, y)));
2571        compute_layout(
2572            card.layout_node(),
2573            AvailableSpace::Definite(100.0),
2574            AvailableSpace::Definite(100.0),
2575        )
2576        .unwrap();
2577
2578        card.on_event(&press(20.0, 20.0, PointerSource::Mouse));
2579        card.on_event(&Event::PointerMoved {
2580            x: 60.0,
2581            y: 25.0,
2582            source: PointerSource::Mouse,
2583        });
2584        card.on_event(&Event::CursorLeft);
2585        assert!(
2586            ends.borrow().is_empty(),
2587            "leaving the window does not finish the drag"
2588        );
2589
2590        // Past the border the coordinates go negative, which is what a local drag reports once the pointer is outside the bounds.
2591        card.on_event(&Event::PointerMoved {
2592            x: -15.0,
2593            y: 25.0,
2594            source: PointerSource::Mouse,
2595        });
2596        assert_eq!(
2597            moves.borrow().last().copied(),
2598            Some((-15.0, 25.0)),
2599            "the drag is still reporting after the pointer left"
2600        );
2601
2602        card.on_event(&release(-15.0, 25.0, PointerSource::Mouse));
2603        assert_eq!(
2604            *ends.borrow(),
2605            vec![(-15.0, 25.0)],
2606            "the release is what ends it, wherever it lands"
2607        );
2608    }
2609
2610    /// The other half of [`a_drag_survives_the_cursor_leaving_the_window`], and the reason the two have to
2611    /// land together: a window that loses focus never sends the release for what was held, and Alt-Tab with a
2612    /// button down never crosses the border. Before `CursorLeft` stopped ending drags this was latent — it
2613    /// only looked safe because leaving was aggressive enough to usually coincide.
2614    #[test]
2615    fn losing_window_focus_ends_a_live_drag() {
2616        use std::cell::RefCell;
2617        let ends: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
2618        let sink = ends.clone();
2619        reset_layout_runtime();
2620        let mut card = StyledContainer::new(
2621            LayoutStyle::new().width(100.0).height(100.0),
2622            |_r| RectStyle::default(),
2623            vec![],
2624        )
2625        .unwrap()
2626        .on_drag_end(move |x, y| sink.borrow_mut().push((x, y)));
2627        compute_layout(
2628            card.layout_node(),
2629            AvailableSpace::Definite(100.0),
2630            AvailableSpace::Definite(100.0),
2631        )
2632        .unwrap();
2633
2634        card.on_event(&press(20.0, 20.0, PointerSource::Mouse));
2635        card.on_event(&Event::PointerMoved {
2636            x: 60.0,
2637            y: 25.0,
2638            source: PointerSource::Mouse,
2639        });
2640        card.on_event(&Event::FocusChanged { is_focused: false });
2641        assert_eq!(
2642            *ends.borrow(),
2643            vec![(60.0, 25.0)],
2644            "the last position the drag reached, since the loss carries none of its own"
2645        );
2646
2647        // And exactly once: the gesture is disarmed, so regaining focus and moving reports nothing more.
2648        card.on_event(&Event::FocusChanged { is_focused: true });
2649        card.on_event(&Event::PointerMoved {
2650            x: 70.0,
2651            y: 30.0,
2652            source: PointerSource::Mouse,
2653        });
2654        assert_eq!(ends.borrow().len(), 1);
2655    }
2656
2657    // `on_drag_end` alone is enough to make a box draggable: a gesture that only cares about the outcome
2658    // should not have to register a per-move callback it ignores.
2659    #[test]
2660    fn on_drag_end_works_without_an_on_drag() {
2661        use std::cell::RefCell;
2662        let ends: Rc<RefCell<Vec<(f32, f32)>>> = Rc::new(RefCell::new(Vec::new()));
2663        let sink = ends.clone();
2664        reset_layout_runtime();
2665        let mut card = StyledContainer::new(
2666            LayoutStyle::new().width(100.0).height(100.0),
2667            |_r| RectStyle::default(),
2668            vec![],
2669        )
2670        .unwrap()
2671        .on_drag_end(move |x, y| sink.borrow_mut().push((x, y)));
2672        compute_layout(
2673            card.layout_node(),
2674            AvailableSpace::Definite(100.0),
2675            AvailableSpace::Definite(100.0),
2676        )
2677        .unwrap();
2678        card.on_event(&press(10.0, 10.0, PointerSource::Mouse));
2679        card.on_event(&release(80.0, 10.0, PointerSource::Mouse));
2680        assert_eq!(*ends.borrow(), vec![(80.0, 10.0)]);
2681    }
2682
2683    // A focusable box fires on_focus(true) when tapped and on_focus(false) when focus is cleared.
2684    #[test]
2685    fn on_focus_fires_on_gain_and_loss() {
2686        use std::cell::RefCell;
2687        let seen: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
2688        let sink = seen.clone();
2689        reset_layout_runtime();
2690        let mut card = StyledContainer::new(
2691            LayoutStyle::new().flex_column().width(100.0).height(100.0),
2692            |_r| RectStyle::default(),
2693            vec![],
2694        )
2695        .unwrap()
2696        .on_focus(move |f| sink.borrow_mut().push(f));
2697        compute_layout(
2698            card.layout_node(),
2699            AvailableSpace::Definite(100.0),
2700            AvailableSpace::Definite(100.0),
2701        )
2702        .unwrap();
2703
2704        card.on_event(&press(50.0, 50.0, PointerSource::Mouse)); // tap focuses → on_focus(true)
2705        crate::focus::clear(); // → on_focus(false)
2706        assert_eq!(
2707            *seen.borrow(),
2708            vec![true, false],
2709            "on_focus fires true on gain then false on loss"
2710        );
2711    }
2712
2713    // What a wrapper forwarding an optional `on_focus` needs: `None` must not join the tab order.
2714    #[test]
2715    fn maybe_on_focus_of_none_does_not_join_the_tab_order() {
2716        reset_layout_runtime();
2717        focus::clear();
2718        let card = StyledContainer::new(
2719            LayoutStyle::new().width(80.0).height(30.0),
2720            |_r| RectStyle::default(),
2721            vec![],
2722        )
2723        .unwrap()
2724        .maybe_on_focus(None::<fn(bool)>);
2725        assert!(card.focusable.id.is_none(), "no handler, no focus id");
2726
2727        focus::focus_next();
2728        assert!(focus::exposed().is_empty(), "and it is not a tab stop");
2729    }
2730
2731    #[test]
2732    fn maybe_on_focus_of_some_fires_like_on_focus() {
2733        use std::cell::RefCell;
2734        let seen: Rc<RefCell<Vec<bool>>> = Rc::new(RefCell::new(Vec::new()));
2735        let sink = seen.clone();
2736        reset_layout_runtime();
2737        let mut card = StyledContainer::new(
2738            LayoutStyle::new().flex_column().width(100.0).height(100.0),
2739            |_r| RectStyle::default(),
2740            vec![],
2741        )
2742        .unwrap()
2743        .maybe_on_focus(Some(move |f| sink.borrow_mut().push(f)));
2744        compute_layout(
2745            card.layout_node(),
2746            AvailableSpace::Definite(100.0),
2747            AvailableSpace::Definite(100.0),
2748        )
2749        .unwrap();
2750
2751        card.on_event(&press(50.0, 50.0, PointerSource::Mouse));
2752        crate::focus::clear();
2753        assert_eq!(*seen.borrow(), vec![true, false]);
2754    }
2755
2756    // A pressable box publishes its laid-out rect to the interactive registry (so a carved-input-region surface
2757    // receives input over it), and withdraws it on drop.
2758    #[test]
2759    fn pressable_publishes_rect_to_interactive_registry_and_withdraws_on_drop() {
2760        use crate::interactive_rects;
2761        reset_layout_runtime();
2762        let baseline = interactive_rects().len();
2763        let card = StyledContainer::new(
2764            LayoutStyle::new().width(120.0).height(40.0),
2765            |_r| RectStyle::default(),
2766            vec![],
2767        )
2768        .unwrap()
2769        .on_press(|| {});
2770        let node = card.layout_node();
2771        // Zero-sized before layout, so it contributes nothing yet.
2772        assert_eq!(
2773            interactive_rects().len(),
2774            baseline,
2775            "an unlaid-out pressable contributes no rect"
2776        );
2777        compute_layout(
2778            node,
2779            AvailableSpace::Definite(120.0),
2780            AvailableSpace::Definite(40.0),
2781        )
2782        .unwrap();
2783        let rects = interactive_rects();
2784        assert_eq!(rects.len(), baseline + 1);
2785        assert!(
2786            rects.iter().any(|r| r.width == 120.0 && r.height == 40.0),
2787            "a laid-out pressable reports its rect"
2788        );
2789        drop(card);
2790        assert_eq!(
2791            interactive_rects().len(),
2792            baseline,
2793            "dropping the pressable withdraws its rect"
2794        );
2795    }
2796
2797    /// The two failures this exists to sit between: an effect dropped on the floor runs once and stops, and one
2798    /// parked somewhere longer-lived keeps firing at a widget that is gone. Kept on the widget it belongs to, it
2799    /// does neither.
2800    #[test]
2801    fn a_kept_effect_lives_exactly_as_long_as_its_widget() {
2802        crate::reset_layout_runtime();
2803        reactive_core::reset_runtime();
2804        let source = signal(0i32);
2805        let seen = std::rc::Rc::new(std::cell::Cell::new(0i32));
2806
2807        let watched = source.clone();
2808        let sink = seen.clone();
2809        let boxed = StyledContainer::new(LayoutStyle::new(), |_r| RectStyle::default(), vec![])
2810            .unwrap()
2811            .keeping(effect(move || sink.set(watched.get())));
2812
2813        source.set(7);
2814        assert_eq!(seen.get(), 7, "the effect runs while the widget is alive");
2815
2816        drop(boxed);
2817        source.set(9);
2818        assert_eq!(
2819            seen.get(),
2820            7,
2821            "and stops when the widget goes, rather than firing at a node that is gone"
2822        );
2823    }
2824}