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