diff --git a/Cargo.toml b/Cargo.toml index cd5d3ad79..2d7797fbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ exclude = [ "*.log", "tags", ] -autoexamples = true edition = "2021" rust-version = "1.74.0" diff --git a/examples/README.md b/examples/README.md index b5b7c99d2..238eefce2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,6 +23,28 @@ This folder might use unreleased code. View the examples for the latest release > We don't keep the CHANGELOG updated with unreleased changes, check the git commit history or run > `git-cliff -u` against a cloned version of this repository. +## Design choices + +The examples contain some opinionated choices in order to make it easier for newer rustaceans to +easily be productive in creating applications: + +- Each example has an App struct, with methods that implement a main loop, handle events and drawing + the UI. +- Each App implements the Widget trait for drawing the UI. The `render` method makes a good point to + write tests against. +- We use color_eyre for handling errors and panics. See [How to use color-eyre with Ratatui] on the + website for more information about this. +- Common code is not extracted into a separate file. This makes each example self-contained and easy + to read as a whole. + +Not every example has been updated with all these points in mind yet, however over time they will +be. None of the above choices are strictly necessary for Ratatui apps, but these choices make +examples easier to run, maintain and explain. These choices are designed to help newer users fall +into the pit of success when incorporating example code into their own apps. We may also eventually +move some of these design choices into the core of Ratatui to simplify apps. + +[How to use color-eyre with Ratatui]: https://ratatui.rs/how-to/develop-apps/color_eyre/ + ## Demo2 This is the demo example from the main README and crate page. Source: [demo2](./demo2/). diff --git a/examples/barchart.rs b/examples/barchart.rs index a7763712b..e64887c61 100644 --- a/examples/barchart.rs +++ b/examples/barchart.rs @@ -14,289 +14,359 @@ //! [examples readme]: https://github.com/ratatui-org/ratatui/blob/main/examples/README.md use std::{ - error::Error, io, time::{Duration, Instant}, }; +use crossterm::event::{self, Event, KeyCode}; +use itertools::{izip, Itertools}; +use rand::{thread_rng, Rng}; use ratatui::{ - backend::{Backend, CrosstermBackend}, - crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - }, + buffer::Buffer, layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - terminal::{Frame, Terminal}, - text::{Line, Span}, - widgets::{Bar, BarChart, BarGroup, Block, Paragraph}, + style::{Color, Modifier, Style, Stylize}, + text::Line, + widgets::{Bar, BarChart, BarGroup, Block, Paragraph, Widget}, }; +use unicode_width::UnicodeWidthStr; + +use self::common::Terminal; -struct Company<'a> { - revenue: [u64; 4], - label: &'a str, - bar_style: Style, +const COMPANY_COUNT: usize = 3; +const PERIOD_COUNT: usize = 4; + +struct App { + exit: bool, + data: Vec>, + last_update: Instant, + companies: [Company; COMPANY_COUNT], + revenues: [Revenues; PERIOD_COUNT], } -struct App<'a> { - data: Vec<(&'a str, u64)>, - months: [&'a str; 4], - companies: [Company<'a>; 3], +struct Revenues { + period: &'static str, + revenues: [u32; COMPANY_COUNT], } -const TOTAL_REVENUE: &str = "Total Revenue"; +struct Company { + name: &'static str, + label: &'static str, + color: Color, +} -impl<'a> App<'a> { +fn main() -> color_eyre::Result<()> { + common::install_hooks()?; + let mut terminal = common::init_terminal()?; + let app = App::new(); + app.run(&mut terminal)?; + common::restore_terminal()?; + Ok(()) +} + +impl App { + // update the data every 250ms + const UPDATE_RATE: Duration = Duration::from_millis(250); + + /// Create a new instance of the application fn new() -> Self { - App { - data: vec![ - ("B1", 9), - ("B2", 12), - ("B3", 5), - ("B4", 8), - ("B5", 2), - ("B6", 4), - ("B7", 5), - ("B8", 9), - ("B9", 14), - ("B10", 15), - ("B11", 1), - ("B12", 0), - ("B13", 4), - ("B14", 6), - ("B15", 4), - ("B16", 6), - ("B17", 4), - ("B18", 7), - ("B19", 13), - ("B20", 8), - ("B21", 11), - ("B22", 9), - ("B23", 3), - ("B24", 5), - ], - companies: [ - Company { - label: "Comp.A", - revenue: [9500, 12500, 5300, 8500], - bar_style: Style::default().fg(Color::Green), - }, - Company { - label: "Comp.B", - revenue: [1500, 2500, 3000, 500], - bar_style: Style::default().fg(Color::Yellow), - }, - Company { - label: "Comp.C", - revenue: [10500, 10600, 9000, 4200], - bar_style: Style::default().fg(Color::White), - }, - ], - months: ["Mars", "Apr", "May", "Jun"], + Self { + exit: false, + data: generate_main_barchart_data(), + last_update: Instant::now(), + companies: Company::fake_companies(), + revenues: Revenues::fake_revenues(), } } - fn on_tick(&mut self) { - let value = self.data.pop().unwrap(); - self.data.insert(0, value); + /// Run the application + fn run(mut self, terminal: &mut Terminal) -> io::Result<()> { + while !self.exit { + self.draw(terminal)?; + self.handle_events()?; + self.update_data(); + } + Ok(()) } -} - -fn main() -> Result<(), Box> { - // setup terminal - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - // create app and run it - let tick_rate = Duration::from_millis(250); - let app = App::new(); - let res = run_app(&mut terminal, app, tick_rate); - - // restore terminal - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - - if let Err(err) = res { - println!("{err:?}"); + fn draw(&self, terminal: &mut Terminal) -> io::Result<()> { + terminal.draw(|frame| frame.render_widget(self, frame.size()))?; + Ok(()) } - Ok(()) -} - -fn run_app( - terminal: &mut Terminal, - mut app: App, - tick_rate: Duration, -) -> io::Result<()> { - let mut last_tick = Instant::now(); - loop { - terminal.draw(|f| ui(f, &app))?; - - let timeout = tick_rate.saturating_sub(last_tick.elapsed()); - if crossterm::event::poll(timeout)? { + fn handle_events(&mut self) -> io::Result<()> { + let timeout = Self::UPDATE_RATE.saturating_sub(self.last_update.elapsed()); + if event::poll(timeout)? { if let Event::Key(key) = event::read()? { if key.code == KeyCode::Char('q') { - return Ok(()); + self.exit = true; } } } - if last_tick.elapsed() >= tick_rate { - app.on_tick(); - last_tick = Instant::now(); - } + Ok(()) } -} -fn ui(frame: &mut Frame, app: &App) { - let vertical = Layout::vertical([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]); - let horizontal = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]); - let [top, bottom] = vertical.areas(frame.size()); - let [left, right] = horizontal.areas(bottom); - - let barchart = BarChart::default() - .block(Block::bordered().title("Data1")) - .data(&app.data) - .bar_width(9) - .bar_style(Style::default().fg(Color::Yellow)) - .value_style(Style::default().fg(Color::Black).bg(Color::Yellow)); - - frame.render_widget(barchart, top); - draw_bar_with_group_labels(frame, app, left); - draw_horizontal_bars(frame, app, right); + // Rotate the data to simulate a real-time update + fn update_data(&mut self) { + if self.last_update.elapsed() >= Self::UPDATE_RATE { + let value = self.data.pop().unwrap(); + self.data.insert(0, value); + self.last_update = Instant::now(); + } + } } -#[allow(clippy::cast_precision_loss)] -fn create_groups<'a>(app: &'a App, combine_values_and_labels: bool) -> Vec> { - app.months - .iter() - .enumerate() - .map(|(i, &month)| { - let bars: Vec = app - .companies - .iter() - .map(|c| { - let mut bar = Bar::default() - .value(c.revenue[i]) - .style(c.bar_style) - .value_style( - Style::default() - .bg(c.bar_style.fg.unwrap()) - .fg(Color::Black), - ); - - if combine_values_and_labels { - bar = bar.text_value(format!( - "{} ({:.1} M)", - c.label, - (c.revenue[i] as f64) / 1000. - )); - } else { - bar = bar - .text_value(format!("{:.1}", (c.revenue[i] as f64) / 1000.)) - .label(c.label.into()); - } - bar - }) - .collect(); - BarGroup::default() - .label(Line::from(month).centered()) - .bars(&bars) +/// Generate some random data for the main bar chart +fn generate_main_barchart_data() -> Vec> { + let mut rng = thread_rng(); + (1..50) + .map(|index| { + Bar::default() + .label(format!("B{index:>02}").into()) + .value(rng.gen_range(1..15)) }) .collect() } -#[allow(clippy::cast_possible_truncation)] -fn draw_bar_with_group_labels(f: &mut Frame, app: &App, area: Rect) { - const LEGEND_HEIGHT: u16 = 6; +impl Widget for &App { + /// Render the application + fn render(self, area: Rect, buf: &mut Buffer) { + use Constraint::{Percentage, Ratio}; + let [top, bottom] = Layout::vertical([Ratio(1, 3), Ratio(2, 3)]).areas(area); + let [left, right] = Layout::horizontal([Percentage(50), Percentage(50)]).areas(bottom); + + let left_legend_area = App::legend_area(left); + let right_legend_area = App::legend_area(right); + + self.main_barchart().render(top, buf); + self.vertical_revenue_barchart().render(left, buf); + self.horizontal_revenue_barchart().render(right, buf); + self.legend().render(left_legend_area, buf); + self.legend().render(right_legend_area, buf); + } +} - let groups = create_groups(app, false); +impl App { + const TOTAL_REVENUE_LABEL: &'static str = "Total Revenue"; + + /// Create a bar chart with the data from the `data` field. + fn main_barchart(&self) -> BarChart<'_> { + BarChart::default() + .block(Block::bordered().title("Vertical Grouped")) + .data(BarGroup::default().bars(&self.data)) + .bar_width(5) + .bar_style(Style::new().fg(Color::Yellow)) + .value_style(Style::new().fg(Color::Black).bg(Color::Yellow)) + } - let mut barchart = BarChart::default() - .block(Block::bordered().title("Data1")) - .bar_width(7) - .group_gap(3); + /// Create a vertical revenue bar chart with the data from the `revenues` field. + fn vertical_revenue_barchart(&self) -> BarChart<'_> { + let mut barchart = BarChart::default() + .block(Block::bordered().title("Vertical Grouped")) + .bar_width(7) + .group_gap(3); + for group in self + .revenues + .iter() + .map(|revenue| revenue.to_vertical_bar_group(&self.companies)) + { + barchart = barchart.data(group); + } + barchart + } - for group in groups { - barchart = barchart.data(group); + /// Create a horizontal revenue bar chart with the data from the `revenues` field. + fn horizontal_revenue_barchart(&self) -> BarChart<'_> { + let mut barchart = BarChart::default() + .block(Block::bordered().title("Horizontal Grouped")) + .bar_width(1) + .group_gap(1) + .bar_gap(0) + .direction(Direction::Horizontal); + for group in self + .revenues + .iter() + .map(|revenue| revenue.to_horizontal_bar_group(&self.companies)) + { + barchart = barchart.data(group); + } + barchart + } + + /// Calculate the area for the legend based on the width of the revenue bar chart. + fn legend_area(area: Rect) -> Rect { + let height = 6; + let width = Self::TOTAL_REVENUE_LABEL.width() as u16 + 2; + Rect::new(area.right().saturating_sub(width), area.y, width, height).intersection(area) } - f.render_widget(barchart, area); - - if area.height >= LEGEND_HEIGHT && area.width >= TOTAL_REVENUE.len() as u16 + 2 { - let legend_width = TOTAL_REVENUE.len() as u16 + 2; - let legend_area = Rect { - height: LEGEND_HEIGHT, - width: legend_width, - y: area.y, - x: area.right() - legend_width, - }; - draw_legend(f, legend_area); + /// Create a `Paragraph` widget with the legend for the revenue bar charts. + fn legend(&self) -> Paragraph<'static> { + let mut text = vec![Line::styled( + Self::TOTAL_REVENUE_LABEL, + (Color::White, Modifier::BOLD), + )]; + for company in &self.companies { + text.push(Line::styled(format!("- {}", company.name), company.color)); + } + Paragraph::new(text).block(Block::bordered().white()) } } -#[allow(clippy::cast_possible_truncation)] -fn draw_horizontal_bars(f: &mut Frame, app: &App, area: Rect) { - const LEGEND_HEIGHT: u16 = 6; +impl Revenues { + /// Create a new instance of `Revenues` + const fn new(period: &'static str, revenues: [u32; COMPANY_COUNT]) -> Self { + Self { period, revenues } + } + + /// Some fake revenue data + const fn fake_revenues() -> [Self; PERIOD_COUNT] { + [ + Self::new("Jan", [9500, 1500, 10500]), + Self::new("Feb", [12500, 2500, 10600]), + Self::new("Mar", [5300, 3000, 9000]), + Self::new("Apr", [8500, 500, 4200]), + ] + } + + /// Create a `BarGroup` with vertical bars for each company + fn to_vertical_bar_group<'a>(&self, companies: &'a [Company]) -> BarGroup<'a> { + let bars = izip!(companies, self.revenues) + .map(|(company, revenue)| company.vertical_revenue_bar(revenue)) + .collect_vec(); + BarGroup::default() + .label(Line::from(self.period).centered()) + .bars(&bars) + } + + /// Create a `BarGroup` with horizontal bars for each company + fn to_horizontal_bar_group<'a>(&'a self, companies: &'a [Company]) -> BarGroup<'a> { + let bars = izip!(companies, self.revenues) + .map(|(company, revenue)| company.horizontal_revenue_bar(revenue)) + .collect_vec(); + BarGroup::default() + .label(Line::from(self.period).centered()) + .bars(&bars) + } +} - let groups = create_groups(app, true); +impl Company { + /// Create a new instance of `Company` + const fn new(name: &'static str, label: &'static str, color: Color) -> Self { + Self { name, label, color } + } - let mut barchart = BarChart::default() - .block(Block::bordered().title("Data1")) - .bar_width(1) - .group_gap(1) - .bar_gap(0) - .direction(Direction::Horizontal); + /// Generate fake company data + const fn fake_companies() -> [Self; COMPANY_COUNT] { + [ + Self::new("Company A", "Comp.A", Color::Green), + Self::new("Company B", "Comp.B", Color::Yellow), + Self::new("Company C", "Comp.C", Color::White), + ] + } - for group in groups { - barchart = barchart.data(group); + /// Create a vertical revenue bar for the company + /// + /// The label is the short name of the company, and will be displayed under the bar + fn vertical_revenue_bar(&self, revenue: u32) -> Bar { + let text_value = format!("{:.1}", f64::from(revenue) / 1000.); + Bar::default() + .label(self.label.into()) + .value(u64::from(revenue)) + .text_value(text_value) + .style(self.color) + .value_style(Style::new().fg(Color::Black).bg(self.color)) } - f.render_widget(barchart, area); - - if area.height >= LEGEND_HEIGHT && area.width >= TOTAL_REVENUE.len() as u16 + 2 { - let legend_width = TOTAL_REVENUE.len() as u16 + 2; - let legend_area = Rect { - height: LEGEND_HEIGHT, - width: legend_width, - y: area.y, - x: area.right() - legend_width, - }; - draw_legend(f, legend_area); + /// Create a horizontal revenue bar for the company + /// + /// The label is the short name of the company combined with the revenue and will be displayed + /// on the bar + fn horizontal_revenue_bar(&self, revenue: u32) -> Bar { + let text_value = format!("{} ({:.1} M)", self.label, f64::from(revenue) / 1000.); + Bar::default() + .value(u64::from(revenue)) + .text_value(text_value) + .style(self.color) + .value_style(Style::new().fg(Color::Black).bg(self.color)) } } -fn draw_legend(f: &mut Frame, area: Rect) { - let text = vec![ - Line::from(Span::styled( - TOTAL_REVENUE, - Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::White), - )), - Line::from(Span::styled( - "- Company A", - Style::default().fg(Color::Green), - )), - Line::from(Span::styled( - "- Company B", - Style::default().fg(Color::Yellow), - )), - Line::from(Span::styled( - "- Company C", - Style::default().fg(Color::White), - )), - ]; - - let block = Block::bordered().style(Style::default().fg(Color::White)); - let paragraph = Paragraph::new(text).block(block); - f.render_widget(paragraph, area); +/// Contains functions common to all examples +mod common { + use std::{ + io::{self, stdout, Stdout}, + panic, + }; + + use color_eyre::{ + config::{EyreHook, HookBuilder, PanicHook}, + eyre::{self}, + }; + use crossterm::{ + execute, + terminal::{ + disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, + LeaveAlternateScreen, + }, + }; + use ratatui::backend::CrosstermBackend; + + // A type alias to simplify the usage of the terminal and make it easier to change the backend + // or choice of writer. + pub type Terminal = ratatui::Terminal>; + + /// Initialize the terminal by enabling raw mode and entering the alternate screen. + /// + /// This function should be called before the program starts to ensure that the terminal is in + /// the correct state for the application. + pub fn init_terminal() -> io::Result { + enable_raw_mode()?; + execute!(stdout(), EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout()); + Terminal::new(backend) + } + + /// Restore the terminal by leaving the alternate screen and disabling raw mode. + /// + /// This function should be called before the program exits to ensure that the terminal is + /// restored to its original state. + pub fn restore_terminal() -> io::Result<()> { + disable_raw_mode()?; + execute!( + stdout(), + LeaveAlternateScreen, + Clear(ClearType::FromCursorDown), + ) + } + + /// Installs hooks for panic and error handling. + /// + /// Makes the app resilient to panics and errors by restoring the terminal before printing the + /// panic or error message. This prevents error messages from being messed up by the terminal + /// state. + pub fn install_hooks() -> color_eyre::Result<()> { + let (panic_hook, eyre_hook) = HookBuilder::default().into_hooks(); + install_panic_hook(panic_hook); + install_error_hook(eyre_hook)?; + Ok(()) + } + + /// Install a panic hook that restores the terminal before printing the panic. + fn install_panic_hook(panic_hook: PanicHook) { + let panic_hook = panic_hook.into_panic_hook(); + panic::set_hook(Box::new(move |panic_info| { + let _ = restore_terminal(); + panic_hook(panic_info); + })); + } + + /// Install an error hook that restores the terminal before printing the error. + fn install_error_hook(eyre_hook: EyreHook) -> color_eyre::Result<()> { + let eyre_hook = eyre_hook.into_eyre_hook(); + eyre::set_hook(Box::new(move |error| { + let _ = restore_terminal(); + eyre_hook(error) + }))?; + Ok(()) + } }