diff --git a/Cargo.toml b/Cargo.toml index 51aadf30..8bab27c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ exclude = [ "*.log", "tags", ] -autoexamples = true edition = "2021" rust-version = "1.74.0" @@ -218,6 +217,11 @@ name = "barchart" required-features = ["crossterm"] doc-scrape-examples = true +[[example]] +name = "barchart-grouped" +required-features = ["crossterm"] +doc-scrape-examples = true + [[example]] name = "block" required-features = ["crossterm"] diff --git a/examples/README.md b/examples/README.md index ecb87f08..88bc2193 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,6 +23,26 @@ 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. +- 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-grouped.rs b/examples/barchart-grouped.rs new file mode 100644 index 00000000..10faf3ec --- /dev/null +++ b/examples/barchart-grouped.rs @@ -0,0 +1,278 @@ +//! # [Ratatui] `BarChart` 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::iter::zip; + +use color_eyre::Result; +use ratatui::{ + crossterm::event::{self, Event, KeyCode}, + prelude::{Color, Constraint, Direction, Frame, Layout, Line, Style}, + style::Stylize, + widgets::{Bar, BarChart, BarGroup, Block}, +}; + +use self::terminal::Terminal; + +const COMPANY_COUNT: usize = 3; +const PERIOD_COUNT: usize = 4; + +struct App { + should_exit: bool, + companies: [Company; COMPANY_COUNT], + revenues: [Revenues; PERIOD_COUNT], +} + +struct Revenues { + period: &'static str, + revenues: [u32; COMPANY_COUNT], +} + +struct Company { + short_name: &'static str, + name: &'static str, + color: Color, +} + +fn main() -> Result<()> { + color_eyre::install()?; + let mut terminal = terminal::init()?; + let app = App::new(); + app.run(&mut terminal)?; + terminal::restore()?; + Ok(()) +} + +impl App { + const fn new() -> Self { + Self { + should_exit: false, + companies: fake_companies(), + revenues: fake_revenues(), + } + } + + fn run(mut self, terminal: &mut Terminal) -> Result<()> { + while !self.should_exit { + self.draw(terminal)?; + self.handle_events()?; + } + Ok(()) + } + + fn draw(&self, terminal: &mut Terminal) -> Result<()> { + terminal.draw(|frame| self.render(frame))?; + Ok(()) + } + + fn handle_events(&mut self) -> Result<()> { + if let Event::Key(key) = event::read()? { + if key.code == KeyCode::Char('q') { + self.should_exit = true; + } + } + Ok(()) + } + + fn render(&self, frame: &mut Frame) { + use Constraint::{Fill, Length, Min}; + let [title, top, bottom] = Layout::vertical([Length(1), Fill(1), Min(20)]) + .spacing(1) + .areas(frame.area()); + + frame.render_widget("Grouped Barchart".bold().into_centered_line(), title); + frame.render_widget(self.vertical_revenue_barchart(), top); + frame.render_widget(self.horizontal_revenue_barchart(), bottom); + } + + /// Create a vertical revenue bar chart with the data from the `revenues` field. + fn vertical_revenue_barchart(&self) -> BarChart<'_> { + let title = Line::from("Company revenues (Vertical)").centered(); + let mut barchart = BarChart::default() + .block(Block::new().title(title)) + .bar_gap(0) + .bar_width(6) + .group_gap(2); + for group in self + .revenues + .iter() + .map(|revenue| revenue.to_vertical_bar_group(&self.companies)) + { + barchart = barchart.data(group); + } + barchart + } + + /// Create a horizontal revenue bar chart with the data from the `revenues` field. + fn horizontal_revenue_barchart(&self) -> BarChart<'_> { + let title = Line::from("Company Revenues (Horizontal)").centered(); + let mut barchart = BarChart::default() + .block(Block::new().title(title)) + .bar_width(1) + .group_gap(2) + .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 + } +} + +/// Generate fake company data +const fn fake_companies() -> [Company; COMPANY_COUNT] { + [ + Company::new("BAKE", "Bake my day", Color::LightRed), + Company::new("BITE", "Bits and Bites", Color::Blue), + Company::new("TART", "Tart of the Table", Color::White), + ] +} + +/// Some fake revenue data +const fn fake_revenues() -> [Revenues; PERIOD_COUNT] { + [ + Revenues::new("Jan", [8500, 6500, 7000]), + Revenues::new("Feb", [9000, 7500, 8500]), + Revenues::new("Mar", [9500, 4500, 8200]), + Revenues::new("Apr", [6300, 4000, 5000]), + ] +} + +impl Revenues { + /// Create a new instance of `Revenues` + const fn new(period: &'static str, revenues: [u32; COMPANY_COUNT]) -> Self { + Self { period, revenues } + } + + /// Create a `BarGroup` with vertical bars for each company + fn to_vertical_bar_group<'a>(&self, companies: &'a [Company]) -> BarGroup<'a> { + let bars: Vec = zip(companies, self.revenues) + .map(|(company, revenue)| company.vertical_revenue_bar(revenue)) + .collect(); + 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: Vec = zip(companies, self.revenues) + .map(|(company, revenue)| company.horizontal_revenue_bar(revenue)) + .collect(); + BarGroup::default() + .label(Line::from(self.period).centered()) + .bars(&bars) + } +} + +impl Company { + /// Create a new instance of `Company` + const fn new(short_name: &'static str, name: &'static str, color: Color) -> Self { + Self { + short_name, + name, + color, + } + } + + /// 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}M", f64::from(revenue) / 1000.); + Bar::default() + .label(self.short_name.into()) + .value(u64::from(revenue)) + .text_value(text_value) + .style(self.color) + .value_style(Style::new().fg(Color::Black).bg(self.color)) + } + + /// Create a horizontal revenue bar for the company + /// + /// The label is the long 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.name, 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)) + } +} + +/// Contains functions common to all examples +mod terminal { + use std::{ + io::{self, stdout, Stdout}, + panic, + }; + + use ratatui::{ + backend::CrosstermBackend, + crossterm::{ + execute, + terminal::{ + disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, + LeaveAlternateScreen, + }, + }, + }; + + // 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() -> io::Result { + install_panic_hook(); + enable_raw_mode()?; + execute!(stdout(), EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::new(backend)?; + Ok(terminal) + } + + /// 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() -> io::Result<()> { + disable_raw_mode()?; + execute!( + stdout(), + LeaveAlternateScreen, + Clear(ClearType::FromCursorDown), + ) + } + + /// Install a panic hook that restores the terminal before printing the panic. + /// + /// This prevents error messages from being messed up by the terminal state. + fn install_panic_hook() { + let panic_hook = panic::take_hook(); + panic::set_hook(Box::new(move |panic_info| { + let _ = restore(); + panic_hook(panic_info); + })); + } +} diff --git a/examples/barchart.rs b/examples/barchart.rs index 3a027cac..0c11393e 100644 --- a/examples/barchart.rs +++ b/examples/barchart.rs @@ -13,290 +13,187 @@ //! [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::{ - error::Error, - io, - time::{Duration, Instant}, -}; - +use color_eyre::Result; +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}, - }, - layout::{Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Bar, BarChart, BarGroup, Block, Paragraph}, - Frame, Terminal, + crossterm::event::{self, Event, KeyCode}, + layout::{Constraint, Direction, Layout}, + prelude::{Color, Line, Style, Stylize}, + widgets::{Bar, BarChart, BarGroup, Block}, }; -struct Company<'a> { - revenue: [u64; 4], - label: &'a str, - bar_style: Style, +use self::terminal::Terminal; + +struct App { + should_exit: bool, + temperatures: Vec, } -struct App<'a> { - data: Vec<(&'a str, u64)>, - months: [&'a str; 4], - companies: [Company<'a>; 3], -} - -const TOTAL_REVENUE: &str = "Total Revenue"; - -impl<'a> App<'a> { - 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"], - } - } - - fn on_tick(&mut self) { - let value = self.data.pop().unwrap(); - self.data.insert(0, value); - } -} - -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); +fn main() -> Result<()> { + color_eyre::install()?; + let mut terminal = terminal::init()?; 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:?}"); - } - + app.run(&mut terminal)?; + terminal::restore()?; 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))?; +impl App { + fn new() -> Self { + let mut rng = thread_rng(); + let temperatures = (0..24).map(|_| rng.gen_range(50..90)).collect(); + Self { + should_exit: false, + temperatures, + } + } - let timeout = tick_rate.saturating_sub(last_tick.elapsed()); - if crossterm::event::poll(timeout)? { - if let Event::Key(key) = event::read()? { - if key.code == KeyCode::Char('q') { - return Ok(()); - } + fn run(mut self, terminal: &mut Terminal) -> Result<()> { + while !self.should_exit { + self.draw(terminal)?; + self.handle_events()?; + } + Ok(()) + } + + fn draw(&self, terminal: &mut Terminal) -> Result<()> { + terminal.draw(|frame| self.render(frame))?; + Ok(()) + } + + fn handle_events(&mut self) -> Result<()> { + if let Event::Key(key) = event::read()? { + if key.code == KeyCode::Char('q') { + self.should_exit = true; } } - if last_tick.elapsed() >= tick_rate { - app.on_tick(); - last_tick = Instant::now(); - } + Ok(()) + } + + fn render(&self, frame: &mut ratatui::Frame) { + let [title, vertical, horizontal] = Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Fill(1), + ]) + .spacing(1) + .areas(frame.area()); + frame.render_widget("Barchart".bold().into_centered_line(), title); + frame.render_widget(vertical_barchart(&self.temperatures), vertical); + frame.render_widget(horizontal_barchart(&self.temperatures), horizontal); } } -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.area()); - 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); -} - -#[allow(clippy::cast_precision_loss)] -fn create_groups<'a>(app: &'a App, combine_values_and_labels: bool) -> Vec> { - app.months +/// Create a vertical bar chart from the temperatures data. +fn vertical_barchart(temperatures: &[u8]) -> BarChart { + let bars: Vec = temperatures .iter() + .map(|v| u64::from(*v)) .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) + .map(|(i, value)| { + Bar::default() + .value(value) + .label(Line::from(format!("{i:>02}:00"))) + .text_value(format!("{value:>3}°")) + .style(temperature_style(value)) + .value_style(temperature_style(value).reversed()) }) - .collect() + .collect(); + let title = Line::from("Weather (Vertical)").centered(); + BarChart::default() + .data(BarGroup::default().bars(&bars)) + .block(Block::new().title(title)) + .bar_width(5) } -#[allow(clippy::cast_possible_truncation)] -fn draw_bar_with_group_labels(f: &mut Frame, app: &App, area: Rect) { - const LEGEND_HEIGHT: u16 = 6; - - let groups = create_groups(app, false); - - let mut barchart = BarChart::default() - .block(Block::bordered().title("Data1")) - .bar_width(7) - .group_gap(3); - - for group in groups { - barchart = barchart.data(group); - } - - 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); - } -} - -#[allow(clippy::cast_possible_truncation)] -fn draw_horizontal_bars(f: &mut Frame, app: &App, area: Rect) { - const LEGEND_HEIGHT: u16 = 6; - - let groups = create_groups(app, true); - - let mut barchart = BarChart::default() - .block(Block::bordered().title("Data1")) +/// Create a horizontal bar chart from the temperatures data. +fn horizontal_barchart(temperatures: &[u8]) -> BarChart { + let bars: Vec = temperatures + .iter() + .map(|v| u64::from(*v)) + .enumerate() + .map(|(i, value)| { + let style = temperature_style(value); + Bar::default() + .value(value) + .label(Line::from(format!("{i:>02}:00"))) + .text_value(format!("{value:>3}°")) + .style(style) + .value_style(style.reversed()) + }) + .collect(); + let title = Line::from("Weather (Horizontal)").centered(); + BarChart::default() + .block(Block::new().title(title)) + .data(BarGroup::default().bars(&bars)) .bar_width(1) - .group_gap(1) .bar_gap(0) - .direction(Direction::Horizontal); - - for group in groups { - barchart = barchart.data(group); - } - - 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); - } + .direction(Direction::Horizontal) } -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); +/// create a yellow to red value based on the value (50-90) +fn temperature_style(value: u64) -> Style { + let green = (255.0 * (1.0 - (value - 50) as f64 / 40.0)) as u8; + let color = Color::Rgb(255, green, 0); + Style::new().fg(color) +} + +/// Contains functions common to all examples +mod terminal { + use std::{ + io::{self, stdout, Stdout}, + panic, + }; + + use ratatui::{ + backend::CrosstermBackend, + crossterm::{ + execute, + terminal::{ + disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, + LeaveAlternateScreen, + }, + }, + }; + + // 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() -> io::Result { + install_panic_hook(); + enable_raw_mode()?; + execute!(stdout(), EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::new(backend)?; + Ok(terminal) + } + + /// 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() -> io::Result<()> { + disable_raw_mode()?; + execute!( + stdout(), + LeaveAlternateScreen, + Clear(ClearType::FromCursorDown), + ) + } + + /// Install a panic hook that restores the terminal before printing the panic. + /// + /// This prevents error messages from being messed up by the terminal state. + fn install_panic_hook() { + let panic_hook = panic::take_hook(); + panic::set_hook(Box::new(move |panic_info| { + let _ = restore(); + panic_hook(panic_info); + })); + } } diff --git a/examples/vhs/barchart-grouped.tape b/examples/vhs/barchart-grouped.tape new file mode 100644 index 00000000..92fd0eb1 --- /dev/null +++ b/examples/vhs/barchart-grouped.tape @@ -0,0 +1,12 @@ +# This is a vhs script. See https://github.com/charmbracelet/vhs for more info. +# To run this script, install vhs and run `vhs ./examples/barchart.tape` +Output "target/barchart-grouped.gif" +Set Theme "Aardvark Blue" +Set Width 1200 +Set Height 1000 +Hide +Type "cargo run --example=barchart-grouped" +Enter +Sleep 1s +Show +Sleep 1s diff --git a/examples/vhs/barchart.tape b/examples/vhs/barchart.tape index 3244be54..9869f73a 100644 --- a/examples/vhs/barchart.tape +++ b/examples/vhs/barchart.tape @@ -3,10 +3,10 @@ Output "target/barchart.gif" Set Theme "Aardvark Blue" Set Width 1200 -Set Height 800 +Set Height 600 Hide Type "cargo run --example=barchart" Enter Sleep 1s Show -Sleep 5s +Sleep 1s