1use 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
65pub const SWITCH_SLIDE_MS: u64 = 140;
71
72pub const SWITCH_SLIDE_EASING: Easing = Easing::EaseOut;
77
78fn 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#[derive(Component, Clone, Copy, Debug, Default)]
101pub struct SwitchThumb {
102 pub placed: Option<bool>,
104}
105
106#[derive(Component, Clone, Copy, Debug)]
110pub struct SwitchThumbSlide(pub Transition<f32>);
111
112pub struct SwitchPlugin;
121
122impl Plugin for SwitchPlugin {
123 fn build(self, app: &mut App) {
124 register_switch_systems(app);
125 }
126}
127
128pub 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#[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 None => {
201 style.inset.left = target_left;
202 thumb.placed = Some(t.checked);
203 dirty = true;
204 }
205 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 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
239pub 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 fn spawn_switch(world: &mut World, checked: bool) -> (Entity, Entity) {
291 spawn_switch_with(world, checked, None, None)
292 }
293
294 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 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 assert_eq!(thumb_left(&world, thumb), 28.0);
368 assert_eq!(
369 track_fill(&world, track),
370 Some(TrackStyle::default().checked_bg)
371 );
372 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 world.run_system_once(sync_switch_visuals).unwrap();
393 assert_eq!(thumb_left(&world, thumb), KNOB_INSET);
394 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 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 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 {
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 #[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 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 #[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 #[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 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 #[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}