mirror of
https://github.com/ratatui-org/ratatui
synced 2024-11-29 16:10:34 +00:00
815757fcbb
Many widgets can be rendered without changing their state. This commit implements The `Widget` trait for references to widgets and changes their implementations to be immutable. This allows us to render widgets without consuming them by passing a ref to the widget when calling `Frame::render_widget()`. ```rust // this might be stored in a struct let paragraph = Paragraph::new("Hello world!"); let [left, right] = area.split(&Layout::horizontal([20, 20])); frame.render_widget(¶graph, left); frame.render_widget(¶graph, right); // we can reuse the widget ``` Implemented for all widgets except BarChart (which has an implementation that modifies the internal state and requires a rewrite to fix. Other widgets will be implemented in follow up commits. Fixes: https://github.com/ratatui-org/ratatui/discussions/164 Replaces PRs: https://github.com/ratatui-org/ratatui/pull/122 and https://github.com/ratatui-org/ratatui/pull/16 Enables: https://github.com/ratatui-org/ratatui/issues/132 Validated as a viable working solution by: https://github.com/ratatui-org/ratatui/pull/836
272 lines
9.5 KiB
Rust
272 lines
9.5 KiB
Rust
/// This example shows the full range of RGB colors that can be displayed in the terminal.
|
|
///
|
|
/// Requires a terminal that supports 24-bit color (true color) and unicode.
|
|
///
|
|
/// This example also demonstrates how implementing the Widget trait on a mutable reference
|
|
/// allows the widget to update its state while it is being rendered. This allows the fps
|
|
/// widget to update the fps calculation and the colors widget to update a cached version of
|
|
/// the colors to render instead of recalculating them every frame.
|
|
///
|
|
/// This is an alternative to using the StatefulWidget trait and a separate state struct. It is
|
|
/// useful when the state is only used by the widget and doesn't need to be shared with other
|
|
/// widgets.
|
|
use std::{
|
|
io::stdout,
|
|
panic,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use color_eyre::{config::HookBuilder, eyre, Result};
|
|
use crossterm::{
|
|
event::{self, Event, KeyCode, KeyEventKind},
|
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
ExecutableCommand,
|
|
};
|
|
use palette::{convert::FromColorUnclamped, Okhsv, Srgb};
|
|
use ratatui::prelude::*;
|
|
|
|
#[derive(Debug, Default)]
|
|
struct App {
|
|
/// The current state of the app (running or quit)
|
|
state: AppState,
|
|
|
|
/// A widget that displays the current frames per second
|
|
fps_widget: FpsWidget,
|
|
|
|
/// A widget that displays the full range of RGB colors that can be displayed in the terminal.
|
|
colors_widget: ColorsWidget,
|
|
}
|
|
|
|
#[derive(Debug, Default, PartialEq, Eq)]
|
|
enum AppState {
|
|
/// The app is running
|
|
#[default]
|
|
Running,
|
|
|
|
/// The user has requested the app to quit
|
|
Quit,
|
|
}
|
|
|
|
/// A widget that displays the current frames per second
|
|
#[derive(Debug)]
|
|
struct FpsWidget {
|
|
/// The number of elapsed frames that have passed - used to calculate the fps
|
|
frame_count: usize,
|
|
|
|
/// The last instant that the fps was calculated
|
|
last_instant: Instant,
|
|
|
|
/// The current frames per second
|
|
fps: Option<f32>,
|
|
}
|
|
|
|
/// A widget that displays the full range of RGB colors that can be displayed in the terminal.
|
|
///
|
|
/// This widget is animated and will change colors over time.
|
|
#[derive(Debug, Default)]
|
|
struct ColorsWidget {
|
|
/// The colors to render - should be double the height of the area as we render two rows of
|
|
/// pixels for each row of the widget using the half block character. This is computed any time
|
|
/// the size of the widget changes.
|
|
colors: Vec<Vec<Color>>,
|
|
|
|
/// the number of elapsed frames that have passed - used to animate the colors by shifting the
|
|
/// x index by the frame number
|
|
frame_count: usize,
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
install_error_hooks()?;
|
|
let terminal = init_terminal()?;
|
|
App::default().run(terminal)?;
|
|
restore_terminal()?;
|
|
Ok(())
|
|
}
|
|
|
|
impl App {
|
|
/// Run the app
|
|
///
|
|
/// This is the main event loop for the app.
|
|
pub fn run(mut self, mut terminal: Terminal<impl Backend>) -> Result<()> {
|
|
while self.is_running() {
|
|
terminal.draw(|frame| frame.render_widget(&mut self, frame.size()))?;
|
|
self.handle_events()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn is_running(&self) -> bool {
|
|
matches!(self.state, AppState::Running)
|
|
}
|
|
|
|
/// Handle any events that have occurred since the last time the app was rendered.
|
|
///
|
|
/// Currently, this only handles the q key to quit the app.
|
|
fn handle_events(&mut self) -> Result<()> {
|
|
// Ensure that the app only blocks for a period that allows the app to render at
|
|
// approximately 60 FPS (this doesn't account for the time to render the frame, and will
|
|
// also update the app immediately any time an event occurs)
|
|
let timeout = Duration::from_secs_f32(1.0 / 60.0);
|
|
if event::poll(timeout)? {
|
|
if let Event::Key(key) = event::read()? {
|
|
if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
|
|
self.state = AppState::Quit;
|
|
};
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Implement the Widget trait for &mut App so that it can be rendered
|
|
///
|
|
/// This is implemented on a mutable reference so that the app can update its state while it is
|
|
/// being rendered. This allows the fps widget to update the fps calculation and the colors widget
|
|
/// to update the colors to render.
|
|
impl Widget for &mut App {
|
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
use Constraint::*;
|
|
let [top, colors] = area.split(&Layout::vertical([Length(1), Min(0)]));
|
|
let [title, fps] = top.split(&Layout::horizontal([Min(0), Length(8)]));
|
|
Text::from("colors_rgb example. Press q to quit")
|
|
.centered()
|
|
.render(title, buf);
|
|
self.fps_widget.render(fps, buf);
|
|
self.colors_widget.render(colors, buf);
|
|
}
|
|
}
|
|
|
|
/// Default impl for FpsWidget
|
|
///
|
|
/// Manual impl is required because we need to initialize the last_instant field to the current
|
|
/// instant.
|
|
impl Default for FpsWidget {
|
|
fn default() -> Self {
|
|
Self {
|
|
frame_count: 0,
|
|
last_instant: Instant::now(),
|
|
fps: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Widget impl for FpsWidget
|
|
///
|
|
/// This is implemented on a mutable reference so that we can update the frame count and fps
|
|
/// calculation while rendering.
|
|
impl Widget for &mut FpsWidget {
|
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
self.calculate_fps();
|
|
if let Some(fps) = self.fps {
|
|
let text = format!("{:.1} fps", fps);
|
|
Text::from(text).render(area, buf);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FpsWidget {
|
|
/// Update the fps calculation.
|
|
///
|
|
/// This updates the fps once a second, but only if the widget has rendered at least 2 frames
|
|
/// since the last calculation. This avoids noise in the fps calculation when rendering on slow
|
|
/// machines that can't render at least 2 frames per second.
|
|
fn calculate_fps(&mut self) {
|
|
self.frame_count += 1;
|
|
let elapsed = self.last_instant.elapsed();
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Widget impl for ColorsWidget
|
|
///
|
|
/// This is implemented on a mutable reference so that we can update the frame count and store a
|
|
/// cached version of the colors to render instead of recalculating them every frame.
|
|
impl Widget for &mut ColorsWidget {
|
|
/// Render the widget
|
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
|
self.setup_colors(area);
|
|
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() {
|
|
// render a half block character for each row of pixels with the foreground color
|
|
// set to the color of the pixel and the background color set to the color of the
|
|
// pixel below it
|
|
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);
|
|
}
|
|
}
|
|
self.frame_count += 1;
|
|
}
|
|
}
|
|
|
|
impl ColorsWidget {
|
|
/// Setup the colors to render.
|
|
///
|
|
/// This is called once per frame to setup the colors to render. It caches the colors so that
|
|
/// they don't need to be recalculated every frame.
|
|
fn setup_colors(&mut self, size: Rect) {
|
|
let Rect { width, height, .. } = size;
|
|
// double the height because each screen row has two rows of half block pixels
|
|
let height = height as usize * 2;
|
|
let width = width as usize;
|
|
// only update the colors if the size has changed since the last time we rendered
|
|
if self.colors.len() == height && self.colors[0].len() == width {
|
|
return;
|
|
}
|
|
self.colors = Vec::with_capacity(height);
|
|
for y in 0..height {
|
|
let mut row = Vec::with_capacity(width);
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Install color_eyre panic and error hooks
|
|
///
|
|
/// The hooks restore the terminal to a usable state before printing the error message.
|
|
fn install_error_hooks() -> Result<()> {
|
|
let (panic, error) = HookBuilder::default().into_hooks();
|
|
let panic = panic.into_panic_hook();
|
|
let error = error.into_eyre_hook();
|
|
eyre::set_hook(Box::new(move |e| {
|
|
let _ = restore_terminal();
|
|
error(e)
|
|
}))?;
|
|
panic::set_hook(Box::new(move |info| {
|
|
let _ = restore_terminal();
|
|
panic(info)
|
|
}));
|
|
Ok(())
|
|
}
|
|
|
|
fn init_terminal() -> 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() -> Result<()> {
|
|
disable_raw_mode()?;
|
|
stdout().execute(LeaveAlternateScreen)?;
|
|
Ok(())
|
|
}
|