1use std::rc::Rc;
70
71use teksilo_canvas::{Rect, SizeProposal};
72use teksilo_core::accessibility::AccessNodeBuilder;
73use teksilo_core::build_context::BuildContext;
74use teksilo_core::signal::{Prop, Signal};
75use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
76use teksilo_core::widget_id::WidgetId;
77use teksilo_data::{CheckState, FlatEntry};
78
79use teksilo_canvas::TextOverflow;
80use teksilo_core::styles::{SharedStandardItemStyle, StandardItemStyleConfig};
81use teksilo_i18n::LocalizedString;
82use teksilo_tokens::{HAlignment, TextRole, TextStyleRole, VAlignment};
83
84use crate::button::InteractionState;
85use crate::checkbox::Checkbox;
86use crate::primitives::{FixedSize, HStack, Shrinkable, Spacer, TextWidget, TwistArrow, VStack};
87
88#[derive(Clone)]
93enum CheckboxKind {
94 TwoState(Signal<bool>),
95 TriState(Signal<CheckState>),
96}
97
98pub struct StandardListItem {
107 label: LocalizedString,
108 subtitle: Option<LocalizedString>,
109 leading_slot: Option<Box<dyn Widget>>,
110 center_slot: Option<Box<dyn Widget>>,
111 trailing_slot: Option<Box<dyn Widget>>,
112 subtitle_leading_slot: Option<Box<dyn Widget>>,
113 subtitle_trailing_slot: Option<Box<dyn Widget>>,
114 checkbox: Option<CheckboxKind>,
115 selected: Signal<bool>,
116 enabled: Signal<bool>,
117 label_style: teksilo_core::color_prop::TextStyleProp,
118 subtitle_style: teksilo_core::color_prop::TextStyleProp,
119 label_color: Option<teksilo_core::color_prop::ColorProp>,
122 subtitle_color: Option<teksilo_core::color_prop::ColorProp>,
124 label_overflow: Option<TextOverflow>,
127 label_slot: Option<Box<dyn Widget>>,
129 subtitle_overflow: Option<TextOverflow>,
132 interaction: Signal<InteractionState>,
133 style_override: Option<SharedStandardItemStyle>,
134 root_child_id: Option<WidgetId>,
135 tooltip_text: Option<LocalizedString>,
139 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
141 composite_tooltip_content: Option<Box<dyn Widget>>,
143}
144
145impl StandardListItem {
146 pub fn new(label: impl Into<LocalizedString>) -> Self {
148 let ls: LocalizedString = label.into();
149 Self {
150 label: ls,
151 subtitle: None,
152 leading_slot: None,
153 center_slot: None,
154 trailing_slot: None,
155 subtitle_leading_slot: None,
156 subtitle_trailing_slot: None,
157 checkbox: None,
158 selected: Signal::new(false),
159 enabled: Signal::new(true),
160 label_style: TextStyleRole::Body.into(),
161 subtitle_style: TextStyleRole::Small.into(),
162 label_color: None,
163 subtitle_color: None,
164 label_overflow: None,
165 label_slot: None,
166 subtitle_overflow: None,
167 interaction: Signal::new(InteractionState::Idle),
168 style_override: None,
169 root_child_id: None,
170 tooltip_text: None,
171 rich_tooltip_source: None,
172 composite_tooltip_content: None,
173 }
174 }
175
176 pub fn style(mut self, style: impl teksilo_core::styles::StandardItemStyle) -> Self {
179 self.style_override = Some(Rc::new(style));
180 self
181 }
182
183 pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
185 let ls: LocalizedString = text.into();
186 self.subtitle = Some(ls);
187 self
188 }
189
190 pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
193 self.leading_slot = Some(Box::new(widget));
194 self
195 }
196
197 pub fn leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
199 self.leading_slot = Some(widget);
200 self
201 }
202
203 pub fn center_slot(mut self, widget: impl Widget + 'static) -> Self {
208 self.center_slot = Some(Box::new(widget));
209 self
210 }
211
212 pub fn center_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
214 self.center_slot = Some(widget);
215 self
216 }
217
218 pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
221 self.trailing_slot = Some(Box::new(widget));
222 self
223 }
224
225 pub fn trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
227 self.trailing_slot = Some(widget);
228 self
229 }
230
231 pub fn subtitle_leading_slot(mut self, widget: impl Widget + 'static) -> Self {
233 self.subtitle_leading_slot = Some(Box::new(widget));
234 self
235 }
236
237 pub fn subtitle_leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
239 self.subtitle_leading_slot = Some(widget);
240 self
241 }
242
243 pub fn subtitle_trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
245 self.subtitle_trailing_slot = Some(Box::new(widget));
246 self
247 }
248
249 pub fn subtitle_trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
251 self.subtitle_trailing_slot = Some(widget);
252 self
253 }
254
255 pub fn checkbox(mut self, checked: Signal<bool>) -> Self {
258 self.checkbox = Some(CheckboxKind::TwoState(checked));
259 self
260 }
261
262 pub fn tristate_checkbox(mut self, state: Signal<CheckState>) -> Self {
266 self.checkbox = Some(CheckboxKind::TriState(state));
267 self
268 }
269
270 pub fn selected(mut self, selected: impl Into<Prop<bool>>) -> Self {
273 self.selected = selected.into().as_signal();
274 self
275 }
276
277 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
280 self.enabled = enabled.into().as_signal();
281 self
282 }
283
284 pub fn label_style(
288 mut self,
289 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
290 ) -> Self {
291 self.label_style = style.into();
292 self
293 }
294
295 pub fn subtitle_style(
297 mut self,
298 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
299 ) -> Self {
300 self.subtitle_style = style.into();
301 self
302 }
303
304 pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
308 self.label_color = Some(color.into());
309 self
310 }
311
312 pub fn subtitle_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
315 self.subtitle_color = Some(color.into());
316 self
317 }
318
319 pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self {
339 self.interaction = signal;
340 self
341 }
342
343 pub fn label_slot(mut self, widget: impl Widget + 'static) -> Self {
358 self.label_slot = Some(Box::new(widget));
359 self
360 }
361
362 pub fn label_overflow(mut self, overflow: TextOverflow) -> Self {
363 self.label_overflow = Some(overflow);
364 self
365 }
366
367 pub fn subtitle_overflow(mut self, overflow: TextOverflow) -> Self {
375 self.subtitle_overflow = Some(overflow);
376 self
377 }
378
379 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
386 self.tooltip_text = Some(text.into());
387 self.rich_tooltip_source = None;
388 self.composite_tooltip_content = None;
389 self
390 }
391
392 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
399 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
400 self.tooltip_text = None;
401 self.composite_tooltip_content = None;
402 self
403 }
404
405 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
413 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
414 self.tooltip_text = None;
415 self.composite_tooltip_content = None;
416 self
417 }
418
419 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
426 self.composite_tooltip_content = Some(Box::new(content));
427 self.tooltip_text = None;
428 self.rich_tooltip_source = None;
429 self
430 }
431}
432
433impl std::fmt::Debug for StandardListItem {
434 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435 f.debug_struct("StandardListItem")
436 .field("label", &self.label)
437 .field("subtitle", &self.subtitle)
438 .field("has_checkbox", &self.checkbox.is_some())
439 .finish()
440 }
441}
442
443fn resolve_label_role(enabled: bool) -> TextRole {
444 if enabled {
445 TextRole::Primary
446 } else {
447 TextRole::Disabled
448 }
449}
450
451struct RowRoles {
460 style: SharedStandardItemStyle,
461 on_selected: Option<TextRole>,
467 emphasised: Option<Signal<bool>>,
471}
472
473impl StandardListItem {
474 fn resolve_roles(&self, ctx: &mut BuildContext) -> RowRoles {
476 let style: SharedStandardItemStyle = self
477 .style_override
478 .clone()
479 .or_else(|| ctx.theme().style_slots.standard_item.clone())
480 .unwrap_or_else(|| Rc::new(crate::styles::RecipeStandardItemStyle::default()));
481 let on_selected = style.selected_label_role();
482 let emphasised =
483 on_selected.map(|_| ctx.view_focus_active().and(&ctx.window_active_signal()));
484 RowRoles {
485 style,
486 on_selected,
487 emphasised,
488 }
489 }
490
491 fn foreground_role(&self, roles: &RowRoles, rest: TextRole) -> Signal<TextRole> {
504 match (roles.on_selected, &roles.emphasised) {
505 (Some(on_selected), Some(emphasised)) => self
506 .enabled
507 .zip3(&self.selected, emphasised)
508 .map(move |(enabled, selected, emphasised)| {
509 if !*enabled {
510 TextRole::Disabled
511 } else if *selected && *emphasised {
512 on_selected
513 } else {
514 rest
515 }
516 }),
517 _ => self
518 .enabled
519 .map(move |e| if *e { rest } else { TextRole::Disabled }),
520 }
521 }
522
523 fn build_content(&mut self, ctx: &mut BuildContext, roles: &RowRoles) -> WidgetId {
527 use crate::styles::recipe_standard_item_style as si;
528
529 let label_role = self.foreground_role(roles, resolve_label_role(true));
535 let subtitle_role = self.foreground_role(roles, TextRole::Secondary);
536
537 let label_id = match self.label_slot.take() {
542 Some(widget) => ctx.add_boxed(widget),
543 None => {
544 let mut label_widget = TextWidget::new(self.label.clone())
545 .style(self.label_style.clone())
546 .a11y_hidden();
547 label_widget = match &self.label_color {
548 Some(c) => label_widget.color(c.clone()),
549 None => label_widget.color(label_role.clone()),
550 };
551 if let Some(overflow) = self.label_overflow {
552 label_widget = label_widget.overflow(overflow);
553 }
554 ctx.add(label_widget)
555 }
556 };
557
558 let label_column_id = if let Some(subtitle) = &self.subtitle {
559 let mut subtitle_widget = TextWidget::new(subtitle.clone())
561 .style(self.subtitle_style.clone())
562 .a11y_hidden();
563 subtitle_widget = match &self.subtitle_color {
564 Some(c) => subtitle_widget.color(c.clone()),
565 None => subtitle_widget.color(subtitle_role.clone()),
570 };
571 if let Some(overflow) = self.subtitle_overflow {
572 subtitle_widget = subtitle_widget.overflow(overflow);
573 }
574 let subtitle_text_id = ctx.add(subtitle_widget);
575
576 let mut sub_row = HStack::new()
578 .spacing(si::STANDARD_ITEM_SUBTITLE_SLOT_GAP)
579 .alignment(VAlignment::Center);
580 if let Some(w) = self.subtitle_leading_slot.take() {
581 let id = ctx.add_boxed(w);
582 sub_row = sub_row.add_child(id);
583 }
584 sub_row = sub_row
585 .add_child(subtitle_text_id)
586 .add_child(ctx.add(Spacer::new()));
587 if let Some(w) = self.subtitle_trailing_slot.take() {
588 let id = ctx.add_boxed(w);
589 sub_row = sub_row.add_child(id);
590 }
591 let sub_row_id = ctx.add(sub_row);
592
593 ctx.add(
594 VStack::new()
595 .spacing(si::STANDARD_ITEM_LABEL_SUBTITLE_GAP)
596 .alignment(HAlignment::Leading)
597 .add_child(label_id)
598 .add_child(sub_row_id),
599 )
600 } else {
601 label_id
603 };
604
605 let label_column_id = if self.label_overflow.is_some() || self.subtitle_overflow.is_some() {
612 ctx.add(
613 Shrinkable::new()
614 .min_width(si::STANDARD_ITEM_LABEL_COLUMN_MIN_WIDTH)
615 .child_id(label_column_id),
616 )
617 } else {
618 label_column_id
619 };
620
621 let mut row = HStack::new()
624 .spacing(si::STANDARD_ITEM_SLOT_GAP)
625 .alignment(VAlignment::Center);
626
627 if let Some(kind) = self.checkbox.take() {
628 use teksilo_core::widget_builder::WidgetBuilder;
641 let cb = match kind {
642 CheckboxKind::TwoState(s) => Checkbox::new(s),
643 CheckboxKind::TriState(s) => Checkbox::tristate(s),
644 }
645 .labels_hidden(true);
646 let cb_id = ctx.add(cb.access_label(self.label.clone()));
647 row = row.add_child(cb_id);
648 }
649 if let Some(w) = self.leading_slot.take() {
650 let id = ctx.add_boxed(w);
651 row = row.add_child(id);
652 }
653 if let Some(w) = self.center_slot.take() {
654 let id = ctx.add_boxed(w);
655 row = row.add_child(id);
656 }
657 row = row
658 .add_child(label_column_id)
659 .add_child(ctx.add(Spacer::new()));
660 if let Some(w) = self.trailing_slot.take() {
661 let id = ctx.add_boxed(w);
662 row = row.add_child(id);
663 }
664
665 ctx.add(row)
666 }
667
668 fn build_with_background(
675 &mut self,
676 ctx: &mut BuildContext,
677 content_id: WidgetId,
678 roles: &RowRoles,
679 ) -> WidgetId {
680 let is_selected = self.selected.clone();
684 let is_disabled = self.enabled.map(|e| !*e);
685 let is_hovered = self
686 .interaction
687 .map(|s| matches!(s, InteractionState::Hovered));
688 let is_pressed = self
689 .interaction
690 .map(|s| matches!(s, InteractionState::Pressed));
691 let is_focused = ctx.view_focus_active();
699 let is_focus_visible = ctx.focus_visible();
702
703 let style: SharedStandardItemStyle = roles.style.clone();
704 let cfg = StandardItemStyleConfig {
705 content: content_id,
706 is_selected,
707 is_hovered,
708 is_pressed,
709 is_focused,
710 is_focus_visible,
711 is_disabled,
712 is_window_active: ctx.window_active_signal(),
713 };
714 let root_id = style.make_body(&cfg, ctx);
715
716 use teksilo_core::widget_builder::HandlerSet;
721 let interaction_for_hover = self.interaction.clone();
722 let handlers = HandlerSet::new().on_hover(move |entered: bool, _ctx: &mut EventContext| {
723 interaction_for_hover.set(if entered {
724 InteractionState::Hovered
725 } else {
726 InteractionState::Idle
727 });
728 });
729 ctx.apply_self_handlers(handlers);
730
731 root_id
732 }
733}
734
735impl Widget for StandardListItem {
736 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
737 let self_id = ctx.self_id();
738 ctx.enabled_when(self_id, self.enabled.clone());
746 let roles = self.resolve_roles(ctx);
749 let content_id = self.build_content(ctx, &roles);
750 let root_id = self.build_with_background(ctx, content_id, &roles);
751 self.root_child_id = Some(root_id);
752
753 let tip_placement = crate::tooltip::TooltipPlacement::Side;
758 if let Some(content) = self.composite_tooltip_content.take() {
759 let delay = ctx.theme().motion.tooltip_delay_heavy;
760 crate::tooltip::attach_composite_tooltip_boxed_with_placement(
761 ctx,
762 root_id,
763 content,
764 delay,
765 tip_placement,
766 );
767 } else if let Some(source) = self.rich_tooltip_source.clone() {
768 let delay = ctx.theme().motion.tooltip_delay;
769 crate::tooltip::attach_rich_tooltip_source_with_placement(
770 ctx,
771 root_id,
772 source,
773 delay,
774 tip_placement,
775 );
776 } else if let Some(text) = self.tooltip_text.clone() {
777 let delay = ctx.theme().motion.tooltip_delay;
778 crate::tooltip::attach_plain_tooltip_with_placement(
779 ctx,
780 root_id,
781 text,
782 delay,
783 tip_placement,
784 );
785 }
786
787 vec![root_id]
788 }
789
790 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
791 use crate::styles::recipe_standard_item_style as si;
792 let min_height = if self.subtitle.is_some() {
793 si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE
794 } else {
795 si::STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE
796 };
797 let raw = self
798 .root_child_id
799 .and_then(|id| ctx.child_size(id, proposal))
800 .unwrap_or_else(|| proposal.resolve(0.0, min_height));
801 let height = raw.height.max(min_height);
802 let width = proposal.width.unwrap_or(raw.width);
809 teksilo_canvas::Size::new(width, height).into()
810 }
811
812 fn place_children(
813 &self,
814 bounds: Rect,
815 _proposal: SizeProposal,
816 children: &mut [WidgetPlacement],
817 _ctx: &LayoutContext,
818 ) {
819 for child in children.iter_mut() {
820 child.origin = bounds.origin();
821 child.size = bounds.size();
822 }
823 }
824
825 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
826 builder.set_name(self.label.clone());
837 if let Some(subtitle) = &self.subtitle {
838 builder.set_description(subtitle.clone());
839 }
840 }
845
846 fn children(&self) -> Vec<WidgetId> {
847 self.root_child_id.into_iter().collect()
848 }
849}
850
851pub struct StandardTreeItem {
861 inner: StandardListItem,
862 depth: usize,
863 has_children: bool,
864 is_expanded: Prop<bool>,
865 on_toggle: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
866}
867
868impl StandardTreeItem {
869 pub fn new(label: impl Into<LocalizedString>) -> Self {
871 Self {
872 inner: StandardListItem::new(label),
873 depth: 0,
874 has_children: false,
875 is_expanded: Prop::Static(false),
876 on_toggle: None,
877 }
878 }
879
880 pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self {
887 self.inner = self.inner.interaction_signal(signal);
888 self
889 }
890
891 pub fn label_slot(mut self, widget: impl Widget + 'static) -> Self {
894 self.inner = self.inner.label_slot(widget);
895 self
896 }
897
898 pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
899 self.inner = self.inner.subtitle(text);
900 self
901 }
902
903 pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
906 self.inner = self.inner.leading_slot(widget);
907 self
908 }
909
910 pub fn leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
912 self.inner = self.inner.leading_slot_boxed(widget);
913 self
914 }
915
916 pub fn center_slot(mut self, widget: impl Widget + 'static) -> Self {
919 self.inner = self.inner.center_slot(widget);
920 self
921 }
922
923 pub fn center_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
925 self.inner = self.inner.center_slot_boxed(widget);
926 self
927 }
928
929 pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
932 self.inner = self.inner.trailing_slot(widget);
933 self
934 }
935
936 pub fn trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
938 self.inner = self.inner.trailing_slot_boxed(widget);
939 self
940 }
941
942 pub fn subtitle_leading_slot(mut self, widget: impl Widget + 'static) -> Self {
945 self.inner = self.inner.subtitle_leading_slot(widget);
946 self
947 }
948
949 pub fn subtitle_leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
952 self.inner = self.inner.subtitle_leading_slot_boxed(widget);
953 self
954 }
955
956 pub fn subtitle_trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
959 self.inner = self.inner.subtitle_trailing_slot(widget);
960 self
961 }
962
963 pub fn subtitle_trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
966 self.inner = self.inner.subtitle_trailing_slot_boxed(widget);
967 self
968 }
969
970 pub fn checkbox(mut self, checked: Signal<bool>) -> Self {
973 self.inner = self.inner.checkbox(checked);
974 self
975 }
976
977 pub fn tristate_checkbox(mut self, state: Signal<CheckState>) -> Self {
980 self.inner = self.inner.tristate_checkbox(state);
981 self
982 }
983
984 pub fn selected(mut self, selected: impl Into<Prop<bool>>) -> Self {
988 self.inner = self.inner.selected(selected);
989 self
990 }
991
992 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
996 self.inner = self.inner.enabled(enabled);
997 self
998 }
999
1000 pub fn label_style(
1004 mut self,
1005 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
1006 ) -> Self {
1007 self.inner = self.inner.label_style(style);
1008 self
1009 }
1010
1011 pub fn subtitle_style(
1015 mut self,
1016 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
1017 ) -> Self {
1018 self.inner = self.inner.subtitle_style(style);
1019 self
1020 }
1021
1022 pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
1025 self.inner = self.inner.label_color(color);
1026 self
1027 }
1028
1029 pub fn subtitle_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
1032 self.inner = self.inner.subtitle_color(color);
1033 self
1034 }
1035
1036 pub fn label_overflow(mut self, overflow: TextOverflow) -> Self {
1040 self.inner = self.inner.label_overflow(overflow);
1041 self
1042 }
1043
1044 pub fn subtitle_overflow(mut self, overflow: TextOverflow) -> Self {
1048 self.inner = self.inner.subtitle_overflow(overflow);
1049 self
1050 }
1051
1052 pub fn style(mut self, style: impl teksilo_core::styles::StandardItemStyle) -> Self {
1057 self.inner = self.inner.style(style);
1058 self
1059 }
1060
1061 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
1065 self.inner = self.inner.tooltip(text);
1066 self
1067 }
1068
1069 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
1073 self.inner = self.inner.rich_tooltip(key);
1074 self
1075 }
1076
1077 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
1082 self.inner = self.inner.rich_tooltip_content(content);
1083 self
1084 }
1085
1086 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
1090 self.inner = self.inner.composite_tooltip(content);
1091 self
1092 }
1093
1094 pub fn depth(mut self, depth: usize) -> Self {
1099 self.depth = depth;
1100 self
1101 }
1102
1103 pub fn has_children(mut self, has: bool) -> Self {
1106 self.has_children = has;
1107 self
1108 }
1109
1110 pub fn is_expanded(mut self, expanded: impl Into<Prop<bool>>) -> Self {
1113 self.is_expanded = expanded.into();
1114 self
1115 }
1116
1117 pub fn from_entry(self, entry: &FlatEntry) -> Self {
1120 self.depth(entry.depth)
1121 .has_children(entry.has_children)
1122 .is_expanded(entry.is_expanded)
1123 }
1124
1125 pub fn on_toggle(
1134 mut self,
1135 f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
1136 ) -> Self {
1137 self.on_toggle = Some(Rc::new(f));
1138 self
1139 }
1140
1141 pub fn on_toggle_rc(mut self, f: Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>) -> Self {
1146 self.on_toggle = Some(f);
1147 self
1148 }
1149}
1150
1151impl std::fmt::Debug for StandardTreeItem {
1152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1153 f.debug_struct("StandardTreeItem")
1154 .field("inner", &self.inner)
1155 .field("depth", &self.depth)
1156 .field("has_children", &self.has_children)
1157 .finish()
1158 }
1159}
1160
1161impl Widget for StandardTreeItem {
1162 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1163 use crate::styles::recipe_standard_item_style as si;
1164
1165 let self_id = ctx.self_id();
1169 ctx.enabled_when(self_id, self.inner.enabled.clone());
1170
1171 let roles = self.inner.resolve_roles(ctx);
1173 let inner_content_id = self.inner.build_content(ctx, &roles);
1174
1175 let indent_width = self.depth as f32 * si::STANDARD_ITEM_TREE_INDENT_STEP;
1177 let indent_id = ctx.add(FixedSize::new().width(indent_width));
1178
1179 let chevron_size = si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH;
1187 let chevron_role = self.inner.foreground_role(&roles, TextRole::Secondary);
1194 let mut chevron = TwistArrow::new(chevron_size, self.has_children, self.is_expanded.get())
1195 .color(chevron_role);
1196 if self.has_children
1197 && let Some(cb) = self.on_toggle.clone()
1198 {
1199 chevron = chevron.on_click(move |ctx| cb(ctx));
1200 }
1201 let chevron_column_id = ctx.add(FixedSize::new().width(chevron_size).child(chevron));
1202
1203 let outer_row_id = ctx.add(
1205 HStack::new()
1206 .spacing(0.0)
1207 .alignment(VAlignment::Center)
1208 .add_child(indent_id)
1209 .add_child(chevron_column_id)
1210 .add_child(inner_content_id),
1211 );
1212
1213 let root_id = self.inner.build_with_background(ctx, outer_row_id, &roles);
1216
1217 self.inner.root_child_id = Some(root_id);
1218
1219 let tip_placement = crate::tooltip::TooltipPlacement::Side;
1223 if let Some(content) = self.inner.composite_tooltip_content.take() {
1224 let delay = ctx.theme().motion.tooltip_delay_heavy;
1225 crate::tooltip::attach_composite_tooltip_boxed_with_placement(
1226 ctx,
1227 root_id,
1228 content,
1229 delay,
1230 tip_placement,
1231 );
1232 } else if let Some(source) = self.inner.rich_tooltip_source.clone() {
1233 let delay = ctx.theme().motion.tooltip_delay;
1234 crate::tooltip::attach_rich_tooltip_source_with_placement(
1235 ctx,
1236 root_id,
1237 source,
1238 delay,
1239 tip_placement,
1240 );
1241 } else if let Some(text) = self.inner.tooltip_text.clone() {
1242 let delay = ctx.theme().motion.tooltip_delay;
1243 crate::tooltip::attach_plain_tooltip_with_placement(
1244 ctx,
1245 root_id,
1246 text,
1247 delay,
1248 tip_placement,
1249 );
1250 }
1251
1252 vec![root_id]
1253 }
1254
1255 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1256 self.inner.layout_response(proposal, ctx)
1257 }
1258
1259 fn place_children(
1260 &self,
1261 bounds: Rect,
1262 proposal: SizeProposal,
1263 children: &mut [WidgetPlacement],
1264 ctx: &LayoutContext,
1265 ) {
1266 self.inner.place_children(bounds, proposal, children, ctx);
1267 }
1268
1269 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1270 self.inner.accessibility(builder);
1271 }
1272
1273 fn children(&self) -> Vec<WidgetId> {
1274 self.inner.children()
1275 }
1276}
1277
1278#[cfg(test)]
1283mod tests {
1284 use super::*;
1285 use teksilo_canvas::SizeProposal;
1286 use teksilo_core::Theme;
1287 use teksilo_core::styles::StandardItemStyle;
1288 use teksilo_core::widget_tree::WidgetTree;
1289 use teksilo_i18n::lit;
1290
1291 fn theme() -> Theme {
1292 teksilo_core::presets::intui::light()
1293 }
1294
1295 fn discriminating_theme() -> Theme {
1303 let mut t = theme();
1304 t.colors.text_on_accent = teksilo_tokens::Color::WHITE;
1305 assert_ne!(t.colors.text_primary, t.colors.text_on_accent);
1306 t
1307 }
1308
1309 fn glyph_colors(tree: &mut WidgetTree) -> Vec<[u8; 4]> {
1311 tree.render()
1312 .glyphs
1313 .iter()
1314 .map(|g| {
1315 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1316 [q(g.color[0]), q(g.color[1]), q(g.color[2]), q(g.color[3])]
1317 })
1318 .collect()
1319 }
1320
1321 fn rgba8(c: teksilo_tokens::Color) -> [u8; 4] {
1322 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1323 [q(c.r()), q(c.g()), q(c.b()), q(c.a())]
1324 }
1325
1326 #[derive(Debug, Default, Clone, Copy)]
1331 struct OnAccentSelectionStyle;
1332
1333 impl StandardItemStyle for OnAccentSelectionStyle {
1334 fn make_body(
1335 &self,
1336 cfg: &StandardItemStyleConfig,
1337 ctx: &mut teksilo_core::build_context::BuildContext,
1338 ) -> WidgetId {
1339 crate::styles::RecipeStandardItemStyle::default().make_body(cfg, ctx)
1340 }
1341
1342 fn selected_label_role(&self) -> Option<TextRole> {
1343 Some(TextRole::OnAccent)
1344 }
1345 }
1346
1347 #[test]
1351 fn a_style_without_the_hook_leaves_the_selected_label_alone() {
1352 let t = discriminating_theme();
1353 let primary = rgba8(t.colors.text_primary);
1354 let on_accent = rgba8(t.colors.text_on_accent);
1355
1356 let mut tree = WidgetTree::new()
1357 .with_theme(t)
1358 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1359 teksilo_canvas::MockTextBackend::new(),
1360 )));
1361 tree.add(StandardListItem::new(lit!("Row")).selected(Signal::new(true)));
1362 tree.layout(SizeProposal::exact(300.0, 40.0));
1363 let colors = glyph_colors(&mut tree);
1364 assert!(colors.contains(&primary));
1365 assert!(!colors.contains(&on_accent));
1366 }
1367
1368 #[test]
1371 fn the_hook_flips_the_label_of_an_emphasised_row() {
1372 let t = discriminating_theme();
1373 let on_accent = rgba8(t.colors.text_on_accent);
1374
1375 let mut tree = WidgetTree::new()
1376 .with_theme(t)
1377 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1378 teksilo_canvas::MockTextBackend::new(),
1379 )));
1380 tree.add(
1381 StandardListItem::new(lit!("Row"))
1382 .selected(Signal::new(true))
1383 .style(OnAccentSelectionStyle),
1384 );
1385 tree.layout(SizeProposal::exact(300.0, 40.0));
1386 assert!(glyph_colors(&mut tree).contains(&on_accent));
1387 }
1388
1389 #[test]
1393 fn the_hook_does_not_touch_an_unselected_row() {
1394 let t = discriminating_theme();
1395 let primary = rgba8(t.colors.text_primary);
1396 let on_accent = rgba8(t.colors.text_on_accent);
1397
1398 let mut tree = WidgetTree::new()
1399 .with_theme(t)
1400 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1401 teksilo_canvas::MockTextBackend::new(),
1402 )));
1403 tree.add(StandardListItem::new(lit!("Row")).style(OnAccentSelectionStyle));
1404 tree.layout(SizeProposal::exact(300.0, 40.0));
1405 let colors = glyph_colors(&mut tree);
1406 assert!(colors.contains(&primary));
1407 assert!(!colors.contains(&on_accent));
1408 }
1409
1410 #[test]
1419 fn the_hook_flips_a_tree_rows_chevron_with_its_label() {
1420 let t = discriminating_theme();
1421 let secondary = rgba8(t.colors.text_secondary);
1422 let on_accent = rgba8(t.colors.text_on_accent);
1423
1424 let mut tree = WidgetTree::new()
1425 .with_theme(t)
1426 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1427 teksilo_canvas::MockTextBackend::new(),
1428 )));
1429 tree.add(
1430 StandardTreeItem::new(lit!("Node"))
1431 .has_children(true)
1432 .selected(Signal::new(true))
1433 .style(OnAccentSelectionStyle),
1434 );
1435 tree.layout(SizeProposal::exact(300.0, 40.0));
1436
1437 let shapes: Vec<[u8; 4]> = tree
1438 .render()
1439 .shapes
1440 .iter()
1441 .map(|s| {
1442 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1443 [q(s.color[0]), q(s.color[1]), q(s.color[2]), q(s.color[3])]
1444 })
1445 .collect();
1446 let paths: Vec<[u8; 4]> = tree
1447 .render()
1448 .paths
1449 .iter()
1450 .map(|p| {
1451 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1452 [q(p.color[0]), q(p.color[1]), q(p.color[2]), q(p.color[3])]
1453 })
1454 .collect();
1455 let painted: Vec<[u8; 4]> = shapes.into_iter().chain(paths).collect();
1456
1457 assert!(
1458 painted.contains(&on_accent),
1459 "the chevron did not flip with the label; painted {painted:?}"
1460 );
1461 assert!(
1462 !painted.contains(&secondary),
1463 "the chevron is still painting the muted role on an accent capsule"
1464 );
1465 }
1466
1467 #[test]
1470 fn a_tree_rows_chevron_is_muted_without_the_hook() {
1471 let t = discriminating_theme();
1472 let secondary = rgba8(t.colors.text_secondary);
1473
1474 let mut tree = WidgetTree::new()
1475 .with_theme(t)
1476 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1477 teksilo_canvas::MockTextBackend::new(),
1478 )));
1479 tree.add(
1480 StandardTreeItem::new(lit!("Node"))
1481 .has_children(true)
1482 .selected(Signal::new(true)),
1483 );
1484 tree.layout(SizeProposal::exact(300.0, 40.0));
1485 let paths: Vec<[u8; 4]> = tree
1486 .render()
1487 .paths
1488 .iter()
1489 .map(|p| {
1490 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1491 [q(p.color[0]), q(p.color[1]), q(p.color[2]), q(p.color[3])]
1492 })
1493 .collect();
1494 assert!(paths.contains(&secondary), "painted {paths:?}");
1495 }
1496
1497 #[test]
1501 fn the_hook_reverts_when_the_row_stops_being_emphasised() {
1502 let t = discriminating_theme();
1503 let primary = rgba8(t.colors.text_primary);
1504 let on_accent = rgba8(t.colors.text_on_accent);
1505
1506 let mut tree = WidgetTree::new()
1507 .with_theme(t)
1508 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1509 teksilo_canvas::MockTextBackend::new(),
1510 )));
1511 tree.add(
1512 StandardListItem::new(lit!("Row"))
1513 .selected(Signal::new(true))
1514 .style(OnAccentSelectionStyle),
1515 );
1516 tree.layout(SizeProposal::exact(300.0, 40.0));
1517 assert!(glyph_colors(&mut tree).contains(&on_accent));
1518
1519 tree.set_window_active(false);
1520 tree.layout(SizeProposal::exact(300.0, 40.0));
1521 let colors = glyph_colors(&mut tree);
1522 assert!(
1523 colors.contains(&primary),
1524 "an inactive window's selected row kept its on-accent label"
1525 );
1526 assert!(!colors.contains(&on_accent));
1527 }
1528
1529 #[test]
1530 fn list_item_layout_single_line() {
1531 let mut tree = WidgetTree::new().with_theme(theme());
1532 let id = tree.add(StandardListItem::new(lit!("Hello")));
1533 tree.layout(SizeProposal {
1534 width: Some(300.0),
1535 height: None,
1536 });
1537 let b = tree.bounds(id);
1538 use crate::styles::recipe_standard_item_style as si;
1539 assert!(b.height >= si::STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE - 0.5);
1540 }
1541
1542 #[test]
1547 fn a_wrapping_subtitle_pushes_the_trailing_slot_out_of_the_row() {
1548 const ROW_W: f32 = 680.0;
1549 let mut tree = WidgetTree::new().with_theme(theme());
1550 let row = tree.add(
1551 StandardListItem::new(lit!("2026-07-14 10:05"))
1552 .subtitle(lit!(
1553 "11 KB · /home/user/Nextcloud/Documents/Books/backups/novel-20260714-100528.skrib"
1554 ))
1555 .trailing_slot(crate::button::Button::new(lit!("Open"))),
1556 );
1557 tree.layout(SizeProposal::exact(ROW_W, 56.0));
1558
1559 let button = tree.find_by_label("Open").expect("trailing button");
1560 assert!(
1561 tree.bounds(button).right() > tree.bounds(row).right(),
1562 "a wrapping subtitle should overflow the row (got button right={}, row right={})",
1563 tree.bounds(button).right(),
1564 tree.bounds(row).right(),
1565 );
1566 }
1567
1568 #[test]
1571 fn an_eliding_subtitle_keeps_the_trailing_slot_inside_the_row() {
1572 const ROW_W: f32 = 680.0;
1573 let mut tree = WidgetTree::new().with_theme(theme());
1574 let row = tree.add(
1575 StandardListItem::new(lit!("2026-07-14 10:05"))
1576 .subtitle(lit!(
1577 "11 KB · /home/user/Nextcloud/Documents/Books/backups/novel-20260714-100528.skrib"
1578 ))
1579 .subtitle_overflow(TextOverflow::Ellipsis(teksilo_canvas::EllipsisMode::Middle))
1580 .trailing_slot(crate::button::Button::new(lit!("Open"))),
1581 );
1582 tree.layout(SizeProposal::exact(ROW_W, 56.0));
1583
1584 let button = tree.find_by_label("Open").expect("trailing button");
1585 assert!(
1586 tree.bounds(button).right() <= tree.bounds(row).right() + 0.5,
1587 "an elided subtitle must keep the trailing slot inside the row \
1588 (got button right={}, row right={})",
1589 tree.bounds(button).right(),
1590 tree.bounds(row).right(),
1591 );
1592 }
1593
1594 #[test]
1596 fn tree_item_forwards_the_overflow_levers() {
1597 const ROW_W: f32 = 400.0;
1598 let mut tree = WidgetTree::new().with_theme(theme());
1599 let row = tree.add(
1600 StandardTreeItem::new(lit!(
1601 "A very long chapter title that cannot possibly fit this row"
1602 ))
1603 .label_overflow(TextOverflow::Ellipsis(
1604 teksilo_canvas::EllipsisMode::Trailing,
1605 ))
1606 .trailing_slot(crate::button::Button::new(lit!("Open"))),
1607 );
1608 tree.layout(SizeProposal::exact(ROW_W, 56.0));
1609
1610 let button = tree.find_by_label("Open").expect("trailing button");
1611 assert!(
1612 tree.bounds(button).right() <= tree.bounds(row).right() + 0.5,
1613 "an elided label must keep the tree row's trailing slot inside it \
1614 (got button right={}, row right={})",
1615 tree.bounds(button).right(),
1616 tree.bounds(row).right(),
1617 );
1618 }
1619
1620 #[test]
1621 fn selected_item_draws_focus_colour_boundary() {
1622 let t = theme();
1626 let border = t.colors.border_focused.to_array();
1627 let has_boundary = |frame: &teksilo_canvas::RenderFrame| {
1630 frame
1631 .shapes
1632 .iter()
1633 .any(|s| s.color == border && s.stroke_width > 0.0)
1634 };
1635
1636 let mut sel = WidgetTree::new().with_theme(t.clone());
1637 sel.add(StandardListItem::new(lit!("X")).selected(true));
1638 sel.layout(SizeProposal::exact(200.0, 40.0));
1639 assert!(
1640 has_boundary(&sel.render()),
1641 "selected item must draw a boundary in the focus/accent colour"
1642 );
1643
1644 let mut plain = WidgetTree::new().with_theme(t);
1645 plain.add(StandardListItem::new(lit!("X")).selected(false));
1646 plain.layout(SizeProposal::exact(200.0, 40.0));
1647 assert!(
1648 !has_boundary(&plain.render()),
1649 "an unselected item draws no such boundary"
1650 );
1651 }
1652
1653 #[test]
1654 fn list_item_layout_two_line() {
1655 let mut tree = WidgetTree::new().with_theme(theme());
1656 let id = tree.add(StandardListItem::new(lit!("Title")).subtitle(lit!("Subtitle text")));
1657 tree.layout(SizeProposal {
1658 width: Some(300.0),
1659 height: None,
1660 });
1661 let b = tree.bounds(id);
1662 use crate::styles::recipe_standard_item_style as si;
1663 assert!(
1664 b.height >= si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE - 0.5,
1665 "two-line height {} < expected {}",
1666 b.height,
1667 si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE
1668 );
1669 }
1670
1671 #[test]
1672 fn list_item_a11y_name_is_label_only() {
1673 let mut tree = WidgetTree::new().with_theme(theme());
1677 let id = tree.add(StandardListItem::new(lit!("Title")).subtitle(lit!("Subtitle")));
1678 tree.layout(SizeProposal::exact(300.0, 100.0));
1679 let info = tree.accessibility_node(id);
1680 assert_eq!(info.name(), Some("Title"));
1681 }
1682
1683 #[test]
1686 fn a_shared_interaction_signal_reports_the_rows_hover() {
1687 let state = Signal::new(InteractionState::Idle);
1688 let mut tree = WidgetTree::new().with_theme(theme());
1689 let id =
1690 tree.add(StandardListItem::new(lit!("A result")).interaction_signal(state.clone()));
1691 tree.layout(SizeProposal::exact(300.0, 40.0));
1692 let _ = tree.render();
1693 assert_eq!(state.get(), InteractionState::Idle);
1694
1695 let b = tree.bounds(id);
1696 tree.dispatch_event(teksilo_core::WidgetEvent::PointerMove {
1697 position: teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1698 });
1699 tree.layout(SizeProposal::exact(300.0, 40.0));
1700 let _ = tree.render();
1701 assert_eq!(
1702 state.get(),
1703 InteractionState::Hovered,
1704 "the row shares its own hover state with whoever asked for it"
1705 );
1706 }
1707
1708 #[test]
1715 fn a_row_that_draws_its_own_label_keeps_its_accessible_name() {
1716 let mut tree = WidgetTree::new().with_theme(theme());
1717 let id = tree.add(
1718 StandardListItem::new(lit!("she walked across the ice"))
1719 .label_slot(TextWidget::new(lit!("…across the ice"))),
1720 );
1721 tree.layout(SizeProposal::exact(300.0, 100.0));
1722 let info = tree.accessibility_node(id);
1723 assert_eq!(
1724 info.name(),
1725 Some("she walked across the ice"),
1726 "the name comes from the label, not from what was drawn instead of it"
1727 );
1728 }
1729
1730 #[test]
1731 fn list_item_a11y_name_no_subtitle() {
1732 let mut tree = WidgetTree::new().with_theme(theme());
1733 let id = tree.add(StandardListItem::new(lit!("Just a title")));
1734 tree.layout(SizeProposal::exact(300.0, 100.0));
1735 let info = tree.accessibility_node(id);
1736 assert_eq!(info.name(), Some("Just a title"));
1737 }
1738
1739 #[test]
1740 fn list_item_with_checkbox_two_state() {
1741 use teksilo_core::signal::Signal;
1742 let checked = Signal::new(false);
1743 let mut tree = WidgetTree::new().with_theme(theme());
1744 let _id =
1745 tree.add(StandardListItem::new(lit!("Item with checkbox")).checkbox(checked.clone()));
1746 tree.layout(SizeProposal::exact(300.0, 100.0));
1747 assert!(!checked.get());
1750 }
1751
1752 #[test]
1753 fn list_item_with_tristate_checkbox() {
1754 use teksilo_core::signal::Signal;
1755 let state = Signal::new(CheckState::Indeterminate);
1756 let mut tree = WidgetTree::new().with_theme(theme());
1757 let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
1758 tree.layout(SizeProposal::exact(300.0, 100.0));
1759 let b = tree.bounds(id);
1760 assert!(b.width > 0.0);
1761 }
1762
1763 #[test]
1764 fn tree_item_chevron_reserved_for_leaf() {
1765 let mut tree = WidgetTree::new().with_theme(theme());
1768 let leaf = tree.add(
1769 StandardTreeItem::new(lit!("file"))
1770 .depth(1)
1771 .has_children(false),
1772 );
1773 let branch = tree.add(
1774 StandardTreeItem::new(lit!("folder"))
1775 .depth(1)
1776 .has_children(true),
1777 );
1778 tree.layout(SizeProposal::exact(400.0, 200.0));
1779 let bl = tree.bounds(leaf);
1780 let bb = tree.bounds(branch);
1781 assert!((bl.width - bb.width).abs() < 0.5);
1782 }
1783
1784 #[test]
1785 fn twist_arrow_on_click_baseline() {
1786 use std::cell::Cell;
1791 use std::rc::Rc;
1792 use teksilo_canvas::Point;
1793 let fired = Rc::new(Cell::new(0u32));
1794 let f = fired.clone();
1795 let mut tree = WidgetTree::new().with_theme(theme());
1796 let id =
1797 tree.add(TwistArrow::new(20.0, true, false).on_click(move |_ctx| f.set(f.get() + 1)));
1798 tree.layout(SizeProposal::exact(40.0, 40.0));
1799 let b = tree.bounds(id);
1800 dispatch_tap(
1801 &mut tree,
1802 Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5),
1803 );
1804 assert_eq!(fired.get(), 1, "TwistArrow.on_click() must fire on tap");
1805 }
1806
1807 #[test]
1808 fn fixed_size_wrapping_twist_arrow_on_tap_baseline() {
1809 use std::cell::Cell;
1814 use std::rc::Rc;
1815 use teksilo_canvas::Point;
1816 use teksilo_core::widget_builder::WidgetBuilder;
1817 let fired = Rc::new(Cell::new(0u32));
1818 let f = fired.clone();
1819 let mut tree = WidgetTree::new().with_theme(theme());
1820 let id = tree.add(
1821 FixedSize::new()
1822 .width(20.0_f32)
1823 .child(TwistArrow::new(20.0, true, false))
1824 .on_tap(move |_, _| f.set(f.get() + 1)),
1825 );
1826 tree.layout(SizeProposal::exact(40.0, 40.0));
1827 let b = tree.bounds(id);
1828 dispatch_tap(
1829 &mut tree,
1830 Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5),
1831 );
1832 assert_eq!(fired.get(), 1);
1833 }
1834
1835 #[test]
1836 fn fixed_size_on_tap_baseline() {
1837 use std::cell::Cell;
1841 use std::rc::Rc;
1842 use teksilo_canvas::Point;
1843 use teksilo_core::widget_builder::WidgetBuilder;
1844 let fired = Rc::new(Cell::new(0u32));
1845 let f = fired.clone();
1846 let mut tree = WidgetTree::new().with_theme(theme());
1847 let id = tree.add(
1848 FixedSize::new()
1849 .width(40.0_f32)
1850 .height(40.0_f32)
1851 .child(TextWidget::new(lit!("x")))
1852 .on_tap(move |_, _| f.set(f.get() + 1)),
1853 );
1854 tree.layout(SizeProposal::exact(200.0, 200.0));
1855 let b = tree.bounds(id);
1856 dispatch_tap(&mut tree, Point::new(b.x + 20.0, b.y + 20.0));
1857 assert_eq!(fired.get(), 1);
1858 }
1859
1860 fn dispatch_tap(tree: &mut WidgetTree, position: teksilo_canvas::Point) {
1861 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
1862 tree.dispatch_event(WidgetEvent::PointerDown {
1863 position,
1864 button: PointerButton::Primary,
1865 modifiers: Modifiers::NONE,
1866 });
1867 tree.dispatch_event(WidgetEvent::PointerUp {
1868 position,
1869 button: PointerButton::Primary,
1870 modifiers: Modifiers::NONE,
1871 });
1872 }
1873
1874 #[test]
1875 fn list_item_checkbox_two_state_toggles_via_tap() {
1876 use teksilo_canvas::Point;
1877 let checked = Signal::new(false);
1878 let mut tree = WidgetTree::new().with_theme(theme());
1879 let id = tree.add(StandardListItem::new(lit!("Row")).checkbox(checked.clone()));
1880 tree.layout(SizeProposal::exact(400.0, 60.0));
1881 let bounds = tree.bounds(id);
1882 use crate::styles::recipe_standard_item_style as si;
1883 let cb_x = bounds.x
1887 + si::STANDARD_ITEM_BG_HORIZONTAL_INSET
1888 + si::STANDARD_ITEM_PADDING_HORIZONTAL
1889 + 4.0;
1890 let cb_y = bounds.y + bounds.height * 0.5;
1891 dispatch_tap(&mut tree, Point::new(cb_x, cb_y));
1892 assert!(
1893 checked.get(),
1894 "tap on checkbox should flip the bound signal"
1895 );
1896 dispatch_tap(&mut tree, Point::new(cb_x, cb_y));
1897 assert!(!checked.get(), "second tap should flip back");
1898 }
1899
1900 #[test]
1901 fn list_item_row_tap_outside_checkbox_does_not_toggle() {
1902 use teksilo_canvas::Point;
1903 let checked = Signal::new(false);
1904 let mut tree = WidgetTree::new().with_theme(theme());
1905 let id = tree.add(
1906 StandardListItem::new(lit!("A long-enough label so the tap target lands on text"))
1907 .checkbox(checked.clone()),
1908 );
1909 tree.layout(SizeProposal::exact(400.0, 60.0));
1910 let bounds = tree.bounds(id);
1911 let label_x = bounds.x + bounds.width * 0.7;
1914 let label_y = bounds.y + bounds.height * 0.5;
1915 dispatch_tap(&mut tree, Point::new(label_x, label_y));
1916 assert!(
1917 !checked.get(),
1918 "tap on row body must not toggle the embedded checkbox"
1919 );
1920 }
1921
1922 #[test]
1923 fn tree_item_chevron_tap_fires_on_toggle() {
1924 use std::cell::Cell;
1925 use std::rc::Rc;
1926 use teksilo_canvas::Point;
1927 let fired = Rc::new(Cell::new(0u32));
1928 let fired_clone = fired.clone();
1929 let mut tree = WidgetTree::new().with_theme(theme());
1930 let id = tree.add(
1931 StandardTreeItem::new(lit!("Folder"))
1932 .depth(0)
1933 .has_children(true)
1934 .is_expanded(false)
1935 .on_toggle(move |_ctx| fired_clone.set(fired_clone.get() + 1)),
1936 );
1937 tree.layout(SizeProposal::exact(400.0, 60.0));
1938 let bounds = tree.bounds(id);
1939 use crate::styles::recipe_standard_item_style as si;
1940 let cx = bounds.x
1944 + si::STANDARD_ITEM_PADDING_HORIZONTAL
1945 + si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH * 0.5;
1946 let cy = bounds.y + bounds.height * 0.5;
1947 dispatch_tap(&mut tree, Point::new(cx, cy));
1948 assert_eq!(
1949 fired.get(),
1950 1,
1951 "tap on chevron column should fire on_toggle exactly once"
1952 );
1953 }
1954
1955 #[test]
1956 fn tristate_checkbox_user_click_never_sets_indeterminate() {
1957 use teksilo_canvas::Point;
1961 let state = Signal::new(CheckState::Unchecked);
1962 let mut tree = WidgetTree::new().with_theme(theme());
1963 let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
1964 tree.layout(SizeProposal::exact(400.0, 60.0));
1965 let bounds = tree.bounds(id);
1966 use crate::styles::recipe_standard_item_style as si;
1967 let cx = bounds.x + si::STANDARD_ITEM_PADDING_HORIZONTAL + 8.0;
1968 let cy = bounds.y + bounds.height * 0.5;
1969 dispatch_tap(&mut tree, Point::new(cx, cy));
1971 assert_eq!(state.get(), CheckState::Checked);
1972 dispatch_tap(&mut tree, Point::new(cx, cy));
1974 assert_eq!(state.get(), CheckState::Unchecked);
1975 dispatch_tap(&mut tree, Point::new(cx, cy));
1977 assert_eq!(state.get(), CheckState::Checked);
1978 }
1979
1980 #[test]
1981 fn tristate_checkbox_user_click_from_indeterminate_goes_to_checked() {
1982 use teksilo_canvas::Point;
1986 let state = Signal::new(CheckState::Indeterminate);
1987 let mut tree = WidgetTree::new().with_theme(theme());
1988 let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
1989 tree.layout(SizeProposal::exact(400.0, 60.0));
1990 let bounds = tree.bounds(id);
1991 use crate::styles::recipe_standard_item_style as si;
1992 let cx = bounds.x + si::STANDARD_ITEM_PADDING_HORIZONTAL + 8.0;
1993 let cy = bounds.y + bounds.height * 0.5;
1994 dispatch_tap(&mut tree, Point::new(cx, cy));
1995 assert_eq!(state.get(), CheckState::Checked);
1996 }
1997
1998 #[test]
1999 fn tree_item_no_toggle_when_no_children() {
2000 use std::cell::Cell;
2001 use std::rc::Rc;
2002 use teksilo_canvas::Point;
2003 let fired = Rc::new(Cell::new(0u32));
2004 let fired_clone = fired.clone();
2005 let mut tree = WidgetTree::new().with_theme(theme());
2006 let id = tree.add(
2007 StandardTreeItem::new(lit!("Leaf"))
2008 .depth(0)
2009 .has_children(false)
2010 .on_toggle(move |_ctx| fired_clone.set(fired_clone.get() + 1)),
2011 );
2012 tree.layout(SizeProposal::exact(400.0, 60.0));
2013 let bounds = tree.bounds(id);
2014 use crate::styles::recipe_standard_item_style as si;
2015 let cx = bounds.x
2016 + si::STANDARD_ITEM_PADDING_HORIZONTAL
2017 + si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH * 0.5;
2018 let cy = bounds.y + bounds.height * 0.5;
2019 dispatch_tap(&mut tree, Point::new(cx, cy));
2020 assert_eq!(
2021 fired.get(),
2022 0,
2023 "leaf rows must not wire on_toggle even if a callback was set"
2024 );
2025 }
2026
2027 #[test]
2028 fn tree_item_from_entry_sets_depth_and_state() {
2029 use teksilo_data::TreeModel;
2030 let m = TreeModel::<&str>::new();
2031 let root = m.insert_root(0, "r");
2032 let _child = m.insert_child(root, 0, "c");
2033
2034 let entry = FlatEntry {
2035 node_id: root,
2036 depth: 1,
2037 has_children: true,
2038 is_expanded: true,
2039 };
2040 let mut tree = WidgetTree::new().with_theme(theme());
2041 let id = tree.add(StandardTreeItem::new(lit!("x")).from_entry(&entry));
2042 tree.layout(SizeProposal::exact(400.0, 100.0));
2043 assert!(tree.bounds(id).width > 0.0);
2044 }
2045
2046 #[test]
2047 fn list_item_tooltip_appears_on_hover() {
2048 let mut tree = WidgetTree::new().with_theme(theme());
2049 let id = tree.add(StandardListItem::new(lit!("Row")).tooltip(lit!("Tip")));
2050 tree.layout(SizeProposal::exact(300.0, 200.0));
2051 tree.pointer_move(tree.bounds(id).center());
2052 tree.advance_time(std::time::Duration::from_secs(1));
2053 assert_eq!(
2054 tree.active_overlays().len(),
2055 1,
2056 "tooltip should appear on hover"
2057 );
2058 assert!(tree.find_by_label("Tip").is_some());
2059 }
2060
2061 #[test]
2062 fn tree_item_tooltip_appears_on_hover() {
2063 let mut tree = WidgetTree::new().with_theme(theme());
2064 let id = tree.add(StandardTreeItem::new(lit!("Node")).tooltip(lit!("TreeTip")));
2065 tree.layout(SizeProposal::exact(300.0, 200.0));
2066 tree.pointer_move(tree.bounds(id).center());
2067 tree.advance_time(std::time::Duration::from_secs(1));
2068 assert_eq!(
2069 tree.active_overlays().len(),
2070 1,
2071 "tooltip should appear on hover"
2072 );
2073 assert!(tree.find_by_label("TreeTip").is_some());
2074 }
2075}