rat_widget/
tabbed.rs

1//! Render tabs.
2//!
3//! This widget doesn't render the content.
4//! Use [TabbedState::widget_area] to render the selected tab.
5//!
6//! ```
7//! use ratatui::buffer::Buffer;
8//! use ratatui::layout::Rect;
9//! use ratatui::text::Text;
10//! use ratatui::widgets::{Block, StatefulWidget, Widget};
11//! use rat_widget::tabbed::{TabPlacement, TabType, Tabbed, TabbedState};
12//! # struct State { tabbed: TabbedState }
13//! # let mut state = State { tabbed: Default::default() };
14//! # let mut buf = Buffer::default();
15//! # let buf = &mut buf;
16//! # let area = Rect::default();
17//!
18//! let mut tab = Tabbed::new()
19//!     .tab_type(TabType::Attached)
20//!     .placement(TabPlacement::Top)
21//!     .closeable(true)
22//!     .block(
23//!          Block::bordered()
24//!     )
25//!     .tabs(["Issues", "Numbers", "More numbers"])
26//!     .render(area, buf, &mut state.tabbed);
27//!
28//! match state.tabbed.selected() {
29//!     Some(0) => {
30//!         Text::from("... issues ...")
31//!             .render(state.tabbed.widget_area, buf);
32//!     }
33//!     Some(1) => {
34//!         Text::from("... 1,2,3,4 ...")
35//!             .render(state.tabbed.widget_area, buf);
36//!     }
37//!     Some(1) => {
38//!         Text::from("... 5,6,7,8 ...")
39//!             .render(state.tabbed.widget_area, buf);
40//!     }
41//!     _ => {}
42//! }
43//!
44//! ```
45//!
46
47use crate::_private::NonExhaustive;
48use crate::event::TabbedOutcome;
49use crate::tabbed::attached::AttachedTabs;
50use crate::tabbed::glued::GluedTabs;
51use crate::util::union_all_non_empty;
52use rat_event::util::MouseFlagsN;
53use rat_event::{HandleEvent, MouseOnly, Regular, ct_event, event_flow};
54use rat_focus::{FocusBuilder, FocusFlag, HasFocus, Navigation};
55use rat_reloc::{RelocatableState, relocate_area, relocate_areas};
56use ratatui::buffer::Buffer;
57use ratatui::layout::Rect;
58use ratatui::style::Style;
59use ratatui::text::Line;
60use ratatui::widgets::{Block, StatefulWidget};
61use std::cmp::min;
62use std::fmt::Debug;
63use std::rc::Rc;
64
65mod attached;
66pub(crate) mod event;
67mod glued;
68
69/// Placement relative to the Rect given to render.
70///
71/// The popup-menu is always rendered outside the box,
72/// and this gives the relative placement.
73#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
74pub enum TabPlacement {
75    /// On top of the given area. Placed slightly left, so that
76    /// the menu text aligns with the left border.
77    #[default]
78    Top,
79    /// Placed left-top of the given area.
80    /// For a submenu opening to the left.
81    Left,
82    /// Placed right-top of the given area.
83    /// For a submenu opening to the right.
84    Right,
85    /// Below the bottom of the given area. Placed slightly left,
86    /// so that the menu text aligns with the left border.
87    Bottom,
88}
89
90/// Rendering style for the tabs.
91#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
92#[non_exhaustive]
93pub enum TabType {
94    /// Basic tabs glued to the outside of the widget.
95    Glued,
96
97    /// Embedded tabs in the Block.
98    ///
99    /// If no block has been set, this will draw a block at the side
100    /// of the tabs.
101    ///
102    /// On the left/right side this will just draw a link to the tab-text.
103    /// On the top/bottom side the tabs will be embedded in the border.
104    #[default]
105    Attached,
106}
107
108/// A tabbed widget.
109///
110/// This widget draws the tabs and handles events.
111///
112/// Use [TabbedState::selected] and [TabbedState::widget_area] to render
113/// the actual content of the tab.
114///
115#[derive(Debug, Default, Clone)]
116pub struct Tabbed<'a> {
117    tab_type: TabType,
118    placement: TabPlacement,
119    closeable: bool,
120    tabs: Vec<Line<'a>>,
121
122    style: Style,
123    block: Option<Block<'a>>,
124    tab_style: Option<Style>,
125    hover_style: Option<Style>,
126    select_style: Option<Style>,
127    focus_style: Option<Style>,
128}
129
130/// Widget for the Layout of the tabs.
131#[derive(Debug, Clone)]
132pub struct LayoutWidget<'a> {
133    tab: Rc<Tabbed<'a>>,
134}
135
136/// Primary widget for rendering the Tabbed.
137#[derive(Debug, Clone)]
138pub struct TabbedWidget<'a> {
139    tab: Rc<Tabbed<'a>>,
140}
141
142/// Combined Styles
143#[derive(Debug, Clone)]
144pub struct TabbedStyle {
145    pub style: Style,
146    pub block: Option<Block<'static>>,
147    pub border_style: Option<Style>,
148    pub title_style: Option<Style>,
149    pub tab: Option<Style>,
150    pub hover: Option<Style>,
151    pub select: Option<Style>,
152    pub focus: Option<Style>,
153
154    pub tab_type: Option<TabType>,
155    pub placement: Option<TabPlacement>,
156
157    pub non_exhaustive: NonExhaustive,
158}
159
160/// State & event-handling.
161#[derive(Debug)]
162pub struct TabbedState {
163    /// Total area.
164    /// __readonly__. renewed for each render.
165    pub area: Rect,
166    /// Area for drawing the Block inside the tabs.
167    /// __readonly__. renewed for each render.
168    pub block_area: Rect,
169    /// Area used to render the content of the tab.
170    /// Use this area to render the current tab content.
171    /// __readonly__. renewed for each render.
172    pub widget_area: Rect,
173
174    /// Total area reserved for tabs.
175    /// __readonly__. renewed for each render.
176    pub tab_title_area: Rect,
177    /// Area of each tab.
178    /// __readonly__. renewed for each render.
179    pub tab_title_areas: Vec<Rect>,
180    /// Area for 'Close Tab' interaction.
181    /// __readonly__. renewed for each render.
182    pub tab_title_close_areas: Vec<Rect>,
183
184    /// Selected Tab, only ever is None if there are no tabs.
185    /// Otherwise, set to 0 on render.
186    /// __read+write___
187    pub selected: Option<usize>,
188
189    /// Focus
190    /// __read+write__
191    pub focus: FocusFlag,
192    /// Mouse flags
193    /// __read+write__
194    pub mouse: MouseFlagsN,
195
196    /// Rendering is split into base-widget and menu-popup.
197    /// Relocate after rendering the popup.
198    relocate_popup: bool,
199
200    pub non_exhaustive: NonExhaustive,
201}
202
203impl<'a> Tabbed<'a> {
204    pub fn new() -> Self {
205        Self::default()
206    }
207
208    /// Tab type.
209    pub fn tab_type(mut self, tab_type: TabType) -> Self {
210        self.tab_type = tab_type;
211        self
212    }
213
214    /// Tab placement.
215    pub fn placement(mut self, placement: TabPlacement) -> Self {
216        self.placement = placement;
217        self
218    }
219
220    /// Tab-text.
221    pub fn tabs(mut self, tabs: impl IntoIterator<Item = impl Into<Line<'a>>>) -> Self {
222        self.tabs = tabs.into_iter().map(|v| v.into()).collect::<Vec<_>>();
223        self
224    }
225
226    /// Closeable tabs?
227    ///
228    /// Renders a close symbol and reacts with [TabbedOutcome::Close].
229    pub fn closeable(mut self, closeable: bool) -> Self {
230        self.closeable = closeable;
231        self
232    }
233
234    /// Block
235    pub fn block(mut self, block: Block<'a>) -> Self {
236        self.block = Some(block);
237        self
238    }
239
240    /// Set combined styles.
241    pub fn styles(mut self, styles: TabbedStyle) -> Self {
242        self.style = styles.style;
243        if styles.block.is_some() {
244            self.block = styles.block;
245        }
246        if let Some(border_style) = styles.border_style {
247            self.block = self.block.map(|v| v.border_style(border_style));
248        }
249        if let Some(title_style) = styles.title_style {
250            self.block = self.block.map(|v| v.title_style(title_style));
251        }
252        self.block = self.block.map(|v| v.style(self.style));
253
254        if styles.tab.is_some() {
255            self.tab_style = styles.tab;
256        }
257        if styles.select.is_some() {
258            self.select_style = styles.select;
259        }
260        if styles.hover.is_some() {
261            self.hover_style = styles.hover;
262        }
263        if styles.focus.is_some() {
264            self.focus_style = styles.focus;
265        }
266        if let Some(tab_type) = styles.tab_type {
267            self.tab_type = tab_type;
268        }
269        if let Some(placement) = styles.placement {
270            self.placement = placement
271        }
272        self
273    }
274
275    /// Base style. Mostly for any background.
276    pub fn style(mut self, style: Style) -> Self {
277        self.style = style;
278        self.block = self.block.map(|v| v.style(style));
279        self
280    }
281
282    /// Style for the tab-text.
283    pub fn tab_style(mut self, style: Style) -> Self {
284        self.tab_style = Some(style);
285        self
286    }
287
288    /// Style for hover.
289    pub fn hover_style(mut self, style: Style) -> Self {
290        self.hover_style = Some(style);
291        self
292    }
293
294    /// Style for the selected tab.
295    pub fn select_style(mut self, style: Style) -> Self {
296        self.select_style = Some(style);
297        self
298    }
299
300    /// Style for a focused tab.
301    pub fn focus_style(mut self, style: Style) -> Self {
302        self.focus_style = Some(style);
303        self
304    }
305
306    /// Constructs the widgets for rendering.
307    ///
308    /// Returns the LayoutWidget that must run first. It
309    /// doesn't actually render anything, it just calculates
310    /// the layout for the tab regions.
311    ///
312    /// Use [TabbedState::widget_area] to render the selected tab.
313    ///
314    /// The TabbedWidget actually renders the tabs.
315    /// Render it after you finished with the content.
316    pub fn into_widgets(self) -> (LayoutWidget<'a>, TabbedWidget<'a>) {
317        let rc = Rc::new(self);
318        (
319            LayoutWidget {
320                tab: rc.clone(), //
321            },
322            TabbedWidget {
323                tab:rc, //
324            },
325        )
326    }
327}
328
329impl Default for TabbedStyle {
330    fn default() -> Self {
331        Self {
332            style: Default::default(),
333            tab: None,
334            hover: None,
335            select: None,
336            focus: None,
337            tab_type: None,
338            placement: None,
339            block: None,
340            border_style: None,
341            title_style: None,
342            non_exhaustive: NonExhaustive,
343        }
344    }
345}
346
347impl StatefulWidget for &Tabbed<'_> {
348    type State = TabbedState;
349
350    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
351        layout(self, area, state);
352        render(self, buf, state);
353        state.relocate_popup = false;
354    }
355}
356
357impl StatefulWidget for Tabbed<'_> {
358    type State = TabbedState;
359
360    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
361        layout(&self, area, state);
362        render(&self, buf, state);
363        state.relocate_popup = false;
364    }
365}
366
367impl<'a> StatefulWidget for &LayoutWidget<'a> {
368    type State = TabbedState;
369
370    fn render(self, area: Rect, _buf: &mut Buffer, state: &mut Self::State) {
371        layout(self.tab.as_ref(), area, state);
372    }
373}
374
375impl<'a> StatefulWidget for LayoutWidget<'a> {
376    type State = TabbedState;
377
378    fn render(self, area: Rect, _buf: &mut Buffer, state: &mut Self::State) {
379        layout(self.tab.as_ref(), area, state);
380    }
381}
382
383fn layout(tabbed: &Tabbed<'_>, area: Rect, state: &mut TabbedState) {
384    state.relocate_popup = true;
385    if tabbed.tabs.is_empty() {
386        state.selected = None;
387    } else {
388        if state.selected.is_none() {
389            state.selected = Some(0);
390        }
391    }
392
393    match tabbed.tab_type {
394        TabType::Glued => {
395            GluedTabs.layout(area, tabbed, state);
396        }
397        TabType::Attached => {
398            AttachedTabs.layout(area, tabbed, state);
399        }
400    }
401}
402
403impl<'a> StatefulWidget for &TabbedWidget<'a> {
404    type State = TabbedState;
405
406    fn render(self, _area: Rect, buf: &mut Buffer, state: &mut Self::State) {
407        render(self.tab.as_ref(), buf, state);
408    }
409}
410
411impl<'a> StatefulWidget for TabbedWidget<'a> {
412    type State = TabbedState;
413
414    fn render(self, _area: Rect, buf: &mut Buffer, state: &mut Self::State) {
415        render(self.tab.as_ref(), buf, state);
416    }
417}
418
419fn render(tabbed: &Tabbed<'_>, buf: &mut Buffer, state: &mut TabbedState) {
420    if tabbed.tabs.is_empty() {
421        state.selected = None;
422    } else {
423        if state.selected.is_none() {
424            state.selected = Some(0);
425        }
426    }
427
428    match tabbed.tab_type {
429        TabType::Glued => {
430            GluedTabs.render(buf, tabbed, state);
431        }
432        TabType::Attached => {
433            AttachedTabs.render(buf, tabbed, state);
434        }
435    }
436}
437
438impl Default for TabbedState {
439    fn default() -> Self {
440        Self {
441            area: Default::default(),
442            block_area: Default::default(),
443            widget_area: Default::default(),
444            tab_title_area: Default::default(),
445            tab_title_areas: Default::default(),
446            tab_title_close_areas: Default::default(),
447            selected: Default::default(),
448            focus: Default::default(),
449            mouse: Default::default(),
450            relocate_popup: Default::default(),
451            non_exhaustive: NonExhaustive,
452        }
453    }
454}
455
456impl Clone for TabbedState {
457    fn clone(&self) -> Self {
458        Self {
459            area: self.area,
460            block_area: self.block_area,
461            widget_area: self.widget_area,
462            tab_title_area: self.tab_title_area,
463            tab_title_areas: self.tab_title_areas.clone(),
464            tab_title_close_areas: self.tab_title_close_areas.clone(),
465            selected: self.selected,
466            focus: self.focus.new_instance(),
467            mouse: Default::default(),
468            relocate_popup: self.relocate_popup,
469            non_exhaustive: NonExhaustive,
470        }
471    }
472}
473
474impl HasFocus for TabbedState {
475    fn build(&self, builder: &mut FocusBuilder) {
476        builder.leaf_widget(self);
477    }
478
479    fn build_nav(&self, navigable: Navigation, builder: &mut FocusBuilder) {
480        if !matches!(navigable, Navigation::None | Navigation::Leave) {
481            builder.widget_with_flags(
482                self.focus(),
483                union_all_non_empty(&self.tab_title_areas),
484                self.area_z(),
485                navigable,
486            );
487        } else {
488            self.build(builder);
489        }
490    }
491
492    fn focus(&self) -> FocusFlag {
493        self.focus.clone()
494    }
495
496    fn area(&self) -> Rect {
497        Rect::default()
498    }
499
500    fn navigable(&self) -> Navigation {
501        Navigation::Leave
502    }
503}
504
505impl RelocatableState for TabbedState {
506    fn relocate(&mut self, shift: (i16, i16), clip: Rect) {
507        if !self.relocate_popup {
508            self.area = relocate_area(self.area, shift, clip);
509            self.block_area = relocate_area(self.block_area, shift, clip);
510            self.widget_area = relocate_area(self.widget_area, shift, clip);
511            self.tab_title_area = relocate_area(self.tab_title_area, shift, clip);
512            relocate_areas(self.tab_title_areas.as_mut(), shift, clip);
513            relocate_areas(self.tab_title_close_areas.as_mut(), shift, clip);
514        }
515    }
516
517    fn relocate_popup(&mut self, shift: (i16, i16), clip: Rect) {
518        if self.relocate_popup {
519            self.relocate_popup = false;
520            self.area = relocate_area(self.area, shift, clip);
521            self.block_area = relocate_area(self.block_area, shift, clip);
522            self.widget_area = relocate_area(self.widget_area, shift, clip);
523            self.tab_title_area = relocate_area(self.tab_title_area, shift, clip);
524            relocate_areas(self.tab_title_areas.as_mut(), shift, clip);
525            relocate_areas(self.tab_title_close_areas.as_mut(), shift, clip);
526        }
527    }
528}
529
530impl TabbedState {
531    /// New initial state.
532    pub fn new() -> Self {
533        Default::default()
534    }
535
536    /// State with a focus name.
537    pub fn named(name: &str) -> Self {
538        let mut z = Self::default();
539        z.focus = z.focus.with_name(name);
540        z
541    }
542
543    pub fn selected(&self) -> Option<usize> {
544        self.selected
545    }
546
547    pub fn select(&mut self, selected: Option<usize>) {
548        self.selected = selected;
549    }
550
551    /// Selects the next tab. Stops at the end.
552    pub fn next_tab(&mut self) -> bool {
553        let old_selected = self.selected;
554
555        if let Some(selected) = self.selected() {
556            self.selected = Some(min(
557                selected + 1,
558                self.tab_title_areas.len().saturating_sub(1),
559            ));
560        }
561
562        old_selected != self.selected
563    }
564
565    /// Selects the previous tab. Stops at the end.
566    pub fn prev_tab(&mut self) -> bool {
567        let old_selected = self.selected;
568
569        if let Some(selected) = self.selected() {
570            if selected > 0 {
571                self.selected = Some(selected - 1);
572            }
573        }
574
575        old_selected != self.selected
576    }
577}
578
579/// Handle the regular events for Tabbed.
580impl HandleEvent<crossterm::event::Event, Regular, TabbedOutcome> for TabbedState {
581    fn handle(&mut self, event: &crossterm::event::Event, _qualifier: Regular) -> TabbedOutcome {
582        if self.is_focused() {
583            event_flow!(
584                return match event {
585                    ct_event!(keycode press Left) => self.prev_tab().into(),
586                    ct_event!(keycode press Right) => self.next_tab().into(),
587                    ct_event!(keycode press Up) => self.prev_tab().into(),
588                    ct_event!(keycode press Down) => self.next_tab().into(),
589                    _ => TabbedOutcome::Continue,
590                }
591            );
592        }
593
594        self.handle(event, MouseOnly)
595    }
596}
597
598impl HandleEvent<crossterm::event::Event, MouseOnly, TabbedOutcome> for TabbedState {
599    fn handle(&mut self, event: &crossterm::event::Event, _qualifier: MouseOnly) -> TabbedOutcome {
600        match event {
601            ct_event!(mouse any for e) if self.mouse.hover(&self.tab_title_close_areas, e) => {
602                TabbedOutcome::Changed
603            }
604            ct_event!(mouse any for e) if self.mouse.drag(&[self.tab_title_area], e) => {
605                if let Some(n) = self.mouse.item_at(&self.tab_title_areas, e.column, e.row) {
606                    self.select(Some(n));
607                    TabbedOutcome::Select(n)
608                } else {
609                    TabbedOutcome::Unchanged
610                }
611            }
612            ct_event!(mouse down Left for x, y)
613                if self.tab_title_area.contains((*x, *y).into()) =>
614            {
615                if let Some(sel) = self.mouse.item_at(&self.tab_title_close_areas, *x, *y) {
616                    TabbedOutcome::Close(sel)
617                } else if let Some(sel) = self.mouse.item_at(&self.tab_title_areas, *x, *y) {
618                    self.select(Some(sel));
619                    TabbedOutcome::Select(sel)
620                } else {
621                    TabbedOutcome::Continue
622                }
623            }
624
625            _ => TabbedOutcome::Continue,
626        }
627    }
628}
629
630/// The design space for tabs is too big to capture with a handful of parameters.
631///
632/// This trait splits off the layout and rendering of the actual tabs from
633/// the general properties and behaviour of tabs.
634trait TabWidget: Debug {
635    /// Calculate the layout for the tabs.
636    fn layout(
637        &self, //
638        area: Rect,
639        tabbed: &Tabbed<'_>,
640        state: &mut TabbedState,
641    );
642
643    /// Render the tabs.
644    fn render(
645        &self, //
646        buf: &mut Buffer,
647        tabbed: &Tabbed<'_>,
648        state: &mut TabbedState,
649    );
650}
651
652/// Handle all events.
653/// Text events are only processed if focus is true.
654/// Mouse events are processed if they are in range.
655pub fn handle_events(
656    state: &mut TabbedState,
657    focus: bool,
658    event: &crossterm::event::Event,
659) -> TabbedOutcome {
660    state.focus.set(focus);
661    HandleEvent::handle(state, event, Regular)
662}
663
664/// Handle only mouse-events.
665pub fn handle_mouse_events(
666    state: &mut TabbedState,
667    event: &crossterm::event::Event,
668) -> TabbedOutcome {
669    HandleEvent::handle(state, event, MouseOnly)
670}