From 9ec43eff1c7a62631fab99e4874ccd15fe7b210a Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy Date: Wed, 31 Jan 2024 00:12:29 -0500 Subject: [PATCH] feat: Constraint Explorer example (#893) Here's a constraint explorer demo put together with @joshka https://github.com/ratatui-org/ratatui/assets/1813121/08d7d8f6-d013-44b4-8331-f4eee3589cce It allows users to interactive explore how the constraints behave with respect to each other and compare that across flex modes. It allows users to swap constraints out for other constraints, increment or decrement the values, add and remove constraints, and add spacing It is also a good example for how to structure a simple TUI with several Ratatui code patterns that are useful for refactoring. Fixes: https://github.com/ratatui-org/ratatui/issues/792 --------- Co-authored-by: Josh McKinney --- Cargo.toml | 5 + examples/constraint-explorer.rs | 577 ++++++++++++++++++++++++++++++++ 2 files changed, 582 insertions(+) create mode 100644 examples/constraint-explorer.rs diff --git a/Cargo.toml b/Cargo.toml index b7267059..a0c35c1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -219,6 +219,11 @@ name = "flex" required-features = ["crossterm"] doc-scrape-examples = true +[[example]] +name = "constraint-explorer" +required-features = ["crossterm"] +doc-scrape-examples = true + [[example]] name = "list" required-features = ["crossterm"] diff --git a/examples/constraint-explorer.rs b/examples/constraint-explorer.rs new file mode 100644 index 00000000..a336cc35 --- /dev/null +++ b/examples/constraint-explorer.rs @@ -0,0 +1,577 @@ +//! # [Ratatui] Constraint explorer example +//! +//! The latest version of this example is available in the [examples] folder in the repository. +//! +//! Please note that the examples are designed to be run against the `main` branch of the Github +//! repository. This means that you may not be able to compile with the latest release version on +//! crates.io, or the one that you have installed locally. +//! +//! See the [examples readme] for more information on finding examples that match the version of the +//! library you are using. +//! +//! [Ratatui]: https://github.com/ratatui-org/ratatui +//! [examples]: https://github.com/ratatui-org/ratatui/blob/main/examples +//! [examples readme]: https://github.com/ratatui-org/ratatui/blob/main/examples/README.md + +use std::io::{self, stdout}; + +use color_eyre::{config::HookBuilder, Result}; +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use itertools::Itertools; +use ratatui::{ + layout::{Constraint::*, Flex}, + prelude::*, + style::palette::tailwind::*, + symbols::line, + widgets::*, +}; +use strum::{Display, EnumIter, FromRepr}; + +#[derive(Default)] +struct App { + mode: AppMode, + spacing: u16, + constraints: Vec, + selected_index: usize, + value: u16, +} + +#[derive(Debug, Default, PartialEq, Eq)] +enum AppMode { + #[default] + Running, + Quit, +} + +/// A variant of [`Constraint`] that can be rendered as a tab. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, EnumIter, FromRepr, Display)] +enum ConstraintName { + #[default] + Length, + Percentage, + Ratio, + Min, + Max, + Fill, +} + +/// A widget that renders a [`Constraint`] as a block. E.g.: +/// ```plain +/// ┌──────────────┐ +/// │ Length(16) │ +/// │ 16px │ +/// └──────────────┘ +/// ``` +struct ConstraintBlock { + selected: bool, + legend: bool, + constraint: Constraint, +} + +/// A widget that renders a spacer with a label indicating the width of the spacer. E.g.: +/// +/// ```plain +/// ┌ ┐ +/// 8 px +/// └ ┘ +/// ``` +struct SpacerBlock; + +fn main() -> Result<()> { + init_error_hooks()?; + let terminal = init_terminal()?; + App::default().run(terminal)?; + restore_terminal()?; + Ok(()) +} + +// App behaviour +impl App { + fn run(&mut self, mut terminal: Terminal) -> Result<()> { + self.insert_test_defaults(); + + while self.is_running() { + self.draw(&mut terminal)?; + self.handle_events()?; + } + Ok(()) + } + + // TODO remove these - these are just for testing + fn insert_test_defaults(&mut self) { + self.constraints = vec![ + Constraint::Length(20), + Constraint::Length(20), + Constraint::Length(20), + ]; + self.value = 20; + } + + fn is_running(&self) -> bool { + self.mode == AppMode::Running + } + + fn draw(&self, terminal: &mut Terminal) -> io::Result<()> { + terminal.draw(|frame| frame.render_widget(self, frame.size()))?; + Ok(()) + } + + fn handle_events(&mut self) -> Result<()> { + use KeyCode::*; + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => match key.code { + Char('q') | Esc => self.exit(), + Char('1') => self.swap_constraint(ConstraintName::Min), + Char('2') => self.swap_constraint(ConstraintName::Max), + Char('3') => self.swap_constraint(ConstraintName::Length), + Char('4') => self.swap_constraint(ConstraintName::Percentage), + Char('5') => self.swap_constraint(ConstraintName::Ratio), + Char('6') => self.swap_constraint(ConstraintName::Fill), + Char('+') => self.increment_spacing(), + Char('-') => self.decrement_spacing(), + Char('x') => self.delete_block(), + Char('a') => self.insert_block(), + Char('k') | Up => self.increment_value(), + Char('j') | Down => self.decrement_value(), + Char('h') | Left => self.prev_block(), + Char('l') | Right => self.next_block(), + _ => {} + }, + _ => {} + } + Ok(()) + } + + /// select the next block with wrap around + fn increment_value(&mut self) { + if self.constraints.is_empty() { + return; + } + self.constraints[self.selected_index] = match self.constraints[self.selected_index] { + Constraint::Length(v) => Constraint::Length(v.saturating_add(1)), + Constraint::Min(v) => Constraint::Min(v.saturating_add(1)), + Constraint::Max(v) => Constraint::Max(v.saturating_add(1)), + Constraint::Fill(v) => Constraint::Fill(v.saturating_add(1)), + Constraint::Percentage(v) => Constraint::Percentage(v.saturating_add(1)), + Constraint::Ratio(n, d) => Constraint::Ratio(n, d.saturating_add(1)), + }; + } + + fn decrement_value(&mut self) { + if self.constraints.is_empty() { + return; + } + self.constraints[self.selected_index] = match self.constraints[self.selected_index] { + Constraint::Length(v) => Constraint::Length(v.saturating_sub(1)), + Constraint::Min(v) => Constraint::Min(v.saturating_sub(1)), + Constraint::Max(v) => Constraint::Max(v.saturating_sub(1)), + Constraint::Fill(v) => Constraint::Fill(v.saturating_sub(1)), + Constraint::Percentage(v) => Constraint::Percentage(v.saturating_sub(1)), + Constraint::Ratio(n, d) => Constraint::Ratio(n, d.saturating_sub(1)), + }; + } + + /// select the next block with wrap around + fn next_block(&mut self) { + if self.constraints.is_empty() { + return; + } + let len = self.constraints.len(); + self.selected_index = (self.selected_index + 1) % len; + } + + /// select the previous block with wrap around + fn prev_block(&mut self) { + if self.constraints.is_empty() { + return; + } + let len = self.constraints.len(); + self.selected_index = (self.selected_index + self.constraints.len() - 1) % len; + } + + /// delete the selected block + fn delete_block(&mut self) { + if self.constraints.is_empty() { + return; + } + self.constraints.remove(self.selected_index); + self.selected_index = self.selected_index.saturating_sub(1); + } + + /// insert a block after the selected block + fn insert_block(&mut self) { + let index = self + .selected_index + .saturating_add(1) + .min(self.constraints.len()); + let constraint = Constraint::Length(self.value); + self.constraints.insert(index, constraint); + self.selected_index = index; + } + + fn increment_spacing(&mut self) { + self.spacing = self.spacing.saturating_add(1); + } + + fn decrement_spacing(&mut self) { + self.spacing = self.spacing.saturating_sub(1); + } + + // exits edit mode or the app + fn exit(&mut self) { + self.mode = AppMode::Quit + } + + fn swap_constraint(&mut self, name: ConstraintName) { + if self.constraints.is_empty() { + return; + } + // save the editor state + let constraint = match name { + ConstraintName::Length => Length(self.value), + ConstraintName::Percentage => Percentage(self.value), + ConstraintName::Min => Min(self.value), + ConstraintName::Max => Max(self.value), + ConstraintName::Fill => Fill(self.value), + ConstraintName::Ratio => Ratio(1, self.value as u32 / 4), // for balance + }; + self.constraints[self.selected_index] = constraint; + } +} + +impl From for ConstraintName { + fn from(constraint: Constraint) -> Self { + use Constraint::*; + match constraint { + Length(_) => ConstraintName::Length, + Percentage(_) => ConstraintName::Percentage, + Ratio(_, _) => ConstraintName::Ratio, + Min(_) => ConstraintName::Min, + Max(_) => ConstraintName::Max, + Fill(_) => ConstraintName::Fill, + } + } +} + +impl Widget for &App { + fn render(self, area: Rect, buf: &mut Buffer) { + let [header_area, instructions_area, legend_area, _, blocks_area] = + area.split(&Layout::vertical([ + Length(2), // header + Length(2), // instructions + Length(1), // legend + Length(1), // gap + Fill(1), + ])); + + self.header().render(header_area, buf); + self.instructions().render(instructions_area, buf); + self.legend().render(legend_area, buf); + self.render_layout_blocks(blocks_area, buf); + } +} + +// App rendering +impl App { + const HEADER_COLOR: Color = SLATE.c200; + const TEXT_COLOR: Color = SLATE.c400; + const AXIS_COLOR: Color = SLATE.c500; + + fn header(&self) -> impl Widget { + let text = "Constraint Explorer"; + text.bold().fg(Self::HEADER_COLOR).to_centered_line() + } + + fn instructions(&self) -> impl Widget { + let text = "◄ ►: select, ▲ ▼: edit, 1-6: swap, a: add, x: delete, q: quit, + -: spacing"; + Paragraph::new(text) + .fg(Self::TEXT_COLOR) + .centered() + .wrap(Wrap { trim: false }) + } + + fn legend(&self) -> impl Widget { + #[allow(unstable_name_collisions)] + Paragraph::new( + Line::from( + [ + ConstraintName::Min, + ConstraintName::Max, + ConstraintName::Length, + ConstraintName::Percentage, + ConstraintName::Ratio, + ConstraintName::Fill, + ] + .iter() + .enumerate() + .map(|(i, name)| { + format!(" {i}: {name} ", i = i + 1) + .fg(SLATE.c200) + .bg(name.color()) + }) + .intersperse(Span::from(" ")) + .collect_vec(), + ) + .centered(), + ) + .wrap(Wrap { trim: false }) + } + + /// A bar like `<----- 80 px (gap: 2 px) ----->` + /// + /// Only shows the gap when spacing is not zero + fn axis(&self, width: u16) -> impl Widget { + let label = if self.spacing != 0 { + format!("{} px (gap: {} px)", width, self.spacing) + } else { + format!("{} px", width) + }; + let bar_width = width.saturating_sub(2) as usize; // we want to `<` and `>` at the ends + let width_bar = format!("<{label:-^bar_width$}>"); + Paragraph::new(width_bar).fg(Self::AXIS_COLOR).centered() + } + + fn render_layout_blocks(&self, area: Rect, buf: &mut Buffer) { + let [user_constraints, area] = area.split(&Layout::vertical([Length(3), Fill(1)])); + + self.render_user_constraints_legend(user_constraints, buf); + + let [start, center, end, space_around, space_between] = + area.split(&Layout::vertical([Length(7); 5])); + + self.render_layout_block(Flex::Start, start, buf); + self.render_layout_block(Flex::Center, center, buf); + self.render_layout_block(Flex::End, end, buf); + self.render_layout_block(Flex::SpaceAround, space_around, buf); + self.render_layout_block(Flex::SpaceBetween, space_between, buf) + } + + fn render_user_constraints_legend(&self, area: Rect, buf: &mut Buffer) { + let blocks = Layout::horizontal( + self.constraints + .iter() + .map(|_| Constraint::Fill(1)) + .collect_vec(), + ) + .split(area); + + for (i, (area, constraint)) in blocks.iter().zip(self.constraints.iter()).enumerate() { + let selected = self.selected_index == i; + ConstraintBlock::new(*constraint, selected, true).render(*area, buf); + } + } + + fn render_layout_block(&self, flex: Flex, area: Rect, buf: &mut Buffer) { + let [label_area, axis_area, blocks_area] = + area.split(&Layout::vertical([Length(1), Length(1), Length(4)])); + + format!("Flex::{:?}", flex).bold().render(label_area, buf); + + self.axis(area.width).render(axis_area, buf); + + let (blocks, spacers) = Layout::horizontal(&self.constraints) + .flex(flex) + .spacing(self.spacing) + .split_with_spacers(blocks_area); + + for (i, (area, constraint)) in blocks.iter().zip(self.constraints.iter()).enumerate() { + let selected = self.selected_index == i; + ConstraintBlock::new(*constraint, selected, false).render(*area, buf); + } + + for area in spacers.iter() { + SpacerBlock.render(*area, buf); + } + } +} + +impl Widget for ConstraintBlock { + fn render(self, area: Rect, buf: &mut Buffer) { + let lighter_color = ConstraintName::from(self.constraint).lighter_color(); + let main_color = ConstraintName::from(self.constraint).color(); + let selected_color = if self.selected { + lighter_color + } else { + main_color + }; + let color = if self.legend { + selected_color + } else { + main_color + }; + let label = self.label(area.width); + let block = Block::bordered() + .border_set(symbols::border::QUADRANT_OUTSIDE) + .border_style(Style::reset().fg(color).reversed()) + .fg(Self::TEXT_COLOR) + .bg(color); + Paragraph::new(label) + .centered() + .fg(Self::TEXT_COLOR) + .bg(color) + .block(block) + .render(area, buf); + + if !self.legend { + let border_color = if self.selected { + lighter_color + } else { + main_color + }; + buf.set_style(area.rows().last().unwrap(), border_color); + } + } +} + +impl ConstraintBlock { + const TEXT_COLOR: Color = SLATE.c200; + + fn new(constraint: Constraint, selected: bool, legend: bool) -> Self { + Self { + constraint, + selected, + legend, + } + } + + fn label(&self, width: u16) -> String { + let long_width = format!("{} px", width); + let short_width = format!("{}", width); + // border takes up 2 columns + let available_space = width.saturating_sub(2) as usize; + let width_label = if long_width.len() < available_space { + long_width + } else if short_width.len() < available_space { + short_width + } else { + "".to_string() + }; + format!("{}\n{}", self.constraint, width_label) + } +} + +impl Widget for SpacerBlock { + fn render(self, area: Rect, buf: &mut Buffer) { + if area.width > 1 { + Self::block().render(area, buf); + } else { + Self::line().render(area, buf); + } + + let row = area.rows().nth(1).unwrap_or_default(); + Self::spacer_label(area.width).render(row, buf); + + let row = area.rows().nth(2).unwrap_or_default(); + Self::label(area.width).render(row, buf); + } +} + +impl SpacerBlock { + const TEXT_COLOR: Color = SLATE.c500; + const BORDER_COLOR: Color = SLATE.c600; + + /// A block with a corner borders + fn block() -> impl Widget { + let corners_only = symbols::border::Set { + top_left: line::NORMAL.top_left, + top_right: line::NORMAL.top_right, + bottom_left: line::NORMAL.bottom_left, + bottom_right: line::NORMAL.bottom_right, + vertical_left: " ", + vertical_right: " ", + horizontal_top: " ", + horizontal_bottom: " ", + }; + Block::bordered() + .border_set(corners_only) + .border_style(Self::BORDER_COLOR) + } + + /// A vertical line used if there is not enough space to render the block + fn line() -> impl Widget { + Paragraph::new(Text::from(vec![ + Line::from(""), + Line::from("│"), + Line::from("│"), + Line::from(""), + ])) + .style(Self::BORDER_COLOR) + } + + /// A label that says "Spacer" if there is enough space + fn spacer_label(width: u16) -> impl Widget { + let label = if width >= 6 { "Spacer" } else { "" }; + label.fg(SpacerBlock::TEXT_COLOR).to_centered_line() + } + + /// A label that says "8 px" if there is enough space + fn label(width: u16) -> impl Widget { + let long_label = format!("{width} px"); + let short_label = format!("{width}"); + let label = if long_label.len() < width as usize { + long_label + } else if short_label.len() < width as usize { + short_label + } else { + "".to_string() + }; + Line::styled(label, Self::TEXT_COLOR).centered() + } +} + +impl ConstraintName { + fn color(&self) -> Color { + match self { + Self::Length => SLATE.c700, + Self::Percentage => SLATE.c800, + Self::Ratio => SLATE.c900, + Self::Fill => SLATE.c950, + Self::Min => BLUE.c800, + Self::Max => BLUE.c900, + } + } + + fn lighter_color(&self) -> Color { + match self { + Self::Length => STONE.c500, + Self::Percentage => STONE.c600, + Self::Ratio => STONE.c700, + Self::Fill => STONE.c800, + Self::Min => SKY.c600, + Self::Max => SKY.c700, + } + } +} + +fn init_error_hooks() -> Result<()> { + let (panic, error) = HookBuilder::default().into_hooks(); + let panic = panic.into_panic_hook(); + let error = error.into_eyre_hook(); + color_eyre::eyre::set_hook(Box::new(move |e| { + let _ = restore_terminal(); + error(e) + }))?; + std::panic::set_hook(Box::new(move |info| { + let _ = restore_terminal(); + panic(info) + })); + Ok(()) +} + +fn init_terminal() -> Result> { + enable_raw_mode()?; + stdout().execute(EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::new(backend)?; + Ok(terminal) +} + +fn restore_terminal() -> Result<()> { + disable_raw_mode()?; + stdout().execute(LeaveAlternateScreen)?; + Ok(()) +}