diff --git a/Cargo.toml b/Cargo.toml index 810cc1d..f40f9be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "faba" -version = "0.16.5" +version = "0.16.6" edition = "2021" rust-version = "1.91" license = "MIT" diff --git a/src/main.rs b/src/main.rs index 19f53d9..e9cd846 100644 --- a/src/main.rs +++ b/src/main.rs @@ -216,29 +216,36 @@ Example:\n \ #[command( name = "pileup", alias = "inspect", - about = "ASCII pileup, or a faceted Miami plot (SVG/PDF) for a gene", - long_about = "Pileup plot for a gene's (or region's) modification sites.\n\ + about = "Browse a gene's sites full screen; or an ASCII pileup, or a Miami plot (SVG/PDF)", + long_about = "Pileup of a gene's (or region's) modification sites.\n\ \n\ - Selection is `-q/--genes` (symbols or Ensembl IDs) and/or `--regions chr:lb-ub`;\n\ - at least one is required,\n\ - and everything matched is aggregated into one pileup.\n\ + Give it a faba output directory (or a run record in it) and it takes\n\ + the site matrices of --modality (m6a, else atoi), one track per batch,\n\ + with the site table and the recorded GFF; or name site matrices\n\ + (zarr/h5, `{batch}_m6a_site`) yourself.\n\ \n\ - ASCII mode (default):\n\ - reads one or more sparse matrices (zarr/h5) from faba output,\n\ - filters to the selection, bins positions along the gene body,\n\ - and renders a vertical ASCII histogram.\n\ + Browser (default): pan, zoom and jump between sites full screen,\n\ + starting from a gene list unless `-q/--genes` (symbols or Ensembl IDs)\n\ + or `--regions chr:lb-ub` picks one.\n\ + \n\ + ASCII mode (--batch-process, or without a terminal): needs --genes or\n\ + --regions, aggregates everything matched into one pileup, bins\n\ + positions along the gene body and prints a vertical histogram.\n\ Multiple files (e.g. replicates via a shell glob) are aggregated per position.\n\ \n\ - Miami figure mode: passing --gtf, --bam, --format, --svg,\n\ - or --png renders a publication SVG/PDF instead —\n\ + Miami figure mode: passing --bam, --format, --svg or --png\n\ + (or --gtf with --batch-process) renders a publication SVG/PDF instead —\n\ a mirrored Manhattan with epi sites up, a GTF gene model in the middle,\n\ and BAM read depth down,\n\ faceted into one panel per cell type (--cell-membership).", after_long_help = "\ Examples:\n\ - # ASCII histogram (unchanged)\n\ - faba pileup out/s1_wt_m6a.zarr.zip -q GENE1\n\ - faba pileup out/s*_wt_m6a.zarr.zip -q GENE1 -s out/m6a_sites.parquet\n\ + # browse an output directory's m6A sites, from its gene list\n\ + faba pileup out/\n\ + faba pileup out/pipeline_summary.json -q GENE1 --modality atoi\n\ + # ASCII histogram\n\ + faba pileup out/s1_wt_m6a_site.zarr.zip -q GENE1 --batch-process\n\ + faba pileup out/s*_wt_m6a_site.zarr.zip -q GENE1 -s out/m6a_sites.parquet --batch-process\n\ # Miami figure: epi sites / gene model / read depth, faceted by cell type\n\ faba pileup out/s1_wt_m6a.zarr.zip -q GENE1 \n\ --gtf gencode.gtf --bam sample.bam --cell-membership cells.tsv \n\ @@ -270,6 +277,9 @@ Example:\n \ so `--isoforms all` is that raw output. Its script then keeps the longest isoform\n\ per gene, which we do not: that drops sites on other isoforms' exons.\n\ --dist-measures is our name for the per-site table that script reads.\n\n\ + Give it a faba output directory (or a run record in it) and it profiles that\n\ + directory's site table of --modality against its recorded GFF. After writing the\n\ + histogram it opens the profile full screen, unless --batch-process.\n\n\ See docs/profiling-methods.md sections 1.2 and 7.\n\n\ Reference:\n\ Olarerin-George and Jaffrey, \"MetaPlotR: a Perl/R pipeline for plotting metagenes of nucleotide modifications and other transcriptomic sites\",\n\ @@ -277,7 +287,9 @@ Example:\n \ https://doi.org/10.1093/bioinformatics/btx002", after_long_help = "\ Example:\n\ - faba metagene -s out/m6a_sites.parquet -g genes.gff -o metagene.tsv --print\n\ + # an output directory's m6A sites against its recorded GFF, then the view\n\ + faba metagene out/\n\ + faba metagene -s out/m6a_sites.parquet -g genes.gff -o metagene.tsv --print --batch-process\n\ # write the table MetaPlotR's visualize_metagenes.R reads:\n\ faba metagene -s out/m6a_sites.parquet -g genes.gff -o metagene.tsv \n\ --dist-measures m6a.dist.measures.txt" diff --git a/src/pipeline/tui/child.rs b/src/pipeline/tui/child.rs index 37aa712..2ff8630 100644 --- a/src/pipeline/tui/child.rs +++ b/src/pipeline/tui/child.rs @@ -263,15 +263,12 @@ pub fn follow(log: Option, mut each: impl FnMut(Said)) -> St last } -/// Frames of the spinners the workspace draws. -const SPINNER: &str = "⠁⠂⠄⡀⢀⠠⠐⠈"; - /// A progress bar's frame, its elapsed time already gone: the bar of `#` /// and `-`, `pos/len`, the time left in brackets, then what it counts. A /// spinner's frame starts with one of its ticks. pub fn progress_of(line: &str) -> Option { let (head, rest) = line.split_once(char::is_whitespace)?; - if head.chars().all(|c| SPINNER.contains(c)) { + if head.chars().all(|c| crate::tui::SPINNER.contains(c)) { return Some(Progress { what: rest.trim().to_string(), ..Progress::default() diff --git a/src/pipeline/tui/draw.rs b/src/pipeline/tui/draw.rs index a5396bb..dd76e5a 100644 --- a/src/pipeline/tui/draw.rs +++ b/src/pipeline/tui/draw.rs @@ -464,15 +464,17 @@ fn draw_run(app: &App, frame: &mut Frame, body: Rect) { .map(|l| Line::raw(l.clone())) .collect(); if let Some(p) = bar { - let filled = (p.len > 0).then(|| filled(p.pos, p.len, BAR)); - lines.push(match filled { - Some(filled) => Line::from(vec![ - Span::styled("█".repeat(filled), ACCENTED), - Span::styled("░".repeat(BAR.saturating_sub(filled)), DIM), - Span::raw(format!(" {}/{} {}", p.pos, p.len, p.what)), - ]), + lines.push(match p.len > 0 { + true => { + let [on, off] = crate::tui::bar_spans(p.pos, p.len, BAR, ACCENTED); + Line::from(vec![ + on, + off, + Span::raw(format!(" {}/{} {}", p.pos, p.len, p.what)), + ]) + } // A spinner: no length to fill. - None => Line::from(vec![ + false => Line::from(vec![ Span::styled("⠿ ", ACCENTED), Span::raw(p.what.clone()), ]), diff --git a/src/qc/layout.rs b/src/qc/layout.rs index 5a5de01..89ab14c 100644 --- a/src/qc/layout.rs +++ b/src/qc/layout.rs @@ -27,7 +27,7 @@ pub const MATRIX_KINDS: &[&str] = &[ ]; /// Editing modalities with a shared `{modality}_sites.parquet` and per-batch -/// `_site` matrices. +/// `_site` matrices, in the order one is taken when none is asked for. pub const SITE_MODALITIES: &[&str] = &[M6A, ATOI]; #[derive(Debug, Clone)] @@ -100,13 +100,45 @@ pub fn looks_like_faba_dir(dir: &Path) -> bool { } else { classify_matrix_name(name, false).is_some() }; - matrix - || name - .strip_suffix("_sites.parquet") + matrix || site_table_modality(name).is_some() + }) +} + +/// Whether `name` is a `{batch}_{modality}_site` matrix's file name (a +/// `.zarr` store counts as a file). +pub fn is_site_matrix_name(name: &str) -> bool { + classify_matrix_name(name, true).is_some_and(|(stem, ..)| { + let (batch, kind) = split_stem(stem); + !batch.is_empty() + && kind + .strip_suffix("_site") .is_some_and(|m| SITE_MODALITIES.contains(&m)) }) } +/// The batch a `{batch}_{kind}` matrix's file name carries, when it has a +/// known kind. +pub fn matrix_batch(name: &str) -> Option> { + let (stem, ..) = classify_matrix_name(name, true)?; + let (batch, kind) = split_stem(stem); + (!kind.is_empty()).then_some(batch) +} + +/// The site modality after `modality` (m6A and A-to-I swap); `None` for +/// one that is not a site modality. +pub fn other_site_modality(modality: &str) -> Option<&'static str> { + let i = SITE_MODALITIES + .iter() + .position(|m| m.eq_ignore_ascii_case(modality))?; + Some(SITE_MODALITIES[(i + 1) % SITE_MODALITIES.len()]) +} + +/// The modality of a `{modality}_sites.parquet` site table's file name. +pub fn site_table_modality(name: &str) -> Option<&str> { + name.strip_suffix("_sites.parquet") + .filter(|m| SITE_MODALITIES.contains(m)) +} + /// Split a matrix file name into `(stem, backend, zipped)`; `None` when it is /// not a sparse backend at all. fn classify_matrix_name(name: &str, is_dir: bool) -> Option<(&str, SparseIoBackend, bool)> { @@ -166,11 +198,9 @@ pub fn scan_input_dir(dir: &str) -> anyhow::Result { if is_dir { continue; } - if let Some(modality) = name.strip_suffix("_sites.parquet") { - if SITE_MODALITIES.contains(&modality) { - layout.site_tables.insert(modality.into(), path_str); - continue; - } + if let Some(modality) = site_table_modality(name) { + layout.site_tables.insert(modality.into(), path_str); + continue; } // A run record describes the directory it sits in; `qc` writes its // own for the new fileset rather than carrying stale ones over. diff --git a/src/qc/path_tui.rs b/src/qc/path_tui.rs index 8784285..6b0db9b 100644 --- a/src/qc/path_tui.rs +++ b/src/qc/path_tui.rs @@ -38,16 +38,16 @@ struct PathPicker { decision: Option>, } -/// A browser of subdirectories only, tagging those that hold faba matrices -/// or site tables, each checked once. -fn faba_browser(cwd: PathBuf) -> Browser { +/// A browser of subdirectories and the files `keep` accepts, tagging the +/// folders that hold faba matrices or site tables, each checked once. +pub fn faba_browser(cwd: PathBuf, keep: impl Fn(&str) -> bool + 'static) -> Browser { let mut seen: FxHashMap = FxHashMap::default(); let tag = move |dir: &Path| { *seen .entry(dir.to_path_buf()) .or_insert_with(|| looks_like_faba_dir(dir)) }; - Browser::new(cwd, |_| false, tag) + Browser::new(cwd, keep, tag) } /// A directory next to `input` named after it, that does not exist yet. @@ -63,7 +63,7 @@ impl PathPicker { fn new(cwd: PathBuf, input: Option, given_output: Option) -> Self { let mut p = Self { step: Step::Input, - browser: faba_browser(cwd.clone()), + browser: faba_browser(cwd.clone(), |_| false), error: None, given_output, decision: None, diff --git a/src/qc/site_tui.rs b/src/qc/site_tui.rs index af513e2..2d2df0f 100644 --- a/src/qc/site_tui.rs +++ b/src/qc/site_tui.rs @@ -40,8 +40,8 @@ use super::progress::Progress; use super::sites::{genomic_sites, Criterion, GeneSites, SiteTable}; use crate::tui::browser::{is_annotation, Browser, Nav}; use crate::tui::{ - apply_key, button_popup, enter_hint, filled, first_visible, is_apply, popup, popup_frame, - run_view, tab_bar, wheel_key, Hits, View, APPLY_KEYS, + apply_key, button_popup, enter_hint, first_visible, is_apply, popup, popup_frame, run_view, + tab_bar, wheel_key, Hits, View, APPLY_KEYS, }; mod annotation; diff --git a/src/qc/site_tui/draw.rs b/src/qc/site_tui/draw.rs index 64b1926..7f41557 100644 --- a/src/qc/site_tui/draw.rs +++ b/src/qc/site_tui/draw.rs @@ -716,16 +716,14 @@ impl<'a> SitePicker<'a> { step = "drawing the figures".into(); } const BAR: usize = 40; - let filled = filled(done as u64, total as u64, BAR); let lines = vec![ Line::from(Span::styled("Writing the filtered fileset to ", DIM)), Line::from(Span::styled(format!(" {}", self.writer.output), HIGHLIGHT)), Line::from(""), - Line::from(vec![ - Span::styled("█".repeat(filled), ACCENTED), - Span::styled("░".repeat(BAR.saturating_sub(filled)), DIM), - Span::raw(format!(" {done} / {total}")), - ]), + { + let [on, off] = crate::tui::bar_spans(done as u64, total as u64, BAR, ACCENTED); + Line::from(vec![on, off, Span::raw(format!(" {done} / {total}"))]) + }, Line::from(Span::styled(format!("{step: Option<(String, PathBuf)> { let dir = if p.is_dir() && !is_matrix_dir(p) { p.to_path_buf() } else { - p.parent() - .map(|d| { - if d.as_os_str().is_empty() { - Path::new(".") - } else { - d - } - })? - .to_path_buf() + folder_of(p)? }; let mut records: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(&dir) .ok()? @@ -305,6 +297,31 @@ pub fn explicit_or_recorded( .or_else(|| find_input(near, key, what)) } +/// The output directory `p` names, when it names one: `p` itself when it is +/// a directory (but not a zarr store, which is one matrix), or the folder of +/// a run record (`*.run.json`, `pipeline_summary.json`). +pub fn output_dir_of(p: &str) -> Option { + let path = Path::new(p); + if path.is_dir() && !is_matrix_dir(path) { + return Some(path.to_path_buf()); + } + let name = path.file_name()?.to_string_lossy(); + if !is_run_record(&name) || !path.is_file() { + return None; + } + folder_of(path) +} + +/// The folder a file sits in, `.` for a bare name. +fn folder_of(p: &Path) -> Option { + let dir = p.parent()?; + Some(if dir.as_os_str().is_empty() { + PathBuf::from(".") + } else { + dir.to_path_buf() + }) +} + /// A zarr store is a directory, but it is one output, not an output directory. fn is_matrix_dir(p: &Path) -> bool { p.extension().is_some_and(|e| e == "zarr") diff --git a/src/site_analysis/input_picker.rs b/src/site_analysis/input_picker.rs new file mode 100644 index 0000000..93a0632 --- /dev/null +++ b/src/site_analysis/input_picker.rs @@ -0,0 +1,277 @@ +//! The pop-up `pileup` and `metagene` open when the command line names no +//! input: browse, mark the files to read together, or choose a whole faba +//! output folder. It opens where the last choice was made from the same +//! working folder. + +use std::path::{Path, PathBuf}; + +use data_beans::interactive::ui::{header, help_line, Screen, HIGHLIGHT}; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::qc::layout::looks_like_faba_dir; +use crate::qc::path_tui::faba_browser; +use crate::tui::browser::{Browser, Nav}; +use crate::tui::{is_apply, popup_frame, run_view, tilde, View, APPLY_KEYS}; + +/// What was chosen: the marked files, or one folder. +#[derive(Debug, PartialEq)] +pub struct Chosen { + pub paths: Vec>, + /// Marked files are to be one track each rather than one together. + pub separate: bool, +} + +/// The choice, independent of the terminal so it can be tested. +pub struct InputPicker { + /// Marking files, in the order marked, from any folder. + browser: Browser, + /// The command asking, for the header. + command: &'static str, + /// What the listed files are, for the title. + what: &'static str, + /// Whether marked files read as one track each (Tab); `None` when the + /// command reads them only together. + separate: Option, + /// Why the last choice was refused. + pub error: Option, + /// The choice; `Some(None)` when cancelled. + pub decision: Option>, +} + +impl InputPicker { + /// Listing the folders and the files `keep` accepts by name, at + /// `last`'s folder with it highlighted when that is still there, else + /// at `cwd`; with Tab to read marked files apart when `separable`. + pub fn new( + cwd: PathBuf, + last: Option<&Path>, + command: &'static str, + what: &'static str, + keep: fn(&str) -> bool, + separable: bool, + ) -> Self { + let at = last.and_then(|l| Some((l.parent().filter(|d| d.is_dir())?, l.file_name()?))); + let start = at.map_or(cwd, |(dir, _)| dir.to_path_buf()); + let mut browser = faba_browser(start.clone(), keep).marking(); + browser.open(start); + if let Some((_, name)) = at { + browser.list.select(&name.to_string_lossy()); + } + Self { + browser, + command, + what, + separate: separable.then_some(false), + error: None, + decision: None, + } + } + + fn finish(&mut self, paths: Vec) { + let paths = paths + .iter() + .map(|p| p.to_string_lossy().into_owned().into_boxed_str()) + .collect(); + let separate = self.separate.unwrap_or(false); + self.decision = Some(Some(Chosen { paths, separate })); + } + + /// The marked count and, when Tab applies, how they read now and what + /// Tab makes of them. + fn track_mode(&self) -> Option<(&'static str, &'static str)> { + self.separate.map(|apart| match apart { + true => ("a track each", "one track"), + false => ("one track", "a track each"), + }) + } + + /// Take the whole of `dir`, if it is a faba output folder. + fn choose_dir(&mut self, dir: PathBuf) { + if looks_like_faba_dir(&dir) { + self.finish(vec![dir]); + } else { + self.error = Some(format!("{} is not a faba output folder", tilde(&dir))); + } + } +} + +impl View for InputPicker { + /// The apply key takes the marked files, or the folder shown. + fn takes_apply(&self) -> bool { + true + } +} + +impl Screen for InputPicker { + fn reports_chords(&self) -> bool { + true + } + + fn done(&self) -> bool { + self.decision.is_some() + } + + fn interrupt(&mut self) { + self.decision = Some(None); + } + + fn handle_key(&mut self, key: KeyEvent) { + self.error = None; + if is_apply(&key) { + let marked = self.browser.marked.take().unwrap_or_default(); + if marked.is_empty() { + self.browser.marked = Some(marked); + return self.choose_dir(self.browser.cwd.clone()); + } + return self.finish(marked); + } + match self.browser.key(key) { + Nav::Picked(file) => return self.browser.toggle_mark(file), + Nav::Moved => return, + Nav::Ignored => {} + } + match key.code { + KeyCode::Char(' ') => { + let Some(e) = self.browser.highlighted() else { + return; + }; + let (path, dir, up) = (e.path.clone(), e.dir, e.name == ".."); + if !dir { + self.browser.toggle_mark(path); + } else if !up { + self.choose_dir(path); + } + } + KeyCode::Tab => { + if let Some(s) = &mut self.separate { + *s = !*s; + } + } + KeyCode::Esc => self.decision = Some(None), + _ => {} + } + } + + fn render(&mut self, frame: &mut Frame) { + let area = frame.area(); + frame.render_widget(header(self.command, "choose the input", ""), area); + let w = area.width.saturating_sub(4).clamp(20, 90); + let rows = area.height.saturating_sub(8).clamp(3, 24); + let n_marked = self.browser.marked.as_ref().map_or(0, Vec::len); + let marked = match (n_marked, self.track_mode()) { + (0, _) => String::new(), + (n, Some((now, _))) => format!("· {n} marked, {now} "), + (n, None) => format!("· {n} marked "), + }; + let title = format!( + " {}, or a faba output folder · {} {}{marked}", + self.what, + tilde(&self.browser.cwd), + self.browser.list.find.tag() + ); + let inner = popup_frame(frame, area, w, rows + 4, title); + let mut lines = self.browser.lines( + rows as usize, + inner.width as usize, + "faba output", + "no subfolder or file to read", + ); + lines.push(match &self.error { + Some(why) => Line::from(Span::styled(format!(" {why}"), HIGHLIGHT)), + None => Line::from(""), + }); + let apply = if n_marked == 0 { + "choose this folder" + } else { + "read the marked" + }; + let mut keys = vec![ + ("type", "find"), + ("↑/↓", "move"), + ("Enter/→", "open"), + ("←", "up"), + ("Space", "mark file / choose folder"), + ]; + if let Some((_, tab)) = self.track_mode() { + keys.push(("Tab", tab)); + } + keys.extend([(APPLY_KEYS, apply), ("Esc", "clear find / cancel")]); + lines.push(help_line(&keys)); + frame.render_widget(Paragraph::new(lines), inner); + } +} + +/// Where the last choices are kept, one line per working folder +/// (`folderchoice`), so the picker opens there next time. +fn memo_file() -> Option { + let cache = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .or_else(|| Some(crate::tui::home()?.join(".cache")))?; + Some(cache.join("faba/last_input")) +} + +/// The last choice made from `cwd`. +fn recall(memo: &Path, cwd: &Path) -> Option { + let text = std::fs::read_to_string(memo).ok()?; + let cwd = cwd.to_string_lossy(); + text.lines() + .find_map(|l| l.split_once('\t').filter(|(from, _)| *from == cwd)) + .map(|(_, last)| PathBuf::from(last)) +} + +/// Keep `choice` as the last made from `cwd`, the most recent folders +/// first and at most [`MEMO_LINES`] of them. A convenience: a failure to +/// write is no failure. +fn remember(memo: &Path, cwd: &Path, choice: &str) { + let cwd = cwd.to_string_lossy(); + let old = std::fs::read_to_string(memo).unwrap_or_default(); + let others = old + .lines() + .filter(|l| l.split_once('\t').is_some_and(|(from, _)| from != cwd)); + let lines: Vec = std::iter::once(format!("{cwd}\t{choice}")) + .chain(others.map(String::from)) + .take(MEMO_LINES) + .collect(); + if let Some(dir) = memo.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(memo, lines.join("\n") + "\n"); +} + +/// Working folders whose last choice is kept. +const MEMO_LINES: usize = 64; + +/// The input when the command line named none: the files marked in a +/// browser (those `keep` accepts by name), or one faba output folder. +/// `Ok(None)` when the user cancels; an error when there is no browsing +/// (`batch`, or no terminal), saying what to name. +pub fn ask_inputs( + command: &'static str, + batch: bool, + what: &'static str, + keep: fn(&str) -> bool, + separable: bool, +) -> anyhow::Result> { + anyhow::ensure!( + !batch && data_beans::interactive::tui_available(), + "name {what}, or a faba output directory" + ); + let cwd = std::env::current_dir()?; + let memo = memo_file(); + let last = memo.as_deref().and_then(|m| recall(m, &cwd)); + let mut picker = InputPicker::new(cwd.clone(), last.as_deref(), command, what, keep, separable); + run_view(&mut picker)?; + let chosen = picker.decision.flatten(); + if let (Some(memo), Some(first)) = (&memo, chosen.as_ref().and_then(|c| c.paths.first())) { + remember(memo, &cwd, first); + } + Ok(chosen) +} + +#[cfg(test)] +#[path = "tests/input_picker.rs"] +mod tests; diff --git a/src/site_analysis/metagene.rs b/src/site_analysis/metagene.rs index 1a6aeab..bc2850c 100644 --- a/src/site_analysis/metagene.rs +++ b/src/site_analysis/metagene.rs @@ -31,13 +31,33 @@ pub enum IsoformPolicy { #[derive(Args, Debug)] pub struct MetageneArgs { + #[arg( + num_args = 1.., + value_name = "SITES|DIR", + help = "Site parquets (from dartseq, atoi or apa), or faba output directories (or run records in them), profiled together", + long_help = "Site-level parquet file (from dartseq, atoi or apa output).\n\ + Or a faba output directory (from `faba run`, a producer, or `faba qc`),\n\ + or a run record in it (`*.run.json`, `pipeline_summary.json`): its\n\ + `{modality}_sites.parquet` of --modality is profiled, against its\n\ + recorded GFF. Several are profiled together, as one set of sites,\n\ + against the first one's GFF. Left out (with -s), a browser asks:\n\ + mark site tables, or choose an output directory." + )] + input: Vec>, + #[arg( short = 's', long = "sites", - required = true, - help = "Site-level parquet file (from dartseq, atoi or apa output)" + help = "Site-level parquet file, as the positional SITES" + )] + sites: Option>, + + #[arg( + long = "modality", + value_name = "m6a|atoi", + help = "Which site table to take from an output directory (default: m6a, else atoi)" )] - site_file: Box, + modality: Option>, #[arg( short = 'g', @@ -101,8 +121,15 @@ pub struct MetageneArgs { )] include_non_coding: bool, - #[arg(short, long, required = true, help = "Output TSV file path")] - output: Box, + #[arg( + short, + long, + help = "Output TSV file path (default with --batch-process: `{sites}.metagene.tsv` here)", + long_help = "Output TSV file path. In the view, written only when given; with\n\ + --batch-process, without it, `{sites}.metagene.tsv` in the current\n\ + directory, after the site table's name." + )] + output: Option>, #[arg( long = "dist-measures", @@ -119,11 +146,23 @@ pub struct MetageneArgs { #[arg(long = "print", help = "Print ASCII histogram to stderr")] print_histogram: bool, + #[arg( + long = "batch-process", + visible_alias = "batch-mode", + default_value_t = false, + help = "Write the histogram and stop, without browsing the profile full screen", + long_help = "Write the histogram and stop, without browsing the profile full screen.\n\ + By default `faba metagene` writes it and then opens the profile in a\n\ + view (when there is a terminal)." + )] + batch_process: bool, + + /// The view is the default now; kept so older command lines still run. #[arg( short = 'I', long = "interactive", - default_value_t = false, - help = "After writing, browse the profile full screen (needs a terminal)" + hide = true, + default_value_t = false )] interactive: bool, @@ -888,18 +927,142 @@ fn non_coding_bodies(records: &[GffRecord]) -> Vec { .collect() } +impl MetageneArgs { + /// The site tables: `-s`, then the positional inputs, each of which may + /// name an output directory (or a run record in it) holding one; with + /// neither, those chosen in a browser. `None` when that is cancelled. + fn site_files(&self) -> anyhow::Result>>> { + let mut given: Vec> = self.sites.iter().chain(&self.input).cloned().collect(); + if given.is_empty() { + let ask = crate::site_analysis::input_picker::ask_inputs( + "metagene", + self.batch_process, + "site tables", + |n| crate::qc::layout::site_table_modality(n).is_some(), + false, + ); + let Some(chosen) = ask? else { + return Ok(None); + }; + given = chosen.paths; + } + given + .iter() + .map(|g| self.site_file(g)) + .collect::>() + .map(Some) + } + + /// `given`, or the site table of the output directory it names. + fn site_file(&self, given: &str) -> anyhow::Result> { + let Some(dir) = crate::run_record::output_dir_of(given) else { + return Ok(given.into()); + }; + let sites = crate::site_analysis::output_dir::output_sites(&dir, self.modality.as_deref())?; + let table = sites.site_table.ok_or_else(|| { + anyhow::anyhow!("{} has no {}_sites.parquet", dir.display(), sites.modality) + })?; + info!("{}: profiling {table}", dir.display()); + Ok(table) + } +} + pub fn run_metagene(args: &MetageneArgs) -> anyhow::Result<()> { - let sites = read_sites(&args.site_file)?; + let Some(site_files) = args.site_files()? else { + return Ok(()); + }; + // In the view a spinner shows while the sites are placed. + let what = format!("profiling {} site table(s)", site_files.len()); + let histogram = crate::tui::busy(!args.batch_process, "metagene", &what, || { + profile(args, &site_files) + })?; + let site_file = site_files[0].as_ref(); + let default = || { + let name = crate::qc::layout::file_name(site_file); + let stem = name.strip_suffix(".parquet").unwrap_or(&name); + let together = if site_files.len() > 1 { + "_combined" + } else { + "" + }; + format!("{stem}{together}.metagene.tsv").into_boxed_str() + }; + let output = args + .output + .clone() + .or_else(|| args.batch_process.then(default)); + if let Some(output) = &output { + histogram.to_tsv(output)?; + info!("wrote metagene histogram to {output}"); + } + + if args.print_histogram { + histogram.print(args.max_width as usize); + } + if !args.batch_process { + let names: Vec = site_files + .iter() + .map(|f| crate::qc::layout::file_name(f).to_string()) + .collect(); + let title = names.join(" + "); + crate::figure::term::when_terminal(|| tui::show_metagene(&title, &histogram))?; + } + + Ok(()) +} + +/// Place the sites of `site_files`, as one set, on the transcripts of the +/// first one's GFF and bin them. +fn profile(args: &MetageneArgs, site_files: &[Box]) -> anyhow::Result { + let site_file = site_files[0].as_ref(); + // Several tables are one set of sites; their reads count only if each + // has them. + // The tables are independent: read them together, then take them in + // order. + let read: Vec<_> = { + use rayon::prelude::*; + site_files + .par_iter() + .map(|f| -> anyhow::Result<_> { + let these = read_sites(f)?; + let reads = crate::site_analysis::site_io::read_site_reads(f, &these) + .unwrap_or_else(|e| { + log::warn!( + "the reads of {f} could not be read ({e}); profiling the sites only" + ); + None + }); + Ok((these, reads)) + }) + .collect() + }; + let mut sites = Vec::new(); + let mut reads = Some(Vec::new()); + for one in read { + let (these, these_reads) = one?; + match (&mut reads, these_reads) { + (Some(all), Some(r)) => all.extend(r), + _ => reads = None, + } + sites.extend(these); + } + if site_files.len() > 1 { + info!( + "{} sites from {} tables, together", + sites.len(), + site_files.len() + ); + } let gff_file = crate::run_record::explicit_or_recorded( args.gff_file.as_deref(), - &args.site_file, + site_file, "gff", "annotation", ) .ok_or_else(|| { anyhow::anyhow!( "-g/--gff is required: no run record next to {} names a GFF", - args.site_file + site_file ) })?; let records = read_gff_record_vec(&gff_file)?; @@ -986,25 +1149,12 @@ pub fn run_metagene(args: &MetageneArgs) -> anyhow::Result<()> { info!("wrote per-site distance table to {}", path); } - // The reads only add views of the same sites: unreadable, the sites remain. - let reads = crate::site_analysis::site_io::read_site_reads(&args.site_file, &sites) - .unwrap_or_else(|e| { - log::warn!("the sites' reads could not be read ({e}); profiling the sites only"); - None - }); - let histogram = GeneFeatureHistogram::accumulate(&grid, scale, &assignments, reads.as_deref()); - histogram.to_tsv(&args.output)?; - info!("wrote metagene histogram to {}", args.output); - - if args.print_histogram { - histogram.print(args.max_width as usize); - } - if args.interactive { - let title = crate::qc::layout::file_name(&args.site_file); - crate::figure::term::when_terminal(|| tui::show_metagene(&title, &histogram))?; - } - - Ok(()) + Ok(GeneFeatureHistogram::accumulate( + &grid, + scale, + &assignments, + reads.as_deref(), + )) } /// Every coding transcript, indexed for placing sites with split weights diff --git a/src/site_analysis/metagene/tests.rs b/src/site_analysis/metagene/tests.rs index 559eba1..02fbb31 100644 --- a/src/site_analysis/metagene/tests.rs +++ b/src/site_analysis/metagene/tests.rs @@ -878,3 +878,53 @@ fn split_shares_that_sum_to_a_whole_print_as_one() { assert_eq!(count_text(0.5, 4), "0.5000"); assert_eq!(count_text(3.0, 1), "3"); } + +#[test] +fn an_output_directory_or_its_run_record_gives_its_site_table() { + use clap::FromArgMatches; + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + for f in ["atoi_sites.parquet", "atoi.run.json"] { + std::fs::write(dir.join(f), b"").unwrap(); + } + let parse = |args: &[&str]| { + let cmd = crate::faba_command() + .find_subcommand("metagene") + .unwrap() + .clone(); + let m = cmd + .try_get_matches_from(std::iter::once("metagene").chain(args.iter().copied())) + .unwrap(); + MetageneArgs::from_arg_matches(&m).unwrap() + }; + let table = dir + .join("atoi_sites.parquet") + .to_string_lossy() + .into_owned(); + let record = dir.join("atoi.run.json").to_string_lossy().into_owned(); + for given in [dir.to_string_lossy().into_owned(), record] { + let args = parse(&[&given]); + assert_eq!( + args.site_files().unwrap().unwrap(), + [Box::from(table.as_str())], + "A-to-I, the one there" + ); + assert!(!args.batch_process, "the view by default"); + } + assert!(parse(&[&dir.to_string_lossy(), "--modality", "m6a"]) + .site_files() + .is_err()); + let args = parse(&["-s", "x.parquet", "--batch-process"]); + assert_eq!( + args.site_files().unwrap().unwrap(), + [Box::from("x.parquet")] + ); + assert!(args.batch_process); + // Several, together, `-s` first. + let dir_arg = dir.to_string_lossy().into_owned(); + let args = parse(&["-s", "x.parquet", "y.parquet", &dir_arg]); + let together: Vec> = ["x.parquet", "y.parquet", table.as_str()] + .map(Box::from) + .into(); + assert_eq!(args.site_files().unwrap().unwrap(), together); +} diff --git a/src/site_analysis/mod.rs b/src/site_analysis/mod.rs index 576e230..7d9b917 100644 --- a/src/site_analysis/mod.rs +++ b/src/site_analysis/mod.rs @@ -1,5 +1,7 @@ +pub mod input_picker; pub mod metagene; pub mod miami; +pub mod output_dir; pub mod pileup; pub mod scan_pwm; pub mod show; diff --git a/src/site_analysis/output_dir.rs b/src/site_analysis/output_dir.rs new file mode 100644 index 0000000..393f78e --- /dev/null +++ b/src/site_analysis/output_dir.rs @@ -0,0 +1,62 @@ +//! What `pileup` and `metagene` need from a faba output directory (from +//! `faba run`, a producer, or `faba qc`), so they can be pointed at the +//! directory, or at a run record in it, rather than at its files. + +use std::path::Path; + +use crate::qc::layout::{scan_input_dir, SITE_MODALITIES}; + +/// One modality's sites in an output directory. +#[derive(Debug)] +pub struct OutputSites { + pub modality: Box, + /// `(batch, path)` of each `{batch}_{modality}_site` matrix, by batch. + pub matrices: Vec<(Box, Box)>, + /// `{modality}_sites.parquet`, when there is one. + pub site_table: Option>, +} + +/// `modality`'s sites in `dir`, or (without one) those of the first of +/// [`SITE_MODALITIES`] the directory has. +pub fn output_sites(dir: &Path, modality: Option<&str>) -> anyhow::Result { + let layout = scan_input_dir(&dir.to_string_lossy())?; + let of = |m: &str| { + let mut matrices: Vec<(Box, Box)> = layout + .matrices + .iter() + .filter(|f| f.site_modality() == Some(m)) + .map(|f| (f.batch.clone(), f.path.clone())) + .collect(); + matrices.sort(); + OutputSites { + modality: m.into(), + matrices, + site_table: layout.site_tables.get(m).cloned(), + } + }; + let has = |s: &OutputSites| !s.matrices.is_empty() || s.site_table.is_some(); + if let Some(m) = modality { + let sites = of(m); + anyhow::ensure!( + has(&sites), + "{} has no {m} site matrices or site table", + dir.display() + ); + return Ok(sites); + } + SITE_MODALITIES + .iter() + .map(|m| of(m)) + .find(has) + .ok_or_else(|| { + anyhow::anyhow!( + "{} has no site matrices or site tables ({})", + dir.display(), + SITE_MODALITIES.join(", ") + ) + }) +} + +#[cfg(test)] +#[path = "tests/output_dir.rs"] +mod tests; diff --git a/src/site_analysis/pileup.rs b/src/site_analysis/pileup.rs index 45ab108..1a80312 100644 --- a/src/site_analysis/pileup.rs +++ b/src/site_analysis/pileup.rs @@ -82,18 +82,33 @@ pub enum FigFormat { Pdf, } -#[derive(Args, Debug)] +#[derive(Args, Debug, Clone)] pub struct PileupArgs { #[arg( - required = true, num_args = 1.., - help = "Sparse matrix file(s) (zarr or h5) from faba output", - long_help = "Sparse matrix file(s) (zarr or h5) from faba output. Multiple files,\n\ - e.g. replicates via a shell glob,\n\ - are aggregated per genomic position into a single track." + value_name = "MATRIX|DIR", + help = "Site matrices (zarr or h5) from faba output, or the output directory (or a run record in it); browse for one when left out", + long_help = "Site matrices (zarr or h5) from faba output: `{batch}_m6a_site` or\n\ + `{batch}_atoi_site`. Multiple files, e.g. replicates via a shell glob,\n\ + are aggregated per genomic position into a single track.\n\ + \n\ + Or a faba output directory (from `faba run`, a producer, or `faba qc`),\n\ + or a run record in it (`*.run.json`, `pipeline_summary.json`): its site\n\ + matrices of --modality are taken, one track per batch, with its\n\ + `{modality}_sites.parquet` for -s and its recorded GFF for the genes.\n\ + \n\ + Left out, a browser asks: mark site matrices to pile up together,\n\ + in one track, or choose an output directory." )] pub data_files: Vec>, + #[arg( + long = "modality", + value_name = "m6a|atoi", + help = "Which sites to take from an output directory (default: m6a, else atoi)" + )] + modality: Option>, + #[arg( short = 'q', long = "genes", @@ -114,8 +129,8 @@ pub struct PileupArgs { long_help = "Genomic regions to pile up:\n\ comma-separated `chr:lb-ub` (`chr17:1000-2000,chr1:50-99`).\n\ Selects rows by position, with or without `--genes`.\n\ - At least one of `--genes`/`--regions` is required, except with\n\ - `--interactive`, which then opens a gene list to pick from." + Without either, the browser opens a gene list to pick from;\n\ + with --batch-process, at least one is required." )] regions: Vec>, @@ -167,7 +182,7 @@ pub struct PileupArgs { long = "depth", value_name = "FILE", num_args = 1.., - help = "`_depth` matrices for a read-depth row in the browser (--interactive): each bin's reads summed over cells and files" + help = "`_depth` matrices for a read-depth row in the browser: each bin's reads summed over cells and files" )] depth_files: Vec>, @@ -184,11 +199,34 @@ pub struct PileupArgs { )] tracks: Vec>, + #[arg( + long = "batch-process", + visible_alias = "batch-mode", + default_value_t = false, + help = "Print the ASCII pileup (and write -o) instead of browsing it full screen", + long_help = "Print the ASCII pileup (and write -o) instead of browsing it full screen.\n\ + By default `faba pileup` opens a browser: pan, zoom and jump between\n\ + sites, with a gene list to pick from when no --genes/--regions are given.\n\ + Without a terminal it prints, as this flag does." + )] + batch_process: bool, + + #[arg( + long = "separate", + default_value_t = false, + help = "Give each named matrix its own track, named after its batch, rather than one together", + long_help = "Give each named matrix its own track, named after its batch, rather than\n\ + summing them into one. The browser's file picker sets this with Tab.\n\ + --track groups them otherwise." + )] + separate: bool, + + /// The browser is the default now; kept so older command lines still run. #[arg( short = 'I', long = "interactive", - default_value_t = false, - help = "Browse the pileup full screen: pan, zoom and jump between sites (needs a terminal; ASCII mode only)" + hide = true, + default_value_t = false )] interactive: bool, @@ -203,7 +241,7 @@ pub struct PileupArgs { long = "gtf", help = "Gene annotation GTF/GFF for the middle gene-model track (exons, introns)", long_help = "Gene annotation GTF/GFF for the middle gene-model track (exons, introns, strand).\n\ - Enables figure mode; with --interactive, draws the genes in view instead.\n\ + Enables figure mode with --batch-process; in the browser, draws the genes in view.\n\ Without it, the browser and the figure use the GFF recorded in the\n\ `*.run.json` next to the first matrix, when there is one." )] @@ -357,6 +395,12 @@ fn wildcard(pattern: &str, text: &str) -> bool { p[pi..].iter().all(|&c| c == '*') } +/// A matrix's own track name: its batch, else its file name. +fn track_name(file: &str) -> Box { + let name = crate::qc::layout::file_name(file); + crate::qc::layout::matrix_batch(&name).unwrap_or(name) +} + /// One labelled group of input matrices. struct TrackFiles { label: Box, @@ -428,6 +472,18 @@ struct Loaded { keys: Option>>, /// `--depth` bins over the extent. depth: Vec<(i64, i64, f64)>, + /// Where the browser's cursor starts; the first site when `None`. + cursor: Option, +} + +impl Loaded { + /// This view, its cursor starting `at`. + fn at(self, at: i64) -> Self { + Self { + cursor: Some(at), + ..self + } + } } /// Load `selector` for drawing. With `totals`, each matrix track also sums @@ -440,21 +496,30 @@ fn load( selector: &Selector, totals: bool, locus: Option<(i64, i64)>, + done: Option<&std::sync::atomic::AtomicUsize>, ) -> anyhow::Result { let totals = totals && !matches!(args.signal, PileupSignal::Nnz); let mut label: Option<(Box, Box, Box)> = None; let mut keys: Vec> = Vec::new(); let mut tracks = Vec::with_capacity(groups.len()); - for g in groups { - let (converted, total) = - match read_matrix_positions(&g.files, selector, &args.signal, totals)? { - Some(m) => { - label.get_or_insert((m.gene, m.chr, m.modality)); - keys.extend(m.genes); - (m.positions, m.total) - } - None => (Vec::new(), None), - }; + // The tracks' files are independent: read them together, then take + // them in order. + let read: Vec<_> = { + use rayon::prelude::*; + groups + .par_iter() + .map(|g| read_matrix_positions(&g.files, selector, &args.signal, totals, done)) + .collect() + }; + for (g, read) in groups.iter().zip(read) { + let (converted, total) = match read? { + Some(m) => { + label.get_or_insert((m.gene, m.chr, m.modality)); + keys.extend(m.genes); + (m.positions, m.total) + } + None => (Vec::new(), None), + }; tracks.push(MatrixTrack { label: g.label.clone(), converted, @@ -496,6 +561,24 @@ fn load( keys }), depth, + cursor: None, + }) +} + +/// [`load`], for the browser (`browser`, with the totals) under a spinner +/// saying `what` is read. +fn loading( + browser: bool, + args: &PileupArgs, + groups: &[TrackFiles], + selector: &Selector, + locus: Option<(i64, i64)>, + what: &str, +) -> anyhow::Result { + let files: usize = groups.iter().map(|g| g.files.len()).sum(); + let text = format!("reading {what}"); + crate::tui::busy_counting(browser, "pileup", &text, files, |done| { + load(args, groups, selector, browser, locus, Some(done)) }) } @@ -525,6 +608,7 @@ fn browse( loaded: &Loaded, genes: Option<&SharedModels>, status: Option, + switchable: bool, ) -> anyhow::Result { let is_log = matches!(args.signal, PileupSignal::Log10Sum); let (on, off) = channel_names(&loaded.modality); @@ -555,6 +639,8 @@ fn browse( off, keys: loaded.keys.as_deref(), genes: genes.cloned(), + cursor: loaded.cursor, + switchable, }; tui::show_pileup(view, tracks, status) } @@ -570,13 +656,13 @@ enum Found { } /// The inputs' genes, read from their row names the first time asked. -struct Catalog<'a> { - files: &'a [Box], +struct Catalog { + files: Vec>, genes: std::cell::OnceCell>, } -impl<'a> Catalog<'a> { - fn new(files: &'a [Box]) -> Self { +impl Catalog { + fn new(files: Vec>) -> Self { Self { files, genes: std::cell::OnceCell::new(), @@ -587,13 +673,29 @@ impl<'a> Catalog<'a> { if let Some(genes) = self.genes.get() { return Ok(genes); } - eprintln!("reading genes from {} file(s) ...", self.files.len()); - let list = picker::gene_catalog(self.files)?; + let what = format!("reading genes from {} file(s)", self.files.len()); + let files = &self.files; + let list = crate::tui::busy(true, "pileup", &what, || picker::gene_catalog(files))?; anyhow::ensure!(!list.is_empty(), "no site rows in the input files"); Ok(self.genes.get_or_init(|| list)) } } +/// Load `chr:lo-hi` for the browser, every gene in it drawn. `Err` carries +/// a message for the footer. +fn load_locus( + args: &PileupArgs, + groups: &[TrackFiles], + chr: &str, + lo: i64, + hi: i64, +) -> Result { + let locus = format!("{chr}:{lo}-{hi}"); + let selector = Selector::build(&[], &[locus.as_str().into()]).map_err(|e| e.to_string())?; + loading(true, args, groups, &selector, Some((lo, hi)), &locus) + .map_err(|_| format!("no sites in {locus}")) +} + /// Open what `query` names: a locus anywhere, or a gene of the catalog. /// `Err` carries a message for the footer. fn search( @@ -609,11 +711,7 @@ fn search( } else { (r.lb, r.ub) }; - let spec: Box = format!("{}:{lo}-{hi}", r.chr).into(); - let selector = Selector::build(&[], &[spec]).map_err(|e| e.to_string())?; - let loaded = load(args, groups, &selector, true, Some((lo, hi))) - .map_err(|_| format!("no sites in {query}"))?; - Ok(Found::View(loaded)) + load_locus(args, groups, &r.chr, lo, hi).map(Found::View) } Some(Query::Gene(g)) => { let genes = catalog.get().map_err(|e| e.to_string())?; @@ -624,9 +722,16 @@ fn search( .collect(); match hits.as_slice() { [] => Err(format!("no gene matches {g}")), - [one] => load(args, groups, &Selector::exact(&one.gene), true, None) - .map(Found::View) - .map_err(|e| e.to_string()), + [one] => loading( + true, + args, + groups, + &Selector::exact(&one.gene), + None, + &one.gene, + ) + .map(Found::View) + .map_err(|e| e.to_string()), _ => Ok(Found::List), } } @@ -651,19 +756,93 @@ fn start_gene_models(gtf: Option<&str>) -> Option { Some(models) } -/// Browse interactively, starting from `first` (a selection given on the -/// command line) or from the gene list. -fn interactive( - args: &PileupArgs, - groups: &[TrackFiles], - first: Option, -) -> anyhow::Result<()> { - let catalog = Catalog::new(&args.data_files); - let genes = start_gene_models(args.annotation().as_deref()); +/// One modality's inputs, resolved, with their gene list read when first +/// asked for. +struct Source { + args: PileupArgs, + groups: Vec, + catalog: Catalog, + /// The sites' modality, once known. + modality: Option>, +} + +impl Source { + fn new(base: &PileupArgs) -> anyhow::Result { + let (args, groups) = base.resolved()?; + let catalog = Catalog::new(args.data_files.clone()); + Ok(Self { + args, + groups, + catalog, + modality: base.modality.clone(), + }) + } +} + +/// `M`: the other modality's sources (resolved the first time), and `here` +/// reopened in them: the same gene, else the same span. The index of the +/// sources and the view, or why not, for the footer. +fn switch_modality( + base: &PileupArgs, + sources: &mut Vec, + shown: usize, + here: &Loaded, +) -> Result<(usize, Loaded), String> { + sources[shown] + .modality + .get_or_insert_with(|| here.modality.clone()); + let to = crate::qc::layout::other_site_modality(&here.modality) + .ok_or_else(|| format!("no other sites to switch {} to", here.modality))?; + let i = match sources + .iter() + .position(|s| s.modality.as_deref() == Some(to)) + { + Some(i) => i, + None => { + let other = PileupArgs { + modality: Some(to.into()), + ..base.clone() + }; + sources.push(Source::new(&other).map_err(|e| e.to_string())?); + sources.len() - 1 + } + }; + let Source { args, groups, .. } = &sources[i]; + let reopened = match here.keys.as_deref() { + Some([key]) => { + loading(true, args, groups, &Selector::exact(key), None, key).map_err(|e| e.to_string()) + } + _ => { + let (lo, hi) = here.extent; + load_locus(args, groups, &here.chr, lo, hi) + } + }; + let l = reopened.map_err(|_| format!("no {to} sites here"))?; + Ok((i, l)) +} + +/// Browse `source` (resolved from `base`) interactively, starting from +/// `first` (a selection given on the command line) or from the gene list; +/// `M` switches between the m6A and A-to-I sites of the output folders +/// among `base`'s inputs. +fn interactive(base: &PileupArgs, source: Source, first: Option) -> anyhow::Result<()> { + let genes = start_gene_models(source.args.annotation().as_deref()); + let switchable = base + .data_files + .iter() + .any(|g| crate::run_record::output_dir_of(g).is_some()); + let mut sources = vec![source]; + let mut shown = 0; let mut filter = String::new(); let mut current = first; let mut status: Option = None; loop { + let Source { + args, + groups, + catalog, + .. + } = &sources[shown]; let loaded = match current.take() { Some(l) => l, None => { @@ -676,18 +855,14 @@ fn interactive( let query = match choice { picker::Choice::Quit => return Ok(()), picker::Choice::Gene(i) => { - current = Some(load( - args, - groups, - &Selector::exact(&genes[i].gene), - true, - None, - )?); + let gene = &genes[i].gene; + let selector = Selector::exact(gene); + current = Some(loading(true, args, groups, &selector, None, gene)?); continue; } picker::Choice::Locus(q) => q, }; - match search(args, groups, &catalog, &query) { + match search(args, groups, catalog, &query) { Ok(Found::View(l)) => l, Ok(Found::List) => continue, Err(msg) => { @@ -697,10 +872,38 @@ fn interactive( } } }; - match browse(args, &loaded, genes.as_ref(), status.take())? { + match browse(args, &loaded, genes.as_ref(), status.take(), switchable)? { tui::Exit::Quit => return Ok(()), tui::Exit::Genes => {} - tui::Exit::Search(q) => match search(args, groups, &catalog, &q) { + tui::Exit::Locus { lo, hi, at } => { + match load_locus(args, groups, &loaded.chr, lo, hi) { + // Wider, but where it was: the same title and cursor. + Ok(l) => { + current = Some(Loaded { + gene: loaded.gene.clone(), + ..l.at(at) + }) + } + Err(msg) => { + status = Some(msg); + current = Some(loaded); + } + } + } + tui::Exit::Modality { at } => { + let here = loaded.at(at); + match switch_modality(base, &mut sources, shown, &here) { + Ok((i, l)) => { + shown = i; + current = Some(l.at(at)); + } + Err(msg) => { + status = Some(msg); + current = Some(here); + } + } + } + tui::Exit::Search(q) => match search(args, groups, catalog, &q) { Ok(Found::View(l)) => current = Some(l), Ok(Found::List) => filter = q, Err(msg) => { @@ -713,6 +916,60 @@ fn interactive( } impl PileupArgs { + /// These arguments with each output directory (or run record) among + /// the inputs replaced by its site matrices of `--modality` and its + /// site table as `-s` when none was given; and the tracks: `--track`'s, + /// else with a directory or `--separate` one per batch and one per file + /// named, else one of them all. + fn resolved(&self) -> anyhow::Result<(PileupArgs, Vec)> { + let mut out = self.clone(); + let mut files = Vec::new(); + let mut own: Vec = Vec::new(); + let mut from_dir = false; + for f in &self.data_files { + let Some(dir) = crate::run_record::output_dir_of(f) else { + files.push(f.clone()); + own.push(TrackFiles { + label: track_name(f), + files: vec![f.clone()], + }); + continue; + }; + from_dir = true; + let sites = + crate::site_analysis::output_dir::output_sites(&dir, self.modality.as_deref())?; + anyhow::ensure!( + !sites.matrices.is_empty(), + "{} has no {}_site matrices to pile up", + dir.display(), + sites.modality + ); + info!( + "{}: {} {}_site matrices", + dir.display(), + sites.matrices.len(), + sites.modality + ); + for (batch, path) in sites.matrices { + files.push(path.clone()); + own.push(TrackFiles { + label: batch, + files: vec![path], + }); + } + if out.site_file.is_none() { + out.site_file = sites.site_table; + } + } + out.data_files = files; + let groups = if (from_dir || self.separate) && self.tracks.is_empty() { + own + } else { + track_files(&out.data_files, &self.tracks)? + }; + Ok((out, groups)) + } + /// `--gtf`, or else the GFF the first matrix was made from, found in its /// run record. fn annotation(&self) -> Option> { @@ -722,21 +979,35 @@ impl PileupArgs { } pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { - // Figure mode is triggered by any figure-only input/output flag. - // Otherwise fall through to the ASCII / TSV path. - // With `--interactive`, `--gtf` feeds the browser's gene row instead. + let mut base = args.clone(); + if base.data_files.is_empty() { + let ask = crate::site_analysis::input_picker::ask_inputs( + "pileup", + args.batch_process, + "site matrices", + crate::qc::layout::is_site_matrix_name, + true, + ); + let Some(chosen) = ask? else { return Ok(()) }; + base.data_files = chosen.paths; + base.separate = chosen.separate; + } + let base = &base; + let source = Source::new(base)?; + let (args, groups) = (&source.args, &source.groups); + // A figure flag makes the Miami figure; otherwise the browser, unless + // asked to print (or there is no terminal to browse in), when `--gtf` + // makes the figure too. In the browser `--gtf` draws the genes in view. let figure_flags = !args.bam_files.is_empty() || args.format.is_some() || args.svg || args.png; - let figure_mode = !args.interactive && (args.gtf.is_some() || figure_flags); - if args.interactive && figure_flags { - log::warn!("--bam/--format/--svg/--png make a figure, not the browser; ignoring them"); + let browse_it = !args.batch_process && !figure_flags; + let terminal = data_beans::interactive::tui_available(); + if browse_it && !terminal { + log::info!("no terminal to browse in: printing the pileup, as --batch-process does"); } - let groups = track_files(&args.data_files, &args.tracks)?; - if args.interactive && !figure_mode && args.genes.is_empty() && args.regions.is_empty() { - anyhow::ensure!( - data_beans::interactive::tui_available(), - "--interactive without --genes/--regions needs stdin and stdout on a terminal" - ); - return interactive(args, &groups, None); + let interactive = browse_it && terminal; + let figure_mode = !interactive && (args.gtf.is_some() || figure_flags); + if interactive && args.genes.is_empty() && args.regions.is_empty() { + return self::interactive(base, source, None); } let selector = Selector::build(&args.genes, &args.regions)?; if figure_mode { @@ -746,7 +1017,19 @@ pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { return run_miami_figure(args, &selector); } - let loaded = load(args, &groups, &selector, args.interactive, None)?; + let what = selector.describe(); + let loaded = loading(interactive, args, groups, &selector, None, &what)?; + if interactive { + // Straight to the browser; -o still writes the binned table. + write_pileup(args, &loaded, false)?; + return self::interactive(base, source, Some(loaded)); + } + write_pileup(args, &loaded, true) +} + +/// With `print`, print `loaded` as ASCII histograms (unless `--quiet`); and +/// write the binned table to `-o`. +fn write_pileup(args: &PileupArgs, loaded: &Loaded, print: bool) -> anyhow::Result<()> { let (min_pos, max_pos) = loaded.extent; let max_sites = loaded .tracks @@ -787,7 +1070,7 @@ pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { }); } - if !args.quiet { + if print && !args.quiet { for p in &pileups { print_vertical_histogram(p, args.plot_height); } @@ -799,10 +1082,6 @@ pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { info!("wrote pileup TSV to {}", output); } - if args.interactive { - crate::figure::term::when_terminal(|| interactive(args, &groups, Some(loaded)))?; - } - Ok(()) } diff --git a/src/site_analysis/pileup/figure.rs b/src/site_analysis/pileup/figure.rs index 39ab90e..2a8935f 100644 --- a/src/site_analysis/pileup/figure.rs +++ b/src/site_analysis/pileup/figure.rs @@ -24,6 +24,7 @@ pub(super) fn run_miami_figure(args: &PileupArgs, selector: &Selector) -> anyhow membership.as_ref(), &args.top_modality, false, + None, )?; anyhow::ensure!( grouped.matched > 0, diff --git a/src/site_analysis/pileup/read.rs b/src/site_analysis/pileup/read.rs index 6160141..9fa9afd 100644 --- a/src/site_analysis/pileup/read.rs +++ b/src/site_analysis/pileup/read.rs @@ -1,7 +1,9 @@ //! Reading the selected rows: sparse matrices, site parquets, read depth. use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; +#[derive(Default)] struct PosAgg { pub(super) sum: f64, pub(super) nnz: usize, @@ -204,131 +206,198 @@ pub(super) struct GroupedMatrix { pub(super) genes: Vec>, } -pub(super) fn read_matrix_positions_grouped( - data_files: &[Box], +/// One file's share of [`read_matrix_positions_grouped`], merged with the +/// other files' in file order. +#[derive(Default)] +struct FileRead { + by_group: FxHashMap, FxHashMap>, + distinct_genes: FxHashMap, usize>, + matched_chrs: Vec>, + matched: usize, + first_modality: Option>, + totals: FxHashMap, + unconverted_rows: usize, +} + +impl FileRead { + /// Add `other`, a later file's share: per group and position the sums + /// add, and the first file's modality stays. + fn merge(&mut self, other: FileRead) { + if self.matched == 0 && self.unconverted_rows == 0 && self.by_group.is_empty() { + // Nothing yet: take it whole rather than copy it over. + *self = other; + return; + } + for (grp, aggs) in other.by_group { + let into = self.by_group.entry(grp).or_default(); + for (pos, agg) in aggs { + let a = into.entry(pos).or_default(); + a.sum += agg.sum; + a.nnz += agg.nnz; + } + } + for (gene, n) in other.distinct_genes { + *self.distinct_genes.entry(gene).or_insert(0) += n; + } + self.matched_chrs.extend(other.matched_chrs); + self.matched += other.matched; + if self.first_modality.is_none() { + self.first_modality = other.first_modality; + } + for (pos, v) in other.totals { + *self.totals.entry(pos).or_insert(0.0) += v; + } + self.unconverted_rows += other.unconverted_rows; + } +} + +/// Read the rows `selector` picks from `data_file`. +fn read_one_file( + data_file: &str, selector: &Selector, - signal: &PileupSignal, membership: Option<&CellMembership>, - top_modality: &[Box], + modality_filter: Option<&[Box]>, with_total: bool, -) -> anyhow::Result { - // Per group: pos -> aggregate. Multiple input files (e.g. replicates) - // merge per genomic position; gene/chr labels reflect the union. - let mut by_group: FxHashMap, FxHashMap> = FxHashMap::default(); - let mut distinct_genes: FxHashMap, usize> = FxHashMap::default(); - let mut matched_chrs: Vec> = Vec::new(); - let mut total_matched = 0usize; - let mut first_modality: Option> = None; - // Both channels summed per position, with `with_total`. - let mut totals: FxHashMap = FxHashMap::default(); - let mut unconverted_rows = 0usize; - - let modality_filter: Option> = if top_modality.is_empty() { - None - } else { - Some( - top_modality - .iter() - .map(|m| m.to_ascii_lowercase()) - .collect(), - ) - }; - - for data_file in data_files { - let (backend, resolved_path) = resolve_backend_file(data_file, None)?; - let data = open_sparse_matrix(&resolved_path, &backend)?; - - let row_names = data.row_names()?; - - let mut matched_rows: Vec<(usize, i64, bool)> = Vec::new(); - for (idx, name) in row_names.iter().enumerate() { - if let Some((gene_part, modality, chr, pos, converted)) = parse_row_channel(name) { - // Unconverted rows count only toward the total. - let is_converted = converted != Some(false); - if !is_converted && !with_total { +) -> anyhow::Result { + let mut part = FileRead::default(); + let (backend, resolved_path) = resolve_backend_file(data_file, None)?; + let data = open_sparse_matrix(&resolved_path, &backend)?; + + let row_names = data.row_names()?; + + let mut matched_rows: Vec<(usize, i64, bool)> = Vec::new(); + for (idx, name) in row_names.iter().enumerate() { + if let Some((gene_part, modality, chr, pos, converted)) = parse_row_channel(name) { + // Unconverted rows count only toward the total. + let is_converted = converted != Some(false); + if !is_converted && !with_total { + continue; + } + if let Some(mf) = modality_filter { + if !mf.iter().any(|m| m.eq_ignore_ascii_case(modality)) { continue; } - if let Some(ref mf) = modality_filter { - if !mf.contains(&modality.to_ascii_lowercase()) { - continue; - } - } - if selector.selects(gene_part, chr, pos) { - if is_converted { - first_modality.get_or_insert_with(|| modality.into()); - *distinct_genes.entry(gene_part.into()).or_insert(0) += 1; - matched_chrs.push(chr.into()); - } else { - unconverted_rows += 1; - } - matched_rows.push((idx, pos, is_converted)); + } + if selector.selects(gene_part, chr, pos) { + if is_converted { + part.first_modality.get_or_insert_with(|| modality.into()); + *part.distinct_genes.entry(gene_part.into()).or_insert(0) += 1; + part.matched_chrs.push(chr.into()); + } else { + part.unconverted_rows += 1; } + matched_rows.push((idx, pos, is_converted)); } } + } - if matched_rows.is_empty() { - continue; + if matched_rows.is_empty() { + return Ok(part); + } + part.matched += matched_rows.iter().filter(|r| r.2).count(); + + // Column index -> cell type (None to drop). Only needed when + // stratifying; the all-cells path skips reading column names. + let col_groups: Option>>> = match membership { + None => None, + Some(m) => { + let col_names = data.column_names()?; + Some( + col_names + .iter() + .map(|bc| m.matches_barcode(&CellBarcode::Barcode(Arc::from(bc.as_ref())))) + .collect(), + ) } - total_matched += matched_rows.iter().filter(|r| r.2).count(); - - // Column index -> cell type (None to drop). Only needed when - // stratifying; the all-cells path skips reading column names. - let col_groups: Option>>> = match membership { - None => None, - Some(m) => { - let col_names = data.column_names()?; - Some( - col_names - .iter() - .map(|bc| m.matches_barcode(&CellBarcode::Barcode(Arc::from(bc.as_ref())))) - .collect(), - ) - } - }; - - let local_to_pos: Vec<(i64, bool)> = matched_rows.iter().map(|r| (r.1, r.2)).collect(); - let row_indices: Vec = matched_rows.iter().map(|r| r.0).collect(); - let (_nrow, _ncol, triplets) = data.read_triplets_by_rows(row_indices)?; + }; - // All-cells: pre-seed every matched position so zero-signal sites - // still appear (axis markers), matching the legacy behavior. - if membership.is_none() { - let g = by_group.entry("".into()).or_default(); - for &(pos, is_converted) in &local_to_pos { - if is_converted { - g.entry(pos).or_insert(PosAgg { sum: 0.0, nnz: 0 }); - } + let local_to_pos: Vec<(i64, bool)> = matched_rows.iter().map(|r| (r.1, r.2)).collect(); + let row_indices: Vec = matched_rows.iter().map(|r| r.0).collect(); + let (_nrow, _ncol, triplets) = data.read_triplets_by_rows(row_indices)?; + + // All-cells: pre-seed every matched position so zero-signal sites + // still appear (axis markers), matching the legacy behavior. + if membership.is_none() { + let g = part.by_group.entry("".into()).or_default(); + for &(pos, is_converted) in &local_to_pos { + if is_converted { + g.entry(pos).or_default(); } } + } - for (row, col, val) in &triplets { - let local_idx = *row as usize; - if local_idx >= local_to_pos.len() || *val == 0.0 { - continue; - } - let group: Box = match &col_groups { - None => "".into(), - Some(cg) => match cg.get(*col as usize).and_then(|o| o.clone()) { - Some(ct) => ct, - None => continue, - }, - }; - let (pos, is_converted) = local_to_pos[local_idx]; - if with_total { - *totals.entry(pos).or_insert(0.0) += *val as f64; - } - if !is_converted { - continue; - } - let agg = by_group - .entry(group) - .or_default() - .entry(pos) - .or_insert(PosAgg { sum: 0.0, nnz: 0 }); - agg.sum += *val as f64; - agg.nnz += 1; + for (row, col, val) in &triplets { + let local_idx = *row as usize; + if local_idx >= local_to_pos.len() || *val == 0.0 { + continue; + } + let group: Box = match &col_groups { + None => "".into(), + Some(cg) => match cg.get(*col as usize).and_then(|o| o.clone()) { + Some(ct) => ct, + None => continue, + }, + }; + let (pos, is_converted) = local_to_pos[local_idx]; + if with_total { + *part.totals.entry(pos).or_insert(0.0) += *val as f64; + } + if !is_converted { + continue; } + let agg = part + .by_group + .entry(group) + .or_default() + .entry(pos) + .or_default(); + agg.sum += *val as f64; + agg.nnz += 1; } + Ok(part) +} + +pub(super) fn read_matrix_positions_grouped( + data_files: &[Box], + selector: &Selector, + signal: &PileupSignal, + membership: Option<&CellMembership>, + top_modality: &[Box], + with_total: bool, + done: Option<&AtomicUsize>, +) -> anyhow::Result { + // Only the rows of these modalities, when some are named. + let modality_filter = (!top_modality.is_empty()).then_some(top_modality); + + // The files are independent: read them together, then merge in file + // order. Multiple input files (e.g. replicates) merge per genomic + // position; gene/chr labels reflect the union. + let parts: Vec> = { + use rayon::prelude::*; + data_files + .par_iter() + .map(|f| { + let part = read_one_file(f, selector, membership, modality_filter, with_total); + if let Some(done) = done { + done.fetch_add(1, Ordering::Relaxed); + } + part + }) + .collect() + }; + let mut all = FileRead::default(); + for part in parts { + all.merge(part?); + } + let FileRead { + by_group, + distinct_genes, + matched_chrs, + matched: total_matched, + first_modality, + totals, + unconverted_rows, + } = all; // The selection can span several genes (and chromosomes); rather than // erroring on ambiguity we pile them together and label the aggregate. @@ -388,9 +457,10 @@ pub(super) fn read_matrix_positions( selector: &Selector, signal: &PileupSignal, with_total: bool, + done: Option<&AtomicUsize>, ) -> anyhow::Result> { let grouped = - read_matrix_positions_grouped(data_files, selector, signal, None, &[], with_total)?; + read_matrix_positions_grouped(data_files, selector, signal, None, &[], with_total, done)?; if grouped.matched == 0 { return Ok(None); } diff --git a/src/site_analysis/pileup/tests.rs b/src/site_analysis/pileup/tests.rs index 49c5b75..1e29ee8 100644 --- a/src/site_analysis/pileup/tests.rs +++ b/src/site_analysis/pileup/tests.rs @@ -303,3 +303,80 @@ fn an_alt_contig_position_is_not_read_as_a_gene() { // The shared gene rule leaves a coordinate whole. assert_eq!(&*query_symbol(gp), gp); } + +/// `faba pileup` with `args`, as its command line would give them. +fn pileup_args(args: &[&str]) -> PileupArgs { + use clap::FromArgMatches; + let cmd = crate::faba_command() + .find_subcommand("pileup") + .unwrap() + .clone(); + let m = cmd + .try_get_matches_from(std::iter::once("pileup").chain(args.iter().copied())) + .unwrap(); + PileupArgs::from_arg_matches(&m).unwrap() +} + +#[test] +fn an_output_directory_gives_a_track_per_batch_and_its_site_table() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + for f in [ + "wt_m6a_site.zarr.zip", + "mut_m6a_site.zarr.zip", + "wt_m6a.zarr.zip", + "m6a_sites.parquet", + "pipeline_summary.json", + ] { + std::fs::write(dir.join(f), b"").unwrap(); + } + let path = |f: &str| dir.join(f).to_string_lossy().into_owned(); + for given in [ + dir.to_string_lossy().into_owned(), + path("pipeline_summary.json"), + ] { + let (args, groups) = resolve(&[&given]); + let files: Vec<&str> = args.data_files.iter().map(|f| &**f).collect(); + assert_eq!( + files, + [path("mut_m6a_site.zarr.zip"), path("wt_m6a_site.zarr.zip")], + "site matrices only" + ); + assert_eq!(labels(&groups), ["mut", "wt"], "a track per batch"); + assert_eq!(args.site_file.as_deref(), Some(&*path("m6a_sites.parquet"))); + } + let d = dir.to_string_lossy().into_owned(); + // A site table given keeps its place. + let (args, _) = resolve(&[&d, "-s", "x.parquet"]); + assert_eq!(args.site_file.as_deref(), Some("x.parquet")); + // A matrix named beside the directory has its own track. + let (_, groups) = resolve(&[&d, "extra_m6a_site.zarr.zip"]); + assert_eq!( + labels(&groups), + ["mut", "wt", "extra"], + "named after its batch" + ); + // --track regroups them all. + let (_, groups) = resolve(&[&d, "--track", "all=*"]); + assert_eq!(labels(&groups), ["all"]); + assert_eq!(groups[0].files.len(), 2); + // Matrices named one by one make one track, as before. + let one = path("wt_m6a_site.zarr.zip"); + let (args, groups) = resolve(&[&one]); + assert_eq!(args.data_files, vec![Box::from(one.as_str())]); + assert_eq!(labels(&groups), ["matrix"]); + // --separate: a track each, by batch. + let two = [path("wt_m6a_site.zarr.zip"), path("mut_m6a_site.zarr.zip")]; + let (_, groups) = resolve(&[&two[0], &two[1], "--separate"]); + assert_eq!(labels(&groups), ["wt", "mut"]); +} + +/// `faba pileup` with `args`, resolved: the inputs and the tracks. +fn resolve(args: &[&str]) -> (PileupArgs, Vec) { + let args = pileup_args(args); + args.resolved().unwrap() +} + +fn labels(groups: &[TrackFiles]) -> Vec<&str> { + groups.iter().map(|g| &*g.label).collect() +} diff --git a/src/site_analysis/pileup/tests/tui.rs b/src/site_analysis/pileup/tests/tui.rs index 18c3ee7..07917a4 100644 --- a/src/site_analysis/pileup/tests/tui.rs +++ b/src/site_analysis/pileup/tests/tui.rs @@ -79,6 +79,17 @@ fn zoom_keeps_the_cursor_and_stops_at_a_base_per_bar() { press(&mut v, KeyCode::Char('-')); } assert_eq!(v.window, EXTENT); + // Past the whole extent, `-` asks for twice its span around it. + assert_eq!( + v.exit, + Some(Exit::Locus { + lo: 950_000, + hi: 1_150_000, + at: c + }), + "the cursor kept" + ); + v.exit = None; press(&mut v, KeyCode::Char('+')); press(&mut v, KeyCode::Char('0')); assert_eq!(v.window, EXTENT); @@ -508,3 +519,17 @@ fn unlike_tracks_are_not_mirrored() { ); assert_eq!(v.rows(), vec![Row::Track(0), Row::Track(1)]); } + +#[test] +fn shift_m_switches_modality_only_where_it_can() { + let (m, s) = (positions(), sites()); + let mut v = view(&m, &s); + screen(&mut v, 100, 24); + press(&mut v, KeyCode::Char('n')); + press(&mut v, KeyCode::Char('M')); + assert_eq!(v.exit, None, "named files: nothing to switch to"); + v.switchable = true; + assert!(screen(&mut v, 200, 24).contains("m6A/A-to-I")); + press(&mut v, KeyCode::Char('M')); + assert_eq!(v.exit, Some(Exit::Modality { at: v.cursor })); +} diff --git a/src/site_analysis/pileup/tui.rs b/src/site_analysis/pileup/tui.rs index 97ddaac..55327e2 100644 --- a/src/site_analysis/pileup/tui.rs +++ b/src/site_analysis/pileup/tui.rs @@ -54,6 +54,10 @@ pub struct View<'a> { pub keys: Option<&'a [Box]>, /// The annotation's gene models, possibly still loading. pub genes: Option, + /// Where the cursor starts; the first site when `None`. + pub cursor: Option, + /// Whether `M` switches to the other modality's sites. + pub switchable: bool, } /// Minimum gap between genes in one lane, as a share of the window, so labels stay apart. @@ -75,8 +79,22 @@ pub enum Exit { Genes, /// `/`: a gene or locus the view cannot show itself. Search(String), + /// `-` past the whole view: this chromosome's `lo..=hi`, wider, the + /// cursor kept `at`. + Locus { + lo: i64, + hi: i64, + at: i64, + }, + /// `M`: the other modality's sites here, the cursor kept `at`. + Modality { + at: i64, + }, } +/// The widest span `-` reloads. +const MAX_SPAN: i64 = 10_000_000; + /// State of the browser, independent of the terminal so it can be tested. pub struct PileupView<'a> { title: String, @@ -113,6 +131,8 @@ pub struct PileupView<'a> { genes: Vec, /// The annotation while it is still loading. pending_genes: Option, + /// Whether `M` switches to the other modality's sites. + switchable: bool, exit: Option, } @@ -157,6 +177,7 @@ impl<'a> PileupView<'a> { mirror: true, genes: Vec::new(), pending_genes: None, + switchable: false, exit: None, } } @@ -428,6 +449,26 @@ impl<'a> PileupView<'a> { self.clamp_window(); } + /// Zoom out twice as wide; with all of the view's own span in view, + /// ask the caller for twice that span around it, flanks and their + /// genes included, up to [`MAX_SPAN`]. + fn zoom_out(&mut self) { + let (lo, hi) = self.extent; + if self.window != self.extent { + return self.zoom(2.0); + } + if hi - lo >= MAX_SPAN { + self.status = Some("zoomed out as far as it goes".into()); + return; + } + let pad = ((hi - lo) / 2).max(self.columns as i64); + self.exit = Some(Exit::Locus { + lo: (lo - pad).max(1), + hi: hi + pad, + at: self.cursor, + }); + } + /// A `/` search: a locus in this view moves there; anything else exits to the caller. fn submit(&mut self, query: &str) { match super::parse_query(query) { @@ -674,6 +715,9 @@ impl Screen for PileupView<'_> { }) } KeyCode::Char('m') => self.mirror = !self.mirror, + KeyCode::Char('M') if self.switchable => { + self.exit = Some(Exit::Modality { at: self.cursor }) + } KeyCode::Left | KeyCode::Char('h') => self.move_cursor(-1), KeyCode::Right | KeyCode::Char('l') => self.move_cursor(1), KeyCode::PageUp => self.move_cursor(-(self.columns as i64) / 2), @@ -681,7 +725,7 @@ impl Screen for PileupView<'_> { KeyCode::Char('n') => self.jump_site(true), KeyCode::Char('p') => self.jump_site(false), KeyCode::Char('+' | '=') => self.zoom(0.5), - KeyCode::Char('-') => self.zoom(2.0), + KeyCode::Char('-') => self.zoom_out(), KeyCode::Char('0') => self.window = self.extent, KeyCode::Char('y') => self.y_scale = self.y_scale.next(), KeyCode::Char('g') => self.exit = Some(Exit::Genes), @@ -898,6 +942,9 @@ impl PileupView<'_> { if self.mirrorable() { keys.push(("m", if self.mirror { "split" } else { "mirror" })); } + if self.switchable { + keys.push(("M", "m6A/A-to-I")); + } self.controls.help_keys(&mut keys); keys.push(("q", "quit")); help_line(&keys) @@ -913,6 +960,10 @@ pub fn show_pileup(view: View, tracks: Vec, status: Option) -> an browser.keys = view.keys.map(<[_]>::to_vec); browser.pending_genes = view.genes; browser.take_genes(); + browser.switchable = view.switchable; + if let Some(at) = view.cursor { + browser.cursor = at.clamp(browser.extent.0, browser.extent.1); + } browser.status = status.or_else(|| { let loading = browser.pending_genes.is_some(); loading.then(|| LOADING.to_string()) diff --git a/src/site_analysis/tests/input_picker.rs b/src/site_analysis/tests/input_picker.rs new file mode 100644 index 0000000..78b2d31 --- /dev/null +++ b/src/site_analysis/tests/input_picker.rs @@ -0,0 +1,150 @@ +use super::*; +use ratatui::crossterm::event::KeyModifiers; + +fn press(p: &mut InputPicker, code: KeyCode) { + crate::tui::feed(p, KeyEvent::new(code, KeyModifiers::NONE)); +} + +/// `root/{plain/, out/{a_m6a_site.zarr.zip, b_m6a_site.zarr.zip, m6a_sites.parquet}}`. +fn tree() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + let out = tmp.path().join("out"); + std::fs::create_dir_all(tmp.path().join("plain")).unwrap(); + std::fs::create_dir_all(&out).unwrap(); + for f in [ + "a_m6a_site.zarr.zip", + "b_m6a_site.zarr.zip", + "m6a_sites.parquet", + ] { + std::fs::write(out.join(f), b"").unwrap(); + } + tmp +} + +fn site_matrix(name: &str) -> bool { + crate::qc::layout::is_site_matrix_name(name) +} + +/// A pileup picker at `cwd`, opening at `last` when given. +fn picker(cwd: PathBuf, last: Option<&Path>) -> InputPicker { + InputPicker::new(cwd, last, "pileup", "site matrices", site_matrix, true) +} + +fn text(p: &Path) -> Box { + p.to_string_lossy().into_owned().into_boxed_str() +} + +#[test] +fn mark_files_from_a_folder_or_choose_the_folder() { + let tmp = tree(); + let root = tmp.path().to_path_buf(); + let mut p = picker(root.clone(), None); + assert!(p.browser.list.select("plain")); + press(&mut p, KeyCode::Char(' ')); + assert!( + p.decision.is_none() && p.error.is_some(), + "not a faba folder" + ); + + // A faba folder, chosen whole. + assert!(p.browser.list.select("out")); + press(&mut p, KeyCode::Char(' ')); + let folder = Chosen { + paths: vec![text(&root.join("out"))], + separate: false, + }; + assert_eq!(p.decision, Some(Some(folder))); + + // Or opened, and only its site matrices listed and marked. + let mut p = picker(root.join("out"), None); + let names: Vec<&str> = p.browser.list.shown().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["..", "a_m6a_site.zarr.zip", "b_m6a_site.zarr.zip"]); + for (name, key) in [ + ("b_m6a_site.zarr.zip", KeyCode::Char(' ')), + ("a_m6a_site.zarr.zip", KeyCode::Enter), + ("b_m6a_site.zarr.zip", KeyCode::Char(' ')), + ("b_m6a_site.zarr.zip", KeyCode::Char(' ')), + ] { + assert!(p.browser.list.select(name)); + press(&mut p, key); + } + let out = root.join("out"); + let marked = vec![ + out.join("a_m6a_site.zarr.zip"), + out.join("b_m6a_site.zarr.zip"), + ]; + assert_eq!( + p.browser.marked.as_ref(), + Some(&marked), + "unmarked and marked again, in the order marked" + ); + // Tab: a track each. + press(&mut p, KeyCode::Tab); + crate::tui::feed(&mut p, crate::tui::apply_key()); + let chosen = Chosen { + paths: marked.iter().map(|m| text(m)).collect(), + separate: true, + }; + assert_eq!( + p.decision, + Some(Some(chosen)), + "the marked, apart, not the folder" + ); +} + +#[test] +fn draws_at_any_terminal_size() { + let tmp = tree(); + let mut p = InputPicker::new( + tmp.path().join("out"), + None, + "metagene", + "site tables", + |n| n.ends_with("_sites.parquet"), + false, + ); + p.browser + .toggle_mark(tmp.path().join("out/m6a_sites.parquet")); + for (w, h) in [(20, 6), (80, 24), (200, 60)] { + let mut t = ratatui::Terminal::new(ratatui::backend::TestBackend::new(w, h)).unwrap(); + t.draw(|f| p.render(f)).unwrap(); + } +} + +#[test] +fn opens_at_the_last_choice() { + let tmp = tree(); + let root = tmp.path().to_path_buf(); + let p = picker(root.clone(), Some(&root.join("out/b_m6a_site.zarr.zip"))); + assert_eq!(p.browser.cwd, root.join("out")); + assert_eq!( + p.browser.highlighted().map(|e| e.name.as_str()), + Some("b_m6a_site.zarr.zip") + ); + // Gone since: where it was asked from. + let p = picker(root.clone(), Some(&root.join("gone/x_m6a_site.zarr.zip"))); + assert_eq!(p.browser.cwd, root); +} + +#[test] +fn the_last_choice_is_kept_per_working_folder() { + let tmp = tempfile::tempdir().unwrap(); + let memo = tmp.path().join("cache/faba/last_input"); + let (a, b) = (Path::new("/work/a"), Path::new("/work/b")); + assert_eq!(recall(&memo, a), None, "nothing kept yet"); + remember(&memo, a, "/work/a/out"); + remember(&memo, b, "/work/b/x_m6a_site.zarr.zip"); + remember(&memo, a, "/work/a/out2"); + assert_eq!( + recall(&memo, a), + Some(PathBuf::from("/work/a/out2")), + "the latest" + ); + assert_eq!( + recall(&memo, b), + Some(PathBuf::from("/work/b/x_m6a_site.zarr.zip")), + "each folder its own" + ); + let kept = std::fs::read_to_string(&memo).unwrap(); + assert_eq!(kept.lines().count(), 2, "one line per folder"); +} diff --git a/src/site_analysis/tests/output_dir.rs b/src/site_analysis/tests/output_dir.rs new file mode 100644 index 0000000..a9c8b25 --- /dev/null +++ b/src/site_analysis/tests/output_dir.rs @@ -0,0 +1,37 @@ +use super::*; + +fn touch(dir: &Path, name: &str) { + std::fs::write(dir.join(name), b"").unwrap(); +} + +#[test] +fn a_directory_gives_its_site_matrices_by_batch_and_its_site_table() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + for f in [ + "b2_m6a_site.zarr.zip", + "b1_m6a_site.zarr.zip", + "b1_m6a.zarr.zip", + "b1_atoi_site.zarr.zip", + "m6a_sites.parquet", + "pipeline_summary.json", + ] { + touch(dir, f); + } + let m6a = output_sites(dir, None).unwrap(); + assert_eq!(&*m6a.modality, "m6a", "m6A first"); + let batches: Vec<&str> = m6a.matrices.iter().map(|(b, _)| &**b).collect(); + assert_eq!(batches, ["b1", "b2"], "site matrices only, by batch"); + assert!(m6a.site_table.unwrap().ends_with("m6a_sites.parquet")); + let atoi = output_sites(dir, Some("atoi")).unwrap(); + assert_eq!(atoi.matrices.len(), 1); + assert!(atoi.site_table.is_none()); + assert!(output_sites(dir, Some("apa")).is_err()); + + // A run record names its folder; a matrix or another file does not. + let record = dir.join("pipeline_summary.json"); + let named = crate::run_record::output_dir_of(&record.to_string_lossy()).unwrap(); + assert_eq!(named, dir); + let matrix = dir.join("b1_m6a.zarr.zip"); + assert!(crate::run_record::output_dir_of(&matrix.to_string_lossy()).is_none()); +} diff --git a/src/tui.rs b/src/tui.rs index 557dbfe..7781811 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -4,12 +4,19 @@ //! folder. pub mod browser; +pub mod busy; + +pub use busy::{busy, busy_counting}; + +/// Frames of the spinners the workspace draws. +pub const SPINNER: &str = "⠁⠂⠄⡀⢀⠠⠐⠈"; use std::path::{Path, PathBuf}; -use data_beans::interactive::ui::{panel, Screen, HIGHLIGHT}; +use data_beans::interactive::ui::{panel, Screen, DIM, HIGHLIGHT}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent}; use ratatui::layout::Rect; +use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Clear, Paragraph}; use ratatui::Frame; @@ -126,6 +133,16 @@ pub fn tab_bar( Line::from(spans) } +/// A `width`-cell bar for `done` of `total`: the filled part in `on`, the +/// rest dim. +pub fn bar_spans(done: u64, total: u64, width: usize, on: Style) -> [Span<'static>; 2] { + let full = filled(done, total, width); + [ + Span::styled("█".repeat(full), on), + Span::styled("░".repeat(width.saturating_sub(full)), DIM), + ] +} + /// How many of `width` cells a bar shows filled for `done` of `total`. pub fn filled(done: u64, total: u64, width: usize) -> usize { match total { diff --git a/src/tui/browser.rs b/src/tui/browser.rs index 8465faf..249b761 100644 --- a/src/tui/browser.rs +++ b/src/tui/browser.rs @@ -70,6 +70,9 @@ pub struct Browser { /// The listing: `..` first unless at the root, then subfolders, then /// files, each sorted, narrowed by what has been typed. pub list: FindList, + /// The files marked, in the order marked, from any folder; `None` when + /// this browser does not mark. + pub marked: Option>, keep: Box bool>, tag: Box bool>, } @@ -85,11 +88,31 @@ impl Browser { Self { cwd, list: FindList::default(), + marked: None, keep: Box::new(keep), tag: Box::new(tag), } } + /// Marking files, with none marked yet. + pub fn marking(mut self) -> Self { + self.marked = Some(Vec::new()); + self + } + + /// Mark `file`, or unmark it if it was; nothing when not marking. + pub fn toggle_mark(&mut self, file: PathBuf) { + let Some(marked) = &mut self.marked else { + return; + }; + match marked.iter().position(|m| *m == file) { + Some(i) => { + marked.remove(i); + } + None => marked.push(file), + } + } + /// Listed at its folder. pub fn opened(mut self) -> Self { self.open(self.cwd.clone()); @@ -110,7 +133,7 @@ impl Browser { let is_dir = e.file_type().is_ok_and(|t| t.is_dir()) || e.path().is_dir(); if is_dir && !name.ends_with(".zarr") { dirs.push(name); - } else if !is_dir && (self.keep)(&name) { + } else if (self.keep)(&name) { files.push(name); } } @@ -187,6 +210,7 @@ impl Browser { /// `rows` lines of the listing around the cursor, `width` wide; marked /// entries carry `tag`, and an empty listing says `empty`. + /// With marking on, each file has a `[x]`/`[ ]` box. pub fn lines(&self, rows: usize, width: usize, tag: &str, empty: &str) -> Vec> { if self.list.is_empty() { let why = self.list.empty_note(empty); @@ -203,7 +227,13 @@ impl Browser { } else { String::new() }; - let name_w = width.saturating_sub(tag.len() + 3); + let check = match &self.marked { + None => "", + Some(_) if e.dir => " ", + Some(m) if m.contains(&e.path) => "[x] ", + Some(_) => "[ ] ", + }; + let name_w = width.saturating_sub(tag.len() + check.len() + 3); let name = if e.dir { format!("{}/", e.name) } else { @@ -216,6 +246,7 @@ impl Browser { }; Line::from(vec![ marker(selected), + Span::styled(check, HIGHLIGHT), Span::styled(format!("{name: { + badge: &'a str, + what: &'a str, + start: Instant, + rx: &'a Receiver, + out: Option, + /// Files done, of `total`; no bar when `total` is 0. + done: &'a AtomicUsize, + total: usize, +} + +impl View for Busy<'_, T> {} + +impl Screen for Busy<'_, T> { + fn render(&mut self, frame: &mut Frame) { + let area = frame.area(); + frame.render_widget(header(self.badge, "loading", ""), area); + let elapsed = self.start.elapsed(); + let frames: Vec = SPINNER.chars().collect(); + let spin = frames[(elapsed.as_millis() / 200) as usize % frames.len()]; + let w = (self.what.chars().count() as u16 + 12) + .max(40) + .min(area.width); + let inner = popup_frame(frame, area, w, 4, " working ".into()); + let secs = format!("{}s", elapsed.as_secs()); + let second = if self.total == 0 { + Line::from(Span::styled(format!(" {secs}"), DIM)) + } else { + let done = self.done.load(Ordering::Relaxed).min(self.total); + let count = format!(" {done}/{} files {secs}", self.total); + let bar_w = (inner.width as usize).saturating_sub(count.len() + 4); + let [on, off] = bar_spans(done as u64, self.total as u64, bar_w, HIGHLIGHT); + Line::from(vec![Span::raw(" "), on, off, Span::styled(count, DIM)]) + }; + let lines = vec![ + Line::from(vec![ + Span::styled(format!(" {spin} "), HIGHLIGHT), + Span::raw(self.what.to_string()), + ]), + second, + ]; + frame.render_widget(Paragraph::new(lines), inner); + } + + /// Keys wait: the work cannot be stopped halfway. + fn handle_key(&mut self, _key: KeyEvent) {} + + fn interrupt(&mut self) {} + + fn done(&self) -> bool { + self.out.is_some() + } + + /// Pick up the result, else redraw for the spinner and the clock. + fn tick(&mut self) -> bool { + if let Ok(out) = self.rx.try_recv() { + self.out = Some(out); + } + true + } +} + +/// Run `work`, and when `show` (and there is a terminal) and it takes more +/// than a moment, show a spinner saying `what` it is doing under `badge` +/// until it is done. +pub fn busy( + show: bool, + badge: &str, + what: &str, + work: impl FnOnce() -> anyhow::Result + Send, +) -> anyhow::Result { + busy_counting(show, badge, what, 0, |_| work()) +} + +/// [`busy`], with a bar of `total` files that `work` counts off on the +/// counter it is given. +pub fn busy_counting( + show: bool, + badge: &str, + what: &str, + total: usize, + work: impl FnOnce(&AtomicUsize) -> anyhow::Result + Send, +) -> anyhow::Result { + let done = AtomicUsize::new(0); + if !show || !data_beans::interactive::tui_available() { + return work(&done); + } + let (tx, rx) = channel(); + let counter = &done; + std::thread::scope(|s| { + s.spawn(move || { + // The receiver outlives the scope, so the send cannot fail. + let _ = tx.send(work(counter)); + }); + match rx.recv_timeout(GRACE) { + Ok(out) => return out, + Err(RecvTimeoutError::Disconnected) => anyhow::bail!("{what}: the work panicked"), + Err(RecvTimeoutError::Timeout) => {} + } + let mut screen = Busy { + badge, + what, + start: Instant::now(), + rx: &rx, + out: None, + done: &done, + total, + }; + run_view(&mut screen)?; + match screen.out { + Some(out) => out, + None => rx + .recv() + .map_err(|_| anyhow::anyhow!("{what}: the work panicked"))?, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn draws_at_any_terminal_size_and_ends_with_the_result() { + let (tx, rx) = channel(); + let done = AtomicUsize::new(1); + let mut b = Busy { + badge: "pileup", + what: "reading GENE1", + start: Instant::now(), + rx: &rx, + out: None, + done: &done, + total: 3, + }; + b.tick(); + assert!(!b.done()); + for (w, h) in [(10, 3), (80, 24), (200, 60)] { + let mut t = ratatui::Terminal::new(ratatui::backend::TestBackend::new(w, h)).unwrap(); + t.draw(|f| b.render(f)).unwrap(); + } + tx.send(7).unwrap(); + b.tick(); + assert!(b.done()); + assert_eq!(b.out, Some(7)); + } + + #[test] + fn without_showing_it_just_runs() { + assert_eq!(busy(false, "x", "y", || Ok(3)).unwrap(), 3); + assert!(busy(false, "x", "y", || anyhow::bail!("no") + as anyhow::Result<()>) + .is_err()); + } +}