diff --git a/Cargo.toml b/Cargo.toml index d080a3b5..6fb32dbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,11 +48,12 @@ better-panic = "0.3.0" cargo-husky = { version = "1.5.0", default-features = false, features = [ "user-hooks", ] } +color-eyre = "0.6.2" criterion = { version = "0.5.1", features = ["html_reports"] } fakeit = "1.1" -rand = "0.8.5" palette = "0.7.3" pretty_assertions = "1.4.0" +rand = "0.8.5" [features] #! The crate provides a set of optional features that can be enabled in your `cargo.toml` file. diff --git a/examples/README.md b/examples/README.md index c1f5c46e..418249ee 100644 --- a/examples/README.md +++ b/examples/README.md @@ -117,7 +117,10 @@ two square-ish pixels in the space of a single rectangular terminal cell. 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. + + ## Custom Widget @@ -308,7 +311,6 @@ examples/generate.bash [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 [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 [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 diff --git a/examples/colors_rgb.rs b/examples/colors_rgb.rs index b15c859a..426c761b 100644 --- a/examples/colors_rgb.rs +++ b/examples/colors_rgb.rs @@ -2,63 +2,85 @@ /// /// Requires a terminal that supports 24-bit color (true color) and unicode. use std::{ - error::Error, - io::{stdout, Stdout}, - rc::Rc, - time::Duration, + io::stdout, + time::{Duration, Instant}, }; +use color_eyre::config::HookBuilder; use crossterm::{ event::{self, Event, KeyCode, KeyEventKind}, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }; -use palette::{ - convert::{FromColorUnclamped, IntoColorUnclamped}, - Okhsv, Srgb, -}; +use palette::{convert::FromColorUnclamped, Okhsv, Srgb}; use ratatui::{prelude::*, widgets::*}; -type Result = std::result::Result>; - -fn main() -> Result<()> { - install_panic_hook(); - App::new()?.run() +fn main() -> color_eyre::Result<()> { + App::run() } +#[derive(Debug, Default)] struct App { - terminal: Terminal>, 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>, + last_size: Rect, + fps: Fps, + frame_count: usize, +} + +#[derive(Debug)] +struct Fps { + frame_count: usize, + last_instant: Instant, + fps: Option, +} + +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>, + /// the number of elapsed frames that have passed - used to animate the colors + frame_count: usize, } impl App { - pub fn new() -> Result { - Ok(Self { - terminal: Terminal::new(CrosstermBackend::new(stdout()))?, - should_quit: false, - }) - } + pub fn run() -> color_eyre::Result<()> { + install_panic_hook()?; - pub fn run(mut self) -> Result<()> { - init_terminal()?; - self.terminal.clear()?; - while !self.should_quit { - self.draw()?; - self.handle_events()?; + let mut terminal = init_terminal()?; + let mut app = Self::default(); + + while !app.should_quit { + app.tick(); + terminal.draw(|frame| { + let size = frame.size(); + app.setup_colors(size); + frame.render_widget(AppWidget::new(&app), size); + })?; + app.handle_events()?; } restore_terminal()?; Ok(()) } - fn draw(&mut self) -> Result<()> { - self.terminal.draw(|frame| { - frame.render_widget(RgbColors, frame.size()); - })?; - Ok(()) + fn tick(&mut self) { + self.frame_count += 1; + self.fps.tick(); } - fn handle_events(&mut self) -> Result<()> { - if event::poll(Duration::from_millis(100))? { + fn handle_events(&mut self) -> color_eyre::Result<()> { + if event::poll(Duration::from_secs_f32(1.0 / 60.0))? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') { self.should_quit = true; @@ -67,80 +89,140 @@ impl App { } Ok(()) } -} -impl Drop for App { - fn drop(&mut self) { - let _ = restore_terminal(); + fn setup_colors(&mut self, size: Rect) { + // only update the colors if the size has changed since the last time we rendered + 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::::from_color_unclamped(color); + let color: Srgb = 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) { - let layout = Self::layout(area); - Self::render_title(layout[0], buf); - Self::render_colors(layout[1], buf); - } -} - -impl RgbColors { - fn layout(area: Rect) -> Rc<[Rect]> { - Layout::default() + let main_layout = Layout::default() .direction(Direction::Vertical) .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) { - Paragraph::new("colors_rgb example. Press q to quit") - .dark_gray() - .alignment(Alignment::Center) - .render(area, buf); + self.title.render(title_layout[0], buf); + self.fps_widget.render(title_layout[1], buf); + self.rgb_colors_widget.render(main_layout[1], buf); } +} - /// Render a colored grid of half block characters (`"▀"`) each with a different RGB color. - fn render_colors(area: Rect, buf: &mut Buffer) { +impl Widget for RgbColorsWidget<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + let colors = self.colors; 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() { - let hue = xi as f32 * 360.0 / area.width as f32; - - let value_fg = (yi as f32) / (area.height as f32 - 0.5); - let fg = Okhsv::::new(hue, Okhsv::max_saturation(), value_fg); - let fg: Srgb = fg.into_color_unclamped(); - let fg: Srgb = 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::::from_color_unclamped(bg); - let bg: Srgb = bg.into_format(); - let bg = Color::Rgb(bg.red, bg.green, bg.blue); - + let fg = colors[yi * 2][xi]; + let bg = colors[yi * 2 + 1][xi]; buf.get_mut(x, y).set_char('▀').set_fg(fg).set_bg(bg); } } } } -/// Install a panic hook that restores the terminal before panicking. -fn install_panic_hook() { - better_panic::install(); - let prev_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - let _ = restore_terminal(); - prev_hook(info); - })); +impl<'a> Widget for FpsWidget<'a> { + fn render(self, area: Rect, buf: &mut Buffer) { + if let Some(fps) = self.fps.fps { + let text = format!("{:.1} fps", fps); + Paragraph::new(text).render(area, buf); + } + } } -fn init_terminal() -> Result<()> { - enable_raw_mode()?; - stdout().execute(EnterAlternateScreen)?; +/// Install a panic hook that restores the terminal before panicking. +fn install_panic_hook() -> color_eyre::Result<()> { + 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(()) } -fn restore_terminal() -> Result<()> { +fn init_terminal() -> color_eyre::Result> { + 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()?; stdout().execute(LeaveAlternateScreen)?; Ok(()) diff --git a/examples/colors_rgb.tape b/examples/colors_rgb.tape index fafb9310..7bc27f89 100644 --- a/examples/colors_rgb.tape +++ b/examples/colors_rgb.tape @@ -1,13 +1,21 @@ # 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` + +# 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" Set Theme "Aardvark Blue" 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 -Type "cargo run --example=colors_rgb --features=crossterm" +Type "cargo run --example=colors_rgb --features=crossterm --release" Enter Sleep 2s -Screenshot "target/colors_rgb.png" +# Screenshot "target/colors_rgb.png" Show -Sleep 1s +Sleep 10s