1use crate::held::{fill_below, hold_fill};
18use bevy_ecs::prelude::*;
19use lumen_core::components::Visuals;
20use lumen_core::prelude::*;
21use lumen_core::render_world::{ExtractedOutline, build_parent_map, paint_order_of};
22use lumen_core::style_overrides::{Live, StyleOverrides, StyleOwner, StyleValues};
23use lumen_core::time::Instant;
24
25fn tween_instant_seed() -> Instant {
31 Instant::now()
32}
33
34#[derive(Component, Clone, Copy, Debug, Default, PartialEq)]
39#[require(StyleOverrides)]
40pub struct Interaction {
41 pub hover_tint: Option<Color>,
44 pub press_tint: Option<Color>,
47 pub focus_outline: Option<FocusOutlineSpec>,
50 pub focus_visible_outline: Option<FocusOutlineSpec>,
55 pub hover_border: Option<lumen_core::components::Border>,
60 pub focus_border: Option<lumen_core::components::Border>,
64}
65
66#[derive(Clone, Copy, Debug, PartialEq)]
69pub struct FocusOutlineSpec {
70 pub width: f32,
72 pub color: Color,
74 pub offset: f32,
77}
78
79pub const HOVER_TWEEN_DURATION: f32 = 0.12;
83
84#[derive(Component, Clone, Copy, Debug)]
89pub struct HoverTween {
90 pub progress: f32,
92 pub target: f32,
94 pub last_step: Instant,
97}
98
99impl StyleOwner for HoverTween {
100 const RANK: u8 = 40;
101}
102
103impl Default for HoverTween {
104 fn default() -> Self {
105 Self {
106 progress: 0.0,
107 target: 0.0,
108 last_step: tween_instant_seed(),
109 }
110 }
111}
112
113pub struct HoverTintPlugin;
125
126impl Plugin for HoverTintPlugin {
127 fn build(self, app: &mut App) {
128 app.add_systems(
137 TickStage::Systems,
138 apply_hover_tint.after(lumen_input::hit_test),
139 );
140 app.add_systems(
141 TickStage::Systems,
142 apply_press_tint
143 .after(apply_hover_tint)
144 .after(lumen_input::dispatch_clicks),
145 );
146 app.add_systems(TickStage::Systems, apply_state_borders);
147 app.add_extract_fn(extract_focus_outlines);
149 }
150}
151
152#[derive(Component, Clone, Copy, Debug, Default)]
155pub struct StateBorderHeld;
156
157impl StyleOwner for StateBorderHeld {
158 const RANK: u8 = 60;
159}
160
161#[allow(clippy::type_complexity)]
167pub fn apply_state_borders(
168 mut commands: Commands,
169 mut active: Query<
170 (
171 Entity,
172 &Interaction,
173 &mut Visuals,
174 &mut StyleOverrides,
175 Has<StateBorderHeld>,
176 Option<&Hovered>,
177 Option<&Focused>,
178 ),
179 Or<(With<Hovered>, With<Focused>)>,
180 >,
181 mut idle: Query<
182 (Entity, &mut Visuals, &mut StyleOverrides),
183 (
184 With<StateBorderHeld>,
185 With<Interaction>,
186 Without<Hovered>,
187 Without<Focused>,
188 ),
189 >,
190 mut orphaned: Query<
191 (Entity, &mut Visuals, &mut StyleOverrides),
192 (With<StateBorderHeld>, Without<Interaction>),
193 >,
194) {
195 for (entity, ix, mut vis, mut overrides, held, hovered, focused) in &mut active {
196 let want = match (
197 focused.and(ix.focus_border.as_ref()),
198 hovered.and(ix.hover_border.as_ref()),
199 ) {
200 (Some(b), _) => Some(*b),
201 (None, Some(b)) => Some(*b),
202 (None, None) => None,
203 };
204 let Some(want) = want else {
205 if held {
206 overrides
207 .release::<StateBorderHeld>()
208 .write_visuals(&mut vis);
209 commands.entity(entity).remove::<StateBorderHeld>();
210 }
211 continue;
212 };
213 if overrides
214 .held_by::<StateBorderHeld>()
215 .is_some_and(|v| v.border == Some(Some(want)))
216 {
217 continue;
218 }
219 if !held {
220 commands.entity(entity).try_insert(StateBorderHeld);
221 }
222 let live = Live {
223 visuals: Some(&vis),
224 ..Live::default()
225 };
226 let values = StyleValues {
227 border: Some(Some(want)),
228 ..StyleValues::default()
229 };
230 overrides
231 .hold::<StateBorderHeld>(values, &live)
232 .write_visuals(&mut vis);
233 }
234 for (entity, mut vis, mut overrides) in idle.iter_mut().chain(orphaned.iter_mut()) {
236 overrides
237 .release::<StateBorderHeld>()
238 .write_visuals(&mut vis);
239 commands.entity(entity).remove::<StateBorderHeld>();
240 }
241}
242
243pub fn extract_focus_outlines(main: &mut World, render: &mut World) {
247 use lumen_core::components::Opacity;
248 use lumen_core::render_world::{RenderEntityMap, hidden_entities, parent_scroll_offsets};
249 let (parents, mut depth_cache) = build_parent_map(main);
250 let scroll = parent_scroll_offsets(main, &parents);
251 let hidden = hidden_entities(main, &parents);
254 type Row<'a> = (
255 Entity,
256 &'a Transform,
257 &'a Interaction,
258 Option<&'a Opacity>,
259 Option<&'a Visuals>,
260 Option<&'a lumen_core::input::FocusVisible>,
261 );
262 let mut q = main.query_filtered::<Row, With<Focused>>();
263 let pairs: Vec<(Entity, ExtractedOutline)> = q
264 .iter(main)
265 .filter(|(e, ..)| !hidden.contains(e))
266 .filter_map(|(e, t, ix, opacity, vis, focus_visible)| {
267 let ring = match (focus_visible, ix.focus_visible_outline) {
271 (Some(_), Some(fv)) => fv,
272 _ => ix.focus_outline?,
273 };
274 let alpha = opacity.copied().unwrap_or_default();
275 let off = scroll.get(&e).copied().unwrap_or(glam::Vec2::ZERO);
276 let half = ring.width * 0.5;
284 let grow = ring.offset + half;
285 let radius = vis.map(|v| v.radius).unwrap_or(0.0);
286 Some((
287 e,
288 ExtractedOutline {
289 origin: t.absolute - off - glam::Vec2::splat(grow),
290 size: t.size + glam::Vec2::splat(grow * 2.0),
291 stroke: alpha.apply(ring.color),
292 width: ring.width,
293 radius: if radius > 0.0 {
294 radius + grow.max(0.0)
295 } else {
296 0.0
297 },
298 order: paint_order_of(e, &parents, &mut depth_cache),
299 },
300 ))
301 })
302 .collect();
303 let prior = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().outline);
305 let mut next: std::collections::HashMap<Entity, Entity> =
306 std::collections::HashMap::with_capacity(pairs.len());
307 for (main_e, outline) in pairs {
308 let reuse = prior
309 .get(&main_e)
310 .copied()
311 .filter(|&re| render.get_entity(re).is_ok());
312 let render_e = match reuse {
313 Some(re) => {
314 render.entity_mut(re).insert(outline);
315 re
316 }
317 None => render.spawn(outline).id(),
318 };
319 next.insert(main_e, render_e);
320 }
321 for (main_e, render_e) in &prior {
322 if !next.contains_key(main_e)
323 && let Ok(em) = render.get_entity_mut(*render_e)
324 {
325 em.despawn();
326 }
327 }
328 render.resource_mut::<RenderEntityMap>().outline = next;
329}
330
331fn bg_tween_params(
338 specs: Option<&crate::transition::TransitionSpecs>,
339 default_duration: f32,
340) -> (f32, crate::transition::Easing) {
341 match specs.and_then(|s| s.for_property(crate::transition::TransitionProperty::BackgroundColor))
342 {
343 Some(spec) => (spec.duration.as_secs_f32().max(f32::EPSILON), spec.easing),
344 None => (default_duration, crate::transition::Easing::EaseOut),
345 }
346}
347
348fn lerp(a: f32, b: f32, t: f32) -> f32 {
349 a + (b - a) * t
350}
351
352fn lerp_color(a: Color, b: Color, t: f32) -> Color {
353 Color {
354 r: lerp(a.r, b.r, t),
355 g: lerp(a.g, b.g, t),
356 b: lerp(a.b, b.b, t),
357 a: lerp(a.a, b.a, t),
358 }
359}
360
361#[allow(clippy::type_complexity)]
375pub fn apply_hover_tint(
376 mut commands: Commands,
377 tick: Res<Tick>,
378 anim: Res<lumen_core::render_world::AnimationsActive>,
379 mut active: Query<
380 (
381 Entity,
382 &Interaction,
383 &mut Visuals,
384 &mut StyleOverrides,
385 Option<&mut HoverTween>,
386 Option<&crate::transition::TransitionSpecs>,
387 ),
388 Or<(With<Hovered>, With<Focused>)>,
389 >,
390 mut idle: Query<
391 (
392 Entity,
393 &mut Visuals,
394 &mut StyleOverrides,
395 &Interaction,
396 &mut HoverTween,
397 Option<&crate::transition::TransitionSpecs>,
398 ),
399 (Without<Hovered>, Without<Focused>),
400 >,
401 mut orphaned: Query<
402 (Entity, &mut Visuals, &mut StyleOverrides),
403 (With<HoverTween>, Without<Interaction>),
404 >,
405) {
406 let now = tick.now;
407 for (entity, mut vis, mut overrides) in &mut orphaned {
410 overrides.release::<HoverTween>().write_visuals(&mut vis);
411 commands.entity(entity).remove::<HoverTween>();
412 }
413 for (entity, ix, mut vis, mut overrides, tween, specs) in &mut active {
414 let Some(tint) = ix.hover_tint else {
415 continue;
416 };
417 let Some(base) = fill_below::<HoverTween>(&overrides, &vis) else {
418 continue;
419 };
420 let (duration, easing) = bg_tween_params(specs, HOVER_TWEEN_DURATION);
423 let progress = match tween {
424 Some(mut t) => {
425 let dt = now.duration_since(t.last_step).as_secs_f32();
426 t.last_step = now;
427 t.target = 1.0;
428 let step = (dt / duration).min(1.0);
429 t.progress = (t.progress + step).min(1.0);
430 t.progress
431 }
432 None => {
433 commands.entity(entity).try_insert(HoverTween {
434 progress: 0.0,
435 target: 1.0,
436 last_step: now,
437 });
438 0.0
439 }
440 };
441 if progress < 1.0 {
443 anim.request();
444 }
445 hold_fill::<HoverTween>(
446 &mut overrides,
447 &mut vis,
448 lerp_color(base, tint, easing.apply(progress)),
449 );
450 }
451 for (entity, mut vis, mut overrides, ix, mut tween, specs) in &mut idle {
452 let (Some(tint), Some(base)) = (ix.hover_tint, fill_below::<HoverTween>(&overrides, &vis))
453 else {
454 overrides.release::<HoverTween>().write_visuals(&mut vis);
455 commands.entity(entity).remove::<HoverTween>();
456 continue;
457 };
458 let (duration, easing) = bg_tween_params(specs, HOVER_TWEEN_DURATION);
459 let dt = now.duration_since(tween.last_step).as_secs_f32();
460 tween.last_step = now;
461 tween.target = 0.0;
462 let step = (dt / duration).min(1.0);
463 tween.progress = (tween.progress - step).max(0.0);
464 if tween.progress <= 0.0 {
465 overrides.release::<HoverTween>().write_visuals(&mut vis);
469 commands.entity(entity).remove::<HoverTween>();
470 continue;
471 }
472 anim.request();
474 hold_fill::<HoverTween>(
475 &mut overrides,
476 &mut vis,
477 lerp_color(base, tint, easing.apply(tween.progress)),
478 );
479 }
480}
481
482pub const PRESS_TWEEN_DURATION: f32 = 0.06;
485
486#[derive(Component, Clone, Copy, Debug)]
488pub struct PressTween {
489 pub progress: f32,
491 pub last_step: Instant,
493}
494
495impl StyleOwner for PressTween {
496 const RANK: u8 = 50;
497}
498
499#[allow(clippy::type_complexity)]
517pub fn apply_press_tint(
518 mut commands: Commands,
519 tick: Res<Tick>,
520 anim: Res<lumen_core::render_world::AnimationsActive>,
521 pointer: Option<Res<PointerState>>,
522 mut tinted: Query<
523 (
524 Entity,
525 &Interaction,
526 &mut Visuals,
527 &mut StyleOverrides,
528 Option<&mut PressTween>,
529 Option<&crate::transition::TransitionSpecs>,
530 Has<Pressed>,
531 Has<Hovered>,
532 ),
533 Or<(With<Pressed>, With<PressTween>)>,
534 >,
535 mut orphaned: Query<
536 (Entity, &mut Visuals, &mut StyleOverrides),
537 (With<PressTween>, Without<Interaction>),
538 >,
539) {
540 let now = tick.now;
541 let primary_down = pointer.map(|p| p.primary_down).unwrap_or(false);
542 for (entity, mut vis, mut overrides) in &mut orphaned {
543 overrides.release::<PressTween>().write_visuals(&mut vis);
544 commands.entity(entity).remove::<PressTween>();
545 }
546 for (entity, ix, mut vis, mut overrides, tween, specs, is_pressed, is_hovered) in &mut tinted {
547 let engaged = is_pressed && (is_hovered || !primary_down);
548 let (duration, easing) = bg_tween_params(specs, PRESS_TWEEN_DURATION);
549 let (Some(tint), Some(base)) = (ix.press_tint, fill_below::<PressTween>(&overrides, &vis))
550 else {
551 if tween.is_some() {
552 overrides.release::<PressTween>().write_visuals(&mut vis);
553 commands.entity(entity).remove::<PressTween>();
554 }
555 continue;
556 };
557 if engaged {
558 let progress = match tween {
559 Some(mut t) => {
560 let dt = now.duration_since(t.last_step).as_secs_f32();
561 t.last_step = now;
562 let step = (dt / duration).min(1.0);
563 t.progress = (t.progress + step).min(1.0);
564 t.progress
565 }
566 None => {
567 commands.entity(entity).try_insert(PressTween {
576 progress: 1.0,
577 last_step: now,
578 });
579 1.0
580 }
581 };
582 if progress < 1.0 {
586 anim.request();
587 }
588 hold_fill::<PressTween>(
589 &mut overrides,
590 &mut vis,
591 lerp_color(base, tint, easing.apply(progress)),
592 );
593 } else {
594 let Some(mut tween) = tween else {
599 continue;
600 };
601 let dt = now.duration_since(tween.last_step).as_secs_f32();
602 tween.last_step = now;
603 let step = (dt / duration).min(1.0);
604 tween.progress = (tween.progress - step).max(0.0);
605 if tween.progress <= 0.0 {
606 overrides.release::<PressTween>().write_visuals(&mut vis);
608 commands.entity(entity).remove::<PressTween>();
609 continue;
610 }
611 anim.request();
614 hold_fill::<PressTween>(
615 &mut overrides,
616 &mut vis,
617 lerp_color(base, tint, easing.apply(tween.progress)),
618 );
619 }
620 }
621}
622
623#[cfg(test)]
624mod press_tint_tests {
625 use super::*;
630 use bevy_ecs::schedule::Schedule;
631 use lumen_core::input::PointerState;
632 use lumen_core::render_world::AnimationsActive;
633
634 const IDLE: Color = Color {
635 r: 0.2,
636 g: 0.2,
637 b: 0.2,
638 a: 1.0,
639 };
640 const TINT: Color = Color {
641 r: 0.9,
642 g: 0.1,
643 b: 0.1,
644 a: 1.0,
645 };
646
647 fn world_with_button(primary_down: bool) -> (World, Entity) {
648 let mut world = World::new();
649 world.insert_resource(Tick::default());
650 world.init_resource::<AnimationsActive>();
651 world.insert_resource(PointerState {
652 position: None,
653 primary_down,
654 });
655 let e = world
656 .spawn((
657 Interaction {
658 hover_tint: None,
659 press_tint: Some(TINT),
660 ..Default::default()
661 },
662 Visuals {
663 fill: Some(Fill::Solid(IDLE)),
664 ..Default::default()
665 },
666 ))
667 .id();
668 (world, e)
669 }
670
671 fn run(world: &mut World) {
672 let mut s = Schedule::default();
673 s.add_systems(apply_press_tint);
674 s.run(world);
675 let mut s2 = Schedule::default();
678 s2.add_systems(apply_press_tint);
679 s2.run(world);
680 }
681
682 fn fill(world: &World, e: Entity) -> Color {
683 world
684 .get::<Visuals>(e)
685 .unwrap()
686 .fill
687 .as_ref()
688 .and_then(Fill::as_solid)
689 .unwrap()
690 }
691
692 #[test]
693 fn pressed_and_hovered_tints_dragged_off_untints_reenter_retints() {
694 let (mut world, e) = world_with_button(true);
695 world.entity_mut(e).insert((Pressed, Hovered));
696 run(&mut world);
697 assert_eq!(fill(&world, e), TINT, "pressed+over shows the press tint");
698
699 world.entity_mut(e).remove::<Hovered>();
703 world.resource_mut::<Tick>().now = Instant::now() + std::time::Duration::from_secs(1);
704 run(&mut world);
705 assert_eq!(
706 fill(&world, e),
707 IDLE,
708 "captured-but-off shows the un-pressed visual"
709 );
710 assert!(world.get::<Pressed>(e).is_some(), "capture retained");
711
712 world.entity_mut(e).insert(Hovered);
714 run(&mut world);
715 assert_eq!(fill(&world, e), TINT, "re-entering re-presses");
716 }
717
718 #[test]
719 fn keyboard_press_tints_without_hover() {
720 let (mut world, e) = world_with_button(false);
721 world.entity_mut(e).insert(Pressed);
722 run(&mut world);
723 assert_eq!(
724 fill(&world, e),
725 TINT,
726 "Space-FSM press (pointer up, no hover) still shows the tint"
727 );
728 }
729}
730
731#[cfg(test)]
732mod state_border_tests {
733 use super::*;
734 use lumen_core::components::{Border, Edges};
735
736 fn border(w: f32) -> Border {
737 Border {
738 widths: Edges::all(w),
739 color: Color::rgb(1.0, 0.0, 0.0),
740 side_colors: None,
741 }
742 }
743
744 fn run(world: &mut bevy_ecs::world::World) {
745 let mut schedule = bevy_ecs::schedule::Schedule::default();
746 schedule.add_systems(apply_state_borders);
747 schedule.run(world);
748 }
749
750 #[test]
754 fn hover_focus_border_swap_and_restore() {
755 let mut world = bevy_ecs::world::World::new();
756 let e = world
757 .spawn((
758 Interaction {
759 hover_border: Some(border(1.0)),
760 focus_border: Some(border(2.0)),
761 ..Default::default()
762 },
763 Visuals::default(),
764 ))
765 .id();
766
767 run(&mut world);
769 assert_eq!(world.get::<Visuals>(e).unwrap().border, None);
770
771 world.entity_mut(e).insert(Hovered);
773 run(&mut world);
774 assert_eq!(
775 world.get::<Visuals>(e).unwrap().border,
776 Some(border(1.0)),
777 "hover border swapped in"
778 );
779 assert!(world.get::<StateBorderHeld>(e).is_some());
780
781 world.entity_mut(e).insert(lumen_core::input::Focused);
783 run(&mut world);
784 assert_eq!(world.get::<Visuals>(e).unwrap().border, Some(border(2.0)));
785
786 world.entity_mut(e).remove::<Hovered>();
788 world.entity_mut(e).remove::<lumen_core::input::Focused>();
789 run(&mut world);
790 assert_eq!(world.get::<Visuals>(e).unwrap().border, None);
791 assert!(world.get::<StateBorderHeld>(e).is_none());
792 }
793}
794
795#[cfg(test)]
796mod focus_outline_tests {
797 use super::*;
801 use lumen_core::input::FocusVisible;
802 use lumen_core::render_world::RenderEntityMap;
803
804 fn spec(width: f32, offset: f32, r: f32) -> FocusOutlineSpec {
805 FocusOutlineSpec {
806 width,
807 color: Color::rgb(r, 0.0, 0.0),
808 offset,
809 }
810 }
811
812 fn outlines(render: &mut bevy_ecs::world::World) -> Vec<ExtractedOutline> {
813 let mut q = render.query::<&ExtractedOutline>();
814 q.iter(render).copied().collect()
815 }
816
817 #[test]
818 fn outline_offset_grows_ring_and_focus_visible_ring_wins_on_keyboard_focus() {
819 let mut main = bevy_ecs::world::World::new();
820 let mut render = bevy_ecs::world::World::new();
821 render.insert_resource(RenderEntityMap::default());
822
823 let e = main
824 .spawn((
825 Transform::new(glam::Vec2::new(10.0, 10.0), glam::Vec2::new(100.0, 40.0)),
826 Interaction {
827 focus_outline: Some(spec(2.0, 0.0, 0.25)),
828 focus_visible_outline: Some(spec(4.0, 3.0, 0.75)),
829 ..Default::default()
830 },
831 Focused,
832 ))
833 .id();
834
835 extract_focus_outlines(&mut main, &mut render);
837 let rings = outlines(&mut render);
838 assert_eq!(rings.len(), 1);
839 assert_eq!(rings[0].width, 2.0, "pointer focus paints the :focus ring");
840 assert_eq!(rings[0].origin, glam::Vec2::new(9.0, 9.0));
842 assert_eq!(rings[0].size, glam::Vec2::new(102.0, 42.0));
843
844 main.entity_mut(e).insert(FocusVisible);
847 extract_focus_outlines(&mut main, &mut render);
848 let rings = outlines(&mut render);
849 assert_eq!(rings.len(), 1);
850 assert_eq!(rings[0].width, 4.0, "keyboard focus paints :focus-visible");
851 assert_eq!(rings[0].origin, glam::Vec2::new(5.0, 5.0));
852 assert_eq!(rings[0].size, glam::Vec2::new(110.0, 50.0));
853 }
854}