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    pub fn key(&self, x: f64) -> i32 {
196        match self.scale {
197            Scale::Log => log_bin_key(x),
198            _ => (self.scale.apply(x) / self.width).floor() as i32,
199        }
200    }
201
202    /// Scaled position where bin `k` starts: log keys round, so their bins
203    /// start half a bin early.
204    fn start(&self, k: i32) -> f64 {
205        match self.scale {
206            Scale::Log => (k as f64 - 0.5) * self.width,
207            _ => k as f64 * self.width,
208        }
209    }
210
211    /// Smallest whole count in bin `k` or above: the cutoff that drops every
212    /// bin left of `k`.
213    pub fn lower_edge(&self, k: i32) -> usize {
214        if k <= 0 {
215            return 0;
216        }
217        // Start from the exact inverse, then settle rounding either way.
218        let mut x = self.scale.invert(self.start(k)).ceil().max(0.0) as usize;
219        while self.key(x as f64) < k {
220            x += 1;
221        }
222        while x > 0 && self.key((x - 1) as f64) >= k {
223            x -= 1;
224        }
225        x
226    }
227
228    /// Label for the tick at bin `k` (the bin centre on the log scale, as the
229    /// printed histogram labels it; the bin start otherwise).
230    fn tick_value(&self, k: i32) -> f64 {
231        self.scale.invert(k as f64 * self.width)
232    }
233
234    /// Ticks every half decade on the log scale, about six otherwise.
235    fn tick_every(&self, nbins: usize) -> i32 {
236        match self.scale {
237            Scale::Log => 5,
238            _ => (nbins as i32 / 6).max(1),
239        }
240    }
241}
242
243/// A sorted statistic binned on a scale: the bins and their counts.
244pub struct Binned {
245    pub bins: Binning,
246    pub kmin: i32,
247    pub counts: Vec<usize>,
248}
249
250impl Binned {
251    /// Bin `sorted` (ascending). All-whole data gets whole-count bins.
252    pub fn new(sorted: &[f32], scale: Scale) -> Self {
253        let (min, max) = match (sorted.first(), sorted.last()) {
254            (Some(&lo), Some(&hi)) => (lo as f64, hi as f64),
255            _ => (0.0, 0.0),
256        };
257        let integer = sorted.iter().all(|v| v.fract() == 0.0);
258        let bins = Binning::new(scale, max, integer);
259        let kmin = bins.key(min);
260        let nbins = (bins.key(max) - kmin + 1).max(1) as usize;
261        let counts = count(&bins, kmin, nbins, sorted.iter().copied());
262        Self { bins, kmin, counts }
263    }
264
265    /// Counts of `values` in these bins.
266    pub fn count(&self, values: impl Iterator<Item = f32>) -> Vec<usize> {
267        count(&self.bins, self.kmin, self.counts.len(), values)
268    }
269
270    pub fn kmax(&self) -> i32 {
271        self.kmin + self.counts.len() as i32 - 1
272    }
273}
274
275/// Counts of `values` per bin, from `kmin` to `kmin + nbins - 1` (values
276/// outside land in the end bins).
277fn count(bins: &Binning, kmin: i32, nbins: usize, values: impl Iterator<Item = f32>) -> Vec<usize> {
278    let mut counts = vec![0; nbins];
279    for v in values {
280        let i = (bins.key(v as f64) - kmin).clamp(0, nbins as i32 - 1);
281        counts[i as usize] += 1;
282    }
283    counts
284}
285
286/// Median of an ascending slice (0 when empty).
287pub fn median(sorted: &[f32]) -> f32 {
288    crate::qc::median_of_sorted(sorted)
289}
290
291/// Compact number for axis labels: 950, 1.2k, 35k, 1.1M; small fractions
292/// keep two significant digits.
293pub fn compact(v: f64) -> String {
294    if v != 0.0 && v.abs() < 10.0 && v.fract() != 0.0 {
295        format!("{:.2}", v)
296            .trim_end_matches('0')
297            .trim_end_matches('.')
298            .to_string()
299    } else if v < 1e3 {
300        format!("{}", v.round() as i64)
301    } else if v < 1e4 {
302        format!("{:.1}k", v / 1e3)
303    } else if v < 1e6 {
304        format!("{}k", (v / 1e3).round() as u64)
305    } else if v < 1e9 {
306        format!("{:.1}M", v / 1e6)
307    } else {
308        format!("{:.1}G", v / 1e9)
309    }
310}
311
312/// A histogram of `counts` over bins `kmin..`, scaled to them.
313pub struct HistPlot<'a> {
314    pub bins: Binning,
315    pub kmin: i32,
316    pub counts: &'a [usize],
317    /// Style of each bin's bar, by key.
318    pub style: &'a dyn Fn(i32) -> Style,
319    /// A subset drawn in front, in the bar style; `counts` then draw dimmed
320    /// behind it.
321    pub subset: Option<&'a [usize]>,
322    pub y_scale: Scale,
323    /// Bin under the accent rule and ▲.
324    pub pointer: Option<i32>,
325    /// Other symbols on the x axis, by key.
326    pub marks: Vec<(i32, &'static str, Style)>,
327}
328
329const EIGHTHS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
330
331impl HistPlot<'_> {
332    /// Draw into `area`: bars over the rows above the last two, which hold
333    /// the x axis and its labels; the left [`GUTTER`] columns hold the y axis.
334    pub fn render(&self, buf: &mut Buffer, area: Rect) {
335        let [plot, axis, labels] = Layout::vertical([
336            Constraint::Min(1),
337            Constraint::Length(1),
338            Constraint::Length(1),
339        ])
340        .areas(area);
341        let [gutter, chart] =
342            Layout::horizontal([Constraint::Length(GUTTER), Constraint::Min(1)]).areas(plot);
343        if chart.width == 0 || chart.height == 0 {
344            return;
345        }
346        let nbins = self.counts.len();
347        let bw = (chart.width / nbins.max(1) as u16).clamp(1, 4);
348        let x_of = |k: i32| -> Option<u16> {
349            let i = k - self.kmin;
350            (i >= 0 && (i as usize) < nbins)
351                .then(|| chart.x + i as u16 * bw)
352                .filter(|&x| x < chart.right())
353        };
354
355        let height = |c: usize| self.y_scale.apply(c as f64);
356        let max_h = self.counts.iter().map(|&c| height(c)).fold(0.0, f64::max);
357        let cells = chart.height as usize * 8;
358        let eighths = |c: usize| {
359            if c == 0 || max_h <= 0.0 {
360                0
361            } else {
362                ((height(c) / max_h * cells as f64).round() as usize).clamp(1, cells)
363            }
364        };
365
366        if let Some(x) = self.pointer.and_then(x_of) {
367            for y in chart.top()..chart.bottom() {
368                put(buf, x, y, "┊", ACCENTED);
369            }
370        }
371
372        let mut bars = |counts: &[usize], behind: Option<&[usize]>, dim: bool| {
373            for (i, &c) in counts.iter().enumerate() {
374                let x0 = chart.x + i as u16 * bw;
375                if x0 >= chart.right() {
376                    break;
377                }
378                let style = if dim {
379                    DIM
380                } else {
381                    (self.style)(self.kmin + i as i32)
382                };
383                let (top, under) = (eighths(c), behind.map_or(0, |b| eighths(b[i])));
384                for (j, y) in (chart.top()..chart.bottom()).rev().enumerate() {
385                    let mut fill = top.saturating_sub(j * 8).min(8);
386                    if fill == 0 {
387                        break;
388                    }
389                    // A partial top in front of a taller bar would show a gap
390                    // above it (the terminal's foreground cannot be a
391                    // background), so it rounds up to a whole cell.
392                    if under >= (j + 1) * 8 {
393                        fill = 8;
394                    }
395                    for x in x0..(x0 + bw).min(chart.right()) {
396                        put(buf, x, y, EIGHTHS[fill - 1], style);
397                    }
398                }
399            }
400        };
401        bars(self.counts, None, self.subset.is_some());
402        if let Some(subset) = self.subset {
403            bars(subset, Some(self.counts), false);
404        }
405
406        // y axis: count at the top and at half height on the y scale.
407        let gx = gutter.right() - 1;
408        for y in gutter.top()..gutter.bottom() {
409            put(buf, gx, y, "│", DIM);
410        }
411        let mut ylabel = |y: u16, v: f64| {
412            let s = compact(v);
413            let x = gx.saturating_sub(1 + s.len() as u16).max(gutter.x);
414            buf.set_string(x, y, &s, DIM);
415            put(buf, gx, y, "┤", DIM);
416        };
417        if max_h > 0.0 {
418            ylabel(gutter.top(), self.y_scale.invert(max_h));
419            if gutter.height >= 6 {
420                ylabel(
421                    gutter.top() + gutter.height / 2,
422                    self.y_scale.invert(max_h / 2.0),
423                );
424            }
425        }
426
427        // x axis: baseline with ticks, labels below, then the marks.
428        for x in axis.left()..axis.right() {
429            let sym = match x.cmp(&gx) {
430                std::cmp::Ordering::Less => " ",
431                std::cmp::Ordering::Equal => "└",
432                std::cmp::Ordering::Greater => "─",
433            };
434            put(buf, x, axis.y, sym, DIM);
435        }
436        let every = self.bins.tick_every(nbins);
437        let mut next_free = labels.x;
438        let kmax = self.kmin + nbins as i32 - 1;
439        for k in (self.kmin..=kmax).filter(|k| k % every == 0) {
440            let Some(x) = x_of(k) else { continue };
441            put(buf, x, axis.y, "┴", DIM);
442            let s = compact(self.bins.tick_value(k));
443            if x >= next_free && x + (s.len() as u16) <= labels.right() {
444                buf.set_string(x, labels.y, &s, DIM);
445                next_free = x + s.len() as u16 + 1;
446            }
447        }
448        let pointer = self.pointer.map(|k| (k, "▲", HIGHLIGHT));
449        for &(k, sym, style) in self.marks.iter().chain(pointer.iter()) {
450            if let Some(x) = x_of(k) {
451                put(buf, x, axis.y, sym, style);
452            }
453        }
454    }
455}
456
457#[cfg(test)]
458#[path = "tests/ui.rs"]
459mod tests;