1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
use ratatui::prelude::*;
use super::theme;
/// Block levels from empty to full, indexed by eighths (0..=8).
const LEVELS: [&str; 9] = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
/// Filled block sparkline (`▁▂▃▄▅▆▇█`) with each bar's colour ramped by its
/// own height — dim near the baseline, the full `style` colour at the peak —
/// instead of every bar being one flat shade. `data` is oldest→newest; only
/// the most recent `area.width` samples are drawn so the newest lands at the
/// right edge (the raw oldest-first history is tail-sliced here).
///
/// When there's less history than the area is wide, the short slice is drawn
/// right-anchored instead of left-aligned — the newest sample must stay at
/// the right edge, and the empty left space must stay genuinely blank rather
/// than fake zero-value bars.
pub fn render(f: &mut Frame, area: Rect, data: &[u64], max: u64, style: Style) {
if area.width == 0 || area.height == 0 || data.is_empty() {
return;
}
let max = max.max(1);
let w = area.width as usize;
let tail = if data.len() > w { &data[data.len() - w..] } else { data };
if tail.is_empty() {
return;
}
// Clamp so a pathological value can't overflow the `value * height * 8 /
// max` height computation below.
let clamped: Vec<u64> = tail.iter().map(|&v| v.min(max)).collect();
// Right-anchor short slices instead of ratatui's default left alignment.
let x0 = area.x + (w - clamped.len()) as u16;
let buf = f.buffer_mut();
for (i, &value) in clamped.iter().enumerate() {
let x = x0 + i as u16;
// Same bottom-up fill ratatui's Sparkline used: each row consumes up
// to 8 eighths of `height`, starting from the bottom row.
let mut height = value * u64::from(area.height) * 8 / max;
let bar_colour = style.fg.map(|fg| theme::ramp(fg, value as f32 / max as f32));
let bar_style = match bar_colour {
Some(c) => style.fg(c),
None => style,
};
for j in (0..area.height).rev() {
let level = height.min(8);
height = height.saturating_sub(8);
let cell = buf.cell_mut((x, area.y + j)).expect("cell within the sparkline's own area");
match (level, bar_colour) {
// A completely full cell is painted as a cell *background*
// rather than a '█' foreground glyph. Terminal.app draws block
// glyphs from the font's outlines instead of as native
// rectangles, so a column of stacked '█' shows a hairline seam
// between every row; a cell background is always painted as a
// full rectangle — the terminal never consults the font for it
// — so it cannot seam. Same fix and same reason as the hero
// font; see `ui/font.rs`'s doc comment.
(8, Some(colour)) => {
cell.set_symbol(" ");
cell.set_style(Style::default().bg(colour));
}
// Partial cells keep a foreground block glyph: it's the only
// way to get sub-cell resolution, and a bar's topmost cell has
// no filled cell above it to seam against. Empty cells (level
// 0) render as a plain space, leaving the frame's BASE.
//
// A caller that supplies no foreground colour has nothing to
// fill a background with, so it falls back to the glyph for
// full cells too. Every real caller passes a colour.
_ => {
cell.set_symbol(LEVELS[level as usize]);
cell.set_style(bar_style);
}
}
}
}
}
#[cfg(test)]
mod tests {
use ratatui::backend::TestBackend;
use ratatui::prelude::*;
use ratatui::Terminal;
use super::theme;
use ratatui::style::Color;
/// Render with a real foreground colour, as every production caller does.
/// That matters now: a completely full cell is painted as a background
/// fill rather than a '█' glyph, and that path only engages when there is
/// a colour to fill with. Returns the symbol dump alongside a per-cell
/// "is this a filled background" map, so assertions can talk about full
/// bars without looking for a '█' the renderer deliberately no longer
/// emits.
fn draw_cells(data: &[u64], max: u64, w: u16, h: u16) -> (String, Vec<bool>) {
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
t.draw(|f| super::render(f, f.area(), data, max, Style::default().fg(theme::ACCENT)))
.unwrap();
let buf = t.backend().buffer().clone();
let symbols = buf.content().iter().map(|c| c.symbol()).collect();
let filled = buf
.content()
.iter()
.map(|c| !matches!(c.style().bg, None | Some(Color::Reset)))
.collect();
(symbols, filled)
}
fn draw(data: &[u64], max: u64, w: u16, h: u16) -> String {
draw_cells(data, max, w, h).0
}
#[test]
fn renders_expected_block_levels() {
// values 0..=8 against max 8 used to fill one row as " ▁▂▃▄▅▆▇█".
// The last cell is completely full, so it is now a background fill and
// its symbol is a space — the seven partial levels are unchanged.
let (s, filled) = draw_cells(&[0, 1, 2, 3, 4, 5, 6, 7, 8], 8, 9, 1);
assert_eq!(s, " ▁▂▃▄▅▆▇ ");
assert_eq!(
filled,
vec![false, false, false, false, false, false, false, false, true],
"only the completely full cell should be a background fill"
);
}
/// The whole point of the background fill: a solid column of bar must not
/// emit stacked '█' glyphs, because Terminal.app draws those from font
/// outlines and seams between every row (see `render`'s comment and
/// `ui/font.rs`). A full-height bar in a tall chart is the worst case.
#[test]
fn full_bars_emit_no_stacked_block_glyphs() {
let (s, filled) = draw_cells(&[8], 8, 1, 6);
assert!(!s.contains('█'), "solid bar still drawn with '█' glyphs: {s:?}");
assert!(filled.iter().all(|&f| f), "every cell of a full-height bar should be filled");
}
#[test]
fn shows_only_the_most_recent_width_samples() {
// 100 ascending samples into a width-10 chart show the last 10
// (samples 91..=100), newest at the right edge.
let data: Vec<u64> = (1..=100).collect();
let (s, filled) = draw_cells(&data, 100, 10, 1);
assert!(*filled.last().unwrap(), "newest (100) should be a full, background-filled cell at right");
// sample 91/100 → 7/8 height → '▇'; the first 10 samples (1..=10) would
// be near-empty, so a filled left cell proves we tail-sliced.
assert!("▅▆▇█".contains(s.chars().next().unwrap()), "left cell should be the recent tail, not the oldest samples");
}
#[test]
fn short_data_right_anchors_instead_of_left_aligning() {
// 3 samples into a 10-wide chart: bars must sit flush against the
// RIGHT edge (newest at the far right), and the left 7 cells must
// stay genuinely blank — not zero-value bars.
let s = draw(&[2, 5, 8], 10, 10, 1);
assert_eq!(s, " ▁▄▆", "bars must hug the right edge with blank space on the left");
}
#[test]
fn data_exactly_equal_to_width_fills_the_whole_area() {
// Trailing cell is at max, so it is a background fill (space), not '█'.
let (s, filled) = draw_cells(&[1, 2, 3, 4, 5], 5, 5, 1);
assert_eq!(s, "▁▃▄▆ ", "no blank padding when data fills the area exactly");
assert_eq!(filled, vec![false, false, false, false, true]);
}
#[test]
fn values_at_or_above_max_are_clamped_not_overflowed() {
// A pathological value far above max must not panic or wrap; it
// should render as a full bar, same as a value exactly at max.
let (_, filled) = draw_cells(&[u64::MAX, 5], 10, 2, 1);
assert!(filled[0], "value above max should clamp to a full (background-filled) bar");
}
#[test]
fn max_zero_does_not_panic_or_divide_by_zero() {
let s = draw(&[1, 2, 3], 0, 3, 1);
assert_eq!(s.chars().count(), 3);
}
#[test]
fn empty_data_does_not_panic() {
let s = draw(&[], 10, 5, 1);
assert_eq!(s, " ", "an empty dataset should render as entirely blank, not panic");
}
#[test]
fn bar_colour_ramps_with_height_not_flat() {
// Two bars in the same chart: a short one (1/8) and the tallest
// possible one (8/8). If colour were still flat (the old Sparkline
// behaviour) both cells would carry the exact same fg. A real ramp
// must make the tall bar visibly brighter than the short one.
let mut t = Terminal::new(TestBackend::new(2, 1)).unwrap();
t.draw(|f| {
super::render(f, f.area(), &[1, 8], 8, Style::default().fg(theme::ACCENT))
})
.unwrap();
let buf = t.backend().buffer().clone();
// The short bar (1/8) is a partial cell, so its colour is a foreground;
// the tall bar (8/8) is completely full, so its colour is a background.
let short = buf[(0, 0)].fg;
let tall = buf[(1, 0)].bg;
assert_ne!(short, tall, "short and tall bars in the same chart must not share a flat colour");
assert_eq!(tall, theme::ACCENT, "the tallest bar should render in the full caller colour");
}
#[test]
fn lowest_nonzero_bar_stays_distinct_from_base() {
// A single-eighth bar against a large max (finest possible non-zero
// fraction of the peak) must still render clearly apart from BASE,
// not fade into the background it's blended toward.
let mut t = Terminal::new(TestBackend::new(1, 8)).unwrap();
t.draw(|f| super::render(f, f.area(), &[1], 64, Style::default().fg(theme::ACCENT)))
.unwrap();
let buf = t.backend().buffer().clone();
let fg = buf[(0, 7)].fg; // bottom row: the one filled eighth
let ch = |c: Color| match c {
Color::Rgb(r, g, b) => (i32::from(r), i32::from(g), i32::from(b)),
_ => panic!(),
};
let (r0, g0, b0) = ch(theme::BASE);
let (r1, g1, b1) = ch(fg);
let dist = (r1 - r0).abs() + (g1 - g0).abs() + (b1 - b0).abs();
assert!(dist > 20, "lowest non-zero bar colour {fg:?} too close to BASE {:?}", theme::BASE);
}
}