1use 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
34const STATS: [&str; 4] = ["nnz", "sum", "mean", "sd"];
36
37const PAGE: usize = 20;
39
40const DECIMALS: usize = 3;
42
43const VALUE_WIDTH: (u16, u16) = (9, 24);
46
47#[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
70pub struct Dataset {
72 pub names: Vec<Box<str>>,
73 pub values: [Vec<f32>; 4],
74}
75
76pub struct Values {
78 pub names: Vec<Box<str>>,
80 pub columns: Vec<Vec<f32>>,
82}
83
84pub type Loader<'a> = Box<dyn FnMut(Side) -> anyhow::Result<Dataset> + 'a>;
86
87pub type ValuesReader<'a> = Box<dyn FnMut(Side, &[usize]) -> anyhow::Result<Values> + 'a>;
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Purpose {
93 Explore,
95 Pick { verb: &'static str },
98 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#[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
129enum Other<'a> {
131 Unavailable,
133 Lazy(Loader<'a>),
135 Ready(Dataset, Vec<bool>),
137}
138
139enum Work {
141 OtherSide,
142 Values(Vec<usize>),
143}
144
145enum Input {
147 Filter,
148 SaveNames,
149 SaveValues,
150 SaveFigure,
151}
152
153enum Mode {
154 Browse,
155 Typing(Input, String),
156 Values,
157}
158
159struct ValuesView {
161 labels: Vec<Box<str>>,
163 names: Vec<Box<str>>,
165 columns: Vec<Vec<f32>>,
166 order: Vec<usize>,
168 sort: Option<(usize, bool)>,
170 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 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
214pub struct StatExplorer<'a> {
216 title: String,
217 purpose: Purpose,
218 side: Side,
220 names: Vec<Box<str>>,
221 values: [Vec<f32>; 4],
223 marked: Vec<bool>,
224 other: Other<'a>,
225 reader: Option<ValuesReader<'a>>,
226 pending: Option<Work>,
227 status: Option<String>,
229 stat: usize,
231 by_name: bool,
232 descending: bool,
233 order: Vec<usize>,
235 filter: String,
236 view: Vec<usize>,
238 cursor: usize,
240 offset: usize,
241 sorted: Vec<f32>,
243 x_scale: Scale,
244 y_scale: Scale,
245 hist: Binned,
246 shown: Option<Vec<usize>>,
248 values_view: Option<ValuesView>,
249 mode: Mode,
250 quit: bool,
251 picked: Option<Picked>,
253 session_keys: Vec<(String, String)>,
255 picker: Option<Picker>,
258 picked_at: Option<u64>,
259 images: bool,
260 plot: PlotImage,
262 plotted: Option<(usize, Option<usize>, usize, Side)>,
263}
264
265impl<'a> StatExplorer<'a> {
266 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 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 fn marked_entries(&self) -> Vec<usize> {
337 (0..self.names.len()).filter(|&i| self.marked[i]).collect()
338 }
339
340 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(®ex::escape(&self.filter)))
349 .ok()
350 }
351
352 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1134fn 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
1153pub fn explore(mut explorer: StatExplorer<'_>) -> anyhow::Result<Option<Picked>> {
1156 run_screen(&mut explorer)?;
1157 Ok(explorer.picked)
1158}
1159
1160pub 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;