use std::collections::VecDeque;
use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
const STEPS: usize = 101;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Gradient {
colors: Box<[Color; STEPS]>,
}
impl Gradient {
pub fn new(start: Color, mid: Option<Color>, end: Option<Color>) -> Self {
let mut colors = Box::new([start; STEPS]);
let Some(end) = end else {
return Self { colors };
};
let true_colour = [Some(start), mid, Some(end)]
.into_iter()
.any(|stop| matches!(stop, Some(Color::Rgb(..))));
let rgb = |stop| if true_colour { rgb(stop) } else { None };
match (rgb(start), mid.map(rgb), rgb(end)) {
(Some(a), Some(Some(m)), Some(b)) => {
for (i, slot) in colors.iter_mut().enumerate().take(51) {
*slot = lerp(a, m, i, 50);
}
for i in 51..STEPS {
colors[i] = lerp(m, b, i - 50, 50);
}
}
(Some(a), None, Some(b)) => {
for (i, slot) in colors.iter_mut().enumerate() {
*slot = lerp(a, b, i, STEPS - 1);
}
}
_ => match mid {
Some(mid) => {
colors[34..=67].fill(mid);
colors[68..].fill(end);
}
None => colors[51..].fill(end),
},
}
Self { colors }
}
#[cfg(test)]
pub fn flat(color: Color) -> Self {
Self {
colors: Box::new([color; STEPS]),
}
}
pub fn at(&self, level: i64) -> Color {
let index = level.clamp(0, 100) as usize;
self.colors[index]
}
pub fn scaled(&self, value: u64, max: u64) -> Color {
if max == 0 {
return self.at(0);
}
let pct = (u128::from(value.min(max)) * 100 / u128::from(max)) as i64;
self.at(pct)
}
}
fn rgb(color: Color) -> Option<(u8, u8, u8)> {
match color {
Color::Rgb(r, g, b) => Some((r, g, b)),
Color::Black => Some((0, 0, 0)),
Color::White => Some((255, 255, 255)),
_ => None,
}
}
fn lerp(a: (u8, u8, u8), b: (u8, u8, u8), step: usize, span: usize) -> Color {
if span == 0 {
return Color::Rgb(a.0, a.1, a.2);
}
let step = i32::try_from(step.min(span)).unwrap_or(i32::MAX);
let span = i32::try_from(span).unwrap_or(i32::MAX);
let channel = |from: u8, to: u8| -> u8 {
let from = i32::from(from);
let to = i32::from(to);
u8::try_from(from + (to - from) * step / span).unwrap_or(u8::MAX)
};
Color::Rgb(channel(a.0, b.0), channel(a.1, b.1), channel(a.2, b.2))
}
const UP_LEFT: [u8; 5] = [0x00, 0x40, 0x44, 0x46, 0x47];
const UP_RIGHT: [u8; 5] = [0x00, 0x80, 0xA0, 0xB0, 0xB8];
fn braille(left: usize, right: usize) -> char {
let mask = UP_LEFT[left.min(4)] | UP_RIGHT[right.min(4)];
char::from_u32(0x2800 | u32::from(mask)).unwrap_or(' ')
}
const TRACK: char = '⣀';
fn level(value: f64, low: f64, high: f64, bias: f64, floor: usize) -> usize {
if value >= high {
return 4;
}
if value <= low {
return floor;
}
let span = (high - low).max(f64::EPSILON);
let scaled = ((value - low) * 4.0 / span + bias).round();
(scaled.max(0.0) as usize).clamp(floor, 4)
}
#[derive(Debug)]
pub struct BrailleGraph<'a> {
data: (&'a [u64], &'a [u64]),
max: u64,
gradient: &'a Gradient,
track_style: Style,
}
impl<'a> BrailleGraph<'a> {
pub fn new(data: &'a [u64], max: u64, gradient: &'a Gradient) -> Self {
Self {
data: (data, &[]),
max,
gradient,
track_style: Style::default(),
}
}
pub fn of_history(history: &'a VecDeque<u64>, max: u64, gradient: &'a Gradient) -> Self {
Self {
data: history.as_slices(),
max,
gradient,
track_style: Style::default(),
}
}
fn len(&self) -> usize {
self.data.0.len() + self.data.1.len()
}
fn sample(&self, index: usize) -> Option<u64> {
let (head, tail) = self.data;
match index.checked_sub(head.len()) {
None => head.get(index).copied(),
Some(index) => tail.get(index).copied(),
}
}
pub fn track_style(mut self, style: Style) -> Self {
self.track_style = style;
self
}
pub fn render(&self, area: Rect, buf: &mut Buffer) {
let area = area.intersection(buf.area);
if area.width == 0 || area.height == 0 {
return;
}
let width = area.width as usize;
let height = area.height as usize;
for row in 0..height {
let y = area.y + row as u16;
let symbol = if row == height - 1 { TRACK } else { ' ' };
for col in 0..width {
buf[(area.x + col as u16, y)]
.set_char(symbol)
.set_style(self.track_style);
}
}
let len = self.len();
if len == 0 || self.max == 0 {
return;
}
let capacity = width * 2;
let start = len.saturating_sub(capacity);
let visible = len - start;
let bias = if height == 1 { 0.3 } else { 0.1 };
for row in 0..height {
let band_high = 100.0 * (height - row) as f64 / height as f64;
let band_low = 100.0 * (height - row - 1) as f64 / height as f64;
let color = self.gradient.at((band_high.round() as i64).min(100));
let style = Style::default().fg(color);
let y = area.y + row as u16;
let floor = usize::from(row == height - 1);
for col in 0..width {
let cell_from_right = width - 1 - col;
let Some(end) = visible.checked_sub(cell_from_right * 2) else {
continue;
};
let pct = |index: Option<usize>| -> Option<f64> {
let raw = self.sample(start + index?)?;
let basis_points =
u128::from(raw.min(self.max)) * 10_000 / u128::from(self.max);
Some(f64::from(u32::try_from(basis_points).unwrap_or(10_000)) / 100.0)
};
let (Some(left), Some(right)) = (pct(end.checked_sub(2)), pct(end.checked_sub(1)))
else {
continue;
};
let l = level(left, band_low, band_high, bias, floor);
let r = level(right, band_low, band_high, bias, floor);
if l == 0 && r == 0 {
continue;
}
buf[(area.x + col as u16, y)]
.set_char(braille(l, r))
.set_style(style);
}
}
}
}
pub fn percent(part: u64, whole: u64) -> u16 {
if whole == 0 {
return 0;
}
u16::try_from(u128::from(part.min(whole)) * 100 / u128::from(whole)).unwrap_or(100)
}
pub fn draw_notice<S: AsRef<str>>(
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
lines: &[S],
) {
let count = u16::try_from(lines.len()).unwrap_or(u16::MAX);
let top = area.y + area.height.saturating_sub(count) / 2;
let shown = usize::from(area.bottom() - top);
let dropped = lines
.iter()
.skip(shown)
.any(|line| !line.as_ref().is_empty());
let style = Style::default().fg(theme.muted);
for (row, (y, text)) in (top..area.bottom()).zip(lines).enumerate() {
let text = text.as_ref();
let width = usize::from(area.width);
let text = if dropped && row + 1 == shown {
crate::grid::truncate(&format!("{text}\u{2026}"), width)
} else if text.is_empty() {
continue;
} else {
crate::grid::truncate(text, width)
};
frame.render_widget(
Paragraph::new(Span::styled(text, style)).centered(),
Rect::new(area.x, y, area.width, 1),
);
}
}
pub fn meter_line(percent: u16, width: u16, fill: Color, track: Color) -> Vec<Span<'static>> {
let width = usize::from(width);
let filled = usize::from(percent.min(100)) * width / 100;
(0..width)
.map(|i| {
Span::styled(
"■",
Style::default().fg(if i < filled { fill } else { track }),
)
})
.collect()
}
pub fn meter_spans(
value: u64,
max: u64,
width: u16,
gradient: &Gradient,
track: Style,
) -> Vec<Span<'static>> {
let width = width as usize;
if width == 0 {
return Vec::new();
}
let filled = if max == 0 {
0
} else {
((u128::from(value.min(max)) * width as u128) / u128::from(max)) as usize
};
(0..width)
.map(|i| {
if i < filled {
let pct = i64::try_from((i + 1) * 100 / width).unwrap_or(100);
Span::styled("■", Style::default().fg(gradient.at(pct)))
} else {
Span::styled("■", track)
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn c(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
#[test]
fn an_area_larger_than_the_buffer_is_clipped_rather_than_panicking() {
let gradient = Gradient::new(c(0, 0, 0), None, Some(c(255, 0, 0)));
let data: Vec<u64> = (0..200).collect();
for (bw, bh) in [(1u16, 1u16), (10, 4), (40, 12)] {
for (aw, ah) in [(1u16, 1u16), (40, 12), (500, 200)] {
let mut buf = Buffer::empty(Rect::new(0, 0, bw, bh));
BrailleGraph::new(&data, 199, &gradient).render(Rect::new(0, 0, aw, ah), &mut buf);
}
}
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 4));
BrailleGraph::new(&data, 199, &gradient).render(Rect::new(50, 50, 10, 4), &mut buf);
}
#[test]
fn saturated_values_still_draw() {
let gradient = Gradient::new(c(0, 0, 0), None, Some(c(255, 0, 0)));
for (data, max) in [
(vec![u64::MAX; 60], u64::MAX),
(vec![u64::MAX; 60], 1),
(vec![0u64; 60], u64::MAX),
(vec![0u64; 60], 0),
] {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 4));
BrailleGraph::new(&data, max, &gradient).render(Rect::new(0, 0, 20, 4), &mut buf);
let painted = buf.content.iter().any(|cell| cell.symbol() != " ");
assert!(painted, "nothing drawn for data={:?} max={max}", &data[..1]);
}
}
#[test]
fn a_flat_gradient_is_the_same_everywhere() {
let g = Gradient::flat(c(10, 20, 30));
for level in [0, 1, 50, 99, 100] {
assert_eq!(g.at(level), c(10, 20, 30));
}
}
#[test]
fn a_two_stop_gradient_hits_both_ends_exactly() {
let g = Gradient::new(c(0, 0, 0), None, Some(c(100, 200, 250)));
assert_eq!(g.at(0), c(0, 0, 0));
assert_eq!(g.at(100), c(100, 200, 250));
}
#[test]
fn a_three_stop_gradient_passes_through_its_midpoint() {
let g = Gradient::new(c(0, 0, 0), Some(c(50, 50, 50)), Some(c(255, 255, 255)));
assert_eq!(g.at(0), c(0, 0, 0));
assert_eq!(g.at(50), c(50, 50, 50));
assert_eq!(g.at(100), c(255, 255, 255));
}
#[test]
fn a_gradient_is_monotonic_per_channel() {
let g = Gradient::new(c(0, 0, 0), Some(c(120, 60, 30)), Some(c(255, 255, 255)));
let mut previous = (0u8, 0u8, 0u8);
for level in 0..=100 {
let now = rgb(g.at(level)).expect("hex stops bake to true colour");
assert!(now.0 >= previous.0, "red went backwards at {level}");
previous = now;
}
}
#[test]
fn gradient_levels_clamp_rather_than_panic() {
let g = Gradient::new(c(0, 0, 0), None, Some(c(255, 255, 255)));
assert_eq!(g.at(-50), g.at(0));
assert_eq!(g.at(9_999), g.at(100));
}
#[test]
fn scaled_handles_a_zero_maximum() {
let g = Gradient::new(c(0, 0, 0), None, Some(c(255, 255, 255)));
assert_eq!(g.scaled(5, 0), g.at(0), "must not divide by zero");
assert_eq!(g.scaled(50, 100), g.at(50));
assert_eq!(g.scaled(500, 100), g.at(100), "over-max must clamp");
}
#[test]
fn scaled_does_not_overflow_on_huge_values() {
let g = Gradient::flat(c(1, 2, 3));
assert_eq!(g.scaled(u64::MAX, u64::MAX), g.at(100));
}
#[test]
fn braille_masks_match_the_expected_characters() {
assert_eq!(braille(0, 0), '\u{2800}');
assert_eq!(braille(4, 4), '⣿');
assert_eq!(braille(1, 0), '⡀');
assert_eq!(braille(0, 1), '⢀');
assert_eq!(braille(2, 2), '⣤');
}
#[test]
fn braille_levels_clamp_out_of_range_input() {
assert_eq!(braille(99, 99), braille(4, 4));
}
#[test]
fn braille_output_is_always_in_the_braille_block() {
for l in 0..=4 {
for r in 0..=4 {
let ch = braille(l, r) as u32;
assert!((0x2800..=0x28FF).contains(&ch), "{l},{r} escaped the block");
}
}
}
#[test]
fn level_saturates_and_floors() {
assert_eq!(level(100.0, 0.0, 100.0, 0.1, 0), 4);
assert_eq!(level(0.0, 0.0, 100.0, 0.1, 0), 0);
assert_eq!(level(0.0, 0.0, 100.0, 0.1, 1), 1);
}
#[test]
fn the_rounding_bias_rescues_values_just_above_a_band_floor() {
assert_eq!(level(11.0, 0.0, 100.0, 0.0, 0), 0);
assert_eq!(level(11.0, 0.0, 100.0, 0.1, 0), 1);
}
#[test]
fn a_value_below_a_bands_floor_draws_nothing_in_that_band() {
assert_eq!(level(1.0, 75.0, 100.0, 0.1, 0), 0);
}
#[test]
fn level_handles_a_degenerate_band() {
assert_eq!(level(10.0, 10.0, 10.0, 0.1, 0), 4, "at the band is the top");
assert_eq!(level(11.0, 10.0, 10.0, 0.1, 0), 4, "above it is the top");
assert_eq!(level(9.0, 10.0, 10.0, 0.1, 0), 0, "below it is the floor");
assert_eq!(
level(9.0, 10.0, 10.0, 0.1, 1),
1,
"and the floor is honoured"
);
}
#[test]
fn graph_renders_without_panicking_at_any_size() {
let gradient = Gradient::new(c(0, 255, 0), Some(c(255, 255, 0)), Some(c(255, 0, 0)));
let data: Vec<u64> = (0..200u64).map(|i| i % 101).collect();
for (w, h) in [(0, 0), (1, 1), (1, 8), (40, 1), (80, 6), (200, 20)] {
let area = Rect::new(0, 0, w, h);
let mut buf = Buffer::empty(area);
BrailleGraph::new(&data, 100, &gradient).render(area, &mut buf);
}
}
#[test]
fn graph_survives_empty_data_and_a_zero_maximum() {
let gradient = Gradient::flat(c(1, 1, 1));
let area = Rect::new(0, 0, 20, 4);
let mut buf = Buffer::empty(area);
BrailleGraph::new(&[], 100, &gradient).render(area, &mut buf);
let mut buf = Buffer::empty(area);
BrailleGraph::new(&[1, 2, 3], 0, &gradient).render(area, &mut buf);
let mut buf = Buffer::empty(area);
BrailleGraph::of_history(&VecDeque::new(), 100, &gradient).render(area, &mut buf);
}
#[test]
fn a_history_is_drawn_from_its_deque_as_it_would_be_from_a_copy() {
let gradient = Gradient::new(c(0, 255, 0), Some(c(255, 255, 0)), Some(c(255, 0, 0)));
let mut ring: VecDeque<u64> = VecDeque::with_capacity(48);
let full = ring.capacity();
ring.extend((0..full as u64).map(|i| i * 37 % 101));
let mut broken = 0;
for step in 0..full as u64 {
ring.pop_front();
ring.push_back(step * 53 % 101);
assert_eq!(ring.capacity(), full, "the ring must not reallocate");
if !ring.as_slices().1.is_empty() && !ring.as_slices().0.is_empty() {
broken += 1;
}
let copy: Vec<u64> = ring.iter().copied().collect();
for width in [1u16, 5, 20, (full / 2) as u16, 40, 70] {
let area = Rect::new(0, 0, width, 3);
let mut from_ring = Buffer::empty(area);
BrailleGraph::of_history(&ring, 100, &gradient).render(area, &mut from_ring);
let mut from_copy = Buffer::empty(area);
BrailleGraph::new(©, 100, &gradient).render(area, &mut from_copy);
assert_eq!(from_ring, from_copy, "rotated {step}, {width} wide");
}
}
assert!(
broken > full / 2,
"the ring broke in two only {broken} times"
);
}
#[test]
fn a_percentage_saturates_and_never_divides_by_zero() {
assert_eq!(percent(0, 0), 0, "nothing at all reads as nothing used");
assert_eq!(percent(5, 0), 0);
assert_eq!(percent(1, 4), 25);
assert_eq!(percent(4, 4), 100);
assert_eq!(
percent(9, 4),
100,
"used past total saturates rather than overflowing"
);
assert_eq!(
percent(u64::MAX, u64::MAX),
100,
"the widest inputs multiply without overflow"
);
assert_eq!(percent(u64::MAX - 1, u64::MAX), 99, "and round down");
}
#[test]
fn an_idle_graph_still_draws_a_baseline() {
let gradient = Gradient::flat(c(1, 1, 1));
let area = Rect::new(0, 0, 10, 3);
let mut buf = Buffer::empty(area);
BrailleGraph::new(&[0; 20], 100, &gradient).render(area, &mut buf);
let bottom: String = (0..10).map(|x| buf[(x, 2)].symbol()).collect();
assert!(
bottom.chars().any(|c| c != ' '),
"the bottom row must show a baseline, got `{bottom}`"
);
}
#[test]
fn a_full_graph_reaches_the_top_row() {
let gradient = Gradient::flat(c(1, 1, 1));
let area = Rect::new(0, 0, 10, 3);
let mut buf = Buffer::empty(area);
BrailleGraph::new(&[100; 40], 100, &gradient).render(area, &mut buf);
let top: String = (0..10).map(|x| buf[(x, 0)].symbol()).collect();
assert!(
top.contains('⣿'),
"a saturated graph must fill the top row, got `{top}`"
);
}
#[test]
fn the_newest_sample_lands_at_the_right_edge() {
let gradient = Gradient::flat(c(1, 1, 1));
let area = Rect::new(0, 0, 4, 1);
let mut buf = Buffer::empty(area);
let mut data = vec![0u64; 20];
data[18] = 100;
data[19] = 100;
BrailleGraph::new(&data, 100, &gradient).render(area, &mut buf);
assert_eq!(
buf[(3, 0)].symbol(),
"⣿",
"newest data belongs on the right"
);
}
#[test]
fn meter_fills_proportionally_and_keeps_its_width() {
let g = Gradient::flat(c(1, 1, 1));
let track = Style::default();
for value in [0u64, 25, 50, 100] {
let cells = meter_spans(value, 100, 10, &g, track);
assert_eq!(cells.len(), 10, "the meter footprint must be constant");
}
assert_eq!(meter_spans(0, 100, 10, &g, track).len(), 10);
assert!(
meter_spans(100, 100, 10, &g, track)
.iter()
.all(|span| span.content == "■")
);
}
#[test]
fn meter_handles_zero_width_and_zero_maximum() {
let g = Gradient::flat(c(1, 1, 1));
let spans = meter_spans(5, 10, 0, &g, Style::default());
assert!(spans.is_empty(), "{spans:?}");
assert_eq!(meter_spans(5, 0, 4, &g, Style::default()).len(), 4);
}
#[test]
fn a_scale_beyond_u32_draws_samples_at_their_true_height() {
let gradient = Gradient::flat(c(1, 1, 1));
let area = Rect::new(0, 0, 10, 4);
let mut buf = Buffer::empty(area);
BrailleGraph::new(&[3_000_000_000; 40], 10_000_000_000, &gradient).render(area, &mut buf);
let row = |y: u16| -> String { (0..10).map(|x| buf[(x, y)].symbol()).collect() };
for y in [0, 1] {
assert!(
row(y).chars().all(|c| c == ' '),
"30% reached row {y} of 4: `{}`",
row(y)
);
}
assert!(
row(2).chars().any(|c| c != ' '),
"30% must rise past the bottom quarter, got `{}`",
row(2)
);
}
#[test]
fn named_colour_stops_keep_their_colours_rather_than_baking_to_grey() {
let three = Gradient::new(Color::Green, Some(Color::Yellow), Some(Color::Red));
assert_eq!(three.at(0), Color::Green);
assert_eq!(three.at(33), Color::Green);
assert_eq!(three.at(34), Color::Yellow);
assert_eq!(three.at(67), Color::Yellow);
assert_eq!(three.at(68), Color::Red);
assert_eq!(three.at(100), Color::Red);
let two = Gradient::new(Color::Indexed(28), None, Some(Color::LightRed));
assert_eq!(two.at(0), Color::Indexed(28));
assert_eq!(two.at(50), Color::Indexed(28));
assert_eq!(two.at(51), Color::LightRed);
assert_eq!(two.at(100), Color::LightRed);
}
#[test]
fn black_and_white_blend_only_beside_a_true_colour() {
for (ramp, gradient) in [
("two", Gradient::new(Color::Black, None, Some(Color::White))),
(
"three",
Gradient::new(Color::White, Some(Color::Black), Some(Color::White)),
),
] {
for level in 0..=100 {
assert!(
!matches!(gradient.at(level), Color::Rgb(..)),
"the {ramp} ramp at {level} is {:?}, a true-colour escape",
gradient.at(level)
);
}
}
let two = Gradient::new(Color::Black, None, Some(Color::White));
assert_eq!(two.at(50), Color::Black);
assert_eq!(two.at(51), Color::White);
let mixed = Gradient::new(Color::Black, None, Some(Color::Rgb(200, 0, 0)));
assert_eq!(mixed.at(50), Color::Rgb(100, 0, 0));
let mixed = Gradient::new(
Color::Rgb(0, 0, 200),
Some(Color::White),
Some(Color::Black),
);
assert_eq!(mixed.at(25), Color::Rgb(127, 127, 227));
assert_eq!(mixed.at(100), Color::Rgb(0, 0, 0));
}
#[test]
fn a_short_graph_shows_every_colour_a_stepped_ramp_has() {
let three = Gradient::new(Color::Green, Some(Color::Yellow), Some(Color::Red));
let two = Gradient::new(Color::Green, None, Some(Color::Red));
for (gradient, rows, want) in [
(&three, 3u16, vec![Color::Red, Color::Yellow, Color::Green]),
(&two, 2, vec![Color::Red, Color::Green]),
] {
let data = [100u64; 20];
let area = Rect::new(0, 0, 10, rows);
let mut buf = Buffer::empty(area);
BrailleGraph::new(&data, 100, gradient).render(area, &mut buf);
let colours: Vec<Color> = (0..rows).map(|y| buf[(9, y)].fg).collect();
assert_eq!(colours, want, "{rows} rows");
}
}
#[test]
fn the_ansi_themes_draw_their_graphs_in_the_terminals_own_colours() {
let ansi = crate::themes::resolve("ansi", None).unwrap().gradients();
assert_eq!(ansi.cpu.at(0), Color::Green);
assert_eq!(ansi.cpu.at(100), Color::Red);
for name in ["ansi", "high-contrast"] {
let g = crate::themes::resolve(name, None).unwrap().gradients();
for (ramp, gradient) in [
("cpu", &g.cpu),
("rx", &g.rx),
("tx", &g.tx),
("gain", &g.gain),
("loss", &g.loss),
] {
for level in 0..=100 {
assert!(
!matches!(gradient.at(level), Color::Rgb(..)),
"`{name}` {ramp} at {level} is {:?}, a true-colour escape",
gradient.at(level)
);
}
}
}
}
#[test]
fn every_bundled_theme_has_a_cpu_ramp_that_changes_colour() {
for name in crate::themes::bundled_names() {
let g = crate::themes::resolve(name, None).unwrap().gradients();
assert_ne!(
g.cpu.at(0),
g.cpu.at(100),
"`{name}` draws an idle and a saturated cpu in one colour"
);
}
}
#[test]
fn a_notice_cut_at_the_bottom_ends_in_an_ellipsis() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let theme = crate::theme::Theme::default();
let notices: [&[&str]; 3] = [
&["No disks", "", "nothing mounted is readable"],
&["No battery", "this machine reports none"],
&["Reading disks", ""],
];
let mut cut = 0;
for lines in notices {
for height in 1..=5u16 {
for width in 4..=40u16 {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| draw_notice(frame, frame.area(), &theme, lines))
.unwrap();
let buffer = terminal.backend().buffer();
let rows: Vec<String> = (0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.trim()
.to_string()
})
.collect();
let dropped = lines
.iter()
.skip(usize::from(height))
.any(|line| !line.is_empty());
let marked = rows.iter().rev().find(|row| !row.is_empty());
let case = format!("{lines:?} at {width}x{height}: {rows:?}");
if dropped {
cut += 1;
assert!(
marked.is_some_and(|row| row.ends_with('\u{2026}')),
"rows were dropped without a mark: {case}"
);
} else if lines.iter().all(|line| line.len() < usize::from(width)) {
assert!(
rows.iter().all(|row| !row.contains('\u{2026}')),
"a notice that fits is marked as cut: {case}"
);
}
}
}
}
assert!(cut > 0, "the sweep reached a height that drops a row");
}
}