docs(examples): add animation and FPS counter to colors_rgb (#583)

This commit is contained in:
Josh McKinney 2023-12-17 01:34:59 -08:00 committed by GitHub
parent 5bf4f52119
commit 1b8b6261e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 183 additions and 90 deletions

View file

@ -48,11 +48,12 @@ better-panic = "0.3.0"
cargo-husky = { version = "1.5.0", default-features = false, features = [ cargo-husky = { version = "1.5.0", default-features = false, features = [
"user-hooks", "user-hooks",
] } ] }
color-eyre = "0.6.2"
criterion = { version = "0.5.1", features = ["html_reports"] } criterion = { version = "0.5.1", features = ["html_reports"] }
fakeit = "1.1" fakeit = "1.1"
rand = "0.8.5"
palette = "0.7.3" palette = "0.7.3"
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"
rand = "0.8.5"
[features] [features]
#! The crate provides a set of optional features that can be enabled in your `cargo.toml` file. #! The crate provides a set of optional features that can be enabled in your `cargo.toml` file.

View file

@ -117,7 +117,10 @@ two square-ish pixels in the space of a single rectangular terminal cell.
cargo run --example=colors_rgb --features=crossterm cargo run --example=colors_rgb --features=crossterm
``` ```
![Colors RGB][colors_rgb.png] Note: VHs renders full screen animations poorly, so this is a screen capture rather than the output
of the VHS tape.
<https://github.com/ratatui-org/ratatui/assets/381361/485e775a-e0b5-4133-899b-1e8aeb56e774>
## Custom Widget ## Custom Widget
@ -308,7 +311,6 @@ examples/generate.bash
[canvas.gif]: https://github.com/ratatui-org/ratatui/blob/images/examples/canvas.gif?raw=true [canvas.gif]: https://github.com/ratatui-org/ratatui/blob/images/examples/canvas.gif?raw=true
[chart.gif]: https://github.com/ratatui-org/ratatui/blob/images/examples/chart.gif?raw=true [chart.gif]: https://github.com/ratatui-org/ratatui/blob/images/examples/chart.gif?raw=true
[colors.gif]: https://github.com/ratatui-org/ratatui/blob/images/examples/colors.gif?raw=true [colors.gif]: https://github.com/ratatui-org/ratatui/blob/images/examples/colors.gif?raw=true
[colors_rgb.png]: https://github.com/ratatui-org/ratatui/blob/images/examples/colors_rgb.png?raw=true
[custom_widget.gif]: https://github.com/ratatui-org/ratatui/blob/images/examples/custom_widget.gif?raw=true [custom_widget.gif]: https://github.com/ratatui-org/ratatui/blob/images/examples/custom_widget.gif?raw=true
[demo.gif]: https://github.com/ratatui-org/ratatui/blob/images/examples/demo.gif?raw=true [demo.gif]: https://github.com/ratatui-org/ratatui/blob/images/examples/demo.gif?raw=true
[demo2.gif]: https://github.com/ratatui-org/ratatui/blob/images/examples/demo2.gif?raw=true [demo2.gif]: https://github.com/ratatui-org/ratatui/blob/images/examples/demo2.gif?raw=true

View file

@ -2,63 +2,85 @@
/// ///
/// Requires a terminal that supports 24-bit color (true color) and unicode. /// Requires a terminal that supports 24-bit color (true color) and unicode.
use std::{ use std::{
error::Error, io::stdout,
io::{stdout, Stdout}, time::{Duration, Instant},
rc::Rc,
time::Duration,
}; };
use color_eyre::config::HookBuilder;
use crossterm::{ use crossterm::{
event::{self, Event, KeyCode, KeyEventKind}, event::{self, Event, KeyCode, KeyEventKind},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand, ExecutableCommand,
}; };
use palette::{ use palette::{convert::FromColorUnclamped, Okhsv, Srgb};
convert::{FromColorUnclamped, IntoColorUnclamped},
Okhsv, Srgb,
};
use ratatui::{prelude::*, widgets::*}; use ratatui::{prelude::*, widgets::*};
type Result<T> = std::result::Result<T, Box<dyn Error>>; fn main() -> color_eyre::Result<()> {
App::run()
fn main() -> Result<()> {
install_panic_hook();
App::new()?.run()
} }
#[derive(Debug, Default)]
struct App { struct App {
terminal: Terminal<CrosstermBackend<Stdout>>,
should_quit: bool, should_quit: bool,
// a 2d vec of the colors to render, calculated when the size changes as this is expensive
// to calculate every frame
colors: Vec<Vec<Color>>,
last_size: Rect,
fps: Fps,
frame_count: usize,
}
#[derive(Debug)]
struct Fps {
frame_count: usize,
last_instant: Instant,
fps: Option<f32>,
}
struct AppWidget<'a> {
title: Paragraph<'a>,
fps_widget: FpsWidget<'a>,
rgb_colors_widget: RgbColorsWidget<'a>,
}
struct FpsWidget<'a> {
fps: &'a Fps,
}
struct RgbColorsWidget<'a> {
/// The colors to render - should be double the height of the area
colors: &'a Vec<Vec<Color>>,
/// the number of elapsed frames that have passed - used to animate the colors
frame_count: usize,
} }
impl App { impl App {
pub fn new() -> Result<Self> { pub fn run() -> color_eyre::Result<()> {
Ok(Self { install_panic_hook()?;
terminal: Terminal::new(CrosstermBackend::new(stdout()))?,
should_quit: false,
})
}
pub fn run(mut self) -> Result<()> { let mut terminal = init_terminal()?;
init_terminal()?; let mut app = Self::default();
self.terminal.clear()?;
while !self.should_quit { while !app.should_quit {
self.draw()?; app.tick();
self.handle_events()?; terminal.draw(|frame| {
let size = frame.size();
app.setup_colors(size);
frame.render_widget(AppWidget::new(&app), size);
})?;
app.handle_events()?;
} }
restore_terminal()?; restore_terminal()?;
Ok(()) Ok(())
} }
fn draw(&mut self) -> Result<()> { fn tick(&mut self) {
self.terminal.draw(|frame| { self.frame_count += 1;
frame.render_widget(RgbColors, frame.size()); self.fps.tick();
})?;
Ok(())
} }
fn handle_events(&mut self) -> Result<()> { fn handle_events(&mut self) -> color_eyre::Result<()> {
if event::poll(Duration::from_millis(100))? { if event::poll(Duration::from_secs_f32(1.0 / 60.0))? {
if let Event::Key(key) = event::read()? { if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
self.should_quit = true; self.should_quit = true;
@ -67,80 +89,140 @@ impl App {
} }
Ok(()) Ok(())
} }
}
impl Drop for App { fn setup_colors(&mut self, size: Rect) {
fn drop(&mut self) { // only update the colors if the size has changed since the last time we rendered
let _ = restore_terminal(); if self.last_size.width == size.width && self.last_size.height == size.height {
return;
}
self.last_size = size;
let Rect { width, height, .. } = size;
// double the height because each screen row has two rows of half block pixels
let height = height * 2;
self.colors.clear();
for y in 0..height {
let mut row = Vec::new();
for x in 0..width {
let hue = x as f32 * 360.0 / width as f32;
let value = (height - y) as f32 / height as f32;
let saturation = Okhsv::max_saturation();
let color = Okhsv::new(hue, saturation, value);
let color = Srgb::<f32>::from_color_unclamped(color);
let color: Srgb<u8> = color.into_format();
let color = Color::Rgb(color.red, color.green, color.blue);
row.push(color);
}
self.colors.push(row);
}
} }
} }
struct RgbColors; impl Fps {
fn tick(&mut self) {
self.frame_count += 1;
let elapsed = self.last_instant.elapsed();
// update the fps every second, but only if we've rendered at least 2 frames (to avoid
// noise in the fps calculation)
if elapsed > Duration::from_secs(1) && self.frame_count > 2 {
self.fps = Some(self.frame_count as f32 / elapsed.as_secs_f32());
self.frame_count = 0;
self.last_instant = Instant::now();
}
}
}
impl Widget for RgbColors { impl Default for Fps {
fn default() -> Self {
Self {
frame_count: 0,
last_instant: Instant::now(),
fps: None,
}
}
}
impl<'a> AppWidget<'a> {
fn new(app: &'a App) -> Self {
let title =
Paragraph::new("colors_rgb example. Press q to quit").alignment(Alignment::Center);
Self {
title,
fps_widget: FpsWidget { fps: &app.fps },
rgb_colors_widget: RgbColorsWidget {
colors: &app.colors,
frame_count: app.frame_count,
},
}
}
}
impl Widget for AppWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let layout = Self::layout(area); let main_layout = Layout::default()
Self::render_title(layout[0], buf);
Self::render_colors(layout[1], buf);
}
}
impl RgbColors {
fn layout(area: Rect) -> Rc<[Rect]> {
Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)]) .constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area) .split(area);
} let title_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(8)])
.split(main_layout[0]);
fn render_title(area: Rect, buf: &mut Buffer) { self.title.render(title_layout[0], buf);
Paragraph::new("colors_rgb example. Press q to quit") self.fps_widget.render(title_layout[1], buf);
.dark_gray() self.rgb_colors_widget.render(main_layout[1], buf);
.alignment(Alignment::Center)
.render(area, buf);
} }
}
/// Render a colored grid of half block characters (`"▀"`) each with a different RGB color. impl Widget for RgbColorsWidget<'_> {
fn render_colors(area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let colors = self.colors;
for (xi, x) in (area.left()..area.right()).enumerate() { for (xi, x) in (area.left()..area.right()).enumerate() {
// animate the colors by shifting the x index by the frame number
let xi = (xi + self.frame_count) % (area.width as usize);
for (yi, y) in (area.top()..area.bottom()).enumerate() { for (yi, y) in (area.top()..area.bottom()).enumerate() {
let hue = xi as f32 * 360.0 / area.width as f32; let fg = colors[yi * 2][xi];
let bg = colors[yi * 2 + 1][xi];
let value_fg = (yi as f32) / (area.height as f32 - 0.5);
let fg = Okhsv::<f32>::new(hue, Okhsv::max_saturation(), value_fg);
let fg: Srgb = fg.into_color_unclamped();
let fg: Srgb<u8> = fg.into_format();
let fg = Color::Rgb(fg.red, fg.green, fg.blue);
let value_bg = (yi as f32 + 0.5) / (area.height as f32 - 0.5);
let bg = Okhsv::new(hue, Okhsv::max_saturation(), value_bg);
let bg = Srgb::<f32>::from_color_unclamped(bg);
let bg: Srgb<u8> = bg.into_format();
let bg = Color::Rgb(bg.red, bg.green, bg.blue);
buf.get_mut(x, y).set_char('▀').set_fg(fg).set_bg(bg); buf.get_mut(x, y).set_char('▀').set_fg(fg).set_bg(bg);
} }
} }
} }
} }
/// Install a panic hook that restores the terminal before panicking. impl<'a> Widget for FpsWidget<'a> {
fn install_panic_hook() { fn render(self, area: Rect, buf: &mut Buffer) {
better_panic::install(); if let Some(fps) = self.fps.fps {
let prev_hook = std::panic::take_hook(); let text = format!("{:.1} fps", fps);
std::panic::set_hook(Box::new(move |info| { Paragraph::new(text).render(area, buf);
let _ = restore_terminal(); }
prev_hook(info); }
}));
} }
fn init_terminal() -> Result<()> { /// Install a panic hook that restores the terminal before panicking.
enable_raw_mode()?; fn install_panic_hook() -> color_eyre::Result<()> {
stdout().execute(EnterAlternateScreen)?; 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(()) Ok(())
} }
fn restore_terminal() -> Result<()> { fn init_terminal() -> color_eyre::Result<Terminal<impl Backend>> {
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
terminal.clear()?;
terminal.hide_cursor()?;
Ok(terminal)
}
fn restore_terminal() -> color_eyre::Result<()> {
disable_raw_mode()?; disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?; stdout().execute(LeaveAlternateScreen)?;
Ok(()) Ok(())

View file

@ -1,13 +1,21 @@
# This is a vhs script. See https://github.com/charmbracelet/vhs for more info. # This is a vhs script. See https://github.com/charmbracelet/vhs for more info.
# To run this script, install vhs and run `vhs ./examples/colors_rgb.tape` # To run this script, install vhs and run `vhs ./examples/colors_rgb.tape`
# note that this script sometimes results in the gif having screen tearing
# issues. I'm not sure why, but it's not a problem with the library.
Output "target/colors_rgb.gif" Output "target/colors_rgb.gif"
Set Theme "Aardvark Blue" Set Theme "Aardvark Blue"
Set Width 1200 Set Width 1200
Set Height 800 Set Height 1200
# unsure if these help the screen tearing issue, but they don't hurt
Set Framerate 60
Set CursorBlink false
Hide Hide
Type "cargo run --example=colors_rgb --features=crossterm" Type "cargo run --example=colors_rgb --features=crossterm --release"
Enter Enter
Sleep 2s Sleep 2s
Screenshot "target/colors_rgb.png" # Screenshot "target/colors_rgb.png"
Show Show
Sleep 1s Sleep 10s