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, Binned, HistPlot, Scale, Screen,
25 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}
248
249impl<'a> StatExplorer<'a> {
250 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 fn marked_entries(&self) -> Vec<usize> {
306 (0..self.names.len()).filter(|&i| self.marked[i]).collect()
307 }
308
309 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(®ex::escape(&self.filter)))
318 .ok()
319 }
320
321 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 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 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 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 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 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 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 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 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 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 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 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 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 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 x_label: None,
749 tick_every: None,
750 }
751 .render(frame.buffer_mut(), plot);
752 }
753
754 fn render_values(&mut self, frame: &mut Frame, area: Rect) {
755 let side = self.side;
756 let Some(view) = self.values_view.as_mut() else {
757 return;
758 };
759 let title = format!(
760 " values · {} {} × {} {} ",
761 view.labels.len(),
762 side.name(),
763 view.names.len(),
764 side.other().name()
765 );
766 let block = panel(title, true);
767 let inner = block.inner(area);
768 frame.render_widget(block, area);
769
770 let name_width = 24u16.min(inner.width / 2);
772 let longest = view
773 .labels
774 .iter()
775 .map(|l| l.chars().count())
776 .max()
777 .unwrap_or(0);
778 let value_width = (longest as u16 + 3).clamp(VALUE_WIDTH.0, VALUE_WIDTH.1);
779 let fit =
780 ((inner.width.saturating_sub(name_width + 2)) / (value_width + 1)).max(1) as usize;
781 (view.col, view.hscroll) = scroll(view.col, view.hscroll, fit);
782 let height = inner.height.saturating_sub(1).max(1) as usize;
783 (view.cursor, view.offset) = scroll(view.cursor, view.offset, height);
784 let shown_cols: Vec<usize> = (view.hscroll..view.labels.len()).take(fit).collect();
785
786 let mut header_cells = vec![Cell::from(side.other().name()).style(DIM)];
787 header_cells.extend(shown_cols.iter().map(|&c| {
788 let arrow = match view.sort {
789 Some((s, true)) if s == c => " ▼",
790 Some((s, false)) if s == c => " ▲",
791 _ => "",
792 };
793 let label = format!("{}{arrow}", view.labels[c]);
794 let cell = Cell::from(Line::from(label).right_aligned());
795 if c == view.col {
796 cell.style(HIGHLIGHT)
797 } else {
798 cell.style(DIM)
799 }
800 }));
801 let rows = view.order.iter().skip(view.offset).take(height).map(|&r| {
802 let mut cells = vec![Cell::from(view.names[r].to_string())];
803 cells.extend(shown_cols.iter().map(|&c| {
804 let v = view.columns[c][r];
805 let cell = Cell::from(Line::from(fmt_stat(v, DECIMALS)).right_aligned());
806 if v == 0.0 {
807 cell.style(DIM)
808 } else {
809 cell
810 }
811 }));
812 Row::new(cells)
813 });
814 let mut widths = vec![Constraint::Length(name_width)];
815 widths.extend(shown_cols.iter().map(|_| Constraint::Length(value_width)));
816 let table = Table::new(rows, widths)
817 .header(Row::new(header_cells))
818 .row_highlight_style(PLAIN.add_modifier(Modifier::REVERSED));
819 let mut state = TableState::default()
820 .with_selected((!view.order.is_empty()).then(|| view.cursor - view.offset));
821 frame.render_stateful_widget(table, inner, &mut state);
822 }
823
824 fn help(&self) -> Line<'static> {
825 let mut line = match &self.mode {
826 Mode::Typing(Input::Filter, text) => {
827 input_line("filter /", text, &[("Enter", "keep"), ("Esc", "clear")])
828 }
829 Mode::Typing(_, text) => {
830 input_line("save to ", text, &[("Enter", "write"), ("Esc", "back")])
831 }
832 Mode::Values => help_line(&[
833 ("↑/↓", "move"),
834 ("←/→", "column"),
835 ("s", "sort"),
836 ("w", "save tsv"),
837 ("Esc", "back"),
838 ]),
839 Mode::Browse => {
840 let other = self.side.other().name();
841 let lazy = format!("{other} (computed on first use)");
842 let marks = self.marks_by_side();
843 let finish = match self.purpose {
844 Purpose::Pick { verb } => {
845 format!("{verb} {} marked", marks.clone().side(self.side).len())
846 }
847 Purpose::PickBoth { verb } => format!(
848 "{verb} {} rows × {} columns (unmarked: all)",
849 marks.rows.len(),
850 marks.columns.len()
851 ),
852 Purpose::Explore => String::new(),
853 };
854 let mut keys = vec![
855 ("↑/↓", "move"),
856 ("1-4", "sort"),
857 ("0", "name"),
858 ("/", "filter"),
859 ("Space", "mark"),
860 ("a", "mark shown"),
861 ];
862 if self.reader.is_some() {
863 keys.push(("v", "values"));
864 }
865 keys.push(("w", "save names"));
866 keys.push(("x/y", "scale"));
867 match self.other {
868 Other::Ready(..) => keys.push(("Tab", other)),
869 Other::Lazy(_) => keys.push(("Tab", &lazy)),
870 Other::Unavailable => {}
871 }
872 if self.purpose != Purpose::Explore {
873 keys.push(("Enter", &finish));
874 }
875 keys.push(("q", "quit"));
876 help_line(&keys)
877 }
878 };
879 if let Some(status) = &self.status {
880 line.spans.push(Span::styled(status.clone(), ACCENTED));
881 }
882 line
883 }
884}
885
886impl Screen for StatExplorer<'_> {
887 fn done(&self) -> bool {
888 self.quit
889 }
890
891 fn interrupt(&mut self) {
892 self.picked = None;
893 self.quit = true;
894 }
895
896 fn pending_work(&self) -> Option<String> {
897 Some(match self.pending.as_ref()? {
898 Work::OtherSide => format!("computing {} statistics ...", self.side.other().name()),
899 Work::Values(entries) => {
900 format!("reading {} {} ...", entries.len(), self.side.name())
901 }
902 })
903 }
904
905 fn do_work(&mut self) {
906 match self.pending.take() {
907 Some(Work::OtherSide) => {
908 let Other::Lazy(loader) = &mut self.other else {
909 return;
910 };
911 match loader(self.side.other()) {
912 Ok(data) => {
913 let marks = vec![false; data.names.len()];
914 self.other = Other::Ready(data, marks);
915 self.switch();
916 }
917 Err(e) => {
918 self.status = Some(format!(
919 "could not compute {}: {e}",
920 self.side.other().name()
921 ))
922 }
923 }
924 }
925 Some(Work::Values(entries)) => {
926 let Some(reader) = self.reader.as_mut() else {
927 return;
928 };
929 match reader(self.side, &entries) {
930 Ok(values) => {
931 let mut view = ValuesView {
932 labels: entries.iter().map(|&i| self.names[i].clone()).collect(),
933 order: (0..values.names.len()).collect(),
934 names: values.names,
935 columns: values.columns,
936 sort: None,
937 col: 0,
938 hscroll: 0,
939 cursor: 0,
940 offset: 0,
941 };
942 view.sort_by_selected();
943 self.values_view = Some(view);
944 self.mode = Mode::Values;
945 }
946 Err(e) => self.status = Some(format!("could not read values: {e}")),
947 }
948 }
949 None => {}
950 }
951 }
952
953 fn handle_key(&mut self, key: KeyEvent) {
954 self.status = None;
955 match std::mem::replace(&mut self.mode, Mode::Browse) {
956 Mode::Typing(input, text) => self.handle_typing(input, text, key),
957 Mode::Values => {
958 self.mode = Mode::Values;
959 self.handle_values(key);
960 }
961 Mode::Browse => self.handle_browse(key),
962 }
963 }
964
965 fn render(&mut self, frame: &mut Frame) {
966 let [top, body, footer] = Layout::vertical([
967 Constraint::Length(1),
968 Constraint::Fill(1),
969 Constraint::Length(1),
970 ])
971 .areas(frame.area());
972
973 let extra = format!(
974 "{} {} · x {} · y {}",
975 self.names.len(),
976 self.side.name(),
977 self.x_scale.name(),
978 self.y_scale.name()
979 );
980 let badge = self.purpose.verb().unwrap_or("stat");
981 frame.render_widget(header(badge, &self.title, &extra), top);
982
983 let in_values = matches!(self.mode, Mode::Values | Mode::Typing(Input::SaveValues, _));
984 if in_values {
985 self.render_values(frame, body);
986 } else {
987 let [left, right] = if body.width >= 110 {
989 Layout::horizontal([Constraint::Percentage(48), Constraint::Percentage(52)])
990 .areas(body)
991 } else {
992 Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)])
993 .areas(body)
994 };
995 self.render_table(frame, left);
996 self.render_hist(frame, right);
997 }
998 frame.render_widget(self.help(), footer);
999 }
1000}
1001
1002fn scroll(cursor: usize, offset: usize, height: usize) -> (usize, usize) {
1005 let offset = if cursor < offset {
1006 cursor
1007 } else if cursor >= offset + height {
1008 cursor + 1 - height
1009 } else {
1010 offset
1011 };
1012 (cursor, offset)
1013}
1014
1015fn sorted_copy(values: &[f32]) -> Vec<f32> {
1016 let mut sorted = values.to_vec();
1017 sorted.sort_unstable_by(f32::total_cmp);
1018 sorted
1019}
1020
1021pub fn explore(mut explorer: StatExplorer<'_>) -> anyhow::Result<Option<Picked>> {
1024 run_screen(&mut explorer)?;
1025 Ok(explorer.picked)
1026}
1027
1028#[cfg(test)]
1029#[path = "tests/stat_tui.rs"]
1030mod tests;