1use 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
29const STATS: [&str; 4] = ["nnz", "sum", "mean", "sd"];
31
32const PAGE: usize = 20;
34
35const DECIMALS: usize = 3;
37
38const VALUE_WIDTH: (u16, u16) = (9, 24);
41
42#[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
65pub struct Dataset {
67 pub names: Vec<Box<str>>,
68 pub values: [Vec<f32>; 4],
69}
70
71pub struct Values {
73 pub names: Vec<Box<str>>,
75 pub columns: Vec<Vec<f32>>,
77}
78
79pub type Loader<'a> = Box<dyn FnMut(Side) -> anyhow::Result<Dataset> + 'a>;
81
82pub type ValuesReader<'a> = Box<dyn FnMut(Side, &[usize]) -> anyhow::Result<Values> + 'a>;
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Purpose {
88 Explore,
90 Pick { verb: &'static str },
93 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#[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
124enum Other<'a> {
126 Unavailable,
128 Lazy(Loader<'a>),
130 Ready(Dataset, Vec<bool>),
132}
133
134enum Work {
136 OtherSide,
137 Values(Vec<usize>),
138}
139
140enum Input {
142 Filter,
143 SaveNames,
144 SaveValues,
145}
146
147enum Mode {
148 Browse,
149 Typing(Input, String),
150 Values,
151}
152
153struct ValuesView {
155 labels: Vec<Box<str>>,
157 names: Vec<Box<str>>,
159 columns: Vec<Vec<f32>>,
160 order: Vec<usize>,
162 sort: Option<(usize, bool)>,
164 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 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
208pub struct StatExplorer<'a> {
210 title: String,
211 purpose: Purpose,
212 side: Side,
214 names: Vec<Box<str>>,
215 values: [Vec<f32>; 4],
217 marked: Vec<bool>,
218 other: Other<'a>,
219 reader: Option<ValuesReader<'a>>,
220 pending: Option<Work>,
221 status: Option<String>,
223 stat: usize,
225 by_name: bool,
226 descending: bool,
227 order: Vec<usize>,
229 filter: String,
230 view: Vec<usize>,
232 cursor: usize,
234 offset: usize,
235 sorted: Vec<f32>,
237 x_scale: Scale,
238 y_scale: Scale,
239 hist: Binned,
240 shown: Option<Vec<usize>>,
242 values_view: Option<ValuesView>,
243 mode: Mode,
244 quit: bool,
245 picked: Option<Picked>,
247 session_keys: Vec<(String, String)>,
249}
250
251impl<'a> StatExplorer<'a> {
252 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 fn marked_entries(&self) -> Vec<usize> {
309 (0..self.names.len()).filter(|&i| self.marked[i]).collect()
310 }
311
312 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(®ex::escape(&self.filter)))
321 .ok()
322 }
323
324 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1019fn 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
1038pub fn explore(mut explorer: StatExplorer<'_>) -> anyhow::Result<Option<Picked>> {
1041 run_screen(&mut explorer)?;
1042 Ok(explorer.picked)
1043}
1044
1045pub 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;