1use std::cell::{Cell, RefCell};
34use std::rc::Rc;
35
36use teksilo_canvas::{Point, Rect, Size, SizeProposal, Transform2D};
37use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
38use teksilo_core::binding::BindingLevel;
39use teksilo_core::build_context::BuildContext;
40use teksilo_core::color_prop::{ColorProp, TextStyleProp};
41use teksilo_core::event::{EventResponse, Key, WidgetEvent};
42use teksilo_core::signal::{Prop, Signal};
43use teksilo_core::widget::{
44 CursorIcon, EventContext, LayoutContext, PendingChild, Widget, WidgetPlacement,
45};
46use teksilo_core::widget_builder::HandlerSet;
47use teksilo_core::widget_id::WidgetId;
48use teksilo_i18n::LocalizedString;
49use teksilo_tokens::{BorderRole, SurfaceRole, TextRole, TextStyleRole};
50
51use crate::primitives::{
52 Divider, FixedSize, HStack, IconWidget, MinSize, RectWidget, Spacer, TextWidget, VStack, ZStack,
53};
54use crate::tooltip::{RichTooltipSource, TooltipContent, attach_rich_tooltip_source};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum ToolBoxOrientation {
67 #[default]
69 Vertical,
70 Horizontal,
73}
74
75pub struct ToolBoxItem {
94 label: LocalizedString,
95 leading: Option<Box<dyn Widget>>,
96 trailing: Option<Box<dyn Widget>>,
97 tooltip_text: Option<LocalizedString>,
101 rich_tooltip: Option<RichTooltipSource>,
103 composite_tooltip_content: Option<Box<dyn Widget>>,
106 content: PendingChild,
107 enabled: Prop<bool>,
113}
114
115impl std::fmt::Debug for ToolBoxItem {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 f.debug_struct("ToolBoxItem")
118 .field("label", &self.label)
119 .field("enabled", &self.enabled.get())
120 .finish()
121 }
122}
123
124impl ToolBoxItem {
125 pub fn new(label: impl Into<LocalizedString>, content: impl Widget + 'static) -> Self {
128 let ls: LocalizedString = label.into();
129 Self {
130 label: ls,
131 leading: None,
132 trailing: None,
133 tooltip_text: None,
134 rich_tooltip: None,
135 composite_tooltip_content: None,
136 content: PendingChild::Deferred(Box::new(content)),
137 enabled: Prop::Static(true),
138 }
139 }
140
141 pub fn new_id(label: impl Into<LocalizedString>, content_id: WidgetId) -> Self {
143 let ls: LocalizedString = label.into();
144 Self {
145 label: ls,
146 leading: None,
147 trailing: None,
148 tooltip_text: None,
149 rich_tooltip: None,
150 composite_tooltip_content: None,
151 content: PendingChild::Id(content_id),
152 enabled: Prop::Static(true),
153 }
154 }
155
156 pub fn leading(mut self, widget: impl Widget + 'static) -> Self {
163 self.leading = Some(Box::new(widget));
164 self
165 }
166
167 pub fn trailing(mut self, widget: impl Widget + 'static) -> Self {
175 self.trailing = Some(Box::new(widget));
176 self
177 }
178
179 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
185 self.tooltip_text = Some(text.into());
186 self.rich_tooltip = None;
187 self.composite_tooltip_content = None;
188 self
189 }
190
191 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
196 self.rich_tooltip = Some(RichTooltipSource::Key(key.into()));
197 self.tooltip_text = None;
198 self.composite_tooltip_content = None;
199 self
200 }
201
202 pub fn rich_tooltip_content(mut self, content: TooltipContent) -> Self {
206 self.rich_tooltip = Some(RichTooltipSource::Content(content));
207 self.tooltip_text = None;
208 self.composite_tooltip_content = None;
209 self
210 }
211
212 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
218 self.composite_tooltip_content = Some(Box::new(content));
219 self.tooltip_text = None;
220 self.rich_tooltip = None;
221 self
222 }
223
224 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
234 self.enabled = enabled.into();
235 self
236 }
237}
238
239pub const TOOL_BOX_HEADER_MIN_HEIGHT: f32 = 28.0;
241pub const TOOL_BOX_HEADER_PADDING_HORIZONTAL: f32 = 12.0;
242pub const TOOL_BOX_ICON_TEXT_SPACING: f32 = 8.0;
243pub const TOOL_BOX_CHEVRON_SIZE: f32 = 12.0;
244pub const TOOL_BOX_INDICATOR_THICKNESS: f32 = 1.0;
245
246const COLLAPSED_SENTINEL: usize = usize::MAX;
250
251pub struct ToolBox {
258 selected: Signal<usize>,
259 items: Vec<ToolBoxItem>,
260 show_dividers: bool,
261 orientation: ToolBoxOrientation,
262 fill: bool,
266 collapsible: bool,
269 header_drag: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
275 root_child_id: Option<WidgetId>,
276}
277
278impl ToolBox {
279 pub fn new(selected: Signal<usize>) -> Self {
283 Self {
284 selected,
285 items: Vec::new(),
286 show_dividers: false,
287 orientation: ToolBoxOrientation::Vertical,
288 fill: false,
289 collapsible: false,
290 header_drag: None,
291 root_child_id: None,
292 }
293 }
294
295 pub fn orientation(mut self, orientation: ToolBoxOrientation) -> Self {
298 self.orientation = orientation;
299 self
300 }
301
302 pub fn fill(mut self, fill: bool) -> Self {
316 self.fill = fill;
317 self
318 }
319
320 pub fn collapsible(mut self, collapsible: bool) -> Self {
328 self.collapsible = collapsible;
329 self
330 }
331
332 pub fn horizontal(mut self) -> Self {
334 self.orientation = ToolBoxOrientation::Horizontal;
335 self
336 }
337
338 pub fn on_header_drag(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
343 self.header_drag = Some(Rc::new(f));
344 self
345 }
346
347 pub fn item(self, label: impl Into<LocalizedString>, content: impl Widget + 'static) -> Self {
351 self.add(ToolBoxItem::new(label, content))
352 }
353
354 pub fn item_id(self, label: impl Into<LocalizedString>, content_id: WidgetId) -> Self {
356 self.add(ToolBoxItem::new_id(label, content_id))
357 }
358
359 #[allow(clippy::should_implement_trait)]
362 pub fn add(mut self, item: ToolBoxItem) -> Self {
363 self.items.push(item);
364 self
365 }
366
367 pub fn items<I>(mut self, items: I) -> Self
369 where
370 I: IntoIterator<Item = ToolBoxItem>,
371 {
372 self.items.extend(items);
373 self
374 }
375
376 pub fn show_dividers(mut self, show: bool) -> Self {
381 self.show_dividers = show;
382 self
383 }
384}
385
386impl std::fmt::Debug for ToolBox {
387 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388 f.debug_struct("ToolBox")
389 .field("items", &self.items.len())
390 .finish()
391 }
392}
393
394fn next_enabled_index(enabled: &[bool], current: usize, direction: isize) -> usize {
399 if enabled.is_empty() {
400 return current;
401 }
402 let len = enabled.len() as isize;
403 let mut offset = 1_isize;
404 while offset <= len {
405 let candidate = (current as isize + direction * offset).rem_euclid(len) as usize;
406 if enabled[candidate] {
407 return candidate;
408 }
409 offset += 1;
410 }
411 current
412}
413
414fn first_enabled_index(enabled: &[bool]) -> Option<usize> {
415 enabled.iter().position(|&e| e)
416}
417
418fn last_enabled_index(enabled: &[bool]) -> Option<usize> {
419 enabled.iter().rposition(|&e| e)
420}
421
422struct ToolBoxHeader {
427 label: LocalizedString,
428 index: usize,
429 initial_enabled: bool,
437 selected: Signal<usize>,
438 header_ids: Rc<RefCell<Vec<WidgetId>>>,
442 panel_ids: Rc<RefCell<Vec<WidgetId>>>,
445 enabled_flags: Rc<Vec<bool>>,
451 pending_leading: Option<Box<dyn Widget>>,
452 pending_trailing: Option<Box<dyn Widget>>,
453 tooltip_text: Option<LocalizedString>,
454 rich_tooltip: Option<RichTooltipSource>,
455 composite_tooltip_content: Option<Box<dyn Widget>>,
456 orientation: ToolBoxOrientation,
457 collapsible: bool,
460 on_header_drag: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
461 root_child_id: Option<WidgetId>,
462}
463
464impl std::fmt::Debug for ToolBoxHeader {
465 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466 f.debug_struct("ToolBoxHeader")
467 .field("index", &self.index)
468 .field("orientation", &self.orientation)
469 .field("draggable", &self.on_header_drag.is_some())
470 .finish()
471 }
472}
473
474impl ToolBoxHeader {
475 #[allow(clippy::too_many_arguments)]
476 fn new(
477 label: LocalizedString,
478 index: usize,
479 initial_enabled: bool,
480 selected: Signal<usize>,
481 header_ids: Rc<RefCell<Vec<WidgetId>>>,
482 panel_ids: Rc<RefCell<Vec<WidgetId>>>,
483 enabled_flags: Rc<Vec<bool>>,
484 pending_leading: Option<Box<dyn Widget>>,
485 pending_trailing: Option<Box<dyn Widget>>,
486 tooltip_text: Option<LocalizedString>,
487 rich_tooltip: Option<RichTooltipSource>,
488 composite_tooltip_content: Option<Box<dyn Widget>>,
489 orientation: ToolBoxOrientation,
490 collapsible: bool,
491 on_header_drag: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
492 ) -> Self {
493 Self {
494 label,
495 index,
496 initial_enabled,
497 selected,
498 header_ids,
499 panel_ids,
500 enabled_flags,
501 pending_leading,
502 pending_trailing,
503 tooltip_text,
504 rich_tooltip,
505 composite_tooltip_content,
506 orientation,
507 collapsible,
508 on_header_drag,
509 root_child_id: None,
510 }
511 }
512}
513
514impl Widget for ToolBoxHeader {
515 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
516 let self_id = ctx.self_id();
517 let theme = ctx.theme();
518 let focus_ring_width = theme.shape.focus_ring_width;
519
520 let idx = self.index;
521 if !self.initial_enabled {
529 ctx.enabled_when(self_id, false);
530 }
531
532 let is_selected = self.selected.map(move |s| *s == idx);
534
535 let interaction = ctx.signal(HeaderInteraction::Idle);
540 let focus_origin: Signal<Option<teksilo_core::focus::FocusOrigin>> = ctx.signal(None);
545
546 let registry = ctx.binding_registry();
547 self.selected
548 .bind_to(self_id, registry, BindingLevel::RepaintOnly);
549 interaction.bind_to(self_id, registry, BindingLevel::RepaintOnly);
550 focus_origin.bind_to(self_id, registry, BindingLevel::RepaintOnly);
551
552 let bg_role = interaction.zip(&is_selected).map(move |(state, sel)| {
561 if *state == HeaderInteraction::Pressed {
562 return SurfaceRole::Pressed;
563 }
564 if *sel {
565 return SurfaceRole::Selected;
566 }
567 if *state == HeaderInteraction::Hovered {
568 return SurfaceRole::Hover;
569 }
570 SurfaceRole::Transparent
571 });
572 let text_role = interaction.zip(&is_selected).map(move |(state, sel)| {
573 if *sel || *state == HeaderInteraction::Hovered {
574 return TextRole::Primary;
575 }
576 TextRole::Secondary
577 });
578
579 let focus_border_width = focus_origin.map(move |o| match o {
584 Some(teksilo_core::focus::FocusOrigin::Keyboard) => focus_ring_width,
585 _ => 0.0,
586 });
587 let focus_border_color = focus_origin.map(|o| match o {
588 Some(teksilo_core::focus::FocusOrigin::Keyboard) => BorderRole::Focused,
589 _ => BorderRole::Transparent,
590 });
591
592 let indicator_bg = is_selected.map(|sel| {
598 if *sel {
599 SurfaceRole::Accent
600 } else {
601 SurfaceRole::Transparent
602 }
603 });
604 let is_horizontal = self.orientation == ToolBoxOrientation::Horizontal;
605
606 let indicator_rect_id = ctx.add(RectWidget::new().background(indicator_bg));
610 let indicator_id = if is_horizontal {
611 ctx.add(
612 FixedSize::new()
613 .height(TOOL_BOX_INDICATOR_THICKNESS)
614 .child_id(indicator_rect_id),
615 )
616 } else {
617 ctx.add(
618 FixedSize::new()
619 .width(TOOL_BOX_INDICATOR_THICKNESS)
620 .child_id(indicator_rect_id),
621 )
622 };
623
624 let leading_id = self.pending_leading.take().map(|w| ctx.add_boxed(w));
626 let trailing_id = self.pending_trailing.take().map(|w| ctx.add_boxed(w));
627 let spacer_id = ctx.add(Spacer::new());
628
629 let padded_content_id = if is_horizontal {
631 let chevron_right_id =
636 ctx.add(IconWidget::chevron_right(TOOL_BOX_CHEVRON_SIZE).color(text_role.clone()));
637 let chevron_left_id =
638 ctx.add(IconWidget::chevron_left(TOOL_BOX_CHEVRON_SIZE).color(text_role.clone()));
639 ctx.visible_when(chevron_left_id, is_selected.clone());
640 ctx.visible_when(chevron_right_id, is_selected.map(|v| !*v));
641 let label_id = ctx.add(RotatedLabel::new(self.label.clone(), text_role));
642
643 let mut col = VStack::new().spacing(TOOL_BOX_ICON_TEXT_SPACING);
644 col = col.add_child(indicator_id);
645 if let Some(id) = leading_id {
646 col = col.add_child(id);
647 }
648 col = col
649 .add_child(chevron_left_id)
650 .add_child(chevron_right_id)
651 .add_child(label_id);
652 if let Some(id) = trailing_id {
653 col = col.add_child(id);
654 }
655 col = col.add_child(spacer_id);
656 let col_id = ctx.add(col);
657 ctx.add(
658 crate::primitives::Padding::symmetric(TOOL_BOX_HEADER_PADDING_HORIZONTAL, 0.0)
659 .child_id(col_id),
660 )
661 } else {
662 let label_id = ctx.add(
665 TextWidget::new(self.label.clone())
666 .color(text_role.clone())
667 .style(TextStyleRole::Body)
668 .single_line()
669 .a11y_hidden(),
670 );
671 let chevron_down_id =
672 ctx.add(IconWidget::chevron_down(TOOL_BOX_CHEVRON_SIZE).color(text_role.clone()));
673 let chevron_right_id =
674 ctx.add(IconWidget::chevron_right(TOOL_BOX_CHEVRON_SIZE).color(text_role));
675 ctx.visible_when(chevron_down_id, is_selected.clone());
676 ctx.visible_when(chevron_right_id, is_selected.map(|v| !*v));
677
678 let mut row = HStack::new().spacing(TOOL_BOX_ICON_TEXT_SPACING);
679 row = row.add_child(indicator_id);
680 if let Some(id) = leading_id {
681 row = row.add_child(id);
682 }
683 row = row.add_child(label_id).add_child(spacer_id);
684 if let Some(id) = trailing_id {
685 row = row.add_child(id);
686 }
687 row = row.add_child(chevron_down_id).add_child(chevron_right_id);
688 let row_id = ctx.add(row);
689 ctx.add(
692 crate::primitives::Padding::symmetric(0.0, TOOL_BOX_HEADER_PADDING_HORIZONTAL)
693 .child_id(row_id),
694 )
695 };
696
697 let bg_rect_id = ctx.add(RectWidget::new().background(bg_role));
699
700 let focus_inset = focus_ring_width * 0.5;
705 let focus_rect_id = ctx.add(
706 RectWidget::new()
707 .border_color(focus_border_color)
708 .border_width(focus_border_width),
709 );
710 let focus_padded_id =
711 ctx.add(crate::primitives::Padding::uniform(focus_inset).child_id(focus_rect_id));
712 let zstack_id = ctx.add(
713 ZStack::new()
714 .add_child(bg_rect_id)
715 .add_child(focus_padded_id)
716 .add_child(padded_content_id),
717 );
718
719 let root_id = if is_horizontal {
722 ctx.add(MinSize::new(TOOL_BOX_HEADER_MIN_HEIGHT, 0.0).child_id(zstack_id))
723 } else {
724 ctx.add(MinSize::new(0.0, TOOL_BOX_HEADER_MIN_HEIGHT).child_id(zstack_id))
725 };
726 self.root_child_id = Some(root_id);
727
728 if let Some(content) = self.composite_tooltip_content.take() {
732 let delay = ctx.theme().motion.tooltip_delay_heavy;
733 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
734 } else if let Some(source) = self.rich_tooltip.take() {
735 let delay = ctx.theme().motion.tooltip_delay;
736 attach_rich_tooltip_source(ctx, root_id, source, delay);
737 } else if let Some(text) = self.tooltip_text.take() {
738 let delay = ctx.theme().motion.tooltip_delay;
739 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
740 }
741
742 let collapsible = self.collapsible;
744 let selected_tap = self.selected.clone();
745 let selected_key = self.selected.clone();
746 let selected_access = self.selected.clone();
747 let header_ids_for_key = self.header_ids.clone();
748 let enabled_flags_for_key = self.enabled_flags.clone();
749 let interaction_for_tap = interaction.clone();
750 let interaction_for_hover = interaction.clone();
751 let interaction_for_key = interaction.clone();
752 let interaction_for_focus = interaction.clone();
753 let focus_origin_for_focus = focus_origin.clone();
754
755 let mut handler_set = HandlerSet::new()
756 .on_tap(move |_pos, _ctx| {
757 if collapsible && selected_tap.get() == idx {
758 selected_tap.set(COLLAPSED_SENTINEL);
759 } else {
760 selected_tap.set(idx);
761 }
762 interaction_for_tap.set(HeaderInteraction::Hovered);
763 })
764 .on_hover(move |entered, _ctx| {
765 interaction_for_hover.set(if entered {
766 HeaderInteraction::Hovered
767 } else {
768 HeaderInteraction::Idle
769 });
770 })
771 .on_focus(move |gained, _ctx| {
772 if !gained {
773 focus_origin_for_focus.set(None);
774 return;
775 }
776 let origin = if interaction_for_focus.get() == HeaderInteraction::Hovered {
781 teksilo_core::focus::FocusOrigin::Pointer
782 } else {
783 teksilo_core::focus::FocusOrigin::Keyboard
784 };
785 focus_origin_for_focus.set(Some(origin));
786 })
787 .on_key(
788 move |event: &WidgetEvent, ctx: &mut EventContext| match event {
789 WidgetEvent::KeyDown {
790 key: Key::Space | Key::Enter,
791 ..
792 } => {
793 interaction_for_key.set(HeaderInteraction::Pressed);
794 EventResponse::Handled
795 }
796 WidgetEvent::KeyUp {
797 key: Key::Space | Key::Enter,
798 ..
799 } => {
800 if collapsible && selected_key.get() == idx {
801 selected_key.set(COLLAPSED_SENTINEL);
802 } else {
803 selected_key.set(idx);
804 }
805 interaction_for_key.set(HeaderInteraction::Hovered);
806 EventResponse::Handled
807 }
808 WidgetEvent::KeyDown {
809 key: Key::ArrowDown,
810 ..
811 } => {
812 let headers = header_ids_for_key.borrow();
813 if headers.is_empty() {
814 return EventResponse::Ignored;
815 }
816 let next = next_enabled_index(&enabled_flags_for_key, idx, 1);
817 if next != idx {
818 ctx.request_focus(headers[next]);
819 }
820 EventResponse::Handled
821 }
822 WidgetEvent::KeyDown {
823 key: Key::ArrowUp, ..
824 } => {
825 let headers = header_ids_for_key.borrow();
826 if headers.is_empty() {
827 return EventResponse::Ignored;
828 }
829 let prev = next_enabled_index(&enabled_flags_for_key, idx, -1);
830 if prev != idx {
831 ctx.request_focus(headers[prev]);
832 }
833 EventResponse::Handled
834 }
835 WidgetEvent::KeyDown { key: Key::Home, .. } => {
836 let headers = header_ids_for_key.borrow();
837 if let Some(first) = first_enabled_index(&enabled_flags_for_key)
838 && let Some(&target) = headers.get(first)
839 {
840 ctx.request_focus(target);
841 return EventResponse::Handled;
842 }
843 EventResponse::Ignored
844 }
845 WidgetEvent::KeyDown { key: Key::End, .. } => {
846 let headers = header_ids_for_key.borrow();
847 if let Some(last) = last_enabled_index(&enabled_flags_for_key)
848 && let Some(&target) = headers.get(last)
849 {
850 ctx.request_focus(target);
851 return EventResponse::Handled;
852 }
853 EventResponse::Ignored
854 }
855 _ => EventResponse::Ignored,
856 },
857 )
858 .on_access_action(move |action, _ctx| {
859 match action {
860 teksilo_core::accesskit::Action::Click
861 | teksilo_core::accesskit::Action::Expand => {
862 selected_access.set(idx);
863 EventResponse::Handled
864 }
865 teksilo_core::accesskit::Action::Collapse => {
866 if collapsible && selected_access.get() == idx {
870 selected_access.set(COLLAPSED_SENTINEL);
871 }
872 EventResponse::Handled
873 }
874 _ => EventResponse::Ignored,
875 }
876 })
877 .focusable(true)
882 .cursor(CursorIcon::Pointer);
883
884 if let Some(drag) = self.on_header_drag.clone() {
888 handler_set = handler_set.on_drag(move |phase, ctx| {
889 if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
890 (drag)(idx, ctx);
891 }
892 });
893 }
894
895 ctx.apply_self_handlers(handler_set);
896
897 vec![root_id]
898 }
899
900 fn layout_response(
901 &self,
902 proposal: SizeProposal,
903 ctx: &LayoutContext,
904 ) -> teksilo_core::widget::LayoutResponse {
905 if let Some(root) = self.root_child_id
906 && let Some(size) = ctx.child_size(root, proposal)
907 {
908 return (size).into();
909 }
910 proposal.resolve(0.0, 0.0).into()
911 }
912
913 fn place_children(
914 &self,
915 bounds: Rect,
916 _proposal: SizeProposal,
917 children: &mut [WidgetPlacement],
918 _ctx: &LayoutContext,
919 ) {
920 for child in children.iter_mut() {
921 child.origin = bounds.origin();
922 child.size = bounds.size();
923 }
924 }
925
926 fn children(&self) -> Vec<WidgetId> {
927 self.root_child_id.into_iter().collect()
928 }
929
930 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
931 use teksilo_core::accesskit::{Action, Role};
932 builder.set_role(Role::Button);
933 builder.set_name(self.label.resolve_now());
934 let is_active = self.selected.get() == self.index;
935 builder.set_expanded(is_active);
936 if self.initial_enabled {
942 builder.add_action(Action::Click);
943 builder.add_action(Action::Expand);
944 builder.add_action(Action::Collapse);
945 }
946 builder.add_action(Action::Focus);
947 if let Some(&panel_id) = self.panel_ids.borrow().get(self.index) {
949 builder.push_controlled(widget_id_to_node_id(panel_id));
950 }
951 }
952}
953
954#[derive(Debug, Clone, Copy, PartialEq, Eq)]
955enum HeaderInteraction {
956 Idle,
957 Hovered,
958 Pressed,
959}
960
961#[derive(Debug)]
966struct ToolBoxPanel {
967 label: LocalizedString,
968 selected: Signal<usize>,
969 index: usize,
970 content: Option<PendingChild>,
971 fill: bool,
976 orientation: ToolBoxOrientation,
977 root_child_id: Option<WidgetId>,
978}
979
980impl ToolBoxPanel {
981 fn new(
982 label: LocalizedString,
983 selected: Signal<usize>,
984 index: usize,
985 content: PendingChild,
986 fill: bool,
987 orientation: ToolBoxOrientation,
988 ) -> Self {
989 Self {
990 label,
991 selected,
992 index,
993 content: Some(content),
994 fill,
995 orientation,
996 root_child_id: None,
997 }
998 }
999}
1000
1001impl Widget for ToolBoxPanel {
1002 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1003 let content_id = match self.content.take().expect("ToolBoxPanel built twice") {
1004 PendingChild::Id(id) => id,
1005 PendingChild::Deferred(w) => ctx.add_boxed(w),
1006 };
1007
1008 let idx = self.index;
1016 let is_selected = self.selected.map(move |s| *s == idx);
1017 ctx.visible_when(content_id, is_selected);
1018 self.root_child_id = Some(content_id);
1019 self.selected.bind_to(
1021 ctx.self_id(),
1022 ctx.binding_registry(),
1023 BindingLevel::Relayout,
1024 );
1025 vec![content_id]
1026 }
1027
1028 fn layout_response(
1029 &self,
1030 proposal: SizeProposal,
1031 ctx: &LayoutContext,
1032 ) -> teksilo_core::widget::LayoutResponse {
1033 let Some(root) = self.root_child_id else {
1034 return proposal.resolve(0.0, 0.0).into();
1035 };
1036
1037 if self.selected.get() != self.index {
1039 return Size::ZERO.into();
1040 }
1041
1042 let content = ctx.child_size(root, proposal).unwrap_or(Size::ZERO);
1043 if self.fill {
1044 let size = match self.orientation {
1049 ToolBoxOrientation::Vertical => {
1050 Size::new(proposal.width.unwrap_or(content.width), content.height)
1051 }
1052 ToolBoxOrientation::Horizontal => {
1053 Size::new(content.width, proposal.height.unwrap_or(content.height))
1054 }
1055 };
1056 return teksilo_core::widget::LayoutResponse::shrinkable(size, Size::ZERO, 1.0)
1057 .with_flex(1.0);
1058 }
1059 content.into()
1060 }
1061
1062 fn place_children(
1063 &self,
1064 bounds: Rect,
1065 _proposal: SizeProposal,
1066 children: &mut [WidgetPlacement],
1067 _ctx: &LayoutContext,
1068 ) {
1069 for child in children.iter_mut() {
1070 child.origin = bounds.origin();
1071 child.size = bounds.size();
1072 }
1073 }
1074
1075 fn clips_children(&self) -> bool {
1076 self.fill
1080 }
1081
1082 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1083 use teksilo_core::accesskit::Role;
1084 builder.set_role(Role::Region);
1087 builder.set_name(self.label.resolve_now());
1088 if self.selected.get() != self.index {
1092 builder.set_hidden();
1093 }
1094 }
1095
1096 fn children(&self) -> Vec<WidgetId> {
1097 self.root_child_id.into_iter().collect()
1098 }
1099}
1100
1101fn pivoted_rotation(pivot: Point, theta: f32) -> Transform2D {
1113 let (s, c) = theta.sin_cos();
1114 Transform2D {
1115 m: [
1116 c,
1117 s,
1118 -s,
1119 c,
1120 pivot.x * (1.0 - c) + pivot.y * s,
1121 pivot.y * (1.0 - c) - pivot.x * s,
1122 ],
1123 }
1124}
1125
1126#[derive(Debug)]
1127pub(crate) struct RotatedLabel {
1128 label: LocalizedString,
1129 color: ColorProp,
1130 style: TextStyleProp,
1131 child_id: Option<WidgetId>,
1132 natural: Cell<Size>,
1133 transform_signal: Option<Signal<Transform2D>>,
1134}
1135
1136impl RotatedLabel {
1137 pub(crate) fn new(label: LocalizedString, color: impl Into<ColorProp>) -> Self {
1138 Self {
1139 label,
1140 color: color.into(),
1141 style: TextStyleRole::Body.into(),
1142 child_id: None,
1143 natural: Cell::new(Size::ZERO),
1144 transform_signal: None,
1145 }
1146 }
1147
1148 pub(crate) fn style(mut self, style: impl Into<TextStyleProp>) -> Self {
1151 self.style = style.into();
1152 self
1153 }
1154}
1155
1156impl Widget for RotatedLabel {
1157 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1158 let child = ctx.add(
1159 TextWidget::new(self.label.clone())
1160 .color(self.color.clone())
1161 .style(self.style.clone())
1162 .single_line()
1163 .a11y_hidden(),
1164 );
1165 self.child_id = Some(child);
1166 let t = ctx.signal(Transform2D::IDENTITY);
1167 ctx.set_transform(ctx.self_id(), t.clone());
1168 self.transform_signal = Some(t);
1169 vec![child]
1170 }
1171
1172 fn layout_response(
1173 &self,
1174 _proposal: SizeProposal,
1175 ctx: &LayoutContext,
1176 ) -> teksilo_core::widget::LayoutResponse {
1177 let natural = self
1180 .child_id
1181 .and_then(|id| {
1182 ctx.child_size(
1183 id,
1184 SizeProposal {
1185 width: None,
1186 height: None,
1187 },
1188 )
1189 })
1190 .unwrap_or(Size::ZERO);
1191 self.natural.set(natural);
1192 Size::new(natural.height, natural.width).into()
1193 }
1194
1195 fn place_children(
1196 &self,
1197 bounds: Rect,
1198 _proposal: SizeProposal,
1199 children: &mut [WidgetPlacement],
1200 _ctx: &LayoutContext,
1201 ) {
1202 let natural = self.natural.get();
1203 let cx = bounds.x + bounds.width * 0.5;
1206 let cy = bounds.y + bounds.height * 0.5;
1207 let origin = Point::new(cx - natural.width * 0.5, cy - natural.height * 0.5);
1208 for child in children.iter_mut() {
1209 child.origin = origin;
1210 child.size = natural;
1211 }
1212 if let Some(t) = &self.transform_signal {
1213 t.set(pivoted_rotation(
1216 Point::new(cx, cy),
1217 -std::f32::consts::FRAC_PI_2,
1218 ));
1219 }
1220 }
1221
1222 fn clips_children(&self) -> bool {
1223 false
1224 }
1225
1226 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
1227 }
1230
1231 fn children(&self) -> Vec<WidgetId> {
1232 self.child_id.into_iter().collect()
1233 }
1234}
1235
1236impl Widget for ToolBox {
1239 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1240 let items = std::mem::take(&mut self.items);
1241 let enabled_flags: Rc<Vec<bool>> = Rc::new(items.iter().map(|i| i.enabled.get()).collect());
1242 let header_ids: Rc<RefCell<Vec<WidgetId>>> =
1243 Rc::new(RefCell::new(Vec::with_capacity(items.len())));
1244 let panel_ids: Rc<RefCell<Vec<WidgetId>>> =
1245 Rc::new(RefCell::new(Vec::with_capacity(items.len())));
1246
1247 let orientation = self.orientation;
1248 let show_dividers = self.show_dividers;
1249 let item_count = items.len();
1250
1251 let mut child_ids: Vec<WidgetId> = Vec::with_capacity(item_count * 3);
1257
1258 for (index, item) in items.into_iter().enumerate() {
1259 let label = item.label.clone();
1260 let header_id = ctx.add(ToolBoxHeader::new(
1261 item.label.clone(),
1262 index,
1263 item.enabled.get(),
1264 self.selected.clone(),
1265 header_ids.clone(),
1266 panel_ids.clone(),
1267 enabled_flags.clone(),
1268 item.leading,
1269 item.trailing,
1270 item.tooltip_text,
1271 item.rich_tooltip,
1272 item.composite_tooltip_content,
1273 orientation,
1274 self.collapsible,
1275 self.header_drag.clone(),
1276 ));
1277 header_ids.borrow_mut().push(header_id);
1278
1279 let panel_id = ctx.add(ToolBoxPanel::new(
1280 label,
1281 self.selected.clone(),
1282 index,
1283 item.content,
1284 self.fill,
1285 orientation,
1286 ));
1287 panel_ids.borrow_mut().push(panel_id);
1288
1289 child_ids.push(header_id);
1290 child_ids.push(panel_id);
1291
1292 if show_dividers && index + 1 < item_count {
1293 let divider = match orientation {
1297 ToolBoxOrientation::Vertical => Divider::new(),
1298 ToolBoxOrientation::Horizontal => Divider::vertical(),
1299 };
1300 child_ids.push(ctx.add(divider.color(BorderRole::Divider)));
1301 }
1302 }
1303
1304 let root = match orientation {
1305 ToolBoxOrientation::Vertical => {
1306 let mut stack = VStack::new().spacing(0.0);
1307 for id in child_ids {
1308 stack = stack.add_child(id);
1309 }
1310 ctx.add(stack)
1311 }
1312 ToolBoxOrientation::Horizontal => {
1313 let mut stack = HStack::new().spacing(0.0);
1314 for id in child_ids {
1315 stack = stack.add_child(id);
1316 }
1317 ctx.add(stack)
1318 }
1319 };
1320 self.root_child_id = Some(root);
1321 vec![root]
1322 }
1323
1324 fn layout_response(
1325 &self,
1326 proposal: SizeProposal,
1327 ctx: &LayoutContext,
1328 ) -> teksilo_core::widget::LayoutResponse {
1329 if let Some(root) = self.root_child_id
1330 && let Some(size) = ctx.child_size(root, proposal)
1331 {
1332 return (size).into();
1333 }
1334 proposal.resolve(0.0, 0.0).into()
1335 }
1336
1337 fn place_children(
1338 &self,
1339 bounds: Rect,
1340 _proposal: SizeProposal,
1341 children: &mut [WidgetPlacement],
1342 _ctx: &LayoutContext,
1343 ) {
1344 for child in children.iter_mut() {
1345 child.origin = bounds.origin();
1346 child.size = bounds.size();
1347 }
1348 }
1349
1350 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1351 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
1352 }
1353
1354 fn children(&self) -> Vec<WidgetId> {
1355 self.root_child_id.into_iter().collect()
1356 }
1357}
1358
1359#[cfg(test)]
1364mod tests {
1365 use super::*;
1366 use crate::primitives::TextWidget;
1367 use teksilo_canvas::SizeProposal;
1368 use teksilo_core::accesskit;
1369 use teksilo_core::event::Modifiers;
1370 use teksilo_core::widget_tree::WidgetTree;
1371 use teksilo_i18n::lit;
1372
1373 fn tree() -> WidgetTree {
1374 WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
1375 }
1376
1377 fn header_id(tree: &WidgetTree, toolbox: WidgetId, index: usize) -> WidgetId {
1381 let vstack = tree.child_widget(toolbox, 0);
1382 tree.child_widget(vstack, index * 2)
1385 }
1386
1387 fn panel_id(tree: &WidgetTree, toolbox: WidgetId, index: usize) -> WidgetId {
1388 let vstack = tree.child_widget(toolbox, 0);
1389 tree.child_widget(vstack, index * 2 + 1)
1390 }
1391
1392 #[test]
1393 fn tool_box_builds_with_first_selected() {
1394 let selected = Signal::new(0_usize);
1395 let mut t = tree();
1396 let tb = t.add(
1397 ToolBox::new(selected.clone())
1398 .item(lit!("Outline"), TextWidget::new(lit!("Outline content")))
1399 .item(lit!("Props"), TextWidget::new(lit!("Props content")))
1400 .item(lit!("Refs"), TextWidget::new(lit!("Refs content"))),
1401 );
1402 t.layout(SizeProposal::exact(300.0, 600.0));
1403
1404 let b = t.bounds(tb);
1405 assert!(b.width > 0.0, "ToolBox width = {}", b.width);
1406 assert!(b.height > 0.0, "ToolBox height = {}", b.height);
1407 }
1408
1409 #[test]
1410 fn clicking_header_changes_selection() {
1411 let selected = Signal::new(0_usize);
1412 let mut t = tree();
1413 let tb = t.add(
1414 ToolBox::new(selected.clone())
1415 .item(lit!("A"), TextWidget::new(lit!("A")))
1416 .item(lit!("B"), TextWidget::new(lit!("B")))
1417 .item(lit!("C"), TextWidget::new(lit!("C"))),
1418 );
1419 t.layout(SizeProposal::exact(300.0, 600.0));
1420
1421 t.click(header_id(&t, tb, 2));
1422 assert_eq!(selected.get(), 2);
1423
1424 t.click(header_id(&t, tb, 0));
1425 assert_eq!(selected.get(), 0);
1426 }
1427
1428 #[test]
1429 fn panel_heights_swap_on_selection_change() {
1430 let selected = Signal::new(0_usize);
1431 let mut t = tree();
1432 let tb = t.add(
1433 ToolBox::new(selected.clone())
1434 .item(lit!("A"), TextWidget::new(lit!("AAAAAAAA")))
1435 .item(lit!("B"), TextWidget::new(lit!("BBBBBBBB"))),
1436 );
1437 t.layout(SizeProposal::exact(300.0, 600.0));
1438
1439 let panel_a_before = t.bounds(panel_id(&t, tb, 0)).height;
1440 let panel_b_before = t.bounds(panel_id(&t, tb, 1)).height;
1441 assert!(
1442 panel_a_before > 0.0,
1443 "active panel should have nonzero height"
1444 );
1445 assert!(panel_b_before < 0.5, "inactive panel should be collapsed");
1446
1447 selected.set(1);
1448 t.layout(SizeProposal::exact(300.0, 600.0));
1449
1450 let panel_a_after = t.bounds(panel_id(&t, tb, 0)).height;
1451 let panel_b_after = t.bounds(panel_id(&t, tb, 1)).height;
1452 assert!(panel_a_after < 0.5, "formerly active panel collapsed");
1453 assert!(panel_b_after > 0.0, "newly active panel expanded");
1454 }
1455
1456 #[test]
1457 fn programmatic_selection_drives_swap_like_click() {
1458 let selected = Signal::new(0_usize);
1459 let mut t = tree();
1460 let tb = t.add(
1461 ToolBox::new(selected.clone())
1462 .item(lit!("A"), TextWidget::new(lit!("AAA")))
1463 .item(lit!("B"), TextWidget::new(lit!("BBB"))),
1464 );
1465 t.layout(SizeProposal::exact(300.0, 600.0));
1466
1467 selected.set(1);
1469 t.layout(SizeProposal::exact(300.0, 600.0));
1470
1471 let panel_a = t.bounds(panel_id(&t, tb, 0)).height;
1472 let panel_b = t.bounds(panel_id(&t, tb, 1)).height;
1473 assert!(panel_a < 0.5);
1474 assert!(panel_b > 0.0);
1475 }
1476
1477 #[test]
1478 fn disabled_item_ignores_click() {
1479 let selected = Signal::new(0_usize);
1480 let mut t = tree();
1481 let tb = t.add(
1482 ToolBox::new(selected.clone())
1483 .item(lit!("A"), TextWidget::new(lit!("A")))
1484 .add(ToolBoxItem::new(lit!("B"), TextWidget::new(lit!("B"))).enabled(false))
1485 .item(lit!("C"), TextWidget::new(lit!("C"))),
1486 );
1487 t.layout(SizeProposal::exact(300.0, 600.0));
1488
1489 let disabled = header_id(&t, tb, 1);
1490 t.click(disabled);
1491 assert_eq!(selected.get(), 0, "disabled header should not activate");
1492 }
1493
1494 #[test]
1495 fn arrow_down_skips_disabled_header() {
1496 let selected = Signal::new(0_usize);
1497 let mut t = tree();
1498 let tb = t.add(
1499 ToolBox::new(selected.clone())
1500 .item(lit!("A"), TextWidget::new(lit!("A")))
1501 .add(ToolBoxItem::new(lit!("B"), TextWidget::new(lit!("B"))).enabled(false))
1502 .item(lit!("C"), TextWidget::new(lit!("C"))),
1503 );
1504 t.layout(SizeProposal::exact(300.0, 600.0));
1505
1506 t.press_key(Key::Tab, Modifiers::NONE);
1508 assert_eq!(t.focused(), Some(header_id(&t, tb, 0)));
1509
1510 t.press_key(Key::ArrowDown, Modifiers::NONE);
1511 assert_eq!(t.focused(), Some(header_id(&t, tb, 2)));
1512 }
1513
1514 #[test]
1515 fn home_and_end_jump_to_first_and_last_enabled() {
1516 let selected = Signal::new(1_usize);
1517 let mut t = tree();
1518 let tb = t.add(
1519 ToolBox::new(selected.clone())
1520 .add(ToolBoxItem::new(lit!("Locked"), TextWidget::new(lit!("x"))).enabled(false))
1521 .item(lit!("Middle"), TextWidget::new(lit!("m")))
1522 .item(lit!("Last"), TextWidget::new(lit!("l"))),
1523 );
1524 t.layout(SizeProposal::exact(300.0, 600.0));
1525
1526 t.press_key(Key::Tab, Modifiers::NONE);
1529 assert_eq!(t.focused(), Some(header_id(&t, tb, 1)));
1530
1531 t.press_key(Key::End, Modifiers::NONE);
1532 assert_eq!(t.focused(), Some(header_id(&t, tb, 2)));
1533
1534 t.press_key(Key::Home, Modifiers::NONE);
1535 assert_eq!(t.focused(), Some(header_id(&t, tb, 1)));
1538 }
1539
1540 #[test]
1541 fn accessibility_marks_selected_expanded_and_controls_panel() {
1542 let selected = Signal::new(0_usize);
1543 let mut t = tree();
1544 let tb = t.add(
1545 ToolBox::new(selected.clone())
1546 .item(lit!("A"), TextWidget::new(lit!("A")))
1547 .item(lit!("B"), TextWidget::new(lit!("B"))),
1548 );
1549 t.layout(SizeProposal::exact(300.0, 600.0));
1550
1551 let h0 = t.accessibility_node(header_id(&t, tb, 0));
1552 let h1 = t.accessibility_node(header_id(&t, tb, 1));
1553 assert!(h0.is_expanded());
1554 assert!(!h1.is_expanded());
1555 assert_eq!(h0.role(), accesskit::Role::Button);
1556
1557 selected.set(1);
1559 t.layout(SizeProposal::exact(300.0, 600.0));
1560 let h0b = t.accessibility_node(header_id(&t, tb, 0));
1561 let h1b = t.accessibility_node(header_id(&t, tb, 1));
1562 assert!(!h0b.is_expanded());
1563 assert!(h1b.is_expanded());
1564
1565 let p0 = t.accessibility_node(panel_id(&t, tb, 0));
1567 assert_eq!(p0.role(), accesskit::Role::Region);
1568 assert_eq!(p0.name(), Some("A"));
1569 }
1570
1571 #[test]
1572 fn access_action_expand_selects_item() {
1573 let selected = Signal::new(0_usize);
1574 let mut t = tree();
1575 let tb = t.add(
1576 ToolBox::new(selected.clone())
1577 .item(lit!("A"), TextWidget::new(lit!("A")))
1578 .item(lit!("B"), TextWidget::new(lit!("B")))
1579 .item(lit!("C"), TextWidget::new(lit!("C"))),
1580 );
1581 t.layout(SizeProposal::exact(300.0, 600.0));
1582
1583 let third = header_id(&t, tb, 2);
1584 t.dispatch_event(WidgetEvent::AccessAction {
1585 action: accesskit::Action::Expand,
1586 target: Some(third),
1587 target_node: teksilo_core::accessibility::root_node_id(),
1588 data: None,
1589 });
1590 assert_eq!(selected.get(), 2);
1591 }
1592
1593 #[test]
1594 fn access_action_collapse_is_swallowed() {
1595 let selected = Signal::new(1_usize);
1596 let mut t = tree();
1597 let tb = t.add(
1598 ToolBox::new(selected.clone())
1599 .item(lit!("A"), TextWidget::new(lit!("A")))
1600 .item(lit!("B"), TextWidget::new(lit!("B"))),
1601 );
1602 t.layout(SizeProposal::exact(300.0, 600.0));
1603
1604 let active = header_id(&t, tb, 1);
1606 t.dispatch_event(WidgetEvent::AccessAction {
1607 action: accesskit::Action::Collapse,
1608 target: Some(active),
1609 target_node: teksilo_core::accessibility::root_node_id(),
1610 data: None,
1611 });
1612 assert_eq!(selected.get(), 1);
1613 }
1614
1615 #[test]
1616 fn leading_slot_widget_is_placed_inside_the_header() {
1617 use crate::Button;
1618
1619 let selected = Signal::new(0_usize);
1620 let mut t = tree();
1621 let tb = t.add(
1622 ToolBox::new(selected.clone()).add(
1623 ToolBoxItem::new(lit!("A"), TextWidget::new(lit!("A")))
1624 .leading(Button::new(lit!("start"))),
1625 ),
1626 );
1627 t.layout(SizeProposal::exact(300.0, 200.0));
1628
1629 let header = header_id(&t, tb, 0);
1630 let header_bounds = t.bounds(header);
1631
1632 fn find_button_inside(t: &WidgetTree, root: WidgetId, outer: WidgetId) -> Option<WidgetId> {
1633 for child in t.children(root) {
1634 if child != outer {
1635 let info = t.accessibility_node(child);
1636 if info.role() == teksilo_core::accesskit::Role::Button {
1637 return Some(child);
1638 }
1639 }
1640 if let Some(found) = find_button_inside(t, child, outer) {
1641 return Some(found);
1642 }
1643 }
1644 None
1645 }
1646
1647 let leading_btn = find_button_inside(&t, header, header)
1648 .expect("leading Button should be a descendant of the header");
1649 let btn_bounds = t.bounds(leading_btn);
1650 assert!(
1651 btn_bounds.x >= header_bounds.x && btn_bounds.right() <= header_bounds.right() + 0.01,
1652 "leading button bounds must fit inside header row"
1653 );
1654 }
1655
1656 #[test]
1657 fn trailing_slot_widget_is_placed_inside_the_header() {
1658 use crate::Button;
1659
1660 let selected = Signal::new(0_usize);
1661 let mut t = tree();
1662 let tb = t.add(
1663 ToolBox::new(selected.clone()).add(
1664 ToolBoxItem::new(lit!("A"), TextWidget::new(lit!("A")))
1665 .trailing(Button::new(lit!("x"))),
1666 ),
1667 );
1668 t.layout(SizeProposal::exact(300.0, 200.0));
1669
1670 let header = header_id(&t, tb, 0);
1671 let header_bounds = t.bounds(header);
1672
1673 fn find_button_inside(t: &WidgetTree, root: WidgetId, outer: WidgetId) -> Option<WidgetId> {
1678 for child in t.children(root) {
1679 if child != outer {
1680 let info = t.accessibility_node(child);
1681 if info.role() == teksilo_core::accesskit::Role::Button {
1682 return Some(child);
1683 }
1684 }
1685 if let Some(found) = find_button_inside(t, child, outer) {
1686 return Some(found);
1687 }
1688 }
1689 None
1690 }
1691
1692 let trailing_btn = find_button_inside(&t, header, header)
1693 .expect("trailing Button should be a descendant of the header");
1694 let btn_bounds = t.bounds(trailing_btn);
1695 assert!(
1696 btn_bounds.x >= header_bounds.x && btn_bounds.right() <= header_bounds.right() + 0.01,
1697 "trailing button bounds must fit inside header row"
1698 );
1699 }
1700
1701 #[test]
1702 fn disabled_header_has_no_click_action() {
1703 let selected = Signal::new(0_usize);
1704 let mut t = tree();
1705 let tb = t.add(
1706 ToolBox::new(selected.clone())
1707 .item(lit!("A"), TextWidget::new(lit!("A")))
1708 .add(ToolBoxItem::new(lit!("B"), TextWidget::new(lit!("B"))).enabled(false)),
1709 );
1710 t.layout(SizeProposal::exact(300.0, 600.0));
1711
1712 let disabled = header_id(&t, tb, 1);
1713 let info = t.accessibility_node(disabled);
1714 assert!(!info.actions().contains(&accesskit::Action::Click));
1715 assert!(!info.actions().contains(&accesskit::Action::Expand));
1716 }
1717
1718 #[test]
1721 fn vertical_orientation_stacks_top_to_bottom() {
1722 let selected = Signal::new(0_usize);
1723 let mut t = tree();
1724 let tb = t.add(
1725 ToolBox::new(selected.clone())
1726 .item(lit!("A"), TextWidget::new(lit!("a")))
1727 .item(lit!("B"), TextWidget::new(lit!("b"))),
1728 );
1729 t.layout(SizeProposal::exact(300.0, 600.0));
1730 let h0 = t.bounds(header_id(&t, tb, 0));
1731 let h1 = t.bounds(header_id(&t, tb, 1));
1732 assert!(h1.y > h0.y, "vertical headers stack top→bottom");
1733 assert!(h0.width > h0.height, "vertical header is a horizontal row");
1735 }
1736
1737 #[test]
1738 fn horizontal_orientation_lays_sections_left_to_right() {
1739 let selected = Signal::new(0_usize);
1740 let mut t = tree();
1741 let tb = t.add(
1742 ToolBox::new(selected.clone())
1743 .horizontal()
1744 .item(lit!("Terminal"), TextWidget::new(lit!("term")))
1745 .item(lit!("Problems"), TextWidget::new(lit!("prob")))
1746 .item(lit!("Output"), TextWidget::new(lit!("out"))),
1747 );
1748 t.layout(SizeProposal::exact(900.0, 220.0));
1749
1750 let h0 = t.bounds(header_id(&t, tb, 0));
1751 let h1 = t.bounds(header_id(&t, tb, 1));
1752 let h2 = t.bounds(header_id(&t, tb, 2));
1753 assert!(
1754 h0.x < h1.x && h1.x < h2.x,
1755 "horizontal headers run left→right: {} {} {}",
1756 h0.x,
1757 h1.x,
1758 h2.x
1759 );
1760 assert!(
1762 h0.height > h0.width,
1763 "horizontal header is a vertical strip ({}×{})",
1764 h0.width,
1765 h0.height
1766 );
1767 assert!(
1768 h0.width <= TOOL_BOX_HEADER_MIN_HEIGHT + 24.0,
1769 "strip stays narrow (got width {})",
1770 h0.width
1771 );
1772 }
1773
1774 #[test]
1775 fn horizontal_collapsed_panel_has_zero_main_extent() {
1776 let selected = Signal::new(0_usize);
1777 let mut t = tree();
1778 let tb = t.add(
1779 ToolBox::new(selected.clone())
1780 .horizontal()
1781 .item(lit!("A"), TextWidget::new(lit!("aaaa")))
1782 .item(lit!("B"), TextWidget::new(lit!("bbbb"))),
1783 );
1784 t.layout(SizeProposal::exact(900.0, 220.0));
1785 assert!(t.bounds(panel_id(&t, tb, 0)).width > 0.0);
1788 assert!(t.bounds(panel_id(&t, tb, 1)).width.abs() < 0.5);
1789 }
1790
1791 #[test]
1792 fn header_drag_hook_fires_with_section_index() {
1793 use std::cell::Cell as StdCell;
1794 let dragged: Rc<StdCell<Option<usize>>> = Rc::new(StdCell::new(None));
1795 let selected = Signal::new(0_usize);
1796 let sink = dragged.clone();
1797 let mut t = tree();
1798 let tb = t.add(
1799 ToolBox::new(selected.clone())
1800 .on_header_drag(move |idx, _ctx| sink.set(Some(idx)))
1801 .item(lit!("A"), TextWidget::new(lit!("a")))
1802 .item(lit!("B"), TextWidget::new(lit!("b"))),
1803 );
1804 t.layout(SizeProposal::exact(300.0, 600.0));
1805
1806 let h1 = t.bounds(header_id(&t, tb, 1));
1807 let from = teksilo_canvas::Point::new(h1.x + h1.width * 0.5, h1.y + h1.height * 0.5);
1808 t.drag(
1810 from,
1811 teksilo_canvas::Point::new(from.x + 120.0, from.y + 40.0),
1812 );
1813 assert_eq!(
1814 dragged.get(),
1815 Some(1),
1816 "dragging header #1 must fire the hook with index 1"
1817 );
1818 }
1819
1820 #[test]
1823 fn fill_active_panel_fills_width_and_leftover_height() {
1824 let selected = Signal::new(0_usize);
1828 let mut t = tree();
1829 let tb = t.add(
1830 ToolBox::new(selected.clone())
1831 .fill(true)
1832 .item(lit!("A"), TextWidget::new(lit!("a")))
1833 .item(lit!("B"), TextWidget::new(lit!("b"))),
1834 );
1835 t.layout(SizeProposal::exact(300.0, 400.0));
1836
1837 assert!(
1840 (t.bounds(tb).height - 400.0).abs() < 1.0,
1841 "fill ToolBox should occupy its full slot height, got {}",
1842 t.bounds(tb).height
1843 );
1844
1845 let panel_a = t.bounds(panel_id(&t, tb, 0));
1846 assert!(
1848 panel_a.width > 290.0,
1849 "active panel should fill the width, got {}",
1850 panel_a.width
1851 );
1852 assert!(
1855 panel_a.height > 200.0,
1856 "active panel should grow into leftover height, got {}",
1857 panel_a.height
1858 );
1859 assert!(
1861 t.bounds(panel_id(&t, tb, 1)).height < 0.5,
1862 "inactive panel collapsed"
1863 );
1864 }
1865
1866 #[test]
1867 fn fill_active_panel_does_not_overflow_oversized_content() {
1868 let selected = Signal::new(0_usize);
1873 let mut t = tree();
1874 let tall = FixedSize::new()
1875 .width(120.0_f32)
1876 .height(1000.0_f32)
1877 .child(TextWidget::new(lit!("x")));
1878 let tb = t.add(
1879 ToolBox::new(selected.clone())
1880 .fill(true)
1881 .item(lit!("A"), tall)
1882 .item(lit!("B"), TextWidget::new(lit!("b"))),
1883 );
1884 t.layout(SizeProposal::exact(300.0, 200.0));
1885
1886 assert!(
1887 (t.bounds(tb).height - 200.0).abs() < 1.0,
1888 "fill ToolBox must not overflow its slot, got {}",
1889 t.bounds(tb).height
1890 );
1891 let panel_a = t.bounds(panel_id(&t, tb, 0));
1892 assert!(
1893 panel_a.height < 200.0,
1894 "oversized active panel shrinks to fit, got {}",
1895 panel_a.height
1896 );
1897 }
1898
1899 #[test]
1900 fn non_fill_panel_keeps_natural_size() {
1901 let selected = Signal::new(0_usize);
1905 let mut t = tree();
1906 let tb = t.add(
1907 ToolBox::new(selected.clone())
1908 .item(lit!("A"), TextWidget::new(lit!("a")))
1909 .item(lit!("B"), TextWidget::new(lit!("b"))),
1910 );
1911 t.layout(SizeProposal::exact(300.0, 400.0));
1912 assert!(
1915 t.bounds(panel_id(&t, tb, 0)).height < 100.0,
1916 "non-fill panel keeps natural height, got {}",
1917 t.bounds(panel_id(&t, tb, 0)).height
1918 );
1919 }
1920
1921 #[test]
1924 fn collapsible_active_header_click_collapses_then_reexpands() {
1925 let selected = Signal::new(0_usize);
1926 let mut t = tree();
1927 let tb = t.add(
1928 ToolBox::new(selected.clone())
1929 .collapsible(true)
1930 .item(lit!("A"), TextWidget::new(lit!("aaa")))
1931 .item(lit!("B"), TextWidget::new(lit!("bbb"))),
1932 );
1933 t.layout(SizeProposal::exact(300.0, 400.0));
1934 assert!(
1935 t.bounds(panel_id(&t, tb, 0)).height > 0.0,
1936 "A starts expanded"
1937 );
1938
1939 t.click(header_id(&t, tb, 0));
1941 t.layout(SizeProposal::exact(300.0, 400.0));
1942 assert!(
1943 t.bounds(panel_id(&t, tb, 0)).height < 0.5,
1944 "active header click collapses its content"
1945 );
1946 assert!(
1947 t.bounds(panel_id(&t, tb, 1)).height < 0.5,
1948 "B stays collapsed"
1949 );
1950
1951 t.click(header_id(&t, tb, 0));
1953 t.layout(SizeProposal::exact(300.0, 400.0));
1954 assert!(
1955 t.bounds(panel_id(&t, tb, 0)).height > 0.0,
1956 "re-expands on next click"
1957 );
1958 }
1959
1960 #[test]
1961 fn non_collapsible_active_header_click_stays_open() {
1962 let selected = Signal::new(0_usize);
1964 let mut t = tree();
1965 let tb = t.add(
1966 ToolBox::new(selected.clone())
1967 .item(lit!("A"), TextWidget::new(lit!("aaa")))
1968 .item(lit!("B"), TextWidget::new(lit!("bbb"))),
1969 );
1970 t.layout(SizeProposal::exact(300.0, 400.0));
1971 t.click(header_id(&t, tb, 0));
1972 t.layout(SizeProposal::exact(300.0, 400.0));
1973 assert_eq!(selected.get(), 0);
1974 assert!(
1975 t.bounds(panel_id(&t, tb, 0)).height > 0.0,
1976 "non-collapsible active section stays open"
1977 );
1978 }
1979
1980 #[test]
1983 fn tooltip_appears_on_hover() {
1984 let selected = Signal::new(0_usize);
1985 let mut t = tree();
1986 let tb = t.add(ToolBox::new(selected.clone()).add(
1987 ToolBoxItem::new(lit!("A"), TextWidget::new(lit!("content"))).tooltip(lit!("Tip")),
1988 ));
1989 t.layout(SizeProposal::exact(300.0, 200.0));
1990 t.pointer_move(t.bounds(header_id(&t, tb, 0)).center());
1991 t.advance_time(std::time::Duration::from_secs(1));
1992 assert_eq!(
1993 t.active_overlays().len(),
1994 1,
1995 "tooltip should appear on hover"
1996 );
1997 assert!(t.find_by_label("Tip").is_some());
1998 }
1999}