1use teksilo_canvas::{Rect, Size, SizeProposal};
33use teksilo_core::accessibility::AccessNodeBuilder;
34use teksilo_core::binding::BindingLevel;
35use teksilo_core::build_context::BuildContext;
36use teksilo_core::color_prop::{ColorProp, TextStyleProp};
37use teksilo_core::event::{EventResponse, Key, WidgetEvent};
38use teksilo_core::signal::Signal;
39use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
40use teksilo_core::widget_builder::HandlerSet;
41use teksilo_core::widget_id::WidgetId;
42use teksilo_tokens::{BorderRole, TextRole, TextStyleRole};
43
44use crate::animations::collapse::Collapse;
45use crate::primitives::{HStack, IconWidget, MinSize, Spacer, TextWidget, VStack};
46use crate::tool_box::RotatedLabel;
47use teksilo_i18n::LocalizedString;
48
49pub(crate) const ACCORDION_FILL_HEADER_EXTENT: f32 = 30.0;
52pub(crate) const ACCORDION_FILL_COLLAPSED_EXTENT: f32 = ACCORDION_FILL_HEADER_EXTENT + 2.0;
55
56#[derive(Debug)]
61struct AccordionRegion {
62 name: LocalizedString,
66 child: Option<WidgetId>,
67}
68
69impl AccordionRegion {
70 fn new(name: LocalizedString, child: WidgetId) -> Self {
71 Self {
72 name,
73 child: Some(child),
74 }
75 }
76}
77
78impl Widget for AccordionRegion {
79 fn layout_response(
80 &self,
81 proposal: SizeProposal,
82 ctx: &LayoutContext,
83 ) -> teksilo_core::widget::LayoutResponse {
84 self.child
85 .and_then(|id| ctx.child_size(id, proposal))
86 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
87 .into()
88 }
89
90 fn place_children(
91 &self,
92 bounds: Rect,
93 _proposal: SizeProposal,
94 children: &mut [WidgetPlacement],
95 _ctx: &LayoutContext,
96 ) {
97 for child in children.iter_mut() {
98 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
99 child.size = bounds.size();
100 }
101 }
102
103 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
104 builder.set_role(teksilo_core::accesskit::Role::Region);
105 builder.set_name(self.name.resolve_now());
106 }
107
108 fn children(&self) -> Vec<WidgetId> {
109 self.child.into_iter().collect()
110 }
111}
112
113pub const ACCORDION_HEADER_HEIGHT: f32 = 28.0;
119pub const ACCORDION_HEADER_PADDING_HORIZONTAL: f32 = 8.0;
121pub const ACCORDION_INDICATOR_SIZE: f32 = 12.0;
123pub const ACCORDION_INDICATOR_GAP: f32 = 6.0;
125pub const ACCORDION_CORNER_RADIUS: f32 = 4.0;
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
134pub enum AccordionOrientation {
135 #[default]
137 Vertical,
138 Horizontal,
140}
141
142pub struct Accordion {
149 title: LocalizedString,
153 expanded: Signal<bool>,
154 content_id: Option<WidgetId>,
155 pending_content: Option<Box<dyn Widget>>,
156 root_child_id: Option<WidgetId>,
157 region_id: Option<WidgetId>,
159 title_color: Option<ColorProp>,
166 title_style: Option<TextStyleProp>,
171 orientation: AccordionOrientation,
173 fill: bool,
178 on_header_drag: Option<std::rc::Rc<dyn Fn(&mut EventContext)>>,
182 trailing: Option<Box<dyn Widget>>,
188 trailing_id: Option<WidgetId>,
192 fill_header_id: Option<WidgetId>,
196 fill_body_id: Option<WidgetId>,
197}
198
199impl Accordion {
200 pub fn new(title: impl Into<LocalizedString>, expanded: Signal<bool>) -> Self {
205 Self {
206 title: title.into(),
207 expanded,
208 content_id: None,
209 pending_content: None,
210 root_child_id: None,
211 region_id: None,
212 title_color: None,
213 title_style: None,
214 orientation: AccordionOrientation::Vertical,
215 fill: false,
216 on_header_drag: None,
217 trailing: None,
218 trailing_id: None,
219 fill_header_id: None,
220 fill_body_id: None,
221 }
222 }
223
224 pub fn orientation(mut self, orientation: AccordionOrientation) -> Self {
226 self.orientation = orientation;
227 self
228 }
229
230 pub fn horizontal(mut self) -> Self {
232 self.orientation = AccordionOrientation::Horizontal;
233 self
234 }
235
236 pub fn fill(mut self, fill: bool) -> Self {
243 self.fill = fill;
244 self
245 }
246
247 pub fn on_header_drag(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
251 self.on_header_drag = Some(std::rc::Rc::new(f));
252 self
253 }
254
255 pub fn trailing(mut self, widget: impl Widget + 'static) -> Self {
262 self.trailing = Some(Box::new(widget));
263 self
264 }
265
266 pub fn trailing_id(mut self, id: WidgetId) -> Self {
270 self.trailing_id = Some(id);
271 self
272 }
273
274 pub fn title_color(mut self, color: impl Into<ColorProp>) -> Self {
278 self.title_color = Some(color.into());
279 self
280 }
281
282 pub fn title_style(mut self, style: impl Into<TextStyleProp>) -> Self {
288 self.title_style = Some(style.into());
289 self
290 }
291
292 pub fn content_id(mut self, id: WidgetId) -> Self {
294 self.content_id = Some(id);
295 self
296 }
297
298 pub fn content(mut self, widget: impl Widget + 'static) -> Self {
300 self.pending_content = Some(Box::new(widget));
301 self
302 }
303}
304
305impl std::fmt::Debug for Accordion {
306 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307 f.debug_struct("Accordion")
308 .field("title", &self.title)
309 .finish()
310 }
311}
312
313impl Widget for Accordion {
314 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
315 if let Some(pending) = self.pending_content.take() {
317 self.content_id = Some(ctx.add_boxed(pending));
318 }
319
320 let theme = ctx.theme();
321 let accordion_corner_radius = ACCORDION_CORNER_RADIUS;
322 let focus_ring_width = theme.shape.focus_ring_width;
323 let expanded = self.expanded.clone();
324
325 let header_focused = ctx.signal(false);
333 let kb_focused = header_focused.and(&ctx.focus_visible());
334
335 self.expanded.bind_to(
342 ctx.self_id(),
343 ctx.binding_registry(),
344 BindingLevel::AccessibilityOnly,
345 );
346
347 let header_fg: ColorProp = self
351 .title_color
352 .clone()
353 .unwrap_or_else(|| TextRole::Primary.into());
354 let title_style: TextStyleProp = self
355 .title_style
356 .clone()
357 .unwrap_or_else(|| TextStyleRole::Body.into());
358
359 let horizontal = self.orientation == AccordionOrientation::Horizontal;
360
361 let trailing_id = self
371 .trailing_id
372 .or_else(|| self.trailing.take().map(|w| ctx.add_boxed(w)))
373 .map(|tid| ctx.add(crate::primitives::DeadZone::new().child_id(tid)));
374
375 let header = if horizontal {
379 let chevron_left_id = ctx.add(IconWidget::chevron_left(16.0).color(header_fg.clone()));
383 let chevron_right_id =
384 ctx.add(IconWidget::chevron_right(16.0).color(header_fg.clone()));
385 ctx.visible_when(chevron_left_id, expanded.clone());
386 ctx.visible_when(chevron_right_id, expanded.map(|v| !*v));
387 let title_id = ctx.add(
388 RotatedLabel::new(self.title.clone(), header_fg.clone()).style(title_style.clone()),
389 );
390 let spacer_id = ctx.add(Spacer::new());
391 let mut col = VStack::new()
392 .spacing(8.0)
393 .add_child(chevron_left_id)
394 .add_child(chevron_right_id)
395 .add_child(title_id);
396 if let Some(t) = trailing_id {
397 col = col.add_child(t);
398 }
399 ctx.add(col.add_child(spacer_id))
400 } else {
401 let chevron_down_id = ctx.add(IconWidget::chevron_down(16.0).color(header_fg.clone()));
402 let chevron_right_id =
403 ctx.add(IconWidget::chevron_right(16.0).color(header_fg.clone()));
404 ctx.visible_when(chevron_down_id, expanded.clone());
405 ctx.visible_when(chevron_right_id, expanded.map(|v| !*v));
406
407 let title_widget = TextWidget::new(self.title.clone())
411 .color(header_fg)
412 .style(title_style.clone())
413 .single_line()
414 .no_shrink()
415 .a11y_hidden();
416 let title_id = ctx.add(title_widget);
417 let spacer_id = ctx.add(Spacer::new());
418
419 let mut row = HStack::new()
420 .spacing(8.0)
421 .add_child(title_id)
422 .add_child(spacer_id);
423 if let Some(t) = trailing_id {
424 row = row.add_child(t);
425 }
426 ctx.add(row.add_child(chevron_down_id).add_child(chevron_right_id))
427 };
428
429 let focus_border_role = kb_focused.map(|f| {
435 if *f {
436 BorderRole::Focused
437 } else {
438 BorderRole::Transparent
439 }
440 });
441 let focus_border_width = kb_focused.map(move |f| if *f { focus_ring_width } else { 0.0 });
442 let focus_rect_id = ctx.add(
443 crate::primitives::RectWidget::new()
444 .border_color(focus_border_role)
445 .border_width(focus_border_width)
446 .corner_radius(teksilo_tokens::CornerRadius::uniform(
447 accordion_corner_radius,
448 )),
449 );
450 let header_with_ring = ctx.add(
451 crate::primitives::ZStack::new()
452 .add_child(focus_rect_id)
453 .add_child(header),
454 );
455
456 if self.fill {
457 let header = if horizontal {
464 ctx.add(MinSize::new(ACCORDION_FILL_HEADER_EXTENT, 0.0).child_id(header_with_ring))
465 } else {
466 ctx.add(MinSize::new(0.0, ACCORDION_FILL_HEADER_EXTENT).child_id(header_with_ring))
467 };
468 self.fill_header_id = Some(header);
469 if let Some(content_id) = self.content_id {
470 let region_id = ctx.add(AccordionRegion::new(self.title.clone(), content_id));
471 self.region_id = Some(region_id);
472 let body = ctx.add(FillBody::new(region_id));
473 self.fill_body_id = Some(body);
474 }
475 } else {
476 let content_wrapper = self.content_id.map(|content_id| {
480 let region_id = ctx.add(AccordionRegion::new(self.title.clone(), content_id));
481 self.region_id = Some(region_id);
482 if horizontal {
483 ctx.visible_when(region_id, self.expanded.clone());
484 region_id
485 } else {
486 ctx.add(Collapse::new(self.expanded.clone()).child_id(region_id))
487 }
488 });
489 let root = if horizontal {
490 let mut hstack = HStack::new().spacing(2.0).add_child(header_with_ring);
491 if let Some(w) = content_wrapper {
492 hstack = hstack.add_child(w);
493 }
494 ctx.add(hstack)
495 } else {
496 let mut vstack = VStack::new().spacing(2.0).add_child(header_with_ring);
497 if let Some(w) = content_wrapper {
498 vstack = vstack.add_child(w);
499 }
500 ctx.add(vstack)
501 };
502 self.root_child_id = Some(root);
503 }
504
505 let expanded_tap = self.expanded.clone();
509 let expanded_key = self.expanded.clone();
510 let expanded_access = self.expanded.clone();
511 let header_focused_focus = header_focused.clone();
512
513 let mut handler_set = HandlerSet::new()
514 .on_tap({
515 move |_pos, _ctx: &mut EventContext| {
516 expanded_tap.set(!expanded_tap.get());
517 }
518 })
519 .on_access_action({
520 move |action: teksilo_core::accesskit::Action,
525 _ctx: &mut EventContext|
526 -> EventResponse {
527 if action == teksilo_core::accesskit::Action::Click {
528 expanded_access.set(!expanded_access.get());
529 EventResponse::Handled
530 } else {
531 EventResponse::Ignored
532 }
533 }
534 })
535 .on_key({
536 move |event: &WidgetEvent, _ctx: &mut EventContext| -> EventResponse {
537 match event {
538 WidgetEvent::KeyDown {
539 key: Key::Space | Key::Enter,
540 ..
541 } => EventResponse::Handled,
542 WidgetEvent::KeyUp {
543 key: Key::Space | Key::Enter,
544 ..
545 } => {
546 expanded_key.set(!expanded_key.get());
547 EventResponse::Handled
548 }
549 _ => EventResponse::Ignored,
550 }
551 }
552 })
553 .on_focus({
554 move |gained: bool, _ctx: &mut EventContext| {
555 header_focused_focus.set(gained);
558 }
559 })
560 .focusable(true)
561 .cursor(CursorIcon::Pointer);
562
563 if let Some(drag) = self.on_header_drag.clone() {
568 handler_set = handler_set.on_drag(move |phase, ctx| {
569 if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
570 (drag)(ctx);
571 }
572 });
573 }
574
575 ctx.apply_self_handlers(handler_set);
576
577 self.child_ids()
578 }
579
580 fn layout_response(
581 &self,
582 proposal: SizeProposal,
583 ctx: &LayoutContext,
584 ) -> teksilo_core::widget::LayoutResponse {
585 if self.fill {
586 return proposal
588 .resolve(
589 proposal.width.unwrap_or(0.0),
590 proposal.height.unwrap_or(0.0),
591 )
592 .into();
593 }
594 if let Some(root) = self.root_child_id
595 && let Some(size) = ctx.child_size(root, proposal)
596 {
597 return (size).into();
598 }
599 proposal.resolve(0.0, 0.0).into()
600 }
601
602 fn place_children(
603 &self,
604 bounds: Rect,
605 _proposal: SizeProposal,
606 children: &mut [WidgetPlacement],
607 ctx: &LayoutContext,
608 ) {
609 if self.fill {
610 self.place_fill(bounds, children, ctx);
611 return;
612 }
613 for child in children.iter_mut() {
614 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
615 child.size = Size::new(bounds.width, bounds.height);
616 }
617 }
618
619 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
620 builder.set_role(teksilo_core::accesskit::Role::Button);
621 builder.set_name(self.title.resolve_now());
623 builder.set_expanded(self.expanded.get());
624 builder.add_action(teksilo_core::accesskit::Action::Click);
625 builder.add_action(teksilo_core::accesskit::Action::Focus);
626 if let Some(region_id) = self.region_id {
627 builder.push_controlled(teksilo_core::accessibility::widget_id_to_node_id(region_id));
628 }
629 }
630
631 fn children(&self) -> Vec<WidgetId> {
632 self.child_ids()
633 }
634
635 fn clips_children(&self) -> bool {
636 self.fill
638 }
639}
640
641impl Accordion {
642 fn child_ids(&self) -> Vec<WidgetId> {
645 if self.fill {
646 let mut ids = Vec::with_capacity(2);
647 ids.extend(self.fill_header_id);
648 ids.extend(self.fill_body_id);
649 ids
650 } else {
651 self.root_child_id.into_iter().collect()
652 }
653 }
654
655 fn place_fill(&self, bounds: Rect, children: &mut [WidgetPlacement], ctx: &LayoutContext) {
661 const GAP: f32 = 2.0;
662 let Some(header_id) = self.fill_header_id else {
663 return;
664 };
665 let horizontal = self.orientation == AccordionOrientation::Horizontal;
666
667 let header_size = ctx
670 .child_size(
671 header_id,
672 if horizontal {
673 SizeProposal {
674 width: None,
675 height: Some(bounds.height),
676 }
677 } else {
678 SizeProposal {
679 width: Some(bounds.width),
680 height: None,
681 }
682 },
683 )
684 .unwrap_or(Size::ZERO);
685
686 let header_rect = if horizontal {
688 Rect::new(bounds.x, bounds.y, header_size.width, bounds.height)
689 } else {
690 Rect::new(bounds.x, bounds.y, bounds.width, header_size.height)
691 };
692 if let Some(c) = children.first_mut() {
693 c.origin = header_rect.origin();
694 c.size = header_rect.size();
695 }
696
697 let Some(body_id) = self.fill_body_id else {
698 return;
699 };
700 let (body_origin, body_proposal) = if horizontal {
703 let leftover = (bounds.width - header_size.width - GAP).max(0.0);
704 (
705 teksilo_canvas::Point::new(header_rect.right() + GAP, bounds.y),
706 SizeProposal {
707 width: Some(leftover),
708 height: Some(bounds.height),
709 },
710 )
711 } else {
712 let leftover = (bounds.height - header_size.height - GAP).max(0.0);
713 (
714 teksilo_canvas::Point::new(bounds.x, header_rect.bottom() + GAP),
715 SizeProposal {
716 width: Some(bounds.width),
717 height: Some(leftover),
718 },
719 )
720 };
721 let body_size = ctx.child_size(body_id, body_proposal).unwrap_or(Size::ZERO);
722 if let Some(c) = children.get_mut(1) {
723 c.origin = body_origin;
724 c.size = body_size;
725 }
726 }
727}
728
729struct FillBody {
738 content_id: WidgetId,
739}
740
741impl FillBody {
742 fn new(content_id: WidgetId) -> Self {
743 Self { content_id }
744 }
745}
746
747impl std::fmt::Debug for FillBody {
748 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
749 f.debug_struct("FillBody").finish()
750 }
751}
752
753impl Widget for FillBody {
754 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
755 ctx.apply_self_handlers(
758 HandlerSet::new()
759 .on_tap(|_e, _ctx| {})
760 .on_drag(|_phase, _ctx| {}),
761 );
762 vec![self.content_id]
763 }
764
765 fn layout_response(
766 &self,
767 proposal: SizeProposal,
768 _ctx: &LayoutContext,
769 ) -> teksilo_core::widget::LayoutResponse {
770 proposal
772 .resolve(
773 proposal.width.unwrap_or(0.0),
774 proposal.height.unwrap_or(0.0),
775 )
776 .into()
777 }
778
779 fn place_children(
780 &self,
781 bounds: Rect,
782 _proposal: SizeProposal,
783 children: &mut [WidgetPlacement],
784 _ctx: &LayoutContext,
785 ) {
786 for child in children.iter_mut() {
787 child.origin = bounds.origin();
788 child.size = bounds.size();
789 }
790 }
791
792 fn clips_children(&self) -> bool {
793 true
794 }
795
796 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {}
797
798 fn children(&self) -> Vec<WidgetId> {
799 vec![self.content_id]
800 }
801}
802
803#[cfg(test)]
804mod tests {
805 use super::*;
806 use teksilo_core::WidgetBuilder;
807 use teksilo_core::widget_tree::WidgetTree;
808 use teksilo_i18n::lit;
809
810 #[test]
811 fn rich_tooltip_more_label_is_a_translatable_framework_string() {
812 use teksilo_i18n::{I18nConfig, I18nManager};
817 let cfg = I18nConfig::new()
818 .supported_locales(["en-US".parse().unwrap(), "fr-FR".parse().unwrap()])
819 .auto_detect_os_locale(false)
820 .framework_locales(crate::framework_locales());
821 let mgr = I18nManager::from_config(&cfg);
822 assert_eq!(mgr.resolve_widget("tooltip-more", &[]), "More");
823 mgr.set_locale("fr-FR".parse().unwrap());
824 assert_eq!(
825 mgr.resolve_widget("tooltip-more", &[]),
826 "Plus",
827 "tooltip-more must translate to French via the framework bundle"
828 );
829 }
830
831 #[test]
832 fn accordion_builds_collapsed() {
833 let expanded = Signal::new(false);
834 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
835 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()));
836 tree.layout(SizeProposal::exact(300.0, 200.0));
837 let b = tree.bounds(acc);
838 assert!(b.width > 0.0);
839 }
840
841 #[test]
842 fn click_toggles_expanded_state() {
843 let expanded = Signal::new(false);
844 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
845 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()));
846 tree.layout(SizeProposal::exact(300.0, 200.0));
847
848 tree.click(acc);
849 assert!(expanded.get());
850 tree.click(acc);
851 assert!(!expanded.get());
852 }
853
854 #[test]
855 fn accordion_with_content() {
856 use crate::primitives::TextWidget;
857 let expanded = Signal::new(true);
858 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
859 let content = tree.add(TextWidget::new(lit!("Content text")));
860 let acc = tree.add(Accordion::new(lit!("Details"), expanded.clone()).content_id(content));
861 tree.layout(SizeProposal::exact(300.0, 200.0));
862 let b = tree.bounds(acc);
863 assert!(b.height > 0.0);
864 }
865
866 #[test]
867 fn accessibility() {
868 let expanded = Signal::new(true);
869 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
870 let acc = tree.add(Accordion::new(lit!("Details"), expanded));
871 tree.layout(SizeProposal::exact(300.0, 200.0));
872 let info = tree.accessibility_node(acc);
873 assert_eq!(info.name(), Some("Details"));
874 assert!(info.is_expanded());
875 }
876
877 #[test]
878 fn access_action_click_toggles_expanded() {
879 let expanded = Signal::new(false);
883 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
884 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()));
885 tree.layout(SizeProposal::exact(300.0, 200.0));
886
887 tree.dispatch_event(WidgetEvent::AccessAction {
888 action: teksilo_core::accesskit::Action::Click,
889 target: Some(acc),
890 target_node: teksilo_core::accessibility::root_node_id(),
891 data: None,
892 });
893 assert!(expanded.get(), "AT click expands the accordion");
894
895 tree.dispatch_event(WidgetEvent::AccessAction {
896 action: teksilo_core::accesskit::Action::Click,
897 target: Some(acc),
898 target_node: teksilo_core::accessibility::root_node_id(),
899 data: None,
900 });
901 assert!(!expanded.get(), "a second AT click collapses it");
902 }
903
904 #[test]
905 fn announced_expanded_state_refreshes_on_external_toggle() {
906 let expanded = Signal::new(false);
910 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
911 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()));
912 tree.layout(SizeProposal::exact(300.0, 200.0));
913 let _ = tree.sync_accessibility();
915 assert!(!tree.accessibility_node(acc).is_expanded());
916
917 expanded.set(true);
918 let _ = tree.sync_accessibility();
919 assert!(
920 tree.accessibility_node(acc).is_expanded(),
921 "announced expanded state must follow an external set"
922 );
923 }
924
925 #[test]
926 fn external_signal_set_triggers_animation() {
927 use crate::primitives::TextWidget;
931 use std::time::Duration;
932
933 let expanded = Signal::new(false);
934 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
935 let content = tree.add(TextWidget::new(lit!("Some content")));
936 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()).content_id(content));
937 tree.layout(SizeProposal {
938 width: Some(300.0),
939 height: None,
940 });
941 let collapsed = tree.bounds(acc).height;
942
943 expanded.set(true);
944 tree.tick_animations(Duration::from_millis(250));
945 tree.layout(SizeProposal {
946 width: Some(300.0),
947 height: None,
948 });
949 let after = tree.bounds(acc).height;
950
951 assert!(
952 after > collapsed,
953 "external set must drive expansion: {} > {}",
954 after,
955 collapsed
956 );
957 }
958
959 #[test]
960 fn double_toggle_round_trips_height() {
961 use crate::primitives::TextWidget;
962 use std::time::Duration;
963
964 let expanded = Signal::new(false);
965 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
966 let content = tree.add(TextWidget::new(lit!("Some content")));
967 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()).content_id(content));
968 tree.layout(SizeProposal {
969 width: Some(300.0),
970 height: None,
971 });
972 let collapsed_initial = tree.bounds(acc).height;
973
974 tree.click(acc);
976 tree.tick_animations(Duration::from_millis(250));
977 tree.layout(SizeProposal {
978 width: Some(300.0),
979 height: None,
980 });
981 let expanded_h = tree.bounds(acc).height;
982
983 tree.click(acc);
984 tree.tick_animations(Duration::from_millis(250));
985 tree.layout(SizeProposal {
986 width: Some(300.0),
987 height: None,
988 });
989 let collapsed_again = tree.bounds(acc).height;
990
991 assert!(expanded_h > collapsed_initial);
992 assert!(
993 (collapsed_again - collapsed_initial).abs() < 1.0,
994 "after collapse round-trip, height should match initial: {} vs {}",
995 collapsed_again,
996 collapsed_initial
997 );
998 }
999
1000 #[test]
1001 fn content_dormant_when_collapsed() {
1002 use crate::primitives::TextWidget;
1003 use std::time::Duration;
1004
1005 let expanded = Signal::new(false);
1006 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1007 let content = tree.add(TextWidget::new(lit!("Some content text here")));
1008 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()).content_id(content));
1009 tree.layout(SizeProposal {
1010 width: Some(300.0),
1011 height: None,
1012 });
1013 let collapsed_height = tree.bounds(acc).height;
1014
1015 tree.click(acc);
1017 assert!(expanded.get(), "should be expanded after click");
1018
1019 tree.tick_animations(Duration::from_millis(250));
1021 tree.layout(SizeProposal {
1022 width: Some(300.0),
1023 height: None,
1024 });
1025 let expanded_height = tree.bounds(acc).height;
1026
1027 assert!(
1028 expanded_height > collapsed_height,
1029 "expanded height ({}) should be greater than collapsed height ({})",
1030 expanded_height,
1031 collapsed_height
1032 );
1033 }
1034
1035 #[test]
1038 fn fill_accordion_header_toggles_but_content_tap_does_not() {
1039 use crate::primitives::TextWidget;
1040 use teksilo_core::event::{Modifiers, PointerButton};
1041
1042 fn tap_at(tree: &mut WidgetTree, p: teksilo_canvas::Point) {
1043 tree.dispatch_event(WidgetEvent::PointerDown {
1044 position: p,
1045 button: PointerButton::Primary,
1046 modifiers: Modifiers::NONE,
1047 });
1048 tree.dispatch_event(WidgetEvent::PointerUp {
1049 position: p,
1050 button: PointerButton::Primary,
1051 modifiers: Modifiers::NONE,
1052 });
1053 }
1054
1055 let expanded = Signal::new(true);
1056 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1057 let content = tree.add(TextWidget::new(lit!("dock body")));
1058 let acc = tree.add(
1059 Accordion::new(lit!("Panel"), expanded.clone())
1060 .fill(true)
1061 .content_id(content),
1062 );
1063 tree.layout(SizeProposal::exact(220.0, 300.0));
1064
1065 let b = tree.bounds(acc);
1067 tap_at(&mut tree, teksilo_canvas::Point::new(b.x + 20.0, b.y + 6.0));
1068 assert!(!expanded.get(), "header tap collapses");
1069 tap_at(&mut tree, teksilo_canvas::Point::new(b.x + 20.0, b.y + 6.0));
1070 assert!(expanded.get(), "header tap re-expands");
1071
1072 tap_at(
1074 &mut tree,
1075 teksilo_canvas::Point::new(b.x + 110.0, b.y + 200.0),
1076 );
1077 assert!(expanded.get(), "content tap does not collapse the panel");
1078 }
1079
1080 #[test]
1081 fn fill_accordion_header_drag_fires_hook() {
1082 use crate::primitives::TextWidget;
1083 use std::cell::Cell as StdCell;
1084 use std::rc::Rc;
1085 let dragged = Rc::new(StdCell::new(false));
1086 let sink = dragged.clone();
1087 let expanded = Signal::new(true);
1088 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1089 let content = tree.add(TextWidget::new(lit!("dock body")));
1090 let acc = tree.add(
1091 Accordion::new(lit!("Panel"), expanded)
1092 .fill(true)
1093 .on_header_drag(move |_ctx| sink.set(true))
1094 .content_id(content),
1095 );
1096 tree.layout(SizeProposal::exact(220.0, 300.0));
1097 let b = tree.bounds(acc);
1098 let from = teksilo_canvas::Point::new(b.x + 20.0, b.y + 6.0);
1099 tree.drag(
1100 from,
1101 teksilo_canvas::Point::new(from.x + 130.0, from.y + 30.0),
1102 );
1103 assert!(dragged.get(), "dragging the header fires on_header_drag");
1104 }
1105
1106 #[test]
1107 fn fill_accordion_body_fills_the_leftover() {
1108 use crate::primitives::TextWidget;
1109 let expanded = Signal::new(true);
1110 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1111 let content = tree.add(TextWidget::new(lit!("dock body")));
1112 let acc = tree.add(
1113 Accordion::new(lit!("Panel"), expanded)
1114 .fill(true)
1115 .content_id(content),
1116 );
1117 tree.layout(SizeProposal::exact(220.0, 300.0));
1118 let header_h = tree.bounds(tree.children(acc)[0]).height;
1122 let body_h = tree.bounds(tree.children(acc)[1]).height;
1123 assert!(
1124 (header_h + body_h - 300.0).abs() < 6.0,
1125 "header({header_h}) + body({body_h}) should fill the 300px pane"
1126 );
1127 assert!(body_h > 200.0, "body fills most of the pane, got {body_h}");
1128 }
1129
1130 #[test]
1131 fn fill_accordion_body_stays_within_the_pane() {
1132 use crate::primitives::{FixedSize, TextWidget};
1133 let expanded = Signal::new(true);
1134 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1135 let content = tree.add(
1137 FixedSize::new()
1138 .width(80.0_f32)
1139 .height(900.0_f32)
1140 .child(TextWidget::new(lit!("x"))),
1141 );
1142 let acc = tree.add(
1143 Accordion::new(lit!("Panel"), expanded)
1144 .fill(true)
1145 .content_id(content),
1146 );
1147 tree.layout(SizeProposal::exact(220.0, 300.0));
1148 let body = tree.children(acc)[1];
1151 assert!(
1152 tree.bounds(body).bottom() <= 300.5,
1153 "body bottom {} must stay within the 300px pane",
1154 tree.bounds(body).bottom()
1155 );
1156 }
1157
1158 #[test]
1159 fn trailing_slot_renders_and_captures_its_own_tap() {
1160 use crate::primitives::{FixedSize, RectWidget};
1165 use std::cell::Cell as StdCell;
1166 use std::rc::Rc;
1167 use teksilo_core::event::{Modifiers, PointerButton};
1168
1169 let expanded = Signal::new(true);
1170 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1171 let tapped = Rc::new(StdCell::new(false));
1172 let sink = tapped.clone();
1173 let trailing = tree.add(
1174 FixedSize::new()
1175 .width(24.0_f32)
1176 .height(24.0_f32)
1177 .child(RectWidget::new())
1178 .on_tap(move |_e, _ctx| sink.set(true)),
1179 );
1180 let content = tree.add(TextWidget::new(lit!("body")));
1181 let acc = tree.add(
1182 Accordion::new(lit!("Panel"), expanded.clone())
1183 .fill(true)
1184 .trailing_id(trailing)
1185 .content_id(content),
1186 );
1187 tree.layout(SizeProposal::exact(260.0, 140.0));
1188
1189 let tb = tree.bounds(trailing);
1191 assert!(tb.width > 0.0 && tb.height > 0.0, "trailing slot is placed");
1192 let _ = acc;
1193
1194 let p = teksilo_canvas::Point::new(tb.x + tb.width / 2.0, tb.y + tb.height / 2.0);
1196 tree.dispatch_event(WidgetEvent::PointerDown {
1197 position: p,
1198 button: PointerButton::Primary,
1199 modifiers: Modifiers::NONE,
1200 });
1201 tree.dispatch_event(WidgetEvent::PointerUp {
1202 position: p,
1203 button: PointerButton::Primary,
1204 modifiers: Modifiers::NONE,
1205 });
1206 assert!(tapped.get(), "trailing widget received the tap");
1207 assert!(
1208 expanded.get(),
1209 "tapping the trailing widget must not toggle the accordion"
1210 );
1211 }
1212
1213 #[test]
1214 fn dragging_the_trailing_slot_does_not_start_the_header_drag() {
1215 use crate::primitives::{FixedSize, RectWidget};
1221 use std::cell::Cell as StdCell;
1222 use std::rc::Rc;
1223
1224 let expanded = Signal::new(true);
1225 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1226 let header_dragged = Rc::new(StdCell::new(false));
1227 let hd = header_dragged.clone();
1228 let trailing = tree.add(
1229 FixedSize::new()
1230 .width(24.0_f32)
1231 .height(24.0_f32)
1232 .child(RectWidget::new()),
1233 );
1234 let content = tree.add(TextWidget::new(lit!("body")));
1235 let acc = tree.add(
1236 Accordion::new(lit!("Panel"), expanded.clone())
1237 .fill(true)
1238 .trailing_id(trailing)
1239 .on_header_drag(move |_ctx| hd.set(true))
1240 .content_id(content),
1241 );
1242 tree.layout(SizeProposal::exact(260.0, 140.0));
1243
1244 let tb = tree.bounds(trailing);
1246 let from = teksilo_canvas::Point::new(tb.x + tb.width / 2.0, tb.y + tb.height / 2.0);
1247 tree.drag(
1248 from,
1249 teksilo_canvas::Point::new(from.x + 90.0, from.y + 12.0),
1250 );
1251 assert!(
1252 !header_dragged.get(),
1253 "dragging the trailing control must not start the header drag"
1254 );
1255
1256 let ab = tree.bounds(acc);
1258 tree.drag(
1259 teksilo_canvas::Point::new(ab.x + 10.0, ab.y + 6.0),
1260 teksilo_canvas::Point::new(ab.x + 120.0, ab.y + 30.0),
1261 );
1262 assert!(
1263 header_dragged.get(),
1264 "dragging the header title still starts the drag"
1265 );
1266 }
1267
1268 #[test]
1269 fn incremental_move_on_trailing_button_does_not_drag_header() {
1270 use crate::primitives::{FixedSize, RectWidget};
1275 use std::cell::Cell as StdCell;
1276 use std::rc::Rc;
1277 use teksilo_core::event::PointerButton;
1278
1279 let expanded = Signal::new(true);
1280 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1281 let header_dragged = Rc::new(StdCell::new(false));
1282 let hd = header_dragged.clone();
1283 let trailing = tree.add(
1285 FixedSize::new()
1286 .width(24.0_f32)
1287 .height(24.0_f32)
1288 .child(RectWidget::new())
1289 .on_tap(|_e, _ctx| {}),
1290 );
1291 let content = tree.add(TextWidget::new(lit!("body")));
1292 let _acc = tree.add(
1293 Accordion::new(lit!("Panel"), expanded.clone())
1294 .fill(true)
1295 .trailing_id(trailing)
1296 .on_header_drag(move |_ctx| hd.set(true))
1297 .content_id(content),
1298 );
1299 tree.layout(SizeProposal::exact(260.0, 140.0));
1300
1301 let tb = tree.bounds(trailing);
1302 let (cx, cy) = (tb.x + tb.width / 2.0, tb.y + tb.height / 2.0);
1303 tree.pointer_down_button(teksilo_canvas::Point::new(cx, cy), PointerButton::Primary);
1304 for i in 1..=10 {
1307 tree.pointer_move(teksilo_canvas::Point::new(cx + (i as f32) * 3.0, cy + 1.0));
1308 }
1309 tree.pointer_up_button(
1310 teksilo_canvas::Point::new(cx + 30.0, cy + 1.0),
1311 PointerButton::Primary,
1312 );
1313 assert!(
1314 !header_dragged.get(),
1315 "a jittery click on the trailing control must not start the header drag"
1316 );
1317 }
1318
1319 #[test]
1320 fn horizontal_fill_accordion_builds() {
1321 use crate::primitives::TextWidget;
1322 let expanded = Signal::new(true);
1323 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1324 let content = tree.add(TextWidget::new(lit!("c")));
1325 let acc = tree.add(
1326 Accordion::new(lit!("Panel"), expanded)
1327 .horizontal()
1328 .fill(true)
1329 .content_id(content),
1330 );
1331 tree.layout(SizeProposal::exact(320.0, 120.0));
1332 let b = tree.bounds(acc);
1333 assert!(
1334 b.width > 0.0 && b.height > 0.0,
1335 "horizontal accordion builds"
1336 );
1337 }
1338}