docs(examples): simplify the barchart example (#1079)

The `barchart` example has been split into two examples: `barchart` and
`barchart-grouped`. The `barchart` example now shows a simple barchart
with random data, while the `barchart-grouped` example shows a grouped
barchart with fake revenue data.

This simplifies the examples a bit so they don't cover too much at once.

- Simplify the rendering functions
- Fix several clippy lints that were marked as allowed

---------

Co-authored-by: EdJoPaTo <rfc-conform-git-commit-email@funny-long-domain-label-everyone-hates-as-it-is-too-long.edjopato.de>
This commit is contained in:
Josh McKinney 2024-08-06 01:10:58 -07:00 committed by GitHub
parent a23ecd9b45
commit fe4eeab676
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 476 additions and 265 deletions

View file

@ -18,7 +18,6 @@ exclude = [
"*.log", "*.log",
"tags", "tags",
] ]
autoexamples = true
edition = "2021" edition = "2021"
rust-version = "1.74.0" rust-version = "1.74.0"
@ -218,6 +217,11 @@ name = "barchart"
required-features = ["crossterm"] required-features = ["crossterm"]
doc-scrape-examples = true doc-scrape-examples = true
[[example]]
name = "barchart-grouped"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]] [[example]]
name = "block" name = "block"
required-features = ["crossterm"] required-features = ["crossterm"]

View file

