1use 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
48struct 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 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 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 pub fn active_tab(&self) -> usize {
133 self.inner.read().active_tab
134 }
135
136 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#[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#[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) } 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}