tessera_ui_basic_components/
tabs.rs

1//! A component for creating a tab-based layout.
2//!
3//! ## Usage
4//!
5//! Use to organize content into separate pages that can be switched between.
6use 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
66/// Holds the mutable state used by the [`tabs`] component.
67///
68/// Clone this handle to share it across UI parts. The state tracks the
69/// active tab index, previous index, animation progress and cached values used to animate the
70/// indicator and content scrolling. The component mutates parts of this state when a tab is
71/// switched; callers may also read the active tab via [`TabsState::active_tab`].
72struct 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    /// Set the active tab index and initiate the transition animation.
104    ///
105    /// If the requested index equals the current active tab this is a no-op.
106    /// Otherwise the method updates cached indicator/content positions and resets the animation
107    /// progress so the component will animate to the new active tab.
108    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/// State handle for the `tabs` component, tracking selection and indicator animation.
133///
134/// ```
135/// use tessera_ui_basic_components::tabs::TabsState;
136///
137/// let tabs_state = TabsState::new(0); // Start with the first tab active
138/// assert_eq!(tabs_state.active_tab(), 0);
139/// ```
140#[derive(Clone)]
141pub struct TabsState {
142    inner: Arc<RwLock<TabsStateInner>>,
143}
144
145impl TabsState {
146    /// Create a new state with the specified initial active tab.
147    pub fn new(initial_tab: usize) -> Self {
148        Self {
149            inner: Arc::new(RwLock::new(TabsStateInner::new(initial_tab))),
150        }
151    }
152
153    /// Updates the active tab index and resets indicator progress.
154    pub fn set_active_tab(&self, index: usize) {
155        self.inner.write().set_active_tab(index);
156    }
157
158    /// Returns the currently active tab index.
159    pub fn active_tab(&self) -> usize {
160        self.inner.read().active_tab
161    }
162
163    /// Returns the previously active tab index (useful during animated transitions).
164    pub fn prev_active_tab(&self) -> usize {
165        self.inner.read().prev_active_tab
166    }
167
168    /// Returns the time of the last tab switch, when available.
169    pub fn last_switch_time(&self) -> Option<Instant> {
170        self.inner.read().last_switch_time
171    }
172
173    /// Sets the indicator animation progress (0.0..1.0).
174    fn set_progress(&self, progress: f32) {
175        self.inner.write().progress = progress;
176    }
177
178    /// Current indicator animation progress (0.0..1.0).
179    fn progress(&self) -> f32 {
180        self.inner.read().progress
181    }
182
183    /// Returns the current and target scroll offsets for tab content.
184    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    /// Updates the current and target content offsets, typically during animations.
193    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    /// Sets the indicator's target width and X offset for the active tab.
200    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    /// Returns indicator start/target width and X positions.
207    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    /// Retrieves or initializes the ripple state for the given tab.
218    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/// Configuration arguments for the [`tabs`] component.
230#[derive(Builder, Clone)]
231#[builder(pattern = "owned")]
232pub struct TabsArgs {
233    /// Color of the active tab indicator.
234    #[builder(default = "crate::material_color::global_material_scheme().primary")]
235    // Material primary tone
236    pub indicator_color: Color,
237    /// Background color for the tab row container.
238    #[builder(default = "crate::material_color::global_material_scheme().surface")]
239    pub container_color: Color,
240    /// Color applied to active tab titles (Material on-surface).
241    #[builder(default = "crate::material_color::global_material_scheme().on_surface")]
242    pub active_content_color: Color,
243    /// Color applied to inactive tab titles (Material on-surface-variant).
244    #[builder(default = "crate::material_color::global_material_scheme().on_surface_variant")]
245    pub inactive_content_color: Color,
246    /// Height of the indicator bar in density-independent pixels.
247    #[builder(default = "Dp(3.0)")]
248    pub indicator_height: Dp,
249    /// Minimum width for the indicator bar.
250    #[builder(default = "Dp(24.0)")]
251    pub indicator_min_width: Dp,
252    /// Optional maximum width for the indicator bar.
253    #[builder(default = "Some(Dp(64.0))")]
254    pub indicator_max_width: Option<Dp>,
255    /// Minimum height for a tab (Material spec uses 48dp).
256    #[builder(default = "Dp(48.0)")]
257    pub min_tab_height: Dp,
258    /// Internal padding for each tab, applied symmetrically.
259    #[builder(default = "Dp(12.0)")]
260    pub tab_padding: Dp,
261    /// Color used for hover/pressed state layers.
262    #[builder(default = "crate::material_color::global_material_scheme().on_surface")]
263    pub state_layer_color: Color,
264    /// Opacity applied to the state layer on hover.
265    #[builder(default = "0.08")]
266    pub hover_state_layer_opacity: f32,
267    /// Width behavior for the entire tabs container.
268    #[builder(default = "DimensionValue::FILLED")]
269    pub width: DimensionValue,
270    /// Height behavior for the tabs container.
271    #[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
293/// Scope passed to tab configuration closures.
294pub struct TabsScope<'a> {
295    tabs: &'a mut Vec<TabDef>,
296}
297
298impl<'a> TabsScope<'a> {
299    /// Adds a tab with its title and content builders.
300    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    /// Adds a tab whose title closure receives the resolved content color (active/inactive).
312    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/// # tabs
364///
365/// Renders a set of tabs with corresponding content pages.
366///
367/// ## Usage
368///
369/// Display a row of tab titles and a content area that switches between different views.
370///
371/// ## Parameters
372///
373/// - `args` — configures the tabs' layout and indicator color; see [`TabsArgs`].
374/// - `state` — a clonable [`TabsState`] to manage the active tab and animation.
375/// - `scope_config` — a closure that receives a [`TabsScope`] for defining each tab's title and content.
376///   Use [`TabsScope::child_with_color`] to let the component supply Material-compliant active/inactive colors.
377///
378/// ## Examples
379///
380/// ```
381/// use tessera_ui::Dp;
382/// use tessera_ui_basic_components::{
383///     tabs::{tabs, TabsArgsBuilder, TabsState},
384///     text::{text, TextArgsBuilder},
385/// };
386///
387/// let tabs_state = TabsState::new(0);
388/// assert_eq!(tabs_state.active_tab(), 0);
389///
390/// tabs(
391///     TabsArgsBuilder::default().build().expect("builder construction failed"),
392///     tabs_state,
393///     |scope| {
394///         scope.child_with_color(
395///             |color| {
396///                 text(
397///                     TextArgsBuilder::default()
398///                         .text("Flights".to_string())
399///                         .color(color)
400///                         .size(Dp(14.0))
401///                         .build()
402///                         .expect("builder construction failed"),
403///                 )
404///             },
405///             || {
406///                 text(
407///                     TextArgsBuilder::default()
408///                         .text("Content for Flights")
409///                         .build()
410///                         .expect("builder construction failed"),
411///                 )
412///             },
413///         );
414///         scope.child_with_color(
415///             |color| {
416///                 text(
417///                     TextArgsBuilder::default()
418///                         .text("Hotel".to_string())
419///                         .color(color)
420///                         .size(Dp(14.0))
421///                         .build()
422///                         .expect("builder construction failed"),
423///                 )
424///             },
425///             || {
426///                 text(
427///                     TextArgsBuilder::default()
428///                         .text("Content for Hotel")
429///                         .build()
430///                         .expect("builder construction failed"),
431///                 )
432///             },
433///         );
434///     },
435/// );
436/// ```
437#[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}