Compare commits

...

2 Commits

Author SHA1 Message Date
Caleb Bassi
4f5594beff feat(sparkline): Add show baseline option
Adds an option to the sparkline widget to render the minimum value as
a baseline symbol rather than as an empty cell.
2023-05-05 14:03:41 -07:00
Josh McKinney
9da02078fe test(buffer): add assert_buffer_eq and impl Debug
- The implementation of Debug is customized to make it easy to use the
output (particularly the content) directly when writing tests (by
surrounding it with `Buffer::with_lines(vec![])`). The styles part of
the message shows the position of every style change, rather than the
style of each cell, which reduces the verbosity of the detail, while
still showing everything necessary to debug the buffer.

```rust
Buffer {
    area: Rect { x: 0, y: 0, width: 12, height: 2 },
    content: [
        "Hello World!",
        "G'day World!",
    ],
    styles: [
        x: 0, y: 0, fg: Reset, bg: Reset, modifier: (empty),
        x: 0, y: 1, fg: Green, bg: Yellow, modifier: BOLD,
    ]
}
```

- The assert_buffer_eq! macro shows debug view and diff of the two
buffers, which makes it easy to understand exactly where the difference
is.

- Also adds a unit test for buffer_set_string_multi_width_overwrite
which was missing from the buffer tests
2023-05-05 12:07:28 -07:00
3 changed files with 310 additions and 32 deletions

View File

