1use teksilo_canvas::{Rect, Size, SizeProposal};
46use teksilo_core::accessibility::AccessNodeBuilder;
47use teksilo_core::binding::BindingLevel;
48use teksilo_core::build_context::BuildContext;
49use teksilo_core::color_prop::{ColorProp, TextStyleProp};
50use teksilo_core::event::{EventResponse, Key, WidgetEvent};
51use teksilo_core::signal::Signal;
52use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
53use teksilo_core::widget_builder::HandlerSet;
54use teksilo_core::widget_id::WidgetId;
55use teksilo_tokens::{BorderRole, InputTokens, TargetRole, TextRole, TextStyleRole};
56
57use crate::animations::collapse::Collapse;
58use crate::primitives::{HStack, IconWidget, MinSize, Spacer, TextWidget, VStack};
59use crate::tool_box::RotatedLabel;
60use teksilo_core::styles::density::{dp, spacing};
61use teksilo_i18n::LocalizedString;
62
63pub(crate) const ACCORDION_FILL_HEADER_EXTENT: f32 = 30.0;
66
67pub(crate) const ACCORDION_FILL_GAP: f32 = 2.0;
69
70pub(crate) fn accordion_fill_gap(tokens: &InputTokens) -> f32 {
73 spacing(ACCORDION_FILL_GAP, tokens)
74}
75
76pub(crate) fn accordion_fill_header_extent(tokens: &InputTokens) -> f32 {
79 dp(ACCORDION_FILL_HEADER_EXTENT, TargetRole::Target, tokens)
80}
81pub(crate) const ACCORDION_FILL_COLLAPSED_EXTENT: f32 = ACCORDION_FILL_HEADER_EXTENT + 2.0;
84
85#[derive(Debug)]
90struct AccordionRegion {
91 name: LocalizedString,
95 child: Option<WidgetId>,
96}
97
98impl AccordionRegion {
99 fn new(name: LocalizedString, child: WidgetId) -> Self {
100 Self {
101 name,
102 child: Some(child),
103 }
104 }
105}
106
107impl Widget for AccordionRegion {
108 fn layout_response(
109 &self,
110 proposal: SizeProposal,
111 ctx: &LayoutContext,
112 ) -> teksilo_core::widget::LayoutResponse {
113 self.child
114 .and_then(|id| ctx.child_size(id, proposal))
115 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
116 .into()
117 }
118
119 fn place_children(
120 &self,
121 bounds: Rect,
122 _proposal: SizeProposal,
123 children: &mut [WidgetPlacement],
124 _ctx: &LayoutContext,
125 ) {
126 for child in children.iter_mut() {
127 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
128 child.size = bounds.size();
129 }
130 }
131
132 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
133 builder.set_role(teksilo_core::accesskit::Role::Region);
134 builder.set_name(self.name.resolve_now());
135 }
136
137 fn children(&self) -> Vec<WidgetId> {
138 self.child.into_iter().collect()
139 }
140}
141
142pub const ACCORDION_HEADER_HEIGHT: f32 = 28.0;
148
149pub fn accordion_header_height(tokens: &InputTokens) -> f32 {
152 dp(ACCORDION_HEADER_HEIGHT, TargetRole::Target, tokens)
153}
154pub const ACCORDION_HEADER_PADDING_HORIZONTAL: f32 = 8.0;
156
157pub fn accordion_header_padding_horizontal(tokens: &InputTokens) -> f32 {
160 spacing(ACCORDION_HEADER_PADDING_HORIZONTAL, tokens)
161}
162pub const ACCORDION_INDICATOR_SIZE: f32 = 12.0;
164pub const ACCORDION_INDICATOR_GAP: f32 = 6.0;
166
167pub fn accordion_indicator_gap(tokens: &InputTokens) -> f32 {
170 spacing(ACCORDION_INDICATOR_GAP, tokens)
171}
172pub const ACCORDION_CORNER_RADIUS: f32 = 4.0;
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
181pub enum AccordionOrientation {
182 #[default]
184 Vertical,
185 Horizontal,
187}
188
189pub struct Accordion {
196 title: LocalizedString,
200 expanded: Signal<bool>,
201 content_id: Option<WidgetId>,
202 pending_content: Option<Box<dyn Widget>>,
203 root_child_id: Option<WidgetId>,
204 region_id: Option<WidgetId>,
206 title_color: Option<ColorProp>,
213 title_style: Option<TextStyleProp>,
218 orientation: AccordionOrientation,
220 fill: bool,
225 on_header_drag: Option<std::rc::Rc<dyn Fn(&mut EventContext)>>,
229 trailing: Option<Box<dyn Widget>>,
235 trailing_id: Option<WidgetId>,
239 fill_header_id: Option<WidgetId>,
243 fill_body_id: Option<WidgetId>,
244}
245
246impl Accordion {
247 pub fn new(title: impl Into<LocalizedString>, expanded: Signal<bool>) -> Self {
252 Self {
253 title: title.into(),
254 expanded,
255 content_id: None,
256 pending_content: None,
257 root_child_id: None,
258 region_id: None,
259 title_color: None,
260 title_style: None,
261 orientation: AccordionOrientation::Vertical,
262 fill: false,
263 on_header_drag: None,
264 trailing: None,
265 trailing_id: None,
266 fill_header_id: None,
267 fill_body_id: None,
268 }
269 }
270
271 pub fn orientation(mut self, orientation: AccordionOrientation) -> Self {
273 self.orientation = orientation;
274 self
275 }
276
277 pub fn horizontal(mut self) -> Self {
279 self.orientation = AccordionOrientation::Horizontal;
280 self
281 }
282
283 pub fn fill(mut self, fill: bool) -> Self {
290 self.fill = fill;
291 self
292 }
293
294 pub fn on_header_drag(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
298 self.on_header_drag = Some(std::rc::Rc::new(f));
299 self
300 }
301
302 pub fn trailing(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
309 match teksilo_core::IntoTeksiChild::into_pending(widget) {
310 teksilo_core::PendingChild::Id(id) => {
311 self.trailing_id = Some(id);
312 self
313 }
314 teksilo_core::PendingChild::Deferred(w) => {
315 self.trailing = Some(w);
316 self
317 }
318 }
319 }
320
321 pub fn title_color(mut self, color: impl Into<ColorProp>) -> Self {
325 self.title_color = Some(color.into());
326 self
327 }
328
329 pub fn title_style(mut self, style: impl Into<TextStyleProp>) -> Self {
335 self.title_style = Some(style.into());
336 self
337 }
338
339 pub fn content(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
341 match teksilo_core::IntoTeksiChild::into_pending(widget) {
342 teksilo_core::PendingChild::Id(id) => {
343 self.content_id = Some(id);
344 self
345 }
346 teksilo_core::PendingChild::Deferred(w) => {
347 self.pending_content = Some(w);
348 self
349 }
350 }
351 }
352}
353
354impl std::fmt::Debug for Accordion {
355 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356 f.debug_struct("Accordion")
357 .field("title", &self.title)
358 .finish()
359 }
360}
361
362impl Widget for Accordion {
363 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
364 if let Some(pending) = self.pending_content.take() {
366 self.content_id = Some(ctx.add_boxed(pending));
367 }
368
369 let theme = ctx.theme();
370 let accordion_corner_radius = ACCORDION_CORNER_RADIUS;
371 let focus_ring_width = theme.shape.focus_ring_width;
372 let expanded = self.expanded.clone();
373
374 let header_focused = ctx.signal(false);
382 let kb_focused = header_focused.and(&ctx.focus_visible());
383
384 self.expanded.bind_to(
391 ctx.self_id(),
392 ctx.binding_registry(),
393 BindingLevel::AccessibilityOnly,
394 );
395
396 let header_fg: ColorProp = self
400 .title_color
401 .clone()
402 .unwrap_or_else(|| TextRole::Primary.into());
403 let title_style: TextStyleProp = self
404 .title_style
405 .clone()
406 .unwrap_or_else(|| TextStyleRole::Body.into());
407
408 let horizontal = self.orientation == AccordionOrientation::Horizontal;
409
410 let trailing_id = self
422 .trailing_id
423 .or_else(|| self.trailing.take().map(|w| ctx.add_boxed(w)))
424 .map(|tid| ctx.add(crate::primitives::DeadZone::new().child(tid)));
425
426 let header = if horizontal {
430 let chevron_left_id = ctx.add(IconWidget::chevron_left(16.0).color(header_fg.clone()));
434 let chevron_right_id =
435 ctx.add(IconWidget::chevron_right(16.0).color(header_fg.clone()));
436 ctx.visible_when(chevron_left_id, expanded.clone());
437 ctx.visible_when(chevron_right_id, expanded.map(|v| !*v));
438 let title_id = ctx.add(
439 RotatedLabel::new(self.title.clone(), header_fg.clone()).style(title_style.clone()),
440 );
441 let spacer_id = ctx.add(Spacer::new());
442 let mut col = VStack::new()
443 .spacing(8.0)
444 .child(chevron_left_id)
445 .child(chevron_right_id)
446 .child(title_id);
447 if let Some(t) = trailing_id {
448 col = col.child(t);
449 }
450 ctx.add(col.child(spacer_id))
451 } else {
452 let chevron_down_id = ctx.add(IconWidget::chevron_down(16.0).color(header_fg.clone()));
453 let chevron_right_id =
454 ctx.add(IconWidget::chevron_right(16.0).color(header_fg.clone()));
455 ctx.visible_when(chevron_down_id, expanded.clone());
456 ctx.visible_when(chevron_right_id, expanded.map(|v| !*v));
457
458 let title_widget = TextWidget::new(self.title.clone())
462 .color(header_fg)
463 .style(title_style.clone())
464 .single_line()
465 .no_shrink()
466 .a11y_hidden();
467 let title_id = ctx.add(title_widget);
468 let spacer_id = ctx.add(Spacer::new());
469
470 let mut row = HStack::new().spacing(8.0).child(title_id).child(spacer_id);
471 if let Some(t) = trailing_id {
472 row = row.child(t);
473 }
474 ctx.add(row.child(chevron_down_id).child(chevron_right_id))
475 };
476
477 let focus_border_role = kb_focused.map(|f| {
483 if *f {
484 BorderRole::Focused
485 } else {
486 BorderRole::Transparent
487 }
488 });
489 let focus_border_width = kb_focused.map(move |f| if *f { focus_ring_width } else { 0.0 });
490 let focus_rect_id = ctx.add(
491 crate::primitives::RectWidget::new()
492 .border_color(focus_border_role)
493 .border_width(focus_border_width)
494 .corner_radius(teksilo_tokens::CornerRadius::uniform(
495 accordion_corner_radius,
496 )),
497 );
498 let header_with_ring = ctx.add(
499 crate::primitives::ZStack::new()
500 .child(focus_rect_id)
501 .child(header),
502 );
503
504 if self.fill {
505 let extent = accordion_fill_header_extent(&ctx.theme().input);
512 let header = if horizontal {
513 ctx.add(MinSize::new(extent, 0.0).child(header_with_ring))
514 } else {
515 ctx.add(MinSize::new(0.0, extent).child(header_with_ring))
516 };
517 self.fill_header_id = Some(header);
518 if let Some(content_id) = self.content_id {
519 let region_id = ctx.add(AccordionRegion::new(self.title.clone(), content_id));
520 self.region_id = Some(region_id);
521 let body = ctx.add(FillBody::new(region_id));
522 self.fill_body_id = Some(body);
523 }
524 } else {
525 let content_wrapper = self.content_id.map(|content_id| {
529 let region_id = ctx.add(AccordionRegion::new(self.title.clone(), content_id));
530 self.region_id = Some(region_id);
531 if horizontal {
532 ctx.visible_when(region_id, self.expanded.clone());
533 region_id
534 } else {
535 ctx.add(Collapse::new(self.expanded.clone()).child(region_id))
536 }
537 });
538 let root = if horizontal {
539 let mut hstack = HStack::new().spacing(2.0).child(header_with_ring);
540 if let Some(w) = content_wrapper {
541 hstack = hstack.child(w);
542 }
543 ctx.add(hstack)
544 } else {
545 let mut vstack = VStack::new().spacing(2.0).child(header_with_ring);
546 if let Some(w) = content_wrapper {
547 vstack = vstack.child(w);
548 }
549 ctx.add(vstack)
550 };
551 self.root_child_id = Some(root);
552 }
553
554 let expanded_tap = self.expanded.clone();
558 let expanded_key = self.expanded.clone();
559 let expanded_access = self.expanded.clone();
560 let header_focused_focus = header_focused.clone();
561
562 let mut handler_set = HandlerSet::new()
563 .on_tap({
564 move |_pos, _ctx: &mut EventContext| {
565 expanded_tap.set(!expanded_tap.get());
566 }
567 })
568 .on_access_action({
569 move |action: teksilo_core::accesskit::Action,
574 _ctx: &mut EventContext|
575 -> EventResponse {
576 if action == teksilo_core::accesskit::Action::Click {
577 expanded_access.set(!expanded_access.get());
578 EventResponse::Handled
579 } else {
580 EventResponse::Ignored
581 }
582 }
583 })
584 .on_key({
585 move |event: &WidgetEvent, _ctx: &mut EventContext| -> EventResponse {
586 match event {
587 WidgetEvent::KeyDown {
588 key: Key::Space | Key::Enter,
589 ..
590 } => EventResponse::Handled,
591 WidgetEvent::KeyUp {
592 key: Key::Space | Key::Enter,
593 ..
594 } => {
595 expanded_key.set(!expanded_key.get());
596 EventResponse::Handled
597 }
598 _ => EventResponse::Ignored,
599 }
600 }
601 })
602 .on_focus({
603 move |gained: bool, _ctx: &mut EventContext| {
604 header_focused_focus.set(gained);
607 }
608 })
609 .focusable(true)
610 .cursor(CursorIcon::Pointer);
611
612 if let Some(drag) = self.on_header_drag.clone() {
617 handler_set = handler_set.on_drag(move |phase, ctx| {
618 if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
619 (drag)(ctx);
620 }
621 });
622 }
623
624 ctx.apply_self_handlers(handler_set);
625
626 self.child_ids()
627 }
628
629 fn layout_response(
630 &self,
631 proposal: SizeProposal,
632 ctx: &LayoutContext,
633 ) -> teksilo_core::widget::LayoutResponse {
634 if self.fill {
635 return proposal
637 .resolve(
638 proposal.width.unwrap_or(0.0),
639 proposal.height.unwrap_or(0.0),
640 )
641 .into();
642 }
643 if let Some(root) = self.root_child_id
644 && let Some(size) = ctx.child_size(root, proposal)
645 {
646 return (size).into();
647 }
648 proposal.resolve(0.0, 0.0).into()
649 }
650
651 fn place_children(
652 &self,
653 bounds: Rect,
654 _proposal: SizeProposal,
655 children: &mut [WidgetPlacement],
656 ctx: &LayoutContext,
657 ) {
658 if self.fill {
659 self.place_fill(bounds, children, ctx);
660 return;
661 }
662 for child in children.iter_mut() {
663 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
664 child.size = Size::new(bounds.width, bounds.height);
665 }
666 }
667
668 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
669 builder.set_role(teksilo_core::accesskit::Role::Button);
670 builder.set_name(self.title.resolve_now());
672 builder.set_expanded(self.expanded.get());
673 builder.add_action(teksilo_core::accesskit::Action::Click);
674 builder.add_action(teksilo_core::accesskit::Action::Focus);
675 if let Some(region_id) = self.region_id {
676 builder.push_controlled(teksilo_core::accessibility::widget_id_to_node_id(region_id));
677 }
678 }
679
680 fn children(&self) -> Vec<WidgetId> {
681 self.child_ids()
682 }
683
684 fn clips_children(&self) -> bool {
685 self.fill
687 }
688}
689
690impl Accordion {
691 fn child_ids(&self) -> Vec<WidgetId> {
694 if self.fill {
695 let mut ids = Vec::with_capacity(2);
696 ids.extend(self.fill_header_id);
697 ids.extend(self.fill_body_id);
698 ids
699 } else {
700 self.root_child_id.into_iter().collect()
701 }
702 }
703
704 fn place_fill(&self, bounds: Rect, children: &mut [WidgetPlacement], ctx: &LayoutContext) {
710 let gap = accordion_fill_gap(&ctx.theme.input);
711 let Some(header_id) = self.fill_header_id else {
712 return;
713 };
714 let horizontal = self.orientation == AccordionOrientation::Horizontal;
715
716 let header_size = ctx
719 .child_size(
720 header_id,
721 if horizontal {
722 SizeProposal {
723 width: None,
724 height: Some(bounds.height),
725 }
726 } else {
727 SizeProposal {
728 width: Some(bounds.width),
729 height: None,
730 }
731 },
732 )
733 .unwrap_or(Size::ZERO);
734
735 let header_rect = if horizontal {
737 Rect::new(bounds.x, bounds.y, header_size.width, bounds.height)
738 } else {
739 Rect::new(bounds.x, bounds.y, bounds.width, header_size.height)
740 };
741 if let Some(c) = children.first_mut() {
742 c.origin = header_rect.origin();
743 c.size = header_rect.size();
744 }
745
746 let Some(body_id) = self.fill_body_id else {
747 return;
748 };
749 let (body_origin, body_proposal) = if horizontal {
752 let leftover = (bounds.width - header_size.width - gap).max(0.0);
753 (
754 teksilo_canvas::Point::new(header_rect.right() + gap, bounds.y),
755 SizeProposal {
756 width: Some(leftover),
757 height: Some(bounds.height),
758 },
759 )
760 } else {
761 let leftover = (bounds.height - header_size.height - gap).max(0.0);
762 (
763 teksilo_canvas::Point::new(bounds.x, header_rect.bottom() + gap),
764 SizeProposal {
765 width: Some(bounds.width),
766 height: Some(leftover),
767 },
768 )
769 };
770 let body_size = ctx.child_size(body_id, body_proposal).unwrap_or(Size::ZERO);
771 if let Some(c) = children.get_mut(1) {
772 c.origin = body_origin;
773 c.size = body_size;
774 }
775 }
776}
777
778struct FillBody {
787 content_id: WidgetId,
788}
789
790impl FillBody {
791 fn new(content_id: WidgetId) -> Self {
792 Self { content_id }
793 }
794}
795
796impl std::fmt::Debug for FillBody {
797 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
798 f.debug_struct("FillBody").finish()
799 }
800}
801
802impl Widget for FillBody {
803 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
804 ctx.apply_self_handlers(
807 HandlerSet::new()
808 .on_tap(|_e, _ctx| {})
809 .on_drag(|_phase, _ctx| {}),
810 );
811 vec![self.content_id]
812 }
813
814 fn layout_response(
815 &self,
816 proposal: SizeProposal,
817 _ctx: &LayoutContext,
818 ) -> teksilo_core::widget::LayoutResponse {
819 proposal
821 .resolve(
822 proposal.width.unwrap_or(0.0),
823 proposal.height.unwrap_or(0.0),
824 )
825 .into()
826 }
827
828 fn place_children(
829 &self,
830 bounds: Rect,
831 _proposal: SizeProposal,
832 children: &mut [WidgetPlacement],
833 _ctx: &LayoutContext,
834 ) {
835 for child in children.iter_mut() {
836 child.origin = bounds.origin();
837 child.size = bounds.size();
838 }
839 }
840
841 fn clips_children(&self) -> bool {
842 true
843 }
844
845 fn accessibility(&self, _builder: &mut AccessNodeBuilder) {}
846
847 fn children(&self) -> Vec<WidgetId> {
848 vec![self.content_id]
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855 use teksilo_core::WidgetBuilder;
856 use teksilo_core::widget_tree::WidgetTree;
857 use teksilo_i18n::lit;
858
859 #[test]
860 fn rich_tooltip_more_label_is_a_translatable_framework_string() {
861 use teksilo_i18n::{I18nConfig, I18nManager};
866 let cfg = I18nConfig::new()
867 .supported_locales(["en-US".parse().unwrap(), "fr-FR".parse().unwrap()])
868 .auto_detect_os_locale(false)
869 .framework_locales(crate::framework_locales());
870 let mgr = I18nManager::from_config(&cfg);
871 assert_eq!(mgr.resolve_widget("tooltip-more", &[]), "More");
872 mgr.set_locale("fr-FR".parse().unwrap());
873 assert_eq!(
874 mgr.resolve_widget("tooltip-more", &[]),
875 "Plus",
876 "tooltip-more must translate to French via the framework bundle"
877 );
878 }
879
880 #[test]
881 fn accordion_builds_collapsed() {
882 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 let b = tree.bounds(acc);
887 assert!(b.width > 0.0);
888 }
889
890 #[test]
891 fn click_toggles_expanded_state() {
892 let expanded = Signal::new(false);
893 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
894 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()));
895 tree.layout(SizeProposal::exact(300.0, 200.0));
896
897 tree.click(acc);
898 assert!(expanded.get());
899 tree.click(acc);
900 assert!(!expanded.get());
901 }
902
903 #[test]
904 fn accordion_with_content() {
905 use crate::primitives::TextWidget;
906 let expanded = Signal::new(true);
907 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
908 let content = tree.add(TextWidget::new(lit!("Content text")));
909 let acc = tree.add(Accordion::new(lit!("Details"), expanded.clone()).content(content));
910 tree.layout(SizeProposal::exact(300.0, 200.0));
911 let b = tree.bounds(acc);
912 assert!(b.height > 0.0);
913 }
914
915 #[test]
916 fn accessibility() {
917 let expanded = Signal::new(true);
918 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
919 let acc = tree.add(Accordion::new(lit!("Details"), expanded));
920 tree.layout(SizeProposal::exact(300.0, 200.0));
921 let info = tree.accessibility_node(acc);
922 assert_eq!(info.name(), Some("Details"));
923 assert!(info.is_expanded());
924 }
925
926 #[test]
927 fn access_action_click_toggles_expanded() {
928 let expanded = Signal::new(false);
932 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
933 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()));
934 tree.layout(SizeProposal::exact(300.0, 200.0));
935
936 tree.dispatch_event(WidgetEvent::AccessAction {
937 action: teksilo_core::accesskit::Action::Click,
938 target: Some(acc),
939 target_node: teksilo_core::accessibility::root_node_id(),
940 data: None,
941 });
942 assert!(expanded.get(), "AT click expands the accordion");
943
944 tree.dispatch_event(WidgetEvent::AccessAction {
945 action: teksilo_core::accesskit::Action::Click,
946 target: Some(acc),
947 target_node: teksilo_core::accessibility::root_node_id(),
948 data: None,
949 });
950 assert!(!expanded.get(), "a second AT click collapses it");
951 }
952
953 #[test]
954 fn announced_expanded_state_refreshes_on_external_toggle() {
955 let expanded = Signal::new(false);
959 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
960 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()));
961 tree.layout(SizeProposal::exact(300.0, 200.0));
962 let _ = tree.sync_accessibility();
964 assert!(!tree.accessibility_node(acc).is_expanded());
965
966 expanded.set(true);
967 let _ = tree.sync_accessibility();
968 assert!(
969 tree.accessibility_node(acc).is_expanded(),
970 "announced expanded state must follow an external set"
971 );
972 }
973
974 #[test]
975 fn external_signal_set_triggers_animation() {
976 use crate::primitives::TextWidget;
980 use std::time::Duration;
981
982 let expanded = Signal::new(false);
983 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
984 let content = tree.add(TextWidget::new(lit!("Some content")));
985 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()).content(content));
986 tree.layout(SizeProposal {
987 width: Some(300.0),
988 height: None,
989 });
990 let collapsed = tree.bounds(acc).height;
991
992 expanded.set(true);
993 tree.tick_animations(Duration::from_millis(250));
994 tree.layout(SizeProposal {
995 width: Some(300.0),
996 height: None,
997 });
998 let after = tree.bounds(acc).height;
999
1000 assert!(
1001 after > collapsed,
1002 "external set must drive expansion: {} > {}",
1003 after,
1004 collapsed
1005 );
1006 }
1007
1008 #[test]
1009 fn double_toggle_round_trips_height() {
1010 use crate::primitives::TextWidget;
1011 use std::time::Duration;
1012
1013 let expanded = Signal::new(false);
1014 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1015 let content = tree.add(TextWidget::new(lit!("Some content")));
1016 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()).content(content));
1017 tree.layout(SizeProposal {
1018 width: Some(300.0),
1019 height: None,
1020 });
1021 let collapsed_initial = tree.bounds(acc).height;
1022
1023 tree.click(acc);
1025 tree.tick_animations(Duration::from_millis(250));
1026 tree.layout(SizeProposal {
1027 width: Some(300.0),
1028 height: None,
1029 });
1030 let expanded_h = tree.bounds(acc).height;
1031
1032 tree.click(acc);
1033 tree.tick_animations(Duration::from_millis(250));
1034 tree.layout(SizeProposal {
1035 width: Some(300.0),
1036 height: None,
1037 });
1038 let collapsed_again = tree.bounds(acc).height;
1039
1040 assert!(expanded_h > collapsed_initial);
1041 assert!(
1042 (collapsed_again - collapsed_initial).abs() < 1.0,
1043 "after collapse round-trip, height should match initial: {} vs {}",
1044 collapsed_again,
1045 collapsed_initial
1046 );
1047 }
1048
1049 #[test]
1050 fn content_dormant_when_collapsed() {
1051 use crate::primitives::TextWidget;
1052 use std::time::Duration;
1053
1054 let expanded = Signal::new(false);
1055 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1056 let content = tree.add(TextWidget::new(lit!("Some content text here")));
1057 let acc = tree.add(Accordion::new(lit!("Section"), expanded.clone()).content(content));
1058 tree.layout(SizeProposal {
1059 width: Some(300.0),
1060 height: None,
1061 });
1062 let collapsed_height = tree.bounds(acc).height;
1063
1064 tree.click(acc);
1066 assert!(expanded.get(), "should be expanded after click");
1067
1068 tree.tick_animations(Duration::from_millis(250));
1070 tree.layout(SizeProposal {
1071 width: Some(300.0),
1072 height: None,
1073 });
1074 let expanded_height = tree.bounds(acc).height;
1075
1076 assert!(
1077 expanded_height > collapsed_height,
1078 "expanded height ({}) should be greater than collapsed height ({})",
1079 expanded_height,
1080 collapsed_height
1081 );
1082 }
1083
1084 #[test]
1087 fn fill_accordion_header_toggles_but_content_tap_does_not() {
1088 use crate::primitives::TextWidget;
1089 use teksilo_core::event::{Modifiers, PointerButton};
1090
1091 fn tap_at(tree: &mut WidgetTree, p: teksilo_canvas::Point) {
1092 tree.dispatch_event(WidgetEvent::pointer_down(
1093 p,
1094 PointerButton::Primary,
1095 Modifiers::NONE,
1096 ));
1097 tree.dispatch_event(WidgetEvent::pointer_up(
1098 p,
1099 PointerButton::Primary,
1100 Modifiers::NONE,
1101 ));
1102 }
1103
1104 let expanded = Signal::new(true);
1105 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1106 let content = tree.add(TextWidget::new(lit!("dock body")));
1107 let acc = tree.add(
1108 Accordion::new(lit!("Panel"), expanded.clone())
1109 .fill(true)
1110 .content(content),
1111 );
1112 tree.layout(SizeProposal::exact(220.0, 300.0));
1113
1114 let b = tree.bounds(acc);
1116 tap_at(&mut tree, teksilo_canvas::Point::new(b.x + 20.0, b.y + 6.0));
1117 assert!(!expanded.get(), "header tap collapses");
1118 tap_at(&mut tree, teksilo_canvas::Point::new(b.x + 20.0, b.y + 6.0));
1119 assert!(expanded.get(), "header tap re-expands");
1120
1121 tap_at(
1123 &mut tree,
1124 teksilo_canvas::Point::new(b.x + 110.0, b.y + 200.0),
1125 );
1126 assert!(expanded.get(), "content tap does not collapse the panel");
1127 }
1128
1129 #[test]
1130 fn fill_accordion_header_drag_fires_hook() {
1131 use crate::primitives::TextWidget;
1132 use std::cell::Cell as StdCell;
1133 use std::rc::Rc;
1134 let dragged = Rc::new(StdCell::new(false));
1135 let sink = dragged.clone();
1136 let expanded = Signal::new(true);
1137 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1138 let content = tree.add(TextWidget::new(lit!("dock body")));
1139 let acc = tree.add(
1140 Accordion::new(lit!("Panel"), expanded)
1141 .fill(true)
1142 .on_header_drag(move |_ctx| sink.set(true))
1143 .content(content),
1144 );
1145 tree.layout(SizeProposal::exact(220.0, 300.0));
1146 let b = tree.bounds(acc);
1147 let from = teksilo_canvas::Point::new(b.x + 20.0, b.y + 6.0);
1148 tree.drag(
1149 from,
1150 teksilo_canvas::Point::new(from.x + 130.0, from.y + 30.0),
1151 );
1152 assert!(dragged.get(), "dragging the header fires on_header_drag");
1153 }
1154
1155 #[test]
1156 fn fill_accordion_body_fills_the_leftover() {
1157 use crate::primitives::TextWidget;
1158 let expanded = Signal::new(true);
1159 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1160 let content = tree.add(TextWidget::new(lit!("dock body")));
1161 let acc = tree.add(
1162 Accordion::new(lit!("Panel"), expanded)
1163 .fill(true)
1164 .content(content),
1165 );
1166 tree.layout(SizeProposal::exact(220.0, 300.0));
1167 let header_h = tree.bounds(tree.children(acc)[0]).height;
1171 let body_h = tree.bounds(tree.children(acc)[1]).height;
1172 assert!(
1173 (header_h + body_h - 300.0).abs() < 6.0,
1174 "header({header_h}) + body({body_h}) should fill the 300px pane"
1175 );
1176 assert!(body_h > 200.0, "body fills most of the pane, got {body_h}");
1177 }
1178
1179 #[test]
1180 fn fill_accordion_body_stays_within_the_pane() {
1181 use crate::primitives::{FixedSize, TextWidget};
1182 let expanded = Signal::new(true);
1183 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1184 let content = tree.add(
1186 FixedSize::new()
1187 .width(80.0_f32)
1188 .height(900.0_f32)
1189 .child(TextWidget::new(lit!("x"))),
1190 );
1191 let acc = tree.add(
1192 Accordion::new(lit!("Panel"), expanded)
1193 .fill(true)
1194 .content(content),
1195 );
1196 tree.layout(SizeProposal::exact(220.0, 300.0));
1197 let body = tree.children(acc)[1];
1200 assert!(
1201 tree.bounds(body).bottom() <= 300.5,
1202 "body bottom {} must stay within the 300px pane",
1203 tree.bounds(body).bottom()
1204 );
1205 }
1206
1207 #[test]
1208 fn trailing_slot_renders_and_captures_its_own_tap() {
1209 use crate::primitives::{FixedSize, RectWidget};
1214 use std::cell::Cell as StdCell;
1215 use std::rc::Rc;
1216 use teksilo_core::event::{Modifiers, PointerButton};
1217
1218 let expanded = Signal::new(true);
1219 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1220 let tapped = Rc::new(StdCell::new(false));
1221 let sink = tapped.clone();
1222 let trailing = tree.add(
1223 FixedSize::new()
1224 .width(24.0_f32)
1225 .height(24.0_f32)
1226 .child(RectWidget::new())
1227 .on_tap(move |_e, _ctx| sink.set(true)),
1228 );
1229 let content = tree.add(TextWidget::new(lit!("body")));
1230 let acc = tree.add(
1231 Accordion::new(lit!("Panel"), expanded.clone())
1232 .fill(true)
1233 .trailing(trailing)
1234 .content(content),
1235 );
1236 tree.layout(SizeProposal::exact(260.0, 140.0));
1237
1238 let tb = tree.bounds(trailing);
1240 assert!(tb.width > 0.0 && tb.height > 0.0, "trailing slot is placed");
1241 let _ = acc;
1242
1243 let p = teksilo_canvas::Point::new(tb.x + tb.width / 2.0, tb.y + tb.height / 2.0);
1245 tree.dispatch_event(WidgetEvent::pointer_down(
1246 p,
1247 PointerButton::Primary,
1248 Modifiers::NONE,
1249 ));
1250 tree.dispatch_event(WidgetEvent::pointer_up(
1251 p,
1252 PointerButton::Primary,
1253 Modifiers::NONE,
1254 ));
1255 assert!(tapped.get(), "trailing widget received the tap");
1256 assert!(
1257 expanded.get(),
1258 "tapping the trailing widget must not toggle the accordion"
1259 );
1260 }
1261
1262 #[test]
1263 fn dragging_the_trailing_slot_does_not_start_the_header_drag() {
1264 use crate::primitives::{FixedSize, RectWidget};
1270 use std::cell::Cell as StdCell;
1271 use std::rc::Rc;
1272
1273 let expanded = Signal::new(true);
1274 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1275 let header_dragged = Rc::new(StdCell::new(false));
1276 let hd = header_dragged.clone();
1277 let trailing = tree.add(
1278 FixedSize::new()
1279 .width(24.0_f32)
1280 .height(24.0_f32)
1281 .child(RectWidget::new()),
1282 );
1283 let content = tree.add(TextWidget::new(lit!("body")));
1284 let acc = tree.add(
1285 Accordion::new(lit!("Panel"), expanded.clone())
1286 .fill(true)
1287 .trailing(trailing)
1288 .on_header_drag(move |_ctx| hd.set(true))
1289 .content(content),
1290 );
1291 tree.layout(SizeProposal::exact(260.0, 140.0));
1292
1293 let tb = tree.bounds(trailing);
1295 let from = teksilo_canvas::Point::new(tb.x + tb.width / 2.0, tb.y + tb.height / 2.0);
1296 tree.drag(
1297 from,
1298 teksilo_canvas::Point::new(from.x + 90.0, from.y + 12.0),
1299 );
1300 assert!(
1301 !header_dragged.get(),
1302 "dragging the trailing control must not start the header drag"
1303 );
1304
1305 let ab = tree.bounds(acc);
1307 tree.drag(
1308 teksilo_canvas::Point::new(ab.x + 10.0, ab.y + 6.0),
1309 teksilo_canvas::Point::new(ab.x + 120.0, ab.y + 30.0),
1310 );
1311 assert!(
1312 header_dragged.get(),
1313 "dragging the header title still starts the drag"
1314 );
1315 }
1316
1317 #[test]
1318 fn incremental_move_on_trailing_button_does_not_drag_header() {
1319 use crate::primitives::{FixedSize, RectWidget};
1324 use std::cell::Cell as StdCell;
1325 use std::rc::Rc;
1326 use teksilo_core::event::PointerButton;
1327
1328 let expanded = Signal::new(true);
1329 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1330 let header_dragged = Rc::new(StdCell::new(false));
1331 let hd = header_dragged.clone();
1332 let trailing = tree.add(
1334 FixedSize::new()
1335 .width(24.0_f32)
1336 .height(24.0_f32)
1337 .child(RectWidget::new())
1338 .on_tap(|_e, _ctx| {}),
1339 );
1340 let content = tree.add(TextWidget::new(lit!("body")));
1341 let _acc = tree.add(
1342 Accordion::new(lit!("Panel"), expanded.clone())
1343 .fill(true)
1344 .trailing(trailing)
1345 .on_header_drag(move |_ctx| hd.set(true))
1346 .content(content),
1347 );
1348 tree.layout(SizeProposal::exact(260.0, 140.0));
1349
1350 let tb = tree.bounds(trailing);
1351 let (cx, cy) = (tb.x + tb.width / 2.0, tb.y + tb.height / 2.0);
1352 tree.pointer_down_button(teksilo_canvas::Point::new(cx, cy), PointerButton::Primary);
1353 for i in 1..=10 {
1356 tree.pointer_move(teksilo_canvas::Point::new(cx + (i as f32) * 3.0, cy + 1.0));
1357 }
1358 tree.pointer_up_button(
1359 teksilo_canvas::Point::new(cx + 30.0, cy + 1.0),
1360 PointerButton::Primary,
1361 );
1362 assert!(
1363 !header_dragged.get(),
1364 "a jittery click on the trailing control must not start the header drag"
1365 );
1366 }
1367
1368 #[test]
1369 fn horizontal_fill_accordion_builds() {
1370 use crate::primitives::TextWidget;
1371 let expanded = Signal::new(true);
1372 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
1373 let content = tree.add(TextWidget::new(lit!("c")));
1374 let acc = tree.add(
1375 Accordion::new(lit!("Panel"), expanded)
1376 .horizontal()
1377 .fill(true)
1378 .content(content),
1379 );
1380 tree.layout(SizeProposal::exact(320.0, 120.0));
1381 let b = tree.bounds(acc);
1382 assert!(
1383 b.width > 0.0 && b.height > 0.0,
1384 "horizontal accordion builds"
1385 );
1386 }
1387
1388 #[test]
1402 fn an_accordion_opened_in_an_unfocused_window_opens() {
1403 use crate::primitives::{Expand, FixedSize, Padding, TextWidget, VStack};
1404 use crate::scroll_area::ScrollArea;
1405 use std::time::Duration;
1406 use teksilo_core::overlay::{DismissBehavior, OverlayPlacement, OverlayRequest};
1407
1408 let expanded = Signal::new(false);
1409 let mut tree = WidgetTree::new()
1410 .with_theme(teksilo_core::presets::intui::light())
1411 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1412 teksilo_canvas::MockTextBackend::new(),
1413 )));
1414 let window = SizeProposal::exact(800.0, 600.0);
1415 let source = tree.add(TextWidget::new(lit!("Show the report")));
1416 tree.layout(window);
1417 let _ = tree.render();
1418
1419 let items = VStack::new()
1420 .spacing(6.0)
1421 .child(TextWidget::new(lit!("Draft / Arrival")))
1422 .child(TextWidget::new(lit!("Draft / Storm")));
1423 let column = VStack::new()
1424 .spacing(10.0)
1425 .child(TextWidget::new(lit!("What came across")))
1426 .child(Accordion::new(lit!("2 items"), expanded.clone()).content(items))
1427 .child(TextWidget::new(lit!("Next finding")));
1428 let dialog =
1431 tree.add(FixedSize::new().width(400.0).height(300.0).child(
1432 VStack::new().child(Expand::vertical().child(
1433 ScrollArea::new().child(Padding::new(8.0, 8.0, 8.0, 8.0).child(column)),
1434 )),
1435 ));
1436 tree.set_dormant(dialog);
1437 tree.activate(dialog);
1438 tree.show_overlay(OverlayRequest::new(
1439 dialog,
1440 source,
1441 OverlayPlacement::Centered,
1442 DismissBehavior::Manual,
1443 ));
1444 tree.layout(window);
1445 let _ = tree.render();
1446
1447 let header = tree
1448 .find_by_label("2 items")
1449 .expect("the accordion's header");
1450 let next = tree
1451 .find_by_label("Next finding")
1452 .expect("the line below it");
1453 let before = tree.bounds(next).y;
1454
1455 tree.set_window_active(false);
1457 tree.click(header);
1458 assert!(expanded.get(), "the click opens the accordion");
1459 for _ in 0..30 {
1460 tree.advance_time(Duration::from_millis(16));
1461 tree.layout(window);
1462 let _ = tree.render();
1463 }
1464
1465 let after = tree.bounds(next).y;
1466 assert!(
1467 after > before + 20.0,
1468 "the accordion's body must take room in a window without focus: \
1469 the line below it went from {before} to {after}"
1470 );
1471 let item = tree
1472 .find_by_label("Draft / Storm")
1473 .expect("the body's last item");
1474 assert!(
1475 tree.bounds(item).y < after,
1476 "the body's items sit above the line that follows them"
1477 );
1478 }
1479}