diff --git a/.markdownlint.yaml b/.markdownlint.yaml index 68c7bfe77..5168908af 100644 --- a/.markdownlint.yaml +++ b/.markdownlint.yaml @@ -1,10 +1,13 @@ # configuration for https://github.com/DavidAnson/markdownlint +first-line-heading: false no-inline-html: allowed_elements: - img - details - summary + - div + - br line-length: line_length: 100 diff --git a/Cargo.toml b/Cargo.toml index 9526ee0e8..1e15a1e00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,14 +48,16 @@ lru = "0.11.1" [dev-dependencies] anyhow = "1.0.71" -argh = "0.1" +argh = "0.1.12" better-panic = "0.3.0" cargo-husky = { version = "1.5.0", default-features = false, features = [ "user-hooks", ] } -criterion = { version = "0.5", features = ["html_reports"] } +criterion = { version = "0.5.1", features = ["html_reports"] } fakeit = "1.1" -rand = "0.8" +lipsum = "0.9.0" +rand = "0.8.5" +palette = "0.7.3" pretty_assertions = "1.4.0" [features] @@ -149,6 +151,12 @@ name = "demo" # this runs for all of the terminal backends, so it can't be built using --all-features or scraped doc-scrape-examples = false +[[example]] +name = "demo2" +required-features = ["crossterm"] +# this runs for all of the terminal backends, so it can't be built using --all-features or scraped +doc-scrape-examples = false + [[example]] name = "gauge" required-features = ["crossterm"] diff --git a/README.md b/README.md index 1dc42ee90..f587b5193 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,32 @@ -# Ratatui - - +![Demo of Ratatui](https://repository-images.githubusercontent.com/600886023/96016d23-3bdd-4611-8c03-2e8b5836f900) + -`ratatui` is a [Rust](https://www.rust-lang.org) library that is all about cooking up terminal user interfaces. -It is a community fork of the original [tui-rs](https://github.com/fdehau/tui-rs) -project. +
[![Crates.io](https://img.shields.io/crates/v/ratatui?logo=rust&style=flat-square)](https://crates.io/crates/ratatui) [![License](https://img.shields.io/crates/l/ratatui?style=flat-square)](./LICENSE) [![GitHub CI Status](https://img.shields.io/github/actions/workflow/status/ratatui-org/ratatui/ci.yml?style=flat-square&logo=github)](https://github.com/ratatui-org/ratatui/actions?query=workflow%3ACI+) -[![Docs.rs](https://img.shields.io/docsrs/ratatui?logo=rust&style=flat-square)](https://docs.rs/crate/ratatui/) +[![Docs.rs](https://img.shields.io/docsrs/ratatui?logo=rust&style=flat-square)](https://docs.rs/crate/ratatui/)
[![Dependency Status](https://deps.rs/repo/github/ratatui-org/ratatui/status.svg?style=flat-square)](https://deps.rs/repo/github/ratatui-org/ratatui) [![Codecov](https://img.shields.io/codecov/c/github/ratatui-org/ratatui?logo=codecov&style=flat-square&token=BAQ8SOKEST)](https://app.codecov.io/gh/ratatui-org/ratatui) [![Discord](https://img.shields.io/discord/1070692720437383208?label=discord&logo=discord&style=flat-square)](https://discord.gg/pMCEU9hNEj) -[![Matrix](https://img.shields.io/matrix/ratatui-general%3Amatrix.org?style=flat-square&logo=matrix&label=Matrix)](https://matrix.to/#/#ratatui:matrix.org) +[![Matrix](https://img.shields.io/matrix/ratatui-general%3Amatrix.org?style=flat-square&logo=matrix&label=Matrix)](https://matrix.to/#/#ratatui:matrix.org)
+[Documentation](https://docs.rs/ratatui) +· [Examples](https://github.com/ratatui-org/ratatui/tree/main/examples) +· [Report a bug](https://github.com/ratatui-org/ratatui/issues/new?labels=bug&projects=&template=bug_report.md) +· [Request a Feature](https://github.com/ratatui-org/ratatui/issues/new?labels=enhancement&projects=&template=feature_request.md) +· [Send a Pull Request](https://github.com/ratatui-org/ratatui/compare) - -![Demo of Ratatui](https://vhs.charm.sh/vhs-tF0QbuPbtHgUeG0sTVgFr.gif) +
+ + + +# Ratatui + +`ratatui` is a [Rust](https://www.rust-lang.org) library that is all about cooking up terminal user +interfaces. It is a community fork of the original [tui-rs](https://github.com/fdehau/tui-rs) +project.
Table of Contents diff --git a/examples/colors_rgb.rs b/examples/colors_rgb.rs index 6f6451f1b..d531fe1d6 100644 --- a/examples/colors_rgb.rs +++ b/examples/colors_rgb.rs @@ -13,7 +13,10 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }; -use itertools::Itertools; +use palette::{ + convert::{FromColorUnclamped, IntoColorUnclamped}, + Okhsv, Srgb, +}; use ratatui::{prelude::*, widgets::*}; type Result = std::result::Result>; @@ -77,9 +80,8 @@ struct RgbColors; impl Widget for RgbColors { fn render(self, area: Rect, buf: &mut Buffer) { let layout = Self::layout(area); - let rgb_colors = Self::create_rgb_color_grid(area.width, area.height * 2); Self::render_title(layout[0], buf); - Self::render_colors(layout[1], buf, rgb_colors); + Self::render_colors(layout[1], buf); } } @@ -99,41 +101,26 @@ impl RgbColors { } /// Render a colored grid of half block characters (`"▀"`) each with a different RGB color. - fn render_colors(area: Rect, buf: &mut Buffer, rgb_colors: Vec>) { - for (x, column) in (area.left()..area.right()).zip(rgb_colors.iter()) { - for (y, (fg, bg)) in (area.top()..area.bottom()).zip(column.iter().tuples()) { - let cell = buf.get_mut(x, y); - cell.fg = *fg; - cell.bg = *bg; - cell.symbol = "▀".into(); - } - } - } - - /// Generate a smooth grid of colors - /// - /// Red ranges from 0 to 255 across the x axis. Green ranges from 0 to 255 across the y axis. - /// Blue repeats every 32 pixels in both directions, but flipped every 16 pixels so that it - /// doesn't transition sharply from light to dark. - /// - /// The result stored in a 2d vector of colors with the x axis as the first dimension, and the - /// y axis the second dimension. - fn create_rgb_color_grid(width: u16, height: u16) -> Vec> { - let mut result = vec![]; - for x in 0..width { - let mut column = vec![]; - for y in 0..height { - // flip both axes every 16 pixels. E.g. [0, 1, ... 15, 15, ... 1, 0] - let yy = if (y % 32) < 16 { y % 32 } else { 31 - y % 32 }; - let xx = if (x % 32) < 16 { x % 32 } else { 31 - x % 32 }; - let r = (256 * x / width) as u8; - let g = (256 * y / height) as u8; - let b = (yy * 16 + xx) as u8; - column.push(Color::Rgb(r, g, b)) + fn render_colors(area: Rect, buf: &mut Buffer) { + for (xi, x) in (area.left()..area.right()).enumerate() { + 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); + + buf.get_mut(x, y).set_char('▀').set_fg(fg).set_bg(bg); } - result.push(column); } - result } } diff --git a/examples/demo2.tape b/examples/demo2.tape new file mode 100644 index 000000000..8535d5b87 --- /dev/null +++ b/examples/demo2.tape @@ -0,0 +1,28 @@ +# This is a vhs script. See https://github.com/charmbracelet/vhs for more info. +# To run this script, install vhs and run `vhs ./examples/demo.tape` +Output "target/demo2.gif" +Set Theme "OceanicMaterial" +# Github social preview size (1280x640 with 80px padding) +Set Width 1280 +Set Height 640 +Set Padding 80 +Hide +Type "cargo run --example demo2" +Enter +Sleep 2s +Show +# About screen +Sleep 5s +Tab +# Email +Sleep 5s +Tab +# Trace route +Sleep 5s +Tab +# Misc +Sleep 5s +Tab +# Recipe +Sleep 5s +Tab \ No newline at end of file diff --git a/examples/demo2/app.rs b/examples/demo2/app.rs new file mode 100644 index 000000000..0a26f332a --- /dev/null +++ b/examples/demo2/app.rs @@ -0,0 +1,127 @@ +use std::{io::Stdout, time::Duration}; + +use anyhow::{Context, Result}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use itertools::Itertools; +use ratatui::prelude::*; + +use crate::{ + app_widget::AppWidget, + tabs, + tabs::{AboutTab, EmailTab, MiscWidgetsTab, RecipeTab, Tab, TracerouteTab}, + tui, +}; + +pub struct App { + terminal: Terminal>, + should_quit: bool, + tab_index: usize, + selected_index: usize, + tabs: Vec>, +} + +impl App { + pub fn new() -> Result { + let terminal = tui::create_terminal()?; + Ok(Self { + terminal, + should_quit: false, + tab_index: 0, + selected_index: 0, + tabs: vec![ + Box::new(tabs::AboutTab::new()), + Box::new(tabs::EmailTab::new(0)), + Box::new(tabs::TracerouteTab::new(0)), + Box::new(tabs::MiscWidgetsTab::new()), + Box::new(tabs::RecipeTab::new(0)), + ], + }) + } + + pub fn run(&mut self) -> Result<()> { + tui::setup()?; + while !self.should_quit { + self.draw()?; + self.handle_events()?; + } + tui::restore()?; + Ok(()) + } + + fn draw(&mut self) -> Result<()> { + self.terminal + .draw(|frame| { + let titles = self.tabs.iter().map(|tab| tab.title()).collect_vec(); + let tab: Box = { + // This is a bit of a hack to get around the borrow checker. + // which works because we know that the tabs are all static. + match self.tab_index { + 0 => Box::new(AboutTab::new()), + 1 => Box::new(EmailTab::new(self.selected_index)), + 2 => Box::new(TracerouteTab::new(self.selected_index)), + 3 => Box::new(MiscWidgetsTab::new()), + 4 => Box::new(RecipeTab::new(self.selected_index)), + _ => unreachable!(), + } + }; + let view = AppWidget::new(tab, self.tab_index, titles); + let area = frame.size(); + frame.render_widget(view, area) + }) + .context("terminal.draw")?; + Ok(()) + } + + fn handle_events(&mut self) -> Result<()> { + match tui::next_event(Duration::from_millis(16))? { + Some(Event::Key(key)) => self.handle_key_event(key), + _ => Ok(()), + } + } + + fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { + if key.kind != KeyEventKind::Press { + return Ok(()); + } + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + } + KeyCode::Tab | KeyCode::BackTab if key.modifiers.contains(KeyModifiers::SHIFT) => { + let tab_index = self.tab_index + self.tabs.len(); // to wrap around properly + self.tab_index = tab_index.saturating_sub(1) % self.tabs.len(); + self.selected_index = 0; + } + KeyCode::Tab | KeyCode::BackTab => { + self.tab_index = self.tab_index.saturating_add(1) % self.tabs.len(); + self.selected_index = 0; + } + KeyCode::Left | KeyCode::Char('h') => {} + KeyCode::Right | KeyCode::Char('l') => {} + KeyCode::Up | KeyCode::Char('k') => { + self.selected_index = self.selected_index.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + self.selected_index = self.selected_index.saturating_add(1); + } + _ => {} + }; + Ok(()) + } +} + +impl Drop for App { + fn drop(&mut self) { + let _ = tui::restore(); + } +} + +pub fn install_panic_hook() { + better_panic::install(); + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = tui::restore(); + hook(info); + std::process::exit(1); + })); +} diff --git a/examples/demo2/app_widget.rs b/examples/demo2/app_widget.rs new file mode 100644 index 000000000..5c5a142c1 --- /dev/null +++ b/examples/demo2/app_widget.rs @@ -0,0 +1,69 @@ +use ratatui::{prelude::*, widgets::*}; + +use crate::{styles, tabs::Tab, tui}; + +pub struct AppWidget { + tab: Box, + tab_index: usize, + titles: Vec, +} + +impl AppWidget { + pub fn new(tab: Box, tab_index: usize, titles: Vec) -> Self { + AppWidget { + tab, + tab_index, + titles, + } + } +} + +impl Widget for AppWidget { + fn render(self, area: Rect, buf: &mut Buffer) { + Block::new().bg(styles::APP_BACKGROUND).render(area, buf); + let area = tui::layout(area, Direction::Vertical, vec![1, 0, 1]); + self.render_title_bar(area[0], buf); + self.render_selected_tab(area[1], buf); + self.render_bottom_bar(area[2], buf); + } +} + +impl AppWidget { + fn render_title_bar(&self, area: Rect, buf: &mut Buffer) { + let area = tui::layout(area, Direction::Horizontal, vec![17, 0]); + + Paragraph::new(Span::styled("Ratatui v0.23.0 ", styles::APP_TITLE)).render(area[0], buf); + + Tabs::new(self.titles.clone()) + .style(styles::TABS) + .highlight_style(styles::TABS_SELECTED) + .select(self.tab_index) + .render(area[1], buf); + } + + fn render_selected_tab(&self, area: Rect, buf: &mut Buffer) { + self.tab.render(area, buf); + } + + fn render_bottom_bar(&self, area: Rect, buf: &mut Buffer) { + let key_style = Style::new().fg(Color::Indexed(232)).bg(Color::Indexed(236)); + Paragraph::new(Line::from(vec![ + " Q/Esc ".set_style(key_style), + " Quit ".into(), + " Tab ".set_style(key_style), + " Next Tab ".into(), + " ←/h ".set_style(key_style), + " Left ".into(), + " →/l ".set_style(key_style), + " Right ".into(), + " ↑/k ".set_style(key_style), + " Up ".into(), + " ↓/j ".set_style(key_style), + " Down".into(), + ])) + .alignment(Alignment::Center) + .fg(Color::Indexed(236)) + .bg(Color::Indexed(232)) + .render(area, buf); + } +} diff --git a/examples/demo2/colors.rs b/examples/demo2/colors.rs new file mode 100644 index 000000000..67489a97c --- /dev/null +++ b/examples/demo2/colors.rs @@ -0,0 +1,59 @@ +#![allow(dead_code)] +use palette::{ + convert::{FromColorUnclamped, IntoColorUnclamped}, + Okhsv, Srgb, +}; +use ratatui::{prelude::*, widgets::*}; + +fn render_16_colors(area: Rect, buf: &mut Buffer) { + let sym = "██"; + Paragraph::new(vec![ + Line::from(vec![sym.black(), sym.red(), sym.green(), sym.yellow()]), + Line::from(vec![sym.blue(), sym.magenta(), sym.cyan(), sym.gray()]), + Line::from(vec![ + sym.dark_gray(), + sym.light_red(), + sym.light_green(), + sym.light_yellow(), + ]), + Line::from(vec![ + sym.light_blue(), + sym.light_magenta(), + sym.light_cyan(), + sym.white(), + ]), + ]) + .render(area, buf); +} + +fn render_256_colors(area: Rect, buf: &mut Buffer) { + for (xi, x) in (16..52).zip(area.left()..area.right()) { + for (yi, y) in (0..3).zip(area.top()..area.bottom()) { + let fg = Color::Indexed(yi * 72 + xi); + let bg = Color::Indexed(yi * 72 + xi + 36); + buf.get_mut(x, y).set_char('▀').set_fg(fg).set_bg(bg); + } + let fg = Color::Indexed(xi.saturating_add(216)); + buf.get_mut(x, area.bottom() - 1).set_char('█').set_fg(fg); + } +} + +pub fn render_rgb_colors(area: Rect, buf: &mut Buffer) { + for (xi, x) in (area.left()..area.right()).enumerate() { + for (yi, y) in (area.top()..area.bottom()).enumerate() { + let yi = area.height as usize - yi - 1; + let hue = xi as f32 * 360.0 / area.width as f32; + let value_bg = (yi as f32 - 0.0) / (area.height as f32); + let value_fg = (yi as f32 + 0.5) / (area.height as f32); + 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 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); + buf.get_mut(x, y).set_char('▀').set_fg(fg).set_bg(bg); + } + } +} diff --git a/examples/demo2/main.rs b/examples/demo2/main.rs new file mode 100644 index 000000000..8a2597901 --- /dev/null +++ b/examples/demo2/main.rs @@ -0,0 +1,13 @@ +use anyhow::Result; + +mod app; +mod app_widget; +mod colors; +mod styles; +mod tabs; +mod tui; + +fn main() -> Result<()> { + app::install_panic_hook(); + app::App::new()?.run() +} diff --git a/examples/demo2/styles.rs b/examples/demo2/styles.rs new file mode 100644 index 000000000..ea853a386 --- /dev/null +++ b/examples/demo2/styles.rs @@ -0,0 +1,16 @@ +use ratatui::prelude::*; + +pub const DARK_BLUE: Color = Color::Rgb(16, 24, 48); +pub const LIGHT_BLUE: Color = Color::Rgb(64, 96, 192); +pub const APP_BACKGROUND: Color = DARK_BLUE; +pub const APP: Style = Style::new().bg(APP_BACKGROUND); +pub const APP_TITLE: Style = Style::new() + .add_modifier(Modifier::BOLD) + .fg(Color::Indexed(252)) + .bg(Color::Indexed(232)); +pub const TABS: Style = Style::new().fg(Color::Indexed(244)).bg(Color::Indexed(232)); +pub const TABS_SELECTED: Style = Style::new().add_modifier(Modifier::BOLD).fg(LIGHT_BLUE); +pub const BORDERS: Style = Style::new() + .fg(Color::Indexed(252)) + .add_modifier(Modifier::BOLD); +pub const DESCRIPTION: Style = Style::new().fg(Color::Gray).bg(DARK_BLUE); diff --git a/examples/demo2/tabs.rs b/examples/demo2/tabs.rs new file mode 100644 index 000000000..f5f91fd34 --- /dev/null +++ b/examples/demo2/tabs.rs @@ -0,0 +1,19 @@ +use ratatui::prelude::*; + +mod about; +mod email; +mod misc; +mod recipe; +mod traceroute; + +pub use about::AboutTab; +pub use email::EmailTab; +pub use misc::MiscWidgetsTab; +pub use recipe::RecipeTab; +pub use traceroute::TracerouteTab; + +pub trait Tab { + fn title(&self) -> String; + fn render(&self, area: Rect, buf: &mut Buffer); + fn select(&mut self, _row: usize) {} +} diff --git a/examples/demo2/tabs/about.rs b/examples/demo2/tabs/about.rs new file mode 100644 index 000000000..33b4695d3 --- /dev/null +++ b/examples/demo2/tabs/about.rs @@ -0,0 +1,139 @@ +use itertools::Itertools; +use ratatui::{prelude::*, widgets::*}; + +use super::Tab; +use crate::{colors, styles, tui::layout}; + +const RATATUI_LOGO: [&str; 32] = [ + " ███ ", + " █████ ", + " ███████ ", + " ████████ ", + " █████████ ", + " ██████████ ", + " ████████████ ", + " █████████████ ", + " █████████████ ██████", + " ███████████ ████████", + " █████ ███████████ ", + " ███ ██xx████████ ", + " █ ███xx████████ ", + " ████ █████████████ ", + " █████████████████ ", + " ████████████████ ", + " ████████████████ ", + " ███ ██████████ ", + " ██ █████████ ", + " █xx█ █████████ ", + " █xxxx█ ██████████ ", + " █xx█xxx█ █████████ ", + " █xx██xxxx█ ████████ ", + " █xxxxxxxxxx█ ██████████ ", + " █xxxxxxxxxxxx█ ██████████ ", + " █xxxxxxx██xxxxx█ █████████ ", + " █xxxxxxxxx██xxxxx█ ████ ███ ", + " █xxxxxxxxxxxxxxxxxx█ ██ ███ ", + "█xxxxxxxxxxxxxxxxxxxx█ █ ███ ", + " █xxxxxxxxxxxxxxxxxxxx█ ███ ", + " █xxxxxxxxxxxxxxxxxxxx█ ██ ", + " █xxxxxxxxxxxxxxxxxxxx█ █ ", +]; + +pub struct AboutTab; + +impl AboutTab { + pub fn new() -> Self { + Self {} + } +} + +impl Tab for AboutTab { + fn title(&self) -> String { + "About".to_string() + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + colors::render_rgb_colors(area, buf); + let area = layout(area, Direction::Horizontal, vec![34, 0]); + render_crate_description(area[1], buf); + render_logo(area[0], buf); + } +} + +pub fn render_logo(area: Rect, buf: &mut Buffer) { + let area = area.inner(&Margin { + vertical: 0, + horizontal: 2, + }); + for (y, (line1, line2)) in RATATUI_LOGO.iter().tuples().enumerate() { + for (x, (ch1, ch2)) in line1.chars().zip(line2.chars()).enumerate() { + let x = area.left() + x as u16; + let y = area.top() + y as u16; + let cell = buf.get_mut(x, y); + match (ch1, ch2) { + ('█', '█') => { + cell.set_char('█'); + cell.fg = Color::Indexed(255); + } + ('█', ' ') => { + cell.set_char('▀'); + cell.fg = Color::Indexed(255); + } + (' ', '█') => { + cell.set_char('▄'); + cell.fg = Color::Indexed(255); + } + ('█', 'x') => { + cell.set_char('▀'); + cell.fg = Color::Indexed(255); + cell.bg = Color::Black; + } + ('x', '█') => { + cell.set_char('▄'); + cell.fg = Color::Indexed(255); + cell.bg = Color::Black; + } + ('x', 'x') => { + cell.set_char(' '); + cell.fg = Color::Indexed(255); + cell.bg = Color::Black; + } + (_, _) => {} + }; + } + } +} + +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(styles::APP).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. Pargraph, Table) and draws them to the \ + screen efficiently every frame."; + Paragraph::new(text) + .style(styles::DESCRIPTION) + .block( + Block::new() + .title("Ratatui") + .title_alignment(Alignment::Center) + .borders(Borders::TOP) + .border_style(styles::BORDERS) + .padding(Padding::new(0, 0, 0, 0)), + ) + .wrap(Wrap { trim: true }) + .scroll((0, 0)) + .render(area, buf); +} diff --git a/examples/demo2/tabs/email.rs b/examples/demo2/tabs/email.rs new file mode 100644 index 000000000..47f6596cc --- /dev/null +++ b/examples/demo2/tabs/email.rs @@ -0,0 +1,139 @@ +use itertools::Itertools; +use ratatui::{prelude::*, widgets::*}; +use unicode_width::UnicodeWidthStr; + +use super::Tab; +use crate::{colors, styles, tui::layout}; + +#[derive(Debug, Default)] +pub struct Email { + from: &'static str, + subject: &'static str, + body: &'static str, +} + +#[derive(Debug, Default)] +pub struct EmailTab { + selected_index: usize, +} + +impl Tab for EmailTab { + fn title(&self) -> String { + "Email".to_string() + } + + fn select(&mut self, row: usize) { + self.selected_index = row; + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.render(area, buf); + } +} + +const EMAILS: &[Email] = &[ + Email { + from: "Alice ", + subject: "Hello", + body: "Hi Bob,\n\nHow are you?\n\nAlice", + }, + Email { + from: "Bob ", + subject: "Re: Hello", + body: "Hi Alice,\nI'm fine, thanks!\n\nBob", + }, + Email { + from: "Charlie ", + subject: "Re: Hello", + body: "Hi Alice,\nI'm fine, thanks!\n\nCharlie", + }, + Email { + from: "Dave ", + subject: "Re: Hello (STOP REPLYING TO ALL)", + body: "Hi Everyone,\nPlease stop replying to all.\n\nDave", + }, + Email { + from: "Eve ", + subject: "Re: Hello (STOP REPLYING TO ALL)", + body: "Hi Everyone,\nI'm reading all your emails.\n\nEve", + }, +]; + +impl EmailTab { + pub fn new(selected_index: usize) -> Self { + Self { + selected_index: selected_index % EMAILS.len(), + } + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + colors::render_rgb_colors(area, buf); + let area = area.inner(&Margin { + vertical: 1, + horizontal: 2, + }); + Clear.render(area, buf); + let area = layout(area, Direction::Vertical, vec![6, 0]); + self.render_inbox(area[0], buf); + self.render_email(area[1], buf); + } + + fn render_inbox(&self, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Vertical, vec![1, 0]); + Tabs::new(vec![" Inbox ", " Sent ", " Drafts "]) + .style(Style::new().fg(Color::Indexed(244)).bg(Color::Indexed(232))) + .highlight_style( + Style::new() + .bold() + .fg(Color::Indexed(232)) + .bg(Color::Rgb(64, 96, 192)), + ) + .select(0) + .divider("") + .render(area[0], buf); + + let highlight_symbol = ">>"; + let from_width = EMAILS + .iter() + .map(|e| e.from.width()) + .max() + .unwrap_or_default(); + let subject_width = area[1].width as usize - from_width - highlight_symbol.width() - 1; + let items = EMAILS + .iter() + .map(|e| { + let from = format!("{:width$}", e.from, width = from_width); + let subject = format!("{:width$}", e.subject, width = subject_width); + let text = [from, subject].join(" "); + ListItem::new(text) + }) + .collect_vec(); + let mut state = ListState::default().with_selected(Some(self.selected_index)); + StatefulWidget::render( + List::new(items) + .highlight_style(Style::new().bold().yellow()) + .highlight_symbol(highlight_symbol), + area[1], + buf, + &mut state, + ); + } + + fn render_email(&self, area: Rect, buf: &mut Buffer) { + let email = EMAILS.get(self.selected_index); + let block = Block::new().borders(Borders::TOP).style(styles::APP); + let inner = block.inner(area); + block.render(area, buf); + if let Some(email) = email { + let mut text = vec![ + Line::from(vec!["From: ".bold(), email.from.clone().into()]), + Line::from(vec!["Subject: ".bold(), email.subject.clone().into()]), + "-".repeat(inner.width as usize).dim().into(), + ]; + text.extend(email.body.lines().map(Line::from)); + Paragraph::new(text).render(inner, buf); + } else { + Paragraph::new("No email selected").render(inner, buf); + } + } +} diff --git a/examples/demo2/tabs/misc.rs b/examples/demo2/tabs/misc.rs new file mode 100644 index 000000000..5ac2e2142 --- /dev/null +++ b/examples/demo2/tabs/misc.rs @@ -0,0 +1,182 @@ +#![allow(dead_code)] + +use ratatui::{prelude::*, widgets::*}; + +use super::Tab; +use crate::{colors, styles, tui::layout}; + +pub struct MiscWidgetsTab { + pub selected_row: usize, +} + +impl MiscWidgetsTab { + pub fn new() -> Self { + Self { selected_row: 0 } + } +} + +impl Tab for MiscWidgetsTab { + fn title(&self) -> String { + "Misc Widgets".to_string() + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + colors::render_rgb_colors(area, buf); + let area = area.inner(&Margin { + vertical: 1, + horizontal: 2, + }); + Clear.render(area, buf); + Block::new().style(styles::APP).render(area, buf); + let area = layout(area, Direction::Vertical, vec![0, 5]); + render_bars(area[0], buf); + render_gauges(self.selected_row, area[1], buf); + } +} + +fn render_bars(area: Rect, buf: &mut Buffer) { + let area = Layout::default() + .direction(Direction::Horizontal) + .constraints(vec![Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) + .split(area); + + render_simple_barchart(area[0], buf); + render_horizontal_barchart(area[1], buf); +} + +fn render_simple_barchart(area: Rect, buf: &mut Buffer) { + let data = vec![ + ("Jan", 10), + ("Feb", 20), + ("Mar", 30), + ("Apr", 40), + ("May", 50), + ("Jun", 60), + ("Jul", 70), + ]; + let block = Block::default() + .title("BarChart") + .borders(Borders::ALL) + .border_type(BorderType::Rounded); + let dark_green = Color::Rgb(32, 96, 48); + let light_green = Color::Rgb(64, 192, 96); + BarChart::default() + .data(&data) + .block(block) + .bar_width(3) + .bar_gap(1) + .value_style(Style::default().fg(dark_green).bg(light_green)) + .label_style(Style::default().fg(light_green)) + .bar_style(Style::default().fg(light_green)) + .render(area, buf); +} + +fn render_horizontal_barchart(area: Rect, buf: &mut Buffer) { + // https://www.videocardbenchmark.net/high_end_gpus.html + let bg = Color::Rgb(32, 48, 96); + let nvidia = Style::new().light_green().bg(bg); + let amd = Style::new().light_red().bg(bg); + let data = [ + Bar::default() + .text_value("GeForce RTX 4090 (38,978)".into()) + .value_style(nvidia) + .value(38978), + Bar::default() + .text_value("GeForce RTX 4080 (34,879)".into()) + .value_style(nvidia) + .value(34879), + Bar::default() + .text_value("Radeon PRO W7800 (32,146)".into()) + .value_style(amd) + .value(32146), + Bar::default() + .text_value("GeForce RTX 4070 Ti (31,659)".into()) + .value_style(nvidia) + .value(31659), + Bar::default() + .text_value("Radeon RX 7900 XTX (31,180)".into()) + .value_style(amd) + .value(31180), + ]; + let group = BarGroup::default().label("GPU".into()).bars(&data); + let block = Block::default() + .title("Passmark") + .borders(Borders::ALL) + .border_type(BorderType::Rounded); + BarChart::default() + .direction(Direction::Horizontal) + .block(block) + .data(group) + .bar_gap(1) + .bar_style(Style::default().fg(bg)) + .render(area, buf); +} + +pub fn render_gauges(progress: usize, area: Rect, buf: &mut Buffer) { + let block = Block::new() + .title("Gauges") + .borders(Borders::ALL) + .border_type(BorderType::Rounded); + let inner = block.inner(area); + block.render(area, buf); + let area = layout(inner, Direction::Vertical, vec![1, 1, 1, 0]); + + let percent = (progress * 2 + 20).min(100) as f64; + let progress_label = if percent < 100.0 { + format!("{}%", percent) + } else { + "Done!".into() + }; + + render_gauge(percent, &progress_label, area[0], buf); + render_line_gauge(percent, &progress_label, area[1], buf); + render_sparkline(progress, "Sparkline", area[2], buf); +} + +fn render_gauge(percent: f64, label: &str, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Horizontal, vec![10, 0]); + Paragraph::new("Gauge") + .style(Style::new().light_green()) + .render(area[0], buf); + let bg = Color::Rgb(32, 96, 48); + let fg = Color::Rgb(64, 192, 96); + Gauge::default() + .ratio(percent / 100.0) + .label(format!("Processing: {}", label)) + .gauge_style(Style::new().fg(fg).bg(bg)) + .use_unicode(false) + .render(area[1], buf); +} + +fn render_line_gauge(percent: f64, label: &str, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Horizontal, vec![10, 0]); + Paragraph::new("LineGauge") + .style(Style::new().light_blue()) + .render(area[0], buf); + LineGauge::default() + .ratio(percent / 100.0) + .label(format!("Download: {}", label)) + .style(Style::new().light_blue()) + .gauge_style(Style::new().blue().on_light_blue()) + .line_set(symbols::line::THICK) + .render(area[1], buf); +} + +pub fn render_sparkline(progress: usize, title: &str, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Horizontal, vec![10, 0]); + Paragraph::new(title) + .style(Style::new().white()) + .render(area[0], buf); + 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); + let style = Style::new().fg(Color::Rgb(192, 192, 64)); + Sparkline::default() + .data(&data) + .style(style) + .render(area[1], buf); +} diff --git a/examples/demo2/tabs/recipe.rs b/examples/demo2/tabs/recipe.rs new file mode 100644 index 000000000..acdd0869c --- /dev/null +++ b/examples/demo2/tabs/recipe.rs @@ -0,0 +1,97 @@ +use ratatui::{prelude::*, widgets::*}; + +use super::Tab; +use crate::{colors, styles, tui}; + +#[derive(Debug)] +pub struct RecipeTab { + selected_row: usize, +} + +impl RecipeTab { + pub fn new(selected_row: usize) -> Self { + const INGREDIENT_COUNT: usize = 11; // TODO: derive this from the table + Self { + selected_row: selected_row % INGREDIENT_COUNT, + } + } +} + +impl Tab for RecipeTab { + fn title(&self) -> String { + "Recipe".to_string() + } + + fn select(&mut self, row: usize) { + self.selected_row = row; + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + colors::render_rgb_colors(area, buf); + let area = area.inner(&Margin { + vertical: 1, + horizontal: 2, + }); + Clear.render(area, buf); + Block::new().style(styles::APP).render(area, buf); + + let area = tui::layout(area, Direction::Vertical, vec![8, 0]); + + let lines: Vec = vec![ + Line::from(vec![ + "Step 1: ".white().bold(), + "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." + .into(), + ]), + Line::from(vec![ + "Step 2: ".white().bold(), + "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." + .into(), + ]), + Line::from(vec!["Ingredients:".white().bold()]), + ]; + Paragraph::new(lines) + .wrap(Wrap { trim: true }) + .render(area[0], buf); + + let mut state = TableState::default().with_selected(Some(self.selected_row)); + // https://www.realsimple.com/food-recipes/browse-all-recipes/ratatouille + StatefulWidget::render( + Table::new(vec![ + Row::new(vec!["4 tbsp", "olive oil", ""]), + Row::new(vec!["1", "onion", "thinly sliced"]), + Row::new(vec!["4", "cloves garlic", "peeled and sliced"]), + Row::new(vec!["1", "small bay leaf", ""]), + Row::new(vec!["1", "small eggplant", "cut into 1/2 inch cubes"]), + Row::new(vec![ + "1".into(), + "small zucchini".into(), + Text::raw("halved lengthwise and cut into\nthin slices"), + ]) + .height(2), + Row::new(vec!["1", "red bell pepper", "cut into slivers"]), + Row::new(vec!["4", "plum tomatoes", "coarsely chopped"]), + Row::new(vec!["1 tsp", "kosher salt", ""]), + Row::new(vec!["1/4 cup", "shredded fresh basil leaves", ""]), + Row::new(vec!["", "freshly ground black pepper", ""]), + ]) + .header( + Row::new(vec!["Qty", "Ingredient", "Notes"]) + .style(Style::new().white().underlined()), + ) + .widths(&[ + Constraint::Length(7), + Constraint::Length(30), + Constraint::Length(450), + ]) + .highlight_style(Style::new().light_yellow()), + area[1], + buf, + &mut state, + ); + } +} diff --git a/examples/demo2/tabs/traceroute.rs b/examples/demo2/tabs/traceroute.rs new file mode 100644 index 000000000..f7da25a46 --- /dev/null +++ b/examples/demo2/tabs/traceroute.rs @@ -0,0 +1,194 @@ +use itertools::Itertools; +use ratatui::{ + prelude::*, + widgets::{ + canvas::{Canvas, Map, Points}, + *, + }, +}; + +use super::Tab; +use crate::{colors, styles, tui::layout}; + +#[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, + } + } +} + +#[derive(Debug)] +pub struct TracerouteTab { + selected_row: usize, +} + +impl TracerouteTab { + pub fn new(selected_row: usize) -> Self { + Self { + selected_row: selected_row % HOPS.len(), + } + } +} + +impl Tab for TracerouteTab { + fn title(&self) -> String { + "Traceroute".to_string() + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_traceroute_tab(area, buf); + } + + fn select(&mut self, row: usize) { + self.selected_row = row; + } +} + +impl TracerouteTab { + fn render_traceroute_tab(&self, area: Rect, buf: &mut Buffer) { + colors::render_rgb_colors(area, buf); + let area = area.inner(&Margin { + vertical: 1, + horizontal: 2, + }); + Clear.render(area, buf); + Block::new().style(styles::APP).render(area, buf); + let area = Layout::default() + .direction(Direction::Horizontal) + .constraints(vec![Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) + .split(area); + let left_area = layout(area[0], Direction::Vertical, vec![0, 4]); + self.render_hops(left_area[0], buf); + render_ping(self.selected_row, left_area[1], buf); + self.render_map(area[1], buf); + } + + fn render_hops(&self, area: Rect, buf: &mut Buffer) { + let mut state = TableState::default().with_selected(Some(self.selected_row)); + let rows = HOPS + .iter() + .map(|hop| Row::new(vec![hop.host, hop.address])) + .collect_vec(); + let block = Block::default() + .title("Traceroute bad.horse") + .borders(Borders::ALL) + .border_type(BorderType::Rounded); + StatefulWidget::render( + Table::new(rows) + .header(Row::new(vec!["Host", "Address"]).bold().underlined()) + .widths(&[Constraint::Max(100), Constraint::Length(15)]) + .highlight_style(Style::new().dark_gray().on_white()) + .block(block), + area, + buf, + &mut state, + ); + } + + fn render_map(&self, area: Rect, buf: &mut Buffer) { + let path: Option<(&Hop, &Hop)> = HOPS.iter().tuple_windows().nth(self.selected_row); + let block = Block::new().title("Map").borders(Borders::ALL); + let map = Map { + resolution: canvas::MapResolution::High, + color: Color::Gray, + }; + Canvas::default() + .marker(Marker::Dot) + .x_bounds([113.0, 154.0]) // australia + .y_bounds([-42.0, -11.0]) // australia + .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, + Color::Blue, + )); + context.draw(&Points { + color: Color::Green, + coords: &[path.0.location], // sydney + }); + context.draw(&Points { + color: Color::Red, + coords: &[path.1.location], // perth + }); + } + }) + .block(block) + .render(area, buf); + } +} + +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), +]; + +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") + .borders(Borders::ALL) + .border_type(BorderType::Rounded), + ) + .data(&data) + .style(Style::new().white()) + .render(area, buf); +} diff --git a/examples/demo2/tui.rs b/examples/demo2/tui.rs new file mode 100644 index 000000000..16aab0c34 --- /dev/null +++ b/examples/demo2/tui.rs @@ -0,0 +1,67 @@ +use std::{ + io::{self, stdout, Stdout}, + rc::Rc, + time::Duration, +}; + +use anyhow::{Context, Result}; +use crossterm::{ + event::{self, Event}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use itertools::Itertools; +use ratatui::prelude::*; + +pub fn create_terminal() -> Result>> { + // 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 options = TerminalOptions { + viewport: Viewport::Fixed(Rect::new(0, 0, 81, 18)), + // viewport: Viewport::Fullscreen, + }; + let terminal = Terminal::with_options(CrosstermBackend::new(io::stdout()), options)?; + Ok(terminal) +} + +pub fn setup() -> Result<()> { + enable_raw_mode().context("enable raw mode")?; + stdout() + .execute(EnterAlternateScreen) + .context("enter alternate screen")?; + Ok(()) +} + +pub fn restore() -> Result<()> { + disable_raw_mode().context("disable raw mode")?; + stdout() + .execute(LeaveAlternateScreen) + .context("leave alternate screen")?; + Ok(()) +} + +pub fn next_event(timeout: Duration) -> io::Result> { + if !event::poll(timeout)? { + return Ok(None); + } + let event = event::read()?; + Ok(Some(event)) +} + +/// helper method to split an area into multiple sub-areas +pub fn layout(area: Rect, direction: Direction, heights: Vec) -> Rc<[Rect]> { + let constraints = heights + .iter() + .map(|&h| { + if h > 0 { + Constraint::Length(h) + } else { + Constraint::Min(0) + } + }) + .collect_vec(); + Layout::default() + .direction(direction) + .constraints(constraints) + .split(area) +}