@@ -3,7 +3,10 @@ use crate::{
style::{Color, Modifier, Style},
text::{Span, Spans},
};
use std::cmp::min;
use std::{
cmp::min,
fmt::{Debug, Formatter, Result},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
@@ -105,7 +108,7 @@ impl Default for Cell {
/// buf.get_mut(5, 0).set_char('x');
/// assert_eq!(buf.get(5, 0).symbol, "x");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[derive(Clone, PartialEq, Eq, Default)]
pub struct Buffer {
/// The area represented by this buffer
pub area: Rect,
@@ -453,6 +456,113 @@ impl Buffer {
}
}
/// Assert that two buffers are equal by comparing their areas and content.
///
/// On panic, displays the areas or the content and a diff of the contents.
#[macro_export]
macro_rules! assert_buffer_eq {
($actual_expr:expr, $expected_expr:expr) => {
match (&$actual_expr, &$expected_expr) {
(actual, expected) => {
if actual.area != expected.area {
panic!(
indoc::indoc!(
"
buffer areas not equal
expected: {:?}
actual: {:?}"
),
expected, actual
);
}
let diff = expected.diff(&actual);
if !diff.is_empty() {
let nice_diff = diff
.iter()
.enumerate()
.map(|(i, (x, y, cell))| {
let expected_cell = expected.get(*x, *y);
format!(
"{}: at ({}, {})\n expected: {:?}\n actual: {:?}",
i, x, y, expected_cell, cell
)
})
.collect::<Vec<String>>()
.join("\n");
panic!(
indoc::indoc!(
"
buffer contents not equal
expected: {:?}
actual: {:?}
diff:
{}"
),
expected, actual, nice_diff
);
}
// shouldn't get here, but this guards against future behavior
// that changes equality but not area or content
assert_eq!(actual, expected, "buffers not equal");
}
}
};
}
impl Debug for Buffer {
/// Writes a debug representation of the buffer to the given formatter.
///
/// The format is like a pretty printed struct, with the following fields:
/// area: displayed as Rect { x: 1, y: 2, width: 3, height: 4 }
/// content: displayed as a list of strings representing the content of the
/// buffer
/// styles: displayed as a list of
/// { x: 1, y: 2, fg: Color::Red, bg: Color::Blue, modifier: Modifier::BOLD }
/// only showing a value when there is a change in style.
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
f.write_fmt(format_args!(
"Buffer {{\n area: {:?},\n content: [\n",
&self.area
))?;
let mut last_style = None;
let mut styles = vec![];
for (y, line) in self.content.chunks(self.area.width as usize).enumerate() {
let mut overwritten = vec![];
let mut skip: usize = 0;
f.write_str(" \"")?;
for (x, c) in line.iter().enumerate() {
if skip == 0 {
f.write_str(&c.symbol)?;
} else {
overwritten.push((x, &c.symbol))
}
skip = std::cmp::max(skip, c.symbol.width()).saturating_sub(1);
let style = (c.fg, c.bg, c.modifier);
if last_style != Some(style) {
last_style = Some(style);
styles.push((x, y, c.fg, c.bg, c.modifier));
}
}
if !overwritten.is_empty() {
f.write_fmt(format_args!(
"// hidden by multi-width symbols: {:?}",
overwritten
))?;
}
f.write_str("\",\n")?;
}
f.write_str(" ],\n styles: [\n")?;
for s in styles {
f.write_fmt(format_args!(
" x: {}, y: {}, fg: {:?}, bg: {:?}, modifier: {:?},\n",
s.0, s.1, s.2, s.3, s.4
))?;
}
f.write_str(" ]\n}")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -463,6 +573,62 @@ mod tests {
cell
}
#[test]
fn it_implements_debug() {
let mut buf = Buffer::empty(Rect::new(0, 0, 12, 2));
buf.set_string(0, 0, "Hello World!", Style::default());
buf.set_string(
0,
1,
"G'day World!",
Style::default()
.fg(Color::Green)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
assert_eq!(
format!("{:?}", buf),
indoc::indoc!(
"
Buffer {
area: Rect { x: 0, y: 0, width: 12, height: 2 },
content: [
\"Hello World!\",
\"G'day World!\",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, modifier: (empty),
x: 0, y: 1, fg: Green, bg: Yellow, modifier: BOLD,
]
}"
)
);
}
#[test]
fn assert_buffer_eq_does_not_panic_on_equal_buffers() {
let buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
let other_buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
assert_buffer_eq!(buffer, other_buffer);
}
#[should_panic]
#[test]
fn assert_buffer_eq_panics_on_unequal_area() {
let buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
let other_buffer = Buffer::empty(Rect::new(0, 0, 6, 1));
assert_buffer_eq!(buffer, other_buffer);
}
#[should_panic]
#[test]
fn assert_buffer_eq_panics_on_unequal_style() {
let buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
let mut other_buffer = Buffer::empty(Rect::new(0, 0, 5, 1));
other_buffer.set_string(0, 0, " ", Style::default().fg(Color::Red));
assert_buffer_eq!(buffer, other_buffer);
}
#[test]
fn it_translates_to_and_from_coordinates() {
let rect = Rect::new(200, 100, 50, 80);
@@ -504,21 +670,38 @@ mod tests {
// Zero-width
buffer.set_stringn(0, 0, "aaa", 0, Style::default());
assert_eq!(buffer, Buffer::with_lines(vec![" "]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" "]));
buffer.set_string(0, 0, "aaa", Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["aaa "]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["aaa "]));
// Width limit:
buffer.set_stringn(0, 0, "bbbbbbbbbbbbbb", 4, Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["bbbb "]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["bbbb "]));
buffer.set_string(0, 0, "12345", Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["12345"]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["12345"]));
// Width truncation:
buffer.set_string(0, 0, "123456", Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["12345"]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["12345"]));
// multi-line
buffer = Buffer::empty(Rect::new(0, 0, 5, 2));
buffer.set_string(0, 0, "12345", Style::default());
buffer.set_string(0, 1, "67890", Style::default());
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["12345", "67890"]));
}
#[test]
fn buffer_set_string_multi_width_overwrite() {
let area = Rect::new(0, 0, 5, 1);
let mut buffer = Buffer::empty(area);
// multi-width overwrite
buffer.set_string(0, 0, "aaaaa", Style::default());
buffer.set_string(0, 0, "称号", Style::default());
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["称号a"]));
}
#[test]
@@ -529,12 +712,12 @@ mod tests {
// Leading grapheme with zero width
let s = "\u{1}a";
buffer.set_stringn(0, 0, s, 1, Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["a"]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["a"]));
// Trailing grapheme with zero with
let s = "a\u{1}";
buffer.set_stringn(0, 0, s, 1, Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["a"]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["a"]));
}
#[test]
@@ -542,11 +725,11 @@ mod tests {
let area = Rect::new(0, 0, 5, 1);
let mut buffer = Buffer::empty(area);
buffer.set_string(0, 0, "コン", Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["コン "]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["コン "]));
// Only 1 space left.
buffer.set_string(0, 0, "コンピ", Style::default());
assert_eq!(buffer, Buffer::with_lines(vec!["コン "]));
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["コン "]));
}
#[test]
@@ -671,7 +854,7 @@ mod tests {
Cell::default().set_symbol("2"),
);
one.merge(&two);
assert_eq!(one, Buffer::with_lines(vec!["11", "11", "22", "22"]));
assert_buffer_eq!(one, Buffer::with_lines(vec!["11", "11", "22", "22"]));
}
#[test]
@@ -695,7 +878,7 @@ mod tests {
Cell::default().set_symbol("2"),
);
one.merge(&two);
assert_eq!(
assert_buffer_eq!(
one,
Buffer::with_lines(vec!["22 ", "22 ", " 11", " 11"])
);
@@ -729,6 +912,6 @@ mod tests {
width: 4,
height: 4,
};
assert_eq!(one, merged);
assert_buffer_eq!(one, merged);
}
}

