Skip to main content

lumen_primitives/
controls.rs

1//! Stateful control behaviors for the `<toggle>` and `<slider>` markup tags.
2//!
3//! Each control owns a single component holding its current value
4//! (`Toggleable.checked`, `SliderValue.value`). These systems map raw
5//! [`ClickEvent`] / [`DragStartEvent`] / [`DragMoveEvent`] messages into
6//! state mutations on the matching entity. Script-facing notifications
7//! ([`ToggleChanged`] / [`SliderChanged`]) fire on every state change so the
8//! Rhai host can forward them as `on_toggle(id, checked)` /
9//! `on_slider(id, value)`.
10
11use bevy_ecs::message::{Message, MessageReader, MessageWriter, Messages};
12use bevy_ecs::prelude::*;
13use lumen_core::components::Visuals;
14use lumen_core::prelude::*;
15use lumen_core::style_overrides::{StyleOverrides, StyleOwner};
16
17use crate::held::hold_fill;
18
19/// Track fill for an unchecked `<toggle>` when neither markup nor CSS
20/// supplied one. Matches the default skin's `#555555`.
21pub const TOGGLE_UNCHECKED_BG: Color = Color::rgb(0.33, 0.33, 0.33);
22
23/// Track fill for a checked `<toggle>` when no `:checked { bg: ... }`
24/// rule supplied one. Accent teal so checked vs unchecked is visible
25/// with zero author CSS.
26pub const TOGGLE_CHECKED_BG: Color = Color::rgb(0.20, 0.66, 0.70);
27
28/// Fill for the toggle knob / slider thumb child tiles.
29pub const KNOB_FILL: Color = Color::rgb(0.92, 0.92, 0.94);
30
31/// Fallback gap between the toggle knob and the track edge, in logical
32/// pixels, used when the entity carries no [`KnobGeometry`] (e.g. no CSS
33/// `knob-inset` authored).
34pub const KNOB_INSET: f32 = 4.0;
35
36/// Fallback slider thumb diameter in logical pixels, used when the
37/// entity carries no [`KnobGeometry`] (e.g. no CSS `thumb-size`
38/// authored).
39pub const THUMB_SIZE: f32 = 16.0;
40
41/// Knob and thumb geometry, resolved from CSS at spawn. Sits on the
42/// `<toggle>` / `<switch>` track entity (consulted via [`KnobGeometry::inset`])
43/// and on the `<slider>` track entity (consulted via
44/// [`KnobGeometry::thumb_size`]). An entity without this component uses
45/// [`KnobGeometry::default`], which reproduces today's hardcoded geometry
46/// exactly.
47#[derive(Component, Clone, Copy, Debug, PartialEq)]
48pub struct KnobGeometry {
49    /// Gap between the knob and the track edge, in logical pixels.
50    pub inset: f32,
51    /// Slider thumb diameter, in logical pixels.
52    pub thumb_size: f32,
53}
54
55impl Default for KnobGeometry {
56    fn default() -> Self {
57        Self {
58            inset: KNOB_INSET,
59            thumb_size: THUMB_SIZE,
60        }
61    }
62}
63
64/// The full track palette of a `<toggle>` or `<switch>`, resolved at
65/// spawn from markup / CSS and refreshed by the restyle pass.
66/// [`sync_track_fill`] is the only thing that reads it, and
67/// [`TrackStyle::fill_for`] is the only place a state maps to a color.
68///
69/// Both controls share one component because they share one skin family:
70/// they differ in shape and in how the knob moves, not in what the track
71/// is painted with.
72#[derive(Component, Clone, Copy, Debug, PartialEq)]
73#[require(StyleOverrides)]
74pub struct TrackStyle {
75    /// Track fill while checked (`:checked { bg: ... }` or the default
76    /// accent).
77    pub checked_bg: Color,
78    /// Track fill while unchecked (author `bg` or the default gray).
79    pub unchecked_bg: Color,
80    /// Track fill while disabled (`:disabled { bg: ... }`), when a rule
81    /// supplied one. `None` leaves a disabled track on its
82    /// checked/unchecked fill, which the generic disabled-opacity dim
83    /// then covers.
84    pub disabled_bg: Option<Color>,
85}
86
87impl StyleOwner for TrackStyle {
88    const RANK: u8 = 20;
89}
90
91impl Default for TrackStyle {
92    fn default() -> Self {
93        Self {
94            checked_bg: TOGGLE_CHECKED_BG,
95            unchecked_bg: TOGGLE_UNCHECKED_BG,
96            disabled_bg: None,
97        }
98    }
99}
100
101impl TrackStyle {
102    /// The fill this track paints in the given state.
103    ///
104    /// Disabled outranks checked: the skins order `:disabled` after
105    /// `:checked`, so in a browser the later rule would win, and a
106    /// control nobody can operate should not advertise its state as
107    /// live.
108    pub fn fill_for(&self, checked: bool, disabled: bool) -> Color {
109        if disabled && let Some(c) = self.disabled_bg {
110            return c;
111        }
112        if checked {
113            self.checked_bg
114        } else {
115            self.unchecked_bg
116        }
117    }
118}
119
120/// Marker for the knob child entity spawned inside every `<toggle>`.
121/// Absolute-positioned; [`sync_toggle_visuals`] slides it left / right
122/// by the parent's checked state.
123#[derive(Component, Clone, Copy, Debug, Default)]
124pub struct ToggleKnob;
125
126/// Marker for the thumb child entity spawned inside every `<slider>`.
127/// Absolute-positioned; [`sync_slider_thumb`] places it along the
128/// track at `(value - min) / (max - min)`.
129#[derive(Component, Clone, Copy, Debug, Default)]
130pub struct SliderThumb;
131
132/// Emitted after a [`Toggleable`] flips its `checked` state in response to
133/// user input. Carries the entity and the new value.
134#[derive(Message, Clone, Copy, Debug)]
135pub struct ToggleChanged {
136    /// Affected entity.
137    pub entity: Entity,
138    /// New checked state.
139    pub checked: bool,
140}
141
142/// Emitted after a [`SliderValue`] is updated by user drag / click. Carries
143/// the entity and the new clamped value.
144#[derive(Message, Clone, Copy, Debug)]
145pub struct SliderChanged {
146    /// Affected entity.
147    pub entity: Entity,
148    /// New value, clamped to `[min, max]`.
149    pub value: f32,
150}
151
152/// Plugin: registers the toggle + slider behavior systems and their
153/// outbound message types.
154pub struct ControlsPlugin;
155
156impl Plugin for ControlsPlugin {
157    fn build(self, app: &mut App) {
158        app.world.init_resource::<Messages<ToggleChanged>>();
159        app.world.init_resource::<Messages<SliderChanged>>();
160        app.add_systems(TickStage::Systems, update_message_buffers);
161        // The user-input mutators are ordered before lumen-core's
162        // signal push-back systems (`push_toggle_to_signal` /
163        // `push_slider_to_signal`, registered by the host when signal
164        // bindings are in play). Without the edge the push system can
165        // run earlier in the same tick, miss this tick's mutation, and
166        // - because the app only ticks on wakes - the bound signal
167        // (and every label bound to it) lags one whole user
168        // interaction behind. Ordering against a system the host never
169        // registers is a no-op, so headless/no-binding apps are
170        // unaffected.
171        // The click / drag consumers are additionally ordered after
172        // their producers (`lumen_input::dispatch_clicks`,
173        // `crate::drag::update_drag_on_move`) so a click or drag-move
174        // mutates the control on the SAME tick it happened rather than
175        // one tick late off the double-buffered bus. Same no-op rule as
176        // above when the host never registers the producer.
177        app.add_systems(
178            TickStage::Systems,
179            flip_toggle_on_click
180                .after(lumen_input::dispatch_clicks)
181                .before(lumen_core::signals::push_toggle_to_signal),
182        );
183        app.add_systems(
184            TickStage::Systems,
185            set_slider_on_click
186                .after(lumen_input::dispatch_clicks)
187                .before(lumen_core::signals::push_slider_to_signal),
188        );
189        app.add_systems(
190            TickStage::Systems,
191            set_slider_on_drag
192                .after(crate::drag::update_drag_on_move)
193                .before(lumen_core::signals::push_slider_to_signal),
194        );
195        app.add_systems(
196            TickStage::Systems,
197            move_slider_on_keys.before(lumen_core::signals::push_slider_to_signal),
198        );
199        app.add_systems(
200            TickStage::Systems,
201            adjust_slider_on_wheel.before(lumen_core::signals::push_slider_to_signal),
202        );
203        // Escape-cancels-drag: the pre-drag value snapshot must be taken
204        // before this tick's DragMoveEvents mutate the value, and the
205        // cancel must run after them so an Escape and a move landing on
206        // the same tick resolve to the restored value.
207        app.add_systems(
208            TickStage::Systems,
209            snapshot_slider_drag_origin
210                .after(crate::drag::update_drag_on_move)
211                .before(set_slider_on_drag),
212        );
213        // `.before(release_drag_on_unpress)`: the Wave-3 generic Escape
214        // press-cancel (`lumen_input::cancel_press_on_escape`, Input
215        // stage) strips `Pressed` on the same keystroke, which would let
216        // `release_drag_on_unpress` tear the `DragState` down as a
217        // *normal* release - committing the dragged value - before this
218        // cancel could observe the drag and restore the snapshot. The
219        // explicit edge guarantees the cancel wins the race.
220        app.add_systems(
221            TickStage::Systems,
222            cancel_slider_drag_on_escape
223                .after(set_slider_on_drag)
224                .before(crate::drag::release_drag_on_unpress)
225                .before(lumen_core::signals::push_slider_to_signal),
226        );
227        app.add_systems(TickStage::Systems, clear_slider_drag_origin_on_end);
228        app.add_systems(
229            TickStage::Systems,
230            sync_toggle_visuals.after(flip_toggle_on_click),
231        );
232        // Track fill for both `<toggle>` and `<switch>` - registered here
233        // rather than beside each widget's knob sync because it is driven
234        // by the shared `Toggleable` path this plugin owns.
235        app.add_systems(
236            TickStage::Systems,
237            sync_track_fill.after(flip_toggle_on_click),
238        );
239        app.add_systems(
240            TickStage::Systems,
241            sync_slider_thumb.after(set_slider_on_drag),
242        );
243        // `<switch>` shares this control's `Toggleable` click / keyboard /
244        // binding path; its visual sync + thumb-slide animation register
245        // here (rather than as a boot-time plugin) so every host that
246        // installs `ControlsPlugin` gets the switch for free.
247        crate::switch::register_switch_systems(app);
248        // Reactive wake (see `crate::wake`): a tick that ends with
249        // undrained PropertyStore writes must schedule a follow-up tick
250        // so if/for reconcilers and binding pulls observe the write
251        // within one frame - measured on widget-garden, the `<dialog>`
252        // otherwise waited ~550 ms (open) / ~4 s (close) for the next
253        // incidental input event. Ordered before the core clear system
254        // that empties the queue at end of tick.
255        app.add_systems(
256            TickStage::A11ySync,
257            crate::wake::request_tick_on_property_writes
258                .before(lumen_core::property_store::clear_property_store_dirty),
259        );
260    }
261}
262
263fn update_message_buffers(
264    mut toggles: ResMut<Messages<ToggleChanged>>,
265    mut sliders: ResMut<Messages<SliderChanged>>,
266) {
267    toggles.update();
268    sliders.update();
269}
270
271/// Resolve an input target to the control entity that owns it: `start`
272/// itself when `contains` matches, else the nearest matching ancestor.
273///
274/// The `<toggle>` knob / `<slider>` thumb children carry [`Visuals`],
275/// so deepest-child-wins hit-testing (`lumen_input::hit_test`) routes
276/// press / drag / click events at the *child* entity - the same
277/// ancestor walk `lumen_primitives::popup::press_hits_popup` uses lets
278/// the click/drag mutators act on the parent control while the children
279/// stay hittable for hover / cursor purposes. Without this, grabbing
280/// the thumb (or clicking the knob) was a permanent dead zone.
281///
282/// `pub(crate)`: the tab / dropdown / menu click dispatchers
283/// (`crate::tabs`) and the radio dispatcher (`crate::radio`) share the
284/// same hit-shadowing fix - any dispatcher matching only the exact
285/// clicked entity silently dead-zones once the control grows a
286/// hit-testable child (text, knob, dot...).
287pub(crate) fn resolve_control(
288    start: Entity,
289    parents: &Query<&ChildOf>,
290    contains: impl Fn(Entity) -> bool,
291) -> Option<Entity> {
292    let mut cur = Some(start);
293    while let Some(e) = cur {
294        if contains(e) {
295            return Some(e);
296        }
297        cur = parents.get(e).ok().map(|c| c.parent());
298    }
299    None
300}
301
302/// Flip [`Toggleable::checked`] every time the entity - or a child of
303/// it, such as the spawned [`ToggleKnob`] - receives a [`ClickEvent`].
304/// Fires [`ToggleChanged`] so the script host can echo it to
305/// `on_toggle(id, checked)`.
306pub fn flip_toggle_on_click(
307    mut clicks: MessageReader<ClickEvent>,
308    mut q: Query<&mut Toggleable>,
309    parents: Query<&ChildOf>,
310    mut out: MessageWriter<ToggleChanged>,
311) {
312    for click in clicks.read() {
313        let Some(target) = resolve_control(click.entity, &parents, |e| q.contains(e)) else {
314            continue;
315        };
316        if let Ok(mut t) = q.get_mut(target) {
317            t.checked = !t.checked;
318            out.write(ToggleChanged {
319                entity: target,
320                checked: t.checked,
321            });
322        }
323    }
324}
325
326/// Clamp `v` into the range described by `lo`/`hi` without panicking on
327/// an inverted (`lo > hi`, e.g. a descending `<slider min="100"
328/// max="0">`) or NaN range. `f32::clamp` asserts `lo <= hi` and would
329/// crash the whole app; this mirrors the guard
330/// `popup_nav::scroll_row_into_view` uses. NaN bounds fall through to the
331/// `min`/`max` path, which ignores NaN rather than propagating it.
332fn clamp_range(v: f32, lo: f32, hi: f32) -> f32 {
333    if lo <= hi {
334        v.clamp(lo, hi)
335    } else {
336        v.min(lo).max(hi)
337    }
338}
339
340/// Map a pointer x (window space) to a `[0, 1]` slider fraction using the
341/// SAME reduced range the thumb is drawn over. [`sync_slider_thumb`]
342/// places the thumb's left edge across `size.x - thumb_size`, so the
343/// thumb centre travels `[thumb_size/2, size.x - thumb_size/2]`. Mapping
344/// the pointer through the full track width instead makes a press on the
345/// thumb jump the value by up to `thumb_size/2` worth of range (the
346/// "volume slider jumps when you grab it" bug); the two only agreed at
347/// `frac = 0.5`. Subtracting the half-thumb and dividing by the reduced
348/// range keeps a stationary grab value-neutral. `thumb_size` is the
349/// entity's resolved [`KnobGeometry::thumb_size`] (or [`THUMB_SIZE`] when
350/// the entity carries no component), so this stays in lockstep with
351/// wherever [`sync_slider_thumb`] actually draws the thumb.
352fn slider_frac(pointer_x: f32, absolute_x: f32, size_x: f32, thumb_size: f32) -> f32 {
353    let track = size_x - thumb_size;
354    if track <= 0.0 {
355        return 0.0;
356    }
357    (((pointer_x - absolute_x) - thumb_size / 2.0) / track).clamp(0.0, 1.0)
358}
359
360/// Click-anywhere-on-track: set [`SliderValue`] based on where in the
361/// track's content rect the pointer landed. Clicks landing on the
362/// [`SliderThumb`] child resolve to the parent slider (see
363/// [`resolve_control`]). Coarse positioning; the drag handler refines
364/// from there.
365///
366/// The pointer fraction is continuous and the slider's positions are not,
367/// so the landing value goes through [`SliderValue::snap`] - a click on a
368/// `step="1024"` slider writes a multiple of 1024, the same as an arrow
369/// key.
370pub fn set_slider_on_click(
371    mut clicks: MessageReader<ClickEvent>,
372    mut q: Query<(&mut SliderValue, &Transform, Option<&KnobGeometry>)>,
373    parents: Query<&ChildOf>,
374    mut out: MessageWriter<SliderChanged>,
375) {
376    for click in clicks.read() {
377        let Some(target) = resolve_control(click.entity, &parents, |e| q.contains(e)) else {
378            continue;
379        };
380        if let Ok((mut s, t, geo)) = q.get_mut(target) {
381            let thumb_size = geo.copied().unwrap_or_default().thumb_size;
382            let frac = slider_frac(click.position.x, t.absolute.x, t.size.x, thumb_size);
383            let new = s.snap(s.min + frac * (s.max - s.min));
384            if new != s.value {
385                s.value = new;
386                out.write(SliderChanged {
387                    entity: target,
388                    value: new,
389                });
390            }
391        }
392    }
393}
394
395/// Keyboard control for a focused `<slider>`. Reads the raw
396/// [`KeyPressed`] bus directly rather than `FocusedKey` - the same
397/// pattern `lumen_primitives::tabs::dispatch_tab_keys` uses for its
398/// roving-tabindex nav - since a slider isn't a `TextInput` and doesn't
399/// need the extra `dispatch_focused_keys` routing hop.
400///
401/// - `ArrowLeft` / `ArrowDown`: decrement by one step.
402/// - `ArrowRight` / `ArrowUp`: increment by one step.
403/// - `PageDown` / `PageUp` (forwarded by the winit backend as
404///   `Key::Character("PageDown"/"PageUp")` - there's no dedicated
405///   `NamedKey` variant for them yet): move by ten steps.
406/// - `Home` / `End`: jump to `min` / `max`.
407///
408/// Step comes from [`SliderValue::step_size`]: the authored `step`
409/// attribute, or `(max - min) / 100` - matching the
410/// `<input type=range>` browser default of 100 discrete positions.
411pub fn move_slider_on_keys(
412    mut keys: MessageReader<KeyPressed>,
413    tracker: Res<FocusTracker>,
414    mut sliders: Query<&mut SliderValue>,
415    mut out: MessageWriter<SliderChanged>,
416) {
417    let Some(entity) = tracker.0 else {
418        keys.read().for_each(drop);
419        return;
420    };
421    let Ok(mut slider) = sliders.get_mut(entity) else {
422        keys.read().for_each(drop);
423        return;
424    };
425    let step = slider.step_size();
426    for ev in keys.read() {
427        let new = match &ev.key {
428            Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::ArrowDown) => {
429                Some(slider.value - step)
430            }
431            Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::ArrowUp) => {
432                Some(slider.value + step)
433            }
434            Key::Character(s) if s.as_str() == "PageDown" => Some(slider.value - step * 10.0),
435            Key::Character(s) if s.as_str() == "PageUp" => Some(slider.value + step * 10.0),
436            Key::Named(NamedKey::Home) => Some(slider.min),
437            Key::Named(NamedKey::End) => Some(slider.max),
438            _ => None,
439        };
440        let Some(new) = new else { continue };
441        let clamped = clamp_range(new, slider.min, slider.max);
442        if clamped != slider.value {
443            slider.value = clamped;
444            out.write(SliderChanged {
445                entity,
446                value: clamped,
447            });
448        }
449    }
450}
451
452/// While the user drags a slider, continuously update its value based on
453/// the cursor x within the track. Reuses [`DragMoveEvent`], so the slider
454/// inherits all of lumen-input's per-OS drag-threshold behavior for free.
455/// Drags that started on the [`SliderThumb`] child (the common grab)
456/// resolve to the parent slider (see [`resolve_control`]).
457///
458/// Snapped through [`SliderValue::snap`] for the same reason a click is:
459/// a drag ends on a position the keyboard could have reached, so the thumb
460/// steps between stops rather than sliding freely.
461pub fn set_slider_on_drag(
462    mut drags: MessageReader<DragMoveEvent>,
463    mut q: Query<(&mut SliderValue, &Transform, Option<&KnobGeometry>)>,
464    parents: Query<&ChildOf>,
465    mut out: MessageWriter<SliderChanged>,
466) {
467    for d in drags.read() {
468        let Some(target) = resolve_control(d.entity, &parents, |e| q.contains(e)) else {
469            continue;
470        };
471        if let Ok((mut s, t, geo)) = q.get_mut(target) {
472            let thumb_size = geo.copied().unwrap_or_default().thumb_size;
473            let frac = slider_frac(d.position.x, t.absolute.x, t.size.x, thumb_size);
474            let new = s.snap(s.min + frac * (s.max - s.min));
475            if new != s.value {
476                s.value = new;
477                out.write(SliderChanged {
478                    entity: target,
479                    value: new,
480                });
481            }
482        }
483    }
484}
485
486/// One wheel line-notch in logical pixels. Must match the window
487/// backend's `LineDelta` normalization (`LINE_PX` in
488/// `lumen-window-winit`), so one physical wheel detent maps to exactly
489/// one slider step; pixel-precise trackpad deltas scale proportionally.
490pub const WHEEL_NOTCH_PX: f32 = 32.0;
491
492/// Wheel over a hovered `<slider>` (or its thumb child) nudges the value
493/// by [`SliderValue::step_size`] per notch - wheel-up (a negative delta,
494/// away from the user) increases, matching Qt's `QSlider::wheelEvent`. The wheel is *consumed* by the slider:
495/// `lumen_primitives::scroll::accumulate_wheel` stands down whenever a
496/// slider sits on the hovered entity's ancestor chain, so adjusting a
497/// slider never also scrolls an ancestor scroll container.
498pub fn adjust_slider_on_wheel(
499    mut wheels: MessageReader<MouseWheel>,
500    hovered: Query<Entity, With<Hovered>>,
501    parents: Query<&ChildOf>,
502    mut sliders: Query<&mut SliderValue>,
503    mut out: MessageWriter<SliderChanged>,
504) {
505    let mut total = 0.0_f32;
506    for ev in wheels.read() {
507        total += ev.delta.y;
508    }
509    if total == 0.0 {
510        return;
511    }
512    let Some(target) = hovered
513        .iter()
514        .next()
515        .and_then(|h| resolve_control(h, &parents, |e| sliders.contains(e)))
516    else {
517        return;
518    };
519    let Ok(mut s) = sliders.get_mut(target) else {
520        return;
521    };
522    let new = clamp_range(
523        s.value - (total / WHEEL_NOTCH_PX) * s.step_size(),
524        s.min,
525        s.max,
526    );
527    if new != s.value {
528        s.value = new;
529        out.write(SliderChanged {
530            entity: target,
531            value: new,
532        });
533    }
534}
535
536/// Pre-drag value snapshot for the Escape-cancel path. Inserted on the
537/// *slider* entity at [`DragStartEvent`] (whether the grab landed on the
538/// slider or its thumb child), consumed by
539/// [`cancel_slider_drag_on_escape`], and cleared on normal
540/// [`DragEndEvent`] release.
541#[derive(Component, Clone, Copy, Debug)]
542pub struct SliderDragOrigin(pub f32);
543
544/// On [`DragStartEvent`] targeting a slider (or its thumb child), stash
545/// the current value so Escape can restore it. Ordered before
546/// [`set_slider_on_drag`] so the snapshot always precedes this tick's
547/// moves.
548pub fn snapshot_slider_drag_origin(
549    mut commands: Commands,
550    mut starts: MessageReader<DragStartEvent>,
551    sliders: Query<&SliderValue>,
552    parents: Query<&ChildOf>,
553) {
554    for ev in starts.read() {
555        let Some(target) = resolve_control(ev.entity, &parents, |e| sliders.contains(e)) else {
556            continue;
557        };
558        if let Ok(s) = sliders.get(target) {
559            commands.entity(target).insert(SliderDragOrigin(s.value));
560        }
561    }
562}
563
564/// A drag that ends normally (pointer release) commits the dragged
565/// value: drop the snapshot so a later Escape doesn't roll back a
566/// finished interaction.
567pub fn clear_slider_drag_origin_on_end(
568    mut commands: Commands,
569    mut ends: MessageReader<DragEndEvent>,
570    sliders: Query<(), With<SliderDragOrigin>>,
571    parents: Query<&ChildOf>,
572) {
573    for ev in ends.read() {
574        if let Some(target) = resolve_control(ev.entity, &parents, |e| sliders.contains(e)) {
575            commands.entity(target).remove::<SliderDragOrigin>();
576        }
577    }
578}
579
580/// Escape while a slider drag is in flight cancels it (Qt drag-cancel
581/// contract): the value snaps back to its [`SliderDragOrigin`] and the
582/// drag machinery is torn down - [`crate::drag::DragState`] *and*
583/// `Pressed` are removed, so the drag doesn't resume while the button
584/// stays held and the eventual pointer release emits no [`ClickEvent`]
585/// (no release commit). Fires [`SliderChanged`] with the restored value
586/// so bound signals / scripts roll back too.
587pub fn cancel_slider_drag_on_escape(
588    mut commands: Commands,
589    mut keys: MessageReader<KeyPressed>,
590    holders: Query<Entity, With<crate::drag::DragState>>,
591    mut sliders: Query<&mut SliderValue>,
592    origins: Query<&SliderDragOrigin>,
593    parents: Query<&ChildOf>,
594    mut out: MessageWriter<SliderChanged>,
595) {
596    let escape = keys
597        .read()
598        .any(|k| matches!(k.key, Key::Named(NamedKey::Escape)));
599    if !escape {
600        return;
601    }
602    for holder in &holders {
603        let Some(target) = resolve_control(holder, &parents, |e| origins.contains(e)) else {
604            continue;
605        };
606        let Ok(origin) = origins.get(target) else {
607            continue;
608        };
609        if let Ok(mut s) = sliders.get_mut(target)
610            && s.value != origin.0
611        {
612            s.value = origin.0;
613            out.write(SliderChanged {
614                entity: target,
615                value: origin.0,
616            });
617        }
618        commands.entity(target).remove::<SliderDragOrigin>();
619        commands
620            .entity(holder)
621            .remove::<(crate::drag::DragState, Pressed)>();
622    }
623}
624
625/// Paint every `<toggle>` / `<switch>` track with the fill its current
626/// state calls for. This is the single owner of a track's
627/// [`Visuals::fill`]; the per-widget sync systems only move the knob.
628///
629/// The state fill is held in [`StyleOverrides`] under the hover and press
630/// tints, so a tint blends from it and lets go onto it, and a restyle
631/// keeps it: state, not the cascade, decides what a track shows. It runs
632/// every tick rather than on `Changed<Toggleable>`, because the state is
633/// not the only thing that moves the fill: a restyle recomputes the
634/// [`TrackStyle`] palette on a theme or media flip. Writes happen only
635/// when the fill differs, so an idle frame still triggers no change
636/// detection.
637#[allow(clippy::type_complexity)]
638pub fn sync_track_fill(
639    mut tracks: Query<(
640        &Toggleable,
641        &TrackStyle,
642        Has<lumen_core::components::Disabled>,
643        &mut Visuals,
644        &mut StyleOverrides,
645    )>,
646) {
647    for (t, style, disabled, mut vis, mut overrides) in &mut tracks {
648        hold_fill::<TrackStyle>(
649            &mut overrides,
650            &mut vis,
651            style.fill_for(t.checked, disabled),
652        );
653    }
654}
655
656/// Slide the [`ToggleKnob`] child of every `<toggle>` to the track end
657/// its [`Toggleable::checked`] state calls for, and size it from the
658/// laid-out track. The track fill itself belongs to
659/// [`sync_track_fill`].
660#[allow(clippy::type_complexity)]
661pub fn sync_toggle_visuals(
662    mut commands: Commands,
663    parents: Query<(&Toggleable, &Transform, Option<&KnobGeometry>)>,
664    mut knobs: Query<
665        (Entity, &ChildOf, &mut Style, &mut Visuals),
666        (With<ToggleKnob>, Without<Toggleable>),
667    >,
668) {
669    for (knob_e, child_of, mut style, mut vis) in &mut knobs {
670        let Ok((t, tr, geo)) = parents.get(child_of.parent()) else {
671            continue;
672        };
673        if tr.size.x <= 0.0 || tr.size.y <= 0.0 {
674            continue;
675        }
676        let inset = geo.copied().unwrap_or_default().inset;
677        let knob = (tr.size.y - 2.0 * inset).max(2.0);
678        let left = if t.checked {
679            (tr.size.x - knob - inset).max(inset)
680        } else {
681            inset
682        };
683        let radius = knob / 2.0;
684        if vis.radius != radius {
685            vis.radius = radius;
686        }
687        let size = Length::Px(knob);
688        if style.width != size
689            || style.height != size
690            || style.inset.left != left
691            || style.inset.top != inset
692        {
693            style.width = size;
694            style.height = size;
695            style.inset.left = left;
696            style.inset.top = inset;
697            commands.entity(knob_e).insert(DirtyLayout);
698        }
699    }
700}
701
702/// Place the [`SliderThumb`] child along its track at
703/// `(value - min) / (max - min)`, vertically centred. Runs every tick
704/// (the position depends on the track's laid-out size, not just
705/// [`SliderValue`]); writes only when the target inset actually moved
706/// so change detection and relayout stay quiet on idle frames.
707pub fn sync_slider_thumb(
708    mut commands: Commands,
709    parents: Query<(&SliderValue, &Transform, Option<&KnobGeometry>)>,
710    mut thumbs: Query<(Entity, &ChildOf, &mut Style), With<SliderThumb>>,
711) {
712    for (thumb_e, child_of, mut style) in &mut thumbs {
713        let Ok((s, tr, geo)) = parents.get(child_of.parent()) else {
714            continue;
715        };
716        if tr.size.x <= 0.0 {
717            continue;
718        }
719        let thumb_size = geo.copied().unwrap_or_default().thumb_size;
720        let denom = s.max - s.min;
721        let frac = if denom.abs() <= f32::EPSILON {
722            0.0
723        } else {
724            ((s.value - s.min) / denom).clamp(0.0, 1.0)
725        };
726        let left = frac * (tr.size.x - thumb_size).max(0.0);
727        let top = ((tr.size.y - thumb_size) / 2.0).max(0.0);
728        // An `auto` (NaN) side compares unequal to everything, so a thumb
729        // whose inset was never placed is placed now.
730        let moved = |cur: f32, want: f32| cur.is_nan() || (cur - want).abs() > 0.25;
731        if moved(style.inset.left, left) || moved(style.inset.top, top) {
732            style.inset.left = left;
733            style.inset.top = top;
734            commands.entity(thumb_e).insert(DirtyLayout);
735        }
736    }
737}
738
739#[cfg(test)]
740mod slider_key_tests {
741    //! `move_slider_on_keys` - arrow/Home/End/PageUp/PageDown control of a
742    //! focused `<slider>`. Driven directly via `run_system_once` against a
743    //! bare `World` (no full `App`/schedule needed since the system only
744    //! touches `FocusTracker`, `KeyPressed`, and `SliderValue`).
745    use super::*;
746    use bevy_ecs::system::RunSystemOnce;
747
748    fn setup(min: f32, max: f32, value: f32) -> (World, Entity) {
749        let mut world = World::new();
750        world.init_resource::<Messages<KeyPressed>>();
751        world.init_resource::<Messages<SliderChanged>>();
752        let e = world
753            .spawn(SliderValue {
754                value,
755                min,
756                max,
757                step: None,
758            })
759            .id();
760        world.insert_resource(FocusTracker(Some(e)));
761        (world, e)
762    }
763
764    fn press(world: &mut World, key: Key) {
765        world
766            .resource_mut::<Messages<KeyPressed>>()
767            .write(KeyPressed {
768                key,
769                modifiers: Modifiers::default(),
770                repeat: false,
771            });
772    }
773
774    /// `run_system_once` builds a fresh system (and thus a fresh
775    /// `MessageReader` cursor) on every call, so - unlike a real ticked
776    /// `App` - a message written before one call is still visible to
777    /// the *next* call's brand-new reader unless the buffer is cleared
778    /// in between. Tests that press more than one key in sequence must
779    /// clear after each `run_system_once` to avoid re-processing stale
780    /// key presses.
781    fn run_and_clear(world: &mut World) {
782        world.run_system_once(move_slider_on_keys).unwrap();
783        world.resource_mut::<Messages<KeyPressed>>().clear();
784    }
785
786    fn value_of(world: &World, e: Entity) -> f32 {
787        world.get::<SliderValue>(e).unwrap().value
788    }
789
790    #[test]
791    fn arrow_right_increments_by_one_step() {
792        let (mut world, e) = setup(0.0, 100.0, 50.0);
793        press(&mut world, Key::Named(NamedKey::ArrowRight));
794        run_and_clear(&mut world);
795        assert_eq!(value_of(&world, e), 51.0); // step = (100-0)/100 = 1.0
796    }
797
798    #[test]
799    fn arrow_left_decrements_by_one_step() {
800        let (mut world, e) = setup(0.0, 100.0, 50.0);
801        press(&mut world, Key::Named(NamedKey::ArrowLeft));
802        run_and_clear(&mut world);
803        assert_eq!(value_of(&world, e), 49.0);
804    }
805
806    #[test]
807    fn arrow_up_and_down_mirror_left_right() {
808        let (mut world, e) = setup(0.0, 100.0, 50.0);
809        press(&mut world, Key::Named(NamedKey::ArrowUp));
810        run_and_clear(&mut world);
811        assert_eq!(value_of(&world, e), 51.0);
812    }
813
814    #[test]
815    fn home_and_end_jump_to_bounds() {
816        let (mut world, e) = setup(0.0, 100.0, 50.0);
817        press(&mut world, Key::Named(NamedKey::End));
818        run_and_clear(&mut world);
819        assert_eq!(value_of(&world, e), 100.0);
820
821        press(&mut world, Key::Named(NamedKey::Home));
822        run_and_clear(&mut world);
823        assert_eq!(value_of(&world, e), 0.0);
824    }
825
826    #[test]
827    fn page_up_and_down_move_by_ten_steps() {
828        let (mut world, e) = setup(0.0, 100.0, 50.0);
829        press(&mut world, Key::Character("PageUp".into()));
830        run_and_clear(&mut world);
831        assert_eq!(value_of(&world, e), 60.0);
832
833        press(&mut world, Key::Character("PageDown".into()));
834        run_and_clear(&mut world);
835        assert_eq!(value_of(&world, e), 50.0);
836    }
837
838    #[test]
839    fn arrow_right_clamps_at_max() {
840        let (mut world, e) = setup(0.0, 100.0, 99.6);
841        press(&mut world, Key::Named(NamedKey::ArrowRight));
842        run_and_clear(&mut world);
843        assert_eq!(value_of(&world, e), 100.0);
844    }
845
846    #[test]
847    fn authored_step_attribute_overrides_default_step() {
848        let (mut world, e) = setup(0.0, 100.0, 50.0);
849        world.get_mut::<SliderValue>(e).unwrap().step = Some(5.0);
850        press(&mut world, Key::Named(NamedKey::ArrowRight));
851        run_and_clear(&mut world);
852        assert_eq!(
853            value_of(&world, e),
854            55.0,
855            "step=\"5\" wins over (max-min)/100"
856        );
857    }
858
859    #[test]
860    fn unfocused_slider_ignores_keys() {
861        let (mut world, e) = setup(0.0, 100.0, 50.0);
862        world.insert_resource(FocusTracker(None));
863        press(&mut world, Key::Named(NamedKey::ArrowRight));
864        run_and_clear(&mut world);
865        assert_eq!(value_of(&world, e), 50.0);
866    }
867
868    /// A legit descending slider (`<slider min="100" max="0">`) driven by
869    /// Arrow/Home/End keys must not panic - `f32::clamp` asserts
870    /// `min <= max` - and the value must stay inside `[0, 100]`.
871    #[test]
872    fn inverted_range_keys_do_not_panic_and_clamp() {
873        let (mut world, e) = setup(100.0, 0.0, 50.0);
874        for key in [
875            Key::Named(NamedKey::ArrowRight),
876            Key::Named(NamedKey::ArrowLeft),
877            Key::Named(NamedKey::End),
878            Key::Named(NamedKey::Home),
879            Key::Character("PageUp".into()),
880            Key::Character("PageDown".into()),
881        ] {
882            press(&mut world, key);
883            run_and_clear(&mut world);
884            let v = value_of(&world, e);
885            assert!(
886                v.is_finite() && (0.0..=100.0).contains(&v),
887                "inverted-range value escaped [0,100]: {v}"
888            );
889        }
890        // Home / End on a descending slider jump to the authored bounds.
891        press(&mut world, Key::Named(NamedKey::End));
892        run_and_clear(&mut world);
893        assert_eq!(value_of(&world, e), 0.0, "End jumps to authored max (0)");
894        press(&mut world, Key::Named(NamedKey::Home));
895        run_and_clear(&mut world);
896        assert_eq!(
897            value_of(&world, e),
898            100.0,
899            "Home jumps to authored min (100)"
900        );
901    }
902
903    /// NaN bounds must not panic (they arrive from a malformed
904    /// `min`/`max` attribute or a mid-layout degenerate range).
905    #[test]
906    fn nan_range_keys_do_not_panic() {
907        let (mut world, e) = setup(f32::NAN, 100.0, 50.0);
908        press(&mut world, Key::Named(NamedKey::End));
909        run_and_clear(&mut world);
910        // End targets `max` (100), which is finite; the clamp guard must
911        // not propagate the NaN `min` bound.
912        assert!(value_of(&world, e).is_finite());
913    }
914
915    /// Wheel over a descending slider must not panic and must clamp into
916    /// `[0, 100]`.
917    #[test]
918    fn inverted_range_wheel_does_not_panic_and_clamps() {
919        let mut world = World::new();
920        world.init_resource::<Messages<MouseWheel>>();
921        world.init_resource::<Messages<SliderChanged>>();
922        let e = world
923            .spawn((
924                SliderValue {
925                    value: 50.0,
926                    min: 100.0,
927                    max: 0.0,
928                    step: None,
929                },
930                Hovered,
931            ))
932            .id();
933        world
934            .resource_mut::<Messages<MouseWheel>>()
935            .write(MouseWheel {
936                delta: glam::Vec2::new(0.0, WHEEL_NOTCH_PX * 100.0),
937                position: glam::Vec2::ZERO,
938                local: None,
939            });
940        world.run_system_once(adjust_slider_on_wheel).unwrap();
941        let v = value_of(&world, e);
942        assert!(
943            v.is_finite() && (0.0..=100.0).contains(&v),
944            "inverted-range wheel value escaped [0,100]: {v}"
945        );
946    }
947}
948
949#[cfg(test)]
950mod track_tests {
951    //! What a track paints and where its knob sits.
952    //!
953    //! [`TrackStyle`] routing: each state paints the fill it names, and a
954    //! repaint from outside (a restyle re-applying the resting `bg`) does
955    //! not leave a track showing the wrong state.
956    //!
957    //! [`KnobGeometry`] plumbing: an entity with no component reproduces
958    //! today's hardcoded [`KNOB_INSET`] / [`THUMB_SIZE`] geometry exactly;
959    //! a CSS-supplied component (as the spawn agent would insert from
960    //! `knob-inset` / `thumb-size`) moves the knob / thumb instead.
961    use super::*;
962    use bevy_ecs::system::RunSystemOnce;
963    use glam::Vec2;
964
965    #[test]
966    fn slider_frac_uses_the_given_thumb_size() {
967        // Track 200 wide; default 16px thumb vs. a wider 40px thumb should
968        // map the same pointer x to different fractions.
969        let default = slider_frac(100.0, 0.0, 200.0, THUMB_SIZE);
970        let wider = slider_frac(100.0, 0.0, 200.0, 40.0);
971        assert!(
972            (default - 0.5).abs() < 1e-6,
973            "pointer at the track centre with the default thumb -> frac 0.5"
974        );
975        assert!(
976            (wider - 0.5).abs() < 1e-6,
977            "pointer at the track centre is still frac 0.5 regardless of \
978             thumb size (the reduced range stays centred)"
979        );
980        // Off-centre: the wider thumb's reduced range is smaller, so the
981        // same pointer x maps to a different fraction.
982        let default_off = slider_frac(60.0, 0.0, 200.0, THUMB_SIZE);
983        let wider_off = slider_frac(60.0, 0.0, 200.0, 40.0);
984        assert!(
985            (default_off - wider_off).abs() > 1e-3,
986            "a different thumb_size must change the mapping off-centre"
987        );
988    }
989
990    fn spawn_toggle(
991        world: &mut World,
992        checked: bool,
993        geo: Option<KnobGeometry>,
994    ) -> (Entity, Entity) {
995        let mut track = world.spawn((
996            Toggleable { checked },
997            Transform::new(Vec2::ZERO, Vec2::new(48.0, 24.0)),
998        ));
999        if let Some(geo) = geo {
1000            track.insert(geo);
1001        }
1002        let track = track.id();
1003        let knob = world
1004            .spawn((
1005                ToggleKnob,
1006                Style::default(),
1007                Visuals::default(),
1008                ChildOf(track),
1009            ))
1010            .id();
1011        (track, knob)
1012    }
1013
1014    fn knob_inset_of(world: &World, knob: Entity) -> f32 {
1015        world.get::<Style>(knob).unwrap().inset.left
1016    }
1017
1018    /// A track carrying the whole palette, plus the `Visuals` the fill
1019    /// sync writes into. `disabled` inserts the marker the cascade's
1020    /// `:disabled` rule keys off.
1021    fn spawn_track(world: &mut World, checked: bool, disabled: bool) -> Entity {
1022        let mut e = world.spawn((
1023            Toggleable { checked },
1024            TrackStyle {
1025                checked_bg: Color::rgb(0.0, 1.0, 0.0),
1026                unchecked_bg: Color::rgb(0.5, 0.5, 0.5),
1027                disabled_bg: Some(Color::rgb(0.0, 0.0, 1.0)),
1028            },
1029            Visuals::default(),
1030        ));
1031        if disabled {
1032            e.insert(lumen_core::components::Disabled);
1033        }
1034        e.id()
1035    }
1036
1037    fn fill_of(world: &World, e: Entity) -> Option<Color> {
1038        world
1039            .get::<Visuals>(e)
1040            .and_then(|v| v.fill.as_ref().and_then(Fill::as_solid))
1041    }
1042
1043    #[test]
1044    fn track_paints_the_fill_its_state_names() {
1045        let mut world = World::new();
1046        let off = spawn_track(&mut world, false, false);
1047        let on = spawn_track(&mut world, true, false);
1048        let disabled = spawn_track(&mut world, false, true);
1049        // Disabled outranks checked, so a disabled-and-checked track still
1050        // reads as unavailable rather than on.
1051        let both = spawn_track(&mut world, true, true);
1052        world.run_system_once(sync_track_fill).unwrap();
1053        assert_eq!(fill_of(&world, off), Some(Color::rgb(0.5, 0.5, 0.5)));
1054        assert_eq!(fill_of(&world, on), Some(Color::rgb(0.0, 1.0, 0.0)));
1055        assert_eq!(fill_of(&world, disabled), Some(Color::rgb(0.0, 0.0, 1.0)));
1056        assert_eq!(fill_of(&world, both), Some(Color::rgb(0.0, 0.0, 1.0)));
1057    }
1058
1059    #[test]
1060    fn track_with_no_disabled_rule_keeps_its_state_fill() {
1061        let mut world = World::new();
1062        let e = world
1063            .spawn((
1064                Toggleable { checked: true },
1065                TrackStyle {
1066                    checked_bg: Color::rgb(0.0, 1.0, 0.0),
1067                    unchecked_bg: Color::rgb(0.5, 0.5, 0.5),
1068                    disabled_bg: None,
1069                },
1070                Visuals::default(),
1071                lumen_core::components::Disabled,
1072            ))
1073            .id();
1074        world.run_system_once(sync_track_fill).unwrap();
1075        assert_eq!(
1076            fill_of(&world, e),
1077            Some(Color::rgb(0.0, 1.0, 0.0)),
1078            "with no `:disabled {{ bg }}` rule the generic opacity dim is \
1079             the disabled affordance, so the state fill stands"
1080        );
1081    }
1082
1083    #[test]
1084    fn track_follows_a_restyled_palette() {
1085        // A restyle recomputes the palette on a theme or media flip, and
1086        // the state fill has to move with it. Gating the repaint on
1087        // `Changed<Toggleable>` once left a checked track on the old color.
1088        let mut world = World::new();
1089        let on = spawn_track(&mut world, true, false);
1090        world.run_system_once(sync_track_fill).unwrap();
1091        world.get_mut::<TrackStyle>(on).unwrap().checked_bg = Color::rgb(0.0, 0.0, 1.0);
1092        world.run_system_once(sync_track_fill).unwrap();
1093        assert_eq!(fill_of(&world, on), Some(Color::rgb(0.0, 0.0, 1.0)));
1094    }
1095
1096    #[test]
1097    fn a_tint_over_the_track_lets_go_onto_its_state_fill() {
1098        use lumen_core::style_overrides::{Live, StyleOverrides, StyleValues};
1099        let mut world = World::new();
1100        let on = spawn_track(&mut world, true, false);
1101        world.run_system_once(sync_track_fill).unwrap();
1102        // A hover tint laid over the checked track.
1103        let tint = Color::rgb(1.0, 0.0, 0.0);
1104        let mut ent = world.entity_mut(on);
1105        let vis = ent.get::<Visuals>().unwrap().clone();
1106        let out = ent
1107            .get_mut::<StyleOverrides>()
1108            .unwrap()
1109            .hold::<crate::hover::HoverTween>(
1110                StyleValues {
1111                    fill: Some(Some(Fill::Solid(tint))),
1112                    ..StyleValues::default()
1113                },
1114                &Live {
1115                    visuals: Some(&vis),
1116                    ..Live::default()
1117                },
1118            );
1119        out.write_visuals(&mut ent.get_mut::<Visuals>().unwrap());
1120        world.run_system_once(sync_track_fill).unwrap();
1121        assert_eq!(fill_of(&world, on), Some(tint), "the tint stays on top");
1122        let mut ent = world.entity_mut(on);
1123        let out = ent
1124            .get_mut::<StyleOverrides>()
1125            .unwrap()
1126            .release::<crate::hover::HoverTween>();
1127        out.write_visuals(&mut ent.get_mut::<Visuals>().unwrap());
1128        assert_eq!(
1129            fill_of(&world, on),
1130            Some(Color::rgb(0.0, 1.0, 0.0)),
1131            "the tint lets go onto the checked fill"
1132        );
1133    }
1134
1135    #[test]
1136    fn toggle_knob_with_no_geometry_uses_the_default_inset() {
1137        let mut world = World::new();
1138        let (_, knob) = spawn_toggle(&mut world, false, None);
1139        world.run_system_once(sync_toggle_visuals).unwrap();
1140        assert_eq!(
1141            knob_inset_of(&world, knob),
1142            KNOB_INSET,
1143            "no KnobGeometry -> today's hardcoded inset"
1144        );
1145    }
1146
1147    #[test]
1148    fn toggle_knob_with_custom_geometry_uses_the_authored_inset() {
1149        let mut world = World::new();
1150        let custom = KnobGeometry {
1151            inset: 10.0,
1152            thumb_size: THUMB_SIZE,
1153        };
1154        let (_, knob) = spawn_toggle(&mut world, false, Some(custom));
1155        world.run_system_once(sync_toggle_visuals).unwrap();
1156        assert_eq!(
1157            knob_inset_of(&world, knob),
1158            10.0,
1159            "a CSS-supplied inset moves the knob off the default position"
1160        );
1161    }
1162
1163    fn spawn_slider_track(
1164        world: &mut World,
1165        value: f32,
1166        geo: Option<KnobGeometry>,
1167    ) -> (Entity, Entity) {
1168        let mut track = world.spawn((
1169            SliderValue {
1170                value,
1171                min: 0.0,
1172                max: 100.0,
1173                step: None,
1174            },
1175            Transform::new(Vec2::ZERO, Vec2::new(200.0, 24.0)),
1176        ));
1177        if let Some(geo) = geo {
1178            track.insert(geo);
1179        }
1180        let track = track.id();
1181        let thumb = world
1182            .spawn((SliderThumb, Style::default(), ChildOf(track)))
1183            .id();
1184        (track, thumb)
1185    }
1186
1187    fn thumb_left_of(world: &World, thumb: Entity) -> f32 {
1188        world.get::<Style>(thumb).unwrap().inset.left
1189    }
1190
1191    #[test]
1192    fn slider_thumb_with_no_geometry_uses_the_default_size() {
1193        let mut world = World::new();
1194        let (_, thumb) = spawn_slider_track(&mut world, 100.0, None);
1195        world.run_system_once(sync_slider_thumb).unwrap();
1196        // value = max -> thumb pinned at the right edge: 200 - THUMB_SIZE.
1197        assert_eq!(thumb_left_of(&world, thumb), 200.0 - THUMB_SIZE);
1198    }
1199
1200    #[test]
1201    fn slider_thumb_with_custom_geometry_uses_the_authored_size() {
1202        let mut world = World::new();
1203        let custom = KnobGeometry {
1204            inset: KNOB_INSET,
1205            thumb_size: 40.0,
1206        };
1207        let (_, thumb) = spawn_slider_track(&mut world, 100.0, Some(custom));
1208        world.run_system_once(sync_slider_thumb).unwrap();
1209        // value = max -> thumb pinned at the right edge: 200 - 40.
1210        assert_eq!(
1211            thumb_left_of(&world, thumb),
1212            160.0,
1213            "a CSS-supplied thumb_size changes the thumb's rest position"
1214        );
1215    }
1216}