1use 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, title_bar, Binned, HistPlot, Scale,
18 Screen, Tui, ACCENTED, DIM, HIGHLIGHT, PLAIN,
19};
20use crate::qc::{below_nnz_cutoff, pct};
21
22pub struct AxisView {
25 label: String,
26 sorted: Vec<f32>,
27 hist: Binned,
28 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 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 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 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 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 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 Edit(String),
213 ConfirmInPlace,
215}
216
217pub struct CutoffPicker {
219 title: String,
220 axes: [AxisView; 2],
221 focus: usize,
222 x_scale: Scale,
223 y_scale: Scale,
224 in_place_target: Option<String>,
226 mode: Mode,
227 decision: Option<Option<(usize, usize)>>,
230 session_keys: Vec<(String, String)>,
232}
233
234impl CutoffPicker {
235 pub fn new(
236 title: &str,
237 row: AxisView,
238 column: AxisView,
239 in_place_target: Option<&str>,
240 ) -> Self {
241 Self {
242 title: title.to_string(),
243 axes: [row, column],
244 focus: 0,
245 x_scale: Scale::Log,
246 y_scale: Scale::Log,
247 in_place_target: in_place_target.map(str::to_string),
248 mode: Mode::Browse,
249 decision: None,
250 session_keys: super::session_keys(),
251 }
252 }
253
254 fn proceed(&mut self) {
255 self.decision = Some(Some((self.axes[0].cutoff, self.axes[1].cutoff)));
256 }
257
258 fn render_confirm(&self, frame: &mut Frame, target: &str) {
259 let area = frame.area();
260 let width = (target.len() as u16 + 6).max(44).min(area.width);
261 let path_lines = (target.len() as u16).div_ceil(width.saturating_sub(2).max(1));
263 let [popup] = Layout::horizontal([Constraint::Length(width)])
264 .flex(Flex::Center)
265 .areas(area);
266 let [popup] = Layout::vertical([Constraint::Length(6 + path_lines)])
267 .flex(Flex::Center)
268 .areas(popup);
269 let text = vec![
270 Line::from("Squeeze in place? This permanently alters"),
271 Line::from(target.to_string()).bold(),
272 Line::from(format!(
273 "row cutoff {}, column cutoff {}",
274 self.axes[0].cutoff, self.axes[1].cutoff
275 ))
276 .style(DIM),
277 help_line(&[("y", "yes"), ("n", "back")]),
278 ];
279 frame.render_widget(Clear, popup);
280 frame.render_widget(
281 Paragraph::new(text)
282 .centered()
283 .wrap(Wrap { trim: false })
284 .block(
285 Block::bordered()
286 .border_type(BorderType::Double)
287 .border_style(ACCENTED)
288 .title(Line::from(" confirm ").style(HIGHLIGHT).centered()),
289 ),
290 popup,
291 );
292 }
293}
294
295impl Screen for CutoffPicker {
296 fn done(&self) -> bool {
297 self.decision.is_some()
298 }
299
300 fn interrupt(&mut self) {
301 self.decision = Some(None);
302 }
303
304 fn status(&self) -> Option<String> {
305 Some(format!("picking nnz cutoffs for {}", self.title))
306 }
307
308 fn handle_key(&mut self, key: KeyEvent) {
309 let shift = key.modifiers.contains(KeyModifiers::SHIFT);
310 let f = self.focus;
311 match &mut self.mode {
312 Mode::Edit(buf) => match key.code {
313 KeyCode::Char(c) if c.is_ascii_digit() && buf.len() < 12 => buf.push(c),
314 KeyCode::Backspace => {
315 buf.pop();
316 }
317 KeyCode::Enter => {
318 if let Ok(v) = buf.parse::<usize>() {
319 self.axes[f].cutoff = v;
320 }
321 self.mode = Mode::Browse;
322 }
323 KeyCode::Esc => self.mode = Mode::Browse,
324 _ => {}
325 },
326 Mode::ConfirmInPlace => match key.code {
327 KeyCode::Char('y' | 'Y') => self.proceed(),
328 KeyCode::Char('n' | 'N') | KeyCode::Esc => self.mode = Mode::Browse,
329 _ => {}
330 },
331 Mode::Browse => match key.code {
332 KeyCode::Tab
333 | KeyCode::BackTab
334 | KeyCode::Up
335 | KeyCode::Down
336 | KeyCode::Char('k' | 'j') => self.focus = 1 - self.focus,
337 KeyCode::Left if shift => self.axes[f].nudge(-1),
338 KeyCode::Right if shift => self.axes[f].nudge(1),
339 KeyCode::Left | KeyCode::Char('h') => self.axes[f].step_bin(-1),
340 KeyCode::Right | KeyCode::Char('l') => self.axes[f].step_bin(1),
341 KeyCode::Char('-' | ',') => self.axes[f].nudge(-1),
342 KeyCode::Char('+' | '=' | '.') => self.axes[f].nudge(1),
343 KeyCode::Char('s') => self.axes[f].snap_to_suggestion(),
344 KeyCode::Char('x') => {
345 self.x_scale = self.x_scale.next();
346 for axis in &mut self.axes {
347 axis.set_scale(self.x_scale);
348 }
349 }
350 KeyCode::Char('y') => self.y_scale = self.y_scale.next(),
351 KeyCode::Char('r') => self.axes[f].reset(),
352 KeyCode::Char('e') => self.mode = Mode::Edit(String::new()),
353 KeyCode::Char(c) if c.is_ascii_digit() => self.mode = Mode::Edit(c.to_string()),
354 KeyCode::Enter => {
355 if self.in_place_target.is_some() {
356 self.mode = Mode::ConfirmInPlace;
357 } else {
358 self.proceed();
359 }
360 }
361 KeyCode::Char('q') | KeyCode::Esc => self.decision = Some(None),
362 _ => {}
363 },
364 }
365 }
366
367 fn render(&mut self, frame: &mut Frame) {
368 let [top, row_area, col_area, footer] = Layout::vertical([
369 Constraint::Length(1),
370 Constraint::Fill(1),
371 Constraint::Fill(1),
372 Constraint::Length(1),
373 ])
374 .areas(frame.area());
375
376 let scales = format!("x {} · y {}", self.x_scale.name(), self.y_scale.name());
377 title_bar(
378 frame,
379 top,
380 header("squeeze", &self.title, &scales),
381 &self.session_keys,
382 );
383 for (i, area) in [row_area, col_area].into_iter().enumerate() {
384 self.axes[i].render(frame, area, self.focus == i, self.y_scale);
385 }
386
387 let help = match &self.mode {
388 Mode::Edit(buf) => input_line(
389 &format!("{} cutoff: ", self.axes[self.focus].label),
390 buf,
391 &[("Enter", "set"), ("Esc", "back")],
392 ),
393 _ => help_line(&[
394 ("←/→", "bin"),
395 ("-/+", "±1"),
396 ("0-9", "type"),
397 ("s", "suggested"),
398 ("r", "reset"),
399 ("x/y", "scale"),
400 ("Tab", "rows/cols"),
401 ("Enter", "squeeze"),
402 ("q", "cancel"),
403 ]),
404 };
405 frame.render_widget(help, footer);
406
407 if let (Mode::ConfirmInPlace, Some(target)) = (&self.mode, &self.in_place_target) {
408 self.render_confirm(frame, target);
409 }
410 }
411}
412
413pub fn choose_cutoffs(mut picker: CutoffPicker) -> anyhow::Result<Option<(usize, usize)>> {
416 run_screen(&mut picker)?;
417 Ok(picker.decision.flatten())
418}
419
420pub fn choose_cutoffs_in(
423 tui: &mut Tui<'_>,
424 mut picker: CutoffPicker,
425) -> anyhow::Result<Option<(usize, usize)>> {
426 tui.run(&mut picker)?;
427 Ok(picker.decision.flatten())
428}
429
430#[cfg(test)]
431#[path = "tests/cutoff_tui.rs"]
432mod tests;