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