Skip to main content

data_beans/interactive/
qc_tui.rs

1//! `data-beans qc`'s full-screen view: the cell QC rules in a table, each
2//! with its edge, how many cells it fails and how many it alone removes;
3//! the selected rule's histogram in three layers (every cell, the cells
4//! passing the other rules, the cells only it removes); total counts
5//! against detected genes; and the removed cells by the rules they fail.
6//!
7//! The view moves a [`CellRules`], so every count is `qc_lib`'s own
8//! decision and `p` shows `qc` flags that decide the same cells. Where the
9//! terminal has an image protocol the plots are the figure's own panels
10//! ([`crate::figure::qc::panel`]); `s` saves the whole figure as PDF and
11//! PNG.
12
13use ratatui::buffer::Buffer;
14use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
15use ratatui::layout::{Constraint, Layout, Rect};
16use ratatui::style::{Color, Style};
17use ratatui::text::{Line, Span};
18use ratatui::widgets::{Paragraph, Wrap};
19use ratatui::Frame;
20use ratatui_image::picker::Picker;
21
22use super::ui::{
23    header, help_line, input_line, panel, title_bar, Screen, Tui, ACCENT, DIM, HIGHLIGHT, PLAIN,
24};
25use crate::figure::qc::{edge_text, panel as panel_svg, Panel};
26use crate::figure::term::PlotImage;
27use crate::figure::{free_name, save, taken, Axis};
28use crate::qc_rules::{CellRules, Mode, Rule};
29
30/// What the view ended with.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Decision {
33    /// Enter: go on with these settings.
34    Apply,
35    /// q, Esc, Ctrl-C or a `kill`: write nothing.
36    Cancel,
37}
38
39enum Typing {
40    /// A value for the selected rule's edge.
41    Edge(String),
42    /// The name to save the figure under.
43    Save(String),
44    /// The output to write the kept cells to.
45    Output(String),
46}
47
48/// The view over `rules`.
49pub struct QcView {
50    rules: CellRules,
51    /// The data's name, for the title and the figure.
52    name: String,
53    /// Where Enter writes the kept cells; asked for when none was given.
54    output: Option<String>,
55    /// The output name offered when one is asked for.
56    suggest: String,
57    focus: usize,
58    typing: Option<Typing>,
59    decision: Option<Decision>,
60    /// A line for the footer until the next key: a save, the flags.
61    note: Option<Line<'static>>,
62    picker: Option<Picker>,
63    picked_at: Option<u64>,
64    images: bool,
65    detecting: bool,
66    plots: [PlotImage; 2],
67}
68
69/// Colours of the text histogram's layers, back to front.
70const LAYERS: [Color; 3] = [Color::DarkGray, Color::Gray, ACCENT];
71
72impl QcView {
73    /// The view of `rules` for data `name`. Enter writes the kept cells to
74    /// `output`, or, without one, asks where (offering a free name after
75    /// `suggest`, say `pbmc.qc`).
76    pub fn new(rules: CellRules, name: &str, output: Option<String>, suggest: &str) -> Self {
77        Self {
78            rules,
79            name: name.into(),
80            output,
81            suggest: suggest.into(),
82            focus: 0,
83            typing: None,
84            decision: None,
85            note: None,
86            picker: None,
87            picked_at: None,
88            images: true,
89            detecting: false,
90            plots: Default::default(),
91        }
92    }
93
94    /// Ask the attached terminal for images, as each attach brings one.
95    pub fn detect_images(mut self) -> Self {
96        self.detecting = true;
97        self
98    }
99
100    pub fn rules(&self) -> &CellRules {
101        &self.rules
102    }
103
104    pub fn into_rules(self) -> CellRules {
105        self.rules
106    }
107
108    pub fn decision(&self) -> Option<Decision> {
109        self.decision
110    }
111
112    /// Where the kept cells go, once Enter has settled it.
113    pub fn output(&self) -> Option<&str> {
114        self.output.as_deref()
115    }
116
117    fn rule(&self) -> Rule {
118        self.rules.rules()[self.focus]
119    }
120
121    fn changed(&mut self) {
122        for p in &mut self.plots {
123            p.invalidate();
124        }
125    }
126
127    /// Ask the terminal attached now for images, once per attach.
128    fn refresh_picker(&mut self) {
129        if !self.detecting {
130            return;
131        }
132        let attach = legume_tui::attaches();
133        if self.picked_at == Some(attach) {
134            return;
135        }
136        self.picked_at = Some(attach);
137        self.picker = legume_tui::current().and_then(crate::figure::term::picker);
138        self.changed();
139    }
140
141    /// Move the selected rule's edge `steps` to the right on its axis.
142    fn nudge(&mut self, steps: i32) {
143        let rule = self.rule();
144        if !self.rules.is_on(rule) {
145            self.rules.set_on(rule, true);
146            self.changed();
147            return;
148        }
149        match self.rules.mode(rule) {
150            Mode::Mad => {
151                // A wider band moves a lower edge left and an upper one right.
152                let sign = if rule.keeps_above() { -1.0 } else { 1.0 };
153                let k = self.rules.config().n_mads + sign * 0.25 * steps as f32;
154                self.rules.set_mads(k);
155            }
156            Mode::Value => {
157                let values = self.rules.values(rule);
158                let axis = Axis::fit(values, rule.count());
159                let edge = self.rules.edge(rule).unwrap_or(axis.lo as f32) as f64;
160                let mut next = axis.at(axis.frac(edge) + steps as f64 / 80.0);
161                if matches!(rule, Rule::Genes | Rule::GeneCells) {
162                    next = next.round();
163                    if next == edge {
164                        next = edge + steps.signum() as f64;
165                    }
166                }
167                self.rules.set_value(rule, next.clamp(0.0, axis.hi) as f32);
168            }
169        }
170        self.changed();
171    }
172
173    fn save_figure(&mut self, name: &str) {
174        let svg = crate::figure::qc::figure(&self.rules, &self.name);
175        self.note = Some(match save(&svg, name) {
176            Ok(paths) => Line::from(Span::styled(format!(" saved {}", paths.join(", ")), DIM)),
177            Err(e) => Line::from(Span::styled(format!(" save failed: {e}"), HIGHLIGHT)),
178        });
179    }
180
181    /// The flags that decide what the view shows, as shell words; empty for
182    /// the defaults.
183    fn flags_text(&self) -> String {
184        self.rules
185            .flags()
186            .iter()
187            .map(|f| shell_word(f))
188            .collect::<Vec<_>>()
189            .join(" ")
190    }
191
192    fn flags_line(&self) -> Line<'static> {
193        let text = match self.flags_text() {
194            t if t.is_empty() => "the defaults: no flags needed".to_string(),
195            t => t,
196        };
197        Line::from(vec![
198            Span::styled(" flags: ", DIM),
199            Span::styled(text, PLAIN),
200        ])
201    }
202
203    fn handle_typing(&mut self, key: KeyEvent) {
204        self.note = None;
205        let Some(typing) = self.typing.as_mut() else {
206            return;
207        };
208        let buf = match typing {
209            Typing::Edge(b) | Typing::Save(b) | Typing::Output(b) => b,
210        };
211        match key.code {
212            KeyCode::Esc => self.typing = None,
213            KeyCode::Backspace => {
214                buf.pop();
215            }
216            KeyCode::Enter => match self.typing.take() {
217                Some(Typing::Edge(text)) => match text.trim().parse::<f32>() {
218                    Ok(v) => {
219                        let rule = self.rule();
220                        self.rules.set_value(rule, v);
221                        self.changed();
222                    }
223                    Err(_) => {
224                        self.note = Some(Line::from(Span::styled(
225                            format!(" not a number: {text}"),
226                            HIGHLIGHT,
227                        )))
228                    }
229                },
230                Some(Typing::Save(name)) if !name.trim().is_empty() => {
231                    let name = name.trim();
232                    if taken(name) {
233                        // Never over a file: the next free name, offered.
234                        self.typing = Some(Typing::Save(free_name(name)));
235                        self.note = Some(Line::from(Span::styled(
236                            format!(" {name} is already there; not replacing it"),
237                            HIGHLIGHT,
238                        )));
239                    } else {
240                        self.save_figure(name)
241                    }
242                }
243                Some(Typing::Output(name)) => {
244                    let name = name.trim();
245                    if name.is_empty() {
246                        self.note = Some(Line::from(Span::styled(
247                            " no output named: Enter again to name one, q to leave",
248                            DIM,
249                        )));
250                    } else if output_taken(name) {
251                        self.typing = Some(Typing::Output(free_output(name)));
252                        self.note = Some(Line::from(Span::styled(
253                            format!(" {name} is already there; not replacing it"),
254                            HIGHLIGHT,
255                        )));
256                    } else {
257                        self.output = Some(name.to_string());
258                        self.decision = Some(Decision::Apply);
259                    }
260                }
261                _ => {}
262            },
263            KeyCode::Char(c) if buf.len() < 512 => buf.push(c),
264            _ => {}
265        }
266    }
267
268    fn render_rules(&self, frame: &mut Frame, area: Rect) {
269        let block = panel(" cell QC rules ".into(), true);
270        let inner = block.inner(area);
271        frame.render_widget(block, area);
272        let mut lines = vec![Line::from(Span::styled(
273            format!(
274                "  {:<16}{:>22}{:>9}{:>6}",
275                "rule", "edge", "removes", "only"
276            ),
277            DIM,
278        ))];
279        for (i, &rule) in self.rules.rules().iter().enumerate() {
280            if rule == Rule::GeneCells {
281                lines.push(Line::from(Span::styled("  genes", DIM)));
282            }
283            let sel = i == self.focus;
284            let on = self.rules.is_on(rule);
285            let c = self.rules.count(rule);
286            let style = if sel {
287                HIGHLIGHT
288            } else if on {
289                PLAIN
290            } else {
291                DIM
292            };
293            let num = |n: usize, w: usize| {
294                Span::styled(
295                    format!("{n:>w$}"),
296                    if n > 0 { Style::new().fg(ACCENT) } else { DIM },
297                )
298            };
299            lines.push(Line::from(vec![
300                Span::styled(if sel { "▸ " } else { "  " }, HIGHLIGHT),
301                Span::styled(format!("{:<16}", rule.label()), style),
302                Span::styled(format!("{:>22}", edge_text(&self.rules, rule)), style),
303                num(c.removes, 9),
304                num(c.only, 6),
305            ]));
306        }
307        let v = self.rules.verdicts();
308        let (cells, kept) = (v.train_keep.len(), self.rules.kept_cells().len());
309        let (genes, kept_genes) = (v.feature_keep.len(), self.rules.kept_genes().len());
310        lines.push(Line::from(""));
311        lines.push(Line::from(vec![
312            Span::raw("  kept "),
313            Span::styled(kept.to_string(), HIGHLIGHT),
314            Span::raw(format!(
315                " / {cells} cells ({:.1}%)",
316                100.0 * kept as f64 / cells.max(1) as f64
317            )),
318        ]));
319        lines.push(Line::from(format!("       {kept_genes} / {genes} genes")));
320        if v.guarded {
321            lines.push(Line::from(Span::styled(
322                "  every cell (or gene) fails: QC keeps them all instead",
323                HIGHLIGHT,
324            )));
325        }
326        // Held to a value, the genes rule is the near-empty floor, which
327        // the other bands are fitted above.
328        if self.rules.mode(Rule::Genes) == Mode::Value && self.rules.is_on(Rule::Genes) {
329            lines.push(Line::from(Span::styled(
330                "  the bands are fitted on the cells above the genes floor",
331                DIM,
332            )));
333        }
334        lines.push(Line::from(""));
335        lines.push(Line::from(Span::styled(
336            "  removes: fails it on its own; only: no other rule removes",
337            DIM,
338        )));
339        frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
340    }
341
342    fn render_overlap(&self, frame: &mut Frame, area: Rect) {
343        let block = panel(" removed cells, by the rules they fail ".into(), false);
344        let inner = block.inner(area);
345        frame.render_widget(block, area);
346        let groups = self.rules.overlap();
347        let most = groups.first().map_or(1, |g| g.1).max(1);
348        let label_w = 26usize;
349        let room = (inner.width as usize).saturating_sub(label_w + 9).max(4);
350        let mut lines = vec![];
351        for (set, n) in groups.iter().take(inner.height as usize) {
352            let name = if set.is_empty() {
353                "(the rules together)".to_string()
354            } else {
355                set.iter()
356                    .map(|r| r.short())
357                    .collect::<Vec<_>>()
358                    .join(" + ")
359            };
360            let eighths = (n * room * 8).div_ceil(most);
361            let bar =
362                "█".repeat(eighths / 8) + ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"][eighths % 8];
363            lines.push(Line::from(vec![
364                Span::raw(format!("  {name:<label_w$}")),
365                Span::raw(format!("{n:>6} ")),
366                Span::styled(bar, Style::new().fg(ACCENT)),
367            ]));
368        }
369        if groups.is_empty() {
370            lines.push(Line::from(Span::styled("  no cell is removed", DIM)));
371        }
372        frame.render_widget(Paragraph::new(lines), inner);
373    }
374
375    fn render_focus(&mut self, frame: &mut Frame, area: Rect) {
376        let rule = self.rule();
377        let title = format!(
378            " {} · {} per bin ",
379            rule.label(),
380            if rule.on_cells() { "cells" } else { "genes" }
381        );
382        let block = panel(title, true);
383        let inner = block.inner(area);
384        frame.render_widget(block, area);
385        if let Some(picker) = self.picker.as_ref().filter(|_| self.images) {
386            let rules = &self.rules;
387            if self.plots[0].render(frame, inner, picker, |w, h| {
388                panel_svg(rules, Panel::Rule(rule), None, w, h)
389            }) {
390                return;
391            }
392        }
393        let [legend, plot] =
394            Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(inner);
395        let count = self.rules.count(rule);
396        let values = self.rules.values(rule);
397        let mut axis = Axis::fit(values, rule.count());
398        let edge = self.rules.edge(rule);
399        if let Some(e) = edge {
400            axis = axis.including(e as f64);
401        }
402        layered_text(
403            frame.buffer_mut(),
404            plot,
405            values,
406            &axis,
407            &[None, Some(&count.pass_others), Some(&count.only_mask)],
408            edge.map(f64::from),
409        );
410        let pass = count.pass_others.iter().filter(|&&p| p).count();
411        frame.render_widget(
412            Line::from(vec![
413                Span::styled(" █", Style::new().fg(LAYERS[0])),
414                Span::raw(format!(" all {}  ", values.len())),
415                Span::styled("█", Style::new().fg(LAYERS[1])),
416                Span::raw(format!(" pass the other rules {pass}  ")),
417                Span::styled("█", Style::new().fg(LAYERS[2])),
418                Span::raw(format!(" removed only by this {}", count.only)),
419            ]),
420            legend,
421        );
422    }
423
424    fn render_joint(&mut self, frame: &mut Frame, area: Rect) {
425        let block = panel(" total counts × detected genes · log ".into(), false);
426        let inner = block.inner(area);
427        frame.render_widget(block, area);
428        if let Some(picker) = self.picker.as_ref().filter(|_| self.images) {
429            let rules = &self.rules;
430            if self.plots[1].render(frame, inner, picker, |w, h| {
431                panel_svg(rules, Panel::Joint, None, w, h)
432            }) {
433                return;
434            }
435        }
436        braille_scatter(frame.buffer_mut(), inner, &self.rules);
437    }
438}
439
440/// `s` as one shell word.
441pub fn shell_word(s: &str) -> String {
442    if !s.is_empty()
443        && s.chars()
444            .all(|c| c.is_ascii_alphanumeric() || "-_=./,:+".contains(c))
445    {
446        s.to_string()
447    } else {
448        format!("'{}'", s.replace('\'', r"'\''"))
449    }
450}
451
452const EIGHTHS: [&str; 9] = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
453
454/// A histogram of `values` as text, layers (masks; `None` for all) drawn
455/// back to front in [`LAYERS`], two columns a bin, the y axis in a 6-wide
456/// gutter, x ticks below, and the edge as a dotted rule with ▲.
457fn layered_text(
458    buf: &mut Buffer,
459    area: Rect,
460    values: &[f32],
461    axis: &Axis,
462    layers: &[Option<&[bool]>],
463    edge: Option<f64>,
464) {
465    if area.height < 4 || area.width < 12 {
466        return;
467    }
468    let gutter = 6u16;
469    let (cx, cw) = (area.x + gutter + 1, area.width - gutter - 1);
470    let ph = area.height - 2;
471    let n = (cw / 2).max(1) as usize;
472    let counts: Vec<Vec<usize>> = layers
473        .iter()
474        .map(|m| axis.counts(values, n, |i| m.is_none_or(|m| m[i])))
475        .collect();
476    let top = counts.iter().flatten().copied().max().unwrap_or(0).max(1);
477    let style = |fg: Color, bg: Option<Color>| {
478        let s = Style::reset().fg(fg);
479        match bg {
480            Some(b) => s.bg(b),
481            None => s,
482        }
483    };
484    for b in 0..n {
485        let hs: Vec<usize> = counts
486            .iter()
487            .map(|c| (c[b] * ph as usize * 8).div_ceil(top))
488            .collect();
489        for k in 0..ph as usize {
490            let at = k * 8;
491            let Some(front) = (0..hs.len()).rev().find(|&l| hs[l] > at) else {
492                continue;
493            };
494            let frac = (hs[front] - at).min(8);
495            let back = (0..front)
496                .rev()
497                .find(|&l| hs[l] > at + frac)
498                .map(|l| LAYERS[l]);
499            let y = area.y + ph - 1 - k as u16;
500            for dx in 0..2 {
501                let x = cx + 2 * b as u16 + dx;
502                if x < area.right() {
503                    buf[(x, y)]
504                        .set_symbol(EIGHTHS[frac])
505                        .set_style(style(LAYERS[front], back));
506                }
507            }
508        }
509    }
510    let dim = Style::reset().add_modifier(ratatui::style::Modifier::DIM);
511    for y in area.y..area.y + ph {
512        buf[(area.x + gutter, y)].set_symbol("│").set_style(dim);
513    }
514    buf.set_string(area.x, area.y, format!("{top:>5}"), dim);
515    buf.set_string(area.x, area.y + ph - 1, format!("{:>5}", 0), dim);
516    let axis_y = area.y + ph;
517    buf.set_string(
518        area.x + gutter,
519        axis_y,
520        format!("└{}", "─".repeat(cw as usize)),
521        dim,
522    );
523    for t in axis.ticks() {
524        let label = crate::figure::short(t);
525        let x = cx + (axis.frac(t) * (cw.saturating_sub(1)) as f64).round() as u16;
526        let x = x
527            .saturating_sub(label.len() as u16 / 2)
528            .max(cx)
529            .min(area.right().saturating_sub(label.len() as u16));
530        buf.set_string(x, axis_y + 1, &label, dim);
531    }
532    if let Some(e) = edge {
533        let x = cx + (axis.frac(e).clamp(0.0, 1.0) * (cw.saturating_sub(1)) as f64).round() as u16;
534        for y in area.y..area.y + ph {
535            if buf[(x, y)].symbol() == " " {
536                buf[(x, y)]
537                    .set_symbol("┊")
538                    .set_style(Style::reset().fg(ACCENT));
539            }
540        }
541        buf[(x, axis_y)]
542            .set_symbol("▲")
543            .set_style(Style::reset().fg(ACCENT));
544    }
545}
546
547/// Total counts against detected genes in braille dots, log on both;
548/// a cell holding any removed cell in the accent, both edges dotted.
549fn braille_scatter(buf: &mut Buffer, area: Rect, rules: &CellRules) {
550    if area.height < 4 || area.width < 16 {
551        return;
552    }
553    let m = rules.metrics();
554    let v = rules.verdicts();
555    let gutter = 6u16;
556    let (sx, sw, sh) = (
557        area.x + gutter + 1,
558        area.width - gutter - 1,
559        area.height - 2,
560    );
561    let mut xa = Axis::fit(&m.total_counts, true);
562    let mut ya = Axis::fit(&m.n_genes, true);
563    let (ce, ge) = (rules.edge(Rule::Counts), rules.edge(Rule::Genes));
564    if let Some(e) = ce {
565        xa = xa.including(e as f64);
566    }
567    if let Some(e) = ge {
568        ya = ya.including(e as f64);
569    }
570    let accent = Style::reset().fg(ACCENT);
571    let dim = Style::reset().add_modifier(ratatui::style::Modifier::DIM);
572    // Edges first; dots drawn over them.
573    if let Some(e) = ce {
574        let x = sx + (xa.frac(e as f64) * (sw - 1) as f64).round() as u16;
575        for y in area.y..area.y + sh {
576            buf[(x, y)].set_symbol("┊").set_style(accent);
577        }
578    }
579    if let Some(e) = ge {
580        let y = area.y + sh - 1 - (ya.frac(e as f64) * (sh - 1) as f64).round() as u16;
581        for x in sx..sx + sw {
582            buf[(x, y)].set_symbol("┄").set_style(accent);
583        }
584    }
585    let (w, h) = (sw as usize, sh as usize);
586    let mut dots = vec![(0u8, false); w * h];
587    for c in 0..m.n_genes.len() {
588        let (fx, fy) = (
589            xa.frac(m.total_counts[c] as f64),
590            ya.frac(m.n_genes[c] as f64),
591        );
592        if !(fx.is_finite() && fy.is_finite()) {
593            continue;
594        }
595        let px = ((fx * (2 * w - 1) as f64).round() as usize).min(2 * w - 1);
596        let py = ((fy * (4 * h - 1) as f64).round() as usize).min(4 * h - 1);
597        let (col, row) = (px / 2, h - 1 - py / 4);
598        let bit = [[0x40u8, 0x80], [0x04, 0x20], [0x02, 0x10], [0x01, 0x08]][py % 4][px % 2];
599        let d = &mut dots[row * w + col];
600        d.0 |= bit;
601        d.1 |= !v.emitted(c);
602    }
603    for (k, &(bits, gone)) in dots.iter().enumerate() {
604        if bits == 0 {
605            continue;
606        }
607        let (x, y) = (sx + (k % w) as u16, area.y + (k / w) as u16);
608        let ch = char::from_u32(0x2800 + bits as u32).unwrap_or('⣿');
609        buf[(x, y)].set_symbol(&ch.to_string()).set_style(if gone {
610            accent
611        } else {
612            Style::reset()
613        });
614    }
615    for y in area.y..area.y + sh {
616        buf[(area.x + gutter, y)].set_symbol("│").set_style(dim);
617    }
618    buf.set_string(
619        area.x,
620        area.y,
621        format!("{:>5}", crate::figure::short(ya.hi)),
622        dim,
623    );
624    buf.set_string(
625        area.x,
626        area.y + sh - 1,
627        format!("{:>5}", crate::figure::short(ya.lo)),
628        dim,
629    );
630    buf.set_string(
631        area.x + gutter,
632        area.y + sh,
633        format!("└{}", "─".repeat(sw as usize)),
634        dim,
635    );
636    for t in xa.ticks() {
637        let label = crate::figure::short(t);
638        let x = sx + (xa.frac(t) * (sw - 1) as f64).round() as u16;
639        let x = x
640            .saturating_sub(label.len() as u16 / 2)
641            .max(sx)
642            .min(area.right().saturating_sub(label.len() as u16));
643        buf.set_string(x, area.y + sh + 1, &label, dim);
644    }
645    // Top left is empty: no cell detects more genes than it counts.
646    let key = "⣿ kept  ";
647    buf.set_string(sx + 1, area.y, key, Style::reset());
648    buf.set_string(
649        sx + 1 + key.chars().count() as u16,
650        area.y,
651        "⣿ removed",
652        accent,
653    );
654}
655
656impl Screen for QcView {
657    fn render(&mut self, frame: &mut Frame) {
658        self.refresh_picker();
659        let [top, body, footer] = Layout::vertical([
660            Constraint::Length(1),
661            Constraint::Fill(1),
662            Constraint::Length(1),
663        ])
664        .areas(frame.area());
665        let v = self.rules.verdicts();
666        let extra = format!(
667            "{} cells × {} genes{}",
668            v.train_keep.len(),
669            v.feature_keep.len(),
670            match (&self.picker, self.images) {
671                (None, _) => "",
672                (Some(_), true) => " · image",
673                (Some(_), false) => " · text",
674            }
675        );
676        title_bar(
677            frame,
678            top,
679            header("qc", &self.name, &extra),
680            &super::session_keys(),
681        );
682        let left_w = (body.width * 2 / 5).clamp(58, 70).min(body.width / 2);
683        let [left, right] =
684            Layout::horizontal([Constraint::Length(left_w), Constraint::Fill(1)]).areas(body);
685        let rows = self.rules.rules().len() as u16 + 12;
686        let [rules_area, overlap] =
687            Layout::vertical([Constraint::Length(rows), Constraint::Fill(1)]).areas(left);
688        let [focus, joint] =
689            Layout::vertical([Constraint::Percentage(50), Constraint::Fill(1)]).areas(right);
690        self.render_rules(frame, rules_area);
691        self.render_overlap(frame, overlap);
692        self.render_focus(frame, focus);
693        self.render_joint(frame, joint);
694        let line = match &self.typing {
695            Some(Typing::Edge(b)) => input_line(
696                &format!("{} edge: ", self.rule().label()),
697                b,
698                &[("Enter", "set"), ("Esc", "back")],
699            ),
700            Some(Typing::Save(b)) => match &self.note {
701                Some(note) => note.clone(),
702                None => input_line(
703                    "save the figure as (.pdf + .png): ",
704                    b,
705                    &[("Enter", "save"), ("Esc", "back")],
706                ),
707            },
708            Some(Typing::Output(b)) => match &self.note {
709                Some(note) => note.clone(),
710                None => input_line(
711                    "write the kept cells to: ",
712                    b,
713                    &[("Enter", "write"), ("Esc", "back")],
714                ),
715            },
716            None => match &self.note {
717                Some(note) => note.clone(),
718                None => {
719                    let mut keys = vec![
720                        ("↑/↓", "rule"),
721                        ("←/→", "move edge"),
722                        ("m", "MAD ↔ value"),
723                        ("e", "type edge"),
724                        ("o", "on/off"),
725                        ("r", "reset"),
726                        ("s", "save figure"),
727                        ("p", "flags"),
728                        ("c", "copy flags"),
729                    ];
730                    if self.picker.is_some() {
731                        keys.push(("i", "image/text"));
732                    }
733                    let enter = match &self.output {
734                        Some(out) => format!("write {}", stem(out)),
735                        None => "write…".to_string(),
736                    };
737                    keys.push(("Enter", enter.as_str()));
738                    keys.push(("q", "quit"));
739                    help_line(&keys)
740                }
741            },
742        };
743        frame.render_widget(line, footer);
744    }
745
746    fn handle_key(&mut self, key: KeyEvent) {
747        if self.typing.is_some() {
748            self.handle_typing(key);
749            return;
750        }
751        self.note = None;
752        let n = self.rules.rules().len();
753        let step = if key.modifiers.contains(KeyModifiers::SHIFT) {
754            5
755        } else {
756            1
757        };
758        match key.code {
759            KeyCode::Up | KeyCode::Char('k') => {
760                self.focus = (self.focus + n - 1) % n;
761                self.changed();
762            }
763            KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => {
764                self.focus = (self.focus + 1) % n;
765                self.changed();
766            }
767            KeyCode::Left | KeyCode::Char('h') => self.nudge(-step),
768            KeyCode::Right | KeyCode::Char('l') => self.nudge(step),
769            KeyCode::Char('H') => self.nudge(-5),
770            KeyCode::Char('L') => self.nudge(5),
771            KeyCode::Char('m') => {
772                let rule = self.rule();
773                if CellRules::banded(rule) {
774                    self.rules.toggle_mode(rule);
775                    self.changed();
776                } else {
777                    self.note = Some(Line::from(Span::styled(
778                        format!(" {} has no MAD band; it takes a value", rule.label()),
779                        DIM,
780                    )));
781                }
782            }
783            KeyCode::Char('o') => {
784                let rule = self.rule();
785                let on = self.rules.is_on(rule);
786                self.rules.set_on(rule, !on);
787                self.changed();
788            }
789            KeyCode::Char('e') => {
790                let now = self
791                    .rules
792                    .edge(self.rule())
793                    .map(|e| e.to_string())
794                    .unwrap_or_default();
795                self.typing = Some(Typing::Edge(now));
796            }
797            KeyCode::Char('r') => {
798                self.rules.reset();
799                self.changed();
800            }
801            KeyCode::Char('s') => {
802                self.typing = Some(Typing::Save(free_name(&format!("{}_qc", stem(&self.name)))));
803            }
804            KeyCode::Char('p') => self.note = Some(self.flags_line()),
805            KeyCode::Char('c') => {
806                let flags = self.flags_text();
807                super::ui::copy(&flags);
808                let said = match flags.is_empty() {
809                    true => " copied nothing: these are the defaults".to_string(),
810                    false => format!(" copied to the clipboard: {flags}"),
811                };
812                self.note = Some(Line::from(Span::styled(said, DIM)));
813            }
814            KeyCode::Char('i') if self.picker.is_some() => {
815                self.images = !self.images;
816                self.changed();
817            }
818            KeyCode::Enter => match self.output {
819                Some(_) => self.decision = Some(Decision::Apply),
820                None => self.typing = Some(Typing::Output(free_output(&self.suggest))),
821            },
822            KeyCode::Char('q') | KeyCode::Esc => self.decision = Some(Decision::Cancel),
823            _ => {}
824        }
825    }
826
827    fn interrupt(&mut self) {
828        self.decision = Some(Decision::Cancel);
829    }
830
831    fn done(&self) -> bool {
832        self.decision.is_some()
833    }
834
835    fn status(&self) -> Option<String> {
836        Some(format!(
837            "qc {}: keeps {} of {} cells",
838            self.name,
839            self.rules.kept_cells().len(),
840            self.rules.verdicts().train_keep.len()
841        ))
842    }
843}
844
845/// A backend's name without its extension: `pbmc` of `pbmc.zarr.zip`.
846pub fn stem(name: &str) -> &str {
847    let base = name.rsplit('/').next().unwrap_or(name);
848    [".zarr.zip", ".zarr", ".h5ad", ".h5"]
849        .iter()
850        .find_map(|e| base.strip_suffix(e))
851        .unwrap_or(base)
852}
853
854/// Whether an output named `name` would replace something: the name as
855/// given, or the backend a write under it makes.
856pub fn output_taken(name: &str) -> bool {
857    let base = name.trim_end_matches(".zip").trim_end_matches(".zarr");
858    [
859        name.to_string(),
860        format!("{base}.zarr.zip"),
861        format!("{base}.zarr"),
862        format!("{base}.h5"),
863    ]
864    .iter()
865    .any(|p| std::path::Path::new(p).exists())
866}
867
868/// `name`, or failing that the first of `{name}-2`, `{name}-3`, … that
869/// [`output_taken`] lets through.
870pub fn free_output(name: &str) -> String {
871    (1..)
872        .map(|k| match k {
873            1 => name.to_string(),
874            k => format!("{name}-{k}"),
875        })
876        .find(|n| !output_taken(n))
877        .expect("some suffix is free")
878}
879
880/// Run the view of `rules` in `tui` until Enter or quit: the rules as left,
881/// how it ended, and where the kept cells go (`output`, or the name Enter
882/// asked for, offering `suggest`).
883pub fn choose_rules(
884    tui: &mut Tui<'_>,
885    rules: CellRules,
886    name: &str,
887    output: Option<String>,
888    suggest: &str,
889) -> anyhow::Result<(CellRules, Decision, Option<String>)> {
890    let mut view = QcView::new(rules, name, output, suggest).detect_images();
891    tui.run(&mut view)?;
892    let decision = view.decision().unwrap_or(Decision::Cancel);
893    let output = view.output().map(str::to_string);
894    Ok((view.into_rules(), decision, output))
895}
896
897#[cfg(test)]
898#[path = "tests/qc_tui.rs"]
899mod tests;