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`. Document tabs bind `Ctrl+Tab` and
206/// `Ctrl+Shift+Tab` too, so an app showing both should unbind them here
207/// or there.
208pub fn default_keymap() -> Keymap<Action> {
209    Keymap::new()
210        .bind(Chord::named(Named::Tab).ctrl(), Action::Next)
211        .bind(Chord::named(Named::ArrowRight), Action::Next)
212        .bind(Chord::named(Named::Tab).ctrl().shift(), Action::Previous)
213        .bind(Chord::named(Named::ArrowLeft), Action::Previous)
214        .bind(Chord::named(Named::Home), Action::First)
215        .bind(Chord::named(Named::End), Action::Last)
216}
217
218/// A tab list builder. Convert it into an [`Element`] to render.
219pub struct Tabs<'a, Id, Message> {
220    state: &'a State<Id>,
221    variant: Variant,
222    width: Option<Length>,
223    on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
224}
225
226/// Renders the tab list of `state`. Without
227/// [`on_event`](Tabs::on_event) every tab renders disabled.
228pub fn tabs<Id, Message>(state: &State<Id>) -> Tabs<'_, Id, Message> {
229    Tabs {
230        state,
231        variant: Variant::default(),
232        width: None,
233        on_event: None,
234    }
235}
236
237impl<Id: std::fmt::Debug, Message> std::fmt::Debug for Tabs<'_, Id, Message> {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        f.debug_struct("Tabs")
240            .field("state", self.state)
241            .field("variant", &self.variant)
242            .finish_non_exhaustive()
243    }
244}
245
246impl<'a, Id, Message> Tabs<'a, Id, Message> {
247    pub fn variant(mut self, variant: Variant) -> Self {
248        self.variant = variant;
249        self
250    }
251
252    /// Underline lists fill their row by default, pills shrink to fit.
253    pub fn width(mut self, width: impl Into<Length>) -> Self {
254        self.width = Some(width.into());
255        self
256    }
257
258    pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
259        self.on_event = Some(Box::new(on_event));
260        self
261    }
262}
263
264impl<'a, Id, Message> From<Tabs<'a, Id, Message>> for Element<'a, Message>
265where
266    Id: Copy + PartialEq + 'a,
267    Message: Clone + 'a,
268{
269    fn from(tabs: Tabs<'a, Id, Message>) -> Self {
270        let Tabs {
271            state,
272            variant,
273            width,
274            on_event,
275        } = tabs;
276
277        let items = state.tabs.iter().map(|tab| {
278            let selected = state.is_selected(tab.id);
279            let message = on_event
280                .as_ref()
281                .filter(|_| !tab.disabled)
282                .map(|on_event| on_event(Event::Select(tab.id)));
283            item(tab, variant, selected, message)
284        });
285
286        match variant {
287            Variant::Underline => column![
288                sideways(row(items).spacing(space::XS)),
289                rule::horizontal(1).style(|theme: &Theme| rule::Style {
290                    color: Tokens::of(theme).border,
291                    radius: 0.0.into(),
292                    fill_mode: rule::FillMode::Full,
293                    snap: true,
294                }),
295            ]
296            .width(width.unwrap_or(Length::Fill))
297            .into(),
298            Variant::Pills => container(sideways(row(items).spacing(space::XS)))
299                .padding(space::XS)
300                .width(width.unwrap_or(Length::Shrink))
301                .style(move |theme| list_style(&Tokens::of(theme), variant))
302                .into(),
303        }
304    }
305}
306
307/// Lets a tab list wider than its space scroll sideways rather than run off
308/// the edge or squeeze its triggers.
309fn sideways<'a, Message: 'a>(list: widget::Row<'a, Message>) -> widget::Scrollable<'a, Message> {
310    widget::scrollable(list).direction(scrollable::Direction::Horizontal(
311        scrollable::Scrollbar::hidden(),
312    ))
313}
314
315fn item<'a, Id, Message: Clone + 'a>(
316    tab: &'a Tab<Id>,
317    variant: Variant,
318    selected: bool,
319    message: Option<Message>,
320) -> Element<'a, Message> {
321    let enabled = message.is_some();
322    let mut label = row![].spacing(space::SM).align_y(Alignment::Center);
323    if let Some(glyph) = tab.icon {
324        let status = if enabled {
325            Status::Active
326        } else {
327            Status::Disabled
328        };
329        label = label.push(themed(glyph, 16.0, opacity(enabled), move |theme| {
330            foreground(&Tokens::of(theme), selected, status)
331        }));
332    }
333    label = label.push(
334        text(tab.label.as_str())
335            .size(text_size::SM)
336            .wrapping(text::Wrapping::None),
337    );
338
339    let button = widget::button(label)
340        .padding([6.0, space::MD])
341        .on_press_maybe(message)
342        .style(move |theme, status| style(&Tokens::of(theme), variant, selected, status));
343
344    if variant == Variant::Pills {
345        return natural(button);
346    }
347
348    natural(
349        column![
350            button,
351            container(iced::widget::space())
352                .width(Length::Fill)
353                .height(2)
354                .style(move |theme| indicator_style(&Tokens::of(theme), selected)),
355        ]
356        .width(Length::Shrink),
357    )
358}
359
360fn foreground(tokens: &Tokens, selected: bool, status: Status) -> Color {
361    let colour = match status {
362        _ if selected => tokens.foreground,
363        Status::Hovered | Status::Pressed => tokens.foreground,
364        _ => tokens.muted_foreground,
365    };
366    if status == Status::Disabled {
367        fade(colour, 0.5)
368    } else {
369        colour
370    }
371}
372
373/// The style of one tab.
374pub fn style(
375    tokens: &Tokens,
376    variant: Variant,
377    selected: bool,
378    status: Status,
379) -> widget::button::Style {
380    let raised = variant == Variant::Pills && selected;
381    let background = raised.then(|| {
382        if tokens.is_dark {
383            mix(tokens.muted, tokens.foreground, 0.12)
384        } else {
385            tokens.background
386        }
387    });
388
389    widget::button::Style {
390        background: background.map(Background::Color),
391        text_color: foreground(tokens, selected, status),
392        border: Border {
393            radius: radius::MD.into(),
394            ..Border::default()
395        },
396        shadow: if raised {
397            Shadow {
398                color: fade(Color::BLACK, 0.1),
399                offset: Vector::new(0.0, 1.0),
400                blur_radius: 3.0,
401            }
402        } else {
403            Shadow::default()
404        },
405        snap: true,
406    }
407}
408
409/// The underline below a tab.
410pub fn indicator_style(tokens: &Tokens, selected: bool) -> container::Style {
411    container::Style {
412        background: selected.then_some(Background::Color(tokens.foreground)),
413        ..container::Style::default()
414    }
415}
416
417/// The container around the tab list.
418pub fn list_style(tokens: &Tokens, variant: Variant) -> container::Style {
419    match variant {
420        Variant::Underline => container::Style::default(),
421        Variant::Pills => container::Style {
422            background: Some(Background::Color(tokens.muted)),
423            border: Border {
424                radius: radius::LG.into(),
425                ..Border::default()
426            },
427            ..container::Style::default()
428        },
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use crate::theme::{dark, light};
436
437    fn state() -> State<u8> {
438        State::new([
439            tab(0, "A"),
440            tab(1, "B").disabled(true),
441            tab(2, "C"),
442            tab(3, "D"),
443        ])
444    }
445
446    #[test]
447    fn new_selects_first_enabled_tab() {
448        assert_eq!(state().selected(), Some(0));
449        let skipped = State::new([tab(0, "A").disabled(true), tab(1, "B")]);
450        assert_eq!(skipped.selected(), Some(1));
451    }
452
453    #[test]
454    fn empty_and_all_disabled_have_no_selection() {
455        let mut empty: State<u8> = State::new([]);
456        assert_eq!(empty.selected(), None);
457        assert_eq!(empty.update(Event::Next), None);
458        assert_eq!(empty.update(Event::Previous), None);
459
460        let mut disabled = State::new([tab(0, "A").disabled(true)]);
461        assert_eq!(disabled.selected(), None);
462        assert_eq!(disabled.update(Event::Next), None);
463    }
464
465    #[test]
466    fn select_ignores_disabled_unknown_and_current() {
467        let mut state = state();
468        assert_eq!(state.update(Event::Select(1)), None);
469        assert_eq!(state.update(Event::Select(9)), None);
470        assert_eq!(state.update(Event::Select(0)), None);
471        assert_eq!(state.update(Event::Select(3)), Some(3));
472        assert_eq!(state.selected(), Some(3));
473    }
474
475    #[test]
476    fn next_skips_disabled_and_wraps() {
477        let mut state = state();
478        assert_eq!(state.update(Event::Next), Some(2));
479        assert_eq!(state.update(Event::Next), Some(3));
480        assert_eq!(state.update(Event::Next), Some(0));
481    }
482
483    #[test]
484    fn previous_skips_disabled_and_wraps() {
485        let mut state = state();
486        assert_eq!(state.update(Event::Previous), Some(3));
487        assert_eq!(state.update(Event::Previous), Some(2));
488        assert_eq!(state.update(Event::Previous), Some(0));
489    }
490
491    #[test]
492    fn single_enabled_tab_never_moves() {
493        let mut state = State::new([tab(0, "A"), tab(1, "B").disabled(true)]);
494        assert_eq!(state.update(Event::Next), None);
495        assert_eq!(state.update(Event::Previous), None);
496        assert_eq!(state.selected(), Some(0));
497    }
498
499    #[test]
500    fn with_selected_and_set_disabled() {
501        let mut state = state().with_selected(2);
502        assert_eq!(state.selected(), Some(2));
503        state.set_disabled(1, false);
504        assert_eq!(state.update(Event::Previous), Some(1));
505    }
506
507    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
508        let chord: Chord = chord.parse().unwrap();
509        keymap.resolve(chord.key(), chord.modifiers())
510    }
511
512    #[test]
513    fn default_keymap_covers_every_action() {
514        let keymap = default_keymap();
515        assert_eq!(press(&keymap, "Ctrl+Tab"), Some(Action::Next));
516        assert_eq!(press(&keymap, "ArrowRight"), Some(Action::Next));
517        assert_eq!(press(&keymap, "Ctrl+Shift+Tab"), Some(Action::Previous));
518        assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::Previous));
519        assert_eq!(press(&keymap, "Home"), Some(Action::First));
520        assert_eq!(press(&keymap, "End"), Some(Action::Last));
521        assert_eq!(press(&keymap, "Tab"), None);
522        for action in <Action as keys::Action>::ALL {
523            assert!(!keymap.chords(action).is_empty(), "{action:?}");
524        }
525        assert_eq!(Keymap::<Action>::defaults(), keymap);
526    }
527
528    #[test]
529    fn keymap_overrides_and_unbinding() {
530        let keymap = default_keymap()
531            .unbind(&"ArrowRight".parse().unwrap())
532            .unbind(&"ArrowLeft".parse().unwrap())
533            .bind("Ctrl+PageDown".parse().unwrap(), Action::Next);
534        assert_eq!(press(&keymap, "ArrowRight"), None);
535        assert_eq!(press(&keymap, "Ctrl+PageDown"), Some(Action::Next));
536        assert_eq!(press(&keymap, "Ctrl+Tab"), Some(Action::Next));
537    }
538
539    #[test]
540    fn actions_map_to_events() {
541        let mut state = state().with_selected(2);
542        assert_eq!(Action::Next.event(&state), Some(Event::Next));
543        assert_eq!(Action::Previous.event(&state), Some(Event::Previous));
544        assert_eq!(Action::First.event(&state), Some(Event::Select(0)));
545        assert_eq!(Action::Last.event(&state), Some(Event::Select(3)));
546
547        let event = Action::First.event(&state).unwrap();
548        assert_eq!(state.update(event), Some(0));
549
550        let disabled = State::new([tab(0, "A").disabled(true)]);
551        assert_eq!(Action::First.event(&disabled), None);
552        assert_eq!(Action::Last.event(&disabled), None);
553    }
554
555    #[test]
556    fn first_and_last_skip_disabled_tabs() {
557        let state = State::new([
558            tab(0, "A").disabled(true),
559            tab(1, "B"),
560            tab(2, "C"),
561            tab(3, "D").disabled(true),
562        ]);
563        assert_eq!(Action::First.event(&state), Some(Event::Select(1)));
564        assert_eq!(Action::Last.event(&state), Some(Event::Select(2)));
565    }
566
567    #[test]
568    fn selected_tab_is_emphasised_in_both_themes() {
569        for theme in [light(), dark()] {
570            let tokens = Tokens::of(&theme);
571            for variant in Variant::ALL {
572                let on = style(&tokens, variant, true, Status::Active);
573                let off = style(&tokens, variant, false, Status::Active);
574                assert_eq!(on.text_color, tokens.foreground);
575                assert_eq!(off.text_color, tokens.muted_foreground);
576            }
577        }
578    }
579
580    #[test]
581    fn only_selected_pill_is_raised() {
582        let tokens = Tokens::of(&light());
583        assert!(
584            style(&tokens, Variant::Pills, true, Status::Active)
585                .background
586                .is_some()
587        );
588        assert!(
589            style(&tokens, Variant::Pills, false, Status::Active)
590                .background
591                .is_none()
592        );
593        assert!(
594            style(&tokens, Variant::Underline, true, Status::Active)
595                .background
596                .is_none()
597        );
598    }
599
600    #[test]
601    fn hover_and_disabled_change_the_label_colour() {
602        let tokens = Tokens::of(&dark());
603        let hovered = style(&tokens, Variant::Underline, false, Status::Hovered);
604        let disabled = style(&tokens, Variant::Underline, false, Status::Disabled);
605        assert_eq!(hovered.text_color, tokens.foreground);
606        assert!(disabled.text_color.a < tokens.muted_foreground.a);
607    }
608
609    #[test]
610    fn indicator_and_list_styles() {
611        let tokens = Tokens::of(&light());
612        assert!(indicator_style(&tokens, true).background.is_some());
613        assert!(indicator_style(&tokens, false).background.is_none());
614        assert!(list_style(&tokens, Variant::Pills).background.is_some());
615        assert!(list_style(&tokens, Variant::Underline).background.is_none());
616    }
617}