1use 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
19pub const TOGGLE_UNCHECKED_BG: Color = Color::rgb(0.33, 0.33, 0.33);
22
23pub const TOGGLE_CHECKED_BG: Color = Color::rgb(0.20, 0.66, 0.70);
27
28pub const KNOB_FILL: Color = Color::rgb(0.92, 0.92, 0.94);
30
31pub const KNOB_INSET: f32 = 4.0;
35
36pub const THUMB_SIZE: f32 = 16.0;
40
41#[derive(Component, Clone, Copy, Debug, PartialEq)]
48pub struct KnobGeometry {
49 pub inset: f32,
51 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#[derive(Component, Clone, Copy, Debug, PartialEq)]
73#[require(StyleOverrides)]
74pub struct TrackStyle {
75 pub checked_bg: Color,
78 pub unchecked_bg: Color,
80 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 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#[derive(Component, Clone, Copy, Debug, Default)]
124pub struct ToggleKnob;
125
126#[derive(Component, Clone, Copy, Debug, Default)]
130pub struct SliderThumb;
131
132#[derive(Message, Clone, Copy, Debug)]
135pub struct ToggleChanged {
136 pub entity: Entity,
138 pub checked: bool,
140}
141
142#[derive(Message, Clone, Copy, Debug)]
145pub struct SliderChanged {
146 pub entity: Entity,
148 pub value: f32,
150}
151
152pub 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 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 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 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 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 crate::switch::register_switch_systems(app);
248 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
271pub(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
302pub 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
326fn 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
340fn 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
360pub 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
395pub 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
452pub 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
486pub const WHEEL_NOTCH_PX: f32 = 32.0;
491
492pub 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#[derive(Component, Clone, Copy, Debug)]
542pub struct SliderDragOrigin(pub f32);
543
544pub 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
564pub 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
580pub 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#[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#[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
702pub 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 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 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 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); }
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 #[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 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 #[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 assert!(value_of(&world, e).is_finite());
913 }
914
915 #[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 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 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 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 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 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 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 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 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 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}