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, Binned, HistPlot, Scale, Screen,
25    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}
248
249impl<'a> StatExplorer<'a> {
250    /// Show `data` for `side`. `other`, if any, computes the other side on
251    /// the first Tab (exploring only); `reader`, if any, backs the values
252    /// view.
253    pub fn new(
254        title: &str,
255        side: Side,
256        data: Dataset,
257        other: Option<Loader<'a>>,
258        reader: Option<ValuesReader<'a>>,
259        purpose: Purpose,
260    ) -> Self {
261        let sorted = sorted_copy(&data.values[0]);
262        let other = match (purpose, other) {
263            (Purpose::Explore | Purpose::PickBoth { .. }, Some(loader)) => Other::Lazy(loader),
264            _ => Other::Unavailable,
265        };
266        let mut explorer = Self {
267            title: title.to_string(),
268            purpose,
269            side,
270            hist: Binned::new(&sorted, Scale::Log),
271            sorted,
272            marked: vec![false; data.names.len()],
273            names: data.names,
274            values: data.values,
275            other,
276            reader,
277            pending: None,
278            status: None,
279            stat: 0,
280            by_name: false,
281            descending: true,
282            order: Vec::new(),
283            filter: String::new(),
284            view: Vec::new(),
285            cursor: 0,
286            offset: 0,
287            x_scale: Scale::Log,
288            y_scale: Scale::Log,
289            shown: None,
290            values_view: None,
291            mode: Mode::Browse,
292            quit: false,
293            picked: None,
294        };
295        explorer.reorder();
296        explorer.refilter(None);
297        explorer
298    }
299
300    fn selected(&self) -> Option<usize> {
301        self.view.get(self.cursor).copied()
302    }
303
304    /// Marked entries in their original order.
305    fn marked_entries(&self) -> Vec<usize> {
306        (0..self.names.len()).filter(|&i| self.marked[i]).collect()
307    }
308
309    /// The filter as a case-insensitive regex; text that is not a valid
310    /// regex matches literally.
311    fn filter_regex(&self) -> Option<Regex> {
312        if self.filter.is_empty() {
313            return None;
314        }
315        let build = |p: &str| RegexBuilder::new(p).case_insensitive(true).build();
316        build(&self.filter)
317            .or_else(|_| build(&regex::escape(&self.filter)))
318            .ok()
319    }
320
321    /// Sort every entry by the current key and direction.
322    fn reorder(&mut self) {
323        let (names, vals) = (&self.names, &self.values[self.stat]);
324        let mut order: Vec<usize> = (0..names.len()).collect();
325        if self.by_name {
326            order.sort_unstable_by(|&a, &b| names[a].cmp(&names[b]));
327        } else {
328            order.sort_unstable_by(|&a, &b| {
329                vals[a].total_cmp(&vals[b]).then(names[a].cmp(&names[b]))
330            });
331        }
332        if self.descending {
333            order.reverse();
334        }
335        self.order = order;
336    }
337
338    /// Pick the entries passing the filter from the sorted order, keeping
339    /// `keep` selected when it is still shown.
340    fn refilter(&mut self, keep: Option<usize>) {
341        let re = self.filter_regex();
342        self.view = match &re {
343            None => self.order.clone(),
344            Some(re) => self
345                .order
346                .iter()
347                .copied()
348                .filter(|&i| re.is_match(&self.names[i]))
349                .collect(),
350        };
351        self.cursor = keep
352            .and_then(|k| self.view.iter().position(|&i| i == k))
353            .unwrap_or(0);
354        self.rebin_shown();
355    }
356
357    /// Sorted values and histogram for a new statistic or side.
358    fn restat(&mut self) {
359        self.sorted = sorted_copy(&self.values[self.stat]);
360        self.hist = Binned::new(&self.sorted, self.x_scale);
361    }
362
363    fn rebin_shown(&mut self) {
364        let vals = &self.values[self.stat];
365        self.shown = (self.view.len() < self.names.len())
366            .then(|| self.hist.count(self.view.iter().map(|&i| vals[i])));
367    }
368
369    /// Reverse the order in place, keeping the same entry selected.
370    fn flip(&mut self) {
371        self.descending = !self.descending;
372        self.order.reverse();
373        self.view.reverse();
374        self.cursor = self.view.len().saturating_sub(1 + self.cursor);
375    }
376
377    /// Sort and plot statistic `s`; pressing the current one flips the order.
378    fn choose_stat(&mut self, s: usize) {
379        if !self.by_name && self.stat == s {
380            return self.flip();
381        }
382        let keep = self.selected();
383        self.by_name = false;
384        self.descending = true;
385        if self.stat != s {
386            self.stat = s;
387            self.restat();
388        }
389        self.reorder();
390        self.refilter(keep);
391    }
392
393    fn sort_by_name(&mut self) {
394        if self.by_name {
395            return self.flip();
396        }
397        let keep = self.selected();
398        self.by_name = true;
399        self.descending = false;
400        self.reorder();
401        self.refilter(keep);
402    }
403
404    fn set_filter(&mut self, filter: String) {
405        let keep = self.selected();
406        self.filter = filter;
407        self.refilter(keep);
408    }
409
410    /// Mark or unmark the selected entry, then move down.
411    fn toggle_mark(&mut self) {
412        if let Some(i) = self.selected() {
413            self.marked[i] = !self.marked[i];
414            self.step(1);
415        }
416    }
417
418    /// Mark every shown entry, or unmark them if all are marked.
419    fn toggle_shown(&mut self) {
420        let mark = !self.view.iter().all(|&i| self.marked[i]);
421        for &i in &self.view {
422            self.marked[i] = mark;
423        }
424    }
425
426    /// Show the other side now if it is computed, else ask for it.
427    fn request_switch(&mut self) {
428        match self.other {
429            Other::Ready(..) => self.switch(),
430            Other::Lazy(_) => self.pending = Some(Work::OtherSide),
431            Other::Unavailable => {}
432        }
433    }
434
435    /// Swap in the other side, keeping the sort, filter, and scales.
436    fn switch(&mut self) {
437        let Other::Ready(data, marked) = std::mem::replace(&mut self.other, Other::Unavailable)
438        else {
439            return;
440        };
441        let names = std::mem::replace(&mut self.names, data.names);
442        let values = std::mem::replace(&mut self.values, data.values);
443        let marks = std::mem::replace(&mut self.marked, marked);
444        self.other = Other::Ready(Dataset { names, values }, marks);
445        self.side = self.side.other();
446        self.status = None;
447        self.offset = 0;
448        self.restat();
449        self.reorder();
450        self.refilter(None);
451    }
452
453    /// Ask for the values view of the marked entries (or the selected one).
454    fn request_values(&mut self) {
455        if self.reader.is_none() {
456            self.status = Some("no values view here".into());
457            return;
458        }
459        let entries = match self.marked_entries() {
460            m if !m.is_empty() => m,
461            _ => self.selected().into_iter().collect(),
462        };
463        if !entries.is_empty() {
464            self.pending = Some(Work::Values(entries));
465        }
466    }
467
468    fn save_names(&mut self, path: &str) {
469        let names: Vec<Box<str>> = self
470            .marked_entries()
471            .into_iter()
472            .map(|i| self.names[i].clone())
473            .collect();
474        self.status = Some(if names.is_empty() {
475            "mark entries first (Space)".into()
476        } else {
477            match write_lines(&names, path) {
478                Ok(()) => format!("wrote {} names to {path}", names.len()),
479                Err(e) => format!("could not write {path}: {e}"),
480            }
481        });
482    }
483
484    fn save_values(&mut self, path: &str) {
485        let Some(view) = &self.values_view else {
486            return;
487        };
488        let lines = view.lines(self.side);
489        self.status = Some(match write_lines(&lines, path) {
490            Ok(()) => format!("wrote {} lines to {path}", lines.len()),
491            Err(e) => format!("could not write {path}: {e}"),
492        });
493    }
494
495    fn step(&mut self, delta: isize) {
496        let last = self.view.len().saturating_sub(1) as isize;
497        self.cursor = (self.cursor as isize + delta).clamp(0, last) as usize;
498    }
499
500    /// Marked entries of both sides (the side not on screen only once it
501    /// has been computed).
502    fn marks_by_side(&self) -> Picked {
503        let here = self.marked_entries();
504        let there = match &self.other {
505            Other::Ready(_, marks) => (0..marks.len()).filter(|&i| marks[i]).collect(),
506            _ => Vec::new(),
507        };
508        match self.side {
509            Side::Rows => Picked {
510                rows: here,
511                columns: there,
512            },
513            Side::Columns => Picked {
514                rows: there,
515                columns: here,
516            },
517        }
518    }
519
520    fn finish_pick(&mut self) {
521        let picked = self.marks_by_side();
522        if picked.rows.is_empty() && picked.columns.is_empty() {
523            self.status = Some("mark entries first (Space)".into());
524        } else {
525            self.picked = Some(picked);
526            self.quit = true;
527        }
528    }
529
530    fn handle_typing(&mut self, input: Input, mut text: String, key: KeyEvent) {
531        match key.code {
532            KeyCode::Enter => {
533                match input {
534                    Input::Filter => {}
535                    Input::SaveNames => self.save_names(&text),
536                    Input::SaveValues => self.save_values(&text),
537                }
538                self.mode = self.resting_mode(&input);
539                return;
540            }
541            KeyCode::Esc => {
542                if let Input::Filter = input {
543                    self.set_filter(String::new());
544                }
545                self.mode = self.resting_mode(&input);
546                return;
547            }
548            KeyCode::Backspace => {
549                text.pop();
550            }
551            KeyCode::Char(c) => text.push(c),
552            _ => {}
553        }
554        if let Input::Filter = input {
555            self.set_filter(text.clone());
556        }
557        self.mode = Mode::Typing(input, text);
558    }
559
560    /// The mode to return to after typing.
561    fn resting_mode(&self, input: &Input) -> Mode {
562        match input {
563            Input::SaveValues => Mode::Values,
564            _ => Mode::Browse,
565        }
566    }
567
568    fn handle_browse(&mut self, key: KeyEvent) {
569        match key.code {
570            KeyCode::Down | KeyCode::Char('j') => self.step(1),
571            KeyCode::Up | KeyCode::Char('k') => self.step(-1),
572            KeyCode::PageDown => self.step(PAGE as isize),
573            KeyCode::PageUp => self.step(-(PAGE as isize)),
574            KeyCode::Home | KeyCode::Char('g') => self.cursor = 0,
575            KeyCode::End | KeyCode::Char('G') => self.cursor = self.view.len().saturating_sub(1),
576            KeyCode::Char(c @ '1'..='4') => self.choose_stat(c as usize - '1' as usize),
577            KeyCode::Char('0' | 'n') => self.sort_by_name(),
578            KeyCode::Char('/') => self.mode = Mode::Typing(Input::Filter, self.filter.clone()),
579            KeyCode::Char(' ') => self.toggle_mark(),
580            KeyCode::Char('a') => self.toggle_shown(),
581            KeyCode::Char('u') => self.marked.fill(false),
582            KeyCode::Char('v') => self.request_values(),
583            KeyCode::Char('w') => {
584                let path = format!("{}.txt", self.side.name());
585                self.mode = Mode::Typing(Input::SaveNames, path);
586            }
587            KeyCode::Tab | KeyCode::BackTab => self.request_switch(),
588            KeyCode::Char('x') => {
589                self.x_scale = self.x_scale.next();
590                self.hist = Binned::new(&self.sorted, self.x_scale);
591                self.rebin_shown();
592            }
593            KeyCode::Char('y') => self.y_scale = self.y_scale.next(),
594            KeyCode::Enter => {
595                if self.purpose != Purpose::Explore {
596                    self.finish_pick();
597                }
598            }
599            KeyCode::Esc if !self.filter.is_empty() => self.set_filter(String::new()),
600            KeyCode::Char('q') | KeyCode::Esc => self.quit = true,
601            _ => {}
602        }
603    }
604
605    fn handle_values(&mut self, key: KeyEvent) {
606        let Some(view) = self.values_view.as_mut() else {
607            self.mode = Mode::Browse;
608            return;
609        };
610        let last_row = view.order.len().saturating_sub(1);
611        let last_col = view.labels.len().saturating_sub(1);
612        match key.code {
613            KeyCode::Down | KeyCode::Char('j') => view.cursor = (view.cursor + 1).min(last_row),
614            KeyCode::Up | KeyCode::Char('k') => view.cursor = view.cursor.saturating_sub(1),
615            KeyCode::PageDown => view.cursor = (view.cursor + PAGE).min(last_row),
616            KeyCode::PageUp => view.cursor = view.cursor.saturating_sub(PAGE),
617            KeyCode::Home | KeyCode::Char('g') => view.cursor = 0,
618            KeyCode::End | KeyCode::Char('G') => view.cursor = last_row,
619            KeyCode::Right | KeyCode::Char('l') => view.col = (view.col + 1).min(last_col),
620            KeyCode::Left | KeyCode::Char('h') => view.col = view.col.saturating_sub(1),
621            KeyCode::Char('s') | KeyCode::Enter => view.sort_by_selected(),
622            KeyCode::Char('w') => {
623                self.mode = Mode::Typing(Input::SaveValues, "values.tsv".into());
624            }
625            KeyCode::Esc | KeyCode::Char('v' | 'q') => self.mode = Mode::Browse,
626            _ => {}
627        }
628    }
629
630    fn render_table(&mut self, frame: &mut Frame, area: Rect) {
631        let arrow = if self.descending { " ▼" } else { " ▲" };
632        let head = |col: Option<usize>, name: &str| {
633            let sorted = match col {
634                Some(s) => !self.by_name && self.stat == s,
635                None => self.by_name,
636            };
637            let line = Line::from(if sorted {
638                format!("{name}{arrow}")
639            } else {
640                name.to_string()
641            });
642            let line = if col.is_some() {
643                line.right_aligned()
644            } else {
645                line
646            };
647            Cell::from(line).style(if sorted { HIGHLIGHT } else { DIM })
648        };
649        let mut header_cells = vec![head(None, "  name")];
650        header_cells.extend((0..4).map(|s| head(Some(s), STATS[s])));
651
652        // Build only the rows on screen (there can be millions), scrolling
653        // just enough to keep the selection in view.
654        let height = area.height.saturating_sub(3).max(1) as usize;
655        (self.cursor, self.offset) = scroll(self.cursor, self.offset, height);
656        let rows = self.view.iter().skip(self.offset).take(height).map(|&i| {
657            let name = Line::from(vec![
658                Span::styled(if self.marked[i] { "● " } else { "  " }, ACCENTED),
659                Span::raw(self.names[i].to_string()),
660            ]);
661            let mut cells = vec![Cell::from(name)];
662            cells.extend((0..4).map(|s| {
663                let cell =
664                    Cell::from(Line::from(fmt_stat(self.values[s][i], DECIMALS)).right_aligned());
665                if !self.by_name && self.stat == s {
666                    cell
667                } else {
668                    cell.style(DIM)
669                }
670            }));
671            Row::new(cells)
672        });
673
674        let mut title = format!(" {}", self.side.name());
675        let n_marked = self.marked.iter().filter(|&&m| m).count();
676        if n_marked > 0 {
677            title += &format!(" · {n_marked} marked");
678        }
679        if !self.filter.is_empty() {
680            title += &format!(
681                " · {} of {} match /{}/",
682                self.view.len(),
683                self.names.len(),
684                self.filter
685            );
686        }
687        title.push(' ');
688        let widths = [
689            Constraint::Fill(1),
690            Constraint::Length(9),
691            Constraint::Length(11),
692            Constraint::Length(9),
693            Constraint::Length(9),
694        ];
695        let table = Table::new(rows, widths)
696            .header(Row::new(header_cells))
697            .row_highlight_style(PLAIN.add_modifier(Modifier::REVERSED))
698            .highlight_symbol(Line::from("▶").style(ACCENTED))
699            .block(panel(title, false));
700        // The table sees only the visible slice, so select relative to it.
701        let mut state = TableState::default()
702            .with_selected((!self.view.is_empty()).then(|| self.cursor - self.offset));
703        frame.render_stateful_widget(table, area, &mut state);
704    }
705
706    fn render_hist(&self, frame: &mut Frame, area: Rect) {
707        let stat = STATS[self.stat];
708        let block = panel(format!(" {stat} "), false);
709        let inner = block.inner(area);
710        frame.render_widget(block, area);
711        let [summary, plot] =
712            Layout::vertical([Constraint::Length(2), Constraint::Min(5)]).areas(inner);
713
714        let n = self.sorted.len();
715        let fmt = |v: f32| fmt_stat(v, DECIMALS);
716        let dim = |t: &str| Span::styled(t.to_string(), DIM);
717        let mut lines = vec![Line::from(vec![
718            dim("min "),
719            Span::raw(fmt(self.sorted.first().copied().unwrap_or(0.0))),
720            dim("   median "),
721            Span::raw(fmt(median(&self.sorted))),
722            dim("   max "),
723            Span::raw(fmt(self.sorted.last().copied().unwrap_or(0.0))),
724        ])];
725        let selected = self.selected();
726        if let Some(i) = selected {
727            let v = self.values[self.stat][i];
728            let above = n - self.sorted.partition_point(|&x| x <= v);
729            lines.push(Line::from(vec![
730                Span::styled(format!("▲ {}", self.names[i]), HIGHLIGHT),
731                dim(&format!(" {stat} ")),
732                Span::raw(fmt(v)),
733                dim(&format!("   rank {} of {}", above + 1, n)),
734            ]));
735        }
736        frame.render_widget(Paragraph::new(lines), summary);
737
738        let bins = self.hist.bins;
739        HistPlot {
740            bins,
741            kmin: self.hist.kmin,
742            counts: &self.hist.counts,
743            style: &|_| PLAIN,
744            subset: self.shown.as_deref(),
745            y_scale: self.y_scale,
746            pointer: selected.map(|i| bins.key(self.values[self.stat][i] as f64)),
747            marks: Vec::new(),
748        }
749        .render(frame.buffer_mut(), plot);
750    }
751
752    fn render_values(&mut self, frame: &mut Frame, area: Rect) {
753        let side = self.side;
754        let Some(view) = self.values_view.as_mut() else {
755            return;
756        };
757        let title = format!(
758            " values · {} {} × {} {} ",
759            view.labels.len(),
760            side.name(),
761            view.names.len(),
762            side.other().name()
763        );
764        let block = panel(title, true);
765        let inner = block.inner(area);
766        frame.render_widget(block, area);
767
768        // Keep the selected column and the cursor row on screen.
769        let name_width = 24u16.min(inner.width / 2);
770        let longest = view
771            .labels
772            .iter()
773            .map(|l| l.chars().count())
774            .max()
775            .unwrap_or(0);
776        let value_width = (longest as u16 + 3).clamp(VALUE_WIDTH.0, VALUE_WIDTH.1);
777        let fit =
778            ((inner.width.saturating_sub(name_width + 2)) / (value_width + 1)).max(1) as usize;
779        (view.col, view.hscroll) = scroll(view.col, view.hscroll, fit);
780        let height = inner.height.saturating_sub(1).max(1) as usize;
781        (view.cursor, view.offset) = scroll(view.cursor, view.offset, height);
782        let shown_cols: Vec<usize> = (view.hscroll..view.labels.len()).take(fit).collect();
783
784        let mut header_cells = vec![Cell::from(side.other().name()).style(DIM)];
785        header_cells.extend(shown_cols.iter().map(|&c| {
786            let arrow = match view.sort {
787                Some((s, true)) if s == c => " ▼",
788                Some((s, false)) if s == c => " ▲",
789                _ => "",
790            };
791            let label = format!("{}{arrow}", view.labels[c]);
792            let cell = Cell::from(Line::from(label).right_aligned());
793            if c == view.col {
794                cell.style(HIGHLIGHT)
795            } else {
796                cell.style(DIM)
797            }
798        }));
799        let rows = view.order.iter().skip(view.offset).take(height).map(|&r| {
800            let mut cells = vec![Cell::from(view.names[r].to_string())];
801            cells.extend(shown_cols.iter().map(|&c| {
802                let v = view.columns[c][r];
803                let cell = Cell::from(Line::from(fmt_stat(v, DECIMALS)).right_aligned());
804                if v == 0.0 {
805                    cell.style(DIM)
806                } else {
807                    cell
808                }
809            }));
810            Row::new(cells)
811        });
812        let mut widths = vec![Constraint::Length(name_width)];
813        widths.extend(shown_cols.iter().map(|_| Constraint::Length(value_width)));
814        let table = Table::new(rows, widths)
815            .header(Row::new(header_cells))
816            .row_highlight_style(PLAIN.add_modifier(Modifier::REVERSED));
817        let mut state = TableState::default()
818            .with_selected((!view.order.is_empty()).then(|| view.cursor - view.offset));
819        frame.render_stateful_widget(table, inner, &mut state);
820    }
821
822    fn help(&self) -> Line<'static> {
823        let mut line = match &self.mode {
824            Mode::Typing(Input::Filter, text) => {
825                input_line("filter /", text, &[("Enter", "keep"), ("Esc", "clear")])
826            }
827            Mode::Typing(_, text) => {
828                input_line("save to ", text, &[("Enter", "write"), ("Esc", "back")])
829            }
830            Mode::Values => help_line(&[
831                ("↑/↓", "move"),
832                ("←/→", "column"),
833                ("s", "sort"),
834                ("w", "save tsv"),
835                ("Esc", "back"),
836            ]),
837            Mode::Browse => {
838                let other = self.side.other().name();
839                let lazy = format!("{other} (computed on first use)");
840                let marks = self.marks_by_side();
841                let finish = match self.purpose {
842                    Purpose::Pick { verb } => {
843                        format!("{verb} {} marked", marks.clone().side(self.side).len())
844                    }
845                    Purpose::PickBoth { verb } => format!(
846                        "{verb} {} rows × {} columns (unmarked: all)",
847                        marks.rows.len(),
848                        marks.columns.len()
849                    ),
850                    Purpose::Explore => String::new(),
851                };
852                let mut keys = vec![
853                    ("↑/↓", "move"),
854                    ("1-4", "sort"),
855                    ("0", "name"),
856                    ("/", "filter"),
857                    ("Space", "mark"),
858                    ("a", "mark shown"),
859                ];
860                if self.reader.is_some() {
861                    keys.push(("v", "values"));
862                }
863                keys.push(("w", "save names"));
864                keys.push(("x/y", "scale"));
865                match self.other {
866                    Other::Ready(..) => keys.push(("Tab", other)),
867                    Other::Lazy(_) => keys.push(("Tab", &lazy)),
868                    Other::Unavailable => {}
869                }
870                if self.purpose != Purpose::Explore {
871                    keys.push(("Enter", &finish));
872                }
873                keys.push(("q", "quit"));
874                help_line(&keys)
875            }
876        };
877        if let Some(status) = &self.status {
878            line.spans.push(Span::styled(status.clone(), ACCENTED));
879        }
880        line
881    }
882}
883
884impl Screen for StatExplorer<'_> {
885    fn done(&self) -> bool {
886        self.quit
887    }
888
889    fn interrupt(&mut self) {
890        self.picked = None;
891        self.quit = true;
892    }
893
894    fn pending_work(&self) -> Option<String> {
895        Some(match self.pending.as_ref()? {
896            Work::OtherSide => format!("computing {} statistics ...", self.side.other().name()),
897            Work::Values(entries) => {
898                format!("reading {} {} ...", entries.len(), self.side.name())
899            }
900        })
901    }
902
903    fn do_work(&mut self) {
904        match self.pending.take() {
905            Some(Work::OtherSide) => {
906                let Other::Lazy(loader) = &mut self.other else {
907                    return;
908                };
909                match loader(self.side.other()) {
910                    Ok(data) => {
911                        let marks = vec![false; data.names.len()];
912                        self.other = Other::Ready(data, marks);
913                        self.switch();
914                    }
915                    Err(e) => {
916                        self.status = Some(format!(
917                            "could not compute {}: {e}",
918                            self.side.other().name()
919                        ))
920                    }
921                }
922            }
923            Some(Work::Values(entries)) => {
924                let Some(reader) = self.reader.as_mut() else {
925                    return;
926                };
927                match reader(self.side, &entries) {
928                    Ok(values) => {
929                        let mut view = ValuesView {
930                            labels: entries.iter().map(|&i| self.names[i].clone()).collect(),
931                            order: (0..values.names.len()).collect(),
932                            names: values.names,
933                            columns: values.columns,
934                            sort: None,
935                            col: 0,
936                            hscroll: 0,
937                            cursor: 0,
938                            offset: 0,
939                        };
940                        view.sort_by_selected();
941                        self.values_view = Some(view);
942                        self.mode = Mode::Values;
943                    }
944                    Err(e) => self.status = Some(format!("could not read values: {e}")),
945                }
946            }
947            None => {}
948        }
949    }
950
951    fn handle_key(&mut self, key: KeyEvent) {
952        self.status = None;
953        match std::mem::replace(&mut self.mode, Mode::Browse) {
954            Mode::Typing(input, text) => self.handle_typing(input, text, key),
955            Mode::Values => {
956                self.mode = Mode::Values;
957                self.handle_values(key);
958            }
959            Mode::Browse => self.handle_browse(key),
960        }
961    }
962
963    fn render(&mut self, frame: &mut Frame) {
964        let [top, body, footer] = Layout::vertical([
965            Constraint::Length(1),
966            Constraint::Fill(1),
967            Constraint::Length(1),
968        ])
969        .areas(frame.area());
970
971        let extra = format!(
972            "{} {} · x {} · y {}",
973            self.names.len(),
974            self.side.name(),
975            self.x_scale.name(),
976            self.y_scale.name()
977        );
978        let badge = self.purpose.verb().unwrap_or("stat");
979        frame.render_widget(header(badge, &self.title, &extra), top);
980
981        let in_values = matches!(self.mode, Mode::Values | Mode::Typing(Input::SaveValues, _));
982        if in_values {
983            self.render_values(frame, body);
984        } else {
985            // Side by side when there is room, else the table above the plot.
986            let [left, right] = if body.width >= 110 {
987                Layout::horizontal([Constraint::Percentage(48), Constraint::Percentage(52)])
988                    .areas(body)
989            } else {
990                Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)])
991                    .areas(body)
992            };
993            self.render_table(frame, left);
994            self.render_hist(frame, right);
995        }
996        frame.render_widget(self.help(), footer);
997    }
998}
999
1000/// Keep `cursor` within the `height` rows shown from `offset`: returns the
1001/// cursor and the offset scrolled just enough.
1002fn scroll(cursor: usize, offset: usize, height: usize) -> (usize, usize) {
1003    let offset = if cursor < offset {
1004        cursor
1005    } else if cursor >= offset + height {
1006        cursor + 1 - height
1007    } else {
1008        offset
1009    };
1010    (cursor, offset)
1011}
1012
1013fn sorted_copy(values: &[f32]) -> Vec<f32> {
1014    let mut sorted = values.to_vec();
1015    sorted.sort_unstable_by(f32::total_cmp);
1016    sorted
1017}
1018
1019/// Run the explorer full screen until the user quits. When picking, returns
1020/// the marked entries if the user finished with Enter.
1021pub fn explore(mut explorer: StatExplorer<'_>) -> anyhow::Result<Option<Picked>> {
1022    run_screen(&mut explorer)?;
1023    Ok(explorer.picked)
1024}
1025
1026#[cfg(test)]
1027#[path = "tests/stat_tui.rs"]
1028mod tests;