Skip to main content

material_ui_rs/widget/component/
tabs.rs

1//! Material 3 primary and secondary tab constructors.
2
3use iced_widget::button::{Status, Style};
4use iced_widget::core::border::Radius;
5use iced_widget::core::text as core_text;
6use iced_widget::core::time::Instant;
7use iced_widget::core::{
8    Background, Border, Color, Element, Layout, Length, Padding, Rectangle, Size, Widget,
9    alignment, border, layout, mouse, renderer,
10};
11use iced_widget::graphics::geometry;
12use iced_widget::renderer::wgpu::primitive;
13use iced_widget::text;
14use iced_widget::{Column, Container, Row, Space, Text};
15
16use super::absolute_line_height;
17use super::button::Button;
18use super::support::{AnimatedScalar, duration_ms};
19use crate::utils::{mix, shadow_from_level};
20use crate::{Theme, fonts, tokens};
21
22/// Animated tab selection state.
23#[derive(Debug, Clone)]
24pub struct State {
25    selected_index: usize,
26    indicator_position: AnimatedScalar,
27}
28
29impl State {
30    /// Creates tab selection state with the initial selected index.
31    pub fn new(selected_index: usize) -> Self {
32        Self {
33            selected_index,
34            indicator_position: AnimatedScalar::new(selected_index as f32),
35        }
36    }
37
38    /// Returns the selected tab index.
39    pub const fn selected_index(&self) -> usize {
40        self.selected_index
41    }
42
43    /// Starts the Material tab indicator transition to `selected_index`.
44    pub fn select(&mut self, selected_index: usize, now: Instant, variant: Variant) {
45        if self.selected_index == selected_index {
46            return;
47        }
48
49        self.selected_index = selected_index;
50        self.indicator_position.set_target(
51            selected_index as f32,
52            now,
53            duration_ms(variant.indicator_animation_duration_ms()),
54            variant.indicator_animation_easing(),
55        );
56    }
57
58    /// Advances the running transition.
59    pub fn advance(&mut self, now: Instant) -> bool {
60        self.indicator_position.advance(now)
61    }
62
63    /// Returns whether the indicator transition is still running.
64    pub fn is_animating(&self) -> bool {
65        self.indicator_position.is_animating()
66    }
67
68    fn indicator_position(&self) -> f32 {
69        self.indicator_position.value
70    }
71}
72
73/// The Material tab variant.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum Variant {
76    Primary,
77    Secondary,
78}
79
80impl Variant {
81    const fn container_height(self) -> f32 {
82        match self {
83            Self::Primary => tokens::component::primary_tab::CONTAINER_HEIGHT,
84            Self::Secondary => tokens::component::secondary_tab::CONTAINER_HEIGHT,
85        }
86    }
87
88    const fn indicator_height(self) -> f32 {
89        match self {
90            Self::Primary => tokens::component::primary_tab::ACTIVE_INDICATOR_HEIGHT,
91            Self::Secondary => tokens::component::secondary_tab::ACTIVE_INDICATOR_HEIGHT,
92        }
93    }
94
95    const fn label_text(self) -> tokens::typography::TypeScale {
96        match self {
97            Self::Primary => tokens::component::primary_tab::LABEL_TEXT,
98            Self::Secondary => tokens::component::secondary_tab::LABEL_TEXT,
99        }
100    }
101
102    const fn icon_size(self) -> f32 {
103        match self {
104            Self::Primary => tokens::component::primary_tab::ICON_SIZE,
105            Self::Secondary => tokens::component::secondary_tab::ICON_SIZE,
106        }
107    }
108
109    const fn indicator_animation_duration_ms(self) -> u16 {
110        match self {
111            Self::Primary => tokens::component::primary_tab::INDICATOR_ANIMATION_DURATION_MS,
112            Self::Secondary => tokens::component::secondary_tab::INDICATOR_ANIMATION_DURATION_MS,
113        }
114    }
115
116    const fn indicator_animation_easing(self) -> tokens::motion::CubicBezier {
117        match self {
118            Self::Primary => tokens::component::primary_tab::INDICATOR_ANIMATION_EASING,
119            Self::Secondary => tokens::component::secondary_tab::INDICATOR_ANIMATION_EASING,
120        }
121    }
122}
123
124/// Creates an equal-width Material tab bar.
125pub fn bar<'a, Message, Renderer>(
126    tabs: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
127) -> Row<'a, Message, Theme, Renderer>
128where
129    Message: 'a,
130    Renderer: iced_widget::core::Renderer + 'a,
131{
132    Row::with_children(tabs.into_iter())
133        .spacing(0)
134        .align_y(alignment::Vertical::Bottom)
135        .width(Length::Fill)
136}
137
138/// Creates an equal-width Material tab bar with an animated shared indicator.
139pub fn animated_bar<'a, Message, Renderer>(
140    variant: Variant,
141    tab_count: usize,
142    state: &State,
143    tabs: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
144) -> Column<'a, Message, Theme, Renderer>
145where
146    Message: 'a,
147    Renderer: iced_widget::core::Renderer + 'a,
148{
149    Column::new()
150        .push(bar(tabs))
151        .push(MovingIndicator {
152            variant,
153            tab_count,
154            position: state.indicator_position(),
155        })
156        .spacing(0)
157        .width(Length::Fill)
158}
159
160/// Creates a primary animated tab bar from icon-label action items.
161pub fn animated_primary_icon_label_bar<'a, Message, Renderer, Icon, Label>(
162    state: &State,
163    tabs: impl IntoIterator<Item = (Icon, Label, Message)>,
164) -> Column<'a, Message, Theme, Renderer>
165where
166    Icon: text::IntoFragment<'a>,
167    Label: text::IntoFragment<'a>,
168    Message: Clone + 'a,
169    Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
170    iced_widget::core::Font: Into<Renderer::Font>,
171{
172    let tabs: Vec<_> = tabs
173        .into_iter()
174        .enumerate()
175        .map(|(index, (icon, label, on_press))| {
176            primary_icon_label_action_for_animated_bar(
177                icon,
178                label,
179                state.selected_index() == index,
180                on_press,
181            )
182        })
183        .collect();
184
185    animated_bar(Variant::Primary, tabs.len(), state, tabs)
186}
187
188/// Creates a secondary animated tab bar from label action items.
189pub fn animated_secondary_label_bar<'a, Message, Renderer, Label>(
190    state: &State,
191    tabs: impl IntoIterator<Item = (Label, Message)>,
192) -> Column<'a, Message, Theme, Renderer>
193where
194    Label: text::IntoFragment<'a>,
195    Message: Clone + 'a,
196    Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
197{
198    let tabs: Vec<_> = tabs
199        .into_iter()
200        .enumerate()
201        .map(|(index, (label, on_press))| {
202            secondary_label_action_for_animated_bar(
203                label,
204                state.selected_index() == index,
205                on_press,
206            )
207        })
208        .collect();
209
210    animated_bar(Variant::Secondary, tabs.len(), state, tabs)
211}
212
213/// Creates a primary label tab.
214pub fn primary_label<'a, Message, Renderer>(
215    label: impl text::IntoFragment<'a>,
216    active: bool,
217) -> Button<'a, Message, Renderer>
218where
219    Message: Clone + 'a,
220    Renderer: geometry::Renderer + core_text::Renderer + 'a,
221{
222    label_tab(Variant::Primary, label, active)
223}
224
225/// Creates a stacked primary tab with icon and label.
226pub fn primary_icon_label<'a, Message, Renderer>(
227    icon_name: impl text::IntoFragment<'a>,
228    label: impl text::IntoFragment<'a>,
229    active: bool,
230) -> Button<'a, Message, Renderer>
231where
232    Message: Clone + 'a,
233    Renderer: geometry::Renderer + core_text::Renderer + 'a,
234    iced_widget::core::Font: Into<Renderer::Font>,
235{
236    let variant = Variant::Primary;
237    let label_text = variant.label_text();
238    let content = Column::<Message, Theme, Renderer>::new()
239        .push(fonts::filled_icon(icon_name, variant.icon_size()))
240        .push(
241            Text::new(label)
242                .size(label_text.size)
243                .line_height(absolute_line_height(label_text.line_height)),
244        )
245        .spacing(tokens::component::primary_tab::STACKED_ICON_LABEL_SPACE)
246        .align_x(alignment::Horizontal::Center);
247
248    tab_button(
249        variant,
250        content.into(),
251        active,
252        tokens::component::primary_tab::WITH_ICON_AND_LABEL_TEXT_CONTAINER_HEIGHT,
253    )
254}
255
256/// Creates a stacked primary tab for an [`animated_bar`].
257pub fn primary_icon_label_for_animated_bar<'a, Message, Renderer>(
258    icon_name: impl text::IntoFragment<'a>,
259    label: impl text::IntoFragment<'a>,
260    active: bool,
261) -> Button<'a, Message, Renderer>
262where
263    Message: Clone + 'a,
264    Renderer: geometry::Renderer + core_text::Renderer + 'a,
265    iced_widget::core::Font: Into<Renderer::Font>,
266{
267    let variant = Variant::Primary;
268    let label_text = variant.label_text();
269    let content = Column::<Message, Theme, Renderer>::new()
270        .push(fonts::filled_icon(icon_name, variant.icon_size()))
271        .push(
272            Text::new(label)
273                .size(label_text.size)
274                .line_height(absolute_line_height(label_text.line_height)),
275        )
276        .spacing(tokens::component::primary_tab::STACKED_ICON_LABEL_SPACE)
277        .align_x(alignment::Horizontal::Center);
278
279    animated_tab_button(
280        variant,
281        content.into(),
282        active,
283        tokens::component::primary_tab::WITH_ICON_AND_LABEL_TEXT_CONTAINER_HEIGHT,
284    )
285}
286
287/// Creates a stacked primary tab with an action message for an [`animated_bar`].
288pub fn primary_icon_label_action_for_animated_bar<'a, Message, Renderer>(
289    icon_name: impl text::IntoFragment<'a>,
290    label: impl text::IntoFragment<'a>,
291    active: bool,
292    on_press: Message,
293) -> Element<'a, Message, Theme, Renderer>
294where
295    Message: Clone + 'a,
296    Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
297    iced_widget::core::Font: Into<Renderer::Font>,
298{
299    primary_icon_label_for_animated_bar(icon_name, label, active)
300        .on_press(on_press)
301        .into()
302}
303
304/// Creates an inline primary tab with icon and label.
305pub fn primary_inline_icon_label<'a, Message, Renderer>(
306    icon_name: impl text::IntoFragment<'a>,
307    label: impl text::IntoFragment<'a>,
308    active: bool,
309) -> Button<'a, Message, Renderer>
310where
311    Message: Clone + 'a,
312    Renderer: geometry::Renderer + core_text::Renderer + 'a,
313    iced_widget::core::Font: Into<Renderer::Font>,
314{
315    inline_icon_label_tab(Variant::Primary, icon_name, label, active)
316}
317
318/// Creates a secondary label tab.
319pub fn secondary_label<'a, Message, Renderer>(
320    label: impl text::IntoFragment<'a>,
321    active: bool,
322) -> Button<'a, Message, Renderer>
323where
324    Message: Clone + 'a,
325    Renderer: geometry::Renderer + core_text::Renderer + 'a,
326{
327    label_tab(Variant::Secondary, label, active)
328}
329
330/// Creates a primary label tab for an [`animated_bar`].
331pub fn primary_label_for_animated_bar<'a, Message, Renderer>(
332    label: impl text::IntoFragment<'a>,
333    active: bool,
334) -> Button<'a, Message, Renderer>
335where
336    Message: Clone + 'a,
337    Renderer: geometry::Renderer + core_text::Renderer + 'a,
338{
339    animated_label_tab(Variant::Primary, label, active)
340}
341
342/// Creates an inline primary tab with icon and label for an [`animated_bar`].
343pub fn primary_inline_icon_label_for_animated_bar<'a, Message, Renderer>(
344    icon_name: impl text::IntoFragment<'a>,
345    label: impl text::IntoFragment<'a>,
346    active: bool,
347) -> Button<'a, Message, Renderer>
348where
349    Message: Clone + 'a,
350    Renderer: geometry::Renderer + core_text::Renderer + 'a,
351    iced_widget::core::Font: Into<Renderer::Font>,
352{
353    animated_inline_icon_label_tab(Variant::Primary, icon_name, label, active)
354}
355
356/// Creates a secondary label tab for an [`animated_bar`].
357pub fn secondary_label_for_animated_bar<'a, Message, Renderer>(
358    label: impl text::IntoFragment<'a>,
359    active: bool,
360) -> Button<'a, Message, Renderer>
361where
362    Message: Clone + 'a,
363    Renderer: geometry::Renderer + core_text::Renderer + 'a,
364{
365    animated_label_tab(Variant::Secondary, label, active)
366}
367
368/// Creates a secondary label tab with an action message for an [`animated_bar`].
369pub fn secondary_label_action_for_animated_bar<'a, Message, Renderer>(
370    label: impl text::IntoFragment<'a>,
371    active: bool,
372    on_press: Message,
373) -> Element<'a, Message, Theme, Renderer>
374where
375    Message: Clone + 'a,
376    Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
377{
378    secondary_label_for_animated_bar(label, active)
379        .on_press(on_press)
380        .into()
381}
382
383/// Creates an inline secondary tab with icon and label.
384pub fn secondary_icon_label<'a, Message, Renderer>(
385    icon_name: impl text::IntoFragment<'a>,
386    label: impl text::IntoFragment<'a>,
387    active: bool,
388) -> Button<'a, Message, Renderer>
389where
390    Message: Clone + 'a,
391    Renderer: geometry::Renderer + core_text::Renderer + 'a,
392    iced_widget::core::Font: Into<Renderer::Font>,
393{
394    inline_icon_label_tab(Variant::Secondary, icon_name, label, active)
395}
396
397/// Creates an inline secondary tab with icon and label for an [`animated_bar`].
398pub fn secondary_icon_label_for_animated_bar<'a, Message, Renderer>(
399    icon_name: impl text::IntoFragment<'a>,
400    label: impl text::IntoFragment<'a>,
401    active: bool,
402) -> Button<'a, Message, Renderer>
403where
404    Message: Clone + 'a,
405    Renderer: geometry::Renderer + core_text::Renderer + 'a,
406    iced_widget::core::Font: Into<Renderer::Font>,
407{
408    animated_inline_icon_label_tab(Variant::Secondary, icon_name, label, active)
409}
410
411fn animated_label_tab<'a, Message, Renderer>(
412    variant: Variant,
413    label: impl text::IntoFragment<'a>,
414    active: bool,
415) -> Button<'a, Message, Renderer>
416where
417    Message: Clone + 'a,
418    Renderer: geometry::Renderer + core_text::Renderer + 'a,
419{
420    let label_text = variant.label_text();
421    animated_tab_button(
422        variant,
423        Text::new(label)
424            .size(label_text.size)
425            .line_height(absolute_line_height(label_text.line_height))
426            .into(),
427        active,
428        variant.container_height(),
429    )
430}
431
432fn animated_inline_icon_label_tab<'a, Message, Renderer>(
433    variant: Variant,
434    icon_name: impl text::IntoFragment<'a>,
435    label: impl text::IntoFragment<'a>,
436    active: bool,
437) -> Button<'a, Message, Renderer>
438where
439    Message: Clone + 'a,
440    Renderer: geometry::Renderer + core_text::Renderer + 'a,
441    iced_widget::core::Font: Into<Renderer::Font>,
442{
443    let label_text = variant.label_text();
444    let gap = match variant {
445        Variant::Primary => tokens::component::primary_tab::INLINE_ICON_LABEL_SPACE,
446        Variant::Secondary => tokens::component::secondary_tab::ICON_LABEL_SPACE,
447    };
448    let content = Row::<Message, Theme, Renderer>::new()
449        .push(fonts::filled_icon(icon_name, variant.icon_size()))
450        .push(
451            Text::new(label)
452                .size(label_text.size)
453                .line_height(absolute_line_height(label_text.line_height)),
454        )
455        .spacing(gap)
456        .align_y(alignment::Vertical::Center);
457
458    animated_tab_button(variant, content.into(), active, variant.container_height())
459}
460
461fn label_tab<'a, Message, Renderer>(
462    variant: Variant,
463    label: impl text::IntoFragment<'a>,
464    active: bool,
465) -> Button<'a, Message, Renderer>
466where
467    Message: Clone + 'a,
468    Renderer: geometry::Renderer + core_text::Renderer + 'a,
469{
470    let label_text = variant.label_text();
471    tab_button(
472        variant,
473        Text::new(label)
474            .size(label_text.size)
475            .line_height(absolute_line_height(label_text.line_height))
476            .into(),
477        active,
478        variant.container_height(),
479    )
480}
481
482fn inline_icon_label_tab<'a, Message, Renderer>(
483    variant: Variant,
484    icon_name: impl text::IntoFragment<'a>,
485    label: impl text::IntoFragment<'a>,
486    active: bool,
487) -> Button<'a, Message, Renderer>
488where
489    Message: Clone + 'a,
490    Renderer: geometry::Renderer + core_text::Renderer + 'a,
491    iced_widget::core::Font: Into<Renderer::Font>,
492{
493    let label_text = variant.label_text();
494    let gap = match variant {
495        Variant::Primary => tokens::component::primary_tab::INLINE_ICON_LABEL_SPACE,
496        Variant::Secondary => tokens::component::secondary_tab::ICON_LABEL_SPACE,
497    };
498    let content = Row::<Message, Theme, Renderer>::new()
499        .push(fonts::filled_icon(icon_name, variant.icon_size()))
500        .push(
501            Text::new(label)
502                .size(label_text.size)
503                .line_height(absolute_line_height(label_text.line_height)),
504        )
505        .spacing(gap)
506        .align_y(alignment::Vertical::Center);
507
508    tab_button(variant, content.into(), active, variant.container_height())
509}
510
511fn tab_button<'a, Message, Renderer>(
512    variant: Variant,
513    content: Element<'a, Message, Theme, Renderer>,
514    active: bool,
515    height: f32,
516) -> Button<'a, Message, Renderer>
517where
518    Message: Clone + 'a,
519    Renderer: geometry::Renderer + core_text::Renderer + 'a,
520{
521    tab_button_with_indicator(variant, content, active, height, true)
522}
523
524fn animated_tab_button<'a, Message, Renderer>(
525    variant: Variant,
526    content: Element<'a, Message, Theme, Renderer>,
527    active: bool,
528    height: f32,
529) -> Button<'a, Message, Renderer>
530where
531    Message: Clone + 'a,
532    Renderer: geometry::Renderer + core_text::Renderer + 'a,
533{
534    tab_button_with_indicator(
535        variant,
536        content,
537        active,
538        height - variant.indicator_height(),
539        false,
540    )
541}
542
543fn tab_button_with_indicator<'a, Message, Renderer>(
544    variant: Variant,
545    content: Element<'a, Message, Theme, Renderer>,
546    active: bool,
547    height: f32,
548    show_indicator: bool,
549) -> Button<'a, Message, Renderer>
550where
551    Message: Clone + 'a,
552    Renderer: geometry::Renderer + core_text::Renderer + 'a,
553{
554    let tab_content = Column::new().push(
555        Container::new(content)
556            .center_x(Length::Fill)
557            .center_y(Length::Fill)
558            .padding(Padding {
559                top: 0.0,
560                right: horizontal_space(variant),
561                bottom: 0.0,
562                left: horizontal_space(variant),
563            }),
564    );
565    let tab_content = if show_indicator {
566        tab_content.push(indicator(variant, active))
567    } else {
568        tab_content
569    }
570    .width(Length::Fill)
571    .height(Length::Fixed(height));
572
573    Button::new(tab_content)
574        .width(Length::Fill)
575        .height(Length::Fixed(height))
576        .padding(Padding::ZERO)
577        .style(move |theme, status| tab_style(theme, status, variant, active))
578}
579
580fn indicator<'a, Message, Renderer>(
581    variant: Variant,
582    active: bool,
583) -> Container<'a, Message, Theme, Renderer>
584where
585    Message: 'a,
586    Renderer: iced_widget::core::Renderer + 'a,
587{
588    Container::new(Space::new())
589        .width(Length::Fill)
590        .height(Length::Fixed(variant.indicator_height()))
591        .style(move |theme| indicator_style(theme, variant, active))
592}
593
594const fn horizontal_space(variant: Variant) -> f32 {
595    match variant {
596        Variant::Primary => tokens::component::primary_tab::HORIZONTAL_SPACE,
597        Variant::Secondary => tokens::component::secondary_tab::HORIZONTAL_SPACE,
598    }
599}
600
601/// Returns the container style for a Material tab.
602pub fn tab_style(theme: &Theme, status: Status, variant: Variant, active: bool) -> Style {
603    let colors = theme.colors();
604    let surface = colors.surface;
605    let content = tab_content_color(theme, variant, active, status);
606    let layer = tab_state_layer_color(theme, variant, active, status);
607    let container = surface.color;
608
609    let active_style = Style {
610        background: Some(Background::Color(container)),
611        text_color: content,
612        border: border::rounded(tab_container_shape(variant)),
613        shadow: shadow_from_level(tab_container_elevation(variant), colors.shadow),
614        snap: cfg!(feature = "crisp"),
615    };
616
617    match status {
618        Status::Active => active_style,
619        Status::Hovered => Style {
620            background: Some(Background::Color(mix(
621                container,
622                layer,
623                tab_hover_opacity(variant, active),
624            ))),
625            ..active_style
626        },
627        Status::Pressed => Style {
628            background: Some(Background::Color(mix(
629                container,
630                layer,
631                tab_pressed_opacity(variant, active),
632            ))),
633            ..active_style
634        },
635        Status::Disabled => Style {
636            background: Some(Background::Color(container)),
637            text_color: Color {
638                a: tokens::state::DISABLED_LABEL_TEXT_OPACITY,
639                ..surface.text
640            },
641            ..active_style
642        },
643    }
644}
645
646fn tab_content_color(theme: &Theme, variant: Variant, active: bool, status: Status) -> Color {
647    let colors = theme.colors();
648
649    match (variant, active, status) {
650        (Variant::Primary, true, _) => colors.primary.color,
651        (Variant::Primary, false, Status::Active) => colors.surface.text_variant,
652        (Variant::Primary, false, _) => colors.surface.text,
653        (Variant::Secondary, true, _) => colors.surface.text,
654        (Variant::Secondary, false, Status::Active) => colors.surface.text_variant,
655        (Variant::Secondary, false, _) => colors.surface.text,
656    }
657}
658
659fn tab_state_layer_color(theme: &Theme, variant: Variant, active: bool, status: Status) -> Color {
660    let colors = theme.colors();
661
662    match (variant, active, status) {
663        (Variant::Primary, true, _) => colors.primary.color,
664        (Variant::Primary, false, Status::Pressed) => colors.primary.color,
665        (Variant::Primary, false, _) => colors.surface.text,
666        (Variant::Secondary, _, _) => colors.surface.text,
667    }
668}
669
670const fn tab_hover_opacity(variant: Variant, active: bool) -> f32 {
671    match (variant, active) {
672        (Variant::Primary, true) => {
673            tokens::component::primary_tab::ACTIVE_HOVER_STATE_LAYER_OPACITY
674        }
675        (Variant::Primary, false) => {
676            tokens::component::primary_tab::INACTIVE_HOVER_STATE_LAYER_OPACITY
677        }
678        (Variant::Secondary, _) => tokens::component::secondary_tab::HOVER_STATE_LAYER_OPACITY,
679    }
680}
681
682const fn tab_pressed_opacity(variant: Variant, active: bool) -> f32 {
683    match (variant, active) {
684        (Variant::Primary, true) => {
685            tokens::component::primary_tab::ACTIVE_PRESSED_STATE_LAYER_OPACITY
686        }
687        (Variant::Primary, false) => {
688            tokens::component::primary_tab::INACTIVE_PRESSED_STATE_LAYER_OPACITY
689        }
690        (Variant::Secondary, _) => tokens::component::secondary_tab::PRESSED_STATE_LAYER_OPACITY,
691    }
692}
693
694const fn tab_container_shape(variant: Variant) -> f32 {
695    match variant {
696        Variant::Primary => tokens::component::primary_tab::CONTAINER_SHAPE,
697        Variant::Secondary => tokens::component::secondary_tab::CONTAINER_SHAPE,
698    }
699}
700
701const fn tab_container_elevation(variant: Variant) -> u8 {
702    match variant {
703        Variant::Primary => tokens::component::primary_tab::CONTAINER_ELEVATION_LEVEL,
704        Variant::Secondary => tokens::component::secondary_tab::CONTAINER_ELEVATION_LEVEL,
705    }
706}
707
708fn indicator_style(theme: &Theme, variant: Variant, active: bool) -> iced_widget::container::Style {
709    let colors = theme.colors();
710    let background = if active {
711        colors.primary.color
712    } else {
713        Color::TRANSPARENT
714    };
715
716    iced_widget::container::Style {
717        background: Some(Background::Color(background)),
718        border: Border {
719            color: Color::TRANSPARENT,
720            width: 0.0,
721            radius: indicator_radius(variant),
722        },
723        snap: cfg!(feature = "crisp"),
724        ..Default::default()
725    }
726}
727
728fn indicator_radius(variant: Variant) -> Radius {
729    match variant {
730        Variant::Primary => Radius {
731            top_left: tokens::component::primary_tab::ACTIVE_INDICATOR_SHAPE_TOP,
732            top_right: tokens::component::primary_tab::ACTIVE_INDICATOR_SHAPE_TOP,
733            bottom_right: tokens::component::primary_tab::ACTIVE_INDICATOR_SHAPE_BOTTOM,
734            bottom_left: tokens::component::primary_tab::ACTIVE_INDICATOR_SHAPE_BOTTOM,
735        },
736        Variant::Secondary => Radius::new(tokens::component::secondary_tab::ACTIVE_INDICATOR_SHAPE),
737    }
738}
739
740#[derive(Debug, Clone, Copy)]
741struct MovingIndicator {
742    variant: Variant,
743    tab_count: usize,
744    position: f32,
745}
746
747impl<Message, Renderer> Widget<Message, Theme, Renderer> for MovingIndicator
748where
749    Renderer: iced_widget::core::Renderer,
750{
751    fn size(&self) -> Size<Length> {
752        Size {
753            width: Length::Fill,
754            height: Length::Fixed(self.variant.indicator_height()),
755        }
756    }
757
758    fn layout(
759        &mut self,
760        _tree: &mut iced_widget::core::widget::Tree,
761        _renderer: &Renderer,
762        limits: &layout::Limits,
763    ) -> layout::Node {
764        layout::Node::new(limits.resolve(
765            Length::Fill,
766            Length::Fixed(self.variant.indicator_height()),
767            Size::ZERO,
768        ))
769    }
770
771    fn draw(
772        &self,
773        _tree: &iced_widget::core::widget::Tree,
774        renderer: &mut Renderer,
775        theme: &Theme,
776        _defaults: &renderer::Style,
777        layout: Layout<'_>,
778        _cursor: mouse::Cursor,
779        _viewport: &Rectangle,
780    ) {
781        if self.tab_count == 0 {
782            return;
783        }
784
785        let bounds = layout.bounds();
786        let tab_width = bounds.width / self.tab_count as f32;
787
788        if tab_width <= 0.0 {
789            return;
790        }
791
792        let position = self
793            .position
794            .clamp(0.0, self.tab_count.saturating_sub(1) as f32);
795        let indicator_width = moving_indicator_width(self.variant, tab_width);
796        let x = bounds.x + tab_width * position + (tab_width - indicator_width) / 2.0;
797        let indicator_bounds = Rectangle {
798            x,
799            y: bounds.y,
800            width: indicator_width,
801            height: self.variant.indicator_height(),
802        };
803
804        renderer.fill_quad(
805            renderer::Quad {
806                bounds: indicator_bounds,
807                border: Border {
808                    color: Color::TRANSPARENT,
809                    width: 0.0,
810                    radius: indicator_radius(self.variant),
811                },
812                snap: cfg!(feature = "crisp"),
813                ..renderer::Quad::default()
814            },
815            Background::Color(theme.colors().primary.color),
816        );
817    }
818}
819
820impl<'a, Message, Renderer> From<MovingIndicator> for Element<'a, Message, Theme, Renderer>
821where
822    Message: 'a,
823    Renderer: iced_widget::core::Renderer + 'a,
824{
825    fn from(indicator: MovingIndicator) -> Self {
826        Element::new(indicator)
827    }
828}
829
830fn moving_indicator_width(variant: Variant, tab_width: f32) -> f32 {
831    match variant {
832        Variant::Primary => (tab_width - horizontal_space(variant) * 2.0).max(0.0),
833        Variant::Secondary => tab_width,
834    }
835}
836
837#[cfg(test)]
838#[path = "../../../tests/widget/component/tabs.rs"]
839mod tests;