Skip to main content

data_beans/interactive/
stat_tui.rs

1//! Full-screen explorer for per-row and per-column statistics.
2//!
3//! A table of every row (or column) with its nnz, sum, mean, and sd, sortable
4//! and filterable by name, beside a histogram of the chosen statistic. A name
5//! filter draws its subset in front of the whole distribution, and the
6//! selected entry is marked on the histogram with its rank.
7//!
8//! Entries can be marked: `v` shows the marked entries' values against the
9//! other side, `w` saves the marked names, `s` saves the statistics figure
10//! (the marked entries in the accent, the cursor with its rank), and when picking (for
11//! `subset-*` and `rows`/`columns`) Enter hands the marked entries back.
12//! While exploring, Tab switches between rows and columns, computing the
13//! other side the first time.
14
15use legume_numeric::matrix::common_io::write_lines;
16use ratatui::crossterm::event::{KeyCode, KeyEvent};
17use ratatui::layout::{Constraint, Layout, Rect};
18use ratatui::style::Modifier;
19use ratatui::text::{Line, Span};
20use ratatui::widgets::{Cell, Paragraph, Row, Table, TableState};
21use ratatui::Frame;
22use regex::{Regex, RegexBuilder};
23
24use super::ui::{
25    header, help_line, input_line, median, panel, run_screen, title_bar, Binned, HistPlot, Scale,
26    Screen, Tui, ACCENTED, DIM, HIGHLIGHT, PLAIN,
27};
28use crate::figure::stat::{draw_stat, figure as stat_figure, StatData};
29use crate::figure::term::PlotImage;
30use crate::figure::{free_name, save, taken, Canvas};
31use crate::qc::fmt_stat;
32use ratatui_image::picker::Picker;
33
34/// Statistics in table order.
35const STATS: [&str; 4] = ["nnz", "sum", "mean", "sd"];
36
37/// Rows the table moves on PageUp / PageDown.
38const PAGE: usize = 20;
39
40/// Decimals for fractional values in the tables and summary.
41const DECIMALS: usize = 3;
42
43/// Width range of a value column in the values view: wide enough for its
44/// label (and a sort arrow) where that fits.
45const VALUE_WIDTH: (u16, u16) = (9, 24);
46
47/// Which margin the explorer shows.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Side {
50    Rows,
51    Columns,
52}
53
54impl Side {
55    fn other(self) -> Self {
56        match self {
57            Side::Rows => Side::Columns,
58            Side::Columns => Side::Rows,
59        }
60    }
61
62    fn name(self) -> &'static str {
63        match self {
64            Side::Rows => "rows",
65            Side::Columns => "columns",
66        }
67    }
68}
69
70/// One side's entries: names, and nnz/sum/mean/sd per entry.
71pub struct Dataset {
72    pub names: Vec<Box<str>>,
73    pub values: [Vec<f32>; 4],
74}
75
76/// The values of some entries of one side against every entry of the other.
77pub struct Values {
78    /// The other side's names, one per value row.
79    pub names: Vec<Box<str>>,
80    /// One column of values per requested entry, in request order.
81    pub columns: Vec<Vec<f32>>,
82}
83
84/// Computes a side's statistics the first time it is shown.
85pub type Loader<'a> = Box<dyn FnMut(Side) -> anyhow::Result<Dataset> + 'a>;
86
87/// Reads the values of the given entries of a side.
88pub type ValuesReader<'a> = Box<dyn FnMut(Side, &[usize]) -> anyhow::Result<Values> + 'a>;
89
90/// What the explorer is for.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Purpose {
93    /// Look around (`stat` on a terminal); Tab switches sides.
94    Explore,
95    /// Mark entries of the starting side for a command labelled `verb`;
96    /// Enter returns them.
97    Pick { verb: &'static str },
98    /// Mark rows and columns for a command labelled `verb` (Tab switches
99    /// sides, each keeping its marks); Enter returns both.
100    PickBoth { verb: &'static str },
101}
102
103impl Purpose {
104    fn verb(self) -> Option<&'static str> {
105        match self {
106            Purpose::Explore => None,
107            Purpose::Pick { verb } | Purpose::PickBoth { verb } => Some(verb),
108        }
109    }
110}
111
112/// Entries marked when picking, by side, in original order (empty when a
113/// side has none marked).
114#[derive(Debug, Default, Clone, PartialEq, Eq)]
115pub struct Picked {
116    pub rows: Vec<usize>,
117    pub columns: Vec<usize>,
118}
119
120impl Picked {
121    pub fn side(self, side: Side) -> Vec<usize> {
122        match side {
123            Side::Rows => self.rows,
124            Side::Columns => self.columns,
125        }
126    }
127}
128
129/// The side not on screen.
130enum Other<'a> {
131    /// No other side (Tab does nothing).
132    Unavailable,
133    /// Computed by the loader on first use.
134    Lazy(Loader<'a>),
135    /// Computed, with its marks.
136    Ready(Dataset, Vec<bool>),
137}
138
139/// Blocking work a key asked for, run between frames.
140enum Work {
141    OtherSide,
142    Values(Vec<usize>),
143}
144
145/// Where typed text goes.
146enum Input {
147    Filter,
148    SaveNames,
149    SaveValues,
150    SaveFigure,
151}
152
153enum Mode {
154    Browse,
155    Typing(Input, String),
156    Values,
157}
158
159/// Marked entries against the other side, sortable by any of them.
160struct ValuesView {
161    /// Names of the marked entries (the value columns).
162    labels: Vec<Box<str>>,
163    /// The other side's names (the value rows).
164    names: Vec<Box<str>>,
165    columns: Vec<Vec<f32>>,
166    /// Value rows in display order.
167    order: Vec<usize>,
168    /// Sorted column and whether descending.
169    sort: Option<(usize, bool)>,
170    /// Selected column, first column on screen, cursor row, first row on
171    /// screen.
172    col: usize,
173    hscroll: usize,
174    cursor: usize,
175    offset: usize,
176}
177
178impl ValuesView {
179    fn sort_by_selected(&mut self) {
180        let descending = match self.sort {
181            Some((c, d)) if c == self.col => !d,
182            _ => true,
183        };
184        let vals = &self.columns[self.col];
185        self.order
186            .sort_unstable_by(|&a, &b| vals[a].total_cmp(&vals[b]).then(a.cmp(&b)));
187        if descending {
188            self.order.reverse();
189        }
190        self.sort = Some((self.col, descending));
191        self.cursor = 0;
192    }
193
194    /// Tab-separated lines: a header, then one line per value row in order.
195    fn lines(&self, side: Side) -> Vec<Box<str>> {
196        let mut head = side.other().name().trim_end_matches('s').to_string();
197        for label in &self.labels {
198            head.push('\t');
199            head.push_str(label);
200        }
201        std::iter::once(head.into_boxed_str())
202            .chain(self.order.iter().map(|&r| {
203                let mut line = self.names[r].to_string();
204                for col in &self.columns {
205                    line.push('\t');
206                    line.push_str(&col[r].to_string());
207                }
208                line.into_boxed_str()
209            }))
210            .collect()
211    }
212}
213
214/// State of the explorer, independent of the terminal so it can be tested.
215pub struct StatExplorer<'a> {
216    title: String,
217    purpose: Purpose,
218    /// The side on screen; its entries are `names` and `values`.
219    side: Side,
220    names: Vec<Box<str>>,
221    /// Per statistic in [`STATS`] order, one value per entry.
222    values: [Vec<f32>; 4],
223    marked: Vec<bool>,
224    other: Other<'a>,
225    reader: Option<ValuesReader<'a>>,
226    pending: Option<Work>,
227    /// The last action's outcome or failure, shown in the footer.
228    status: Option<String>,
229    /// Statistic on the histogram, and the sort key unless sorting by name.
230    stat: usize,
231    by_name: bool,
232    descending: bool,
233    /// Every entry in sort order; the filter only picks from it.
234    order: Vec<usize>,
235    filter: String,
236    /// Entries passing the filter, in sort order.
237    view: Vec<usize>,
238    /// Position of the selection in `view`, and the first row on screen.
239    cursor: usize,
240    offset: usize,
241    /// The statistic, sorted, for ranks and the summary line.
242    sorted: Vec<f32>,
243    x_scale: Scale,
244    y_scale: Scale,
245    hist: Binned,
246    /// Histogram of the filtered entries, when a filter hides some.
247    shown: Option<Vec<usize>>,
248    values_view: Option<ValuesView>,
249    mode: Mode,
250    quit: bool,
251    /// Marked entries handed back by Enter when picking.
252    picked: Option<Picked>,
253    /// The detach and switch keys, for the title bar.
254    session_keys: Vec<(String, String)>,
255    /// The terminal's image support, asked once per attach, and whether
256    /// images are wanted (`i`).
257    picker: Option<Picker>,
258    picked_at: Option<u64>,
259    images: bool,
260    /// The histogram as an image, and what it was drawn for.
261    plot: PlotImage,
262    plotted: Option<(usize, Option<usize>, usize, Side)>,
263}
264
265impl<'a> StatExplorer<'a> {
266    /// Show `data` for `side`. `other`, if any, computes the other side on
267    /// the first Tab (exploring only); `reader`, if any, backs the values
268    /// view.
269    pub fn new(
270        title: &str,
271        side: Side,
272        data: Dataset,
273        other: Option<Loader<'a>>,
274        reader: Option<ValuesReader<'a>>,
275        purpose: Purpose,
276    ) -> Self {
277        let sorted = sorted_copy(&data.values[0]);
278        let other = match (purpose, other) {
279            (Purpose::Explore | Purpose::PickBoth { .. }, Some(loader)) => Other::Lazy(loader),
280            _ => Other::Unavailable,
281        };
282        let mut explorer = Self {
283            title: title.to_string(),
284            purpose,
285            side,
286            hist: Binned::new(&sorted, Scale::Log),
287            sorted,
288            marked: vec![false; data.names.len()],
289            names: data.names,
290            values: data.values,
291            other,
292            reader,
293            pending: None,
294            status: None,
295            stat: 0,
296            by_name: false,
297            descending: true,
298            order: Vec::new(),
299            filter: String::new(),
300            view: Vec::new(),
301            cursor: 0,
302            offset: 0,
303            x_scale: Scale::Log,
304            y_scale: Scale::Log,
305            shown: None,
306            values_view: None,
307            mode: Mode::Browse,
308            quit: false,
309            picked: None,
310            session_keys: super::session_keys(),
311            picker: None,
312            picked_at: None,
313            images: true,
314            plot: PlotImage::default(),
315            plotted: None,
316        };
317        explorer.reorder();
318        explorer.refilter(None);
319        explorer
320    }
321
322    /// Open on statistic `s` (nnz, sum, mean, sd), sorted by it.
323    pub fn on_stat(mut self, s: usize) -> Self {
324        if s < STATS.len() && s != self.stat {
325            self.choose_stat(s);
326            (self.cursor, self.offset) = (0, 0);
327        }
328        self
329    }
330
331    fn selected(&self) -> Option<usize> {
332        self.view.get(self.cursor).copied()
333    }
334
335    /// Marked entries in their original order.
336    fn marked_entries(&self) -> Vec<usize> {
337        (0..self.names.len()).filter(|&i| self.marked[i]).collect()
338    }
339
340    /// The filter as a case-insensitive regex; text that is not a valid
341    /// regex matches literally.
342    fn filter_regex(&self) -> Option<Regex> {
343        if self.filter.is_empty() {
344            return None;
345        }
346        let build = |p: &str| RegexBuilder::new(p).case_insensitive(true).build();
347        build(&self.filter)
348            .or_else(|_| build(&regex::escape(&self.filter)))
349            .ok()
350    }
351
352    /// Sort every entry by the current key and direction.
353    fn reorder(&mut self) {
354        let (names, vals) = (&self.names, &self.values[self.stat]);
355        let mut order: Vec<usize> = (0..names.len()).collect();
356        if self.by_name {
357            order.sort_unstable_by(|&a, &b| names[a].cmp(&names[b]));
358        } else {
359            order.sort_unstable_by(|&a, &b| {
360                vals[a].total_cmp(&vals[b]).then(names[a].cmp(&names[b]))
361            });
362        }
363        if self.descending {
364            order.reverse();
365        }
366        self.order = order;
367    }
368
369    /// Pick the entries passing the filter from the sorted order, keeping
370    /// `keep` selected when it is still shown.
371    fn refilter(&mut self, keep: Option<usize>) {
372        let re = self.filter_regex();
373        self.view = match &re {
374            None => self.order.clone(),
375            Some(re) => self
376                .order
377                .iter()
378                .copied()
379                .filter(|&i| re.is_match(&self.names[i]))
380                .collect(),
381        };
382        self.cursor = keep
383            .and_then(|k| self.view.iter().position(|&i| i == k))
384            .unwrap_or(0);
385        self.rebin_shown();
386    }
387
388    /// Sorted values and histogram for a new statistic or side.
389    fn restat(&mut self) {
390        self.sorted = sorted_copy(&self.values[self.stat]);
391        self.hist = Binned::new(&self.sorted, self.x_scale);
392    }
393
394    fn rebin_shown(&mut self) {
395        let vals = &self.values[self.stat];
396        self.shown = (self.view.len() < self.names.len())
397            .then(|| self.hist.count(self.view.iter().map(|&i| vals[i])));
398    }
399
400    /// Reverse the order in place, keeping the same entry selected.
401    fn flip(&mut self) {
402        self.descending = !self.descending;
403        self.order.reverse();
404        self.view.reverse();
405        self.cursor = self.view.len().saturating_sub(1 + self.cursor);
406    }
407
408    /// Sort and plot statistic `s`; pressing the current one flips the order.
409    fn choose_stat(&mut self, s: usize) {
410        if !self.by_name && self.stat == s {
411            return self.flip();
412        }
413        let keep = self.selected();
414        self.by_name = false;
415        self.descending = true;
416        if self.stat != s {
417            self.stat = s;
418            self.restat();
419        }
420        self.reorder();
421        self.refilter(keep);
422    }
423
424    fn sort_by_name(&mut self) {
425        if self.by_name {
426            return self.flip();
427        }
428        let keep = self.selected();
429        self.by_name = true;
430        self.descending = false;
431        self.reorder();
432        self.refilter(keep);
433    }
434
435    fn set_filter(&mut self, filter: String) {
436        let keep = self.selected();
437        self.filter = filter;
438        self.refilter(keep);
439    }
440
441    /// Mark or unmark the selected entry, then move down.
442    fn toggle_mark(&mut self) {
443        if let Some(i) = self.selected() {
444            self.marked[i] = !self.marked[i];
445            self.step(1);
446        }
447    }
448
449    /// Mark every shown entry, or unmark them if all are marked.
450    fn toggle_shown(&mut self) {
451        let mark = !self.view.iter().all(|&i| self.marked[i]);
452        for &i in &self.view {
453            self.marked[i] = mark;
454        }
455    }
456
457    /// Show the other side now if it is computed, else ask for it.
458    fn request_switch(&mut self) {
459        match self.other {
460            Other::Ready(..) => self.switch(),
461            Other::Lazy(_) => self.pending = Some(Work::OtherSide),
462            Other::Unavailable => {}
463        }
464    }
465
466    /// Swap in the other side, keeping the sort, filter, and scales.
467    fn switch(&mut self) {
468        let Other::Ready(data, marked) = std::mem::replace(&mut self.other, Other::Unavailable)
469        else {
470            return;
471        };
472        let names = std::mem::replace(&mut self.names, data.names);
473        let values = std::mem::replace(&mut self.values, data.values);
474        let marks = std::mem::replace(&mut self.marked, marked);
475        self.other = Other::Ready(Dataset { names, values }, marks);
476        self.side = self.side.other();
477        self.status = None;
478        self.offset = 0;
479        self.restat();
480        self.reorder();
481        self.refilter(None);
482    }
483
484    /// Ask for the values view of the marked entries (or the selected one).
485    fn request_values(&mut self) {
486        if self.reader.is_none() {
487            self.status = Some("no values view here".into());
488            return;
489        }
490        let entries = match self.marked_entries() {
491            m if !m.is_empty() => m,
492            _ => self.selected().into_iter().collect(),
493        };
494        if !entries.is_empty() {
495            self.pending = Some(Work::Values(entries));
496        }
497    }
498
499    fn save_names(&mut self, path: &str) {
500        let names: Vec<Box<str>> = self
501            .marked_entries()
502            .into_iter()
503            .map(|i| self.names[i].clone())
504            .collect();
505        self.status = Some(if names.is_empty() {
506            "mark entries first (Space)".into()
507        } else {
508            match write_lines(&names, path) {
509                Ok(()) => format!("wrote {} names to {path}", names.len()),
510                Err(e) => format!("could not write {path}: {e}"),
511            }
512        });
513    }
514
515    /// The statistics figure of the side on screen, the marked entries in
516    /// the accent and the cursor's entry with its rank.
517    fn figure_svg(&self) -> String {
518        let v = &self.values;
519        let cursor = self.selected().map(|i| (i, &*self.names[i]));
520        stat_figure(&StatData {
521            title: &self.title,
522            side: self.side.name(),
523            values: [&v[0], &v[1], &v[2], &v[3]],
524            marked: Some(&self.marked),
525            cursor,
526        })
527    }
528
529    fn save_figure(&mut self, name: &str) {
530        self.status = Some(match save(&self.figure_svg(), name) {
531            Ok(paths) => format!("saved {}", paths.join(", ")),
532            Err(e) => format!("could not save {name}: {e}"),
533        });
534    }
535
536    /// Ask the attached terminal for images, once per attach.
537    fn refresh_picker(&mut self) {
538        let attach = legume_tui::attaches();
539        if self.picked_at == Some(attach) {
540            return;
541        }
542        self.picked_at = Some(attach);
543        self.picker = legume_tui::current().and_then(crate::figure::term::picker);
544        self.plot.invalidate();
545    }
546
547    fn save_values(&mut self, path: &str) {
548        let Some(view) = &self.values_view else {
549            return;
550        };
551        let lines = view.lines(self.side);
552        self.status = Some(match write_lines(&lines, path) {
553            Ok(()) => format!("wrote {} lines to {path}", lines.len()),
554            Err(e) => format!("could not write {path}: {e}"),
555        });
556    }
557
558    fn step(&mut self, delta: isize) {
559        let last = self.view.len().saturating_sub(1) as isize;
560        self.cursor = (self.cursor as isize + delta).clamp(0, last) as usize;
561    }
562
563    /// Marked entries of both sides (the side not on screen only once it
564    /// has been computed).
565    fn marks_by_side(&self) -> Picked {
566        let here = self.marked_entries();
567        let there = match &self.other {
568            Other::Ready(_, marks) => (0..marks.len()).filter(|&i| marks[i]).collect(),
569            _ => Vec::new(),
570        };
571        match self.side {
572            Side::Rows => Picked {
573                rows: here,
574                columns: there,
575            },
576            Side::Columns => Picked {
577                rows: there,
578                columns: here,
579            },
580        }
581    }
582
583    fn finish_pick(&mut self) {
584        let picked = self.marks_by_side();
585        if picked.rows.is_empty() && picked.columns.is_empty() {
586            self.status = Some("mark entries first (Space)".into());
587        } else {
588            self.picked = Some(picked);
589            self.quit = true;
590        }
591    }
592
593    fn handle_typing(&mut self, input: Input, mut text: String, key: KeyEvent) {
594        match key.code {
595            KeyCode::Enter => {
596                match input {
597                    Input::Filter => {}
598                    Input::SaveNames => self.save_names(&text),
599                    Input::SaveValues => self.save_values(&text),
600                    Input::SaveFigure if taken(text.trim()) => {
601                        // Never over a file: the next free name, offered.
602                        let name = text.trim().to_string();
603                        self.status = Some(format!("{name} is already there; not replacing it"));
604                        self.mode = Mode::Typing(Input::SaveFigure, free_name(&name));
605                        return;
606                    }
607                    Input::SaveFigure => self.save_figure(text.trim()),
608                }
609                self.mode = self.resting_mode(&input);
610                return;
611            }
612            KeyCode::Esc => {
613                if let Input::Filter = input {
614                    self.set_filter(String::new());
615                }
616                self.mode = self.resting_mode(&input);
617                return;
618            }
619            KeyCode::Backspace => {
620                text.pop();
621            }
622            KeyCode::Char(c) => text.push(c),
623            _ => {}
624        }
625        if let Input::Filter = input {
626            self.set_filter(text.clone());
627        }
628        self.mode = Mode::Typing(input, text);
629    }
630
631    /// The mode to return to after typing.
632    fn resting_mode(&self, input: &Input) -> Mode {
633        match input {
634            Input::SaveValues => Mode::Values,
635            _ => Mode::Browse,
636        }
637    }
638
639    fn handle_browse(&mut self, key: KeyEvent) {
640        match key.code {
641            KeyCode::Down | KeyCode::Char('j') => self.step(1),
642            KeyCode::Up | KeyCode::Char('k') => self.step(-1),
643            KeyCode::PageDown => self.step(PAGE as isize),
644            KeyCode::PageUp => self.step(-(PAGE as isize)),
645            KeyCode::Home | KeyCode::Char('g') => self.cursor = 0,
646            KeyCode::End | KeyCode::Char('G') => self.cursor = self.view.len().saturating_sub(1),
647            KeyCode::Char(c @ '1'..='4') => self.choose_stat(c as usize - '1' as usize),
648            KeyCode::Char('0' | 'n') => self.sort_by_name(),
649            KeyCode::Char('/') => self.mode = Mode::Typing(Input::Filter, self.filter.clone()),
650            KeyCode::Char(' ') => self.toggle_mark(),
651            KeyCode::Char('a') => self.toggle_shown(),
652            KeyCode::Char('u') => self.marked.fill(false),
653            KeyCode::Char('v') => self.request_values(),
654            KeyCode::Char('w') => {
655                let path = format!("{}.txt", self.side.name());
656                self.mode = Mode::Typing(Input::SaveNames, path);
657            }
658            KeyCode::Char('s') => {
659                let stem = super::qc_tui::stem(&self.title).replace([',', ' '], "_");
660                let name = free_name(&format!("{stem}_{}_stats", self.side.name()));
661                self.mode = Mode::Typing(Input::SaveFigure, name);
662            }
663            KeyCode::Char('i') if self.picker.is_some() => {
664                self.images = !self.images;
665                self.plot.invalidate();
666            }
667            KeyCode::Tab | KeyCode::BackTab => self.request_switch(),
668            KeyCode::Char('x') => {
669                self.x_scale = self.x_scale.next();
670                self.hist = Binned::new(&self.sorted, self.x_scale);
671                self.rebin_shown();
672            }
673            KeyCode::Char('y') => self.y_scale = self.y_scale.next(),
674            KeyCode::Enter => {
675                if self.purpose != Purpose::Explore {
676                    self.finish_pick();
677                }
678            }
679            KeyCode::Esc if !self.filter.is_empty() => self.set_filter(String::new()),
680            KeyCode::Char('q') | KeyCode::Esc => self.quit = true,
681            _ => {}
682        }
683    }
684
685    fn handle_values(&mut self, key: KeyEvent) {
686        let Some(view) = self.values_view.as_mut() else {
687            self.mode = Mode::Browse;
688            return;
689        };
690        let last_row = view.order.len().saturating_sub(1);
691        let last_col = view.labels.len().saturating_sub(1);
692        match key.code {
693            KeyCode::Down | KeyCode::Char('j') => view.cursor = (view.cursor + 1).min(last_row),
694            KeyCode::Up | KeyCode::Char('k') => view.cursor = view.cursor.saturating_sub(1),
695            KeyCode::PageDown => view.cursor = (view.cursor + PAGE).min(last_row),
696            KeyCode::PageUp => view.cursor = view.cursor.saturating_sub(PAGE),
697            KeyCode::Home | KeyCode::Char('g') => view.cursor = 0,
698            KeyCode::End | KeyCode::Char('G') => view.cursor = last_row,
699            KeyCode::Right | KeyCode::Char('l') => view.col = (view.col + 1).min(last_col),
700            KeyCode::Left | KeyCode::Char('h') => view.col = view.col.saturating_sub(1),
701            KeyCode::Char('s') | KeyCode::Enter => view.sort_by_selected(),
702            KeyCode::Char('w') => {
703                self.mode = Mode::Typing(Input::SaveValues, "values.tsv".into());
704            }
705            KeyCode::Esc | KeyCode::Char('v' | 'q') => self.mode = Mode::Browse,
706            _ => {}
707        }
708    }
709
710    fn render_table(&mut self, frame: &mut Frame, area: Rect) {
711        let arrow = if self.descending { " ▼" } else { " ▲" };
712        let head = |col: Option<usize>, name: &str| {
713            let sorted = match col {
714                Some(s) => !self.by_name && self.stat == s,
715                None => self.by_name,
716            };
717            let line = Line::from(if sorted {
718                format!("{name}{arrow}")
719            } else {
720                name.to_string()
721            });
722            let line = if col.is_some() {
723                line.right_aligned()
724            } else {
725                line
726            };
727            Cell::from(line).style(if sorted { HIGHLIGHT } else { DIM })
728        };
729        let mut header_cells = vec![head(None, "  name")];
730        header_cells.extend((0..4).map(|s| head(Some(s), STATS[s])));
731
732        // Build only the rows on screen (there can be millions), scrolling
733        // just enough to keep the selection in view.
734        let height = area.height.saturating_sub(3).max(1) as usize;
735        (self.cursor, self.offset) = scroll(self.cursor, self.offset, height);
736        let rows = self.view.iter().skip(self.offset).take(height).map(|&i| {
737            let name = Line::from(vec![
738                Span::styled(if self.marked[i] { "● " } else { "  " }, ACCENTED),
739                Span::raw(self.names[i].to_string()),
740            ]);
741            let mut cells = vec![Cell::from(name)];
742            cells.extend((0..4).map(|s| {
743                let cell =
744                    Cell::from(Line::from(fmt_stat(self.values[s][i], DECIMALS)).right_aligned());
745                if !self.by_name && self.stat == s {
746                    cell
747                } else {
748                    cell.style(DIM)
749                }
750            }));
751            Row::new(cells)
752        });
753
754        let mut title = format!(" {}", self.side.name());
755        let n_marked = self.marked.iter().filter(|&&m| m).count();
756        if n_marked > 0 {
757            title += &format!(" · {n_marked} marked");
758        }
759        if !self.filter.is_empty() {
760            title += &format!(
761                " · {} of {} match /{}/",
762                self.view.len(),
763                self.names.len(),
764                self.filter
765            );
766        }
767        title.push(' ');
768        let widths = [
769            Constraint::Fill(1),
770            Constraint::Length(9),
771            Constraint::Length(11),
772            Constraint::Length(9),
773            Constraint::Length(9),
774        ];
775        let table = Table::new(rows, widths)
776            .header(Row::new(header_cells))
777            .row_highlight_style(PLAIN.add_modifier(Modifier::REVERSED))
778            .highlight_symbol(Line::from("▶").style(ACCENTED))
779            .block(panel(title, false));
780        // The table sees only the visible slice, so select relative to it.
781        let mut state = TableState::default()
782            .with_selected((!self.view.is_empty()).then(|| self.cursor - self.offset));
783        frame.render_stateful_widget(table, area, &mut state);
784    }
785
786    fn render_hist(&mut self, frame: &mut Frame, area: Rect) {
787        let stat = STATS[self.stat];
788        let block = panel(format!(" {stat} "), false);
789        let inner = block.inner(area);
790        frame.render_widget(block, area);
791        let [summary, plot] =
792            Layout::vertical([Constraint::Length(2), Constraint::Min(5)]).areas(inner);
793
794        let n = self.sorted.len();
795        let fmt = |v: f32| fmt_stat(v, DECIMALS);
796        let dim = |t: &str| Span::styled(t.to_string(), DIM);
797        let mut lines = vec![Line::from(vec![
798            dim("min "),
799            Span::raw(fmt(self.sorted.first().copied().unwrap_or(0.0))),
800            dim("   median "),
801            Span::raw(fmt(median(&self.sorted))),
802            dim("   max "),
803            Span::raw(fmt(self.sorted.last().copied().unwrap_or(0.0))),
804        ])];
805        let selected = self.selected();
806        if let Some(i) = selected {
807            let v = self.values[self.stat][i];
808            let above = n - self.sorted.partition_point(|&x| x <= v);
809            lines.push(Line::from(vec![
810                Span::styled(format!("▲ {}", self.names[i]), HIGHLIGHT),
811                dim(&format!(" {stat} ")),
812                Span::raw(fmt(v)),
813                dim(&format!("   rank {} of {}", above + 1, n)),
814            ]));
815        }
816        frame.render_widget(Paragraph::new(lines), summary);
817
818        // As the figure's own panel where the terminal draws images: made
819        // again when the statistic, the cursor, the marks or the side change.
820        if let Some(picker) = self.picker.as_ref().filter(|_| self.images) {
821            let marks = self.marked.iter().filter(|&&m| m).count();
822            let now = (self.stat, selected, marks, self.side);
823            if self.plotted != Some(now) {
824                self.plot.invalidate();
825                self.plotted = Some(now);
826            }
827            let v = &self.values;
828            let data = StatData {
829                title: &self.title,
830                side: self.side.name(),
831                values: [&v[0], &v[1], &v[2], &v[3]],
832                marked: Some(&self.marked),
833                cursor: selected.map(|i| (i, &*self.names[i])),
834            };
835            let stat = self.stat;
836            let drawn = self.plot.render(frame, plot, picker, |w, h| {
837                let mut c = Canvas::new(w, h);
838                draw_stat(&data, stat, &mut c, (0.0, 0.0, w, h));
839                c.finish()
840            });
841            if drawn {
842                return;
843            }
844        }
845
846        let bins = self.hist.bins;
847        HistPlot {
848            bins,
849            kmin: self.hist.kmin,
850            counts: &self.hist.counts,
851            style: &|_| PLAIN,
852            subset: self.shown.as_deref(),
853            y_scale: self.y_scale,
854            y_max: None,
855            pointer: selected.map(|i| bins.key(self.values[self.stat][i] as f64)),
856            marks: Vec::new(),
857            x_label: None,
858            tick_every: None,
859        }
860        .render(frame.buffer_mut(), plot);
861    }
862
863    fn render_values(&mut self, frame: &mut Frame, area: Rect) {
864        let side = self.side;
865        let Some(view) = self.values_view.as_mut() else {
866            return;
867        };
868        let title = format!(
869            " values · {} {} × {} {} ",
870            view.labels.len(),
871            side.name(),
872            view.names.len(),
873            side.other().name()
874        );
875        let block = panel(title, true);
876        let inner = block.inner(area);
877        frame.render_widget(block, area);
878
879        // Keep the selected column and the cursor row on screen.
880        let name_width = 24u16.min(inner.width / 2);
881        let longest = view
882            .labels
883            .iter()
884            .map(|l| l.chars().count())
885            .max()
886            .unwrap_or(0);
887        let value_width = (longest as u16 + 3).clamp(VALUE_WIDTH.0, VALUE_WIDTH.1);
888        let fit =
889            ((inner.width.saturating_sub(name_width + 2)) / (value_width + 1)).max(1) as usize;
890        (view.col, view.hscroll) = scroll(view.col, view.hscroll, fit);
891        let height = inner.height.saturating_sub(1).max(1) as usize;
892        (view.cursor, view.offset) = scroll(view.cursor, view.offset, height);
893        let shown_cols: Vec<usize> = (view.hscroll..view.labels.len()).take(fit).collect();
894
895        let mut header_cells = vec![Cell::from(side.other().name()).style(DIM)];
896        header_cells.extend(shown_cols.iter().map(|&c| {
897            let arrow = match view.sort {
898                Some((s, true)) if s == c => " ▼",
899                Some((s, false)) if s == c => " ▲",
900                _ => "",
901            };
902            let label = format!("{}{arrow}", view.labels[c]);
903            let cell = Cell::from(Line::from(label).right_aligned());
904            if c == view.col {
905                cell.style(HIGHLIGHT)
906            } else {
907                cell.style(DIM)
908            }
909        }));
910        let rows = view.order.iter().skip(view.offset).take(height).map(|&r| {
911            let mut cells = vec![Cell::from(view.names[r].to_string())];
912            cells.extend(shown_cols.iter().map(|&c| {
913                let v = view.columns[c][r];
914                let cell = Cell::from(Line::from(fmt_stat(v, DECIMALS)).right_aligned());
915                if v == 0.0 {
916                    cell.style(DIM)
917                } else {
918                    cell
919                }
920            }));
921            Row::new(cells)
922        });
923        let mut widths = vec![Constraint::Length(name_width)];
924        widths.extend(shown_cols.iter().map(|_| Constraint::Length(value_width)));
925        let table = Table::new(rows, widths)
926            .header(Row::new(header_cells))
927            .row_highlight_style(PLAIN.add_modifier(Modifier::REVERSED));
928        let mut state = TableState::default()
929            .with_selected((!view.order.is_empty()).then(|| view.cursor - view.offset));
930        frame.render_stateful_widget(table, inner, &mut state);
931    }
932
933    fn help(&self) -> Line<'static> {
934        let mut line = match &self.mode {
935            Mode::Typing(Input::Filter, text) => {
936                input_line("filter /", text, &[("Enter", "keep"), ("Esc", "clear")])
937            }
938            Mode::Typing(Input::SaveFigure, text) => input_line(
939                "save the figure as (.pdf + .png): ",
940                text,
941                &[("Enter", "save"), ("Esc", "back")],
942            ),
943            Mode::Typing(_, text) => {
944                input_line("save to ", text, &[("Enter", "write"), ("Esc", "back")])
945            }
946            Mode::Values => help_line(&[
947                ("↑/↓", "move"),
948                ("←/→", "column"),
949                ("s", "sort"),
950                ("w", "save tsv"),
951                ("Esc", "back"),
952            ]),
953            Mode::Browse => {
954                let other = self.side.other().name();
955                let lazy = format!("{other} (computed on first use)");
956                let marks = self.marks_by_side();
957                let finish = match self.purpose {
958                    Purpose::Pick { verb } => {
959                        format!("{verb} {} marked", marks.clone().side(self.side).len())
960                    }
961                    Purpose::PickBoth { verb } => format!(
962                        "{verb} {} rows × {} columns (unmarked: all)",
963                        marks.rows.len(),
964                        marks.columns.len()
965                    ),
966                    Purpose::Explore => String::new(),
967                };
968                let mut keys = vec![
969                    ("↑/↓", "move"),
970                    ("1-4", "sort"),
971                    ("0", "name"),
972                    ("/", "filter"),
973                    ("Space", "mark"),
974                    ("a", "mark shown"),
975                ];
976                if self.reader.is_some() {
977                    keys.push(("v", "values"));
978                }
979                keys.push(("w", "save names"));
980                keys.push(("s", "save figure"));
981                if self.picker.is_some() {
982                    keys.push(("i", "image/text"));
983                }
984                keys.push(("x/y", "scale"));
985                match self.other {
986                    Other::Ready(..) => keys.push(("Tab", other)),
987                    Other::Lazy(_) => keys.push(("Tab", &lazy)),
988                    Other::Unavailable => {}
989                }
990                if self.purpose != Purpose::Explore {
991                    keys.push(("Enter", &finish));
992                }
993                keys.push(("q", "quit"));
994                help_line(&keys)
995            }
996        };
997        if let Some(status) = &self.status {
998            line.spans.push(Span::styled(status.clone(), ACCENTED));
999        }
1000        line
1001    }
1002}
1003
1004impl Screen for StatExplorer<'_> {
1005    fn done(&self) -> bool {
1006        self.quit
1007    }
1008
1009    fn interrupt(&mut self) {
1010        self.picked = None;
1011        self.quit = true;
1012    }
1013
1014    fn status(&self) -> Option<String> {
1015        let doing = match self.purpose {
1016            Purpose::Explore => "exploring",
1017            Purpose::Pick { verb } | Purpose::PickBoth { verb } => verb,
1018        };
1019        Some(format!("{doing} {} of {}", self.side.name(), self.title))
1020    }
1021
1022    fn pending_work(&self) -> Option<String> {
1023        Some(match self.pending.as_ref()? {
1024            Work::OtherSide => format!("computing {} statistics ...", self.side.other().name()),
1025            Work::Values(entries) => {
1026                format!("reading {} {} ...", entries.len(), self.side.name())
1027            }
1028        })
1029    }
1030
1031    fn do_work(&mut self) {
1032        match self.pending.take() {
1033            Some(Work::OtherSide) => {
1034                let Other::Lazy(loader) = &mut self.other else {
1035                    return;
1036                };
1037                match loader(self.side.other()) {
1038                    Ok(data) => {
1039                        let marks = vec![false; data.names.len()];
1040                        self.other = Other::Ready(data, marks);
1041                        self.switch();
1042                    }
1043                    Err(e) => {
1044                        self.status = Some(format!(
1045                            "could not compute {}: {e}",
1046                            self.side.other().name()
1047                        ))
1048                    }
1049                }
1050            }
1051            Some(Work::Values(entries)) => {
1052                let Some(reader) = self.reader.as_mut() else {
1053                    return;
1054                };
1055                match reader(self.side, &entries) {
1056                    Ok(values) => {
1057                        let mut view = ValuesView {
1058                            labels: entries.iter().map(|&i| self.names[i].clone()).collect(),
1059                            order: (0..values.names.len()).collect(),
1060                            names: values.names,
1061                            columns: values.columns,
1062                            sort: None,
1063                            col: 0,
1064                            hscroll: 0,
1065                            cursor: 0,
1066                            offset: 0,
1067                        };
1068                        view.sort_by_selected();
1069                        self.values_view = Some(view);
1070                        self.mode = Mode::Values;
1071                    }
1072                    Err(e) => self.status = Some(format!("could not read values: {e}")),
1073                }
1074            }
1075            None => {}
1076        }
1077    }
1078
1079    fn handle_key(&mut self, key: KeyEvent) {
1080        self.status = None;
1081        match std::mem::replace(&mut self.mode, Mode::Browse) {
1082            Mode::Typing(input, text) => self.handle_typing(input, text, key),
1083            Mode::Values => {
1084                self.mode = Mode::Values;
1085                self.handle_values(key);
1086            }
1087            Mode::Browse => self.handle_browse(key),
1088        }
1089    }
1090
1091    fn render(&mut self, frame: &mut Frame) {
1092        self.refresh_picker();
1093        let [top, body, footer] = Layout::vertical([
1094            Constraint::Length(1),
1095            Constraint::Fill(1),
1096            Constraint::Length(1),
1097        ])
1098        .areas(frame.area());
1099
1100        let extra = format!(
1101            "{} {} · x {} · y {}",
1102            self.names.len(),
1103            self.side.name(),
1104            self.x_scale.name(),
1105            self.y_scale.name()
1106        );
1107        let badge = self.purpose.verb().unwrap_or("stat");
1108        title_bar(
1109            frame,
1110            top,
1111            header(badge, &self.title, &extra),
1112            &self.session_keys,
1113        );
1114
1115        let in_values = matches!(self.mode, Mode::Values | Mode::Typing(Input::SaveValues, _));
1116        if in_values {
1117            self.render_values(frame, body);
1118        } else {
1119            // Side by side when there is room, else the table above the plot.
1120            let [left, right] = if body.width >= 110 {
1121                Layout::horizontal([Constraint::Percentage(48), Constraint::Percentage(52)])
1122                    .areas(body)
1123            } else {
1124                Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)])
1125                    .areas(body)
1126            };
1127            self.render_table(frame, left);
1128            self.render_hist(frame, right);
1129        }
1130        frame.render_widget(self.help(), footer);
1131    }
1132}
1133
1134/// Keep `cursor` within the `height` rows shown from `offset`: returns the
1135/// cursor and the offset scrolled just enough.
1136fn scroll(cursor: usize, offset: usize, height: usize) -> (usize, usize) {
1137    let offset = if cursor < offset {
1138        cursor
1139    } else if cursor >= offset + height {
1140        cursor + 1 - height
1141    } else {
1142        offset
1143    };
1144    (cursor, offset)
1145}
1146
1147fn sorted_copy(values: &[f32]) -> Vec<f32> {
1148    let mut sorted = values.to_vec();
1149    sorted.sort_unstable_by(f32::total_cmp);
1150    sorted
1151}
1152
1153/// Run the explorer full screen until the user quits. When picking, returns
1154/// the marked entries if the user finished with Enter.
1155pub fn explore(mut explorer: StatExplorer<'_>) -> anyhow::Result<Option<Picked>> {
1156    run_screen(&mut explorer)?;
1157    Ok(explorer.picked)
1158}
1159
1160/// [`explore`] on the screen of `tui`, in its session: a `kill` quits it
1161/// as `q` does, handing nothing back.
1162pub fn explore_in(
1163    tui: &mut Tui<'_>,
1164    mut explorer: StatExplorer<'_>,
1165) -> anyhow::Result<Option<Picked>> {
1166    tui.run(&mut explorer)?;
1167    Ok(explorer.picked)
1168}
1169
1170#[cfg(test)]
1171#[path = "tests/stat_tui.rs"]
1172mod tests;