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 derive_builder::Builder;
13use parking_lot::RwLock;
14use tessera_ui::{
15    Color, ComputedData, Constraint, DimensionValue, Dp, MeasurementError, Px, PxPosition,
16    place_node, tessera,
17};
18
19use crate::{
20    RippleState, animation,
21    button::{ButtonArgsBuilder, button},
22    shape_def::Shape,
23    surface::{SurfaceArgs, surface},
24};
25
26const ANIMATION_DURATION: Duration = Duration::from_millis(250);
27
28fn clamp_wrap(min: Option<Px>, max: Option<Px>, measure: Px) -> Px {
29    min.unwrap_or(Px(0))
30        .max(measure)
31        .min(max.unwrap_or(Px::MAX))
32}
33
34fn fill_value(min: Option<Px>, max: Option<Px>, measure: Px) -> Px {
35    max.expect("Seems that you are trying to fill an infinite dimension, which is not allowed")
36        .max(measure)
37        .max(min.unwrap_or(Px(0)))
38}
39
40fn resolve_dimension(dim: DimensionValue, measure: Px) -> Px {
41    match dim {
42        DimensionValue::Fixed(v) => v,
43        DimensionValue::Wrap { min, max } => clamp_wrap(min, max, measure),
44        DimensionValue::Fill { min, max } => fill_value(min, max, measure),
45    }
46}
47
48/// Holds the mutable state used by the [`tabs`] component.
49///
50/// Clone this handle to share it across UI parts. The state tracks the
51/// active tab index, previous index, animation progress and cached values used to animate the
52/// indicator and content scrolling. The component mutates parts of this state when a tab is
53/// switched; callers may also read the active tab via [`TabsState::active_tab`].
54struct TabsStateInner {
55    active_tab: usize,
56    prev_active_tab: usize,
57    progress: f32,
58    last_switch_time: Option<Instant>,
59    indicator_from_width: Px,
60    indicator_to_width: Px,
61    indicator_from_x: Px,
62    indicator_to_x: Px,
63    content_scroll_offset: Px,
64    target_content_scroll_offset: Px,
65    ripple_states: HashMap<usize, RippleState>,
66}
67
68impl TabsStateInner {
69    fn new(initial_tab: usize) -> Self {
70        Self {
71            active_tab: initial_tab,
72            prev_active_tab: initial_tab,
73            progress: 1.0,
74            last_switch_time: None,
75            indicator_from_width: Px(0),
76            indicator_to_width: Px(0),
77            indicator_from_x: Px(0),
78            indicator_to_x: Px(0),
79            content_scroll_offset: Px(0),
80            target_content_scroll_offset: Px(0),
81            ripple_states: Default::default(),
82        }
83    }
84
85    /// Set the active tab index and initiate the transition animation.
86    ///
87    /// If the requested index equals the current active tab this is a no-op.
88    /// Otherwise the method updates cached indicator/content positions and resets the animation
89    /// progress so the component will animate to the new active tab.
90    fn set_active_tab(&mut self, index: usize) {
91        if self.active_tab != index {
92            self.prev_active_tab = self.active_tab;
93            self.active_tab = index;
94            self.last_switch_time = Some(Instant::now());
95            let eased_progress = animation::easing(self.progress);
96            self.indicator_from_width = Px((self.indicator_from_width.0 as f32
97                + (self.indicator_to_width.0 - self.indicator_from_width.0) as f32 * eased_progress)
98                as i32);
99            self.indicator_from_x = Px((self.indicator_from_x.0 as f32
100                + (self.indicator_to_x.0 - self.indicator_from_x.0) as f32 * eased_progress)
101                as i32);
102            self.content_scroll_offset = Px((self.content_scroll_offset.0 as f32
103                + (self.target_content_scroll_offset.0 - self.content_scroll_offset.0) as f32
104                    * eased_progress) as i32);
105            self.progress = 0.0;
106        }
107    }
108
109    fn ripple_state(&mut self, index: usize) -> RippleState {
110        self.ripple_states.entry(index).or_default().clone()
111    }
112}
113
114#[derive(Clone)]
115pub struct TabsState {
116    inner: Arc<RwLock<TabsStateInner>>,
117}
118
119impl TabsState {
120    /// Create a new state with the specified initial active tab.
121    pub fn new(initial_tab: usize) -> Self {
122        Self {
123            inner: Arc::new(RwLock::new(TabsStateInner::new(initial_tab))),
124        }
125    }
126
127    pub fn set_active_tab(&self, index: usize) {
128        self.inner.write().set_active_tab(index);
129    }
130
131    /// Returns the currently active tab index.
132    pub fn active_tab(&self) -> usize {
133        self.inner.read().active_tab
134    }
135
136    /// Returns the previously active tab index (useful during animated transitions).
137    pub fn prev_active_tab(&self) -> usize {
138        self.inner.read().prev_active_tab
139    }
140
141    pub fn last_switch_time(&self) -> Option<Instant> {
142        self.inner.read().last_switch_time
143    }
144
145    pub fn set_progress(&self, progress: f32) {
146        self.inner.write().progress = progress;
147    }
148
149    pub fn progress(&self) -> f32 {
150        self.inner.read().progress
151    }
152
153    pub fn content_offsets(&self) -> (Px, Px) {
154        let inner = self.inner.read();
155        (
156            inner.content_scroll_offset,
157            inner.target_content_scroll_offset,
158        )
159    }
160
161    pub fn update_content_offsets(&self, current: Px, target: Px) {
162        let mut inner = self.inner.write();
163        inner.content_scroll_offset = current;
164        inner.target_content_scroll_offset = target;
165    }
166
167    pub fn set_indicator_targets(&self, width: Px, x: Px) {
168        let mut inner = self.inner.write();
169        inner.indicator_to_width = width;
170        inner.indicator_to_x = x;
171    }
172
173    pub fn indicator_metrics(&self) -> (Px, Px, Px, Px) {
174        let inner = self.inner.read();
175        (
176            inner.indicator_from_width,
177            inner.indicator_to_width,
178            inner.indicator_from_x,
179            inner.indicator_to_x,
180        )
181    }
182
183    pub fn ripple_state(&self, index: usize) -> RippleState {
184        self.inner.write().ripple_state(index)
185    }
186}
187
188impl Default for TabsState {
189    fn default() -> Self {
190        Self::new(0)
191    }
192}
193
194/// Configuration arguments for the [`tabs`] component.
195#[derive(Builder, Clone)]
196#[builder(pattern = "owned")]
197pub struct TabsArgs {
198    #[builder(default = "Color::new(0.4745, 0.5255, 0.7961, 1.0)")]
199    pub indicator_color: Color,
200    #[builder(default = "DimensionValue::FILLED")]
201    pub width: DimensionValue,
202    #[builder(default = "DimensionValue::Wrap { min: None, max: None }")]
203    pub height: DimensionValue,
204}
205
206impl Default for TabsArgs {
207    fn default() -> Self {
208        TabsArgsBuilder::default().build().unwrap()
209    }
210}
211
212pub struct TabDef {
213    title: Box<dyn FnOnce() + Send + Sync>,
214    content: Box<dyn FnOnce() + Send + Sync>,
215}
216
217pub struct TabsScope<'a> {
218    tabs: &'a mut Vec<TabDef>,
219}
220
221impl<'a> TabsScope<'a> {
222    pub fn child<F1, F2>(&mut self, title: F1, content: F2)
223    where
224        F1: FnOnce() + Send + Sync + 'static,
225        F2: FnOnce() + Send + Sync + 'static,
226    {
227        self.tabs.push(TabDef {
228            title: Box::new(title),
229            content: Box::new(content),
230        });
231    }
232}
233
234#[tessera]
235fn tabs_content_container(scroll_offset: Px, children: Vec<Box<dyn FnOnce() + Send + Sync>>) {
236    for child in children {
237        child();
238    }
239
240    measure(Box::new(
241        move |input| -> Result<ComputedData, MeasurementError> {
242            input.enable_clipping();
243
244            let mut max_height = Px(0);
245            let container_width = resolve_dimension(input.parent_constraint.width, Px(0));
246
247            for &child_id in input.children_ids.iter() {
248                let child_constraint = Constraint::new(
249                    DimensionValue::Fixed(container_width),
250                    DimensionValue::Wrap {
251                        min: None,
252                        max: None,
253                    },
254                );
255                let child_size = input.measure_child(child_id, &child_constraint)?;
256                max_height = max_height.max(child_size.height);
257            }
258
259            let mut current_x = scroll_offset;
260            for &child_id in input.children_ids.iter() {
261                place_node(child_id, PxPosition::new(current_x, Px(0)), input.metadatas);
262                current_x += container_width;
263            }
264
265            Ok(ComputedData {
266                width: container_width,
267                height: max_height,
268            })
269        },
270    ));
271}
272
273/// # tabs
274///
275/// Renders a set of tabs with corresponding content pages.
276///
277/// ## Usage
278///
279/// Display a row of tab titles and a content area that switches between different views.
280///
281/// ## Parameters
282///
283/// - `args` — configures the tabs' layout and indicator color; see [`TabsArgs`].
284/// - `state` — a clonable [`TabsState`] to manage the active tab and animation.
285/// - `scope_config` — a closure that receives a [`TabsScope`] for defining each tab's title and content.
286///
287/// ## Examples
288///
289/// ```
290/// use tessera_ui_basic_components::{
291///     tabs::{tabs, TabsArgsBuilder, TabsState},
292///     text::{text, TextArgsBuilder},
293/// };
294///
295/// // In a real app, you would manage this state.
296/// let tabs_state = TabsState::new(0);
297///
298/// tabs(
299///     TabsArgsBuilder::default().build().unwrap(),
300///     tabs_state,
301///     |scope| {
302///         scope.child(
303///             || text(TextArgsBuilder::default().text("Tab 1".to_string()).build().unwrap()),
304///             || text(TextArgsBuilder::default().text("Content for Tab 1").build().unwrap())
305///         );
306///         scope.child(
307///             || text(TextArgsBuilder::default().text("Tab 2".to_string()).build().unwrap()),
308///             || text(TextArgsBuilder::default().text("Content for Tab 2").build().unwrap())
309///         );
310///     },
311/// );
312/// ```
313#[tessera]
314pub fn tabs<F>(args: TabsArgs, state: TabsState, scope_config: F)
315where
316    F: FnOnce(&mut TabsScope),
317{
318    let mut tabs = Vec::new();
319    let mut scope = TabsScope { tabs: &mut tabs };
320    scope_config(&mut scope);
321
322    let num_tabs = tabs.len();
323    let active_tab = state.active_tab().min(num_tabs.saturating_sub(1));
324
325    let (title_closures, content_closures): (Vec<_>, Vec<_>) =
326        tabs.into_iter().map(|def| (def.title, def.content)).unzip();
327
328    surface(
329        SurfaceArgs {
330            style: args.indicator_color.into(),
331            width: DimensionValue::FILLED,
332            height: DimensionValue::FILLED,
333            ..Default::default()
334        },
335        None,
336        || {},
337    );
338
339    let titles_count = title_closures.len();
340    for (index, child) in title_closures.into_iter().enumerate() {
341        let color = if index == active_tab {
342            Color::new(0.9, 0.9, 0.9, 1.0) // Active tab color
343        } else {
344            Color::TRANSPARENT
345        };
346        let ripple_state = state.ripple_state(index);
347        let state_clone = state.clone();
348
349        let shape = if index == 0 {
350            Shape::RoundedRectangle {
351                top_left: Dp(25.0),
352                top_right: Dp(0.0),
353                bottom_right: Dp(0.0),
354                bottom_left: Dp(0.0),
355                g2_k_value: 3.0,
356            }
357        } else if index == titles_count - 1 {
358            Shape::RoundedRectangle {
359                top_left: Dp(0.0),
360                top_right: Dp(25.0),
361                bottom_right: Dp(0.0),
362                bottom_left: Dp(0.0),
363                g2_k_value: 3.0,
364            }
365        } else {
366            Shape::RECTANGLE
367        };
368
369        button(
370            ButtonArgsBuilder::default()
371                .color(color)
372                .on_click({
373                    let state_clone = state_clone.clone();
374                    Arc::new(move || {
375                        state_clone.set_active_tab(index);
376                    })
377                })
378                .width(DimensionValue::FILLED)
379                .shape(shape)
380                .build()
381                .unwrap(),
382            ripple_state,
383            child,
384        );
385    }
386
387    let scroll_offset = {
388        let eased_progress = animation::easing(state.progress());
389        let (content_offset, target_offset) = state.content_offsets();
390        let offset =
391            content_offset.0 as f32 + (target_offset.0 - content_offset.0) as f32 * eased_progress;
392        Px(offset as i32)
393    };
394
395    tabs_content_container(scroll_offset, content_closures);
396
397    let state_clone = state.clone();
398    input_handler(Box::new(move |_| {
399        if let Some(last_switch_time) = state_clone.last_switch_time() {
400            let elapsed = last_switch_time.elapsed();
401            let fraction = (elapsed.as_secs_f32() / ANIMATION_DURATION.as_secs_f32()).min(1.0);
402            state_clone.set_progress(fraction);
403        }
404    }));
405
406    let tabs_args = args.clone();
407
408    measure(Box::new(
409        move |input| -> Result<ComputedData, MeasurementError> {
410            let tabs_intrinsic_constraint = Constraint::new(tabs_args.width, tabs_args.height);
411            let tabs_effective_constraint =
412                tabs_intrinsic_constraint.merge(input.parent_constraint);
413
414            let tab_effective_width = Constraint {
415                width: {
416                    match tabs_effective_constraint.width {
417                        DimensionValue::Fixed(v) => DimensionValue::Fixed(v / num_tabs as i32),
418                        DimensionValue::Wrap { min, max } => {
419                            let max = max.map(|v| v / num_tabs as i32);
420                            DimensionValue::Wrap { min, max }
421                        }
422                        DimensionValue::Fill { min, max } => {
423                            let max = max.map(|v| v / num_tabs as i32);
424                            DimensionValue::Fill { min, max }
425                        }
426                    }
427                },
428                height: tabs_effective_constraint.height,
429            };
430
431            let indicator_id = input.children_ids[0];
432            let title_ids = &input.children_ids[1..=num_tabs];
433            let content_container_id = input.children_ids[num_tabs + 1];
434
435            let title_constraints: Vec<_> = title_ids
436                .iter()
437                .map(|&id| (id, tab_effective_width))
438                .collect();
439            let title_results = input.measure_children(title_constraints)?;
440
441            let mut title_sizes = Vec::with_capacity(num_tabs);
442            let mut titles_total_width = Px(0);
443            let mut titles_max_height = Px(0);
444            for &title_id in title_ids {
445                if let Some(result) = title_results.get(&title_id) {
446                    title_sizes.push(*result);
447                    titles_total_width += result.width;
448                    titles_max_height = titles_max_height.max(result.height);
449                }
450            }
451
452            let content_container_constraint = Constraint::new(
453                DimensionValue::Fill {
454                    min: None,
455                    max: Some(titles_total_width),
456                },
457                DimensionValue::Wrap {
458                    min: None,
459                    max: None,
460                },
461            );
462            let content_container_size =
463                input.measure_child(content_container_id, &content_container_constraint)?;
464
465            let final_width = titles_total_width;
466            let target_offset = -Px(active_tab as i32 * final_width.0);
467            let (_, target_content_scroll_offset) = state.content_offsets();
468            if target_content_scroll_offset != target_offset {
469                state.update_content_offsets(target_content_scroll_offset, target_offset);
470            }
471
472            let (indicator_width, indicator_x) = {
473                let active_title_width = title_sizes.get(active_tab).map_or(Px(0), |s| s.width);
474                let active_title_x: Px = title_sizes
475                    .iter()
476                    .take(active_tab)
477                    .map(|s| s.width)
478                    .fold(Px(0), |acc, w| acc + w);
479
480                state.set_indicator_targets(active_title_width, active_title_x);
481
482                let (from_width, to_width, from_x, to_x) = state.indicator_metrics();
483                let eased_progress = animation::easing(state.progress());
484                let width = Px((from_width.0 as f32
485                    + (to_width.0 - from_width.0) as f32 * eased_progress)
486                    as i32);
487                let x = Px((from_x.0 as f32 + (to_x.0 - from_x.0) as f32 * eased_progress) as i32);
488                (width, x)
489            };
490
491            let indicator_height = Dp(2.0).into();
492            let indicator_constraint = Constraint::new(
493                DimensionValue::Fixed(indicator_width),
494                DimensionValue::Fixed(indicator_height),
495            );
496            let _ = input.measure_child(indicator_id, &indicator_constraint)?;
497
498            let final_width = titles_total_width;
499            let final_height = titles_max_height + content_container_size.height;
500
501            let mut current_x = Px(0);
502            for (i, &title_id) in title_ids.iter().enumerate() {
503                place_node(title_id, PxPosition::new(current_x, Px(0)), input.metadatas);
504                if let Some(title_size) = title_sizes.get(i) {
505                    current_x += title_size.width;
506                }
507            }
508
509            place_node(
510                indicator_id,
511                PxPosition::new(indicator_x, titles_max_height),
512                input.metadatas,
513            );
514
515            place_node(
516                content_container_id,
517                PxPosition::new(Px(0), titles_max_height),
518                input.metadatas,
519            );
520
521            Ok(ComputedData {
522                width: final_width,
523                height: final_height,
524            })
525        },
526    ));
527}