Skip to main content

tui_piechart/
waterfall.rs

1//! [`Waterfall`]: running totals built from up / down steps and total bars.
2
3#![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/// One step of a [`Waterfall`].
20#[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    /// A step that adds (`> 0`) or removes (`< 0`) `value` from the running total.
34    #[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    /// A bar from zero up to the running total so far.
43    #[must_use]
44    pub const fn total(label: &'a str) -> Self {
45        Self {
46            label,
47            kind: StepKind::Total,
48        }
49    }
50}
51
52/// A bar with its value range, as computed by [`waterfall_bars`].
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct WaterfallBar {
55    /// Lower end of the bar.
56    pub low: f64,
57    /// Upper end of the bar.
58    pub high: f64,
59    /// Running total after this step.
60    pub end: f64,
61    /// Whether this is a total bar.
62    pub is_total: bool,
63    /// Whether the step went down.
64    pub is_decrease: bool,
65}
66
67/// Computes bar ranges for `steps` (non-finite deltas count as `0`).
68#[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/// A waterfall chart.
98///
99/// # Examples
100///
101/// ```
102/// use tui_piechart::{Waterfall, WaterfallStep};
103///
104/// let chart = Waterfall::new(vec![
105///     WaterfallStep::delta("Revenue", 120.0),
106///     WaterfallStep::delta("Costs", -45.0),
107///     WaterfallStep::delta("Tax", -20.0),
108///     WaterfallStep::total("Profit"),
109/// ]);
110/// ```
111#[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    /// Creates a waterfall from steps.
123    #[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    /// Colors for increases, decreases and totals.
136    #[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    /// Wraps the chart in a block.
145    #[must_use]
146    pub fn block(mut self, block: Block<'a>) -> Self {
147        self.block = Some(block);
148        self
149    }
150
151    /// Base style of the whole area.
152    #[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                    // Upper part only: a lower block with swapped colors.
215                    (
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        // Row just above the label row: the "Cost" bar floats, "Rev" and "Net" start there.
307        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}