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 std::time::{Duration, Instant};
7
8use legume_tui::{Keyboard, Modes, Mouse, Round, Session};
9use ratatui::crossterm::event::{
10    self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags,
11    MouseEvent,
12};
13use ratatui::layout::{Constraint, Layout, Rect};
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::{Block, BorderType};
17use ratatui::Frame;
18
19use crate::qc::Axis;
20
21// Palette: the terminal's own foreground (so light and dark backgrounds both
22// work) for nearly everything, and one accent for what needs the eye: what a
23// cutoff drops, the selection, and key hints.
24pub const ACCENT: Color = Color::Rgb(217, 119, 87);
25
26/// Plain text and bars in the terminal's foreground.
27pub const PLAIN: Style = Style::new();
28/// Secondary text: labels, axes, units.
29pub const DIM: Style = Style::new().add_modifier(Modifier::DIM);
30/// Accent bars and marks.
31pub const ACCENTED: Style = Style::new().fg(ACCENT);
32/// Key names in help lines, typed values, and the value in focus.
33pub const HIGHLIGHT: Style = Style::new().fg(ACCENT).add_modifier(Modifier::BOLD);
34
35/// A full-screen view driven by [`run_screen`].
36pub trait Screen {
37    fn render(&mut self, frame: &mut Frame);
38    /// A key press (Ctrl-C goes to [`Screen::interrupt`] instead).
39    fn handle_key(&mut self, key: KeyEvent);
40    fn interrupt(&mut self);
41    fn done(&self) -> bool;
42    /// Blocking work the last key asked for, as a line to print while it
43    /// runs; [`Screen::do_work`] then does it.
44    fn pending_work(&self) -> Option<String> {
45        None
46    }
47    fn do_work(&mut self) {}
48    /// Called about every [`TICK`], keys or not: true redraws, for a view
49    /// waiting on work in the background.
50    fn tick(&mut self) -> bool {
51        false
52    }
53    /// Whether the view takes the mouse: only then is the terminal asked for
54    /// it, since a view holding it keeps text from being selected.
55    fn takes_mouse(&self) -> bool {
56        false
57    }
58    /// A mouse event (a button pressed or let go, or a turn of the wheel)
59    /// at a cell of the screen; whether the screen changed and is drawn
60    /// again.
61    fn mouse(&mut self, _event: MouseEvent) -> bool {
62        false
63    }
64    /// Whether the terminal is asked to report keys held with Shift, Alt or
65    /// Ctrl apart (the kitty keyboard protocol; others ignore the request),
66    /// so that Shift+Enter, say, is not taken for Enter.
67    fn reports_chords(&self) -> bool {
68        false
69    }
70    /// Something to tell whoever is away, once: [`run_screen`] rings the
71    /// terminal's bell and asks it for a desktop notification saying it.
72    fn take_notice(&mut self) -> Option<String> {
73        None
74    }
75    /// What the view is doing, in one short line, for `data-beans
76    /// sessions` and the session picker to show.
77    fn status(&self) -> Option<String> {
78        None
79    }
80}
81
82/// The terminal modes `screen` asks for, as its session turns them on (and
83/// on again after every attach): the mouse as buttons and the wheel in SGR
84/// form, not the pointer's every move, which would wake the loop for
85/// nothing; keys held with modifiers told apart.
86pub fn modes_of(screen: &impl Screen) -> Modes {
87    Modes::new()
88        .mouse(if screen.takes_mouse() {
89            Mouse::Buttons
90        } else {
91            Mouse::Off
92        })
93        .keyboard_enhancement(if screen.reports_chords() {
94            Keyboard::On(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
95        } else {
96            Keyboard::Off
97        })
98}
99
100/// Write `text` to the terminal as it is.
101fn send(text: &str) -> bool {
102    let mut out = std::io::stdout();
103    std::io::Write::write_all(&mut out, text.as_bytes()).is_ok()
104        && std::io::Write::flush(&mut out).is_ok()
105}
106
107/// Ask the terminal to put `text` on the clipboard (OSC 52): kitty, iTerm2,
108/// WezTerm and others do (tmux when its `set-clipboard` lets it through);
109/// the rest ignore it. Whether it took cannot be known.
110pub fn copy(text: &str) {
111    send(&format!("\x1b]52;c;{}\x07", base64(text.as_bytes())));
112}
113
114/// `bytes` in standard base64, padded.
115fn base64(bytes: &[u8]) -> String {
116    const ABC: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
117    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
118    for chunk in bytes.chunks(3) {
119        let n = chunk.iter().fold(0u32, |n, &b| n << 8 | u32::from(b)) << (8 * (3 - chunk.len()));
120        for k in 0..4 {
121            out.push(match k <= chunk.len() {
122                true => ABC[(n >> (18 - 6 * k) & 63) as usize] as char,
123                false => '=',
124            });
125        }
126    }
127    out
128}
129
130/// Ring the terminal's bell and ask it for a desktop notification saying
131/// `text` (OSC 9); terminals without either ignore them.
132pub fn notify(text: &str) {
133    let text: String = text.chars().filter(|c| !c.is_control()).collect();
134    send(&format!("\x07\x1b]9;{text}\x07"));
135}
136
137/// How often a view's loop asks [`Screen::tick`], keys or not.
138pub const TICK: std::time::Duration = std::time::Duration::from_millis(200);
139
140/// The terminal a command's views draw on, in the command's session: up
141/// from the first view ([`Tui::run`]) until dropped, so every view of the
142/// command and the work between them ([`Tui::work`]) share one screen.
143/// While it is up, a `data-beans kill` asks the views to stop as Ctrl-C
144/// does ([`Tui::stop_requested`]), and work under way finishes first. Log
145/// records raised while it is up are held back and written once the
146/// normal screen is back, or as work between views runs.
147pub struct Tui<'s> {
148    session: &'s Session,
149    terminal: Option<ratatui::DefaultTerminal>,
150    /// The modes the screen has on now.
151    modes: Modes,
152    _held: legume_tui::logs::Hold,
153}
154
155impl<'s> Tui<'s> {
156    /// Views of `session`, the screen not up yet.
157    pub fn new(session: &'s Session) -> Self {
158        Self {
159            session,
160            terminal: None,
161            modes: Modes::new(),
162            _held: legume_tui::logs::hold(),
163        }
164    }
165
166    pub fn session(&self) -> &'s Session {
167        self.session
168    }
169
170    /// Whether `data-beans kill` asked the command to stop: no more views
171    /// and no more work after the one running.
172    pub fn stop_requested(&self) -> bool {
173        self.session.stop_requested()
174    }
175
176    /// Run `screen` until it is done, its keys to [`Screen::handle_key`].
177    pub fn run<S: Screen>(&mut self, screen: &mut S) -> anyhow::Result<()> {
178        self.run_with(screen, S::handle_key)
179    }
180
181    /// Run `screen` until it is done, each key (but Ctrl-C) handed to
182    /// `on_key`. The screen comes up with the first view and stays up
183    /// after. A stop interrupts the view, as Ctrl-C does. Blocking work
184    /// ([`Screen::pending_work`]) runs on the normal screen, where its own
185    /// progress output belongs, and the view comes back after it. The
186    /// mouse and keys held with modifiers are reported only to a screen
187    /// that asks for them, and events already queued (a turn of the wheel
188    /// is several) are taken before the screen is drawn again. After an
189    /// attach the modes are on again and the view is drawn whole.
190    pub fn run_with<S: Screen>(
191        &mut self,
192        screen: &mut S,
193        mut on_key: impl FnMut(&mut S, KeyEvent),
194    ) -> anyhow::Result<()> {
195        let session = self.session;
196        if session.stop_requested() {
197            screen.interrupt();
198            return Ok(());
199        }
200        let wanted = modes_of(screen);
201        self.up(wanted)?;
202        let Some(terminal) = self.terminal.as_mut() else {
203            unreachable!("the screen is up");
204        };
205        let mut redraw = true;
206        // Ticks keep time while events come: a stream of them must not
207        // keep a view from its background work.
208        let mut next_tick = Instant::now() + TICK;
209        while !screen.done() {
210            if session.stop_requested() {
211                screen.interrupt();
212                break;
213            }
214            if redraw {
215                session.draw(terminal, |f| screen.render(f))?;
216                let wanted = modes_of(screen);
217                if wanted != self.modes {
218                    session.set_modes(wanted)?;
219                    self.modes = wanted;
220                }
221            }
222            if let Some(status) = screen.status() {
223                session.status(&status);
224            }
225            if let Some(notice) = screen.take_notice() {
226                notify(&notice);
227            }
228            redraw = false;
229            let wait = next_tick.saturating_duration_since(Instant::now());
230            match session.poll(terminal, wait)? {
231                Round::Event(mut event) => loop {
232                    redraw |= feed(screen, &mut on_key, event);
233                    if screen.done()
234                        || screen.pending_work().is_some()
235                        || session.stop_requested()
236                        || !event::poll(Duration::ZERO)?
237                    {
238                        break;
239                    }
240                    event = event::read()?;
241                },
242                // An attach cleared the screen: drawn whole again.
243                Round::Repainted => redraw = true,
244                _ => {}
245            }
246            if Instant::now() >= next_tick {
247                redraw |= screen.tick();
248                next_tick = Instant::now() + TICK;
249            }
250            if let Some(message) = screen.pending_work() {
251                session.off_screen(|| {
252                    eprintln!("{message}");
253                    screen.do_work();
254                })?;
255                redraw = true;
256            }
257        }
258        Ok(())
259    }
260
261    /// The screen, brought up with `modes` if it is not up yet, else
262    /// switched to them.
263    fn up(&mut self, modes: Modes) -> anyhow::Result<()> {
264        if self.terminal.is_none() {
265            self.terminal = Some(self.session.terminal_with(modes)?);
266        } else if modes != self.modes {
267            self.session.set_modes(modes)?;
268        }
269        self.modes = modes;
270        Ok(())
271    }
272
273    /// Run `f`, the command's own work before, between or after its
274    /// views, on the normal screen, its log records written as they come;
275    /// the screen is taken back after it, to be drawn whole by the next
276    /// view. A `kill` meanwhile lets `f` finish and is then
277    /// [`Tui::stop_requested`]. Before the first view the screen stays
278    /// down: `f` runs where it is, a `kill` waiting for it all the same.
279    pub fn work<T>(&mut self, f: impl FnOnce() -> T) -> anyhow::Result<T> {
280        if !self.session.screen_up() {
281            return Ok(self.session.stoppable(f));
282        }
283        Ok(self.session.off_screen(f)?)
284    }
285}
286
287impl Drop for Tui<'_> {
288    fn drop(&mut self) {
289        if self.terminal.take().is_some() {
290            self.session.restore();
291        }
292    }
293}
294
295/// Hand `event` to `screen`: Ctrl-C to [`Screen::interrupt`], other key
296/// presses to `on_key`. Whether the screen is drawn again.
297fn feed<S: Screen>(
298    screen: &mut S,
299    on_key: &mut impl FnMut(&mut S, KeyEvent),
300    event: Event,
301) -> bool {
302    match event {
303        Event::Key(key) if key.kind != KeyEventKind::Press => false,
304        Event::Key(key)
305            if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') =>
306        {
307            screen.interrupt();
308            true
309        }
310        Event::Key(key) => {
311            on_key(screen, key);
312            true
313        }
314        Event::Mouse(m) => screen.mouse(m),
315        Event::Resize(..) => true,
316        _ => false,
317    }
318}
319
320/// Run `screen` full screen until it is done, in a plain session of its
321/// own (`Session::plain`). The terminal is restored on return and
322/// on panic. See [`Tui::run_with`] for the rest.
323pub fn run_screen<S: Screen>(screen: &mut S) -> anyhow::Result<()> {
324    run_screen_with(screen, S::handle_key)
325}
326
327/// [`run_screen`], with each key (but Ctrl-C) handed to `on_key` rather
328/// than [`Screen::handle_key`]: for a caller with rules of its own about
329/// which keys a screen sees.
330pub fn run_screen_with<S: Screen>(
331    screen: &mut S,
332    on_key: impl FnMut(&mut S, KeyEvent),
333) -> anyhow::Result<()> {
334    let session = Session::plain(&super::TOOL);
335    run_screen_in_with(&session, screen, on_key)
336}
337
338/// [`run_screen`] in `session`: hosted, it keeps running when the
339/// connection drops and a `kill` stops it as Ctrl-C does. The screen is
340/// handed back on return; [`Tui`] keeps it up across several views.
341pub fn run_screen_in<S: Screen>(session: &Session, screen: &mut S) -> anyhow::Result<()> {
342    run_screen_in_with(session, screen, S::handle_key)
343}
344
345/// [`run_screen_in`], with each key (but Ctrl-C) handed to `on_key`.
346pub fn run_screen_in_with<S: Screen>(
347    session: &Session,
348    screen: &mut S,
349    on_key: impl FnMut(&mut S, KeyEvent),
350) -> anyhow::Result<()> {
351    Tui::new(session).run_with(screen, on_key)
352}
353
354/// Title bar: a reverse-video badge naming the view, then plain text.
355pub fn header(badge: &str, title: &str, extra: &str) -> Line<'static> {
356    Line::from(vec![
357        Span::styled(
358            format!(" {badge} "),
359            HIGHLIGHT.add_modifier(Modifier::REVERSED),
360        ),
361        Span::raw(format!(" {title}")),
362        Span::styled(format!("   {extra}"), DIM),
363    ])
364}
365
366/// Title bar: `title` (a [`header`]) with the session keys, `keys`
367/// ([`super::session_keys`]), at its right end. The keys stay whole however
368/// narrow the terminal, the title giving way, so the detach key is always
369/// on screen.
370pub fn title_bar(frame: &mut Frame, area: Rect, title: Line<'static>, keys: &[(String, String)]) {
371    if keys.is_empty() {
372        frame.render_widget(title, area);
373        return;
374    }
375    let pairs: Vec<_> = keys.iter().map(|(k, w)| (k.as_str(), w.as_str())).collect();
376    let keys = help_line(&pairs);
377    let width = (keys.width() as u16).min(area.width);
378    let [left, right] =
379        Layout::horizontal([Constraint::Fill(1), Constraint::Length(width)]).areas(area);
380    frame.render_widget(title, left);
381    frame.render_widget(keys, right);
382}
383
384/// Rounded panel titled `title`: plain border and accent title when
385/// `focused`, dim border otherwise.
386pub fn panel(title: String, focused: bool) -> Block<'static> {
387    Block::bordered()
388        .border_type(BorderType::Rounded)
389        .border_style(if focused { PLAIN } else { DIM })
390        // Titles inherit the border style; start clear of it.
391        .title(Line::from(title).style(Style::reset().patch(HIGHLIGHT)))
392}
393
394/// Help line from `(key, what it does)` pairs.
395pub fn help_line(pairs: &[(&str, &str)]) -> Line<'static> {
396    let mut spans = vec![Span::raw(" ")];
397    for (key, what) in pairs {
398        spans.push(Span::styled(key.to_string(), HIGHLIGHT));
399        spans.push(Span::styled(format!(" {what}  "), DIM));
400    }
401    Line::from(spans)
402}
403
404/// Footer while typing: `prompt`, the text so far with a cursor, then keys.
405pub fn input_line(prompt: &str, text: &str, keys: &[(&str, &str)]) -> Line<'static> {
406    let mut spans = vec![
407        Span::raw(format!(" {prompt}")),
408        Span::styled(format!("{text}▏"), HIGHLIGHT),
409        Span::raw("  "),
410    ];
411    spans.extend(help_line(keys).spans);
412    Line::from(spans)
413}
414
415/// Set a cell outright, rather than layering `style` over what was there.
416fn put(buf: &mut Buffer, x: u16, y: u16, symbol: &str, style: Style) {
417    buf[(x, y)]
418        .set_symbol(symbol)
419        .set_style(Style::reset().patch(style));
420}
421
422/// Width of the y-axis gutter left of each histogram.
423const GUTTER: u16 = 6;
424
425/// Bins across the range on every scale.
426const TARGET_BINS: f64 = 50.0;
427
428/// How a histogram axis is drawn.
429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum Scale {
431    Log,
432    Sqrt,
433    Linear,
434}
435
436impl Scale {
437    pub fn next(self) -> Self {
438        match self {
439            Scale::Log => Scale::Sqrt,
440            Scale::Sqrt => Scale::Linear,
441            Scale::Linear => Scale::Log,
442        }
443    }
444
445    pub fn name(self) -> &'static str {
446        match self {
447            Scale::Log => "log",
448            Scale::Sqrt => "sqrt",
449            Scale::Linear => "linear",
450        }
451    }
452
453    fn apply(self, v: f64) -> f64 {
454        match self {
455            Scale::Log => (v + 1.0).log10(),
456            Scale::Sqrt => v.max(0.0).sqrt(),
457            Scale::Linear => v,
458        }
459    }
460
461    fn invert(self, t: f64) -> f64 {
462        match self {
463            Scale::Log => 10f64.powf(t) - 1.0,
464            Scale::Sqrt => t * t,
465            Scale::Linear => t,
466        }
467    }
468}
469
470/// Equal-width bins on a scale, keyed by integers from 0 at 0. The log
471/// scale is the [`Axis`] every histogram reads values on: `ln(shift + x)`,
472/// the shift 1 for counts and smaller for values mostly below 1.
473#[derive(Debug, Clone, Copy)]
474pub struct Binning {
475    pub scale: Scale,
476    /// Bin width on the scaled axis.
477    width: f64,
478    /// What the log scale adds before its log.
479    shift: f64,
480}
481
482impl Binning {
483    /// Bins spanning `0..=max`. Whole counts (`integer`) get linear bins at
484    /// least one count wide, so no bin falls between two integers. The log
485    /// scale shifts by 1 for whole counts or a `max` of 20 and over, else
486    /// by a thousandth of `max`; [`Binned::new`] shifts as the data says.
487    pub fn new(scale: Scale, max: f64, integer: bool) -> Self {
488        let shift = if integer || max >= 20.0 {
489            1.0
490        } else {
491            (max / 1e3).clamp(1e-9, 1.0)
492        };
493        Self::shifted(scale, max, integer, shift)
494    }
495
496    /// [`Binning::new`] with the log scale shifted by `shift`.
497    fn shifted(scale: Scale, max: f64, integer: bool, shift: f64) -> Self {
498        let span = if integer { max + 1.0 } else { max };
499        let mut bins = Self {
500            scale,
501            width: 1.0,
502            shift,
503        };
504        let width = match scale {
505            Scale::Linear if integer => (span / TARGET_BINS).ceil().max(1.0),
506            _ => bins.t(span) / TARGET_BINS,
507        };
508        bins.width = width.max(f64::MIN_POSITIVE);
509        bins
510    }
511
512    /// Bins of an explicit `width` on the scaled axis. On the linear scale
513    /// with width 1, bin `k` holds exactly the value `k`, so a histogram of
514    /// bin indices draws one bar per category.
515    pub fn with_width(scale: Scale, width: f64) -> Self {
516        Self {
517            scale,
518            width: width.max(f64::MIN_POSITIVE),
519            shift: 1.0,
520        }
521    }
522
523    /// `x` on the scaled axis, 0 at 0.
524    fn t(&self, x: f64) -> f64 {
525        match self.scale {
526            Scale::Log => (x.max(0.0) + self.shift).ln() - self.shift.ln(),
527            _ => self.scale.apply(x),
528        }
529    }
530
531    /// The value at `t` on the scaled axis.
532    fn value(&self, t: f64) -> f64 {
533        match self.scale {
534            Scale::Log => ((t + self.shift.ln()).exp() - self.shift).max(0.0),
535            _ => self.scale.invert(t),
536        }
537    }
538
539    pub fn key(&self, x: f64) -> i32 {
540        (self.t(x) / self.width).floor() as i32
541    }
542
543    /// Scaled position where bin `k` starts.
544    fn start(&self, k: i32) -> f64 {
545        k as f64 * self.width
546    }
547
548    /// Smallest whole count in bin `k` or above: the cutoff that drops every
549    /// bin left of `k`.
550    pub fn lower_edge(&self, k: i32) -> usize {
551        if k <= 0 {
552            return 0;
553        }
554        // Start from the exact inverse, then settle rounding either way.
555        let mut x = self.value(self.start(k)).ceil().max(0.0) as usize;
556        while self.key(x as f64) < k {
557            x += 1;
558        }
559        while x > 0 && self.key((x - 1) as f64) >= k {
560            x -= 1;
561        }
562        x
563    }
564
565    /// The value where bin `k` starts, its tick's label.
566    fn tick_value(&self, k: i32) -> f64 {
567        self.value(self.start(k))
568    }
569
570    /// About six ticks across.
571    fn tick_every(&self, nbins: usize) -> i32 {
572        (nbins as i32 / 6).max(1)
573    }
574
575    /// The ticks over bins `kmin..=kmax`, as bin and value: on the log
576    /// scale at the axis' 1-2-5 values, so labels read round; else every
577    /// [`Binning::tick_every`] bins.
578    fn ticks(&self, kmin: i32, kmax: i32) -> Vec<(i32, f64)> {
579        match self.scale {
580            Scale::Log => {
581                let axis = Axis {
582                    lo: self.tick_value(kmin),
583                    hi: self.value(self.start(kmax + 1)),
584                    log: true,
585                    shift: self.shift,
586                };
587                axis.ticks().into_iter().map(|v| (self.key(v), v)).collect()
588            }
589            _ => {
590                let every = self.tick_every((kmax - kmin + 1).max(1) as usize);
591                (kmin..=kmax)
592                    .filter(|k| k % every == 0)
593                    .map(|k| (k, self.tick_value(k)))
594                    .collect()
595            }
596        }
597    }
598}
599
600/// A sorted statistic binned on a scale: the bins and their counts.
601pub struct Binned {
602    pub bins: Binning,
603    pub kmin: i32,
604    pub counts: Vec<usize>,
605}
606
607impl Binned {
608    /// Bin `sorted`: finite, ascending. Its ends are the range the bins
609    /// span. All-whole data gets whole-count bins.
610    pub fn new(sorted: &[f32], scale: Scale) -> Self {
611        let (min, max) = match (sorted.first(), sorted.last()) {
612            (Some(&lo), Some(&hi)) => (lo as f64, hi as f64),
613            _ => (0.0, 0.0),
614        };
615        let integer = sorted.iter().all(|v| v.fract() == 0.0);
616        // The log scale shifts as the axis does for these values.
617        let shift = match integer {
618            true => 1.0,
619            false => Axis::fit(sorted, true).shift,
620        };
621        let bins = Binning::shifted(scale, max, integer, shift);
622        let kmin = bins.key(min);
623        let nbins = (bins.key(max) - kmin + 1).max(1) as usize;
624        let counts = count(&bins, kmin, nbins, sorted.iter().copied());
625        Self { bins, kmin, counts }
626    }
627
628    /// Counts of the finite `values` in these bins.
629    pub fn count(&self, values: impl Iterator<Item = f32>) -> Vec<usize> {
630        count(&self.bins, self.kmin, self.counts.len(), values)
631    }
632
633    pub fn kmax(&self) -> i32 {
634        self.kmin + self.counts.len() as i32 - 1
635    }
636}
637
638/// Counts of the finite `values` per bin, from `kmin` to `kmin + nbins - 1`
639/// (values outside land in the end bins).
640fn count(bins: &Binning, kmin: i32, nbins: usize, values: impl Iterator<Item = f32>) -> Vec<usize> {
641    let mut counts = vec![0; nbins];
642    for v in values.filter(|v| v.is_finite()) {
643        let i = (bins.key(v as f64) - kmin).clamp(0, nbins as i32 - 1);
644        counts[i as usize] += 1;
645    }
646    counts
647}
648
649/// Median of an ascending slice (0 when empty).
650pub fn median(sorted: &[f32]) -> f32 {
651    crate::qc::median_of_sorted(sorted)
652}
653
654/// Compact number for axis labels: 950, 1.2k, 35k, 1.1M; small fractions
655/// keep two significant digits.
656pub fn compact(v: f64) -> String {
657    if v != 0.0 && v.abs() < 10.0 && v.fract() != 0.0 {
658        format!("{:.2}", v)
659            .trim_end_matches('0')
660            .trim_end_matches('.')
661            .to_string()
662    } else if v < 1e3 {
663        format!("{}", v.round() as i64)
664    } else if v < 1e4 {
665        format!("{:.1}k", v / 1e3)
666    } else if v < 1e6 {
667        format!("{}k", (v / 1e3).round() as u64)
668    } else if v < 1e9 {
669        format!("{:.1}M", v / 1e6)
670    } else {
671        format!("{:.1}G", v / 1e9)
672    }
673}
674
675/// A histogram of `counts` over bins `kmin..`, scaled to them.
676/// A bar height [`HistPlot`] can draw: whole counts, or any non-negative
677/// real value (a summed signal, a log statistic).
678pub trait BarValue: Copy {
679    fn bar(self) -> f64;
680}
681
682impl BarValue for usize {
683    fn bar(self) -> f64 {
684        self as f64
685    }
686}
687
688impl BarValue for f64 {
689    fn bar(self) -> f64 {
690        self
691    }
692}
693
694pub struct HistPlot<'a, T: BarValue = usize> {
695    pub bins: Binning,
696    pub kmin: i32,
697    pub counts: &'a [T],
698    /// Style of each bin's bar, by key.
699    pub style: &'a dyn Fn(i32) -> Style,
700    /// A subset drawn in front, in the bar style; `counts` then draw dimmed
701    /// behind it.
702    pub subset: Option<&'a [T]>,
703    pub y_scale: Scale,
704    /// Top of the y axis in count units; `None` scales to the tallest bar.
705    /// Set it to put several plots on one scale.
706    pub y_max: Option<f64>,
707    /// Bin under the accent rule and ▲.
708    pub pointer: Option<i32>,
709    /// Other symbols on the x axis, by key.
710    pub marks: Vec<(i32, &'static str, Style)>,
711    /// Tick label at bin `k` in place of the bin's value; `None` from it
712    /// drops that tick, so the labels decide where ticks go (e.g. at
713    /// category boundaries with `tick_every: Some(1)`). Unset, every tick
714    /// shows its value.
715    pub x_label: Option<&'a dyn Fn(i32) -> Option<String>>,
716    /// Ticks every this many bins, in place of the scale's default.
717    pub tick_every: Option<i32>,
718}
719
720const EIGHTHS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
721
722impl<T: BarValue> HistPlot<'_, T> {
723    /// Draw into `area`: bars over the rows above the last two, which hold
724    /// the x axis and its labels; the left [`GUTTER`] columns hold the y axis.
725    pub fn render(&self, buf: &mut Buffer, area: Rect) {
726        let [plot, axis, labels] = Layout::vertical([
727            Constraint::Min(1),
728            Constraint::Length(1),
729            Constraint::Length(1),
730        ])
731        .areas(area);
732        let [gutter, chart] =
733            Layout::horizontal([Constraint::Length(GUTTER), Constraint::Min(1)]).areas(plot);
734        if chart.width == 0 || chart.height == 0 {
735            return;
736        }
737        let nbins = self.counts.len();
738        let bw = (chart.width / nbins.max(1) as u16).clamp(1, 4);
739        let x_of = |k: i32| -> Option<u16> {
740            let i = k - self.kmin;
741            (i >= 0 && (i as usize) < nbins)
742                .then(|| chart.x + i as u16 * bw)
743                .filter(|&x| x < chart.right())
744        };
745
746        let height = |c: T| self.y_scale.apply(c.bar().max(0.0));
747        let tallest = self.counts.iter().map(|&c| height(c)).fold(0.0, f64::max);
748        let max_h = self
749            .y_max
750            .map_or(tallest, |m| self.y_scale.apply(m.max(0.0)).max(tallest));
751        let cells = chart.height as usize * 8;
752        let eighths = |c: T| {
753            if c.bar() <= 0.0 || max_h <= 0.0 {
754                0
755            } else {
756                ((height(c) / max_h * cells as f64).round() as usize).clamp(1, cells)
757            }
758        };
759
760        if let Some(x) = self.pointer.and_then(x_of) {
761            for y in chart.top()..chart.bottom() {
762                put(buf, x, y, "┊", ACCENTED);
763            }
764        }
765
766        let mut bars = |counts: &[T], behind: Option<&[T]>, dim: bool| {
767            for (i, &c) in counts.iter().enumerate() {
768                let x0 = chart.x + i as u16 * bw;
769                if x0 >= chart.right() {
770                    break;
771                }
772                let style = if dim {
773                    DIM
774                } else {
775                    (self.style)(self.kmin + i as i32)
776                };
777                let (top, under) = (eighths(c), behind.map_or(0, |b| eighths(b[i])));
778                for (j, y) in (chart.top()..chart.bottom()).rev().enumerate() {
779                    let mut fill = top.saturating_sub(j * 8).min(8);
780                    if fill == 0 {
781                        break;
782                    }
783                    // A partial top in front of a taller bar would show a gap
784                    // above it (the terminal's foreground cannot be a
785                    // background), so it rounds up to a whole cell.
786                    if under >= (j + 1) * 8 {
787                        fill = 8;
788                    }
789                    for x in x0..(x0 + bw).min(chart.right()) {
790                        put(buf, x, y, EIGHTHS[fill - 1], style);
791                    }
792                }
793            }
794        };
795        bars(self.counts, None, self.subset.is_some());
796        if let Some(subset) = self.subset {
797            bars(subset, Some(self.counts), false);
798        }
799
800        // y axis: count at the top and at half height on the y scale.
801        let gx = gutter.right() - 1;
802        for y in gutter.top()..gutter.bottom() {
803            put(buf, gx, y, "│", DIM);
804        }
805        let mut ylabel = |y: u16, v: f64| {
806            let s = compact(v);
807            let x = gx.saturating_sub(1 + s.len() as u16).max(gutter.x);
808            buf.set_string(x, y, &s, DIM);
809            put(buf, gx, y, "┤", DIM);
810        };
811        if max_h > 0.0 {
812            ylabel(gutter.top(), self.y_scale.invert(max_h));
813            if gutter.height >= 6 {
814                ylabel(
815                    gutter.top() + gutter.height / 2,
816                    self.y_scale.invert(max_h / 2.0),
817                );
818            }
819        }
820
821        // x axis: baseline with ticks, labels below, then the marks.
822        for x in axis.left()..axis.right() {
823            let sym = match x.cmp(&gx) {
824                std::cmp::Ordering::Less => " ",
825                std::cmp::Ordering::Equal => "└",
826                std::cmp::Ordering::Greater => "─",
827            };
828            put(buf, x, axis.y, sym, DIM);
829        }
830        let mut next_free = labels.x;
831        let kmax = self.kmin + nbins as i32 - 1;
832        // Labels given, or a spacing, tick every so many bins; else the
833        // bins' own ticks (round values on the log scale).
834        let ticks: Vec<(i32, Option<f64>)> = match (self.x_label, self.tick_every) {
835            (None, None) => self
836                .bins
837                .ticks(self.kmin, kmax)
838                .into_iter()
839                .map(|(k, v)| (k, Some(v)))
840                .collect(),
841            _ => {
842                let every = self
843                    .tick_every
844                    .unwrap_or_else(|| self.bins.tick_every(nbins))
845                    .max(1);
846                (self.kmin..=kmax)
847                    .filter(|k| k % every == 0)
848                    .map(|k| (k, None))
849                    .collect()
850            }
851        };
852        for (k, value) in ticks {
853            let Some(x) = x_of(k) else { continue };
854            let s = match (self.x_label, value) {
855                (Some(label), _) => match label(k) {
856                    Some(s) => s,
857                    None => continue,
858                },
859                (None, Some(v)) => compact(v),
860                (None, None) => compact(self.bins.tick_value(k)),
861            };
862            put(buf, x, axis.y, "┴", DIM);
863            if x >= next_free && x + (s.len() as u16) <= labels.right() {
864                buf.set_string(x, labels.y, &s, DIM);
865                next_free = x + s.len() as u16 + 1;
866            }
867        }
868        let pointer = self.pointer.map(|k| (k, "▲", HIGHLIGHT));
869        for &(k, sym, style) in self.marks.iter().chain(pointer.iter()) {
870            if let Some(x) = x_of(k) {
871                put(buf, x, axis.y, sym, style);
872            }
873        }
874    }
875}
876
877/// One side of a [`MirrorPlot`].
878pub struct MirrorSide<'a, T: BarValue = f64> {
879    pub counts: &'a [T],
880    /// A subset drawn in front, in `style`; `counts` then draw dimmed
881    /// behind it.
882    pub subset: Option<&'a [T]>,
883    pub style: Style,
884    /// Named in the side's outer corner; empty for none.
885    pub name: &'a str,
886}
887
888/// Two bar series on one scale around a zero line, one growing up and the
889/// other down, as a Miami plot, in half cells. Signed values are a mirror of
890/// their positive and negative parts. Axes as [`HistPlot`]'s, one column
891/// per bar.
892pub struct MirrorPlot<'a, T: BarValue = f64> {
893    pub up: MirrorSide<'a, T>,
894    pub down: MirrorSide<'a, T>,
895    pub y_scale: Scale,
896    /// Top of either side in count units; `None` scales to the tallest bar.
897    pub y_max: Option<f64>,
898    /// Labels at the top, the zero line and the bottom, in place of the
899    /// scale's own.
900    pub y_labels: Option<[String; 3]>,
901    /// Bar under the accent rule and ▲.
902    pub pointer: Option<usize>,
903    /// Tick label at bar `i`; `None` from it drops that tick. Unset, there
904    /// are no ticks.
905    pub x_label: Option<&'a dyn Fn(usize) -> Option<String>>,
906}
907
908impl<T: BarValue> MirrorPlot<'_, T> {
909    /// Draw into `area`: the halves over the rows above the last two, which
910    /// hold the x axis and its labels; the left [`GUTTER`] columns hold the
911    /// y axis.
912    pub fn render(&self, buf: &mut Buffer, area: Rect) {
913        let [plot, axis, labels] = Layout::vertical([
914            Constraint::Min(1),
915            Constraint::Length(1),
916            Constraint::Length(1),
917        ])
918        .areas(area);
919        let [gutter, chart] =
920            Layout::horizontal([Constraint::Length(GUTTER), Constraint::Min(1)]).areas(plot);
921        if chart.width == 0 || chart.height < 3 {
922            return;
923        }
924        let half = (chart.height - 1) / 2;
925        let zero = chart.top() + half;
926        let x_of = |i: usize| Some(chart.x + i as u16).filter(|&x| x < chart.right());
927
928        let height = |c: T| self.y_scale.apply(c.bar().max(0.0));
929        let all = self.up.counts.iter().chain(self.down.counts);
930        let tallest = all.map(|&c| height(c)).fold(0.0, f64::max);
931        let max_h = self
932            .y_max
933            .map_or(tallest, |m| self.y_scale.apply(m.max(0.0)).max(tallest));
934        let cells = half as usize * 2;
935        let halves = |c: T| {
936            if c.bar() <= 0.0 || max_h <= 0.0 {
937                0
938            } else {
939                ((height(c) / max_h * cells as f64).round() as usize).clamp(1, cells)
940            }
941        };
942
943        if let Some(x) = self.pointer.and_then(x_of) {
944            for y in chart.top()..chart.top() + 2 * half + 1 {
945                put(buf, x, y, "┊", ACCENTED);
946            }
947        }
948        for x in chart.left()..chart.right() {
949            put(buf, x, zero, "─", DIM);
950        }
951        for (side, up) in [(&self.up, true), (&self.down, false)] {
952            let (whole, part) = if up { ("█", "▄") } else { ("█", "▀") };
953            let mut bars = |counts: &[T], behind: Option<&[T]>, style: Style| {
954                for (i, &c) in counts.iter().enumerate() {
955                    let Some(x) = x_of(i) else { break };
956                    let (top, under) = (halves(c), behind.map_or(0, |b| halves(b[i])));
957                    for k in 0..top.div_ceil(2) {
958                        let y = if up {
959                            zero - 1 - k as u16
960                        } else {
961                            zero + 1 + k as u16
962                        };
963                        // As in HistPlot, a partial end in front of a longer
964                        // bar rounds up to a whole cell.
965                        let full = 2 * k + 2 <= top || under >= 2 * k + 2;
966                        put(buf, x, y, if full { whole } else { part }, style);
967                    }
968                }
969            };
970            match side.subset {
971                Some(subset) => {
972                    bars(side.counts, None, DIM);
973                    bars(subset, Some(side.counts), side.style);
974                }
975                None => bars(side.counts, None, side.style),
976            }
977        }
978        buf.set_string(chart.x, chart.top(), self.up.name, DIM);
979        buf.set_string(chart.x, chart.top() + 2 * half, self.down.name, DIM);
980
981        // y axis: the scale's top on both sides of the zero line.
982        let gx = gutter.right() - 1;
983        for y in gutter.top()..gutter.bottom() {
984            put(buf, gx, y, "│", DIM);
985        }
986        let own = || {
987            let top = compact(self.y_scale.invert(max_h));
988            [top.clone(), "0".to_string(), top]
989        };
990        let ys = [chart.top(), zero, chart.top() + 2 * half];
991        for (y, s) in ys
992            .into_iter()
993            .zip(self.y_labels.clone().unwrap_or_else(own))
994        {
995            let x = gx.saturating_sub(1 + s.len() as u16).max(gutter.x);
996            buf.set_string(x, y, &s, DIM);
997            put(buf, gx, y, "┤", DIM);
998        }
999
1000        // x axis: baseline with ticks, labels below, then the pointer.
1001        for x in axis.left()..axis.right() {
1002            let sym = match x.cmp(&gx) {
1003                std::cmp::Ordering::Less => " ",
1004                std::cmp::Ordering::Equal => "└",
1005                std::cmp::Ordering::Greater => "─",
1006            };
1007            put(buf, x, axis.y, sym, DIM);
1008        }
1009        let n = self.up.counts.len().max(self.down.counts.len());
1010        let mut next_free = labels.x;
1011        for i in 0..n {
1012            let Some(x) = x_of(i) else { break };
1013            let Some(s) = self.x_label.and_then(|label| label(i)) else {
1014                continue;
1015            };
1016            put(buf, x, axis.y, "┴", DIM);
1017            if x >= next_free && x + (s.len() as u16) <= labels.right() {
1018                buf.set_string(x, labels.y, &s, DIM);
1019                next_free = x + s.len() as u16 + 1;
1020            }
1021        }
1022        if let Some(x) = self.pointer.and_then(x_of) {
1023            put(buf, x, axis.y, "▲", HIGHLIGHT);
1024        }
1025    }
1026}
1027
1028#[cfg(test)]
1029#[path = "tests/ui.rs"]
1030mod tests;