mirror of
https://github.com/ratatui-org/ratatui
synced 2024-11-10 07:04:17 +00:00
8719608bda
With the Rust method naming conventions these methods are into methods consuming the Span. Therefore, it's more consistent to use `into_` instead of `to_`. ```rust Span::to_centered_line Span::to_left_aligned_line Span::to_right_aligned_line ``` Are marked deprecated and replaced with the following ```rust Span::into_centered_line Span::into_left_aligned_line Span::into_right_aligned_line ```
646 lines
20 KiB
Rust
646 lines
20 KiB
Rust
//! # [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
|
|
|
|
#![allow(clippy::enum_glob_use, clippy::wildcard_imports)]
|
|
|
|
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::{Block, Paragraph, Wrap},
|
|
};
|
|
use strum::{Display, EnumIter, FromRepr};
|
|
|
|
#[derive(Default)]
|
|
struct App {
|
|
mode: AppMode,
|
|
spacing: u16,
|
|
constraints: Vec<Constraint>,
|
|
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 {
|
|
constraint: Constraint,
|
|
legend: bool,
|
|
selected: bool,
|
|
}
|
|
|
|
/// 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<impl Backend>) -> 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<impl Backend>) -> 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(())
|
|
}
|
|
|
|
fn increment_value(&mut self) {
|
|
let Some(constraint) = self.constraints.get_mut(self.selected_index) else {
|
|
return;
|
|
};
|
|
match constraint {
|
|
Constraint::Length(v)
|
|
| Constraint::Min(v)
|
|
| Constraint::Max(v)
|
|
| Constraint::Fill(v)
|
|
| Constraint::Percentage(v) => *v = v.saturating_add(1),
|
|
Constraint::Ratio(_n, d) => *d = d.saturating_add(1),
|
|
};
|
|
}
|
|
|
|
fn decrement_value(&mut self) {
|
|
let Some(constraint) = self.constraints.get_mut(self.selected_index) else {
|
|
return;
|
|
};
|
|
match constraint {
|
|
Constraint::Length(v)
|
|
| Constraint::Min(v)
|
|
| Constraint::Max(v)
|
|
| Constraint::Fill(v)
|
|
| Constraint::Percentage(v) => *v = v.saturating_sub(1),
|
|
Constraint::Ratio(_n, d) => *d = 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);
|
|
}
|
|
|
|
fn exit(&mut self) {
|
|
self.mode = AppMode::Quit;
|
|
}
|
|
|
|
fn swap_constraint(&mut self, name: ConstraintName) {
|
|
if self.constraints.is_empty() {
|
|
return;
|
|
}
|
|
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, u32::from(self.value) / 4), // for balance
|
|
};
|
|
self.constraints[self.selected_index] = constraint;
|
|
}
|
|
}
|
|
|
|
impl From<Constraint> for ConstraintName {
|
|
fn from(constraint: Constraint) -> Self {
|
|
match constraint {
|
|
Length(_) => Self::Length,
|
|
Percentage(_) => Self::Percentage,
|
|
Ratio(_, _) => Self::Ratio,
|
|
Min(_) => Self::Min,
|
|
Max(_) => Self::Max,
|
|
Fill(_) => Self::Fill,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Widget for &App {
|
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
let [header_area, instructions_area, swap_legend_area, _, blocks_area] =
|
|
Layout::vertical([
|
|
Length(2), // header
|
|
Length(2), // instructions
|
|
Length(1), // swap key legend
|
|
Length(1), // gap
|
|
Fill(1), // blocks
|
|
])
|
|
.areas(area);
|
|
|
|
App::header().render(header_area, buf);
|
|
App::instructions().render(instructions_area, buf);
|
|
App::swap_legend().render(swap_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() -> impl Widget {
|
|
let text = "Constraint Explorer";
|
|
text.bold().fg(Self::HEADER_COLOR).into_centered_line()
|
|
}
|
|
|
|
fn instructions() -> 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 swap_legend() -> 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!("{width} px")
|
|
};
|
|
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] = Layout::vertical([Length(3), Fill(1)])
|
|
.spacing(1)
|
|
.areas(area);
|
|
|
|
self.render_user_constraints_legend(user_constraints, buf);
|
|
|
|
let [start, center, end, space_around, space_between] =
|
|
Layout::vertical([Length(7); 5]).areas(area);
|
|
|
|
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] =
|
|
Layout::vertical([Length(1), Max(1), Length(4)]).areas(area);
|
|
|
|
if label_area.height > 0 {
|
|
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) {
|
|
match area.height {
|
|
1 => self.render_1px(area, buf),
|
|
2 => self.render_2px(area, buf),
|
|
_ => self.render_4px(area, buf),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ConstraintBlock {
|
|
const TEXT_COLOR: Color = SLATE.c200;
|
|
|
|
const fn new(constraint: Constraint, selected: bool, legend: bool) -> Self {
|
|
Self {
|
|
constraint,
|
|
legend,
|
|
selected,
|
|
}
|
|
}
|
|
|
|
fn label(&self, width: u16) -> String {
|
|
let long_width = format!("{width} px");
|
|
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 {
|
|
String::new()
|
|
};
|
|
format!("{}\n{}", self.constraint, width_label)
|
|
}
|
|
|
|
fn render_1px(&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
|
|
};
|
|
Block::default()
|
|
.fg(Self::TEXT_COLOR)
|
|
.bg(selected_color)
|
|
.render(area, buf);
|
|
}
|
|
|
|
fn render_2px(&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
|
|
};
|
|
Block::bordered()
|
|
.border_set(symbols::border::QUADRANT_OUTSIDE)
|
|
.border_style(Style::reset().fg(selected_color).reversed())
|
|
.render(area, buf);
|
|
}
|
|
|
|
fn render_4px(&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
|
|
};
|
|
if let Some(last_row) = area.rows().last() {
|
|
buf.set_style(last_row, border_color);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Widget for SpacerBlock {
|
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
match area.height {
|
|
1 => (),
|
|
2 => Self::render_2px(area, buf),
|
|
3 => Self::render_3px(area, buf),
|
|
_ => Self::render_4px(area, 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(Self::TEXT_COLOR).into_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 {
|
|
String::new()
|
|
};
|
|
Line::styled(label, Self::TEXT_COLOR).centered()
|
|
}
|
|
|
|
fn render_2px(area: Rect, buf: &mut Buffer) {
|
|
if area.width > 1 {
|
|
Self::block().render(area, buf);
|
|
} else {
|
|
Self::line().render(area, buf);
|
|
}
|
|
}
|
|
|
|
fn render_3px(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);
|
|
}
|
|
|
|
fn render_4px(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 ConstraintName {
|
|
const 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,
|
|
}
|
|
}
|
|
|
|
const 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<Terminal<impl Backend>> {
|
|
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(())
|
|
}
|