From be55a5fbcdffc4fd6aeb7edffa32f6e6c942a41e Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Thu, 21 Sep 2023 01:47:23 -0700 Subject: [PATCH] feat(examples): add demo2 example (#500) --- .markdownlint.yaml | 3 + Cargo.toml | 12 +- README.md | 33 +++-- RELEASE.md | 10 +- examples/README.md | 2 +- examples/colors_rgb.rs | 57 ++++----- examples/demo2-social.tape | 43 +++++++ examples/demo2.tape | 49 ++++++++ examples/demo2/app.rs | 99 +++++++++++++++ examples/demo2/colors.rs | 34 +++++ examples/demo2/main.rs | 17 +++ examples/demo2/root.rs | 93 ++++++++++++++ examples/demo2/tabs.rs | 11 ++ examples/demo2/tabs/about.rs | 157 +++++++++++++++++++++++ examples/demo2/tabs/email.rs | 143 +++++++++++++++++++++ examples/demo2/tabs/recipe.rs | 171 +++++++++++++++++++++++++ examples/demo2/tabs/traceroute.rs | 199 ++++++++++++++++++++++++++++++ examples/demo2/tabs/weather.rs | 141 +++++++++++++++++++++ examples/demo2/term.rs | 71 +++++++++++ examples/demo2/theme.rs | 136 ++++++++++++++++++++ src/lib.rs | 3 +- 21 files changed, 1430 insertions(+), 54 deletions(-) create mode 100644 examples/demo2-social.tape create mode 100644 examples/demo2.tape create mode 100644 examples/demo2/app.rs create mode 100644 examples/demo2/colors.rs create mode 100644 examples/demo2/main.rs create mode 100644 examples/demo2/root.rs create mode 100644 examples/demo2/tabs.rs create mode 100644 examples/demo2/tabs/about.rs create mode 100644 examples/demo2/tabs/email.rs create mode 100644 examples/demo2/tabs/recipe.rs create mode 100644 examples/demo2/tabs/traceroute.rs create mode 100644 examples/demo2/tabs/weather.rs create mode 100644 examples/demo2/term.rs create mode 100644 examples/demo2/theme.rs diff --git a/.markdownlint.yaml b/.markdownlint.yaml index 68c7bfe7..5168908a 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 9526ee0e..a97acfc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,14 +48,15 @@ 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" +rand = "0.8.5" +palette = "0.7.3" pretty_assertions = "1.4.0" [features] @@ -149,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" +required-features = ["crossterm", "widget-calendar"] +doc-scrape-examples = true + [[example]] name = "gauge" required-features = ["crossterm"] diff --git a/README.md b/README.md index 1dc42ee9..6d0fe3b2 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,36 @@ -# Ratatui +![Demo of +Ratatui](https://raw.githubusercontent.com/ratatui-org/ratatui/aa09e59dc0058347f68d7c1e0c91f863c6f2b8c9/examples/demo2.gif) + - - -`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/RELEASE.md b/RELEASE.md index 4365a40d..451f1dd8 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -7,12 +7,14 @@ actions](.github/workflows/cd.yml) and triggered by pushing a tag. [vhs](https://github.com/charmbracelet/vhs) (installation instructions in README). ```shell - cargo build --example demo - vhs examples/demo.tape --publish --quiet + cargo build --example demo2 + vhs examples/demo2.tape ``` - Then update the link in the [examples README](./examples/README) and the main README. Avoid - adding the gif to the git repo as binary files tend to bloat repositories. +1. Switch branches to the images branch and copy demo2.gif to examples/, commit, and push. +1. Grab the permalink from and + append `?raw=true` to redirect to the actual image url. Then update the link in the main README. + Avoid adding the gif to the git repo as binary files tend to bloat repositories. 1. Bump the version in [Cargo.toml](Cargo.toml). 1. Bump versions in the doc comments of [lib.rs](src/lib.rs). diff --git a/examples/README.md b/examples/README.md index 71bf2613..2f95cf0f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,7 +8,7 @@ occur in a terminal. ## Demo -This is the demo example from the main README. It is available for each of the backends. Source: +This is the previous demo example from the main README. It is available for each of the backends. Source: [demo.rs](./demo/). ```shell diff --git a/examples/colors_rgb.rs b/examples/colors_rgb.rs index 6f6451f1..d531fe1d 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(); - } - } - } + 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; - /// 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)) + 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-social.tape b/examples/demo2-social.tape new file mode 100644 index 00000000..1fb29458 --- /dev/null +++ b/examples/demo2-social.tape @@ -0,0 +1,43 @@ +# 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-social.gif" +Set Theme "OceanicMaterial" +# Github social preview size (1280x640 with 80px padding) and must be < 1MB +# This puts some constraints on the amount of interactivity we can do. +Set Width 1280 +Set Height 640 +Set Padding 80 +Hide +Type "cargo run --example demo2 --features crossterm,widget-calendar" +Enter +Sleep 2s +Show +# About screen +Sleep 3.5s +Down # Red eye +Sleep 0.4s +Down # black eye +Sleep 1s +Tab +# Recipe +Sleep 1s +Set TypingSpeed 500ms +Down 7 +Sleep 1s +Tab +# Email +Sleep 2s +Down 4 +Sleep 2s +Tab +# Trace route +Sleep 1s +Set TypingSpeed 200ms +Down 10 +Sleep 2s +Tab +# Weather +Set TypingSpeed 100ms +Down 40 +Sleep 2s \ No newline at end of file diff --git a/examples/demo2.tape b/examples/demo2.tape new file mode 100644 index 00000000..d912ee3e --- /dev/null +++ b/examples/demo2.tape @@ -0,0 +1,49 @@ +# 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` +# NOTE: Requires VHS 0.6.1 or later for Screenshot support +Output "target/demo2.gif" +Set Theme "OceanicMaterial" +# The reason for this strange size is that the social preview image for this +# demo is 1280x64 with 80 pixels of padding on each side. We want a version +# without the padding for README.md, etc. +Set Width 1120 +Set Height 480 +Set Padding 0 +Hide +Type "cargo run --example demo2 --features crossterm,widget-calendar" +Enter +Sleep 2s +Show +# About screen +Screenshot "target/demo2-about.png" +Sleep 3.5s +Down # Red eye +Sleep 0.4s +Down # black eye +Sleep 1s +Tab +# Recipe +Screenshot "target/demo2-recipe.png" +Sleep 1s +Set TypingSpeed 500ms +Down 7 +Sleep 1s +Tab +# Email +Screenshot "target/demo2-email.png" +Sleep 2s +Down 4 +Sleep 2s +Tab +# Trace route +Screenshot "target/demo2-trace.png" +Sleep 1s +Set TypingSpeed 200ms +Down 10 +Sleep 2s +Tab +# Weather +Screenshot "target/demo2-weather.png" +Set TypingSpeed 100ms +Down 40 +Sleep 2s \ No newline at end of file diff --git a/examples/demo2/app.rs b/examples/demo2/app.rs new file mode 100644 index 00000000..fcf8a019 --- /dev/null +++ b/examples/demo2/app.rs @@ -0,0 +1,99 @@ +use std::time::Duration; + +use anyhow::{Context, Result}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::prelude::Rect; + +use crate::{Root, Term}; + +#[derive(Debug)] +pub struct App { + term: Term, + should_quit: bool, + context: AppContext, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct AppContext { + pub tab_index: usize, + pub row_index: usize, +} + +impl App { + fn new() -> Result { + Ok(Self { + term: Term::start()?, + should_quit: false, + context: AppContext::default(), + }) + } + + pub fn run() -> Result<()> { + install_panic_hook(); + let mut app = Self::new()?; + while !app.should_quit { + app.draw()?; + app.handle_events()?; + } + Term::stop()?; + Ok(()) + } + + fn draw(&mut self) -> Result<()> { + self.term + .draw(|frame| frame.render_widget(Root::new(&self.context), frame.size())) + .context("terminal.draw")?; + Ok(()) + } + + fn handle_events(&mut self) -> Result<()> { + match Term::next_event(Duration::from_millis(16))? { + Some(Event::Key(key)) => self.handle_key_event(key), + Some(Event::Resize(width, height)) => { + Ok(self.term.resize(Rect::new(0, 0, width, height))?) + } + _ => Ok(()), + } + } + + fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { + if key.kind != KeyEventKind::Press { + return Ok(()); + } + + let context = &mut self.context; + const TAB_COUNT: usize = 5; + match key.code { + KeyCode::Char('q') | KeyCode::Esc => { + self.should_quit = true; + } + KeyCode::Tab | KeyCode::BackTab if key.modifiers.contains(KeyModifiers::SHIFT) => { + let tab_index = context.tab_index + TAB_COUNT; // to wrap around properly + context.tab_index = tab_index.saturating_sub(1) % TAB_COUNT; + context.row_index = 0; + } + KeyCode::Tab | KeyCode::BackTab => { + context.tab_index = context.tab_index.saturating_add(1) % TAB_COUNT; + context.row_index = 0; + } + KeyCode::Up | KeyCode::Char('k') => { + context.row_index = context.row_index.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + context.row_index = context.row_index.saturating_add(1); + } + _ => {} + }; + Ok(()) + } +} + +pub fn install_panic_hook() { + better_panic::install(); + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = Term::stop(); + hook(info); + std::process::exit(1); + })); +} diff --git a/examples/demo2/colors.rs b/examples/demo2/colors.rs new file mode 100644 index 00000000..902ffa3f --- /dev/null +++ b/examples/demo2/colors.rs @@ -0,0 +1,34 @@ +use palette::{IntoColor, Okhsv, Srgb}; +use ratatui::{prelude::*, widgets::*}; + +/// 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 { + fn render(self, area: Rect, buf: &mut Buffer) { + for (yi, y) in (area.top()..area.bottom()).enumerate() { + let value = area.height as f32 - yi as f32; + let value_fg = value / (area.height as f32); + let value_bg = (value - 0.5) / (area.height as f32); + for (xi, x) in (area.left()..area.right()).enumerate() { + let hue = xi as f32 * 360.0 / area.width as f32; + let fg = color_from_oklab(hue, Okhsv::max_saturation(), value_fg); + let bg = color_from_oklab(hue, Okhsv::max_saturation(), value_bg); + buf.get_mut(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 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) +} diff --git a/examples/demo2/main.rs b/examples/demo2/main.rs new file mode 100644 index 00000000..14c75d65 --- /dev/null +++ b/examples/demo2/main.rs @@ -0,0 +1,17 @@ +use anyhow::Result; +pub use app::*; +pub use colors::*; +pub use root::*; +pub use term::*; +pub use theme::*; + +mod app; +mod colors; +mod root; +mod tabs; +mod term; +mod theme; + +fn main() -> Result<()> { + App::run() +} diff --git a/examples/demo2/root.rs b/examples/demo2/root.rs new file mode 100644 index 00000000..14b60dd6 --- /dev/null +++ b/examples/demo2/root.rs @@ -0,0 +1,93 @@ +use std::rc::Rc; + +use itertools::Itertools; +use ratatui::{prelude::*, widgets::*}; + +use crate::{tabs::*, AppContext, THEME}; + +pub struct Root<'a> { + context: &'a AppContext, +} + +impl<'a> Root<'a> { + pub fn new(context: &'a AppContext) -> Self { + Root { context } + } +} + +impl Widget for Root<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + Block::new().style(THEME.root).render(area, buf); + let area = 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 Root<'_> { + fn render_title_bar(&self, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Horizontal, vec![0, 45]); + + Paragraph::new(Span::styled("Ratatui", THEME.app_title)).render(area[0], buf); + let titles = vec!["", " Recipe ", " Email ", " Traceroute ", " Weather "]; + Tabs::new(titles) + .style(THEME.tabs) + .highlight_style(THEME.tabs_selected) + .select(self.context.tab_index) + .divider("") + .render(area[1], buf); + } + + fn render_selected_tab(&self, area: Rect, buf: &mut Buffer) { + let row_index = self.context.row_index; + match self.context.tab_index { + 0 => AboutTab::new(row_index).render(area, buf), + 1 => RecipeTab::new(row_index).render(area, buf), + 2 => EmailTab::new(row_index).render(area, buf), + 3 => TracerouteTab::new(row_index).render(area, buf), + 4 => WeatherTab::new(row_index).render(area, buf), + _ => unreachable!(), + }; + } + + fn render_bottom_bar(&self, area: Rect, buf: &mut Buffer) { + let keys = [ + ("Q/Esc", "Quit"), + ("Tab", "Next Tab"), + ("↑/k", "Up"), + ("↓/j", "Down"), + ]; + 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(); + Paragraph::new(Line::from(spans)) + .alignment(Alignment::Center) + .fg(Color::Indexed(236)) + .bg(Color::Indexed(232)) + .render(area, buf); + } +} + +/// simple 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/tabs.rs b/examples/demo2/tabs.rs new file mode 100644 index 00000000..bb601b90 --- /dev/null +++ b/examples/demo2/tabs.rs @@ -0,0 +1,11 @@ +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; diff --git a/examples/demo2/tabs/about.rs b/examples/demo2/tabs/about.rs new file mode 100644 index 00000000..bd9610db --- /dev/null +++ b/examples/demo2/tabs/about.rs @@ -0,0 +1,157 @@ +use itertools::Itertools; +use ratatui::{prelude::*, widgets::*}; + +use crate::{layout, RgbSwatch, THEME}; + +const RATATUI_LOGO: [&str; 32] = [ + " ███ ", + " ██████ ", + " ███████ ", + " ████████ ", + " █████████ ", + " ██████████ ", + " ████████████ ", + " █████████████ ", + " █████████████ ██████", + " ███████████ ████████", + " █████ ███████████ ", + " ███ ██ee████████ ", + " █ █████████████ ", + " ████ █████████████ ", + " █████████████████ ", + " ████████████████ ", + " ████████████████ ", + " ███ ██████████ ", + " ██ █████████ ", + " █xx█ █████████ ", + " █xxxx█ ██████████ ", + " █xx█xxx█ █████████ ", + " █xx██xxxx█ ████████ ", + " █xxxxxxxxxx█ ██████████ ", + " █xxxxxxxxxxxx█ ██████████ ", + " █xxxxxxx██xxxxx█ █████████ ", + " █xxxxxxxxx██xxxxx█ ████ ███ ", + " █xxxxxxxxxxxxxxxxxx█ ██ ███ ", + "█xxxxxxxxxxxxxxxxxxxx█ █ ███ ", + "█xxxxxxxxxxxxxxxxxxxxx█ ███ ", + " █xxxxxxxxxxxxxxxxxxxxx█ ███ ", + " █xxxxxxxxxxxxxxxxxxxxx█ █ ", +]; + +pub struct AboutTab { + selected_row: usize, +} + +impl AboutTab { + pub fn new(selected_row: usize) -> Self { + Self { selected_row } + } +} + +impl Widget for AboutTab { + fn render(self, area: Rect, buf: &mut Buffer) { + RgbSwatch.render(area, buf); + let area = layout(area, Direction::Horizontal, vec![34, 0]); + render_crate_description(area[1], buf); + render_logo(self.selected_row, area[0], 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 half block characters to render a logo based on the RATATUI_LOGO const. +/// +/// The logo is rendered in three colors, one for the rat, one for the terminal, and one for the +/// rat's eye. The eye color alternates between two colors based on the selected row. +pub fn render_logo(selected_row: usize, area: Rect, buf: &mut Buffer) { + let eye_color = if selected_row % 2 == 0 { + THEME.logo.rat_eye + } else { + THEME.logo.rat_eye_alt + }; + 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); + let rat_color = THEME.logo.rat; + let term_color = THEME.logo.term; + match (ch1, ch2) { + ('█', '█') => { + cell.set_char('█'); + cell.fg = rat_color; + } + ('█', ' ') => { + cell.set_char('▀'); + cell.fg = rat_color; + } + (' ', '█') => { + cell.set_char('▄'); + cell.fg = rat_color; + } + ('█', 'x') => { + cell.set_char('▀'); + cell.fg = rat_color; + cell.bg = term_color; + } + ('x', '█') => { + cell.set_char('▄'); + cell.fg = rat_color; + cell.bg = term_color; + } + ('x', 'x') => { + cell.set_char(' '); + cell.fg = term_color; + cell.bg = term_color; + } + ('█', 'e') => { + cell.set_char('▀'); + cell.fg = rat_color; + cell.bg = eye_color; + } + ('e', '█') => { + cell.set_char('▄'); + cell.fg = rat_color; + cell.bg = eye_color; + } + (_, _) => {} + }; + } + } +} diff --git a/examples/demo2/tabs/email.rs b/examples/demo2/tabs/email.rs new file mode 100644 index 00000000..4ac463dc --- /dev/null +++ b/examples/demo2/tabs/email.rs @@ -0,0 +1,143 @@ +use itertools::Itertools; +use ratatui::{prelude::*, widgets::*}; +use unicode_width::UnicodeWidthStr; + +use crate::{layout, RgbSwatch, THEME}; + +#[derive(Debug, Default)] +pub struct Email { + from: &'static str, + subject: &'static str, + body: &'static str, +} + +const EMAILS: &[Email] = &[ + Email { + from: "Alice ", + subject: "Hello", + body: "Hi Bob,\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", + }, +]; + +#[derive(Debug, Default)] +pub struct EmailTab { + selected_index: usize, +} + +impl EmailTab { + pub fn new(selected_index: usize) -> Self { + Self { + selected_index: selected_index % 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 area = layout(area, Direction::Vertical, vec![5, 0]); + render_inbox(self.selected_index, area[0], buf); + render_email(self.selected_index, area[1], buf); + } +} +fn render_inbox(selected_index: usize, area: Rect, buf: &mut Buffer) { + let area = layout(area, Direction::Vertical, vec![1, 0]); + let theme = THEME.email; + Tabs::new(vec![" Inbox ", " Sent ", " Drafts "]) + .style(theme.tabs) + .highlight_style(theme.tabs_selected) + .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 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()])) + }) + .collect_vec(); + 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), + area[1], + 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(area[1], 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 area = layout(inner, Direction::Vertical, vec![3, 0]); + 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(area[0], buf); + let body = email.body.lines().map(Line::from).collect_vec(); + Paragraph::new(body).style(theme.body).render(area[1], buf); + } else { + Paragraph::new("No email selected").render(inner, buf); + } +} diff --git a/examples/demo2/tabs/recipe.rs b/examples/demo2/tabs/recipe.rs new file mode 100644 index 00000000..e5d17ef5 --- /dev/null +++ b/examples/demo2/tabs/recipe.rs @@ -0,0 +1,171 @@ +use itertools::Itertools; +use ratatui::{prelude::*, widgets::*}; + +use crate::{layout, RgbSwatch, THEME}; + +#[derive(Debug, Default, Clone, Copy)] +struct Ingredient { + quantity: &'static str, + name: &'static str, +} + +impl Ingredient { + fn height(&self) -> u16 { + self.name.lines().count() as u16 + } +} + +impl<'a> From for Row<'a> { + fn from(i: Ingredient) -> Self { + Row::new(vec![i.quantity, i.name]).height(i.height()) + } +} + +// https://www.realsimple.com/food-recipes/browse-all-recipes/ratatouille +const 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)] +pub struct RecipeTab { + selected_row: usize, +} + +impl RecipeTab { + pub fn new(selected_row: usize) -> Self { + Self { + selected_row: selected_row % 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.selected_row, scrollbar_area, buf); + + let area = area.inner(&Margin { + horizontal: 2, + vertical: 1, + }); + let area = layout(area, Direction::Horizontal, vec![44, 0]); + + render_recipe(area[0], buf); + render_ingredients(self.selected_row, area[1], 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().map(|&i| i.into()).collect_vec(); + let theme = THEME.recipe; + StatefulWidget::render( + Table::new(rows) + .block(Block::new().style(theme.ingredients)) + .header(Row::new(vec!["Qty", "Ingredient"]).style(theme.ingredients_header)) + .widths(&[Constraint::Length(7), Constraint::Length(30)]) + .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) +} diff --git a/examples/demo2/tabs/traceroute.rs b/examples/demo2/tabs/traceroute.rs new file mode 100644 index 00000000..cb1867bd --- /dev/null +++ b/examples/demo2/tabs/traceroute.rs @@ -0,0 +1,199 @@ +use itertools::Itertools; +use ratatui::{ + prelude::*, + widgets::{canvas::*, *}, +}; + +use crate::{layout, RgbSwatch, THEME}; + +#[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 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 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, 3]); + render_hops(self.selected_row, left_area[0], buf); + render_ping(self.selected_row, left_area[1], buf); + render_map(self.selected_row, area[1], 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])) + .collect_vec(); + let block = Block::default() + .title("Traceroute bad.horse".bold().white()) + .title_alignment(Alignment::Center) + .padding(Padding::new(1, 1, 1, 1)); + StatefulWidget::render( + Table::new(rows) + .header(Row::new(vec!["Host", "Address"]).set_style(THEME.traceroute.header)) + .widths(&[Constraint::Max(100), Constraint::Length(15)]) + .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: canvas::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::Dot) + // 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([113.0, 154.0]) + .y_bounds([-42.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), +]; diff --git a/examples/demo2/tabs/weather.rs b/examples/demo2/tabs/weather.rs new file mode 100644 index 00000000..b97bc9ab --- /dev/null +++ b/examples/demo2/tabs/weather.rs @@ -0,0 +1,141 @@ +use itertools::Itertools; +use palette::Okhsv; +use ratatui::{ + prelude::*, + widgets::{calendar::CalendarEventStore, *}, +}; +use time::OffsetDateTime; + +use crate::{color_from_oklab, layout, RgbSwatch, THEME}; + +pub struct WeatherTab { + pub selected_row: usize, +} + +impl WeatherTab { + pub fn new(selected_row: usize) -> Self { + Self { selected_row } + } +} + +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 area = layout(area, Direction::Vertical, vec![0, 1, 1]); + render_gauges(self.selected_row, area[2], buf); + + let area = layout(area[0], Direction::Horizontal, vec![23, 0]); + render_calendar(area[0], buf); + let area = layout(area[1], Direction::Horizontal, vec![29, 0]); + render_simple_barchart(area[0], buf); + render_horizontal_barchart(area[1], buf); + } +} + +fn render_calendar(area: Rect, buf: &mut Buffer) { + let date = OffsetDateTime::now_utc().date(); + calendar::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-org/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.into()) + }) + .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".into()).value(51), + Bar::default().text_value("Spring 40-65".into()).value(65), + Bar::default().text_value("Summer 54-77".into()).value(77), + Bar::default() + .text_value("Fall 41-71".into()) + .value(71) + .value_style(Style::new().bold()), // current season + ]; + let group = BarGroup::default().label("GPU".into()).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); +} + +pub fn render_gauges(progress: usize, area: Rect, buf: &mut Buffer) { + let percent = (progress * 3).min(100) as f64; + + render_line_gauge(percent, area, buf); +} + +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 fg = color_from_oklab(hue, Okhsv::max_saturation(), value); + let bg = 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()) + .gauge_style(Style::new().fg(fg).bg(bg)) + .line_set(symbols::line::THICK) + .render(area, buf); +} diff --git a/examples/demo2/term.rs b/examples/demo2/term.rs new file mode 100644 index 00000000..3dad0f68 --- /dev/null +++ b/examples/demo2/term.rs @@ -0,0 +1,71 @@ +use std::{ + io::{self, stdout, Stdout}, + ops::{Deref, DerefMut}, + time::Duration, +}; + +use anyhow::{Context, Result}; +use crossterm::{ + event::{self, Event}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use ratatui::prelude::*; + +/// A wrapper around the terminal that handles setting up and tearing down the terminal +/// and provides a helper method to read events from the terminal. +#[derive(Debug)] +pub struct Term { + terminal: Terminal>, +} + +impl Term { + pub fn start() -> 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)), + }; + let terminal = Terminal::with_options(CrosstermBackend::new(io::stdout()), options)?; + enable_raw_mode().context("enable raw mode")?; + stdout() + .execute(EnterAlternateScreen) + .context("enter alternate screen")?; + Ok(Self { terminal }) + } + + pub fn stop() -> 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)) + } +} + +impl Deref for Term { + type Target = Terminal>; + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl DerefMut for Term { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for Term { + fn drop(&mut self) { + let _ = Term::stop(); + } +} diff --git a/examples/demo2/theme.rs b/examples/demo2/theme.rs new file mode 100644 index 00000000..972e978b --- /dev/null +++ b/examples/demo2/theme.rs @@ -0,0 +1,136 @@ +use ratatui::prelude::*; + +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: Color, + pub rat_eye: Color, + pub rat_eye_alt: Color, + pub term: 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: WHITE, + rat_eye: BLACK, + rat_eye_alt: RED, + term: BLACK, + }, + 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::Indexed(160); +const BLACK: Color = Color::Indexed(232); // not really black, often #080808 +const DARK_GRAY: Color = Color::Indexed(238); +const MID_GRAY: Color = Color::Indexed(244); +const LIGHT_GRAY: Color = Color::Indexed(250); +const WHITE: Color = Color::Indexed(255); // not really white, often #eeeeee diff --git a/src/lib.rs b/src/lib.rs index 07c01d41..7b242d51 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,8 @@ //! [ratatui](https://github.com/ratatui-org/ratatui) is a library that is all about cooking up terminal user //! interfaces (TUIs). //! -//! ![Demo](https://vhs.charm.sh/vhs-tF0QbuPbtHgUeG0sTVgFr.gif) +//! ![Demo](https://raw.githubusercontent.com/ratatui-org/ratatui/aa09e59dc0058347f68d7c1e0c91f863c6f2b8c9/examples/demo2.gif) +// this is a permalink to https://github.com/ratatui-org/ratatui/blob/images/examples/demo2.gif //! //! # Get started //!