Skip to main content

iced_cube/navigation/
tabs.rs

1//! Switches between related views that share one place on screen.
2//!
3//! [`State`] owns the tabs and the selection. Its [`update`](State::update)
4//! handles selecting, moving to the next or previous tab with wrap-around,
5//! and skips disabled tabs. The app renders the selected view itself.
6//!
7//! Keyboard shortcuts resolve through a [`Keymap`] of [`Action`]s; see
8//! [`default_keymap`] and [`crate::keys`].
9
10use iced::keyboard::key::Named;
11use iced::widget::{self, button::Status, column, container, row, rule, scrollable, text};
12use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, Vector};
13
14use crate::icon::{Glyph, opacity, themed};
15use crate::keys::{self, Chord, Keymap};
16use crate::natural::natural;
17use crate::theme::{Tokens, fade, mix, radius, space, text_size};
18
19/// How the tab list looks.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
21pub enum Variant {
22    /// Plain labels over a rule, the selected one underlined.
23    #[default]
24    Underline,
25    /// A segmented control, the selected tab raised.
26    Pills,
27}
28
29impl Variant {
30    pub const ALL: [Variant; 2] = [Variant::Underline, Variant::Pills];
31}
32
33/// One tab in a [`State`].
34#[derive(Debug, Clone, PartialEq)]
35pub struct Tab<Id> {
36    pub id: Id,
37    pub label: String,
38    pub icon: Option<Glyph>,
39    pub disabled: bool,
40}
41
42/// Creates an enabled tab.
43pub fn tab<Id>(id: Id, label: impl Into<String>) -> Tab<Id> {
44    Tab {
45        id,
46        label: label.into(),
47        icon: None,
48        disabled: false,
49    }
50}
51
52impl<Id> Tab<Id> {
53    pub fn icon(mut self, glyph: Glyph) -> Self {
54        self.icon = Some(glyph);
55        self
56    }
57
58    pub fn disabled(mut self, disabled: bool) -> Self {
59        self.disabled = disabled;
60        self
61    }
62}
63
64/// Changes to the selection.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Event<Id> {
67    Select(Id),
68    Next,
69    Previous,
70}
71
72/// The tabs and which one is selected.
73#[derive(Debug, Clone, PartialEq)]
74pub struct State<Id> {
75    tabs: Vec<Tab<Id>>,
76    selected: Option<usize>,
77}
78
79impl<Id: Copy + PartialEq> State<Id> {
80    /// Selects the first enabled tab.
81    pub fn new(tabs: impl IntoIterator<Item = Tab<Id>>) -> Self {
82        let tabs: Vec<_> = tabs.into_iter().collect();
83        let selected = tabs.iter().position(|tab| !tab.disabled);
84        Self { tabs, selected }
85    }
86
87    /// Starts on `id` when it exists and is enabled.
88    pub fn with_selected(mut self, id: Id) -> Self {
89        let _ = self.update(Event::Select(id));
90        self
91    }
92
93    pub fn tabs(&self) -> &[Tab<Id>] {
94        &self.tabs
95    }
96
97    pub fn selected(&self) -> Option<Id> {
98        self.selected.map(|index| self.tabs[index].id)
99    }
100
101    pub fn is_selected(&self, id: Id) -> bool {
102        self.selected() == Some(id)
103    }
104
105    /// Enables or disables a tab. The selection stays where it is.
106    pub fn set_disabled(&mut self, id: Id, disabled: bool) {
107        if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
108            tab.disabled = disabled;
109        }
110    }
111
112    /// Applies an event and returns the newly selected id, if it changed.
113    pub fn update(&mut self, event: Event<Id>) -> Option<Id> {
114        let target = match event {
115            Event::Select(id) => self
116                .tabs
117                .iter()
118                .position(|tab| tab.id == id && !tab.disabled),
119            Event::Next => self.step(true),
120            Event::Previous => self.step(false),
121        }?;
122
123        if self.selected == Some(target) {
124            return None;
125        }
126        self.selected = Some(target);
127        Some(self.tabs[target].id)
128    }
129
130    /// The nearest enabled index in one direction, wrapping round.
131    fn step(&self, forward: bool) -> Option<usize> {
132        let len = self.tabs.len();
133        if len == 0 {
134            return None;
135        }
136        let start = self.selected.unwrap_or(if forward { len - 1 } else { 0 });
137        let offset = if forward { 1 } else { len - 1 };
138        (1..=len)
139            .map(|n| (start + offset * n) % len)
140            .find(|&index| !self.tabs[index].disabled)
141    }
142}
143
144/// What a tabs keyboard shortcut does.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146pub enum Action {
147    Next,
148    Previous,
149    First,
150    Last,
151}
152
153impl Action {
154    /// The [`Event`] this action sends to `state`, or `None` when no tab
155    /// is enabled.
156    pub fn event<Id: Copy + PartialEq>(self, state: &State<Id>) -> Option<Event<Id>> {
157        let mut enabled = state.tabs.iter().filter(|tab| !tab.disabled);
158        match self {
159            Action::Next => Some(Event::Next),
160            Action::Previous => Some(Event::Previous),
161            Action::First => enabled.next().map(|tab| Event::Select(tab.id)),
162            Action::Last => enabled.next_back().map(|tab| Event::Select(tab.id)),
163        }
164    }
165}
166
167impl keys::Action for Action {
168    const ALL: &'static [Self] = &[Action::Next, Action::Previous, Action::First, Action::Last];
169
170    fn defaults() -> Keymap<Self> {
171        default_keymap()
172    }
173
174    fn name(self) -> &'static str {
175        match self {
176            Action::Next => "Next",
177            Action::Previous => "Previous",
178            Action::First => "First",
179            Action::Last => "Last",
180        }
181    }
182
183    fn description(self) -> &'static str {
184        match self {
185            Action::Next => "Selects the next enabled tab, wrapping at the end.",
186            Action::Previous => "Selects the previous enabled tab, wrapping at the start.",
187            Action::First => "Selects the first enabled tab.",
188            Action::Last => "Selects the last enabled tab.",
189        }
190    }
191}
192
193/// The default tabs shortcuts:
194///
195/// | Keys | Action |
196/// | --- | --- |
197/// | `Ctrl+Tab`, `ArrowRight` | [`Action::Next`] |
198/// | `Ctrl+Shift+Tab`, `ArrowLeft` | [`Action::Previous`] |
199/// | `Home` | [`Action::First`] |
200/// | `End` | [`Action::Last`] |
201///
202/// Shortcuts are app-wide, not tied to focus. Arrow keys, Home and End
203/// only reach the keymap when no widget captures them, but an app with
204/// text inputs or other arrow-driven components may still want to unbind
205/// them and keep only `Ctrl+Tab`.
206pub fn default_keymap() -> Keymap<Action> {
207    Keymap::new()
208        .bind(Chord::named(Named::Tab).ctrl(), Action::Next)
209        .bind(Chord::named(Named::ArrowRight), Action::Next)
210        .bind(Chord::named(Named::Tab).ctrl().shift(), Action::Previous)
211        .bind(Chord::named(Named::ArrowLeft), Action::Previous)
212        .bind(Chord::named(Named::Home), Action::First)
213        .bind(Chord::named(Named::End), Action::Last)
214}
215
216/// A tab list builder. Convert it into an [`Element`] to render.
217pub struct Tabs<'a, Id, Message> {
218    state: &'a State<Id>,
219    variant: Variant,
220    width: Option<Length>,
221    on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
222}
223
224/// Renders the tab list of `state`. Without
225/// [`on_event`](Tabs::on_event) every tab renders disabled.
226pub fn tabs<Id, Message>(state: &State<Id>) -> Tabs<'_, Id, Message> {
227    Tabs {
228        state,
229        variant: Variant::default(),
230        width: None,
231        on_event: None,
232    }
233}
234
235impl<Id: std::fmt::Debug, Message> std::fmt::Debug for Tabs<'_, Id, Message> {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        f.debug_struct("Tabs")
238            .field("state", self.state)
239            .field("variant", &self.variant)
240            .finish_non_exhaustive()
241    }
242}
243
244impl<'a, Id, Message> Tabs<'a, Id, Message> {
245    pub fn variant(mut self, variant: Variant) -> Self {
246        self.variant = variant;
247        self
248    }
249
250    /// Underline lists fill their row by default, pills shrink to fit.
251    pub fn width(mut self, width: impl Into<Length>) -> Self {
252        self.width = Some(width.into());
253        self
254    }
255
256    pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
257        self.on_event = Some(Box::new(on_event));
258        self
259    }
260}
261
262impl<'a, Id, Message> From<Tabs<'a, Id, Message>> for Element<'a, Message>
263where
264    Id: Copy + PartialEq + 'a,
265    Message: Clone + 'a,
266{
267    fn from(tabs: Tabs<'a, Id, Message>) -> Self {
268        let Tabs {
269            state,
270            variant,
271            width,
272            on_event,
273        } = tabs;
274
275        let items = state.tabs.iter().map(|tab| {
276            let selected = state.is_selected(tab.id);
277            let message = on_event
278                .as_ref()
279                .filter(|_| !tab.disabled)
280                .map(|on_event| on_event(Event::Select(tab.id)));
281            item(tab, variant, selected, message)
282        });
283
284        match variant {
285            Variant::Underline => column![
286                sideways(row(items).spacing(space::XS)),
287                rule::horizontal(1).style(|theme: &Theme| rule::Style {
288                    color: Tokens::of(theme).border,
289                    radius: 0.0.into(),
290                    fill_mode: rule::FillMode::Full,
291                    snap: true,
292                }),
293            ]
294            .width(width.unwrap_or(Length::Fill))
295            .into(),
296            Variant::Pills => container(sideways(row(items).spacing(space::XS)))
297                .padding(space::XS)
298                .width(width.unwrap_or(Length::Shrink))
299                .style(move |theme| list_style(&Tokens::of(theme), variant))
300                .into(),
301        }
302    }
303}
304
305/// Lets a tab list wider than its space scroll sideways rather than run off
306/// the edge or squeeze its triggers.
307fn sideways<'a, Message: 'a>(list: widget::Row<'a, Message>) -> widget::Scrollable<'a, Message> {
308    widget::scrollable(list).direction(scrollable::Direction::Horizontal(
309        scrollable::Scrollbar::hidden(),
310    ))
311}
312
313fn item<'a, Id, Message: Clone + 'a>(
314    tab: &'a Tab<Id>,
315    variant: Variant,
316    selected: bool,
317    message: Option<Message>,
318) -> Element<'a, Message> {
319    let enabled = message.is_some();
320    let mut label = row![].spacing(space::SM).align_y(Alignment::Center);
321    if let Some(glyph) = tab.icon {
322        let status = if enabled {
323            Status::Active
324        } else {
325            Status::Disabled
326        };
327        label = label.push(themed(glyph, 16.0, opacity(enabled), move |theme| {
328            foreground(&Tokens::of(theme), selected, status)
329        }));
330    }
331    label = label.push(
332        text(tab.label.as_str())
333            .size(text_size::SM)
334            .wrapping(text::Wrapping::None),
335    );
336
337    let button = widget::button(label)
338        .padding([6.0, space::MD])
339        .on_press_maybe(message)
340        .style(move |theme, status| style(&Tokens::of(theme), variant, selected, status));
341
342    if variant == Variant::Pills {
343        return natural(button);
344    }
345
346    natural(
347        column![
348            button,
349            container(iced::widget::space())
350                .width(Length::Fill)
351                .height(2)
352                .style(move |theme| indicator_style(&Tokens::of(theme), selected)),
353        ]
354        .width(Length::Shrink),
355    )
356}
357
358fn foreground(tokens: &Tokens, selected: bool, status: Status) -> Color {
359    let colour = match status {
360        _ if selected => tokens.foreground,
361        Status::Hovered | Status::Pressed => tokens.foreground,
362        _ => tokens.muted_foreground,
363    };
364    if status == Status::Disabled {
365        fade(colour, 0.5)
366    } else {
367        colour
368    }
369}
370
371/// The style of one tab.
372pub fn style(
373    tokens: &Tokens,
374    variant: Variant,
375    selected: bool,
376    status: Status,
377) -> widget::button::Style {
378    let raised = variant == Variant::Pills && selected;
379    let background = raised.then(|| {
380        if tokens.is_dark {
381            mix(tokens.muted, tokens.foreground, 0.12)
382        } else {
383            tokens.background
384        }
385    });
386
387    widget::button::Style {
388        background: background.map(Background::Color),
389        text_color: foreground(tokens, selected, status),
390        border: Border {
391            radius: radius::MD.into(),
392            ..Border::default()
393        },
394        shadow: if raised {
395            Shadow {
396                color: fade(Color::BLACK, 0.1),
397                offset: Vector::new(0.0, 1.0),
398                blur_radius: 3.0,
399            }
400        } else {
401            Shadow::default()
402        },
403        snap: true,
404    }
405}
406
407/// The underline below a tab.
408pub fn indicator_style(tokens: &Tokens, selected: bool) -> container::Style {
409    container::Style {
410        background: selected.then_some(Background::Color(tokens.foreground)),
411        ..container::Style::default()
412    }
413}
414
415/// The container around the tab list.
416pub fn list_style(tokens: &Tokens, variant: Variant) -> container::Style {
417    match variant {
418        Variant::Underline => container::Style::default(),
419        Variant::Pills => container::Style {
420            background: Some(Background::Color(tokens.muted)),
421            border: Border {
422                radius: radius::LG.into(),
423                ..Border::default()
424            },
425            ..container::Style::default()
426        },
427    }
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::theme::{dark, light};
434
435    fn state() -> State<u8> {
436        State::new([
437            tab(0, "A"),
438            tab(1, "B").disabled(true),
439            tab(2, "C"),
440            tab(3, "D"),
441        ])
442    }
443
444    #[test]
445    fn new_selects_first_enabled_tab() {
446        assert_eq!(state().selected(), Some(0));
447        let skipped = State::new([tab(0, "A").disabled(true), tab(1, "B")]);
448        assert_eq!(skipped.selected(), Some(1));
449    }
450
451    #[test]
452    fn empty_and_all_disabled_have_no_selection() {
453        let mut empty: State<u8> = State::new([]);
454        assert_eq!(empty.selected(), None);
455        assert_eq!(empty.update(Event::Next), None);
456        assert_eq!(empty.update(Event::Previous), None);
457
458        let mut disabled = State::new([tab(0, "A").disabled(true)]);
459        assert_eq!(disabled.selected(), None);
460        assert_eq!(disabled.update(Event::Next), None);
461    }
462
463    #[test]
464    fn select_ignores_disabled_unknown_and_current() {
465        let mut state = state();
466        assert_eq!(state.update(Event::Select(1)), None);
467        assert_eq!(state.update(Event::Select(9)), None);
468        assert_eq!(state.update(Event::Select(0)), None);
469        assert_eq!(state.update(Event::Select(3)), Some(3));
470        assert_eq!(state.selected(), Some(3));
471    }
472
473    #[test]
474    fn next_skips_disabled_and_wraps() {
475        let mut state = state();
476        assert_eq!(state.update(Event::Next), Some(2));
477        assert_eq!(state.update(Event::Next), Some(3));
478        assert_eq!(state.update(Event::Next), Some(0));
479    }
480
481    #[test]
482    fn previous_skips_disabled_and_wraps() {
483        let mut state = state();
484        assert_eq!(state.update(Event::Previous), Some(3));
485        assert_eq!(state.update(Event::Previous), Some(2));
486        assert_eq!(state.update(Event::Previous), Some(0));
487    }
488
489    #[test]
490    fn single_enabled_tab_never_moves() {
491        let mut state = State::new([tab(0, "A"), tab(1, "B").disabled(true)]);
492        assert_eq!(state.update(Event::Next), None);
493        assert_eq!(state.update(Event::Previous), None);
494        assert_eq!(state.selected(), Some(0));
495    }
496
497    #[test]
498    fn with_selected_and_set_disabled() {
499        let mut state = state().with_selected(2);
500        assert_eq!(state.selected(), Some(2));
501        state.set_disabled(1, false);
502        assert_eq!(state.update(Event::Previous), Some(1));
503    }
504
505    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
506        let chord: Chord = chord.parse().unwrap();
507        keymap.resolve(chord.key(), chord.modifiers())
508    }
509
510    #[test]
511    fn default_keymap_covers_every_action() {
512        let keymap = default_keymap();
513        assert_eq!(press(&keymap, "Ctrl+Tab"), Some(Action::Next));
514        assert_eq!(press(&keymap, "ArrowRight"), Some(Action::Next));
515        assert_eq!(press(&keymap, "Ctrl+Shift+Tab"), Some(Action::Previous));
516        assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::Previous));
517        assert_eq!(press(&keymap, "Home"), Some(Action::First));
518        assert_eq!(press(&keymap, "End"), Some(Action::Last));
519        assert_eq!(press(&keymap, "Tab"), None);
520        for action in <Action as keys::Action>::ALL {
521            assert!(!keymap.chords(action).is_empty(), "{action:?}");
522        }
523        assert_eq!(Keymap::<Action>::defaults(), keymap);
524    }
525
526    #[test]
527    fn keymap_overrides_and_unbinding() {
528        let keymap = default_keymap()
529            .unbind(&"ArrowRight".parse().unwrap())
530            .unbind(&"ArrowLeft".parse().unwrap())
531            .bind("Ctrl+PageDown".parse().unwrap(), Action::Next);
532        assert_eq!(press(&keymap, "ArrowRight"), None);
533        assert_eq!(press(&keymap, "Ctrl+PageDown"), Some(Action::Next));
534        assert_eq!(press(&keymap, "Ctrl+Tab"), Some(Action::Next));
535    }
536
537    #[test]
538    fn actions_map_to_events() {
539        let mut state = state().with_selected(2);
540        assert_eq!(Action::Next.event(&state), Some(Event::Next));
541        assert_eq!(Action::Previous.event(&state), Some(Event::Previous));
542        assert_eq!(Action::First.event(&state), Some(Event::Select(0)));
543        assert_eq!(Action::Last.event(&state), Some(Event::Select(3)));
544
545        let event = Action::First.event(&state).unwrap();
546        assert_eq!(state.update(event), Some(0));
547
548        let disabled = State::new([tab(0, "A").disabled(true)]);
549        assert_eq!(Action::First.event(&disabled), None);
550        assert_eq!(Action::Last.event(&disabled), None);
551    }
552
553    #[test]
554    fn first_and_last_skip_disabled_tabs() {
555        let state = State::new([
556            tab(0, "A").disabled(true),
557            tab(1, "B"),
558            tab(2, "C"),
559            tab(3, "D").disabled(true),
560        ]);
561        assert_eq!(Action::First.event(&state), Some(Event::Select(1)));
562        assert_eq!(Action::Last.event(&state), Some(Event::Select(2)));
563    }
564
565    #[test]
566    fn selected_tab_is_emphasised_in_both_themes() {
567        for theme in [light(), dark()] {
568            let tokens = Tokens::of(&theme);
569            for variant in Variant::ALL {
570                let on = style(&tokens, variant, true, Status::Active);
571                let off = style(&tokens, variant, false, Status::Active);
572                assert_eq!(on.text_color, tokens.foreground);
573                assert_eq!(off.text_color, tokens.muted_foreground);
574            }
575        }
576    }
577
578    #[test]
579    fn only_selected_pill_is_raised() {
580        let tokens = Tokens::of(&light());
581        assert!(
582            style(&tokens, Variant::Pills, true, Status::Active)
583                .background
584                .is_some()
585        );
586        assert!(
587            style(&tokens, Variant::Pills, false, Status::Active)
588                .background
589                .is_none()
590        );
591        assert!(
592            style(&tokens, Variant::Underline, true, Status::Active)
593                .background
594                .is_none()
595        );
596    }
597
598    #[test]
599    fn hover_and_disabled_change_the_label_colour() {
600        let tokens = Tokens::of(&dark());
601        let hovered = style(&tokens, Variant::Underline, false, Status::Hovered);
602        let disabled = style(&tokens, Variant::Underline, false, Status::Disabled);
603        assert_eq!(hovered.text_color, tokens.foreground);
604        assert!(disabled.text_color.a < tokens.muted_foreground.a);
605    }
606
607    #[test]
608    fn indicator_and_list_styles() {
609        let tokens = Tokens::of(&light());
610        assert!(indicator_style(&tokens, true).background.is_some());
611        assert!(indicator_style(&tokens, false).background.is_none());
612        assert!(list_style(&tokens, Variant::Pills).background.is_some());
613        assert!(list_style(&tokens, Variant::Underline).background.is_none());
614    }
615}