1use crate::computed::{ComputedStyle, StyleContext};
14use lumen_core::components::AuthoredStrings;
15use lumen_core::style_overrides::{Live, StyleOwner, StyleValues};
16
17use lumen_core::property_store::PropertyStore;
18use lumen_core::signals::signal_is_truthy;
19use lumen_ir::interpolate::{Scope, element_carries_placeholder, substitute_element};
20use lumen_ir::layout_ir::{Attributes, BindKind, Element, LayoutIR};
21use lumen_ir::translate::{TranslatedStrings, translate};
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
28pub enum IfMode {
29 #[default]
31 Render,
32 Hide,
34}
35
36impl From<lumen_ir::layout_ir::IfModeSpec> for IfMode {
37 fn from(s: lumen_ir::layout_ir::IfModeSpec) -> Self {
38 use lumen_ir::layout_ir::IfModeSpec;
39 match s {
40 IfModeSpec::Render => IfMode::Render,
41 IfModeSpec::Hide => IfMode::Hide,
42 }
43 }
44}
45
46#[derive(bevy_ecs::component::Component, Clone, Copy, Debug, Default)]
52pub struct DialogMarker;
53
54#[derive(bevy_ecs::component::Component, Clone, Debug, PartialEq, Eq)]
58pub struct TypographyRole(pub std::sync::Arc<str>);
59
60#[derive(bevy_ecs::component::Component, Clone, Copy, Debug, Default)]
63pub struct AutoFocus;
64
65#[derive(bevy_ecs::component::Component, Clone, Copy, Debug, Default)]
70pub struct DefaultButton;
71
72#[derive(bevy_ecs::component::Component, Clone, Debug, Default)]
76pub struct DialogSession {
77 pub open: bool,
79 pub needs_focus: bool,
83 pub prev_focus: Option<Entity>,
86 pub prev_focus_visible: bool,
88 pub pending_accept: bool,
92}
93
94#[derive(bevy_ecs::component::Component, Clone, Debug)]
104#[require(lumen_core::style_overrides::StyleOverrides)]
105pub struct IfMarker {
106 pub signal_name: String,
108 pub body: Vec<Element>,
111 pub currently_mounted: bool,
113 pub mode: IfMode,
115 pub eq: Option<String>,
118 pub applied_visible: Option<bool>,
123}
124
125impl StyleOwner for IfMarker {
126 const RANK: u8 = 20;
127}
128
129impl StyleOwner for ForMarker {
130 const RANK: u8 = 20;
131}
132
133#[derive(bevy_ecs::component::Component, Clone, Debug)]
145#[require(lumen_core::style_overrides::StyleOverrides)]
146pub struct ForMarker {
147 pub array_name: String,
149 pub body: Vec<Element>,
151 pub key_field: Option<String>,
154 pub cached_keys: Vec<String>,
158 pub virtualized: bool,
163 pub row_height: f32,
167 pub win_rows: Vec<(usize, String)>,
173 pub cascaded_body: Option<std::sync::Arc<Vec<Element>>>,
180 pub row_items: Vec<ArrayItem>,
186}
187
188#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
190pub enum Virtualization {
191 #[default]
195 Enabled,
196 HostManaged,
199}
200
201#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
203pub enum RowStyle {
204 #[default]
207 Cascade,
208 HostStyled,
212}
213
214#[derive(bevy_ecs::resource::Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
221pub struct ScenePolicy {
222 pub virtualization: Virtualization,
224 pub row_style: RowStyle,
226}
227
228use bevy_ecs::prelude::*;
229use lumen_core::prelude::*;
230use lumen_core::signals::ArrayItem;
231
232pub use lumen_ir::typography_role_to_px;
237
238pub trait SpawnIntoWorld {
243 fn spawn_into(&self, world: &mut World) -> Entity;
245}
246
247impl SpawnIntoWorld for LayoutIR {
248 fn spawn_into(&self, world: &mut World) -> Entity {
249 if let Some(sheet) = self.combined_stylesheet.clone() {
250 world.insert_resource(LumenStylesheet(sheet));
251 }
252 let root = spawn_subtree(world, &self.root, None, Placeholders::Unresolved);
253 install_document_root(world, root);
254 root
255 }
256}
257
258#[derive(Resource, Debug, Clone, Copy)]
264pub struct DocumentRoot(pub Entity);
265
266pub fn install_document_root(world: &mut World, root: Entity) {
274 world.insert_resource(DocumentRoot(root));
275}
276
277impl SpawnIntoWorld for lumen_ir::artifact::CompiledApp {
285 fn spawn_into(&self, world: &mut World) -> Entity {
286 crate::fragments::install(world, self.fragments.clone());
287 self.ir.spawn_into(world)
288 }
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum Placeholders {
295 Unresolved,
298 Resolved,
303}
304
305pub fn spawn_subtree(
312 world: &mut World,
313 el: &Element,
314 parent: Option<Entity>,
315 placeholders: Placeholders,
316) -> Entity {
317 match with_globals_resolved(world, el, placeholders) {
318 Some(resolved) => spawn_element(world, &resolved, parent),
319 None => spawn_element(world, el, parent),
320 }
321}
322
323fn with_globals_resolved(
332 world: &World,
333 el: &Element,
334 placeholders: Placeholders,
335) -> Option<Element> {
336 if placeholders == Placeholders::Resolved || !tree_carries_placeholder(el) {
337 return None;
338 }
339 let store = world.get_resource::<PropertyStore>()?;
340 Some(substitute_element(el, &Scope::new(store)))
341}
342
343fn tree_carries_placeholder(el: &Element) -> bool {
346 element_carries_placeholder(el) || el.children.iter().any(tree_carries_placeholder)
347}
348
349#[derive(Resource, Clone)]
353pub struct LumenStylesheet(pub lumen_ir::css::Stylesheet);
354
355#[derive(Resource, Default)]
358struct DocumentOrderCounter(u32);
359
360fn next_document_order(world: &mut World) -> u32 {
373 let mut counter = world.get_resource_or_insert_with(DocumentOrderCounter::default);
374 let n = counter.0;
375 counter.0 = counter.0.wrapping_add(1);
376 n
377}
378
379pub fn renumber_document_order(
398 mut commands: bevy_ecs::system::Commands,
399 reshaped: bevy_ecs::system::Query<(), Changed<bevy_ecs::hierarchy::Children>>,
400 roots: bevy_ecs::system::Query<
401 (Entity, &lumen_core::components::DocumentOrder),
402 Without<bevy_ecs::hierarchy::ChildOf>,
403 >,
404 children: bevy_ecs::system::Query<&bevy_ecs::hierarchy::Children>,
405 orders: bevy_ecs::system::Query<&lumen_core::components::DocumentOrder>,
406) {
407 if reshaped.is_empty() {
410 return;
411 }
412 let mut top: Vec<(u32, Entity)> = roots.iter().map(|(e, d)| (d.0, e)).collect();
413 top.sort_unstable();
414 let mut stack: Vec<Entity> = top.into_iter().rev().map(|(_, e)| e).collect();
417 let mut n: u32 = 0;
418 while let Some(e) = stack.pop() {
419 if orders.get(e).ok().map(|d| d.0) != Some(n) {
420 commands
427 .entity(e)
428 .try_insert(lumen_core::components::DocumentOrder(n));
429 }
430 n = n.wrapping_add(1);
431 if let Ok(kids) = children.get(e) {
432 for kid in kids.iter().rev() {
433 stack.push(kid);
434 }
435 }
436 }
437}
438
439pub fn resolve_strings(
460 i18n: Option<&lumen_core::i18n::AppI18n>,
461 authored: &AuthoredStrings,
462 format: Option<&str>,
463) -> TranslatedStrings {
464 let mut strings = translate(authored, &|key| {
465 i18n.and_then(|i18n| i18n.try_translate(key))
466 });
467 let (Some(text), Some(spec)) = (&strings.text, format) else {
468 return strings;
469 };
470 if let Some(formatted) = i18n.and_then(|i18n| i18n.format(spec, text)) {
471 strings.text = Some(formatted);
472 }
473 strings
474}
475
476pub(crate) const DISABLED_OPACITY_DEFAULT: f32 = 0.5;
481
482pub fn track_style_over(
491 base: lumen_primitives::TrackStyle,
492 attrs: &Attributes,
493) -> lumen_primitives::TrackStyle {
494 lumen_primitives::TrackStyle {
495 checked_bg: attrs.checked_bg.map(Into::into).unwrap_or(base.checked_bg),
496 unchecked_bg: attrs
497 .bg
498 .as_ref()
499 .and_then(|b| Fill::try_from(b).ok()?.as_solid())
500 .unwrap_or(base.unchecked_bg),
501 disabled_bg: attrs.disabled_bg.map(Into::into).or(base.disabled_bg),
502 }
503}
504
505pub fn apply_background_image(
518 ent: &mut bevy_ecs::world::EntityWorldMut<'_>,
519 tag: &str,
520 attrs: &Attributes,
521) {
522 use lumen_assets::{
523 AssetServer, BackgroundImage, Enqueued, ImageLoadFailed, ImageSource, LoadedImage,
524 LoadedSvg,
525 };
526 use lumen_ir::layout_ir::BgSpec;
527 if tag == "image" {
528 return;
529 }
530 let current = ent.get::<BackgroundImage>().copied();
531 match &attrs.bg {
532 Some(BgSpec::Image(path)) => {
533 let source = lumen_assets::resolve_source_path(path);
534 if current.is_none() || ent.get::<ImageSource>().is_none_or(|s| s.0 != source) {
535 ent.remove::<(LoadedImage, LoadedSvg, ImageLoadFailed, Enqueued)>();
539 let id = ent.id();
540 ent.world_scope(|world| {
541 if let Some(mut server) = world.get_resource_mut::<AssetServer>() {
542 server.bump_request_id(id);
543 }
544 });
545 ent.insert(ImageSource(source));
546 }
547 let fit = attrs.bg_fit.map(ImageFit::from).unwrap_or(ImageFit::Cover);
548 if current != Some(BackgroundImage { fit }) {
549 ent.insert(BackgroundImage { fit });
550 }
551 }
552 Some(_) | None => {
553 if current.is_some() {
554 ent.remove::<(
555 BackgroundImage,
556 ImageSource,
557 LoadedImage,
558 LoadedSvg,
559 ImageLoadFailed,
560 Enqueued,
561 )>();
562 }
563 }
564 }
565}
566
567fn drag_source(attrs: &Attributes) -> Option<lumen_os_dnd::DragSource> {
570 let payload = attrs.drag_payload.as_ref()?;
571 let text = if payload.is_empty() {
572 attrs.id.clone().unwrap_or_default()
573 } else {
574 payload.clone()
575 };
576 Some(lumen_os_dnd::DragSource::new(text).with_effects(lumen_os_dnd::DropEffectSet::ANY))
577}
578
579fn spawn_element(world: &mut World, el: &Element, parent: Option<Entity>) -> Entity {
580 if let Some((name, default_value)) = &el.attrs.signal_seed
584 && let Some(mut store) = world.get_resource_mut::<PropertyStore>()
585 && store.get_global_str(name).is_none()
586 {
587 store.set_global_str(name, default_value.as_str());
588 }
589 if let Some(spec) = &el.attrs.bind
604 && el.attrs.dropdown_button.is_none()
605 {
606 let authored: Option<String> = match spec.kind {
607 BindKind::Checked => el
608 .attrs
609 .checked
610 .map(|b| if b { "true" } else { "false" }.to_string()),
611 BindKind::Value => el.attrs.value.map(|v| format!("{v}")),
612 BindKind::Text => el.attrs.text.clone(),
613 };
614 if let Some(v) = authored
615 && let Some(mut store) = world.get_resource_mut::<PropertyStore>()
616 && store.get_global_str(&spec.name).is_none()
617 {
618 store.set_global_str(&spec.name, v.as_str());
619 }
620 }
621 let authored = AuthoredStrings::from(&el.attrs);
624 let strings = resolve_strings(
625 world.get_resource::<lumen_core::i18n::AppI18n>(),
626 &authored,
627 el.attrs.format.as_deref(),
628 );
629 let text = strings.text.clone();
630 let in_scroll = parent.is_some_and(|p| world.get::<Scroll>(p).is_some());
634 let computed = ComputedStyle::new(
635 &StyleContext::of_attrs(&el.tag, &el.attrs, in_scroll),
636 &el.attrs,
637 );
638 let knob_geometry = computed.knob_geometry.unwrap_or_default();
639 let knob_fill = computed.knob_fill;
640 let doc_order = next_document_order(world);
641 let mut entity = world.spawn((
642 DirtyLayout,
643 lumen_core::components::DocumentOrder(doc_order),
644 ));
645 if let Some(p) = parent {
646 entity.insert(ChildOf(p));
647 }
648 computed.insert(&mut entity);
649 if let Some(role) = el.attrs.style_role.as_deref() {
653 entity.insert(TypographyRole(role.into()));
654 }
655 if let Some(use_site) = &el.frag_use {
659 entity.insert(lumen_core::components::PendingFill {
660 function: use_site.key.clone(),
661 args: use_site
662 .args
663 .iter()
664 .map(|(_, value)| value.clone())
665 .collect(),
666 });
667 }
668 if let Some(text) = &text {
669 entity.insert(TextContent(text.clone()));
670 }
671 if matches!(el.tag.as_str(), "input" | "textarea") {
672 let default_multiline = el.tag == "textarea";
673 entity.insert(TextInput {
674 placeholder: strings.placeholder.clone().unwrap_or_default(),
675 cursor: 0,
676 selection_anchor: None,
677 multiline: el.attrs.multiline.unwrap_or(default_multiline),
678 });
679 }
680 if matches!(el.tag.as_str(), "toggle" | "switch" | "checkbox") {
681 entity.insert(Toggleable {
682 checked: el.attrs.checked.unwrap_or(false),
683 });
684 }
685 if el.tag == "switch" {
686 entity.insert(lumen_core::components::A11yRole::Switch);
689 }
690 if el.tag == "slider" {
691 entity.insert(SliderValue::from(&el.attrs));
692 }
693 if el.tag == "checkbox" && el.attrs.indeterminate {
694 entity.insert(lumen_primitives::Indeterminate);
695 }
696 if el.tag == "radio"
697 && let (Some(group), Some(value)) = (&el.attrs.radio_group, &el.attrs.radio_value)
698 {
699 entity.insert(lumen_primitives::RadioButton {
700 group: group.clone(),
701 value: value.clone(),
702 });
703 }
704 if el.tag == "progress" {
705 let bar = lumen_primitives::ProgressBar::from(&el.attrs);
708 entity.insert(lumen_core::components::A11yValue {
711 now: f64::from(bar.value.unwrap_or(0.0)),
712 min: 0.0,
713 max: f64::from(bar.max),
714 step: 0.0,
715 text: None,
716 });
717 entity.insert(bar);
718 }
719 if let Some(part) = el.attrs.part {
721 match part {
722 lumen_ir::layout_ir::WidgetPart::CheckboxBox => {
723 entity.insert(lumen_primitives::CheckboxBox);
724 }
725 lumen_ir::layout_ir::WidgetPart::RadioDot => {
726 entity.insert(lumen_primitives::RadioDot);
727 }
728 lumen_ir::layout_ir::WidgetPart::ProgressFill => {
729 entity.insert(lumen_primitives::ProgressFill);
730 }
731 }
732 }
733 let validate_min_max = !matches!(el.tag.as_str(), "slider" | "progress")
740 && (el.attrs.min.is_some() || el.attrs.max.is_some());
741 if el.attrs.required || el.attrs.pattern.is_some() || validate_min_max {
742 entity.insert(lumen_core::components::Validation {
743 required: el.attrs.required,
744 pattern: el.attrs.pattern.clone(),
745 min: if validate_min_max { el.attrs.min } else { None },
746 max: if validate_min_max { el.attrs.max } else { None },
747 is_valid: true,
748 });
749 }
750 if el.attrs.drop_target {
751 entity.insert(DropTarget);
752 let accept = match &el.attrs.drop_accept {
756 Some(mime) => {
757 lumen_os_dnd::DropAccept::only([lumen_os_dnd::mime::MimeKind::from(mime.as_str())])
758 }
759 None => lumen_os_dnd::DropAccept::any(),
760 }
761 .with_effects(lumen_os_dnd::DropEffectSet::ANY);
762 entity.insert(accept);
763 }
764 if let Some(source) = drag_source(&el.attrs) {
769 entity.insert(source);
770 }
771 if el.attrs.title_bar_drag {
772 entity.insert(lumen_core::components::TitleBarDraggable);
773 }
774 if let Some(spec) = &el.attrs.tooltip {
775 let defaults = lumen_primitives::TooltipSource::default();
779 entity.insert(lumen_primitives::TooltipSource {
780 text: strings.tooltip.clone().unwrap_or_default(),
781 delay_ms: spec.delay_ms.unwrap_or(defaults.delay_ms),
782 offset: spec.offset.unwrap_or(defaults.offset),
783 });
784 }
785 if let Some((signal_name, value)) = &el.attrs.tab_strip {
786 entity.insert(lumen_primitives::TabStripButton {
787 signal_name: signal_name.clone(),
788 value: value.clone(),
789 });
790 }
791 if let Some(spec) = &el.attrs.dropdown_button {
792 entity.insert(lumen_primitives::DropdownButton {
793 open_signal: spec.open_signal.clone(),
794 value_signal: spec.value_signal.clone(),
795 options: spec
796 .options
797 .iter()
798 .map(
799 |(value, label, disabled)| lumen_primitives::DropdownOptionSpec {
800 value: value.clone(),
801 label: label.clone(),
802 disabled: *disabled,
803 },
804 )
805 .collect(),
806 });
807 entity.insert(lumen_core::components::BindTextLabels(
813 spec.options
814 .iter()
815 .map(|(value, label, _)| (value.clone(), label.clone()))
816 .collect(),
817 ));
818 }
819 if let Some((value_signal, value, open_signal)) = &el.attrs.dropdown_option {
820 entity.insert(lumen_primitives::DropdownOptionButton {
821 value_signal: value_signal.clone(),
822 value: value.clone(),
823 open_signal: open_signal.clone(),
824 });
825 }
826 if let Some((open_signal, item_id)) = &el.attrs.menu_item {
827 entity.insert(lumen_primitives::MenuItemButton {
828 open_signal: open_signal.clone(),
829 item_id: item_id.clone(),
830 });
831 }
832 if (el.attrs.dropdown_option.is_some() || el.attrs.menu_item.is_some())
837 && el.attrs.tab_index.is_none()
838 {
839 entity.insert(TabIndex(-1));
840 }
841 if let Some(open_signal) = &el.attrs.popup_panel {
842 entity.insert((
843 lumen_primitives::PopupPanel {
844 open_signal: open_signal.clone(),
845 },
846 lumen_core::render_world::OverlayLayer,
850 ));
851 }
852 if el.tag == "image"
853 && let Some(src) = &el.attrs.src
854 {
855 entity.insert(lumen_assets::ImageSource(std::path::PathBuf::from(src)));
856 }
857 apply_background_image(&mut entity, &el.tag, &el.attrs);
858 if let Some(tab) = el.attrs.tab_index {
859 entity.insert(TabIndex(tab));
860 }
861 if el.attrs.autofocus {
862 entity.insert(AutoFocus);
863 }
864 if el.attrs.default_button {
865 entity.insert(DefaultButton);
866 }
867 if let Some(spec) = &el.attrs.format {
871 entity.insert(lumen_core::components::TextFormat(spec.clone()));
872 }
873 if authored.key.is_some() || authored.tooltip_key.is_some() || el.attrs.format.is_some() {
883 entity.insert(authored);
884 }
885 if let Some(spec) = &el.attrs.bind {
886 match spec.kind {
887 BindKind::Text => {
888 entity.insert(BindText::from(spec.name.clone()));
889 if text.is_none() {
894 entity.insert(TextContent(String::new()));
895 }
896 }
897 BindKind::Checked => {
898 entity.insert(BindChecked(spec.name.clone()));
899 }
900 BindKind::Value => {
901 entity.insert(BindValue(spec.name.clone()));
902 }
903 }
904 }
905 if let Some(field) = &el.attrs.bind_self_text {
911 entity.insert(BindSelfText::from(field.as_str()));
912 if text.is_none() {
913 entity.insert(TextContent(String::new()));
914 }
915 }
916 if let Some(field) = &el.attrs.bind_self_value {
917 entity.insert(BindSelfValue::from(field.as_str()));
918 }
919 if let Some(field) = &el.attrs.bind_self_checked {
920 entity.insert(BindSelfChecked::from(field.as_str()));
921 }
922 if let Some(field) = &el.attrs.bind_parent_text {
923 entity.insert(BindParentText::from(field.as_str()));
924 if text.is_none() {
925 entity.insert(TextContent(String::new()));
926 }
927 }
928 if let Some(field) = &el.attrs.bind_parent_value {
929 entity.insert(BindParentValue::from(field.as_str()));
930 }
931 if let Some(field) = &el.attrs.bind_parent_checked {
932 entity.insert(BindParentChecked::from(field.as_str()));
933 }
934 if let Some(id) = &el.attrs.id {
935 entity.insert(LumenId(id.clone()));
936 }
937 if el.tag == "a"
940 && let Some(href) = &el.attrs.href
941 {
942 entity.insert(crate::routing::Anchor(href.clone()));
943 if el.attrs.tab_index.is_none() {
950 entity.insert(TabIndex(-1));
951 }
952 }
953 if !el.attrs.classes.is_empty() {
954 entity.insert(LumenClasses::from(el.attrs.classes.clone()));
955 }
956 let markup: Vec<(String, String)> = el
961 .attrs
962 .markup_styles
963 .iter()
964 .filter(|(_, value)| !value.contains('{'))
965 .cloned()
966 .collect();
967 if !markup.is_empty() {
968 entity.insert(lumen_core::components::MarkupStyle(markup.into()));
969 }
970 if !el.tag.is_empty() {
978 entity.insert(lumen_core::components::LumenTag(el.tag.as_str().into()));
979 }
980 if let Some(dir) = el.attrs.dir {
984 entity.insert(dir);
985 }
986 if let Some(lang) = &el.attrs.lang {
987 entity.insert(lumen_core::components::Lang::from(lang.as_str()));
988 }
989 if el.attrs.draggable {
990 entity.insert(lumen_primitives::Draggable);
991 }
992 if let Some(name) = &el.attrs.bind_scroll {
1004 entity.insert(lumen_core::components::BindScroll(name.clone()));
1005 }
1006 if let Some(name) = &el.attrs.bind_disabled {
1007 entity.insert(lumen_core::components::BindDisabled(name.clone()));
1008 if el.attrs.disabled {
1009 entity.insert(lumen_core::components::Disabled);
1013 }
1014 } else if el.attrs.disabled {
1015 entity.insert(lumen_core::components::Disabled);
1019 }
1020 if let Some(axis) = el.attrs.scroll {
1021 let mut scroll = Scroll {
1022 axis: axis.into(),
1023 sensitivity: 1.0,
1024 inertia: 0.4,
1025 velocity: glam::Vec2::ZERO,
1026 };
1027 if let Some(s) = el.attrs.sensitivity {
1028 scroll.sensitivity = s;
1029 }
1030 if let Some(i) = el.attrs.inertia {
1031 scroll.inertia = i;
1032 }
1033 entity.insert((scroll, ScrollOffset::default()));
1034 } else {
1035 use lumen_ir::layout_ir::OverflowSpec;
1041 let ox = el.attrs.overflow_x.or(el.attrs.overflow);
1042 let oy = el.attrs.overflow_y.or(el.attrs.overflow);
1043 let sx = matches!(ox, Some(OverflowSpec::Scroll));
1044 let sy = matches!(oy, Some(OverflowSpec::Scroll));
1045 if sx || sy {
1046 let axis = match (sx, sy) {
1047 (true, true) => lumen_core::input::ScrollAxis::Both,
1048 (true, false) => lumen_core::input::ScrollAxis::X,
1049 _ => lumen_core::input::ScrollAxis::Y,
1050 };
1051 let mut scroll = Scroll {
1052 axis,
1053 sensitivity: 1.0,
1054 inertia: 0.4,
1055 velocity: glam::Vec2::ZERO,
1056 };
1057 if let Some(s) = el.attrs.sensitivity {
1058 scroll.sensitivity = s;
1059 }
1060 if let Some(i) = el.attrs.inertia {
1061 scroll.inertia = i;
1062 }
1063 entity.insert((scroll, ScrollOffset::default()));
1064 }
1065 }
1066 let id = entity.id();
1067 if el.tag == "toggle" {
1075 world.spawn((
1076 knob_style(28.0, knob_geometry.inset),
1077 DirtyLayout,
1078 ChildOf(id),
1079 Visuals {
1080 fill: Some(Fill::Solid(knob_fill)),
1081 radius: 14.0,
1082 corner_radii: None,
1083 shadows: Vec::new(),
1084 border: None,
1085 },
1086 lumen_primitives::ToggleKnob,
1087 ));
1088 }
1089 if el.tag == "switch" {
1090 world.spawn((
1094 knob_style(20.0, knob_geometry.inset),
1095 DirtyLayout,
1096 ChildOf(id),
1097 Visuals {
1098 fill: Some(Fill::Solid(knob_fill)),
1099 radius: 10.0,
1100 corner_radii: None,
1101 shadows: Vec::new(),
1102 border: None,
1103 },
1104 lumen_primitives::SwitchThumb::default(),
1105 ));
1106 }
1107 if el.tag == "slider" {
1108 let thumb = knob_geometry.thumb_size;
1109 world.spawn((
1110 knob_style(thumb, 0.0),
1111 DirtyLayout,
1112 ChildOf(id),
1113 Visuals {
1114 fill: Some(Fill::Solid(knob_fill)),
1115 radius: thumb / 2.0,
1116 corner_radii: None,
1117 shadows: Vec::new(),
1118 border: None,
1119 },
1120 lumen_primitives::SliderThumb,
1121 ));
1122 }
1123 if let Some(name) = crate::fragments::slot_name_of(el) {
1127 world
1128 .entity_mut(id)
1129 .insert(crate::fragments::SlotPlaceholder(name));
1130 }
1131 if let Some(authored) = &el.attrs.widget_attributes
1136 && let Some(spawn) = lumen_widget::widget_spawner(&el.tag)
1137 {
1138 let attrs = lumen_widget::Attributes::from(authored.clone());
1139 spawn(id, &attrs, world);
1140 }
1141 if el.tag == "for"
1146 && let Some(name) = &el.attrs.each
1147 {
1148 world.entity_mut(id).insert(ForMarker {
1149 array_name: name.clone(),
1150 body: el.children.clone(),
1151 key_field: el.attrs.key.clone(),
1152 cached_keys: Vec::new(),
1153 virtualized: el.attrs.virtualized,
1154 row_height: el.attrs.row_height.unwrap_or(32.0),
1155 win_rows: Vec::new(),
1156 cascaded_body: None,
1157 row_items: Vec::new(),
1158 });
1159 } else if (el.tag == "if" || el.tag == "dialog")
1160 && let Some(name) = &el.attrs.if_signal
1161 {
1162 world.entity_mut(id).insert(IfMarker {
1163 signal_name: name.clone(),
1164 body: el.children.clone(),
1165 currently_mounted: false,
1166 mode: el.attrs.if_mode.into(),
1167 eq: el.attrs.if_eq.clone(),
1168 applied_visible: None,
1169 });
1170 if el.tag == "dialog" {
1171 world.entity_mut(id).insert((
1172 DialogMarker,
1173 lumen_core::components::FocusBoundary,
1174 lumen_core::render_world::OverlayLayer,
1177 ));
1178 }
1179 } else {
1180 for child in &el.children {
1181 spawn_element(world, child, Some(id));
1182 }
1183 }
1184 id
1185}
1186
1187pub fn settle_tree(world: &mut bevy_ecs::world::World) {
1200 use bevy_ecs::system::RunSystemOnce;
1201 let _ = world.run_system_once(lumen_core::property_store::drain_external_properties);
1204 let _ = world.run_system_once(lumen_core::signals::drain_external_signals);
1205 let _ = world.run_system_once(reconcile_for_blocks);
1206 let _ = world.run_system_once(reconcile_if_blocks);
1207}
1208
1209pub fn reconcile_if_blocks(
1228 store: bevy_ecs::system::Res<PropertyStore>,
1229 mut markers: bevy_ecs::system::Query<(
1230 Entity,
1231 &mut IfMarker,
1232 Option<&bevy_ecs::hierarchy::Children>,
1233 )>,
1234 mut styles: bevy_ecs::system::Query<(
1235 &mut lumen_core::components::Style,
1236 &mut lumen_core::style_overrides::StyleOverrides,
1237 )>,
1238 fade_targets: bevy_ecs::system::Query<(
1239 Option<&lumen_primitives::TransitionSpecs>,
1240 Option<&lumen_core::components::Opacity>,
1241 )>,
1242 mut commands: bevy_ecs::system::Commands,
1243) {
1244 for (parent_id, mut marker, children) in markers.iter_mut() {
1245 let value: Option<std::sync::Arc<str>> = store.get_global_str(&marker.signal_name);
1246 let value_str: Option<&str> = value.as_deref();
1247 let now_truthy = match &marker.eq {
1248 Some(expected) => value_str == Some(expected.as_str()),
1249 None => value_str.is_some_and(signal_is_truthy),
1250 };
1251 match marker.mode {
1252 IfMode::Render => {
1253 if now_truthy == marker.currently_mounted {
1254 continue;
1255 }
1256 if now_truthy {
1257 for tmpl in &marker.body {
1258 spawn_body_child(
1264 &mut commands,
1265 tmpl,
1266 parent_id,
1267 Placeholders::Resolved,
1268 BodyChild::Gated,
1269 );
1270 }
1271 if let Ok((specs, cur)) = fade_targets.get(parent_id)
1274 && let Some(bundle) = mount_fade_bundle(specs, cur.copied())
1275 {
1276 commands.entity(parent_id).insert(bundle);
1277 }
1278 marker.currently_mounted = true;
1279 } else {
1280 if let Some(kids) = children {
1281 for child in kids.iter() {
1282 commands.entity(child).despawn();
1283 }
1284 }
1285 marker.currently_mounted = false;
1286 }
1287 }
1288 IfMode::Hide => {
1289 if now_truthy && !marker.currently_mounted {
1293 for tmpl in &marker.body {
1294 spawn_body_child(
1295 &mut commands,
1296 tmpl,
1297 parent_id,
1298 Placeholders::Resolved,
1299 BodyChild::Gated,
1300 );
1301 }
1302 marker.currently_mounted = true;
1303 }
1304 if marker.applied_visible == Some(now_truthy) {
1308 continue;
1309 }
1310 marker.applied_visible = Some(now_truthy);
1311 commands
1312 .entity(parent_id)
1313 .insert(lumen_core::components::Visible(now_truthy));
1314 if now_truthy {
1321 let mut targets: Vec<Entity> = vec![parent_id];
1322 if let Some(kids) = children {
1323 targets.extend(kids.iter());
1324 }
1325 for target in targets {
1326 if let Ok((specs, cur)) = fade_targets.get(target)
1327 && let Some(bundle) = mount_fade_bundle(specs, cur.copied())
1328 {
1329 commands.entity(target).insert(bundle);
1330 }
1331 }
1332 }
1333 if let Ok((mut style, mut overrides)) = styles.get_mut(parent_id) {
1338 let out = if now_truthy {
1339 overrides.release::<IfMarker>()
1340 } else {
1341 let live = Live {
1342 style: Some(&style),
1343 ..Live::default()
1344 };
1345 let hidden = StyleValues {
1346 display: Some(lumen_core::components::Display::None),
1347 ..StyleValues::default()
1348 };
1349 overrides.hold::<IfMarker>(hidden, &live)
1350 };
1351 out.write_style(&mut style);
1352 }
1353 }
1354 }
1355 }
1356}
1357
1358pub fn close_dialogs_on_escape(
1384 mut keys: bevy_ecs::message::MessageReader<lumen_core::input::KeyPressed>,
1385 press_cancel: Option<bevy_ecs::system::Res<lumen_core::input::EscapePressCancel>>,
1386 mut store: bevy_ecs::system::ResMut<PropertyStore>,
1387 blocks: bevy_ecs::system::Query<(
1388 &IfMarker,
1389 Option<&lumen_core::components::Visible>,
1390 Option<&DialogMarker>,
1391 )>,
1392) {
1393 let escape_pressed = keys.read().any(|k| {
1394 matches!(
1395 &k.key,
1396 lumen_core::input::Key::Named(lumen_core::input::NamedKey::Escape)
1397 )
1398 });
1399 if !escape_pressed {
1400 return;
1401 }
1402 if press_cancel.is_some_and(|f| f.0) {
1403 return;
1404 }
1405
1406 for (marker, _visible, _dialog) in &blocks {
1408 if marker.signal_name.starts_with("__dropdown_open:")
1409 && store.get_global_bool(&marker.signal_name) == Some(true)
1410 {
1411 store.set_global_bool(&marker.signal_name, false);
1412 return;
1413 }
1414 }
1415 for (marker, _visible, _dialog) in &blocks {
1417 if marker.signal_name.starts_with("__menu_open:")
1418 && store.get_global_bool(&marker.signal_name) == Some(true)
1419 {
1420 store.set_global_bool(&marker.signal_name, false);
1421 return;
1422 }
1423 }
1424 for (marker, visible, dialog) in &blocks {
1426 if dialog.is_none() {
1427 continue;
1428 }
1429 let open = visible.map(|v| v.0).unwrap_or(true);
1430 if !open {
1431 continue;
1432 }
1433 let curr = store.get_global_str(&marker.signal_name);
1434 if curr.as_deref() != Some("") {
1435 store.set_global_str(&marker.signal_name, "");
1436 return;
1437 }
1438 }
1439}
1440
1441fn collect_descendants(
1444 root: Entity,
1445 children: &bevy_ecs::system::Query<&bevy_ecs::hierarchy::Children>,
1446 out: &mut Vec<Entity>,
1447) {
1448 if let Ok(kids) = children.get(root) {
1449 for kid in kids.iter() {
1450 out.push(kid);
1451 collect_descendants(kid, children, out);
1452 }
1453 }
1454}
1455
1456#[allow(clippy::too_many_arguments, clippy::type_complexity)]
1480pub fn manage_dialog_lifecycle(
1481 mut commands: bevy_ecs::system::Commands,
1482 mut tracker: bevy_ecs::system::ResMut<lumen_core::input::FocusTracker>,
1483 mut dialogs: bevy_ecs::system::Query<
1484 (
1485 Entity,
1486 Option<&lumen_core::components::Visible>,
1487 Option<&mut DialogSession>,
1488 Option<&lumen_core::components::LumenId>,
1489 &IfMarker,
1490 ),
1491 With<DialogMarker>,
1492 >,
1493 children: bevy_ecs::system::Query<&bevy_ecs::hierarchy::Children>,
1494 parents: bevy_ecs::system::Query<&bevy_ecs::hierarchy::ChildOf>,
1495 focusables: bevy_ecs::system::Query<
1496 (
1497 &lumen_core::components::TabIndex,
1498 Option<&lumen_core::components::DocumentOrder>,
1499 ),
1500 Without<lumen_core::components::Disabled>,
1501 >,
1502 autofocused: bevy_ecs::system::Query<
1503 Option<&lumen_core::components::DocumentOrder>,
1504 (With<AutoFocus>, Without<lumen_core::components::Disabled>),
1505 >,
1506 focus_visibles: bevy_ecs::system::Query<(), With<lumen_core::input::FocusVisible>>,
1507 live: bevy_ecs::system::Query<Entity, Without<bevy_ecs::resource::IsResource>>,
1508 mut closed_out: bevy_ecs::message::MessageWriter<lumen_core::input::DialogClosed>,
1509) {
1510 for (dialog_e, visible, session, lumen_id, marker) in dialogs.iter_mut() {
1511 let open = visible.map(|v| v.0).unwrap_or(false);
1512 let Some(mut session) = session else {
1513 commands.entity(dialog_e).insert(DialogSession {
1518 open,
1519 ..Default::default()
1520 });
1521 continue;
1522 };
1523 if open && !session.open {
1524 session.open = true;
1526 session.needs_focus = true;
1527 session.pending_accept = false;
1528 session.prev_focus = tracker.0;
1529 session.prev_focus_visible = tracker
1530 .0
1531 .map(|e| focus_visibles.contains(e))
1532 .unwrap_or(false);
1533 } else if !open && session.open {
1534 session.open = false;
1536 session.needs_focus = false;
1537 let id = lumen_id
1538 .map(|i| i.0.clone())
1539 .unwrap_or_else(|| marker.signal_name.clone());
1540 closed_out.write(lumen_core::input::DialogClosed {
1541 entity: dialog_e,
1542 id,
1543 accepted: session.pending_accept,
1544 });
1545 session.pending_accept = false;
1546 let focus_inside = match tracker.0 {
1549 None => true,
1550 Some(cur) => {
1551 let mut e = cur;
1552 loop {
1553 if e == dialog_e {
1554 break true;
1555 }
1556 match parents.get(e) {
1557 Ok(co) => e = co.parent(),
1558 Err(_) => break false,
1559 }
1560 }
1561 }
1562 };
1563 if focus_inside {
1564 if let Some(cur) = tracker.0 {
1565 commands
1566 .entity(cur)
1567 .remove::<(lumen_core::input::Focused, lumen_core::input::FocusVisible)>();
1568 }
1569 match session.prev_focus.filter(|e| live.get(*e).is_ok()) {
1570 Some(prev) => {
1571 commands.entity(prev).insert(lumen_core::input::Focused);
1572 if session.prev_focus_visible {
1573 commands
1574 .entity(prev)
1575 .insert(lumen_core::input::FocusVisible);
1576 }
1577 tracker.0 = Some(prev);
1578 }
1579 None => {
1580 tracker.0 = None;
1581 }
1582 }
1583 }
1584 }
1585 if session.open && session.needs_focus {
1586 let mut descendants = Vec::new();
1589 collect_descendants(dialog_e, &children, &mut descendants);
1590 if descendants.is_empty() {
1591 continue; }
1593 let pick = |pred: &mut dyn FnMut(Entity) -> Option<u32>| -> Option<Entity> {
1594 let mut best: Option<(u32, Entity)> = None;
1595 for &d in &descendants {
1596 if let Some(order) = pred(d) {
1597 let candidate = (order, d);
1598 if best.map(|b| candidate < b).unwrap_or(true) {
1599 best = Some(candidate);
1600 }
1601 }
1602 }
1603 best.map(|(_, e)| e)
1604 };
1605 let target = pick(&mut |e| {
1606 autofocused
1607 .get(e)
1608 .ok()
1609 .map(|doc| doc.map(|d| d.0).unwrap_or(u32::MAX))
1610 })
1611 .or_else(|| {
1612 pick(&mut |e| {
1613 focusables
1614 .get(e)
1615 .ok()
1616 .filter(|(ti, _)| ti.0 >= 0)
1617 .map(|(_, doc)| doc.map(|d| d.0).unwrap_or(u32::MAX))
1618 })
1619 })
1620 .unwrap_or(dialog_e);
1621 if let Some(cur) = tracker.0
1622 && cur != target
1623 {
1624 commands
1625 .entity(cur)
1626 .remove::<(lumen_core::input::Focused, lumen_core::input::FocusVisible)>();
1627 }
1628 commands.entity(target).insert(lumen_core::input::Focused);
1629 tracker.0 = Some(target);
1630 session.needs_focus = false;
1631 }
1632 }
1633}
1634
1635#[allow(clippy::too_many_arguments, clippy::type_complexity)]
1651pub fn activate_dialog_default_on_enter(
1652 mut keys: bevy_ecs::message::MessageReader<lumen_core::input::KeyPressed>,
1653 tracker: bevy_ecs::system::Res<lumen_core::input::FocusTracker>,
1654 mut dialogs: bevy_ecs::system::Query<
1655 (
1656 Entity,
1657 Option<&lumen_core::components::Visible>,
1658 Option<&mut DialogSession>,
1659 ),
1660 With<DialogMarker>,
1661 >,
1662 children: bevy_ecs::system::Query<&bevy_ecs::hierarchy::Children>,
1663 tags: bevy_ecs::system::Query<&lumen_core::components::LumenTag>,
1664 inputs: bevy_ecs::system::Query<&lumen_core::components::TextInput>,
1665 defaults: bevy_ecs::system::Query<(), With<DefaultButton>>,
1666 disabled: bevy_ecs::system::Query<(), With<lumen_core::components::Disabled>>,
1667 orders: bevy_ecs::system::Query<&lumen_core::components::DocumentOrder>,
1668 mut clicks: bevy_ecs::message::MessageWriter<lumen_core::input::ClickEvent>,
1669) {
1670 let enter = keys.read().any(|k| {
1671 !k.repeat
1672 && matches!(
1673 &k.key,
1674 lumen_core::input::Key::Named(lumen_core::input::NamedKey::Enter)
1675 )
1676 });
1677 if !enter {
1678 return;
1679 }
1680 if let Some(focused) = tracker.0 {
1683 if tags.get(focused).is_ok_and(|t| t.0.as_ref() == "button") {
1684 return;
1685 }
1686 if inputs.get(focused).is_ok_and(|i| i.multiline) {
1687 return;
1688 }
1689 }
1690 let Some((dialog_e, session)) = dialogs
1692 .iter_mut()
1693 .filter(|(_, vis, _)| vis.map(|v| v.0).unwrap_or(false))
1694 .map(|(e, _, s)| (e, s))
1695 .next()
1696 else {
1697 return;
1698 };
1699 let mut descendants = Vec::new();
1700 collect_descendants(dialog_e, &children, &mut descendants);
1701 let best_by_order = |pred: &dyn Fn(Entity) -> bool| -> Option<Entity> {
1702 descendants
1703 .iter()
1704 .copied()
1705 .filter(|&e| pred(e) && !disabled.contains(e))
1706 .min_by_key(|&e| (orders.get(e).map(|d| d.0).unwrap_or(u32::MAX), e))
1707 };
1708 let target = best_by_order(&|e| defaults.contains(e))
1709 .or_else(|| best_by_order(&|e| tags.get(e).is_ok_and(|t| t.0.as_ref() == "button")));
1710 let Some(target) = target else {
1711 return;
1712 };
1713 if let Some(mut session) = session {
1714 session.pending_accept = true;
1715 }
1716 clicks.write(lumen_core::input::ClickEvent {
1717 entity: target,
1718 position: glam::Vec2::ZERO,
1719 button: lumen_core::input::PointerButton::Primary,
1720 local: None,
1721 });
1722}
1723
1724pub fn mark_dialog_accept_on_default_click(
1729 mut clicks: bevy_ecs::message::MessageReader<lumen_core::input::ClickEvent>,
1730 defaults: bevy_ecs::system::Query<(), With<DefaultButton>>,
1731 parents: bevy_ecs::system::Query<&bevy_ecs::hierarchy::ChildOf>,
1732 mut sessions: bevy_ecs::system::Query<&mut DialogSession, With<DialogMarker>>,
1733) {
1734 for ev in clicks.read() {
1735 let mut cur = Some(ev.entity);
1738 let mut hit_default = false;
1739 while let Some(e) = cur {
1740 if !hit_default && defaults.contains(e) {
1741 hit_default = true;
1742 }
1743 if hit_default && let Ok(mut session) = sessions.get_mut(e) {
1744 session.pending_accept = true;
1745 break;
1746 }
1747 cur = parents.get(e).ok().map(|c| c.parent());
1748 }
1749 }
1750}
1751
1752#[allow(clippy::too_many_arguments)]
1773pub fn reconcile_for_blocks(
1774 array_signals: bevy_ecs::system::Res<lumen_core::signals::ArraySignals>,
1775 store: bevy_ecs::system::Res<PropertyStore>,
1782 mut markers: bevy_ecs::system::Query<(
1783 Entity,
1784 &mut ForMarker,
1785 Option<&bevy_ecs::hierarchy::Children>,
1786 )>,
1787 mut commands: bevy_ecs::system::Commands,
1788 lookup: RowLookup,
1789 parents: bevy_ecs::system::Query<&bevy_ecs::hierarchy::ChildOf>,
1793 scroll_state: bevy_ecs::system::Query<
1794 (
1795 &lumen_core::input::ScrollOffset,
1796 &lumen_core::components::Transform,
1797 ),
1798 With<lumen_core::input::Scroll>,
1799 >,
1800 styles: bevy_ecs::system::Query<&lumen_core::components::Style>,
1801 stylesheet: Option<bevy_ecs::system::Res<LumenStylesheet>>,
1802 policy: Option<bevy_ecs::system::Res<ScenePolicy>>,
1803) {
1804 let policy = policy.map(|p| *p).unwrap_or_default();
1805 let arrays_changed = array_signals.is_changed();
1808 let css_changed = stylesheet.as_ref().map(|s| s.is_changed()).unwrap_or(false);
1809 let row_css = match policy.row_style {
1812 RowStyle::Cascade => stylesheet.as_ref().map(|s| &s.0),
1813 RowStyle::HostStyled => None,
1814 };
1815 for (parent_id, mut marker, children) in markers.iter_mut() {
1816 let report_missing = |field: &str| warn_missing_row_field_once(parent_id, field);
1820 let items: &[ArrayItem] = array_signals
1824 .get(&marker.array_name)
1825 .unwrap_or(&[] as &[ArrayItem]);
1826
1827 if marker.virtualized && policy.virtualization == Virtualization::Enabled {
1840 let row_h = marker.row_height.max(1.0);
1841 let mut anc = parents.get(parent_id).ok().map(|c| c.parent());
1848 let mut offset_y = 0.0_f32;
1849 let mut viewport_h = 600.0_f32;
1850 while let Some(e) = anc {
1851 if let Ok((off, t)) = scroll_state.get(e) {
1852 offset_y = off.0.y.max(0.0);
1853 viewport_h = t.size.y.max(0.0);
1854 break;
1855 }
1856 anc = parents.get(e).ok().map(|c| c.parent());
1857 }
1858 const BUFFER_ROWS: usize = 8;
1870 let first = ((offset_y / row_h).floor() as i64 - BUFFER_ROWS as i64).max(0) as usize;
1871 let last =
1872 (((offset_y + viewport_h) / row_h).ceil() as usize + BUFFER_ROWS).min(items.len());
1873
1874 let content_h = items.len() as f32 * row_h;
1890 let extent = lumen_core::components::Length::Px(content_h);
1891 if styles
1897 .get(parent_id)
1898 .map(|s| s.height != extent || s.min_height != extent)
1899 .unwrap_or(true)
1900 {
1901 let held = StyleValues {
1902 width: Some(lumen_core::components::Length::Percent(100.0)),
1903 height: Some(extent),
1904 min_height: Some(extent),
1905 ..StyleValues::default()
1906 };
1907 commands.entity(parent_id).queue(
1908 move |mut ent: bevy_ecs::world::EntityWorldMut<'_>| {
1909 lumen_core::style_overrides::hold_on::<ForMarker>(&mut ent, held);
1910 },
1911 );
1912 }
1913
1914 let key_field = marker.key_field.clone();
1917 let key_of = |i: usize, item: &ArrayItem| -> String {
1918 key_field
1919 .as_ref()
1920 .and_then(|f| item.get(f).cloned())
1921 .unwrap_or_else(|| i.to_string())
1922 };
1923 let desired: Vec<(usize, String)> = items
1924 .iter()
1925 .enumerate()
1926 .skip(first)
1927 .take(last.saturating_sub(first))
1928 .map(|(i, item)| (i, key_of(i, item)))
1929 .collect();
1930
1931 if css_changed {
1934 marker.cascaded_body = None;
1935 }
1936
1937 if children.is_some() && marker.win_rows == desired && !arrays_changed {
1942 continue;
1943 }
1944
1945 let body_len = marker.body.len().max(1);
1946 let kids: Vec<Entity> = children.map(|c| c.iter().collect()).unwrap_or_default();
1947 let aligned = kids.len() == marker.win_rows.len() * body_len;
1951
1952 let desired_by_idx: std::collections::HashMap<usize, &str> =
1955 desired.iter().map(|(i, k)| (*i, k.as_str())).collect();
1956 let mut new_win: Vec<(usize, String)> = Vec::new();
1957 let mut new_items: Vec<ArrayItem> = Vec::new();
1958 let mut patches: Vec<RowPatch> = Vec::new();
1959 let row_template = RowTemplate {
1960 body: &marker.body,
1961 store: &store,
1962 css: row_css,
1963 };
1964 if aligned {
1965 for (row_i, (idx, key)) in marker.win_rows.iter().enumerate() {
1966 let slice = &kids[row_i * body_len..(row_i + 1) * body_len];
1967 let was = marker.row_items.get(row_i);
1968 let item =
1973 (desired_by_idx.get(idx) == Some(&key.as_str())).then(|| &items[*idx]);
1974 let kept = item.filter(|&item| {
1977 was == Some(item)
1978 || row_template.plan(
1979 slice,
1980 (was, item),
1981 *idx,
1982 &report_missing,
1983 &lookup,
1984 &mut patches,
1985 )
1986 });
1987 if let Some(item) = kept {
1988 new_win.push((*idx, key.clone()));
1989 new_items.push(item.clone());
1990 } else {
1991 for child in slice {
1992 commands.entity(*child).despawn();
1993 }
1994 }
1995 }
1996 } else {
1997 for child in &kids {
1998 commands.entity(*child).despawn();
1999 }
2000 }
2001 let mounted: std::collections::HashSet<usize> =
2002 new_win.iter().map(|(i, _)| *i).collect();
2003
2004 let per_row_cascade = body_has_dynamic_selector_attrs(&marker.body);
2011 let template: std::sync::Arc<Vec<Element>> = match (per_row_cascade, row_css) {
2012 (true, _) | (false, None) => std::sync::Arc::new(marker.body.clone()),
2013 (false, Some(_)) if marker.cascaded_body.is_some() => {
2014 marker.cascaded_body.clone().expect("checked above")
2015 }
2016 (false, Some(sheet)) => {
2017 let cascaded: Vec<Element> = marker
2018 .body
2019 .iter()
2020 .map(|el| {
2021 let mut c = el.clone();
2022 cascade_element_tree(&mut c, sheet);
2023 c
2024 })
2025 .collect();
2026 let arc = std::sync::Arc::new(cascaded);
2027 marker.cascaded_body = Some(arc.clone());
2028 arc
2029 }
2030 };
2031
2032 for (i, key) in &desired {
2033 if mounted.contains(i) {
2034 continue;
2035 }
2036 let item = &items[*i];
2037 let scope = Scope::new(&*store)
2038 .with_row(item, *i)
2039 .reporting_to(&report_missing);
2040 for tmpl in template.iter() {
2041 let inst = substitute_in_element_with_css(
2044 tmpl,
2045 &scope,
2046 if per_row_cascade { row_css } else { None },
2047 );
2048 let slot = StyleValues {
2056 position: Some(lumen_core::components::Position::Absolute),
2057 inset: Some(lumen_core::components::Edges {
2058 top: *i as f32 * row_h,
2059 right: f32::NAN,
2060 bottom: f32::NAN,
2061 left: 0.0,
2062 ..lumen_core::components::Edges::default()
2063 }),
2064 height: Some(lumen_core::components::Length::Px(row_h)),
2065 width: Some(lumen_core::components::Length::Percent(100.0)),
2066 ..StyleValues::default()
2067 };
2068 spawn_body_child(
2069 &mut commands,
2070 &inst,
2071 parent_id,
2072 Placeholders::Resolved,
2073 BodyChild::VirtualRow(Box::new(slot)),
2074 );
2075 }
2076 new_win.push((*i, key.clone()));
2077 new_items.push(item.clone());
2078 }
2079 if !patches.is_empty() {
2080 commands.queue(move |world: &mut World| apply_row_patches(world, patches));
2081 }
2082 let mut row_order: Vec<usize> = (0..new_win.len()).collect();
2093 row_order.sort_unstable_by_key(|&r| new_win[r].0);
2094 if row_order.iter().enumerate().any(|(slot, &r)| slot != r) {
2095 new_win = row_order.iter().map(|&r| new_win[r].clone()).collect();
2096 new_items = row_order.iter().map(|&r| new_items[r].clone()).collect();
2097 commands.queue(move |world: &mut World| {
2098 sort_virtual_rows(world, parent_id, body_len, &row_order);
2099 });
2100 }
2101 marker.win_rows = new_win;
2102 marker.row_items = new_items;
2103 continue;
2104 }
2105
2106 let new_keys: Vec<String> = items
2109 .iter()
2110 .enumerate()
2111 .map(|(i, item)| {
2112 marker
2113 .key_field
2114 .as_ref()
2115 .and_then(|f| item.get(f).cloned())
2116 .unwrap_or_else(|| i.to_string())
2117 })
2118 .collect();
2119
2120 let same_keys = new_keys == marker.cached_keys && children.is_some();
2121 if same_keys && !arrays_changed {
2122 continue;
2123 }
2124
2125 let body_len = marker.body.len();
2126 let cached = marker.cached_keys.clone();
2127 let appended = cached.len() < new_keys.len() && new_keys[..cached.len()] == cached[..];
2128 let trimmed = new_keys.len() < cached.len() && cached[..new_keys.len()] == new_keys[..];
2129 let kids: Vec<Entity> = children.map(|c| c.iter().collect()).unwrap_or_default();
2130
2131 let kept_rows =
2135 (same_keys || appended || trimmed).then(|| cached.len().min(new_keys.len()));
2136 let aligned = kids.len() == cached.len() * body_len;
2137 let followed = kept_rows.and_then(|kept| {
2138 let row_template = RowTemplate {
2139 body: &marker.body,
2140 store: &store,
2141 css: row_css,
2142 };
2143 let mut patches: Vec<RowPatch> = Vec::new();
2144 let mut moved = false;
2145 for (i, item) in items.iter().enumerate().take(kept) {
2146 let was = marker.row_items.get(i);
2147 if was == Some(item) {
2148 continue;
2149 }
2150 moved = true;
2151 if !aligned {
2152 return None;
2153 }
2154 let roots = &kids[i * body_len..(i + 1) * body_len];
2155 if !row_template.plan(
2156 roots,
2157 (was, item),
2158 i,
2159 &report_missing,
2160 &lookup,
2161 &mut patches,
2162 ) {
2163 return None;
2164 }
2165 }
2166 Some((patches, moved))
2167 });
2168
2169 if let Some((patches, moved)) = followed {
2170 if !patches.is_empty() {
2171 commands.queue(move |world: &mut World| apply_row_patches(world, patches));
2172 }
2173 if appended {
2175 for (i, item) in items.iter().enumerate().skip(cached.len()) {
2176 let scope = Scope::new(&*store)
2177 .with_row(item, i)
2178 .reporting_to(&report_missing);
2179 for tmpl in &marker.body {
2180 let inst = substitute_in_element_with_css(tmpl, &scope, row_css);
2181 spawn_body_child(
2182 &mut commands,
2183 &inst,
2184 parent_id,
2185 Placeholders::Resolved,
2186 BodyChild::Row,
2187 );
2188 }
2189 }
2190 }
2191 if trimmed {
2194 let drop_count = (cached.len() - new_keys.len()) * body_len;
2195 for child in kids.iter().rev().take(drop_count) {
2196 commands.entity(*child).despawn();
2197 }
2198 }
2199 if moved || !same_keys {
2200 marker.row_items = items.to_vec();
2201 }
2202 marker.cached_keys = new_keys;
2203 continue;
2204 }
2205
2206 if let Some(kids) = children {
2211 for child in kids.iter() {
2212 commands.entity(child).despawn();
2213 }
2214 }
2215 for (i, item) in items.iter().enumerate() {
2216 let scope = Scope::new(&*store)
2217 .with_row(item, i)
2218 .reporting_to(&report_missing);
2219 for tmpl in &marker.body {
2220 let inst = substitute_in_element_with_css(tmpl, &scope, row_css);
2221 spawn_body_child(
2222 &mut commands,
2223 &inst,
2224 parent_id,
2225 Placeholders::Resolved,
2226 BodyChild::Row,
2227 );
2228 }
2229 }
2230 marker.cached_keys = new_keys;
2231 marker.row_items = items.to_vec();
2232 }
2233}
2234
2235#[derive(Clone, Copy, Debug, Default)]
2240struct RowStringChanges {
2241 strings: bool,
2244 id: bool,
2245 classes: bool,
2246 src: bool,
2247 style_role: bool,
2248 drag_payload: bool,
2249}
2250
2251impl RowStringChanges {
2252 fn between(old: &Attributes, new: &Attributes) -> Self {
2253 Self {
2254 strings: old.text != new.text || old.placeholder != new.placeholder,
2255 id: old.id != new.id,
2256 classes: old.classes != new.classes,
2257 src: old.src != new.src,
2258 style_role: old.style_role != new.style_role,
2259 drag_payload: old.drag_payload != new.drag_payload,
2260 }
2261 }
2262
2263 fn any(self) -> bool {
2264 self.strings || self.id || self.classes || self.src || self.style_role || self.drag_payload
2265 }
2266}
2267
2268type RowPatch = (Entity, Box<Attributes>, RowStringChanges);
2271
2272fn use_args_differ(old: &Element, new: &Element) -> bool {
2274 match (&old.frag_use, &new.frag_use) {
2275 (Some(a), Some(b)) => a.args != b.args,
2276 (None, None) => false,
2277 _ => true,
2278 }
2279}
2280
2281fn same_resolution(old: &Element, new: &Element) -> bool {
2284 !RowStringChanges::between(&old.attrs, &new.attrs).any()
2285 && !use_args_differ(old, new)
2286 && old.children.len() == new.children.len()
2287 && old
2288 .children
2289 .iter()
2290 .zip(&new.children)
2291 .all(|(a, b)| same_resolution(a, b))
2292}
2293
2294fn generated_children(tag: &str) -> usize {
2298 usize::from(matches!(tag, "toggle" | "switch" | "slider"))
2299}
2300
2301fn plan_row_element(
2307 entity: Entity,
2308 old: &Element,
2309 new: &Element,
2310 lookup: &RowLookup<'_, '_>,
2311 out: &mut Vec<RowPatch>,
2312) -> bool {
2313 if !new.tag.is_empty() && lookup.tags.get(entity).map(|t| &*t.0) != Ok(new.tag.as_str()) {
2314 return false;
2315 }
2316 if use_args_differ(old, new) {
2317 return false;
2318 }
2319 let changes = RowStringChanges::between(&old.attrs, &new.attrs);
2320 if changes.any() {
2321 out.push((entity, Box::new(new.attrs.clone()), changes));
2322 }
2323 if old.children.len() != new.children.len() {
2324 return false;
2325 }
2326 let holds_template = new.frag_use.is_some()
2330 || (new.tag == "for" && new.attrs.each.is_some())
2331 || (matches!(new.tag.as_str(), "if" | "dialog") && new.attrs.if_signal.is_some());
2332 if holds_template {
2333 return old
2334 .children
2335 .iter()
2336 .zip(&new.children)
2337 .all(|(a, b)| same_resolution(a, b));
2338 }
2339 if new.children.is_empty() {
2340 return true;
2341 }
2342 let skip = generated_children(&new.tag);
2343 let Some(kids) = lookup
2344 .children
2345 .get(entity)
2346 .ok()
2347 .and_then(|c| c.get(skip..skip + new.children.len()))
2348 else {
2349 return false;
2350 };
2351 kids.iter()
2352 .zip(old.children.iter().zip(&new.children))
2353 .all(|(&kid, (o, n))| plan_row_element(kid, o, n, lookup, out))
2354}
2355
2356#[derive(bevy_ecs::system::SystemParam)]
2359pub struct RowLookup<'w, 's> {
2360 children: bevy_ecs::system::Query<'w, 's, &'static bevy_ecs::hierarchy::Children>,
2361 tags: bevy_ecs::system::Query<'w, 's, &'static lumen_core::components::LumenTag>,
2362}
2363
2364struct RowTemplate<'a> {
2368 body: &'a [Element],
2369 store: &'a PropertyStore,
2370 css: Option<&'a lumen_ir::css::Stylesheet>,
2371}
2372
2373impl RowTemplate<'_> {
2374 fn plan(
2379 &self,
2380 roots: &[Entity],
2381 (old, new): (Option<&ArrayItem>, &ArrayItem),
2382 index: usize,
2383 report_missing: &dyn Fn(&str),
2384 lookup: &RowLookup<'_, '_>,
2385 out: &mut Vec<RowPatch>,
2386 ) -> bool {
2387 if roots.len() != self.body.len() {
2388 return false;
2389 }
2390 let unknown = ArrayItem::new();
2391 let old_scope = Scope::new(self.store).with_row(old.unwrap_or(&unknown), index);
2392 let new_scope = Scope::new(self.store)
2393 .with_row(new, index)
2394 .reporting_to(report_missing);
2395 roots.iter().zip(self.body).all(|(&root, tmpl)| {
2396 let before = substitute_in_element_with_css(tmpl, &old_scope, self.css);
2397 let after = substitute_in_element_with_css(tmpl, &new_scope, self.css);
2398 plan_row_element(root, &before, &after, lookup, out)
2399 })
2400 }
2401}
2402
2403fn apply_row_patches(world: &mut World, patches: Vec<RowPatch>) {
2407 use lumen_core::components::{BindText, LumenTag, TextInput};
2408 use lumen_core::input::Focused;
2409 use lumen_primitives::TooltipSource;
2410 let mut shown = world.query::<(
2411 Option<&mut TextContent>,
2412 Option<&mut TextInput>,
2413 Option<&mut TooltipSource>,
2414 Has<Focused>,
2415 Has<BindText>,
2416 )>();
2417 for (entity, attrs, changes) in patches {
2418 if world.get_entity(entity).is_err() {
2419 continue;
2420 }
2421 if changes.strings {
2422 let authored = AuthoredStrings::from(&*attrs);
2423 let strings = resolve_strings(
2424 world.get_resource::<lumen_core::i18n::AppI18n>(),
2425 &authored,
2426 attrs.format.as_deref(),
2427 );
2428 if world.get::<AuthoredStrings>(entity).is_some() {
2429 world.entity_mut(entity).insert(authored);
2430 }
2431 if let Ok((text, input, tooltip, focused, bound)) = shown.get_mut(world, entity) {
2432 let text = if bound { None } else { text };
2434 crate::i18n::write_strings(strings, text, input, tooltip, focused);
2435 }
2436 }
2437 let mut e = world.entity_mut(entity);
2438 if changes.id {
2441 match &attrs.id {
2442 Some(id) => e.insert(LumenId(id.clone())),
2443 None => e.remove::<LumenId>(),
2444 };
2445 e.insert(lumen_core::components::StyleDirty);
2446 }
2447 if changes.classes {
2448 if attrs.classes.is_empty() {
2449 e.remove::<LumenClasses>();
2450 } else {
2451 e.insert(LumenClasses::from(attrs.classes.clone()));
2452 }
2453 e.insert(lumen_core::components::StyleDirty);
2454 }
2455 if changes.src && e.get::<LumenTag>().is_some_and(|t| &*t.0 == "image") {
2456 match &attrs.src {
2457 Some(src) => e.insert(lumen_assets::ImageSource(std::path::PathBuf::from(src))),
2458 None => e.remove::<lumen_assets::ImageSource>(),
2459 };
2460 }
2461 if changes.style_role {
2462 match attrs.style_role.as_deref() {
2463 Some(role) => e.insert(TypographyRole(role.into())),
2464 None => e.remove::<TypographyRole>(),
2465 };
2466 e.insert(lumen_core::components::StyleDirty);
2467 }
2468 if changes.drag_payload || changes.id {
2470 match drag_source(&attrs) {
2471 Some(source) => e.insert(source),
2472 None => e.remove::<lumen_os_dnd::DragSource>(),
2473 };
2474 }
2475 }
2476}
2477
2478fn body_has_dynamic_selector_attrs(body: &[Element]) -> bool {
2484 fn walk(el: &Element) -> bool {
2485 if el.attrs.id.as_deref().is_some_and(|id| id.contains('{')) {
2486 return true;
2487 }
2488 if el.attrs.classes.iter().any(|c| c.contains('{')) {
2489 return true;
2490 }
2491 el.children.iter().any(walk)
2492 }
2493 body.iter().any(walk)
2494}
2495
2496fn cascade_element_tree(el: &mut Element, sheet: &lumen_ir::css::Stylesheet) {
2500 lumen_ir::css::reapply_detached(el, sheet, &lumen_ir::css::MediaContext::default());
2501 for child in el.children.iter_mut() {
2502 cascade_element_tree(child, sheet);
2503 }
2504}
2505
2506pub fn substitute_in_element_with_css(
2513 template: &Element,
2514 scope: &Scope<'_>,
2515 css: Option<&lumen_ir::css::Stylesheet>,
2516) -> Element {
2517 let mut instance = substitute_element(template, scope);
2518 if let Some(sheet) = css {
2519 cascade_element_tree(&mut instance, sheet);
2520 }
2521 instance
2522}
2523
2524fn warn_missing_row_field_once(parent_id: Entity, field: &str) {
2528 use std::collections::HashSet;
2529 use std::sync::Mutex;
2530 static SEEN: std::sync::OnceLock<Mutex<HashSet<(u64, String)>>> = std::sync::OnceLock::new();
2531 let key = (parent_id.to_bits(), field.to_string());
2532 let seen = SEEN.get_or_init(|| Mutex::new(HashSet::new()));
2533 let mut guard = seen.lock().expect("warn-once mutex poisoned");
2534 if guard.insert(key) {
2535 tracing::warn!(
2536 target: "lumenc::spawn::row",
2537 "<for> row field `{field}` not present on iteration record - substituting empty string"
2538 );
2539 }
2540}
2541
2542fn spawn_body_child(
2565 commands: &mut bevy_ecs::system::Commands,
2566 el: &Element,
2567 parent: Entity,
2568 placeholders: Placeholders,
2569 kind: BodyChild,
2570) {
2571 let el = el.clone();
2572 commands.queue(move |world: &mut World| {
2573 let id = spawn_subtree(world, &el, Some(parent), placeholders);
2574 match kind {
2575 BodyChild::Gated => {
2583 world
2584 .entity_mut(id)
2585 .insert(lumen_core::components::StyleDirty);
2586 }
2587 BodyChild::Row => {}
2588 BodyChild::VirtualRow(slot) => {
2591 let mut ent = world.entity_mut(id);
2592 ent.insert(VirtualRow);
2593 lumen_core::style_overrides::hold_on::<VirtualRow>(&mut ent, *slot);
2594 }
2595 }
2596 let specs = world.get::<lumen_primitives::TransitionSpecs>(id).cloned();
2605 let cur = world.get::<Opacity>(id).copied();
2606 if let Some(bundle) = mount_fade_bundle(specs.as_ref(), cur) {
2607 world.entity_mut(id).insert(bundle);
2608 }
2609 });
2610}
2611
2612enum BodyChild {
2614 Gated,
2616 Row,
2618 VirtualRow(Box<StyleValues>),
2620}
2621
2622#[derive(bevy_ecs::component::Component, Clone, Copy, Debug, Default)]
2626#[require(lumen_core::style_overrides::StyleOverrides)]
2627pub struct VirtualRow;
2628
2629impl StyleOwner for VirtualRow {
2630 const RANK: u8 = 20;
2631}
2632
2633fn sort_virtual_rows(world: &mut World, parent: Entity, body_len: usize, row_order: &[usize]) {
2640 let Some(kids) = world
2641 .get::<bevy_ecs::hierarchy::Children>(parent)
2642 .map(|c| c.iter().collect::<Vec<Entity>>())
2643 else {
2644 return;
2645 };
2646 if kids.len() != row_order.len() * body_len {
2650 return;
2651 }
2652 let mut ordered: Vec<Entity> = Vec::with_capacity(kids.len());
2653 for &row in row_order {
2654 ordered.extend_from_slice(&kids[row * body_len..(row + 1) * body_len]);
2655 }
2656 world.entity_mut(parent).insert_children(0, &ordered);
2657}
2658
2659fn mount_fade_bundle(
2664 specs: Option<&lumen_primitives::TransitionSpecs>,
2665 current: Option<Opacity>,
2666) -> Option<(Opacity, lumen_primitives::OpacityTransition)> {
2667 let spec = specs?.for_property(lumen_primitives::TransitionProperty::Opacity)?;
2668 if spec.duration.is_zero() {
2669 return None;
2670 }
2671 let target = current.map(|o| o.0).unwrap_or(1.0);
2672 if target <= 0.0 {
2673 return None;
2674 }
2675 Some((
2676 Opacity(0.0),
2677 lumen_primitives::OpacityTransition(lumen_primitives::Transition::new(
2678 0.0,
2679 target,
2680 spec.duration,
2681 spec.easing,
2682 )),
2683 ))
2684}
2685
2686pub fn apply_ua_style_defaults(tag: &str, attrs: &Attributes, page_gate: bool, style: &mut Style) {
2730 if tag == "switch" && attrs.width.is_none() && attrs.min_width.is_none() {
2731 style.min_width = Length::Px(52.0);
2732 }
2733 if page_gate && attrs.position.is_none() {
2734 style.position = Position::Absolute;
2735 style.inset = Edges::default();
2736 }
2737 if tag == "dialog" {
2738 if attrs.position.is_none() {
2739 style.position = Position::Absolute;
2740 }
2741 if attrs.inset.is_none() {
2742 style.inset = Edges::default();
2743 }
2744 }
2745 if matches!(tag, "input" | "textarea") {
2746 if attrs.overflow.is_none() && attrs.overflow_x.is_none() {
2751 style.overflow_x = Overflow::Hidden;
2752 }
2753 if attrs.overflow.is_none() && attrs.overflow_y.is_none() {
2754 style.overflow_y = Overflow::Hidden;
2755 }
2756 }
2757 if let Some(axis) = attrs.scroll {
2758 let axis: ScrollAxis = axis.into();
2768 let overflow_for = |scrolls: bool| {
2769 if scrolls {
2770 Overflow::Scroll
2771 } else {
2772 Overflow::Hidden
2773 }
2774 };
2775 if attrs.overflow.is_none() && attrs.overflow_x.is_none() {
2776 style.overflow_x = overflow_for(axis.allows_x());
2777 }
2778 if attrs.overflow.is_none() && attrs.overflow_y.is_none() {
2779 style.overflow_y = overflow_for(axis.allows_y());
2780 }
2781 }
2782}
2783
2784#[cfg(test)]
2785mod ua_style_tests {
2786 use super::*;
2787 use lumen_ir::layout_ir::{OverflowSpec, ScrollAxisSpec};
2788
2789 fn ua_style(attrs: &Attributes) -> Style {
2790 let mut style = Style::from(attrs);
2791 apply_ua_style_defaults("scroll", attrs, false, &mut style);
2792 style
2793 }
2794
2795 #[test]
2796 fn a_scroller_scrolls_its_axes_and_clips_the_other() {
2797 let y = ua_style(&Attributes {
2798 scroll: Some(ScrollAxisSpec::Y),
2799 ..Attributes::default()
2800 });
2801 assert_eq!(
2802 (y.overflow_x, y.overflow_y),
2803 (Overflow::Hidden, Overflow::Scroll)
2804 );
2805 let x = ua_style(&Attributes {
2806 scroll: Some(ScrollAxisSpec::X),
2807 ..Attributes::default()
2808 });
2809 assert_eq!(
2810 (x.overflow_x, x.overflow_y),
2811 (Overflow::Scroll, Overflow::Hidden)
2812 );
2813 let both = ua_style(&Attributes {
2814 scroll: Some(ScrollAxisSpec::Both),
2815 ..Attributes::default()
2816 });
2817 assert_eq!(
2818 (both.overflow_x, both.overflow_y),
2819 (Overflow::Scroll, Overflow::Scroll)
2820 );
2821 }
2822
2823 #[test]
2824 fn an_authored_overflow_wins_over_the_scroller_default() {
2825 let s = ua_style(&Attributes {
2826 scroll: Some(ScrollAxisSpec::Both),
2827 overflow: Some(OverflowSpec::Hidden),
2828 ..Attributes::default()
2829 });
2830 assert_eq!(
2831 (s.overflow_x, s.overflow_y),
2832 (Overflow::Hidden, Overflow::Hidden)
2833 );
2834 let s = ua_style(&Attributes {
2835 scroll: Some(ScrollAxisSpec::Y),
2836 overflow_y: Some(OverflowSpec::Visible),
2837 ..Attributes::default()
2838 });
2839 assert_eq!(s.overflow_y, Overflow::Visible);
2840 }
2841}
2842
2843fn knob_style(size: f32, inset_px: f32) -> Style {
2849 Style {
2850 position: lumen_core::components::Position::Absolute,
2851 width: Length::Px(size),
2852 height: Length::Px(size),
2853 inset: lumen_core::components::Edges {
2854 left: inset_px,
2855 top: inset_px,
2856 right: f32::NAN,
2857 bottom: f32::NAN,
2858 ..Default::default()
2859 },
2860 ..Default::default()
2861 }
2862}
2863
2864#[cfg(test)]
2865mod body_spawn_tests {
2866 use super::*;
2873 use bevy_ecs::system::RunSystemOnce;
2874
2875 fn el(tag: &str) -> Element {
2876 Element {
2877 tag: tag.to_string(),
2878 ..Element::default()
2879 }
2880 }
2881
2882 #[test]
2883 fn if_body_children_spawn_with_full_widget_behavior() {
2884 let mut world = World::new();
2885 world.insert_resource(PropertyStore::default());
2886 world
2887 .resource_mut::<PropertyStore>()
2888 .set_global_str("open", "1");
2889
2890 let mut toggle = el("toggle");
2891 toggle.attrs.tab_index = Some(0);
2892 let mut slider = el("slider");
2893 slider.attrs.tab_index = Some(0);
2894 let mut input = el("input");
2895 input.attrs.tab_index = Some(0);
2896 let mut scroll = el("scroll");
2897 scroll.attrs.scroll = Some(lumen_ir::layout_ir::ScrollAxisSpec::Y);
2898 let mut header = el("button");
2899 header.attrs.dropdown_button = Some(lumen_ir::layout_ir::DropdownButtonSpec {
2900 open_signal: "__dropdown_open:x".to_string(),
2901 value_signal: "x".to_string(),
2902 options: vec![("a".to_string(), "A".to_string(), false)],
2903 });
2904
2905 world.spawn(IfMarker {
2906 signal_name: "open".to_string(),
2907 body: vec![toggle, slider, input, scroll, header],
2908 currently_mounted: false,
2909 mode: IfMode::Hide,
2910 eq: None,
2911 applied_visible: None,
2912 });
2913 world.run_system_once(reconcile_if_blocks).unwrap();
2914
2915 let toggle_e = world
2917 .query::<(Entity, &Toggleable)>()
2918 .iter(&world)
2919 .next()
2920 .expect("toggle in an if-body must carry Toggleable")
2921 .0;
2922 let knob_parent = world
2923 .query_filtered::<&ChildOf, With<lumen_primitives::ToggleKnob>>()
2924 .iter(&world)
2925 .next()
2926 .expect("toggle in an if-body must spawn its knob child")
2927 .parent();
2928 assert_eq!(knob_parent, toggle_e, "knob is the toggle's child");
2929
2930 let slider_e = world
2932 .query::<(Entity, &SliderValue)>()
2933 .iter(&world)
2934 .next()
2935 .expect("slider in an if-body must carry SliderValue")
2936 .0;
2937 let thumb_parent = world
2938 .query_filtered::<&ChildOf, With<lumen_primitives::SliderThumb>>()
2939 .iter(&world)
2940 .next()
2941 .expect("slider in an if-body must spawn its thumb child")
2942 .parent();
2943 assert_eq!(thumb_parent, slider_e, "thumb is the slider's child");
2944
2945 assert_eq!(
2948 world.query::<&TextInput>().iter(&world).count(),
2949 1,
2950 "input in an if-body must carry TextInput"
2951 );
2952 assert_eq!(
2953 world
2954 .query::<(&Scroll, &ScrollOffset)>()
2955 .iter(&world)
2956 .count(),
2957 1,
2958 "scroll in an if-body must carry Scroll + ScrollOffset"
2959 );
2960 assert_eq!(
2961 world
2962 .query::<&lumen_primitives::DropdownButton>()
2963 .iter(&world)
2964 .count(),
2965 1,
2966 "dropdown header in an if-body must carry DropdownButton"
2967 );
2968 for (label, e) in [("slider", slider_e), ("toggle", toggle_e)] {
2972 assert!(
2973 world.get::<Visuals>(e).is_some(),
2974 "unstyled {label} must carry UA fallback Visuals"
2975 );
2976 }
2977 let input_e = world
2978 .query_filtered::<Entity, With<TextInput>>()
2979 .iter(&world)
2980 .next()
2981 .unwrap();
2982 assert!(
2983 world.get::<Visuals>(input_e).is_some(),
2984 "unstyled input must carry UA fallback Visuals"
2985 );
2986
2987 assert_eq!(
2989 world
2990 .query::<(&TabIndex, &lumen_core::components::DocumentOrder)>()
2991 .iter(&world)
2992 .count(),
2993 3,
2994 "if-body focusables must carry TabIndex + DocumentOrder"
2995 );
2996 }
2997}
2998
2999#[cfg(test)]
3000mod document_order_tests {
3001 use super::*;
3008 use bevy_ecs::system::RunSystemOnce;
3009
3010 fn el(tag: &str) -> Element {
3011 Element {
3012 tag: tag.to_string(),
3013 ..Element::default()
3014 }
3015 }
3016
3017 fn order_of(world: &World, e: Entity) -> u32 {
3018 world
3019 .get::<lumen_core::components::DocumentOrder>(e)
3020 .expect("every spawned element carries DocumentOrder")
3021 .0
3022 }
3023
3024 fn tree_with_a_gate(rows: usize) -> (World, Entity, Entity, Entity, Entity) {
3028 let mut world = World::new();
3029 world.insert_resource(PropertyStore::default());
3030 world
3031 .resource_mut::<PropertyStore>()
3032 .set_global_str("open", "1");
3033
3034 let mut gate = el("if");
3035 gate.attrs.if_signal = Some("open".to_string());
3036 gate.children = vec![el("button")];
3037 let mut list = el("column");
3038 list.children = (0..rows).map(|_| el("button")).collect();
3039 let mut root = el("root");
3040 root.children = vec![el("button"), gate, list, el("button")];
3041
3042 let root_e = spawn_subtree(&mut world, &root, None, Placeholders::Unresolved);
3043 let kids: Vec<Entity> = world
3044 .get::<bevy_ecs::hierarchy::Children>(root_e)
3045 .map(|c| c.iter().collect())
3046 .expect("root has children");
3047 let (head, gate_e, list_e, tail) = (kids[0], kids[1], kids[2], kids[3]);
3048
3049 world.run_system_once(reconcile_if_blocks).unwrap();
3050 let gated = world
3051 .get::<bevy_ecs::hierarchy::Children>(gate_e)
3052 .and_then(|c| c.iter().next())
3053 .expect("the if body mounted");
3054 let first_row = world
3055 .get::<bevy_ecs::hierarchy::Children>(list_e)
3056 .and_then(|c| c.iter().next())
3057 .expect("the list has rows");
3058 world.run_system_once(renumber_document_order).unwrap();
3059 (world, head, gated, first_row, tail)
3060 }
3061
3062 #[test]
3063 fn a_mounted_if_body_sorts_where_it_sits_not_where_it_landed() {
3064 let (world, head, gated, first_row, tail) = tree_with_a_gate(500);
3065 assert!(
3066 order_of(&world, head) < order_of(&world, gated),
3067 "the gated button follows the head button"
3068 );
3069 assert!(
3070 order_of(&world, gated) < order_of(&world, first_row),
3071 "the gated button precedes the list beside it"
3072 );
3073 assert!(
3074 order_of(&world, first_row) < order_of(&world, tail),
3075 "the list precedes the tail button"
3076 );
3077 }
3078
3079 #[test]
3083 fn list_size_does_not_change_the_order() {
3084 let (world, head, gated, first_row, tail) = tree_with_a_gate(1);
3085 assert!(order_of(&world, head) < order_of(&world, gated));
3086 assert!(order_of(&world, gated) < order_of(&world, first_row));
3087 assert!(order_of(&world, first_row) < order_of(&world, tail));
3088 }
3089
3090 #[test]
3093 fn a_settled_tree_is_left_alone() {
3094 let (mut world, _, gated, _, _) = tree_with_a_gate(4);
3095 let before = order_of(&world, gated);
3096 world.clear_trackers();
3097 world.run_system_once(renumber_document_order).unwrap();
3098 assert_eq!(order_of(&world, gated), before);
3099 }
3100}
3101
3102#[cfg(test)]
3103mod escape_tests {
3104 use super::*;
3108 use bevy_ecs::message::Messages;
3109 use bevy_ecs::system::RunSystemOnce;
3110 use lumen_core::input::{Key, KeyPressed, Modifiers, NamedKey};
3111
3112 fn if_marker(signal: &str) -> IfMarker {
3113 IfMarker {
3114 signal_name: signal.to_string(),
3115 body: Vec::new(),
3116 currently_mounted: true,
3117 mode: IfMode::Hide,
3118 eq: None,
3119 applied_visible: None,
3120 }
3121 }
3122
3123 fn press_escape(world: &mut World) {
3124 world
3125 .resource_mut::<Messages<KeyPressed>>()
3126 .write(KeyPressed {
3127 key: Key::Named(NamedKey::Escape),
3128 modifiers: Modifiers::default(),
3129 repeat: false,
3130 });
3131 }
3132
3133 fn run(world: &mut World) {
3134 world.run_system_once(close_dialogs_on_escape).unwrap();
3135 world.resource_mut::<Messages<KeyPressed>>().clear();
3136 }
3137
3138 #[test]
3139 fn escape_closes_dropdown_before_dialog() {
3140 let mut world = World::new();
3141 world.init_resource::<Messages<KeyPressed>>();
3142 world.insert_resource(PropertyStore::default());
3143
3144 world
3146 .resource_mut::<PropertyStore>()
3147 .set_global_bool("__dropdown_open:fruit", true);
3148 world.spawn(if_marker("__dropdown_open:fruit"));
3149 world
3150 .resource_mut::<PropertyStore>()
3151 .set_global_str("show_dialog", "1");
3152 world.spawn((if_marker("show_dialog"), DialogMarker));
3153
3154 press_escape(&mut world);
3156 run(&mut world);
3157 let store = world.resource::<PropertyStore>();
3158 assert_eq!(
3159 store.get_global_bool("__dropdown_open:fruit"),
3160 Some(false),
3161 "dropdown closed first"
3162 );
3163 assert_eq!(
3164 store.get_global_str("show_dialog").as_deref(),
3165 Some("1"),
3166 "dialog still open after one press"
3167 );
3168
3169 press_escape(&mut world);
3171 run(&mut world);
3172 assert_eq!(
3173 world
3174 .resource::<PropertyStore>()
3175 .get_global_str("show_dialog")
3176 .as_deref(),
3177 Some(""),
3178 "dialog closes on the second press"
3179 );
3180 }
3181
3182 #[test]
3183 fn escape_closes_menu_before_dialog() {
3184 let mut world = World::new();
3185 world.init_resource::<Messages<KeyPressed>>();
3186 world.insert_resource(PropertyStore::default());
3187
3188 world
3189 .resource_mut::<PropertyStore>()
3190 .set_global_bool("__menu_open:file", true);
3191 world.spawn(if_marker("__menu_open:file"));
3192 world
3193 .resource_mut::<PropertyStore>()
3194 .set_global_str("show_dialog", "1");
3195 world.spawn((if_marker("show_dialog"), DialogMarker));
3196
3197 press_escape(&mut world);
3198 run(&mut world);
3199 let store = world.resource::<PropertyStore>();
3200 assert_eq!(store.get_global_bool("__menu_open:file"), Some(false));
3201 assert_eq!(store.get_global_str("show_dialog").as_deref(), Some("1"));
3202 }
3203
3204 #[test]
3208 fn escape_consumed_by_press_cancel_leaves_dialog_open() {
3209 let mut world = World::new();
3210 world.init_resource::<Messages<KeyPressed>>();
3211 world.insert_resource(PropertyStore::default());
3212 world
3213 .resource_mut::<PropertyStore>()
3214 .set_global_str("show_dialog", "1");
3215 world.spawn((if_marker("show_dialog"), DialogMarker));
3216
3217 world.insert_resource(lumen_core::input::EscapePressCancel(true));
3219 press_escape(&mut world);
3220 run(&mut world);
3221 assert_eq!(
3222 world
3223 .resource::<PropertyStore>()
3224 .get_global_str("show_dialog")
3225 .as_deref(),
3226 Some("1"),
3227 "dialog stays open when the Escape cancelled a press"
3228 );
3229
3230 world.insert_resource(lumen_core::input::EscapePressCancel(false));
3232 press_escape(&mut world);
3233 run(&mut world);
3234 assert_eq!(
3235 world
3236 .resource::<PropertyStore>()
3237 .get_global_str("show_dialog")
3238 .as_deref(),
3239 Some(""),
3240 "un-consumed Escape still closes the dialog"
3241 );
3242 }
3243}
3244
3245#[cfg(test)]
3250mod dialog_contract_tests {
3251 use super::*;
3252 use bevy_ecs::message::Messages;
3253 use bevy_ecs::system::RunSystemOnce;
3254 use lumen_core::components::{DocumentOrder, LumenId, LumenTag, TabIndex, Visible};
3255 use lumen_core::input::{
3256 ClickEvent, DialogClosed, FocusTracker, Focused, Key, KeyPressed, Modifiers, NamedKey,
3257 PointerButton,
3258 };
3259
3260 fn if_marker(signal: &str) -> IfMarker {
3261 IfMarker {
3262 signal_name: signal.to_string(),
3263 body: Vec::new(),
3264 currently_mounted: true,
3265 mode: IfMode::Hide,
3266 eq: None,
3267 applied_visible: None,
3268 }
3269 }
3270
3271 struct Fixture {
3274 dialog: Entity,
3275 input: Entity,
3276 cancel: Entity,
3277 confirm: Entity,
3278 opener: Entity,
3279 }
3280
3281 fn build(world: &mut World, autofocus_input: bool) -> Fixture {
3282 world.init_resource::<Messages<DialogClosed>>();
3283 world.init_resource::<Messages<KeyPressed>>();
3284 world.init_resource::<Messages<ClickEvent>>();
3285 world.insert_resource(FocusTracker(None));
3286 let opener = world
3287 .spawn((LumenTag("button".into()), TabIndex(0), DocumentOrder(0)))
3288 .id();
3289 let dialog = world
3290 .spawn((DialogMarker, if_marker("dlg_open"), Visible(false)))
3291 .id();
3292 let mut input = world.spawn((
3293 LumenTag("input".into()),
3294 TabIndex(0),
3295 DocumentOrder(10),
3296 ChildOf(dialog),
3297 ));
3298 if autofocus_input {
3299 input.insert(AutoFocus);
3300 }
3301 let input = input.id();
3302 let cancel = world
3303 .spawn((
3304 LumenTag("button".into()),
3305 TabIndex(0),
3306 DocumentOrder(11),
3307 ChildOf(dialog),
3308 ))
3309 .id();
3310 let confirm = world
3311 .spawn((
3312 LumenTag("button".into()),
3313 TabIndex(0),
3314 DocumentOrder(12),
3315 DefaultButton,
3316 ChildOf(dialog),
3317 ))
3318 .id();
3319 Fixture {
3320 dialog,
3321 input,
3322 cancel,
3323 confirm,
3324 opener,
3325 }
3326 }
3327
3328 fn run_lifecycle(world: &mut World) {
3329 world.run_system_once(manage_dialog_lifecycle).unwrap();
3330 }
3331
3332 fn set_open(world: &mut World, dialog: Entity, open: bool) {
3333 world.entity_mut(dialog).insert(Visible(open));
3334 }
3335
3336 fn drain_closed(world: &mut World) -> Vec<DialogClosed> {
3337 world
3338 .resource_mut::<Messages<DialogClosed>>()
3339 .drain()
3340 .collect()
3341 }
3342
3343 #[test]
3344 fn open_focuses_autofocus_descendant() {
3345 let mut world = World::new();
3346 let fx = build(&mut world, true);
3347 world.insert_resource(FocusTracker(Some(fx.opener)));
3348 run_lifecycle(&mut world); set_open(&mut world, fx.dialog, true);
3350 run_lifecycle(&mut world); assert_eq!(world.resource::<FocusTracker>().0, Some(fx.input));
3352 assert!(world.get::<Focused>(fx.input).is_some());
3353 }
3354
3355 #[test]
3356 fn open_without_autofocus_focuses_first_focusable_descendant() {
3357 let mut world = World::new();
3358 let fx = build(&mut world, false);
3359 world.insert_resource(FocusTracker(Some(fx.opener)));
3360 run_lifecycle(&mut world);
3361 set_open(&mut world, fx.dialog, true);
3362 run_lifecycle(&mut world);
3363 assert_eq!(
3364 world.resource::<FocusTracker>().0,
3365 Some(fx.input),
3366 "input has the lowest DocumentOrder among focusables"
3367 );
3368 }
3369
3370 #[test]
3371 fn close_restores_previous_focus_and_fires_rejected_once() {
3372 let mut world = World::new();
3373 let fx = build(&mut world, true);
3374 world.insert_resource(FocusTracker(Some(fx.opener)));
3375 world.entity_mut(fx.opener).insert(Focused);
3376 run_lifecycle(&mut world);
3377 set_open(&mut world, fx.dialog, true);
3378 run_lifecycle(&mut world);
3379 assert_eq!(world.resource::<FocusTracker>().0, Some(fx.input));
3380 set_open(&mut world, fx.dialog, false);
3382 run_lifecycle(&mut world);
3383 assert_eq!(
3384 world.resource::<FocusTracker>().0,
3385 Some(fx.opener),
3386 "focus returns to the pre-open holder"
3387 );
3388 let closed = drain_closed(&mut world);
3389 assert_eq!(closed.len(), 1, "exactly one close event");
3390 assert!(!closed[0].accepted, "non-default close is rejected");
3391 run_lifecycle(&mut world);
3393 run_lifecycle(&mut world);
3394 assert!(drain_closed(&mut world).is_empty(), "never fires twice");
3395 }
3396
3397 #[test]
3398 fn default_button_click_resolves_close_as_accepted_exactly_once() {
3399 let mut world = World::new();
3400 let fx = build(&mut world, true);
3401 run_lifecycle(&mut world);
3402 set_open(&mut world, fx.dialog, true);
3403 run_lifecycle(&mut world);
3404 let text_child = world.spawn(ChildOf(fx.confirm)).id();
3407 world
3408 .resource_mut::<Messages<ClickEvent>>()
3409 .write(ClickEvent {
3410 entity: text_child,
3411 position: glam::Vec2::ZERO,
3412 button: PointerButton::Primary,
3413 local: None,
3414 });
3415 world
3416 .run_system_once(mark_dialog_accept_on_default_click)
3417 .unwrap();
3418 set_open(&mut world, fx.dialog, false);
3419 run_lifecycle(&mut world);
3420 let closed = drain_closed(&mut world);
3421 assert_eq!(closed.len(), 1);
3422 assert!(closed[0].accepted, "default-button close is accepted");
3423 assert_eq!(closed[0].id, "dlg_open", "falls back to the open signal");
3424 set_open(&mut world, fx.dialog, true);
3426 run_lifecycle(&mut world);
3427 set_open(&mut world, fx.dialog, false);
3428 run_lifecycle(&mut world);
3429 let closed = drain_closed(&mut world);
3430 assert_eq!(closed.len(), 1);
3431 assert!(
3432 !closed[0].accepted,
3433 "pending accept never leaks into the next cycle"
3434 );
3435 }
3436
3437 #[test]
3438 fn dialog_id_attribute_wins_over_signal_name() {
3439 let mut world = World::new();
3440 let fx = build(&mut world, true);
3441 world
3442 .entity_mut(fx.dialog)
3443 .insert(LumenId("demo-dialog".into()));
3444 run_lifecycle(&mut world);
3445 set_open(&mut world, fx.dialog, true);
3446 run_lifecycle(&mut world);
3447 set_open(&mut world, fx.dialog, false);
3448 run_lifecycle(&mut world);
3449 let closed = drain_closed(&mut world);
3450 assert_eq!(closed[0].id, "demo-dialog");
3451 }
3452
3453 fn press_enter(world: &mut World) {
3454 world
3455 .resource_mut::<Messages<KeyPressed>>()
3456 .write(KeyPressed {
3457 key: Key::Named(NamedKey::Enter),
3458 modifiers: Modifiers::default(),
3459 repeat: false,
3460 });
3461 }
3462
3463 fn run_enter(world: &mut World) {
3464 world
3465 .run_system_once(activate_dialog_default_on_enter)
3466 .unwrap();
3467 world.resource_mut::<Messages<KeyPressed>>().clear();
3468 }
3469
3470 fn synthesized_clicks(world: &mut World) -> Vec<Entity> {
3471 world
3472 .resource_mut::<Messages<ClickEvent>>()
3473 .drain()
3474 .map(|c| c.entity)
3475 .collect()
3476 }
3477
3478 #[test]
3479 fn enter_on_non_button_focus_activates_default() {
3480 let mut world = World::new();
3481 let fx = build(&mut world, true);
3482 run_lifecycle(&mut world);
3483 set_open(&mut world, fx.dialog, true);
3484 run_lifecycle(&mut world); press_enter(&mut world);
3486 run_enter(&mut world);
3487 assert_eq!(
3488 synthesized_clicks(&mut world),
3489 vec![fx.confirm],
3490 "Enter with focus on the (single-line) input clicks the default button"
3491 );
3492 assert!(
3493 world
3494 .get::<DialogSession>(fx.dialog)
3495 .unwrap()
3496 .pending_accept,
3497 "Enter-default marks the accept path"
3498 );
3499 }
3500
3501 #[test]
3502 fn enter_on_focused_button_does_not_double_activate() {
3503 let mut world = World::new();
3504 let fx = build(&mut world, false);
3505 run_lifecycle(&mut world);
3506 set_open(&mut world, fx.dialog, true);
3507 run_lifecycle(&mut world);
3508 world.insert_resource(FocusTracker(Some(fx.cancel)));
3511 press_enter(&mut world);
3512 run_enter(&mut world);
3513 assert!(
3514 synthesized_clicks(&mut world).is_empty(),
3515 "focused button consumes Enter itself"
3516 );
3517 }
3518
3519 #[test]
3520 fn enter_ignores_closed_dialogs() {
3521 let mut world = World::new();
3522 let fx = build(&mut world, false);
3523 run_lifecycle(&mut world);
3524 world.insert_resource(FocusTracker(None));
3525 press_enter(&mut world);
3526 run_enter(&mut world);
3527 assert!(synthesized_clicks(&mut world).is_empty());
3528 let _ = fx;
3529 }
3530
3531 #[test]
3532 fn enter_falls_back_to_first_enabled_button_without_default_marker() {
3533 let mut world = World::new();
3534 let fx = build(&mut world, false);
3535 world.entity_mut(fx.confirm).remove::<DefaultButton>();
3536 run_lifecycle(&mut world);
3537 set_open(&mut world, fx.dialog, true);
3538 run_lifecycle(&mut world);
3539 world.insert_resource(FocusTracker(Some(fx.input)));
3540 press_enter(&mut world);
3541 run_enter(&mut world);
3542 assert_eq!(
3543 synthesized_clicks(&mut world),
3544 vec![fx.cancel],
3545 "Qt autoDefault: first enabled button in markup order"
3546 );
3547 }
3548}
3549
3550#[cfg(test)]
3551mod css_spawn_wiring_tests {
3552 use super::*;
3557
3558 fn el(tag: &str) -> Element {
3559 Element {
3560 tag: tag.to_string(),
3561 ..Element::default()
3562 }
3563 }
3564
3565 #[test]
3566 fn input_gets_caret_width_and_password_character_from_css() {
3567 let mut world = World::new();
3568 let mut input = el("input");
3569 input.attrs.caret_width = Some(3.5);
3570 input.attrs.password_character = Some('#');
3571 let e = spawn_subtree(&mut world, &input, None, Placeholders::Unresolved);
3572
3573 assert_eq!(
3574 world.get::<lumen_core::components::CaretWidth>(e),
3575 Some(&lumen_core::components::CaretWidth(3.5))
3576 );
3577 assert_eq!(
3578 world.get::<lumen_core::components::PasswordCharacter>(e),
3579 Some(&lumen_core::components::PasswordCharacter('#'))
3580 );
3581 }
3582
3583 #[test]
3587 fn input_gets_default_caret_width_and_password_character_when_unauthored() {
3588 let mut world = World::new();
3589 let e = spawn_subtree(&mut world, &el("input"), None, Placeholders::Unresolved);
3590
3591 assert_eq!(
3592 world.get::<lumen_core::components::CaretWidth>(e),
3593 Some(&lumen_core::components::CaretWidth::default())
3594 );
3595 assert_eq!(
3596 world.get::<lumen_core::components::PasswordCharacter>(e),
3597 Some(&lumen_core::components::PasswordCharacter::default())
3598 );
3599 }
3600
3601 #[test]
3606 fn anchor_is_a_pointer_target_without_a_background() {
3607 let mut world = World::new();
3608 let mut a = el("a");
3609 a.attrs.href = Some("about".to_string());
3610 let e = spawn_subtree(&mut world, &a, None, Placeholders::Unresolved);
3611
3612 assert!(
3613 world.get::<Visuals>(e).is_none(),
3614 "an unstyled anchor paints nothing"
3615 );
3616 assert_eq!(
3617 world.get::<TabIndex>(e).map(|t| t.0),
3618 Some(-1),
3619 "an anchor is a pointer target but not a Tab stop"
3620 );
3621 }
3622
3623 #[test]
3626 fn authored_tab_index_on_an_anchor_wins() {
3627 let mut world = World::new();
3628 let mut a = el("a");
3629 a.attrs.href = Some("about".to_string());
3630 a.attrs.tab_index = Some(0);
3631 let e = spawn_subtree(&mut world, &a, None, Placeholders::Unresolved);
3632
3633 assert_eq!(world.get::<TabIndex>(e).map(|t| t.0), Some(0));
3634 }
3635
3636 #[test]
3639 fn non_input_does_not_get_caret_width_or_password_character() {
3640 let mut world = World::new();
3641 let e = spawn_subtree(&mut world, &el("column"), None, Placeholders::Unresolved);
3642
3643 assert!(world.get::<lumen_core::components::CaretWidth>(e).is_none());
3644 assert!(
3645 world
3646 .get::<lumen_core::components::PasswordCharacter>(e)
3647 .is_none()
3648 );
3649 }
3650
3651 #[test]
3654 fn textarea_gets_caret_width_from_css() {
3655 let mut world = World::new();
3656 let mut textarea = el("textarea");
3657 textarea.attrs.caret_width = Some(4.0);
3658 let e = spawn_subtree(&mut world, &textarea, None, Placeholders::Unresolved);
3659
3660 assert_eq!(
3661 world.get::<lumen_core::components::CaretWidth>(e),
3662 Some(&lumen_core::components::CaretWidth(4.0))
3663 );
3664 }
3665
3666 #[test]
3670 fn text_style_carries_authored_line_height_at_spawn() {
3671 let mut world = World::new();
3672 let mut label = el("label");
3673 label.attrs.line_height = Some(lumen_ir::layout_ir::LineHeightSpec::Multiplier(1.6));
3674 let e = spawn_subtree(&mut world, &label, None, Placeholders::Unresolved);
3675
3676 let ts = world
3677 .get::<TextStyle>(e)
3678 .expect("line-height alone must still spawn a TextStyle");
3679 assert_eq!(
3680 ts.line_height,
3681 Some(lumen_core::components::LineHeightSpec::Multiplier(1.6))
3682 );
3683 }
3684
3685 #[test]
3690 fn scrollbar_style_carries_the_new_css_fields_at_spawn() {
3691 let mut world = World::new();
3692 let mut column = el("column");
3693 column.attrs.scrollbar_thickness = Some(10.0);
3694 column.attrs.scrollbar_thickness_thin = Some(5.0);
3695 column.attrs.scrollbar_margin = Some(3.0);
3696 column.attrs.scrollbar_min_thumb = Some(24.0);
3697 column.attrs.scrollbar_hover_boost = Some(2.5);
3698 column.attrs.scrollbar_fade_delay_ms = Some(2000);
3699 column.attrs.scrollbar_fade_duration_ms = Some(500);
3700 let e = spawn_subtree(&mut world, &column, None, Placeholders::Unresolved);
3701
3702 let sb = world
3703 .get::<lumen_core::input::ScrollbarStyle>(e)
3704 .expect("authored scrollbar-* properties must spawn a ScrollbarStyle");
3705 assert_eq!(sb.thickness, 10.0);
3706 assert_eq!(sb.thickness_thin, 5.0);
3707 assert_eq!(sb.margin, 3.0);
3708 assert_eq!(sb.min_thumb, 24.0);
3709 assert_eq!(sb.hover_boost, 2.5);
3710 assert_eq!(sb.fade_delay_secs, 2.0, "2000ms -> 2.0s");
3711 assert_eq!(sb.fade_secs, 0.5, "500ms -> 0.5s");
3712 }
3713
3714 #[test]
3720 fn scrollbar_thickness_alone_spawns_scrollbar_style() {
3721 let mut world = World::new();
3722 let mut column = el("column");
3723 column.attrs.scrollbar_thickness = Some(12.0);
3724 let e = spawn_subtree(&mut world, &column, None, Placeholders::Unresolved);
3725
3726 assert!(
3727 world.get::<lumen_core::input::ScrollbarStyle>(e).is_some(),
3728 "scrollbar-thickness alone must spawn ScrollbarStyle"
3729 );
3730 }
3731
3732 #[test]
3736 fn no_scrollbar_properties_spawns_no_scrollbar_style() {
3737 let mut world = World::new();
3738 let e = spawn_subtree(&mut world, &el("column"), None, Placeholders::Unresolved);
3739
3740 assert!(world.get::<lumen_core::input::ScrollbarStyle>(e).is_none());
3741 }
3742
3743 fn world_holding(pairs: &[(&str, &str)]) -> World {
3746 let mut world = World::new();
3747 let mut store = PropertyStore::default();
3748 for (name, value) in pairs {
3749 store.set_global_str(name, *value);
3750 }
3751 world.insert_resource(store);
3752 world
3753 }
3754
3755 #[test]
3756 fn a_placeholder_naming_a_global_is_resolved_when_the_element_is_built() {
3757 let mut world = world_holding(&[("name", "Ada")]);
3758 let mut label = el("label");
3759 label.attrs.text = Some("hello {name}".to_string());
3760 label.attrs.classes = vec!["tier-{name}".to_string()];
3761 let e = spawn_subtree(&mut world, &label, None, Placeholders::Unresolved);
3762
3763 assert_eq!(
3764 world.get::<TextContent>(e).map(|text| text.0.as_str()),
3765 Some("hello Ada"),
3766 "the web emitter writes the same string into the page"
3767 );
3768 let classes = world
3769 .get::<lumen_core::components::LumenClasses>(e)
3770 .expect("a class list");
3771 assert!(
3772 classes.0.iter().any(|class| &**class == "tier-Ada"),
3773 "{:?}",
3774 classes.0
3775 );
3776 }
3777
3778 #[test]
3779 fn a_placeholder_no_global_answers_stays_in_the_text() {
3780 let mut world = world_holding(&[]);
3781 let mut label = el("label");
3782 label.attrs.text = Some("hello {name}".to_string());
3783 let e = spawn_subtree(&mut world, &label, None, Placeholders::Unresolved);
3784
3785 assert_eq!(
3786 world.get::<TextContent>(e).map(|text| text.0.as_str()),
3787 Some("hello {name}")
3788 );
3789 }
3790
3791 #[test]
3792 fn a_tree_a_row_already_walked_is_not_walked_again() {
3793 let mut world = world_holding(&[("name", "Ada")]);
3794 let mut label = el("label");
3795 label.attrs.text = Some("{name}".to_string());
3798 let e = spawn_subtree(&mut world, &label, None, Placeholders::Resolved);
3799
3800 assert_eq!(
3801 world.get::<TextContent>(e).map(|text| text.0.as_str()),
3802 Some("{name}")
3803 );
3804 }
3805}
3806
3807#[cfg(test)]
3808mod virtualized_for_tests {
3809 use super::*;
3813 use bevy_ecs::system::RunSystemOnce;
3814 use lumen_core::signals::{ArrayItem, ArraySignals};
3815
3816 fn rows(ids: &[usize]) -> Vec<ArrayItem> {
3817 ids.iter()
3818 .map(|i| {
3819 let mut m = ArrayItem::default();
3820 m.insert("id".to_string(), i.to_string());
3821 m.insert("label".to_string(), format!("row {i}"));
3822 m
3823 })
3824 .collect()
3825 }
3826
3827 #[test]
3828 fn a_filter_that_shrinks_the_array_under_the_window_keeps_the_surviving_row() {
3829 let mut world = World::new();
3830 let mut arrays = ArraySignals::default();
3831 arrays.set("rows", rows(&[0, 1, 2]));
3832 world.insert_resource(arrays);
3833 world.insert_resource(PropertyStore::default());
3834
3835 let mut label = Element {
3836 tag: "label".to_string(),
3837 ..Element::default()
3838 };
3839 label.attrs.text = Some("{label}".to_string());
3840 let block = world
3841 .spawn((
3842 lumen_core::components::Style::default(),
3843 ForMarker {
3844 array_name: "rows".into(),
3845 body: vec![label],
3846 key_field: Some("id".to_string()),
3847 cached_keys: Vec::new(),
3848 virtualized: true,
3849 row_height: 20.0,
3850 win_rows: Vec::new(),
3851 cascaded_body: None,
3852 row_items: Vec::new(),
3853 },
3854 ))
3855 .id();
3856 world.run_system_once(reconcile_for_blocks).unwrap();
3857 assert_eq!(world.get::<ForMarker>(block).unwrap().win_rows.len(), 3);
3858
3859 world.resource_mut::<ArraySignals>().set("rows", rows(&[2]));
3862 world.run_system_once(reconcile_for_blocks).unwrap();
3863
3864 assert_eq!(
3865 world.get::<ForMarker>(block).unwrap().win_rows,
3866 vec![(0, "2".to_string())]
3867 );
3868 let kids: Vec<Entity> = world
3869 .get::<bevy_ecs::hierarchy::Children>(block)
3870 .map(|c| c.iter().collect())
3871 .unwrap_or_default();
3872 assert_eq!(kids.len(), 1);
3873 assert_eq!(
3874 world.get::<TextContent>(kids[0]).map(|t| t.0.as_str()),
3875 Some("row 2")
3876 );
3877 }
3878}