feat: add WidgetExt trait for debugging widgets

Importing the `WidgetExt` trait allows users to easily get a string
representation of a widget with ANSI escape sequences for the terminal.
This is useful for debugging and testing widgets.

```rust
use ratatui::{prelude::*, widgets::widget_ext::WidgetExt};

fn main() {
    let greeting = Text::from(vec![
        Line::styled("Hello", Color::Blue),
        Line::styled("World ", Color::Green),
    ]);
    println!("{}", greeting.to_ansi_string(5, 2));
}
```

Fixes: https://github.com/ratatui-org/ratatui/issues/1045
This commit is contained in:
Josh McKinney 2024-04-25 18:49:17 -07:00
parent 97ee102f17
commit 0d54ca06f8
No known key found for this signature in database
GPG key ID: 722287396A903BC5
6 changed files with 252 additions and 0 deletions

View file

@ -148,6 +148,9 @@ unstable-rendered-line-info = []
## the future.
unstable-widget-ref = []
## Enables the `WidgetExt` trait which is experimental and may change in the future.
unstable-widget-ext = []
[package.metadata.docs.rs]
all-features = true
# see https://doc.rust-lang.org/nightly/rustdoc/scraped-examples.html
@ -325,6 +328,11 @@ name = "inline"
required-features = ["crossterm"]
doc-scrape-examples = true
[[example]]
name = "widget_ext"
required-features = ["unstable-widget-ext"]
doc-scrape-examples = true
[[test]]
name = "state_serde"
required-features = ["serde"]

View file

@ -0,0 +1,12 @@
# This is a vhs script. See https://github.com/charmbracelet/vhs for more info.
# To run this script, install vhs and run `vhs ./examples/hello_world.tape`
Output "target/widget_ext.gif"
Set Theme "Aardvark Blue"
Set Width 1200
Set Height 300
Set TypingSpeed 10ms
Type "cargo run --example=widget_ext --features=unstable-widget-ext"
Enter
Sleep 2s
Screenshot "target/widget_ext.png"
Sleep 1s

9
examples/widget_ext.rs Normal file
View file

@ -0,0 +1,9 @@
use ratatui::{prelude::*, widgets::widget_ext::WidgetExt};
fn main() {
let greeting = Text::from(vec![
Line::styled("Hello", Color::Blue),
Line::styled("World ", Color::Green),
]);
println!("{}", greeting.to_ansi_string(5, 2));
}

View file

