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
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum IndicatorMode {
127 Fixed,
129 Shared,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum IconLabelLayout {
137 Stacked,
139 Inline,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum Content<'a> {
146 Label(text::Fragment<'a>),
147 IconLabel {
148 icon: text::Fragment<'a>,
149 label: text::Fragment<'a>,
150 layout: IconLabelLayout,
151 },
152}
153
154impl<'a> Content<'a> {
155 pub fn label(label: impl text::IntoFragment<'a>) -> Self {
157 Self::Label(label.into_fragment())
158 }
159
160 pub fn stacked_icon_label(
162 icon: impl text::IntoFragment<'a>,
163 label: impl text::IntoFragment<'a>,
164 ) -> Self {
165 Self::IconLabel {
166 icon: icon.into_fragment(),
167 label: label.into_fragment(),
168 layout: IconLabelLayout::Stacked,
169 }
170 }
171
172 pub fn inline_icon_label(
174 icon: impl text::IntoFragment<'a>,
175 label: impl text::IntoFragment<'a>,
176 ) -> Self {
177 Self::IconLabel {
178 icon: icon.into_fragment(),
179 label: label.into_fragment(),
180 layout: IconLabelLayout::Inline,
181 }
182 }
183}
184
185pub fn bar<'a, Message, Renderer>(
187 tabs: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
188) -> Row<'a, Message, Theme, Renderer>
189where
190 Message: 'a,
191 Renderer: iced_widget::core::Renderer + 'a,
192{
193 Row::with_children(tabs)
194 .spacing(0)
195 .align_y(alignment::Vertical::Bottom)
196 .width(Length::Fill)
197}
198
199pub fn animated_bar<'a, Message, Renderer>(
201 variant: Variant,
202 tab_count: usize,
203 state: &State,
204 tabs: impl IntoIterator<Item = Element<'a, Message, Theme, Renderer>>,
205) -> Column<'a, Message, Theme, Renderer>
206where
207 Message: 'a,
208 Renderer: iced_widget::core::Renderer + 'a,
209{
210 Column::new()
211 .push(bar(tabs))
212 .push(MovingIndicator {
213 variant,
214 tab_count,
215 position: state.indicator_position(),
216 })
217 .spacing(0)
218 .width(Length::Fill)
219}
220
221pub fn tab<'a, Message, Renderer>(
223 variant: Variant,
224 content: Content<'a>,
225 active: bool,
226 indicator_mode: IndicatorMode,
227) -> Button<'a, Message, Renderer>
228where
229 Message: Clone + 'a,
230 Renderer: geometry::Renderer + core_text::Renderer + 'a,
231 iced_widget::core::Font: Into<Renderer::Font>,
232{
233 match content {
234 Content::Label(label) => label_tab(variant, label, active, indicator_mode),
235 Content::IconLabel {
236 icon,
237 label,
238 layout,
239 } => icon_label_tab(variant, icon, label, active, indicator_mode, layout),
240 }
241}
242
243pub fn animated_tabs<'a, Message, Renderer>(
245 variant: Variant,
246 state: &State,
247 tabs: impl IntoIterator<Item = (Content<'a>, Message)>,
248) -> Column<'a, Message, Theme, Renderer>
249where
250 Message: Clone + 'a,
251 Renderer: geometry::Renderer + primitive::Renderer + core_text::Renderer + 'a,
252 iced_widget::core::Font: Into<Renderer::Font>,
253{
254 let tabs: Vec<_> = tabs
255 .into_iter()
256 .enumerate()
257 .map(|(index, (content, on_press))| {
258 tab(
259 variant,
260 content,
261 state.selected_index() == index,
262 IndicatorMode::Shared,
263 )
264 .on_press(on_press)
265 .into()
266 })
267 .collect();
268
269 animated_bar(variant, tabs.len(), state, tabs)
270}
271
272fn label_tab<'a, Message, Renderer>(
273 variant: Variant,
274 label: text::Fragment<'a>,
275 active: bool,
276 indicator_mode: IndicatorMode,
277) -> Button<'a, Message, Renderer>
278where
279 Message: Clone + 'a,
280 Renderer: geometry::Renderer + core_text::Renderer + 'a,
281{
282 let label_text = variant.label_text();
283 tab_button_for_mode(
284 variant,
285 Text::new(label)
286 .size(label_text.size)
287 .line_height(absolute_line_height(label_text.line_height))
288 .into(),
289 active,
290 variant.container_height(),
291 indicator_mode,
292 )
293}
294
295fn icon_label_tab<'a, Message, Renderer>(
296 variant: Variant,
297 icon: text::Fragment<'a>,
298 label: text::Fragment<'a>,
299 active: bool,
300 indicator_mode: IndicatorMode,
301 layout: IconLabelLayout,
302) -> Button<'a, Message, Renderer>
303where
304 Message: Clone + 'a,
305 Renderer: geometry::Renderer + core_text::Renderer + 'a,
306 iced_widget::core::Font: Into<Renderer::Font>,
307{
308 match (variant, layout) {
309 (Variant::Primary, IconLabelLayout::Stacked) => {
310 stacked_icon_label_tab(variant, icon, label, active, indicator_mode)
311 }
312 _ => inline_icon_label_tab(variant, icon, label, active, indicator_mode),
313 }
314}
315
316fn stacked_icon_label_tab<'a, Message, Renderer>(
317 variant: Variant,
318 icon: text::Fragment<'a>,
319 label: text::Fragment<'a>,
320 active: bool,
321 indicator_mode: IndicatorMode,
322) -> Button<'a, Message, Renderer>
323where
324 Message: Clone + 'a,
325 Renderer: geometry::Renderer + core_text::Renderer + 'a,
326 iced_widget::core::Font: Into<Renderer::Font>,
327{
328 let label_text = variant.label_text();
329 let content = Column::<Message, Theme, Renderer>::new()
330 .push(fonts::filled_icon(icon, variant.icon_size()))
331 .push(
332 Text::new(label)
333 .size(label_text.size)
334 .line_height(absolute_line_height(label_text.line_height)),
335 )
336 .spacing(tokens::component::primary_tab::STACKED_ICON_LABEL_SPACE)
337 .align_x(alignment::Horizontal::Center);
338
339 tab_button_for_mode(
340 variant,
341 content.into(),
342 active,
343 tokens::component::primary_tab::WITH_ICON_AND_LABEL_TEXT_CONTAINER_HEIGHT,
344 indicator_mode,
345 )
346}
347
348fn inline_icon_label_tab<'a, Message, Renderer>(
349 variant: Variant,
350 icon: text::Fragment<'a>,
351 label: text::Fragment<'a>,
352 active: bool,
353 indicator_mode: IndicatorMode,
354) -> Button<'a, Message, Renderer>
355where
356 Message: Clone + 'a,
357 Renderer: geometry::Renderer + core_text::Renderer + 'a,
358 iced_widget::core::Font: Into<Renderer::Font>,
359{
360 let label_text = variant.label_text();
361 let gap = match variant {
362 Variant::Primary => tokens::component::primary_tab::INLINE_ICON_LABEL_SPACE,
363 Variant::Secondary => tokens::component::secondary_tab::ICON_LABEL_SPACE,
364 };
365 let content = Row::<Message, Theme, Renderer>::new()
366 .push(fonts::filled_icon(icon, variant.icon_size()))
367 .push(
368 Text::new(label)
369 .size(label_text.size)
370 .line_height(absolute_line_height(label_text.line_height)),
371 )
372 .spacing(gap)
373 .align_y(alignment::Vertical::Center);
374
375 tab_button_for_mode(
376 variant,
377 content.into(),
378 active,
379 variant.container_height(),
380 indicator_mode,
381 )
382}
383
384fn tab_button_for_mode<'a, Message, Renderer>(
385 variant: Variant,
386 content: Element<'a, Message, Theme, Renderer>,
387 active: bool,
388 height: f32,
389 indicator_mode: IndicatorMode,
390) -> Button<'a, Message, Renderer>
391where
392 Message: Clone + 'a,
393 Renderer: geometry::Renderer + core_text::Renderer + 'a,
394{
395 match indicator_mode {
396 IndicatorMode::Fixed => tab_button(variant, content, active, height),
397 IndicatorMode::Shared => animated_tab_button(variant, content, active, height),
398 }
399}
400
401fn tab_button<'a, Message, Renderer>(
402 variant: Variant,
403 content: Element<'a, Message, Theme, Renderer>,
404 active: bool,
405 height: f32,
406) -> Button<'a, Message, Renderer>
407where
408 Message: Clone + 'a,
409 Renderer: geometry::Renderer + core_text::Renderer + 'a,
410{
411 tab_button_with_indicator(variant, content, active, height, true)
412}
413
414fn animated_tab_button<'a, Message, Renderer>(
415 variant: Variant,
416 content: Element<'a, Message, Theme, Renderer>,
417 active: bool,
418 height: f32,
419) -> Button<'a, Message, Renderer>
420where
421 Message: Clone + 'a,
422 Renderer: geometry::Renderer + core_text::Renderer + 'a,
423{
424 tab_button_with_indicator(
425 variant,
426 content,
427 active,
428 height - variant.indicator_height(),
429 false,
430 )
431}
432
433fn tab_button_with_indicator<'a, Message, Renderer>(
434 variant: Variant,
435 content: Element<'a, Message, Theme, Renderer>,
436 active: bool,
437 height: f32,
438 show_indicator: bool,
439) -> Button<'a, Message, Renderer>
440where
441 Message: Clone + 'a,
442 Renderer: geometry::Renderer + core_text::Renderer + 'a,
443{
444 let tab_content = Column::new().push(
445 Container::new(content)
446 .center_x(Length::Fill)
447 .center_y(Length::Fill)
448 .padding(Padding {
449 top: 0.0,
450 right: horizontal_space(variant),
451 bottom: 0.0,
452 left: horizontal_space(variant),
453 }),
454 );
455 let tab_content = if show_indicator {
456 tab_content.push(indicator(variant, active))
457 } else {
458 tab_content
459 }
460 .width(Length::Fill)
461 .height(Length::Fixed(height));
462
463 Button::new(tab_content)
464 .width(Length::Fill)
465 .height(Length::Fixed(height))
466 .padding(Padding::ZERO)
467 .style(move |theme, status| tab_style(theme, status, variant, active))
468}
469
470fn indicator<'a, Message, Renderer>(
471 variant: Variant,
472 active: bool,
473) -> Container<'a, Message, Theme, Renderer>
474where
475 Message: 'a,
476 Renderer: iced_widget::core::Renderer + 'a,
477{
478 Container::new(Space::new())
479 .width(Length::Fill)
480 .height(Length::Fixed(variant.indicator_height()))
481 .style(move |theme| indicator_style(theme, variant, active))
482}
483
484const fn horizontal_space(variant: Variant) -> f32 {
485 match variant {
486 Variant::Primary => tokens::component::primary_tab::HORIZONTAL_SPACE,
487 Variant::Secondary => tokens::component::secondary_tab::HORIZONTAL_SPACE,
488 }
489}
490
491pub fn tab_style(theme: &Theme, status: Status, variant: Variant, active: bool) -> Style {
493 let colors = theme.colors();
494 let surface = colors.surface;
495 let content = tab_content_color(theme, variant, active, status);
496 let layer = tab_state_layer_color(theme, variant, active, status);
497 let container = surface.color;
498
499 let active_style = Style {
500 background: Some(Background::Color(container)),
501 text_color: content,
502 border: border::rounded(tab_container_shape(variant)),
503 shadow: shadow_from_level(tab_container_elevation(variant), colors.shadow),
504 snap: cfg!(feature = "crisp"),
505 };
506
507 match status {
508 Status::Active => active_style,
509 Status::Hovered => Style {
510 background: Some(Background::Color(mix(
511 container,
512 layer,
513 tab_hover_opacity(variant, active),
514 ))),
515 ..active_style
516 },
517 Status::Pressed => Style {
518 background: Some(Background::Color(mix(
519 container,
520 layer,
521 tab_pressed_opacity(variant, active),
522 ))),
523 ..active_style
524 },
525 Status::Disabled => Style {
526 background: Some(Background::Color(container)),
527 text_color: Color {
528 a: tokens::state::DISABLED_LABEL_TEXT_OPACITY,
529 ..surface.text
530 },
531 ..active_style
532 },
533 }
534}
535
536fn tab_content_color(theme: &Theme, variant: Variant, active: bool, status: Status) -> Color {
537 let colors = theme.colors();
538
539 match (variant, active, status) {
540 (Variant::Primary, true, _) => colors.primary.color,
541 (Variant::Primary, false, Status::Active) => colors.surface.text_variant,
542 (Variant::Primary, false, _) => colors.surface.text,
543 (Variant::Secondary, true, _) => colors.surface.text,
544 (Variant::Secondary, false, Status::Active) => colors.surface.text_variant,
545 (Variant::Secondary, false, _) => colors.surface.text,
546 }
547}
548
549fn tab_state_layer_color(theme: &Theme, variant: Variant, active: bool, status: Status) -> Color {
550 let colors = theme.colors();
551
552 match (variant, active, status) {
553 (Variant::Primary, true, _) => colors.primary.color,
554 (Variant::Primary, false, Status::Pressed) => colors.primary.color,
555 (Variant::Primary, false, _) => colors.surface.text,
556 (Variant::Secondary, _, _) => colors.surface.text,
557 }
558}
559
560const fn tab_hover_opacity(variant: Variant, active: bool) -> f32 {
561 match (variant, active) {
562 (Variant::Primary, true) => {
563 tokens::component::primary_tab::ACTIVE_HOVER_STATE_LAYER_OPACITY
564 }
565 (Variant::Primary, false) => {
566 tokens::component::primary_tab::INACTIVE_HOVER_STATE_LAYER_OPACITY
567 }
568 (Variant::Secondary, _) => tokens::component::secondary_tab::HOVER_STATE_LAYER_OPACITY,
569 }
570}
571
572const fn tab_pressed_opacity(variant: Variant, active: bool) -> f32 {
573 match (variant, active) {
574 (Variant::Primary, true) => {
575 tokens::component::primary_tab::ACTIVE_PRESSED_STATE_LAYER_OPACITY
576 }
577 (Variant::Primary, false) => {
578 tokens::component::primary_tab::INACTIVE_PRESSED_STATE_LAYER_OPACITY
579 }
580 (Variant::Secondary, _) => tokens::component::secondary_tab::PRESSED_STATE_LAYER_OPACITY,
581 }
582}
583
584const fn tab_container_shape(variant: Variant) -> f32 {
585 match variant {
586 Variant::Primary => tokens::component::primary_tab::CONTAINER_SHAPE,
587 Variant::Secondary => tokens::component::secondary_tab::CONTAINER_SHAPE,
588 }
589}
590
591const fn tab_container_elevation(variant: Variant) -> u8 {
592 match variant {
593 Variant::Primary => tokens::component::primary_tab::CONTAINER_ELEVATION_LEVEL,
594 Variant::Secondary => tokens::component::secondary_tab::CONTAINER_ELEVATION_LEVEL,
595 }
596}
597
598fn indicator_style(theme: &Theme, variant: Variant, active: bool) -> iced_widget::container::Style {
599 let colors = theme.colors();
600 let background = if active {
601 colors.primary.color
602 } else {
603 Color::TRANSPARENT
604 };
605
606 iced_widget::container::Style {
607 background: Some(Background::Color(background)),
608 border: Border {
609 color: Color::TRANSPARENT,
610 width: 0.0,
611 radius: indicator_radius(variant),
612 },
613 snap: cfg!(feature = "crisp"),
614 ..Default::default()
615 }
616}
617
618fn indicator_radius(variant: Variant) -> Radius {
619 match variant {
620 Variant::Primary => Radius {
621 top_left: tokens::component::primary_tab::ACTIVE_INDICATOR_SHAPE_TOP,
622 top_right: tokens::component::primary_tab::ACTIVE_INDICATOR_SHAPE_TOP,
623 bottom_right: tokens::component::primary_tab::ACTIVE_INDICATOR_SHAPE_BOTTOM,
624 bottom_left: tokens::component::primary_tab::ACTIVE_INDICATOR_SHAPE_BOTTOM,
625 },
626 Variant::Secondary => Radius::new(tokens::component::secondary_tab::ACTIVE_INDICATOR_SHAPE),
627 }
628}
629
630#[derive(Debug, Clone, Copy)]
631struct MovingIndicator {
632 variant: Variant,
633 tab_count: usize,
634 position: f32,
635}
636
637impl<Message, Renderer> Widget<Message, Theme, Renderer> for MovingIndicator
638where
639 Renderer: iced_widget::core::Renderer,
640{
641 fn size(&self) -> Size<Length> {
642 Size {
643 width: Length::Fill,
644 height: Length::Fixed(self.variant.indicator_height()),
645 }
646 }
647
648 fn layout(
649 &mut self,
650 _tree: &mut iced_widget::core::widget::Tree,
651 _renderer: &Renderer,
652 limits: &layout::Limits,
653 ) -> layout::Node {
654 layout::Node::new(limits.resolve(
655 Length::Fill,
656 Length::Fixed(self.variant.indicator_height()),
657 Size::ZERO,
658 ))
659 }
660
661 fn draw(
662 &self,
663 _tree: &iced_widget::core::widget::Tree,
664 renderer: &mut Renderer,
665 theme: &Theme,
666 _defaults: &renderer::Style,
667 layout: Layout<'_>,
668 _cursor: mouse::Cursor,
669 _viewport: &Rectangle,
670 ) {
671 if self.tab_count == 0 {
672 return;
673 }
674
675 let bounds = layout.bounds();
676 let tab_width = bounds.width / self.tab_count as f32;
677
678 if tab_width <= 0.0 {
679 return;
680 }
681
682 let position = self
683 .position
684 .clamp(0.0, self.tab_count.saturating_sub(1) as f32);
685 let indicator_width = moving_indicator_width(self.variant, tab_width);
686 let x = bounds.x + tab_width * position + (tab_width - indicator_width) / 2.0;
687 let indicator_bounds = Rectangle {
688 x,
689 y: bounds.y,
690 width: indicator_width,
691 height: self.variant.indicator_height(),
692 };
693
694 renderer.fill_quad(
695 renderer::Quad {
696 bounds: indicator_bounds,
697 border: Border {
698 color: Color::TRANSPARENT,
699 width: 0.0,
700 radius: indicator_radius(self.variant),
701 },
702 snap: cfg!(feature = "crisp"),
703 ..renderer::Quad::default()
704 },
705 Background::Color(theme.colors().primary.color),
706 );
707 }
708}
709
710impl<'a, Message, Renderer> From<MovingIndicator> for Element<'a, Message, Theme, Renderer>
711where
712 Message: 'a,
713 Renderer: iced_widget::core::Renderer + 'a,
714{
715 fn from(indicator: MovingIndicator) -> Self {
716 Element::new(indicator)
717 }
718}
719
720fn moving_indicator_width(variant: Variant, tab_width: f32) -> f32 {
721 match variant {
722 Variant::Primary => (tab_width - horizontal_space(variant) * 2.0).max(0.0),
723 Variant::Secondary => tab_width,
724 }
725}
726
727#[cfg(test)]
728#[path = "../../../tests/widget/component/tabs.rs"]
729mod tests;