1use std::{any::TypeId, sync::Arc};
7
8use tessera_foundation::gesture::{ScrollRecognizer, ScrollSettings};
9use tessera_ui::{
10 AxisConstraint, Callback, Color, ComputedData, Constraint, Dp, FocusManager, FocusProperties,
11 FocusRequester, FocusState, LayoutResult, MeasurementError, Modifier, ParentDataMap,
12 ParentDataModifierNode, Px, PxPosition, RenderSlot, RenderSlotWith, State,
13 accesskit::Role,
14 layout::{MeasureScope, layout},
15 modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
16 receive_frame_nanos, remember, tessera, use_context,
17};
18
19use crate::{
20 alignment::{Alignment, CrossAxisAlignment, MainAxisAlignment},
21 animation::Animatable,
22 boxed::boxed,
23 column::column,
24 icon::icon as icon_component,
25 interaction_state::InteractionState,
26 modifier::{ModifierExt, SemanticsArgs, with_pointer_input},
27 painter::Painter,
28 shape_def::{RoundedCorner, Shape},
29 spacer::spacer,
30 surface::surface,
31 text::text as text_component,
32 theme::{ContentColor, MaterialAlpha, MaterialColorScheme, MaterialTheme},
33};
34
35use navigation::{TabFocusData, TabListSemantics, TabNavigation};
36
37const DEFAULT_SPATIAL_DAMPING_RATIO: f32 = 0.9;
38const DEFAULT_SPATIAL_STIFFNESS: f32 = 700.0;
39
40mod navigation;
41
42#[cfg(test)]
43mod accessibility_tests;
44
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub enum TabsVariant {
48 #[default]
50 Primary,
51 Secondary,
53}
54
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum TabActivationMode {
58 #[default]
60 Manual,
61 Automatic,
63}
64
65pub struct TabsDefaults;
67
68impl TabsDefaults {
69 pub const INDICATOR_HEIGHT: Dp = Dp(3.0);
71 pub const SECONDARY_INDICATOR_HEIGHT: Dp = Dp(2.0);
73 pub const INDICATOR_MIN_WIDTH: Dp = Dp(24.0);
75 pub const INDICATOR_MAX_WIDTH: Option<Dp> = None;
77 pub const MIN_TAB_HEIGHT: Dp = Dp(48.0);
79 pub const TAB_PADDING: Dp = Dp(16.0);
81 pub const HOVER_STATE_LAYER_OPACITY: f32 = MaterialAlpha::HOVER;
83 pub const DIVIDER_HEIGHT: Dp = Dp(1.0);
85 pub const SCROLLABLE_MIN_TAB_WIDTH: Dp = Dp(90.0);
87 pub const SCROLLABLE_EDGE_PADDING: Dp = Dp(52.0);
89 pub const LARGE_TAB_HEIGHT: Dp = Dp(64.0);
91
92 pub fn disabled_content_color(scheme: &MaterialColorScheme) -> Color {
94 scheme
95 .on_surface
96 .with_alpha(MaterialAlpha::DISABLED_CONTENT)
97 }
98
99 pub fn ripple_color(selected_content_color: Color) -> Color {
101 selected_content_color
102 }
103}
104
105#[derive(Clone, PartialEq, Copy, Debug)]
106struct Spring1D {
107 value: f32,
108 velocity: f32,
109 target: f32,
110}
111
112impl Spring1D {
113 fn new(value: f32) -> Self {
114 Self {
115 value,
116 velocity: 0.0,
117 target: value,
118 }
119 }
120
121 fn snap_to(&mut self, value: f32) {
122 self.value = value;
123 self.target = value;
124 self.velocity = 0.0;
125 }
126
127 fn set_target(&mut self, target: f32) {
128 self.target = target;
129 }
130
131 fn update(&mut self, dt: f32, stiffness: f32, damping_ratio: f32) {
132 let stiffness = stiffness.max(0.0);
133 if stiffness == 0.0 {
134 self.snap_to(self.target);
135 return;
136 }
137
138 let mut motion = Animatable::spring(self.value, stiffness.sqrt(), damping_ratio)
139 .with_velocity(self.velocity);
140 motion.set_target(self.target);
141 self.value = motion.tick(dt);
142 self.velocity = motion.velocity();
143
144 if (self.value - self.target).abs() < 0.5 && self.velocity.abs() < 0.5 {
145 self.snap_to(self.target);
146 }
147 }
148
149 fn value_px(self) -> Px {
150 Px::saturating_from_f32(self.value)
151 }
152
153 fn is_animating(self) -> bool {
154 (self.value - self.target).abs() >= 0.5 || self.velocity.abs() >= 0.5
155 }
156}
157
158fn clamp_px(value: Px, min: Px, max: Option<Px>) -> Px {
159 let clamped_max = max.unwrap_or(value);
160 Px(value.0.max(min.0).min(clamped_max.0))
161}
162
163#[derive(Clone, PartialEq)]
169struct TabsController {
170 density: f64,
171 targets: Vec<TabFocusData>,
172 focused_requester: Option<FocusRequester>,
173 pending_focus: Option<FocusRequester>,
174 activation_mode: TabActivationMode,
175 active_tab: usize,
176 indicator_x: Spring1D,
177 indicator_width: Spring1D,
178 tab_row_scroll_offset: Spring1D,
179 tab_row_scroll_max: Px,
180 tab_row_scroll_user_overridden: bool,
181 tab_bar_height: Px,
182 last_frame_nanos: Option<u64>,
183 indicator_initialized: bool,
184 tab_row_scroll_initialized: bool,
185 pending_retarget_frame: bool,
186}
187
188impl TabsController {
189 fn new(initial_tab: usize) -> Self {
190 Self {
191 density: tessera_ui::dp::scale_factor(),
192 targets: Vec::new(),
193 focused_requester: None,
194 pending_focus: None,
195 activation_mode: TabActivationMode::Manual,
196 active_tab: initial_tab,
197 indicator_x: Spring1D::new(0.0),
198 indicator_width: Spring1D::new(0.0),
199 tab_row_scroll_offset: Spring1D::new(0.0),
200 tab_row_scroll_max: Px(0),
201 tab_row_scroll_user_overridden: false,
202 tab_bar_height: Px(0),
203 last_frame_nanos: None,
204 indicator_initialized: false,
205 tab_row_scroll_initialized: false,
206 pending_retarget_frame: false,
207 }
208 }
209
210 fn sync_selected(&mut self, index: usize) {
215 if index != self.active_tab {
216 self.active_tab = index;
217 self.tab_row_scroll_user_overridden = false;
218 self.pending_retarget_frame = true;
219 self.last_frame_nanos = None;
220 }
221 }
222
223 fn update_density(&mut self, density: f64) {
224 let ratio = density / self.density;
225 for spring in [
226 &mut self.indicator_x,
227 &mut self.indicator_width,
228 &mut self.tab_row_scroll_offset,
229 ] {
230 spring.value = (f64::from(spring.value) * ratio) as f32;
231 spring.target = (f64::from(spring.target) * ratio) as f32;
232 spring.velocity = (f64::from(spring.velocity) * ratio) as f32;
233 }
234 self.tab_row_scroll_max = Px((f64::from(self.tab_row_scroll_max.0) * ratio) as i32);
235 self.tab_bar_height = Px((f64::from(self.tab_bar_height.0) * ratio) as i32);
236 self.density = density;
237 }
238
239 fn active_tab(&self) -> usize {
240 self.active_tab
241 }
242
243 fn set_focused_requester(&mut self, requester: Option<FocusRequester>) {
244 self.focused_requester = requester;
245 self.tab_row_scroll_user_overridden = requester.is_none();
246 }
247
248 fn sync_targets(&mut self, targets: Vec<TabFocusData>) {
249 if self.focused_requester.is_some_and(|requester| {
250 !targets
251 .iter()
252 .any(|target| target.requester == requester && target.enabled)
253 }) {
254 let fallback = targets
255 .iter()
256 .find(|target| target.selected && target.enabled)
257 .or_else(|| targets.iter().find(|target| target.enabled))
258 .map(|target| target.requester);
259 self.focused_requester = fallback;
260 self.pending_focus = fallback;
261 }
262 self.targets = targets;
263 }
264
265 fn set_tab_row_scroll_bounds(&mut self, max: Px) {
266 self.tab_row_scroll_max = max.max(Px(0));
267 let clamped = self
268 .tab_row_scroll_offset
269 .value
270 .clamp(0.0, self.tab_row_scroll_max.to_f32());
271 self.tab_row_scroll_offset.snap_to(clamped);
272 }
273
274 fn tab_row_scroll_max(&self) -> Px {
275 self.tab_row_scroll_max
276 }
277
278 fn set_tab_bar_height(&mut self, height: Px) {
279 self.tab_bar_height = height.max(Px(0));
280 }
281
282 fn tab_bar_height(&self) -> Px {
283 self.tab_bar_height
284 }
285
286 fn set_tab_row_scroll_immediate(&mut self, value: Px) {
287 let value = value.max(Px(0)).min(self.tab_row_scroll_max);
288 self.tab_row_scroll_offset.snap_to(value.to_f32());
289 self.tab_row_scroll_initialized = true;
290 self.tab_row_scroll_user_overridden = true;
291 }
292
293 fn set_tab_row_scroll_target(&mut self, target: Px) {
294 self.pending_retarget_frame = false;
295 let target = target.max(Px(0)).min(self.tab_row_scroll_max);
296 if !self.tab_row_scroll_initialized {
297 self.tab_row_scroll_offset.snap_to(target.to_f32());
298 self.tab_row_scroll_initialized = true;
299 } else {
300 self.last_frame_nanos = None;
301 self.tab_row_scroll_offset.set_target(target.to_f32());
302 }
303 }
304
305 fn tab_row_scroll_px(&self) -> Px {
306 self.tab_row_scroll_offset.value_px()
307 }
308
309 fn set_indicator_targets(&mut self, width: Px, x: Px) {
310 self.pending_retarget_frame = false;
311 let width = width.max(Px(0)).to_f32();
312 let x = x.to_f32();
313 if !self.indicator_initialized {
314 self.indicator_width.snap_to(width);
315 self.indicator_x.snap_to(x);
316 self.indicator_initialized = true;
317 } else {
318 self.last_frame_nanos = None;
319 self.indicator_width.set_target(width);
320 self.indicator_x.set_target(x);
321 }
322 }
323
324 fn indicator_width_px(&self) -> Px {
325 self.indicator_width.value_px().max(Px(0))
326 }
327
328 fn indicator_x_px(&self) -> Px {
329 self.indicator_x.value_px()
330 }
331
332 fn advance_from_frame_nanos(&mut self, frame_nanos: u64) {
333 let dt = if let Some(last_frame_nanos) = self.last_frame_nanos {
334 frame_nanos.saturating_sub(last_frame_nanos) as f32 / 1_000_000_000.0
335 } else {
336 1.0 / 60.0
337 };
338 self.last_frame_nanos = Some(frame_nanos);
339
340 self.indicator_x
341 .update(dt, DEFAULT_SPATIAL_STIFFNESS, DEFAULT_SPATIAL_DAMPING_RATIO);
342 self.indicator_width
343 .update(dt, DEFAULT_SPATIAL_STIFFNESS, DEFAULT_SPATIAL_DAMPING_RATIO);
344 self.tab_row_scroll_offset.update(
345 dt,
346 DEFAULT_SPATIAL_STIFFNESS,
347 DEFAULT_SPATIAL_DAMPING_RATIO,
348 );
349 }
350
351 fn has_pending_animation_frame(&self) -> bool {
352 self.pending_retarget_frame
353 || self.indicator_x.is_animating()
354 || self.indicator_width.is_animating()
355 || self.tab_row_scroll_offset.is_animating()
356 }
357}
358
359impl Default for TabsController {
360 fn default() -> Self {
361 Self::new(0)
362 }
363}
364
365#[derive(Clone, PartialEq)]
366struct TabsConfig {
367 selection: State<usize>,
368 modifier: Modifier,
369 variant: TabsVariant,
370 indicator_color: Color,
371 container_color: Color,
372 active_content_color: Color,
373 inactive_content_color: Color,
374 inactive_interaction_color: Color,
375 indicator_height: Dp,
376 indicator_min_width: Dp,
377 indicator_max_width: Option<Dp>,
378 min_tab_height: Dp,
379 tab_padding: Dp,
380 enabled: bool,
381 disabled_content_color: Color,
382 divider_color: Color,
383 scrollable: bool,
384 edge_padding: Dp,
385 min_scrollable_tab_width: Dp,
386}
387
388#[derive(Clone, Copy, PartialEq)]
389pub struct TabRow {
391 controller: State<TabsController>,
392 selected: State<usize>,
393 enabled: bool,
394 active_content_color: Color,
395 inactive_content_color: Color,
396 inactive_interaction_color: Color,
397 disabled_content_color: Color,
398 ripple_color: Color,
399 min_tab_height: Dp,
400 tab_padding: Dp,
401}
402
403#[derive(Clone, PartialEq)]
404enum TabTitle {
405 Custom(RenderSlot),
406 Label { text: String, icon: Option<Painter> },
407}
408
409#[derive(Clone, Copy, PartialEq)]
410struct TabIndicatorPadding(Dp);
411
412impl ParentDataModifierNode for TabIndicatorPadding {
413 fn apply_parent_data(&self, data: &mut ParentDataMap) {
414 data.insert(TypeId::of::<Self>(), Arc::new(*self));
415 }
416}
417
418#[derive(Clone, Copy, PartialEq)]
419struct TabActivation {
420 selection: State<usize>,
421 controlled: bool,
422 on_click: Option<Callback>,
423}
424
425impl TabBuilder {
426 pub fn title<F, Output>(&self, title: F) -> Self
428 where
429 F: Fn() -> Output + Send + Sync + 'static,
430 {
431 self.props.configure_deferred("title_slot", move |props| {
432 props.title_slot = Some(RenderSlot::new(title))
433 });
434 let value = None;
435 self.props
436 .configure("label_text", move |props| props.label_text = value);
437 let value = None;
438 self.props
439 .configure("label_icon", move |props| props.label_icon = value);
440 Self { props: self.props }
441 }
442
443 pub fn title_shared(&self, title: impl Into<RenderSlot> + Send + Sync + 'static) -> Self {
446 self.props.configure_deferred("title_slot", move |props| {
447 props.title_slot = Some(title.into())
448 });
449 let value = None;
450 self.props
451 .configure("label_text", move |props| props.label_text = value);
452 let value = None;
453 self.props
454 .configure("label_icon", move |props| props.label_icon = value);
455 Self { props: self.props }
456 }
457
458 pub fn label(&self, label: impl Into<String>) -> Self {
461 let value = None;
462 self.props
463 .configure("title_slot", move |props| props.title_slot = value);
464 let value = Some(label.into());
465 self.props
466 .configure("label_text", move |props| props.label_text = value);
467 Self { props: self.props }
468 }
469
470 pub fn icon(&self, icon: impl Into<Painter>) -> Self {
473 let value = None;
474 self.props
475 .configure("title_slot", move |props| props.title_slot = value);
476 let value = Some(icon.into());
477 self.props
478 .configure("label_icon", move |props| props.label_icon = value);
479 Self { props: self.props }
480 }
481
482 pub fn label_with_icon(&self, label: impl Into<String>, icon: impl Into<Painter>) -> Self {
484 self.label(label).icon(icon)
485 }
486}
487
488#[tessera]
533pub fn tab(
534 row: TabRow,
535 selected: Option<bool>,
536 enabled: Option<bool>,
537 #[prop(skip_setter)] title_slot: Option<RenderSlot>,
538 #[prop(skip_setter)] label_text: Option<String>,
539 #[prop(skip_setter)] label_icon: Option<Painter>,
540 on_click: Option<Callback>,
541 #[prop(into)] accessibility_label: Option<String>,
542) {
543 let composition = row;
544 let focus_requester = remember(FocusRequester::new).get();
545 let controller = composition.controller;
546 let selected_state = composition.selected;
547 let controlled = selected.is_some();
548 let activation = TabActivation {
549 selection: selected_state,
550 controlled,
551 on_click,
552 };
553 let live_activation = remember(|| activation);
554 if live_activation.with(|value| value != &activation) {
555 live_activation.set(activation);
556 }
557 let selected = selected.unwrap_or_else(|| {
558 controller.with(|controller| {
559 controller
560 .targets
561 .iter()
562 .position(|target| target.requester == focus_requester)
563 == Some(controller.active_tab())
564 })
565 });
566 let enabled = composition.enabled && enabled.unwrap_or(true);
567 let interaction = remember(InteractionState::new);
568 let interacting = interaction.with(|interaction| {
569 interaction.is_hovered() || interaction.is_focused() || interaction.is_pressed()
570 });
571 let activate = Callback::new(move || {
572 let index = controller.with(|controller| {
573 controller
574 .targets
575 .iter()
576 .position(|target| target.requester == focus_requester && target.enabled)
577 });
578 if let Some(index) = index {
579 let activation = live_activation.get();
580 if !activation.controlled && activation.selection.get() != index {
581 activation.selection.set(index);
582 }
583 if let Some(on_click) = activation.on_click {
584 on_click.call();
585 }
586 FocusManager::current().defer_keyboard_until_layout();
587 }
588 });
589 let title = if let Some(title_slot) = title_slot {
590 TabTitle::Custom(title_slot)
591 } else if label_text.is_some() || label_icon.is_some() {
592 TabTitle::Label {
593 text: label_text.unwrap_or_default(),
594 icon: label_icon,
595 }
596 } else {
597 panic!("tab requires title(), label(), icon(), or label_with_icon()");
598 };
599 let accessibility_label = accessibility_label.or_else(|| match &title {
600 TabTitle::Label { text, .. } if !text.trim().is_empty() => Some(text.clone()),
601 _ => None,
602 });
603 let label_color = if !enabled {
604 composition.disabled_content_color
605 } else if selected {
606 composition.active_content_color
607 } else if interacting {
608 composition.inactive_interaction_color
609 } else {
610 composition.inactive_content_color
611 };
612 let tab_height = match &title {
613 TabTitle::Label {
614 text,
615 icon: Some(_),
616 } if !text.is_empty() => Dp(TabsDefaults::LARGE_TAB_HEIGHT
617 .0
618 .max(composition.min_tab_height.0)),
619 _ => composition.min_tab_height,
620 };
621 tab_trigger().args(TabTriggerArgs {
622 controller,
623 interaction,
624 title,
625 enabled,
626 selected,
627 on_click: Some(activate),
628 focus_requester,
629 label_color,
630 ripple_color: if selected {
631 composition.ripple_color
632 } else {
633 composition.inactive_interaction_color
634 },
635 tab_height,
636 tab_padding: composition.tab_padding,
637 accessibility_label,
638 });
639}
640
641#[tessera]
673pub fn tab_label(
674 #[prop(into)] text: Option<String>,
675 #[prop(into)] icon: Option<Painter>,
676 horizontal_text_padding: Option<Dp>,
677 icon_size: Option<Dp>,
678) {
679 let text = text.unwrap_or_default();
680 let has_icon = icon.is_some();
681 let has_text = !text.is_empty();
682 let icon_size = icon_size.unwrap_or(Dp(24.0));
683 let horizontal_padding = horizontal_text_padding.unwrap_or(TabsDefaults::TAB_PADDING);
684 let current = TabLabelContent {
685 text,
686 icon,
687 icon_size,
688 };
689 let live = remember(|| current.clone());
690 if live.with(|value| value != ¤t) {
691 live.set(current);
692 }
693 let small_height = TabsDefaults::MIN_TAB_HEIGHT;
694 let large_height = TabsDefaults::LARGE_TAB_HEIGHT;
695 let container_height = if has_icon && has_text {
696 large_height
697 } else {
698 small_height
699 };
700
701 let modifier = Modifier::new()
702 .constrain(
703 Some(AxisConstraint::NONE),
704 Some(AxisConstraint::exact(container_height.into())),
705 )
706 .padding_symmetric(horizontal_padding, Dp(0.0));
707
708 boxed()
709 .alignment(Alignment::Center)
710 .modifier(modifier)
711 .children(move || {
712 let current = live.get();
713 let has_icon = current.icon.is_some();
714 let has_text = !current.text.is_empty();
715 let content_color = use_context::<ContentColor>()
716 .map(|color| color.get().current)
717 .unwrap_or_else(|| ContentColor::default().current);
718 let style = use_context::<MaterialTheme>()
719 .expect("MaterialTheme must be provided")
720 .get()
721 .typography
722 .title_small;
723 {
724 if has_icon && has_text {
725 column()
726 .main_axis_alignment(MainAxisAlignment::Center)
727 .cross_axis_alignment(CrossAxisAlignment::Center)
728 .modifier(Modifier::new())
729 .children(move || {
730 let current = live.get();
731 let content_color = use_context::<ContentColor>()
732 .map(|color| color.get().current)
733 .unwrap_or_else(|| ContentColor::default().current);
734 let style = use_context::<MaterialTheme>()
735 .expect("MaterialTheme must be provided")
736 .get()
737 .typography
738 .title_small;
739 {
740 if let Some(ic) = current.icon {
741 icon_component()
742 .painter(ic)
743 .size(current.icon_size)
744 .tint(content_color);
745 }
746 };
747 {
748 spacer().modifier(Modifier::new().constrain(
749 Some(AxisConstraint::exact(Px(0))),
750 Some(AxisConstraint::exact(Dp(2.0).into())),
751 ));
752 };
753 {
754 text_component(current.text)
755 .color(content_color)
756 .style(style);
757 };
758 });
759 } else if has_icon {
760 if let Some(ic) = current.icon {
761 icon_component()
762 .painter(ic)
763 .size(current.icon_size)
764 .tint(content_color);
765 }
766 } else if has_text {
767 text_component(current.text)
768 .color(content_color)
769 .style(style);
770 }
771 };
772 });
773}
774
775#[derive(Clone, PartialEq)]
776struct TabLabelContent {
777 text: String,
778 icon: Option<Painter>,
779 icon_size: Dp,
780}
781
782#[derive(Clone)]
783struct TabsLayout {
784 args: TabsConfig,
785 controller: State<TabsController>,
786 tab_row_scroll_px: Px,
787 indicator_x_px: Px,
788 indicator_width_px: Px,
789 focused_requester: Option<FocusRequester>,
790 active_tab: usize,
791 scroll_user_overridden: bool,
792}
793
794impl PartialEq for TabsLayout {
795 fn eq(&self, other: &Self) -> bool {
796 self.tab_row_scroll_px == other.tab_row_scroll_px
797 && self.focused_requester == other.focused_requester
798 && self.active_tab == other.active_tab
799 && self.scroll_user_overridden == other.scroll_user_overridden
800 && self.indicator_x_px == other.indicator_x_px
801 && self.indicator_width_px == other.indicator_width_px
802 && self.args.variant == other.args.variant
803 && self.args.indicator_height == other.args.indicator_height
804 && self.args.indicator_min_width == other.args.indicator_min_width
805 && self.args.indicator_max_width == other.args.indicator_max_width
806 && self.args.min_tab_height == other.args.min_tab_height
807 && self.args.tab_padding == other.args.tab_padding
808 && self.args.scrollable == other.args.scrollable
809 && self.args.edge_padding == other.args.edge_padding
810 && self.args.min_scrollable_tab_width == other.args.min_scrollable_tab_width
811 }
812}
813
814impl tessera_ui::LayoutModifierNode for TabsLayout {
815 fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
816 let mut result = LayoutResult::default();
817 let children = input.children();
818 let parent_constraint = *input.parent_constraint().as_ref();
819
820 let container = children[0];
821 let divider = children[1];
822 let indicator = children[2];
823 let title_ids = &children[3..];
824 let num_tabs = title_ids.len();
825 let targets = title_ids
826 .iter()
827 .filter_map(|child| child.parent_data::<TabFocusData>(input))
828 .collect::<Vec<_>>();
829 if self
830 .controller
831 .with(|controller| controller.targets != targets)
832 {
833 self.controller
834 .with_mut(|controller| controller.sync_targets(targets));
835 }
836 let active_tab = self
837 .controller
838 .with(|controller| controller.active_tab())
839 .min(num_tabs.saturating_sub(1));
840 let reveal_tab = self
841 .controller
842 .with(|controller| {
843 controller.focused_requester.and_then(|requester| {
844 controller
845 .targets
846 .iter()
847 .position(|target| target.requester == requester)
848 })
849 })
850 .unwrap_or(active_tab);
851
852 let title_padding = title_ids
853 .iter()
854 .map(|title| {
855 title
856 .parent_data::<TabIndicatorPadding>(input)
857 .map_or(0.0, |padding| padding.0.to_px().to_f32() * 2.0)
858 })
859 .collect::<Vec<_>>();
860 let indicator_min_width: Px = self.args.indicator_min_width.into();
861 let available_width = parent_constraint.width.resolve_max();
862
863 let is_scrollable = self.args.scrollable
864 || available_width.is_none()
865 || available_width.is_some_and(|width| {
866 width.0
867 < TabsDefaults::MIN_TAB_HEIGHT
868 .to_px()
869 .0
870 .saturating_mul(num_tabs as i32)
871 });
872 let match_content_size = matches!(self.args.variant, TabsVariant::Primary);
873
874 let (
875 final_width,
876 strip_width_total,
877 tab_widths,
878 tab_lefts,
879 indicator_widths,
880 titles_max_height,
881 scroll_target,
882 ): (Px, Px, Vec<Px>, Vec<Px>, Vec<Px>, Px, Px) = if !is_scrollable {
883 let final_width = available_width.unwrap_or(Px(0));
884 let tab_width = if num_tabs == 0 {
885 Px(0)
886 } else {
887 Px(final_width.0 / num_tabs as i32)
888 };
889 let remainder = if num_tabs == 0 {
890 0
891 } else {
892 final_width.0 % num_tabs as i32
893 };
894 let tab_widths = (0..num_tabs)
895 .map(|index| tab_width + Px(i32::from((index as i32) < remainder)))
896 .collect::<Vec<_>>();
897
898 let mut titles_max_height = Px(0);
899 for (index, &title_id) in title_ids.iter().enumerate() {
900 let result = title_id.probe(
901 input,
902 &Constraint::new(
903 AxisConstraint::exact(tab_widths[index]),
904 AxisConstraint::NONE,
905 ),
906 )?;
907 titles_max_height = titles_max_height.max(result.height);
908 }
909
910 let intrinsic_results: Vec<_> = title_ids
911 .iter()
912 .enumerate()
913 .map(|(index, &id)| {
914 id.probe_intrinsic(
915 input,
916 &Constraint::new(
917 AxisConstraint::at_most(tab_widths[index]),
918 AxisConstraint::exact(titles_max_height),
919 ),
920 )
921 })
922 .collect::<Result<_, _>>()?;
923
924 let indicator_widths: Vec<Px> = title_ids
925 .iter()
926 .enumerate()
927 .map(|(idx, _id)| {
928 if match_content_size {
929 let intrinsic_width = intrinsic_results[idx].width.min(tab_widths[idx]);
930 let content_width =
931 (intrinsic_width.to_f32() - title_padding[idx]).max(0.0);
932 Px::saturating_from_f32(content_width).max(indicator_min_width)
933 } else {
934 tab_widths[idx]
935 }
936 })
937 .collect();
938
939 let mut left = Px(0);
940 let tab_lefts = tab_widths
941 .iter()
942 .map(|width| {
943 let current = left;
944 left += *width;
945 current
946 })
947 .collect::<Vec<_>>();
948
949 (
950 final_width,
951 final_width,
952 tab_widths,
953 tab_lefts,
954 indicator_widths,
955 titles_max_height,
956 Px(0),
957 )
958 } else {
959 let min_tab_width: Px = self.args.min_scrollable_tab_width.into();
960 let edge_padding: Px = self.args.edge_padding.into();
961
962 let mut tab_widths = Vec::with_capacity(num_tabs);
963 let mut titles_max_height = Px(0);
964 for &title_id in title_ids {
965 let result = title_id.probe(
966 input,
967 &Constraint::new(
968 AxisConstraint::at_least(min_tab_width),
969 AxisConstraint::NONE,
970 ),
971 )?;
972 tab_widths.push(result.width);
973 titles_max_height = titles_max_height.max(result.height);
974 }
975
976 let intrinsic_results: Vec<_> = title_ids
977 .iter()
978 .map(|&id| {
979 id.probe_intrinsic(
980 input,
981 &Constraint::new(
982 AxisConstraint::NONE,
983 AxisConstraint::exact(titles_max_height),
984 ),
985 )
986 })
987 .collect::<Result<_, _>>()?;
988
989 let mut indicator_widths = Vec::with_capacity(num_tabs);
990 for (idx, &_title_id) in title_ids.iter().enumerate() {
991 let tab_width = tab_widths.get(idx).copied().unwrap_or(Px(0));
992 if match_content_size {
993 let intrinsic_width = intrinsic_results[idx].width.min(tab_width);
994 let content_width = (intrinsic_width.to_f32() - title_padding[idx]).max(0.0);
995 let indicator_width =
996 Px::saturating_from_f32(content_width).max(indicator_min_width);
997 indicator_widths.push(indicator_width);
998 } else {
999 indicator_widths.push(tab_width);
1000 }
1001 }
1002
1003 let mut tab_lefts = Vec::with_capacity(num_tabs);
1004 let mut left = edge_padding;
1005 for width in &tab_widths {
1006 tab_lefts.push(left);
1007 left += *width;
1008 }
1009
1010 let strip_width_total = left + edge_padding;
1011 let final_width = available_width.unwrap_or(strip_width_total);
1012
1013 let max_scroll = (strip_width_total - final_width).max(Px(0));
1014 let should_update_scroll_bounds = self.controller.with(|c| {
1015 if c.tab_row_scroll_max != max_scroll {
1016 return true;
1017 }
1018 let clamped = c
1019 .tab_row_scroll_offset
1020 .value
1021 .clamp(0.0, max_scroll.to_f32());
1022 (c.tab_row_scroll_offset.value - clamped).abs() > f32::EPSILON
1023 });
1024 if should_update_scroll_bounds {
1025 self.controller
1026 .with_mut(|c| c.set_tab_row_scroll_bounds(max_scroll));
1027 }
1028
1029 let selected_left = tab_lefts.get(reveal_tab).copied().unwrap_or(edge_padding);
1030 let selected_width = tab_widths.get(reveal_tab).copied().unwrap_or(Px(0));
1031 let selected_center = selected_left + Px(selected_width.0.saturating_div(2));
1032 let target_scroll_f = (selected_center.to_f32() - final_width.to_f32() / 2.0)
1033 .clamp(0.0, max_scroll.to_f32());
1034 let scroll_target = Px::saturating_from_f32(target_scroll_f);
1035
1036 (
1037 final_width,
1038 strip_width_total,
1039 tab_widths,
1040 tab_lefts,
1041 indicator_widths,
1042 titles_max_height,
1043 scroll_target,
1044 )
1045 };
1046
1047 if is_scrollable {
1048 let should_update_tab_row_scroll_target = self.controller.with(|c| {
1049 !c.tab_row_scroll_user_overridden
1050 && (!c.tab_row_scroll_initialized
1051 || (c.tab_row_scroll_offset.target - scroll_target.to_f32()).abs()
1052 > f32::EPSILON)
1053 });
1054 if should_update_tab_row_scroll_target {
1055 self.controller
1056 .with_mut(|c| c.set_tab_row_scroll_target(scroll_target));
1057 }
1058 }
1059
1060 let current_scroll_px = if is_scrollable {
1061 self.controller.with(|c| c.tab_row_scroll_px())
1062 } else {
1063 Px(0)
1064 };
1065
1066 let (indicator_width, indicator_x) = {
1067 let desired_width = indicator_widths.get(active_tab).copied().unwrap_or(Px(0));
1068 let clamped_width = clamp_px(
1069 desired_width,
1070 self.args.indicator_min_width.into(),
1071 self.args.indicator_max_width.map(|v| v.into()),
1072 );
1073 if !is_scrollable
1074 && self
1075 .controller
1076 .with(|controller| controller.tab_row_scroll_max != Px(0))
1077 {
1078 self.controller
1079 .with_mut(|controller| controller.set_tab_row_scroll_bounds(Px(0)));
1080 }
1081
1082 let tab_left = tab_lefts.get(active_tab).copied().unwrap_or(Px(0));
1083 let tab_width = tab_widths.get(active_tab).copied().unwrap_or(Px(0));
1084
1085 let centered_x = tab_left + Px((tab_width.0 - clamped_width.0) / 2);
1086
1087 let should_update_indicator_targets = self.controller.with(|c| {
1088 !c.indicator_initialized
1089 || (c.indicator_width.target - clamped_width.to_f32()).abs() > f32::EPSILON
1090 || (c.indicator_x.target - centered_x.to_f32()).abs() > f32::EPSILON
1091 });
1092 if should_update_indicator_targets {
1093 self.controller
1094 .with_mut(|c| c.set_indicator_targets(clamped_width, centered_x));
1095 }
1096 (
1097 self.controller.with(|c| c.indicator_width_px()),
1098 self.controller.with(|c| c.indicator_x_px()),
1099 )
1100 };
1101
1102 let indicator_height: Px = self.args.indicator_height.into();
1103 let indicator_constraint = Constraint::new(
1104 AxisConstraint::exact(indicator_width),
1105 AxisConstraint::exact(indicator_height),
1106 );
1107 let _ = indicator.measure(input, &indicator_constraint)?;
1108
1109 let divider_height: Px = TabsDefaults::DIVIDER_HEIGHT.into();
1110 let divider_width = if is_scrollable {
1111 strip_width_total
1112 } else {
1113 final_width
1114 };
1115 let divider_constraint = Constraint::new(
1116 AxisConstraint::exact(divider_width),
1117 AxisConstraint::exact(divider_height),
1118 );
1119 let _ = divider.measure(input, ÷r_constraint)?;
1120
1121 let tab_bar_height = titles_max_height.max(self.args.min_tab_height.into());
1122 let should_update_tab_bar_height =
1123 self.controller.with(|c| c.tab_bar_height != tab_bar_height);
1124 if should_update_tab_bar_height {
1125 self.controller
1126 .with_mut(|c| c.set_tab_bar_height(tab_bar_height));
1127 }
1128 let final_height = tab_bar_height;
1129 let title_offset_y = Px((tab_bar_height.0 - titles_max_height.0) / 2).max(Px(0));
1130
1131 for (idx, &title_id) in title_ids.iter().enumerate() {
1132 let _ = title_id.measure(
1133 input,
1134 &Constraint::new(
1135 AxisConstraint::exact(tab_widths.get(idx).copied().unwrap_or(Px(0))),
1136 AxisConstraint::exact(tab_bar_height),
1137 ),
1138 )?;
1139 }
1140
1141 let container_constraint = Constraint::new(
1142 AxisConstraint::exact(final_width),
1143 AxisConstraint::exact(tab_bar_height),
1144 );
1145 let _ = container.measure(input, &container_constraint)?;
1146
1147 for (i, &title_id) in title_ids.iter().enumerate() {
1148 let x = tab_lefts.get(i).copied().unwrap_or(Px(0)) - current_scroll_px;
1149 result.place_child(title_id, PxPosition::new(x, title_offset_y));
1150 }
1151
1152 result.place_child(container, PxPosition::new(Px(0), Px(0)));
1153 result.place_child(
1154 divider,
1155 PxPosition::new(
1156 if is_scrollable {
1157 -current_scroll_px
1158 } else {
1159 Px(0)
1160 },
1161 tab_bar_height - divider_height,
1162 ),
1163 );
1164 result.place_child(
1165 indicator,
1166 PxPosition::new(
1167 indicator_x - current_scroll_px,
1168 tab_bar_height - indicator_height,
1169 ),
1170 );
1171
1172 Ok(result.with_size(ComputedData {
1173 width: final_width,
1174 height: final_height,
1175 }))
1176 }
1177}
1178
1179impl tessera_ui::DrawModifierNode for TabsLayout {
1180 fn draw(
1181 &self,
1182 ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
1183 content: &mut dyn tessera_ui::DrawModifierContent,
1184 ) {
1185 let input = &mut *ctx.render_input;
1186 input.metadata_mut().set_clips_children(true);
1187 content.draw(input);
1188 }
1189}
1190
1191#[tessera]
1268pub fn tabs(
1269 modifier: Option<Modifier>,
1270 variant: Option<TabsVariant>,
1271 selected: Option<State<usize>>,
1272 activation_mode: Option<TabActivationMode>,
1273 indicator_color: Option<Color>,
1274 container_color: Option<Color>,
1275 active_content_color: Option<Color>,
1276 inactive_content_color: Option<Color>,
1277 indicator_height: Option<Dp>,
1278 indicator_min_width: Option<Dp>,
1279 indicator_max_width: Option<Dp>,
1280 min_tab_height: Option<Dp>,
1281 tab_padding: Option<Dp>,
1282 enabled: Option<bool>,
1283 disabled_content_color: Option<Color>,
1284 divider_color: Option<Color>,
1285 scrollable: Option<bool>,
1286 edge_padding: Option<Dp>,
1287 min_scrollable_tab_width: Option<Dp>,
1288 content: Option<RenderSlotWith<TabRow>>,
1289) {
1290 let selected = if let Some(selected) = selected {
1291 selected
1292 } else {
1293 remember(|| 0usize)
1294 };
1295 let modifier = modifier.unwrap_or_default();
1296 let variant = variant.unwrap_or_default();
1297 let scrollable = scrollable.unwrap_or(false);
1298 let scheme = use_context::<MaterialTheme>()
1299 .expect("MaterialTheme must be provided")
1300 .get()
1301 .color_scheme;
1302 let config = TabsConfig {
1303 selection: selected,
1304 modifier,
1305 variant,
1306 indicator_color: indicator_color.unwrap_or(scheme.primary),
1307 container_color: container_color.unwrap_or(scheme.surface),
1308 active_content_color: active_content_color.unwrap_or(match variant {
1309 TabsVariant::Primary => scheme.primary,
1310 TabsVariant::Secondary => scheme.on_surface,
1311 }),
1312 inactive_content_color: inactive_content_color.unwrap_or(scheme.on_surface_variant),
1313 inactive_interaction_color: scheme.on_surface,
1314 indicator_height: indicator_height.unwrap_or(match variant {
1315 TabsVariant::Primary => TabsDefaults::INDICATOR_HEIGHT,
1316 TabsVariant::Secondary => TabsDefaults::SECONDARY_INDICATOR_HEIGHT,
1317 }),
1318 indicator_min_width: indicator_min_width.unwrap_or(TabsDefaults::INDICATOR_MIN_WIDTH),
1319 indicator_max_width,
1320 min_tab_height: min_tab_height.unwrap_or(TabsDefaults::MIN_TAB_HEIGHT),
1321 tab_padding: tab_padding.unwrap_or(TabsDefaults::TAB_PADDING),
1322 enabled: enabled.unwrap_or(true),
1323 disabled_content_color: disabled_content_color
1324 .unwrap_or_else(|| TabsDefaults::disabled_content_color(&scheme)),
1325 divider_color: divider_color.unwrap_or(scheme.outline_variant),
1326 scrollable,
1327 edge_padding: edge_padding.unwrap_or(TabsDefaults::SCROLLABLE_EDGE_PADDING),
1328 min_scrollable_tab_width: min_scrollable_tab_width
1329 .unwrap_or(TabsDefaults::SCROLLABLE_MIN_TAB_WIDTH),
1330 };
1331 let controller = remember(|| TabsController::new(0));
1335 let density = tessera_ui::dp::scale_factor();
1336 if controller.with(|controller| controller.density != density) {
1337 controller.with_mut(|controller| controller.update_density(density));
1338 }
1339 let activation_mode = activation_mode.unwrap_or_default();
1340 if controller.with(|controller| controller.activation_mode != activation_mode) {
1341 controller.with_mut(|controller| controller.activation_mode = activation_mode);
1342 }
1343 if controller.with(|controller| controller.pending_focus.is_some()) {
1344 let requester = controller.with_mut(|controller| controller.pending_focus.take());
1345 if let Some(requester) = requester {
1346 requester.request_focus();
1347 FocusManager::current().defer_keyboard_until_layout();
1348 }
1349 }
1350 let tab_row_scroll_recognizer =
1351 remember(|| ScrollRecognizer::new(ScrollSettings { consume: true }));
1352 let selected_index = selected.get();
1353 if controller.with(|controller| controller.active_tab()) != selected_index {
1354 controller.with_mut(|controller| controller.sync_selected(selected_index));
1355 }
1356 if controller.with(|c| c.has_pending_animation_frame()) {
1357 receive_frame_nanos(move |frame_nanos| {
1358 let has_pending_animation_frame = controller.with_mut(|controller| {
1359 controller.advance_from_frame_nanos(frame_nanos);
1360 controller.has_pending_animation_frame()
1361 });
1362 if has_pending_animation_frame {
1363 tessera_ui::FrameNanosControl::Continue
1364 } else {
1365 tessera_ui::FrameNanosControl::Stop
1366 }
1367 });
1368 }
1369 let body = tabs_content()
1370 .args(config)
1371 .controller(controller)
1372 .tab_row_scroll_recognizer(tab_row_scroll_recognizer);
1373 if let Some(content) = content {
1374 body.content_shared(content);
1375 }
1376}
1377
1378#[tessera]
1379fn tabs_content(
1380 args: Option<TabsConfig>,
1381 controller: Option<State<TabsController>>,
1382 content: Option<RenderSlotWith<TabRow>>,
1383 tab_row_scroll_recognizer: Option<State<ScrollRecognizer>>,
1384) {
1385 let args = args.expect("tabs_content requires args to be set");
1386 let live = remember(|| args.clone());
1387 if live.with(|value| value != &args) {
1388 live.set(args.clone());
1389 }
1390 let controller = controller.expect("tabs_content requires controller to be set");
1391 let tab_row_scroll_recognizer =
1392 tab_row_scroll_recognizer.expect("tabs_content requires scroll recognizer to be set");
1393 let tab_row_scroll_px = controller.with(|c| c.tab_row_scroll_px());
1394 let indicator_x_px = controller.with(|c| c.indicator_x_px());
1395 let indicator_width_px = controller.with(|c| c.indicator_width_px());
1396
1397 let layout_args = args.clone();
1398 let modifier = with_pointer_input(
1399 args.modifier
1400 .clone()
1401 .push_semantics(TabListSemantics)
1402 .focus_group()
1403 .on_focus_changed(move |state: FocusState| {
1404 if !state.has_focus()
1405 && controller.with(|controller| controller.focused_requester.is_some())
1406 {
1407 controller.with_mut(|controller| controller.set_focused_requester(None));
1408 FocusManager::current().defer_keyboard_until_layout();
1409 }
1410 }),
1411 move |input| {
1412 let is_scrollable = controller.with(|c| c.tab_row_scroll_max() > Px(0));
1413 if is_scrollable {
1414 let cursor_in_tab_bar = if let Some(pos) = input.cursor_position_rel {
1415 let within_x = pos.x >= 0.0 && pos.x < input.computed_data.width.0 as f32;
1416 let within_y =
1417 pos.y >= 0.0 && pos.y < controller.with(|c| c.tab_bar_height()).0 as f32;
1418 within_x && within_y
1419 } else {
1420 false
1421 };
1422
1423 if cursor_in_tab_bar {
1424 let scroll_result = tab_row_scroll_recognizer.with_mut(|recognizer| {
1425 recognizer.update(input.pass, input.pointer_changes.as_mut_slice())
1426 });
1427 let delta = if scroll_result.delta_x.abs() >= 0.01 {
1428 scroll_result.delta_x
1429 } else {
1430 scroll_result.delta_y
1431 };
1432 if delta.abs() >= 0.01 {
1433 controller.with_mut(|c| {
1434 let current = c.tab_row_scroll_offset.target;
1435 let max = c.tab_row_scroll_max().to_f32();
1436 let next = (current - delta).clamp(0.0, max);
1437 c.set_tab_row_scroll_immediate(Px::saturating_from_f32(next));
1438 });
1439 }
1440 }
1441 }
1442 },
1443 );
1444
1445 let policy = TabsLayout {
1446 args: layout_args,
1447 controller,
1448 tab_row_scroll_px,
1449 indicator_x_px,
1450 indicator_width_px,
1451 focused_requester: controller.with(|controller| controller.focused_requester),
1452 active_tab: controller.with(|controller| controller.active_tab),
1453 scroll_user_overridden: controller
1454 .with(|controller| controller.tab_row_scroll_user_overridden),
1455 };
1456 layout()
1457 .modifier(modifier)
1458 .policy(policy.clone())
1459 .draw(policy)
1460 .child(move || {
1461 let args = live.get();
1462 let ripple_color = TabsDefaults::ripple_color(args.active_content_color);
1463 let indicator_shape = match args.variant {
1464 TabsVariant::Primary => Shape::RoundedRectangle {
1465 top_left: RoundedCorner::manual(Dp(3.0), 3.0),
1466 top_right: RoundedCorner::manual(Dp(3.0), 3.0),
1467 bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
1468 bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
1469 },
1470 TabsVariant::Secondary => Shape::RECTANGLE,
1471 };
1472 surface()
1473 .style(args.container_color.into())
1474 .modifier(Modifier::new().fill_max_size())
1475 .shape(Shape::RECTANGLE)
1476 .child(|| {});
1477
1478 surface()
1479 .style(args.divider_color.into())
1480 .modifier(Modifier::new().fill_max_size())
1481 .shape(Shape::RECTANGLE)
1482 .child(|| {});
1483
1484 surface()
1485 .style(args.indicator_color.into())
1486 .modifier(Modifier::new().fill_max_size())
1487 .shape(indicator_shape)
1488 .child(|| {});
1489
1490 let composition = TabRow {
1491 controller,
1492 selected: args.selection,
1493 enabled: args.enabled,
1494 active_content_color: args.active_content_color,
1495 inactive_content_color: args.inactive_content_color,
1496 inactive_interaction_color: args.inactive_interaction_color,
1497 disabled_content_color: args.disabled_content_color,
1498 ripple_color,
1499 min_tab_height: args.min_tab_height,
1500 tab_padding: args.tab_padding,
1501 };
1502 if let Some(content) = content {
1503 content.render(composition);
1504 }
1505 });
1506}
1507
1508#[derive(Clone, PartialEq)]
1509struct TabTriggerArgs {
1510 controller: State<TabsController>,
1511 interaction: State<InteractionState>,
1512 title: TabTitle,
1513 enabled: bool,
1514 selected: bool,
1515 on_click: Option<Callback>,
1516 focus_requester: FocusRequester,
1517 label_color: Color,
1518 ripple_color: Color,
1519 tab_height: Dp,
1520 tab_padding: Dp,
1521 accessibility_label: Option<String>,
1522}
1523
1524#[tessera]
1525fn tab_trigger(args: Option<TabTriggerArgs>) {
1526 let args = args.expect("tab_trigger requires args to be set");
1527 let live = remember(|| args.clone());
1528 if live.with(|value| value != &args) {
1529 live.set(args.clone());
1530 }
1531 let controller = args.controller;
1532 let requester = args.focus_requester;
1533 let target = TabFocusData {
1534 requester,
1535 selected: args.selected,
1536 enabled: args.enabled,
1537 activate: args.on_click.expect("tab activation"),
1538 };
1539 if controller.with(|controller| {
1540 controller
1541 .targets
1542 .iter()
1543 .any(|current| current.requester == requester && current != &target)
1544 }) {
1545 controller.with_mut(|controller| {
1546 let mut targets = controller.targets.clone();
1547 if let Some(current) = targets
1548 .iter_mut()
1549 .find(|current| current.requester == requester)
1550 {
1551 *current = target;
1552 }
1553 controller.sync_targets(targets);
1554 });
1555 }
1556 let tab_stop = controller.with(|controller| {
1557 if let Some(focused) = controller.focused_requester {
1558 focused == requester
1559 } else {
1560 args.selected
1561 || (!controller
1562 .targets
1563 .iter()
1564 .any(|target| target.enabled && target.selected)
1565 && controller
1566 .targets
1567 .iter()
1568 .find(|target| target.enabled)
1569 .is_some_and(|target| target.requester == requester))
1570 }
1571 });
1572 let mut tab_modifier = Modifier::new()
1573 .semantics(SemanticsArgs {
1574 selected: Some(args.selected),
1575 ..Default::default()
1576 })
1577 .constrain(None, Some(AxisConstraint::exact(args.tab_height.into())))
1578 .focus_group()
1579 .focus_properties(
1580 FocusProperties::new()
1581 .skip_traversal(!tab_stop)
1582 .can_focus(args.enabled),
1583 )
1584 .push_parent_data(target)
1585 .push_parent_data(TabIndicatorPadding(match &args.title {
1586 TabTitle::Custom(_) => Dp::ZERO,
1587 TabTitle::Label { .. } => args.tab_padding,
1588 }))
1589 .push_keyboard_input(TabNavigation {
1590 controller,
1591 requester,
1592 });
1593
1594 if args.enabled {
1595 tab_modifier = tab_modifier.on_focus_event(move |focus_state: FocusState| {
1596 if focus_state.has_focus()
1597 && controller.with(|controller| controller.focused_requester != Some(requester))
1598 {
1599 controller.with_mut(|controller| controller.set_focused_requester(Some(requester)));
1600 FocusManager::current().defer_keyboard_until_layout();
1601 }
1602 });
1603 }
1604
1605 let accessibility_label = args.accessibility_label.clone();
1606 let label_color = args.label_color;
1607 let ripple_color = args.ripple_color;
1608 let focus_requester = args.focus_requester;
1609 let on_click = args.on_click;
1610
1611 surface()
1612 .style(Color::TRANSPARENT.into())
1613 .content_alignment(Alignment::Center)
1614 .content_color(label_color)
1615 .modifier(tab_modifier)
1616 .ripple_color(ripple_color)
1617 .shape(Shape::RECTANGLE)
1618 .enabled(args.enabled)
1619 .interaction_state(args.interaction)
1620 .focus_requester(focus_requester)
1621 .accessibility_role(Role::Tab)
1622 .accessibility_focusable(args.enabled)
1623 .accessibility_label_optional(accessibility_label)
1624 .on_click_optional(on_click)
1625 .child(move || {
1626 let args = live.get();
1627 tab_title_content()
1628 .title(args.title)
1629 .tab_padding(args.tab_padding);
1630 });
1631}
1632
1633#[tessera]
1634fn tab_title_content(title: Option<TabTitle>, tab_padding: Option<Dp>) {
1635 let title = title.expect("tab_title_content requires title to be set");
1636 let tab_padding = tab_padding.unwrap_or(TabsDefaults::TAB_PADDING);
1637 match title {
1638 TabTitle::Custom(render) => render.render(),
1639 TabTitle::Label {
1640 text,
1641 icon: Some(icon),
1642 } => {
1643 tab_label()
1644 .text(text)
1645 .horizontal_text_padding(tab_padding)
1646 .icon(icon);
1647 }
1648 TabTitle::Label { text, icon: None } => {
1649 tab_label().text(text).horizontal_text_padding(tab_padding);
1650 }
1651 }
1652}
1653
1654#[cfg(test)]
1655mod tests {
1656
1657 use tessera_ui::{
1658 CursorEventContent, Dp, GestureState, Modifier, PointerChange, PressKeyEventType, Px,
1659 PxPosition, remember, tessera,
1660 };
1661
1662 use crate::{
1663 column::column,
1664 lazy_list::lazy_column,
1665 modifier::{ModifierExt as _, SemanticsArgs},
1666 spacer::spacer,
1667 theme::{MaterialTheme, material_theme},
1668 };
1669
1670 use super::{Spring1D, TabsController, tab, tabs};
1671
1672 #[test]
1673 fn density_changes_preserve_in_flight_indicator_and_scroll_motion() {
1674 let mut original = TabsController::new(0);
1675 original.indicator_x = Spring1D {
1676 value: 10.0,
1677 velocity: 20.0,
1678 target: 100.0,
1679 };
1680 original.indicator_width = Spring1D {
1681 value: 24.0,
1682 velocity: 3.0,
1683 target: 60.0,
1684 };
1685 original.tab_row_scroll_offset = Spring1D {
1686 value: 40.0,
1687 velocity: 10.0,
1688 target: 200.0,
1689 };
1690 original.tab_row_scroll_max = Px(1000);
1691 original.tab_bar_height = Px(48);
1692 original.last_frame_nanos = Some(0);
1693 let mut scaled = original.clone();
1694 scaled.update_density(original.density * 1.5);
1695 original.advance_from_frame_nanos(16_666_667);
1696 scaled.advance_from_frame_nanos(16_666_667);
1697 for (before, after) in [
1698 (original.indicator_x, scaled.indicator_x),
1699 (original.indicator_width, scaled.indicator_width),
1700 (original.tab_row_scroll_offset, scaled.tab_row_scroll_offset),
1701 ] {
1702 assert!((after.value - before.value * 1.5).abs() < 0.0001);
1703 assert!((after.velocity - before.velocity * 1.5).abs() < 0.001);
1704 assert_eq!(after.target, before.target * 1.5);
1705 }
1706 assert_eq!(scaled.tab_row_scroll_max, Px(1500));
1707 assert_eq!(scaled.tab_bar_height, Px(72));
1708 scaled.update_density(original.density);
1709 assert!((scaled.indicator_x.value - original.indicator_x.value).abs() < 0.0001);
1710 assert!(scaled.has_pending_animation_frame());
1711 }
1712
1713 #[tessera]
1714 fn idle_tabs_probe() {
1715 material_theme().theme(MaterialTheme::default).child(|| {
1716 tabs_row();
1717 });
1718 }
1719
1720 #[test]
1721 fn tabs_stop_rebuilding_after_the_indicator_settles() {
1722 let mut session = tessera_ui::testing::start_layout_test_session(
1723 tessera_ui::testing::layout_test(|| {
1724 idle_tabs_probe();
1725 })
1726 .viewport_px(1000, 900),
1727 );
1728 for frame in 0..90 {
1729 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
1730 }
1731 for frame in 90..98 {
1732 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
1733 assert_eq!(
1734 session
1735 .work()
1736 .count("tabs_row", tessera_ui::WorkPhase::Build),
1737 0
1738 );
1739 }
1740 }
1741
1742 #[tessera]
1743 fn tabs_row() {
1744 let selected = remember(|| 0usize);
1745 column().children(move || {
1746 tabs().selected(selected).content(move |row| {
1747 tab(row)
1748 .label("Flights")
1749 .selected(selected.get() == 0)
1750 .on_click(move || selected.set(0));
1751 tab(row)
1752 .label("Hotels")
1753 .selected(selected.get() == 1)
1754 .on_click(move || selected.set(1));
1755 tab(row)
1756 .label("Trains")
1757 .selected(selected.get() == 2)
1758 .on_click(move || selected.set(2));
1759 });
1760 if selected.get() == 1 {
1761 spacer().modifier(Modifier::new().width(Dp(10.0)).height(Dp(10.0)).semantics(
1762 SemanticsArgs {
1763 test_tag: Some("hotels-active".to_string()),
1764 ..Default::default()
1765 },
1766 ));
1767 }
1768 });
1769 }
1770
1771 #[tessera]
1772 fn tabs_click_probe() {
1773 material_theme()
1774 .theme(MaterialTheme::default)
1775 .child(move || {
1776 lazy_column().item(move || {
1777 tabs_row();
1778 });
1779 });
1780 }
1781
1782 #[test]
1783 fn clicking_middle_tab_selects_middle_tab() {
1784 let mut session = tessera_ui::testing::start_layout_test_session(
1785 tessera_ui::testing::layout_test(|| {
1786 tabs_click_probe();
1787 })
1788 .viewport_px(300, 200),
1789 );
1790 let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 0);
1791
1792 let now = tessera_ui::time::Instant::now();
1795 let cursor = Some(PxPosition::new(Px::new(150), Px::new(24)));
1796
1797 let moved = PointerChange::new(
1801 now,
1802 1,
1803 CursorEventContent::Moved(PxPosition::new(Px::new(150), Px::new(24))),
1804 GestureState::TapCandidate,
1805 );
1806 let _ = tessera_ui::testing::pump_layout_test_session_with_input(
1807 &mut session,
1808 16_666_667,
1809 vec![moved],
1810 cursor,
1811 );
1812
1813 let press = PointerChange::new(
1814 now,
1815 1,
1816 CursorEventContent::Pressed(PressKeyEventType::Left),
1817 GestureState::TapCandidate,
1818 );
1819 let _ = tessera_ui::testing::pump_layout_test_session_with_input(
1820 &mut session,
1821 33_333_334,
1822 vec![press],
1823 cursor,
1824 );
1825 let release = PointerChange::new(
1826 now,
1827 1,
1828 CursorEventContent::Released(PressKeyEventType::Left),
1829 GestureState::TapCandidate,
1830 );
1831 let mut snapshot = tessera_ui::testing::pump_layout_test_session_with_input(
1832 &mut session,
1833 50_000_001,
1834 vec![release],
1835 cursor,
1836 );
1837 for frame in 3..40u64 {
1838 snapshot = tessera_ui::testing::pump_layout_test_session(
1839 &mut session,
1840 50_000_001 + 16_666_667 * (frame - 2),
1841 );
1842 }
1843
1844 snapshot.node("hotels-active").exists();
1847 }
1848}
1849
1850#[cfg(test)]
1851mod custom_title_tests {
1852
1853 use tessera_ui::{Dp, Modifier, Px, State, remember, tessera};
1854
1855 use crate::{modifier::ModifierExt as _, spacer::spacer, theme::material_theme};
1856
1857 use super::{TabsController, tab, tabs};
1858
1859 #[derive(Clone, Copy, Default)]
1860 struct FixtureHandles {
1861 controller: Option<State<TabsController>>,
1862 selected: Option<State<usize>>,
1863 scrollable: Option<State<bool>>,
1864 }
1865
1866 #[tessera]
1867 fn fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
1868 let selected = remember(|| 0usize);
1869 let scrollable = remember(|| false);
1870 observed.with_mut(|handles| handles.selected = Some(selected));
1871 observed.with_mut(|handles| handles.scrollable = Some(scrollable));
1872 material_theme().child(move || {
1873 let observed = observed.clone();
1874 tabs()
1875 .selected(selected)
1876 .scrollable(scrollable.get())
1877 .modifier(Modifier::new().width(Dp(300.0)))
1878 .content(move |row| {
1879 observed.with_mut(|handles| handles.controller = Some(row.controller));
1880 tab(row).accessibility_label("Eighty").title(|| {
1881 spacer().modifier(Modifier::new().width(Dp(80.0)).height(Dp(24.0)));
1882 });
1883 tab(row).accessibility_label("One twenty").title(|| {
1884 spacer().modifier(Modifier::new().width(Dp(120.0)).height(Dp(24.0)));
1885 });
1886 });
1887 });
1888 }
1889
1890 #[test]
1891 fn primary_custom_title_indicator_uses_unpadded_slot_width_in_both_row_modes() {
1892 let observed = tessera_ui::testing::TestProbe::default();
1893 let fixture_observed = observed.clone();
1894 let mut session = tessera_ui::testing::start_layout_test_session(
1895 tessera_ui::testing::layout_test(move || fixture(fixture_observed.clone()))
1896 .viewport_px(300, 100),
1897 );
1898 let mut frame = 0_u64;
1899 tessera_ui::testing::pump_layout_test_session(&mut session, frame);
1900 let selected = observed.get().selected.unwrap();
1901 let scrollable = observed.get().scrollable.unwrap();
1902 let controller = observed.get().controller.unwrap();
1903 for scrolling in [false, true] {
1904 scrollable.set(scrolling);
1905 for (index, width) in [(0, 80), (1, 120)] {
1906 selected.set(index);
1907 for _ in 0..90 {
1908 frame += 16_666_667;
1909 tessera_ui::testing::pump_layout_test_session(&mut session, frame);
1910 }
1911 assert_eq!(controller.with(|c| c.indicator_width.value_px()), Px(width));
1912 }
1913 }
1914 }
1915}
1916
1917#[cfg(all(test, not(target_family = "wasm")))]
1918mod small_custom_title_tests {
1919 use std::time::Duration;
1920
1921 use tessera_ui::{
1922 Color, Dp, HeadlessConfig, HeadlessRenderer, Modifier, Px, State,
1923 accesskit::{Action, ActionRequest, Node, NodeId, Role, TreeId},
1924 remember, tessera,
1925 };
1926
1927 use crate::{
1928 modifier::ModifierExt as _,
1929 spacer::spacer,
1930 theme::{MaterialTheme, material_theme},
1931 };
1932
1933 use super::{TabsController, tab, tabs};
1934
1935 #[derive(Clone, Copy, Default)]
1936 struct FixtureHandles {
1937 controller: Option<State<TabsController>>,
1938 selected: Option<State<usize>>,
1939 scrollable: Option<State<bool>>,
1940 }
1941
1942 #[tessera]
1943 fn fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
1944 let selected = remember(|| 0usize);
1945 let scrollable = remember(|| false);
1946 observed.with_mut(|handles| handles.selected = Some(selected));
1947 observed.with_mut(|handles| handles.scrollable = Some(scrollable));
1948 material_theme()
1949 .theme(|| {
1950 let mut theme = MaterialTheme::default();
1951 theme.color_scheme.primary = Color::RED;
1952 theme.color_scheme.surface = Color::WHITE;
1953 theme
1954 })
1955 .child(move || {
1956 let observed = observed.clone();
1957 tabs()
1958 .selected(selected)
1959 .scrollable(scrollable.get())
1960 .min_scrollable_tab_width(Dp::ZERO)
1961 .edge_padding(Dp::ZERO)
1962 .modifier(Modifier::new().width(Dp(300.0)))
1963 .content(move |row| {
1964 observed.with_mut(|handles| handles.controller = Some(row.controller));
1965 tab(row).accessibility_label("Small title").title(|| {
1966 spacer().modifier(
1967 Modifier::new()
1968 .width(Dp(32.0))
1969 .height(Dp(16.0))
1970 .background(Color::BLUE),
1971 );
1972 });
1973 tab(row).accessibility_label("Minimum title").title(|| {
1974 spacer().modifier(
1975 Modifier::new()
1976 .width(Dp(16.0))
1977 .height(Dp(16.0))
1978 .background(Color::BLUE),
1979 );
1980 });
1981 });
1982 });
1983 }
1984
1985 fn node(renderer: &HeadlessRenderer, label: &str, role: Role) -> (NodeId, Node) {
1986 renderer
1987 .accessibility_snapshot()
1988 .unwrap()
1989 .nodes
1990 .into_iter()
1991 .find(|(_, node)| node.role() == role && node.label() == Some(label))
1992 .unwrap_or_else(|| panic!("missing {role:?} {label}"))
1993 }
1994
1995 #[test]
1996 #[ignore = "requires a native GPU adapter"]
1997 fn gpu_small_custom_title_indicators_exclude_minimum_interactive_padding() {
1998 crate::test_utils::run_isolated_test(
1999 "tabs::small_custom_title_tests::gpu_small_custom_title_indicators_exclude_minimum_interactive_padding",
2000 || {
2001 let observed = tessera_ui::testing::TestProbe::default();
2002 let fixture_observed = observed.clone();
2003 let mut renderer = HeadlessRenderer::new(
2004 Box::new(move || {
2005 fixture(fixture_observed.clone());
2006 }),
2007 vec![Box::new(crate::TesseraComponents)],
2008 HeadlessConfig {
2009 width: 300,
2010 height: 100,
2011 sample_count: 1,
2012 frame_time: Duration::from_millis(20),
2013 ..Default::default()
2014 },
2015 )
2016 .unwrap();
2017 renderer.render_frames(12);
2018 let selected = observed.get().selected.unwrap();
2019 let scrollable = observed.get().scrollable.unwrap();
2020 let controller = observed.get().controller.unwrap();
2021 for scrolling in [false, true] {
2022 scrollable.set(scrolling);
2023 renderer.render_frames(90);
2024 for (index, label, visual_width, indicator_width) in
2025 [(0, "Small title", 32, 32), (1, "Minimum title", 16, 24)]
2026 {
2027 let target_node = node(&renderer, label, Role::Tab).0;
2028 assert!(renderer.dispatch_accessibility_action(ActionRequest {
2029 action: Action::Click,
2030 target_tree: TreeId::ROOT,
2031 target_node,
2032 data: None,
2033 }));
2034 renderer.render_frames(90);
2035 assert_eq!(selected.get(), index);
2036 assert_eq!(
2037 renderer.accessibility_snapshot().unwrap().focus,
2038 target_node
2039 );
2040 tessera_ui::FocusManager::current().clear_focus(true);
2041 renderer.render_frames(12);
2042 assert_ne!(
2043 renderer.accessibility_snapshot().unwrap().focus,
2044 target_node
2045 );
2046 assert_eq!(selected.get(), index);
2047 let target = node(&renderer, label, Role::Tab).1.bounds().unwrap();
2048 assert_eq!(target.x1 - target.x0, if scrolling { 48.0 } else { 150.0 });
2049 assert_eq!(target.y1 - target.y0, 48.0);
2050 let center = (target.x0 + target.x1) * 0.5;
2051 assert_eq!(
2052 controller.with(|c| c.indicator_width_px()),
2053 Px(indicator_width)
2054 );
2055 let pixels = renderer.read_pixels().unwrap();
2056 let width = renderer.physical_size().width;
2057 let mut visual = Vec::new();
2058 for y in target.y0 as u32..target.y1 as u32 {
2059 for x in target.x0 as u32..target.x1 as u32 {
2060 let offset = ((y * width + x) * 4) as usize;
2061 if pixels[offset] < 30
2062 && pixels[offset + 1] < 30
2063 && pixels[offset + 2] > 240
2064 {
2065 visual.push((x, y));
2066 }
2067 }
2068 }
2069 assert!(
2070 !visual.is_empty(),
2071 "no blue title paint within {target:?} after clearing focus"
2072 );
2073 let left = visual.iter().map(|(x, _)| *x).min().unwrap();
2074 let right = visual.iter().map(|(x, _)| *x).max().unwrap() + 1;
2075 let top = visual.iter().map(|(_, y)| *y).min().unwrap();
2076 let bottom = visual.iter().map(|(_, y)| *y).max().unwrap() + 1;
2077 assert_eq!(
2078 (right - left, bottom - top),
2079 (visual_width as u32, 16),
2080 "title paint bbox={:?}",
2081 (left, top, right, bottom)
2082 );
2083 assert_eq!(visual.len(), visual_width * 16);
2084 assert_eq!((f64::from(left) + f64::from(right)) * 0.5, center);
2085 assert_eq!(
2086 (f64::from(top) + f64::from(bottom)) * 0.5,
2087 (target.y0 + target.y1) * 0.5,
2088 "the full natural title paint must remain centered within the interaction target",
2089 );
2090 let y = target.y1 as u32 - 1;
2091 let painted = (0..width)
2092 .filter(|x| {
2093 let offset = ((y * width + x) * 4) as usize;
2094 pixels[offset] > 240
2095 && pixels[offset + 1] < 30
2096 && pixels[offset + 2] < 30
2097 })
2098 .collect::<Vec<_>>();
2099 assert_eq!(painted.len(), indicator_width as usize);
2100 assert_eq!(
2101 (f64::from(*painted.first().unwrap())
2102 + f64::from(*painted.last().unwrap())
2103 + 1.0)
2104 * 0.5,
2105 center,
2106 "the visual indicator must remain centered within the full interaction target",
2107 );
2108 }
2109 }
2110 },
2111 );
2112 }
2113}