@ -21,6 +21,7 @@
//! - [`Tabs`]: displays a tab bar and allows selection.
//!
//! [`Canvas`]: crate::widgets::canvas::Canvas
mod ansi_string_buffer;
mod barchart;
pub mod block;
mod borders;
@ -37,6 +38,7 @@ mod scrollbar;
mod sparkline;
mod table;
mod tabs;
pub mod widget_ext;
pub use self::{
barchart::{Bar, BarChart, BarGroup},

View file

@ -0,0 +1,171 @@
use std::fmt::{self, Display, Formatter};
use crate::prelude::*;
/// A buffer that allows widgets to easily be rendered to a string with ANSI escape sequences.
pub struct AnsiStringBuffer {
pub area: Rect,
buf: Buffer,
}
impl AnsiStringBuffer {
pub fn new(width: u16, height: u16) -> Self {
Self {
area: Rect::new(0, 0, width, height),
buf: Buffer::empty(Rect::new(0, 0, width, height)),
}
}
pub fn render_ref(&mut self, widget: &impl WidgetRef) {
widget.render_ref(self.area, &mut self.buf);
}
}
impl Display for AnsiStringBuffer {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let mut last_style = None;
for y in 0..self.area.height {
if y > 0 {
f.write_str("\n")?;
}
for x in 0..self.area.width {
let cell = self.buf.get(x, y);
let style = (cell.fg, cell.bg, cell.modifier);
if last_style.is_none() || last_style != Some(style) {
write_cell_style(cell, f)?;
last_style = Some(style);
}
f.write_str(cell.symbol())?;
}
}
f.write_str("\u{1b}[0m")
}
}
fn write_cell_style(cell: &buffer::Cell, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str("\u{1b}[")?;
write_modifier(cell.modifier, f)?;
write_fg(cell.fg, f)?;
write_bg(cell.bg, f)?;
f.write_str("m")
}
fn write_modifier(modifier: Modifier, f: &mut Formatter<'_>) -> fmt::Result {
if modifier.contains(Modifier::BOLD) {
f.write_str("1;")?;
}
if modifier.contains(Modifier::DIM) {
f.write_str("2;")?;
}
if modifier.contains(Modifier::ITALIC) {
f.write_str("3;")?;
}
if modifier.contains(Modifier::UNDERLINED) {
f.write_str("4;")?;
}
if modifier.contains(Modifier::SLOW_BLINK) {
f.write_str("5;")?;
}
if modifier.contains(Modifier::RAPID_BLINK) {
f.write_str("6;")?;
}
if modifier.contains(Modifier::REVERSED) {
f.write_str("7;")?;
}
if modifier.contains(Modifier::HIDDEN) {
f.write_str("8;")?;
}
if modifier.contains(Modifier::CROSSED_OUT) {
f.write_str("9;")?;
}
Ok(())
}
fn write_fg(color: Color, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(match color {
Color::Reset => "39",
Color::Black => "30",
Color::Red => "31",
Color::Green => "32",
Color::Yellow => "33",
Color::Blue => "34",
Color::Magenta => "35",
Color::Cyan => "36",
Color::Gray => "37",
Color::DarkGray => "90",
Color::LightRed => "91",
Color::LightGreen => "92",
Color::LightYellow => "93",
Color::LightBlue => "94",
Color::LightMagenta => "95",
Color::LightCyan => "96",
Color::White => "97",
_ => "",
})?;
if let Color::Rgb(red, green, blue) = color {
f.write_fmt(format_args!("38;2;{red};{green};{blue}"))?;
}
if let Color::Indexed(i) = color {
f.write_fmt(format_args!("38;5;{i}"))?;
}
f.write_str(";")
}
fn write_bg(color: Color, f: &mut Formatter<'_>) -> fmt::Result {
f.write_str(match color {
Color::Reset => "49",
Color::Black => "40",
Color::Red => "41",
Color::Green => "42",
Color::Yellow => "43",
Color::Blue => "44",
Color::Magenta => "45",
Color::Cyan => "46",
Color::Gray => "47",
Color::DarkGray => "100",
Color::LightRed => "101",
Color::LightGreen => "102",
Color::LightYellow => "103",
Color::LightBlue => "104",
Color::LightMagenta => "105",
Color::LightCyan => "106",
Color::White => "107",
_ => "",
})?;
if let Color::Rgb(red, green, blue) = color {
f.write_fmt(format_args!("48;2;{red};{green};{blue}"))?;
}
if let Color::Indexed(i) = color {
f.write_fmt(format_args!("48;5;{i}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
struct Greeting;
impl WidgetRef for Greeting {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let text = Text::from(vec![
Line::styled("Hello", Color::Blue),
Line::styled("World ", Color::Green),
]);
text.render(area, buf);
}
}
#[test]
fn to_string() {
let mut buffer = AnsiStringBuffer::new(10, 2);
buffer.render_ref(&Greeting);
let ansi_string = buffer.to_string();
println!("{ansi_string}");
assert_eq!(
ansi_string,
"\u{1b}[34;49mHello \n\u{1b}[32;49mWorld \u{1b}[0m"
);
}
}

50
src/widgets/widget_ext.rs Normal file
View file

@ -0,0 +1,50 @@
//! A module that provides an extension trait for widgets that provides methods that are useful for
//! debugging.
use super::ansi_string_buffer::AnsiStringBuffer;
use crate::prelude::*;
/// An extension trait for widgets that provides methods that are useful for debugging.
#[stability::unstable(
feature = "widget-ext",
issue = "https://github.com/ratatui-org/ratatui/issues/1045"
)]
pub trait WidgetExt {
/// Returns a string representation of the widget with ANSI escape sequences for the terminal.
fn to_ansi_string(&self, width: u16, height: u16) -> String;
}
impl<W: WidgetRef> WidgetExt for W {
fn to_ansi_string(&self, width: u16, height: u16) -> String {
let mut buf = AnsiStringBuffer::new(width, height);
buf.render_ref(self);
buf.to_string()
}
}
#[cfg(test)]
mod widget_ext_tests {
use super::*;
struct Greeting;
impl WidgetRef for Greeting {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let text = Text::from(vec![
Line::styled("Hello", Color::Blue),
Line::styled("World ", Color::Green),
]);
text.render(area, buf);
}
}
#[test]
fn widget_ext_to_ansi_string() {
let ansi_string = Greeting.to_ansi_string(5, 2);
println!("{ansi_string}");
assert_eq!(
ansi_string,
"\u{1b}[34;49mHello\n\u{1b}[32;49mWorld\u{1b}[0m"
);
}
}