1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Decision {
33 Apply,
35 Cancel,
37}
38
39enum Typing {
40 Edge(String),
42 Save(String),
44 Output(String),
46}
47
48pub struct QcView {
50 rules: CellRules,
51 name: String,
53 output: Option<String>,
55 suggest: String,
57 focus: usize,
58 typing: Option<Typing>,
59 decision: Option<Decision>,
60 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
69const LAYERS: [Color; 3] = [Color::DarkGray, Color::Gray, ACCENT];
71
72impl QcView {
73 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 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 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 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 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 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 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 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 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
440pub 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
454fn 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
547fn 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 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 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
845pub 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
854pub 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
868pub 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
880pub 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;