Skip to main content

data_beans/interactive/
cutoff_tui.rs

1//! Full-screen nnz cutoff picker for `squeeze --interactive`.
2//!
3//! Shows the row and column nnz histograms with the current cutoff, and lets
4//! the user move each cutoff with the keyboard while the drop counts update
5//! live. Either histogram axis can be on a log, sqrt, or linear scale. On the
6//! log scale the bins are the printed histogram's (`qc`), and drop counts
7//! always use the squeeze's own rule, so all views agree.
8
9use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10use ratatui::layout::{Constraint, Flex, Layout, Rect};
11use ratatui::style::Stylize;
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Block, BorderType, Clear, LineGauge, Paragraph, Wrap};
14use ratatui::Frame;
15
16use super::ui::{
17    header, help_line, input_line, median, panel, run_screen, Binned, HistPlot, Scale, Screen,
18    ACCENTED, DIM, HIGHLIGHT, PLAIN,
19};
20use crate::qc::{below_nnz_cutoff, pct};
21
22/// One axis (rows or columns): the sorted nnz counts, their histogram, and
23/// the cutoff being edited.
24pub struct AxisView {
25    label: String,
26    sorted: Vec<f32>,
27    hist: Binned,
28    /// Cutoffs the bin steps visit, ascending: 0, the first count of every
29    /// bin past the lowest (the cutoff that drops the bars left of it), and
30    /// one past the max (drops everything).
31    stops: Vec<usize>,
32    cutoff: usize,
33    initial: usize,
34    suggest: Option<usize>,
35}
36
37impl AxisView {
38    pub fn new(label: &str, nnz: &[f32], cutoff: usize, suggest: Option<usize>) -> Self {
39        let mut sorted = nnz.to_vec();
40        sorted.sort_unstable_by(f32::total_cmp);
41        let hist = Binned::new(&sorted, Scale::Log);
42        let stops = Self::stops(&hist, &sorted);
43        Self {
44            label: label.to_string(),
45            sorted,
46            hist,
47            stops,
48            cutoff,
49            initial: cutoff,
50            suggest,
51        }
52    }
53
54    fn stops(hist: &Binned, sorted: &[f32]) -> Vec<usize> {
55        let max = sorted.last().map_or(0, |&x| x as usize);
56        let mut stops: Vec<usize> = std::iter::once(0)
57            .chain((hist.kmin + 1..=hist.kmax()).map(|k| hist.bins.lower_edge(k)))
58            .chain(std::iter::once(max + 1))
59            .collect();
60        stops.dedup();
61        stops
62    }
63
64    /// Re-bin the histogram on `scale`.
65    fn set_scale(&mut self, scale: Scale) {
66        self.hist = Binned::new(&self.sorted, scale);
67        self.stops = Self::stops(&self.hist, &self.sorted);
68    }
69
70    fn total(&self) -> usize {
71        self.sorted.len()
72    }
73
74    /// Number of entries `cutoff` drops, by the rule the squeeze applies.
75    fn removed_at(&self, cutoff: usize) -> usize {
76        self.sorted
77            .partition_point(|&x| below_nnz_cutoff(x, cutoff))
78    }
79
80    fn removed(&self) -> usize {
81        self.removed_at(self.cutoff)
82    }
83
84    fn max_value(&self) -> usize {
85        self.sorted.last().map_or(0, |&x| x as usize)
86    }
87
88    /// Move the cutoff to the next (`dir > 0`) or previous stop, so one
89    /// keypress moves the marker by exactly one bar.
90    fn step_bin(&mut self, dir: i32) {
91        self.cutoff = if dir > 0 {
92            let i = self.stops.partition_point(|&s| s <= self.cutoff);
93            self.stops.get(i).copied().unwrap_or(self.cutoff)
94        } else {
95            let i = self.stops.partition_point(|&s| s < self.cutoff);
96            i.checked_sub(1).map_or(0, |i| self.stops[i])
97        };
98    }
99
100    /// Nudge the cutoff by an exact amount.
101    fn nudge(&mut self, delta: i64) {
102        let cap = self.max_value() as i64 + 1;
103        self.cutoff = (self.cutoff as i64 + delta).clamp(0, cap) as usize;
104    }
105
106    fn snap_to_suggestion(&mut self) {
107        if let Some(s) = self.suggest {
108            self.cutoff = s;
109        }
110    }
111
112    fn reset(&mut self) {
113        self.cutoff = self.initial;
114    }
115
116    fn stats_lines(&self) -> Vec<Line<'static>> {
117        let dim = |t: &str| Span::styled(t.to_string(), DIM);
118        let removed = self.removed();
119        let suggestion = match self.suggest {
120            Some(s) => dim(&format!(
121                "   ◆ suggested {} (drops {:.2}%)",
122                s,
123                pct(self.removed_at(s), self.total())
124            )),
125            None => dim("   no trough suggestion"),
126        };
127        vec![
128            Line::from(vec![
129                dim("n "),
130                Span::raw(self.total().to_string()),
131                dim("   min "),
132                Span::raw(self.sorted.first().map_or(0, |&x| x as usize).to_string()),
133                dim("   median "),
134                Span::raw(median(&self.sorted).to_string()),
135                dim("   max "),
136                Span::raw(self.max_value().to_string()),
137            ]),
138            Line::from(vec![
139                dim("cutoff "),
140                Span::styled(self.cutoff.to_string(), HIGHLIGHT),
141                dim("   drops "),
142                Span::styled(
143                    format!(
144                        "{} / {} ({:.2}%)",
145                        removed,
146                        self.total(),
147                        pct(removed, self.total())
148                    ),
149                    ACCENTED,
150                ),
151                suggestion,
152            ]),
153        ]
154    }
155
156    fn render(&self, frame: &mut Frame, area: Rect, focused: bool, y_scale: Scale) {
157        let block = panel(format!(" {} nnz ", self.label), focused);
158        let inner = block.inner(area);
159        frame.render_widget(block, area);
160
161        let [stats, gauge, plot] = Layout::vertical([
162            Constraint::Length(2),
163            Constraint::Length(1),
164            Constraint::Min(5),
165        ])
166        .areas(inner);
167        frame.render_widget(Paragraph::new(self.stats_lines()), stats);
168
169        let ratio = 1.0 - pct(self.removed(), self.total()) / 100.0;
170        frame.render_widget(
171            LineGauge::default()
172                .ratio(ratio)
173                .label(Line::from(format!("keeps {:>6.2}% ", 100.0 * ratio)))
174                .filled_symbol("━")
175                .unfilled_symbol("━")
176                .filled_style(PLAIN)
177                .unfilled_style(ACCENTED),
178            gauge,
179        );
180
181        // Bars left of the cutoff's bin are what it drops.
182        let bins = self.hist.bins;
183        let cut_key = (self.cutoff > 0).then(|| bins.key(self.cutoff as f64));
184        let style = |k: i32| match cut_key {
185            Some(c) if k < c => ACCENTED,
186            _ => PLAIN,
187        };
188        HistPlot {
189            bins,
190            kmin: self.hist.kmin,
191            counts: &self.hist.counts,
192            style: &style,
193            subset: None,
194            y_scale,
195            y_max: None,
196            pointer: cut_key,
197            marks: self
198                .suggest
199                .map(|s| (bins.key(s as f64), "◆", PLAIN.bold()))
200                .into_iter()
201                .collect(),
202            x_label: None,
203            tick_every: None,
204        }
205        .render(frame.buffer_mut(), plot);
206    }
207}
208
209enum Mode {
210    Browse,
211    /// Typing an exact cutoff for the focused axis.
212    Edit(String),
213    /// Asking before an in-place write.
214    ConfirmInPlace,
215}
216
217/// State of the picker, independent of the terminal so it can be tested.
218pub struct CutoffPicker {
219    title: String,
220    axes: [AxisView; 2],
221    focus: usize,
222    x_scale: Scale,
223    y_scale: Scale,
224    /// When set, Enter asks before squeezing this file in place.
225    in_place_target: Option<String>,
226    mode: Mode,
227    /// Set once the user is done: the row and column cutoffs, or `None` to
228    /// cancel.
229    decision: Option<Option<(usize, usize)>>,
230}
231
232impl CutoffPicker {
233    pub fn new(
234        title: &str,
235        row: AxisView,
236        column: AxisView,
237        in_place_target: Option<&str>,
238    ) -> Self {
239        Self {
240            title: title.to_string(),
241            axes: [row, column],
242            focus: 0,
243            x_scale: Scale::Log,
244            y_scale: Scale::Log,
245            in_place_target: in_place_target.map(str::to_string),
246            mode: Mode::Browse,
247            decision: None,
248        }
249    }
250
251    fn proceed(&mut self) {
252        self.decision = Some(Some((self.axes[0].cutoff, self.axes[1].cutoff)));
253    }
254
255    fn render_confirm(&self, frame: &mut Frame, target: &str) {
256        let area = frame.area();
257        let width = (target.len() as u16 + 6).max(44).min(area.width);
258        // Long paths wrap onto extra lines rather than getting cut off.
259        let path_lines = (target.len() as u16).div_ceil(width.saturating_sub(2).max(1));
260        let [popup] = Layout::horizontal([Constraint::Length(width)])
261            .flex(Flex::Center)
262            .areas(area);
263        let [popup] = Layout::vertical([Constraint::Length(6 + path_lines)])
264            .flex(Flex::Center)
265            .areas(popup);
266        let text = vec![
267            Line::from("Squeeze in place? This permanently alters"),
268            Line::from(target.to_string()).bold(),
269            Line::from(format!(
270                "row cutoff {}, column cutoff {}",
271                self.axes[0].cutoff, self.axes[1].cutoff
272            ))
273            .style(DIM),
274            help_line(&[("y", "yes"), ("n", "back")]),
275        ];
276        frame.render_widget(Clear, popup);
277        frame.render_widget(
278            Paragraph::new(text)
279                .centered()
280                .wrap(Wrap { trim: false })
281                .block(
282                    Block::bordered()
283                        .border_type(BorderType::Double)
284                        .border_style(ACCENTED)
285                        .title(Line::from(" confirm ").style(HIGHLIGHT).centered()),
286                ),
287            popup,
288        );
289    }
290}
291
292impl Screen for CutoffPicker {
293    fn done(&self) -> bool {
294        self.decision.is_some()
295    }
296
297    fn interrupt(&mut self) {
298        self.decision = Some(None);
299    }
300
301    fn handle_key(&mut self, key: KeyEvent) {
302        let shift = key.modifiers.contains(KeyModifiers::SHIFT);
303        let f = self.focus;
304        match &mut self.mode {
305            Mode::Edit(buf) => match key.code {
306                KeyCode::Char(c) if c.is_ascii_digit() && buf.len() < 12 => buf.push(c),
307                KeyCode::Backspace => {
308                    buf.pop();
309                }
310                KeyCode::Enter => {
311                    if let Ok(v) = buf.parse::<usize>() {
312                        self.axes[f].cutoff = v;
313                    }
314                    self.mode = Mode::Browse;
315                }
316                KeyCode::Esc => self.mode = Mode::Browse,
317                _ => {}
318            },
319            Mode::ConfirmInPlace => match key.code {
320                KeyCode::Char('y' | 'Y') => self.proceed(),
321                KeyCode::Char('n' | 'N') | KeyCode::Esc => self.mode = Mode::Browse,
322                _ => {}
323            },
324            Mode::Browse => match key.code {
325                KeyCode::Tab
326                | KeyCode::BackTab
327                | KeyCode::Up
328                | KeyCode::Down
329                | KeyCode::Char('k' | 'j') => self.focus = 1 - self.focus,
330                KeyCode::Left if shift => self.axes[f].nudge(-1),
331                KeyCode::Right if shift => self.axes[f].nudge(1),
332                KeyCode::Left | KeyCode::Char('h') => self.axes[f].step_bin(-1),
333                KeyCode::Right | KeyCode::Char('l') => self.axes[f].step_bin(1),
334                KeyCode::Char('-' | ',') => self.axes[f].nudge(-1),
335                KeyCode::Char('+' | '=' | '.') => self.axes[f].nudge(1),
336                KeyCode::Char('s') => self.axes[f].snap_to_suggestion(),
337                KeyCode::Char('x') => {
338                    self.x_scale = self.x_scale.next();
339                    for axis in &mut self.axes {
340                        axis.set_scale(self.x_scale);
341                    }
342                }
343                KeyCode::Char('y') => self.y_scale = self.y_scale.next(),
344                KeyCode::Char('r') => self.axes[f].reset(),
345                KeyCode::Char('e') => self.mode = Mode::Edit(String::new()),
346                KeyCode::Char(c) if c.is_ascii_digit() => self.mode = Mode::Edit(c.to_string()),
347                KeyCode::Enter => {
348                    if self.in_place_target.is_some() {
349                        self.mode = Mode::ConfirmInPlace;
350                    } else {
351                        self.proceed();
352                    }
353                }
354                KeyCode::Char('q') | KeyCode::Esc => self.decision = Some(None),
355                _ => {}
356            },
357        }
358    }
359
360    fn render(&mut self, frame: &mut Frame) {
361        let [top, row_area, col_area, footer] = Layout::vertical([
362            Constraint::Length(1),
363            Constraint::Fill(1),
364            Constraint::Fill(1),
365            Constraint::Length(1),
366        ])
367        .areas(frame.area());
368
369        let scales = format!("x {} · y {}", self.x_scale.name(), self.y_scale.name());
370        frame.render_widget(header("squeeze", &self.title, &scales), top);
371        for (i, area) in [row_area, col_area].into_iter().enumerate() {
372            self.axes[i].render(frame, area, self.focus == i, self.y_scale);
373        }
374
375        let help = match &self.mode {
376            Mode::Edit(buf) => input_line(
377                &format!("{} cutoff: ", self.axes[self.focus].label),
378                buf,
379                &[("Enter", "set"), ("Esc", "back")],
380            ),
381            _ => help_line(&[
382                ("←/→", "bin"),
383                ("-/+", "±1"),
384                ("0-9", "type"),
385                ("s", "suggested"),
386                ("r", "reset"),
387                ("x/y", "scale"),
388                ("Tab", "rows/cols"),
389                ("Enter", "squeeze"),
390                ("q", "cancel"),
391            ]),
392        };
393        frame.render_widget(help, footer);
394
395        if let (Mode::ConfirmInPlace, Some(target)) = (&self.mode, &self.in_place_target) {
396            self.render_confirm(frame, target);
397        }
398    }
399}
400
401/// Run the picker full screen until the user proceeds (the row and column
402/// cutoffs) or cancels (`None`).
403pub fn choose_cutoffs(mut picker: CutoffPicker) -> anyhow::Result<Option<(usize, usize)>> {
404    run_screen(&mut picker)?;
405    Ok(picker.decision.flatten())
406}
407
408#[cfg(test)]
409#[path = "tests/cutoff_tui.rs"]
410mod tests;