From dfb1604f9fc489064743d2925afc78d059cc144e Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 6 Oct 2026 10:24:29 -0700 Subject: [PATCH 1/4] pileup and metagene: browse by default, output folders as input, a file picker, wider zoom-out (0.16.6) - `faba pileup` and `faba metagene` open full screen by default; `--batch-process` (alias `--batch-mode`) prints or writes instead, as they do without a terminal. `--interactive` is still accepted, hidden. - Either takes a faba output directory, or a run record in it, in place of its files: pileup takes the site matrices of `--modality` (m6A, else A-to-I), one track per batch, with the site table and recorded GFF; metagene profiles the site table against the recorded GFF. - With no input, a browser asks: mark files to read together (pileup sums the marked matrices into one track, metagene profiles the marked site tables as one set of sites), or choose a whole output folder. Metagene also takes several site tables on the command line. - `--track` now regroups a directory's matrices too, instead of being dropped when a directory is given. - In the pileup browser, `-` past the whole gene reloads a span twice as wide, flanks and their genes included. - A spinner shows while the browsers load, when a load takes more than a moment. - Marking lives in the shared folder browser, which now also lists unzipped `.zarr` stores; one spinner frame set, one list of site modalities and one site-table name test. --- Cargo.toml | 2 +- docs/profiling-methods.md | 9 +- src/main.rs | 42 +++-- src/pipeline/tui/child.rs | 5 +- src/qc/layout.rs | 31 +++- src/qc/path_tui.rs | 10 +- src/run_record.rs | 35 +++- src/site_analysis/input_picker.rs | 185 +++++++++++++++++++ src/site_analysis/metagene.rs | 191 ++++++++++++++++--- src/site_analysis/metagene/tests.rs | 50 +++++ src/site_analysis/mod.rs | 2 + src/site_analysis/output_dir.rs | 62 +++++++ src/site_analysis/pileup.rs | 232 +++++++++++++++++++----- src/site_analysis/pileup/tests.rs | 69 +++++++ src/site_analysis/pileup/tests/tui.rs | 3 + src/site_analysis/pileup/tui.rs | 23 ++- src/site_analysis/tests/input_picker.rs | 84 +++++++++ src/site_analysis/tests/output_dir.rs | 37 ++++ src/tui.rs | 6 + src/tui/browser.rs | 35 +++- src/tui/busy.rs | 142 +++++++++++++++ 21 files changed, 1135 insertions(+), 120 deletions(-) create mode 100644 src/site_analysis/input_picker.rs create mode 100644 src/site_analysis/output_dir.rs create mode 100644 src/site_analysis/tests/input_picker.rs create mode 100644 src/site_analysis/tests/output_dir.rs create mode 100644 src/tui/busy.rs 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/docs/profiling-methods.md b/docs/profiling-methods.md index 246c93d..2600946 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -641,7 +641,14 @@ None of these fit a model or produce a p-value. which on a 3′-biased library means the last bins of the 3′UTR; a terminal peak is not evidence of enrichment on its own. -The `--interactive` views of `pileup` and `metagene` draw the converted reads by default, where a +`pileup` and `metagene` open full screen by default (`--batch-process` prints instead, as they do +without a terminal), and either takes a faba output directory, or a run record in it, in place of +its files: `pileup` then takes the directory's site matrices of `--modality` (m6A, else A-to-I), +one track per batch, with its `{modality}_sites.parquet` and the GFF its run record names, and +`metagene` profiles that site table against that GFF. Given no input, they open a browser: mark files +(Space) to read together, `pileup` summing the marked matrices into one track and `metagene` +profiling the marked site tables as one set of sites against the first one's GFF, or choose a +whole output folder. The views draw the converted reads by default, where a track carries both channels (in `metagene`, where the site table has `converted` and `coverage` columns). `c` cycles what the bars count: converted reads, unconverted reads (the total less the converted), both (the converted in front of the total), and the sites themselves. In `pileup`, `d` 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/qc/layout.rs b/src/qc/layout.rs index 5a5de01..749f108 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,28 @@ 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 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 +181,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/run_record.rs b/src/run_record.rs index a8d81c6..73be62c 100644 --- a/src/run_record.rs +++ b/src/run_record.rs @@ -202,15 +202,7 @@ fn find_recorded(near: &str, key: &str) -> 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..28fbc3b --- /dev/null +++ b/src/site_analysis/input_picker.rs @@ -0,0 +1,185 @@ +//! 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. + +use std::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}; + +/// 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, + /// Why the last choice was refused. + pub error: Option, + /// The files or the folder chosen; `Some(None)` when cancelled. + pub decision: Option>>, +} + +impl InputPicker { + /// Listing `cwd`'s folders and the files `keep` accepts by name. + pub fn new( + cwd: PathBuf, + command: &'static str, + what: &'static str, + keep: fn(&str) -> bool, + ) -> Self { + let mut browser = faba_browser(cwd.clone(), keep).marking(); + browser.open(cwd); + Self { + browser, + command, + what, + error: None, + decision: None, + } + } + + /// 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.decision = Some(Some(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()); + } + self.decision = Some(Some(marked)); + return; + } + 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::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 { + 0 => String::new(), + n => 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" + }; + lines.push(help_line(&[ + ("type", "find"), + ("↑/↓", "move"), + ("Enter/→", "open"), + ("←", "up"), + ("Space", "mark file / choose folder"), + (APPLY_KEYS, apply), + ("Esc", "clear find / cancel"), + ])); + frame.render_widget(Paragraph::new(lines), inner); + } +} + +/// 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, +) -> anyhow::Result>>> { + anyhow::ensure!( + !batch && data_beans::interactive::tui_available(), + "name {what}, or a faba output directory" + ); + let mut picker = InputPicker::new(std::env::current_dir()?, command, what, keep); + run_view(&mut picker)?; + Ok(picker.decision.flatten().map(|paths| { + paths + .iter() + .map(|p| p.to_string_lossy().into_owned().into_boxed_str()) + .collect() + })) +} + +#[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..ad2c3fd 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" )] - site_file: Box, + sites: Option>, + + #[arg( + long = "modality", + value_name = "m6a|atoi", + help = "Which site table to take from an output directory (default: m6a, else atoi)" + )] + 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,125 @@ 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(), + ); + let Some(chosen) = ask? else { return Ok(None) }; + given = chosen; + } + 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. + let mut sites = Vec::new(); + let mut reads = Some(Vec::new()); + for f in site_files { + let these = read_sites(f)?; + let these_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 + }); + 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 +1132,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..e3580bb 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,24 @@ 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, + + /// 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 +231,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." )] @@ -499,6 +527,22 @@ fn load( }) } +/// [`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 text = format!("reading {what} from {} file(s)", args.data_files.len()); + crate::tui::busy(browser, "pileup", &text, || { + load(args, groups, selector, browser, locus) + }) +} + /// The gene models to draw: those of the matched gene `keys`, by key or /// failing that by symbol (a GTF may version its gene ids differently), or /// every model for a searched locus (`None`). @@ -587,13 +631,28 @@ 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 list = crate::tui::busy(true, "pileup", &what, || picker::gene_catalog(self.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 +668,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 +679,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), } } @@ -676,12 +738,14 @@ fn interactive( let query = match choice { picker::Choice::Quit => return Ok(()), picker::Choice::Gene(i) => { - current = Some(load( + let gene = &genes[i].gene; + current = Some(loading( + true, args, groups, - &Selector::exact(&genes[i].gene), - true, + &Selector::exact(gene), None, + gene, )?); continue; } @@ -700,6 +764,13 @@ fn interactive( match browse(args, &loaded, genes.as_ref(), status.take())? { tui::Exit::Quit => return Ok(()), tui::Exit::Genes => {} + tui::Exit::Locus(lo, hi) => match load_locus(args, groups, &loaded.chr, lo, hi) { + Ok(l) => current = Some(l), + Err(msg) => { + status = Some(msg); + current = Some(loaded); + } + }, tui::Exit::Search(q) => match search(args, groups, &catalog, &q) { Ok(Found::View(l)) => current = Some(l), Ok(Found::List) => filter = q, @@ -713,6 +784,60 @@ fn interactive( } impl PileupArgs { + /// These arguments with `given` as the inputs, each output directory + /// (or run record) among them 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 one per batch and one per + /// file named beside it, else one of them all. + fn resolved(&self, given: &[Box]) -> 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 given { + let Some(dir) = crate::run_record::output_dir_of(f) else { + files.push(f.clone()); + own.push(TrackFiles { + label: crate::qc::layout::file_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.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 +847,32 @@ 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 given = if args.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, + ); + let Some(files) = ask? else { return Ok(()) }; + files + } else { + args.data_files.clone() + }; + let (args, groups) = &args.resolved(&given)?; + // 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(args, groups, None); } let selector = Selector::build(&args.genes, &args.regions)?; if figure_mode { @@ -746,7 +882,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(args, groups, 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 +935,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 +947,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/tests.rs b/src/site_analysis/pileup/tests.rs index 49c5b75..7c9083d 100644 --- a/src/site_analysis/pileup/tests.rs +++ b/src/site_analysis/pileup/tests.rs @@ -303,3 +303,72 @@ 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_m6a_site.zarr.zip"]); + // --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"]); +} + +/// `faba pileup` with `args`, resolved: the inputs and the tracks. +fn resolve(args: &[&str]) -> (PileupArgs, Vec) { + let args = pileup_args(args); + args.resolved(&args.data_files).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..97a407a 100644 --- a/src/site_analysis/pileup/tests/tui.rs +++ b/src/site_analysis/pileup/tests/tui.rs @@ -79,6 +79,9 @@ 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(950_000, 1_150_000))); + v.exit = None; press(&mut v, KeyCode::Char('+')); press(&mut v, KeyCode::Char('0')); assert_eq!(v.window, EXTENT); diff --git a/src/site_analysis/pileup/tui.rs b/src/site_analysis/pileup/tui.rs index 97ddaac..0a0bd16 100644 --- a/src/site_analysis/pileup/tui.rs +++ b/src/site_analysis/pileup/tui.rs @@ -75,8 +75,13 @@ 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. + Locus(i64, 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, @@ -428,6 +433,22 @@ 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 - pad).max(1), hi + pad)); + } + /// 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) { @@ -681,7 +702,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), diff --git a/src/site_analysis/tests/input_picker.rs b/src/site_analysis/tests/input_picker.rs new file mode 100644 index 0000000..a1dde57 --- /dev/null +++ b/src/site_analysis/tests/input_picker.rs @@ -0,0 +1,84 @@ +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) +} + +#[test] +fn mark_files_from_a_folder_or_choose_the_folder() { + let tmp = tree(); + let root = tmp.path().to_path_buf(); + let mut p = InputPicker::new(root.clone(), "pileup", "site matrices", site_matrix); + 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(' ')); + assert_eq!(p.decision, Some(Some(vec![root.join("out")]))); + + // Or opened, and only its site matrices listed and marked. + let mut p = InputPicker::new(root.join("out"), "pileup", "site matrices", site_matrix); + 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" + ); + crate::tui::feed(&mut p, crate::tui::apply_key()); + assert_eq!(p.decision, Some(Some(marked)), "the marked, not the folder"); +} + +#[test] +fn draws_at_any_terminal_size() { + let tmp = tree(); + let mut p = InputPicker::new(tmp.path().join("out"), "metagene", "site tables", |n| { + n.ends_with("_sites.parquet") + }); + 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(); + } +} 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..29104bd 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -4,6 +4,12 @@ //! folder. pub mod browser; +pub mod busy; + +pub use busy::busy; + +/// Frames of the spinners the workspace draws. +pub const SPINNER: &str = "⠁⠂⠄⡀⢀⠠⠐⠈"; use std::path::{Path, PathBuf}; 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, +} + +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(30) + .min(area.width); + let inner = popup_frame(frame, area, w, 4, " working ".into()); + let lines = vec![ + Line::from(vec![ + Span::styled(format!(" {spin} "), HIGHLIGHT), + Span::raw(self.what.to_string()), + ]), + Line::from(Span::styled(format!(" {}s", elapsed.as_secs()), DIM)), + ]; + 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 { + if !show || !data_beans::interactive::tui_available() { + return work(); + } + let (tx, rx) = channel(); + std::thread::scope(|s| { + s.spawn(move || { + // The receiver outlives the scope, so the send cannot fail. + let _ = tx.send(work()); + }); + 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, + }; + 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 mut b = Busy { + badge: "pileup", + what: "reading GENE1 from 2 file(s)", + start: Instant::now(), + rx: &rx, + out: None, + }; + 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()); + } +} From f57408511ccf7dbb0a33befb0b782590f8dad8ee Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 6 Oct 2026 10:33:47 -0700 Subject: [PATCH 2/4] pileup and metagene: read tracks and site tables in parallel pileup reads each track's matrices on its own thread and metagene each site table, then takes them in order, so an output folder with many batches, or several marked tables, loads in about the time of the slowest one. --- src/site_analysis/metagene.rs | 28 +++++++++++++++++++++------- src/site_analysis/pileup.rs | 28 ++++++++++++++++++---------- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/site_analysis/metagene.rs b/src/site_analysis/metagene.rs index ad2c3fd..39df44a 100644 --- a/src/site_analysis/metagene.rs +++ b/src/site_analysis/metagene.rs @@ -1014,15 +1014,29 @@ fn profile(args: &MetageneArgs, site_files: &[Box]) -> anyhow::Result = { + 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 f in site_files { - let these = read_sites(f)?; - let these_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 - }); + for one in read { + let (these, these_reads) = one?; match (&mut reads, these_reads) { (Some(all), Some(r)) => all.extend(r), _ => reads = None, diff --git a/src/site_analysis/pileup.rs b/src/site_analysis/pileup.rs index e3580bb..7a22d27 100644 --- a/src/site_analysis/pileup.rs +++ b/src/site_analysis/pileup.rs @@ -473,16 +473,24 @@ fn load( 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)) + .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, From 0e3963f4336e72f1f28f35122b1e5443f38520f7 Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 6 Oct 2026 10:47:14 -0700 Subject: [PATCH 3/4] pileup and metagene: progress, separate tracks, modality switch, last folder - The loading pop-up shows a bar of files read, and pileup reads the files of one track in parallel too, merging them in file order. - `-` past the whole gene keeps the gene's title and the cursor. - In the file picker Tab gives each marked matrix its own track, named after its batch; `--separate` does the same on the command line. - `M` in the pileup browser switches an output folder between its m6A and A-to-I sites, at the same gene or span. - The picker opens where the last choice was made. --- docs/profiling-methods.md | 5 +- src/qc/layout.rs | 8 + src/site_analysis/input_picker.rs | 107 +++++++--- src/site_analysis/metagene.rs | 5 +- src/site_analysis/pileup.rs | 175 ++++++++++++++--- src/site_analysis/pileup/figure.rs | 1 + src/site_analysis/pileup/read.rs | 247 +++++++++++++++--------- src/site_analysis/pileup/tests.rs | 12 +- src/site_analysis/pileup/tests/tui.rs | 24 ++- src/site_analysis/pileup/tui.rs | 36 +++- src/site_analysis/tests/input_picker.rs | 55 +++++- src/tui.rs | 2 +- src/tui/busy.rs | 50 ++++- 13 files changed, 567 insertions(+), 160 deletions(-) diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md index 2600946..085e38a 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -648,7 +648,10 @@ one track per batch, with its `{modality}_sites.parquet` and the GFF its run rec `metagene` profiles that site table against that GFF. Given no input, they open a browser: mark files (Space) to read together, `pileup` summing the marked matrices into one track and `metagene` profiling the marked site tables as one set of sites against the first one's GFF, or choose a -whole output folder. The views draw the converted reads by default, where a +whole output folder; Tab gives `pileup` a track per marked matrix instead (`--separate` on the +command line), and the browser opens where the last choice was made. In the `pileup` browser, +`-` past the whole gene reloads a span twice as wide, keeping the cursor, and `M` switches an +output folder between its m6A and A-to-I sites at the same gene. The views draw the converted reads by default, where a track carries both channels (in `metagene`, where the site table has `converted` and `coverage` columns). `c` cycles what the bars count: converted reads, unconverted reads (the total less the converted), both (the converted in front of the total), and the sites themselves. In `pileup`, `d` diff --git a/src/qc/layout.rs b/src/qc/layout.rs index 749f108..84cedf0 100644 --- a/src/qc/layout.rs +++ b/src/qc/layout.rs @@ -116,6 +116,14 @@ pub fn is_site_matrix_name(name: &str) -> bool { }) } +/// 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 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") diff --git a/src/site_analysis/input_picker.rs b/src/site_analysis/input_picker.rs index 28fbc3b..2687725 100644 --- a/src/site_analysis/input_picker.rs +++ b/src/site_analysis/input_picker.rs @@ -1,8 +1,8 @@ //! 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. +//! output folder. It opens where the last choice was made. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use data_beans::interactive::ui::{header, help_line, Screen, HIGHLIGHT}; use ratatui::crossterm::event::{KeyCode, KeyEvent}; @@ -15,6 +15,14 @@ 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. @@ -23,19 +31,24 @@ pub struct InputPicker { 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 files or the folder chosen; `Some(None)` when cancelled. - pub decision: Option>>, + /// The choice; `Some(None)` when cancelled. + pub decision: Option>, } impl InputPicker { - /// Listing `cwd`'s folders and the files `keep` accepts by name. + /// Listing `cwd`'s folders and the files `keep` accepts by name, with + /// Tab to read marked files apart when `separable`. pub fn new( cwd: PathBuf, command: &'static str, what: &'static str, keep: fn(&str) -> bool, + separable: bool, ) -> Self { let mut browser = faba_browser(cwd.clone(), keep).marking(); browser.open(cwd); @@ -43,15 +56,32 @@ impl InputPicker { browser, command, what, + separate: separable.then_some(false), error: None, decision: None, } } + /// Open at `last`'s folder with `last` highlighted. + fn start_at(&mut self, last: &Path) { + let (Some(dir), Some(name)) = (last.parent(), last.file_name()) else { + return; + }; + if dir.is_dir() { + self.browser.open(dir.to_path_buf()); + self.browser.list.select(&name.to_string_lossy()); + } + } + + fn finish(&mut self, paths: Vec) { + let separate = self.separate.unwrap_or(false); + self.decision = Some(Some(Chosen { paths, separate })); + } + /// 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.decision = Some(Some(vec![dir])); + self.finish(vec![dir]); } else { self.error = Some(format!("{} is not a faba output folder", tilde(&dir))); } @@ -86,8 +116,7 @@ impl Screen for InputPicker { self.browser.marked = Some(marked); return self.choose_dir(self.browser.cwd.clone()); } - self.decision = Some(Some(marked)); - return; + return self.finish(marked); } match self.browser.key(key) { Nav::Picked(file) => return self.browser.toggle_mark(file), @@ -106,6 +135,11 @@ impl Screen for InputPicker { self.choose_dir(path); } } + KeyCode::Tab => { + if let Some(s) = &mut self.separate { + *s = !*s; + } + } KeyCode::Esc => self.decision = Some(None), _ => {} } @@ -117,9 +151,11 @@ impl Screen for InputPicker { 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 { - 0 => String::new(), - n => format!("· {n} marked "), + let marked = match (n_marked, self.separate) { + (0, _) => String::new(), + (n, Some(true)) => format!("· {n} marked, a track each "), + (n, Some(false)) => format!("· {n} marked, one track "), + (n, None) => format!("· {n} marked "), }; let title = format!( " {}, or a faba output folder · {} {}{marked}", @@ -143,19 +179,29 @@ impl Screen for InputPicker { } else { "read the marked" }; - lines.push(help_line(&[ + let mut keys = vec![ ("type", "find"), ("↑/↓", "move"), ("Enter/→", "open"), ("←", "up"), ("Space", "mark file / choose folder"), - (APPLY_KEYS, apply), - ("Esc", "clear find / cancel"), - ])); + ]; + match self.separate { + Some(true) => keys.push(("Tab", "one track")), + Some(false) => keys.push(("Tab", "a track each")), + None => {} + } + keys.extend([(APPLY_KEYS, apply), ("Esc", "clear find / cancel")]); + lines.push(help_line(&keys)); frame.render_widget(Paragraph::new(lines), inner); } } +/// Where the last choice is kept, so the picker opens there next time. +fn last_choice_file() -> Option { + Some(crate::tui::home()?.join(".cache/faba/last_input")) +} + /// 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 @@ -165,19 +211,34 @@ pub fn ask_inputs( batch: bool, what: &'static str, keep: fn(&str) -> bool, -) -> anyhow::Result>>> { + separable: bool, +) -> anyhow::Result>, bool)>> { anyhow::ensure!( !batch && data_beans::interactive::tui_available(), "name {what}, or a faba output directory" ); - let mut picker = InputPicker::new(std::env::current_dir()?, command, what, keep); + let mut picker = InputPicker::new(std::env::current_dir()?, command, what, keep, separable); + let memo = last_choice_file(); + if let Some(last) = memo.as_ref().and_then(|m| std::fs::read_to_string(m).ok()) { + picker.start_at(Path::new(last.trim())); + } run_view(&mut picker)?; - Ok(picker.decision.flatten().map(|paths| { - paths - .iter() - .map(|p| p.to_string_lossy().into_owned().into_boxed_str()) - .collect() - })) + let Some(chosen) = picker.decision.flatten() else { + return Ok(None); + }; + // Remembering is a convenience: a failure to write is no failure. + if let (Some(memo), Some(first)) = (memo, chosen.paths.first()) { + if let Some(dir) = memo.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(memo, first.to_string_lossy().as_bytes()); + } + let paths = chosen + .paths + .iter() + .map(|p| p.to_string_lossy().into_owned().into_boxed_str()) + .collect(); + Ok(Some((paths, chosen.separate))) } #[cfg(test)] diff --git a/src/site_analysis/metagene.rs b/src/site_analysis/metagene.rs index 39df44a..e3c209e 100644 --- a/src/site_analysis/metagene.rs +++ b/src/site_analysis/metagene.rs @@ -939,8 +939,11 @@ impl MetageneArgs { self.batch_process, "site tables", |n| crate::qc::layout::site_table_modality(n).is_some(), + false, ); - let Some(chosen) = ask? else { return Ok(None) }; + let Some((chosen, _)) = ask? else { + return Ok(None); + }; given = chosen; } given diff --git a/src/site_analysis/pileup.rs b/src/site_analysis/pileup.rs index 7a22d27..ef698d9 100644 --- a/src/site_analysis/pileup.rs +++ b/src/site_analysis/pileup.rs @@ -211,6 +211,16 @@ pub struct PileupArgs { )] 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', @@ -385,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, @@ -456,6 +472,8 @@ 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, } /// Load `selector` for drawing. With `totals`, each matrix track also sums @@ -468,6 +486,7 @@ 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; @@ -479,7 +498,7 @@ fn load( use rayon::prelude::*; groups .par_iter() - .map(|g| read_matrix_positions(&g.files, selector, &args.signal, totals)) + .map(|g| read_matrix_positions(&g.files, selector, &args.signal, totals, done)) .collect() }; for (g, read) in groups.iter().zip(read) { @@ -532,6 +551,7 @@ fn load( keys }), depth, + cursor: None, }) } @@ -545,9 +565,10 @@ fn loading( locus: Option<(i64, i64)>, what: &str, ) -> anyhow::Result { - let text = format!("reading {what} from {} file(s)", args.data_files.len()); - crate::tui::busy(browser, "pileup", &text, || { - load(args, groups, selector, browser, locus) + 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)) }) } @@ -577,6 +598,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); @@ -607,6 +629,8 @@ fn browse( off, keys: loaded.keys.as_deref(), genes: genes.cloned(), + cursor: loaded.cursor, + switchable, }; tui::show_pileup(view, tracks, status) } @@ -721,18 +745,100 @@ fn start_gene_models(gtf: Option<&str>) -> Option { Some(models) } +/// `M` in the browser: the view it was pressed in, and its cursor. +struct Switch { + loaded: Loaded, + at: i64, +} + +/// Browse `args` (resolved from `given`), switching between the m6A and +/// A-to-I sites of the output folders among `given` on `M`. +fn browse_switching( + base: &PileupArgs, + given: &[Box], + separate: bool, + resolved: (PileupArgs, Vec), + first: Option, +) -> anyhow::Result<()> { + let (mut args, mut groups) = resolved; + let genes = start_gene_models(args.annotation().as_deref()); + let switchable = given + .iter() + .any(|g| crate::run_record::output_dir_of(g).is_some()); + let mut first = first; + let mut status = None; + loop { + let Some(Switch { loaded, at }) = interactive( + &args, + &groups, + first.take(), + genes.as_ref(), + switchable, + status.take(), + )? + else { + return Ok(()); + }; + let to = crate::qc::layout::SITE_MODALITIES + .iter() + .find(|m| !m.eq_ignore_ascii_case(&loaded.modality)) + .copied() + .unwrap_or_default(); + let other = PileupArgs { + modality: Some(to.into()), + ..base.clone() + }; + let (a, g) = match other.resolved(given, separate) { + Ok(r) => r, + Err(e) => { + status = Some(e.to_string()); + first = Some(loaded); + continue; + } + }; + // The same gene, else the same span, in the other sites. + let reopened = match loaded.keys.as_deref() { + Some([key]) => { + loading(true, &a, &g, &Selector::exact(key), None, key).map_err(|e| e.to_string()) + } + _ => { + let (lo, hi) = loaded.extent; + load_locus(&a, &g, &loaded.chr, lo, hi) + } + }; + match reopened { + Ok(l) => { + (args, groups) = (a, g); + first = Some(Loaded { + cursor: Some(at), + ..l + }); + } + Err(_) => { + status = Some(format!("no {to} sites here")); + first = Some(Loaded { + cursor: Some(at), + ..loaded + }); + } + } + } +} + /// 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<()> { + genes: Option<&SharedModels>, + switchable: bool, + status: Option, +) -> anyhow::Result> { let catalog = Catalog::new(&args.data_files); - let genes = start_gene_models(args.annotation().as_deref()); let mut filter = String::new(); let mut current = first; - let mut status: Option = None; + let mut status = status; loop { let loaded = match current.take() { Some(l) => l, @@ -744,7 +850,7 @@ fn interactive( let choice = list.pick()?; filter = list.filter().to_string(); let query = match choice { - picker::Choice::Quit => return Ok(()), + picker::Choice::Quit => return Ok(None), picker::Choice::Gene(i) => { let gene = &genes[i].gene; current = Some(loading( @@ -769,16 +875,26 @@ fn interactive( } } }; - match browse(args, &loaded, genes.as_ref(), status.take())? { - tui::Exit::Quit => return Ok(()), + match browse(args, &loaded, genes, status.take(), switchable)? { + tui::Exit::Quit => return Ok(None), tui::Exit::Genes => {} - tui::Exit::Locus(lo, hi) => match load_locus(args, groups, &loaded.chr, lo, hi) { - Ok(l) => current = Some(l), - Err(msg) => { - status = Some(msg); - current = Some(loaded); + tui::Exit::Modality { at } => return Ok(Some(Switch { loaded, at })), + 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(), + cursor: Some(at), + ..l + }) + } + Err(msg) => { + status = Some(msg); + current = Some(loaded); + } } - }, + } tui::Exit::Search(q) => match search(args, groups, &catalog, &q) { Ok(Found::View(l)) => current = Some(l), Ok(Found::List) => filter = q, @@ -797,7 +913,11 @@ impl PileupArgs { /// `--modality` and its site table as `-s` when none was given; and the /// tracks: `--track`'s, else with a directory one per batch and one per /// file named beside it, else one of them all. - fn resolved(&self, given: &[Box]) -> anyhow::Result<(PileupArgs, Vec)> { + fn resolved( + &self, + given: &[Box], + separate: bool, + ) -> anyhow::Result<(PileupArgs, Vec)> { let mut out = self.clone(); let mut files = Vec::new(); let mut own: Vec = Vec::new(); @@ -806,7 +926,7 @@ impl PileupArgs { let Some(dir) = crate::run_record::output_dir_of(f) else { files.push(f.clone()); own.push(TrackFiles { - label: crate::qc::layout::file_name(f), + label: track_name(f), files: vec![f.clone()], }); continue; @@ -838,7 +958,7 @@ impl PileupArgs { } } out.data_files = files; - let groups = if from_dir && self.tracks.is_empty() { + let groups = if (from_dir || separate) && self.tracks.is_empty() { own } else { track_files(&out.data_files, &self.tracks)? @@ -855,19 +975,22 @@ impl PileupArgs { } pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { - let given = if args.data_files.is_empty() { + let (given, separate) = if args.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(files) = ask? else { return Ok(()) }; - files + let Some(chosen) = ask? else { return Ok(()) }; + chosen } else { - args.data_files.clone() + (args.data_files.clone(), args.separate) }; - let (args, groups) = &args.resolved(&given)?; + let base = args; + let resolved = base.resolved(&given, separate)?; + let (args, groups) = (&resolved.0, &resolved.1); // 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. @@ -880,7 +1003,7 @@ pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { 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(args, groups, None); + return browse_switching(base, &given, separate, resolved, None); } let selector = Selector::build(&args.genes, &args.regions)?; if figure_mode { @@ -895,7 +1018,7 @@ pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { if interactive { // Straight to the browser; -o still writes the binned table. write_pileup(args, &loaded, false)?; - return self::interactive(args, groups, Some(loaded)); + return browse_switching(base, &given, separate, resolved, Some(loaded)); } write_pileup(args, &loaded, true) } 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..5372281 100644 --- a/src/site_analysis/pileup/read.rs +++ b/src/site_analysis/pileup/read.rs @@ -204,6 +204,125 @@ pub(super) struct GroupedMatrix { pub(super) genes: Vec>, } +/// 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, +} + +/// Read the rows `selector` picks from `data_file`. +fn read_one_file( + data_file: &str, + selector: &Selector, + membership: Option<&CellMembership>, + modality_filter: Option<&[String]>, + with_total: bool, +) -> 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.contains(&modality.to_ascii_lowercase()) { + continue; + } + } + 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() { + 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(), + ) + } + }; + + 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_insert(PosAgg { sum: 0.0, nnz: 0 }); + } + } + } + + 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_insert(PosAgg { sum: 0.0, nnz: 0 }); + agg.sum += *val as f64; + agg.nnz += 1; + } + Ok(part) +} + pub(super) fn read_matrix_positions_grouped( data_files: &[Box], selector: &Selector, @@ -211,6 +330,7 @@ pub(super) fn read_matrix_positions_grouped( membership: Option<&CellMembership>, top_modality: &[Box], with_total: bool, + done: Option<&std::sync::atomic::AtomicUsize>, ) -> anyhow::Result { // Per group: pos -> aggregate. Multiple input files (e.g. replicates) // merge per genomic position; gene/chr labels reflect the union. @@ -234,100 +354,48 @@ pub(super) fn read_matrix_positions_grouped( ) }; - 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 { - 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)); + // The files are independent: read them together, then merge in order. + let parts: Vec> = { + use rayon::prelude::*; + data_files + .par_iter() + .map(|f| { + let part = read_one_file( + f, + selector, + membership, + modality_filter.as_deref(), + with_total, + ); + if let Some(done) = done { + done.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } + part + }) + .collect() + }; + for part in parts { + let part = part?; + for (grp, aggs) in part.by_group { + let into = by_group.entry(grp).or_default(); + for (pos, agg) in aggs { + let a = into.entry(pos).or_insert(PosAgg { sum: 0.0, nnz: 0 }); + a.sum += agg.sum; + a.nnz += agg.nnz; } } - - if matched_rows.is_empty() { - continue; + for (gene, n) in part.distinct_genes { + *distinct_genes.entry(gene).or_insert(0) += n; } - 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 }); - } - } + matched_chrs.extend(part.matched_chrs); + total_matched += part.matched; + if first_modality.is_none() { + first_modality = part.first_modality; } - - 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 (pos, v) in part.totals { + *totals.entry(pos).or_insert(0.0) += v; } + unconverted_rows += part.unconverted_rows; } // The selection can span several genes (and chromosomes); rather than @@ -388,9 +456,10 @@ pub(super) fn read_matrix_positions( selector: &Selector, signal: &PileupSignal, with_total: bool, + done: Option<&std::sync::atomic::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 7c9083d..9b16c31 100644 --- a/src/site_analysis/pileup/tests.rs +++ b/src/site_analysis/pileup/tests.rs @@ -351,7 +351,11 @@ fn an_output_directory_gives_a_track_per_batch_and_its_site_table() { 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_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"]); @@ -361,12 +365,16 @@ fn an_output_directory_gives_a_track_per_batch_and_its_site_table() { 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(&args.data_files).unwrap() + args.resolved(&args.data_files, args.separate).unwrap() } fn labels(groups: &[TrackFiles]) -> Vec<&str> { diff --git a/src/site_analysis/pileup/tests/tui.rs b/src/site_analysis/pileup/tests/tui.rs index 97a407a..07917a4 100644 --- a/src/site_analysis/pileup/tests/tui.rs +++ b/src/site_analysis/pileup/tests/tui.rs @@ -80,7 +80,15 @@ fn zoom_keeps_the_cursor_and_stops_at_a_base_per_bar() { } assert_eq!(v.window, EXTENT); // Past the whole extent, `-` asks for twice its span around it. - assert_eq!(v.exit, Some(Exit::Locus(950_000, 1_150_000))); + 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')); @@ -511,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 0a0bd16..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,17 @@ 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. - Locus(i64, i64), + /// `-` 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. @@ -118,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, } @@ -162,6 +177,7 @@ impl<'a> PileupView<'a> { mirror: true, genes: Vec::new(), pending_genes: None, + switchable: false, exit: None, } } @@ -446,7 +462,11 @@ impl<'a> PileupView<'a> { return; } let pad = ((hi - lo) / 2).max(self.columns as i64); - self.exit = Some(Exit::Locus((lo - pad).max(1), hi + pad)); + 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. @@ -695,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), @@ -919,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) @@ -934,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 index a1dde57..a8eb82a 100644 --- a/src/site_analysis/tests/input_picker.rs +++ b/src/site_analysis/tests/input_picker.rs @@ -29,7 +29,7 @@ fn site_matrix(name: &str) -> bool { fn mark_files_from_a_folder_or_choose_the_folder() { let tmp = tree(); let root = tmp.path().to_path_buf(); - let mut p = InputPicker::new(root.clone(), "pileup", "site matrices", site_matrix); + let mut p = InputPicker::new(root.clone(), "pileup", "site matrices", site_matrix, true); assert!(p.browser.list.select("plain")); press(&mut p, KeyCode::Char(' ')); assert!( @@ -40,10 +40,20 @@ fn mark_files_from_a_folder_or_choose_the_folder() { // A faba folder, chosen whole. assert!(p.browser.list.select("out")); press(&mut p, KeyCode::Char(' ')); - assert_eq!(p.decision, Some(Some(vec![root.join("out")]))); + let folder = Chosen { + paths: vec![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 = InputPicker::new(root.join("out"), "pileup", "site matrices", site_matrix); + let mut p = InputPicker::new( + root.join("out"), + "pileup", + "site matrices", + site_matrix, + true, + ); 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 [ @@ -65,16 +75,30 @@ fn mark_files_from_a_folder_or_choose_the_folder() { 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()); - assert_eq!(p.decision, Some(Some(marked)), "the marked, not the folder"); + let chosen = Chosen { + paths: marked, + 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"), "metagene", "site tables", |n| { - n.ends_with("_sites.parquet") - }); + let mut p = InputPicker::new( + tmp.path().join("out"), + "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)] { @@ -82,3 +106,20 @@ fn draws_at_any_terminal_size() { 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 mut p = InputPicker::new(root.clone(), "pileup", "site matrices", site_matrix, true); + p.start_at(&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. + let mut p = InputPicker::new(root.clone(), "pileup", "site matrices", site_matrix, true); + p.start_at(&root.join("gone/x_m6a_site.zarr.zip")); + assert_eq!(p.browser.cwd, root); +} diff --git a/src/tui.rs b/src/tui.rs index 29104bd..5cef9dd 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -6,7 +6,7 @@ pub mod browser; pub mod busy; -pub use busy::busy; +pub use busy::{busy, busy_counting}; /// Frames of the spinners the workspace draws. pub const SPINNER: &str = "⠁⠂⠄⡀⢀⠠⠐⠈"; diff --git a/src/tui/busy.rs b/src/tui/busy.rs index ff51626..1db6dc5 100644 --- a/src/tui/busy.rs +++ b/src/tui/busy.rs @@ -1,6 +1,7 @@ //! A spinner on screen while slow work runs, so a view never leaves the //! terminal blank while it reads. +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{channel, Receiver, RecvTimeoutError}; use std::time::{Duration, Instant}; @@ -10,7 +11,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; -use super::{popup_frame, run_view, View, SPINNER}; +use super::{filled, popup_frame, run_view, View, SPINNER}; /// How long the work may take before the spinner shows, so quick work /// never flickers the screen. @@ -23,6 +24,9 @@ struct Busy<'a, T> { start: Instant, rx: &'a Receiver, out: Option, + /// Files (or other units) done, of `total`; no bar when `total` is 0. + done: &'a AtomicUsize, + total: usize, } impl View for Busy<'_, T> {} @@ -35,15 +39,30 @@ impl Screen for Busy<'_, T> { 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(30) + .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 = filled(done as u64, self.total as u64, bar_w); + Line::from(vec![ + Span::raw(" "), + Span::styled("█".repeat(on), HIGHLIGHT), + Span::styled("░".repeat(bar_w - on), DIM), + Span::styled(count, DIM), + ]) + }; let lines = vec![ Line::from(vec![ Span::styled(format!(" {spin} "), HIGHLIGHT), Span::raw(self.what.to_string()), ]), - Line::from(Span::styled(format!(" {}s", elapsed.as_secs()), DIM)), + second, ]; frame.render_widget(Paragraph::new(lines), inner); } @@ -75,14 +94,28 @@ pub fn busy( 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(); + return work(&done); } let (tx, rx) = channel(); std::thread::scope(|s| { + let counter = &done; s.spawn(move || { // The receiver outlives the scope, so the send cannot fail. - let _ = tx.send(work()); + let _ = tx.send(work(counter)); }); match rx.recv_timeout(GRACE) { Ok(out) => return out, @@ -95,6 +128,8 @@ pub fn busy( start: Instant::now(), rx: &rx, out: None, + done: &done, + total, }; run_view(&mut screen)?; match screen.out { @@ -113,12 +148,15 @@ mod tests { #[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 from 2 file(s)", + what: "reading GENE1", start: Instant::now(), rx: &rx, out: None, + done: &done, + total: 3, }; b.tick(); assert!(!b.done()); From 13f6ccc13abec6cab70efac4324bc94753819b57 Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 6 Oct 2026 11:05:23 -0700 Subject: [PATCH 4/4] pileup: one browse loop for both modalities; picker memo per working folder - `M` is handled in the browse loop itself: each modality's inputs are resolved and their gene list read once, the gene-list filter kept, and a failed switch stays at the cursor; an unknown modality says so. - The picked inputs and the separate-tracks choice live on the arguments; the picker returns its choice as a struct and opens its folder once. - The picker's last choice is kept per working folder, under the XDG cache directory. - One bar helper for the progress bars, one helper for the other site modality, and a merge for the per-file reads that takes over the first file's maps; the modality filter no longer allocates per row. - Leave the methods document as it was. --- docs/profiling-methods.md | 12 +- src/pipeline/tui/draw.rs | 18 +- src/qc/layout.rs | 9 + src/qc/site_tui.rs | 4 +- src/qc/site_tui/draw.rs | 10 +- src/site_analysis/input_picker.rs | 127 +++++++----- src/site_analysis/metagene.rs | 4 +- src/site_analysis/pileup.rs | 260 ++++++++++++------------ src/site_analysis/pileup/read.rs | 115 +++++------ src/site_analysis/pileup/tests.rs | 2 +- src/site_analysis/tests/input_picker.rs | 55 +++-- src/tui.rs | 13 +- src/tui/busy.rs | 15 +- 13 files changed, 355 insertions(+), 289 deletions(-) diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md index 085e38a..246c93d 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -641,17 +641,7 @@ None of these fit a model or produce a p-value. which on a 3′-biased library means the last bins of the 3′UTR; a terminal peak is not evidence of enrichment on its own. -`pileup` and `metagene` open full screen by default (`--batch-process` prints instead, as they do -without a terminal), and either takes a faba output directory, or a run record in it, in place of -its files: `pileup` then takes the directory's site matrices of `--modality` (m6A, else A-to-I), -one track per batch, with its `{modality}_sites.parquet` and the GFF its run record names, and -`metagene` profiles that site table against that GFF. Given no input, they open a browser: mark files -(Space) to read together, `pileup` summing the marked matrices into one track and `metagene` -profiling the marked site tables as one set of sites against the first one's GFF, or choose a -whole output folder; Tab gives `pileup` a track per marked matrix instead (`--separate` on the -command line), and the browser opens where the last choice was made. In the `pileup` browser, -`-` past the whole gene reloads a span twice as wide, keeping the cursor, and `M` switches an -output folder between its m6A and A-to-I sites at the same gene. The views draw the converted reads by default, where a +The `--interactive` views of `pileup` and `metagene` draw the converted reads by default, where a track carries both channels (in `metagene`, where the site table has `converted` and `coverage` columns). `c` cycles what the bars count: converted reads, unconverted reads (the total less the converted), both (the converted in front of the total), and the sites themselves. In `pileup`, `d` 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 84cedf0..89ab14c 100644 --- a/src/qc/layout.rs +++ b/src/qc/layout.rs @@ -124,6 +124,15 @@ pub fn matrix_batch(name: &str) -> Option> { (!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") 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:, + pub paths: Vec>, /// Marked files are to be one track each rather than one together. pub separate: bool, } @@ -41,17 +42,24 @@ pub struct InputPicker { } impl InputPicker { - /// Listing `cwd`'s folders and the files `keep` accepts by name, with - /// Tab to read marked files apart when `separable`. + /// 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 mut browser = faba_browser(cwd.clone(), keep).marking(); - browser.open(cwd); + 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, @@ -62,22 +70,24 @@ impl InputPicker { } } - /// Open at `last`'s folder with `last` highlighted. - fn start_at(&mut self, last: &Path) { - let (Some(dir), Some(name)) = (last.parent(), last.file_name()) else { - return; - }; - if dir.is_dir() { - self.browser.open(dir.to_path_buf()); - self.browser.list.select(&name.to_string_lossy()); - } - } - 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) { @@ -151,10 +161,9 @@ impl Screen for InputPicker { 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.separate) { + let marked = match (n_marked, self.track_mode()) { (0, _) => String::new(), - (n, Some(true)) => format!("· {n} marked, a track each "), - (n, Some(false)) => format!("· {n} marked, one track "), + (n, Some((now, _))) => format!("· {n} marked, {now} "), (n, None) => format!("· {n} marked "), }; let title = format!( @@ -186,10 +195,8 @@ impl Screen for InputPicker { ("←", "up"), ("Space", "mark file / choose folder"), ]; - match self.separate { - Some(true) => keys.push(("Tab", "one track")), - Some(false) => keys.push(("Tab", "a track each")), - None => {} + 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)); @@ -197,11 +204,47 @@ impl Screen for InputPicker { } } -/// Where the last choice is kept, so the picker opens there next time. -fn last_choice_file() -> Option { - Some(crate::tui::home()?.join(".cache/faba/last_input")) +/// 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 @@ -212,33 +255,21 @@ pub fn ask_inputs( what: &'static str, keep: fn(&str) -> bool, separable: bool, -) -> anyhow::Result>, bool)>> { +) -> anyhow::Result> { anyhow::ensure!( !batch && data_beans::interactive::tui_available(), "name {what}, or a faba output directory" ); - let mut picker = InputPicker::new(std::env::current_dir()?, command, what, keep, separable); - let memo = last_choice_file(); - if let Some(last) = memo.as_ref().and_then(|m| std::fs::read_to_string(m).ok()) { - picker.start_at(Path::new(last.trim())); - } + 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 Some(chosen) = picker.decision.flatten() else { - return Ok(None); - }; - // Remembering is a convenience: a failure to write is no failure. - if let (Some(memo), Some(first)) = (memo, chosen.paths.first()) { - if let Some(dir) = memo.parent() { - let _ = std::fs::create_dir_all(dir); - } - let _ = std::fs::write(memo, first.to_string_lossy().as_bytes()); + 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); } - let paths = chosen - .paths - .iter() - .map(|p| p.to_string_lossy().into_owned().into_boxed_str()) - .collect(); - Ok(Some((paths, chosen.separate))) + Ok(chosen) } #[cfg(test)] diff --git a/src/site_analysis/metagene.rs b/src/site_analysis/metagene.rs index e3c209e..bc2850c 100644 --- a/src/site_analysis/metagene.rs +++ b/src/site_analysis/metagene.rs @@ -941,10 +941,10 @@ impl MetageneArgs { |n| crate::qc::layout::site_table_modality(n).is_some(), false, ); - let Some((chosen, _)) = ask? else { + let Some(chosen) = ask? else { return Ok(None); }; - given = chosen; + given = chosen.paths; } given .iter() diff --git a/src/site_analysis/pileup.rs b/src/site_analysis/pileup.rs index ef698d9..1a80312 100644 --- a/src/site_analysis/pileup.rs +++ b/src/site_analysis/pileup.rs @@ -476,6 +476,16 @@ struct Loaded { 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 /// both channels, for the converted-over-total bars (not for `nnz`, where /// adding cells across channels would count cells twice). A `locus` fixes @@ -646,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(), @@ -664,7 +674,8 @@ impl<'a> Catalog<'a> { return Ok(genes); } let what = format!("reading genes from {} file(s)", self.files.len()); - let list = crate::tui::busy(true, "pileup", &what, || picker::gene_catalog(self.files))?; + 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)) } @@ -745,101 +756,93 @@ fn start_gene_models(gtf: Option<&str>) -> Option { Some(models) } -/// `M` in the browser: the view it was pressed in, and its cursor. -struct Switch { - loaded: Loaded, - at: i64, +/// 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(), + }) + } } -/// Browse `args` (resolved from `given`), switching between the m6A and -/// A-to-I sites of the output folders among `given` on `M`. -fn browse_switching( +/// `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, - given: &[Box], - separate: bool, - resolved: (PileupArgs, Vec), - first: Option, -) -> anyhow::Result<()> { - let (mut args, mut groups) = resolved; - let genes = start_gene_models(args.annotation().as_deref()); - let switchable = given + 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() - .any(|g| crate::run_record::output_dir_of(g).is_some()); - let mut first = first; - let mut status = None; - loop { - let Some(Switch { loaded, at }) = interactive( - &args, - &groups, - first.take(), - genes.as_ref(), - switchable, - status.take(), - )? - else { - return Ok(()); - }; - let to = crate::qc::layout::SITE_MODALITIES - .iter() - .find(|m| !m.eq_ignore_ascii_case(&loaded.modality)) - .copied() - .unwrap_or_default(); - let other = PileupArgs { - modality: Some(to.into()), - ..base.clone() - }; - let (a, g) = match other.resolved(given, separate) { - Ok(r) => r, - Err(e) => { - status = Some(e.to_string()); - first = Some(loaded); - continue; - } - }; - // The same gene, else the same span, in the other sites. - let reopened = match loaded.keys.as_deref() { - Some([key]) => { - loading(true, &a, &g, &Selector::exact(key), None, key).map_err(|e| e.to_string()) - } - _ => { - let (lo, hi) = loaded.extent; - load_locus(&a, &g, &loaded.chr, lo, hi) - } - }; - match reopened { - Ok(l) => { - (args, groups) = (a, g); - first = Some(Loaded { - cursor: Some(at), - ..l - }); - } - Err(_) => { - status = Some(format!("no {to} sites here")); - first = Some(Loaded { - cursor: Some(at), - ..loaded - }); - } + .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 interactively, starting from `first` (a selection given on the -/// command line) or from the gene list. -fn interactive( - args: &PileupArgs, - groups: &[TrackFiles], - first: Option, - genes: Option<&SharedModels>, - switchable: bool, - status: Option, -) -> anyhow::Result> { - let catalog = Catalog::new(&args.data_files); +/// 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 = status; + let mut status: Option = None; loop { + let Source { + args, + groups, + catalog, + .. + } = &sources[shown]; let loaded = match current.take() { Some(l) => l, None => { @@ -850,22 +853,16 @@ fn interactive( let choice = list.pick()?; filter = list.filter().to_string(); let query = match choice { - picker::Choice::Quit => return Ok(None), + picker::Choice::Quit => return Ok(()), picker::Choice::Gene(i) => { let gene = &genes[i].gene; - current = Some(loading( - true, - args, - groups, - &Selector::exact(gene), - None, - 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) => { @@ -875,18 +872,16 @@ fn interactive( } } }; - match browse(args, &loaded, genes, status.take(), switchable)? { - tui::Exit::Quit => return Ok(None), + match browse(args, &loaded, genes.as_ref(), status.take(), switchable)? { + tui::Exit::Quit => return Ok(()), tui::Exit::Genes => {} - tui::Exit::Modality { at } => return Ok(Some(Switch { loaded, at })), 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(), - cursor: Some(at), - ..l + ..l.at(at) }) } Err(msg) => { @@ -895,7 +890,20 @@ fn interactive( } } } - tui::Exit::Search(q) => match search(args, groups, &catalog, &q) { + 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) => { @@ -908,21 +916,17 @@ fn interactive( } impl PileupArgs { - /// These arguments with `given` as the inputs, each output directory - /// (or run record) among them 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 one per batch and one per - /// file named beside it, else one of them all. - fn resolved( - &self, - given: &[Box], - separate: bool, - ) -> anyhow::Result<(PileupArgs, Vec)> { + /// 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 given { + for f in &self.data_files { let Some(dir) = crate::run_record::output_dir_of(f) else { files.push(f.clone()); own.push(TrackFiles { @@ -958,7 +962,7 @@ impl PileupArgs { } } out.data_files = files; - let groups = if (from_dir || separate) && self.tracks.is_empty() { + let groups = if (from_dir || self.separate) && self.tracks.is_empty() { own } else { track_files(&out.data_files, &self.tracks)? @@ -975,7 +979,8 @@ impl PileupArgs { } pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { - let (given, separate) = if args.data_files.is_empty() { + let mut base = args.clone(); + if base.data_files.is_empty() { let ask = crate::site_analysis::input_picker::ask_inputs( "pileup", args.batch_process, @@ -984,13 +989,12 @@ pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { true, ); let Some(chosen) = ask? else { return Ok(()) }; - chosen - } else { - (args.data_files.clone(), args.separate) - }; - let base = args; - let resolved = base.resolved(&given, separate)?; - let (args, groups) = (&resolved.0, &resolved.1); + 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. @@ -1003,7 +1007,7 @@ pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { 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 browse_switching(base, &given, separate, resolved, None); + return self::interactive(base, source, None); } let selector = Selector::build(&args.genes, &args.regions)?; if figure_mode { @@ -1018,7 +1022,7 @@ pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { if interactive { // Straight to the browser; -o still writes the binned table. write_pileup(args, &loaded, false)?; - return browse_switching(base, &given, separate, resolved, Some(loaded)); + return self::interactive(base, source, Some(loaded)); } write_pileup(args, &loaded, true) } diff --git a/src/site_analysis/pileup/read.rs b/src/site_analysis/pileup/read.rs index 5372281..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, @@ -217,12 +219,44 @@ struct FileRead { 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, membership: Option<&CellMembership>, - modality_filter: Option<&[String]>, + modality_filter: Option<&[Box]>, with_total: bool, ) -> anyhow::Result { let mut part = FileRead::default(); @@ -240,7 +274,7 @@ fn read_one_file( continue; } if let Some(mf) = modality_filter { - if !mf.contains(&modality.to_ascii_lowercase()) { + if !mf.iter().any(|m| m.eq_ignore_ascii_case(modality)) { continue; } } @@ -287,7 +321,7 @@ fn read_one_file( let g = part.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 }); + g.entry(pos).or_default(); } } } @@ -316,7 +350,7 @@ fn read_one_file( .entry(group) .or_default() .entry(pos) - .or_insert(PosAgg { sum: 0.0, nnz: 0 }); + .or_default(); agg.sum += *val as f64; agg.nnz += 1; } @@ -330,73 +364,40 @@ pub(super) fn read_matrix_positions_grouped( membership: Option<&CellMembership>, top_modality: &[Box], with_total: bool, - done: Option<&std::sync::atomic::AtomicUsize>, + done: Option<&AtomicUsize>, ) -> 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(), - ) - }; + // 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 order. + // 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.as_deref(), - with_total, - ); + let part = read_one_file(f, selector, membership, modality_filter, with_total); if let Some(done) = done { - done.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + done.fetch_add(1, Ordering::Relaxed); } part }) .collect() }; + let mut all = FileRead::default(); for part in parts { - let part = part?; - for (grp, aggs) in part.by_group { - let into = by_group.entry(grp).or_default(); - for (pos, agg) in aggs { - let a = into.entry(pos).or_insert(PosAgg { sum: 0.0, nnz: 0 }); - a.sum += agg.sum; - a.nnz += agg.nnz; - } - } - for (gene, n) in part.distinct_genes { - *distinct_genes.entry(gene).or_insert(0) += n; - } - matched_chrs.extend(part.matched_chrs); - total_matched += part.matched; - if first_modality.is_none() { - first_modality = part.first_modality; - } - for (pos, v) in part.totals { - *totals.entry(pos).or_insert(0.0) += v; - } - unconverted_rows += part.unconverted_rows; + 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. @@ -456,7 +457,7 @@ pub(super) fn read_matrix_positions( selector: &Selector, signal: &PileupSignal, with_total: bool, - done: Option<&std::sync::atomic::AtomicUsize>, + done: Option<&AtomicUsize>, ) -> anyhow::Result> { let grouped = read_matrix_positions_grouped(data_files, selector, signal, None, &[], with_total, done)?; diff --git a/src/site_analysis/pileup/tests.rs b/src/site_analysis/pileup/tests.rs index 9b16c31..1e29ee8 100644 --- a/src/site_analysis/pileup/tests.rs +++ b/src/site_analysis/pileup/tests.rs @@ -374,7 +374,7 @@ fn an_output_directory_gives_a_track_per_batch_and_its_site_table() { /// `faba pileup` with `args`, resolved: the inputs and the tracks. fn resolve(args: &[&str]) -> (PileupArgs, Vec) { let args = pileup_args(args); - args.resolved(&args.data_files, args.separate).unwrap() + args.resolved().unwrap() } fn labels(groups: &[TrackFiles]) -> Vec<&str> { diff --git a/src/site_analysis/tests/input_picker.rs b/src/site_analysis/tests/input_picker.rs index a8eb82a..78b2d31 100644 --- a/src/site_analysis/tests/input_picker.rs +++ b/src/site_analysis/tests/input_picker.rs @@ -25,11 +25,20 @@ 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 = InputPicker::new(root.clone(), "pileup", "site matrices", site_matrix, true); + let mut p = picker(root.clone(), None); assert!(p.browser.list.select("plain")); press(&mut p, KeyCode::Char(' ')); assert!( @@ -41,19 +50,13 @@ fn mark_files_from_a_folder_or_choose_the_folder() { assert!(p.browser.list.select("out")); press(&mut p, KeyCode::Char(' ')); let folder = Chosen { - paths: vec![root.join("out")], + 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 = InputPicker::new( - root.join("out"), - "pileup", - "site matrices", - site_matrix, - true, - ); + 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 [ @@ -79,7 +82,7 @@ fn mark_files_from_a_folder_or_choose_the_folder() { press(&mut p, KeyCode::Tab); crate::tui::feed(&mut p, crate::tui::apply_key()); let chosen = Chosen { - paths: marked, + paths: marked.iter().map(|m| text(m)).collect(), separate: true, }; assert_eq!( @@ -94,6 +97,7 @@ 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"), @@ -111,15 +115,36 @@ fn draws_at_any_terminal_size() { fn opens_at_the_last_choice() { let tmp = tree(); let root = tmp.path().to_path_buf(); - let mut p = InputPicker::new(root.clone(), "pileup", "site matrices", site_matrix, true); - p.start_at(&root.join("out/b_m6a_site.zarr.zip")); + 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. - let mut p = InputPicker::new(root.clone(), "pileup", "site matrices", site_matrix, true); - p.start_at(&root.join("gone/x_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/tui.rs b/src/tui.rs index 5cef9dd..7781811 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -13,9 +13,10 @@ 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; @@ -132,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/busy.rs b/src/tui/busy.rs index 1db6dc5..fc2e266 100644 --- a/src/tui/busy.rs +++ b/src/tui/busy.rs @@ -11,7 +11,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; -use super::{filled, popup_frame, run_view, View, SPINNER}; +use super::{bar_spans, popup_frame, run_view, View, SPINNER}; /// How long the work may take before the spinner shows, so quick work /// never flickers the screen. @@ -24,7 +24,7 @@ struct Busy<'a, T> { start: Instant, rx: &'a Receiver, out: Option, - /// Files (or other units) done, of `total`; no bar when `total` is 0. + /// Files done, of `total`; no bar when `total` is 0. done: &'a AtomicUsize, total: usize, } @@ -49,13 +49,8 @@ impl Screen for Busy<'_, T> { 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 = filled(done as u64, self.total as u64, bar_w); - Line::from(vec![ - Span::raw(" "), - Span::styled("█".repeat(on), HIGHLIGHT), - Span::styled("░".repeat(bar_w - on), DIM), - Span::styled(count, DIM), - ]) + 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![ @@ -111,8 +106,8 @@ pub fn busy_counting( return work(&done); } let (tx, rx) = channel(); + let counter = &done; std::thread::scope(|s| { - let counter = &done; s.spawn(move || { // The receiver outlives the scope, so the send cannot fail. let _ = tx.send(work(counter));