1use std::cell::{Cell, RefCell};
52use std::rc::Rc;
53
54use teksilo_canvas::{Point, Rect, Size, SizeProposal, Transform2D};
55use teksilo_core::accessibility::{AccessNodeBuilder, widget_id_to_node_id};
56use teksilo_core::binding::BindingLevel;
57use teksilo_core::build_context::BuildContext;
58use teksilo_core::color_prop::{ColorProp, TextStyleProp};
59use teksilo_core::event::{EventResponse, Key, WidgetEvent};
60use teksilo_core::signal::{Prop, Signal};
61use teksilo_core::widget::{
62 CursorIcon, EventContext, LayoutContext, PendingChild, Widget, WidgetPlacement,
63};
64use teksilo_core::widget_builder::HandlerSet;
65use teksilo_core::widget_id::WidgetId;
66use teksilo_i18n::LocalizedString;
67use teksilo_tokens::{BorderRole, InputTokens, SurfaceRole, TargetRole, TextRole, TextStyleRole};
68
69use crate::primitives::{
70 Divider, FixedSize, HStack, IconWidget, MinSize, RectWidget, Spacer, TextWidget, VStack, ZStack,
71};
72use crate::tooltip::{RichTooltipSource, TooltipContent, attach_rich_tooltip_source};
73use teksilo_core::styles::density::{dp, spacing};
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum ToolBoxOrientation {
86 #[default]
88 Vertical,
89 Horizontal,
92}
93
94pub struct ToolBoxItem {
113 label: LocalizedString,
114 leading: Option<Box<dyn Widget>>,
115 trailing: Option<Box<dyn Widget>>,
116 tooltip_text: Option<LocalizedString>,
120 rich_tooltip: Option<RichTooltipSource>,
122 composite_tooltip_content: Option<Box<dyn Widget>>,
125 content: PendingChild,
126 enabled: Prop<bool>,
132}
133
134impl std::fmt::Debug for ToolBoxItem {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 f.debug_struct("ToolBoxItem")
137 .field("label", &self.label)
138 .field("enabled", &self.enabled.get())
139 .finish()
140 }
141}
142
143impl ToolBoxItem {
144 pub fn new(label: impl Into<LocalizedString>, content: impl Widget + 'static) -> Self {
147 let ls: LocalizedString = label.into();
148 Self {
149 label: ls,
150 leading: None,
151 trailing: None,
152 tooltip_text: None,
153 rich_tooltip: None,
154 composite_tooltip_content: None,
155 content: teksilo_core::IntoTeksiChild::into_pending(content),
156 enabled: Prop::Static(true),
157 }
158 }
159
160 pub fn new_id(label: impl Into<LocalizedString>, content_id: WidgetId) -> Self {
162 let ls: LocalizedString = label.into();
163 Self {
164 label: ls,
165 leading: None,
166 trailing: None,
167 tooltip_text: None,
168 rich_tooltip: None,
169 composite_tooltip_content: None,
170 content: PendingChild::Id(content_id),
171 enabled: Prop::Static(true),
172 }
173 }
174
175 pub fn leading(mut self, widget: impl Widget + 'static) -> Self {
182 self.leading = Some(Box::new(widget));
183 self
184 }
185
186 pub fn trailing(mut self, widget: impl Widget + 'static) -> Self {
194 self.trailing = Some(Box::new(widget));
195 self
196 }
197
198 pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
204 self.tooltip_text = Some(text.into());
205 self.rich_tooltip = None;
206 self.composite_tooltip_content = None;
207 self
208 }
209
210 pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
215 self.rich_tooltip = Some(RichTooltipSource::Key(key.into()));
216 self.tooltip_text = None;
217 self.composite_tooltip_content = None;
218 self
219 }
220
221 pub fn rich_tooltip_content(mut self, content: TooltipContent) -> Self {
225 self.rich_tooltip = Some(RichTooltipSource::Content(content));
226 self.tooltip_text = None;
227 self.composite_tooltip_content = None;
228 self
229 }
230
231 pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
237 self.composite_tooltip_content = Some(Box::new(content));
238 self.tooltip_text = None;
239 self.rich_tooltip = None;
240 self
241 }
242
243 pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
255 self.enabled = enabled.into();
256 self
257 }
258}
259
260pub const TOOL_BOX_HEADER_MIN_HEIGHT: f32 = 28.0;
262
263pub fn tool_box_header_min_height(tokens: &InputTokens) -> f32 {
266 dp(TOOL_BOX_HEADER_MIN_HEIGHT, TargetRole::Target, tokens)
267}
268pub const TOOL_BOX_HEADER_PADDING_HORIZONTAL: f32 = 12.0;
269
270pub fn tool_box_header_padding_horizontal(tokens: &InputTokens) -> f32 {
273 spacing(TOOL_BOX_HEADER_PADDING_HORIZONTAL, tokens)
274}
275pub const TOOL_BOX_ICON_TEXT_SPACING: f32 = 8.0;
276
277pub fn tool_box_icon_text_spacing(tokens: &InputTokens) -> f32 {
280 spacing(TOOL_BOX_ICON_TEXT_SPACING, tokens)
281}
282pub const TOOL_BOX_CHEVRON_SIZE: f32 = 12.0;
283pub const TOOL_BOX_INDICATOR_THICKNESS: f32 = 1.0;
284
285const COLLAPSED_SENTINEL: usize = usize::MAX;
289
290pub struct ToolBox {
297 selected: Signal<usize>,
298 items: Vec<ToolBoxItem>,
299 show_dividers: bool,
300 orientation: ToolBoxOrientation,
301 fill: bool,
305 collapsible: bool,
308 header_drag: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
314 root_child_id: Option<WidgetId>,
315}
316
317impl ToolBox {
318 pub fn new(selected: Signal<usize>) -> Self {
322 Self {
323 selected,
324 items: Vec::new(),
325 show_dividers: false,
326 orientation: ToolBoxOrientation::Vertical,
327 fill: false,
328 collapsible: false,
329 header_drag: None,
330 root_child_id: None,
331 }
332 }
333
334 pub fn orientation(mut self, orientation: ToolBoxOrientation) -> Self {
337 self.orientation = orientation;
338 self
339 }
340
341 pub fn fill(mut self, fill: bool) -> Self {
355 self.fill = fill;
356 self
357 }
358
359 pub fn collapsible(mut self, collapsible: bool) -> Self {
367 self.collapsible = collapsible;
368 self
369 }
370
371 pub fn horizontal(mut self) -> Self {
373 self.orientation = ToolBoxOrientation::Horizontal;
374 self
375 }
376
377 pub fn on_header_drag(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
382 self.header_drag = Some(Rc::new(f));
383 self
384 }
385
386 pub fn item(
390 self,
391 label: impl Into<LocalizedString>,
392 content: impl teksilo_core::IntoTeksiChild,
393 ) -> Self {
394 match teksilo_core::IntoTeksiChild::into_pending(content) {
395 teksilo_core::PendingChild::Id(id) => self.add(ToolBoxItem::new_id(label, id)),
396 teksilo_core::PendingChild::Deferred(w) => self.add(ToolBoxItem::new(label, w)),
397 }
398 }
399
400 #[allow(clippy::should_implement_trait)]
403 pub fn add(mut self, item: ToolBoxItem) -> Self {
404 self.items.push(item);
405 self
406 }
407
408 pub fn items<I>(mut self, items: I) -> Self
410 where
411 I: IntoIterator<Item = ToolBoxItem>,
412 {
413 self.items.extend(items);
414 self
415 }
416
417 pub fn show_dividers(mut self, show: bool) -> Self {
422 self.show_dividers = show;
423 self
424 }
425}
426
427impl std::fmt::Debug for ToolBox {
428 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429 f.debug_struct("ToolBox")
430 .field("items", &self.items.len())
431 .finish()
432 }
433}
434
435fn next_enabled_index(enabled: &[bool], current: usize, direction: isize) -> usize {
440 if enabled.is_empty() {
441 return current;
442 }
443 let len = enabled.len() as isize;
444 let mut offset = 1_isize;
445 while offset <= len {
446 let candidate = (current as isize + direction * offset).rem_euclid(len) as usize;
447 if enabled[candidate] {
448 return candidate;
449 }
450 offset += 1;
451 }
452 current
453}
454
455fn first_enabled_index(enabled: &[bool]) -> Option<usize> {
456 enabled.iter().position(|&e| e)
457}
458
459fn last_enabled_index(enabled: &[bool]) -> Option<usize> {
460 enabled.iter().rposition(|&e| e)
461}
462
463struct ToolBoxHeader {
468 label: LocalizedString,
469 index: usize,
470 initial_enabled: bool,
478 selected: Signal<usize>,
479 header_ids: Rc<RefCell<Vec<WidgetId>>>,
483 panel_ids: Rc<RefCell<Vec<WidgetId>>>,
486 enabled_flags: Rc<Vec<bool>>,
492 pending_leading: Option<Box<dyn Widget>>,
493 pending_trailing: Option<Box<dyn Widget>>,
494 tooltip_text: Option<LocalizedString>,
495 rich_tooltip: Option<RichTooltipSource>,
496 composite_tooltip_content: Option<Box<dyn Widget>>,
497 orientation: ToolBoxOrientation,
498 collapsible: bool,
501 on_header_drag: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
502 root_child_id: Option<WidgetId>,
503}
504
505impl std::fmt::Debug for ToolBoxHeader {
506 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507 f.debug_struct("ToolBoxHeader")
508 .field("index", &self.index)
509 .field("orientation", &self.orientation)
510 .field("draggable", &self.on_header_drag.is_some())
511 .finish()
512 }
513}
514
515impl ToolBoxHeader {
516 #[allow(clippy::too_many_arguments)]
517 fn new(
518 label: LocalizedString,
519 index: usize,
520 initial_enabled: bool,
521 selected: Signal<usize>,
522 header_ids: Rc<RefCell<Vec<WidgetId>>>,
523 panel_ids: Rc<RefCell<Vec<WidgetId>>>,
524 enabled_flags: Rc<Vec<bool>>,
525 pending_leading: Option<Box<dyn Widget>>,
526 pending_trailing: Option<Box<dyn Widget>>,
527 tooltip_text: Option<LocalizedString>,
528 rich_tooltip: Option<RichTooltipSource>,
529 composite_tooltip_content: Option<Box<dyn Widget>>,
530 orientation: ToolBoxOrientation,
531 collapsible: bool,
532 on_header_drag: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
533 ) -> Self {
534 Self {
535 label,
536 index,
537 initial_enabled,
538 selected,
539 header_ids,
540 panel_ids,
541 enabled_flags,
542 pending_leading,
543 pending_trailing,
544 tooltip_text,
545 rich_tooltip,
546 composite_tooltip_content,
547 orientation,
548 collapsible,
549 on_header_drag,
550 root_child_id: None,
551 }
552 }
553}
554
555impl Widget for ToolBoxHeader {
556 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
557 let self_id = ctx.self_id();
558 let theme = ctx.theme();
559 let focus_ring_width = theme.shape.focus_ring_width;
560
561 let idx = self.index;
562 if !self.initial_enabled {
570 ctx.enabled_when(self_id, false);
571 }
572
573 let is_selected = self.selected.map(move |s| *s == idx);
575
576 let interaction = ctx.signal(HeaderInteraction::Idle);
581 let focus_origin: Signal<Option<teksilo_core::focus::FocusOrigin>> = ctx.signal(None);
586
587 let registry = ctx.binding_registry();
588 self.selected
589 .bind_to(self_id, registry, BindingLevel::RepaintOnly);
590 interaction.bind_to(self_id, registry, BindingLevel::RepaintOnly);
591 focus_origin.bind_to(self_id, registry, BindingLevel::RepaintOnly);
592
593 let bg_role = interaction.zip(&is_selected).map(move |(state, sel)| {
606 if *state == HeaderInteraction::Pressed {
607 return SurfaceRole::Pressed;
608 }
609 if *sel {
610 return SurfaceRole::Selected;
611 }
612 if *state == HeaderInteraction::Hovered {
613 return SurfaceRole::Hover;
614 }
615 SurfaceRole::Transparent
616 });
617 let text_role = interaction.zip(&is_selected).map(move |(state, sel)| {
618 if *sel || *state == HeaderInteraction::Hovered {
619 return TextRole::Primary;
620 }
621 TextRole::Secondary
622 });
623
624 let focus_border_width = focus_origin.map(move |o| match o {
629 Some(teksilo_core::focus::FocusOrigin::Keyboard) => focus_ring_width,
630 _ => 0.0,
631 });
632 let focus_border_color = focus_origin.map(|o| match o {
633 Some(teksilo_core::focus::FocusOrigin::Keyboard) => BorderRole::Focused,
634 _ => BorderRole::Transparent,
635 });
636
637 let indicator_bg = is_selected.map(|sel| {
643 if *sel {
644 SurfaceRole::Accent
645 } else {
646 SurfaceRole::Transparent
647 }
648 });
649 let is_horizontal = self.orientation == ToolBoxOrientation::Horizontal;
650
651 let indicator_rect_id = ctx.add(RectWidget::new().background(indicator_bg));
655 let indicator_id = if is_horizontal {
656 ctx.add(
657 FixedSize::new()
658 .height(TOOL_BOX_INDICATOR_THICKNESS)
659 .child(indicator_rect_id),
660 )
661 } else {
662 ctx.add(
663 FixedSize::new()
664 .width(TOOL_BOX_INDICATOR_THICKNESS)
665 .child(indicator_rect_id),
666 )
667 };
668
669 let leading_id = self.pending_leading.take().map(|w| ctx.add_boxed(w));
671 let trailing_id = self.pending_trailing.take().map(|w| ctx.add_boxed(w));
672 let spacer_id = ctx.add(Spacer::new());
673
674 let padded_content_id = if is_horizontal {
676 let chevron_right_id =
681 ctx.add(IconWidget::chevron_right(TOOL_BOX_CHEVRON_SIZE).color(text_role.clone()));
682 let chevron_left_id =
683 ctx.add(IconWidget::chevron_left(TOOL_BOX_CHEVRON_SIZE).color(text_role.clone()));
684 ctx.visible_when(chevron_left_id, is_selected.clone());
685 ctx.visible_when(chevron_right_id, is_selected.map(|v| !*v));
686 let label_id = ctx.add(RotatedLabel::new(self.label.clone(), text_role));
687
688 let mut col = VStack::new().spacing(tool_box_icon_text_spacing(&ctx.theme().input));
689 col = col.child(indicator_id);
690 if let Some(id) = leading_id {
691 col = col.child(id);
692 }
693 col = col
694 .child(chevron_left_id)
695 .child(chevron_right_id)
696 .child(label_id);
697 if let Some(id) = trailing_id {
698 col = col.child(id);
699 }
700 col = col.child(spacer_id);
701 let col_id = ctx.add(col);
702 ctx.add(
703 crate::primitives::Padding::symmetric(
704 tool_box_header_padding_horizontal(&ctx.theme().input),
705 0.0,
706 )
707 .child(col_id),
708 )
709 } else {
710 let label_id = ctx.add(
713 TextWidget::new(self.label.clone())
714 .color(text_role.clone())
715 .style(TextStyleRole::Body)
716 .single_line()
717 .a11y_hidden(),
718 );
719 let chevron_down_id =
720 ctx.add(IconWidget::chevron_down(TOOL_BOX_CHEVRON_SIZE).color(text_role.clone()));
721 let chevron_right_id =
722 ctx.add(IconWidget::chevron_right(TOOL_BOX_CHEVRON_SIZE).color(text_role));
723 ctx.visible_when(chevron_down_id, is_selected.clone());
724 ctx.visible_when(chevron_right_id, is_selected.map(|v| !*v));
725
726 let mut row = HStack::new().spacing(tool_box_icon_text_spacing(&ctx.theme().input));
727 row = row.child(indicator_id);
728 if let Some(id) = leading_id {
729 row = row.child(id);
730 }
731 row = row.child(label_id).child(spacer_id);
732 if let Some(id) = trailing_id {
733 row = row.child(id);
734 }
735 row = row.child(chevron_down_id).child(chevron_right_id);
736 let row_id = ctx.add(row);
737 ctx.add(
740 crate::primitives::Padding::symmetric(
741 0.0,
742 tool_box_header_padding_horizontal(&ctx.theme().input),
743 )
744 .child(row_id),
745 )
746 };
747
748 let bg_rect_id = ctx.add(RectWidget::new().background(bg_role));
750
751 let focus_inset = focus_ring_width * 0.5;
756 let focus_rect_id = ctx.add(
757 RectWidget::new()
758 .border_color(focus_border_color)
759 .border_width(focus_border_width),
760 );
761 let focus_padded_id =
762 ctx.add(crate::primitives::Padding::uniform(focus_inset).child(focus_rect_id));
763 let zstack_id = ctx.add(
764 ZStack::new()
765 .child(bg_rect_id)
766 .child(focus_padded_id)
767 .child(padded_content_id),
768 );
769
770 let header_extent = tool_box_header_min_height(&ctx.theme().input);
773 let root_id = if is_horizontal {
774 ctx.add(MinSize::new(header_extent, 0.0).child(zstack_id))
775 } else {
776 ctx.add(MinSize::new(0.0, header_extent).child(zstack_id))
777 };
778 self.root_child_id = Some(root_id);
779
780 if let Some(content) = self.composite_tooltip_content.take() {
784 let delay = ctx.theme().motion.tooltip_delay_heavy;
785 crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
786 } else if let Some(source) = self.rich_tooltip.take() {
787 let delay = ctx.theme().motion.tooltip_delay;
788 attach_rich_tooltip_source(ctx, root_id, source, delay);
789 } else if let Some(text) = self.tooltip_text.take() {
790 let delay = ctx.theme().motion.tooltip_delay;
791 crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
792 }
793
794 let collapsible = self.collapsible;
796 let selected_tap = self.selected.clone();
797 let selected_key = self.selected.clone();
798 let selected_access = self.selected.clone();
799 let header_ids_for_key = self.header_ids.clone();
800 let enabled_flags_for_key = self.enabled_flags.clone();
801 let interaction_for_tap = interaction.clone();
802 let interaction_for_hover = interaction.clone();
803 let hovered_cell = Rc::new(Cell::new(false));
810 {
811 let hovered = hovered_cell.clone();
812 crate::common::interaction::bind_press_state(
813 ctx,
814 interaction.clone(),
815 HeaderInteraction::Pressed,
816 move || {
817 if hovered.get() {
818 HeaderInteraction::Hovered
819 } else {
820 HeaderInteraction::Idle
821 }
822 },
823 );
824 }
825 let hovered_for_hover = hovered_cell.clone();
826 let interaction_for_key = interaction.clone();
827 let interaction_for_focus = interaction.clone();
828 let focus_origin_for_focus = focus_origin.clone();
829
830 let mut handler_set = HandlerSet::new()
831 .on_tap(move |_pos, ctx: &mut EventContext| {
832 if collapsible && selected_tap.get() == idx {
833 selected_tap.set(COLLAPSED_SENTINEL);
834 } else {
835 selected_tap.set(idx);
836 }
837 interaction_for_tap.set(if ctx.pointer_kind().hovers() {
842 HeaderInteraction::Hovered
843 } else {
844 HeaderInteraction::Idle
845 });
846 })
847 .on_hover(move |entered, _ctx| {
848 hovered_for_hover.set(entered);
852 interaction_for_hover.set(if entered {
853 HeaderInteraction::Hovered
854 } else {
855 HeaderInteraction::Idle
856 });
857 })
858 .on_focus(move |gained, _ctx| {
859 if !gained {
860 focus_origin_for_focus.set(None);
861 return;
862 }
863 let origin = if interaction_for_focus.get() == HeaderInteraction::Hovered {
868 teksilo_core::focus::FocusOrigin::POINTER
869 } else {
870 teksilo_core::focus::FocusOrigin::Keyboard
871 };
872 focus_origin_for_focus.set(Some(origin));
873 })
874 .on_key(
875 move |event: &WidgetEvent, ctx: &mut EventContext| match event {
876 WidgetEvent::KeyDown {
877 key: Key::Space | Key::Enter,
878 ..
879 } => {
880 interaction_for_key.set(HeaderInteraction::Pressed);
881 EventResponse::Handled
882 }
883 WidgetEvent::KeyUp {
884 key: Key::Space | Key::Enter,
885 ..
886 } => {
887 if collapsible && selected_key.get() == idx {
888 selected_key.set(COLLAPSED_SENTINEL);
889 } else {
890 selected_key.set(idx);
891 }
892 interaction_for_key.set(HeaderInteraction::Hovered);
893 EventResponse::Handled
894 }
895 WidgetEvent::KeyDown {
896 key: Key::ArrowDown,
897 ..
898 } => {
899 let headers = header_ids_for_key.borrow();
900 if headers.is_empty() {
901 return EventResponse::Ignored;
902 }
903 let next = next_enabled_index(&enabled_flags_for_key, idx, 1);
904 if next != idx {
905 ctx.request_focus(headers[next]);
906 }
907 EventResponse::Handled
908 }
909 WidgetEvent::KeyDown {
910 key: Key::ArrowUp, ..
911 } => {
912 let headers = header_ids_for_key.borrow();
913 if headers.is_empty() {
914 return EventResponse::Ignored;
915 }
916 let prev = next_enabled_index(&enabled_flags_for_key, idx, -1);
917 if prev != idx {
918 ctx.request_focus(headers[prev]);
919 }
920 EventResponse::Handled
921 }
922 WidgetEvent::KeyDown { key: Key::Home, .. } => {
923 let headers = header_ids_for_key.borrow();
924 if let Some(first) = first_enabled_index(&enabled_flags_for_key)
925 && let Some(&target) = headers.get(first)
926 {
927 ctx.request_focus(target);
928 return EventResponse::Handled;
929 }
930 EventResponse::Ignored
931 }
932 WidgetEvent::KeyDown { key: Key::End, .. } => {
933 let headers = header_ids_for_key.borrow();
934 if let Some(last) = last_enabled_index(&enabled_flags_for_key)
935 && let Some(&target) = headers.get(last)
936 {
937 ctx.request_focus(target);
938 return EventResponse::Handled;
939 }
940 EventResponse::Ignored
941 }
942 _ => EventResponse::Ignored,
943 },
944 )
945 .on_access_action(move |action, _ctx| {
946 match action {
947 teksilo_core::accesskit::Action::Click
948 | teksilo_core::accesskit::Action::Expand => {
949 selected_access.set(idx);
950 EventResponse::Handled
951 }
952 teksilo_core::accesskit::Action::Collapse => {
953 if collapsible && selected_access.get() == idx {
957 selected_access.set(COLLAPSED_SENTINEL);
958 }
959 EventResponse::Handled
960 }
961 _ => EventResponse::Ignored,
962 }
963 })
964 .focusable(true)
969 .cursor(CursorIcon::Pointer);
970
971 if let Some(drag) = self.on_header_drag.clone() {
975 handler_set = handler_set.on_drag(move |phase, ctx| {
976 if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
977 (drag)(idx, ctx);
978 }
979 });
980 }
981
982 ctx.apply_self_handlers(handler_set);
983
984 vec![root_id]
985 }
986
987 fn layout_response(
988 &self,
989 proposal: SizeProposal,
990 ctx: &LayoutContext,
991 ) -> teksilo_core::widget::LayoutResponse {
992 if let Some(root) = self.root_child_id
993 && let Some(size) = ctx.child_size(root, proposal)
994 {
995 return (size).into();
996 }
997 proposal.resolve(0.0, 0.0).into()
998 }
999
1000 fn place_children(
1001 &self,
1002 bounds: Rect,
1003 _proposal: SizeProposal,
1004 children: &mut [WidgetPlacement],
1005 _ctx: &LayoutContext,
1006 ) {
1007 for child in children.iter_mut() {
1008 child.origin = bounds.origin();
1009 child.size = bounds.size();
1010 }
1011 }
1012
1013 fn children(&self) -> Vec<WidgetId> {
1014 self.root_child_id.into_iter().collect()
1015 }
1016
1017 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1018 use teksilo_core::accesskit::{Action, Role};
1019 builder.set_role(Role::Button);
1020 builder.set_name(self.label.resolve_now());
1021 let is_active = self.selected.get() == self.index;
1022 builder.set_expanded(is_active);
1023 if self.initial_enabled {
1029 builder.add_action(Action::Click);
1030 builder.add_action(Action::Expand);
1031 builder.add_action(Action::Collapse);
1032 }
1033 builder.add_action(Action::Focus);
1034 if let Some(&panel_id) = self.panel_ids.borrow().get(self.index) {
1036 builder.push_controlled(widget_id_to_node_id(panel_id));
1037 }
1038 }
1039}
1040
1041#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1042enum HeaderInteraction {
1043 Idle,
1044 Hovered,
1045 Pressed,
1046}
1047
1048#[derive(Debug)]
1054struct ToolBoxPanel {
1055 label: LocalizedString,
1056 selected: Signal<usize>,
1057 index: usize,
1058 content: Option<PendingChild>,
1059 fill: bool,
1064 orientation: ToolBoxOrientation,
1065 root_child_id: Option<WidgetId>,
1066}
1067
1068impl ToolBoxPanel {
1069 fn new(
1070 label: LocalizedString,
1071 selected: Signal<usize>,
1072 index: usize,
1073 content: PendingChild,
1074 fill: bool,
1075 orientation: ToolBoxOrientation,
1076 ) -> Self {
1077 Self {
1078 label,
1079 selected,
1080 index,
1081 content: Some(content),
1082 fill,
1083 orientation,
1084 root_child_id: None,
1085 }
1086 }
1087}
1088
1089impl Widget for ToolBoxPanel {
1090 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1091 let content_id = match self.content.take().expect("ToolBoxPanel built twice") {
1092 PendingChild::Id(id) => id,
1093 PendingChild::Deferred(w) => ctx.add_boxed(w),
1094 };
1095
1096 let idx = self.index;
1104 let is_selected = self.selected.map(move |s| *s == idx);
1105 ctx.visible_when(content_id, is_selected);
1106 self.root_child_id = Some(content_id);
1107 self.selected.bind_to(
1109 ctx.self_id(),
1110 ctx.binding_registry(),
1111 BindingLevel::Relayout,
1112 );
1113 vec![content_id]
1114 }
1115
1116 fn layout_response(
1117 &self,
1118 proposal: SizeProposal,
1119 ctx: &LayoutContext,
1120 ) -> teksilo_core::widget::LayoutResponse {
1121 let Some(root) = self.root_child_id else {
1122 return proposal.resolve(0.0, 0.0).into();
1123 };
1124
1125 if self.selected.get() != self.index {
1127 return Size::ZERO.into();
1128 }
1129
1130 let content = ctx.child_size(root, proposal).unwrap_or(Size::ZERO);
1131 if self.fill {
1132 let size = match self.orientation {
1137 ToolBoxOrientation::Vertical => {
1138 Size::new(proposal.width.unwrap_or(content.width), content.height)
1139 }
1140 ToolBoxOrientation::Horizontal => {
1141 Size::new(content.width, proposal.height.unwrap_or(content.height))
1142 }
1143 };
1144 return teksilo_core::widget::LayoutResponse::shrinkable(size, Size::ZERO, 1.0)
1145 .with_flex(1.0);
1146 }
1147 content.into()
1148 }
1149
1150 fn place_children(
1151 &self,
1152 bounds: Rect,
1153 _proposal: SizeProposal,
1154 children: &mut [WidgetPlacement],
1155 _ctx: &LayoutContext,
1156 ) {
1157 for child in children.iter_mut() {
1158 child.origin = bounds.origin();
1159 child.size = bounds.size();
1160 }
1161 }
1162
1163 fn clips_children(&self) -> bool {
1164 self.fill
1168 }
1169
1170 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1171 use teksilo_core::accesskit::Role;
1172 builder.set_role(Role::Region);
1175 builder.set_name(self.label.resolve_now());
1176 if self.selected.get() != self.index {
1180 builder.set_hidden();
1181 }
1182 }
1183
1184 fn children(&self) -> Vec<WidgetId> {
1185 self.root_child_id.into_iter().collect()
1186 }
1187}
1188
1189fn pivoted_rotation(pivot: Point, theta: f32) -> Transform2D {
1201 let (s, c) = theta.sin_cos();
1202 Transform2D {
1203 m: [
1204 c,
1205 s,
1206 -s,
1207 c,
1208 pivot.x * (1.0 - c) + pivot.y * s,
1209 pivot.y * (1.0 - c) - pivot.x * s,
1210 ],
1211 }
1212}
1213
1214#[derive(Debug)]
1215pub(crate) struct RotatedLabel {
1216 label: LocalizedString,
1217 color: ColorProp,
1218 style: TextStyleProp,
1219 child_id: Option<WidgetId>,
1220 natural: Cell<Size>,
1221 transform_signal: Option<Signal<Transform2D>>,
1222}
1223
1224impl RotatedLabel {
1225 pub(crate) fn new(label: LocalizedString, color: impl Into<ColorProp>) -> Self {
1226 Self {
1227 label,
1228 color: color.into(),
1229 style: TextStyleRole::Body.into(),
1230 child_id: None,
1231 natural: Cell::new(Size::ZERO),
1232 transform_signal: None,
1233 }
1234 }
1235
1236 pub(crate) fn style(mut self, style: impl Into<TextStyleProp>) -> Self {
1239 self.style = style.into();
1240 self
1241 }
1242}
1243
1244impl Widget for RotatedLabel {
1245 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1246 let child = ctx.add(
1247 TextWidget::new(self.label.clone())
1248 .color(self.color.clone())
1249 .style(self.style.clone())
1250 .single_line()
1251 .a11y_hidden(),
1252 );
1253 self.child_id = Some(child);
1254 let t = ctx.signal(Transform2D::IDENTITY);
1255 ctx.set_transform(ctx.self_id(), t.clone());
1256 self.transform_signal = Some(t);
1257 vec![child]
1258 }
1259
1260 fn layout_response(
1261 &self,
1262 _proposal: SizeProposal,
1263 ctx: &LayoutContext,
1264 ) -> teksilo_core::widget::LayoutResponse {
1265 let natural = self
1268 .child_id
1269 .and_then(|id| {
1270 ctx.child_size(
1271 id,
1272 SizeProposal {
1273 width: None,
1274 height: None,
1275 },
1276 )
1277 })
1278 .unwrap_or(Size::ZERO);
1279 self.natural.set(natural);
1280 Size::new(natural.height, natural.width).into()
1281 }
1282
1283 fn place_children(
1284 &self,
1285 bounds: Rect,
1286 _proposal: SizeProposal,
1287 children: &mut [WidgetPlacement],
1288 _ctx: &LayoutContext,
1289 ) {
1290 let natural = self.natural.get();
1291 let cx = bounds.x + bounds.width * 0.5;
1294 let cy = bounds.y + bounds.height * 0.5;
1295 let origin = Point::new(cx - natural.width * 0.5, cy - natural.height * 0.5);
1296 for child in children.iter_mut() {
1297 child.origin = origin;
1298 child.size = natural;
1299 }
1300 if let Some(t) = &self.transform_signal {
1301 t.set(pivoted_rotation(
1304 Point::new(cx, cy),
1305 -std::f32::consts::FRAC_PI_2,
1306 ));
1307 }
1308 }
1309
1310 fn clips_children(&self) -> bool {
1311 false
1312 }
1313
1314 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
1315 }
1318
1319 fn children(&self) -> Vec<WidgetId> {
1320 self.child_id.into_iter().collect()
1321 }
1322}
1323
1324impl Widget for ToolBox {
1327 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1328 let items = std::mem::take(&mut self.items);
1329 let enabled_flags: Rc<Vec<bool>> = Rc::new(items.iter().map(|i| i.enabled.get()).collect());
1330 let header_ids: Rc<RefCell<Vec<WidgetId>>> =
1331 Rc::new(RefCell::new(Vec::with_capacity(items.len())));
1332 let panel_ids: Rc<RefCell<Vec<WidgetId>>> =
1333 Rc::new(RefCell::new(Vec::with_capacity(items.len())));
1334
1335 let orientation = self.orientation;
1336 let show_dividers = self.show_dividers;
1337 let item_count = items.len();
1338
1339 let mut child_ids: Vec<WidgetId> = Vec::with_capacity(item_count * 3);
1345
1346 for (index, item) in items.into_iter().enumerate() {
1347 let label = item.label.clone();
1348 let header_id = ctx.add(ToolBoxHeader::new(
1349 item.label.clone(),
1350 index,
1351 item.enabled.get(),
1352 self.selected.clone(),
1353 header_ids.clone(),
1354 panel_ids.clone(),
1355 enabled_flags.clone(),
1356 item.leading,
1357 item.trailing,
1358 item.tooltip_text,
1359 item.rich_tooltip,
1360 item.composite_tooltip_content,
1361 orientation,
1362 self.collapsible,
1363 self.header_drag.clone(),
1364 ));
1365 header_ids.borrow_mut().push(header_id);
1366
1367 let panel_id = ctx.add(ToolBoxPanel::new(
1368 label,
1369 self.selected.clone(),
1370 index,
1371 item.content,
1372 self.fill,
1373 orientation,
1374 ));
1375 panel_ids.borrow_mut().push(panel_id);
1376
1377 child_ids.push(header_id);
1378 child_ids.push(panel_id);
1379
1380 if show_dividers && index + 1 < item_count {
1381 let divider = match orientation {
1385 ToolBoxOrientation::Vertical => Divider::new(),
1386 ToolBoxOrientation::Horizontal => Divider::vertical(),
1387 };
1388 child_ids.push(ctx.add(divider.color(BorderRole::Divider)));
1389 }
1390 }
1391
1392 let root = match orientation {
1393 ToolBoxOrientation::Vertical => {
1394 let mut stack = VStack::new().spacing(0.0);
1395 for id in child_ids {
1396 stack = stack.child(id);
1397 }
1398 ctx.add(stack)
1399 }
1400 ToolBoxOrientation::Horizontal => {
1401 let mut stack = HStack::new().spacing(0.0);
1402 for id in child_ids {
1403 stack = stack.child(id);
1404 }
1405 ctx.add(stack)
1406 }
1407 };
1408 self.root_child_id = Some(root);
1409 vec![root]
1410 }
1411
1412 fn layout_response(
1413 &self,
1414 proposal: SizeProposal,
1415 ctx: &LayoutContext,
1416 ) -> teksilo_core::widget::LayoutResponse {
1417 if let Some(root) = self.root_child_id
1418 && let Some(size) = ctx.child_size(root, proposal)
1419 {
1420 return (size).into();
1421 }
1422 proposal.resolve(0.0, 0.0).into()
1423 }
1424
1425 fn place_children(
1426 &self,
1427 bounds: Rect,
1428 _proposal: SizeProposal,
1429 children: &mut [WidgetPlacement],
1430 _ctx: &LayoutContext,
1431 ) {
1432 for child in children.iter_mut() {
1433 child.origin = bounds.origin();
1434 child.size = bounds.size();
1435 }
1436 }
1437
1438 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1439 builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
1440 }
1441
1442 fn children(&self) -> Vec<WidgetId> {
1443 self.root_child_id.into_iter().collect()
1444 }
1445}
1446
1447#[cfg(test)]
1452mod tests {
1453 use super::*;
1454 use crate::primitives::TextWidget;
1455 use teksilo_canvas::SizeProposal;
1456 use teksilo_core::accesskit;
1457 use teksilo_core::event::Modifiers;
1458 use teksilo_core::widget_tree::WidgetTree;
1459 use teksilo_i18n::lit;
1460
1461 fn tree() -> WidgetTree {
1462 WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
1463 }
1464
1465 fn header_id(tree: &WidgetTree, toolbox: WidgetId, index: usize) -> WidgetId {
1469 let vstack = tree.child_widget(toolbox, 0);
1470 tree.child_widget(vstack, index * 2)
1473 }
1474
1475 fn panel_id(tree: &WidgetTree, toolbox: WidgetId, index: usize) -> WidgetId {
1476 let vstack = tree.child_widget(toolbox, 0);
1477 tree.child_widget(vstack, index * 2 + 1)
1478 }
1479
1480 #[test]
1481 fn tool_box_builds_with_first_selected() {
1482 let selected = Signal::new(0_usize);
1483 let mut t = tree();
1484 let tb = t.add(
1485 ToolBox::new(selected.clone())
1486 .item(lit!("Outline"), TextWidget::new(lit!("Outline content")))
1487 .item(lit!("Props"), TextWidget::new(lit!("Props content")))
1488 .item(lit!("Refs"), TextWidget::new(lit!("Refs content"))),
1489 );
1490 t.layout(SizeProposal::exact(300.0, 600.0));
1491
1492 let b = t.bounds(tb);
1493 assert!(b.width > 0.0, "ToolBox width = {}", b.width);
1494 assert!(b.height > 0.0, "ToolBox height = {}", b.height);
1495 }
1496
1497 #[test]
1498 fn clicking_header_changes_selection() {
1499 let selected = Signal::new(0_usize);
1500 let mut t = tree();
1501 let tb = t.add(
1502 ToolBox::new(selected.clone())
1503 .item(lit!("A"), TextWidget::new(lit!("A")))
1504 .item(lit!("B"), TextWidget::new(lit!("B")))
1505 .item(lit!("C"), TextWidget::new(lit!("C"))),
1506 );
1507 t.layout(SizeProposal::exact(300.0, 600.0));
1508
1509 t.click(header_id(&t, tb, 2));
1510 assert_eq!(selected.get(), 2);
1511
1512 t.click(header_id(&t, tb, 0));
1513 assert_eq!(selected.get(), 0);
1514 }
1515
1516 #[test]
1517 fn panel_heights_swap_on_selection_change() {
1518 let selected = Signal::new(0_usize);
1519 let mut t = tree();
1520 let tb = t.add(
1521 ToolBox::new(selected.clone())
1522 .item(lit!("A"), TextWidget::new(lit!("AAAAAAAA")))
1523 .item(lit!("B"), TextWidget::new(lit!("BBBBBBBB"))),
1524 );
1525 t.layout(SizeProposal::exact(300.0, 600.0));
1526
1527 let panel_a_before = t.bounds(panel_id(&t, tb, 0)).height;
1528 let panel_b_before = t.bounds(panel_id(&t, tb, 1)).height;
1529 assert!(
1530 panel_a_before > 0.0,
1531 "active panel should have nonzero height"
1532 );
1533 assert!(panel_b_before < 0.5, "inactive panel should be collapsed");
1534
1535 selected.set(1);
1536 t.layout(SizeProposal::exact(300.0, 600.0));
1537
1538 let panel_a_after = t.bounds(panel_id(&t, tb, 0)).height;
1539 let panel_b_after = t.bounds(panel_id(&t, tb, 1)).height;
1540 assert!(panel_a_after < 0.5, "formerly active panel collapsed");
1541 assert!(panel_b_after > 0.0, "newly active panel expanded");
1542 }
1543
1544 #[test]
1545 fn programmatic_selection_drives_swap_like_click() {
1546 let selected = Signal::new(0_usize);
1547 let mut t = tree();
1548 let tb = t.add(
1549 ToolBox::new(selected.clone())
1550 .item(lit!("A"), TextWidget::new(lit!("AAA")))
1551 .item(lit!("B"), TextWidget::new(lit!("BBB"))),
1552 );
1553 t.layout(SizeProposal::exact(300.0, 600.0));
1554
1555 selected.set(1);
1557 t.layout(SizeProposal::exact(300.0, 600.0));
1558
1559 let panel_a = t.bounds(panel_id(&t, tb, 0)).height;
1560 let panel_b = t.bounds(panel_id(&t, tb, 1)).height;
1561 assert!(panel_a < 0.5);
1562 assert!(panel_b > 0.0);
1563 }
1564
1565 #[test]
1566 fn disabled_item_ignores_click() {
1567 let selected = Signal::new(0_usize);
1568 let mut t = tree();
1569 let tb = t.add(
1570 ToolBox::new(selected.clone())
1571 .item(lit!("A"), TextWidget::new(lit!("A")))
1572 .add(ToolBoxItem::new(lit!("B"), TextWidget::new(lit!("B"))).enabled(false))
1573 .item(lit!("C"), TextWidget::new(lit!("C"))),
1574 );
1575 t.layout(SizeProposal::exact(300.0, 600.0));
1576
1577 let disabled = header_id(&t, tb, 1);
1578 t.click(disabled);
1579 assert_eq!(selected.get(), 0, "disabled header should not activate");
1580 }
1581
1582 #[test]
1583 fn arrow_down_skips_disabled_header() {
1584 let selected = Signal::new(0_usize);
1585 let mut t = tree();
1586 let tb = t.add(
1587 ToolBox::new(selected.clone())
1588 .item(lit!("A"), TextWidget::new(lit!("A")))
1589 .add(ToolBoxItem::new(lit!("B"), TextWidget::new(lit!("B"))).enabled(false))
1590 .item(lit!("C"), TextWidget::new(lit!("C"))),
1591 );
1592 t.layout(SizeProposal::exact(300.0, 600.0));
1593
1594 t.press_key(Key::Tab, Modifiers::NONE);
1596 assert_eq!(t.focused(), Some(header_id(&t, tb, 0)));
1597
1598 t.press_key(Key::ArrowDown, Modifiers::NONE);
1599 assert_eq!(t.focused(), Some(header_id(&t, tb, 2)));
1600 }
1601
1602 #[test]
1603 fn home_and_end_jump_to_first_and_last_enabled() {
1604 let selected = Signal::new(1_usize);
1605 let mut t = tree();
1606 let tb = t.add(
1607 ToolBox::new(selected.clone())
1608 .add(ToolBoxItem::new(lit!("Locked"), TextWidget::new(lit!("x"))).enabled(false))
1609 .item(lit!("Middle"), TextWidget::new(lit!("m")))
1610 .item(lit!("Last"), TextWidget::new(lit!("l"))),
1611 );
1612 t.layout(SizeProposal::exact(300.0, 600.0));
1613
1614 t.press_key(Key::Tab, Modifiers::NONE);
1617 assert_eq!(t.focused(), Some(header_id(&t, tb, 1)));
1618
1619 t.press_key(Key::End, Modifiers::NONE);
1620 assert_eq!(t.focused(), Some(header_id(&t, tb, 2)));
1621
1622 t.press_key(Key::Home, Modifiers::NONE);
1623 assert_eq!(t.focused(), Some(header_id(&t, tb, 1)));
1626 }
1627
1628 #[test]
1629 fn accessibility_marks_selected_expanded_and_controls_panel() {
1630 let selected = Signal::new(0_usize);
1631 let mut t = tree();
1632 let tb = t.add(
1633 ToolBox::new(selected.clone())
1634 .item(lit!("A"), TextWidget::new(lit!("A")))
1635 .item(lit!("B"), TextWidget::new(lit!("B"))),
1636 );
1637 t.layout(SizeProposal::exact(300.0, 600.0));
1638
1639 let h0 = t.accessibility_node(header_id(&t, tb, 0));
1640 let h1 = t.accessibility_node(header_id(&t, tb, 1));
1641 assert!(h0.is_expanded());
1642 assert!(!h1.is_expanded());
1643 assert_eq!(h0.role(), accesskit::Role::Button);
1644
1645 selected.set(1);
1647 t.layout(SizeProposal::exact(300.0, 600.0));
1648 let h0b = t.accessibility_node(header_id(&t, tb, 0));
1649 let h1b = t.accessibility_node(header_id(&t, tb, 1));
1650 assert!(!h0b.is_expanded());
1651 assert!(h1b.is_expanded());
1652
1653 let p0 = t.accessibility_node(panel_id(&t, tb, 0));
1655 assert_eq!(p0.role(), accesskit::Role::Region);
1656 assert_eq!(p0.name(), Some("A"));
1657 }
1658
1659 #[test]
1660 fn access_action_expand_selects_item() {
1661 let selected = Signal::new(0_usize);
1662 let mut t = tree();
1663 let tb = t.add(
1664 ToolBox::new(selected.clone())
1665 .item(lit!("A"), TextWidget::new(lit!("A")))
1666 .item(lit!("B"), TextWidget::new(lit!("B")))
1667 .item(lit!("C"), TextWidget::new(lit!("C"))),
1668 );
1669 t.layout(SizeProposal::exact(300.0, 600.0));
1670
1671 let third = header_id(&t, tb, 2);
1672 t.dispatch_event(WidgetEvent::AccessAction {
1673 action: accesskit::Action::Expand,
1674 target: Some(third),
1675 target_node: teksilo_core::accessibility::root_node_id(),
1676 data: None,
1677 });
1678 assert_eq!(selected.get(), 2);
1679 }
1680
1681 #[test]
1682 fn access_action_collapse_is_swallowed() {
1683 let selected = Signal::new(1_usize);
1684 let mut t = tree();
1685 let tb = t.add(
1686 ToolBox::new(selected.clone())
1687 .item(lit!("A"), TextWidget::new(lit!("A")))
1688 .item(lit!("B"), TextWidget::new(lit!("B"))),
1689 );
1690 t.layout(SizeProposal::exact(300.0, 600.0));
1691
1692 let active = header_id(&t, tb, 1);
1694 t.dispatch_event(WidgetEvent::AccessAction {
1695 action: accesskit::Action::Collapse,
1696 target: Some(active),
1697 target_node: teksilo_core::accessibility::root_node_id(),
1698 data: None,
1699 });
1700 assert_eq!(selected.get(), 1);
1701 }
1702
1703 #[test]
1704 fn leading_slot_widget_is_placed_inside_the_header() {
1705 use crate::Button;
1706
1707 let selected = Signal::new(0_usize);
1708 let mut t = tree();
1709 let tb = t.add(
1710 ToolBox::new(selected.clone()).add(
1711 ToolBoxItem::new(lit!("A"), TextWidget::new(lit!("A")))
1712 .leading(Button::new(lit!("start"))),
1713 ),
1714 );
1715 t.layout(SizeProposal::exact(300.0, 200.0));
1716
1717 let header = header_id(&t, tb, 0);
1718 let header_bounds = t.bounds(header);
1719
1720 fn find_button_inside(t: &WidgetTree, root: WidgetId, outer: WidgetId) -> Option<WidgetId> {
1721 for child in t.children(root) {
1722 if child != outer {
1723 let info = t.accessibility_node(child);
1724 if info.role() == teksilo_core::accesskit::Role::Button {
1725 return Some(child);
1726 }
1727 }
1728 if let Some(found) = find_button_inside(t, child, outer) {
1729 return Some(found);
1730 }
1731 }
1732 None
1733 }
1734
1735 let leading_btn = find_button_inside(&t, header, header)
1736 .expect("leading Button should be a descendant of the header");
1737 let btn_bounds = t.bounds(leading_btn);
1738 assert!(
1739 btn_bounds.x >= header_bounds.x && btn_bounds.right() <= header_bounds.right() + 0.01,
1740 "leading button bounds must fit inside header row"
1741 );
1742 }
1743
1744 #[test]
1745 fn trailing_slot_widget_is_placed_inside_the_header() {
1746 use crate::Button;
1747
1748 let selected = Signal::new(0_usize);
1749 let mut t = tree();
1750 let tb = t.add(
1751 ToolBox::new(selected.clone()).add(
1752 ToolBoxItem::new(lit!("A"), TextWidget::new(lit!("A")))
1753 .trailing(Button::new(lit!("x"))),
1754 ),
1755 );
1756 t.layout(SizeProposal::exact(300.0, 200.0));
1757
1758 let header = header_id(&t, tb, 0);
1759 let header_bounds = t.bounds(header);
1760
1761 fn find_button_inside(t: &WidgetTree, root: WidgetId, outer: WidgetId) -> Option<WidgetId> {
1766 for child in t.children(root) {
1767 if child != outer {
1768 let info = t.accessibility_node(child);
1769 if info.role() == teksilo_core::accesskit::Role::Button {
1770 return Some(child);
1771 }
1772 }
1773 if let Some(found) = find_button_inside(t, child, outer) {
1774 return Some(found);
1775 }
1776 }
1777 None
1778 }
1779
1780 let trailing_btn = find_button_inside(&t, header, header)
1781 .expect("trailing Button should be a descendant of the header");
1782 let btn_bounds = t.bounds(trailing_btn);
1783 assert!(
1784 btn_bounds.x >= header_bounds.x && btn_bounds.right() <= header_bounds.right() + 0.01,
1785 "trailing button bounds must fit inside header row"
1786 );
1787 }
1788
1789 #[test]
1790 fn disabled_header_has_no_click_action() {
1791 let selected = Signal::new(0_usize);
1792 let mut t = tree();
1793 let tb = t.add(
1794 ToolBox::new(selected.clone())
1795 .item(lit!("A"), TextWidget::new(lit!("A")))
1796 .add(ToolBoxItem::new(lit!("B"), TextWidget::new(lit!("B"))).enabled(false)),
1797 );
1798 t.layout(SizeProposal::exact(300.0, 600.0));
1799
1800 let disabled = header_id(&t, tb, 1);
1801 let info = t.accessibility_node(disabled);
1802 assert!(!info.actions().contains(&accesskit::Action::Click));
1803 assert!(!info.actions().contains(&accesskit::Action::Expand));
1804 }
1805
1806 #[test]
1809 fn vertical_orientation_stacks_top_to_bottom() {
1810 let selected = Signal::new(0_usize);
1811 let mut t = tree();
1812 let tb = t.add(
1813 ToolBox::new(selected.clone())
1814 .item(lit!("A"), TextWidget::new(lit!("a")))
1815 .item(lit!("B"), TextWidget::new(lit!("b"))),
1816 );
1817 t.layout(SizeProposal::exact(300.0, 600.0));
1818 let h0 = t.bounds(header_id(&t, tb, 0));
1819 let h1 = t.bounds(header_id(&t, tb, 1));
1820 assert!(h1.y > h0.y, "vertical headers stack top→bottom");
1821 assert!(h0.width > h0.height, "vertical header is a horizontal row");
1823 }
1824
1825 #[test]
1826 fn horizontal_orientation_lays_sections_left_to_right() {
1827 let selected = Signal::new(0_usize);
1828 let mut t = tree();
1829 let tb = t.add(
1830 ToolBox::new(selected.clone())
1831 .horizontal()
1832 .item(lit!("Terminal"), TextWidget::new(lit!("term")))
1833 .item(lit!("Problems"), TextWidget::new(lit!("prob")))
1834 .item(lit!("Output"), TextWidget::new(lit!("out"))),
1835 );
1836 t.layout(SizeProposal::exact(900.0, 220.0));
1837
1838 let h0 = t.bounds(header_id(&t, tb, 0));
1839 let h1 = t.bounds(header_id(&t, tb, 1));
1840 let h2 = t.bounds(header_id(&t, tb, 2));
1841 assert!(
1842 h0.x < h1.x && h1.x < h2.x,
1843 "horizontal headers run left→right: {} {} {}",
1844 h0.x,
1845 h1.x,
1846 h2.x
1847 );
1848 assert!(
1850 h0.height > h0.width,
1851 "horizontal header is a vertical strip ({}×{})",
1852 h0.width,
1853 h0.height
1854 );
1855 assert!(
1856 h0.width <= TOOL_BOX_HEADER_MIN_HEIGHT + 24.0,
1857 "strip stays narrow (got width {})",
1858 h0.width
1859 );
1860 }
1861
1862 #[test]
1863 fn horizontal_collapsed_panel_has_zero_main_extent() {
1864 let selected = Signal::new(0_usize);
1865 let mut t = tree();
1866 let tb = t.add(
1867 ToolBox::new(selected.clone())
1868 .horizontal()
1869 .item(lit!("A"), TextWidget::new(lit!("aaaa")))
1870 .item(lit!("B"), TextWidget::new(lit!("bbbb"))),
1871 );
1872 t.layout(SizeProposal::exact(900.0, 220.0));
1873 assert!(t.bounds(panel_id(&t, tb, 0)).width > 0.0);
1876 assert!(t.bounds(panel_id(&t, tb, 1)).width.abs() < 0.5);
1877 }
1878
1879 #[test]
1880 fn header_drag_hook_fires_with_section_index() {
1881 use std::cell::Cell as StdCell;
1882 let dragged: Rc<StdCell<Option<usize>>> = Rc::new(StdCell::new(None));
1883 let selected = Signal::new(0_usize);
1884 let sink = dragged.clone();
1885 let mut t = tree();
1886 let tb = t.add(
1887 ToolBox::new(selected.clone())
1888 .on_header_drag(move |idx, _ctx| sink.set(Some(idx)))
1889 .item(lit!("A"), TextWidget::new(lit!("a")))
1890 .item(lit!("B"), TextWidget::new(lit!("b"))),
1891 );
1892 t.layout(SizeProposal::exact(300.0, 600.0));
1893
1894 let h1 = t.bounds(header_id(&t, tb, 1));
1895 let from = teksilo_canvas::Point::new(h1.x + h1.width * 0.5, h1.y + h1.height * 0.5);
1896 t.drag(
1898 from,
1899 teksilo_canvas::Point::new(from.x + 120.0, from.y + 40.0),
1900 );
1901 assert_eq!(
1902 dragged.get(),
1903 Some(1),
1904 "dragging header #1 must fire the hook with index 1"
1905 );
1906 }
1907
1908 #[test]
1911 fn fill_active_panel_fills_width_and_leftover_height() {
1912 let selected = Signal::new(0_usize);
1916 let mut t = tree();
1917 let tb = t.add(
1918 ToolBox::new(selected.clone())
1919 .fill(true)
1920 .item(lit!("A"), TextWidget::new(lit!("a")))
1921 .item(lit!("B"), TextWidget::new(lit!("b"))),
1922 );
1923 t.layout(SizeProposal::exact(300.0, 400.0));
1924
1925 assert!(
1928 (t.bounds(tb).height - 400.0).abs() < 1.0,
1929 "fill ToolBox should occupy its full slot height, got {}",
1930 t.bounds(tb).height
1931 );
1932
1933 let panel_a = t.bounds(panel_id(&t, tb, 0));
1934 assert!(
1936 panel_a.width > 290.0,
1937 "active panel should fill the width, got {}",
1938 panel_a.width
1939 );
1940 assert!(
1943 panel_a.height > 200.0,
1944 "active panel should grow into leftover height, got {}",
1945 panel_a.height
1946 );
1947 assert!(
1949 t.bounds(panel_id(&t, tb, 1)).height < 0.5,
1950 "inactive panel collapsed"
1951 );
1952 }
1953
1954 #[test]
1955 fn fill_active_panel_does_not_overflow_oversized_content() {
1956 let selected = Signal::new(0_usize);
1961 let mut t = tree();
1962 let tall = FixedSize::new()
1963 .width(120.0_f32)
1964 .height(1000.0_f32)
1965 .child(TextWidget::new(lit!("x")));
1966 let tb = t.add(
1967 ToolBox::new(selected.clone())
1968 .fill(true)
1969 .item(lit!("A"), tall)
1970 .item(lit!("B"), TextWidget::new(lit!("b"))),
1971 );
1972 t.layout(SizeProposal::exact(300.0, 200.0));
1973
1974 assert!(
1975 (t.bounds(tb).height - 200.0).abs() < 1.0,
1976 "fill ToolBox must not overflow its slot, got {}",
1977 t.bounds(tb).height
1978 );
1979 let panel_a = t.bounds(panel_id(&t, tb, 0));
1980 assert!(
1981 panel_a.height < 200.0,
1982 "oversized active panel shrinks to fit, got {}",
1983 panel_a.height
1984 );
1985 }
1986
1987 #[test]
1988 fn non_fill_panel_keeps_natural_size() {
1989 let selected = Signal::new(0_usize);
1993 let mut t = tree();
1994 let tb = t.add(
1995 ToolBox::new(selected.clone())
1996 .item(lit!("A"), TextWidget::new(lit!("a")))
1997 .item(lit!("B"), TextWidget::new(lit!("b"))),
1998 );
1999 t.layout(SizeProposal::exact(300.0, 400.0));
2000 assert!(
2003 t.bounds(panel_id(&t, tb, 0)).height < 100.0,
2004 "non-fill panel keeps natural height, got {}",
2005 t.bounds(panel_id(&t, tb, 0)).height
2006 );
2007 }
2008
2009 #[test]
2012 fn collapsible_active_header_click_collapses_then_reexpands() {
2013 let selected = Signal::new(0_usize);
2014 let mut t = tree();
2015 let tb = t.add(
2016 ToolBox::new(selected.clone())
2017 .collapsible(true)
2018 .item(lit!("A"), TextWidget::new(lit!("aaa")))
2019 .item(lit!("B"), TextWidget::new(lit!("bbb"))),
2020 );
2021 t.layout(SizeProposal::exact(300.0, 400.0));
2022 assert!(
2023 t.bounds(panel_id(&t, tb, 0)).height > 0.0,
2024 "A starts expanded"
2025 );
2026
2027 t.click(header_id(&t, tb, 0));
2029 t.layout(SizeProposal::exact(300.0, 400.0));
2030 assert!(
2031 t.bounds(panel_id(&t, tb, 0)).height < 0.5,
2032 "active header click collapses its content"
2033 );
2034 assert!(
2035 t.bounds(panel_id(&t, tb, 1)).height < 0.5,
2036 "B stays collapsed"
2037 );
2038
2039 t.click(header_id(&t, tb, 0));
2041 t.layout(SizeProposal::exact(300.0, 400.0));
2042 assert!(
2043 t.bounds(panel_id(&t, tb, 0)).height > 0.0,
2044 "re-expands on next click"
2045 );
2046 }
2047
2048 #[test]
2049 fn non_collapsible_active_header_click_stays_open() {
2050 let selected = Signal::new(0_usize);
2052 let mut t = tree();
2053 let tb = t.add(
2054 ToolBox::new(selected.clone())
2055 .item(lit!("A"), TextWidget::new(lit!("aaa")))
2056 .item(lit!("B"), TextWidget::new(lit!("bbb"))),
2057 );
2058 t.layout(SizeProposal::exact(300.0, 400.0));
2059 t.click(header_id(&t, tb, 0));
2060 t.layout(SizeProposal::exact(300.0, 400.0));
2061 assert_eq!(selected.get(), 0);
2062 assert!(
2063 t.bounds(panel_id(&t, tb, 0)).height > 0.0,
2064 "non-collapsible active section stays open"
2065 );
2066 }
2067
2068 #[test]
2071 fn tooltip_appears_on_hover() {
2072 let selected = Signal::new(0_usize);
2073 let mut t = tree();
2074 let tb = t.add(ToolBox::new(selected.clone()).add(
2075 ToolBoxItem::new(lit!("A"), TextWidget::new(lit!("content"))).tooltip(lit!("Tip")),
2076 ));
2077 t.layout(SizeProposal::exact(300.0, 200.0));
2078 t.pointer_move(t.bounds(header_id(&t, tb, 0)).center());
2079 t.advance_time(std::time::Duration::from_secs(1));
2080 assert_eq!(
2081 t.active_overlays().len(),
2082 1,
2083 "tooltip should appear on hover"
2084 );
2085 assert!(t.find_by_label("Tip").is_some());
2086 }
2087}