Skip to main content

data_beans/interactive/
ui.rs

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