1use bevy_ecs::prelude::*;
27use lumen_core::components::{
28 Color, Disabled, DocumentOrder, DropHovered, Fill, Opacity, ShadowSpec, TextStyle, Visuals,
29};
30use lumen_core::input::FocusVisible;
31use lumen_core::prelude::*;
32use lumen_core::style_overrides::{Live, StyleOverrides, StyleOwner, StyleValues};
33
34#[derive(Clone, Debug, Default, PartialEq)]
37pub struct StatePatch {
38 pub text_color: Option<Color>,
40 pub opacity: Option<f32>,
42 pub shadows: Option<Vec<ShadowSpec>>,
44 pub bg: Option<Color>,
48}
49
50impl StatePatch {
51 pub fn is_empty(&self) -> bool {
53 self.text_color.is_none()
54 && self.opacity.is_none()
55 && self.shadows.is_none()
56 && self.bg.is_none()
57 }
58}
59
60#[derive(Component, Clone, Debug, Default, PartialEq)]
64#[require(StyleOverrides)]
65pub struct StateVisuals {
66 pub hover: StatePatch,
68 pub focus: StatePatch,
70 pub focus_visible: StatePatch,
74 pub active: StatePatch,
76 pub drag_over: StatePatch,
80 pub disabled: StatePatch,
85}
86
87impl StateVisuals {
88 pub fn is_empty(&self) -> bool {
90 self.hover.is_empty()
91 && self.focus.is_empty()
92 && self.focus_visible.is_empty()
93 && self.active.is_empty()
94 && self.drag_over.is_empty()
95 && self.disabled.is_empty()
96 }
97}
98
99#[derive(Component, Clone, Copy, Debug, Default)]
102pub struct StatePatched;
103
104impl StyleOwner for StatePatched {
105 const RANK: u8 = 30;
106}
107
108pub struct StateStylePlugin;
111
112impl Plugin for StateStylePlugin {
113 fn build(self, app: &mut App) {
114 app.add_systems(
118 TickStage::Systems,
119 eject_interaction_on_disable.before(apply_state_visuals),
120 );
121 app.add_systems(
126 TickStage::Systems,
127 apply_state_visuals.after(lumen_input::dispatch_clicks),
128 );
129 }
130}
131
132#[allow(clippy::type_complexity)]
148pub fn apply_state_visuals(
149 mut commands: Commands,
150 pointer: Option<Res<PointerState>>,
151 mut active: Query<
152 (
153 Entity,
154 &StateVisuals,
155 Option<&mut TextStyle>,
156 Option<&mut Visuals>,
157 Option<&Opacity>,
158 &mut StyleOverrides,
159 Has<StatePatched>,
160 Option<&Hovered>,
161 Option<&Focused>,
162 Option<&lumen_core::input::FocusVisible>,
163 Option<&Pressed>,
164 Has<Disabled>,
165 Has<DropHovered>,
166 ),
167 Or<(
168 With<Hovered>,
169 With<Focused>,
170 With<Pressed>,
171 With<Disabled>,
172 With<DropHovered>,
173 )>,
174 >,
175 mut idle: Query<
176 (
177 Entity,
178 Option<&mut TextStyle>,
179 Option<&mut Visuals>,
180 Option<&Opacity>,
181 &mut StyleOverrides,
182 ),
183 (
184 With<StatePatched>,
185 With<StateVisuals>,
186 Without<Hovered>,
187 Without<Focused>,
188 Without<Pressed>,
189 Without<Disabled>,
190 Without<DropHovered>,
191 ),
192 >,
193 mut orphaned: Query<
194 (
195 Entity,
196 Option<&mut TextStyle>,
197 Option<&mut Visuals>,
198 Option<&Opacity>,
199 &mut StyleOverrides,
200 ),
201 (With<StatePatched>, Without<StateVisuals>),
202 >,
203) {
204 let primary_down = pointer.map(|p| p.primary_down).unwrap_or(false);
205 for (
206 entity,
207 sv,
208 text,
209 vis,
210 opacity,
211 mut overrides,
212 patched,
213 hovered,
214 focused,
215 focus_visible,
216 pressed,
217 disabled,
218 drag_over,
219 ) in &mut active
220 {
221 let active_on = pressed.is_some() && (hovered.is_some() || !primary_down);
226 let mut patch = StatePatch::default();
229 for (marker_on, p) in [
230 (hovered.is_some(), &sv.hover),
231 (focused.is_some(), &sv.focus),
232 (
233 focused.is_some() && focus_visible.is_some(),
234 &sv.focus_visible,
235 ),
236 (active_on, &sv.active),
237 (drag_over, &sv.drag_over),
238 (disabled, &sv.disabled),
239 ] {
240 if !marker_on {
241 continue;
242 }
243 if p.text_color.is_some() {
244 patch.text_color = p.text_color;
245 }
246 if p.opacity.is_some() {
247 patch.opacity = p.opacity;
248 }
249 if p.shadows.is_some() {
250 patch.shadows = p.shadows.clone();
251 }
252 if p.bg.is_some() {
253 patch.bg = p.bg;
254 }
255 }
256 let values = StyleValues {
257 text_color: patch.text_color.filter(|_| text.is_some()),
258 opacity: patch.opacity.map(Some),
259 shadows: patch.shadows.filter(|_| vis.is_some()),
260 fill: patch
261 .bg
262 .filter(|_| vis.is_some())
263 .map(|c| Some(Fill::Solid(c))),
264 ..StyleValues::default()
265 };
266 let out = if values.fields().is_empty() {
267 if !patched {
271 continue;
272 }
273 commands.entity(entity).remove::<StatePatched>();
274 overrides.release::<StatePatched>()
275 } else {
276 if overrides.held_by::<StatePatched>() == Some(&values) {
277 continue;
278 }
279 if !patched {
280 commands.entity(entity).try_insert(StatePatched);
281 }
282 let live = Live {
283 style: None,
284 visuals: vis.as_deref(),
285 text: text.as_deref(),
286 opacity,
287 };
288 overrides.hold::<StatePatched>(values, &live)
289 };
290 write_out(&mut commands, entity, &out, text, vis, opacity);
291 }
292 for (entity, text, vis, opacity, mut overrides) in idle.iter_mut().chain(orphaned.iter_mut()) {
294 let out = overrides.release::<StatePatched>();
295 write_out(&mut commands, entity, &out, text, vis, opacity);
296 commands.entity(entity).remove::<StatePatched>();
297 }
298}
299
300fn write_out(
302 commands: &mut Commands,
303 entity: Entity,
304 out: &StyleValues,
305 text: Option<Mut<TextStyle>>,
306 vis: Option<Mut<Visuals>>,
307 opacity: Option<&Opacity>,
308) {
309 if let Some(mut t) = text {
310 out.write_text(&mut t);
311 }
312 if let Some(mut v) = vis {
313 out.write_visuals(&mut v);
314 }
315 match out.opacity_change(opacity) {
316 Some(Some(o)) => {
317 commands.entity(entity).try_insert(o);
318 }
319 Some(None) => {
320 commands.entity(entity).remove::<Opacity>();
321 }
322 None => {}
323 }
324}
325
326#[allow(clippy::type_complexity, clippy::too_many_arguments)]
341pub fn eject_interaction_on_disable(
342 mut commands: Commands,
343 newly_disabled: Query<Entity, Added<Disabled>>,
344 mut tracker: ResMut<FocusTracker>,
345 mut tinted: Query<(&mut Visuals, &mut StyleOverrides)>,
346 focus_visibles: Query<(), With<FocusVisible>>,
347 focusables: Query<(Entity, &TabIndex, Option<&DocumentOrder>), Without<Disabled>>,
348 tab_info: Query<(&TabIndex, Option<&DocumentOrder>)>,
349 parents: Query<&ChildOf>,
350 visibles: Query<&lumen_core::components::Visible>,
351 styles: Query<&lumen_core::components::Style>,
352) {
353 for entity in &newly_disabled {
354 if let Ok((mut vis, mut overrides)) = tinted.get_mut(entity) {
355 overrides
356 .release::<crate::hover::HoverTween>()
357 .write_visuals(&mut vis);
358 overrides
359 .release::<crate::hover::PressTween>()
360 .write_visuals(&mut vis);
361 }
362 commands.entity(entity).remove::<(
363 Hovered,
364 Pressed,
365 Focused,
366 FocusVisible,
367 crate::hover::HoverTween,
368 crate::hover::PressTween,
369 )>();
370
371 if tracker.0 != Some(entity) {
372 continue;
373 }
374 let mut sorted: Vec<(i32, u32, Entity)> = focusables
377 .iter()
378 .filter(|(e, t, _)| t.0 >= 0 && !hidden_via_ancestors(*e, &parents, &visibles, &styles))
379 .map(|(e, t, doc)| (t.0, doc.map(|d| d.0).unwrap_or(u32::MAX), e))
380 .collect();
381 sorted.sort();
382 let next = if sorted.is_empty() {
383 None
384 } else {
385 let own_key = tab_info
386 .get(entity)
387 .map(|(t, doc)| (t.0, doc.map(|d| d.0).unwrap_or(u32::MAX), entity))
388 .unwrap_or((i32::MIN, 0, entity));
389 sorted
390 .iter()
391 .find(|k| **k > own_key)
392 .or_else(|| sorted.first())
393 .copied()
394 };
395 let had_visible = focus_visibles.contains(entity);
396 match next {
397 Some((_, _, next_e)) => {
398 if had_visible {
399 commands.entity(next_e).insert((Focused, FocusVisible));
400 } else {
401 commands.entity(next_e).insert(Focused);
402 }
403 tracker.0 = Some(next_e);
404 }
405 None => {
406 tracker.0 = None;
407 }
408 }
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 fn shadow(blur: f32) -> ShadowSpec {
417 ShadowSpec {
418 offset_x: 0.0,
419 offset_y: 1.0,
420 blur,
421 spread: 0.0,
422 color: Color::rgb(0.0, 0.0, 0.0),
423 inner: false,
424 }
425 }
426
427 fn run(world: &mut World) {
428 let mut schedule = bevy_ecs::schedule::Schedule::default();
429 schedule.add_systems(apply_state_visuals);
430 schedule.run(world);
431 let mut schedule2 = bevy_ecs::schedule::Schedule::default();
434 schedule2.add_systems(apply_state_visuals);
435 schedule2.run(world);
436 }
437
438 #[test]
441 fn state_text_color_opacity_shadow_swap_and_restore() {
442 let mut world = World::new();
443 let red = Color::rgb(1.0, 0.0, 0.0);
444 let green = Color::rgb(0.0, 1.0, 0.0);
445 let idle = Color::rgb(0.5, 0.5, 0.5);
446 let e = world
447 .spawn((
448 StateVisuals {
449 hover: StatePatch {
450 text_color: Some(red),
451 opacity: Some(0.8),
452 shadows: Some(vec![shadow(4.0)]),
453 ..Default::default()
454 },
455 active: StatePatch {
456 text_color: Some(green),
457 ..Default::default()
458 },
459 ..Default::default()
460 },
461 TextStyle {
462 color: idle,
463 ..Default::default()
464 },
465 Visuals::default(),
466 ))
467 .id();
468
469 world.entity_mut(e).insert(Hovered);
471 run(&mut world);
472 assert_eq!(world.get::<TextStyle>(e).unwrap().color, red);
473 assert_eq!(world.get::<Opacity>(e).map(|o| o.0), Some(0.8));
474 assert_eq!(world.get::<Visuals>(e).unwrap().shadows, vec![shadow(4.0)]);
475
476 world.entity_mut(e).insert(Pressed);
478 run(&mut world);
479 assert_eq!(world.get::<TextStyle>(e).unwrap().color, green);
480 assert_eq!(world.get::<Opacity>(e).map(|o| o.0), Some(0.8));
481
482 world.entity_mut(e).remove::<Hovered>();
484 world.entity_mut(e).remove::<Pressed>();
485 run(&mut world);
486 assert_eq!(world.get::<TextStyle>(e).unwrap().color, idle);
487 assert!(world.get::<Opacity>(e).is_none(), "idle Opacity was absent");
488 assert!(world.get::<Visuals>(e).unwrap().shadows.is_empty());
489 assert!(world.get::<StatePatched>(e).is_none());
490 }
491
492 #[test]
500 fn focus_ring_box_shadow_swaps_on_focus_and_restores_on_blur() {
501 let mut world = World::new();
502 let ring = ShadowSpec {
503 offset_x: 0.0,
504 offset_y: 0.0,
505 blur: 0.0,
506 spread: 2.0,
507 color: Color::rgba(0.2, 0.5, 1.0, 0.9),
510 inner: false,
511 };
512 let e = world
513 .spawn((
514 StateVisuals {
515 focus: StatePatch {
516 shadows: Some(vec![ring]),
517 ..Default::default()
518 },
519 ..Default::default()
520 },
521 TextInput::default(),
522 Visuals::default(),
523 ))
524 .id();
525
526 world.entity_mut(e).insert(Focused);
528 run(&mut world);
529 assert_eq!(
530 world.get::<Visuals>(e).unwrap().shadows,
531 vec![ring],
532 ":focus ring must swap in on Focused"
533 );
534
535 world.entity_mut(e).remove::<Focused>();
537 run(&mut world);
538 assert!(
539 world.get::<Visuals>(e).unwrap().shadows.is_empty(),
540 "focus ring must clear on blur"
541 );
542 }
543
544 #[test]
548 fn disabled_patch_swaps_on_marker_add_and_restores_on_remove() {
549 let mut world = World::new();
550 let idle_bg = Color::rgb(0.1, 0.4, 0.9);
551 let disabled_bg = Color::rgb(0.3, 0.3, 0.3);
552 let e = world
553 .spawn((
554 StateVisuals {
555 disabled: StatePatch {
556 opacity: Some(0.5),
557 bg: Some(disabled_bg),
558 ..Default::default()
559 },
560 ..Default::default()
561 },
562 Visuals {
563 fill: Some(Fill::Solid(idle_bg)),
564 ..Default::default()
565 },
566 ))
567 .id();
568
569 world.entity_mut(e).insert(Disabled);
571 run(&mut world);
572 assert_eq!(
573 world.get::<Visuals>(e).unwrap().fill,
574 Some(Fill::Solid(disabled_bg)),
575 ":disabled bg applied on marker add"
576 );
577 assert_eq!(world.get::<Opacity>(e).map(|o| o.0), Some(0.5));
578
579 world.entity_mut(e).remove::<Disabled>();
581 run(&mut world);
582 assert_eq!(
583 world.get::<Visuals>(e).unwrap().fill,
584 Some(Fill::Solid(idle_bg)),
585 "idle bg restored on marker remove"
586 );
587 assert!(world.get::<Opacity>(e).is_none());
588 assert!(world.get::<StatePatched>(e).is_none());
589 }
590
591 #[test]
594 fn disabled_patch_wins_over_hover() {
595 let mut world = World::new();
596 let red = Color::rgb(1.0, 0.0, 0.0);
597 let grey = Color::rgb(0.4, 0.4, 0.4);
598 let e = world
599 .spawn((
600 StateVisuals {
601 hover: StatePatch {
602 text_color: Some(red),
603 ..Default::default()
604 },
605 disabled: StatePatch {
606 text_color: Some(grey),
607 ..Default::default()
608 },
609 ..Default::default()
610 },
611 TextStyle::default(),
612 ))
613 .id();
614 world.entity_mut(e).insert((Hovered, Disabled));
615 run(&mut world);
616 assert_eq!(world.get::<TextStyle>(e).unwrap().color, grey);
617 }
618}
619
620#[cfg(test)]
621mod eject_tests {
622 use super::*;
626
627 fn run(world: &mut World) {
628 let mut schedule = bevy_ecs::schedule::Schedule::default();
629 schedule.add_systems(eject_interaction_on_disable);
630 schedule.run(world);
631 }
632
633 #[test]
634 fn disable_strips_hover_press_and_moves_focus_forward() {
635 let mut world = World::new();
636 let a = world.spawn((TabIndex(0), DocumentOrder(0))).id();
637 let b = world.spawn((TabIndex(0), DocumentOrder(1))).id();
638 let c = world.spawn((TabIndex(0), DocumentOrder(2))).id();
639 world.insert_resource(FocusTracker(Some(b)));
640 world
641 .entity_mut(b)
642 .insert((Hovered, Pressed, Focused, FocusVisible, Disabled));
643
644 run(&mut world);
645 assert!(world.get::<Hovered>(b).is_none(), "hover stripped");
646 assert!(world.get::<Pressed>(b).is_none(), "press stripped");
647 assert!(world.get::<Focused>(b).is_none(), "focus stripped");
648 assert_eq!(
649 world.resource::<FocusTracker>().0,
650 Some(c),
651 "focus moved to the next focusable in document order"
652 );
653 assert!(world.get::<Focused>(c).is_some());
654 assert!(
655 world.get::<FocusVisible>(c).is_some(),
656 "keyboard-visible focus travels with the ejection"
657 );
658 let _ = a;
659 }
660
661 #[test]
662 fn disable_of_last_focusable_wraps_to_first() {
663 let mut world = World::new();
664 let a = world.spawn((TabIndex(0), DocumentOrder(0))).id();
665 let b = world.spawn((TabIndex(0), DocumentOrder(1))).id();
666 world.insert_resource(FocusTracker(Some(b)));
667 world.entity_mut(b).insert((Focused, Disabled));
668 run(&mut world);
669 assert_eq!(world.resource::<FocusTracker>().0, Some(a), "wraps");
670 }
671
672 #[test]
673 fn disable_of_only_focusable_clears_focus() {
674 let mut world = World::new();
675 let a = world.spawn((TabIndex(0), DocumentOrder(0))).id();
676 world.insert_resource(FocusTracker(Some(a)));
677 world.entity_mut(a).insert((Focused, Disabled));
678 run(&mut world);
679 assert_eq!(world.resource::<FocusTracker>().0, None);
680 }
681
682 #[test]
683 fn disable_without_focus_leaves_tracker_alone() {
684 let mut world = World::new();
685 let a = world.spawn((TabIndex(0), DocumentOrder(0))).id();
686 let b = world.spawn((TabIndex(0), DocumentOrder(1), Hovered)).id();
687 world.insert_resource(FocusTracker(Some(a)));
688 world.entity_mut(b).insert(Disabled);
689 run(&mut world);
690 assert_eq!(world.resource::<FocusTracker>().0, Some(a));
691 assert!(world.get::<Hovered>(b).is_none());
692 }
693}