Skip to main content

lumen_primitives/
switch.rs

1//! `<switch>` behavior: an on/off toggle rendered as a pill track with a
2//! thumb that slides between the two ends, built on the same shared
3//! [`Toggleable`] machinery as `<checkbox>`.
4//!
5//! The markup parser spawns `<switch>` as a track element (tag `switch`,
6//! carrying [`Toggleable`] + [`crate::controls::TrackStyle`]) with a single absolute-
7//! positioned child - the `.switch-thumb` tile (carrying [`SwitchThumb`]).
8//! Every visual is design-token / CSS reachable through the skins:
9//!
10//! ```css
11//! switch            { width: 52; height: 28; bg: ...; }  /* track (off) */
12//! switch:checked    { bg: var(--lumen-accent); }        /* track (on) */
13//! .switch-thumb     { knob-color: ... }                   /* thumb fill */
14//! ```
15//!
16//! Behavior reuse - identical to `<checkbox>`, nothing bespoke:
17//! - Click anywhere on the track (or the thumb) toggles -
18//!   [`crate::controls::flip_toggle_on_click`] resolves a child hit to the
19//!   ancestor [`Toggleable`].
20//! - Space / Enter on the focused switch toggles -
21//!   `lumen_input::activate_focused_on_enter`'s press-and-release FSM emits
22//!   the same `ClickEvent` (mirrors Slint `SwitchBase`'s
23//!   `key-pressed: if event.text == " " || "\n" { toggle-checked() }` and
24//!   Qt `QAbstractButton`'s space-bar activation).
25//! - `bind-checked` two-way signal binding comes with [`Toggleable`]
26//!   (`apply_checked_bindings` / `push_toggle_to_signal`) - mirrors Slint's
27//!   `in-out property <bool> checked` two-way binding.
28//! - `disabled` inserts the generic `Disabled` marker; interaction is
29//!   ejected by [`crate::state_style::eject_interaction_on_disable`].
30//!
31//! What IS bespoke: the thumb slides between off and on with an implicit
32//! animation driven by the shared transition primitive
33//! ([`crate::transition::Transition`]) rather than snapping - mirroring
34//! Slint's `animate ... x { duration: 75ms }` on the switch handle and Qt
35//! QML `Switch`'s `Behavior on position { NumberAnimation }`. The tween is
36//! reactive: [`step_switch_thumb`] samples it and pings
37//! [`AnimationsActive`] only while a slide is in flight, then retires the
38//! component - no per-frame loop.
39//!
40//! The slide's duration + easing come from a `transition: bg <duration>
41//! <easing>` declaration on the track (see [`slide_tween_params`]) - the
42//! same mechanism [`crate::hover`] uses for its hover / press tint tween -
43//! falling back to [`SWITCH_SLIDE_MS`] / [`SWITCH_SLIDE_EASING`] when
44//! none is authored. Track / thumb geometry (the inset gap and the
45//! thumb's diameter) come from [`crate::controls::KnobGeometry`],
46//! populated from the CSS `knob-inset` property, with the same
47//! today's-appearance fallback.
48//!
49//! Accessibility: `<switch>` carries an explicit
50//! [`lumen_core::components::A11yRole::Switch`], so the a11y tree exposes
51//! it as `Role::Switch` with the `Toggled` state derived from
52//! [`Toggleable::checked`] - the `Role::Switch` analogue of how
53//! `<checkbox>` surfaces `Role::CheckBox`.
54
55use std::time::Duration;
56
57use bevy_ecs::prelude::*;
58use lumen_core::components::{Visible, Visuals};
59use lumen_core::prelude::*;
60use lumen_core::render_world::AnimationsActive;
61
62use crate::controls::KnobGeometry;
63use crate::transition::{Easing, Transition, TransitionProperty, TransitionSpecs};
64
65/// Fallback duration of the thumb slide when the switch flips, used when
66/// the track carries no `transition: bg ...` [`TransitionSpecs`] (see
67/// [`slide_tween_params`]). 140 ms sits between Slint's 75 ms handle
68/// tween and the 200 ms Qt QML `Switch` default - long enough to read as
69/// motion, short enough to feel instant.
70pub const SWITCH_SLIDE_MS: u64 = 140;
71
72/// Fallback easing for the thumb slide, used under the same condition as
73/// [`SWITCH_SLIDE_MS`]. Ease-out (fast start, gentle settle) is the
74/// Cocoa / Material short-transition curve; the same curve `<checkbox>` and
75/// the hover tints reach for.
76pub const SWITCH_SLIDE_EASING: Easing = Easing::EaseOut;
77
78/// Resolve the thumb slide's duration + easing for one flip. A CSS
79/// `transition: bg <duration> <easing>` declaration on the track entity
80/// (its [`TransitionSpecs`]) wins - the same `TransitionSpecs` +
81/// `TransitionProperty::BackgroundColor` lookup [`crate::hover`] consults
82/// for its hover / press tint tween duration - otherwise the built-in
83/// [`SWITCH_SLIDE_MS`] / [`SWITCH_SLIDE_EASING`] fallback, which
84/// reproduces today's hardcoded slide exactly.
85fn slide_tween_params(specs: Option<&TransitionSpecs>) -> (Duration, Easing) {
86    match specs.and_then(|s| s.for_property(TransitionProperty::BackgroundColor)) {
87        Some(spec) => (spec.duration, spec.easing),
88        None => (Duration::from_millis(SWITCH_SLIDE_MS), SWITCH_SLIDE_EASING),
89    }
90}
91
92/// Marker for the thumb child spawned inside every `<switch>`.
93/// Absolute-positioned; [`sync_switch_visuals`] slides it between the two
94/// track ends, animating flips through [`SwitchThumbSlide`].
95///
96/// `placed` records the `checked` state the thumb was last positioned for.
97/// `None` until the first sync after layout, so the initial placement snaps
98/// (no slide-in from the wrong end on spawn); a later mismatch means the
99/// state flipped and starts an animated slide.
100#[derive(Component, Clone, Copy, Debug, Default)]
101pub struct SwitchThumb {
102    /// Last `checked` value the thumb was positioned for; `None` = unplaced.
103    pub placed: Option<bool>,
104}
105
106/// Active thumb-slide tween driving a [`SwitchThumb`]'s horizontal inset.
107/// Reuses the shared [`Transition<f32>`] primitive; [`step_switch_thumb`]
108/// samples it and removes the component when the slide completes.
109#[derive(Component, Clone, Copy, Debug)]
110pub struct SwitchThumbSlide(pub Transition<f32>);
111
112/// Plugin registering the switch visual-sync + thumb-slide systems.
113///
114/// The toggle behavior itself (click / keyboard / bindings) ships with
115/// [`crate::ControlsPlugin`] via the shared [`Toggleable`] path, exactly as
116/// `<checkbox>` reuses it; [`crate::ControlsPlugin`] also wires these two
117/// systems so a host that installs `ControlsPlugin` gets `<switch>` for
118/// free. This standalone plugin exists for tests / hosts that want the
119/// switch systems in isolation.
120pub struct SwitchPlugin;
121
122impl Plugin for SwitchPlugin {
123    fn build(self, app: &mut App) {
124        register_switch_systems(app);
125    }
126}
127
128/// Register [`sync_switch_visuals`] + [`step_switch_thumb`] in
129/// `TickStage::Systems`. Called by both [`SwitchPlugin`] and
130/// [`crate::ControlsPlugin`]; system de-duplication makes a double install
131/// harmless.
132pub fn register_switch_systems(app: &mut App) {
133    app.add_systems(
134        TickStage::Systems,
135        sync_switch_visuals.after(crate::controls::flip_toggle_on_click),
136    );
137    app.add_systems(
138        TickStage::Systems,
139        step_switch_thumb.after(sync_switch_visuals),
140    );
141}
142
143/// Place the [`SwitchThumb`] child: size + corner radius from the
144/// laid-out track height, and the horizontal inset toward the
145/// checked/unchecked end. The first placement snaps; a subsequent
146/// `checked` flip starts a [`SwitchThumbSlide`] from the thumb's current
147/// position so it glides.
148///
149/// The track fill belongs to [`crate::controls::sync_track_fill`], which
150/// serves `<toggle>` and `<switch>` alike.
151#[allow(clippy::type_complexity)]
152pub fn sync_switch_visuals(
153    mut commands: Commands,
154    parents: Query<(
155        &Toggleable,
156        &Transform,
157        Option<&KnobGeometry>,
158        Option<&TransitionSpecs>,
159    )>,
160    mut thumbs: Query<
161        (
162            Entity,
163            &ChildOf,
164            &mut SwitchThumb,
165            &mut Style,
166            &mut Visuals,
167            Option<&SwitchThumbSlide>,
168        ),
169        (With<SwitchThumb>, Without<Toggleable>),
170    >,
171) {
172    for (thumb_e, child_of, mut thumb, mut style, mut vis, slide) in &mut thumbs {
173        let Ok((t, tr, geo, specs)) = parents.get(child_of.parent()) else {
174            continue;
175        };
176        if tr.size.x <= 0.0 || tr.size.y <= 0.0 {
177            continue;
178        }
179        let inset = geo.copied().unwrap_or_default().inset;
180        let knob = (tr.size.y - 2.0 * inset).max(2.0);
181        let target_left = if t.checked {
182            (tr.size.x - knob - inset).max(inset)
183        } else {
184            inset
185        };
186        let radius = knob / 2.0;
187        if vis.radius != radius {
188            vis.radius = radius;
189        }
190        let size = Length::Px(knob);
191        let mut dirty = false;
192        if style.width != size || style.height != size || style.inset.top != inset {
193            style.width = size;
194            style.height = size;
195            style.inset.top = inset;
196            dirty = true;
197        }
198        match thumb.placed {
199            // First placement after layout: snap, no slide.
200            None => {
201                style.inset.left = target_left;
202                thumb.placed = Some(t.checked);
203                dirty = true;
204            }
205            // State flipped: glide from the current rest position.
206            Some(prev) if prev != t.checked => {
207                let from = style.inset.left;
208                thumb.placed = Some(t.checked);
209                if (from - target_left).abs() > f32::EPSILON {
210                    let (duration, easing) = slide_tween_params(specs);
211                    commands
212                        .entity(thumb_e)
213                        .insert(SwitchThumbSlide(Transition::new(
214                            from,
215                            target_left,
216                            duration,
217                            easing,
218                        )));
219                } else {
220                    style.inset.left = target_left;
221                    dirty = true;
222                }
223            }
224            // No flip: park at the target when idle (handles track resize)
225            // without fighting an in-flight slide.
226            Some(_) => {
227                if slide.is_none() && (style.inset.left - target_left).abs() > 0.25 {
228                    style.inset.left = target_left;
229                    dirty = true;
230                }
231            }
232        }
233        if dirty {
234            commands.entity(thumb_e).insert(DirtyLayout);
235        }
236    }
237}
238
239/// Advance every active [`SwitchThumbSlide`], writing the eased offset into
240/// the thumb's [`Style::inset`]`.left` and marking it dirty so layout
241/// repositions it. Pings [`AnimationsActive`] while running so the loop
242/// keeps ticking without an unrelated OS event; snaps to the endpoint and
243/// retires the component when done (or immediately if the thumb is hidden -
244/// a `display: none` subtree runs no animation, matching the CSS transition
245/// drivers in [`crate::transition`]).
246pub fn step_switch_thumb(
247    mut commands: Commands,
248    tick: Res<Tick>,
249    anim: Res<AnimationsActive>,
250    mut q: Query<(Entity, &SwitchThumbSlide, &mut Style, Option<&Visible>)>,
251) {
252    let now = tick.now;
253    for (entity, slide, mut style, visible) in &mut q {
254        let hidden = visible.is_some_and(|v| !v.0);
255        let next = if hidden {
256            slide.0.to
257        } else {
258            slide.0.sample(now)
259        };
260        if style.inset.left != next {
261            style.inset.left = next;
262            commands.entity(entity).insert(DirtyLayout);
263        }
264        if hidden || slide.0.done(now) {
265            if style.inset.left != slide.0.to {
266                style.inset.left = slide.0.to;
267                commands.entity(entity).insert(DirtyLayout);
268            }
269            commands.entity(entity).remove::<SwitchThumbSlide>();
270        } else {
271            anim.request();
272        }
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::controls::{KNOB_INSET, THUMB_SIZE, TrackStyle, sync_track_fill};
280    use crate::transition::TransitionSpec;
281    use bevy_ecs::system::RunSystemOnce;
282    use glam::Vec2;
283    use lumen_core::components::{Color, Fill};
284
285    /// Spawn a laid-out `<switch>` track + thumb child, with no
286    /// [`KnobGeometry`] / [`TransitionSpecs`] on the track - the
287    /// no-CSS-authored path, which must reproduce today's hardcoded
288    /// geometry / slide timing exactly. Track is 52x28 so the thumb math
289    /// is `knob = 28 - 8 = 20`, `off = 4`, `on = 52-20-4 = 28`.
290    fn spawn_switch(world: &mut World, checked: bool) -> (Entity, Entity) {
291        spawn_switch_with(world, checked, None, None)
292    }
293
294    /// The component-based counterpart of [`spawn_switch`]: same track +
295    /// thumb shape, but with an optional CSS-supplied [`KnobGeometry`]
296    /// and/or [`TransitionSpecs`] inserted on the track, to prove they
297    /// move the resolved geometry / slide timing off the defaults.
298    fn spawn_switch_with(
299        world: &mut World,
300        checked: bool,
301        geo: Option<KnobGeometry>,
302        specs: Option<TransitionSpecs>,
303    ) -> (Entity, Entity) {
304        let mut track = world.spawn((
305            Toggleable { checked },
306            TrackStyle::default(),
307            Visuals {
308                fill: Some(Fill::Solid(TrackStyle::default().unchecked_bg)),
309                ..Default::default()
310            },
311            Transform {
312                absolute: Vec2::ZERO,
313                size: Vec2::new(52.0, 28.0),
314                baseline_y: None,
315            },
316        ));
317        if let Some(geo) = geo {
318            track.insert(geo);
319        }
320        if let Some(specs) = specs {
321            track.insert(specs);
322        }
323        let track = track.id();
324        let thumb = world
325            .spawn((
326                SwitchThumb::default(),
327                Style::default(),
328                Visuals::default(),
329                ChildOf(track),
330            ))
331            .id();
332        (track, thumb)
333    }
334
335    fn thumb_left(world: &World, thumb: Entity) -> f32 {
336        world.get::<Style>(thumb).unwrap().inset.left
337    }
338
339    fn track_fill(world: &World, track: Entity) -> Option<Color> {
340        world
341            .get::<Visuals>(track)
342            .and_then(|v| v.fill.as_ref().and_then(Fill::as_solid))
343    }
344
345    #[test]
346    fn unchecked_parks_thumb_left_and_paints_off_track() {
347        let mut world = World::new();
348        let (track, thumb) = spawn_switch(&mut world, false);
349        world.run_system_once(sync_switch_visuals).unwrap();
350        world.run_system_once(sync_track_fill).unwrap();
351        assert_eq!(thumb_left(&world, thumb), KNOB_INSET);
352        assert_eq!(
353            track_fill(&world, track),
354            Some(TrackStyle::default().unchecked_bg)
355        );
356        // First placement snaps - no slide component.
357        assert!(world.get::<SwitchThumbSlide>(thumb).is_none());
358    }
359
360    #[test]
361    fn checked_on_spawn_snaps_thumb_right_and_paints_on_track() {
362        let mut world = World::new();
363        let (track, thumb) = spawn_switch(&mut world, true);
364        world.run_system_once(sync_switch_visuals).unwrap();
365        world.run_system_once(sync_track_fill).unwrap();
366        // on = 52 - 20 - 4 = 28.
367        assert_eq!(thumb_left(&world, thumb), 28.0);
368        assert_eq!(
369            track_fill(&world, track),
370            Some(TrackStyle::default().checked_bg)
371        );
372        // A switch spawned already-on must not animate in from the off end.
373        assert!(world.get::<SwitchThumbSlide>(thumb).is_none());
374    }
375
376    #[test]
377    fn thumb_size_and_radius_follow_track_height() {
378        let mut world = World::new();
379        let (_, thumb) = spawn_switch(&mut world, false);
380        world.run_system_once(sync_switch_visuals).unwrap();
381        let style = world.get::<Style>(thumb).unwrap();
382        assert_eq!(style.width, Length::Px(20.0));
383        assert_eq!(style.height, Length::Px(20.0));
384        assert_eq!(world.get::<Visuals>(thumb).unwrap().radius, 10.0);
385    }
386
387    #[test]
388    fn flip_starts_a_slide_from_the_current_position() {
389        let mut world = World::new();
390        let (track, thumb) = spawn_switch(&mut world, false);
391        // First sync: place at off (snap).
392        world.run_system_once(sync_switch_visuals).unwrap();
393        assert_eq!(thumb_left(&world, thumb), KNOB_INSET);
394        // Flip to on and re-sync: a slide toward the on end must start,
395        // beginning from the current (off) position - the animated
396        // equivalent of Slint's `animate x` on the handle.
397        world.get_mut::<Toggleable>(track).unwrap().checked = true;
398        world.run_system_once(sync_switch_visuals).unwrap();
399        let slide = world
400            .get::<SwitchThumbSlide>(thumb)
401            .expect("flip must start a thumb slide");
402        assert_eq!(slide.0.from, KNOB_INSET, "slide starts at the old rest pos");
403        assert_eq!(slide.0.to, 28.0, "slide targets the on end");
404    }
405
406    #[test]
407    fn step_advances_and_retires_the_slide() {
408        let mut world = World::new();
409        world.insert_resource(Tick::default());
410        world.insert_resource(AnimationsActive::default());
411        let (_, thumb) = spawn_switch(&mut world, false);
412        // Install a slide directly: off (4) -> on (28) over 140 ms.
413        let tween = Transition::new(
414            KNOB_INSET,
415            28.0,
416            Duration::from_millis(SWITCH_SLIDE_MS),
417            Easing::Linear,
418        );
419        world.entity_mut(thumb).insert(SwitchThumbSlide(tween));
420        // Mid-flight: back-date the start so sampling lands halfway.
421        world.get_mut::<SwitchThumbSlide>(thumb).unwrap().0.start -=
422            Duration::from_millis(SWITCH_SLIDE_MS / 2);
423        {
424            let now = world.get::<SwitchThumbSlide>(thumb).unwrap().0.start
425                + Duration::from_millis(SWITCH_SLIDE_MS / 2);
426            world.resource_mut::<Tick>().now = now;
427        }
428        world.run_system_once(step_switch_thumb).unwrap();
429        let mid = thumb_left(&world, thumb);
430        assert!(
431            mid > KNOB_INSET + 4.0 && mid < 28.0,
432            "thumb mid-slide between the ends (left = {mid})"
433        );
434        assert!(
435            world.get::<SwitchThumbSlide>(thumb).is_some(),
436            "slide still active mid-flight"
437        );
438        assert!(
439            world.resource::<AnimationsActive>().get(),
440            "an in-flight slide keeps the loop awake"
441        );
442        // Past the end: snap to the on position and retire.
443        {
444            let now = world.get::<SwitchThumbSlide>(thumb).unwrap().0.start
445                + Duration::from_millis(SWITCH_SLIDE_MS * 4);
446            world.resource_mut::<Tick>().now = now;
447        }
448        world.run_system_once(step_switch_thumb).unwrap();
449        assert_eq!(thumb_left(&world, thumb), 28.0);
450        assert!(world.get::<SwitchThumbSlide>(thumb).is_none());
451    }
452
453    #[test]
454    fn hidden_switch_snaps_slide_without_waking() {
455        let mut world = World::new();
456        world.insert_resource(Tick::default());
457        world.insert_resource(AnimationsActive::default());
458        let (_, thumb) = spawn_switch(&mut world, false);
459        world.entity_mut(thumb).insert((
460            SwitchThumbSlide(Transition::new(
461                KNOB_INSET,
462                28.0,
463                Duration::from_millis(SWITCH_SLIDE_MS),
464                Easing::Linear,
465            )),
466            Visible(false),
467        ));
468        world.run_system_once(step_switch_thumb).unwrap();
469        assert_eq!(thumb_left(&world, thumb), 28.0, "hidden slide jumps to end");
470        assert!(world.get::<SwitchThumbSlide>(thumb).is_none());
471        assert!(
472            !world.resource::<AnimationsActive>().get(),
473            "hidden slide must not keep the loop awake"
474        );
475    }
476
477    /// A CSS-supplied [`KnobGeometry`] on the track moves the thumb's
478    /// rest position (and size) off the [`KNOB_INSET`] / track-height
479    /// default - the component-based counterpart of the KNOB_INSET
480    /// assertions above.
481    #[test]
482    fn custom_knob_geometry_moves_the_thumb_off_the_default_inset() {
483        let mut world = World::new();
484        let geo = KnobGeometry {
485            inset: 8.0,
486            thumb_size: THUMB_SIZE,
487        };
488        let (_, thumb) = spawn_switch_with(&mut world, false, Some(geo), None);
489        world.run_system_once(sync_switch_visuals).unwrap();
490        assert_eq!(
491            thumb_left(&world, thumb),
492            8.0,
493            "a CSS-supplied inset moves the resting position off KNOB_INSET (4)"
494        );
495        // knob = 28 - 2*8 = 12, vs. the default geometry's 20.
496        let style = world.get::<Style>(thumb).unwrap();
497        assert_eq!(style.width, Length::Px(12.0));
498        assert_eq!(style.height, Length::Px(12.0));
499    }
500
501    /// An entity with no [`KnobGeometry`] must keep today's geometry - the
502    /// explicit no-component counterpart of the custom-geometry test
503    /// above (also covered implicitly by every `spawn_switch`-based test).
504    #[test]
505    fn no_knob_geometry_keeps_the_default_inset() {
506        let mut world = World::new();
507        let (_, thumb) = spawn_switch_with(&mut world, false, None, None);
508        world.run_system_once(sync_switch_visuals).unwrap();
509        assert_eq!(thumb_left(&world, thumb), KNOB_INSET);
510    }
511
512    /// An authored `transition: bg <duration> <easing>` on the track
513    /// overrides [`SWITCH_SLIDE_MS`] / [`SWITCH_SLIDE_EASING`] for the
514    /// thumb slide - the same [`TransitionSpecs`] +
515    /// `TransitionProperty::BackgroundColor` lookup [`crate::hover`] uses
516    /// for its tint tween duration.
517    #[test]
518    fn authored_transition_overrides_the_default_slide_duration_and_easing() {
519        let mut world = World::new();
520        let specs = TransitionSpecs(vec![TransitionSpec {
521            property: TransitionProperty::BackgroundColor,
522            duration: Duration::from_millis(300),
523            easing: Easing::Linear,
524        }]);
525        let (track, thumb) = spawn_switch_with(&mut world, false, None, Some(specs));
526        // First sync: snap to off (no slide yet).
527        world.run_system_once(sync_switch_visuals).unwrap();
528        world.get_mut::<Toggleable>(track).unwrap().checked = true;
529        world.run_system_once(sync_switch_visuals).unwrap();
530        let slide = world
531            .get::<SwitchThumbSlide>(thumb)
532            .expect("flip must start a thumb slide");
533        assert_eq!(
534            slide.0.duration,
535            Duration::from_millis(300),
536            "authored transition: bg 300ms overrides SWITCH_SLIDE_MS"
537        );
538        assert_eq!(
539            slide.0.easing,
540            Easing::Linear,
541            "authored easing overrides SWITCH_SLIDE_EASING"
542        );
543    }
544
545    /// No `transition:` authored on the track falls back to
546    /// [`SWITCH_SLIDE_MS`] / [`SWITCH_SLIDE_EASING`] - appearance must not
547    /// change for a skin that never declares `transition: bg` on `switch`.
548    #[test]
549    fn no_authored_transition_falls_back_to_switch_slide_defaults() {
550        let mut world = World::new();
551        let (track, thumb) = spawn_switch_with(&mut world, false, None, None);
552        world.run_system_once(sync_switch_visuals).unwrap();
553        world.get_mut::<Toggleable>(track).unwrap().checked = true;
554        world.run_system_once(sync_switch_visuals).unwrap();
555        let slide = world
556            .get::<SwitchThumbSlide>(thumb)
557            .expect("flip must start a thumb slide");
558        assert_eq!(slide.0.duration, Duration::from_millis(SWITCH_SLIDE_MS));
559        assert_eq!(slide.0.easing, SWITCH_SLIDE_EASING);
560    }
561}