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