1use std::{
7 collections::HashMap,
8 sync::Arc,
9 time::{Duration, Instant},
10};
11
12use closure::closure;
13use derive_builder::Builder;
14use parking_lot::RwLock;
15use tessera_ui::{
16 Color, ComputedData, Constraint, DimensionValue, Dp, MeasurementError, Px, PxPosition, tessera,
17};
18
19use crate::{
20 RippleState,
21 alignment::Alignment,
22 animation,
23 boxed::{BoxedArgs, boxed},
24 button::{ButtonArgsBuilder, button},
25 shape_def::{RoundedCorner, Shape},
26 surface::{SurfaceArgs, surface},
27};
28
29const ANIMATION_DURATION: Duration = Duration::from_millis(300);
30
31fn clamp_wrap(min: Option<Px>, max: Option<Px>, measure: Px) -> Px {
32 min.unwrap_or(Px(0))
33 .max(measure)
34 .min(max.unwrap_or(Px::MAX))
35}
36
37fn fill_value(min: Option<Px>, max: Option<Px>, measure: Px) -> Px {
38 max.expect("Seems that you are trying to fill an infinite dimension, which is not allowed")
39 .max(measure)
40 .max(min.unwrap_or(Px(0)))
41}
42
43fn clamp_px(value: Px, min: Px, max: Option<Px>) -> Px {
44 let clamped_max = max.unwrap_or(value);
45 Px(value.0.max(min.0).min(clamped_max.0))
46}
47
48fn resolve_dimension(dim: DimensionValue, measure: Px) -> Px {
49 match dim {
50 DimensionValue::Fixed(v) => v,
51 DimensionValue::Wrap { min, max } => clamp_wrap(min, max, measure),
52 DimensionValue::Fill { min, max } => fill_value(min, max, measure),
53 }
54}
55
56fn blend_state_layer(base: Color, layer: Color, opacity: f32) -> Color {
57 let opacity = opacity.clamp(0.0, 1.0);
58 Color {
59 r: base.r * (1.0 - opacity) + layer.r * opacity,
60 g: base.g * (1.0 - opacity) + layer.g * opacity,
61 b: base.b * (1.0 - opacity) + layer.b * opacity,
62 a: base.a,
63 }
64}
65
66struct TabsStateInner {
73 active_tab: usize,
74 prev_active_tab: usize,
75 progress: f32,
76 last_switch_time: Option<Instant>,
77 indicator_from_width: Px,
78 indicator_to_width: Px,
79 indicator_from_x: Px,
80 indicator_to_x: Px,
81 content_scroll_offset: Px,
82 target_content_scroll_offset: Px,
83 ripple_states: HashMap<usize, RippleState>,
84}
85
86impl TabsStateInner {
87 fn new(initial_tab: usize) -> Self {
88 Self {
89 active_tab: initial_tab,
90 prev_active_tab: initial_tab,
91 progress: 1.0,
92 last_switch_time: None,
93 indicator_from_width: Px(0),
94 indicator_to_width: Px(0),
95 indicator_from_x: Px(0),
96 indicator_to_x: Px(0),
97 content_scroll_offset: Px(0),
98 target_content_scroll_offset: Px(0),
99 ripple_states: Default::default(),
100 }
101 }
102
103 fn set_active_tab(&mut self, index: usize) {
109 if self.active_tab != index {
110 self.prev_active_tab = self.active_tab;
111 self.active_tab = index;
112 self.last_switch_time = Some(Instant::now());
113 let eased_progress = animation::easing(self.progress);
114 self.indicator_from_width = Px((self.indicator_from_width.0 as f32
115 + (self.indicator_to_width.0 - self.indicator_from_width.0) as f32 * eased_progress)
116 as i32);
117 self.indicator_from_x = Px((self.indicator_from_x.0 as f32
118 + (self.indicator_to_x.0 - self.indicator_from_x.0) as f32 * eased_progress)
119 as i32);
120 self.content_scroll_offset = Px((self.content_scroll_offset.0 as f32
121 + (self.target_content_scroll_offset.0 - self.content_scroll_offset.0) as f32
122 * eased_progress) as i32);
123 self.progress = 0.0;
124 }
125 }
126
127 fn ripple_state(&mut self, index: usize) -> RippleState {
128 self.ripple_states.entry(index).or_default().clone()
129 }
130}
131
132#[derive(Clone)]
141pub struct TabsState {
142 inner: Arc<RwLock<TabsStateInner>>,
143}
144
145impl TabsState {
146 pub fn new(initial_tab: usize) -> Self {
148 Self {
149 inner: Arc::new(RwLock::new(TabsStateInner::new(initial_tab))),
150 }
151 }
152
153 pub fn set_active_tab(&self, index: usize) {
155 self.inner.write().set_active_tab(index);
156 }
157
158 pub fn active_tab(&self) -> usize {
160 self.inner.read().active_tab
161 }
162
163 pub fn prev_active_tab(&self) -> usize {
165 self.inner.read().prev_active_tab
166 }
167
168 pub fn last_switch_time(&self) -> Option<Instant> {
170 self.inner.read().last_switch_time
171 }
172
173 fn set_progress(&self, progress: f32) {
175 self.inner.write().progress = progress;
176 }
177
178 fn progress(&self) -> f32 {
180 self.inner.read().progress
181 }
182
183 fn content_offsets(&self) -> (Px, Px) {
185 let inner = self.inner.read();
186 (
187 inner.content_scroll_offset,
188 inner.target_content_scroll_offset,
189 )
190 }
191
192 fn update_content_offsets(&self, current: Px, target: Px) {
194 let mut inner = self.inner.write();
195 inner.content_scroll_offset = current;
196 inner.target_content_scroll_offset = target;
197 }
198
199 fn set_indicator_targets(&self, width: Px, x: Px) {
201 let mut inner = self.inner.write();
202 inner.indicator_to_width = width;
203 inner.indicator_to_x = x;
204 }
205
206 fn indicator_metrics(&self) -> (Px, Px, Px, Px) {
208 let inner = self.inner.read();
209 (
210 inner.indicator_from_width,
211 inner.indicator_to_width,
212 inner.indicator_from_x,
213 inner.indicator_to_x,
214 )
215 }
216
217 fn ripple_state(&self, index: usize) -> RippleState {
219 self.inner.write().ripple_state(index)
220 }
221}
222
223impl Default for TabsState {
224 fn default() -> Self {
225 Self::new(0)
226 }
227}
228
229#[derive(Builder, Clone)]
231#[builder(pattern = "owned")]
232pub struct TabsArgs {
233 #[builder(default = "crate::material_color::global_material_scheme().primary")]
235 pub indicator_color: Color,
237 #[builder(default = "crate::material_color::global_material_scheme().surface")]
239 pub container_color: Color,
240 #[builder(default = "crate::material_color::global_material_scheme().on_surface")]
242 pub active_content_color: Color,
243 #[builder(default = "crate::material_color::global_material_scheme().on_surface_variant")]
245 pub inactive_content_color: Color,
246 #[builder(default = "Dp(3.0)")]
248 pub indicator_height: Dp,
249 #[builder(default = "Dp(24.0)")]
251 pub indicator_min_width: Dp,
252 #[builder(default = "Some(Dp(64.0))")]
254 pub indicator_max_width: Option<Dp>,
255 #[builder(default = "Dp(48.0)")]
257 pub min_tab_height: Dp,
258 #[builder(default = "Dp(12.0)")]
260 pub tab_padding: Dp,
261 #[builder(default = "crate::material_color::global_material_scheme().on_surface")]
263 pub state_layer_color: Color,
264 #[builder(default = "0.08")]
266 pub hover_state_layer_opacity: f32,
267 #[builder(default = "DimensionValue::FILLED")]
269 pub width: DimensionValue,
270 #[builder(default = "DimensionValue::Wrap { min: None, max: None }")]
272 pub height: DimensionValue,
273}
274
275impl Default for TabsArgs {
276 fn default() -> Self {
277 TabsArgsBuilder::default()
278 .build()
279 .expect("builder construction failed")
280 }
281}
282
283struct TabDef {
284 title: TabTitle,
285 content: Box<dyn FnOnce() + Send + Sync>,
286}
287
288enum TabTitle {
289 Custom(Box<dyn FnOnce() + Send + Sync>),
290 Themed(Box<dyn FnOnce(Color) + Send + Sync>),
291}
292
293pub struct TabsScope<'a> {
295 tabs: &'a mut Vec<TabDef>,
296}
297
298impl<'a> TabsScope<'a> {
299 pub fn child<F1, F2>(&mut self, title: F1, content: F2)
301 where
302 F1: FnOnce() + Send + Sync + 'static,
303 F2: FnOnce() + Send + Sync + 'static,
304 {
305 self.tabs.push(TabDef {
306 title: TabTitle::Custom(Box::new(title)),
307 content: Box::new(content),
308 });
309 }
310
311 pub fn child_with_color<F1, F2>(&mut self, title: F1, content: F2)
313 where
314 F1: FnOnce(Color) + Send + Sync + 'static,
315 F2: FnOnce() + Send + Sync + 'static,
316 {
317 self.tabs.push(TabDef {
318 title: TabTitle::Themed(Box::new(title)),
319 content: Box::new(content),
320 });
321 }
322}
323
324#[tessera]
325fn tabs_content_container(scroll_offset: Px, children: Vec<Box<dyn FnOnce() + Send + Sync>>) {
326 for child in children {
327 child();
328 }
329
330 measure(Box::new(
331 move |input| -> Result<ComputedData, MeasurementError> {
332 input.enable_clipping();
333
334 let mut max_height = Px(0);
335 let container_width = resolve_dimension(input.parent_constraint.width, Px(0));
336
337 for &child_id in input.children_ids.iter() {
338 let child_constraint = Constraint::new(
339 DimensionValue::Fixed(container_width),
340 DimensionValue::Wrap {
341 min: None,
342 max: None,
343 },
344 );
345 let child_size = input.measure_child(child_id, &child_constraint)?;
346 max_height = max_height.max(child_size.height);
347 }
348
349 let mut current_x = scroll_offset;
350 for &child_id in input.children_ids.iter() {
351 input.place_child(child_id, PxPosition::new(current_x, Px(0)));
352 current_x += container_width;
353 }
354
355 Ok(ComputedData {
356 width: container_width,
357 height: max_height,
358 })
359 },
360 ));
361}
362
363#[tessera]
438pub fn tabs<F>(args: TabsArgs, state: TabsState, scope_config: F)
439where
440 F: FnOnce(&mut TabsScope),
441{
442 let mut tabs = Vec::new();
443 let mut scope = TabsScope { tabs: &mut tabs };
444 scope_config(&mut scope);
445
446 let num_tabs = tabs.len();
447 if num_tabs == 0 {
448 return;
449 }
450 let active_tab = state.active_tab().min(num_tabs.saturating_sub(1));
451
452 let (title_closures, content_closures): (Vec<_>, Vec<_>) =
453 tabs.into_iter().map(|def| (def.title, def.content)).unzip();
454
455 surface(
456 SurfaceArgs {
457 style: args.indicator_color.into(),
458 width: DimensionValue::FILLED,
459 height: DimensionValue::FILLED,
460 shape: Shape::RoundedRectangle {
461 top_left: RoundedCorner::Capsule,
462 top_right: RoundedCorner::Capsule,
463 bottom_right: RoundedCorner::ZERO,
464 bottom_left: RoundedCorner::ZERO,
465 },
466 ..Default::default()
467 },
468 None,
469 || {},
470 );
471
472 let hover_color = blend_state_layer(
473 args.container_color,
474 args.state_layer_color,
475 args.hover_state_layer_opacity,
476 );
477
478 for (index, child) in title_closures.into_iter().enumerate() {
479 let ripple_state = state.ripple_state(index);
480
481 let label_color = if index == active_tab {
482 args.active_content_color
483 } else {
484 args.inactive_content_color
485 };
486
487 button(
488 ButtonArgsBuilder::default()
489 .color(args.container_color)
490 .hover_color(Some(hover_color))
491 .padding(args.tab_padding)
492 .ripple_color(args.state_layer_color)
493 .on_click(Arc::new(closure!(clone state, || {
494 state.set_active_tab(index);
495 })))
496 .width(DimensionValue::FILLED)
497 .shape(Shape::RECTANGLE)
498 .build()
499 .expect("builder construction failed"),
500 ripple_state,
501 move || {
502 boxed(
503 BoxedArgs {
504 alignment: Alignment::Center,
505 width: DimensionValue::FILLED,
506 ..Default::default()
507 },
508 |scope| {
509 scope.child(move || match child {
510 TabTitle::Custom(render) => render(),
511 TabTitle::Themed(render) => render(label_color),
512 });
513 },
514 );
515 },
516 );
517 }
518
519 let scroll_offset = {
520 let eased_progress = animation::easing(state.progress());
521 let (content_offset, target_offset) = state.content_offsets();
522 let offset =
523 content_offset.0 as f32 + (target_offset.0 - content_offset.0) as f32 * eased_progress;
524 Px(offset as i32)
525 };
526
527 tabs_content_container(scroll_offset, content_closures);
528
529 let state_clone = state.clone();
530 input_handler(Box::new(move |_| {
531 if let Some(last_switch_time) = state_clone.last_switch_time() {
532 let elapsed = last_switch_time.elapsed();
533 let fraction = (elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32()).min(1.0);
534 state_clone.set_progress(fraction);
535 }
536 }));
537
538 let tabs_args = args.clone();
539
540 measure(Box::new(
541 move |input| -> Result<ComputedData, MeasurementError> {
542 let tabs_intrinsic_constraint = Constraint::new(tabs_args.width, tabs_args.height);
543 let tabs_effective_constraint =
544 tabs_intrinsic_constraint.merge(input.parent_constraint);
545
546 let tab_effective_width = Constraint {
547 width: {
548 match tabs_effective_constraint.width {
549 DimensionValue::Fixed(v) => DimensionValue::Fixed(v / num_tabs as i32),
550 DimensionValue::Wrap { min, max } => {
551 let max = max.map(|v| v / num_tabs as i32);
552 DimensionValue::Wrap { min, max }
553 }
554 DimensionValue::Fill { min, max } => {
555 let max = max.map(|v| v / num_tabs as i32);
556 DimensionValue::Fill { min, max }
557 }
558 }
559 },
560 height: tabs_effective_constraint.height,
561 };
562
563 let indicator_id = input.children_ids[0];
564 let title_ids = &input.children_ids[1..=num_tabs];
565 let content_container_id = input.children_ids[num_tabs + 1];
566
567 let title_constraints: Vec<_> = title_ids
568 .iter()
569 .map(|&id| (id, tab_effective_width))
570 .collect();
571 let title_results = input.measure_children(title_constraints)?;
572
573 let mut title_sizes = Vec::with_capacity(num_tabs);
574 let mut titles_total_width = Px(0);
575 let mut titles_max_height = Px(0);
576 for &title_id in title_ids {
577 if let Some(result) = title_results.get(&title_id) {
578 title_sizes.push(*result);
579 titles_total_width += result.width;
580 titles_max_height = titles_max_height.max(result.height);
581 }
582 }
583
584 let content_container_constraint = Constraint::new(
585 DimensionValue::Fill {
586 min: Some(titles_total_width),
587 max: Some(titles_total_width),
588 },
589 DimensionValue::Wrap {
590 min: None,
591 max: None,
592 },
593 );
594 let content_container_size =
595 input.measure_child(content_container_id, &content_container_constraint)?;
596
597 let final_width = titles_total_width;
598 let page_width = content_container_size.width;
599 let target_offset = -Px(active_tab as i32 * page_width.0);
600 let (_, target_content_scroll_offset) = state.content_offsets();
601 if target_content_scroll_offset != target_offset {
602 state.update_content_offsets(target_content_scroll_offset, target_offset);
603 }
604
605 let (indicator_width, indicator_x) = {
606 let active_title_width = title_sizes.get(active_tab).map_or(Px(0), |s| s.width);
607 let active_title_x: Px = title_sizes
608 .iter()
609 .take(active_tab)
610 .map(|s| s.width)
611 .fold(Px(0), |acc, w| acc + w);
612
613 let clamped_width = clamp_px(
614 active_title_width,
615 tabs_args.indicator_min_width.into(),
616 tabs_args.indicator_max_width.map(|v| v.into()),
617 );
618 let centered_x = active_title_x + Px((active_title_width.0 - clamped_width.0) / 2);
619
620 state.set_indicator_targets(clamped_width, centered_x);
621
622 let (from_width, to_width, from_x, to_x) = state.indicator_metrics();
623 let eased_progress = animation::easing(state.progress());
624 let width = Px((from_width.0 as f32
625 + (to_width.0 - from_width.0) as f32 * eased_progress)
626 as i32);
627 let x = Px((from_x.0 as f32 + (to_x.0 - from_x.0) as f32 * eased_progress) as i32);
628 (width, x)
629 };
630
631 let indicator_height: Px = tabs_args.indicator_height.into();
632 let indicator_constraint = Constraint::new(
633 DimensionValue::Fixed(indicator_width),
634 DimensionValue::Fixed(indicator_height),
635 );
636 let _ = input.measure_child(indicator_id, &indicator_constraint)?;
637
638 let tab_bar_height =
639 (titles_max_height + indicator_height).max(tabs_args.min_tab_height.into());
640 let final_height = tab_bar_height + content_container_size.height;
641 let title_offset_y = (tab_bar_height - indicator_height - titles_max_height).max(Px(0));
642
643 let mut current_x = Px(0);
644 for (i, &title_id) in title_ids.iter().enumerate() {
645 input.place_child(title_id, PxPosition::new(current_x, title_offset_y));
646 if let Some(title_size) = title_sizes.get(i) {
647 current_x += title_size.width;
648 }
649 }
650
651 input.place_child(
652 indicator_id,
653 PxPosition::new(indicator_x, tab_bar_height - indicator_height),
654 );
655
656 input.place_child(content_container_id, PxPosition::new(Px(0), tab_bar_height));
657
658 Ok(ComputedData {
659 width: final_width,
660 height: final_height,
661 })
662 },
663 ));
664}