1#![allow(
4 clippy::cast_possible_truncation,
5 clippy::cast_precision_loss,
6 clippy::cast_sign_loss,
7 clippy::many_single_char_names,
8 clippy::too_many_lines,
9 clippy::cast_possible_wrap
10)]
11
12use ratatui::buffer::Buffer;
13use ratatui::layout::Rect;
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::widgets::{Block, Widget};
16
17use crate::util::{block_inner, put, text_width, truncate};
18
19#[derive(Debug, Clone, PartialEq)]
21pub struct WaterfallStep<'a> {
22 label: &'a str,
23 kind: StepKind,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq)]
27enum StepKind {
28 Delta(f64),
29 Total,
30}
31
32impl<'a> WaterfallStep<'a> {
33 #[must_use]
35 pub const fn delta(label: &'a str, value: f64) -> Self {
36 Self {
37 label,
38 kind: StepKind::Delta(value),
39 }
40 }
41
42 #[must_use]
44 pub const fn total(label: &'a str) -> Self {
45 Self {
46 label,
47 kind: StepKind::Total,
48 }
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct WaterfallBar {
55 pub low: f64,
57 pub high: f64,
59 pub end: f64,
61 pub is_total: bool,
63 pub is_decrease: bool,
65}
66
67#[must_use]
69pub fn waterfall_bars(steps: &[WaterfallStep<'_>]) -> Vec<WaterfallBar> {
70 let mut run = 0.0_f64;
71 steps
72 .iter()
73 .map(|s| match s.kind {
74 StepKind::Delta(v) => {
75 let v = if v.is_finite() { v } else { 0.0 };
76 let start = run;
77 run += v;
78 WaterfallBar {
79 low: start.min(run),
80 high: start.max(run),
81 end: run,
82 is_total: false,
83 is_decrease: v < 0.0,
84 }
85 }
86 StepKind::Total => WaterfallBar {
87 low: run.min(0.0),
88 high: run.max(0.0),
89 end: run,
90 is_total: true,
91 is_decrease: false,
92 },
93 })
94 .collect()
95}
96
97#[derive(Debug, Clone)]
112pub struct Waterfall<'a> {
113 steps: Vec<WaterfallStep<'a>>,
114 up: Color,
115 down: Color,
116 total: Color,
117 block: Option<Block<'a>>,
118 base: Style,
119}
120
121impl<'a> Waterfall<'a> {
122 #[must_use]
124 pub fn new(steps: Vec<WaterfallStep<'a>>) -> Self {
125 Self {
126 steps,
127 up: Color::Green,
128 down: Color::Red,
129 total: Color::Cyan,
130 block: None,
131 base: Style::default(),
132 }
133 }
134
135 #[must_use]
137 pub const fn colors(mut self, up: Color, down: Color, total: Color) -> Self {
138 self.up = up;
139 self.down = down;
140 self.total = total;
141 self
142 }
143
144 #[must_use]
146 pub fn block(mut self, block: Block<'a>) -> Self {
147 self.block = Some(block);
148 self
149 }
150
151 #[must_use]
153 pub const fn base_style(mut self, style: Style) -> Self {
154 self.base = style;
155 self
156 }
157}
158
159const LOWER: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
160
161impl Widget for Waterfall<'_> {
162 fn render(self, area: Rect, buf: &mut Buffer) {
163 buf.set_style(area, self.base);
164 let inner = block_inner(self.block.as_ref(), area, buf);
165 let n = self.steps.len();
166 if inner.is_empty() || n == 0 {
167 return;
168 }
169 let bars = waterfall_bars(&self.steps);
170 let label_row = inner.height >= 3;
171 let rows = inner.height - u16::from(label_row);
172 let lo = bars.iter().map(|b| b.low).fold(0.0, f64::min);
173 let hi = bars.iter().map(|b| b.high).fold(0.0, f64::max);
174 let span = hi - lo;
175 if span <= 0.0 || rows == 0 {
176 return;
177 }
178 let gap: usize = usize::from(usize::from(inner.width) >= n * 2);
179 let bw = ((usize::from(inner.width) + gap) / n)
180 .saturating_sub(gap)
181 .max(1);
182 let used = n * bw + (n - 1) * gap;
183 if used > usize::from(inner.width) {
184 return;
185 }
186 let x0 = inner.x + ((usize::from(inner.width) - used) / 2) as u16;
187 let units = f64::from(rows) * 8.0;
188 let to_unit = |v: f64| ((v - lo) / span * units).round() as i64;
189
190 for (i, (bar, step)) in bars.iter().zip(&self.steps).enumerate() {
191 let color = if bar.is_total {
192 self.total
193 } else if bar.is_decrease {
194 self.down
195 } else {
196 self.up
197 };
198 let (ya, mut yb) = (to_unit(bar.low), to_unit(bar.high));
199 if yb <= ya && bar.high > bar.low {
200 yb = ya + 1;
201 }
202 let bx = x0 + (i * (bw + gap)) as u16;
203 for r in 0..i64::from(rows) {
204 let (cell_lo, cell_hi) = (r * 8, r * 8 + 8);
205 let a = ya.max(cell_lo) - cell_lo;
206 let b = yb.min(cell_hi) - cell_lo;
207 if b <= a {
208 continue;
209 }
210 let y = inner.y + (i64::from(rows) - 1 - r) as u16;
211 let (ch, style) = if a == 0 {
212 (LOWER[b as usize], Style::default().fg(color))
213 } else if b == 8 {
214 (
216 LOWER[a as usize],
217 Style::default().fg(color).add_modifier(Modifier::REVERSED),
218 )
219 } else {
220 (LOWER[(b - a) as usize], Style::default().fg(color))
221 };
222 for dx in 0..bw {
223 put(buf, bx + dx as u16, y, ch, style);
224 }
225 }
226 if label_row {
227 let cell_w = bw + gap;
228 let text = truncate(step.label, cell_w.max(1));
229 let tx = bx + (bw.saturating_sub(text_width(&text)) / 2) as u16;
230 if tx + (text_width(&text) as u16) <= inner.right() {
231 buf.set_string(
232 tx,
233 inner.bottom() - 1,
234 &text,
235 Style::default().fg(Color::DarkGray),
236 );
237 }
238 }
239 }
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 #![allow(clippy::assert_is_empty, clippy::float_cmp)]
246 use super::*;
247
248 fn steps() -> Vec<WaterfallStep<'static>> {
249 vec![
250 WaterfallStep::delta("Rev", 120.0),
251 WaterfallStep::delta("Cost", -45.0),
252 WaterfallStep::delta("Tax", -20.0),
253 WaterfallStep::total("Net"),
254 ]
255 }
256
257 fn draw(w: Waterfall<'_>, width: u16, height: u16) -> Buffer {
258 let a = Rect::new(0, 0, width, height);
259 let mut b = Buffer::empty(a);
260 w.render(a, &mut b);
261 b
262 }
263
264 #[test]
265 fn bars_track_running_total() {
266 let b = waterfall_bars(&steps());
267 assert_eq!((b[0].low, b[0].high, b[0].end), (0.0, 120.0, 120.0));
268 assert_eq!((b[1].low, b[1].high, b[1].end), (75.0, 120.0, 75.0));
269 assert!(b[1].is_decrease && !b[0].is_decrease);
270 assert_eq!((b[2].low, b[2].high), (55.0, 75.0));
271 assert!(b[3].is_total && b[3].low == 0.0 && b[3].high == 55.0);
272 }
273
274 #[test]
275 fn negative_running_total_supported() {
276 let b = waterfall_bars(&[WaterfallStep::delta("a", -10.0), WaterfallStep::total("t")]);
277 assert_eq!((b[1].low, b[1].high), (-10.0, 0.0));
278 }
279
280 #[test]
281 fn non_finite_delta_is_zero() {
282 let b = waterfall_bars(&[WaterfallStep::delta("a", f64::NAN)]);
283 assert_eq!((b[0].low, b[0].high, b[0].end), (0.0, 0.0, 0.0));
284 }
285
286 #[test]
287 fn renders_colors_and_labels() {
288 let b = draw(Waterfall::new(steps()), 40, 12);
289 let text: String = b
290 .content()
291 .iter()
292 .map(ratatui::buffer::Cell::symbol)
293 .collect();
294 assert!(text.contains("Rev") && text.contains("Net"));
295 for c in [Color::Green, Color::Red, Color::Cyan] {
296 assert!(
297 b.content().iter().any(|x| x.fg == c && x.symbol() == "█"),
298 "{c:?}"
299 );
300 }
301 }
302
303 #[test]
304 fn floating_bar_does_not_touch_the_floor() {
305 let b = draw(Waterfall::new(steps()), 40, 12);
306 let floor = 10u16;
308 let cost_x = (0..40).find(|x| b[(*x, 2)].fg == Color::Red && b[(*x, 2)].symbol() != " ");
309 assert!(cost_x.is_some());
310 assert_eq!(b[(cost_x.unwrap(), floor)].symbol(), " ");
311 }
312
313 #[test]
314 fn robust() {
315 for (w, h) in [(0, 0), (1, 1), (3, 2), (10, 3), (80, 30)] {
316 for st in [
317 vec![],
318 steps(),
319 vec![WaterfallStep::delta("z", 0.0)],
320 vec![WaterfallStep::delta("n", f64::INFINITY); 5],
321 (0..100)
322 .map(|i| WaterfallStep::delta("s", f64::from(i) - 50.0))
323 .collect(),
324 ] {
325 let _ = draw(Waterfall::new(st).block(Block::bordered()), w, h);
326 }
327 }
328 }
329}