diff --git a/Cargo.toml b/Cargo.toml index 3cb3736d5..4cd66f932 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,14 +47,16 @@ document-features = { version = "0.2.7", optional = true } [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] @@ -148,6 +150,11 @@ 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" +# 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/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..c64c7cf7c --- /dev/null +++ b/examples/demo2.tape @@ -0,0 +1,34 @@ +# 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 +Set TypingSpeed 200ms +Show +Sleep 1.5s +Down 10 +Sleep 1s +Right +Sleep 1.5s +Down 20 +Right +Sleep 1.5s +Down@50ms 40 +Sleep 1s +Right +Sleep 1.5s +Right +Sleep 1.5s +Down @2s 4 +Sleep 2s +Right +Sleep 1.5s +Down@0.5s 28 +Sleep 5s diff --git a/examples/demo2/app.rs b/examples/demo2/app.rs new file mode 100644 index 000000000..1ca30db21 --- /dev/null +++ b/examples/demo2/app.rs @@ -0,0 +1,131 @@ +use std::{ + io::{self, stdout, Stdout}, + time::Duration, +}; + +use crossterm::{ + event::{self, KeyEventKind}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use ratatui::prelude::*; + +use crate::{main_view::MainView, *}; + +pub struct App { + terminal: Terminal>, + should_quit: bool, + selected_tab: usize, + selected_row: usize, +} + +impl App { + pub fn new() -> 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(Self { + terminal, + should_quit: false, + selected_tab: 0, + selected_row: 0, + }) + } + + pub fn run(&mut self) -> Result<()> { + setup_terminal()?; + while !self.should_quit { + self.draw()?; + self.handle_events()?; + } + restore_terminal()?; + Ok(()) + } + + fn draw(&mut self) -> Result<()> { + self.terminal + .draw(|frame| { + let area = frame.size(); + let widget = MainView { + selected_tab: self.selected_tab, + selected_row: self.selected_row, + }; + frame.render_widget(widget, area); + }) + .context("terminal.draw")?; + Ok(()) + } + + fn handle_events(&mut self) -> Result<()> { + if !event::poll(Duration::from_millis(16))? { + return Ok(()); + } + match event::read()? { + event::Event::Key(key) => self.handle_key_event(key), + _ => Ok(()), + } + } + + fn handle_key_event(&mut self, key: event::KeyEvent) -> std::result::Result<(), anyhow::Error> { + if key.kind != KeyEventKind::Press { + return Ok(()); + } + match key.code { + event::KeyCode::Char('q') => { + self.should_quit = true; + } + event::KeyCode::Left | event::KeyCode::Char('h') => { + self.selected_tab = self.selected_tab.saturating_sub(1); + self.selected_row = 0; + } + event::KeyCode::Right | event::KeyCode::Char('l') => { + self.selected_tab = self.selected_tab.saturating_add(1).min(5); + self.selected_row = 0; + } + event::KeyCode::Up | event::KeyCode::Char('k') => { + self.selected_row = self.selected_row.saturating_sub(1); + } + event::KeyCode::Down | event::KeyCode::Char('j') => { + self.selected_row = self.selected_row.saturating_add(1); + } + _ => {} + }; + Ok(()) + } +} + +impl Drop for App { + fn drop(&mut self) { + let _ = restore_terminal(); + } +} + +fn setup_terminal() -> Result<()> { + enable_raw_mode().context("enable raw mode")?; + stdout() + .execute(EnterAlternateScreen) + .context("enter alternate screen")?; + Ok(()) +} + +fn restore_terminal() -> Result<()> { + disable_raw_mode().context("disable raw mode")?; + stdout() + .execute(LeaveAlternateScreen) + .context("leave alternate screen")?; + Ok(()) +} + +pub fn install_panic_hook() { + better_panic::install(); + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = restore_terminal(); + hook(info); + std::process::exit(1); + })); +} diff --git a/examples/demo2/bars.rs b/examples/demo2/bars.rs new file mode 100644 index 000000000..c4b025e2c --- /dev/null +++ b/examples/demo2/bars.rs @@ -0,0 +1,86 @@ +use ratatui::{prelude::*, widgets::*}; + +pub fn render(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); + BarChart::default() + .data(&data) + .block(block) + .bar_width(3) + .bar_gap(1) + .value_style( + Style::default() + .fg(Color::Black) + .bg(Color::Green) + .add_modifier(Modifier::ITALIC), + ) + .label_style(Style::default().fg(Color::Yellow)) + .bar_style(Style::default().fg(Color::Green)) + .render(area, buf); +} + +fn render_horizontal_barchart(area: Rect, buf: &mut Buffer) { + // https://www.videocardbenchmark.net/high_end_gpus.html + let nvidia = Style::new().bg(Color::Green); + let amd = Style::new().bg(Color::Red); + 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) + .value_style( + Style::default() + .fg(Color::Black) + .add_modifier(Modifier::ITALIC), + ) + .label_style(Style::default().fg(Color::Yellow)) + .bar_style(Style::default().light_blue()) + .render(area, buf); +} diff --git a/examples/demo2/chart.rs b/examples/demo2/chart.rs new file mode 100644 index 000000000..068448169 --- /dev/null +++ b/examples/demo2/chart.rs @@ -0,0 +1,38 @@ +use ratatui::{prelude::*, widgets::*}; + +use crate::main_view::{layout, render_title}; + +pub fn render(area: Rect, buf: &mut Buffer) { + let layout = layout(area, Direction::Vertical, vec![1, 0]); + render_title("Chart", layout[0], buf); + let area = layout[1]; + let data = (0..area.width * 2) + .map(f64::from) + .map(|x| (x / area.width as f64) * 10.0 - 5.0) + .map(|x| { + ( + x, + x.powi(5) + 3.5 * x.powi(4) - 2.5 * x.powi(3) - 12.5 * x.powi(2) + 1.5 * x + 9.0, + ) + }) + .collect::>(); + let datasets = vec![Dataset::default() + .name("data1") + .marker(Marker::Braille) + .style(Style::new().fg(Color::Red)) + .graph_type(GraphType::Line) + .data(&data[..])]; + let chart = Chart::new(datasets) + .x_axis(Axis::default().title("x").bounds([-4.0, 4.0]).labels(vec![ + "-4.0".into(), + "0".into(), + "4.0".into(), + ])) + .y_axis(Axis::default().title("y").bounds([-8.0, 8.0]).labels(vec![ + "-8".into(), + "0".into(), + "8".into(), + ])) + .style(Style::new().on_black()); + chart.render(layout[1], buf); +} diff --git a/examples/demo2/colors.rs b/examples/demo2/colors.rs new file mode 100644 index 000000000..ef167b171 --- /dev/null +++ b/examples/demo2/colors.rs @@ -0,0 +1,75 @@ +use palette::{ + convert::{FromColorUnclamped, IntoColorUnclamped}, + Okhsv, Srgb, +}; +use ratatui::{prelude::*, widgets::*}; + +use crate::main_view::layout; + +pub fn render(rotate: usize, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Horizontal, vec![8, 1, 36, 1, 0]); + + render_16_colors(area[0], buf); + render_256_colors(area[2], buf); + render_rgb_colors(rotate, area[4], buf); +} + +fn render_16_colors(area: Rect, buf: &mut Buffer) { + let areas = layout(area, Direction::Vertical, vec![1, 0]); + Paragraph::new("16 color").render(areas[0], buf); + 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(areas[1], buf); +} + +fn render_256_colors(area: Rect, buf: &mut Buffer) { + let layout = layout(area, Direction::Vertical, vec![1, 0]); + Paragraph::new("256 colors (Indexed RGB)").render(layout[0], buf); + let area = layout[1]; + 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); + } +} + +fn render_rgb_colors(rotate: usize, area: Rect, buf: &mut Buffer) { + let layout = layout(area, Direction::Vertical, vec![1, 0]); + Paragraph::new("24bit RGB (Truecolor)").render(layout[0], buf); + let area = layout[1]; + 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 + rotate as f32 * 10.0; + let value_fg = (yi as f32 + 0.5) / (area.height as f32); + let value_bg = (yi as f32 + 1.0) / (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/email.rs b/examples/demo2/email.rs new file mode 100644 index 000000000..7acfd4e23 --- /dev/null +++ b/examples/demo2/email.rs @@ -0,0 +1,94 @@ +use itertools::Itertools; +use ratatui::{prelude::*, widgets::*}; +use unicode_width::UnicodeWidthStr; + +use crate::main_view::layout; + +struct Email { + from: String, + subject: String, + body: String, +} + +pub fn render(selected_row: usize, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Vertical, vec![6, 0]); + let emails = vec![ + Email { + from: "Alice ".into(), + subject: "Hello".into(), + body: "Hi Bob,\n\nHow are you?\n\nAlice".into(), + }, + Email { + from: "Bob ".into(), + subject: "Re: Hello".into(), + body: "Hi Alice,\n\nI'm fine, thanks!\n\nBob".into(), + }, + Email { + from: "Charlie ".into(), + subject: "Re: Hello".into(), + body: "Hi Alice,\n\nI'm fine, thanks!\n\nCharlie".into(), + }, + Email { + from: "Dave ".into(), + subject: "Re: Hello (STOP REPLYING TO ALL)".into(), + body: "Hi Everyone,\n\nPlease stop replying to all.\n\nDave".into(), + }, + Email { + from: "Eve ".into(), + subject: "Re: Hello (STOP REPLYING TO ALL)".into(), + body: "Hi Everyone,\n\nI'm reading all of your emails.\n\nEve".into(), + }, + ]; + let email = emails.get(selected_row); + render_inbox(&emails, selected_row, area[0], buf); + render_email(email, area[1], buf); +} + +fn render_inbox(emails: &[Email], selected_row: usize, area: Rect, buf: &mut Buffer) { + let block = Block::new().title("Inbox").borders(Borders::ALL); + let inner = block.inner(area); + block.render(area, buf); + + let highlight_symbol = ">>"; + let from_width = emails + .iter() + .map(|e| e.from.width()) + .max() + .unwrap_or_default(); + let subject_width = inner.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(selected_row)); + StatefulWidget::render( + List::new(items) + .highlight_style(Style::new().bold().yellow()) + .highlight_symbol(highlight_symbol), + inner, + buf, + &mut state, + ); +} + +fn render_email(email: Option<&Email>, area: Rect, buf: &mut Buffer) { + let block = Block::new().title("Email").borders(Borders::ALL); + 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/gauges.rs b/examples/demo2/gauges.rs new file mode 100644 index 000000000..ec04e78d8 --- /dev/null +++ b/examples/demo2/gauges.rs @@ -0,0 +1,76 @@ +use ratatui::{prelude::*, widgets::*}; + +use crate::main_view::layout; + +pub fn render(progress: usize, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Vertical, vec![1, 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_1(percent, &progress_label, area[1], buf); + render_line_gauge_2(percent, &progress_label, area[2], buf); + render_sparkline(progress, area[3], 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); + Gauge::default() + .ratio(percent / 100.0) + .label(format!("Processing: {}", label)) + .style(Style::new().black()) + .gauge_style(Style::new().green().on_light_green()) + .use_unicode(false) + .render(area[1], buf); +} + +fn render_line_gauge_1(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!("Upload: {}", label)) + .style(Style::new().light_blue()) + .gauge_style(Style::new().blue().on_light_blue()) + .line_set(symbols::line::NORMAL) + .render(area[1], buf); +} + +fn render_line_gauge_2(percent: f64, label: &str, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Horizontal, vec![10, 0]); + LineGauge::default() + .ratio(1.0 - percent / 100.0) + .label(format!("Download: {}", label)) + .style(Style::new().light_yellow()) + .gauge_style(Style::new().light_red().on_yellow()) + .line_set(symbols::line::THICK) + .render(area[1], buf); +} + +fn render_sparkline(progress: usize, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Horizontal, vec![10, 0]); + Paragraph::new("Sparkline") + .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); + Sparkline::default() + .data(&data) + .style(Style::new().white()) + .render(area[1], buf); +} diff --git a/examples/demo2/main.rs b/examples/demo2/main.rs new file mode 100644 index 000000000..d500f6e4c --- /dev/null +++ b/examples/demo2/main.rs @@ -0,0 +1,18 @@ +use anyhow::{Context, Result}; + +mod app; +mod bars; +mod chart; +mod colors; +mod email; +mod gauges; +mod main_view; +mod modifiers; +mod recipe; +mod text; +mod traceroute; + +fn main() -> Result<()> { + app::install_panic_hook(); + app::App::new()?.run() +} diff --git a/examples/demo2/main_view.rs b/examples/demo2/main_view.rs new file mode 100644 index 000000000..490606e80 --- /dev/null +++ b/examples/demo2/main_view.rs @@ -0,0 +1,122 @@ +use std::rc::Rc; + +use itertools::Itertools; +use ratatui::{prelude::*, widgets::*}; + +use crate::{bars, chart, colors, email, gauges, modifiers, recipe, text, traceroute}; + +pub struct MainView { + pub selected_tab: usize, + pub selected_row: usize, +} + +impl Widget for MainView { + fn render(self, area: Rect, buf: &mut Buffer) { + Block::new().on_black().render(area, buf); + let area = layout(area, Direction::Vertical, vec![1, 0, 1]); + self.render_title_bar(area[0], buf); + match self.selected_tab { + 0 => self.render_tab1(area[1], buf), + 1 => self.render_tab2(area[1], buf), + 2 => self.render_tab3(area[1], buf), + 3 => self.render_tab4(area[1], buf), + 4 => self.render_tab5(area[1], buf), + 5 => self.render_tab6(area[1], buf), + _ => unreachable!(), + } + self.render_bottom_bar(area[2], buf); + } +} + +impl MainView { + fn render_title_bar(&self, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Horizontal, vec![17, 0]); + Paragraph::new("Ratatui v0.23.0 ".italic().bold().white().on_red()).render(area[0], buf); + + Tabs::new(vec![ + "Recipe", + "Words", + "Bars", + "Chart", + "Email", + "Traceroute", + ]) + .style(Style::new().blue()) + .highlight_style(Style::new().bold().underlined().light_blue()) + .select(self.selected_tab) + .render(area[1], buf); + } + + fn render_bottom_bar(&self, area: Rect, buf: &mut Buffer) { + let key_style = Style::new().black().on_dark_gray().not_dim(); + Paragraph::new(Line::from(vec![ + " Q ".set_style(key_style), + " Quit ".into(), + " ←/h ".set_style(key_style), + " Previous Tab ".into(), + " →/l ".set_style(key_style), + " Next Tab ".into(), + " ↑/k ".set_style(key_style), + " Previous Row ".into(), + " ↓/j ".set_style(key_style), + " Next Row".into(), + ])) + .on_black() + .dim() + .render(area, buf); + } + + fn render_tab1(&self, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Vertical, vec![0]); + recipe::render(self.selected_row, area[0], buf); + } + + fn render_tab2(&self, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Vertical, vec![5, 1, 0]); + colors::render(self.selected_row, area[0], buf); + modifiers::render(area[1], buf); + text::render(self.selected_row, area[2], buf); + } + + fn render_tab3(&self, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Vertical, vec![4, 0]); + gauges::render(self.selected_row, area[0], buf); + bars::render(area[1], buf); + } + + fn render_tab4(&self, area: Rect, buf: &mut Buffer) { + chart::render(area, buf); + } + + fn render_tab5(&self, area: Rect, buf: &mut Buffer) { + email::render(self.selected_row, area, buf); + } + + fn render_tab6(&self, area: Rect, buf: &mut Buffer) { + traceroute::render(self.selected_row, area, buf); + } +} + +pub fn render_title(title: &str, area: Rect, buf: &mut Buffer) { + Paragraph::new(title) + .dim() + .render(Rect { height: 1, ..area }, buf); +} + +/// 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) +} diff --git a/examples/demo2/modifiers.rs b/examples/demo2/modifiers.rs new file mode 100644 index 000000000..9adbc719e --- /dev/null +++ b/examples/demo2/modifiers.rs @@ -0,0 +1,24 @@ +use ratatui::{prelude::*, widgets::*}; + +pub fn render(area: Rect, buf: &mut Buffer) { + Paragraph::new(Line::from(vec![ + "Bold".bold(), + " ".into(), + "Dim".dim(), + " ".into(), + "Italic".italic(), + " ".into(), + "Underlined".underlined(), + " ".into(), + "SlowBlink".slow_blink(), + " ".into(), + "RapidBlink".rapid_blink(), + " ".into(), + "Reversed".reversed(), + "█".reset().reversed(), + "Hidden".hidden(), + " ".into(), + "CrossedOut".crossed_out(), + ])) + .render(area, buf); +} diff --git a/examples/demo2/recipe.rs b/examples/demo2/recipe.rs new file mode 100644 index 000000000..1d83915cb --- /dev/null +++ b/examples/demo2/recipe.rs @@ -0,0 +1,63 @@ +use ratatui::{prelude::*, widgets::*}; + +use crate::main_view::layout; + +pub fn render(selected_row: usize, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Vertical, vec![8, 0]); + + let lines: Vec = vec![ + Line::from(vec![ + "Step 1: ".light_green().bold().italic(), + "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: ".light_green().bold().italic(), + "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:".light_green().bold().italic()]), + ]; + Paragraph::new(lines) + .wrap(Wrap { trim: true }) + .render(area[0], buf); + + let mut state = TableState::default().with_selected(Some(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/text.rs b/examples/demo2/text.rs new file mode 100644 index 000000000..3a3b9f8aa --- /dev/null +++ b/examples/demo2/text.rs @@ -0,0 +1,51 @@ +use ratatui::{prelude::*, widgets::*}; + +pub fn render(scroll: usize, area: Rect, buf: &mut Buffer) { + let area = Layout::default() + .direction(Direction::Horizontal) + .constraints(vec![ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ]) + .split(area); + render_paragraph(Alignment::Left, Color::LightRed, scroll, area[0], buf); + render_paragraph(Alignment::Center, Color::LightGreen, scroll, area[1], buf); + render_paragraph(Alignment::Right, Color::LightBlue, scroll, area[2], buf); +} + +fn render_paragraph( + alignment: Alignment, + color: Color, + scroll: usize, + area: Rect, + buf: &mut Buffer, +) { + let block = Block::new() + .title(format!("{} Paragraph", alignment)) + .title_alignment(alignment) + .border_type(BorderType::Rounded) + .borders(Borders::ALL) + .padding(Padding::new(0, 1, 0, 0)); // for scrollbar + let offset = (scroll as u16, 0); + Paragraph::new(lipsum::lipsum(40)) + .style(Style::new().fg(color)) + .alignment(alignment) + .block(block) + .wrap(Wrap { trim: true }) + .scroll(offset) + .render(area, buf); + + let scroll_area = area.inner(&Margin { + vertical: 1, + horizontal: 0, + }); + let mut scroll_state = ScrollbarState::new(14) + .viewport_content_length(scroll_area.height as usize) + .position(scroll); + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .style(Style::new().fg(color)) + .begin_symbol(None) + .end_symbol(None) + .render(scroll_area, buf, &mut scroll_state); +} diff --git a/examples/demo2/traceroute.rs b/examples/demo2/traceroute.rs new file mode 100644 index 000000000..4a37fab03 --- /dev/null +++ b/examples/demo2/traceroute.rs @@ -0,0 +1,134 @@ +use itertools::Itertools; +use ratatui::{ + prelude::*, + widgets::{ + canvas::{Canvas, Map, Points}, + *, + }, +}; + +struct Hop { + host: &'static str, + address: &'static str, + location: (f64, f64), +} + +impl Hop { + fn new(name: &'static str, address: &'static str, location: (f64, f64)) -> Self { + Self { + host: name, + address, + location, + } + } +} + +pub fn render(selected_row: usize, area: Rect, buf: &mut Buffer) { + let area = Layout::default() + .direction(Direction::Horizontal) + .constraints(vec![Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) + .split(area); + let hops = generate_hops(); + render_hops(&hops, selected_row, area[0], buf); + let path: Option<(&Hop, &Hop)> = hops.iter().tuple_windows().nth(selected_row); + render_map(path, area[1], buf); +} + +fn generate_hops() -> Vec { + let canberra = (149.1, -35.3); + let sydney = (151.1, -33.9); + let melbourne = (144.9, -37.8); + let perth = (115.9, -31.9); + let darwin = (130.8, -12.4); + let brisbane = (153.0, -27.5); + let adelaide = (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). + vec![ + 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), + ] +} + +fn render_hops(hops: &[Hop], 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])) + .collect_vec(); + let block = Block::default() + .title("IP Addresses") + .borders(Borders::ALL) + .border_type(BorderType::Rounded); + StatefulWidget::render( + Table::new(rows) + .header(Row::new(vec!["Host", "Address"]).dark_gray().on_gray()) + .widths(&[Constraint::Max(100), Constraint::Length(15)]) + .highlight_style(Style::new().dark_gray().on_white()) + .block(block), + area, + buf, + &mut state, + ); +} + +fn render_map(path: Option<(&Hop, &Hop)>, area: Rect, buf: &mut Buffer) { + 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); +}