ratatui/widgets/
tabs.rs

1use itertools::Itertools;
2
3use crate::{
4    buffer::Buffer,
5    layout::Rect,
6    style::{Modifier, Style, Styled},
7    symbols::{self},
8    text::{Line, Span},
9    widgets::{block::BlockExt, Block, Widget, WidgetRef},
10};
11
12const DEFAULT_HIGHLIGHT_STYLE: Style = Style::new().add_modifier(Modifier::REVERSED);
13
14/// A widget that displays a horizontal set of Tabs with a single tab selected.
15///
16/// Each tab title is stored as a [`Line`] which can be individually styled. The selected tab is set
17/// using [`Tabs::select`] and styled using [`Tabs::highlight_style`]. The divider can be customized
18/// with [`Tabs::divider`]. Padding can be set with [`Tabs::padding`] or [`Tabs::padding_left`] and
19/// [`Tabs::padding_right`].
20///
21/// The divider defaults to |, and padding defaults to a singular space on each side.
22///
23/// # Example
24///
25/// ```
26/// use ratatui::{
27///     style::{Style, Stylize},
28///     symbols,
29///     widgets::{Block, Tabs},
30/// };
31///
32/// Tabs::new(vec!["Tab1", "Tab2", "Tab3", "Tab4"])
33///     .block(Block::bordered().title("Tabs"))
34///     .style(Style::default().white())
35///     .highlight_style(Style::default().yellow())
36///     .select(2)
37///     .divider(symbols::DOT)
38///     .padding("->", "<-");
39/// ```
40///
41/// In addition to `Tabs::new`, any iterator whose element is convertible to `Line` can be collected
42/// into `Tabs`.
43///
44/// ```
45/// use ratatui::widgets::Tabs;
46///
47/// (0..5).map(|i| format!("Tab{i}")).collect::<Tabs>();
48/// ```
49#[derive(Debug, Clone, Eq, PartialEq, Hash)]
50pub struct Tabs<'a> {
51    /// A block to wrap this widget in if necessary
52    block: Option<Block<'a>>,
53    /// One title for each tab
54    titles: Vec<Line<'a>>,
55    /// The index of the selected tabs
56    selected: Option<usize>,
57    /// The style used to draw the text
58    style: Style,
59    /// Style to apply to the selected item
60    highlight_style: Style,
61    /// Tab divider
62    divider: Span<'a>,
63    /// Tab Left Padding
64    padding_left: Line<'a>,
65    /// Tab Right Padding
66    padding_right: Line<'a>,
67}
68
69impl Default for Tabs<'_> {
70    /// Returns a default `Tabs` widget.
71    ///
72    /// The default widget has:
73    /// - No tabs
74    /// - No selected tab
75    /// - The highlight style is set to reversed.
76    /// - The divider is set to a pipe (`|`).
77    /// - The padding on the left and right is set to a space.
78    ///
79    /// This is rarely useful on its own without calling [`Tabs::titles`].
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use ratatui::widgets::Tabs;
85    ///
86    /// let tabs = Tabs::default().titles(["Tab 1", "Tab 2"]);
87    /// ```
88    fn default() -> Self {
89        Self::new(Vec::<Line>::new())
90    }
91}
92
93impl<'a> Tabs<'a> {
94    /// Creates new `Tabs` from their titles.
95    ///
96    /// `titles` can be a [`Vec`] of [`&str`], [`String`] or anything that can be converted into
97    /// [`Line`]. As such, titles can be styled independently.
98    ///
99    /// The selected tab can be set with [`Tabs::select`]. The first tab has index 0 (this is also
100    /// the default index).
101    ///
102    /// The selected tab can have a different style with [`Tabs::highlight_style`]. This defaults to
103    /// a style with the [`Modifier::REVERSED`] modifier added.
104    ///
105    /// The default divider is a pipe (`|`), but it can be customized with [`Tabs::divider`].
106    ///
107    /// The entire widget can be styled with [`Tabs::style`].
108    ///
109    /// The widget can be wrapped in a [`Block`] using [`Tabs::block`].
110    ///
111    /// # Examples
112    ///
113    /// Basic titles.
114    /// ```
115    /// use ratatui::widgets::Tabs;
116    ///
117    /// let tabs = Tabs::new(vec!["Tab 1", "Tab 2"]);
118    /// ```
119    ///
120    /// Styled titles
121    /// ```
122    /// use ratatui::{style::Stylize, widgets::Tabs};
123    ///
124    /// let tabs = Tabs::new(vec!["Tab 1".red(), "Tab 2".blue()]);
125    /// ```
126    pub fn new<Iter>(titles: Iter) -> Self
127    where
128        Iter: IntoIterator,
129        Iter::Item: Into<Line<'a>>,
130    {
131        let titles = titles.into_iter().map(Into::into).collect_vec();
132        let selected = if titles.is_empty() { None } else { Some(0) };
133        Self {
134            block: None,
135            titles,
136            selected,
137            style: Style::default(),
138            highlight_style: DEFAULT_HIGHLIGHT_STYLE,
139            divider: Span::raw(symbols::line::VERTICAL),
140            padding_left: Line::from(" "),
141            padding_right: Line::from(" "),
142        }
143    }
144
145    /// Sets the titles of the tabs.
146    ///
147    /// `titles` is an iterator whose elements can be converted into `Line`.
148    ///
149    /// The selected tab can be set with [`Tabs::select`]. The first tab has index 0 (this is also
150    /// the default index).
151    ///
152    /// # Examples
153    ///
154    /// Basic titles.
155    ///
156    /// ```
157    /// use ratatui::widgets::Tabs;
158    ///
159    /// let tabs = Tabs::default().titles(vec!["Tab 1", "Tab 2"]);
160    /// ```
161    ///
162    /// Styled titles.
163    ///
164    /// ```
165    /// use ratatui::{style::Stylize, widgets::Tabs};
166    ///
167    /// let tabs = Tabs::default().titles(vec!["Tab 1".red(), "Tab 2".blue()]);
168    /// ```
169    #[must_use = "method moves the value of self and returns the modified value"]
170    pub fn titles<Iter>(mut self, titles: Iter) -> Self
171    where
172        Iter: IntoIterator,
173        Iter::Item: Into<Line<'a>>,
174    {
175        self.titles = titles.into_iter().map(Into::into).collect_vec();
176        self.selected = if self.titles.is_empty() {
177            None
178        } else {
179            // Ensure selected is within bounds, and default to 0 if no selected tab
180            self.selected
181                .map(|selected| selected.min(self.titles.len() - 1))
182                .or(Some(0))
183        };
184        self
185    }
186
187    /// Surrounds the `Tabs` with a [`Block`].
188    #[must_use = "method moves the value of self and returns the modified value"]
189    pub fn block(mut self, block: Block<'a>) -> Self {
190        self.block = Some(block);
191        self
192    }
193
194    /// Sets the selected tab.
195    ///
196    /// The first tab has index 0 (this is also the default index).
197    /// The selected tab can have a different style with [`Tabs::highlight_style`].
198    ///
199    /// # Examples
200    ///
201    /// Select the second tab.
202    ///
203    /// ```
204    /// use ratatui::widgets::Tabs;
205    ///
206    /// let tabs = Tabs::new(vec!["Tab 1", "Tab 2"]).select(1);
207    /// ```
208    ///
209    /// Deselect the selected tab.
210    ///
211    /// ```
212    /// use ratatui::widgets::Tabs;
213    ///
214    /// let tabs = Tabs::new(vec!["Tab 1", "Tab 2"]).select(None);
215    /// ```
216    #[must_use = "method moves the value of self and returns the modified value"]
217    pub fn select<T: Into<Option<usize>>>(mut self, selected: T) -> Self {
218        self.selected = selected.into();
219        self
220    }
221
222    /// Sets the style of the tabs.
223    ///
224    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
225    /// your own type that implements [`Into<Style>`]).
226    ///
227    /// This will set the given style on the entire render area.
228    /// More precise style can be applied to the titles by styling the ones given to [`Tabs::new`].
229    /// The selected tab can be styled differently using [`Tabs::highlight_style`].
230    ///
231    /// [`Color`]: crate::style::Color
232    #[must_use = "method moves the value of self and returns the modified value"]
233    pub fn style<S: Into<Style>>(mut self, style: S) -> Self {
234        self.style = style.into();
235        self
236    }
237
238    /// Sets the style for the highlighted tab.
239    ///
240    /// `style` accepts any type that is convertible to [`Style`] (e.g. [`Style`], [`Color`], or
241    /// your own type that implements [`Into<Style>`]).
242    ///
243    /// Highlighted tab can be selected with [`Tabs::select`].
244    #[must_use = "method moves the value of self and returns the modified value"]
245    ///
246    /// [`Color`]: crate::style::Color
247    pub fn highlight_style<S: Into<Style>>(mut self, style: S) -> Self {
248        self.highlight_style = style.into();
249        self
250    }
251
252    /// Sets the string to use as tab divider.
253    ///
254    /// By default, the divider is a pipe (`|`).
255    ///
256    /// # Examples
257    ///
258    /// Use a dot (`•`) as separator.
259    /// ```
260    /// use ratatui::{symbols, widgets::Tabs};
261    ///
262    /// let tabs = Tabs::new(vec!["Tab 1", "Tab 2"]).divider(symbols::DOT);
263    /// ```
264    /// Use dash (`-`) as separator.
265    /// ```
266    /// use ratatui::widgets::Tabs;
267    ///
268    /// let tabs = Tabs::new(vec!["Tab 1", "Tab 2"]).divider("-");
269    /// ```
270    #[must_use = "method moves the value of self and returns the modified value"]
271    pub fn divider<T>(mut self, divider: T) -> Self
272    where
273        T: Into<Span<'a>>,
274    {
275        self.divider = divider.into();
276        self
277    }
278
279    /// Sets the padding between tabs.
280    ///
281    /// Both default to space.
282    ///
283    /// # Examples
284    ///
285    /// A space on either side of the tabs.
286    /// ```
287    /// use ratatui::widgets::Tabs;
288    ///
289    /// let tabs = Tabs::new(vec!["Tab 1", "Tab 2"]).padding(" ", " ");
290    /// ```
291    /// Nothing on either side of the tabs.
292    /// ```
293    /// use ratatui::widgets::Tabs;
294    ///
295    /// let tabs = Tabs::new(vec!["Tab 1", "Tab 2"]).padding("", "");
296    /// ```
297    #[must_use = "method moves the value of self and returns the modified value"]
298    pub fn padding<T, U>(mut self, left: T, right: U) -> Self
299    where
300        T: Into<Line<'a>>,
301        U: Into<Line<'a>>,
302    {
303        self.padding_left = left.into();
304        self.padding_right = right.into();
305        self
306    }
307
308    /// Sets the left side padding between tabs.
309    ///
310    /// Defaults to a space.
311    ///
312    /// # Example
313    ///
314    /// An arrow on the left of tabs.
315    /// ```
316    /// use ratatui::widgets::Tabs;
317    ///
318    /// let tabs = Tabs::new(vec!["Tab 1", "Tab 2"]).padding_left("->");
319    /// ```
320    #[must_use = "method moves the value of self and returns the modified value"]
321    pub fn padding_left<T>(mut self, padding: T) -> Self
322    where
323        T: Into<Line<'a>>,
324    {
325        self.padding_left = padding.into();
326        self
327    }
328
329    /// Sets the right side padding between tabs.
330    ///
331    /// Defaults to a space.
332    ///
333    /// # Example
334    ///
335    /// An arrow on the right of tabs.
336    /// ```
337    /// use ratatui::widgets::Tabs;
338    ///
339    /// let tabs = Tabs::new(vec!["Tab 1", "Tab 2"]).padding_right("<-");
340    /// ```
341    #[must_use = "method moves the value of self and returns the modified value"]
342    pub fn padding_right<T>(mut self, padding: T) -> Self
343    where
344        T: Into<Line<'a>>,
345    {
346        self.padding_left = padding.into();
347        self
348    }
349}
350
351impl<'a> Styled for Tabs<'a> {
352    type Item = Self;
353
354    fn style(&self) -> Style {
355        self.style
356    }
357
358    fn set_style<S: Into<Style>>(self, style: S) -> Self::Item {
359        self.style(style)
360    }
361}
362
363impl Widget for Tabs<'_> {
364    fn render(self, area: Rect, buf: &mut Buffer) {
365        self.render_ref(area, buf);
366    }
367}
368
369impl WidgetRef for Tabs<'_> {
370    fn render_ref(&self, area: Rect, buf: &mut Buffer) {
371        buf.set_style(area, self.style);
372        self.block.render_ref(area, buf);
373        let inner = self.block.inner_if_some(area);
374        self.render_tabs(inner, buf);
375    }
376}
377
378impl Tabs<'_> {
379    fn render_tabs(&self, tabs_area: Rect, buf: &mut Buffer) {
380        if tabs_area.is_empty() {
381            return;
382        }
383
384        let mut x = tabs_area.left();
385        let titles_length = self.titles.len();
386        for (i, title) in self.titles.iter().enumerate() {
387            let last_title = titles_length - 1 == i;
388            let remaining_width = tabs_area.right().saturating_sub(x);
389
390            if remaining_width == 0 {
391                break;
392            }
393
394            // Left Padding
395            let pos = buf.set_line(x, tabs_area.top(), &self.padding_left, remaining_width);
396            x = pos.0;
397            let remaining_width = tabs_area.right().saturating_sub(x);
398            if remaining_width == 0 {
399                break;
400            }
401
402            // Title
403            let pos = buf.set_line(x, tabs_area.top(), title, remaining_width);
404            if Some(i) == self.selected {
405                buf.set_style(
406                    Rect {
407                        x,
408                        y: tabs_area.top(),
409                        width: pos.0.saturating_sub(x),
410                        height: 1,
411                    },
412                    self.highlight_style,
413                );
414            }
415            x = pos.0;
416            let remaining_width = tabs_area.right().saturating_sub(x);
417            if remaining_width == 0 {
418                break;
419            }
420
421            // Right Padding
422            let pos = buf.set_line(x, tabs_area.top(), &self.padding_right, remaining_width);
423            x = pos.0;
424            let remaining_width = tabs_area.right().saturating_sub(x);
425            if remaining_width == 0 || last_title {
426                break;
427            }
428
429            let pos = buf.set_span(x, tabs_area.top(), &self.divider, remaining_width);
430            x = pos.0;
431        }
432    }
433}
434
435impl<'a, Item> FromIterator<Item> for Tabs<'a>
436where
437    Item: Into<Line<'a>>,
438{
439    fn from_iter<Iter: IntoIterator<Item = Item>>(iter: Iter) -> Self {
440        Self::new(iter)
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::style::{Color, Stylize};
448
449    #[test]
450    fn new() {
451        let titles = vec!["Tab1", "Tab2", "Tab3", "Tab4"];
452        let tabs = Tabs::new(titles.clone());
453        assert_eq!(
454            tabs,
455            Tabs {
456                block: None,
457                titles: vec![
458                    Line::from("Tab1"),
459                    Line::from("Tab2"),
460                    Line::from("Tab3"),
461                    Line::from("Tab4"),
462                ],
463                selected: Some(0),
464                style: Style::default(),
465                highlight_style: DEFAULT_HIGHLIGHT_STYLE,
466                divider: Span::raw(symbols::line::VERTICAL),
467                padding_right: Line::from(" "),
468                padding_left: Line::from(" "),
469            }
470        );
471    }
472
473    #[test]
474    fn default() {
475        assert_eq!(
476            Tabs::default(),
477            Tabs {
478                block: None,
479                titles: vec![],
480                selected: None,
481                style: Style::default(),
482                highlight_style: DEFAULT_HIGHLIGHT_STYLE,
483                divider: Span::raw(symbols::line::VERTICAL),
484                padding_right: Line::from(" "),
485                padding_left: Line::from(" "),
486            }
487        );
488    }
489
490    #[test]
491    fn select_into() {
492        let tabs = Tabs::new(vec!["Tab1", "Tab2", "Tab3", "Tab4"]);
493        assert_eq!(tabs.clone().select(2).selected, Some(2));
494        assert_eq!(tabs.clone().select(None).selected, None);
495        assert_eq!(tabs.clone().select(1u8 as usize).selected, Some(1));
496    }
497
498    #[test]
499    fn select_before_titles() {
500        let tabs = Tabs::default().select(1).titles(["Tab1", "Tab2"]);
501        assert_eq!(tabs.selected, Some(1));
502    }
503
504    #[test]
505    fn new_from_vec_of_str() {
506        Tabs::new(vec!["a", "b"]);
507    }
508
509    #[test]
510    fn collect() {
511        let tabs: Tabs = (0..5).map(|i| format!("Tab{i}")).collect();
512        assert_eq!(
513            tabs.titles,
514            vec![
515                Line::from("Tab0"),
516                Line::from("Tab1"),
517                Line::from("Tab2"),
518                Line::from("Tab3"),
519                Line::from("Tab4"),
520            ],
521        );
522    }
523
524    #[track_caller]
525    fn test_case(tabs: Tabs, area: Rect, expected: &Buffer) {
526        let mut buffer = Buffer::empty(area);
527        tabs.render(area, &mut buffer);
528        assert_eq!(&buffer, expected);
529    }
530
531    #[test]
532    fn render_new() {
533        let tabs = Tabs::new(vec!["Tab1", "Tab2", "Tab3", "Tab4"]);
534        let mut expected = Buffer::with_lines([" Tab1 │ Tab2 │ Tab3 │ Tab4    "]);
535        // first tab selected
536        expected.set_style(Rect::new(1, 0, 4, 1), DEFAULT_HIGHLIGHT_STYLE);
537        test_case(tabs, Rect::new(0, 0, 30, 1), &expected);
538    }
539
540    #[test]
541    fn render_no_padding() {
542        let tabs = Tabs::new(vec!["Tab1", "Tab2", "Tab3", "Tab4"]).padding("", "");
543        let mut expected = Buffer::with_lines(["Tab1│Tab2│Tab3│Tab4           "]);
544        // first tab selected
545        expected.set_style(Rect::new(0, 0, 4, 1), DEFAULT_HIGHLIGHT_STYLE);
546        test_case(tabs, Rect::new(0, 0, 30, 1), &expected);
547    }
548
549    #[test]
550    fn render_more_padding() {
551        let tabs = Tabs::new(vec!["Tab1", "Tab2", "Tab3", "Tab4"]).padding("---", "++");
552        let mut expected = Buffer::with_lines(["---Tab1++│---Tab2++│---Tab3++│"]);
553        // first tab selected
554        expected.set_style(Rect::new(3, 0, 4, 1), DEFAULT_HIGHLIGHT_STYLE);
555        test_case(tabs, Rect::new(0, 0, 30, 1), &expected);
556    }
557
558    #[test]
559    fn render_with_block() {
560        let tabs =
561            Tabs::new(vec!["Tab1", "Tab2", "Tab3", "Tab4"]).block(Block::bordered().title("Tabs"));
562        let mut expected = Buffer::with_lines([
563            "┌Tabs────────────────────────┐",
564            "│ Tab1 │ Tab2 │ Tab3 │ Tab4  │",
565            "└────────────────────────────┘",
566        ]);
567        // first tab selected
568        expected.set_style(Rect::new(2, 1, 4, 1), DEFAULT_HIGHLIGHT_STYLE);
569        test_case(tabs, Rect::new(0, 0, 30, 3), &expected);
570    }
571
572    #[test]
573    fn render_style() {
574        let tabs =
575            Tabs::new(vec!["Tab1", "Tab2", "Tab3", "Tab4"]).style(Style::default().fg(Color::Red));
576        let mut expected = Buffer::with_lines([" Tab1 │ Tab2 │ Tab3 │ Tab4    ".red()]);
577        expected.set_style(Rect::new(1, 0, 4, 1), DEFAULT_HIGHLIGHT_STYLE.red());
578        test_case(tabs, Rect::new(0, 0, 30, 1), &expected);
579    }
580
581    #[test]
582    fn render_select() {
583        let tabs = Tabs::new(vec!["Tab1", "Tab2", "Tab3", "Tab4"]);
584
585        // first tab selected
586        let expected = Buffer::with_lines([Line::from(vec![
587            " ".into(),
588            "Tab1".reversed(),
589            " │ Tab2 │ Tab3 │ Tab4    ".into(),
590        ])]);
591        test_case(tabs.clone().select(0), Rect::new(0, 0, 30, 1), &expected);
592
593        // second tab selected
594        let expected = Buffer::with_lines([Line::from(vec![
595            " Tab1 │ ".into(),
596            "Tab2".reversed(),
597            " │ Tab3 │ Tab4    ".into(),
598        ])]);
599        test_case(tabs.clone().select(1), Rect::new(0, 0, 30, 1), &expected);
600
601        // last tab selected
602        let expected = Buffer::with_lines([Line::from(vec![
603            " Tab1 │ Tab2 │ Tab3 │ ".into(),
604            "Tab4".reversed(),
605            "    ".into(),
606        ])]);
607        test_case(tabs.clone().select(3), Rect::new(0, 0, 30, 1), &expected);
608
609        // out of bounds selects no tab
610        let expected = Buffer::with_lines([" Tab1 │ Tab2 │ Tab3 │ Tab4    "]);
611        test_case(tabs.clone().select(4), Rect::new(0, 0, 30, 1), &expected);
612
613        // deselect
614        let expected = Buffer::with_lines([" Tab1 │ Tab2 │ Tab3 │ Tab4    "]);
615        test_case(tabs.clone().select(None), Rect::new(0, 0, 30, 1), &expected);
616    }
617
618    #[test]
619    fn render_style_and_selected() {
620        let tabs = Tabs::new(vec!["Tab1", "Tab2", "Tab3", "Tab4"])
621            .style(Style::new().red())
622            .highlight_style(Style::new().underlined())
623            .select(0);
624        let expected = Buffer::with_lines([Line::from(vec![
625            " ".red(),
626            "Tab1".red().underlined(),
627            " │ Tab2 │ Tab3 │ Tab4    ".red(),
628        ])]);
629        test_case(tabs, Rect::new(0, 0, 30, 1), &expected);
630    }
631
632    #[test]
633    fn render_divider() {
634        let tabs = Tabs::new(vec!["Tab1", "Tab2", "Tab3", "Tab4"]).divider("--");
635        let mut expected = Buffer::with_lines([" Tab1 -- Tab2 -- Tab3 -- Tab4 "]);
636        // first tab selected
637        expected.set_style(Rect::new(1, 0, 4, 1), DEFAULT_HIGHLIGHT_STYLE);
638        test_case(tabs, Rect::new(0, 0, 30, 1), &expected);
639    }
640
641    #[test]
642    fn can_be_stylized() {
643        assert_eq!(
644            Tabs::new(vec![""])
645                .black()
646                .on_white()
647                .bold()
648                .not_italic()
649                .style,
650            Style::default()
651                .fg(Color::Black)
652                .bg(Color::White)
653                .add_modifier(Modifier::BOLD)
654                .remove_modifier(Modifier::ITALIC)
655        );
656    }
657}