Skip to main content

data_beans/interactive/
ui.rs

1//! Shared pieces of the full-screen views: the palette, panels, header and
2//! help lines, the event loop, histogram scales and binning, and a histogram
3//! plot with a y gutter, an x axis, and markers.
4
5use ratatui::buffer::Buffer;
6use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
7use ratatui::layout::{Constraint, Layout, Rect};
8use ratatui::style::{Color, Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, BorderType};
11use ratatui::Frame;
12
13use crate::qc::log_bin_key;
14
15// Palette: the terminal's own foreground (so light and dark backgrounds both
16// work) for nearly everything, and one accent for what needs the eye: what a
17// cutoff drops, the selection, and key hints.
18pub const ACCENT: Color = Color::Rgb(217, 119, 87);
19
20/// Plain text and bars in the terminal's foreground.
21pub const PLAIN: Style = Style::new();
22/// Secondary text: labels, axes, units.
23pub const DIM: Style = Style::new().add_modifier(Modifier::DIM);
24/// Accent bars and marks.
25pub const ACCENTED: Style = Style::new().fg(ACCENT);
26/// Key names in help lines, typed values, and the value in focus.
27pub const HIGHLIGHT: Style = Style::new().fg(ACCENT).add_modifier(Modifier::BOLD);
28
29/// A full-screen view driven by [`run_screen`].
30pub trait Screen {
31    fn render(&mut self, frame: &mut Frame);
32    /// A key press (Ctrl-C goes to [`Screen::interrupt`] instead).
33    fn handle_key(&mut self, key: KeyEvent);
34    fn interrupt(&mut self);
35    fn done(&self) -> bool;
36    /// Blocking work the last key asked for, as a line to print while it
37    /// runs; [`Screen::do_work`] then does it.
38    fn pending_work(&self) -> Option<String> {
39        None
40    }
41    fn do_work(&mut self) {}
42}
43
44/// Run `screen` full screen until it is done. The terminal is restored on
45/// return and on panic. Blocking work runs on the normal screen, where its
46/// own progress output belongs, and the view comes back after it.
47pub fn run_screen(screen: &mut impl Screen) -> anyhow::Result<()> {
48    ratatui::run(|terminal| -> anyhow::Result<()> {
49        while !screen.done() {
50            terminal.draw(|f| screen.render(f))?;
51            if let Event::Key(key) = event::read()? {
52                if key.kind != KeyEventKind::Press {
53                    continue;
54                }
55                if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
56                    screen.interrupt();
57                } else {
58                    screen.handle_key(key);
59                }
60            }
61            if let Some(message) = screen.pending_work() {
62                ratatui::restore();
63                eprintln!("{message}");
64                screen.do_work();
65                *terminal = ratatui::try_init()?;
66            }
67        }
68        Ok(())
69    })
70}
71
72/// Title bar: a reverse-video badge naming the view, then plain text.
73pub fn header(badge: &str, title: &str, extra: &str) -> Line<'static> {
74    Line::from(vec![
75        Span::styled(
76            format!(" {badge} "),
77            HIGHLIGHT.add_modifier(Modifier::REVERSED),
78        ),
79        Span::raw(format!(" {title}")),
80        Span::styled(format!("   {extra}"), DIM),
81    ])
82}
83
84/// Rounded panel titled `title`: plain border and accent title when
85/// `focused`, dim border otherwise.
86pub fn panel(title: String, focused: bool) -> Block<'static> {
87    Block::bordered()
88        .border_type(BorderType::Rounded)
89        .border_style(if focused { PLAIN } else { DIM })
90        // Titles inherit the border style; start clear of it.
91        .title(Line::from(title).style(Style::reset().patch(HIGHLIGHT)))
92}
93
94/// Help line from `(key, what it does)` pairs.
95pub fn help_line(pairs: &[(&str, &str)]) -> Line<'static> {
96    let mut spans = vec![Span::raw(" ")];
97    for (key, what) in pairs {
98        spans.push(Span::styled(key.to_string(), HIGHLIGHT));
99        spans.push(Span::styled(format!(" {what}  "), DIM));
100    }
101    Line::from(spans)
102}
103
104/// Footer while typing: `prompt`, the text so far with a cursor, then keys.
105pub fn input_line(prompt: &str, text: &str, keys: &[(&str, &str)]) -> Line<'static> {
106    let mut spans = vec![
107        Span::raw(format!(" {prompt}")),
108        Span::styled(format!("{text}▏"), HIGHLIGHT),
109        Span::raw("  "),
110    ];
111    spans.extend(help_line(keys).spans);
112    Line::from(spans)
113}
114
115/// Set a cell outright, rather than layering `style` over what was there.
116fn put(buf: &mut Buffer, x: u16, y: u16, symbol: &str, style: Style) {
117    buf[(x, y)]
118        .set_symbol(symbol)
119        .set_style(Style::reset().patch(style));
120}
121
122/// Width of the y-axis gutter left of each histogram.
123const GUTTER: u16 = 6;
124
125/// Bins on the sqrt and linear scales (the log scale uses tenth-decade bins).
126const TARGET_BINS: f64 = 50.0;
127
128/// How a histogram axis is drawn.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Scale {
131    Log,
132    Sqrt,
133    Linear,
134}
135
136impl Scale {
137    pub fn next(self) -> Self {
138        match self {
139            Scale::Log => Scale::Sqrt,
140            Scale::Sqrt => Scale::Linear,
141            Scale::Linear => Scale::Log,
142        }
143    }
144
145    pub fn name(self) -> &'static str {
146        match self {
147            Scale::Log => "log",
148            Scale::Sqrt => "sqrt",
149            Scale::Linear => "linear",
150        }
151    }
152
153    fn apply(self, v: f64) -> f64 {
154        match self {
155            Scale::Log => (v + 1.0).log10(),
156            Scale::Sqrt => v.max(0.0).sqrt(),
157            Scale::Linear => v,
158        }
159    }
160
161    fn invert(self, t: f64) -> f64 {
162        match self {
163            Scale::Log => 10f64.powf(t) - 1.0,
164            Scale::Sqrt => t * t,
165            Scale::Linear => t,
166        }
167    }
168}
169
170/// Equal-width bins on a scale, keyed by integers. On the log scale these are
171/// the printed histogram's tenth-decade bins, keyed by rounding.
172#[derive(Debug, Clone, Copy)]
173pub struct Binning {
174    pub scale: Scale,
175    /// Bin width on the scaled axis.
176    width: f64,
177}
178
179impl Binning {
180    /// Bins spanning `0..=max`. Whole counts (`integer`) get linear bins at
181    /// least one count wide, so no bin falls between two integers.
182    pub fn new(scale: Scale, max: f64, integer: bool) -> Self {
183        let span = if integer { max + 1.0 } else { max };
184        let width = match scale {
185            Scale::Log => 0.1,
186            Scale::Linear if integer => (span / TARGET_BINS).ceil().max(1.0),
187            _ => scale.apply(span) / TARGET_BINS,
188        };
189        Self {
190            scale,
191            width: width.max(f64::MIN_POSITIVE),
192        }
193    }
194
195    /// Bins of an explicit `width` on the scaled axis. On the linear scale
196    /// with width 1, bin `k` holds exactly the value `k`, so a histogram of
197    /// bin indices draws one bar per category.
198    pub fn with_width(scale: Scale, width: f64) -> Self {
199        Self {
200            scale,
201            width: width.max(f64::MIN_POSITIVE),
202        }
203    }
204
205    pub fn key(&self, x: f64) -> i32 {
206        match self.scale {
207            Scale::Log => log_bin_key(x),
208            _ => (self.scale.apply(x) / self.width).floor() as i32,
209        }
210    }
211
212    /// Scaled position where bin `k` starts: log keys round, so their bins
213    /// start half a bin early.
214    fn start(&self, k: i32) -> f64 {
215        match self.scale {
216            Scale::Log => (k as f64 - 0.5) * self.width,
217            _ => k as f64 * self.width,
218        }
219    }
220
221    /// Smallest whole count in bin `k` or above: the cutoff that drops every
222    /// bin left of `k`.
223    pub fn lower_edge(&self, k: i32) -> usize {
224        if k <= 0 {
225            return 0;
226        }
227        // Start from the exact inverse, then settle rounding either way.
228        let mut x = self.scale.invert(self.start(k)).ceil().max(0.0) as usize;
229        while self.key(x as f64) < k {
230            x += 1;
231        }
232        while x > 0 && self.key((x - 1) as f64) >= k {
233            x -= 1;
234        }
235        x
236    }
237
238    /// Label for the tick at bin `k` (the bin centre on the log scale, as the
239    /// printed histogram labels it; the bin start otherwise).
240    fn tick_value(&self, k: i32) -> f64 {
241        self.scale.invert(k as f64 * self.width)
242    }
243
244    /// Ticks every half decade on the log scale, about six otherwise.
245    fn tick_every(&self, nbins: usize) -> i32 {
246        match self.scale {
247            Scale::Log => 5,
248            _ => (nbins as i32 / 6).max(1),
249        }
250    }
251}
252
253/// A sorted statistic binned on a scale: the bins and their counts.
254pub struct Binned {
255    pub bins: Binning,
256    pub kmin: i32,
257    pub counts: Vec<usize>,
258}
259
260impl Binned {
261    /// Bin `sorted` (ascending). All-whole data gets whole-count bins.
262    pub fn new(sorted: &[f32], scale: Scale) -> Self {
263        let (min, max) = match (sorted.first(), sorted.last()) {
264            (Some(&lo), Some(&hi)) => (lo as f64, hi as f64),
265            _ => (0.0, 0.0),
266        };
267        let integer = sorted.iter().all(|v| v.fract() == 0.0);
268        let bins = Binning::new(scale, max, integer);
269        let kmin = bins.key(min);
270        let nbins = (bins.key(max) - kmin + 1).max(1) as usize;
271        let counts = count(&bins, kmin, nbins, sorted.iter().copied());
272        Self { bins, kmin, counts }
273    }
274
275    /// Counts of `values` in these bins.
276    pub fn count(&self, values: impl Iterator<Item = f32>) -> Vec<usize> {
277        count(&self.bins, self.kmin, self.counts.len(), values)
278    }
279
280    pub fn kmax(&self) -> i32 {
281        self.kmin + self.counts.len() as i32 - 1
282    }
283}
284
285/// Counts of `values` per bin, from `kmin` to `kmin + nbins - 1` (values
286/// outside land in the end bins).
287fn count(bins: &Binning, kmin: i32, nbins: usize, values: impl Iterator<Item = f32>) -> Vec<usize> {
288    let mut counts = vec![0; nbins];
289    for v in values {
290        let i = (bins.key(v as f64) - kmin).clamp(0, nbins as i32 - 1);
291        counts[i as usize] += 1;
292    }
293    counts
294}
295
296/// Median of an ascending slice (0 when empty).
297pub fn median(sorted: &[f32]) -> f32 {
298    crate::qc::median_of_sorted(sorted)
299}
300
301/// Compact number for axis labels: 950, 1.2k, 35k, 1.1M; small fractions
302/// keep two significant digits.
303pub fn compact(v: f64) -> String {
304    if v != 0.0 && v.abs() < 10.0 && v.fract() != 0.0 {
305        format!("{:.2}", v)
306            .trim_end_matches('0')
307            .trim_end_matches('.')
308            .to_string()
309    } else if v < 1e3 {
310        format!("{}", v.round() as i64)
311    } else if v < 1e4 {
312        format!("{:.1}k", v / 1e3)
313    } else if v < 1e6 {
314        format!("{}k", (v / 1e3).round() as u64)
315    } else if v < 1e9 {
316        format!("{:.1}M", v / 1e6)
317    } else {
318        format!("{:.1}G", v / 1e9)
319    }
320}
321
322/// A histogram of `counts` over bins `kmin..`, scaled to them.
323/// A bar height [`HistPlot`] can draw: whole counts, or any non-negative
324/// real value (a summed signal, a log statistic).
325pub trait BarValue: Copy {
326    fn bar(self) -> f64;
327}
328
329impl BarValue for usize {
330    fn bar(self) -> f64 {
331        self as f64
332    }
333}
334
335impl BarValue for f64 {
336    fn bar(self) -> f64 {
337        self
338    }
339}
340
341pub struct HistPlot<'a, T: BarValue = usize> {
342    pub bins: Binning,
343    pub kmin: i32,
344    pub counts: &'a [T],
345    /// Style of each bin's bar, by key.
346    pub style: &'a dyn Fn(i32) -> Style,
347    /// A subset drawn in front, in the bar style; `counts` then draw dimmed
348    /// behind it.
349    pub subset: Option<&'a [T]>,
350    pub y_scale: Scale,
351    /// Bin under the accent rule and ▲.
352    pub pointer: Option<i32>,
353    /// Other symbols on the x axis, by key.
354    pub marks: Vec<(i32, &'static str, Style)>,
355    /// Tick label at bin `k` in place of the bin's value; `None` from it
356    /// drops that tick, so the labels decide where ticks go (e.g. at
357    /// category boundaries with `tick_every: Some(1)`). Unset, every tick
358    /// shows its value.
359    pub x_label: Option<&'a dyn Fn(i32) -> Option<String>>,
360    /// Ticks every this many bins, in place of the scale's default.
361    pub tick_every: Option<i32>,
362}
363
364const EIGHTHS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
365
366impl<T: BarValue> HistPlot<'_, T> {
367    /// Draw into `area`: bars over the rows above the last two, which hold
368    /// the x axis and its labels; the left [`GUTTER`] columns hold the y axis.
369    pub fn render(&self, buf: &mut Buffer, area: Rect) {
370        let [plot, axis, labels] = Layout::vertical([
371            Constraint::Min(1),
372            Constraint::Length(1),
373            Constraint::Length(1),
374        ])
375        .areas(area);
376        let [gutter, chart] =
377            Layout::horizontal([Constraint::Length(GUTTER), Constraint::Min(1)]).areas(plot);
378        if chart.width == 0 || chart.height == 0 {
379            return;
380        }
381        let nbins = self.counts.len();
382        let bw = (chart.width / nbins.max(1) as u16).clamp(1, 4);
383        let x_of = |k: i32| -> Option<u16> {
384            let i = k - self.kmin;
385            (i >= 0 && (i as usize) < nbins)
386                .then(|| chart.x + i as u16 * bw)
387                .filter(|&x| x < chart.right())
388        };
389
390        let height = |c: T| self.y_scale.apply(c.bar().max(0.0));
391        let max_h = self.counts.iter().map(|&c| height(c)).fold(0.0, f64::max);
392        let cells = chart.height as usize * 8;
393        let eighths = |c: T| {
394            if c.bar() <= 0.0 || max_h <= 0.0 {
395                0
396            } else {
397                ((height(c) / max_h * cells as f64).round() as usize).clamp(1, cells)
398            }
399        };
400
401        if let Some(x) = self.pointer.and_then(x_of) {
402            for y in chart.top()..chart.bottom() {
403                put(buf, x, y, "┊", ACCENTED);
404            }
405        }
406
407        let mut bars = |counts: &[T], behind: Option<&[T]>, dim: bool| {
408            for (i, &c) in counts.iter().enumerate() {
409                let x0 = chart.x + i as u16 * bw;
410                if x0 >= chart.right() {
411                    break;
412                }
413                let style = if dim {
414                    DIM
415                } else {
416                    (self.style)(self.kmin + i as i32)
417                };
418                let (top, under) = (eighths(c), behind.map_or(0, |b| eighths(b[i])));
419                for (j, y) in (chart.top()..chart.bottom()).rev().enumerate() {
420                    let mut fill = top.saturating_sub(j * 8).min(8);
421                    if fill == 0 {
422                        break;
423                    }
424                    // A partial top in front of a taller bar would show a gap
425                    // above it (the terminal's foreground cannot be a
426                    // background), so it rounds up to a whole cell.
427                    if under >= (j + 1) * 8 {
428                        fill = 8;
429                    }
430                    for x in x0..(x0 + bw).min(chart.right()) {
431                        put(buf, x, y, EIGHTHS[fill - 1], style);
432                    }
433                }
434            }
435        };
436        bars(self.counts, None, self.subset.is_some());
437        if let Some(subset) = self.subset {
438            bars(subset, Some(self.counts), false);
439        }
440
441        // y axis: count at the top and at half height on the y scale.
442        let gx = gutter.right() - 1;
443        for y in gutter.top()..gutter.bottom() {
444            put(buf, gx, y, "│", DIM);
445        }
446        let mut ylabel = |y: u16, v: f64| {
447            let s = compact(v);
448            let x = gx.saturating_sub(1 + s.len() as u16).max(gutter.x);
449            buf.set_string(x, y, &s, DIM);
450            put(buf, gx, y, "┤", DIM);
451        };
452        if max_h > 0.0 {
453            ylabel(gutter.top(), self.y_scale.invert(max_h));
454            if gutter.height >= 6 {
455                ylabel(
456                    gutter.top() + gutter.height / 2,
457                    self.y_scale.invert(max_h / 2.0),
458                );
459            }
460        }
461
462        // x axis: baseline with ticks, labels below, then the marks.
463        for x in axis.left()..axis.right() {
464            let sym = match x.cmp(&gx) {
465                std::cmp::Ordering::Less => " ",
466                std::cmp::Ordering::Equal => "└",
467                std::cmp::Ordering::Greater => "─",
468            };
469            put(buf, x, axis.y, sym, DIM);
470        }
471        let every = self
472            .tick_every
473            .unwrap_or_else(|| self.bins.tick_every(nbins))
474            .max(1);
475        let mut next_free = labels.x;
476        let kmax = self.kmin + nbins as i32 - 1;
477        for k in (self.kmin..=kmax).filter(|k| k % every == 0) {
478            let Some(x) = x_of(k) else { continue };
479            let s = match self.x_label {
480                Some(label) => match label(k) {
481                    Some(s) => s,
482                    None => continue,
483                },
484                None => compact(self.bins.tick_value(k)),
485            };
486            put(buf, x, axis.y, "┴", DIM);
487            if x >= next_free && x + (s.len() as u16) <= labels.right() {
488                buf.set_string(x, labels.y, &s, DIM);
489                next_free = x + s.len() as u16 + 1;
490            }
491        }
492        let pointer = self.pointer.map(|k| (k, "▲", HIGHLIGHT));
493        for &(k, sym, style) in self.marks.iter().chain(pointer.iter()) {
494            if let Some(x) = x_of(k) {
495                put(buf, x, axis.y, sym, style);
496            }
497        }
498    }
499}
500
501#[cfg(test)]
502#[path = "tests/ui.rs"]
503mod tests;