Demo 2
This is the demo example from the main README and crate page. Source demo2.
git clone https://github.com/ratatui/ratatui.git --branch latestcd ratatuicargo run -p demo2
//! # [Ratatui] Demo2 example//!//! The latest version of this example is available in the [examples] folder in the repository.//!//! Please note that the examples are designed to be run against the `main` branch of the Github//! repository. This means that you may not be able to compile with the latest release version on//! crates.io, or the one that you have installed locally.//!//! See the [examples readme] for more information on finding examples that match the version of the//! library you are using.//!//! [Ratatui]: https://github.com/ratatui/ratatui//! [examples]: https://github.com/ratatui/ratatui/blob/main/examples//! [examples readme]: https://github.com/ratatui/ratatui/blob/main/examples/README.md
#![allow( clippy::missing_errors_doc, clippy::module_name_repetitions, clippy::must_use_candidate)]
mod app;mod colors;mod destroy;mod tabs;mod theme;
use std::io::stdout;
use app::App;use color_eyre::Result;use crossterm::execute;use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};use ratatui::layout::Rect;use ratatui::{TerminalOptions, Viewport};
pub use self::colors::{color_from_oklab, RgbSwatch};pub use self::theme::THEME;
fn main() -> Result<()> { color_eyre::install()?; // this size is to match the size of the terminal when running the demo // using vhs in a 1280x640 sized window (github social preview size) let viewport = Viewport::Fixed(Rect::new(0, 0, 81, 18)); let terminal = ratatui::init_with_options(TerminalOptions { viewport }); execute!(stdout(), EnterAlternateScreen).expect("failed to enter alternate screen"); let app_result = App::default().run(terminal); execute!(stdout(), LeaveAlternateScreen).expect("failed to leave alternate screen"); ratatui::restore(); app_result}use std::time::Duration;
use color_eyre::eyre::Context;use color_eyre::Result;use crossterm::event::{self, KeyCode};use itertools::Itertools;use ratatui::buffer::Buffer;use ratatui::layout::{Constraint, Layout, Rect};use ratatui::style::Color;use ratatui::text::{Line, Span};use ratatui::widgets::{Block, Tabs, Widget};use ratatui::{DefaultTerminal, Frame};use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
use crate::tabs::{AboutTab, EmailTab, RecipeTab, TracerouteTab, WeatherTab};use crate::{destroy, THEME};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]pub struct App { mode: Mode, tab: Tab, about_tab: AboutTab, recipe_tab: RecipeTab, email_tab: EmailTab, traceroute_tab: TracerouteTab, weather_tab: WeatherTab,}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]enum Mode { #[default] Running, Destroy, Quit,}
#[derive(Debug, Clone, Copy, Default, Display, EnumIter, FromRepr, PartialEq, Eq)]enum Tab { #[default] About, Recipe, Email, Traceroute, Weather,}
impl App { /// Run the app until the user quits. pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { while self.is_running() { terminal .draw(|frame| self.render(frame)) .wrap_err("terminal.draw")?; self.handle_events()?; } Ok(()) }
fn is_running(&self) -> bool { self.mode != Mode::Quit }
/// Render a single frame of the app. fn render(&self, frame: &mut Frame) { frame.render_widget(self, frame.area()); if self.mode == Mode::Destroy { destroy::destroy(frame); } }
/// Handle events from the terminal. /// /// This function is called once per frame, The events are polled from the stdin with timeout of /// 1/50th of a second. This was chosen to try to match the default frame rate of a GIF in VHS. fn handle_events(&mut self) -> Result<()> { let timeout = Duration::from_secs_f64(1.0 / 50.0); if !event::poll(timeout)? { return Ok(()); } if let Some(key) = event::read()?.as_key_press_event() { match key.code { KeyCode::Char('q') | KeyCode::Esc => self.mode = Mode::Quit, KeyCode::Char('h') | KeyCode::Left => self.prev_tab(), KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => self.next_tab(), KeyCode::Char('k') | KeyCode::Up => self.prev(), KeyCode::Char('j') | KeyCode::Down => self.next(), KeyCode::Char('d') | KeyCode::Delete => self.destroy(), _ => {} }; } Ok(()) }
fn prev(&mut self) { match self.tab { Tab::About => self.about_tab.prev_row(), Tab::Recipe => self.recipe_tab.prev(), Tab::Email => self.email_tab.prev(), Tab::Traceroute => self.traceroute_tab.prev_row(), Tab::Weather => self.weather_tab.prev(), } }
fn next(&mut self) { match self.tab { Tab::About => self.about_tab.next_row(), Tab::Recipe => self.recipe_tab.next(), Tab::Email => self.email_tab.next(), Tab::Traceroute => self.traceroute_tab.next_row(), Tab::Weather => self.weather_tab.next(), } }
fn prev_tab(&mut self) { self.tab = self.tab.prev(); }
fn next_tab(&mut self) { self.tab = self.tab.next(); }
fn destroy(&mut self) { self.mode = Mode::Destroy; }}
/// Implement Widget for &App rather than for App as we would otherwise have to clone or copy the/// entire app state on every frame. For this example, the app state is small enough that it doesn't/// matter, but for larger apps this can be a significant performance improvement.impl Widget for &App { fn render(self, area: Rect, buf: &mut Buffer) { let layout = Layout::vertical([ Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ]); let [title_bar, tab, bottom_bar] = area.layout(&layout);
Block::new().style(THEME.root).render(area, buf); self.render_title_bar(title_bar, buf); self.render_selected_tab(tab, buf); App::render_bottom_bar(bottom_bar, buf); }}
impl App { fn render_title_bar(&self, area: Rect, buf: &mut Buffer) { let layout = Layout::horizontal([Constraint::Min(0), Constraint::Length(43)]); let [title, tabs] = area.layout(&layout);
Span::styled("Ratatui", THEME.app_title).render(title, buf); let titles = Tab::iter().map(Tab::title); Tabs::new(titles) .style(THEME.tabs) .highlight_style(THEME.tabs_selected) .select(self.tab as usize) .divider("") .padding("", "") .render(tabs, buf); }
fn render_selected_tab(&self, area: Rect, buf: &mut Buffer) { match self.tab { Tab::About => self.about_tab.render(area, buf), Tab::Recipe => self.recipe_tab.render(area, buf), Tab::Email => self.email_tab.render(area, buf), Tab::Traceroute => self.traceroute_tab.render(area, buf), Tab::Weather => self.weather_tab.render(area, buf), }; }
fn render_bottom_bar(area: Rect, buf: &mut Buffer) { let keys = [ ("H/←", "Left"), ("L/→", "Right"), ("K/↑", "Up"), ("J/↓", "Down"), ("D/Del", "Destroy"), ("Q/Esc", "Quit"), ]; let spans = keys .iter() .flat_map(|(key, desc)| { let key = Span::styled(format!(" {key} "), THEME.key_binding.key); let desc = Span::styled(format!(" {desc} "), THEME.key_binding.description); [key, desc] }) .collect_vec(); Line::from(spans) .centered() .style((Color::Indexed(236), Color::Indexed(232))) .render(area, buf); }}
impl Tab { fn next(self) -> Self { let current_index = self as usize; let next_index = current_index.saturating_add(1); Self::from_repr(next_index).unwrap_or(self) }
fn prev(self) -> Self { let current_index = self as usize; let prev_index = current_index.saturating_sub(1); Self::from_repr(prev_index).unwrap_or(self) }
fn title(self) -> String { match self { Self::About => String::new(), tab => format!(" {tab} "), } }}use palette::{IntoColor, Okhsv, Srgb};use ratatui::buffer::Buffer;use ratatui::layout::Rect;use ratatui::style::Color;use ratatui::widgets::Widget;
/// A widget that renders a color swatch of RGB colors.////// The widget is rendered as a rectangle with the hue changing along the x-axis from 0.0 to 360.0/// and the value changing along the y-axis (from 1.0 to 0.0). Each pixel is rendered as a block/// character with the top half slightly lighter than the bottom half.pub struct RgbSwatch;
impl Widget for RgbSwatch { #[expect(clippy::cast_precision_loss, clippy::similar_names)] fn render(self, area: Rect, buf: &mut Buffer) { for (yi, y) in (area.top()..area.bottom()).enumerate() { let value = f32::from(area.height) - yi as f32; let value_fg = value / f32::from(area.height); let value_bg = (value - 0.5) / f32::from(area.height); for (xi, x) in (area.left()..area.right()).enumerate() { let hue = xi as f32 * 360.0 / f32::from(area.width); let fg = color_from_oklab(hue, Okhsv::max_saturation(), value_fg); let bg = color_from_oklab(hue, Okhsv::max_saturation(), value_bg); buf[(x, y)].set_char('▀').set_fg(fg).set_bg(bg); } } }}
/// Convert a hue and value into an RGB color via the Oklab color space.////// See <https://bottosson.github.io/posts/oklab/> for more details.pub fn color_from_oklab(hue: f32, saturation: f32, value: f32) -> Color { let color: Srgb = Okhsv::new(hue, saturation, value).into_color(); let color = color.into_format(); Color::Rgb(color.red, color.green, color.blue)}use rand::RngExt;use rand_chacha::rand_core::SeedableRng;use ratatui::buffer::Buffer;use ratatui::layout::{Flex, Layout, Rect};use ratatui::style::{Color, Style};use ratatui::text::Text;use ratatui::widgets::Widget;use ratatui::Frame;
/// delay the start of the animation so it doesn't start immediatelyconst DELAY: usize = 120;/// higher means more pixels per frame are modified in the animationconst DRIP_SPEED: usize = 500;/// delay the start of the text animation so it doesn't start immediately after the initial delayconst TEXT_DELAY: usize = 180;
/// Destroy mode activated by pressing `d`pub fn destroy(frame: &mut Frame<'_>) { let frame_count = frame.count().saturating_sub(DELAY); if frame_count == 0 { return; }
let area = frame.area(); let buf = frame.buffer_mut();
drip(frame_count, area, buf); text(frame_count, area, buf);}
/// Move a bunch of random pixels down one row.////// Each pick some random pixels and move them each down one row. This is a very inefficient way to/// do this, but it works well enough for this demo.#[expect( clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)]fn drip(frame_count: usize, area: Rect, buf: &mut Buffer) { // a seeded rng as we have to move the same random pixels each frame let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(10); let ramp_frames = 450; let fractional_speed = frame_count as f64 / f64::from(ramp_frames); let variable_speed = DRIP_SPEED as f64 * fractional_speed * fractional_speed * fractional_speed; let pixel_count = (frame_count as f64 * variable_speed).floor() as usize; for _ in 0..pixel_count { let src_x = rng.random_range(0..area.width); let src_y = rng.random_range(1..area.height - 2); let src = buf[(src_x, src_y)].clone(); // 1% of the time, move a blank or pixel (10:1) to the top line of the screen if rng.random_ratio(1, 100) { let dest_x = rng .random_range(src_x.saturating_sub(5)..src_x.saturating_add(5)) .clamp(area.left(), area.right() - 1); let dest_y = area.top() + 1;
let dest = &mut buf[(dest_x, dest_y)]; // copy the cell to the new location about 1/10 of the time blank out the cell the rest // of the time. This has the effect of gradually removing the pixels from the screen. if rng.random_ratio(1, 10) { *dest = src; } else { dest.reset(); } } else { // move the pixel down one row let dest_x = src_x; let dest_y = src_y.saturating_add(1).min(area.bottom() - 2); // copy the cell to the new location buf[(dest_x, dest_y)] = src; } }}
/// draw some text fading in and out from black to red and back#[expect(clippy::cast_possible_truncation, clippy::cast_precision_loss)]fn text(frame_count: usize, area: Rect, buf: &mut Buffer) { let sub_frame = frame_count.saturating_sub(TEXT_DELAY); if sub_frame == 0 { return; }
let logo = indoc::indoc! {" ██████ ████ ██████ ████ ██████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██████ ████████ ██ ████████ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ████ ██ "}; let logo_text = Text::styled(logo, Color::Rgb(255, 255, 255)); let area = centered_rect(area, logo_text.width() as u16, logo_text.height() as u16);
let mask_buf = &mut Buffer::empty(area); logo_text.render(area, mask_buf);
let percentage = (sub_frame as f64 / 480.0).clamp(0.0, 1.0);
for row in area.rows() { for col in row.columns() { let cell = &mut buf[(col.x, col.y)]; let mask_cell = &mut mask_buf[(col.x, col.y)]; cell.set_symbol(mask_cell.symbol());
// blend the mask cell color with the cell color let cell_color = cell.style().bg.unwrap_or(Color::Rgb(0, 0, 0)); let mask_color = mask_cell.style().fg.unwrap_or(Color::Rgb(255, 0, 0));
let color = blend(mask_color, cell_color, percentage); cell.set_style(Style::new().fg(color)); } }}
fn blend(mask_color: Color, cell_color: Color, percentage: f64) -> Color { let Color::Rgb(mask_red, mask_green, mask_blue) = mask_color else { return mask_color; }; let Color::Rgb(cell_red, cell_green, cell_blue) = cell_color else { return mask_color; };
let remain = 1.0 - percentage;
let red = f64::from(mask_red).mul_add(percentage, f64::from(cell_red) * remain); let green = f64::from(mask_green).mul_add(percentage, f64::from(cell_green) * remain); let blue = f64::from(mask_blue).mul_add(percentage, f64::from(cell_blue) * remain);
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] Color::Rgb(red as u8, green as u8, blue as u8)}
/// a centered rect of the given sizefn centered_rect(area: Rect, width: u16, height: u16) -> Rect { let horizontal = Layout::horizontal([width]).flex(Flex::Center); let vertical = Layout::vertical([height]).flex(Flex::Center); let [area] = area.layout(&vertical); let [area] = area.layout(&horizontal); area}mod about;mod email;mod recipe;mod traceroute;mod weather;
pub use about::AboutTab;pub use email::EmailTab;pub use recipe::RecipeTab;pub use traceroute::TracerouteTab;pub use weather::WeatherTab;use ratatui::style::{Color, Modifier, Style};
pub struct Theme { pub root: Style, pub content: Style, pub app_title: Style, pub tabs: Style, pub tabs_selected: Style, pub borders: Style, pub description: Style, pub description_title: Style, pub key_binding: KeyBinding, pub logo: Logo, pub email: Email, pub traceroute: Traceroute, pub recipe: Recipe,}
pub struct KeyBinding { pub key: Style, pub description: Style,}
pub struct Logo { pub rat_eye: Color, pub rat_eye_alt: Color,}
pub struct Email { pub tabs: Style, pub tabs_selected: Style, pub inbox: Style, pub item: Style, pub selected_item: Style, pub header: Style, pub header_value: Style, pub body: Style,}
pub struct Traceroute { pub header: Style, pub selected: Style, pub ping: Style, pub map: Map,}
pub struct Map { pub style: Style, pub color: Color, pub path: Color, pub source: Color, pub destination: Color, pub background_color: Color,}
pub struct Recipe { pub ingredients: Style, pub ingredients_header: Style,}
pub const THEME: Theme = Theme { root: Style::new().bg(DARK_BLUE), content: Style::new().bg(DARK_BLUE).fg(LIGHT_GRAY), app_title: Style::new() .fg(WHITE) .bg(DARK_BLUE) .add_modifier(Modifier::BOLD), tabs: Style::new().fg(MID_GRAY).bg(DARK_BLUE), tabs_selected: Style::new() .fg(WHITE) .bg(DARK_BLUE) .add_modifier(Modifier::BOLD) .add_modifier(Modifier::REVERSED), borders: Style::new().fg(LIGHT_GRAY), description: Style::new().fg(LIGHT_GRAY).bg(DARK_BLUE), description_title: Style::new().fg(LIGHT_GRAY).add_modifier(Modifier::BOLD), logo: Logo { rat_eye: BLACK, rat_eye_alt: RED, }, key_binding: KeyBinding { key: Style::new().fg(BLACK).bg(DARK_GRAY), description: Style::new().fg(DARK_GRAY).bg(BLACK), }, email: Email { tabs: Style::new().fg(MID_GRAY).bg(DARK_BLUE), tabs_selected: Style::new() .fg(WHITE) .bg(DARK_BLUE) .add_modifier(Modifier::BOLD), inbox: Style::new().bg(DARK_BLUE).fg(LIGHT_GRAY), item: Style::new().fg(LIGHT_GRAY), selected_item: Style::new().fg(LIGHT_YELLOW), header: Style::new().add_modifier(Modifier::BOLD), header_value: Style::new().fg(LIGHT_GRAY), body: Style::new().bg(DARK_BLUE).fg(LIGHT_GRAY), }, traceroute: Traceroute { header: Style::new() .bg(DARK_BLUE) .add_modifier(Modifier::BOLD) .add_modifier(Modifier::UNDERLINED), selected: Style::new().fg(LIGHT_YELLOW), ping: Style::new().fg(WHITE), map: Map { style: Style::new().bg(DARK_BLUE), background_color: DARK_BLUE, color: LIGHT_GRAY, path: LIGHT_BLUE, source: LIGHT_GREEN, destination: LIGHT_RED, }, }, recipe: Recipe { ingredients: Style::new().bg(DARK_BLUE).fg(LIGHT_GRAY), ingredients_header: Style::new() .add_modifier(Modifier::BOLD) .add_modifier(Modifier::UNDERLINED), },};
const DARK_BLUE: Color = Color::Rgb(16, 24, 48);const LIGHT_BLUE: Color = Color::Rgb(64, 96, 192);const LIGHT_YELLOW: Color = Color::Rgb(192, 192, 96);const LIGHT_GREEN: Color = Color::Rgb(64, 192, 96);const LIGHT_RED: Color = Color::Rgb(192, 96, 96);const RED: Color = Color::Rgb(215, 0, 0);const BLACK: Color = Color::Rgb(8, 8, 8); // not really black, often #080808const DARK_GRAY: Color = Color::Rgb(68, 68, 68);const MID_GRAY: Color = Color::Rgb(128, 128, 128);const LIGHT_GRAY: Color = Color::Rgb(188, 188, 188);const WHITE: Color = Color::Rgb(238, 238, 238); // not really white, often #eeeeeeuse ratatui::buffer::Buffer;use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect};use ratatui::widgets::{ Block, Borders, Clear, MascotEyeColor, Padding, Paragraph, RatatuiMascot, Widget, Wrap,};
use crate::{RgbSwatch, THEME};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]pub struct AboutTab { row_index: usize,}
impl AboutTab { pub fn prev_row(&mut self) { self.row_index = self.row_index.saturating_sub(1); }
pub fn next_row(&mut self) { self.row_index = self.row_index.saturating_add(1); }}
impl Widget for AboutTab { fn render(self, area: Rect, buf: &mut Buffer) { RgbSwatch.render(area, buf); let layout = Layout::horizontal([Constraint::Length(34), Constraint::Min(0)]); let [logo_area, description] = area.layout(&layout); render_crate_description(description, buf); let eye_state = if self.row_index.is_multiple_of(2) { MascotEyeColor::Default } else { MascotEyeColor::Red }; RatatuiMascot::default().set_eye(eye_state).render( logo_area.inner(Margin { vertical: 0, horizontal: 2, }), buf, ); }}
fn render_crate_description(area: Rect, buf: &mut Buffer) { let area = area.inner(Margin { vertical: 4, horizontal: 2, }); Clear.render(area, buf); // clear out the color swatches Block::new().style(THEME.content).render(area, buf); let area = area.inner(Margin { vertical: 1, horizontal: 2, }); let text = "- cooking up terminal user interfaces -
Ratatui is a Rust crate that provides widgets (e.g. Paragraph, Table) and draws them to the \ screen efficiently every frame."; Paragraph::new(text) .style(THEME.description) .block( Block::new() .title(" Ratatui ") .title_alignment(Alignment::Center) .borders(Borders::TOP) .border_style(THEME.description_title) .padding(Padding::new(0, 0, 0, 0)), ) .wrap(Wrap { trim: true }) .scroll((0, 0)) .render(area, buf);}use itertools::Itertools;use ratatui::buffer::Buffer;use ratatui::layout::{Constraint, Layout, Margin, Rect};use ratatui::style::{Styled, Stylize};use ratatui::text::Line;use ratatui::widgets::{ Block, BorderType, Borders, Clear, List, ListItem, ListState, Padding, Paragraph, Scrollbar, ScrollbarState, StatefulWidget, Tabs, Widget,};use unicode_width::UnicodeWidthStr;
use crate::{RgbSwatch, THEME};
#[derive(Debug, Default)]pub struct Email { from: &'static str, subject: &'static str, body: &'static str,}
const EMAILS: &[Email] = &[ Email { from: "Alice <alice@example.com>", subject: "Hello", body: "Hi Bob,\nHow are you?\n\nAlice", }, Email { from: "Bob <bob@example.com>", subject: "Re: Hello", body: "Hi Alice,\nI'm fine, thanks!\n\nBob", }, Email { from: "Charlie <charlie@example.com>", subject: "Re: Hello", body: "Hi Alice,\nI'm fine, thanks!\n\nCharlie", }, Email { from: "Dave <dave@example.com>", subject: "Re: Hello (STOP REPLYING TO ALL)", body: "Hi Everyone,\nPlease stop replying to all.\n\nDave", }, Email { from: "Eve <eve@example.com>", subject: "Re: Hello (STOP REPLYING TO ALL)", body: "Hi Everyone,\nI'm reading all your emails.\n\nEve", },];
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]pub struct EmailTab { row_index: usize,}
impl EmailTab { /// Select the previous email (with wrap around). pub fn prev(&mut self) { self.row_index = self.row_index.saturating_add(EMAILS.len() - 1) % EMAILS.len(); }
/// Select the next email (with wrap around). pub fn next(&mut self) { self.row_index = self.row_index.saturating_add(1) % EMAILS.len(); }}
impl Widget for EmailTab { fn render(self, area: Rect, buf: &mut Buffer) { RgbSwatch.render(area, buf); let area = area.inner(Margin { vertical: 1, horizontal: 2, }); Clear.render(area, buf); let layout = Layout::vertical([Constraint::Length(5), Constraint::Min(0)]); let [inbox, email] = area.layout(&layout); render_inbox(self.row_index, inbox, buf); render_email(self.row_index, email, buf); }}fn render_inbox(selected_index: usize, area: Rect, buf: &mut Buffer) { let layout = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]); let [tabs, inbox] = area.layout(&layout); let theme = THEME.email; Tabs::new(vec![" Inbox ", " Sent ", " Drafts "]) .style(theme.tabs) .highlight_style(theme.tabs_selected) .select(0) .divider("") .render(tabs, buf);
let highlight_symbol = ">>"; let from_width = EMAILS .iter() .map(|e| e.from.width()) .max() .unwrap_or_default(); let items = EMAILS.iter().map(|e| { let from = format!("{:width$}", e.from, width = from_width).into(); ListItem::new(Line::from(vec![from, " ".into(), e.subject.into()])) }); let mut state = ListState::default().with_selected(Some(selected_index)); StatefulWidget::render( List::new(items) .style(theme.inbox) .highlight_style(theme.selected_item) .highlight_symbol(highlight_symbol), inbox, buf, &mut state, ); let mut scrollbar_state = ScrollbarState::default() .content_length(EMAILS.len()) .position(selected_index); Scrollbar::default() .begin_symbol(None) .end_symbol(None) .track_symbol(None) .thumb_symbol("▐") .render(inbox, buf, &mut scrollbar_state);}
fn render_email(selected_index: usize, area: Rect, buf: &mut Buffer) { let theme = THEME.email; let email = EMAILS.get(selected_index); let block = Block::new() .style(theme.body) .padding(Padding::new(2, 2, 0, 0)) .borders(Borders::TOP) .border_type(BorderType::Thick); let inner = block.inner(area); block.render(area, buf); if let Some(email) = email { let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]); let [headers_area, body_area] = inner.layout(&layout); let headers = vec![ Line::from(vec![ "From: ".set_style(theme.header), email.from.set_style(theme.header_value), ]), Line::from(vec![ "Subject: ".set_style(theme.header), email.subject.set_style(theme.header_value), ]), "-".repeat(inner.width as usize).dim().into(), ]; Paragraph::new(headers) .style(theme.body) .render(headers_area, buf); let body = email.body.lines().map(Line::from).collect_vec(); Paragraph::new(body) .style(theme.body) .render(body_area, buf); } else { Paragraph::new("No email selected").render(inner, buf); }}use itertools::Itertools;use ratatui::buffer::Buffer;use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect};use ratatui::style::{Style, Stylize};use ratatui::text::Line;use ratatui::widgets::{ Block, Clear, Padding, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Table, TableState, Widget, Wrap,};
use crate::{RgbSwatch, THEME};
#[derive(Debug, Default, Clone, Copy)]struct Ingredient { quantity: &'static str, name: &'static str,}
impl Ingredient { #[expect(clippy::cast_possible_truncation)] fn height(&self) -> u16 { self.name.lines().count() as u16 }}
impl From<Ingredient> for Row<'_> { fn from(i: Ingredient) -> Self { Row::new(vec![i.quantity, i.name]).height(i.height()) }}
// https://www.realsimple.com/food-recipes/browse-all-recipes/ratatouilleconst RECIPE: &[(&str, &str)] = &[ ( "Step 1: ", "Over medium-low heat, add the oil to a large skillet with the onion, garlic, and bay \ leaf, stirring occasionally, until the onion has softened.", ), ( "Step 2: ", "Add the eggplant and cook, stirring occasionally, for 8 minutes or until the eggplant \ has softened. Stir in the zucchini, red bell pepper, tomatoes, and salt, and cook over \ medium heat, stirring occasionally, for 5 to 7 minutes or until the vegetables are \ tender. Stir in the basil and few grinds of pepper to taste.", ),];
const INGREDIENTS: &[Ingredient] = &[ Ingredient { quantity: "4 tbsp", name: "olive oil", }, Ingredient { quantity: "1", name: "onion thinly sliced", }, Ingredient { quantity: "4", name: "cloves garlic\npeeled and sliced", }, Ingredient { quantity: "1", name: "small bay leaf", }, Ingredient { quantity: "1", name: "small eggplant cut\ninto 1/2 inch cubes", }, Ingredient { quantity: "1", name: "small zucchini halved\nlengthwise and cut\ninto thin slices", }, Ingredient { quantity: "1", name: "red bell pepper cut\ninto slivers", }, Ingredient { quantity: "4", name: "plum tomatoes\ncoarsely chopped", }, Ingredient { quantity: "1 tsp", name: "kosher salt", }, Ingredient { quantity: "1/4 cup", name: "shredded fresh basil\nleaves", }, Ingredient { quantity: "", name: "freshly ground black\npepper", },];
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]pub struct RecipeTab { row_index: usize,}
impl RecipeTab { /// Select the previous item in the ingredients list (with wrap around) pub fn prev(&mut self) { self.row_index = self.row_index.saturating_add(INGREDIENTS.len() - 1) % INGREDIENTS.len(); }
/// Select the next item in the ingredients list (with wrap around) pub fn next(&mut self) { self.row_index = self.row_index.saturating_add(1) % INGREDIENTS.len(); }}
impl Widget for RecipeTab { fn render(self, area: Rect, buf: &mut Buffer) { RgbSwatch.render(area, buf); let area = area.inner(Margin { vertical: 1, horizontal: 2, }); Clear.render(area, buf); Block::new() .title("Ratatouille Recipe".bold().white()) .title_alignment(Alignment::Center) .style(THEME.content) .padding(Padding::new(1, 1, 2, 1)) .render(area, buf);
let scrollbar_area = Rect { y: area.y + 2, height: area.height - 3, ..area }; render_scrollbar(self.row_index, scrollbar_area, buf);
let area = area.inner(Margin { horizontal: 2, vertical: 1, }); let layout = Layout::horizontal([Constraint::Length(44), Constraint::Min(0)]); let [recipe, ingredients] = area.layout(&layout);
render_recipe(recipe, buf); render_ingredients(self.row_index, ingredients, buf); }}
fn render_recipe(area: Rect, buf: &mut Buffer) { let lines = RECIPE .iter() .map(|(step, text)| Line::from(vec![step.white().bold(), text.gray()])) .collect_vec(); Paragraph::new(lines) .wrap(Wrap { trim: true }) .block(Block::new().padding(Padding::new(0, 1, 0, 0))) .render(area, buf);}
fn render_ingredients(selected_row: usize, area: Rect, buf: &mut Buffer) { let mut state = TableState::default().with_selected(Some(selected_row)); let rows = INGREDIENTS.iter().copied(); let theme = THEME.recipe; StatefulWidget::render( Table::new(rows, [Constraint::Length(7), Constraint::Length(30)]) .block(Block::new().style(theme.ingredients)) .header(Row::new(vec!["Qty", "Ingredient"]).style(theme.ingredients_header)) .row_highlight_style(Style::new().light_yellow()), area, buf, &mut state, );}
fn render_scrollbar(position: usize, area: Rect, buf: &mut Buffer) { let mut state = ScrollbarState::default() .content_length(INGREDIENTS.len()) .viewport_content_length(6) .position(position); Scrollbar::new(ScrollbarOrientation::VerticalRight) .begin_symbol(None) .end_symbol(None) .track_symbol(None) .thumb_symbol("▐") .render(area, buf, &mut state);}use itertools::Itertools;use ratatui::buffer::Buffer;use ratatui::layout::{Alignment, Constraint, Layout, Margin, Rect};use ratatui::style::{Styled, Stylize};use ratatui::symbols::Marker;use ratatui::widgets::canvas::{self, Canvas, Map, MapResolution, Points};use ratatui::widgets::{ Block, BorderType, Clear, Padding, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Sparkline, StatefulWidget, Table, TableState, Widget,};
use crate::{RgbSwatch, THEME};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]pub struct TracerouteTab { row_index: usize,}
impl TracerouteTab { /// Select the previous row (with wrap around). pub fn prev_row(&mut self) { self.row_index = self.row_index.saturating_add(HOPS.len() - 1) % HOPS.len(); }
/// Select the next row (with wrap around). pub fn next_row(&mut self) { self.row_index = self.row_index.saturating_add(1) % HOPS.len(); }}
impl Widget for TracerouteTab { fn render(self, area: Rect, buf: &mut Buffer) { RgbSwatch.render(area, buf); let area = area.inner(Margin { vertical: 1, horizontal: 2, }); Clear.render(area, buf); Block::new().style(THEME.content).render(area, buf); let horizontal = Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]); let vertical = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]); let [left, map] = area.layout(&horizontal); let [hops, pings] = left.layout(&vertical);
render_hops(self.row_index, hops, buf); render_ping(self.row_index, pings, buf); render_map(self.row_index, map, buf); }}
fn render_hops(selected_row: usize, area: Rect, buf: &mut Buffer) { let mut state = TableState::default().with_selected(Some(selected_row)); let rows = HOPS.iter().map(|hop| Row::new(vec![hop.host, hop.address])); let block = Block::new() .padding(Padding::new(1, 1, 1, 1)) .title_alignment(Alignment::Center) .title("Traceroute bad.horse".bold().white()); StatefulWidget::render( Table::new(rows, [Constraint::Max(100), Constraint::Length(15)]) .header(Row::new(vec!["Host", "Address"]).set_style(THEME.traceroute.header)) .row_highlight_style(THEME.traceroute.selected) .block(block), area, buf, &mut state, ); let mut scrollbar_state = ScrollbarState::default() .content_length(HOPS.len()) .position(selected_row); let area = Rect { width: area.width + 1, y: area.y + 3, height: area.height - 4, ..area }; Scrollbar::default() .orientation(ScrollbarOrientation::VerticalLeft) .begin_symbol(None) .end_symbol(None) .track_symbol(None) .thumb_symbol("▌") .render(area, buf, &mut scrollbar_state);}
pub fn render_ping(progress: usize, area: Rect, buf: &mut Buffer) { let mut data = [ 8, 8, 8, 8, 7, 7, 7, 6, 6, 5, 4, 3, 3, 2, 2, 1, 1, 1, 2, 2, 3, 4, 5, 6, 7, 7, 8, 8, 8, 7, 7, 6, 5, 4, 3, 2, 1, 1, 1, 1, 1, 2, 4, 6, 7, 8, 8, 8, 8, 6, 4, 2, 1, 1, 1, 1, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6, 7, 7, 7, ]; let mid = progress % data.len(); data.rotate_left(mid); Sparkline::default() .block( Block::new() .title("Ping") .title_alignment(Alignment::Center) .border_type(BorderType::Thick), ) .data(data) .style(THEME.traceroute.ping) .render(area, buf);}
fn render_map(selected_row: usize, area: Rect, buf: &mut Buffer) { let theme = THEME.traceroute.map; let path: Option<(&Hop, &Hop)> = HOPS.iter().tuple_windows().nth(selected_row); let map = Map { resolution: MapResolution::High, color: theme.color, }; Canvas::default() .background_color(theme.background_color) .block( Block::new() .padding(Padding::new(1, 0, 1, 0)) .style(theme.style), ) .marker(Marker::HalfBlock) // picked to show Australia for the demo as it's the most interesting part of the map // (and the only part with hops ;)) .x_bounds([112.0, 155.0]) .y_bounds([-46.0, -11.0]) .paint(|context| { context.draw(&map); if let Some(path) = path { context.draw(&canvas::Line::new( path.0.location.0, path.0.location.1, path.1.location.0, path.1.location.1, theme.path, )); context.draw(&Points { color: theme.source, coords: &[path.0.location], // sydney }); context.draw(&Points { color: theme.destination, coords: &[path.1.location], // perth }); } }) .render(area, buf);}
#[derive(Debug)]struct Hop { host: &'static str, address: &'static str, location: (f64, f64),}
impl Hop { const fn new(name: &'static str, address: &'static str, location: (f64, f64)) -> Self { Self { host: name, address, location, } }}
const CANBERRA: (f64, f64) = (149.1, -35.3);const SYDNEY: (f64, f64) = (151.1, -33.9);const MELBOURNE: (f64, f64) = (144.9, -37.8);const PERTH: (f64, f64) = (115.9, -31.9);const DARWIN: (f64, f64) = (130.8, -12.4);const BRISBANE: (f64, f64) = (153.0, -27.5);const ADELAIDE: (f64, f64) = (138.6, -34.9);
// Go traceroute bad.horse some time, it's fun. these locations are made up and don't correspond// to the actual IP addresses (which are in Toronto, Canada).const HOPS: &[Hop] = &[ Hop::new("home", "127.0.0.1", CANBERRA), Hop::new("bad.horse", "162.252.205.130", SYDNEY), Hop::new("bad.horse", "162.252.205.131", MELBOURNE), Hop::new("bad.horse", "162.252.205.132", BRISBANE), Hop::new("bad.horse", "162.252.205.133", SYDNEY), Hop::new("he.rides.across.the.nation", "162.252.205.134", PERTH), Hop::new("the.thoroughbred.of.sin", "162.252.205.135", DARWIN), Hop::new("he.got.the.application", "162.252.205.136", BRISBANE), Hop::new("that.you.just.sent.in", "162.252.205.137", ADELAIDE), Hop::new("it.needs.evaluation", "162.252.205.138", DARWIN), Hop::new("so.let.the.games.begin", "162.252.205.139", PERTH), Hop::new("a.heinous.crime", "162.252.205.140", BRISBANE), Hop::new("a.show.of.force", "162.252.205.141", CANBERRA), Hop::new("a.murder.would.be.nice.of.course", "162.252.205.142", PERTH), Hop::new("bad.horse", "162.252.205.143", MELBOURNE), Hop::new("bad.horse", "162.252.205.144", DARWIN), Hop::new("bad.horse", "162.252.205.145", MELBOURNE), Hop::new("he-s.bad", "162.252.205.146", PERTH), Hop::new("the.evil.league.of.evil", "162.252.205.147", BRISBANE), Hop::new("is.watching.so.beware", "162.252.205.148", DARWIN), Hop::new("the.grade.that.you.receive", "162.252.205.149", PERTH), Hop::new("will.be.your.last.we.swear", "162.252.205.150", ADELAIDE), Hop::new("so.make.the.bad.horse.gleeful", "162.252.205.151", SYDNEY), Hop::new("or.he-ll.make.you.his.mare", "162.252.205.152", MELBOURNE), Hop::new("o_o", "162.252.205.153", BRISBANE), Hop::new("you-re.saddled.up", "162.252.205.154", DARWIN), Hop::new("there-s.no.recourse", "162.252.205.155", PERTH), Hop::new("it-s.hi-ho.silver", "162.252.205.156", SYDNEY), Hop::new("signed.bad.horse", "162.252.205.157", CANBERRA),];use itertools::Itertools;use palette::Okhsv;use ratatui::buffer::Buffer;use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect};use ratatui::style::{Color, Style};use ratatui::symbols;use ratatui::widgets::calendar::{CalendarEventStore, Monthly};use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Clear, LineGauge, Padding, Widget};use time::OffsetDateTime;
use crate::{color_from_oklab, RgbSwatch, THEME};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]pub struct WeatherTab { pub download_progress: usize,}
impl WeatherTab { /// Simulate a download indicator by decrementing the row index. pub fn prev(&mut self) { self.download_progress = self.download_progress.saturating_sub(1); }
/// Simulate a download indicator by incrementing the row index. pub fn next(&mut self) { self.download_progress = self.download_progress.saturating_add(1); }}
impl Widget for WeatherTab { fn render(self, area: Rect, buf: &mut Buffer) { RgbSwatch.render(area, buf); let area = area.inner(Margin { vertical: 1, horizontal: 2, }); Clear.render(area, buf); Block::new().style(THEME.content).render(area, buf);
let area = area.inner(Margin { horizontal: 2, vertical: 1, }); let tab_layout = Layout::vertical([ Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), ]); let [main, _, gauges] = area.layout(&tab_layout); let main_layout = Layout::horizontal([Constraint::Length(23), Constraint::Min(0)]); let [calendar, charts] = main.layout(&main_layout); let charts_layout = Layout::vertical([Constraint::Length(29), Constraint::Min(0)]); let [simple, horizontal] = charts.layout(&charts_layout);
render_calendar(calendar, buf); render_simple_barchart(simple, buf); render_horizontal_barchart(horizontal, buf); render_gauge(self.download_progress, gauges, buf); }}
fn render_calendar(area: Rect, buf: &mut Buffer) { let date = OffsetDateTime::now_utc().date(); Monthly::new(date, CalendarEventStore::today(Style::new().red().bold())) .block(Block::new().padding(Padding::new(0, 0, 2, 0))) .show_month_header(Style::new().bold()) .show_weekdays_header(Style::new().italic()) .render(area, buf);}
fn render_simple_barchart(area: Rect, buf: &mut Buffer) { let data = [ ("Sat", 76), ("Sun", 69), ("Mon", 65), ("Tue", 67), ("Wed", 65), ("Thu", 69), ("Fri", 73), ]; let data = data .into_iter() .map(|(label, value)| { Bar::default() .value(value) // This doesn't actually render correctly as the text is too wide for the bar // See https://github.com/ratatui/ratatui/issues/513 for more info // (the demo GIFs hack around this by hacking the calculation in bars.rs) .text_value(format!("{value}°")) .style(if value > 70 { Style::new().fg(Color::Red) } else { Style::new().fg(Color::Yellow) }) .value_style(if value > 70 { Style::new().fg(Color::Gray).bg(Color::Red).bold() } else { Style::new().fg(Color::DarkGray).bg(Color::Yellow).bold() }) .label(label) }) .collect_vec(); let group = BarGroup::default().bars(&data); BarChart::default() .data(group) .bar_width(3) .bar_gap(1) .render(area, buf);}
fn render_horizontal_barchart(area: Rect, buf: &mut Buffer) { let bg = Color::Rgb(32, 48, 96); let data = [ Bar::default().text_value("Winter 37-51").value(51), Bar::default().text_value("Spring 40-65").value(65), Bar::default().text_value("Summer 54-77").value(77), Bar::default() .text_value("Fall 41-71") .value(71) .value_style(Style::new().bold()), // current season ]; let group = BarGroup::default().label("GPU").bars(&data); BarChart::default() .block(Block::new().padding(Padding::new(0, 0, 2, 0))) .direction(Direction::Horizontal) .data(group) .bar_gap(1) .bar_style(Style::new().fg(bg)) .value_style(Style::new().bg(bg).fg(Color::Gray)) .render(area, buf);}
#[expect(clippy::cast_precision_loss)]pub fn render_gauge(progress: usize, area: Rect, buf: &mut Buffer) { let percent = (progress * 3).min(100) as f64;
render_line_gauge(percent, area, buf);}
#[expect(clippy::cast_possible_truncation)]fn render_line_gauge(percent: f64, area: Rect, buf: &mut Buffer) { // cycle color hue based on the percent for a neat effect yellow -> red let hue = 90.0 - (percent as f32 * 0.6); let value = Okhsv::max_value(); let filled_color = color_from_oklab(hue, Okhsv::max_saturation(), value); let unfilled_color = color_from_oklab(hue, Okhsv::max_saturation(), value * 0.5); let label = if percent < 100.0 { format!("Downloading: {percent}%") } else { "Download Complete!".into() }; LineGauge::default() .ratio(percent / 100.0) .label(label) .style(Style::new().light_blue()) .filled_style(Style::new().fg(filled_color)) .unfilled_style(Style::new().fg(unfilled_color)) .filled_symbol(symbols::line::THICK_HORIZONTAL) .unfilled_symbol(symbols::line::THICK_HORIZONTAL) .render(area, buf);}