1use std::rc::Rc;
14
15use frus_core::{BorderRadius, Color, Point, Rect, Scene, TextStyle};
16use frus_layout::{Dimension, FlexDirection, Style};
17
18use crate::disabled::disabled_content;
19use crate::icons::IconData;
20use crate::interaction::Status;
21use crate::pageview::PageView;
22use crate::theme::Theme;
23use crate::transparent::Shared;
24use crate::widget::Widget;
25
26pub const TAB_HEIGHT: f32 = 46.0;
28pub const TAB_ICON_HEIGHT: f32 = 72.0;
32pub const TAB_ICON_SIZE: f32 = 24.0;
34pub const TAB_ICON_GAP: f32 = 2.0;
36pub const TAB_LABEL_PADDING: f32 = 16.0;
38pub const TAB_START_OFFSET: f32 = 52.0;
40pub const TAB_INDICATOR_PRIMARY: f32 = 3.0;
42pub const TAB_INDICATOR_SECONDARY: f32 = 2.0;
44pub const TAB_DIVIDER_HEIGHT: f32 = 1.0;
46
47#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
53pub enum TabBarVariant {
54 #[default]
56 Primary,
57 Secondary,
59}
60
61#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
67pub enum TabAlignment {
68 #[default]
71 Auto,
72 Start,
74 StartOffset,
76 Fill,
83 Center,
96}
97
98impl TabAlignment {
99 fn resolved(self, scrolls: bool) -> Self {
107 match (self, scrolls) {
108 (Self::Auto, true) | (Self::Fill, true) | (Self::Center, true) => Self::Start,
109 (Self::Auto, false) => Self::Fill,
110 (other, _) => other,
111 }
112 }
113
114 fn lead(self) -> f32 {
116 match self {
117 Self::StartOffset => TAB_START_OFFSET,
118 _ => 0.0,
119 }
120 }
121}
122
123#[derive(Clone, Debug, Default, PartialEq)]
125pub(crate) struct TabSpec {
126 pub label: String,
129 pub icon: Option<IconData>,
131 pub show_label: bool,
136 pub drawn: bool,
139 pub width: Option<f32>,
142}
143
144impl TabSpec {
145 fn text(label: impl Into<String>) -> Self {
147 Self {
148 label: label.into(),
149 icon: None,
150 show_label: true,
151 drawn: false,
152 width: None,
153 }
154 }
155
156 fn stacked(&self) -> bool {
159 self.icon.is_some() && self.show_label && !self.label.is_empty()
160 }
161}
162
163#[derive(Clone, Copy, Debug, Default, PartialEq)]
169pub(crate) struct TabStyle {
170 pub variant: Option<TabBarVariant>,
171 pub indicator_color: Option<Color>,
172 pub indicator_weight: Option<f32>,
173 pub label_color: Option<Color>,
174 pub unselected_label_color: Option<Color>,
175 pub label_style: Option<TextStyle>,
176 pub divider_color: Option<Color>,
177 pub divider_height: Option<f32>,
178 pub label_padding: Option<f32>,
179 pub tab_height: Option<f32>,
180 pub alignment: Option<TabAlignment>,
182 pub tall: bool,
186}
187
188impl TabStyle {
189 fn variant(&self, theme: &Theme) -> TabBarVariant {
190 self.variant
191 .or(theme.widgets.tab_bar.variant)
192 .unwrap_or_default()
193 }
194
195 fn indicator_weight(&self, theme: &Theme) -> f32 {
196 self.indicator_weight
197 .or(theme.widgets.tab_bar.indicator_weight)
198 .unwrap_or(match self.variant(theme) {
199 TabBarVariant::Primary => TAB_INDICATOR_PRIMARY,
200 TabBarVariant::Secondary => TAB_INDICATOR_SECONDARY,
201 })
202 }
203
204 fn indicator_color(&self, theme: &Theme) -> Color {
205 self.indicator_color
206 .or(theme.widgets.tab_bar.indicator_color)
207 .unwrap_or(theme.scheme.primary)
208 }
209
210 fn label_color(&self, theme: &Theme) -> Color {
211 self.label_color
212 .or(theme.widgets.tab_bar.label_color)
213 .unwrap_or(match self.variant(theme) {
214 TabBarVariant::Primary => theme.scheme.primary,
217 TabBarVariant::Secondary => theme.scheme.on_surface,
218 })
219 }
220
221 fn unselected_label_color(&self, theme: &Theme) -> Color {
222 self.unselected_label_color
223 .or(theme.widgets.tab_bar.unselected_label_color)
224 .unwrap_or(theme.scheme.on_surface_variant)
225 }
226
227 fn label_style(&self, theme: &Theme) -> TextStyle {
228 self.label_style
229 .or(theme.widgets.tab_bar.label_style)
230 .unwrap_or(theme.text.title_small)
231 }
232
233 fn divider_color(&self, theme: &Theme) -> Color {
234 self.divider_color
235 .or(theme.widgets.tab_bar.divider_color)
236 .unwrap_or(theme.scheme.outline_variant)
237 }
238
239 fn divider_height(&self, theme: &Theme) -> f32 {
240 self.divider_height
241 .or(theme.widgets.tab_bar.divider_height)
242 .unwrap_or(TAB_DIVIDER_HEIGHT)
243 }
244
245 fn label_padding(&self, theme: &Theme) -> f32 {
246 self.label_padding
247 .or(theme.widgets.tab_bar.label_padding)
248 .unwrap_or(TAB_LABEL_PADDING)
249 }
250
251 fn alignment(&self, theme: &Theme, scrolls: bool) -> TabAlignment {
253 self.alignment
254 .or(theme.widgets.tab_bar.alignment)
255 .unwrap_or_default()
256 .resolved(scrolls)
257 }
258
259 fn tab_height(&self, theme: &Theme) -> f32 {
260 self.tab_height
261 .or(theme.widgets.tab_bar.tab_height)
262 .unwrap_or(match self.tall {
263 true => TAB_ICON_HEIGHT,
264 false => TAB_HEIGHT,
265 })
266 }
267
268 fn bar_height(&self, theme: &Theme) -> f32 {
270 self.tab_height(theme) + self.indicator_weight(theme)
271 }
272
273 fn scrolled_tab_width(&self, theme: &Theme, spec: &TabSpec) -> f32 {
284 self.content_width(theme, spec) + 2.0 * self.label_padding(theme)
285 }
286
287 fn content_width(&self, theme: &Theme, spec: &TabSpec) -> f32 {
294 if let Some(width) = spec.width {
300 return width;
301 }
302 let text = match spec.show_label && !spec.label.is_empty() {
303 true => {
304 let style = self.label_style(theme);
305 frus_text::measure_style(&spec.label, style).width
306 }
307 false => 0.0,
308 };
309 let icon = match spec.icon {
310 Some(_) => TAB_ICON_SIZE,
311 None => 0.0,
312 };
313 text.max(icon)
314 }
315
316 fn tab_spans(
322 &self,
323 theme: &Theme,
324 tabs: &[TabSpec],
325 width: f32,
326 alignment: TabAlignment,
327 ) -> Vec<(f32, f32)> {
328 if alignment == TabAlignment::Fill {
329 let each = width / tabs.len().max(1) as f32;
330 return (0..tabs.len()).map(|i| (i as f32 * each, each)).collect();
331 }
332 let widths: Vec<f32> = tabs
333 .iter()
334 .map(|spec| self.scrolled_tab_width(theme, spec))
335 .collect();
336 let lead = match alignment {
341 TabAlignment::Center => (width - widths.iter().sum::<f32>()) / 2.0,
342 other => other.lead(),
343 }
344 .max(0.0);
345 let mut spans = Vec::with_capacity(tabs.len());
346 let mut x = lead;
347 for w in widths {
348 spans.push((x, w));
349 x += w;
350 }
351 spans
352 }
353
354 fn indicator_width(&self, theme: &Theme, spec: &TabSpec, tab_width: f32) -> f32 {
355 match self.variant(theme) {
356 TabBarVariant::Secondary => tab_width,
357 TabBarVariant::Primary => {
358 let room = (tab_width - 2.0 * self.label_padding(theme)).max(0.0);
361 self.content_width(theme, spec).min(room)
362 }
363 }
364 }
365}
366
367struct Tab<Msg> {
369 spec: TabSpec,
370 children: Vec<Box<dyn Widget<Msg>>>,
372 selected: bool,
373 style: TabStyle,
374 enabled: bool,
376 scrolls: bool,
379 message: Msg,
380}
381
382impl<Msg: Clone> Widget<Msg> for Tab<Msg> {
383 fn style(&self) -> Style {
384 self.style_themed(&Theme::default())
385 }
386
387 fn style_themed(&self, theme: &Theme) -> Style {
388 let pad = self.style.label_padding(theme);
393 let drawn = match self.spec.drawn {
399 true => Style {
400 flex_direction: FlexDirection::Row,
401 align: frus_layout::Align::Center,
402 justify: frus_layout::Justify::Center,
403 ..Default::default()
404 },
405 false => Style::default(),
406 };
407 if self.style.alignment(theme, self.scrolls) == TabAlignment::Fill {
408 return Style {
409 width: Dimension::Length(0.0),
410 flex_grow: 1.0,
411 padding: frus_core::Insets::new(0.0, pad, 0.0, pad),
412 ..drawn
413 };
414 }
415 Style {
424 width: Dimension::Length(self.style.scrolled_tab_width(theme, &self.spec)),
425 flex_grow: 0.0,
426 flex_shrink: 0.0,
427 padding: frus_core::Insets::new(0.0, pad, 0.0, pad),
428 ..drawn
429 }
430 }
431
432 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
433 &self.children
434 }
435
436 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
437 if self.spec.drawn {
441 return;
442 }
443 let o = status.opacity;
444 let label_style = self.style.label_style(theme);
445 let color = if !self.enabled {
449 disabled_content(theme)
450 } else if self.selected {
451 self.style.label_color(theme)
452 } else {
453 self.style.unselected_label_color(theme)
454 };
455 let shows_label = self.spec.show_label && !self.spec.label.is_empty();
456 let measured = match shows_label {
457 true => frus_text::measure_style(&self.spec.label, label_style),
458 false => frus_core::Size::new(0.0, 0.0),
459 };
460 let height = self.style.tab_height(theme);
464 let icon_block = match self.spec.icon {
468 Some(_) if shows_label => TAB_ICON_SIZE + TAB_ICON_GAP,
469 Some(_) => TAB_ICON_SIZE,
470 None => 0.0,
471 };
472 let block = icon_block + measured.height * f32::from(u8::from(shows_label));
473 let top = bounds.y + (height - block) / 2.0;
474 if let Some(icon) = self.spec.icon {
475 let path = icon.placed(
477 TAB_ICON_SIZE,
478 bounds.x + (bounds.width - TAB_ICON_SIZE) / 2.0,
479 top,
480 theme.direction,
481 );
482 scene.fill_path(&path, color.fade(o));
483 }
484 if shows_label {
485 scene.text(
486 Point::new(
487 bounds.x + (bounds.width - measured.width) / 2.0,
488 top + icon_block,
489 ),
490 self.spec.label.clone(),
491 &label_style.resolved(),
492 color.fade(o),
493 );
494 }
495 }
496
497 fn on_click(&self) -> Option<Msg> {
498 if !self.enabled {
499 return None;
500 }
501 Some(self.message.clone())
502 }
503
504 fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
505 if !self.enabled {
507 return None;
508 }
509 let splash = if self.selected {
513 self.style.indicator_color(theme).fade(0.10)
514 } else {
515 theme.scheme.on_surface.fade(0.08)
516 };
517 Some(crate::InkStyle::of(theme).color(splash).radius(0.0))
518 }
519
520 fn focusable(&self) -> bool {
521 self.enabled
522 }
523
524 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
525 let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Tab)
528 .label(self.spec.label.clone())
529 .toggled(self.selected);
530 Some(if self.enabled {
531 semantics.clickable()
532 } else {
533 semantics.disabled(true)
534 })
535 }
536}
537
538struct TabStrip<Msg> {
541 selected: usize,
542 tabs: Vec<TabSpec>,
543 style: TabStyle,
544 scrolls: bool,
547 enabled: bool,
551 children: Vec<Box<dyn Widget<Msg>>>,
552}
553
554impl<Msg: Clone> Widget<Msg> for TabStrip<Msg> {
555 fn style(&self) -> Style {
556 self.style_themed(&Theme::default())
557 }
558
559 fn style_themed(&self, theme: &Theme) -> Style {
560 let alignment = self.style.alignment(theme, self.scrolls);
564 let centred = alignment == TabAlignment::Center;
565 Style {
566 width: match self.scrolls && !centred {
569 true => Dimension::Auto,
570 false => Dimension::Percent(1.0),
571 },
572 height: Dimension::Length(self.style.bar_height(theme)),
573 flex_direction: FlexDirection::Row,
574 justify: match centred {
575 true => frus_layout::Justify::Center,
576 false => frus_layout::Justify::Start,
577 },
578 padding: frus_core::Insets::new(0.0, 0.0, 0.0, alignment.lead()),
579 ..Default::default()
580 }
581 }
582
583 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
584 &self.children
585 }
586
587 fn anim_target(&self) -> Option<f32> {
590 Some(self.selected as f32)
591 }
592
593 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
594 let count = self.tabs.len();
595 if count == 0 || bounds.width <= 0.0 {
596 return;
597 }
598 let o = status.opacity;
599 let weight = self.style.indicator_weight(theme);
600
601 if weight <= 0.0 {
607 return;
608 }
609
610 let spans = self.style.tab_spans(
615 theme,
616 &self.tabs,
617 bounds.width,
618 self.style.alignment(theme, self.scrolls),
619 );
620 let last = (count - 1) as f32;
621 let value = status.value.clamp(0.0, last);
622 let (low, high) = (value.floor(), value.ceil());
623 let t = value - low;
624 let span = |index: f32| {
625 let i = index as usize;
626 let (start, tab_width) = spans[i];
627 let centre = bounds.x + start + tab_width / 2.0;
628 let width = self.style.indicator_width(theme, &self.tabs[i], tab_width);
629 (centre, width)
630 };
631 let (centre_low, width_low) = span(low);
632 let (centre_high, width_high) = span(high);
633 let centre = centre_low + (centre_high - centre_low) * t;
634 let width = width_low + (width_high - width_low) * t;
635
636 let radius = match self.style.variant(theme) {
640 TabBarVariant::Primary => BorderRadius {
641 top_left: weight,
642 top_right: weight,
643 ..BorderRadius::ZERO
644 },
645 TabBarVariant::Secondary => BorderRadius::ZERO,
646 };
647 scene.draw_rect(
648 Rect::new(
649 centre - width / 2.0,
650 bounds.y + bounds.height - weight,
651 width,
652 weight,
653 ),
654 if self.enabled {
655 self.style.indicator_color(theme)
656 } else {
657 disabled_content(theme)
658 }
659 .fade(o),
660 radius,
661 0.0,
662 Color::TRANSPARENT,
663 );
664 }
665
666 fn on_click(&self) -> Option<Msg> {
667 None
668 }
669
670 fn keep_visible(&self, size: frus_core::Size, theme: &Theme) -> Option<crate::ui::KeepVisible> {
680 if !self.scrolls {
681 return None;
682 }
683 let spans = self.style.tab_spans(
684 theme,
685 &self.tabs,
686 size.width,
687 self.style.alignment(theme, self.scrolls),
688 );
689 let &(x, width) = spans.get(self.selected)?;
690 Some(crate::ui::KeepVisible {
691 key: self.selected as u64,
692 rect: Rect::new(x, 0.0, width, size.height),
693 centre: true,
694 })
695 }
696}
697
698pub struct TabItem<Msg = crate::callback::Callback> {
729 spec: TabSpec,
730 child: Option<Rc<dyn Widget<Msg>>>,
731}
732
733impl<Msg> TabItem<Msg> {
734 pub fn new(label: impl Into<String>) -> Self {
736 Self {
737 spec: TabSpec::text(label),
738 child: None,
739 }
740 }
741
742 pub fn icon(icon: IconData, label: impl Into<String>) -> Self {
744 Self {
745 spec: TabSpec {
746 icon: Some(icon),
747 ..TabSpec::text(label)
748 },
749 child: None,
750 }
751 }
752
753 pub fn icon_only(icon: IconData, label: impl Into<String>) -> Self {
756 Self {
757 spec: TabSpec {
758 icon: Some(icon),
759 show_label: false,
760 ..TabSpec::text(label)
761 },
762 child: None,
763 }
764 }
765
766 pub fn widget(child: impl Widget<Msg> + 'static, label: impl Into<String>) -> Self {
773 Self {
774 spec: TabSpec {
775 drawn: true,
776 ..TabSpec::text(label)
777 },
778 child: Some(Rc::new(child)),
779 }
780 }
781
782 #[must_use]
792 pub fn width(mut self, width: f32) -> Self {
793 self.spec.width = Some(width);
794 self
795 }
796}
797
798pub struct TabBar<Msg = crate::callback::Callback> {
800 selected: usize,
801 on_select: Rc<dyn Fn(usize) -> Msg>,
802 tabs: Vec<TabSpec>,
803 drawn: Vec<Option<Rc<dyn Widget<Msg>>>>,
805 enabled: bool,
806 style: TabStyle,
807 scrollable: bool,
809 swipeable: bool,
811 panels: Vec<Rc<dyn Widget<Msg>>>,
814 children: Vec<Box<dyn Widget<Msg>>>,
816}
817
818impl<Msg: Clone + 'static> TabBar<Msg> {
819 pub fn new(selected: usize, on_select: impl Fn(usize) -> Msg + 'static) -> Self {
821 let mut tabs = Self {
822 selected,
823 on_select: Rc::new(on_select),
824 tabs: Vec::new(),
825 drawn: Vec::new(),
826 enabled: true,
827 style: TabStyle::default(),
828 scrollable: false,
829 swipeable: false,
830 panels: Vec::new(),
831 children: Vec::new(),
832 };
833 tabs.rebuild_bar();
834 tabs
835 }
836
837 pub fn tab(self, label: impl Into<String>, content: impl Widget<Msg> + 'static) -> Self {
840 self.item(TabItem::new(label), content)
841 }
842
843 pub fn tab_widget(
848 self,
849 child: impl Widget<Msg> + 'static,
850 label: impl Into<String>,
851 content: impl Widget<Msg> + 'static,
852 ) -> Self {
853 self.item(TabItem::widget(child, label), content)
854 }
855
856 pub fn item(mut self, tab: TabItem<Msg>, content: impl Widget<Msg> + 'static) -> Self {
859 self.push(tab, content);
860 self
861 }
862
863 pub fn icon_tab(
868 self,
869 icon: IconData,
870 label: impl Into<String>,
871 content: impl Widget<Msg> + 'static,
872 ) -> Self {
873 self.item(TabItem::icon(icon, label), content)
874 }
875
876 pub fn icon_only_tab(
883 self,
884 icon: IconData,
885 label: impl Into<String>,
886 content: impl Widget<Msg> + 'static,
887 ) -> Self {
888 self.item(TabItem::icon_only(icon, label), content)
889 }
890
891 fn push(&mut self, tab: TabItem<Msg>, content: impl Widget<Msg> + 'static) {
895 let index = self.tabs.len();
896 self.tabs.push(tab.spec);
897 self.drawn.push(tab.child);
898 self.panels.push(Rc::new(content));
899 self.rebuild_bar();
900 self.rebuild_panel(index);
901 }
902
903 fn rebuild_panel(&mut self, added: usize) {
906 let panel: Option<Box<dyn Widget<Msg>>> = if self.swipeable {
907 let panels = self.panels.clone();
908 let on_select = self.on_select.clone();
909 Some(Box::new(
910 PageView::new(panels.len(), move |i| Shared::new(panels[i].clone()))
911 .page(self.selected)
912 .on_page_changed(move |i| on_select(i))
916 .flex(1.0),
917 ))
918 } else if added == self.selected {
919 Some(Box::new(Shared::new(self.panels[added].clone())))
920 } else {
921 None
922 };
923 let Some(panel) = panel else { return };
924 if self.children.len() > 1 {
925 self.children[1] = panel;
926 } else {
927 self.children.push(panel);
928 }
929 }
930
931 pub fn swipeable(mut self, swipeable: bool) -> Self {
950 self.swipeable = swipeable;
951 self.children.truncate(1);
952 for index in 0..self.panels.len() {
953 self.rebuild_panel(index);
954 }
955 self
956 }
957
958 pub fn scrollable(mut self, scrollable: bool) -> Self {
972 self.scrollable = scrollable;
973 self.rebuild_bar();
974 self
975 }
976
977 pub fn alignment(mut self, alignment: TabAlignment) -> Self {
982 self.style.alignment = Some(alignment);
983 self.rebuild_bar();
984 self
985 }
986
987 pub fn enabled(mut self, enabled: bool) -> Self {
993 self.enabled = enabled;
994 self.rebuild_bar();
995 self
996 }
997
998 pub fn variant(mut self, variant: TabBarVariant) -> Self {
1000 self.style.variant = Some(variant);
1001 self.rebuild_bar();
1002 self
1003 }
1004
1005 pub fn indicator_color(mut self, color: Color) -> Self {
1007 self.style.indicator_color = Some(color);
1008 self.rebuild_bar();
1009 self
1010 }
1011
1012 pub fn indicator_weight(mut self, weight: f32) -> Self {
1015 self.style.indicator_weight = Some(weight);
1016 self.rebuild_bar();
1017 self
1018 }
1019
1020 pub fn label_color(mut self, color: Color) -> Self {
1022 self.style.label_color = Some(color);
1023 self.rebuild_bar();
1024 self
1025 }
1026
1027 pub fn unselected_label_color(mut self, color: Color) -> Self {
1029 self.style.unselected_label_color = Some(color);
1030 self.rebuild_bar();
1031 self
1032 }
1033
1034 pub fn label_style(mut self, style: TextStyle) -> Self {
1036 self.style.label_style = Some(style);
1037 self.rebuild_bar();
1038 self
1039 }
1040
1041 pub fn divider_color(mut self, color: Color) -> Self {
1043 self.style.divider_color = Some(color);
1044 self.rebuild_bar();
1045 self
1046 }
1047
1048 pub fn divider_height(mut self, height: f32) -> Self {
1051 self.style.divider_height = Some(height);
1052 self.rebuild_bar();
1053 self
1054 }
1055
1056 pub fn label_padding(mut self, padding: f32) -> Self {
1058 self.style.label_padding = Some(padding);
1059 self.rebuild_bar();
1060 self
1061 }
1062
1063 pub fn tab_height(mut self, height: f32) -> Self {
1065 self.style.tab_height = Some(height);
1066 self.rebuild_bar();
1067 self
1068 }
1069
1070 fn rebuild_bar(&mut self) {
1076 self.style.tall = self.tabs.iter().any(TabSpec::stacked);
1079 let tabs: Vec<Box<dyn Widget<Msg>>> = self
1080 .tabs
1081 .iter()
1082 .enumerate()
1083 .map(|(i, spec)| {
1084 Box::new(Tab {
1085 spec: spec.clone(),
1086 children: self.drawn[i]
1087 .clone()
1088 .map(|inner| Box::new(Shared::new(inner)) as Box<dyn Widget<Msg>>)
1089 .into_iter()
1090 .collect(),
1091 selected: i == self.selected,
1092 style: self.style,
1093 enabled: self.enabled,
1094 scrolls: self.scrollable,
1095 message: (self.on_select)(i),
1096 }) as Box<dyn Widget<Msg>>
1097 })
1098 .collect();
1099 let strip = TabStrip {
1100 selected: self.selected,
1101 tabs: self.tabs.clone(),
1102 style: self.style,
1103 enabled: self.enabled,
1104 scrolls: self.scrollable,
1105 children: tabs,
1106 };
1107 let bar: Box<dyn Widget<Msg>> = match self.scrollable {
1112 true => Box::new(
1113 crate::SingleChildScrollView::new()
1114 .axis(crate::scroll::Axis::Horizontal)
1115 .child(strip),
1116 ),
1117 false => Box::new(strip),
1118 };
1119 if self.children.is_empty() {
1120 self.children.push(bar);
1121 } else {
1122 self.children[0] = bar;
1123 }
1124 }
1125}
1126
1127impl<Msg: Clone> Widget<Msg> for TabBar<Msg> {
1128 fn style(&self) -> Style {
1129 Style {
1130 width: Dimension::Auto,
1131 flex_direction: FlexDirection::Column,
1132 ..Default::default()
1135 }
1136 }
1137
1138 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1139 &self.children
1140 }
1141
1142 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
1147 match self.swipeable {
1151 true => crate::widget::FillAxes::BOTH,
1152 false => crate::widget::FillAxes::WIDTH,
1153 }
1154 }
1155
1156 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1163 let divider = self.style.divider_height(theme);
1164 if divider <= 0.0 || bounds.width <= 0.0 {
1165 return;
1166 }
1167 scene.fill_rect(
1168 Rect::new(
1169 bounds.x,
1170 bounds.y + self.style.bar_height(theme) - divider,
1171 bounds.width,
1172 divider,
1173 ),
1174 self.style.divider_color(theme).fade(status.opacity),
1175 );
1176 }
1177
1178 fn on_click(&self) -> Option<Msg> {
1179 None
1180 }
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185 use super::*;
1186 use crate::icons::Icons;
1187 use crate::runtime::Runtime;
1188 use crate::ui::build_ui;
1189 use crate::Text;
1190 use frus_core::{Primitive, Size};
1191
1192 #[derive(Clone, Debug, PartialEq)]
1193 enum Msg {
1194 Select(usize),
1195 }
1196
1197 #[test]
1205 fn a_tab_can_be_a_widget_the_caller_drew() {
1206 let mark = Color::rgb(0.9, 0.1, 0.1);
1207 let bar = TabBar::new(0, Msg::Select)
1208 .tab_widget(
1209 crate::Container::<Msg>::new()
1210 .width(40.0)
1211 .height(18.0)
1212 .color(mark),
1213 "Inbox",
1214 crate::Container::<Msg>::new(),
1215 )
1216 .tab("Archive", crate::Container::<Msg>::new());
1217 let root = crate::flex::Flex::column()
1218 .width(300.0)
1219 .height(120.0)
1220 .child(bar);
1221 let ui = build_ui(
1222 &root,
1223 Size::new(300.0, 120.0),
1224 &Runtime::default(),
1225 &Theme::default(),
1226 );
1227 assert!(
1228 rects(ui.scene()).iter().any(|(_, color)| *color == mark),
1229 "the caller's own widget is drawn"
1230 );
1231 let drawn = texts(ui.scene());
1232 assert!(
1233 !drawn.iter().any(|t| t == "Inbox"),
1234 "and the bar draws no label of its own under it: {drawn:?}"
1235 );
1236 assert!(
1237 drawn.iter().any(|t| t == "Archive"),
1238 "while an ordinary tab still does"
1239 );
1240 }
1241
1242 #[test]
1245 fn a_drawn_tab_is_still_named_for_a_reader() {
1246 let bar = TabBar::new(0, Msg::Select).tab_widget(
1247 crate::Container::<Msg>::new().width(20.0).height(20.0),
1248 "Inbox",
1249 crate::Container::<Msg>::new(),
1250 );
1251 let strip = &Widget::<Msg>::children(&bar)[0];
1252 let tab = &strip.children()[0];
1253 let announced = tab.semantics().expect("announced");
1254 assert_eq!(announced.label.as_deref(), Some("Inbox"));
1255 assert!(tab.focusable(), "and it is still a tab");
1256 assert_eq!(tab.on_click(), Some(Msg::Select(0)));
1257 }
1258
1259 #[test]
1268 fn a_drawn_tab_says_how_wide_it_is_when_the_bar_scrolls() {
1269 let bar = |width: Option<f32>, scrollable: bool| {
1270 let mut item = TabItem::widget(
1271 crate::Container::<Msg>::new().width(20.0).height(20.0),
1272 "Hi",
1273 );
1274 if let Some(width) = width {
1275 item = item.width(width);
1276 }
1277 TabBar::new(0, Msg::Select)
1278 .scrollable(scrollable)
1279 .item(item, crate::Container::<Msg>::new())
1280 .tab("Archive", crate::Container::<Msg>::new())
1281 };
1282 let press = |bar: TabBar<Msg>| {
1286 let root = crate::flex::Flex::column()
1287 .width(400.0)
1288 .height(120.0)
1289 .child(bar);
1290 let ui = build_ui(
1291 &root,
1292 Size::new(400.0, 120.0),
1293 &Runtime::default(),
1294 &Theme::default(),
1295 );
1296 ui.hit(crate::Point::new(100.0, 20.0))
1297 .and_then(|id| ui.msg_for(id))
1298 };
1299 assert_eq!(press(bar(Some(150.0), true)), Some(Msg::Select(0)));
1300 assert_eq!(press(bar(None, true)), Some(Msg::Select(1)));
1301
1302 assert_eq!(press(bar(Some(150.0), false)), press(bar(None, false)));
1305 }
1306
1307 #[test]
1315 fn a_drag_across_the_panel_lands_on_the_next_tab() {
1316 let bar = || {
1317 TabBar::new(0, Msg::Select)
1318 .swipeable(true)
1319 .tab("One", crate::Container::<Msg>::new())
1320 .tab("Two", crate::Container::<Msg>::new())
1321 .tab("Three", crate::Container::<Msg>::new())
1322 };
1323 let root = |bar| {
1324 crate::flex::Flex::column()
1325 .width(300.0)
1326 .height(400.0)
1327 .child(bar)
1328 };
1329 let mut runtime = Runtime::default();
1330 let ui = build_ui(
1331 &root(bar()),
1332 Size::new(300.0, 400.0),
1333 &runtime,
1334 &Theme::default(),
1335 );
1336 let region = ui
1337 .scroll_regions()
1338 .iter()
1339 .find(|area| area.page.is_some())
1340 .expect("the panel is paged");
1341 let id = region.id;
1342 let snap = region.page.expect("a paged region");
1343 assert_eq!(snap.count, 3, "one page per tab");
1344
1345 runtime.page_changes(ui.scroll_regions());
1348 runtime.scroll.insert(id, (snap.extent, 0.0));
1349 let ui = build_ui(
1350 &root(bar()),
1351 Size::new(300.0, 400.0),
1352 &runtime,
1353 &Theme::default(),
1354 );
1355 let turned = runtime.page_changes(ui.scroll_regions());
1356 assert_eq!(turned, vec![(id, 1)], "one page across");
1357
1358 let bar = bar();
1359 let panel = &Widget::<Msg>::children(&bar)[1];
1360 assert_eq!(
1361 panel.on_page_changed(1),
1362 Some(Msg::Select(1)),
1363 "and it reports the tab a press would have"
1364 );
1365 }
1366
1367 #[test]
1371 fn only_a_swipeable_bar_keeps_every_panel() {
1372 let bar = |swipeable: bool| {
1373 TabBar::new(0, Msg::Select)
1374 .swipeable(swipeable)
1375 .tab("One", crate::Container::<Msg>::new().width(11.0))
1376 .tab("Two", crate::Container::<Msg>::new().width(22.0))
1377 };
1378 let plain = bar(false);
1379 assert_eq!(
1380 Widget::<Msg>::children(&plain).len(),
1381 2,
1382 "a bar and a panel"
1383 );
1384 let root = crate::flex::Flex::column()
1385 .width(300.0)
1386 .height(400.0)
1387 .child(bar(true));
1388 let ui = build_ui(
1389 &root,
1390 Size::new(300.0, 400.0),
1391 &Runtime::default(),
1392 &Theme::default(),
1393 );
1394 assert!(
1395 ui.scroll_regions().iter().any(|area| area.page.is_some()),
1396 "and the swipeable one has a paged panel instead"
1397 );
1398 }
1399
1400 fn rects(scene: &frus_core::Scene) -> Vec<(Rect, Color)> {
1402 fn walk(primitives: &[frus_core::Primitive], out: &mut Vec<(Rect, Color)>) {
1403 for p in primitives {
1404 match p {
1405 frus_core::Primitive::Rect { rect, color, .. } => out.push((*rect, *color)),
1406 frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
1407 _ => {}
1408 }
1409 }
1410 }
1411 let mut out = Vec::new();
1412 walk(scene.primitives(), &mut out);
1413 out
1414 }
1415
1416 fn texts(scene: &frus_core::Scene) -> Vec<String> {
1418 fn walk(primitives: &[frus_core::Primitive], out: &mut Vec<String>) {
1419 for p in primitives {
1420 match p {
1421 frus_core::Primitive::Text { text, .. } => out.push(text.clone()),
1422 frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
1423 _ => {}
1424 }
1425 }
1426 }
1427 let mut out = Vec::new();
1428 walk(scene.primitives(), &mut out);
1429 out
1430 }
1431
1432 fn wide(selected: usize) -> TabBar<Msg> {
1435 let mut tabs = TabBar::new(selected, Msg::Select).scrollable(true);
1436 for i in 0..10 {
1437 tabs = tabs.tab(format!("Section {i}"), Text::new("panel"));
1438 }
1439 tabs
1440 }
1441
1442 fn frame(tabs: TabBar<Msg>, runtime: &Runtime) -> crate::Ui<Msg> {
1443 build_ui(&tabs, Size::new(300.0, 400.0), runtime, &Theme::default())
1444 }
1445
1446 fn label_x(ui: &crate::Ui<Msg>, label: &str) -> Option<f32> {
1448 ui.scene().primitives().iter().find_map(|p| match p {
1449 Primitive::Text { position, text, .. } if text == label => Some(position.x),
1450 _ => None,
1451 })
1452 }
1453
1454 #[test]
1467 fn a_scrolling_bar_does_not_push_its_panel_down_the_page() {
1468 let panel = crate::Container::new()
1469 .height(30.0)
1470 .color(Color::rgb(1.0, 0.0, 0.0));
1471 let tabs = TabBar::new(0, Msg::Select)
1472 .scrollable(true)
1473 .tab("One", panel)
1474 .tab("Two", crate::Container::new().height(30.0));
1475 let ui = build_ui(
1476 &tabs,
1477 Size::new(400.0, 400.0),
1478 &Runtime::default(),
1479 &Theme::default(),
1480 );
1481 let red = ui
1482 .scene()
1483 .primitives()
1484 .iter()
1485 .find_map(|p| match p {
1486 Primitive::Rect { rect, color, .. } if color.r > 0.9 && color.g < 0.1 => {
1487 Some(*rect)
1488 }
1489 _ => None,
1490 })
1491 .expect("the panel is drawn");
1492 let bar = TAB_HEIGHT;
1495 assert!(
1496 (red.y - bar).abs() <= 4.0,
1497 "the panel starts under the bar ({bar}), not at {}",
1498 red.y
1499 );
1500 }
1501
1502 #[test]
1506 fn a_scrolling_bar_opens_on_its_selected_tab() {
1507 let runtime = Runtime::default();
1508 let ui = frame(wide(8), &runtime);
1509 let area = ui.scroll_regions()[0];
1510 let keep = area
1511 .keep_visible
1512 .expect("the bar keeps its selected tab in view");
1513 assert_eq!(keep.key, 8);
1514 assert!(keep.centre, "centred, as the reference's bar is");
1515
1516 let x = label_x(&ui, "Section 8").expect("the selected tab is drawn");
1518 assert!(x > 0.0 && x < 300.0, "Section 8 drawn at {x}");
1519 assert!(label_x(&ui, "Section 0").is_none_or(|x| x < 0.0));
1521 }
1522
1523 #[test]
1526 fn a_bar_that_shares_its_width_asks_for_nothing() {
1527 let runtime = Runtime::default();
1528 let mut tabs = TabBar::new(8, Msg::Select);
1529 for i in 0..10 {
1530 tabs = tabs.tab(format!("Section {i}"), Text::new("panel"));
1531 }
1532 assert!(frame(tabs, &runtime).scroll_regions().is_empty());
1533 }
1534
1535 #[test]
1538 fn changing_the_tab_slides_the_bar() {
1539 let mut runtime = Runtime::default();
1540 let regions = frame(wide(0), &runtime).scroll_regions().to_vec();
1541 let id = regions[0].id;
1542 runtime.sync_visible(®ions);
1544 assert_eq!(runtime.scroll[&id], (0.0, 0.0));
1545 assert!(!runtime.scroll_target.contains_key(&id));
1546
1547 let regions = frame(wide(8), &runtime).scroll_regions().to_vec();
1548 runtime.sync_visible(®ions);
1549 let target = runtime.scroll_target[&id];
1550 assert!(
1551 target.0 > 100.0,
1552 "glides across to the ninth tab: {target:?}"
1553 );
1554 assert_eq!(runtime.scroll[&id], (0.0, 0.0), "a target, not a jump");
1555 }
1556
1557 #[test]
1561 fn the_same_tab_twice_does_not_fight_the_finger() {
1562 let mut runtime = Runtime::default();
1563 let regions = frame(wide(3), &runtime).scroll_regions().to_vec();
1564 let id = regions[0].id;
1565 runtime.sync_visible(®ions);
1566 runtime.scroll_target.remove(&id);
1567
1568 runtime.scroll.insert(id, (500.0, 0.0));
1570 let regions = frame(wide(3), &runtime).scroll_regions().to_vec();
1571 runtime.sync_visible(®ions);
1572 assert!(
1573 !runtime.scroll_target.contains_key(&id),
1574 "nothing pulls it back"
1575 );
1576 assert_eq!(runtime.scroll[&id], (500.0, 0.0));
1577 }
1578
1579 fn drawn_bar_height(tabs: TabBar<Msg>) -> f32 {
1581 let root = crate::flex::Flex::column()
1582 .width(400.0)
1583 .height(300.0)
1584 .child(tabs);
1585 let mut runtime = Runtime::default();
1586 runtime.advance_values::<Msg>(&root, 0.0);
1587 build_ui(&root, Size::new(400.0, 300.0), &runtime, &Theme::default())
1588 .scene()
1589 .primitives()
1590 .iter()
1591 .filter_map(|p| match p {
1592 Primitive::Rect { rect, .. } if rect.width > 399.0 && rect.height < 2.0 => {
1594 Some(rect.y + rect.height)
1595 }
1596 _ => None,
1597 })
1598 .next()
1599 .expect("the hairline is drawn")
1600 }
1601
1602 fn drawn(tabs: TabBar<Msg>) -> (usize, Vec<String>) {
1604 let root = crate::flex::Flex::column()
1605 .width(400.0)
1606 .height(300.0)
1607 .child(tabs);
1608 let mut runtime = Runtime::default();
1609 runtime.advance_values::<Msg>(&root, 0.0);
1610 let ui = build_ui(&root, Size::new(400.0, 300.0), &runtime, &Theme::default());
1611 let mut paths = 0;
1612 let mut texts = Vec::new();
1613 for p in ui.scene().primitives() {
1614 match p {
1615 Primitive::Path { .. } => paths += 1,
1616 Primitive::Text { text, .. } => texts.push(text.clone()),
1617 _ => {}
1618 }
1619 }
1620 (paths, texts)
1621 }
1622
1623 #[test]
1625 fn an_icon_tab_draws_its_icon_and_its_label() {
1626 let tabs = TabBar::new(0, Msg::Select)
1627 .icon_tab(Icons::STAR, "Home", Text::new("panel"))
1628 .icon_tab(Icons::FAVORITE, "Search", Text::new("panel"));
1629 let (paths, texts) = drawn(tabs);
1630 assert_eq!(paths, 2, "one icon per tab");
1631 assert!(texts.contains(&"Home".to_string()));
1632 assert!(texts.contains(&"Search".to_string()));
1633 }
1634
1635 #[test]
1638 fn one_icon_tab_raises_the_whole_row() {
1639 let plain = TabBar::new(0, Msg::Select)
1640 .tab("One", Text::new("panel"))
1641 .tab("Two", Text::new("panel"));
1642 let mixed = TabBar::new(0, Msg::Select)
1643 .tab("One", Text::new("panel"))
1644 .icon_tab(Icons::FAVORITE, "Two", Text::new("panel"));
1645 let short = drawn_bar_height(plain);
1646 let tall = drawn_bar_height(mixed);
1647 assert!(
1648 (short - (TAB_HEIGHT + TAB_INDICATOR_PRIMARY)).abs() < 0.5,
1649 "{short}"
1650 );
1651 assert!(
1652 (tall - (TAB_ICON_HEIGHT + TAB_INDICATOR_PRIMARY)).abs() < 0.5,
1653 "{tall}"
1654 );
1655 }
1656
1657 #[test]
1660 fn an_icon_only_tab_is_short_and_still_named() {
1661 let tabs = TabBar::new(0, Msg::Select)
1662 .icon_only_tab(Icons::STAR, "Home", Text::new("panel"))
1663 .icon_only_tab(Icons::FAVORITE, "Search", Text::new("panel"));
1664 let (paths, texts) = drawn(tabs);
1665 assert_eq!(paths, 2, "the icons");
1666 assert!(!texts.contains(&"Home".to_string()), "no label is drawn");
1667
1668 let short = drawn_bar_height(TabBar::new(0, Msg::Select).icon_only_tab(
1669 Icons::STAR,
1670 "Home",
1671 Text::new("panel"),
1672 ));
1673 assert!(
1674 (short - (TAB_HEIGHT + TAB_INDICATOR_PRIMARY)).abs() < 0.5,
1675 "{short}"
1676 );
1677
1678 let root = crate::flex::Flex::column()
1680 .width(400.0)
1681 .height(300.0)
1682 .child(TabBar::new(0, Msg::Select).icon_only_tab(
1683 Icons::STAR,
1684 "Home",
1685 Text::new("panel"),
1686 ));
1687 let ui = build_ui(
1688 &root,
1689 Size::new(400.0, 300.0),
1690 &Runtime::default(),
1691 &Theme::default(),
1692 );
1693 assert!(
1694 ui.semantics()
1695 .iter()
1696 .any(|(_, _, s)| s.label.as_deref() == Some("Home")),
1697 "the icon-only tab still has a name"
1698 );
1699 }
1700
1701 #[test]
1704 fn a_stacked_tab_is_as_wide_as_its_widest_part() {
1705 let style = TabStyle::default();
1706 let theme = Theme::default();
1707 let narrow = TabSpec {
1708 icon: Some(Icons::STAR),
1709 ..TabSpec::text("Hi")
1710 };
1711 assert_eq!(
1713 style.scrolled_tab_width(&theme, &narrow),
1714 TAB_ICON_SIZE + 2.0 * TAB_LABEL_PADDING
1715 );
1716 let wide = TabSpec {
1718 icon: Some(Icons::STAR),
1719 ..TabSpec::text("Notifications")
1720 };
1721 assert!(
1722 style.scrolled_tab_width(&theme, &wide) > style.scrolled_tab_width(&theme, &narrow)
1723 );
1724 }
1725
1726 fn label_xs(tabs: TabBar<Msg>, width: f32) -> Vec<f32> {
1728 let root = crate::flex::Flex::column()
1729 .width(width)
1730 .height(300.0)
1731 .child(tabs);
1732 let mut runtime = Runtime::default();
1733 runtime.advance_values::<Msg>(&root, 0.0);
1734 let ui = build_ui(&root, Size::new(width, 300.0), &runtime, &Theme::default());
1735 let mut xs: Vec<f32> = ui
1739 .scene()
1740 .primitives()
1741 .iter()
1742 .filter_map(|p| match p {
1743 Primitive::Text { position, text, .. } if text == "One" || text == "Two" => {
1744 Some(position.x)
1745 }
1746 _ => None,
1747 })
1748 .collect();
1749 xs.sort_by(f32::total_cmp);
1750 xs
1751 }
1752
1753 fn two(alignment: TabAlignment, scrolls: bool) -> TabBar<Msg> {
1754 TabBar::new(0, Msg::Select)
1755 .scrollable(scrolls)
1756 .alignment(alignment)
1757 .tab("One", crate::text(""))
1758 .tab("Two", crate::text(""))
1759 }
1760
1761 #[test]
1764 fn auto_is_whatever_the_bar_already_did() {
1765 assert_eq!(TabAlignment::Auto.resolved(false), TabAlignment::Fill);
1766 assert_eq!(TabAlignment::Auto.resolved(true), TabAlignment::Start);
1767 assert_eq!(TabAlignment::Fill.resolved(true), TabAlignment::Start);
1770 assert_eq!(TabAlignment::Center.resolved(false), TabAlignment::Center);
1772 assert_eq!(TabAlignment::Center.resolved(true), TabAlignment::Start);
1773 assert_eq!(TabAlignment::Start.resolved(false), TabAlignment::Start);
1774 }
1775
1776 #[test]
1779 fn the_tabs_go_where_they_were_told() {
1780 let filled = label_xs(two(TabAlignment::Fill, false), 600.0);
1781 let started = label_xs(two(TabAlignment::Start, false), 600.0);
1782 let centred = label_xs(two(TabAlignment::Center, false), 600.0);
1783
1784 assert!(filled[1] - filled[0] > 250.0, "{filled:?}");
1786 assert!((started[0] - TAB_LABEL_PADDING).abs() < 1.0, "{started:?}");
1788 assert!(started[1] - started[0] < 120.0, "{started:?}");
1789 let gap = |xs: &[f32]| xs[1] - xs[0];
1791 assert!((gap(¢red) - gap(&started)).abs() < 1.0, "{centred:?}");
1792 assert!(centred[0] > started[0] + 100.0, "{centred:?}");
1793 let width: f32 = (0..2)
1795 .map(|i| {
1796 TabStyle::default().scrolled_tab_width(
1797 &Theme::default(),
1798 &TabSpec::text(if i == 0 { "One" } else { "Two" }),
1799 )
1800 })
1801 .sum();
1802 assert!(
1803 (centred[0] - TAB_LABEL_PADDING - (600.0 - width) / 2.0).abs() < 1.0,
1804 "{centred:?}"
1805 );
1806 }
1807
1808 #[test]
1811 fn the_start_offset_moves_the_whole_row() {
1812 let started = label_xs(two(TabAlignment::Start, false), 600.0);
1813 let offset = label_xs(two(TabAlignment::StartOffset, false), 600.0);
1814 for (a, b) in started.iter().zip(&offset) {
1815 assert!(
1816 (b - a - TAB_START_OFFSET).abs() < 1.0,
1817 "{started:?} {offset:?}"
1818 );
1819 }
1820 }
1821
1822 #[test]
1832 fn a_scrolling_bar_cannot_centre_and_does_not_pretend() {
1833 let started = label_xs(two(TabAlignment::Start, true), 600.0);
1834 let centred = label_xs(two(TabAlignment::Center, true), 600.0);
1835 assert_eq!(started, centred);
1836 assert!((started[0] - TAB_LABEL_PADDING).abs() < 1.0, "{started:?}");
1837 let still = label_xs(two(TabAlignment::Center, false), 600.0);
1839 assert!(still[0] > started[0] + 100.0, "{still:?}");
1840 }
1841
1842 #[test]
1845 fn the_indicator_follows_the_alignment() {
1846 let indicator = |alignment: TabAlignment| {
1847 let root = crate::flex::Flex::column()
1848 .width(600.0)
1849 .height(300.0)
1850 .child(two(alignment, false));
1851 let mut runtime = Runtime::default();
1852 runtime.advance_values::<Msg>(&root, 0.0);
1853 build_ui(&root, Size::new(600.0, 300.0), &runtime, &Theme::default())
1854 .scene()
1855 .primitives()
1856 .iter()
1857 .filter_map(|p| match p {
1858 Primitive::Rect { rect, .. }
1860 if (rect.height - TAB_INDICATOR_PRIMARY).abs() < 0.5 =>
1861 {
1862 Some(rect.x + rect.width / 2.0)
1863 }
1864 _ => None,
1865 })
1866 .next()
1867 .expect("an indicator")
1868 };
1869 let started = indicator(TabAlignment::Start);
1870 let centred = indicator(TabAlignment::Center);
1871 let labels_started = label_xs(two(TabAlignment::Start, false), 600.0);
1872 let labels_centred = label_xs(two(TabAlignment::Center, false), 600.0);
1873 assert!(
1875 ((centred - started) - (labels_centred[0] - labels_started[0])).abs() < 1.0,
1876 "{started} {centred}"
1877 );
1878 }
1879
1880 fn three(selected: usize) -> TabBar<Msg> {
1881 TabBar::new(selected, Msg::Select)
1882 .tab("One", Text::new("panel one"))
1883 .tab("Two", Text::new("panel two"))
1884 .tab("Three", Text::new("panel three"))
1885 }
1886
1887 #[test]
1892 fn a_tab_set_takes_the_width_it_is_offered() {
1893 let root = crate::flex::Flex::row()
1894 .width(300.0)
1895 .height(200.0)
1896 .justify(frus_layout::Justify::Center)
1897 .child(three(0));
1898 let mut runtime = Runtime::default();
1899 runtime.advance_values::<Msg>(&root, 0.0);
1900 let ui = build_ui(&root, Size::new(300.0, 200.0), &runtime, &Theme::default());
1901 let widest = ui
1904 .scene()
1905 .primitives()
1906 .iter()
1907 .filter_map(|p| match p {
1908 Primitive::Rect { rect, .. } => Some(rect.width),
1909 _ => None,
1910 })
1911 .fold(0.0_f32, f32::max);
1912 assert!(
1913 (widest - 300.0).abs() < 0.5,
1914 "the whole row, not the widest label: {widest}"
1915 );
1916 }
1917
1918 fn boxes(tabs: TabBar<Msg>) -> Vec<(Rect, Color)> {
1920 let root = crate::flex::Flex::column()
1921 .width(300.0)
1922 .height(200.0)
1923 .child(tabs);
1924 let mut runtime = Runtime::default();
1927 runtime.advance_values::<Msg>(&root, 0.0);
1928 build_ui(&root, Size::new(300.0, 200.0), &runtime, &Theme::default())
1929 .scene()
1930 .primitives()
1931 .iter()
1932 .filter_map(|p| match p {
1933 Primitive::Rect {
1934 rect, color, blur, ..
1935 } if *blur == 0.0 => Some((*rect, *color)),
1936 _ => None,
1937 })
1938 .collect()
1939 }
1940
1941 #[test]
1942 fn shows_bar_and_selected_panel() {
1943 let tabs = three(1);
1944 let children = Widget::<Msg>::children(&tabs);
1946 assert_eq!(children.len(), 2);
1947 assert_eq!(children[0].children().len(), 3, "three tabs in the bar");
1948 }
1949
1950 #[test]
1951 fn no_panel_when_selection_out_of_range() {
1952 let tabs = TabBar::new(9, Msg::Select).tab("One", Text::new("x"));
1953 assert_eq!(Widget::<Msg>::children(&tabs).len(), 1); }
1955
1956 #[test]
1957 fn the_indicator_sits_under_the_selected_tab() {
1958 let theme = Theme::default();
1961 let painted = boxes(three(1));
1962 let indicator = painted
1963 .iter()
1964 .find(|(_, color)| *color == theme.scheme.primary)
1965 .expect("an indicator in the accent colour");
1966 let centre = indicator.0.x + indicator.0.width / 2.0;
1968 assert!(
1969 (centre - 150.0).abs() < 1.0,
1970 "centred on the second tab, not at {centre}"
1971 );
1972 assert_eq!(indicator.0.height, TAB_INDICATOR_PRIMARY);
1973 }
1974
1975 #[test]
1976 fn a_primary_indicator_is_as_wide_as_its_label_and_a_secondary_one_as_wide_as_its_tab() {
1977 let theme = Theme::default();
1980 let width = |tabs: TabBar<Msg>| {
1981 boxes(tabs)
1982 .iter()
1983 .find(|(_, color)| *color == theme.scheme.primary)
1984 .expect("an indicator")
1985 .0
1986 .width
1987 };
1988 let primary = width(three(1));
1989 let secondary = width(three(1).variant(TabBarVariant::Secondary));
1990 assert_eq!(secondary, 100.0, "a third of 300 — the whole tab");
1991 assert!(
1992 primary < secondary,
1993 "a label is narrower than its tab: {primary} against {secondary}"
1994 );
1995 assert!(primary > 0.0, "and it is not nothing");
1996 }
1997
1998 #[test]
1999 fn the_hairline_runs_the_whole_bar() {
2000 let theme = Theme::default();
2001 let painted = boxes(three(0));
2002 let line = painted
2003 .iter()
2004 .find(|(_, color)| *color == theme.scheme.outline_variant)
2005 .expect("a divider");
2006 assert_eq!(line.0.width, 300.0, "across the bar, not across a tab");
2007 assert_eq!(line.0.height, TAB_DIVIDER_HEIGHT);
2008 let indicator = painted
2010 .iter()
2011 .find(|(_, color)| *color == theme.scheme.primary)
2012 .expect("an indicator");
2013 assert_eq!(
2014 line.0.y + line.0.height,
2015 indicator.0.y + indicator.0.height,
2016 "the indicator sits on the hairline"
2017 );
2018 }
2019
2020 #[test]
2021 fn the_bar_is_the_tabs_plus_the_indicator() {
2022 let theme = Theme::default();
2023 let tabs = three(0);
2024 let bar = &Widget::<Msg>::children(&tabs)[0];
2025 assert_eq!(
2026 bar.style_themed(&theme).height,
2027 Dimension::Length(TAB_HEIGHT + TAB_INDICATOR_PRIMARY)
2028 );
2029 }
2030
2031 #[test]
2032 fn every_measurement_is_the_callers_and_then_the_themes() {
2033 let mut theme = Theme::default();
2035 theme.widgets.tab_bar.tab_height = Some(60.0);
2036 let height = |tabs: TabBar<Msg>, theme: &Theme| {
2037 Widget::<Msg>::style_themed(&Widget::<Msg>::children(&tabs)[0], theme).height
2038 };
2039 assert_eq!(
2040 height(three(0), &Theme::default()),
2041 Dimension::Length(TAB_HEIGHT + TAB_INDICATOR_PRIMARY),
2042 "the framework's"
2043 );
2044 assert_eq!(
2045 height(three(0), &theme),
2046 Dimension::Length(60.0 + TAB_INDICATOR_PRIMARY),
2047 "the theme's"
2048 );
2049 assert_eq!(
2050 height(three(0).tab_height(20.0), &theme),
2051 Dimension::Length(20.0 + TAB_INDICATOR_PRIMARY),
2052 "the caller's, over the theme's"
2053 );
2054 }
2055
2056 #[test]
2057 fn a_builder_after_the_tabs_still_reaches_them() {
2058 let painted = boxes(three(1).indicator_weight(9.0));
2061 assert!(
2062 painted.iter().any(|(rect, _)| rect.height == 9.0),
2063 "the indicator took the weight set after the tabs"
2064 );
2065 }
2066
2067 #[test]
2068 fn tabs_are_tabs_to_a_screen_reader() {
2069 let tabs = three(0);
2070 let bar = &Widget::<Msg>::children(&tabs)[0];
2071 let first = &bar.children()[0];
2072 let semantics = first.semantics().expect("a tab announces itself");
2073 assert_eq!(semantics.role, frus_core::Role::Tab);
2074 assert_eq!(semantics.label.as_deref(), Some("One"));
2075 assert!(semantics.clickable);
2076 }
2077
2078 #[test]
2079 fn a_tab_emits_its_index() {
2080 let tabs = three(0);
2081 let bar = &Widget::<Msg>::children(&tabs)[0];
2082 assert_eq!(bar.children()[2].on_click(), Some(Msg::Select(2)));
2083 }
2084
2085 #[test]
2086 fn a_disabled_bar_is_inert_but_keeps_its_panel() {
2087 let dead = TabBar::new(1, Msg::Select)
2088 .tab("One", Text::new("first"))
2089 .tab("Two", Text::new("second"))
2090 .enabled(false);
2091 assert_eq!(
2093 Widget::<Msg>::children(&dead).len(),
2094 2,
2095 "the bar and its panel"
2096 );
2097 let bar = &Widget::<Msg>::children(&dead)[0];
2098 for (i, tab) in bar.children().iter().enumerate() {
2099 assert_eq!(tab.on_click(), None, "tab {i} still answers");
2100 assert!(!tab.focusable(), "tab {i} is still in the tab order");
2101 assert!(
2102 tab.ink(&Theme::default()).is_none(),
2103 "tab {i} still splashes"
2104 );
2105 let semantics = tab.semantics().expect("still announced");
2106 assert!(semantics.disabled, "tab {i} does not say it is disabled");
2107 }
2108 assert_eq!(
2110 bar.children()[1].semantics().unwrap().toggled,
2111 frus_core::Toggled::True
2112 );
2113 }
2114
2115 #[test]
2116 fn a_disabled_bar_flattens_rather_than_fading_the_accent() {
2117 let theme = Theme::default();
2119 let dead = TabBar::new(0, Msg::Select)
2120 .tab("One", Text::new("first"))
2121 .tab("Two", Text::new("second"))
2122 .enabled(false);
2123 let bar = &Widget::<Msg>::children(&dead)[0];
2124 for tab in bar.children() {
2125 let mut scene = frus_core::Scene::new();
2126 tab.paint(
2127 Rect::new(0.0, 0.0, 80.0, 48.0),
2128 Status {
2129 opacity: 1.0,
2130 ..Default::default()
2131 },
2132 &theme,
2133 &mut scene,
2134 );
2135 let color = scene
2136 .primitives()
2137 .iter()
2138 .find_map(|p| match p {
2139 Primitive::Text { color, .. } => Some(*color),
2140 _ => None,
2141 })
2142 .expect("a label");
2143 assert_eq!(color, disabled_content(&theme));
2144 }
2145 }
2146
2147 #[test]
2150 fn a_disabled_bars_indicator_loses_the_accent_too() {
2151 let theme = Theme::default();
2152 let indicator = |enabled: bool| {
2153 let tabs = TabBar::new(0, Msg::Select)
2154 .tab("One", Text::new("first"))
2155 .tab("Two", Text::new("second"))
2156 .enabled(enabled);
2157 let bar = &Widget::<Msg>::children(&tabs)[0];
2158 let mut scene = frus_core::Scene::new();
2159 bar.paint(
2160 Rect::new(0.0, 0.0, 200.0, 48.0),
2161 Status {
2162 opacity: 1.0,
2163 ..Default::default()
2164 },
2165 &theme,
2166 &mut scene,
2167 );
2168 scene
2170 .primitives()
2171 .iter()
2172 .rev()
2173 .find_map(|p| match p {
2174 Primitive::Rect { color, .. } => Some(*color),
2175 _ => None,
2176 })
2177 .expect("an indicator")
2178 };
2179 assert_eq!(indicator(false), disabled_content(&theme));
2180 assert_ne!(indicator(true), disabled_content(&theme));
2181 assert_ne!(
2182 indicator(false),
2183 indicator(true),
2184 "the instrument must be able to tell the two apart"
2185 );
2186 }
2187}
2188
2189#[cfg(test)]
2191mod scrollable_tests {
2192 use super::*;
2193 use crate::{build_ui, Runtime, Size};
2194 use frus_core::Primitive;
2195
2196 #[derive(Clone, Debug, PartialEq)]
2197 enum Msg {
2198 Pick(usize),
2199 }
2200
2201 const LABELS: [&str; 8] = [
2203 "All", "Unread", "Starred", "Drafts", "Sent", "Archived", "Spam", "Bin",
2204 ];
2205
2206 fn bar(scrollable: bool) -> TabBar<Msg> {
2207 let mut tabs = TabBar::new(0, Msg::Pick);
2208 for label in LABELS {
2209 tabs = tabs.tab(label, crate::text(""));
2210 }
2211 tabs.scrollable(scrollable)
2212 }
2213
2214 fn painted(tabs: TabBar<Msg>, width: f32) -> Vec<frus_core::Rect> {
2216 build_ui(
2217 &tabs,
2218 Size::new(width, 200.0),
2219 &Runtime::default(),
2220 &Theme::default(),
2221 )
2222 .scene()
2223 .primitives()
2224 .iter()
2225 .filter_map(|p| match p {
2226 Primitive::Rect { rect, .. } => Some(*rect),
2227 _ => None,
2228 })
2229 .collect()
2230 }
2231
2232 #[test]
2234 fn a_bar_says_nothing_and_shares_its_width() {
2235 let tabs = bar(false);
2236 let ui = build_ui(
2237 &tabs,
2238 Size::new(360.0, 200.0),
2239 &Runtime::default(),
2240 &Theme::default(),
2241 );
2242 assert!(
2243 ui.scrollable_maxes().is_empty(),
2244 "an ordinary bar registers no scrollable"
2245 );
2246 }
2247
2248 #[test]
2253 fn eight_tabs_on_a_phone_scroll() {
2254 let ui = build_ui(
2255 &bar(true),
2256 Size::new(360.0, 200.0),
2257 &Runtime::default(),
2258 &Theme::default(),
2259 );
2260 let maxes = ui.scrollable_maxes();
2261 assert_eq!(maxes.len(), 1, "the strip is inside a scroll");
2262 assert!(maxes[0].1 > 0.0, "and it has room to move: {:?}", maxes[0]);
2263 assert_eq!(maxes[0].2, 0.0, "across, not down");
2264 }
2265
2266 #[test]
2269 fn a_long_label_gets_a_wide_tab() {
2270 let theme = Theme::default();
2271 let style = TabStyle::default();
2272 let narrow = style.scrolled_tab_width(&theme, &TabSpec::text("Bin"));
2273 let wide = style.scrolled_tab_width(&theme, &TabSpec::text("Archived"));
2274 assert!(wide > narrow, "{wide} should exceed {narrow}");
2275 }
2276
2277 #[test]
2284 fn the_indicator_sits_over_the_tab_it_marks() {
2285 let theme = Theme::default();
2286 let style = TabStyle::default();
2287 let labels: Vec<TabSpec> = LABELS.iter().map(|s| TabSpec::text(*s)).collect();
2288 let spans = style.tab_spans(&theme, &labels, 360.0, TabAlignment::Start);
2289
2290 let mut expected = 0.0;
2291 for (i, label) in labels.iter().enumerate() {
2292 assert!(
2293 (spans[i].0 - expected).abs() < 0.01,
2294 "tab {i} starts at {} rather than {expected}",
2295 spans[i].0
2296 );
2297 expected += style.scrolled_tab_width(&theme, label);
2298 }
2299 let (start, width) = spans[labels.len() - 1];
2301 assert!((start + width - expected).abs() < 0.01);
2302 }
2303
2304 #[test]
2306 fn an_ordinary_bar_still_divides_its_width_evenly() {
2307 let labels: Vec<TabSpec> = LABELS.iter().map(|s| TabSpec::text(*s)).collect();
2308 let spans =
2309 TabStyle::default().tab_spans(&Theme::default(), &labels, 800.0, TabAlignment::Fill);
2310 assert_eq!(spans[0], (0.0, 100.0));
2311 assert_eq!(spans[7], (700.0, 100.0));
2312 }
2313
2314 #[test]
2317 fn a_short_scrollable_bar_still_rules_a_full_line() {
2318 let mut tabs = TabBar::new(0, Msg::Pick);
2319 tabs = tabs.tab("A", crate::text("")).tab("B", crate::text(""));
2320 let rects = painted(tabs.scrollable(true), 400.0);
2321 assert!(
2322 rects.iter().any(|r| r.width >= 399.0),
2323 "something runs the full width: {rects:?}"
2324 );
2325 }
2326}