flex/
flex.rs

1//! # [Ratatui] Flex example
2//!
3//! The latest version of this example is available in the [examples] folder in the repository.
4//!
5//! Please note that the examples are designed to be run against the `main` branch of the Github
6//! repository. This means that you may not be able to compile with the latest release version on
7//! crates.io, or the one that you have installed locally.
8//!
9//! See the [examples readme] for more information on finding examples that match the version of the
10//! library you are using.
11//!
12//! [Ratatui]: https://github.com/ratatui/ratatui
13//! [examples]: https://github.com/ratatui/ratatui/blob/main/examples
14//! [examples readme]: https://github.com/ratatui/ratatui/blob/main/examples/README.md
15
16use std::num::NonZeroUsize;
17
18use color_eyre::Result;
19use ratatui::{
20    buffer::Buffer,
21    crossterm::event::{self, Event, KeyCode, KeyEventKind},
22    layout::{
23        Alignment,
24        Constraint::{self, Fill, Length, Max, Min, Percentage, Ratio},
25        Flex, Layout, Rect,
26    },
27    style::{palette::tailwind, Color, Modifier, Style, Stylize},
28    symbols::{self, line},
29    text::{Line, Text},
30    widgets::{
31        Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Tabs,
32        Widget,
33    },
34    DefaultTerminal,
35};
36use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
37
38fn main() -> Result<()> {
39    color_eyre::install()?;
40    let terminal = ratatui::init();
41    let app_result = App::default().run(terminal);
42    ratatui::restore();
43    app_result
44}
45
46const EXAMPLE_DATA: &[(&str, &[Constraint])] = &[
47    (
48        "Min(u16) takes any excess space always",
49        &[Length(10), Min(10), Max(10), Percentage(10), Ratio(1,10)],
50    ),
51    (
52        "Fill(u16) takes any excess space always",
53        &[Length(20), Percentage(20), Ratio(1, 5), Fill(1)],
54    ),
55    (
56        "Here's all constraints in one line",
57        &[Length(10), Min(10), Max(10), Percentage(10), Ratio(1,10), Fill(1)],
58    ),
59    (
60        "",
61        &[Max(50), Min(50)],
62    ),
63    (
64        "",
65        &[Max(20), Length(10)],
66    ),
67    (
68        "",
69        &[Max(20), Length(10)],
70    ),
71    (
72        "Min grows always but also allows Fill to grow",
73        &[Percentage(50), Fill(1), Fill(2), Min(50)],
74    ),
75    (
76        "In `Legacy`, the last constraint of lowest priority takes excess space",
77        &[Length(20), Length(20), Percentage(20)],
78    ),
79    ("", &[Length(20), Percentage(20), Length(20)]),
80    ("A lowest priority constraint will be broken before a high priority constraint", &[Ratio(1,4), Percentage(20)]),
81    ("`Length` is higher priority than `Percentage`", &[Percentage(20), Length(10)]),
82    ("`Min/Max` is higher priority than `Length`", &[Length(10), Max(20)]),
83    ("", &[Length(100), Min(20)]),
84    ("`Length` is higher priority than `Min/Max`", &[Max(20), Length(10)]),
85    ("", &[Min(20), Length(90)]),
86    ("Fill is the lowest priority and will fill any excess space", &[Fill(1), Ratio(1, 4)]),
87    ("Fill can be used to scale proportionally with other Fill blocks", &[Fill(1), Percentage(20), Fill(2)]),
88    ("", &[Ratio(1, 3), Percentage(20), Ratio(2, 3)]),
89    ("Legacy will stretch the last lowest priority constraint\nStretch will only stretch equal weighted constraints", &[Length(20), Length(15)]),
90    ("", &[Percentage(20), Length(15)]),
91    ("`Fill(u16)` fills up excess space, but is lower priority to spacers.\ni.e. Fill will only have widths in Flex::Stretch and Flex::Legacy", &[Fill(1), Fill(1)]),
92    ("", &[Length(20), Length(20)]),
93    (
94        "When not using `Flex::Stretch` or `Flex::Legacy`,\n`Min(u16)` and `Max(u16)` collapse to their lowest values",
95        &[Min(20), Max(20)],
96    ),
97    (
98        "",
99        &[Max(20)],
100    ),
101    ("", &[Min(20), Max(20), Length(20), Length(20)]),
102    ("", &[Fill(0), Fill(0)]),
103    (
104        "`Fill(1)` can be to scale with respect to other `Fill(2)`",
105        &[Fill(1), Fill(2)],
106    ),
107    (
108        "",
109        &[Fill(1), Min(10), Max(10), Fill(2)],
110    ),
111    (
112        "`Fill(0)` collapses if there are other non-zero `Fill(_)`\nconstraints. e.g. `[Fill(0), Fill(0), Fill(1)]`:",
113        &[
114            Fill(0),
115            Fill(0),
116            Fill(1),
117        ],
118    ),
119];
120
121#[derive(Default, Clone, Copy)]
122struct App {
123    selected_tab: SelectedTab,
124    scroll_offset: u16,
125    spacing: u16,
126    state: AppState,
127}
128
129#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
130enum AppState {
131    #[default]
132    Running,
133    Quit,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137struct Example {
138    constraints: Vec<Constraint>,
139    description: String,
140    flex: Flex,
141    spacing: u16,
142}
143
144/// Tabs for the different layouts
145///
146/// Note: the order of the variants will determine the order of the tabs this uses several derive
147/// macros from the `strum` crate to make it easier to iterate over the variants.
148/// (`FromRepr`,`Display`,`EnumIter`).
149#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, FromRepr, Display, EnumIter)]
150enum SelectedTab {
151    #[default]
152    Legacy,
153    Start,
154    Center,
155    End,
156    SpaceAround,
157    SpaceBetween,
158}
159
160impl App {
161    fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> {
162        // increase the layout cache to account for the number of layout events. This ensures that
163        // layout is not generally reprocessed on every frame (which would lead to possible janky
164        // results when there are more than one possible solution to the requested layout). This
165        // assumes the user changes spacing about a 100 times or so.
166        let cache_size = EXAMPLE_DATA.len() * SelectedTab::iter().len() * 100;
167        Layout::init_cache(NonZeroUsize::new(cache_size).unwrap());
168
169        while self.is_running() {
170            terminal.draw(|frame| frame.render_widget(self, frame.area()))?;
171            self.handle_events()?;
172        }
173        Ok(())
174    }
175
176    fn is_running(self) -> bool {
177        self.state == AppState::Running
178    }
179
180    fn handle_events(&mut self) -> Result<()> {
181        match event::read()? {
182            Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
183                KeyCode::Char('q') | KeyCode::Esc => self.quit(),
184                KeyCode::Char('l') | KeyCode::Right => self.next(),
185                KeyCode::Char('h') | KeyCode::Left => self.previous(),
186                KeyCode::Char('j') | KeyCode::Down => self.down(),
187                KeyCode::Char('k') | KeyCode::Up => self.up(),
188                KeyCode::Char('g') | KeyCode::Home => self.top(),
189                KeyCode::Char('G') | KeyCode::End => self.bottom(),
190                KeyCode::Char('+') => self.increment_spacing(),
191                KeyCode::Char('-') => self.decrement_spacing(),
192                _ => (),
193            },
194            _ => {}
195        }
196        Ok(())
197    }
198
199    fn next(&mut self) {
200        self.selected_tab = self.selected_tab.next();
201    }
202
203    fn previous(&mut self) {
204        self.selected_tab = self.selected_tab.previous();
205    }
206
207    fn up(&mut self) {
208        self.scroll_offset = self.scroll_offset.saturating_sub(1);
209    }
210
211    fn down(&mut self) {
212        self.scroll_offset = self
213            .scroll_offset
214            .saturating_add(1)
215            .min(max_scroll_offset());
216    }
217
218    fn top(&mut self) {
219        self.scroll_offset = 0;
220    }
221
222    fn bottom(&mut self) {
223        self.scroll_offset = max_scroll_offset();
224    }
225
226    fn increment_spacing(&mut self) {
227        self.spacing = self.spacing.saturating_add(1);
228    }
229
230    fn decrement_spacing(&mut self) {
231        self.spacing = self.spacing.saturating_sub(1);
232    }
233
234    fn quit(&mut self) {
235        self.state = AppState::Quit;
236    }
237}
238
239// when scrolling, make sure we don't scroll past the last example
240fn max_scroll_offset() -> u16 {
241    example_height()
242        - EXAMPLE_DATA
243            .last()
244            .map_or(0, |(desc, _)| get_description_height(desc) + 4)
245}
246
247/// The height of all examples combined
248///
249/// Each may or may not have a title so we need to account for that.
250fn example_height() -> u16 {
251    EXAMPLE_DATA
252        .iter()
253        .map(|(desc, _)| get_description_height(desc) + 4)
254        .sum()
255}
256
257impl Widget for App {
258    fn render(self, area: Rect, buf: &mut Buffer) {
259        let layout = Layout::vertical([Length(3), Length(1), Fill(0)]);
260        let [tabs, axis, demo] = layout.areas(area);
261        self.tabs().render(tabs, buf);
262        let scroll_needed = self.render_demo(demo, buf);
263        let axis_width = if scroll_needed {
264            axis.width.saturating_sub(1)
265        } else {
266            axis.width
267        };
268        Self::axis(axis_width, self.spacing).render(axis, buf);
269    }
270}
271
272impl App {
273    fn tabs(self) -> impl Widget {
274        let tab_titles = SelectedTab::iter().map(SelectedTab::to_tab_title);
275        let block = Block::new()
276            .title("Flex Layouts ".bold())
277            .title(" Use ◄ ► to change tab, ▲ ▼  to scroll, - + to change spacing ");
278        Tabs::new(tab_titles)
279            .block(block)
280            .highlight_style(Modifier::REVERSED)
281            .select(self.selected_tab as usize)
282            .divider(" ")
283            .padding("", "")
284    }
285
286    /// a bar like `<----- 80 px (gap: 2 px)? ----->`
287    fn axis(width: u16, spacing: u16) -> impl Widget {
288        let width = width as usize;
289        // only show gap when spacing is not zero
290        let label = if spacing != 0 {
291            format!("{width} px (gap: {spacing} px)")
292        } else {
293            format!("{width} px")
294        };
295        let bar_width = width.saturating_sub(2); // we want to `<` and `>` at the ends
296        let width_bar = format!("<{label:-^bar_width$}>");
297        Paragraph::new(width_bar.dark_gray()).centered()
298    }
299
300    /// Render the demo content
301    ///
302    /// This function renders the demo content into a separate buffer and then splices the buffer
303    /// into the main buffer. This is done to make it possible to handle scrolling easily.
304    ///
305    /// Returns bool indicating whether scroll was needed
306    #[allow(clippy::cast_possible_truncation)]
307    fn render_demo(self, area: Rect, buf: &mut Buffer) -> bool {
308        // render demo content into a separate buffer so all examples fit we add an extra
309        // area.height to make sure the last example is fully visible even when the scroll offset is
310        // at the max
311        let height = example_height();
312        let demo_area = Rect::new(0, 0, area.width, height);
313        let mut demo_buf = Buffer::empty(demo_area);
314
315        let scrollbar_needed = self.scroll_offset != 0 || height > area.height;
316        let content_area = if scrollbar_needed {
317            Rect {
318                width: demo_area.width - 1,
319                ..demo_area
320            }
321        } else {
322            demo_area
323        };
324
325        let mut spacing = self.spacing;
326        self.selected_tab
327            .render(content_area, &mut demo_buf, &mut spacing);
328
329        let visible_content = demo_buf
330            .content
331            .into_iter()
332            .skip((area.width * self.scroll_offset) as usize)
333            .take(area.area() as usize);
334        for (i, cell) in visible_content.enumerate() {
335            let x = i as u16 % area.width;
336            let y = i as u16 / area.width;
337            buf[(area.x + x, area.y + y)] = cell;
338        }
339
340        if scrollbar_needed {
341            let area = area.intersection(buf.area);
342            let mut state = ScrollbarState::new(max_scroll_offset() as usize)
343                .position(self.scroll_offset as usize);
344            Scrollbar::new(ScrollbarOrientation::VerticalRight).render(area, buf, &mut state);
345        }
346        scrollbar_needed
347    }
348}
349
350impl SelectedTab {
351    /// Get the previous tab, if there is no previous tab return the current tab.
352    fn previous(self) -> Self {
353        let current_index: usize = self as usize;
354        let previous_index = current_index.saturating_sub(1);
355        Self::from_repr(previous_index).unwrap_or(self)
356    }
357
358    /// Get the next tab, if there is no next tab return the current tab.
359    fn next(self) -> Self {
360        let current_index = self as usize;
361        let next_index = current_index.saturating_add(1);
362        Self::from_repr(next_index).unwrap_or(self)
363    }
364
365    /// Convert a `SelectedTab` into a `Line` to display it by the `Tabs` widget.
366    fn to_tab_title(value: Self) -> Line<'static> {
367        use tailwind::{INDIGO, ORANGE, SKY};
368        let text = value.to_string();
369        let color = match value {
370            Self::Legacy => ORANGE.c400,
371            Self::Start => SKY.c400,
372            Self::Center => SKY.c300,
373            Self::End => SKY.c200,
374            Self::SpaceAround => INDIGO.c400,
375            Self::SpaceBetween => INDIGO.c300,
376        };
377        format!(" {text} ").fg(color).bg(Color::Black).into()
378    }
379}
380
381impl StatefulWidget for SelectedTab {
382    type State = u16;
383    fn render(self, area: Rect, buf: &mut Buffer, spacing: &mut Self::State) {
384        let spacing = *spacing;
385        match self {
386            Self::Legacy => Self::render_examples(area, buf, Flex::Legacy, spacing),
387            Self::Start => Self::render_examples(area, buf, Flex::Start, spacing),
388            Self::Center => Self::render_examples(area, buf, Flex::Center, spacing),
389            Self::End => Self::render_examples(area, buf, Flex::End, spacing),
390            Self::SpaceAround => Self::render_examples(area, buf, Flex::SpaceAround, spacing),
391            Self::SpaceBetween => Self::render_examples(area, buf, Flex::SpaceBetween, spacing),
392        }
393    }
394}
395
396impl SelectedTab {
397    fn render_examples(area: Rect, buf: &mut Buffer, flex: Flex, spacing: u16) {
398        let heights = EXAMPLE_DATA
399            .iter()
400            .map(|(desc, _)| get_description_height(desc) + 4);
401        let areas = Layout::vertical(heights).flex(Flex::Start).split(area);
402        for (area, (description, constraints)) in areas.iter().zip(EXAMPLE_DATA.iter()) {
403            Example::new(constraints, description, flex, spacing).render(*area, buf);
404        }
405    }
406}
407
408impl Example {
409    fn new(constraints: &[Constraint], description: &str, flex: Flex, spacing: u16) -> Self {
410        Self {
411            constraints: constraints.into(),
412            description: description.into(),
413            flex,
414            spacing,
415        }
416    }
417}
418
419impl Widget for Example {
420    fn render(self, area: Rect, buf: &mut Buffer) {
421        let title_height = get_description_height(&self.description);
422        let layout = Layout::vertical([Length(title_height), Fill(0)]);
423        let [title, illustrations] = layout.areas(area);
424
425        let (blocks, spacers) = Layout::horizontal(&self.constraints)
426            .flex(self.flex)
427            .spacing(self.spacing)
428            .split_with_spacers(illustrations);
429
430        if !self.description.is_empty() {
431            Paragraph::new(
432                self.description
433                    .split('\n')
434                    .map(|s| format!("// {s}").italic().fg(tailwind::SLATE.c400))
435                    .map(Line::from)
436                    .collect::<Vec<Line>>(),
437            )
438            .render(title, buf);
439        }
440
441        for (block, constraint) in blocks.iter().zip(&self.constraints) {
442            Self::illustration(*constraint, block.width).render(*block, buf);
443        }
444
445        for spacer in spacers.iter() {
446            Self::render_spacer(*spacer, buf);
447        }
448    }
449}
450
451impl Example {
452    fn render_spacer(spacer: Rect, buf: &mut Buffer) {
453        if spacer.width > 1 {
454            let corners_only = symbols::border::Set {
455                top_left: line::NORMAL.top_left,
456                top_right: line::NORMAL.top_right,
457                bottom_left: line::NORMAL.bottom_left,
458                bottom_right: line::NORMAL.bottom_right,
459                vertical_left: " ",
460                vertical_right: " ",
461                horizontal_top: " ",
462                horizontal_bottom: " ",
463            };
464            Block::bordered()
465                .border_set(corners_only)
466                .border_style(Style::reset().dark_gray())
467                .render(spacer, buf);
468        } else {
469            Paragraph::new(Text::from(vec![
470                Line::from(""),
471                Line::from("│"),
472                Line::from("│"),
473                Line::from(""),
474            ]))
475            .style(Style::reset().dark_gray())
476            .render(spacer, buf);
477        }
478        let width = spacer.width;
479        let label = if width > 4 {
480            format!("{width} px")
481        } else if width > 2 {
482            format!("{width}")
483        } else {
484            String::new()
485        };
486        let text = Text::from(vec![
487            Line::raw(""),
488            Line::raw(""),
489            Line::styled(label, Style::reset().dark_gray()),
490        ]);
491        Paragraph::new(text)
492            .style(Style::reset().dark_gray())
493            .alignment(Alignment::Center)
494            .render(spacer, buf);
495    }
496
497    fn illustration(constraint: Constraint, width: u16) -> impl Widget {
498        let main_color = color_for_constraint(constraint);
499        let fg_color = Color::White;
500        let title = format!("{constraint}");
501        let content = format!("{width} px");
502        let text = format!("{title}\n{content}");
503        let block = Block::bordered()
504            .border_set(symbols::border::QUADRANT_OUTSIDE)
505            .border_style(Style::reset().fg(main_color).reversed())
506            .style(Style::default().fg(fg_color).bg(main_color));
507        Paragraph::new(text).centered().block(block)
508    }
509}
510
511const fn color_for_constraint(constraint: Constraint) -> Color {
512    use tailwind::{BLUE, SLATE};
513    match constraint {
514        Constraint::Min(_) => BLUE.c900,
515        Constraint::Max(_) => BLUE.c800,
516        Constraint::Length(_) => SLATE.c700,
517        Constraint::Percentage(_) => SLATE.c800,
518        Constraint::Ratio(_, _) => SLATE.c900,
519        Constraint::Fill(_) => SLATE.c950,
520    }
521}
522
523#[allow(clippy::cast_possible_truncation)]
524fn get_description_height(s: &str) -> u16 {
525    if s.is_empty() {
526        0
527    } else {
528        s.split('\n').count() as u16
529    }
530}