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