mirror of
https://github.com/ratatui-org/ratatui
synced 2024-11-10 07:04:17 +00:00
docs(examples): add animation and FPS counter to colors_rgb (#583)
This commit is contained in:
parent
5bf4f52119
commit
1b8b6261e2
4 changed files with 183 additions and 90 deletions
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(())
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue