1use 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#[derive(Debug, Clone)]
24pub struct State {
25 selected_index: usize,
26 indicator_position: AnimatedScalar,
27}
28
29impl State {
30 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 pub const fn selected_index(&self) -> usize {
40 self.selected_index
41 }
42
43 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 pub fn advance(&mut self, now: Instant) -> bool {
60 self.indicator_position.advance(now)
61 }
62
63 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#[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
124pub 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
138pub 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
160pub 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
188pub 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
213pub 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
225pub 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
256pub 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
287pub 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
304pub 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
318pub 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
330pub 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
342pub 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
356pub 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
368pub 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
383pub 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
397pub 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
601pub 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;