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 kept: (usize, usize),
69}
70
71const LAYERS: [Color; 3] = [Color::DarkGray, Color::Gray, ACCENT];
73
74impl QcView {
75 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 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 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 fn changed(&mut self) {
127 for p in &mut self.plots {
128 p.invalidate();
129 }
130 self.kept = kept(&self.rules);
131 }
132
133 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 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 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 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 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 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 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
447pub 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
461fn 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
554fn 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 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 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
854fn 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
865pub 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
874pub 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
888pub 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
900pub 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;