@ -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 > 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. > `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 ## Demo2
This is the demo example from the main README and crate page. Source: [demo2](./demo2/). This is the demo example from the main README and crate page. Source: [demo2](./demo2/).

View file

@ -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<Bar> = 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<Bar> = 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<CrosstermBackend<Stdout>>;
/// 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<Terminal> {
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);
}));
}
}

View file

@ -13,290 +13,187 @@
//! [examples]: https://github.com/ratatui-org/ratatui/blob/main/examples //! [examples]: https://github.com/ratatui-org/ratatui/blob/main/examples
//! [examples readme]: https://github.com/ratatui-org/ratatui/blob/main/examples/README.md //! [examples readme]: https://github.com/ratatui-org/ratatui/blob/main/examples/README.md
use std::{ use color_eyre::Result;
error::Error, use rand::{thread_rng, Rng};
io,
time::{Duration, Instant},
};
use ratatui::{ use ratatui::{
backend::{Backend, CrosstermBackend}, crossterm::event::{self, Event, KeyCode},
crossterm::{ layout::{Constraint, Direction, Layout},
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, prelude::{Color, Line, Style, Stylize},
execute, widgets::{Bar, BarChart, BarGroup, Block},
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,
}; };
struct Company<'a> { use self::terminal::Terminal;
revenue: [u64; 4],
label: &'a str, struct App {
bar_style: Style, should_exit: bool,
temperatures: Vec<u8>,
} }
struct App<'a> { fn main() -> Result<()> {
data: Vec<(&'a str, u64)>, color_eyre::install()?;
months: [&'a str; 4], let mut terminal = terminal::init()?;
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<dyn Error>> {
// 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 app = App::new();
let res = run_app(&mut terminal, app, tick_rate); app.run(&mut terminal)?;
terminal::restore()?;
// restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
Ok(()) Ok(())
} }
fn run_app<B: Backend>( impl App {
terminal: &mut Terminal<B>, fn new() -> Self {
mut app: App, let mut rng = thread_rng();
tick_rate: Duration, let temperatures = (0..24).map(|_| rng.gen_range(50..90)).collect();
) -> io::Result<()> { Self {
let mut last_tick = Instant::now(); should_exit: false,
loop { temperatures,
terminal.draw(|f| ui(f, &app))?; }
}
let timeout = tick_rate.saturating_sub(last_tick.elapsed()); fn run(mut self, terminal: &mut Terminal) -> Result<()> {
if crossterm::event::poll(timeout)? { while !self.should_exit {
if let Event::Key(key) = event::read()? { self.draw(terminal)?;
if key.code == KeyCode::Char('q') { self.handle_events()?;
return Ok(()); }
} 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 { Ok(())
app.on_tick(); }
last_tick = Instant::now();
} 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) { /// Create a vertical bar chart from the temperatures data.
let vertical = Layout::vertical([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]); fn vertical_barchart(temperatures: &[u8]) -> BarChart {
let horizontal = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]); let bars: Vec<Bar> = temperatures
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<BarGroup<'a>> {
app.months
.iter() .iter()
.map(|v| u64::from(*v))
.enumerate() .enumerate()
.map(|(i, &month)| { .map(|(i, value)| {
let bars: Vec<Bar> = app Bar::default()
.companies .value(value)
.iter() .label(Line::from(format!("{i:>02}:00")))
.map(|c| { .text_value(format!("{value:>3}°"))
let mut bar = Bar::default() .style(temperature_style(value))
.value(c.revenue[i]) .value_style(temperature_style(value).reversed())
.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)
}) })
.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)] /// Create a horizontal bar chart from the temperatures data.
fn draw_bar_with_group_labels(f: &mut Frame, app: &App, area: Rect) { fn horizontal_barchart(temperatures: &[u8]) -> BarChart {
const LEGEND_HEIGHT: u16 = 6; let bars: Vec<Bar> = temperatures
.iter()
let groups = create_groups(app, false); .map(|v| u64::from(*v))
.enumerate()
let mut barchart = BarChart::default() .map(|(i, value)| {
.block(Block::bordered().title("Data1")) let style = temperature_style(value);
.bar_width(7) Bar::default()
.group_gap(3); .value(value)
.label(Line::from(format!("{i:>02}:00")))
for group in groups { .text_value(format!("{value:>3}°"))
barchart = barchart.data(group); .style(style)
} .value_style(style.reversed())
})
f.render_widget(barchart, area); .collect();
let title = Line::from("Weather (Horizontal)").centered();
if area.height >= LEGEND_HEIGHT && area.width >= TOTAL_REVENUE.len() as u16 + 2 { BarChart::default()
let legend_width = TOTAL_REVENUE.len() as u16 + 2; .block(Block::new().title(title))
let legend_area = Rect { .data(BarGroup::default().bars(&bars))
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"))
.bar_width(1) .bar_width(1)
.group_gap(1)
.bar_gap(0) .bar_gap(0)
.direction(Direction::Horizontal); .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);
}
} }
fn draw_legend(f: &mut Frame, area: Rect) { /// create a yellow to red value based on the value (50-90)
let text = vec![ fn temperature_style(value: u64) -> Style {
Line::from(Span::styled( let green = (255.0 * (1.0 - (value - 50) as f64 / 40.0)) as u8;
TOTAL_REVENUE, let color = Color::Rgb(255, green, 0);
Style::default() Style::new().fg(color)
.add_modifier(Modifier::BOLD) }
.fg(Color::White),
)), /// Contains functions common to all examples
Line::from(Span::styled( mod terminal {
"- Company A", use std::{
Style::default().fg(Color::Green), io::{self, stdout, Stdout},
)), panic,
Line::from(Span::styled( };
"- Company B",
Style::default().fg(Color::Yellow), use ratatui::{
)), backend::CrosstermBackend,
Line::from(Span::styled( crossterm::{
"- Company C", execute,
Style::default().fg(Color::White), terminal::{
)), disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
]; LeaveAlternateScreen,
},
let block = Block::bordered().style(Style::default().fg(Color::White)); },
let paragraph = Paragraph::new(text).block(block); };
f.render_widget(paragraph, area);
// 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<CrosstermBackend<Stdout>>;
/// 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<Terminal> {
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);
}));
}
} }

View file

@ -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

View file

@ -3,10 +3,10 @@
Output "target/barchart.gif" Output "target/barchart.gif"
Set Theme "Aardvark Blue" Set Theme "Aardvark Blue"
Set Width 1200 Set Width 1200
Set Height 800 Set Height 600
Hide Hide
Type "cargo run --example=barchart" Type "cargo run --example=barchart"
Enter Enter
Sleep 1s Sleep 1s
Show Show
Sleep 5s Sleep 1s