tessera_ui_basic_components/
tabs.rs

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