View File

@@ -31,6 +31,8 @@ pub struct Sparkline<'a> {
/// The maximum value to take to compute the maximum bar height (if nothing is specified, the
/// widget uses the max of the dataset)
max: Option<u64>,
/// If true, draws a baseline of `bar::ONE_EIGHTH` spanning the bottom of the sparkline graph
show_baseline: bool,
/// A set of bar symbols used to represent the give data
bar_set: symbols::bar::Set,
// The direction to render the sparkine, either from left to right, or from right to left
@@ -50,6 +52,7 @@ impl<'a> Default for Sparkline<'a> {
style: Default::default(),
data: &[],
max: None,
show_baseline: false,
bar_set: symbols::bar::NINE_LEVELS,
direction: RenderDirection::LeftToRight,
}
@@ -77,6 +80,11 @@ impl<'a> Sparkline<'a> {
self
}
pub fn show_baseline(mut self, show_baseline: bool) -> Sparkline<'a> {
self.show_baseline = show_baseline;
self
}
pub fn bar_set(mut self, bar_set: symbols::bar::Set) -> Sparkline<'a> {
self.bar_set = bar_set;
self
@@ -103,6 +111,14 @@ impl<'a> Widget for Sparkline<'a> {
return;
}
if self.show_baseline {
for i in spark_area.left()..spark_area.right() {
buf.get_mut(i, spark_area.bottom() - 1)
.set_symbol(self.bar_set.one_eighth)
.set_style(self.style);
}
}
let max = match self.max {
Some(v) => v,
None => *self.data.iter().max().unwrap_or(&1u64),
@@ -123,7 +139,13 @@ impl<'a> Widget for Sparkline<'a> {
for j in (0..spark_area.height).rev() {
for (i, d) in data.iter_mut().enumerate() {
let symbol = match *d {
0 => self.bar_set.empty,
0 => {
if self.show_baseline && j == spark_area.height - 1 {
self.bar_set.one_eighth
} else {
self.bar_set.empty
}
}
1 => self.bar_set.one_eighth,
2 => self.bar_set.one_quarter,
3 => self.bar_set.three_eighths,
@@ -153,40 +175,91 @@ impl<'a> Widget for Sparkline<'a> {
#[cfg(test)]
mod tests {
use crate::buffer::Cell;
use super::*;
use crate::{assert_buffer_eq, buffer::Cell};
// Helper function to render a sparkline to a buffer with a given width
// filled with x symbols to make it easier to assert on the result
fn render(widget: Sparkline, width: u16) -> Buffer {
let area = Rect::new(0, 0, width, 1);
fn render(widget: Sparkline, width: u16, height: u16) -> Buffer {
let mut cell = Cell::default();
cell.set_symbol("x");
let mut buffer = Buffer::filled(area, &cell);
widget.render(area, &mut buffer);
let mut buffer = Buffer::filled(Rect::new(0, 0, width, height), &cell);
widget.render(buffer.area, &mut buffer);
buffer
}
#[test]
fn it_does_not_panic_if_max_is_zero() {
let widget = Sparkline::default().data(&[0, 0, 0]);
let buffer = render(widget, 6);
assert_eq!(buffer, Buffer::with_lines(vec![" xxx"]));
let buffer = render(widget, 6, 1);
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" xxx"]));
}
#[test]
fn it_does_not_panic_if_max_is_set_to_zero() {
let widget = Sparkline::default().data(&[0, 1, 2]).max(0);
let buffer = render(widget, 6);
assert_eq!(buffer, Buffer::with_lines(vec![" xxx"]));
let buffer = render(widget, 6, 1);
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" xxx"]));
}
#[test]
fn it_draws() {
fn it_renders() {
let widget = Sparkline::default().data(&[0, 1, 2, 3, 4, 5, 6, 7, 8]);
let buffer = render(widget, 12);
assert_eq!(buffer, Buffer::with_lines(vec![" ▁▂▃▄▅▆▇█xxx"]));
let buffer = render(widget, 12, 1);
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" ▁▂▃▄▅▆▇█xxx"]));
}
#[test]
fn it_renders_with_max_more_than_data_max() {
let widget = Sparkline::default()
.data(&[0, 1, 2, 3, 4, 5, 6, 7, 8])
.max(16);
let buffer = render(widget, 12, 1);
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" ▁▁▂▂▃▃▄xxx"]));
}
#[test]
fn it_renders_with_max_less_than_data_max() {
let widget = Sparkline::default()
.data(&[0, 1, 2, 3, 4, 5, 6, 7, 8])
.max(4);
let buffer = render(widget, 12, 1);
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" ▂▄▆█████xxx"]));
}
#[test]
fn it_renders_with_multi_line() {
let widget = Sparkline::default().data(&[0, 1, 2, 3, 4, 5, 6, 7, 8]);
let buffer = render(widget, 15, 3);
assert_buffer_eq!(
buffer,
Buffer::with_lines(vec![
" ▂▅█xxxxxx",
" ▁▄▇███xxxxxx",
" ▃▆██████xxxxxx",
])
);
}
#[test]
fn it_renders_with_multi_line_and_baseline() {
let widget = Sparkline::default()
.data(&[0, 1, 2, 3, 4, 5, 6, 7, 8])
.show_baseline(true);
let buffer = render(widget, 15, 3);
assert_buffer_eq!(
buffer,
// this currently fails because the baseline logic doesn't clear
// the parts above the line
// " ▂▅█xxxxxx",
// " ▁▄▇███xxxxxx",
// " ▃▆██████▁▁▁▁▁▁",
Buffer::with_lines(vec![
" ▂▅█ ",
" ▁▄▇███ ",
" ▃▆██████▁▁▁▁▁▁",
])
);
}
#[test]
@@ -194,8 +267,8 @@ mod tests {
let widget = Sparkline::default()
.data(&[0, 1, 2, 3, 4, 5, 6, 7, 8])
.direction(RenderDirection::LeftToRight);
let buffer = render(widget, 12);
assert_eq!(buffer, Buffer::with_lines(vec![" ▁▂▃▄▅▆▇█xxx"]));
let buffer = render(widget, 12, 1);
assert_buffer_eq!(buffer, Buffer::with_lines(vec![" ▁▂▃▄▅▆▇█xxx"]));
}
#[test]
@@ -203,7 +276,26 @@ mod tests {
let widget = Sparkline::default()
.data(&[0, 1, 2, 3, 4, 5, 6, 7, 8])
.direction(RenderDirection::RightToLeft);
let buffer = render(widget, 12);
assert_eq!(buffer, Buffer::with_lines(vec!["xxx█▇▆▅▄▃▂▁ "]));
let buffer = render(widget, 12, 1);
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["xxx█▇▆▅▄▃▂▁ "]));
}
#[test]
fn it_renders_baseline() {
let widget = Sparkline::default()
.data(&[0, 1, 2, 3, 4, 5, 6, 7, 8])
.show_baseline(true);
let buffer = render(widget, 12, 1);
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["▁▁▂▃▄▅▆▇█▁▁▁"]));
}
#[test]
fn it_renders_baseline_right_to_left() {
let widget = Sparkline::default()
.data(&[0, 1, 2, 3, 4, 5, 6, 7, 8])
.direction(RenderDirection::RightToLeft)
.show_baseline(true);
let buffer = render(widget, 12, 1);
assert_buffer_eq!(buffer, Buffer::with_lines(vec!["▁▁▁█▇▆▅▄▃▂▁▁"]));
}
}

View File

@@ -1,4 +1,5 @@
use ratatui::{
assert_buffer_eq,
backend::TestBackend,
buffer::Buffer,
layout::{Alignment, Rect},
@@ -237,6 +238,8 @@ fn widgets_block_title_alignment() {
.unwrap();
terminal.backend().assert_buffer(&expected);
let area = Rect::new(1, 0, 13, 2);
assert_buffer_eq!(Buffer::empty(area), Buffer::empty(area));
};
// title top-left with all borders