Skip to content

Commit

Permalink
docs(examples): add animation and FPS counter to colors_rgb (#583)
Browse files Browse the repository at this point in the history
  • Loading branch information
joshka authored Dec 17, 2023
1 parent 5bf4f52 commit 1b8b626
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 84 deletions.
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<https://github.com/ratatui-org/ratatui/assets/381361/485e775a-e0b5-4133-899b-1e8aeb56e774>

## Custom Widget

Expand Down Expand Up @@ -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
Expand Down
236 changes: 159 additions & 77 deletions examples/colors_rgb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = std::result::Result<T, Box<dyn Error>>;

fn main() -> Result<()> {
install_panic_hook();
App::new()?.run()
fn main() -> color_eyre::Result<()> {
App::run()
}

#[derive(Debug, Default)]
struct App {
terminal: Terminal<CrosstermBackend<Stdout>>,
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,
}

impl App {
pub fn new() -> Result<Self> {
Ok(Self {
terminal: Terminal::new(CrosstermBackend::new(stdout()))?,
should_quit: false,
})
}
#[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,
}

pub fn run(mut self) -> Result<()> {
init_terminal()?;
self.terminal.clear()?;
while !self.should_quit {
self.draw()?;
self.handle_events()?;
impl App {
pub fn run() -> color_eyre::Result<()> {
install_panic_hook()?;

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;
Expand All @@ -67,80 +89,140 @@ impl App {
}
Ok(())
}

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::<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);
}
}
}

impl Drop for App {
fn drop(&mut self) {
let _ = restore_terminal();
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();
}
}
}

struct RgbColors;
impl Default for Fps {
fn default() -> Self {
Self {
frame_count: 0,
last_instant: Instant::now(),
fps: None,
}
}
}

impl Widget for RgbColors {
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<'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 RgbColors {
fn layout(area: Rect) -> Rc<[Rect]> {
Layout::default()
impl Widget for AppWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area)
}

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);
.split(area);
let title_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(8)])
.split(main_layout[0]);

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::<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);

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);
}
}
}
}

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);
}
}
}

/// Install a panic hook that restores the terminal before panicking.
fn install_panic_hook() {
better_panic::install();
let prev_hook = std::panic::take_hook();
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();
prev_hook(info);
panic(info)
}));
Ok(())
}

fn init_terminal() -> Result<()> {
fn init_terminal() -> color_eyre::Result<Terminal<impl Backend>> {
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
Ok(())
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
terminal.clear()?;
terminal.hide_cursor()?;
Ok(terminal)
}

fn restore_terminal() -> Result<()> {
fn restore_terminal() -> color_eyre::Result<()> {
disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
Ok(())
Expand Down
16 changes: 12 additions & 4 deletions examples/colors_rgb.tape
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 1b8b626

Please sign in to comment.