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;
637 let cb = match kind {
638 CheckboxKind::TwoState(s) => Checkbox::new(s),
639 CheckboxKind::TriState(s) => Checkbox::tristate(s),
640 }
641 .labels_hidden(true);
642 let cb_id = ctx.add(cb.access_label(self.label.clone()));
643 row = row.add_child(cb_id);
644 }
645 if let Some(w) = self.leading_slot.take() {
646 let id = ctx.add_boxed(w);
647 row = row.add_child(id);
648 }
649 if let Some(w) = self.center_slot.take() {
650 let id = ctx.add_boxed(w);
651 row = row.add_child(id);
652 }
653 row = row
654 .add_child(label_column_id)
655 .add_child(ctx.add(Spacer::new()));
656 if let Some(w) = self.trailing_slot.take() {
657 let id = ctx.add_boxed(w);
658 row = row.add_child(id);
659 }
660
661 ctx.add(row)
662 }
663
664 fn build_with_background(
671 &mut self,
672 ctx: &mut BuildContext,
673 content_id: WidgetId,
674 roles: &RowRoles,
675 ) -> WidgetId {
676 let is_selected = self.selected.clone();
680 let is_disabled = self.enabled.map(|e| !*e);
681 let is_hovered = self
682 .interaction
683 .map(|s| matches!(s, InteractionState::Hovered));
684 let is_pressed = self
685 .interaction
686 .map(|s| matches!(s, InteractionState::Pressed));
687 let is_focused = ctx.view_focus_active();
695 let is_focus_visible = ctx.focus_visible();
698
699 let style: SharedStandardItemStyle = roles.style.clone();
700 let cfg = StandardItemStyleConfig {
701 content: content_id,
702 is_selected,
703 is_hovered,
704 is_pressed,
705 is_focused,
706 is_focus_visible,
707 is_disabled,
708 is_window_active: ctx.window_active_signal(),
709 };
710 let root_id = style.make_body(&cfg, ctx);
711
712 use teksilo_core::widget_builder::HandlerSet;
717 let interaction_for_hover = self.interaction.clone();
718 let handlers = HandlerSet::new().on_hover(move |entered: bool, _ctx: &mut EventContext| {
719 interaction_for_hover.set(if entered {
720 InteractionState::Hovered
721 } else {
722 InteractionState::Idle
723 });
724 });
725 ctx.apply_self_handlers(handlers);
726
727 root_id
728 }
729}
730
731impl Widget for StandardListItem {
732 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
733 let self_id = ctx.self_id();
734 ctx.enabled_when(self_id, self.enabled.clone());
742 let roles = self.resolve_roles(ctx);
745 let content_id = self.build_content(ctx, &roles);
746 let root_id = self.build_with_background(ctx, content_id, &roles);
747 self.root_child_id = Some(root_id);
748
749 let tip_placement = crate::tooltip::TooltipPlacement::Side;
754 if let Some(content) = self.composite_tooltip_content.take() {
755 let delay = ctx.theme().motion.tooltip_delay_heavy;
756 crate::tooltip::attach_composite_tooltip_boxed_with_placement(
757 ctx,
758 root_id,
759 content,
760 delay,
761 tip_placement,
762 );
763 } else if let Some(source) = self.rich_tooltip_source.clone() {
764 let delay = ctx.theme().motion.tooltip_delay;
765 crate::tooltip::attach_rich_tooltip_source_with_placement(
766 ctx,
767 root_id,
768 source,
769 delay,
770 tip_placement,
771 );
772 } else if let Some(text) = self.tooltip_text.clone() {
773 let delay = ctx.theme().motion.tooltip_delay;
774 crate::tooltip::attach_plain_tooltip_with_placement(
775 ctx,
776 root_id,
777 text,
778 delay,
779 tip_placement,
780 );
781 }
782
783 vec![root_id]
784 }
785
786 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
787 use crate::styles::recipe_standard_item_style as si;
788 let min_height = if self.subtitle.is_some() {
789 si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE
790 } else {
791 si::STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE
792 };
793 let raw = self
794 .root_child_id
795 .and_then(|id| ctx.child_size(id, proposal))
796 .unwrap_or_else(|| proposal.resolve(0.0, min_height));
797 let height = raw.height.max(min_height);
798 let width = proposal.width.unwrap_or(raw.width);
805 teksilo_canvas::Size::new(width, height).into()
806 }
807
808 fn place_children(
809 &self,
810 bounds: Rect,
811 _proposal: SizeProposal,
812 children: &mut [WidgetPlacement],
813 _ctx: &LayoutContext,
814 ) {
815 for child in children.iter_mut() {
816 child.origin = bounds.origin();
817 child.size = bounds.size();
818 }
819 }
820
821 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
822 builder.set_name(self.label.clone());
833 if let Some(subtitle) = &self.subtitle {
834 builder.set_description(subtitle.clone());
835 }
836 }
841
842 fn children(&self) -> Vec<WidgetId> {
843 self.root_child_id.into_iter().collect()
844 }
845}
846
847pub struct StandardTreeItem {
857 inner: StandardListItem,
858 depth: usize,
859 has_children: bool,
860 is_expanded: Prop<bool>,
861 on_toggle: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
862}
863
864impl StandardTreeItem {
865 pub fn new(label: impl Into<LocalizedString>) -> Self {
867 Self {
868 inner: StandardListItem::new(label),
869 depth: 0,
870 has_children: false,
871 is_expanded: Prop::Static(false),
872 on_toggle: None,
873 }
874 }
875
876 pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self {
883 self.inner = self.inner.interaction_signal(signal);
884 self
885 }
886
887 pub fn label_slot(mut self, widget: impl Widget + 'static) -> Self {
890 self.inner = self.inner.label_slot(widget);
891 self
892 }
893
894 pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
895 self.inner = self.inner.subtitle(text);
896 self
897 }
898
899 pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
902 self.inner = self.inner.leading_slot(widget);
903 self
904 }
905
906 pub fn leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
908 self.inner = self.inner.leading_slot_boxed(widget);
909 self
910 }
911
912 pub fn center_slot(mut self, widget: impl Widget + 'static) -> Self {
915 self.inner = self.inner.center_slot(widget);
916 self
917 }
918
919 pub fn center_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
921 self.inner = self.inner.center_slot_boxed(widget);
922 self
923 }
924
925 pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
928 self.inner = self.inner.trailing_slot(widget);
929 self
930 }
931
932 pub fn trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
934 self.inner = self.inner.trailing_slot_boxed(widget);
935 self
936 }
937
938 pub fn subtitle_leading_slot(mut self, widget: impl Widget + 'static) -> Self {
941 self.inner = self.inner.subtitle_leading_slot(widget);
942 self
943 }
944
945 pub fn subtitle_leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
948 self.inner = self.inner.subtitle_leading_slot_boxed(widget);
949 self
950 }
951
952 pub fn subtitle_trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
955 self.inner = self.inner.subtitle_trailing_slot(widget);
956 self
957 }
958
959 pub fn subtitle_trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
962 self.inner = self.inner.subtitle_trailing_slot_boxed(widget);
963 self
964 }
965
966 pub fn checkbox(mut self, checked: Signal<bool>) -> Self {
969 self.inner = self.inner.checkbox(checked);
970 self
971 }
972
973 pub fn tristate_checkbox(mut self, state: Signal<CheckState>) -> Self {
976 self.inner = self.inner.tristate_checkbox(state);
977 self
978 }
979
980 pub fn selected(mut self, selected: impl Into<Prop<bool>>) -> Self {
984 self.inner = self.inner.selected(selected);
985 self
986 }
987
988 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
992 self.inner = self.inner.enabled(enabled);
993 self
994 }
995
996 pub fn label_style(
1000 mut self,
1001 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
1002 ) -> Self {
1003 self.inner = self.inner.label_style(style);
1004 self
1005 }
1006
1007 pub fn subtitle_style(
1011 mut self,
1012 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
1013 ) -> Self {
1014 self.inner = self.inner.subtitle_style(style);
1015 self
1016 }
1017
1018 pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
1021 self.inner = self.inner.label_color(color);
1022 self
1023 }
1024
1025 pub fn subtitle_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
1028 self.inner = self.inner.subtitle_color(color);
1029 self
1030 }
1031
1032 pub fn label_overflow(mut self, overflow: TextOverflow) -> Self {
1036 self.inner = self.inner.label_overflow(overflow);
1037 self
1038 }
1039
1040 pub fn subtitle_overflow(mut self, overflow: TextOverflow) -> Self {
1044 self.inner = self.inner.subtitle_overflow(overflow);
1045 self
1046 }
1047
1048 pub fn style(mut self, style: impl teksilo_core::styles::StandardItemStyle) -> Self {
1053 self.inner = self.inner.style(style);
1054 self
1055 }
1056
1057 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
1061 self.inner = self.inner.tooltip(text);
1062 self
1063 }
1064
1065 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
1069 self.inner = self.inner.rich_tooltip(key);
1070 self
1071 }
1072
1073 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
1078 self.inner = self.inner.rich_tooltip_content(content);
1079 self
1080 }
1081
1082 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
1086 self.inner = self.inner.composite_tooltip(content);
1087 self
1088 }
1089
1090 pub fn depth(mut self, depth: usize) -> Self {
1095 self.depth = depth;
1096 self
1097 }
1098
1099 pub fn has_children(mut self, has: bool) -> Self {
1102 self.has_children = has;
1103 self
1104 }
1105
1106 pub fn is_expanded(mut self, expanded: impl Into<Prop<bool>>) -> Self {
1109 self.is_expanded = expanded.into();
1110 self
1111 }
1112
1113 pub fn from_entry(self, entry: &FlatEntry) -> Self {
1116 self.depth(entry.depth)
1117 .has_children(entry.has_children)
1118 .is_expanded(entry.is_expanded)
1119 }
1120
1121 pub fn on_toggle(
1130 mut self,
1131 f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
1132 ) -> Self {
1133 self.on_toggle = Some(Rc::new(f));
1134 self
1135 }
1136
1137 pub fn on_toggle_rc(mut self, f: Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>) -> Self {
1142 self.on_toggle = Some(f);
1143 self
1144 }
1145}
1146
1147impl std::fmt::Debug for StandardTreeItem {
1148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1149 f.debug_struct("StandardTreeItem")
1150 .field("inner", &self.inner)
1151 .field("depth", &self.depth)
1152 .field("has_children", &self.has_children)
1153 .finish()
1154 }
1155}
1156
1157impl Widget for StandardTreeItem {
1158 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1159 use crate::styles::recipe_standard_item_style as si;
1160
1161 let self_id = ctx.self_id();
1165 ctx.enabled_when(self_id, self.inner.enabled.clone());
1166
1167 let roles = self.inner.resolve_roles(ctx);
1169 let inner_content_id = self.inner.build_content(ctx, &roles);
1170
1171 let indent_width = self.depth as f32 * si::STANDARD_ITEM_TREE_INDENT_STEP;
1173 let indent_id = ctx.add(FixedSize::new().width(indent_width));
1174
1175 let chevron_size = si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH;
1183 let chevron_role = self.inner.foreground_role(&roles, TextRole::Secondary);
1190 let mut chevron = TwistArrow::new(chevron_size, self.has_children, self.is_expanded.get())
1191 .color(chevron_role);
1192 if self.has_children
1193 && let Some(cb) = self.on_toggle.clone()
1194 {
1195 chevron = chevron.on_click(move |ctx| cb(ctx));
1196 }
1197 let chevron_column_id = ctx.add(FixedSize::new().width(chevron_size).child(chevron));
1198
1199 let outer_row_id = ctx.add(
1201 HStack::new()
1202 .spacing(0.0)
1203 .alignment(VAlignment::Center)
1204 .add_child(indent_id)
1205 .add_child(chevron_column_id)
1206 .add_child(inner_content_id),
1207 );
1208
1209 let root_id = self.inner.build_with_background(ctx, outer_row_id, &roles);
1212
1213 self.inner.root_child_id = Some(root_id);
1214
1215 let tip_placement = crate::tooltip::TooltipPlacement::Side;
1219 if let Some(content) = self.inner.composite_tooltip_content.take() {
1220 let delay = ctx.theme().motion.tooltip_delay_heavy;
1221 crate::tooltip::attach_composite_tooltip_boxed_with_placement(
1222 ctx,
1223 root_id,
1224 content,
1225 delay,
1226 tip_placement,
1227 );
1228 } else if let Some(source) = self.inner.rich_tooltip_source.clone() {
1229 let delay = ctx.theme().motion.tooltip_delay;
1230 crate::tooltip::attach_rich_tooltip_source_with_placement(
1231 ctx,
1232 root_id,
1233 source,
1234 delay,
1235 tip_placement,
1236 );
1237 } else if let Some(text) = self.inner.tooltip_text.clone() {
1238 let delay = ctx.theme().motion.tooltip_delay;
1239 crate::tooltip::attach_plain_tooltip_with_placement(
1240 ctx,
1241 root_id,
1242 text,
1243 delay,
1244 tip_placement,
1245 );
1246 }
1247
1248 vec![root_id]
1249 }
1250
1251 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1252 self.inner.layout_response(proposal, ctx)
1253 }
1254
1255 fn place_children(
1256 &self,
1257 bounds: Rect,
1258 proposal: SizeProposal,
1259 children: &mut [WidgetPlacement],
1260 ctx: &LayoutContext,
1261 ) {
1262 self.inner.place_children(bounds, proposal, children, ctx);
1263 }
1264
1265 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1266 self.inner.accessibility(builder);
1267 }
1268
1269 fn children(&self) -> Vec<WidgetId> {
1270 self.inner.children()
1271 }
1272}
1273
1274#[cfg(test)]
1279mod tests {
1280 use super::*;
1281 use teksilo_canvas::SizeProposal;
1282 use teksilo_core::Theme;
1283 use teksilo_core::styles::StandardItemStyle;
1284 use teksilo_core::widget_tree::WidgetTree;
1285 use teksilo_i18n::lit;
1286
1287 fn theme() -> Theme {
1288 teksilo_core::presets::intui::light()
1289 }
1290
1291 fn discriminating_theme() -> Theme {
1299 let mut t = theme();
1300 t.colors.text_on_accent = teksilo_tokens::Color::WHITE;
1301 assert_ne!(t.colors.text_primary, t.colors.text_on_accent);
1302 t
1303 }
1304
1305 fn glyph_colors(tree: &mut WidgetTree) -> Vec<[u8; 4]> {
1307 tree.render()
1308 .glyphs
1309 .iter()
1310 .map(|g| {
1311 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1312 [q(g.color[0]), q(g.color[1]), q(g.color[2]), q(g.color[3])]
1313 })
1314 .collect()
1315 }
1316
1317 fn rgba8(c: teksilo_tokens::Color) -> [u8; 4] {
1318 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1319 [q(c.r()), q(c.g()), q(c.b()), q(c.a())]
1320 }
1321
1322 #[derive(Debug, Default, Clone, Copy)]
1327 struct OnAccentSelectionStyle;
1328
1329 impl StandardItemStyle for OnAccentSelectionStyle {
1330 fn make_body(
1331 &self,
1332 cfg: &StandardItemStyleConfig,
1333 ctx: &mut teksilo_core::build_context::BuildContext,
1334 ) -> WidgetId {
1335 crate::styles::RecipeStandardItemStyle::default().make_body(cfg, ctx)
1336 }
1337
1338 fn selected_label_role(&self) -> Option<TextRole> {
1339 Some(TextRole::OnAccent)
1340 }
1341 }
1342
1343 #[test]
1347 fn a_style_without_the_hook_leaves_the_selected_label_alone() {
1348 let t = discriminating_theme();
1349 let primary = rgba8(t.colors.text_primary);
1350 let on_accent = rgba8(t.colors.text_on_accent);
1351
1352 let mut tree = WidgetTree::new()
1353 .with_theme(t)
1354 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1355 teksilo_canvas::MockTextBackend::new(),
1356 )));
1357 tree.add(StandardListItem::new(lit!("Row")).selected(Signal::new(true)));
1358 tree.layout(SizeProposal::exact(300.0, 40.0));
1359 let colors = glyph_colors(&mut tree);
1360 assert!(colors.contains(&primary));
1361 assert!(!colors.contains(&on_accent));
1362 }
1363
1364 #[test]
1367 fn the_hook_flips_the_label_of_an_emphasised_row() {
1368 let t = discriminating_theme();
1369 let on_accent = rgba8(t.colors.text_on_accent);
1370
1371 let mut tree = WidgetTree::new()
1372 .with_theme(t)
1373 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1374 teksilo_canvas::MockTextBackend::new(),
1375 )));
1376 tree.add(
1377 StandardListItem::new(lit!("Row"))
1378 .selected(Signal::new(true))
1379 .style(OnAccentSelectionStyle),
1380 );
1381 tree.layout(SizeProposal::exact(300.0, 40.0));
1382 assert!(glyph_colors(&mut tree).contains(&on_accent));
1383 }
1384
1385 #[test]
1389 fn the_hook_does_not_touch_an_unselected_row() {
1390 let t = discriminating_theme();
1391 let primary = rgba8(t.colors.text_primary);
1392 let on_accent = rgba8(t.colors.text_on_accent);
1393
1394 let mut tree = WidgetTree::new()
1395 .with_theme(t)
1396 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1397 teksilo_canvas::MockTextBackend::new(),
1398 )));
1399 tree.add(StandardListItem::new(lit!("Row")).style(OnAccentSelectionStyle));
1400 tree.layout(SizeProposal::exact(300.0, 40.0));
1401 let colors = glyph_colors(&mut tree);
1402 assert!(colors.contains(&primary));
1403 assert!(!colors.contains(&on_accent));
1404 }
1405
1406 #[test]
1415 fn the_hook_flips_a_tree_rows_chevron_with_its_label() {
1416 let t = discriminating_theme();
1417 let secondary = rgba8(t.colors.text_secondary);
1418 let on_accent = rgba8(t.colors.text_on_accent);
1419
1420 let mut tree = WidgetTree::new()
1421 .with_theme(t)
1422 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1423 teksilo_canvas::MockTextBackend::new(),
1424 )));
1425 tree.add(
1426 StandardTreeItem::new(lit!("Node"))
1427 .has_children(true)
1428 .selected(Signal::new(true))
1429 .style(OnAccentSelectionStyle),
1430 );
1431 tree.layout(SizeProposal::exact(300.0, 40.0));
1432
1433 let shapes: Vec<[u8; 4]> = tree
1434 .render()
1435 .shapes
1436 .iter()
1437 .map(|s| {
1438 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1439 [q(s.color[0]), q(s.color[1]), q(s.color[2]), q(s.color[3])]
1440 })
1441 .collect();
1442 let paths: Vec<[u8; 4]> = tree
1443 .render()
1444 .paths
1445 .iter()
1446 .map(|p| {
1447 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1448 [q(p.color[0]), q(p.color[1]), q(p.color[2]), q(p.color[3])]
1449 })
1450 .collect();
1451 let painted: Vec<[u8; 4]> = shapes.into_iter().chain(paths).collect();
1452
1453 assert!(
1454 painted.contains(&on_accent),
1455 "the chevron did not flip with the label; painted {painted:?}"
1456 );
1457 assert!(
1458 !painted.contains(&secondary),
1459 "the chevron is still painting the muted role on an accent capsule"
1460 );
1461 }
1462
1463 #[test]
1466 fn a_tree_rows_chevron_is_muted_without_the_hook() {
1467 let t = discriminating_theme();
1468 let secondary = rgba8(t.colors.text_secondary);
1469
1470 let mut tree = WidgetTree::new()
1471 .with_theme(t)
1472 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1473 teksilo_canvas::MockTextBackend::new(),
1474 )));
1475 tree.add(
1476 StandardTreeItem::new(lit!("Node"))
1477 .has_children(true)
1478 .selected(Signal::new(true)),
1479 );
1480 tree.layout(SizeProposal::exact(300.0, 40.0));
1481 let paths: Vec<[u8; 4]> = tree
1482 .render()
1483 .paths
1484 .iter()
1485 .map(|p| {
1486 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1487 [q(p.color[0]), q(p.color[1]), q(p.color[2]), q(p.color[3])]
1488 })
1489 .collect();
1490 assert!(paths.contains(&secondary), "painted {paths:?}");
1491 }
1492
1493 #[test]
1497 fn the_hook_reverts_when_the_row_stops_being_emphasised() {
1498 let t = discriminating_theme();
1499 let primary = rgba8(t.colors.text_primary);
1500 let on_accent = rgba8(t.colors.text_on_accent);
1501
1502 let mut tree = WidgetTree::new()
1503 .with_theme(t)
1504 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1505 teksilo_canvas::MockTextBackend::new(),
1506 )));
1507 tree.add(
1508 StandardListItem::new(lit!("Row"))
1509 .selected(Signal::new(true))
1510 .style(OnAccentSelectionStyle),
1511 );
1512 tree.layout(SizeProposal::exact(300.0, 40.0));
1513 assert!(glyph_colors(&mut tree).contains(&on_accent));
1514
1515 tree.set_window_active(false);
1516 tree.layout(SizeProposal::exact(300.0, 40.0));
1517 let colors = glyph_colors(&mut tree);
1518 assert!(
1519 colors.contains(&primary),
1520 "an inactive window's selected row kept its on-accent label"
1521 );
1522 assert!(!colors.contains(&on_accent));
1523 }
1524
1525 #[test]
1526 fn list_item_layout_single_line() {
1527 let mut tree = WidgetTree::new().with_theme(theme());
1528 let id = tree.add(StandardListItem::new(lit!("Hello")));
1529 tree.layout(SizeProposal {
1530 width: Some(300.0),
1531 height: None,
1532 });
1533 let b = tree.bounds(id);
1534 use crate::styles::recipe_standard_item_style as si;
1535 assert!(b.height >= si::STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE - 0.5);
1536 }
1537
1538 #[test]
1543 fn a_wrapping_subtitle_pushes_the_trailing_slot_out_of_the_row() {
1544 const ROW_W: f32 = 680.0;
1545 let mut tree = WidgetTree::new().with_theme(theme());
1546 let row = tree.add(
1547 StandardListItem::new(lit!("2026-07-14 10:05"))
1548 .subtitle(lit!(
1549 "11 KB · /home/user/Nextcloud/Documents/Books/backups/novel-20260714-100528.skrib"
1550 ))
1551 .trailing_slot(crate::button::Button::new(lit!("Open"))),
1552 );
1553 tree.layout(SizeProposal::exact(ROW_W, 56.0));
1554
1555 let button = tree.find_by_label("Open").expect("trailing button");
1556 assert!(
1557 tree.bounds(button).right() > tree.bounds(row).right(),
1558 "a wrapping subtitle should overflow the row (got button right={}, row right={})",
1559 tree.bounds(button).right(),
1560 tree.bounds(row).right(),
1561 );
1562 }
1563
1564 #[test]
1567 fn an_eliding_subtitle_keeps_the_trailing_slot_inside_the_row() {
1568 const ROW_W: f32 = 680.0;
1569 let mut tree = WidgetTree::new().with_theme(theme());
1570 let row = tree.add(
1571 StandardListItem::new(lit!("2026-07-14 10:05"))
1572 .subtitle(lit!(
1573 "11 KB · /home/user/Nextcloud/Documents/Books/backups/novel-20260714-100528.skrib"
1574 ))
1575 .subtitle_overflow(TextOverflow::Ellipsis(teksilo_canvas::EllipsisMode::Middle))
1576 .trailing_slot(crate::button::Button::new(lit!("Open"))),
1577 );
1578 tree.layout(SizeProposal::exact(ROW_W, 56.0));
1579
1580 let button = tree.find_by_label("Open").expect("trailing button");
1581 assert!(
1582 tree.bounds(button).right() <= tree.bounds(row).right() + 0.5,
1583 "an elided subtitle must keep the trailing slot inside the row \
1584 (got button right={}, row right={})",
1585 tree.bounds(button).right(),
1586 tree.bounds(row).right(),
1587 );
1588 }
1589
1590 #[test]
1592 fn tree_item_forwards_the_overflow_levers() {
1593 const ROW_W: f32 = 400.0;
1594 let mut tree = WidgetTree::new().with_theme(theme());
1595 let row = tree.add(
1596 StandardTreeItem::new(lit!(
1597 "A very long chapter title that cannot possibly fit this row"
1598 ))
1599 .label_overflow(TextOverflow::Ellipsis(
1600 teksilo_canvas::EllipsisMode::Trailing,
1601 ))
1602 .trailing_slot(crate::button::Button::new(lit!("Open"))),
1603 );
1604 tree.layout(SizeProposal::exact(ROW_W, 56.0));
1605
1606 let button = tree.find_by_label("Open").expect("trailing button");
1607 assert!(
1608 tree.bounds(button).right() <= tree.bounds(row).right() + 0.5,
1609 "an elided label must keep the tree row's trailing slot inside it \
1610 (got button right={}, row right={})",
1611 tree.bounds(button).right(),
1612 tree.bounds(row).right(),
1613 );
1614 }
1615
1616 #[test]
1617 fn selected_item_draws_focus_colour_boundary() {
1618 let t = theme();
1622 let border = t.colors.border_focused.to_array();
1623 let has_boundary = |frame: &teksilo_canvas::RenderFrame| {
1626 frame
1627 .shapes
1628 .iter()
1629 .any(|s| s.color == border && s.stroke_width > 0.0)
1630 };
1631
1632 let mut sel = WidgetTree::new().with_theme(t.clone());
1633 sel.add(StandardListItem::new(lit!("X")).selected(true));
1634 sel.layout(SizeProposal::exact(200.0, 40.0));
1635 assert!(
1636 has_boundary(&sel.render()),
1637 "selected item must draw a boundary in the focus/accent colour"
1638 );
1639
1640 let mut plain = WidgetTree::new().with_theme(t);
1641 plain.add(StandardListItem::new(lit!("X")).selected(false));
1642 plain.layout(SizeProposal::exact(200.0, 40.0));
1643 assert!(
1644 !has_boundary(&plain.render()),
1645 "an unselected item draws no such boundary"
1646 );
1647 }
1648
1649 #[test]
1650 fn list_item_layout_two_line() {
1651 let mut tree = WidgetTree::new().with_theme(theme());
1652 let id = tree.add(StandardListItem::new(lit!("Title")).subtitle(lit!("Subtitle text")));
1653 tree.layout(SizeProposal {
1654 width: Some(300.0),
1655 height: None,
1656 });
1657 let b = tree.bounds(id);
1658 use crate::styles::recipe_standard_item_style as si;
1659 assert!(
1660 b.height >= si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE - 0.5,
1661 "two-line height {} < expected {}",
1662 b.height,
1663 si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE
1664 );
1665 }
1666
1667 #[test]
1668 fn list_item_a11y_name_is_label_only() {
1669 let mut tree = WidgetTree::new().with_theme(theme());
1673 let id = tree.add(StandardListItem::new(lit!("Title")).subtitle(lit!("Subtitle")));
1674 tree.layout(SizeProposal::exact(300.0, 100.0));
1675 let info = tree.accessibility_node(id);
1676 assert_eq!(info.name(), Some("Title"));
1677 }
1678
1679 #[test]
1682 fn a_shared_interaction_signal_reports_the_rows_hover() {
1683 let state = Signal::new(InteractionState::Idle);
1684 let mut tree = WidgetTree::new().with_theme(theme());
1685 let id =
1686 tree.add(StandardListItem::new(lit!("A result")).interaction_signal(state.clone()));
1687 tree.layout(SizeProposal::exact(300.0, 40.0));
1688 let _ = tree.render();
1689 assert_eq!(state.get(), InteractionState::Idle);
1690
1691 let b = tree.bounds(id);
1692 tree.dispatch_event(teksilo_core::WidgetEvent::PointerMove {
1693 position: teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1694 });
1695 tree.layout(SizeProposal::exact(300.0, 40.0));
1696 let _ = tree.render();
1697 assert_eq!(
1698 state.get(),
1699 InteractionState::Hovered,
1700 "the row shares its own hover state with whoever asked for it"
1701 );
1702 }
1703
1704 #[test]
1711 fn a_row_that_draws_its_own_label_keeps_its_accessible_name() {
1712 let mut tree = WidgetTree::new().with_theme(theme());
1713 let id = tree.add(
1714 StandardListItem::new(lit!("she walked across the ice"))
1715 .label_slot(TextWidget::new(lit!("…across the ice"))),
1716 );
1717 tree.layout(SizeProposal::exact(300.0, 100.0));
1718 let info = tree.accessibility_node(id);
1719 assert_eq!(
1720 info.name(),
1721 Some("she walked across the ice"),
1722 "the name comes from the label, not from what was drawn instead of it"
1723 );
1724 }
1725
1726 #[test]
1727 fn list_item_a11y_name_no_subtitle() {
1728 let mut tree = WidgetTree::new().with_theme(theme());
1729 let id = tree.add(StandardListItem::new(lit!("Just a title")));
1730 tree.layout(SizeProposal::exact(300.0, 100.0));
1731 let info = tree.accessibility_node(id);
1732 assert_eq!(info.name(), Some("Just a title"));
1733 }
1734
1735 #[test]
1736 fn list_item_with_checkbox_two_state() {
1737 use teksilo_core::signal::Signal;
1738 let checked = Signal::new(false);
1739 let mut tree = WidgetTree::new().with_theme(theme());
1740 let _id =
1741 tree.add(StandardListItem::new(lit!("Item with checkbox")).checkbox(checked.clone()));
1742 tree.layout(SizeProposal::exact(300.0, 100.0));
1743 assert!(!checked.get());
1746 }
1747
1748 #[test]
1749 fn list_item_with_tristate_checkbox() {
1750 use teksilo_core::signal::Signal;
1751 let state = Signal::new(CheckState::Indeterminate);
1752 let mut tree = WidgetTree::new().with_theme(theme());
1753 let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
1754 tree.layout(SizeProposal::exact(300.0, 100.0));
1755 let b = tree.bounds(id);
1756 assert!(b.width > 0.0);
1757 }
1758
1759 #[test]
1760 fn tree_item_chevron_reserved_for_leaf() {
1761 let mut tree = WidgetTree::new().with_theme(theme());
1764 let leaf = tree.add(
1765 StandardTreeItem::new(lit!("file"))
1766 .depth(1)
1767 .has_children(false),
1768 );
1769 let branch = tree.add(
1770 StandardTreeItem::new(lit!("folder"))
1771 .depth(1)
1772 .has_children(true),
1773 );
1774 tree.layout(SizeProposal::exact(400.0, 200.0));
1775 let bl = tree.bounds(leaf);
1776 let bb = tree.bounds(branch);
1777 assert!((bl.width - bb.width).abs() < 0.5);
1778 }
1779
1780 #[test]
1781 fn twist_arrow_on_click_baseline() {
1782 use std::cell::Cell;
1787 use std::rc::Rc;
1788 use teksilo_canvas::Point;
1789 let fired = Rc::new(Cell::new(0u32));
1790 let f = fired.clone();
1791 let mut tree = WidgetTree::new().with_theme(theme());
1792 let id =
1793 tree.add(TwistArrow::new(20.0, true, false).on_click(move |_ctx| f.set(f.get() + 1)));
1794 tree.layout(SizeProposal::exact(40.0, 40.0));
1795 let b = tree.bounds(id);
1796 dispatch_tap(
1797 &mut tree,
1798 Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5),
1799 );
1800 assert_eq!(fired.get(), 1, "TwistArrow.on_click() must fire on tap");
1801 }
1802
1803 #[test]
1804 fn fixed_size_wrapping_twist_arrow_on_tap_baseline() {
1805 use std::cell::Cell;
1810 use std::rc::Rc;
1811 use teksilo_canvas::Point;
1812 use teksilo_core::widget_builder::WidgetBuilder;
1813 let fired = Rc::new(Cell::new(0u32));
1814 let f = fired.clone();
1815 let mut tree = WidgetTree::new().with_theme(theme());
1816 let id = tree.add(
1817 FixedSize::new()
1818 .width(20.0_f32)
1819 .child(TwistArrow::new(20.0, true, false))
1820 .on_tap(move |_, _| f.set(f.get() + 1)),
1821 );
1822 tree.layout(SizeProposal::exact(40.0, 40.0));
1823 let b = tree.bounds(id);
1824 dispatch_tap(
1825 &mut tree,
1826 Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5),
1827 );
1828 assert_eq!(fired.get(), 1);
1829 }
1830
1831 #[test]
1832 fn fixed_size_on_tap_baseline() {
1833 use std::cell::Cell;
1837 use std::rc::Rc;
1838 use teksilo_canvas::Point;
1839 use teksilo_core::widget_builder::WidgetBuilder;
1840 let fired = Rc::new(Cell::new(0u32));
1841 let f = fired.clone();
1842 let mut tree = WidgetTree::new().with_theme(theme());
1843 let id = tree.add(
1844 FixedSize::new()
1845 .width(40.0_f32)
1846 .height(40.0_f32)
1847 .child(TextWidget::new(lit!("x")))
1848 .on_tap(move |_, _| f.set(f.get() + 1)),
1849 );
1850 tree.layout(SizeProposal::exact(200.0, 200.0));
1851 let b = tree.bounds(id);
1852 dispatch_tap(&mut tree, Point::new(b.x + 20.0, b.y + 20.0));
1853 assert_eq!(fired.get(), 1);
1854 }
1855
1856 fn dispatch_tap(tree: &mut WidgetTree, position: teksilo_canvas::Point) {
1857 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
1858 tree.dispatch_event(WidgetEvent::PointerDown {
1859 position,
1860 button: PointerButton::Primary,
1861 modifiers: Modifiers::NONE,
1862 });
1863 tree.dispatch_event(WidgetEvent::PointerUp {
1864 position,
1865 button: PointerButton::Primary,
1866 modifiers: Modifiers::NONE,
1867 });
1868 }
1869
1870 #[test]
1871 fn list_item_checkbox_two_state_toggles_via_tap() {
1872 use teksilo_canvas::Point;
1873 let checked = Signal::new(false);
1874 let mut tree = WidgetTree::new().with_theme(theme());
1875 let id = tree.add(StandardListItem::new(lit!("Row")).checkbox(checked.clone()));
1876 tree.layout(SizeProposal::exact(400.0, 60.0));
1877 let bounds = tree.bounds(id);
1878 use crate::styles::recipe_standard_item_style as si;
1879 let cb_x = bounds.x
1883 + si::STANDARD_ITEM_BG_HORIZONTAL_INSET
1884 + si::STANDARD_ITEM_PADDING_HORIZONTAL
1885 + 4.0;
1886 let cb_y = bounds.y + bounds.height * 0.5;
1887 dispatch_tap(&mut tree, Point::new(cb_x, cb_y));
1888 assert!(
1889 checked.get(),
1890 "tap on checkbox should flip the bound signal"
1891 );
1892 dispatch_tap(&mut tree, Point::new(cb_x, cb_y));
1893 assert!(!checked.get(), "second tap should flip back");
1894 }
1895
1896 #[test]
1897 fn list_item_row_tap_outside_checkbox_does_not_toggle() {
1898 use teksilo_canvas::Point;
1899 let checked = Signal::new(false);
1900 let mut tree = WidgetTree::new().with_theme(theme());
1901 let id = tree.add(
1902 StandardListItem::new(lit!("A long-enough label so the tap target lands on text"))
1903 .checkbox(checked.clone()),
1904 );
1905 tree.layout(SizeProposal::exact(400.0, 60.0));
1906 let bounds = tree.bounds(id);
1907 let label_x = bounds.x + bounds.width * 0.7;
1910 let label_y = bounds.y + bounds.height * 0.5;
1911 dispatch_tap(&mut tree, Point::new(label_x, label_y));
1912 assert!(
1913 !checked.get(),
1914 "tap on row body must not toggle the embedded checkbox"
1915 );
1916 }
1917
1918 #[test]
1919 fn tree_item_chevron_tap_fires_on_toggle() {
1920 use std::cell::Cell;
1921 use std::rc::Rc;
1922 use teksilo_canvas::Point;
1923 let fired = Rc::new(Cell::new(0u32));
1924 let fired_clone = fired.clone();
1925 let mut tree = WidgetTree::new().with_theme(theme());
1926 let id = tree.add(
1927 StandardTreeItem::new(lit!("Folder"))
1928 .depth(0)
1929 .has_children(true)
1930 .is_expanded(false)
1931 .on_toggle(move |_ctx| fired_clone.set(fired_clone.get() + 1)),
1932 );
1933 tree.layout(SizeProposal::exact(400.0, 60.0));
1934 let bounds = tree.bounds(id);
1935 use crate::styles::recipe_standard_item_style as si;
1936 let cx = bounds.x
1940 + si::STANDARD_ITEM_PADDING_HORIZONTAL
1941 + si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH * 0.5;
1942 let cy = bounds.y + bounds.height * 0.5;
1943 dispatch_tap(&mut tree, Point::new(cx, cy));
1944 assert_eq!(
1945 fired.get(),
1946 1,
1947 "tap on chevron column should fire on_toggle exactly once"
1948 );
1949 }
1950
1951 #[test]
1952 fn tristate_checkbox_user_click_never_sets_indeterminate() {
1953 use teksilo_canvas::Point;
1957 let state = Signal::new(CheckState::Unchecked);
1958 let mut tree = WidgetTree::new().with_theme(theme());
1959 let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
1960 tree.layout(SizeProposal::exact(400.0, 60.0));
1961 let bounds = tree.bounds(id);
1962 use crate::styles::recipe_standard_item_style as si;
1963 let cx = bounds.x + si::STANDARD_ITEM_PADDING_HORIZONTAL + 8.0;
1964 let cy = bounds.y + bounds.height * 0.5;
1965 dispatch_tap(&mut tree, Point::new(cx, cy));
1967 assert_eq!(state.get(), CheckState::Checked);
1968 dispatch_tap(&mut tree, Point::new(cx, cy));
1970 assert_eq!(state.get(), CheckState::Unchecked);
1971 dispatch_tap(&mut tree, Point::new(cx, cy));
1973 assert_eq!(state.get(), CheckState::Checked);
1974 }
1975
1976 #[test]
1977 fn tristate_checkbox_user_click_from_indeterminate_goes_to_checked() {
1978 use teksilo_canvas::Point;
1982 let state = Signal::new(CheckState::Indeterminate);
1983 let mut tree = WidgetTree::new().with_theme(theme());
1984 let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
1985 tree.layout(SizeProposal::exact(400.0, 60.0));
1986 let bounds = tree.bounds(id);
1987 use crate::styles::recipe_standard_item_style as si;
1988 let cx = bounds.x + si::STANDARD_ITEM_PADDING_HORIZONTAL + 8.0;
1989 let cy = bounds.y + bounds.height * 0.5;
1990 dispatch_tap(&mut tree, Point::new(cx, cy));
1991 assert_eq!(state.get(), CheckState::Checked);
1992 }
1993
1994 #[test]
1995 fn tree_item_no_toggle_when_no_children() {
1996 use std::cell::Cell;
1997 use std::rc::Rc;
1998 use teksilo_canvas::Point;
1999 let fired = Rc::new(Cell::new(0u32));
2000 let fired_clone = fired.clone();
2001 let mut tree = WidgetTree::new().with_theme(theme());
2002 let id = tree.add(
2003 StandardTreeItem::new(lit!("Leaf"))
2004 .depth(0)
2005 .has_children(false)
2006 .on_toggle(move |_ctx| fired_clone.set(fired_clone.get() + 1)),
2007 );
2008 tree.layout(SizeProposal::exact(400.0, 60.0));
2009 let bounds = tree.bounds(id);
2010 use crate::styles::recipe_standard_item_style as si;
2011 let cx = bounds.x
2012 + si::STANDARD_ITEM_PADDING_HORIZONTAL
2013 + si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH * 0.5;
2014 let cy = bounds.y + bounds.height * 0.5;
2015 dispatch_tap(&mut tree, Point::new(cx, cy));
2016 assert_eq!(
2017 fired.get(),
2018 0,
2019 "leaf rows must not wire on_toggle even if a callback was set"
2020 );
2021 }
2022
2023 #[test]
2024 fn tree_item_from_entry_sets_depth_and_state() {
2025 use teksilo_data::TreeModel;
2026 let m = TreeModel::<&str>::new();
2027 let root = m.insert_root(0, "r");
2028 let _child = m.insert_child(root, 0, "c");
2029
2030 let entry = FlatEntry {
2031 node_id: root,
2032 depth: 1,
2033 has_children: true,
2034 is_expanded: true,
2035 };
2036 let mut tree = WidgetTree::new().with_theme(theme());
2037 let id = tree.add(StandardTreeItem::new(lit!("x")).from_entry(&entry));
2038 tree.layout(SizeProposal::exact(400.0, 100.0));
2039 assert!(tree.bounds(id).width > 0.0);
2040 }
2041
2042 #[test]
2043 fn list_item_tooltip_appears_on_hover() {
2044 let mut tree = WidgetTree::new().with_theme(theme());
2045 let id = tree.add(StandardListItem::new(lit!("Row")).tooltip(lit!("Tip")));
2046 tree.layout(SizeProposal::exact(300.0, 200.0));
2047 tree.pointer_move(tree.bounds(id).center());
2048 tree.advance_time(std::time::Duration::from_secs(1));
2049 assert_eq!(
2050 tree.active_overlays().len(),
2051 1,
2052 "tooltip should appear on hover"
2053 );
2054 assert!(tree.find_by_label("Tip").is_some());
2055 }
2056
2057 #[test]
2058 fn tree_item_tooltip_appears_on_hover() {
2059 let mut tree = WidgetTree::new().with_theme(theme());
2060 let id = tree.add(StandardTreeItem::new(lit!("Node")).tooltip(lit!("TreeTip")));
2061 tree.layout(SizeProposal::exact(300.0, 200.0));
2062 tree.pointer_move(tree.bounds(id).center());
2063 tree.advance_time(std::time::Duration::from_secs(1));
2064 assert_eq!(
2065 tree.active_overlays().len(),
2066 1,
2067 "tooltip should appear on hover"
2068 );
2069 assert!(tree.find_by_label("TreeTip").is_some());
2070 }
2071}