1use std::rc::Rc;
151use std::time::Duration;
152
153use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
154use frust::authoring::{
155 BoxConstraints, BuildCtx, ChangeFlags, CornerRadii, ErasedCallback, EventCtx, EventResult,
156 InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx, View, Widget,
157};
158use frust::{AnimationController, FrameTime, IconData, IconSource, SpringDesc, Theme};
159use kurbo::{Affine, BezPath, Point, Rect, Size, Vec2};
160use peniko::{Brush, Color};
161
162use crate::interaction::{
163 DISABLED_CONTENT_OPACITY, HapticSignal, InteractionState, MaterialHaptics,
164};
165use crate::press::presses;
166
167pub const MIN_TABS: usize = 2;
170
171const HEIGHT: f64 = 48.0;
174const ICON_SIZE: f64 = 24.0;
177const INDICATOR_HEIGHT: f64 = 3.0;
180const INDICATOR_CORNER_RADIUS: f64 = 3.0;
184const DIVIDER_WIDTH: f64 = 1.0;
188
189const INDICATOR_SPRING: SpringDesc = SpringDesc {
195 mass: 1.0,
196 stiffness: 700.0,
197 damping_ratio: 0.9,
198};
199const INDICATOR_ANIM_PERIOD: Duration = Duration::from_millis(300);
204const INDICATOR_FLING_VELOCITY: f64 = 4.0;
208const INDICATOR_RETARGET_TOLERANCE: f64 = 0.1;
213
214const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
216const SURFACE: Color = Color::from_rgb8(0xFE, 0xF7, 0xFF);
218const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
220const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
222const SURFACE_CONTAINER_HIGHEST: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
224
225const TITLE_SMALL: (f32, f32, f32, FontWeight) = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
229
230const SHAPING_INK: Color = Color::BLACK;
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
239pub enum TabsVariant {
240 #[default]
243 Primary,
244 Secondary,
247}
248
249struct Colors {
257 background: Color,
258 divider: Color,
259 primary: Color,
260 on_surface: Color,
261 on_surface_variant: Color,
262}
263
264fn resolve_colors(theme: Option<&Theme>) -> Colors {
265 match theme {
266 Some(theme) => {
267 let s = theme.scheme();
268 Colors {
269 background: s.surface,
270 divider: s.surface_container_highest,
271 primary: s.primary,
272 on_surface: s.on_surface,
273 on_surface_variant: s.on_surface_variant,
274 }
275 }
276 None => Colors {
277 background: SURFACE,
278 divider: SURFACE_CONTAINER_HIGHEST,
279 primary: PRIMARY,
280 on_surface: ON_SURFACE,
281 on_surface_variant: ON_SURFACE_VARIANT,
282 },
283 }
284}
285
286fn with_alpha(color: Color, alpha: f32) -> Color {
288 let c = color.components;
289 Color::new([c[0], c[1], c[2], alpha])
290}
291
292fn tab_fg(colors: &Colors, selected: bool, enabled: bool) -> Color {
299 if !enabled {
300 return with_alpha(colors.on_surface, DISABLED_CONTENT_OPACITY);
301 }
302 if selected {
303 colors.primary
304 } else {
305 colors.on_surface_variant
306 }
307}
308
309fn label_style(theme: Option<&Theme>) -> TextStyle {
313 let mut style = match theme {
314 Some(theme) => theme.type_scale.title_small.clone(),
315 None => {
316 let (size, line_height, letter_spacing, weight) = TITLE_SMALL;
317 let mut style = TextStyle::new(size, SHAPING_INK);
318 style.line_height = LineHeight::Absolute(line_height);
319 style.letter_spacing = letter_spacing;
320 style.weight = weight;
321 style
322 }
323 };
324 style.color = SHAPING_INK;
325 style
326}
327
328fn same_icon_source(a: Option<IconSource>, b: Option<IconSource>) -> bool {
332 match (a, b) {
333 (Some(x), Some(y)) => x.d == y.d && x.design == y.design,
334 (None, None) => true,
335 _ => false,
336 }
337}
338
339struct LabelRun {
345 content: String,
346 layout: Option<TextLayout>,
347 shaped_for: Option<TextStyle>,
348}
349
350impl LabelRun {
351 fn new(content: String) -> Self {
352 Self {
353 content,
354 layout: None,
355 shaped_for: None,
356 }
357 }
358
359 fn set_content(&mut self, content: &str) {
360 if self.content != content {
361 self.content = content.to_string();
362 self.layout = None;
363 self.shaped_for = None;
364 }
365 }
366
367 fn content(&self) -> &str {
368 &self.content
369 }
370
371 fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
372 if let Some(cached) = &self.layout
373 && self.shaped_for.as_ref() == Some(style)
374 {
375 return cached.size();
376 }
377 let text_ctx = ctx.text_context::<TextContext>();
378 let laid = text_ctx.layout(&self.content, style, None);
379 let size = laid.size();
380 self.layout = Some(laid);
381 self.shaped_for = Some(style.clone());
382 size
383 }
384
385 fn size(&self) -> Size {
386 self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
387 }
388
389 fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
390 let Some(layout) = &self.layout else {
391 return;
392 };
393 for mut run in layout.to_scene_runs(origin) {
394 run.brush = Brush::Solid(color);
395 scene.draw_glyph_run(run);
396 }
397 }
398}
399
400fn lerp(from: f64, to: f64, t: f64) -> f64 {
406 from + (to - from) * t
407}
408
409#[derive(Clone, Copy, Debug)]
414struct IndicatorMotion {
415 from_left: f64,
416 to_left: f64,
417 from_width: f64,
418 to_width: f64,
419 anim: AnimationController,
420 seeded: bool,
424}
425
426impl IndicatorMotion {
427 fn new() -> Self {
431 Self {
432 from_left: 0.0,
433 to_left: 0.0,
434 from_width: 0.0,
435 to_width: 0.0,
436 anim: AnimationController::new(INDICATOR_ANIM_PERIOD),
437 seeded: false,
438 }
439 }
440
441 fn retarget(&mut self, left: f64, width: f64) -> bool {
448 if !self.seeded {
449 self.snap_to(left, width);
450 return false;
451 }
452 if (self.to_left - left).abs() <= INDICATOR_RETARGET_TOLERANCE
453 && (self.to_width - width).abs() <= INDICATOR_RETARGET_TOLERANCE
454 {
455 return false;
456 }
457 self.from_left = self.left();
458 self.from_width = self.width();
459 self.to_left = left;
460 self.to_width = width;
461 self.anim = AnimationController::new(INDICATOR_ANIM_PERIOD);
462 self.anim.fling(INDICATOR_FLING_VELOCITY, INDICATOR_SPRING);
463 true
464 }
465
466 fn snap_to(&mut self, left: f64, width: f64) {
469 self.from_left = left;
470 self.to_left = left;
471 self.from_width = width;
472 self.to_width = width;
473 self.anim = AnimationController::new(INDICATOR_ANIM_PERIOD);
474 self.seeded = true;
475 }
476
477 fn snap(&mut self) {
485 self.from_left = self.to_left;
486 self.from_width = self.to_width;
487 self.anim.stop();
488 }
489
490 fn advance(&mut self, now: FrameTime) -> bool {
493 self.anim.advance(now)
494 }
495
496 fn is_seeded(&self) -> bool {
499 self.seeded
500 }
501
502 fn factor(&self) -> f64 {
503 let raw = self.anim.value();
504 if raw.is_finite() { raw } else { 0.0 }
505 }
506
507 fn left(&self) -> f64 {
509 lerp(self.from_left, self.to_left, self.factor())
510 }
511
512 fn width(&self) -> f64 {
516 lerp(self.from_width, self.to_width, self.factor()).max(0.0)
517 }
518}
519
520pub struct Tab {
525 label: Option<String>,
526 icon: Option<IconSource>,
527 enabled: bool,
528}
529
530pub fn tab() -> Tab {
534 Tab {
535 label: None,
536 icon: None,
537 enabled: true,
538 }
539}
540
541impl Tab {
542 pub fn label(mut self, label: impl Into<String>) -> Self {
544 self.label = Some(label.into());
545 self
546 }
547
548 pub fn icon(mut self, icon: IconSource) -> Self {
552 self.icon = Some(icon);
553 self
554 }
555
556 pub fn enabled(mut self, enabled: bool) -> Self {
560 self.enabled = enabled;
561 self
562 }
563}
564
565type OnSelected<State> = Rc<dyn Fn(&mut State, usize)>;
567
568pub struct TabsView<State: 'static> {
570 tabs: Vec<Tab>,
571 selected: usize,
572 variant: TabsVariant,
573 on_selected: OnSelected<State>,
574}
575
576pub fn tabs<State: 'static, F: Fn(&mut State, usize) + 'static>(
584 tabs: impl IntoIterator<Item = Tab>,
585 selected: usize,
586 on_selected: F,
587) -> TabsView<State> {
588 let tabs: Vec<Tab> = tabs.into_iter().collect();
589 debug_assert!(
590 tabs.len() >= MIN_TABS,
591 "a tab bar needs {MIN_TABS}+ tabs (m3e_tabs.dart:27), got {}",
592 tabs.len()
593 );
594 for t in &tabs {
595 debug_assert!(
596 t.label.is_some() || t.icon.is_some(),
597 "a tab needs a label or an icon (m3e_tab.dart:8-11)"
598 );
599 }
600 TabsView {
601 tabs,
602 selected,
603 variant: TabsVariant::Primary,
604 on_selected: Rc::new(on_selected),
605 }
606}
607
608#[allow(non_snake_case)]
610pub fn Tabs<State: 'static, F: Fn(&mut State, usize) + 'static>(
611 tabs_: impl IntoIterator<Item = Tab>,
612 selected: usize,
613 on_selected: F,
614) -> TabsView<State> {
615 tabs(tabs_, selected, on_selected)
616}
617
618impl<State: 'static> TabsView<State> {
619 pub fn variant(mut self, variant: TabsVariant) -> Self {
622 self.variant = variant;
623 self
624 }
625}
626
627fn bind_on_tap<State: 'static>(on_selected: &OnSelected<State>, idx: usize) -> ErasedCallback {
633 let callback = on_selected.clone();
634 Box::new(move |ctx: &mut EventCtx| {
635 MaterialHaptics::fire(HapticSignal::None);
636 let state = ctx.state_mut::<State>();
637 callback(state, idx);
638 })
639}
640
641fn bind_on_taps<State: 'static>(
642 tabs: &[Tab],
643 on_selected: &OnSelected<State>,
644) -> Vec<ErasedCallback> {
645 (0..tabs.len())
646 .map(|i| bind_on_tap::<State>(on_selected, i))
647 .collect()
648}
649
650type TabRuntime = (
653 Vec<Option<LabelRun>>,
654 Vec<Option<(BezPath, f64)>>,
655 Vec<bool>,
656);
657
658fn build_tab_runtime(tabs: &[Tab]) -> TabRuntime {
661 let labels = tabs
662 .iter()
663 .map(|t| t.label.as_ref().map(|l| LabelRun::new(l.clone())))
664 .collect();
665 let icon_geoms = tabs
666 .iter()
667 .map(|t| t.icon.map(|src| IconData::from(src).resolve()))
668 .collect();
669 let enabled_flags = tabs.iter().map(|t| t.enabled).collect();
670 (labels, icon_geoms, enabled_flags)
671}
672
673pub struct TabsWidget {
675 labels: Vec<Option<LabelRun>>,
677 icon_geoms: Vec<Option<(BezPath, f64)>>,
679 enabled_flags: Vec<bool>,
682 selected: usize,
683 variant: TabsVariant,
684 tab_rects: Vec<Rect>,
689 content_rects: Vec<Rect>,
693 interactions: Vec<InteractionState>,
696 indicator: IndicatorMotion,
699 armed: bool,
701 active_tab: Option<usize>,
703 pressed_inside: bool,
705 on_taps: Vec<ErasedCallback>,
707}
708
709impl TabsWidget {
710 fn tab_at(&self, pos: Point) -> Option<usize> {
712 self.tab_rects.iter().position(|r| r.contains(pos))
713 }
714
715 fn indicator_target(&self, i: usize) -> (f64, f64) {
721 let rect = match self.variant {
722 TabsVariant::Primary => self.content_rects.get(i).copied().unwrap_or(Rect::ZERO),
723 TabsVariant::Secondary => self.tab_rects.get(i).copied().unwrap_or(Rect::ZERO),
724 };
725 (rect.x0, rect.width())
726 }
727}
728
729impl<State: 'static> View<State> for TabsView<State> {
730 type Element = TabsWidget;
731
732 fn build(&self, _ctx: &mut BuildCtx<'_>) -> TabsWidget {
733 let (labels, icon_geoms, enabled_flags) = build_tab_runtime(&self.tabs);
734 let on_taps = bind_on_taps::<State>(&self.tabs, &self.on_selected);
735 let n = self.tabs.len();
736 TabsWidget {
737 labels,
738 icon_geoms,
739 enabled_flags,
740 selected: self.selected,
741 variant: self.variant,
742 tab_rects: vec![Rect::ZERO; n],
743 content_rects: vec![Rect::ZERO; n],
744 interactions: vec![InteractionState::new(); n],
745 indicator: IndicatorMotion::new(),
746 armed: false,
747 active_tab: None,
748 pressed_inside: false,
749 on_taps,
750 }
751 }
752
753 fn rebuild(
754 &self,
755 prev: &Self,
756 element: &mut TabsWidget,
757 _ctx: &mut BuildCtx<'_>,
758 ) -> ChangeFlags {
759 let mut flags = ChangeFlags::NONE;
760
761 if prev.tabs.len() != self.tabs.len() {
762 let (labels, icon_geoms, enabled_flags) = build_tab_runtime(&self.tabs);
768 element.labels = labels;
769 element.icon_geoms = icon_geoms;
770 element.enabled_flags = enabled_flags;
771 element.tab_rects = vec![Rect::ZERO; self.tabs.len()];
772 element.content_rects = vec![Rect::ZERO; self.tabs.len()];
773 element.interactions = vec![InteractionState::new(); self.tabs.len()];
774 element.indicator = IndicatorMotion::new();
775 element.armed = false;
776 element.active_tab = None;
777 element.pressed_inside = false;
778 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
779 } else {
780 for (i, (prev_t, next_t)) in prev.tabs.iter().zip(self.tabs.iter()).enumerate() {
781 match (&prev_t.label, &next_t.label) {
782 (None, None) => {}
783 (Some(p), Some(n)) if p == n => {}
784 (_, Some(n)) => {
785 let run =
786 element.labels[i].get_or_insert_with(|| LabelRun::new(String::new()));
787 run.set_content(n);
788 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
789 }
790 (Some(_), None) => {
791 element.labels[i] = None;
792 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
793 }
794 }
795 if !same_icon_source(prev_t.icon, next_t.icon) {
796 element.icon_geoms[i] = next_t.icon.map(|src| IconData::from(src).resolve());
797 flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
798 }
799 if element.enabled_flags[i] != next_t.enabled {
800 element.enabled_flags[i] = next_t.enabled;
801 flags |= ChangeFlags::PAINT;
802 }
803 }
804 }
805
806 let n = self.tabs.len();
807 let next_selected = if n == 0 { 0 } else { self.selected.min(n - 1) };
808 let selection_or_variant_changed =
809 element.selected != next_selected || element.variant != self.variant;
810 element.selected = next_selected;
811 element.variant = self.variant;
812
813 if selection_or_variant_changed {
814 flags |= ChangeFlags::PAINT;
815 if !flags.needs_layout() {
816 let (left, width) = element.indicator_target(element.selected);
823 element.indicator.retarget(left, width);
824 }
825 }
826
827 element.on_taps = bind_on_taps::<State>(&self.tabs, &self.on_selected);
830
831 flags
832 }
833}
834
835impl Widget for TabsWidget {
836 fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
837 let width = if bc.max().width.is_finite() {
838 bc.max().width
839 } else {
840 0.0
841 };
842 let n = self.labels.len();
843 if n == 0 {
844 return bc.constrain(Size::new(width, HEIGHT));
845 }
846
847 let style = {
848 let theme = Theme::from_layout_ctx(ctx);
849 label_style(theme)
850 };
851 let slot_w = width / n as f64;
854
855 self.tab_rects.clear();
856 self.content_rects.clear();
857 for i in 0..n {
858 let has_icon = self.icon_geoms[i].is_some();
859 let (label_w, label_h) = if let Some(run) = self.labels[i].as_mut() {
860 let size = run.shape(ctx, &style);
861 (size.width, size.height)
862 } else {
863 (0.0, 0.0)
864 };
865 let icon_w = if has_icon { ICON_SIZE } else { 0.0 };
866 let icon_h = if has_icon { ICON_SIZE } else { 0.0 };
867 let content_w = label_w.max(icon_w);
871 let content_h = icon_h + label_h;
872
873 let slot_x = i as f64 * slot_w;
874 self.tab_rects
875 .push(Rect::new(slot_x, 0.0, slot_x + slot_w, HEIGHT));
876
877 let content_x = slot_x + (slot_w - content_w) / 2.0;
878 let content_y = (HEIGHT - content_h) / 2.0;
879 self.content_rects.push(Rect::new(
880 content_x,
881 content_y,
882 content_x + content_w,
883 content_y + content_h,
884 ));
885 }
886
887 let selected = self.selected.min(n - 1);
888 let (left, target_width) = self.indicator_target(selected);
889 self.indicator.retarget(left, target_width);
890
891 bc.constrain(Size::new(width, HEIGHT))
892 }
893
894 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
895 if self.indicator.is_seeded() {
901 let reduce_motion = Theme::from_paint_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
902 if reduce_motion {
903 let before = (self.indicator.left(), self.indicator.width());
904 self.indicator.snap();
905 let after = (self.indicator.left(), self.indicator.width());
906 if before != after {
907 ctx.request_frame();
908 }
909 } else if self.indicator.advance(ctx.frame_time()) {
910 ctx.request_frame();
911 }
912 }
913
914 let origin = ctx.origin();
915 let size = ctx.size();
916 let n = self.tab_rects.len();
917
918 let colors = {
919 let theme = Theme::from_paint_ctx(ctx);
920 resolve_colors(theme)
921 };
922
923 scene.fill_rect(origin, size, colors.background);
924 scene.stroke_line(
925 Point::new(origin.x, origin.y + size.height),
926 Point::new(origin.x + size.width, origin.y + size.height),
927 DIVIDER_WIDTH,
928 colors.divider,
929 );
930
931 for i in 0..n {
932 let tab_rect = self.tab_rects[i];
933 let content_rect = self.content_rects[i];
934 let enabled = self.enabled_flags[i];
935 let selected = i == self.selected;
936
937 let opacity = self.interactions[i].resolve_opacity();
940 if opacity > 0.0 {
941 let tab_origin = Point::new(origin.x + tab_rect.x0, origin.y + tab_rect.y0);
942 scene.fill_rect(
943 tab_origin,
944 tab_rect.size(),
945 with_alpha(colors.primary, opacity),
946 );
947 }
948
949 let fg = tab_fg(&colors, selected, enabled);
950 let has_icon = self.icon_geoms[i].is_some();
951 let icon_w = if has_icon { ICON_SIZE } else { 0.0 };
952 let icon_h = if has_icon { ICON_SIZE } else { 0.0 };
953 let label_w = self.labels[i].as_ref().map_or(0.0, |run| run.size().width);
954
955 if let Some((path, design)) = &self.icon_geoms[i] {
956 let scale = if *design > 0.0 {
957 ICON_SIZE / *design
958 } else {
959 1.0
960 };
961 let icon_x = content_rect.x0 + (content_rect.width() - icon_w) / 2.0;
962 let icon_y = content_rect.y0;
963 let transform = Affine::translate(Vec2::new(icon_x, icon_y)) * Affine::scale(scale);
964 let scaled = transform * path.clone();
965 scene.fill_path(origin, &scaled, &Brush::Solid(fg));
966 }
967
968 if let Some(run) = &self.labels[i] {
969 let label_x = content_rect.x0 + (content_rect.width() - label_w) / 2.0;
970 let label_y = content_rect.y0 + icon_h;
971 run.paint(
972 Point::new(origin.x + label_x, origin.y + label_y),
973 fg,
974 scene,
975 );
976 }
977 }
978
979 if self.indicator.is_seeded() {
980 let left = self.indicator.left();
981 let width = self.indicator.width();
982 let indicator_origin =
983 Point::new(origin.x + left, origin.y + HEIGHT - INDICATOR_HEIGHT);
984 scene.fill_rounded_rect_radii(
985 indicator_origin,
986 Size::new(width, INDICATOR_HEIGHT),
987 CornerRadii::new(INDICATOR_CORNER_RADIUS, INDICATOR_CORNER_RADIUS, 0.0, 0.0),
988 colors.primary,
989 );
990 }
991 }
992
993 fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
994 let InputEvent::Pointer(p) = event else {
995 return EventResult::Ignored;
996 };
997 match p.phase {
998 PointerPhase::Down => {
999 if !presses(p) {
1000 return EventResult::Ignored;
1001 }
1002 let Some(i) = self.tab_at(p.position) else {
1003 return EventResult::Ignored;
1004 };
1005 if !self.enabled_flags[i] {
1006 return EventResult::Ignored;
1009 }
1010 self.armed = true;
1011 self.active_tab = Some(i);
1012 self.pressed_inside = true;
1013 self.interactions[i].set_pressed(true);
1014 ctx.capture_pointer();
1015 ctx.request_redraw();
1016 EventResult::Handled
1017 }
1018 PointerPhase::Move => {
1019 if !self.armed {
1020 return EventResult::Ignored;
1021 }
1022 let Some(i) = self.active_tab else {
1023 return EventResult::Ignored;
1024 };
1025 let inside = self
1026 .tab_rects
1027 .get(i)
1028 .is_some_and(|r| r.contains(p.position));
1029 if inside != self.pressed_inside {
1030 self.pressed_inside = inside;
1031 self.interactions[i].set_pressed(inside);
1032 ctx.request_redraw();
1033 }
1034 EventResult::Handled
1035 }
1036 PointerPhase::Up => {
1037 if !self.armed {
1038 return EventResult::Ignored;
1039 }
1040 if let Some(i) = self.active_tab {
1041 self.interactions[i].set_pressed(false);
1042 if self.pressed_inside {
1043 (self.on_taps[i])(ctx);
1044 }
1045 }
1046 self.armed = false;
1047 self.pressed_inside = false;
1048 self.active_tab = None;
1049 ctx.request_redraw();
1050 EventResult::Handled
1051 }
1052 PointerPhase::Cancel => {
1053 if !self.armed {
1054 return EventResult::Ignored;
1055 }
1056 if let Some(i) = self.active_tab {
1057 self.interactions[i].set_pressed(false);
1058 }
1059 self.armed = false;
1060 self.pressed_inside = false;
1061 self.active_tab = None;
1062 ctx.request_redraw();
1063 EventResult::Handled
1064 }
1065 }
1066 }
1067
1068 fn semantics(&self, ctx: &mut SemanticsCtx) {
1069 let labels = &self.labels;
1070 let enabled_flags = &self.enabled_flags;
1071 let selected = self.selected;
1072 ctx.push_container(
1073 Role::TabList,
1074 |_| {},
1075 |ctx| {
1076 for i in 0..labels.len() {
1077 let label = labels[i].as_ref().map(LabelRun::content);
1078 let enabled = enabled_flags[i];
1079 ctx.push_node(Role::Tab, |node| {
1080 if let Some(l) = label {
1081 node.set_label(l);
1082 }
1083 node.set_selected(i == selected);
1084 if !enabled {
1085 node.set_disabled();
1086 }
1087 });
1088 }
1089 },
1090 );
1091 }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096 use super::*;
1097 use frust::authoring::{PointerButton, PointerEvent};
1098 use std::any::Any;
1099
1100 fn ft_secs(s: f64) -> FrameTime {
1101 FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
1102 }
1103
1104 fn build_view(selected: usize) -> TabsView<Vec<usize>> {
1105 tabs(
1106 [tab().label("One"), tab().label("Two"), tab().label("Three")],
1107 selected,
1108 |s: &mut Vec<usize>, i| s.push(i),
1109 )
1110 }
1111
1112 fn build(view: &TabsView<Vec<usize>>) -> TabsWidget {
1113 let mut counter = 0u64;
1114 View::<Vec<usize>>::build(view, &mut BuildCtx::new(&mut counter))
1115 }
1116
1117 fn layout_themed(w: &mut TabsWidget, width: f64, theme: Option<&Theme>) -> Size {
1118 let bc = BoxConstraints::loose(Size::new(width, f64::INFINITY));
1119 let mut tcx = TextContext::new();
1120 let mut ctx =
1121 LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1122 w.layout(&mut ctx, &bc)
1123 }
1124
1125 fn layout(w: &mut TabsWidget, width: f64) -> Size {
1126 layout_themed(w, width, None)
1127 }
1128
1129 fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1130 InputEvent::Pointer(PointerEvent {
1131 phase,
1132 position: Point::new(x, y),
1133 button: PointerButton::Primary,
1134 })
1135 }
1136
1137 #[allow(clippy::ptr_arg)]
1138 fn dispatch(w: &mut TabsWidget, state: &mut Vec<usize>, event: &InputEvent) {
1139 let state_any: &mut dyn Any = state;
1140 let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, HEIGHT));
1141 w.event(&mut ctx, event);
1142 }
1143
1144 #[test]
1147 fn indicator_spring_matches_material_spatial_default() {
1148 let token = crate::tokens::MaterialSpring::SPATIAL_DEFAULT;
1149 assert_eq!(INDICATOR_SPRING.stiffness, token.stiffness);
1150 assert_eq!(INDICATOR_SPRING.damping_ratio, token.damping_ratio);
1151 assert_eq!(INDICATOR_SPRING.mass, 1.0);
1152 assert_eq!(INDICATOR_SPRING.stiffness, 700.0);
1153 assert_eq!(INDICATOR_SPRING.damping_ratio, 0.9);
1154 }
1155
1156 #[test]
1157 fn indicator_slides_between_two_tabs_start_mid_end() {
1158 let mut motion = IndicatorMotion::new();
1159 assert!(
1160 !motion.retarget(0.0, 40.0),
1161 "the mount frame seeds, it does not animate"
1162 );
1163 assert_eq!(
1164 (motion.left(), motion.width()),
1165 (0.0, 40.0),
1166 "start: unseeded snap"
1167 );
1168
1169 assert!(
1170 motion.retarget(120.0, 60.0),
1171 "a real geometry change starts a spring leg"
1172 );
1173 assert_eq!(
1174 (motion.left(), motion.width()),
1175 (0.0, 40.0),
1176 "mid-flight starts exactly where the previous frame painted"
1177 );
1178
1179 motion.advance(ft_secs(0.0));
1180 motion.advance(ft_secs(0.05));
1181 let (mid_left, mid_width) = (motion.left(), motion.width());
1182 assert!(
1183 mid_left > 0.0 && mid_left < 120.0,
1184 "left sampled strictly between start and end, got {mid_left}"
1185 );
1186 assert!(
1187 mid_width > 40.0 && mid_width < 60.0,
1188 "width sampled strictly between start and end, got {mid_width}"
1189 );
1190
1191 let mut t = 0.05;
1192 for _ in 0..600 {
1193 t += 1.0 / 60.0;
1194 if !motion.advance(ft_secs(t)) {
1195 break;
1196 }
1197 }
1198 assert_eq!(
1199 (motion.left(), motion.width()),
1200 (120.0, 60.0),
1201 "end: settles exactly on the new tab's geometry"
1202 );
1203 }
1204
1205 #[test]
1206 fn a_target_inside_the_dead_band_is_ignored() {
1207 let mut motion = IndicatorMotion::new();
1208 motion.retarget(10.0, 40.0);
1209 assert!(
1210 !motion.retarget(
1211 10.0 + INDICATOR_RETARGET_TOLERANCE / 2.0,
1212 40.0 + INDICATOR_RETARGET_TOLERANCE / 2.0
1213 ),
1214 "a sub-tolerance change must not restart the spring"
1215 );
1216 assert!(motion.retarget(10.0 + INDICATOR_RETARGET_TOLERANCE * 2.0, 40.0));
1217 }
1218
1219 #[test]
1222 #[should_panic(expected = "needs 2+ tabs")]
1223 fn fewer_than_two_tabs_panics() {
1224 let _ = tabs::<(), _>([tab().label("Only")], 0, |_s: &mut (), _i| {});
1225 }
1226
1227 #[test]
1228 #[should_panic(expected = "label or an icon")]
1229 fn a_tab_with_neither_label_nor_icon_panics() {
1230 let _ = tabs::<(), _>(
1231 [
1232 Tab {
1233 label: None,
1234 icon: None,
1235 enabled: true,
1236 },
1237 tab().label("B"),
1238 ],
1239 0,
1240 |_s: &mut (), _i| {},
1241 );
1242 }
1243
1244 #[test]
1247 fn tabs_always_divide_the_available_width_evenly_no_scroll_variant() {
1248 let many: Vec<Tab> = (0..6).map(|i| tab().label(format!("Tab {i}"))).collect();
1249 let view: TabsView<()> = tabs(many, 0, |_s: &mut (), _i: usize| {});
1250 let mut counter = 0u64;
1251 let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
1252 let size = layout(&mut w, 300.0);
1253 assert_eq!(size.width, 300.0);
1254 assert_eq!(size.height, HEIGHT);
1255 let slot_w = 300.0 / 6.0;
1256 for (i, rect) in w.tab_rects.iter().enumerate() {
1257 assert_eq!(rect.x0, i as f64 * slot_w);
1258 assert_eq!(rect.width(), slot_w);
1259 }
1260 }
1261
1262 #[test]
1265 fn primary_indicator_matches_content_width_not_slot_width() {
1266 let view = build_view(0);
1267 let mut w = build(&view);
1268 layout(&mut w, 300.0);
1269 let (left, width) = w.indicator_target(0);
1270 let slot = w.tab_rects[0];
1271 assert!(
1272 width < slot.width(),
1273 "primary indicator narrower than the slot"
1274 );
1275 assert!(left > slot.x0, "content indented from the slot's left edge");
1276 assert!(
1277 left + width < slot.x1,
1278 "content indented from the slot's right edge too"
1279 );
1280 }
1281
1282 #[test]
1283 fn secondary_indicator_spans_the_whole_slot() {
1284 let view = build_view(0).variant(TabsVariant::Secondary);
1285 let mut w = build(&view);
1286 layout(&mut w, 300.0);
1287 let (left, width) = w.indicator_target(0);
1288 let slot = w.tab_rects[0];
1289 assert_eq!((left, width), (slot.x0, slot.width()));
1290 }
1291
1292 #[test]
1295 fn is_controlled_selection_only_moves_via_rebuild() {
1296 let mut w = build(&build_view(0));
1297 layout(&mut w, 300.0);
1298 let mut state = Vec::new();
1299 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 150.0, 20.0));
1300 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 150.0, 20.0));
1301 assert_eq!(w.selected, 0, "widget did not self-mutate");
1302 assert_eq!(state, vec![1]);
1303
1304 let next = build_view(1);
1305 let prev = build_view(0);
1306 let mut counter = 0u64;
1307 View::<Vec<usize>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1308 assert_eq!(w.selected, 1);
1309 }
1310
1311 #[test]
1312 fn tap_reports_the_tapped_tabs_index() {
1313 let mut w = build(&build_view(0));
1314 layout(&mut w, 300.0);
1315 let mut state = Vec::new();
1316 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 250.0, 20.0));
1318 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 250.0, 20.0));
1319 assert_eq!(state, vec![2]);
1320 }
1321
1322 #[test]
1323 fn up_outside_the_pressed_tab_does_not_fire() {
1324 let mut w = build(&build_view(0));
1325 layout(&mut w, 300.0);
1326 let mut state = Vec::new();
1327 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 20.0));
1328 dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 250.0, 20.0));
1329 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 250.0, 20.0));
1330 assert!(state.is_empty());
1331 }
1332
1333 #[test]
1334 fn cancel_clears_the_press_without_firing() {
1335 let mut w = build(&build_view(0));
1336 layout(&mut w, 300.0);
1337 let mut state = Vec::new();
1338 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 20.0));
1339 assert!(w.armed);
1340 dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 20.0));
1341 assert!(!w.armed);
1342 assert!(state.is_empty());
1343 }
1344
1345 #[test]
1346 fn disabled_tab_swallows_the_press_and_does_not_fire() {
1347 let view: TabsView<Vec<usize>> = tabs(
1348 [tab().label("A"), tab().label("B").enabled(false)],
1349 0,
1350 |s: &mut Vec<usize>, i| s.push(i),
1351 );
1352 let mut counter = 0u64;
1353 let mut w = View::<Vec<usize>>::build(&view, &mut BuildCtx::new(&mut counter));
1354 layout(&mut w, 200.0);
1355 let mut state = Vec::new();
1356 dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 150.0, 20.0));
1358 dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 150.0, 20.0));
1359 assert!(
1360 state.is_empty(),
1361 "a disabled tab must not report a selection"
1362 );
1363 assert!(!w.armed, "a disabled tab must not arm a press at all");
1364 }
1365
1366 #[test]
1369 fn reduce_motion_snaps_the_indicator_and_requests_one_frame_never_layout() {
1370 let mut theme = crate::baseline();
1371 theme.motion.reduce_motion = true;
1372
1373 let prev = build_view(0);
1374 let mut w = build(&prev);
1375 layout_themed(&mut w, 300.0, Some(&theme));
1376
1377 let next = build_view(2);
1378 let mut counter = 0u64;
1379 View::<Vec<usize>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1380
1381 struct NullScene;
1382 impl PaintScene for NullScene {
1383 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1384 fn draw_text(&mut self, _o: Point, _t: &str) {}
1385 }
1386 let mut scene = NullScene;
1387 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT)).with_theme(&theme);
1388 w.paint(&mut ctx, &mut scene);
1389
1390 assert!(
1391 ctx.needs_frame(),
1392 "the snap moved the indicator, so one more frame must be requested"
1393 );
1394 assert!(
1395 !ctx.needs_layout(),
1396 "the indicator is paint-only — it must never feed layout"
1397 );
1398
1399 let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT)).with_theme(&theme);
1401 w.paint(&mut ctx2, &mut scene);
1402 assert!(
1403 !ctx2.needs_frame(),
1404 "already at rest — no further frame needed"
1405 );
1406 }
1407
1408 #[test]
1409 fn animating_indicator_requests_a_frame_each_advance() {
1410 let prev = build_view(0);
1411 let mut w = build(&prev);
1412 layout(&mut w, 300.0);
1413
1414 let next = build_view(2);
1415 let mut counter = 0u64;
1416 View::<Vec<usize>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1417
1418 struct NullScene;
1419 impl PaintScene for NullScene {
1420 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1421 fn draw_text(&mut self, _o: Point, _t: &str) {}
1422 }
1423 let mut scene = NullScene;
1424 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT));
1425 w.paint(&mut ctx, &mut scene);
1426 assert!(
1427 ctx.needs_frame(),
1428 "mid-flight spring keeps asking for frames"
1429 );
1430 assert!(!ctx.needs_layout(), "never feeds layout");
1431 }
1432
1433 #[test]
1434 fn divider_is_stroked_along_the_bottom_edge() {
1435 let mut w = build(&build_view(0));
1436 layout(&mut w, 300.0);
1437
1438 #[derive(Default)]
1439 struct StrokeRecorder {
1440 lines: Vec<(Point, Point, f64)>,
1441 }
1442 impl PaintScene for StrokeRecorder {
1443 fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1444 fn draw_text(&mut self, _o: Point, _t: &str) {}
1445 fn stroke_line(&mut self, p0: Point, p1: Point, width: f64, _c: Color) {
1446 self.lines.push((p0, p1, width));
1447 }
1448 }
1449 let mut rec = StrokeRecorder::default();
1450 let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT));
1451 w.paint(&mut ctx, &mut rec);
1452 assert_eq!(rec.lines.len(), 1);
1453 let (p0, p1, width) = rec.lines[0];
1454 assert_eq!(p0.y, HEIGHT);
1455 assert_eq!(p1.y, HEIGHT);
1456 assert_eq!(width, DIVIDER_WIDTH);
1457 }
1458
1459 #[test]
1462 fn semantics_yields_a_tablist_of_tabs_with_selection_and_disabled() {
1463 fn logic(_s: &mut ()) -> TabsView<()> {
1464 tabs::<(), _>(
1465 [tab().label("A"), tab().label("B").enabled(false)],
1466 1,
1467 |_s: &mut (), _i| {},
1468 )
1469 }
1470 let mut root: frust_core::RenderRoot<(), TabsView<()>> = frust_core::RenderRoot::new();
1471 let mut state = ();
1472 root.rebuild(&mut logic, &mut state);
1473 let mut tcx = TextContext::new();
1474 root.layout_with_text(Size::new(300.0, HEIGHT), &mut tcx as &mut dyn Any);
1475 let update = root.semantics();
1476
1477 let tablist = update
1478 .nodes
1479 .iter()
1480 .find(|(_, n)| n.role() == Role::TabList)
1481 .expect("a TabList container node is contributed");
1482 assert_eq!(tablist.1.children().len(), 2);
1483
1484 let tabs_found: Vec<_> = update
1485 .nodes
1486 .iter()
1487 .filter(|(_, n)| n.role() == Role::Tab)
1488 .collect();
1489 assert_eq!(tabs_found.len(), 2);
1490
1491 let a = tabs_found
1492 .iter()
1493 .find(|(_, n)| n.label() == Some("A"))
1494 .expect("tab A is present");
1495 assert_eq!(a.1.is_selected(), Some(false));
1496 assert!(!a.1.is_disabled());
1497
1498 let b = tabs_found
1499 .iter()
1500 .find(|(_, n)| n.label() == Some("B"))
1501 .expect("tab B is present");
1502 assert_eq!(b.1.is_selected(), Some(true));
1503 assert!(b.1.is_disabled());
1504 }
1505}