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