tessera_ui_basic_components/
tabs.rs1use 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
83pub 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 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 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 pub fn active_tab(&self) -> usize {
153 self.active_tab
154 }
155
156 pub fn prev_active_tab(&self) -> usize {
158 self.prev_active_tab
159 }
160}
161
162#[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#[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) } 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}