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",
"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"]

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
> `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/).

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 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<u8>,
}
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<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);
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<B: Backend>(
terminal: &mut Terminal<B>,
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<BarGroup<'a>> {
app.months
/// Create a vertical bar chart from the temperatures data.
fn vertical_barchart(temperatures: &[u8]) -> BarChart {
let bars: Vec<Bar> = temperatures
.iter()
.map(|v| u64::from(*v))
.enumerate()
.map(|(i, &month)| {
let bars: Vec<Bar> = 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<Bar> = 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<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"
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