1use std::rc::Rc;
91
92use teksilo_canvas::{Rect, SizeProposal};
93use teksilo_core::accessibility::AccessNodeBuilder;
94use teksilo_core::build_context::BuildContext;
95use teksilo_core::signal::{Prop, Signal};
96use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
97use teksilo_core::widget_id::WidgetId;
98use teksilo_data::{CheckState, FlatEntry};
99
100use teksilo_canvas::TextOverflow;
101use teksilo_core::styles::{SharedStandardItemStyle, StandardItemStyleConfig};
102use teksilo_i18n::LocalizedString;
103use teksilo_tokens::{HAlignment, RevealPolicy, TextRole, TextStyleRole, VAlignment};
104
105use crate::button::InteractionState;
106use crate::checkbox::Checkbox;
107use crate::primitives::{FixedSize, HStack, Shrinkable, Spacer, TextWidget, TwistArrow, VStack};
108
109#[derive(Clone)]
114enum CheckboxKind {
115 TwoState(Signal<bool>),
116 TriState(Signal<CheckState>),
117}
118
119pub struct StandardListItem {
128 label: LocalizedString,
129 subtitle: Option<LocalizedString>,
130 leading_slot: Option<Box<dyn Widget>>,
131 center_slot: Option<Box<dyn Widget>>,
132 trailing_slot: Option<Box<dyn Widget>>,
133 subtitle_leading_slot: Option<Box<dyn Widget>>,
134 subtitle_trailing_slot: Option<Box<dyn Widget>>,
135 checkbox: Option<CheckboxKind>,
136 on_checkbox_toggle: Option<Rc<dyn Fn(bool, &mut teksilo_core::widget::EventContext)>>,
137 selected: Signal<bool>,
138 enabled: Signal<bool>,
139 label_style: teksilo_core::color_prop::TextStyleProp,
140 subtitle_style: teksilo_core::color_prop::TextStyleProp,
141 label_color: Option<teksilo_core::color_prop::ColorProp>,
144 subtitle_color: Option<teksilo_core::color_prop::ColorProp>,
146 label_overflow: Option<TextOverflow>,
149 label_slot: Option<Box<dyn Widget>>,
151 subtitle_overflow: Option<TextOverflow>,
154 interaction: Signal<InteractionState>,
155 reveal: Option<Signal<bool>>,
158 style_override: Option<SharedStandardItemStyle>,
159 resolved_style: Option<SharedStandardItemStyle>,
163 decorated: bool,
168 root_child_id: Option<WidgetId>,
169 tooltip_text: Option<LocalizedString>,
173 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
175 composite_tooltip_content: Option<Box<dyn Widget>>,
177}
178
179impl StandardListItem {
180 pub fn new(label: impl Into<LocalizedString>) -> Self {
182 let ls: LocalizedString = label.into();
183 Self {
184 label: ls,
185 subtitle: None,
186 leading_slot: None,
187 center_slot: None,
188 trailing_slot: None,
189 subtitle_leading_slot: None,
190 subtitle_trailing_slot: None,
191 checkbox: None,
192 on_checkbox_toggle: None,
193 selected: Signal::new(false),
194 enabled: Signal::new(true),
195 label_style: TextStyleRole::Body.into(),
196 subtitle_style: TextStyleRole::Small.into(),
197 label_color: None,
198 subtitle_color: None,
199 label_overflow: None,
200 label_slot: None,
201 subtitle_overflow: None,
202 interaction: Signal::new(InteractionState::Idle),
203 reveal: None,
204 style_override: None,
205 resolved_style: None,
206 decorated: false,
207 root_child_id: None,
208 tooltip_text: None,
209 rich_tooltip_source: None,
210 composite_tooltip_content: None,
211 }
212 }
213
214 pub fn style(mut self, style: impl teksilo_core::styles::StandardItemStyle) -> Self {
217 self.style_override = Some(Rc::new(style));
218 self
219 }
220
221 pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
223 let ls: LocalizedString = text.into();
224 self.subtitle = Some(ls);
225 self
226 }
227
228 pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
231 self.leading_slot = Some(Box::new(widget));
232 self
233 }
234
235 pub fn leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
237 self.leading_slot = Some(widget);
238 self
239 }
240
241 pub fn center_slot(mut self, widget: impl Widget + 'static) -> Self {
246 self.center_slot = Some(Box::new(widget));
247 self
248 }
249
250 pub fn center_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
252 self.center_slot = Some(widget);
253 self
254 }
255
256 pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
259 self.trailing_slot = Some(Box::new(widget));
260 self
261 }
262
263 pub fn trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
265 self.trailing_slot = Some(widget);
266 self
267 }
268
269 pub fn subtitle_leading_slot(mut self, widget: impl Widget + 'static) -> Self {
271 self.subtitle_leading_slot = Some(Box::new(widget));
272 self
273 }
274
275 pub fn subtitle_leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
277 self.subtitle_leading_slot = Some(widget);
278 self
279 }
280
281 pub fn subtitle_trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
283 self.subtitle_trailing_slot = Some(Box::new(widget));
284 self
285 }
286
287 pub fn subtitle_trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
289 self.subtitle_trailing_slot = Some(widget);
290 self
291 }
292
293 pub fn checkbox(mut self, checked: Signal<bool>) -> Self {
296 self.checkbox = Some(CheckboxKind::TwoState(checked));
297 self
298 }
299
300 pub fn tristate_checkbox(mut self, state: Signal<CheckState>) -> Self {
306 self.checkbox = Some(CheckboxKind::TriState(state));
307 self
308 }
309
310 pub fn on_checkbox_toggle(
322 mut self,
323 f: impl Fn(bool, &mut teksilo_core::widget::EventContext) + 'static,
324 ) -> Self {
325 self.on_checkbox_toggle = Some(Rc::new(f));
326 self
327 }
328
329 pub fn selected(mut self, selected: impl Into<Prop<bool>>) -> Self {
332 self.selected = selected.into().as_signal();
333 self
334 }
335
336 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
339 self.enabled = enabled.into().as_signal();
340 self
341 }
342
343 pub fn label_style(
347 mut self,
348 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
349 ) -> Self {
350 self.label_style = style.into();
351 self
352 }
353
354 pub fn subtitle_style(
356 mut self,
357 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
358 ) -> Self {
359 self.subtitle_style = style.into();
360 self
361 }
362
363 pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
367 self.label_color = Some(color.into());
368 self
369 }
370
371 pub fn subtitle_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
374 self.subtitle_color = Some(color.into());
375 self
376 }
377
378 pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self {
391 self.interaction = signal;
392 self
393 }
394
395 pub fn reveal_signal(mut self, signal: Signal<bool>) -> Self {
410 self.reveal = Some(signal);
411 self
412 }
413
414 pub fn label_slot(mut self, widget: impl Widget + 'static) -> Self {
429 self.label_slot = Some(Box::new(widget));
430 self
431 }
432
433 pub fn label_overflow(mut self, overflow: TextOverflow) -> Self {
434 self.label_overflow = Some(overflow);
435 self
436 }
437
438 pub fn subtitle_overflow(mut self, overflow: TextOverflow) -> Self {
446 self.subtitle_overflow = Some(overflow);
447 self
448 }
449
450 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
457 self.tooltip_text = Some(text.into());
458 self.rich_tooltip_source = None;
459 self.composite_tooltip_content = None;
460 self
461 }
462
463 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
470 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
471 self.tooltip_text = None;
472 self.composite_tooltip_content = None;
473 self
474 }
475
476 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
484 self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
485 self.tooltip_text = None;
486 self.composite_tooltip_content = None;
487 self
488 }
489
490 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
497 self.composite_tooltip_content = Some(Box::new(content));
498 self.tooltip_text = None;
499 self.rich_tooltip_source = None;
500 self
501 }
502}
503
504impl std::fmt::Debug for StandardListItem {
505 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
506 f.debug_struct("StandardListItem")
507 .field("label", &self.label)
508 .field("subtitle", &self.subtitle)
509 .field("has_checkbox", &self.checkbox.is_some())
510 .finish()
511 }
512}
513
514fn resolve_label_role(enabled: bool) -> TextRole {
515 if enabled {
516 TextRole::Primary
517 } else {
518 TextRole::Disabled
519 }
520}
521
522struct RowRoles {
531 style: SharedStandardItemStyle,
532 on_selected: Option<TextRole>,
538 emphasised: Option<Signal<bool>>,
544}
545
546impl StandardListItem {
547 fn resolve_roles(&mut self, ctx: &mut BuildContext) -> RowRoles {
552 let style: SharedStandardItemStyle = self.style_override.clone().unwrap_or_else(|| {
553 crate::styles::recipe_standard_item_style::resolve_standard_item_style(ctx)
554 });
555 self.resolved_style = Some(style.clone());
556 let on_selected = style.selected_label_role();
557 let emphasis = style.selection_emphasis();
558 let emphasised = on_selected
559 .map(|_| emphasis.emphasised(&ctx.view_focus_active(), &ctx.window_active_signal()));
560 RowRoles {
561 style,
562 on_selected,
563 emphasised,
564 }
565 }
566
567 fn foreground_role(&self, roles: &RowRoles, rest: TextRole) -> Signal<TextRole> {
580 match (roles.on_selected, &roles.emphasised) {
581 (Some(on_selected), Some(emphasised)) => self
582 .enabled
583 .zip3(&self.selected, emphasised)
584 .map(move |(enabled, selected, emphasised)| {
585 if !*enabled {
586 TextRole::Disabled
587 } else if *selected && *emphasised {
588 on_selected
589 } else {
590 rest
591 }
592 }),
593 _ => self
594 .enabled
595 .map(move |e| if *e { rest } else { TextRole::Disabled }),
596 }
597 }
598
599 fn build_content(&mut self, ctx: &mut BuildContext, roles: &RowRoles) -> WidgetId {
603 use crate::styles::recipe_standard_item_style as si;
604
605 self.decorated |= self.checkbox.is_some() || self.leading_slot.is_some();
608
609 let label_role = self.foreground_role(roles, resolve_label_role(true));
615 let subtitle_role = self.foreground_role(roles, TextRole::Secondary);
616
617 let label_id = match self.label_slot.take() {
622 Some(widget) => ctx.add_boxed(widget),
623 None => {
624 let mut label_widget = TextWidget::new(self.label.clone())
625 .style(self.label_style.clone())
626 .a11y_hidden();
627 label_widget = match &self.label_color {
628 Some(c) => label_widget.color(c.clone()),
629 None => label_widget.color(label_role.clone()),
630 };
631 if let Some(overflow) = self.label_overflow {
632 label_widget = label_widget.overflow(overflow);
633 }
634 ctx.add(label_widget)
635 }
636 };
637
638 let label_column_id = if let Some(subtitle) = &self.subtitle {
639 let mut subtitle_widget = TextWidget::new(subtitle.clone())
641 .style(self.subtitle_style.clone())
642 .a11y_hidden();
643 subtitle_widget = match &self.subtitle_color {
644 Some(c) => subtitle_widget.color(c.clone()),
645 None => subtitle_widget.color(subtitle_role.clone()),
650 };
651 if let Some(overflow) = self.subtitle_overflow {
652 subtitle_widget = subtitle_widget.overflow(overflow);
653 }
654 let subtitle_text_id = ctx.add(subtitle_widget);
655
656 let mut sub_row = HStack::new()
658 .spacing(si::STANDARD_ITEM_SUBTITLE_SLOT_GAP)
659 .alignment(VAlignment::Center);
660 if let Some(w) = self.subtitle_leading_slot.take() {
661 let id = ctx.add_boxed(w);
662 sub_row = sub_row.child(id);
663 }
664 sub_row = sub_row
665 .child(subtitle_text_id)
666 .child(ctx.add(Spacer::new()));
667 if let Some(w) = self.subtitle_trailing_slot.take() {
668 let id = ctx.add_boxed(w);
669 sub_row = sub_row.child(id);
670 }
671 let sub_row_id = ctx.add(sub_row);
672
673 ctx.add(
674 VStack::new()
675 .spacing(si::STANDARD_ITEM_LABEL_SUBTITLE_GAP)
676 .alignment(HAlignment::Leading)
677 .child(label_id)
678 .child(sub_row_id),
679 )
680 } else {
681 label_id
683 };
684
685 let label_column_id = if self.label_overflow.is_some() || self.subtitle_overflow.is_some() {
692 ctx.add(
693 Shrinkable::new()
694 .min_width(si::STANDARD_ITEM_LABEL_COLUMN_MIN_WIDTH)
695 .child(label_column_id),
696 )
697 } else {
698 label_column_id
699 };
700
701 let mut row = HStack::new()
704 .spacing(si::STANDARD_ITEM_SLOT_GAP)
705 .alignment(VAlignment::Center);
706
707 if let Some(kind) = self.checkbox.take() {
708 use teksilo_core::widget_builder::WidgetBuilder;
721 let mut cb = match kind {
722 CheckboxKind::TwoState(s) => Checkbox::new(s),
723 CheckboxKind::TriState(s) => Checkbox::tristate(s),
724 }
725 .labelled_externally();
726 if let Some(f) = self.on_checkbox_toggle.take() {
727 cb = cb.on_change(move |now, ctx| f(now, ctx));
728 }
729 let cb_id = ctx.add(cb.access_label(self.label.clone()));
730 row = row.child(cb_id);
731 }
732 if let Some(w) = self.leading_slot.take() {
733 let id = ctx.add_boxed(w);
734 row = row.child(id);
735 }
736 if let Some(w) = self.center_slot.take() {
737 let id = ctx.add_boxed(w);
738 row = row.child(id);
739 }
740 row = row.child(label_column_id).child(ctx.add(Spacer::new()));
741 if let Some(w) = self.trailing_slot.take() {
742 let id = ctx.add_boxed(w);
743 row = row.child(id);
744 }
745
746 ctx.add(row)
747 }
748
749 fn build_with_background(
756 &mut self,
757 ctx: &mut BuildContext,
758 content_id: WidgetId,
759 roles: &RowRoles,
760 ) -> WidgetId {
761 let is_selected = self.selected.clone();
765 let is_disabled = self.enabled.map(|e| !*e);
766 let is_hovered = self
767 .interaction
768 .map(|s| matches!(s, InteractionState::Hovered));
769 let is_pressed = self
770 .interaction
771 .map(|s| matches!(s, InteractionState::Pressed));
772 let is_focused = ctx.view_focus_active();
780 let is_focus_visible = ctx.focus_visible();
783
784 let style: SharedStandardItemStyle = roles.style.clone();
785 let cfg = StandardItemStyleConfig {
786 content: content_id,
787 is_selected,
788 is_hovered,
789 is_pressed,
790 is_focused,
791 is_focus_visible,
792 is_disabled,
793 is_window_active: ctx.window_active_signal(),
794 };
795 let root_id = style.make_body(&cfg, ctx);
796
797 use teksilo_core::widget_builder::HandlerSet;
802 let reveal_always = ctx.theme().input.reveal == RevealPolicy::Always;
808 if let Some(reveal) = self.reveal.as_ref() {
809 reveal
810 .set(reveal_always || matches!(self.interaction.get(), InteractionState::Hovered));
811 }
812 let interaction_for_hover = self.interaction.clone();
813 let reveal_for_hover = (!reveal_always).then(|| self.reveal.clone()).flatten();
814 let handlers = HandlerSet::new().on_hover(move |entered: bool, _ctx: &mut EventContext| {
815 interaction_for_hover.set(if entered {
816 InteractionState::Hovered
817 } else {
818 InteractionState::Idle
819 });
820 if let Some(reveal) = reveal_for_hover.as_ref() {
821 reveal.set(entered);
822 }
823 });
824 ctx.apply_self_handlers(handlers);
825
826 root_id
827 }
828}
829
830impl Widget for StandardListItem {
831 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
832 let self_id = ctx.self_id();
833 ctx.enabled_when(self_id, self.enabled.clone());
841 let roles = self.resolve_roles(ctx);
844 let content_id = self.build_content(ctx, &roles);
845 let root_id = self.build_with_background(ctx, content_id, &roles);
846 self.root_child_id = Some(root_id);
847
848 let tip_placement = crate::tooltip::TooltipPlacement::Side;
853 if let Some(content) = self.composite_tooltip_content.take() {
854 let delay = ctx.theme().motion.tooltip_delay_heavy;
855 crate::tooltip::attach_composite_tooltip_boxed_with_placement(
856 ctx,
857 root_id,
858 content,
859 delay,
860 tip_placement,
861 );
862 } else if let Some(source) = self.rich_tooltip_source.clone() {
863 let delay = ctx.theme().motion.tooltip_delay;
864 crate::tooltip::attach_rich_tooltip_source_with_placement(
865 ctx,
866 root_id,
867 source,
868 delay,
869 tip_placement,
870 );
871 } else if let Some(text) = self.tooltip_text.clone() {
872 let delay = ctx.theme().motion.tooltip_delay;
873 crate::tooltip::attach_plain_tooltip_with_placement(
874 ctx,
875 root_id,
876 text,
877 delay,
878 tip_placement,
879 );
880 }
881
882 vec![root_id]
883 }
884
885 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
886 let input = &ctx.theme.input;
892 let two_line = self.subtitle.is_some();
893 let min_height = match self.resolved_style.as_ref() {
894 Some(style) if two_line => style.min_height_two_line(input),
895 Some(style) if self.decorated => style.min_height_decorated(input),
896 Some(style) => style.min_height_single_line(input),
897 None => {
898 let fallback = crate::styles::RecipeStandardItemStyle::for_tokens(input);
899 if two_line {
900 fallback.recipe.min_height_two_line
901 } else {
902 fallback.recipe.min_height_single_line
903 }
904 }
905 };
906 let raw = self
907 .root_child_id
908 .and_then(|id| ctx.child_size(id, proposal))
909 .unwrap_or_else(|| proposal.resolve(0.0, min_height));
910 let height = raw.height.max(min_height);
911 let width = proposal.width.unwrap_or(raw.width);
918 teksilo_canvas::Size::new(width, height).into()
919 }
920
921 fn place_children(
922 &self,
923 bounds: Rect,
924 _proposal: SizeProposal,
925 children: &mut [WidgetPlacement],
926 _ctx: &LayoutContext,
927 ) {
928 for child in children.iter_mut() {
929 child.origin = bounds.origin();
930 child.size = bounds.size();
931 }
932 }
933
934 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
935 builder.set_name(self.label.clone());
946 if let Some(subtitle) = &self.subtitle {
947 builder.set_description(subtitle.clone());
948 }
949 }
954
955 fn children(&self) -> Vec<WidgetId> {
956 self.root_child_id.into_iter().collect()
957 }
958}
959
960pub struct StandardTreeItem {
970 inner: StandardListItem,
971 depth: usize,
972 has_children: bool,
973 is_expanded: Prop<bool>,
974 on_toggle: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
975}
976
977impl StandardTreeItem {
978 pub fn new(label: impl Into<LocalizedString>) -> Self {
980 Self {
981 inner: StandardListItem::new(label),
982 depth: 0,
983 has_children: false,
984 is_expanded: Prop::Static(false),
985 on_toggle: None,
986 }
987 }
988
989 pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self {
995 self.inner = self.inner.interaction_signal(signal);
996 self
997 }
998
999 pub fn reveal_signal(mut self, signal: Signal<bool>) -> Self {
1002 self.inner = self.inner.reveal_signal(signal);
1003 self
1004 }
1005
1006 pub fn label_slot(mut self, widget: impl Widget + 'static) -> Self {
1009 self.inner = self.inner.label_slot(widget);
1010 self
1011 }
1012
1013 pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
1014 self.inner = self.inner.subtitle(text);
1015 self
1016 }
1017
1018 pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
1021 self.inner = self.inner.leading_slot(widget);
1022 self
1023 }
1024
1025 pub fn leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
1027 self.inner = self.inner.leading_slot_boxed(widget);
1028 self
1029 }
1030
1031 pub fn center_slot(mut self, widget: impl Widget + 'static) -> Self {
1034 self.inner = self.inner.center_slot(widget);
1035 self
1036 }
1037
1038 pub fn center_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
1040 self.inner = self.inner.center_slot_boxed(widget);
1041 self
1042 }
1043
1044 pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
1047 self.inner = self.inner.trailing_slot(widget);
1048 self
1049 }
1050
1051 pub fn trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
1053 self.inner = self.inner.trailing_slot_boxed(widget);
1054 self
1055 }
1056
1057 pub fn subtitle_leading_slot(mut self, widget: impl Widget + 'static) -> Self {
1060 self.inner = self.inner.subtitle_leading_slot(widget);
1061 self
1062 }
1063
1064 pub fn subtitle_leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
1067 self.inner = self.inner.subtitle_leading_slot_boxed(widget);
1068 self
1069 }
1070
1071 pub fn subtitle_trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
1074 self.inner = self.inner.subtitle_trailing_slot(widget);
1075 self
1076 }
1077
1078 pub fn subtitle_trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
1081 self.inner = self.inner.subtitle_trailing_slot_boxed(widget);
1082 self
1083 }
1084
1085 pub fn checkbox(mut self, checked: Signal<bool>) -> Self {
1088 self.inner = self.inner.checkbox(checked);
1089 self
1090 }
1091
1092 pub fn on_checkbox_toggle(
1097 mut self,
1098 f: impl Fn(bool, &mut teksilo_core::widget::EventContext) + 'static,
1099 ) -> Self {
1100 self.inner = self.inner.on_checkbox_toggle(f);
1101 self
1102 }
1103
1104 pub fn tristate_checkbox(mut self, state: Signal<CheckState>) -> Self {
1107 self.inner = self.inner.tristate_checkbox(state);
1108 self
1109 }
1110
1111 pub fn selected(mut self, selected: impl Into<Prop<bool>>) -> Self {
1115 self.inner = self.inner.selected(selected);
1116 self
1117 }
1118
1119 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
1123 self.inner = self.inner.enabled(enabled);
1124 self
1125 }
1126
1127 pub fn label_style(
1131 mut self,
1132 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
1133 ) -> Self {
1134 self.inner = self.inner.label_style(style);
1135 self
1136 }
1137
1138 pub fn subtitle_style(
1142 mut self,
1143 style: impl Into<teksilo_core::color_prop::TextStyleProp>,
1144 ) -> Self {
1145 self.inner = self.inner.subtitle_style(style);
1146 self
1147 }
1148
1149 pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
1152 self.inner = self.inner.label_color(color);
1153 self
1154 }
1155
1156 pub fn subtitle_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
1159 self.inner = self.inner.subtitle_color(color);
1160 self
1161 }
1162
1163 pub fn label_overflow(mut self, overflow: TextOverflow) -> Self {
1167 self.inner = self.inner.label_overflow(overflow);
1168 self
1169 }
1170
1171 pub fn subtitle_overflow(mut self, overflow: TextOverflow) -> Self {
1175 self.inner = self.inner.subtitle_overflow(overflow);
1176 self
1177 }
1178
1179 pub fn style(mut self, style: impl teksilo_core::styles::StandardItemStyle) -> Self {
1184 self.inner = self.inner.style(style);
1185 self
1186 }
1187
1188 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
1192 self.inner = self.inner.tooltip(text);
1193 self
1194 }
1195
1196 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
1200 self.inner = self.inner.rich_tooltip(key);
1201 self
1202 }
1203
1204 pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
1209 self.inner = self.inner.rich_tooltip_content(content);
1210 self
1211 }
1212
1213 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
1217 self.inner = self.inner.composite_tooltip(content);
1218 self
1219 }
1220
1221 pub fn depth(mut self, depth: usize) -> Self {
1226 self.depth = depth;
1227 self
1228 }
1229
1230 pub fn has_children(mut self, has: bool) -> Self {
1233 self.has_children = has;
1234 self
1235 }
1236
1237 pub fn is_expanded(mut self, expanded: impl Into<Prop<bool>>) -> Self {
1240 self.is_expanded = expanded.into();
1241 self
1242 }
1243
1244 pub fn from_entry(self, entry: &FlatEntry) -> Self {
1247 self.depth(entry.depth)
1248 .has_children(entry.has_children)
1249 .is_expanded(entry.is_expanded)
1250 }
1251
1252 pub fn on_chevron_toggle(
1263 mut self,
1264 f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
1265 ) -> Self {
1266 self.on_toggle = Some(Rc::new(f));
1267 self
1268 }
1269
1270 pub fn on_chevron_toggle_rc(
1275 mut self,
1276 f: Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>,
1277 ) -> Self {
1278 self.on_toggle = Some(f);
1279 self
1280 }
1281}
1282
1283impl std::fmt::Debug for StandardTreeItem {
1284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1285 f.debug_struct("StandardTreeItem")
1286 .field("inner", &self.inner)
1287 .field("depth", &self.depth)
1288 .field("has_children", &self.has_children)
1289 .finish()
1290 }
1291}
1292
1293impl Widget for StandardTreeItem {
1294 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1295 use crate::styles::recipe_standard_item_style as si;
1296
1297 let self_id = ctx.self_id();
1301 ctx.enabled_when(self_id, self.inner.enabled.clone());
1302
1303 let roles = self.inner.resolve_roles(ctx);
1305 let inner_content_id = self.inner.build_content(ctx, &roles);
1306
1307 let indent_width = self.depth as f32 * si::STANDARD_ITEM_TREE_INDENT_STEP;
1309 let indent_id = ctx.add(FixedSize::new().width(indent_width));
1310
1311 let chevron_size = si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH;
1319 let chevron_role = self.inner.foreground_role(&roles, TextRole::Secondary);
1326 let mut chevron = TwistArrow::new(chevron_size, self.has_children, self.is_expanded.get())
1327 .color(chevron_role);
1328 if self.has_children
1329 && let Some(cb) = self.on_toggle.clone()
1330 {
1331 chevron = chevron.on_click(move |ctx| cb(ctx));
1332 }
1333 let chevron_column_id = ctx.add(FixedSize::new().width(chevron_size).child(chevron));
1334
1335 let outer_row_id = ctx.add(
1337 HStack::new()
1338 .spacing(0.0)
1339 .alignment(VAlignment::Center)
1340 .child(indent_id)
1341 .child(chevron_column_id)
1342 .child(inner_content_id),
1343 );
1344
1345 let root_id = self.inner.build_with_background(ctx, outer_row_id, &roles);
1348
1349 self.inner.root_child_id = Some(root_id);
1350
1351 let tip_placement = crate::tooltip::TooltipPlacement::Side;
1355 if let Some(content) = self.inner.composite_tooltip_content.take() {
1356 let delay = ctx.theme().motion.tooltip_delay_heavy;
1357 crate::tooltip::attach_composite_tooltip_boxed_with_placement(
1358 ctx,
1359 root_id,
1360 content,
1361 delay,
1362 tip_placement,
1363 );
1364 } else if let Some(source) = self.inner.rich_tooltip_source.clone() {
1365 let delay = ctx.theme().motion.tooltip_delay;
1366 crate::tooltip::attach_rich_tooltip_source_with_placement(
1367 ctx,
1368 root_id,
1369 source,
1370 delay,
1371 tip_placement,
1372 );
1373 } else if let Some(text) = self.inner.tooltip_text.clone() {
1374 let delay = ctx.theme().motion.tooltip_delay;
1375 crate::tooltip::attach_plain_tooltip_with_placement(
1376 ctx,
1377 root_id,
1378 text,
1379 delay,
1380 tip_placement,
1381 );
1382 }
1383
1384 vec![root_id]
1385 }
1386
1387 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1388 self.inner.layout_response(proposal, ctx)
1389 }
1390
1391 fn place_children(
1392 &self,
1393 bounds: Rect,
1394 proposal: SizeProposal,
1395 children: &mut [WidgetPlacement],
1396 ctx: &LayoutContext,
1397 ) {
1398 self.inner.place_children(bounds, proposal, children, ctx);
1399 }
1400
1401 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1402 self.inner.accessibility(builder);
1403 }
1404
1405 fn children(&self) -> Vec<WidgetId> {
1406 self.inner.children()
1407 }
1408}
1409
1410#[cfg(test)]
1415mod tests {
1416 use super::*;
1417 use teksilo_canvas::SizeProposal;
1418 use teksilo_core::Theme;
1419 use teksilo_core::styles::StandardItemStyle;
1420 use teksilo_core::widget_tree::WidgetTree;
1421 use teksilo_i18n::lit;
1422
1423 fn theme() -> Theme {
1424 teksilo_core::presets::intui::light()
1425 }
1426
1427 fn discriminating_theme() -> Theme {
1435 let mut t = theme();
1436 t.colors.text_on_accent = teksilo_tokens::Color::WHITE;
1437 assert_ne!(t.colors.text_primary, t.colors.text_on_accent);
1438 t
1439 }
1440
1441 fn glyph_colors(tree: &mut WidgetTree) -> Vec<[u8; 4]> {
1443 tree.render()
1444 .glyphs
1445 .iter()
1446 .map(|g| {
1447 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1448 [q(g.color[0]), q(g.color[1]), q(g.color[2]), q(g.color[3])]
1449 })
1450 .collect()
1451 }
1452
1453 fn rgba8(c: teksilo_tokens::Color) -> [u8; 4] {
1454 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1455 [q(c.r()), q(c.g()), q(c.b()), q(c.a())]
1456 }
1457
1458 #[derive(Debug, Default, Clone, Copy)]
1463 struct OnAccentSelectionStyle;
1464
1465 impl StandardItemStyle for OnAccentSelectionStyle {
1466 fn make_body(
1467 &self,
1468 cfg: &StandardItemStyleConfig,
1469 ctx: &mut teksilo_core::build_context::BuildContext,
1470 ) -> WidgetId {
1471 crate::styles::RecipeStandardItemStyle::for_tokens(&ctx.theme().input)
1472 .make_body(cfg, ctx)
1473 }
1474
1475 fn selected_label_role(&self) -> Option<TextRole> {
1476 Some(TextRole::OnAccent)
1477 }
1478 }
1479
1480 #[test]
1484 fn a_style_without_the_hook_leaves_the_selected_label_alone() {
1485 let t = discriminating_theme();
1486 let primary = rgba8(t.colors.text_primary);
1487 let on_accent = rgba8(t.colors.text_on_accent);
1488
1489 let mut tree = WidgetTree::new()
1490 .with_theme(t)
1491 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1492 teksilo_canvas::MockTextBackend::new(),
1493 )));
1494 tree.add(StandardListItem::new(lit!("Row")).selected(Signal::new(true)));
1495 tree.layout(SizeProposal::exact(300.0, 40.0));
1496 let colors = glyph_colors(&mut tree);
1497 assert!(colors.contains(&primary));
1498 assert!(!colors.contains(&on_accent));
1499 }
1500
1501 #[test]
1504 fn the_hook_flips_the_label_of_an_emphasised_row() {
1505 let t = discriminating_theme();
1506 let on_accent = rgba8(t.colors.text_on_accent);
1507
1508 let mut tree = WidgetTree::new()
1509 .with_theme(t)
1510 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1511 teksilo_canvas::MockTextBackend::new(),
1512 )));
1513 tree.add(
1514 StandardListItem::new(lit!("Row"))
1515 .selected(Signal::new(true))
1516 .style(OnAccentSelectionStyle),
1517 );
1518 tree.layout(SizeProposal::exact(300.0, 40.0));
1519 assert!(glyph_colors(&mut tree).contains(&on_accent));
1520 }
1521
1522 #[test]
1526 fn the_hook_does_not_touch_an_unselected_row() {
1527 let t = discriminating_theme();
1528 let primary = rgba8(t.colors.text_primary);
1529 let on_accent = rgba8(t.colors.text_on_accent);
1530
1531 let mut tree = WidgetTree::new()
1532 .with_theme(t)
1533 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1534 teksilo_canvas::MockTextBackend::new(),
1535 )));
1536 tree.add(StandardListItem::new(lit!("Row")).style(OnAccentSelectionStyle));
1537 tree.layout(SizeProposal::exact(300.0, 40.0));
1538 let colors = glyph_colors(&mut tree);
1539 assert!(colors.contains(&primary));
1540 assert!(!colors.contains(&on_accent));
1541 }
1542
1543 #[test]
1552 fn the_hook_flips_a_tree_rows_chevron_with_its_label() {
1553 let t = discriminating_theme();
1554 let secondary = rgba8(t.colors.text_secondary);
1555 let on_accent = rgba8(t.colors.text_on_accent);
1556
1557 let mut tree = WidgetTree::new()
1558 .with_theme(t)
1559 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1560 teksilo_canvas::MockTextBackend::new(),
1561 )));
1562 tree.add(
1563 StandardTreeItem::new(lit!("Node"))
1564 .has_children(true)
1565 .selected(Signal::new(true))
1566 .style(OnAccentSelectionStyle),
1567 );
1568 tree.layout(SizeProposal::exact(300.0, 40.0));
1569
1570 let shapes: Vec<[u8; 4]> = tree
1571 .render()
1572 .shapes
1573 .iter()
1574 .map(|s| {
1575 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1576 [q(s.color[0]), q(s.color[1]), q(s.color[2]), q(s.color[3])]
1577 })
1578 .collect();
1579 let paths: Vec<[u8; 4]> = tree
1580 .render()
1581 .paths
1582 .iter()
1583 .map(|p| {
1584 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1585 [q(p.color[0]), q(p.color[1]), q(p.color[2]), q(p.color[3])]
1586 })
1587 .collect();
1588 let painted: Vec<[u8; 4]> = shapes.into_iter().chain(paths).collect();
1589
1590 assert!(
1591 painted.contains(&on_accent),
1592 "the chevron did not flip with the label; painted {painted:?}"
1593 );
1594 assert!(
1595 !painted.contains(&secondary),
1596 "the chevron is still painting the muted role on an accent capsule"
1597 );
1598 }
1599
1600 #[test]
1603 fn a_tree_rows_chevron_is_muted_without_the_hook() {
1604 let t = discriminating_theme();
1605 let secondary = rgba8(t.colors.text_secondary);
1606
1607 let mut tree = WidgetTree::new()
1608 .with_theme(t)
1609 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1610 teksilo_canvas::MockTextBackend::new(),
1611 )));
1612 tree.add(
1613 StandardTreeItem::new(lit!("Node"))
1614 .has_children(true)
1615 .selected(Signal::new(true)),
1616 );
1617 tree.layout(SizeProposal::exact(300.0, 40.0));
1618 let paths: Vec<[u8; 4]> = tree
1619 .render()
1620 .paths
1621 .iter()
1622 .map(|p| {
1623 let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1624 [q(p.color[0]), q(p.color[1]), q(p.color[2]), q(p.color[3])]
1625 })
1626 .collect();
1627 assert!(paths.contains(&secondary), "painted {paths:?}");
1628 }
1629
1630 #[test]
1634 fn the_hook_reverts_when_the_row_stops_being_emphasised() {
1635 let t = discriminating_theme();
1636 let primary = rgba8(t.colors.text_primary);
1637 let on_accent = rgba8(t.colors.text_on_accent);
1638
1639 let mut tree = WidgetTree::new()
1640 .with_theme(t)
1641 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1642 teksilo_canvas::MockTextBackend::new(),
1643 )));
1644 tree.add(
1645 StandardListItem::new(lit!("Row"))
1646 .selected(Signal::new(true))
1647 .style(OnAccentSelectionStyle),
1648 );
1649 tree.layout(SizeProposal::exact(300.0, 40.0));
1650 assert!(glyph_colors(&mut tree).contains(&on_accent));
1651
1652 tree.set_window_active(false);
1653 tree.layout(SizeProposal::exact(300.0, 40.0));
1654 let colors = glyph_colors(&mut tree);
1655 assert!(
1656 colors.contains(&primary),
1657 "an inactive window's selected row kept its on-accent label"
1658 );
1659 assert!(!colors.contains(&on_accent));
1660 }
1661
1662 #[derive(Debug, Default, Clone, Copy)]
1665 struct WindowActiveEmphasisStyle;
1666
1667 impl StandardItemStyle for WindowActiveEmphasisStyle {
1668 fn make_body(
1669 &self,
1670 cfg: &StandardItemStyleConfig,
1671 ctx: &mut teksilo_core::build_context::BuildContext,
1672 ) -> WidgetId {
1673 crate::styles::RecipeStandardItemStyle::for_tokens(&ctx.theme().input)
1674 .make_body(cfg, ctx)
1675 }
1676
1677 fn selected_label_role(&self) -> Option<TextRole> {
1678 Some(TextRole::OnAccent)
1679 }
1680
1681 fn selection_emphasis(&self) -> teksilo_core::styles::SelectionEmphasis {
1682 teksilo_core::styles::SelectionEmphasis::WindowActive
1683 }
1684 }
1685
1686 #[derive(Debug)]
1689 struct UnfocusedView {
1690 row: Option<StandardListItem>,
1691 child: Option<WidgetId>,
1692 }
1693
1694 impl Widget for UnfocusedView {
1695 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
1696 ctx.begin_view_focus();
1697 let id = ctx.add(self.row.take().expect("built once"));
1698 ctx.end_view_focus();
1699 self.child = Some(id);
1700 vec![id]
1701 }
1702
1703 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1704 self.child
1705 .and_then(|id| ctx.child_size(id, proposal))
1706 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1707 .into()
1708 }
1709
1710 fn children(&self) -> Vec<WidgetId> {
1711 self.child.into_iter().collect()
1712 }
1713 }
1714
1715 fn unfocused_view_row_colors(
1719 style: impl StandardItemStyle,
1720 window_active: bool,
1721 ) -> Vec<[u8; 4]> {
1722 let mut tree = WidgetTree::new()
1723 .with_theme(discriminating_theme())
1724 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1725 teksilo_canvas::MockTextBackend::new(),
1726 )));
1727 tree.add(UnfocusedView {
1728 row: Some(
1729 StandardListItem::new(lit!("Row"))
1730 .selected(Signal::new(true))
1731 .style(style),
1732 ),
1733 child: None,
1734 });
1735 tree.layout(SizeProposal::exact(300.0, 40.0));
1736 if !window_active {
1737 tree.set_window_active(false);
1738 tree.layout(SizeProposal::exact(300.0, 40.0));
1739 }
1740 glyph_colors(&mut tree)
1741 }
1742
1743 #[test]
1747 fn the_default_emphasis_mutes_a_selected_row_in_an_unfocused_view() {
1748 let t = discriminating_theme();
1749 let primary = rgba8(t.colors.text_primary);
1750 let on_accent = rgba8(t.colors.text_on_accent);
1751
1752 let colors = unfocused_view_row_colors(OnAccentSelectionStyle, true);
1753 assert!(colors.contains(&primary), "painted {colors:?}");
1754 assert!(!colors.contains(&on_accent));
1755 }
1756
1757 #[test]
1760 fn window_active_emphasis_emphasises_a_selected_row_in_an_unfocused_view() {
1761 let on_accent = rgba8(discriminating_theme().colors.text_on_accent);
1762 let colors = unfocused_view_row_colors(WindowActiveEmphasisStyle, true);
1763 assert!(colors.contains(&on_accent), "painted {colors:?}");
1764 }
1765
1766 #[test]
1768 fn window_active_emphasis_mutes_a_selected_row_in_an_inactive_window() {
1769 let t = discriminating_theme();
1770 let primary = rgba8(t.colors.text_primary);
1771 let on_accent = rgba8(t.colors.text_on_accent);
1772
1773 let colors = unfocused_view_row_colors(WindowActiveEmphasisStyle, false);
1774 assert!(colors.contains(&primary), "painted {colors:?}");
1775 assert!(!colors.contains(&on_accent));
1776 }
1777
1778 #[test]
1780 fn the_selection_emphasis_hook_defaults_to_view_focus_and_window_active() {
1781 assert_eq!(
1782 OnAccentSelectionStyle.selection_emphasis(),
1783 teksilo_core::styles::SelectionEmphasis::ViewFocusedAndWindowActive
1784 );
1785 }
1786
1787 #[test]
1788 fn list_item_layout_single_line() {
1789 let mut tree = WidgetTree::new().with_theme(theme());
1790 let id = tree.add(StandardListItem::new(lit!("Hello")));
1791 tree.layout(SizeProposal {
1792 width: Some(300.0),
1793 height: None,
1794 });
1795 let b = tree.bounds(id);
1796 use crate::styles::recipe_standard_item_style as si;
1797 assert!(b.height >= si::STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE - 0.5);
1798 }
1799
1800 #[test]
1805 fn a_wrapping_subtitle_pushes_the_trailing_slot_out_of_the_row() {
1806 const ROW_W: f32 = 680.0;
1807 let mut tree = WidgetTree::new().with_theme(theme());
1808 let row = tree.add(
1809 StandardListItem::new(lit!("2026-07-14 10:05"))
1810 .subtitle(lit!(
1811 "11 KB · /home/user/Nextcloud/Documents/Books/backups/novel-20260714-100528.skrib"
1812 ))
1813 .trailing_slot(crate::button::Button::new(lit!("Open"))),
1814 );
1815 tree.layout(SizeProposal::exact(ROW_W, 56.0));
1816
1817 let button = tree.find_by_label("Open").expect("trailing button");
1818 assert!(
1819 tree.bounds(button).right() > tree.bounds(row).right(),
1820 "a wrapping subtitle should overflow the row (got button right={}, row right={})",
1821 tree.bounds(button).right(),
1822 tree.bounds(row).right(),
1823 );
1824 }
1825
1826 #[test]
1829 fn an_eliding_subtitle_keeps_the_trailing_slot_inside_the_row() {
1830 const ROW_W: f32 = 680.0;
1831 let mut tree = WidgetTree::new().with_theme(theme());
1832 let row = tree.add(
1833 StandardListItem::new(lit!("2026-07-14 10:05"))
1834 .subtitle(lit!(
1835 "11 KB · /home/user/Nextcloud/Documents/Books/backups/novel-20260714-100528.skrib"
1836 ))
1837 .subtitle_overflow(TextOverflow::Ellipsis(teksilo_canvas::EllipsisMode::Middle))
1838 .trailing_slot(crate::button::Button::new(lit!("Open"))),
1839 );
1840 tree.layout(SizeProposal::exact(ROW_W, 56.0));
1841
1842 let button = tree.find_by_label("Open").expect("trailing button");
1843 assert!(
1844 tree.bounds(button).right() <= tree.bounds(row).right() + 0.5,
1845 "an elided subtitle must keep the trailing slot inside the row \
1846 (got button right={}, row right={})",
1847 tree.bounds(button).right(),
1848 tree.bounds(row).right(),
1849 );
1850 }
1851
1852 #[test]
1854 fn tree_item_forwards_the_overflow_levers() {
1855 const ROW_W: f32 = 400.0;
1856 let mut tree = WidgetTree::new().with_theme(theme());
1857 let row = tree.add(
1858 StandardTreeItem::new(lit!(
1859 "A very long chapter title that cannot possibly fit this row"
1860 ))
1861 .label_overflow(TextOverflow::Ellipsis(
1862 teksilo_canvas::EllipsisMode::Trailing,
1863 ))
1864 .trailing_slot(crate::button::Button::new(lit!("Open"))),
1865 );
1866 tree.layout(SizeProposal::exact(ROW_W, 56.0));
1867
1868 let button = tree.find_by_label("Open").expect("trailing button");
1869 assert!(
1870 tree.bounds(button).right() <= tree.bounds(row).right() + 0.5,
1871 "an elided label must keep the tree row's trailing slot inside it \
1872 (got button right={}, row right={})",
1873 tree.bounds(button).right(),
1874 tree.bounds(row).right(),
1875 );
1876 }
1877
1878 #[test]
1879 fn selected_item_draws_focus_colour_boundary() {
1880 let t = theme();
1884 let border = t.colors.border_focused.to_array();
1885 let has_boundary = |frame: &teksilo_canvas::RenderFrame| {
1888 frame
1889 .shapes
1890 .iter()
1891 .any(|s| s.color == border && s.stroke_width > 0.0)
1892 };
1893
1894 let mut sel = WidgetTree::new().with_theme(t.clone());
1895 sel.add(StandardListItem::new(lit!("X")).selected(true));
1896 sel.layout(SizeProposal::exact(200.0, 40.0));
1897 assert!(
1898 has_boundary(&sel.render()),
1899 "selected item must draw a boundary in the focus/accent colour"
1900 );
1901
1902 let mut plain = WidgetTree::new().with_theme(t);
1903 plain.add(StandardListItem::new(lit!("X")).selected(false));
1904 plain.layout(SizeProposal::exact(200.0, 40.0));
1905 assert!(
1906 !has_boundary(&plain.render()),
1907 "an unselected item draws no such boundary"
1908 );
1909 }
1910
1911 #[test]
1912 fn list_item_layout_two_line() {
1913 let mut tree = WidgetTree::new().with_theme(theme());
1914 let id = tree.add(StandardListItem::new(lit!("Title")).subtitle(lit!("Subtitle text")));
1915 tree.layout(SizeProposal {
1916 width: Some(300.0),
1917 height: None,
1918 });
1919 let b = tree.bounds(id);
1920 use crate::styles::recipe_standard_item_style as si;
1921 assert!(
1922 b.height >= si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE - 0.5,
1923 "two-line height {} < expected {}",
1924 b.height,
1925 si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE
1926 );
1927 }
1928
1929 #[test]
1930 fn list_item_a11y_name_is_label_only() {
1931 let mut tree = WidgetTree::new().with_theme(theme());
1935 let id = tree.add(StandardListItem::new(lit!("Title")).subtitle(lit!("Subtitle")));
1936 tree.layout(SizeProposal::exact(300.0, 100.0));
1937 let info = tree.accessibility_node(id);
1938 assert_eq!(info.name(), Some("Title"));
1939 }
1940
1941 #[test]
1944 fn a_shared_interaction_signal_reports_the_rows_hover() {
1945 let state = Signal::new(InteractionState::Idle);
1946 let mut tree = WidgetTree::new().with_theme(theme());
1947 let id =
1948 tree.add(StandardListItem::new(lit!("A result")).interaction_signal(state.clone()));
1949 tree.layout(SizeProposal::exact(300.0, 40.0));
1950 let _ = tree.render();
1951 assert_eq!(state.get(), InteractionState::Idle);
1952
1953 let b = tree.bounds(id);
1954 tree.dispatch_event(teksilo_core::WidgetEvent::pointer_move(
1955 teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1956 ));
1957 tree.layout(SizeProposal::exact(300.0, 40.0));
1958 let _ = tree.render();
1959 assert_eq!(
1960 state.get(),
1961 InteractionState::Hovered,
1962 "the row shares its own hover state with whoever asked for it"
1963 );
1964 }
1965
1966 #[test]
1973 fn a_row_that_draws_its_own_label_keeps_its_accessible_name() {
1974 let mut tree = WidgetTree::new().with_theme(theme());
1975 let id = tree.add(
1976 StandardListItem::new(lit!("she walked across the ice"))
1977 .label_slot(TextWidget::new(lit!("…across the ice"))),
1978 );
1979 tree.layout(SizeProposal::exact(300.0, 100.0));
1980 let info = tree.accessibility_node(id);
1981 assert_eq!(
1982 info.name(),
1983 Some("she walked across the ice"),
1984 "the name comes from the label, not from what was drawn instead of it"
1985 );
1986 }
1987
1988 #[test]
1989 fn list_item_a11y_name_no_subtitle() {
1990 let mut tree = WidgetTree::new().with_theme(theme());
1991 let id = tree.add(StandardListItem::new(lit!("Just a title")));
1992 tree.layout(SizeProposal::exact(300.0, 100.0));
1993 let info = tree.accessibility_node(id);
1994 assert_eq!(info.name(), Some("Just a title"));
1995 }
1996
1997 #[test]
1998 fn list_item_with_checkbox_two_state() {
1999 use teksilo_core::signal::Signal;
2000 let checked = Signal::new(false);
2001 let mut tree = WidgetTree::new().with_theme(theme());
2002 let _id =
2003 tree.add(StandardListItem::new(lit!("Item with checkbox")).checkbox(checked.clone()));
2004 tree.layout(SizeProposal::exact(300.0, 100.0));
2005 assert!(!checked.get());
2008 }
2009
2010 #[test]
2011 fn list_item_with_tristate_checkbox() {
2012 use teksilo_core::signal::Signal;
2013 let state = Signal::new(CheckState::Indeterminate);
2014 let mut tree = WidgetTree::new().with_theme(theme());
2015 let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
2016 tree.layout(SizeProposal::exact(300.0, 100.0));
2017 let b = tree.bounds(id);
2018 assert!(b.width > 0.0);
2019 }
2020
2021 #[test]
2022 fn tree_item_chevron_reserved_for_leaf() {
2023 let mut tree = WidgetTree::new().with_theme(theme());
2026 let leaf = tree.add(
2027 StandardTreeItem::new(lit!("file"))
2028 .depth(1)
2029 .has_children(false),
2030 );
2031 let branch = tree.add(
2032 StandardTreeItem::new(lit!("folder"))
2033 .depth(1)
2034 .has_children(true),
2035 );
2036 tree.layout(SizeProposal::exact(400.0, 200.0));
2037 let bl = tree.bounds(leaf);
2038 let bb = tree.bounds(branch);
2039 assert!((bl.width - bb.width).abs() < 0.5);
2040 }
2041
2042 #[test]
2043 fn twist_arrow_on_click_baseline() {
2044 use std::cell::Cell;
2049 use std::rc::Rc;
2050 use teksilo_canvas::Point;
2051 let fired = Rc::new(Cell::new(0u32));
2052 let f = fired.clone();
2053 let mut tree = WidgetTree::new().with_theme(theme());
2054 let id =
2055 tree.add(TwistArrow::new(20.0, true, false).on_click(move |_ctx| f.set(f.get() + 1)));
2056 tree.layout(SizeProposal::exact(40.0, 40.0));
2057 let b = tree.bounds(id);
2058 dispatch_tap(
2059 &mut tree,
2060 Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5),
2061 );
2062 assert_eq!(fired.get(), 1, "TwistArrow.on_click() must fire on tap");
2063 }
2064
2065 #[test]
2066 fn fixed_size_wrapping_twist_arrow_on_tap_baseline() {
2067 use std::cell::Cell;
2072 use std::rc::Rc;
2073 use teksilo_canvas::Point;
2074 use teksilo_core::widget_builder::WidgetBuilder;
2075 let fired = Rc::new(Cell::new(0u32));
2076 let f = fired.clone();
2077 let mut tree = WidgetTree::new().with_theme(theme());
2078 let id = tree.add(
2079 FixedSize::new()
2080 .width(20.0_f32)
2081 .child(TwistArrow::new(20.0, true, false))
2082 .on_tap(move |_, _| f.set(f.get() + 1)),
2083 );
2084 tree.layout(SizeProposal::exact(40.0, 40.0));
2085 let b = tree.bounds(id);
2086 dispatch_tap(
2087 &mut tree,
2088 Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5),
2089 );
2090 assert_eq!(fired.get(), 1);
2091 }
2092
2093 #[test]
2094 fn fixed_size_on_tap_baseline() {
2095 use std::cell::Cell;
2099 use std::rc::Rc;
2100 use teksilo_canvas::Point;
2101 use teksilo_core::widget_builder::WidgetBuilder;
2102 let fired = Rc::new(Cell::new(0u32));
2103 let f = fired.clone();
2104 let mut tree = WidgetTree::new().with_theme(theme());
2105 let id = tree.add(
2106 FixedSize::new()
2107 .width(40.0_f32)
2108 .height(40.0_f32)
2109 .child(TextWidget::new(lit!("x")))
2110 .on_tap(move |_, _| f.set(f.get() + 1)),
2111 );
2112 tree.layout(SizeProposal::exact(200.0, 200.0));
2113 let b = tree.bounds(id);
2114 dispatch_tap(&mut tree, Point::new(b.x + 20.0, b.y + 20.0));
2115 assert_eq!(fired.get(), 1);
2116 }
2117
2118 fn dispatch_tap(tree: &mut WidgetTree, position: teksilo_canvas::Point) {
2119 use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
2120 tree.dispatch_event(WidgetEvent::pointer_down(
2121 position,
2122 PointerButton::Primary,
2123 Modifiers::NONE,
2124 ));
2125 tree.dispatch_event(WidgetEvent::pointer_up(
2126 position,
2127 PointerButton::Primary,
2128 Modifiers::NONE,
2129 ));
2130 }
2131
2132 #[test]
2133 fn list_item_checkbox_two_state_toggles_via_tap() {
2134 use teksilo_canvas::Point;
2135 let checked = Signal::new(false);
2136 let mut tree = WidgetTree::new().with_theme(theme());
2137 let id = tree.add(StandardListItem::new(lit!("Row")).checkbox(checked.clone()));
2138 tree.layout(SizeProposal::exact(400.0, 60.0));
2139 let bounds = tree.bounds(id);
2140 use crate::styles::recipe_standard_item_style as si;
2141 let cb_x = bounds.x
2145 + si::STANDARD_ITEM_BG_HORIZONTAL_INSET
2146 + si::STANDARD_ITEM_PADDING_HORIZONTAL
2147 + 4.0;
2148 let cb_y = bounds.y + bounds.height * 0.5;
2149 dispatch_tap(&mut tree, Point::new(cb_x, cb_y));
2150 assert!(
2151 checked.get(),
2152 "tap on checkbox should flip the bound signal"
2153 );
2154 dispatch_tap(&mut tree, Point::new(cb_x, cb_y));
2155 assert!(!checked.get(), "second tap should flip back");
2156 }
2157
2158 #[test]
2159 fn list_item_row_tap_outside_checkbox_does_not_toggle() {
2160 use teksilo_canvas::Point;
2161 let checked = Signal::new(false);
2162 let mut tree = WidgetTree::new().with_theme(theme());
2163 let id = tree.add(
2164 StandardListItem::new(lit!("A long-enough label so the tap target lands on text"))
2165 .checkbox(checked.clone()),
2166 );
2167 tree.layout(SizeProposal::exact(400.0, 60.0));
2168 let bounds = tree.bounds(id);
2169 let label_x = bounds.x + bounds.width * 0.7;
2172 let label_y = bounds.y + bounds.height * 0.5;
2173 dispatch_tap(&mut tree, Point::new(label_x, label_y));
2174 assert!(
2175 !checked.get(),
2176 "tap on row body must not toggle the embedded checkbox"
2177 );
2178 }
2179
2180 #[test]
2181 fn tree_item_chevron_tap_fires_on_toggle() {
2182 use std::cell::Cell;
2183 use std::rc::Rc;
2184 use teksilo_canvas::Point;
2185 let fired = Rc::new(Cell::new(0u32));
2186 let fired_clone = fired.clone();
2187 let mut tree = WidgetTree::new().with_theme(theme());
2188 let id = tree.add(
2189 StandardTreeItem::new(lit!("Folder"))
2190 .depth(0)
2191 .has_children(true)
2192 .is_expanded(false)
2193 .on_chevron_toggle(move |_ctx| fired_clone.set(fired_clone.get() + 1)),
2194 );
2195 tree.layout(SizeProposal::exact(400.0, 60.0));
2196 let bounds = tree.bounds(id);
2197 use crate::styles::recipe_standard_item_style as si;
2198 let cx = bounds.x
2202 + si::STANDARD_ITEM_PADDING_HORIZONTAL
2203 + si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH * 0.5;
2204 let cy = bounds.y + bounds.height * 0.5;
2205 dispatch_tap(&mut tree, Point::new(cx, cy));
2206 assert_eq!(
2207 fired.get(),
2208 1,
2209 "tap on chevron column should fire on_toggle exactly once"
2210 );
2211 }
2212
2213 #[test]
2214 fn tristate_checkbox_user_click_never_sets_indeterminate() {
2215 use teksilo_canvas::Point;
2219 let state = Signal::new(CheckState::Unchecked);
2220 let mut tree = WidgetTree::new().with_theme(theme());
2221 let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
2222 tree.layout(SizeProposal::exact(400.0, 60.0));
2223 let bounds = tree.bounds(id);
2224 use crate::styles::recipe_standard_item_style as si;
2225 let cx = bounds.x + si::STANDARD_ITEM_PADDING_HORIZONTAL + 8.0;
2226 let cy = bounds.y + bounds.height * 0.5;
2227 dispatch_tap(&mut tree, Point::new(cx, cy));
2229 assert_eq!(state.get(), CheckState::Checked);
2230 dispatch_tap(&mut tree, Point::new(cx, cy));
2232 assert_eq!(state.get(), CheckState::Unchecked);
2233 dispatch_tap(&mut tree, Point::new(cx, cy));
2235 assert_eq!(state.get(), CheckState::Checked);
2236 }
2237
2238 #[test]
2239 fn tristate_checkbox_user_click_from_indeterminate_goes_to_checked() {
2240 use teksilo_canvas::Point;
2244 let state = Signal::new(CheckState::Indeterminate);
2245 let mut tree = WidgetTree::new().with_theme(theme());
2246 let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
2247 tree.layout(SizeProposal::exact(400.0, 60.0));
2248 let bounds = tree.bounds(id);
2249 use crate::styles::recipe_standard_item_style as si;
2250 let cx = bounds.x + si::STANDARD_ITEM_PADDING_HORIZONTAL + 8.0;
2251 let cy = bounds.y + bounds.height * 0.5;
2252 dispatch_tap(&mut tree, Point::new(cx, cy));
2253 assert_eq!(state.get(), CheckState::Checked);
2254 }
2255
2256 #[test]
2257 fn tree_item_no_toggle_when_no_children() {
2258 use std::cell::Cell;
2259 use std::rc::Rc;
2260 use teksilo_canvas::Point;
2261 let fired = Rc::new(Cell::new(0u32));
2262 let fired_clone = fired.clone();
2263 let mut tree = WidgetTree::new().with_theme(theme());
2264 let id = tree.add(
2265 StandardTreeItem::new(lit!("Leaf"))
2266 .depth(0)
2267 .has_children(false)
2268 .on_chevron_toggle(move |_ctx| fired_clone.set(fired_clone.get() + 1)),
2269 );
2270 tree.layout(SizeProposal::exact(400.0, 60.0));
2271 let bounds = tree.bounds(id);
2272 use crate::styles::recipe_standard_item_style as si;
2273 let cx = bounds.x
2274 + si::STANDARD_ITEM_PADDING_HORIZONTAL
2275 + si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH * 0.5;
2276 let cy = bounds.y + bounds.height * 0.5;
2277 dispatch_tap(&mut tree, Point::new(cx, cy));
2278 assert_eq!(
2279 fired.get(),
2280 0,
2281 "leaf rows must not wire on_toggle even if a callback was set"
2282 );
2283 }
2284
2285 #[test]
2286 fn tree_item_from_entry_sets_depth_and_state() {
2287 use teksilo_data::TreeModel;
2288 let m = TreeModel::<&str>::new();
2289 let root = m.insert_root(0, "r");
2290 let _child = m.insert_child(root, 0, "c");
2291
2292 let entry = FlatEntry {
2293 node_id: root,
2294 depth: 1,
2295 has_children: true,
2296 is_expanded: true,
2297 };
2298 let mut tree = WidgetTree::new().with_theme(theme());
2299 let id = tree.add(StandardTreeItem::new(lit!("x")).from_entry(&entry));
2300 tree.layout(SizeProposal::exact(400.0, 100.0));
2301 assert!(tree.bounds(id).width > 0.0);
2302 }
2303
2304 #[test]
2305 fn list_item_tooltip_appears_on_hover() {
2306 let mut tree = WidgetTree::new().with_theme(theme());
2307 let id = tree.add(StandardListItem::new(lit!("Row")).tooltip(lit!("Tip")));
2308 tree.layout(SizeProposal::exact(300.0, 200.0));
2309 tree.pointer_move(tree.bounds(id).center());
2310 tree.advance_time(std::time::Duration::from_secs(1));
2311 assert_eq!(
2312 tree.active_overlays().len(),
2313 1,
2314 "tooltip should appear on hover"
2315 );
2316 assert!(tree.find_by_label("Tip").is_some());
2317 }
2318
2319 #[test]
2320 fn tree_item_tooltip_appears_on_hover() {
2321 let mut tree = WidgetTree::new().with_theme(theme());
2322 let id = tree.add(StandardTreeItem::new(lit!("Node")).tooltip(lit!("TreeTip")));
2323 tree.layout(SizeProposal::exact(300.0, 200.0));
2324 tree.pointer_move(tree.bounds(id).center());
2325 tree.advance_time(std::time::Duration::from_secs(1));
2326 assert_eq!(
2327 tree.active_overlays().len(),
2328 1,
2329 "tooltip should appear on hover"
2330 );
2331 assert!(tree.find_by_label("TreeTip").is_some());
2332 }
2333
2334 #[derive(Debug, Default, Clone, Copy)]
2336 struct DecoratedRowStyle;
2337
2338 impl StandardItemStyle for DecoratedRowStyle {
2339 fn make_body(
2340 &self,
2341 cfg: &StandardItemStyleConfig,
2342 ctx: &mut teksilo_core::build_context::BuildContext,
2343 ) -> WidgetId {
2344 crate::styles::RecipeStandardItemStyle::for_tokens(&ctx.theme().input)
2345 .make_body(cfg, ctx)
2346 }
2347
2348 fn min_height_single_line(&self, _tokens: &teksilo_tokens::InputTokens) -> f32 {
2349 31.0
2350 }
2351
2352 fn min_height_decorated(&self, _tokens: &teksilo_tokens::InputTokens) -> f32 {
2353 37.0
2354 }
2355
2356 fn min_height_two_line(&self, _tokens: &teksilo_tokens::InputTokens) -> f32 {
2357 53.0
2358 }
2359 }
2360
2361 fn row_height(row: impl Widget + 'static) -> f32 {
2363 let mut tree = WidgetTree::new()
2364 .with_theme(theme())
2365 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
2366 teksilo_canvas::MockTextBackend::new(),
2367 )));
2368 let id = tree.add(row);
2369 tree.layout(SizeProposal {
2370 width: Some(300.0),
2371 height: None,
2372 });
2373 tree.bounds(id).height
2374 }
2375
2376 #[test]
2381 fn a_decorated_row_takes_the_styles_decorated_floor() {
2382 let icon = || crate::primitives::RectWidget::new();
2383 assert_eq!(
2384 row_height(StandardListItem::new(lit!("Row")).style(DecoratedRowStyle)),
2385 31.0
2386 );
2387 assert_eq!(
2388 row_height(
2389 StandardListItem::new(lit!("Row"))
2390 .checkbox(Signal::new(false))
2391 .style(DecoratedRowStyle)
2392 ),
2393 37.0
2394 );
2395 assert_eq!(
2396 row_height(
2397 StandardListItem::new(lit!("Row"))
2398 .leading_slot(icon())
2399 .style(DecoratedRowStyle)
2400 ),
2401 37.0
2402 );
2403 assert_eq!(
2404 row_height(
2405 StandardListItem::new(lit!("Row"))
2406 .leading_slot(icon())
2407 .subtitle(lit!("Below"))
2408 .style(DecoratedRowStyle)
2409 ),
2410 53.0
2411 );
2412 assert_eq!(
2413 row_height(
2414 StandardTreeItem::new(lit!("Node"))
2415 .leading_slot(icon())
2416 .style(DecoratedRowStyle)
2417 ),
2418 37.0
2419 );
2420 assert_eq!(
2421 row_height(StandardTreeItem::new(lit!("Node")).style(DecoratedRowStyle)),
2422 31.0
2423 );
2424 }
2425
2426 #[test]
2429 fn the_decorated_floor_defaults_to_the_single_line_one() {
2430 let recipe = crate::styles::RecipeStandardItemStyle::default();
2431 let tokens = teksilo_tokens::InputTokens::default();
2432 assert_eq!(
2433 recipe.min_height_decorated(&tokens),
2434 recipe.min_height_single_line(&tokens)
2435 );
2436 assert_eq!(
2437 row_height(
2438 StandardListItem::new(lit!("Row"))
2439 .leading_slot(crate::primitives::RectWidget::new())
2440 ),
2441 row_height(StandardListItem::new(lit!("Row"))),
2442 );
2443 }
2444}