1use 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#[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 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
239fn max_scroll_offset() -> u16 {
241 example_height()
242 - EXAMPLE_DATA
243 .last()
244 .map_or(0, |(desc, _)| get_description_height(desc) + 4)
245}
246
247fn 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 fn axis(width: u16, spacing: u16) -> impl Widget {
288 let width = width as usize;
289 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); let width_bar = format!("<{label:-^bar_width$}>");
297 Paragraph::new(width_bar.dark_gray()).centered()
298 }
299
300 #[allow(clippy::cast_possible_truncation)]
307 fn render_demo(self, area: Rect, buf: &mut Buffer) -> bool {
308 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 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 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 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}