Skip to main content

tessera_components/
tabs.rs

1//! A component for creating a tab-based layout.
2//!
3//! ## Usage
4//!
5//! Use to organize related destinations into a horizontal tab strip.
6use std::{any::TypeId, sync::Arc};
7
8use tessera_foundation::gesture::{ScrollRecognizer, ScrollSettings};
9use tessera_ui::{
10    AxisConstraint, Callback, Color, ComputedData, Constraint, Dp, FocusManager, FocusProperties,
11    FocusRequester, FocusState, LayoutResult, MeasurementError, Modifier, ParentDataMap,
12    ParentDataModifierNode, Px, PxPosition, RenderSlot, RenderSlotWith, State,
13    accesskit::Role,
14    layout::{MeasureScope, layout},
15    modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
16    receive_frame_nanos, remember, tessera, use_context,
17};
18
19use crate::{
20    alignment::{Alignment, CrossAxisAlignment, MainAxisAlignment},
21    animation::Animatable,
22    boxed::boxed,
23    column::column,
24    icon::icon as icon_component,
25    interaction_state::InteractionState,
26    modifier::{ModifierExt, SemanticsArgs, with_pointer_input},
27    painter::Painter,
28    shape_def::{RoundedCorner, Shape},
29    spacer::spacer,
30    surface::surface,
31    text::text as text_component,
32    theme::{ContentColor, MaterialAlpha, MaterialColorScheme, MaterialTheme},
33};
34
35use navigation::{TabFocusData, TabListSemantics, TabNavigation};
36
37const DEFAULT_SPATIAL_DAMPING_RATIO: f32 = 0.9;
38const DEFAULT_SPATIAL_STIFFNESS: f32 = 700.0;
39
40mod navigation;
41
42#[cfg(test)]
43mod accessibility_tests;
44
45/// Visual variants supported by [`tabs`].
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub enum TabsVariant {
48    /// Primary tabs.
49    #[default]
50    Primary,
51    /// Secondary tabs.
52    Secondary,
53}
54
55/// Keyboard activation behavior for [`tabs`].
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum TabActivationMode {
58    /// Move focus with arrows and select with Enter or Space.
59    #[default]
60    Manual,
61    /// Select the destination when an arrow key moves focus to it.
62    Automatic,
63}
64
65/// Material Design 3 defaults for [`tabs`].
66pub struct TabsDefaults;
67
68impl TabsDefaults {
69    /// Default indicator height.
70    pub const INDICATOR_HEIGHT: Dp = Dp(3.0);
71    /// Default secondary indicator height.
72    pub const SECONDARY_INDICATOR_HEIGHT: Dp = Dp(2.0);
73    /// Default minimum indicator width.
74    pub const INDICATOR_MIN_WIDTH: Dp = Dp(24.0);
75    /// Default maximum indicator width.
76    pub const INDICATOR_MAX_WIDTH: Option<Dp> = None;
77    /// Minimum height for a tab (Material spec uses 48dp).
78    pub const MIN_TAB_HEIGHT: Dp = Dp(48.0);
79    /// Default internal padding for each tab.
80    pub const TAB_PADDING: Dp = Dp(16.0);
81    /// Default hover alpha for state layers.
82    pub const HOVER_STATE_LAYER_OPACITY: f32 = MaterialAlpha::HOVER;
83    /// Default divider height for tab rows.
84    pub const DIVIDER_HEIGHT: Dp = Dp(1.0);
85    /// Default minimum width for a scrollable tab.
86    pub const SCROLLABLE_MIN_TAB_WIDTH: Dp = Dp(90.0);
87    /// Default edge padding applied to scrollable tab rows.
88    pub const SCROLLABLE_EDGE_PADDING: Dp = Dp(52.0);
89    /// Default height for a tab that shows both icon and text.
90    pub const LARGE_TAB_HEIGHT: Dp = Dp(64.0);
91
92    /// Default disabled content color.
93    pub fn disabled_content_color(scheme: &MaterialColorScheme) -> Color {
94        scheme
95            .on_surface
96            .with_alpha(MaterialAlpha::DISABLED_CONTENT)
97    }
98
99    /// Default ripple color derived from the selected content color.
100    pub fn ripple_color(selected_content_color: Color) -> Color {
101        selected_content_color
102    }
103}
104
105#[derive(Clone, PartialEq, Copy, Debug)]
106struct Spring1D {
107    value: f32,
108    velocity: f32,
109    target: f32,
110}
111
112impl Spring1D {
113    fn new(value: f32) -> Self {
114        Self {
115            value,
116            velocity: 0.0,
117            target: value,
118        }
119    }
120
121    fn snap_to(&mut self, value: f32) {
122        self.value = value;
123        self.target = value;
124        self.velocity = 0.0;
125    }
126
127    fn set_target(&mut self, target: f32) {
128        self.target = target;
129    }
130
131    fn update(&mut self, dt: f32, stiffness: f32, damping_ratio: f32) {
132        let stiffness = stiffness.max(0.0);
133        if stiffness == 0.0 {
134            self.snap_to(self.target);
135            return;
136        }
137
138        let mut motion = Animatable::spring(self.value, stiffness.sqrt(), damping_ratio)
139            .with_velocity(self.velocity);
140        motion.set_target(self.target);
141        self.value = motion.tick(dt);
142        self.velocity = motion.velocity();
143
144        if (self.value - self.target).abs() < 0.5 && self.velocity.abs() < 0.5 {
145            self.snap_to(self.target);
146        }
147    }
148
149    fn value_px(self) -> Px {
150        Px::saturating_from_f32(self.value)
151    }
152
153    fn is_animating(self) -> bool {
154        (self.value - self.target).abs() >= 0.5 || self.velocity.abs() >= 0.5
155    }
156}
157
158fn clamp_px(value: Px, min: Px, max: Option<Px>) -> Px {
159    let clamped_max = max.unwrap_or(value);
160    Px(value.0.max(min.0).min(clamped_max.0))
161}
162
163/// Internal runtime state for the tab row.
164///
165/// Holds the animation springs plus a cached copy of the externally owned
166/// selected index. The selected index itself lives in caller-owned `State`;
167/// this struct only mirrors it to drive the indicator and scroll animation.
168#[derive(Clone, PartialEq)]
169struct TabsController {
170    density: f64,
171    targets: Vec<TabFocusData>,
172    focused_requester: Option<FocusRequester>,
173    pending_focus: Option<FocusRequester>,
174    activation_mode: TabActivationMode,
175    active_tab: usize,
176    indicator_x: Spring1D,
177    indicator_width: Spring1D,
178    tab_row_scroll_offset: Spring1D,
179    tab_row_scroll_max: Px,
180    tab_row_scroll_user_overridden: bool,
181    tab_bar_height: Px,
182    last_frame_nanos: Option<u64>,
183    indicator_initialized: bool,
184    tab_row_scroll_initialized: bool,
185    pending_retarget_frame: bool,
186}
187
188impl TabsController {
189    fn new(initial_tab: usize) -> Self {
190        Self {
191            density: tessera_ui::dp::scale_factor(),
192            targets: Vec::new(),
193            focused_requester: None,
194            pending_focus: None,
195            activation_mode: TabActivationMode::Manual,
196            active_tab: initial_tab,
197            indicator_x: Spring1D::new(0.0),
198            indicator_width: Spring1D::new(0.0),
199            tab_row_scroll_offset: Spring1D::new(0.0),
200            tab_row_scroll_max: Px(0),
201            tab_row_scroll_user_overridden: false,
202            tab_bar_height: Px(0),
203            last_frame_nanos: None,
204            indicator_initialized: false,
205            tab_row_scroll_initialized: false,
206            pending_retarget_frame: false,
207        }
208    }
209
210    /// Mirror the caller-owned selected index into the animation state.
211    ///
212    /// Only subsequent changes retarget the indicator/scroll springs;
213    /// identical values are no-ops.
214    fn sync_selected(&mut self, index: usize) {
215        if index != self.active_tab {
216            self.active_tab = index;
217            self.tab_row_scroll_user_overridden = false;
218            self.pending_retarget_frame = true;
219            self.last_frame_nanos = None;
220        }
221    }
222
223    fn update_density(&mut self, density: f64) {
224        let ratio = density / self.density;
225        for spring in [
226            &mut self.indicator_x,
227            &mut self.indicator_width,
228            &mut self.tab_row_scroll_offset,
229        ] {
230            spring.value = (f64::from(spring.value) * ratio) as f32;
231            spring.target = (f64::from(spring.target) * ratio) as f32;
232            spring.velocity = (f64::from(spring.velocity) * ratio) as f32;
233        }
234        self.tab_row_scroll_max = Px((f64::from(self.tab_row_scroll_max.0) * ratio) as i32);
235        self.tab_bar_height = Px((f64::from(self.tab_bar_height.0) * ratio) as i32);
236        self.density = density;
237    }
238
239    fn active_tab(&self) -> usize {
240        self.active_tab
241    }
242
243    fn set_focused_requester(&mut self, requester: Option<FocusRequester>) {
244        self.focused_requester = requester;
245        self.tab_row_scroll_user_overridden = requester.is_none();
246    }
247
248    fn sync_targets(&mut self, targets: Vec<TabFocusData>) {
249        if self.focused_requester.is_some_and(|requester| {
250            !targets
251                .iter()
252                .any(|target| target.requester == requester && target.enabled)
253        }) {
254            let fallback = targets
255                .iter()
256                .find(|target| target.selected && target.enabled)
257                .or_else(|| targets.iter().find(|target| target.enabled))
258                .map(|target| target.requester);
259            self.focused_requester = fallback;
260            self.pending_focus = fallback;
261        }
262        self.targets = targets;
263    }
264
265    fn set_tab_row_scroll_bounds(&mut self, max: Px) {
266        self.tab_row_scroll_max = max.max(Px(0));
267        let clamped = self
268            .tab_row_scroll_offset
269            .value
270            .clamp(0.0, self.tab_row_scroll_max.to_f32());
271        self.tab_row_scroll_offset.snap_to(clamped);
272    }
273
274    fn tab_row_scroll_max(&self) -> Px {
275        self.tab_row_scroll_max
276    }
277
278    fn set_tab_bar_height(&mut self, height: Px) {
279        self.tab_bar_height = height.max(Px(0));
280    }
281
282    fn tab_bar_height(&self) -> Px {
283        self.tab_bar_height
284    }
285
286    fn set_tab_row_scroll_immediate(&mut self, value: Px) {
287        let value = value.max(Px(0)).min(self.tab_row_scroll_max);
288        self.tab_row_scroll_offset.snap_to(value.to_f32());
289        self.tab_row_scroll_initialized = true;
290        self.tab_row_scroll_user_overridden = true;
291    }
292
293    fn set_tab_row_scroll_target(&mut self, target: Px) {
294        self.pending_retarget_frame = false;
295        let target = target.max(Px(0)).min(self.tab_row_scroll_max);
296        if !self.tab_row_scroll_initialized {
297            self.tab_row_scroll_offset.snap_to(target.to_f32());
298            self.tab_row_scroll_initialized = true;
299        } else {
300            self.last_frame_nanos = None;
301            self.tab_row_scroll_offset.set_target(target.to_f32());
302        }
303    }
304
305    fn tab_row_scroll_px(&self) -> Px {
306        self.tab_row_scroll_offset.value_px()
307    }
308
309    fn set_indicator_targets(&mut self, width: Px, x: Px) {
310        self.pending_retarget_frame = false;
311        let width = width.max(Px(0)).to_f32();
312        let x = x.to_f32();
313        if !self.indicator_initialized {
314            self.indicator_width.snap_to(width);
315            self.indicator_x.snap_to(x);
316            self.indicator_initialized = true;
317        } else {
318            self.last_frame_nanos = None;
319            self.indicator_width.set_target(width);
320            self.indicator_x.set_target(x);
321        }
322    }
323
324    fn indicator_width_px(&self) -> Px {
325        self.indicator_width.value_px().max(Px(0))
326    }
327
328    fn indicator_x_px(&self) -> Px {
329        self.indicator_x.value_px()
330    }
331
332    fn advance_from_frame_nanos(&mut self, frame_nanos: u64) {
333        let dt = if let Some(last_frame_nanos) = self.last_frame_nanos {
334            frame_nanos.saturating_sub(last_frame_nanos) as f32 / 1_000_000_000.0
335        } else {
336            1.0 / 60.0
337        };
338        self.last_frame_nanos = Some(frame_nanos);
339
340        self.indicator_x
341            .update(dt, DEFAULT_SPATIAL_STIFFNESS, DEFAULT_SPATIAL_DAMPING_RATIO);
342        self.indicator_width
343            .update(dt, DEFAULT_SPATIAL_STIFFNESS, DEFAULT_SPATIAL_DAMPING_RATIO);
344        self.tab_row_scroll_offset.update(
345            dt,
346            DEFAULT_SPATIAL_STIFFNESS,
347            DEFAULT_SPATIAL_DAMPING_RATIO,
348        );
349    }
350
351    fn has_pending_animation_frame(&self) -> bool {
352        self.pending_retarget_frame
353            || self.indicator_x.is_animating()
354            || self.indicator_width.is_animating()
355            || self.tab_row_scroll_offset.is_animating()
356    }
357}
358
359impl Default for TabsController {
360    fn default() -> Self {
361        Self::new(0)
362    }
363}
364
365#[derive(Clone, PartialEq)]
366struct TabsConfig {
367    selection: State<usize>,
368    modifier: Modifier,
369    variant: TabsVariant,
370    indicator_color: Color,
371    container_color: Color,
372    active_content_color: Color,
373    inactive_content_color: Color,
374    inactive_interaction_color: Color,
375    indicator_height: Dp,
376    indicator_min_width: Dp,
377    indicator_max_width: Option<Dp>,
378    min_tab_height: Dp,
379    tab_padding: Dp,
380    enabled: bool,
381    disabled_content_color: Color,
382    divider_color: Color,
383    scrollable: bool,
384    edge_padding: Dp,
385    min_scrollable_tab_width: Dp,
386}
387
388#[derive(Clone, Copy, PartialEq)]
389/// The row configuration and selection handles supplied to its tab content.
390pub struct TabRow {
391    controller: State<TabsController>,
392    selected: State<usize>,
393    enabled: bool,
394    active_content_color: Color,
395    inactive_content_color: Color,
396    inactive_interaction_color: Color,
397    disabled_content_color: Color,
398    ripple_color: Color,
399    min_tab_height: Dp,
400    tab_padding: Dp,
401}
402
403#[derive(Clone, PartialEq)]
404enum TabTitle {
405    Custom(RenderSlot),
406    Label { text: String, icon: Option<Painter> },
407}
408
409#[derive(Clone, Copy, PartialEq)]
410struct TabIndicatorPadding(Dp);
411
412impl ParentDataModifierNode for TabIndicatorPadding {
413    fn apply_parent_data(&self, data: &mut ParentDataMap) {
414        data.insert(TypeId::of::<Self>(), Arc::new(*self));
415    }
416}
417
418#[derive(Clone, Copy, PartialEq)]
419struct TabActivation {
420    selection: State<usize>,
421    controlled: bool,
422    on_click: Option<Callback>,
423}
424
425impl TabBuilder {
426    /// Set a custom title slot for the tab and clear built-in label state.
427    pub fn title<F, Output>(&self, title: F) -> Self
428    where
429        F: Fn() -> Output + Send + Sync + 'static,
430    {
431        self.props.configure_deferred("title_slot", move |props| {
432            props.title_slot = Some(RenderSlot::new(title))
433        });
434        let value = None;
435        self.props
436            .configure("label_text", move |props| props.label_text = value);
437        let value = None;
438        self.props
439            .configure("label_icon", move |props| props.label_icon = value);
440        Self { props: self.props }
441    }
442
443    /// Set a shared custom title slot for the tab and clear built-in label
444    /// state.
445    pub fn title_shared(&self, title: impl Into<RenderSlot> + Send + Sync + 'static) -> Self {
446        self.props.configure_deferred("title_slot", move |props| {
447            props.title_slot = Some(title.into())
448        });
449        let value = None;
450        self.props
451            .configure("label_text", move |props| props.label_text = value);
452        let value = None;
453        self.props
454            .configure("label_icon", move |props| props.label_icon = value);
455        Self { props: self.props }
456    }
457
458    /// Set the built-in Material text label for the tab and clear any custom
459    /// title slot.
460    pub fn label(&self, label: impl Into<String>) -> Self {
461        let value = None;
462        self.props
463            .configure("title_slot", move |props| props.title_slot = value);
464        let value = Some(label.into());
465        self.props
466            .configure("label_text", move |props| props.label_text = value);
467        Self { props: self.props }
468    }
469
470    /// Set the built-in Material icon for the tab and clear any custom title
471    /// slot.
472    pub fn icon(&self, icon: impl Into<Painter>) -> Self {
473        let value = None;
474        self.props
475            .configure("title_slot", move |props| props.title_slot = value);
476        let value = Some(icon.into());
477        self.props
478            .configure("label_icon", move |props| props.label_icon = value);
479        Self { props: self.props }
480    }
481
482    /// Set the built-in Material label and icon for the tab.
483    pub fn label_with_icon(&self, label: impl Into<String>, icon: impl Into<Painter>) -> Self {
484        self.label(label).icon(icon)
485    }
486}
487
488/// # tab
489///
490/// Renders a single tab inside [`tabs`].
491///
492/// ## Usage
493///
494/// Use inside a `tabs().content(...)` slot to declare one tab-strip item.
495///
496/// ## Parameters
497///
498/// - `row` — row configuration received by the enclosing tabs content slot.
499/// - `selected` — optional controlled selection; otherwise follows the tab row.
500/// - `on_click` — callback invoked when the tab is activated.
501/// - `enabled` — whether this tab can be focused or activated.
502/// - `title` — custom title slot; primary indicators match its intrinsic width.
503///   The slot owns its content padding.
504/// - `label` — built-in Material text label for the tab.
505/// - `icon` — built-in Material icon for the tab.
506/// - `accessibility_label` — accessible name override. Authors must provide a
507///   nonempty name for icon-only tabs and non-text custom titles.
508///
509/// ## Examples
510///
511/// ```
512/// use tessera_components::tabs::{tab, tabs};
513/// use tessera_components::text::text;
514/// use tessera_ui::{remember, tessera};
515/// # use tessera_components::theme::{MaterialTheme, material_theme};
516///
517/// #[tessera]
518/// fn demo() {
519/// #     material_theme()
520/// #         .theme(|| MaterialTheme::default())
521/// #         .child(|| {
522///     let selected = remember(|| 0usize);
523///     tabs().selected(selected).content(move |row| {
524///         tab(row)
525///             .label("Home")
526///             .selected(selected.get() == 0)
527///             .on_click(move || selected.set(0));
528///     });
529/// #         });
530/// }
531/// ```
532#[tessera]
533pub fn tab(
534    row: TabRow,
535    selected: Option<bool>,
536    enabled: Option<bool>,
537    #[prop(skip_setter)] title_slot: Option<RenderSlot>,
538    #[prop(skip_setter)] label_text: Option<String>,
539    #[prop(skip_setter)] label_icon: Option<Painter>,
540    on_click: Option<Callback>,
541    #[prop(into)] accessibility_label: Option<String>,
542) {
543    let composition = row;
544    let focus_requester = remember(FocusRequester::new).get();
545    let controller = composition.controller;
546    let selected_state = composition.selected;
547    let controlled = selected.is_some();
548    let activation = TabActivation {
549        selection: selected_state,
550        controlled,
551        on_click,
552    };
553    let live_activation = remember(|| activation);
554    if live_activation.with(|value| value != &activation) {
555        live_activation.set(activation);
556    }
557    let selected = selected.unwrap_or_else(|| {
558        controller.with(|controller| {
559            controller
560                .targets
561                .iter()
562                .position(|target| target.requester == focus_requester)
563                == Some(controller.active_tab())
564        })
565    });
566    let enabled = composition.enabled && enabled.unwrap_or(true);
567    let interaction = remember(InteractionState::new);
568    let interacting = interaction.with(|interaction| {
569        interaction.is_hovered() || interaction.is_focused() || interaction.is_pressed()
570    });
571    let activate = Callback::new(move || {
572        let index = controller.with(|controller| {
573            controller
574                .targets
575                .iter()
576                .position(|target| target.requester == focus_requester && target.enabled)
577        });
578        if let Some(index) = index {
579            let activation = live_activation.get();
580            if !activation.controlled && activation.selection.get() != index {
581                activation.selection.set(index);
582            }
583            if let Some(on_click) = activation.on_click {
584                on_click.call();
585            }
586            FocusManager::current().defer_keyboard_until_layout();
587        }
588    });
589    let title = if let Some(title_slot) = title_slot {
590        TabTitle::Custom(title_slot)
591    } else if label_text.is_some() || label_icon.is_some() {
592        TabTitle::Label {
593            text: label_text.unwrap_or_default(),
594            icon: label_icon,
595        }
596    } else {
597        panic!("tab requires title(), label(), icon(), or label_with_icon()");
598    };
599    let accessibility_label = accessibility_label.or_else(|| match &title {
600        TabTitle::Label { text, .. } if !text.trim().is_empty() => Some(text.clone()),
601        _ => None,
602    });
603    let label_color = if !enabled {
604        composition.disabled_content_color
605    } else if selected {
606        composition.active_content_color
607    } else if interacting {
608        composition.inactive_interaction_color
609    } else {
610        composition.inactive_content_color
611    };
612    let tab_height = match &title {
613        TabTitle::Label {
614            text,
615            icon: Some(_),
616        } if !text.is_empty() => Dp(TabsDefaults::LARGE_TAB_HEIGHT
617            .0
618            .max(composition.min_tab_height.0)),
619        _ => composition.min_tab_height,
620    };
621    tab_trigger().args(TabTriggerArgs {
622        controller,
623        interaction,
624        title,
625        enabled,
626        selected,
627        on_click: Some(activate),
628        focus_requester,
629        label_color,
630        ripple_color: if selected {
631            composition.ripple_color
632        } else {
633            composition.inactive_interaction_color
634        },
635        tab_height,
636        tab_padding: composition.tab_padding,
637        accessibility_label,
638    });
639}
640
641/// # tab_label
642///
643/// Renders a standard Material tab label with optional icon and text.
644///
645/// ## Usage
646///
647/// Use inside tab rows to match Material baseline and icon spacing.
648///
649/// ## Parameters
650///
651/// - `text` — text shown in the tab.
652/// - `icon` — optional icon shown above the text.
653/// - `horizontal_text_padding` — horizontal padding applied to the text area.
654/// - `icon_size` — size of the icon when present.
655///
656/// ## Examples
657///
658/// ```
659/// use tessera_components::tabs::tab_label;
660/// use tessera_ui::tessera;
661/// # use tessera_components::theme::{MaterialTheme, material_theme};
662///
663/// #[tessera]
664/// fn demo() {
665/// #     material_theme()
666/// #         .theme(|| MaterialTheme::default())
667/// #         .child(|| {
668///     tab_label().text("Home");
669/// #         });
670/// }
671/// ```
672#[tessera]
673pub fn tab_label(
674    #[prop(into)] text: Option<String>,
675    #[prop(into)] icon: Option<Painter>,
676    horizontal_text_padding: Option<Dp>,
677    icon_size: Option<Dp>,
678) {
679    let text = text.unwrap_or_default();
680    let has_icon = icon.is_some();
681    let has_text = !text.is_empty();
682    let icon_size = icon_size.unwrap_or(Dp(24.0));
683    let horizontal_padding = horizontal_text_padding.unwrap_or(TabsDefaults::TAB_PADDING);
684    let current = TabLabelContent {
685        text,
686        icon,
687        icon_size,
688    };
689    let live = remember(|| current.clone());
690    if live.with(|value| value != &current) {
691        live.set(current);
692    }
693    let small_height = TabsDefaults::MIN_TAB_HEIGHT;
694    let large_height = TabsDefaults::LARGE_TAB_HEIGHT;
695    let container_height = if has_icon && has_text {
696        large_height
697    } else {
698        small_height
699    };
700
701    let modifier = Modifier::new()
702        .constrain(
703            Some(AxisConstraint::NONE),
704            Some(AxisConstraint::exact(container_height.into())),
705        )
706        .padding_symmetric(horizontal_padding, Dp(0.0));
707
708    boxed()
709        .alignment(Alignment::Center)
710        .modifier(modifier)
711        .children(move || {
712            let current = live.get();
713            let has_icon = current.icon.is_some();
714            let has_text = !current.text.is_empty();
715            let content_color = use_context::<ContentColor>()
716                .map(|color| color.get().current)
717                .unwrap_or_else(|| ContentColor::default().current);
718            let style = use_context::<MaterialTheme>()
719                .expect("MaterialTheme must be provided")
720                .get()
721                .typography
722                .title_small;
723            {
724                if has_icon && has_text {
725                    column()
726                        .main_axis_alignment(MainAxisAlignment::Center)
727                        .cross_axis_alignment(CrossAxisAlignment::Center)
728                        .modifier(Modifier::new())
729                        .children(move || {
730                            let current = live.get();
731                            let content_color = use_context::<ContentColor>()
732                                .map(|color| color.get().current)
733                                .unwrap_or_else(|| ContentColor::default().current);
734                            let style = use_context::<MaterialTheme>()
735                                .expect("MaterialTheme must be provided")
736                                .get()
737                                .typography
738                                .title_small;
739                            {
740                                if let Some(ic) = current.icon {
741                                    icon_component()
742                                        .painter(ic)
743                                        .size(current.icon_size)
744                                        .tint(content_color);
745                                }
746                            };
747                            {
748                                spacer().modifier(Modifier::new().constrain(
749                                    Some(AxisConstraint::exact(Px(0))),
750                                    Some(AxisConstraint::exact(Dp(2.0).into())),
751                                ));
752                            };
753                            {
754                                text_component(current.text)
755                                    .color(content_color)
756                                    .style(style);
757                            };
758                        });
759                } else if has_icon {
760                    if let Some(ic) = current.icon {
761                        icon_component()
762                            .painter(ic)
763                            .size(current.icon_size)
764                            .tint(content_color);
765                    }
766                } else if has_text {
767                    text_component(current.text)
768                        .color(content_color)
769                        .style(style);
770                }
771            };
772        });
773}
774
775#[derive(Clone, PartialEq)]
776struct TabLabelContent {
777    text: String,
778    icon: Option<Painter>,
779    icon_size: Dp,
780}
781
782#[derive(Clone)]
783struct TabsLayout {
784    args: TabsConfig,
785    controller: State<TabsController>,
786    tab_row_scroll_px: Px,
787    indicator_x_px: Px,
788    indicator_width_px: Px,
789    focused_requester: Option<FocusRequester>,
790    active_tab: usize,
791    scroll_user_overridden: bool,
792}
793
794impl PartialEq for TabsLayout {
795    fn eq(&self, other: &Self) -> bool {
796        self.tab_row_scroll_px == other.tab_row_scroll_px
797            && self.focused_requester == other.focused_requester
798            && self.active_tab == other.active_tab
799            && self.scroll_user_overridden == other.scroll_user_overridden
800            && self.indicator_x_px == other.indicator_x_px
801            && self.indicator_width_px == other.indicator_width_px
802            && self.args.variant == other.args.variant
803            && self.args.indicator_height == other.args.indicator_height
804            && self.args.indicator_min_width == other.args.indicator_min_width
805            && self.args.indicator_max_width == other.args.indicator_max_width
806            && self.args.min_tab_height == other.args.min_tab_height
807            && self.args.tab_padding == other.args.tab_padding
808            && self.args.scrollable == other.args.scrollable
809            && self.args.edge_padding == other.args.edge_padding
810            && self.args.min_scrollable_tab_width == other.args.min_scrollable_tab_width
811    }
812}
813
814impl tessera_ui::LayoutModifierNode for TabsLayout {
815    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
816        let mut result = LayoutResult::default();
817        let children = input.children();
818        let parent_constraint = *input.parent_constraint().as_ref();
819
820        let container = children[0];
821        let divider = children[1];
822        let indicator = children[2];
823        let title_ids = &children[3..];
824        let num_tabs = title_ids.len();
825        let targets = title_ids
826            .iter()
827            .filter_map(|child| child.parent_data::<TabFocusData>(input))
828            .collect::<Vec<_>>();
829        if self
830            .controller
831            .with(|controller| controller.targets != targets)
832        {
833            self.controller
834                .with_mut(|controller| controller.sync_targets(targets));
835        }
836        let active_tab = self
837            .controller
838            .with(|controller| controller.active_tab())
839            .min(num_tabs.saturating_sub(1));
840        let reveal_tab = self
841            .controller
842            .with(|controller| {
843                controller.focused_requester.and_then(|requester| {
844                    controller
845                        .targets
846                        .iter()
847                        .position(|target| target.requester == requester)
848                })
849            })
850            .unwrap_or(active_tab);
851
852        let title_padding = title_ids
853            .iter()
854            .map(|title| {
855                title
856                    .parent_data::<TabIndicatorPadding>(input)
857                    .map_or(0.0, |padding| padding.0.to_px().to_f32() * 2.0)
858            })
859            .collect::<Vec<_>>();
860        let indicator_min_width: Px = self.args.indicator_min_width.into();
861        let available_width = parent_constraint.width.resolve_max();
862
863        let is_scrollable = self.args.scrollable
864            || available_width.is_none()
865            || available_width.is_some_and(|width| {
866                width.0
867                    < TabsDefaults::MIN_TAB_HEIGHT
868                        .to_px()
869                        .0
870                        .saturating_mul(num_tabs as i32)
871            });
872        let match_content_size = matches!(self.args.variant, TabsVariant::Primary);
873
874        let (
875            final_width,
876            strip_width_total,
877            tab_widths,
878            tab_lefts,
879            indicator_widths,
880            titles_max_height,
881            scroll_target,
882        ): (Px, Px, Vec<Px>, Vec<Px>, Vec<Px>, Px, Px) = if !is_scrollable {
883            let final_width = available_width.unwrap_or(Px(0));
884            let tab_width = if num_tabs == 0 {
885                Px(0)
886            } else {
887                Px(final_width.0 / num_tabs as i32)
888            };
889            let remainder = if num_tabs == 0 {
890                0
891            } else {
892                final_width.0 % num_tabs as i32
893            };
894            let tab_widths = (0..num_tabs)
895                .map(|index| tab_width + Px(i32::from((index as i32) < remainder)))
896                .collect::<Vec<_>>();
897
898            let mut titles_max_height = Px(0);
899            for (index, &title_id) in title_ids.iter().enumerate() {
900                let result = title_id.probe(
901                    input,
902                    &Constraint::new(
903                        AxisConstraint::exact(tab_widths[index]),
904                        AxisConstraint::NONE,
905                    ),
906                )?;
907                titles_max_height = titles_max_height.max(result.height);
908            }
909
910            let intrinsic_results: Vec<_> = title_ids
911                .iter()
912                .enumerate()
913                .map(|(index, &id)| {
914                    id.probe_intrinsic(
915                        input,
916                        &Constraint::new(
917                            AxisConstraint::at_most(tab_widths[index]),
918                            AxisConstraint::exact(titles_max_height),
919                        ),
920                    )
921                })
922                .collect::<Result<_, _>>()?;
923
924            let indicator_widths: Vec<Px> = title_ids
925                .iter()
926                .enumerate()
927                .map(|(idx, _id)| {
928                    if match_content_size {
929                        let intrinsic_width = intrinsic_results[idx].width.min(tab_widths[idx]);
930                        let content_width =
931                            (intrinsic_width.to_f32() - title_padding[idx]).max(0.0);
932                        Px::saturating_from_f32(content_width).max(indicator_min_width)
933                    } else {
934                        tab_widths[idx]
935                    }
936                })
937                .collect();
938
939            let mut left = Px(0);
940            let tab_lefts = tab_widths
941                .iter()
942                .map(|width| {
943                    let current = left;
944                    left += *width;
945                    current
946                })
947                .collect::<Vec<_>>();
948
949            (
950                final_width,
951                final_width,
952                tab_widths,
953                tab_lefts,
954                indicator_widths,
955                titles_max_height,
956                Px(0),
957            )
958        } else {
959            let min_tab_width: Px = self.args.min_scrollable_tab_width.into();
960            let edge_padding: Px = self.args.edge_padding.into();
961
962            let mut tab_widths = Vec::with_capacity(num_tabs);
963            let mut titles_max_height = Px(0);
964            for &title_id in title_ids {
965                let result = title_id.probe(
966                    input,
967                    &Constraint::new(
968                        AxisConstraint::at_least(min_tab_width),
969                        AxisConstraint::NONE,
970                    ),
971                )?;
972                tab_widths.push(result.width);
973                titles_max_height = titles_max_height.max(result.height);
974            }
975
976            let intrinsic_results: Vec<_> = title_ids
977                .iter()
978                .map(|&id| {
979                    id.probe_intrinsic(
980                        input,
981                        &Constraint::new(
982                            AxisConstraint::NONE,
983                            AxisConstraint::exact(titles_max_height),
984                        ),
985                    )
986                })
987                .collect::<Result<_, _>>()?;
988
989            let mut indicator_widths = Vec::with_capacity(num_tabs);
990            for (idx, &_title_id) in title_ids.iter().enumerate() {
991                let tab_width = tab_widths.get(idx).copied().unwrap_or(Px(0));
992                if match_content_size {
993                    let intrinsic_width = intrinsic_results[idx].width.min(tab_width);
994                    let content_width = (intrinsic_width.to_f32() - title_padding[idx]).max(0.0);
995                    let indicator_width =
996                        Px::saturating_from_f32(content_width).max(indicator_min_width);
997                    indicator_widths.push(indicator_width);
998                } else {
999                    indicator_widths.push(tab_width);
1000                }
1001            }
1002
1003            let mut tab_lefts = Vec::with_capacity(num_tabs);
1004            let mut left = edge_padding;
1005            for width in &tab_widths {
1006                tab_lefts.push(left);
1007                left += *width;
1008            }
1009
1010            let strip_width_total = left + edge_padding;
1011            let final_width = available_width.unwrap_or(strip_width_total);
1012
1013            let max_scroll = (strip_width_total - final_width).max(Px(0));
1014            let should_update_scroll_bounds = self.controller.with(|c| {
1015                if c.tab_row_scroll_max != max_scroll {
1016                    return true;
1017                }
1018                let clamped = c
1019                    .tab_row_scroll_offset
1020                    .value
1021                    .clamp(0.0, max_scroll.to_f32());
1022                (c.tab_row_scroll_offset.value - clamped).abs() > f32::EPSILON
1023            });
1024            if should_update_scroll_bounds {
1025                self.controller
1026                    .with_mut(|c| c.set_tab_row_scroll_bounds(max_scroll));
1027            }
1028
1029            let selected_left = tab_lefts.get(reveal_tab).copied().unwrap_or(edge_padding);
1030            let selected_width = tab_widths.get(reveal_tab).copied().unwrap_or(Px(0));
1031            let selected_center = selected_left + Px(selected_width.0.saturating_div(2));
1032            let target_scroll_f = (selected_center.to_f32() - final_width.to_f32() / 2.0)
1033                .clamp(0.0, max_scroll.to_f32());
1034            let scroll_target = Px::saturating_from_f32(target_scroll_f);
1035
1036            (
1037                final_width,
1038                strip_width_total,
1039                tab_widths,
1040                tab_lefts,
1041                indicator_widths,
1042                titles_max_height,
1043                scroll_target,
1044            )
1045        };
1046
1047        if is_scrollable {
1048            let should_update_tab_row_scroll_target = self.controller.with(|c| {
1049                !c.tab_row_scroll_user_overridden
1050                    && (!c.tab_row_scroll_initialized
1051                        || (c.tab_row_scroll_offset.target - scroll_target.to_f32()).abs()
1052                            > f32::EPSILON)
1053            });
1054            if should_update_tab_row_scroll_target {
1055                self.controller
1056                    .with_mut(|c| c.set_tab_row_scroll_target(scroll_target));
1057            }
1058        }
1059
1060        let current_scroll_px = if is_scrollable {
1061            self.controller.with(|c| c.tab_row_scroll_px())
1062        } else {
1063            Px(0)
1064        };
1065
1066        let (indicator_width, indicator_x) = {
1067            let desired_width = indicator_widths.get(active_tab).copied().unwrap_or(Px(0));
1068            let clamped_width = clamp_px(
1069                desired_width,
1070                self.args.indicator_min_width.into(),
1071                self.args.indicator_max_width.map(|v| v.into()),
1072            );
1073            if !is_scrollable
1074                && self
1075                    .controller
1076                    .with(|controller| controller.tab_row_scroll_max != Px(0))
1077            {
1078                self.controller
1079                    .with_mut(|controller| controller.set_tab_row_scroll_bounds(Px(0)));
1080            }
1081
1082            let tab_left = tab_lefts.get(active_tab).copied().unwrap_or(Px(0));
1083            let tab_width = tab_widths.get(active_tab).copied().unwrap_or(Px(0));
1084
1085            let centered_x = tab_left + Px((tab_width.0 - clamped_width.0) / 2);
1086
1087            let should_update_indicator_targets = self.controller.with(|c| {
1088                !c.indicator_initialized
1089                    || (c.indicator_width.target - clamped_width.to_f32()).abs() > f32::EPSILON
1090                    || (c.indicator_x.target - centered_x.to_f32()).abs() > f32::EPSILON
1091            });
1092            if should_update_indicator_targets {
1093                self.controller
1094                    .with_mut(|c| c.set_indicator_targets(clamped_width, centered_x));
1095            }
1096            (
1097                self.controller.with(|c| c.indicator_width_px()),
1098                self.controller.with(|c| c.indicator_x_px()),
1099            )
1100        };
1101
1102        let indicator_height: Px = self.args.indicator_height.into();
1103        let indicator_constraint = Constraint::new(
1104            AxisConstraint::exact(indicator_width),
1105            AxisConstraint::exact(indicator_height),
1106        );
1107        let _ = indicator.measure(input, &indicator_constraint)?;
1108
1109        let divider_height: Px = TabsDefaults::DIVIDER_HEIGHT.into();
1110        let divider_width = if is_scrollable {
1111            strip_width_total
1112        } else {
1113            final_width
1114        };
1115        let divider_constraint = Constraint::new(
1116            AxisConstraint::exact(divider_width),
1117            AxisConstraint::exact(divider_height),
1118        );
1119        let _ = divider.measure(input, &divider_constraint)?;
1120
1121        let tab_bar_height = titles_max_height.max(self.args.min_tab_height.into());
1122        let should_update_tab_bar_height =
1123            self.controller.with(|c| c.tab_bar_height != tab_bar_height);
1124        if should_update_tab_bar_height {
1125            self.controller
1126                .with_mut(|c| c.set_tab_bar_height(tab_bar_height));
1127        }
1128        let final_height = tab_bar_height;
1129        let title_offset_y = Px((tab_bar_height.0 - titles_max_height.0) / 2).max(Px(0));
1130
1131        for (idx, &title_id) in title_ids.iter().enumerate() {
1132            let _ = title_id.measure(
1133                input,
1134                &Constraint::new(
1135                    AxisConstraint::exact(tab_widths.get(idx).copied().unwrap_or(Px(0))),
1136                    AxisConstraint::exact(tab_bar_height),
1137                ),
1138            )?;
1139        }
1140
1141        let container_constraint = Constraint::new(
1142            AxisConstraint::exact(final_width),
1143            AxisConstraint::exact(tab_bar_height),
1144        );
1145        let _ = container.measure(input, &container_constraint)?;
1146
1147        for (i, &title_id) in title_ids.iter().enumerate() {
1148            let x = tab_lefts.get(i).copied().unwrap_or(Px(0)) - current_scroll_px;
1149            result.place_child(title_id, PxPosition::new(x, title_offset_y));
1150        }
1151
1152        result.place_child(container, PxPosition::new(Px(0), Px(0)));
1153        result.place_child(
1154            divider,
1155            PxPosition::new(
1156                if is_scrollable {
1157                    -current_scroll_px
1158                } else {
1159                    Px(0)
1160                },
1161                tab_bar_height - divider_height,
1162            ),
1163        );
1164        result.place_child(
1165            indicator,
1166            PxPosition::new(
1167                indicator_x - current_scroll_px,
1168                tab_bar_height - indicator_height,
1169            ),
1170        );
1171
1172        Ok(result.with_size(ComputedData {
1173            width: final_width,
1174            height: final_height,
1175        }))
1176    }
1177}
1178
1179impl tessera_ui::DrawModifierNode for TabsLayout {
1180    fn draw(
1181        &self,
1182        ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
1183        content: &mut dyn tessera_ui::DrawModifierContent,
1184    ) {
1185        let input = &mut *ctx.render_input;
1186        input.metadata_mut().set_clips_children(true);
1187        content.draw(input);
1188    }
1189}
1190
1191/// # tabs
1192///
1193/// Renders a Material tab row.
1194///
1195/// ## Usage
1196///
1197/// Show a row of related destinations and render the selected page outside the
1198/// tab row.
1199///
1200/// ## Parameters
1201///
1202/// - `modifier` — modifier chain applied to the tabs subtree.
1203/// - `variant` — visual variant of the tab row.
1204/// - `activation_mode` — manual activation by default, or selection with
1205///   arrows.
1206/// - `selected` — caller-owned state holding the active tab index. When
1207///   omitted, the row keeps an internal selection slot and drive is not
1208///   observable to the caller.
1209/// - `indicator_color` — optional override for the active indicator color.
1210/// - `container_color` — optional override for the tab row background color.
1211/// - `active_content_color` — optional override for active tab content color.
1212/// - `inactive_content_color` — optional override for inactive tab content
1213///   color.
1214/// - `indicator_height` — optional override for the indicator height.
1215/// - `indicator_min_width` — optional override for the minimum indicator width.
1216/// - `indicator_max_width` — optional override for the maximum indicator width.
1217/// - `min_tab_height` — optional override for the minimum tab height.
1218/// - `tab_padding` — optional override for the per-tab horizontal padding.
1219/// - `enabled` — whether the tab row is interactive.
1220/// - `disabled_content_color` — optional override for disabled tab content
1221///   color.
1222/// - `divider_color` — optional override for the divider color.
1223/// - `scrollable` — use content widths and horizontal scrolling; narrow fixed
1224///   rows also scroll when equal tabs cannot retain 48dp targets.
1225/// - `edge_padding` — optional override for scrollable edge padding.
1226/// - `min_scrollable_tab_width` — optional override for minimum scrollable tab
1227///   width.
1228/// - `content` — tab declarations rendered inside the tab row.
1229///
1230/// ## Examples
1231///
1232/// ```
1233/// use tessera_components::{
1234///     tabs::{tab, tabs},
1235///     text::text,
1236/// };
1237/// use tessera_ui::{remember, tessera};
1238/// # use tessera_components::theme::{MaterialTheme, material_theme};
1239///
1240/// #[tessera]
1241/// fn demo() {
1242/// #     material_theme()
1243/// #         .theme(|| MaterialTheme::default())
1244/// #         .child(|| {
1245///     let selected = remember(|| 1usize);
1246///     let active_tab = selected.get();
1247///
1248///     tabs().selected(selected).content(move |row| {
1249///         tab(row)
1250///             .label("Flights")
1251///             .selected(selected.get() == 0)
1252///             .on_click(move || selected.set(0));
1253///         tab(row)
1254///             .label("Hotel")
1255///             .selected(selected.get() == 1)
1256///             .on_click(move || selected.set(1));
1257///     });
1258///
1259///     if active_tab == 0 {
1260///         text("Content for Flights");
1261///     } else {
1262///         text("Content for Hotel");
1263///     }
1264/// #         });
1265/// }
1266/// ```
1267#[tessera]
1268pub fn tabs(
1269    modifier: Option<Modifier>,
1270    variant: Option<TabsVariant>,
1271    selected: Option<State<usize>>,
1272    activation_mode: Option<TabActivationMode>,
1273    indicator_color: Option<Color>,
1274    container_color: Option<Color>,
1275    active_content_color: Option<Color>,
1276    inactive_content_color: Option<Color>,
1277    indicator_height: Option<Dp>,
1278    indicator_min_width: Option<Dp>,
1279    indicator_max_width: Option<Dp>,
1280    min_tab_height: Option<Dp>,
1281    tab_padding: Option<Dp>,
1282    enabled: Option<bool>,
1283    disabled_content_color: Option<Color>,
1284    divider_color: Option<Color>,
1285    scrollable: Option<bool>,
1286    edge_padding: Option<Dp>,
1287    min_scrollable_tab_width: Option<Dp>,
1288    content: Option<RenderSlotWith<TabRow>>,
1289) {
1290    let selected = if let Some(selected) = selected {
1291        selected
1292    } else {
1293        remember(|| 0usize)
1294    };
1295    let modifier = modifier.unwrap_or_default();
1296    let variant = variant.unwrap_or_default();
1297    let scrollable = scrollable.unwrap_or(false);
1298    let scheme = use_context::<MaterialTheme>()
1299        .expect("MaterialTheme must be provided")
1300        .get()
1301        .color_scheme;
1302    let config = TabsConfig {
1303        selection: selected,
1304        modifier,
1305        variant,
1306        indicator_color: indicator_color.unwrap_or(scheme.primary),
1307        container_color: container_color.unwrap_or(scheme.surface),
1308        active_content_color: active_content_color.unwrap_or(match variant {
1309            TabsVariant::Primary => scheme.primary,
1310            TabsVariant::Secondary => scheme.on_surface,
1311        }),
1312        inactive_content_color: inactive_content_color.unwrap_or(scheme.on_surface_variant),
1313        inactive_interaction_color: scheme.on_surface,
1314        indicator_height: indicator_height.unwrap_or(match variant {
1315            TabsVariant::Primary => TabsDefaults::INDICATOR_HEIGHT,
1316            TabsVariant::Secondary => TabsDefaults::SECONDARY_INDICATOR_HEIGHT,
1317        }),
1318        indicator_min_width: indicator_min_width.unwrap_or(TabsDefaults::INDICATOR_MIN_WIDTH),
1319        indicator_max_width,
1320        min_tab_height: min_tab_height.unwrap_or(TabsDefaults::MIN_TAB_HEIGHT),
1321        tab_padding: tab_padding.unwrap_or(TabsDefaults::TAB_PADDING),
1322        enabled: enabled.unwrap_or(true),
1323        disabled_content_color: disabled_content_color
1324            .unwrap_or_else(|| TabsDefaults::disabled_content_color(&scheme)),
1325        divider_color: divider_color.unwrap_or(scheme.outline_variant),
1326        scrollable,
1327        edge_padding: edge_padding.unwrap_or(TabsDefaults::SCROLLABLE_EDGE_PADDING),
1328        min_scrollable_tab_width: min_scrollable_tab_width
1329            .unwrap_or(TabsDefaults::SCROLLABLE_MIN_TAB_WIDTH),
1330    };
1331    // The selected index is owned by the caller; the tab row only mirrors it to
1332    // drive the indicator and scroll animation. Callers that do not care about
1333    // reading the selection get an internal slot so the API stays zero-config.
1334    let controller = remember(|| TabsController::new(0));
1335    let density = tessera_ui::dp::scale_factor();
1336    if controller.with(|controller| controller.density != density) {
1337        controller.with_mut(|controller| controller.update_density(density));
1338    }
1339    let activation_mode = activation_mode.unwrap_or_default();
1340    if controller.with(|controller| controller.activation_mode != activation_mode) {
1341        controller.with_mut(|controller| controller.activation_mode = activation_mode);
1342    }
1343    if controller.with(|controller| controller.pending_focus.is_some()) {
1344        let requester = controller.with_mut(|controller| controller.pending_focus.take());
1345        if let Some(requester) = requester {
1346            requester.request_focus();
1347            FocusManager::current().defer_keyboard_until_layout();
1348        }
1349    }
1350    let tab_row_scroll_recognizer =
1351        remember(|| ScrollRecognizer::new(ScrollSettings { consume: true }));
1352    let selected_index = selected.get();
1353    if controller.with(|controller| controller.active_tab()) != selected_index {
1354        controller.with_mut(|controller| controller.sync_selected(selected_index));
1355    }
1356    if controller.with(|c| c.has_pending_animation_frame()) {
1357        receive_frame_nanos(move |frame_nanos| {
1358            let has_pending_animation_frame = controller.with_mut(|controller| {
1359                controller.advance_from_frame_nanos(frame_nanos);
1360                controller.has_pending_animation_frame()
1361            });
1362            if has_pending_animation_frame {
1363                tessera_ui::FrameNanosControl::Continue
1364            } else {
1365                tessera_ui::FrameNanosControl::Stop
1366            }
1367        });
1368    }
1369    let body = tabs_content()
1370        .args(config)
1371        .controller(controller)
1372        .tab_row_scroll_recognizer(tab_row_scroll_recognizer);
1373    if let Some(content) = content {
1374        body.content_shared(content);
1375    }
1376}
1377
1378#[tessera]
1379fn tabs_content(
1380    args: Option<TabsConfig>,
1381    controller: Option<State<TabsController>>,
1382    content: Option<RenderSlotWith<TabRow>>,
1383    tab_row_scroll_recognizer: Option<State<ScrollRecognizer>>,
1384) {
1385    let args = args.expect("tabs_content requires args to be set");
1386    let live = remember(|| args.clone());
1387    if live.with(|value| value != &args) {
1388        live.set(args.clone());
1389    }
1390    let controller = controller.expect("tabs_content requires controller to be set");
1391    let tab_row_scroll_recognizer =
1392        tab_row_scroll_recognizer.expect("tabs_content requires scroll recognizer to be set");
1393    let tab_row_scroll_px = controller.with(|c| c.tab_row_scroll_px());
1394    let indicator_x_px = controller.with(|c| c.indicator_x_px());
1395    let indicator_width_px = controller.with(|c| c.indicator_width_px());
1396
1397    let layout_args = args.clone();
1398    let modifier = with_pointer_input(
1399        args.modifier
1400            .clone()
1401            .push_semantics(TabListSemantics)
1402            .focus_group()
1403            .on_focus_changed(move |state: FocusState| {
1404                if !state.has_focus()
1405                    && controller.with(|controller| controller.focused_requester.is_some())
1406                {
1407                    controller.with_mut(|controller| controller.set_focused_requester(None));
1408                    FocusManager::current().defer_keyboard_until_layout();
1409                }
1410            }),
1411        move |input| {
1412            let is_scrollable = controller.with(|c| c.tab_row_scroll_max() > Px(0));
1413            if is_scrollable {
1414                let cursor_in_tab_bar = if let Some(pos) = input.cursor_position_rel {
1415                    let within_x = pos.x >= 0.0 && pos.x < input.computed_data.width.0 as f32;
1416                    let within_y =
1417                        pos.y >= 0.0 && pos.y < controller.with(|c| c.tab_bar_height()).0 as f32;
1418                    within_x && within_y
1419                } else {
1420                    false
1421                };
1422
1423                if cursor_in_tab_bar {
1424                    let scroll_result = tab_row_scroll_recognizer.with_mut(|recognizer| {
1425                        recognizer.update(input.pass, input.pointer_changes.as_mut_slice())
1426                    });
1427                    let delta = if scroll_result.delta_x.abs() >= 0.01 {
1428                        scroll_result.delta_x
1429                    } else {
1430                        scroll_result.delta_y
1431                    };
1432                    if delta.abs() >= 0.01 {
1433                        controller.with_mut(|c| {
1434                            let current = c.tab_row_scroll_offset.target;
1435                            let max = c.tab_row_scroll_max().to_f32();
1436                            let next = (current - delta).clamp(0.0, max);
1437                            c.set_tab_row_scroll_immediate(Px::saturating_from_f32(next));
1438                        });
1439                    }
1440                }
1441            }
1442        },
1443    );
1444
1445    let policy = TabsLayout {
1446        args: layout_args,
1447        controller,
1448        tab_row_scroll_px,
1449        indicator_x_px,
1450        indicator_width_px,
1451        focused_requester: controller.with(|controller| controller.focused_requester),
1452        active_tab: controller.with(|controller| controller.active_tab),
1453        scroll_user_overridden: controller
1454            .with(|controller| controller.tab_row_scroll_user_overridden),
1455    };
1456    layout()
1457        .modifier(modifier)
1458        .policy(policy.clone())
1459        .draw(policy)
1460        .child(move || {
1461            let args = live.get();
1462            let ripple_color = TabsDefaults::ripple_color(args.active_content_color);
1463            let indicator_shape = match args.variant {
1464                TabsVariant::Primary => Shape::RoundedRectangle {
1465                    top_left: RoundedCorner::manual(Dp(3.0), 3.0),
1466                    top_right: RoundedCorner::manual(Dp(3.0), 3.0),
1467                    bottom_right: RoundedCorner::manual(Dp(0.0), 3.0),
1468                    bottom_left: RoundedCorner::manual(Dp(0.0), 3.0),
1469                },
1470                TabsVariant::Secondary => Shape::RECTANGLE,
1471            };
1472            surface()
1473                .style(args.container_color.into())
1474                .modifier(Modifier::new().fill_max_size())
1475                .shape(Shape::RECTANGLE)
1476                .child(|| {});
1477
1478            surface()
1479                .style(args.divider_color.into())
1480                .modifier(Modifier::new().fill_max_size())
1481                .shape(Shape::RECTANGLE)
1482                .child(|| {});
1483
1484            surface()
1485                .style(args.indicator_color.into())
1486                .modifier(Modifier::new().fill_max_size())
1487                .shape(indicator_shape)
1488                .child(|| {});
1489
1490            let composition = TabRow {
1491                controller,
1492                selected: args.selection,
1493                enabled: args.enabled,
1494                active_content_color: args.active_content_color,
1495                inactive_content_color: args.inactive_content_color,
1496                inactive_interaction_color: args.inactive_interaction_color,
1497                disabled_content_color: args.disabled_content_color,
1498                ripple_color,
1499                min_tab_height: args.min_tab_height,
1500                tab_padding: args.tab_padding,
1501            };
1502            if let Some(content) = content {
1503                content.render(composition);
1504            }
1505        });
1506}
1507
1508#[derive(Clone, PartialEq)]
1509struct TabTriggerArgs {
1510    controller: State<TabsController>,
1511    interaction: State<InteractionState>,
1512    title: TabTitle,
1513    enabled: bool,
1514    selected: bool,
1515    on_click: Option<Callback>,
1516    focus_requester: FocusRequester,
1517    label_color: Color,
1518    ripple_color: Color,
1519    tab_height: Dp,
1520    tab_padding: Dp,
1521    accessibility_label: Option<String>,
1522}
1523
1524#[tessera]
1525fn tab_trigger(args: Option<TabTriggerArgs>) {
1526    let args = args.expect("tab_trigger requires args to be set");
1527    let live = remember(|| args.clone());
1528    if live.with(|value| value != &args) {
1529        live.set(args.clone());
1530    }
1531    let controller = args.controller;
1532    let requester = args.focus_requester;
1533    let target = TabFocusData {
1534        requester,
1535        selected: args.selected,
1536        enabled: args.enabled,
1537        activate: args.on_click.expect("tab activation"),
1538    };
1539    if controller.with(|controller| {
1540        controller
1541            .targets
1542            .iter()
1543            .any(|current| current.requester == requester && current != &target)
1544    }) {
1545        controller.with_mut(|controller| {
1546            let mut targets = controller.targets.clone();
1547            if let Some(current) = targets
1548                .iter_mut()
1549                .find(|current| current.requester == requester)
1550            {
1551                *current = target;
1552            }
1553            controller.sync_targets(targets);
1554        });
1555    }
1556    let tab_stop = controller.with(|controller| {
1557        if let Some(focused) = controller.focused_requester {
1558            focused == requester
1559        } else {
1560            args.selected
1561                || (!controller
1562                    .targets
1563                    .iter()
1564                    .any(|target| target.enabled && target.selected)
1565                    && controller
1566                        .targets
1567                        .iter()
1568                        .find(|target| target.enabled)
1569                        .is_some_and(|target| target.requester == requester))
1570        }
1571    });
1572    let mut tab_modifier = Modifier::new()
1573        .semantics(SemanticsArgs {
1574            selected: Some(args.selected),
1575            ..Default::default()
1576        })
1577        .constrain(None, Some(AxisConstraint::exact(args.tab_height.into())))
1578        .focus_group()
1579        .focus_properties(
1580            FocusProperties::new()
1581                .skip_traversal(!tab_stop)
1582                .can_focus(args.enabled),
1583        )
1584        .push_parent_data(target)
1585        .push_parent_data(TabIndicatorPadding(match &args.title {
1586            TabTitle::Custom(_) => Dp::ZERO,
1587            TabTitle::Label { .. } => args.tab_padding,
1588        }))
1589        .push_keyboard_input(TabNavigation {
1590            controller,
1591            requester,
1592        });
1593
1594    if args.enabled {
1595        tab_modifier = tab_modifier.on_focus_event(move |focus_state: FocusState| {
1596            if focus_state.has_focus()
1597                && controller.with(|controller| controller.focused_requester != Some(requester))
1598            {
1599                controller.with_mut(|controller| controller.set_focused_requester(Some(requester)));
1600                FocusManager::current().defer_keyboard_until_layout();
1601            }
1602        });
1603    }
1604
1605    let accessibility_label = args.accessibility_label.clone();
1606    let label_color = args.label_color;
1607    let ripple_color = args.ripple_color;
1608    let focus_requester = args.focus_requester;
1609    let on_click = args.on_click;
1610
1611    surface()
1612        .style(Color::TRANSPARENT.into())
1613        .content_alignment(Alignment::Center)
1614        .content_color(label_color)
1615        .modifier(tab_modifier)
1616        .ripple_color(ripple_color)
1617        .shape(Shape::RECTANGLE)
1618        .enabled(args.enabled)
1619        .interaction_state(args.interaction)
1620        .focus_requester(focus_requester)
1621        .accessibility_role(Role::Tab)
1622        .accessibility_focusable(args.enabled)
1623        .accessibility_label_optional(accessibility_label)
1624        .on_click_optional(on_click)
1625        .child(move || {
1626            let args = live.get();
1627            tab_title_content()
1628                .title(args.title)
1629                .tab_padding(args.tab_padding);
1630        });
1631}
1632
1633#[tessera]
1634fn tab_title_content(title: Option<TabTitle>, tab_padding: Option<Dp>) {
1635    let title = title.expect("tab_title_content requires title to be set");
1636    let tab_padding = tab_padding.unwrap_or(TabsDefaults::TAB_PADDING);
1637    match title {
1638        TabTitle::Custom(render) => render.render(),
1639        TabTitle::Label {
1640            text,
1641            icon: Some(icon),
1642        } => {
1643            tab_label()
1644                .text(text)
1645                .horizontal_text_padding(tab_padding)
1646                .icon(icon);
1647        }
1648        TabTitle::Label { text, icon: None } => {
1649            tab_label().text(text).horizontal_text_padding(tab_padding);
1650        }
1651    }
1652}
1653
1654#[cfg(test)]
1655mod tests {
1656
1657    use tessera_ui::{
1658        CursorEventContent, Dp, GestureState, Modifier, PointerChange, PressKeyEventType, Px,
1659        PxPosition, remember, tessera,
1660    };
1661
1662    use crate::{
1663        column::column,
1664        lazy_list::lazy_column,
1665        modifier::{ModifierExt as _, SemanticsArgs},
1666        spacer::spacer,
1667        theme::{MaterialTheme, material_theme},
1668    };
1669
1670    use super::{Spring1D, TabsController, tab, tabs};
1671
1672    #[test]
1673    fn density_changes_preserve_in_flight_indicator_and_scroll_motion() {
1674        let mut original = TabsController::new(0);
1675        original.indicator_x = Spring1D {
1676            value: 10.0,
1677            velocity: 20.0,
1678            target: 100.0,
1679        };
1680        original.indicator_width = Spring1D {
1681            value: 24.0,
1682            velocity: 3.0,
1683            target: 60.0,
1684        };
1685        original.tab_row_scroll_offset = Spring1D {
1686            value: 40.0,
1687            velocity: 10.0,
1688            target: 200.0,
1689        };
1690        original.tab_row_scroll_max = Px(1000);
1691        original.tab_bar_height = Px(48);
1692        original.last_frame_nanos = Some(0);
1693        let mut scaled = original.clone();
1694        scaled.update_density(original.density * 1.5);
1695        original.advance_from_frame_nanos(16_666_667);
1696        scaled.advance_from_frame_nanos(16_666_667);
1697        for (before, after) in [
1698            (original.indicator_x, scaled.indicator_x),
1699            (original.indicator_width, scaled.indicator_width),
1700            (original.tab_row_scroll_offset, scaled.tab_row_scroll_offset),
1701        ] {
1702            assert!((after.value - before.value * 1.5).abs() < 0.0001);
1703            assert!((after.velocity - before.velocity * 1.5).abs() < 0.001);
1704            assert_eq!(after.target, before.target * 1.5);
1705        }
1706        assert_eq!(scaled.tab_row_scroll_max, Px(1500));
1707        assert_eq!(scaled.tab_bar_height, Px(72));
1708        scaled.update_density(original.density);
1709        assert!((scaled.indicator_x.value - original.indicator_x.value).abs() < 0.0001);
1710        assert!(scaled.has_pending_animation_frame());
1711    }
1712
1713    #[tessera]
1714    fn idle_tabs_probe() {
1715        material_theme().theme(MaterialTheme::default).child(|| {
1716            tabs_row();
1717        });
1718    }
1719
1720    #[test]
1721    fn tabs_stop_rebuilding_after_the_indicator_settles() {
1722        let mut session = tessera_ui::testing::start_layout_test_session(
1723            tessera_ui::testing::layout_test(|| {
1724                idle_tabs_probe();
1725            })
1726            .viewport_px(1000, 900),
1727        );
1728        for frame in 0..90 {
1729            let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
1730        }
1731        for frame in 90..98 {
1732            let _ = tessera_ui::testing::pump_layout_test_session(&mut session, frame * 16_666_667);
1733            assert_eq!(
1734                session
1735                    .work()
1736                    .count("tabs_row", tessera_ui::WorkPhase::Build),
1737                0
1738            );
1739        }
1740    }
1741
1742    #[tessera]
1743    fn tabs_row() {
1744        let selected = remember(|| 0usize);
1745        column().children(move || {
1746            tabs().selected(selected).content(move |row| {
1747                tab(row)
1748                    .label("Flights")
1749                    .selected(selected.get() == 0)
1750                    .on_click(move || selected.set(0));
1751                tab(row)
1752                    .label("Hotels")
1753                    .selected(selected.get() == 1)
1754                    .on_click(move || selected.set(1));
1755                tab(row)
1756                    .label("Trains")
1757                    .selected(selected.get() == 2)
1758                    .on_click(move || selected.set(2));
1759            });
1760            if selected.get() == 1 {
1761                spacer().modifier(Modifier::new().width(Dp(10.0)).height(Dp(10.0)).semantics(
1762                    SemanticsArgs {
1763                        test_tag: Some("hotels-active".to_string()),
1764                        ..Default::default()
1765                    },
1766                ));
1767            }
1768        });
1769    }
1770
1771    #[tessera]
1772    fn tabs_click_probe() {
1773        material_theme()
1774            .theme(MaterialTheme::default)
1775            .child(move || {
1776                lazy_column().item(move || {
1777                    tabs_row();
1778                });
1779            });
1780    }
1781
1782    #[test]
1783    fn clicking_middle_tab_selects_middle_tab() {
1784        let mut session = tessera_ui::testing::start_layout_test_session(
1785            tessera_ui::testing::layout_test(|| {
1786                tabs_click_probe();
1787            })
1788            .viewport_px(300, 200),
1789        );
1790        let _ = tessera_ui::testing::pump_layout_test_session(&mut session, 0);
1791
1792        // Three equal tabs span the 300px viewport, so the middle tab covers
1793        // x in [100, 200] and its center is at x = 150.
1794        let now = tessera_ui::time::Instant::now();
1795        let cursor = Some(PxPosition::new(Px::new(150), Px::new(24)));
1796
1797        // Move the pointer over the middle tab first. This rebuilds that tab
1798        // in isolation inside `lazy_column`; a per-instance self-assigned index
1799        // must still resolve to the middle tab after the click.
1800        let moved = PointerChange::new(
1801            now,
1802            1,
1803            CursorEventContent::Moved(PxPosition::new(Px::new(150), Px::new(24))),
1804            GestureState::TapCandidate,
1805        );
1806        let _ = tessera_ui::testing::pump_layout_test_session_with_input(
1807            &mut session,
1808            16_666_667,
1809            vec![moved],
1810            cursor,
1811        );
1812
1813        let press = PointerChange::new(
1814            now,
1815            1,
1816            CursorEventContent::Pressed(PressKeyEventType::Left),
1817            GestureState::TapCandidate,
1818        );
1819        let _ = tessera_ui::testing::pump_layout_test_session_with_input(
1820            &mut session,
1821            33_333_334,
1822            vec![press],
1823            cursor,
1824        );
1825        let release = PointerChange::new(
1826            now,
1827            1,
1828            CursorEventContent::Released(PressKeyEventType::Left),
1829            GestureState::TapCandidate,
1830        );
1831        let mut snapshot = tessera_ui::testing::pump_layout_test_session_with_input(
1832            &mut session,
1833            50_000_001,
1834            vec![release],
1835            cursor,
1836        );
1837        for frame in 3..40u64 {
1838            snapshot = tessera_ui::testing::pump_layout_test_session(
1839                &mut session,
1840                50_000_001 + 16_666_667 * (frame - 2),
1841            );
1842        }
1843
1844        // With the regression the shared counter keeps growing, so the click
1845        // selects the last tab and this probe never appears.
1846        snapshot.node("hotels-active").exists();
1847    }
1848}
1849
1850#[cfg(test)]
1851mod custom_title_tests {
1852
1853    use tessera_ui::{Dp, Modifier, Px, State, remember, tessera};
1854
1855    use crate::{modifier::ModifierExt as _, spacer::spacer, theme::material_theme};
1856
1857    use super::{TabsController, tab, tabs};
1858
1859    #[derive(Clone, Copy, Default)]
1860    struct FixtureHandles {
1861        controller: Option<State<TabsController>>,
1862        selected: Option<State<usize>>,
1863        scrollable: Option<State<bool>>,
1864    }
1865
1866    #[tessera]
1867    fn fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
1868        let selected = remember(|| 0usize);
1869        let scrollable = remember(|| false);
1870        observed.with_mut(|handles| handles.selected = Some(selected));
1871        observed.with_mut(|handles| handles.scrollable = Some(scrollable));
1872        material_theme().child(move || {
1873            let observed = observed.clone();
1874            tabs()
1875                .selected(selected)
1876                .scrollable(scrollable.get())
1877                .modifier(Modifier::new().width(Dp(300.0)))
1878                .content(move |row| {
1879                    observed.with_mut(|handles| handles.controller = Some(row.controller));
1880                    tab(row).accessibility_label("Eighty").title(|| {
1881                        spacer().modifier(Modifier::new().width(Dp(80.0)).height(Dp(24.0)));
1882                    });
1883                    tab(row).accessibility_label("One twenty").title(|| {
1884                        spacer().modifier(Modifier::new().width(Dp(120.0)).height(Dp(24.0)));
1885                    });
1886                });
1887        });
1888    }
1889
1890    #[test]
1891    fn primary_custom_title_indicator_uses_unpadded_slot_width_in_both_row_modes() {
1892        let observed = tessera_ui::testing::TestProbe::default();
1893        let fixture_observed = observed.clone();
1894        let mut session = tessera_ui::testing::start_layout_test_session(
1895            tessera_ui::testing::layout_test(move || fixture(fixture_observed.clone()))
1896                .viewport_px(300, 100),
1897        );
1898        let mut frame = 0_u64;
1899        tessera_ui::testing::pump_layout_test_session(&mut session, frame);
1900        let selected = observed.get().selected.unwrap();
1901        let scrollable = observed.get().scrollable.unwrap();
1902        let controller = observed.get().controller.unwrap();
1903        for scrolling in [false, true] {
1904            scrollable.set(scrolling);
1905            for (index, width) in [(0, 80), (1, 120)] {
1906                selected.set(index);
1907                for _ in 0..90 {
1908                    frame += 16_666_667;
1909                    tessera_ui::testing::pump_layout_test_session(&mut session, frame);
1910                }
1911                assert_eq!(controller.with(|c| c.indicator_width.value_px()), Px(width));
1912            }
1913        }
1914    }
1915}
1916
1917#[cfg(all(test, not(target_family = "wasm")))]
1918mod small_custom_title_tests {
1919    use std::time::Duration;
1920
1921    use tessera_ui::{
1922        Color, Dp, HeadlessConfig, HeadlessRenderer, Modifier, Px, State,
1923        accesskit::{Action, ActionRequest, Node, NodeId, Role, TreeId},
1924        remember, tessera,
1925    };
1926
1927    use crate::{
1928        modifier::ModifierExt as _,
1929        spacer::spacer,
1930        theme::{MaterialTheme, material_theme},
1931    };
1932
1933    use super::{TabsController, tab, tabs};
1934
1935    #[derive(Clone, Copy, Default)]
1936    struct FixtureHandles {
1937        controller: Option<State<TabsController>>,
1938        selected: Option<State<usize>>,
1939        scrollable: Option<State<bool>>,
1940    }
1941
1942    #[tessera]
1943    fn fixture(observed: tessera_ui::testing::TestProbe<FixtureHandles>) {
1944        let selected = remember(|| 0usize);
1945        let scrollable = remember(|| false);
1946        observed.with_mut(|handles| handles.selected = Some(selected));
1947        observed.with_mut(|handles| handles.scrollable = Some(scrollable));
1948        material_theme()
1949            .theme(|| {
1950                let mut theme = MaterialTheme::default();
1951                theme.color_scheme.primary = Color::RED;
1952                theme.color_scheme.surface = Color::WHITE;
1953                theme
1954            })
1955            .child(move || {
1956                let observed = observed.clone();
1957                tabs()
1958                    .selected(selected)
1959                    .scrollable(scrollable.get())
1960                    .min_scrollable_tab_width(Dp::ZERO)
1961                    .edge_padding(Dp::ZERO)
1962                    .modifier(Modifier::new().width(Dp(300.0)))
1963                    .content(move |row| {
1964                        observed.with_mut(|handles| handles.controller = Some(row.controller));
1965                        tab(row).accessibility_label("Small title").title(|| {
1966                            spacer().modifier(
1967                                Modifier::new()
1968                                    .width(Dp(32.0))
1969                                    .height(Dp(16.0))
1970                                    .background(Color::BLUE),
1971                            );
1972                        });
1973                        tab(row).accessibility_label("Minimum title").title(|| {
1974                            spacer().modifier(
1975                                Modifier::new()
1976                                    .width(Dp(16.0))
1977                                    .height(Dp(16.0))
1978                                    .background(Color::BLUE),
1979                            );
1980                        });
1981                    });
1982            });
1983    }
1984
1985    fn node(renderer: &HeadlessRenderer, label: &str, role: Role) -> (NodeId, Node) {
1986        renderer
1987            .accessibility_snapshot()
1988            .unwrap()
1989            .nodes
1990            .into_iter()
1991            .find(|(_, node)| node.role() == role && node.label() == Some(label))
1992            .unwrap_or_else(|| panic!("missing {role:?} {label}"))
1993    }
1994
1995    #[test]
1996    #[ignore = "requires a native GPU adapter"]
1997    fn gpu_small_custom_title_indicators_exclude_minimum_interactive_padding() {
1998        crate::test_utils::run_isolated_test(
1999            "tabs::small_custom_title_tests::gpu_small_custom_title_indicators_exclude_minimum_interactive_padding",
2000            || {
2001                let observed = tessera_ui::testing::TestProbe::default();
2002                let fixture_observed = observed.clone();
2003                let mut renderer = HeadlessRenderer::new(
2004                    Box::new(move || {
2005                        fixture(fixture_observed.clone());
2006                    }),
2007                    vec![Box::new(crate::TesseraComponents)],
2008                    HeadlessConfig {
2009                        width: 300,
2010                        height: 100,
2011                        sample_count: 1,
2012                        frame_time: Duration::from_millis(20),
2013                        ..Default::default()
2014                    },
2015                )
2016                .unwrap();
2017                renderer.render_frames(12);
2018                let selected = observed.get().selected.unwrap();
2019                let scrollable = observed.get().scrollable.unwrap();
2020                let controller = observed.get().controller.unwrap();
2021                for scrolling in [false, true] {
2022                    scrollable.set(scrolling);
2023                    renderer.render_frames(90);
2024                    for (index, label, visual_width, indicator_width) in
2025                        [(0, "Small title", 32, 32), (1, "Minimum title", 16, 24)]
2026                    {
2027                        let target_node = node(&renderer, label, Role::Tab).0;
2028                        assert!(renderer.dispatch_accessibility_action(ActionRequest {
2029                            action: Action::Click,
2030                            target_tree: TreeId::ROOT,
2031                            target_node,
2032                            data: None,
2033                        }));
2034                        renderer.render_frames(90);
2035                        assert_eq!(selected.get(), index);
2036                        assert_eq!(
2037                            renderer.accessibility_snapshot().unwrap().focus,
2038                            target_node
2039                        );
2040                        tessera_ui::FocusManager::current().clear_focus(true);
2041                        renderer.render_frames(12);
2042                        assert_ne!(
2043                            renderer.accessibility_snapshot().unwrap().focus,
2044                            target_node
2045                        );
2046                        assert_eq!(selected.get(), index);
2047                        let target = node(&renderer, label, Role::Tab).1.bounds().unwrap();
2048                        assert_eq!(target.x1 - target.x0, if scrolling { 48.0 } else { 150.0 });
2049                        assert_eq!(target.y1 - target.y0, 48.0);
2050                        let center = (target.x0 + target.x1) * 0.5;
2051                        assert_eq!(
2052                            controller.with(|c| c.indicator_width_px()),
2053                            Px(indicator_width)
2054                        );
2055                        let pixels = renderer.read_pixels().unwrap();
2056                        let width = renderer.physical_size().width;
2057                        let mut visual = Vec::new();
2058                        for y in target.y0 as u32..target.y1 as u32 {
2059                            for x in target.x0 as u32..target.x1 as u32 {
2060                                let offset = ((y * width + x) * 4) as usize;
2061                                if pixels[offset] < 30
2062                                    && pixels[offset + 1] < 30
2063                                    && pixels[offset + 2] > 240
2064                                {
2065                                    visual.push((x, y));
2066                                }
2067                            }
2068                        }
2069                        assert!(
2070                            !visual.is_empty(),
2071                            "no blue title paint within {target:?} after clearing focus"
2072                        );
2073                        let left = visual.iter().map(|(x, _)| *x).min().unwrap();
2074                        let right = visual.iter().map(|(x, _)| *x).max().unwrap() + 1;
2075                        let top = visual.iter().map(|(_, y)| *y).min().unwrap();
2076                        let bottom = visual.iter().map(|(_, y)| *y).max().unwrap() + 1;
2077                        assert_eq!(
2078                            (right - left, bottom - top),
2079                            (visual_width as u32, 16),
2080                            "title paint bbox={:?}",
2081                            (left, top, right, bottom)
2082                        );
2083                        assert_eq!(visual.len(), visual_width * 16);
2084                        assert_eq!((f64::from(left) + f64::from(right)) * 0.5, center);
2085                        assert_eq!(
2086                            (f64::from(top) + f64::from(bottom)) * 0.5,
2087                            (target.y0 + target.y1) * 0.5,
2088                            "the full natural title paint must remain centered within the interaction target",
2089                        );
2090                        let y = target.y1 as u32 - 1;
2091                        let painted = (0..width)
2092                            .filter(|x| {
2093                                let offset = ((y * width + x) * 4) as usize;
2094                                pixels[offset] > 240
2095                                    && pixels[offset + 1] < 30
2096                                    && pixels[offset + 2] < 30
2097                            })
2098                            .collect::<Vec<_>>();
2099                        assert_eq!(painted.len(), indicator_width as usize);
2100                        assert_eq!(
2101                            (f64::from(*painted.first().unwrap())
2102                                + f64::from(*painted.last().unwrap())
2103                                + 1.0)
2104                                * 0.5,
2105                            center,
2106                            "the visual indicator must remain centered within the full interaction target",
2107                        );
2108                    }
2109                }
2110            },
2111        );
2112    }
2113}