1use bevy_ecs::prelude::*;
13use bevy_ecs::world::EntityWorldMut;
14use lumen_core::components::{
15 CaretWidth, Color, Fill, ImageFit, Length, Opacity, PasswordCharacter, ShadowSpec, Style,
16 TextInputPaint, TextStyle, Visuals, ZIndex,
17};
18use lumen_core::input::ScrollbarStyle;
19use lumen_core::style_overrides::{StyleOverrides, StyleValues};
20use lumen_ir::layout_ir::{Attributes, WidgetPart};
21use lumen_primitives::{
22 BackgroundTransition, BorderColorTransition, Interaction, OpacityTransition, StatePatch,
23 StateVisuals, TextColorTransition, TransitionProperty, TransitionSpec, TransitionSpecs,
24 retarget,
25};
26
27use crate::spawn::{DISABLED_OPACITY_DEFAULT, apply_ua_style_defaults, track_style_over};
28
29#[derive(Clone, Copy, Debug, Default)]
33pub struct StyleContext<'a> {
34 pub tag: &'a str,
36 pub static_disabled: bool,
39 pub bind_disabled: bool,
42 pub part: Option<WidgetPart>,
44 pub tab_strip: bool,
46 pub dropdown_button: bool,
48 pub radio: bool,
50 pub page_gate: bool,
52 pub virtualized_for: bool,
54 pub in_scroll: bool,
56 pub layout_boundary: bool,
58}
59
60impl<'a> StyleContext<'a> {
61 pub fn of_attrs(tag: &'a str, attrs: &Attributes, in_scroll: bool) -> Self {
63 Self {
64 tag,
65 static_disabled: attrs.disabled && attrs.bind_disabled.is_none(),
66 bind_disabled: attrs.bind_disabled.is_some(),
67 part: attrs.part,
68 tab_strip: attrs.tab_strip.is_some(),
69 dropdown_button: attrs.dropdown_button.is_some(),
70 radio: tag == "radio" && attrs.radio_group.is_some() && attrs.radio_value.is_some(),
71 page_gate: tag == "if"
72 && attrs.if_signal.as_deref() == Some(lumen_core::nav::PATH_SIGNAL),
73 virtualized_for: tag == "for" && attrs.virtualized,
74 in_scroll,
75 layout_boundary: attrs.layout_boundary,
76 }
77 }
78}
79
80#[derive(Clone, Debug)]
83pub struct ComputedStyle {
84 pub style: Style,
86 pub relayout_boundary: bool,
88 pub visuals: Option<Visuals>,
90 pub text: Option<TextStyle>,
92 pub opacity: Option<f32>,
94 pub z_index: Option<i32>,
96 pub interaction: Option<Interaction>,
98 pub state_visuals: Option<StateVisuals>,
100 pub transitions: Vec<TransitionSpec>,
102 pub scrollbar: Option<ScrollbarStyle>,
104 pub image_fit: Option<ImageFit>,
106 pub input_paint: Option<TextInputPaint>,
108 pub caret_width: Option<CaretWidth>,
110 pub password_character: Option<PasswordCharacter>,
112 pub caret_blink_ms: Option<u32>,
114 pub track: Option<lumen_primitives::TrackStyle>,
116 pub checkbox: Option<lumen_primitives::CheckboxStyle>,
118 pub radio: Option<lumen_primitives::RadioStyle>,
120 pub tab_button: Option<lumen_primitives::TabButtonStyle>,
122 pub progress_chunk: Option<lumen_primitives::progress::ProgressChunk>,
124 pub popup_gap: Option<lumen_primitives::popup::PopupGap>,
126 pub knob_geometry: Option<lumen_primitives::controls::KnobGeometry>,
128 pub knob_fill: Color,
130}
131
132impl ComputedStyle {
133 pub fn new(ctx: &StyleContext<'_>, attrs: &Attributes) -> Self {
136 let tag = ctx.tag;
137 let mut style = Style::from(attrs);
138 apply_ua_style_defaults(tag, attrs, ctx.page_gate, &mut style);
139 if attrs.shrink.is_none() && ctx.in_scroll {
148 style.shrink = 0.0;
149 }
150 if ctx.virtualized_for {
155 style.width = Length::Percent(100.0);
156 }
157 let relayout_boundary = is_relayout_boundary(&style, ctx.layout_boundary, attrs);
158
159 let track = matches!(tag, "toggle" | "switch")
160 .then(|| track_style_over(lumen_primitives::TrackStyle::default(), attrs));
161 let tab_button = ctx.tab_strip.then(|| {
162 let defaults = lumen_primitives::TabButtonStyle::default();
163 lumen_primitives::TabButtonStyle {
164 selected_bg: attrs
165 .selected_bg
166 .map(Into::into)
167 .unwrap_or(defaults.selected_bg),
168 unselected_bg: attrs
169 .bg
170 .as_ref()
171 .and_then(|b| Fill::try_from(b).ok()?.as_solid())
172 .unwrap_or(defaults.unselected_bg),
173 }
174 });
175
176 let mut visuals = Option::<Visuals>::from(attrs).or_else(|| {
179 let fallback = |fill: Color, radius: f32| Visuals {
180 fill: Some(Fill::Solid(fill)),
181 radius,
182 corner_radii: None,
183 shadows: Vec::new(),
184 border: None,
185 };
186 use lumen_primitives::controls::{TOGGLE_CHECKED_BG, TOGGLE_UNCHECKED_BG};
187 match (tag, ctx.part) {
188 ("toggle", _) => Some(fallback(track?.unchecked_bg, 12.0)),
189 ("switch", _) => Some(fallback(track?.unchecked_bg, 14.0)),
190 ("slider", _) => Some(fallback(TOGGLE_UNCHECKED_BG, 4.0)),
191 ("progress", _) => Some(fallback(TOGGLE_UNCHECKED_BG, 3.0)),
192 (_, Some(WidgetPart::CheckboxBox)) => Some(fallback(TOGGLE_UNCHECKED_BG, 4.0)),
193 (_, Some(WidgetPart::RadioDot)) => Some(fallback(TOGGLE_UNCHECKED_BG, 9.0)),
194 (_, Some(WidgetPart::ProgressFill)) => Some(fallback(TOGGLE_CHECKED_BG, 3.0)),
195 ("input" | "textarea", _) => Some(fallback(Color::rgba(0.5, 0.5, 0.5, 0.18), 4.0)),
199 _ if ctx.tab_strip => Some(fallback(tab_button?.unselected_bg, 0.0)),
200 _ => None,
201 }
202 });
203 let mut text = Option::<TextStyle>::from(attrs);
204 let mut opacity = attrs.opacity;
205
206 let disabled_opacity_default = attrs
209 .disabled_opacity_default
210 .unwrap_or(DISABLED_OPACITY_DEFAULT);
211 let state_visuals = state_visuals(ctx, attrs, disabled_opacity_default);
212 if let Some(sv) = &state_visuals {
213 let patches = [
217 &sv.hover,
218 &sv.focus,
219 &sv.focus_visible,
220 &sv.active,
221 &sv.drag_over,
222 &sv.disabled,
223 ];
224 if patches.iter().any(|p| p.text_color.is_some()) && text.is_none() {
225 text = Some(TextStyle::default());
226 }
227 let needs_visuals = patches.iter().any(|p| p.shadows.is_some())
228 || sv.drag_over.bg.is_some()
229 || sv.disabled.bg.is_some();
230 if needs_visuals && visuals.is_none() {
231 visuals = Some(Visuals::default());
232 }
233 }
234
235 if ctx.static_disabled {
238 if let Some(c) = attrs.disabled_text_color {
239 text.get_or_insert_with(TextStyle::default).color = c.into();
240 }
241 if let Some(o) = attrs.disabled_opacity {
242 opacity = Some(o);
243 }
244 if let Some(shadows) = &attrs.disabled_shadows {
245 visuals.get_or_insert_with(Visuals::default).shadows =
246 shadows.iter().copied().map(Into::into).collect();
247 }
248 if let Some(c) = attrs.disabled_bg {
249 let v = visuals.get_or_insert_with(|| Visuals {
250 radius: attrs.radius.unwrap_or(0.0),
251 corner_radii: attrs.radius_corners,
252 ..Visuals::default()
253 });
254 v.fill = Some(Fill::Solid(c.into()));
255 } else if attrs.opacity.is_none() && attrs.disabled_opacity.is_none() {
256 opacity = Some(disabled_opacity_default);
260 }
261 }
262
263 let is_input = matches!(tag, "input" | "textarea");
264 let knob = matches!(tag, "toggle" | "switch" | "slider");
265 Self {
266 style,
267 relayout_boundary,
268 visuals,
269 text,
270 opacity,
271 z_index: attrs.z_index,
272 interaction: Option::<Interaction>::from(attrs),
273 state_visuals,
274 transitions: attrs
275 .effective_transitions()
276 .iter()
277 .map(Into::into)
278 .collect(),
279 scrollbar: scrollbar_style(attrs),
280 image_fit: attrs.image_fit.map(ImageFit::from),
281 input_paint: (is_input
286 && (attrs.caret_color.is_some() || attrs.selection_text_color.is_some()))
287 .then(|| TextInputPaint {
288 caret_color: attrs.caret_color.map(Into::into),
289 selection_foreground: attrs.selection_text_color.map(Into::into),
290 }),
291 caret_width: is_input.then(|| attrs.caret_width.map(CaretWidth).unwrap_or_default()),
294 password_character: is_input.then(|| {
295 attrs
296 .password_character
297 .map(PasswordCharacter)
298 .unwrap_or_default()
299 }),
300 caret_blink_ms: is_input.then_some(attrs.caret_blink_ms).flatten(),
301 track,
302 checkbox: (tag == "checkbox").then(|| lumen_primitives::CheckboxStyle {
303 checked_bg: attrs
306 .checked_bg
307 .map(Into::into)
308 .unwrap_or(lumen_primitives::CheckboxStyle::default().checked_bg),
309 }),
310 radio: ctx.radio.then(|| lumen_primitives::RadioStyle {
311 selected_bg: attrs
313 .selected_bg
314 .map(Into::into)
315 .unwrap_or(lumen_primitives::RadioStyle::default().selected_bg),
316 }),
317 tab_button,
318 progress_chunk: (tag == "progress").then(|| {
319 attrs
320 .progress_chunk
321 .map(lumen_primitives::progress::ProgressChunk)
322 .unwrap_or_default()
323 }),
324 popup_gap: ctx.dropdown_button.then(|| {
325 attrs
326 .popup_gap
327 .map(lumen_primitives::popup::PopupGap)
328 .unwrap_or_default()
329 }),
330 knob_geometry: knob.then(|| lumen_primitives::controls::KnobGeometry {
331 inset: attrs
332 .knob_inset
333 .unwrap_or(lumen_primitives::controls::KNOB_INSET),
334 thumb_size: attrs
335 .thumb_size
336 .unwrap_or(lumen_primitives::controls::THUMB_SIZE),
337 }),
338 knob_fill: attrs
341 .knob_color
342 .map(Into::into)
343 .unwrap_or(lumen_primitives::controls::KNOB_FILL),
344 }
345 }
346
347 fn holdable(&self) -> StyleValues {
350 let v = self.visuals.as_ref();
351 StyleValues {
352 display: Some(self.style.display),
353 position: Some(self.style.position),
354 inset: Some(self.style.inset),
355 width: Some(self.style.width),
356 height: Some(self.style.height),
357 min_height: Some(self.style.min_height),
358 fill: Some(v.and_then(|v| v.fill.clone())),
359 shadows: Some(v.map(|v| v.shadows.clone()).unwrap_or_default()),
360 border: Some(v.and_then(|v| v.border)),
361 opacity: Some(self.opacity),
362 text_color: self.text.as_ref().map(|t| t.color),
363 }
364 }
365
366 fn lay_over(&mut self, held: &StyleValues) {
369 held.write_style(&mut &mut self.style);
370 if held.fill.is_some() || held.shadows.is_some() || held.border.is_some() {
371 held.write_visuals(&mut self.visuals.get_or_insert_with(Visuals::default));
372 }
373 if held.text_color.is_some()
374 && let Some(text) = self.text.as_mut()
375 {
376 held.write_text(&mut &mut *text);
377 }
378 if let Some(o) = held.opacity {
379 self.opacity = o;
380 }
381 }
382
383 pub fn insert(self, ent: &mut EntityWorldMut<'_>) {
385 if self.relayout_boundary {
386 ent.insert(lumen_core::components::RelayoutBoundary);
387 }
388 ent.insert(self.style);
389 if let Some(v) = self.visuals {
390 ent.insert(v);
391 }
392 if let Some(t) = self.text {
393 ent.insert(t);
394 }
395 if let Some(o) = self.opacity {
396 ent.insert(Opacity(o));
397 }
398 if let Some(z) = self.z_index {
399 ent.insert(ZIndex(z));
400 }
401 if let Some(ix) = self.interaction {
402 ent.insert(ix);
403 }
404 if let Some(sv) = self.state_visuals {
405 ent.insert(sv);
406 }
407 if !self.transitions.is_empty() {
408 ent.insert(TransitionSpecs(self.transitions));
409 }
410 insert_some(ent, self.scrollbar);
411 insert_some(ent, self.image_fit);
412 insert_some(ent, self.input_paint);
413 insert_some(ent, self.caret_width);
414 insert_some(ent, self.password_character);
415 insert_some(ent, self.track);
416 insert_some(ent, self.checkbox);
417 insert_some(ent, self.radio);
418 insert_some(ent, self.tab_button);
419 insert_some(ent, self.progress_chunk);
420 insert_some(ent, self.popup_gap);
421 insert_some(ent, self.knob_geometry);
422 if let Some(ms) = self.caret_blink_ms {
423 set_caret_blink(ent, ms);
424 }
425 }
426
427 pub fn commit(mut self, ent: &mut EntityWorldMut<'_>, animate: bool) {
438 let held = match ent.get_mut::<StyleOverrides>() {
439 Some(mut ov) if !ov.is_empty() => {
440 let fresh = self.holdable();
441 Some(ov.rebase(&fresh))
442 }
443 _ => None,
444 };
445 let held_fields = held.as_ref().map(StyleValues::fields).unwrap_or_default();
446 if let Some(held) = &held {
447 self.lay_over(held);
448 }
449
450 let specs = TransitionSpecs(std::mem::take(&mut self.transitions));
453 let spec_for = |p: TransitionProperty| {
454 if !animate {
455 return None;
456 }
457 specs
458 .for_property(p)
459 .filter(|s| !s.duration.is_zero())
460 .copied()
461 };
462 let mut tweened = false;
463 use lumen_core::style_overrides::StyleFields;
464
465 let live_visuals = ent.get::<Visuals>().cloned();
472 if !held_fields.contains(StyleFields::FILL) {
473 let cur = live_visuals
474 .as_ref()
475 .and_then(|v| v.fill.as_ref())
476 .and_then(Fill::as_solid);
477 let new = self
478 .visuals
479 .as_ref()
480 .and_then(|v| v.fill.as_ref())
481 .and_then(Fill::as_solid);
482 let running = ent.get::<BackgroundTransition>().map(|t| t.0.to);
483 match (cur, new) {
484 (Some(cur), Some(new)) if running == Some(new) => {
485 set_solid_fill(&mut self.visuals, cur);
486 }
487 (Some(cur), Some(new)) => {
488 match spec_for(TransitionProperty::BackgroundColor)
489 .and_then(|spec| retarget(cur, new, &spec))
490 {
491 Some(t) => {
492 ent.insert(BackgroundTransition(t));
493 set_solid_fill(&mut self.visuals, cur);
494 tweened = true;
495 }
496 None if running.is_some() => {
497 ent.remove::<BackgroundTransition>();
498 }
499 None => {}
500 }
501 }
502 _ if running.is_some() => {
503 ent.remove::<BackgroundTransition>();
504 }
505 _ => {}
506 }
507 }
508 if !held_fields.contains(StyleFields::BORDER) {
509 let cur = live_visuals.as_ref().and_then(|v| v.border);
510 let new = self.visuals.as_ref().and_then(|v| v.border);
511 let running = ent.get::<BorderColorTransition>().map(|t| t.0.to);
512 match (cur, new) {
513 (Some(cur), Some(new))
514 if cur.widths == new.widths && running == Some(new.color) =>
515 {
516 set_border_color(&mut self.visuals, cur.color);
517 }
518 (Some(cur), Some(new)) if cur.widths == new.widths => {
519 match spec_for(TransitionProperty::BorderColor)
520 .and_then(|spec| retarget(cur.color, new.color, &spec))
521 {
522 Some(t) => {
523 ent.insert(BorderColorTransition(t));
524 set_border_color(&mut self.visuals, cur.color);
525 tweened = true;
526 }
527 None if running.is_some() => {
528 ent.remove::<BorderColorTransition>();
529 }
530 None => {}
531 }
532 }
533 _ if running.is_some() => {
534 ent.remove::<BorderColorTransition>();
535 }
536 _ => {}
537 }
538 }
539 if !held_fields.contains(StyleFields::TEXT_COLOR) {
540 let cur = ent.get::<TextStyle>().map(|t| t.color);
541 let new = self.text.as_ref().map(|t| t.color);
542 let running = ent.get::<TextColorTransition>().map(|t| t.0.to);
543 match (cur, new, self.text.as_mut()) {
544 (Some(cur), Some(new), Some(text)) if running == Some(new) => {
545 text.color = cur;
546 }
547 (Some(cur), Some(new), Some(text)) => {
548 match spec_for(TransitionProperty::TextColor)
549 .and_then(|spec| retarget(cur, new, &spec))
550 {
551 Some(t) => {
552 ent.insert(TextColorTransition(t));
553 text.color = cur;
554 tweened = true;
555 }
556 None if running.is_some() => {
557 ent.remove::<TextColorTransition>();
558 }
559 None => {}
560 }
561 }
562 _ if running.is_some() => {
563 ent.remove::<TextColorTransition>();
564 }
565 _ => {}
566 }
567 }
568 if !held_fields.contains(StyleFields::OPACITY) {
572 let cur = ent.get::<Opacity>().map(|o| o.0);
573 let target = self.opacity.unwrap_or(1.0);
574 let running = ent.get::<OpacityTransition>().map(|t| t.0.to);
575 if running == Some(target) {
576 self.opacity = cur;
577 } else {
578 match spec_for(TransitionProperty::Opacity)
579 .and_then(|spec| retarget(cur.unwrap_or(1.0), target, &spec))
580 {
581 Some(t) => {
582 ent.insert(OpacityTransition(t));
583 self.opacity = Some(cur.unwrap_or(1.0));
584 tweened = true;
585 }
586 None if running.is_some() => {
587 ent.remove::<OpacityTransition>();
588 }
589 None => {}
590 }
591 }
592 }
593
594 if specs.0.is_empty() {
595 if ent.contains::<TransitionSpecs>() {
596 ent.remove::<TransitionSpecs>();
597 }
598 } else if ent.get::<TransitionSpecs>() != Some(&specs) {
599 ent.insert(specs);
600 }
601
602 if ent.get::<Style>() != Some(&self.style) {
606 ent.insert(self.style);
607 }
608 put_marker::<lumen_core::components::RelayoutBoundary>(ent, self.relayout_boundary);
609 put(ent, self.visuals);
610 put(ent, self.text);
611 put(ent, self.opacity.map(Opacity));
612 put(ent, self.z_index.map(ZIndex));
613 put(ent, self.interaction);
614 put(ent, self.state_visuals);
615 put(ent, self.scrollbar);
616 put(ent, self.image_fit);
617 put(ent, self.input_paint);
618 put(ent, self.caret_width);
619 put(ent, self.password_character);
620 put(ent, self.track);
621 put(ent, self.checkbox);
622 put(ent, self.radio);
623 put(ent, self.tab_button);
624 put(ent, self.progress_chunk);
625 put(ent, self.popup_gap);
626 put(ent, self.knob_geometry);
627 if let Some(ms) = self.caret_blink_ms {
628 set_caret_blink(ent, ms);
629 }
630 if self.knob_geometry.is_some() {
631 set_knob_fill(ent, self.knob_fill);
632 }
633
634 if tweened {
637 ent.world_scope(|world| {
638 if let Some(anim) =
639 world.get_resource::<lumen_core::render_world::AnimationsActive>()
640 {
641 anim.request();
642 }
643 });
644 }
645 }
646}
647
648fn state_visuals(
652 ctx: &StyleContext<'_>,
653 attrs: &Attributes,
654 disabled_opacity_default: f32,
655) -> Option<StateVisuals> {
656 let to_shadows = |v: &Option<Vec<lumen_ir::layout_ir::ShadowSpec>>| {
657 v.as_ref().map(|list| {
658 list.iter()
659 .copied()
660 .map(Into::into)
661 .collect::<Vec<ShadowSpec>>()
662 })
663 };
664 let disabled = if ctx.bind_disabled {
668 StatePatch {
669 text_color: attrs.disabled_text_color.map(Into::into),
670 opacity: attrs.disabled_opacity.or({
674 if attrs.disabled_bg.is_none() && attrs.opacity.is_none() {
675 Some(disabled_opacity_default)
676 } else {
677 None
678 }
679 }),
680 shadows: to_shadows(&attrs.disabled_shadows),
681 bg: attrs.disabled_bg.map(Into::into),
682 }
683 } else {
684 StatePatch::default()
685 };
686 let sv = StateVisuals {
687 hover: StatePatch {
688 text_color: attrs.hover_text_color.map(Into::into),
689 opacity: attrs.hover_opacity,
690 shadows: to_shadows(&attrs.hover_shadows),
691 bg: None,
692 },
693 focus: StatePatch {
694 text_color: attrs.focus_text_color.map(Into::into),
695 opacity: attrs.focus_opacity,
696 shadows: to_shadows(&attrs.focus_shadows),
697 bg: None,
698 },
699 focus_visible: StatePatch {
700 text_color: attrs.focus_visible_text_color.map(Into::into),
701 opacity: attrs.focus_visible_opacity,
702 shadows: to_shadows(&attrs.focus_visible_shadows),
703 bg: None,
704 },
705 active: StatePatch {
706 text_color: attrs.active_text_color.map(Into::into),
707 opacity: attrs.active_opacity,
708 shadows: to_shadows(&attrs.active_shadows),
709 bg: None,
710 },
711 drag_over: StatePatch {
712 text_color: attrs.drag_over_text_color.map(Into::into),
713 opacity: attrs.drag_over_opacity,
714 shadows: to_shadows(&attrs.drag_over_shadows),
715 bg: attrs.drag_over_bg.map(Into::into),
716 },
717 disabled,
718 };
719 (!sv.is_empty()).then_some(sv)
720}
721
722fn scrollbar_style(attrs: &Attributes) -> Option<ScrollbarStyle> {
726 if attrs.scrollbar_color.is_none()
727 && attrs.scrollbar_width.is_none()
728 && attrs.scrollbar_thickness.is_none()
729 && attrs.scrollbar_thickness_thin.is_none()
730 && attrs.scrollbar_margin.is_none()
731 && attrs.scrollbar_min_thumb.is_none()
732 && attrs.scrollbar_track_hover.is_none()
733 && attrs.scrollbar_hover_boost.is_none()
734 && attrs.scrollbar_fade_delay_ms.is_none()
735 && attrs.scrollbar_fade_duration_ms.is_none()
736 {
737 return None;
738 }
739 let mut sb = ScrollbarStyle::default();
740 if let Some((thumb, track)) = attrs.scrollbar_color {
741 sb.thumb = thumb.into();
742 sb.track = track.map(Into::into);
743 }
744 if let Some(w) = attrs.scrollbar_width {
745 sb.width = w.into();
746 }
747 if let Some(v) = attrs.scrollbar_thickness {
748 sb.thickness = v;
749 }
750 if let Some(v) = attrs.scrollbar_thickness_thin {
751 sb.thickness_thin = v;
752 }
753 if let Some(v) = attrs.scrollbar_margin {
754 sb.margin = v;
755 }
756 if let Some(v) = attrs.scrollbar_min_thumb {
757 sb.min_thumb = v;
758 }
759 if let Some(c) = attrs.scrollbar_track_hover {
760 sb.hover_track = c.into();
761 }
762 if let Some(v) = attrs.scrollbar_hover_boost {
763 sb.hover_boost = v;
764 }
765 if let Some(ms) = attrs.scrollbar_fade_delay_ms {
769 sb.fade_delay_secs = ms as f32 / 1000.0;
770 }
771 if let Some(ms) = attrs.scrollbar_fade_duration_ms {
772 sb.fade_secs = ms as f32 / 1000.0;
773 }
774 Some(sb)
775}
776
777fn is_relayout_boundary(style: &Style, layout_boundary: bool, attrs: &Attributes) -> bool {
797 use lumen_core::components::Overflow;
798 if layout_boundary {
799 return true;
800 }
801 if attrs.scroll.is_some()
805 || matches!(style.overflow_x, Overflow::Scroll)
806 || matches!(style.overflow_y, Overflow::Scroll)
807 {
808 return true;
809 }
810 matches!(style.width, Length::Px(_))
811 && matches!(style.height, Length::Px(_))
812 && style.shrink == 0.0
813}
814
815fn set_solid_fill(visuals: &mut Option<Visuals>, color: Color) {
817 if let Some(Some(Fill::Solid(slot))) = visuals.as_mut().map(|v| v.fill.as_mut()) {
818 *slot = color;
819 }
820}
821
822fn set_border_color(visuals: &mut Option<Visuals>, color: Color) {
824 if let Some(Some(border)) = visuals.as_mut().map(|v| v.border.as_mut()) {
825 border.color = color;
826 }
827}
828
829fn insert_some<C: Component>(ent: &mut EntityWorldMut<'_>, c: Option<C>) {
830 if let Some(c) = c {
831 ent.insert(c);
832 }
833}
834
835fn put<C: Component + PartialEq>(ent: &mut EntityWorldMut<'_>, want: Option<C>) {
838 match want {
839 Some(c) => {
840 if ent.get::<C>() != Some(&c) {
841 ent.insert(c);
842 }
843 }
844 None => {
845 if ent.contains::<C>() {
846 ent.remove::<C>();
847 }
848 }
849 }
850}
851
852fn put_marker<C: Component + Default>(ent: &mut EntityWorldMut<'_>, want: bool) {
853 match (want, ent.contains::<C>()) {
854 (true, false) => {
855 ent.insert(C::default());
856 }
857 (false, true) => {
858 ent.remove::<C>();
859 }
860 _ => {}
861 }
862}
863
864fn set_caret_blink(ent: &mut EntityWorldMut<'_>, ms: u32) {
867 ent.world_scope(|world| {
868 if let Some(mut blink) = world.get_resource_mut::<lumen_core::components::CaretBlink>() {
869 let period = std::time::Duration::from_millis(u64::from(ms));
870 if blink.period != period {
871 blink.period = period;
872 }
873 }
874 });
875}
876
877fn set_knob_fill(ent: &mut EntityWorldMut<'_>, fill: Color) {
881 let id = ent.id();
882 ent.world_scope(|world| {
883 let children: Vec<Entity> = world
884 .get::<bevy_ecs::hierarchy::Children>(id)
885 .map(|c| c.iter().collect())
886 .unwrap_or_default();
887 for child in children {
888 let is_knob = world.get::<lumen_primitives::ToggleKnob>(child).is_some()
889 || world.get::<lumen_primitives::SliderThumb>(child).is_some()
890 || world.get::<lumen_primitives::SwitchThumb>(child).is_some();
891 if is_knob
892 && let Some(mut v) = world.get_mut::<Visuals>(child)
893 && v.fill != Some(Fill::Solid(fill))
894 {
895 v.fill = Some(Fill::Solid(fill));
896 }
897 }
898 });
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
908 use lumen_core::components::{CaretBlink, LineHeightSpec as CoreLineHeightSpec, TextInput};
909 use lumen_core::style_overrides::{Live, StyleOwner};
910 use lumen_ir::layout_ir::{BgSpec, LineHeightSpec as IrLineHeightSpec, Rgba};
911
912 fn ctx(tag: &str) -> StyleContext<'_> {
913 StyleContext {
914 tag,
915 ..StyleContext::default()
916 }
917 }
918
919 fn rgba(r: f32, g: f32, b: f32) -> Rgba {
920 Rgba { r, g, b, a: 1.0 }
921 }
922
923 fn commit(world: &mut World, e: Entity, tag: &str, attrs: &Attributes) {
925 ComputedStyle::new(&ctx(tag), attrs).commit(&mut world.entity_mut(e), true);
926 }
927
928 #[test]
929 fn line_height_reaches_the_text_style_in_both_units() {
930 for (ir, core) in [
931 (
932 IrLineHeightSpec::Multiplier(1.5),
933 CoreLineHeightSpec::Multiplier(1.5),
934 ),
935 (IrLineHeightSpec::Px(19.0), CoreLineHeightSpec::Px(19.0)),
936 ] {
937 let attrs = Attributes {
938 line_height: Some(ir),
939 ..Default::default()
940 };
941 let text = ComputedStyle::new(&ctx("label"), &attrs)
942 .text
943 .expect("a text style");
944 assert_eq!(text.line_height, Some(core));
945 }
946 }
947
948 #[test]
952 fn text_field_properties_reach_only_text_fields() {
953 let attrs = Attributes {
954 caret_width: Some(3.5),
955 password_character: Some('#'),
956 caret_blink_ms: Some(250),
957 ..Default::default()
958 };
959 let mut world = World::new();
960 world.insert_resource(CaretBlink {
961 visible: true,
962 phase: std::time::Instant::now(),
963 period: std::time::Duration::from_millis(530),
964 });
965 let plain = world.spawn(Style::default()).id();
966 commit(&mut world, plain, "label", &attrs);
967 assert!(world.get::<CaretWidth>(plain).is_none());
968 assert!(world.get::<PasswordCharacter>(plain).is_none());
969 assert_eq!(
970 world.resource::<CaretBlink>().period,
971 std::time::Duration::from_millis(530),
972 );
973
974 let input = world.spawn((Style::default(), TextInput::default())).id();
975 commit(&mut world, input, "input", &attrs);
976 assert_eq!(world.get::<CaretWidth>(input), Some(&CaretWidth(3.5)));
977 assert_eq!(
978 world.get::<PasswordCharacter>(input),
979 Some(&PasswordCharacter('#'))
980 );
981 assert_eq!(
982 world.resource::<CaretBlink>().period,
983 std::time::Duration::from_millis(250)
984 );
985 }
986
987 #[test]
991 fn a_restyle_builds_the_layout_spawning_builds() {
992 use lumen_ir::layout_ir::{
993 DisplaySpec, Edges, FlexAlign, FlexJustify, LengthSpec, OverflowSpec, PositionSpec,
994 };
995 let attrs = Attributes {
996 display: Some(DisplaySpec::Grid),
997 width: Some(LengthSpec::Px(120.0)),
998 align: Some(FlexAlign::Center),
999 justify: Some(FlexJustify::End),
1000 position: Some(PositionSpec::Absolute),
1001 inset: Some(Edges::all(3.0)),
1002 overflow_y: Some(OverflowSpec::Scroll),
1003 ..Default::default()
1004 };
1005 let mut world = World::new();
1006 let e = world.spawn(Style::default()).id();
1007 commit(&mut world, e, "tile", &attrs);
1008 assert_eq!(
1009 world.get::<Style>(e),
1010 Some(&ComputedStyle::new(&ctx("tile"), &attrs).style),
1011 "restyle and spawn agree"
1012 );
1013 assert_eq!(world.get::<Style>(e).unwrap(), &Style::from(&attrs));
1014 }
1015
1016 #[test]
1019 fn a_restyle_removes_what_nothing_sets_any_more() {
1020 use lumen_ir::layout_ir::{FlexJustify, LengthSpec};
1021 let styled = Attributes {
1022 bg: Some(BgSpec::Solid(rgba(1.0, 0.0, 0.0))),
1023 hover_bg: Some(rgba(0.0, 1.0, 0.0)),
1024 text_color: Some(rgba(0.0, 0.0, 1.0)),
1025 opacity: Some(0.5),
1026 justify: Some(FlexJustify::End),
1027 height: Some(LengthSpec::Px(40.0)),
1028 ..Default::default()
1029 };
1030 let mut world = World::new();
1031 let e = world.spawn_empty().id();
1032 ComputedStyle::new(&ctx("row"), &styled).insert(&mut world.entity_mut(e));
1033 assert!(world.get::<Visuals>(e).is_some());
1034
1035 commit(&mut world, e, "row", &Attributes::default());
1036 assert!(world.get::<Visuals>(e).is_none(), "no bg, no paint");
1037 assert!(world.get::<TextStyle>(e).is_none(), "no text style");
1038 assert!(world.get::<Interaction>(e).is_none(), "no hover tint");
1039 assert!(world.get::<Opacity>(e).is_none(), "fully opaque again");
1040 let style = world.get::<Style>(e).unwrap();
1041 assert_eq!(style.justify, Style::default().justify);
1042 assert_eq!(style.height, Length::Auto);
1043 }
1044
1045 struct Holder;
1046 impl StyleOwner for Holder {
1047 const RANK: u8 = 40;
1048 }
1049
1050 #[test]
1053 fn a_held_value_survives_a_restyle_and_lets_go_onto_the_new_one() {
1054 let old = Attributes {
1055 bg: Some(BgSpec::Solid(rgba(0.0, 0.0, 0.0))),
1056 ..Default::default()
1057 };
1058 let mut world = World::new();
1059 let e = world.spawn(StyleOverrides::default()).id();
1060 ComputedStyle::new(&ctx("button"), &old).insert(&mut world.entity_mut(e));
1061 let tint = Fill::Solid(Color::rgb(1.0, 0.0, 0.0));
1062 let mut ent = world.entity_mut(e);
1063 let vis = ent.get::<Visuals>().unwrap().clone();
1064 let out = ent.get_mut::<StyleOverrides>().unwrap().hold::<Holder>(
1065 StyleValues {
1066 fill: Some(Some(tint.clone())),
1067 ..Default::default()
1068 },
1069 &Live {
1070 visuals: Some(&vis),
1071 ..Live::default()
1072 },
1073 );
1074 out.write_visuals(&mut ent.get_mut::<Visuals>().unwrap());
1075
1076 let blue = rgba(0.0, 0.0, 1.0);
1077 let new = Attributes {
1078 bg: Some(BgSpec::Solid(blue)),
1079 ..Default::default()
1080 };
1081 commit(&mut world, e, "button", &new);
1082 assert_eq!(
1083 world.get::<Visuals>(e).unwrap().fill,
1084 Some(tint),
1085 "the held tint stays on top"
1086 );
1087 let mut ent = world.entity_mut(e);
1088 let out = ent.get_mut::<StyleOverrides>().unwrap().release::<Holder>();
1089 out.write_visuals(&mut ent.get_mut::<Visuals>().unwrap());
1090 assert_eq!(
1091 world.get::<Visuals>(e).unwrap().fill,
1092 Some(Fill::Solid(blue.into())),
1093 "letting go lands on the restyled fill"
1094 );
1095 }
1096
1097 #[test]
1100 fn an_opacity_transition_starts_from_one_when_none_was_authored() {
1101 use lumen_ir::layout_ir::{EasingIr, TransitionIr, TransitionPropertyIr};
1102 let mut world = World::new();
1103 let e = world.spawn(Style::default()).id();
1104 let attrs = Attributes {
1105 opacity: Some(0.2),
1106 transitions: vec![TransitionIr {
1107 property: TransitionPropertyIr::Opacity,
1108 duration_ms: 6000,
1109 easing: EasingIr::Linear,
1110 }],
1111 ..Default::default()
1112 };
1113 commit(&mut world, e, "tile", &attrs);
1114 let tween = world
1115 .get::<OpacityTransition>(e)
1116 .expect("the change tweens");
1117 assert_eq!((tween.0.from, tween.0.to), (1.0, 0.2));
1118 assert_eq!(world.get::<Opacity>(e), Some(&Opacity(1.0)));
1119 }
1120
1121 #[test]
1124 fn a_running_tween_toward_the_same_value_runs_on() {
1125 use lumen_ir::layout_ir::{EasingIr, TransitionIr, TransitionPropertyIr};
1126 let red = rgba(1.0, 0.0, 0.0);
1127 let attrs = Attributes {
1128 bg: Some(BgSpec::Solid(red)),
1129 transitions: vec![TransitionIr {
1130 property: TransitionPropertyIr::BackgroundColor,
1131 duration_ms: 6000,
1132 easing: EasingIr::Linear,
1133 }],
1134 ..Default::default()
1135 };
1136 let mut world = World::new();
1137 let e = world.spawn_empty().id();
1138 let start = Attributes {
1139 bg: Some(BgSpec::Solid(rgba(0.0, 0.0, 0.0))),
1140 ..attrs.clone()
1141 };
1142 ComputedStyle::new(&ctx("tile"), &start).insert(&mut world.entity_mut(e));
1143 commit(&mut world, e, "tile", &attrs);
1144 let first = world.get::<BackgroundTransition>(e).expect("a tween").0;
1145 assert_eq!(first.to, red.into());
1146 commit(&mut world, e, "tile", &attrs);
1147 let again = world
1148 .get::<BackgroundTransition>(e)
1149 .expect("still tweening")
1150 .0;
1151 assert_eq!(again.start, first.start, "the same tween, not a restart");
1152 assert_eq!(
1153 world.get::<Visuals>(e).unwrap().fill,
1154 Some(Fill::Solid(Color::rgb(0.0, 0.0, 0.0))),
1155 "the tween, not the restyle, moves the fill"
1156 );
1157 }
1158
1159 #[test]
1162 fn an_unchanged_restyle_leaves_style_unchanged() {
1163 let mut world = World::new();
1164 let e = world.spawn(Style::default()).id();
1165 world.clear_trackers();
1166 let tick = world.change_tick();
1167 commit(&mut world, e, "tile", &Attributes::default());
1168 let changed = world
1169 .entity(e)
1170 .get_change_ticks::<Style>()
1171 .unwrap()
1172 .is_changed(tick, world.change_tick());
1173 assert!(!changed, "an unchanged restyle must not touch Style");
1174 }
1175}