From 512805fb36ed98c803d64456b54a147925e461dd Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 10:26:18 -0700 Subject: [PATCH 1/7] pwm: the logo by default, output folders as input, a file picker; saves never overwrite (0.16.7) faba pwm opens the sequence logo full screen unless --batch-process (or there is no terminal), takes a faba output directory or run record for its site table and recorded genome, and with no sites opens the file picker. Saved figures never replace a file; the picker's key hints take three lines. --- Cargo.toml | 2 +- docs/profiling-methods.md | 3 +- src/figure/mod.rs | 58 ++++++- src/figure/term.rs | 11 -- src/figure/tests/figure.rs | 28 +++ src/main.rs | 11 +- src/site_analysis/input_picker.rs | 44 ++--- src/site_analysis/metagene.rs | 63 +------ src/site_analysis/output_dir.rs | 67 +++++++- src/site_analysis/pileup.rs | 7 +- src/site_analysis/scan_pwm.rs | 234 +++++++++++++++++++------- src/site_analysis/scan_pwm/tests.rs | 95 +++++++++++ src/site_analysis/scan_pwm/tui.rs | 2 +- src/site_analysis/tests/output_dir.rs | 13 ++ src/tui.rs | 13 ++ src/tui/busy.rs | 33 +++- 16 files changed, 519 insertions(+), 165 deletions(-) create mode 100644 src/site_analysis/scan_pwm/tests.rs diff --git a/Cargo.toml b/Cargo.toml index f40f9be..8f82faf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "faba" -version = "0.16.6" +version = "0.16.7" edition = "2021" rust-version = "1.91" license = "MIT" diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md index 246c93d..b9fb736 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -614,7 +614,8 @@ None of these fit a model or produce a p-value. - **`pwm`** collects base counts in a ± `--window` (10) bp window around every site in the parquet, reverse-complementing minus-strand sites. Bases come from the reference FASTA by default (`--source reference`) or from the reads (`--source reads`). The output is a base-frequency - matrix, not a log-odds PWM. + matrix, not a log-odds PWM. It takes a faba output directory for its site table, browses for + sites when none is named, and shows a sequence logo unless `--batch-process`. - **`pileup`** aggregates the matrix rows matched by `--genes` and/or `--regions` into one ASCII histogram, or (with `--gtf`, `--bam`, `--format`, `--svg` or `--png`) a faceted Miami plot: sites above, gene model in the middle, read depth below, one panel per cell type. diff --git a/src/figure/mod.rs b/src/figure/mod.rs index c2555f2..4066274 100644 --- a/src/figure/mod.rs +++ b/src/figure/mod.rs @@ -533,6 +533,32 @@ fn strip_extension(prefix: &str) -> &str { .unwrap_or(prefix) } +/// The files a save under `prefix` writes: the PDF, then the PNG. +fn saved_paths(prefix: &str) -> [String; 2] { + let prefix = strip_extension(prefix); + [format!("{prefix}.pdf"), format!("{prefix}.png")] +} + +/// Whether a save under `prefix` would replace a file already there. +fn taken(prefix: &str) -> bool { + saved_paths(prefix) + .iter() + .any(|p| std::path::Path::new(p).exists()) +} + +/// `prefix`, or failing that the first of `{prefix}_2`, `{prefix}_3`, … +/// that no save would replace a file under. +fn free_name(prefix: &str) -> String { + let prefix = strip_extension(prefix); + (1..) + .map(|n| match n { + 1 => prefix.to_string(), + n => format!("{prefix}_{n}"), + }) + .find(|p| !taken(p)) + .expect("some suffix is free") +} + /// SVG parsing options, with the system fonts loaded once. fn options() -> usvg::Options<'static> { usvg::Options { @@ -549,13 +575,11 @@ pub fn save(svg: &str, prefix: &str) -> anyhow::Result> { /// [`save`], and the picture the PNG holds. fn save_picture(svg: &str, prefix: &str) -> anyhow::Result<(Vec, image::RgbaImage)> { - let prefix = strip_extension(prefix); + let [pdf_path, png_path] = saved_paths(prefix); let tree = usvg::Tree::from_str(svg, &options())?; let pdf = svg2pdf::to_pdf(&tree, Default::default(), Default::default()) .map_err(|e| anyhow::anyhow!("PDF conversion failed: {e:?}"))?; - let pdf_path = format!("{prefix}.pdf"); std::fs::write(&pdf_path, pdf)?; - let png_path = format!("{prefix}.png"); let pixmap = term::pixmap(&tree, PNG_SCALE)?; pixmap.save_png(&png_path)?; let (w, h) = (pixmap.width(), pixmap.height()); @@ -625,12 +649,16 @@ impl LineInput { } } -/// The save-as prompt every view shares: it opens with a suggested name, -/// Enter confirms, Esc closes; the outcome shows until the next key. +/// The save-as prompt every view shares: it opens with a suggested name +/// no save has used here yet, Enter confirms, Esc closes; the outcome shows +/// until the next key. It never replaces a file: a name already taken +/// keeps the prompt open, offering the next free one. pub struct SavePrompt { input: LineInput, status: Option>, default: String, + /// Why the prompt is still open: the name tried was taken. + taken: Option, } impl SavePrompt { @@ -639,6 +667,7 @@ impl SavePrompt { input: LineInput::new(512), status: None, default: default.to_string(), + taken: None, } } @@ -647,15 +676,22 @@ impl SavePrompt { } pub fn open(&mut self) { - self.input.open(&self.default); + self.input.open(&free_name(&self.default)); self.status = None; + self.taken = None; } /// Handle a key while open: `Some(prefix)` once the user confirms. pub fn handle(&mut self, key: KeyEvent) -> Option { match self.input.handle(key) { + Edit::Submitted(name) if taken(&name) => { + self.input.open(&free_name(&name)); + self.taken = Some(format!("{} is already there", strip_extension(&name))); + None + } Edit::Submitted(name) if !name.is_empty() => { self.default = name.clone(); + self.taken = None; Some(name) } _ => None, @@ -680,6 +716,11 @@ impl SavePrompt { self.input.text() } + /// Why the prompt offers another name, after a taken one was tried. + pub fn taken(&self) -> Option<&str> { + self.taken.as_deref() + } + /// The footer while the prompt or an outcome is showing. pub fn footer(&self) -> Option> { if let Some(buf) = self.input.text() { @@ -823,7 +864,10 @@ impl Controls { Line::from(Span::styled(" save as (.pdf + .png)", DIM)), Line::from(""), Line::from(Span::styled(format!(" {name}▏"), HIGHLIGHT)), - Line::from(""), + match self.save.taken() { + Some(why) => status_line(&format!("{why}; not replacing it")), + None => Line::from(""), + }, Line::from(Span::styled(format!(" in {}", self.here), DIM)), Line::from(""), help_line(&[("Enter", "save"), ("Esc", "back")]), diff --git a/src/figure/term.rs b/src/figure/term.rs index 8f4ada6..2a96003 100644 --- a/src/figure/term.rs +++ b/src/figure/term.rs @@ -92,17 +92,6 @@ pub(super) fn protocol(picker: &Picker, img: DynamicImage, area: Rect) -> Option .ok() } -/// Run a view when stdin and stdout are a terminal; otherwise say why not -/// and carry on without it. -pub fn when_terminal(view: impl FnOnce() -> anyhow::Result<()>) -> anyhow::Result<()> { - if data_beans::interactive::tui_available() { - view() - } else { - log::warn!("--interactive needs stdin and stdout on a terminal; skipping the view"); - Ok(()) - } -} - /// One plot area drawn as an image: rebuilt only when marked stale or when /// the area changes size. #[derive(Default)] diff --git a/src/figure/tests/figure.rs b/src/figure/tests/figure.rs index f44a68b..fdd08ac 100644 --- a/src/figure/tests/figure.rs +++ b/src/figure/tests/figure.rs @@ -156,3 +156,31 @@ fn the_save_prompt_pops_up_with_the_figures_saved_here() { "{text}" ); } + +#[test] +fn the_prompt_never_replaces_a_saved_figure() { + let dir = tempfile::tempdir().unwrap(); + let at = |n: &str| dir.path().join(n).to_string_lossy().into_owned(); + std::fs::write(at("logo.pdf"), b"").unwrap(); + std::fs::write(at("logo_2.png"), b"").unwrap(); + let mut p = SavePrompt::new(&at("logo")); + p.open(); + assert_eq!( + p.text(), + Some(at("logo_3").as_str()), + "the first name free of both" + ); + + // A taken name typed in keeps the prompt open, with the next free one. + p.handle(key(KeyCode::Esc)); + p.open(); + for _ in 0..2 { + p.handle(key(KeyCode::Backspace)); + } + assert_eq!(p.text(), Some(at("logo").as_str())); + assert_eq!(p.handle(key(KeyCode::Enter)), None, "logo.pdf is there"); + assert!(p.active() && p.taken().is_some()); + assert_eq!(p.text(), Some(at("logo_3").as_str())); + assert_eq!(p.handle(key(KeyCode::Enter)), Some(at("logo_3"))); + assert!(p.taken().is_none()); +} diff --git a/src/main.rs b/src/main.rs index e9cd846..5cf3f5d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -205,10 +205,17 @@ Example:\n \ \n\ Reads site-level parquet files from dartseq, atoi or apa output,\n\ collects base frequencies in a +/- window around each site,\n\ - and outputs a position weight matrix as TSV.", + and outputs a position weight matrix as TSV.\n\ + \n\ + Give it a faba output directory (or a run record in it) and it reads\n\ + that directory's site table of --modality against its recorded genome.\n\ + With no sites named, a browser asks for them. It then shows the PWM\n\ + as a sequence logo full screen, unless --batch-process.", after_long_help = "\ Example:\n\ - faba pwm -s out/m6a_sites.parquet -f genome.fa -o pwm.tsv\n\ + # an output directory's m6A sites against its recorded genome, then the logo\n\ + faba pwm out/\n\ + faba pwm -s out/m6a_sites.parquet -f genome.fa -o pwm.tsv --batch-process\n\ faba pwm -s out/m6a_sites.parquet sample.bam --source reads -o pwm.tsv" )] Pwm(ScanPwmArgs), diff --git a/src/site_analysis/input_picker.rs b/src/site_analysis/input_picker.rs index 93a0632..c9614a8 100644 --- a/src/site_analysis/input_picker.rs +++ b/src/site_analysis/input_picker.rs @@ -159,7 +159,6 @@ impl Screen for InputPicker { let area = frame.area(); frame.render_widget(header(self.command, "choose the input", ""), area); let w = area.width.saturating_sub(4).clamp(20, 90); - let rows = area.height.saturating_sub(8).clamp(3, 24); let n_marked = self.browser.marked.as_ref().map_or(0, Vec::len); let marked = match (n_marked, self.track_mode()) { (0, _) => String::new(), @@ -172,7 +171,30 @@ impl Screen for InputPicker { tilde(&self.browser.cwd), self.browser.list.find.tag() ); - let inner = popup_frame(frame, area, w, rows + 4, title); + let apply = if n_marked == 0 { + "choose this folder" + } else { + "read the marked" + }; + // Three lines: getting about, marking, and finishing. + let mut mark = vec![("Space", "mark file / choose folder")]; + if let Some((_, tab)) = self.track_mode() { + mark.push(("Tab", tab)); + } + let help = [ + help_line(&[ + ("type", "find"), + ("↑/↓", "move"), + ("Enter/→", "open"), + ("←", "up"), + ]), + help_line(&mark), + help_line(&[(APPLY_KEYS, apply), ("Esc", "clear find / cancel")]), + ]; + // Header, border and the error line, besides the list and the help. + let help_rows = help.len() as u16; + let rows = area.height.saturating_sub(7 + help_rows).clamp(3, 24); + let inner = popup_frame(frame, area, w, rows + 3 + help_rows, title); let mut lines = self.browser.lines( rows as usize, inner.width as usize, @@ -183,23 +205,7 @@ impl Screen for InputPicker { Some(why) => Line::from(Span::styled(format!(" {why}"), HIGHLIGHT)), None => Line::from(""), }); - let apply = if n_marked == 0 { - "choose this folder" - } else { - "read the marked" - }; - let mut keys = vec![ - ("type", "find"), - ("↑/↓", "move"), - ("Enter/→", "open"), - ("←", "up"), - ("Space", "mark file / choose folder"), - ]; - if let Some((_, tab)) = self.track_mode() { - keys.push(("Tab", tab)); - } - keys.extend([(APPLY_KEYS, apply), ("Esc", "clear find / cancel")]); - lines.push(help_line(&keys)); + lines.extend(help); frame.render_widget(Paragraph::new(lines), inner); } } diff --git a/src/site_analysis/metagene.rs b/src/site_analysis/metagene.rs index bc2850c..1f7f98d 100644 --- a/src/site_analysis/metagene.rs +++ b/src/site_analysis/metagene.rs @@ -4,6 +4,7 @@ //! not procedure (Olarerin-George & Jaffrey, Bioinformatics 33:1563, 2017, //! ). +use super::output_dir; use super::site_io::*; use crate::site_analysis::show::Show; use clap::{Args, ValueEnum}; @@ -932,38 +933,9 @@ impl MetageneArgs { /// name an output directory (or a run record in it) holding one; with /// neither, those chosen in a browser. `None` when that is cancelled. fn site_files(&self) -> anyhow::Result>>> { - let mut given: Vec> = self.sites.iter().chain(&self.input).cloned().collect(); - if given.is_empty() { - let ask = crate::site_analysis::input_picker::ask_inputs( - "metagene", - self.batch_process, - "site tables", - |n| crate::qc::layout::site_table_modality(n).is_some(), - false, - ); - let Some(chosen) = ask? else { - return Ok(None); - }; - given = chosen.paths; - } - given - .iter() - .map(|g| self.site_file(g)) - .collect::>() - .map(Some) - } - - /// `given`, or the site table of the output directory it names. - fn site_file(&self, given: &str) -> anyhow::Result> { - let Some(dir) = crate::run_record::output_dir_of(given) else { - return Ok(given.into()); - }; - let sites = crate::site_analysis::output_dir::output_sites(&dir, self.modality.as_deref())?; - let table = sites.site_table.ok_or_else(|| { - anyhow::anyhow!("{} has no {}_sites.parquet", dir.display(), sites.modality) - })?; - info!("{}: profiling {table}", dir.display()); - Ok(table) + let given = self.sites.iter().chain(&self.input).cloned().collect(); + let modality = self.modality.as_deref(); + output_dir::site_tables("metagene", self.batch_process, given, modality) } } @@ -971,26 +943,14 @@ pub fn run_metagene(args: &MetageneArgs) -> anyhow::Result<()> { let Some(site_files) = args.site_files()? else { return Ok(()); }; + let batch = crate::tui::batch(args.batch_process, "metagene"); // 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 histogram = crate::tui::busy(!batch, "metagene", &what, || profile(args, &site_files))?; let output = args .output .clone() - .or_else(|| args.batch_process.then(default)); + .or_else(|| batch.then(|| output_dir::default_output(&site_files, ".metagene.tsv"))); if let Some(output) = &output { histogram.to_tsv(output)?; info!("wrote metagene histogram to {output}"); @@ -999,13 +959,8 @@ pub fn run_metagene(args: &MetageneArgs) -> anyhow::Result<()> { 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))?; + if !batch { + tui::show_metagene(&output_dir::title(&site_files), &histogram)?; } Ok(()) diff --git a/src/site_analysis/output_dir.rs b/src/site_analysis/output_dir.rs index 393f78e..7e544ff 100644 --- a/src/site_analysis/output_dir.rs +++ b/src/site_analysis/output_dir.rs @@ -1,4 +1,4 @@ -//! What `pileup` and `metagene` need from a faba output directory (from +//! What `pileup`, `metagene` and `pwm` 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. @@ -57,6 +57,71 @@ pub fn output_sites(dir: &Path, modality: Option<&str>) -> anyhow::Result], suffix: &str) -> Box { + let name = crate::qc::layout::file_name(&site_files[0]); + let stem = name.strip_suffix(".parquet").unwrap_or(&name); + let together = if site_files.len() > 1 { + "_combined" + } else { + "" + }; + format!("{stem}{together}{suffix}").into() +} + +/// The view's title: the site tables' file names. +pub fn title(site_files: &[Box]) -> String { + let names: Vec = site_files + .iter() + .map(|f| crate::qc::layout::file_name(f).to_string()) + .collect(); + names.join(" + ") +} + +/// The site tables to read: each of `given` (a site table, or an output +/// directory or run record holding one of `modality`), or with none given, +/// those chosen in a browser for `command`. `None` when that is cancelled. +pub fn site_tables( + command: &'static str, + batch: bool, + given: Vec>, + modality: Option<&str>, +) -> anyhow::Result>>> { + let mut given = given; + if given.is_empty() { + let ask = crate::site_analysis::input_picker::ask_inputs( + command, + batch, + "site tables", + |n| crate::qc::layout::site_table_modality(n).is_some(), + false, + ); + let Some(chosen) = ask? else { + return Ok(None); + }; + given = chosen.paths; + } + given + .iter() + .map(|g| site_table_of(g, modality)) + .collect::>() + .map(Some) +} + +/// `given`, or the site table of the output directory it names. +fn site_table_of(given: &str, modality: Option<&str>) -> anyhow::Result> { + let Some(dir) = crate::run_record::output_dir_of(given) else { + return Ok(given.into()); + }; + let sites = output_sites(&dir, modality)?; + let table = sites.site_table.ok_or_else(|| { + anyhow::anyhow!("{} has no {}_sites.parquet", dir.display(), sites.modality) + })?; + log::info!("{}: reading {table}", dir.display()); + Ok(table) +} + #[cfg(test)] #[path = "tests/output_dir.rs"] mod tests; diff --git a/src/site_analysis/pileup.rs b/src/site_analysis/pileup.rs index 1a80312..3b8d825 100644 --- a/src/site_analysis/pileup.rs +++ b/src/site_analysis/pileup.rs @@ -999,12 +999,7 @@ pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { // 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 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 interactive = browse_it && terminal; + let interactive = !figure_flags && !crate::tui::batch(args.batch_process, "pileup"); let figure_mode = !interactive && (args.gtf.is_some() || figure_flags); if interactive && args.genes.is_empty() && args.regions.is_empty() { return self::interactive(base, source, None); diff --git a/src/site_analysis/scan_pwm.rs b/src/site_analysis/scan_pwm.rs index 62b6513..1224694 100644 --- a/src/site_analysis/scan_pwm.rs +++ b/src/site_analysis/scan_pwm.rs @@ -1,5 +1,8 @@ +#[cfg(test)] +mod tests; mod tui; +use super::output_dir; use super::site_io::*; use crate::common::*; use crate::data::dna::{Dna, DnaBaseCount}; @@ -20,19 +23,33 @@ pub enum PwmSource { #[derive(Args, Debug)] pub struct ScanPwmArgs { + #[arg( + value_delimiter = ',', + value_name = "SITES|DIR|BAM", + help = "Site parquets or faba output directories, and BAM files for --source reads", + 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 read. Several are read\n\ + together, as one set of sites. A `.bam` or `.cram` is a BAM file,\n\ + read with --source reads. Without sites (here or -s), a browser\n\ + asks: 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( - value_delimiter = ',', - help = "Input BAM file(s), comma-separated (required for --source reads)" + long = "modality", + value_name = "m6a|atoi", + help = "Which site table to take from an output directory (default: m6a, else atoi)" )] - bam_files: Vec>, + modality: Option>, #[arg( short = 'f', @@ -70,16 +87,30 @@ pub struct ScanPwmArgs { #[arg( short, long, - required = true, - help = "Output file path (TSV, or .gz for gzipped)" + help = "Output file path, TSV or .gz (default with --batch-process: `{sites}.pwm.tsv` here)", + long_help = "Output file path (TSV, or .gz for gzipped). In the view, written only\n\ + when given; with --batch-process, without it, `{sites}.pwm.tsv` in the\n\ + current directory, after the site table's name." + )] + output: Option>, + + #[arg( + long = "batch-process", + visible_alias = "batch-mode", + default_value_t = false, + help = "Write the PWM and stop, without showing the logo full screen", + long_help = "Write the PWM and stop, without showing the logo full screen.\n\ + By default `faba pwm` writes it (when -o is given) and then shows the\n\ + sequence logo in a view (when there is a terminal)." )] - output: Box, + 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, show the PWM as a sequence logo full screen (needs a terminal)" + hide = true, + default_value_t = false )] interactive: bool, } @@ -140,10 +171,12 @@ fn collect_from_reference( } /// Collect PWM from BAM read base frequencies around each site. +/// `quiet` drops the progress bar, for when a view's spinner shows instead. fn collect_from_reads( sites: &[GenomicSite], bam_files: &[Box], window: i64, + quiet: bool, ) -> anyhow::Result> { let width = (2 * window + 1) as usize; @@ -152,12 +185,16 @@ fn collect_from_reads( util_htslib::check_bam_index(bam_file, None)?; } + let empty = || (0..width).map(|_| DnaBaseCount::new()).collect::>(); let pwm: Vec = sites .par_iter() - .progress_with(new_progress_bar(sites.len() as u64)) - .map(|site| { - let mut local_pwm: Vec = - (0..width).map(|_| DnaBaseCount::new()).collect(); + .progress_with(if quiet { + indicatif::ProgressBar::hidden() + } else { + new_progress_bar(sites.len() as u64) + }) + .map(|site| -> anyhow::Result> { + let mut local_pwm = empty(); let bed = Bed { chr: site.chr.clone(), @@ -167,9 +204,9 @@ fn collect_from_reads( let mut freq_map = DnaBaseFreqMap::new(); for bam_file in bam_files { - freq_map - .update_from_region(bam_file, &bed) - .expect("failed to read BAM file"); + freq_map.update_from_region(bam_file, &bed).map_err(|e| { + anyhow::anyhow!("{bam_file}: {}:{}: {e}", bed.chr, site.position) + })?; } if let Some(counts) = freq_map.marginal_frequency_map() { @@ -190,17 +227,14 @@ fn collect_from_reads( } } - local_pwm + Ok(local_pwm) }) - .reduce( - || (0..width).map(|_| DnaBaseCount::new()).collect(), - |mut acc, local| { - for (a, b) in acc.iter_mut().zip(local.iter()) { - *a += b; - } - acc - }, - ); + .try_reduce(empty, |mut acc, local| { + for (a, b) in acc.iter_mut().zip(local.iter()) { + *a += b; + } + Ok(acc) + })?; Ok(pwm) } @@ -253,46 +287,130 @@ fn write_pwm(pwm: &[DnaBaseCount], window: i64, output: &str) -> anyhow::Result< Ok(()) } +/// The genome to read every table's sites against: `explicit`, else the one +/// recorded next to each table, which must be the same for all. +fn genome_of(explicit: Option<&str>, site_files: &[Box]) -> anyhow::Result> { + if let Some(genome) = explicit { + return Ok(genome.into()); + } + let recorded = |f: &str| { + crate::run_record::explicit_or_recorded(None, f, "genome", "genome").ok_or_else(|| { + anyhow::anyhow!( + "--genome is required when --source reference: \ + no run record next to {f} names a genome" + ) + }) + }; + let first = recorded(&site_files[0])?; + for f in &site_files[1..] { + let this = recorded(f)?; + anyhow::ensure!( + this == first, + "the site tables were called against different genomes ({first}, {this}); \ + name one with --genome" + ); + } + Ok(first) +} + +/// Whether `path` names an alignment file (BAM, CRAM or SAM) rather than +/// sites. +fn is_bam(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + [".bam", ".cram", ".sam"] + .iter() + .any(|ext| lower.ends_with(ext)) +} + +/// Site tables, and BAM files. +type Inputs = (Vec>, Vec>); + +impl ScanPwmArgs { + /// The site tables and the BAM files: `-s`, then the positional inputs + /// that are not BAMs, 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 inputs(&self) -> anyhow::Result> { + let (bams, given): (Vec>, Vec>) = + self.input.iter().cloned().partition(|p| is_bam(p)); + let given = self.sites.iter().cloned().chain(given).collect(); + let modality = self.modality.as_deref(); + let ask = output_dir::site_tables("pwm", self.batch_process, given, modality); + Ok(ask?.map(|sites| (sites, bams))) + } +} + pub fn run_scan_pwm(args: &ScanPwmArgs) -> anyhow::Result<()> { rayon::ThreadPoolBuilder::new() .num_threads(args.threads) .build_global() .ok(); // ignore if already set - let sites = read_sites(&args.site_file)?; - - let pwm = match args.source { - PwmSource::Reference => { - let genome = crate::run_record::explicit_or_recorded( - args.genome_file.as_deref(), - &args.site_file, - "genome", - "genome", - ) - .ok_or_else(|| { - anyhow::anyhow!( - "--genome is required when --source reference: \ - no run record next to {} names a genome", - args.site_file - ) - })?; - collect_from_reference(&sites, &genome, args.window)? - } + let Some((site_files, bam_files)) = args.inputs()? else { + return Ok(()); + }; + if !bam_files.is_empty() && matches!(args.source, PwmSource::Reference) { + log::warn!( + "{} read only with --source reads; the reference gives the bases", + bam_files.join(", ") + ); + } + // A genome missing, or differing between tables, fails before the reading. + let genome = match args.source { + PwmSource::Reference => Some(genome_of(args.genome_file.as_deref(), &site_files)?), PwmSource::Reads => { - if args.bam_files.is_empty() { - return Err(anyhow::anyhow!("BAM file(s) required when --source reads")); - } - collect_from_reads(&sites, &args.bam_files, args.window)? + anyhow::ensure!( + !bam_files.is_empty(), + "BAM file(s) required when --source reads" + ); + None } }; - - write_pwm(&pwm, args.window, &args.output)?; - info!("wrote PWM to {}", args.output); - - if args.interactive { - let title = crate::qc::layout::file_name(&args.site_file); - crate::figure::term::when_terminal(|| tui::show_pwm(&title, &pwm, args.window))?; + let batch = crate::tui::batch(args.batch_process, "pwm"); + let what = format!("collecting bases around {} site table(s)", site_files.len()); + let pwm = crate::tui::busy(!batch, "pwm", &what, || { + collect(args, &site_files, genome.as_deref(), &bam_files, !batch) + })?; + + let output = args + .output + .clone() + .or_else(|| batch.then(|| output_dir::default_output(&site_files, ".pwm.tsv"))); + if let Some(output) = &output { + write_pwm(&pwm, args.window, output)?; + info!("wrote PWM to {output}"); + } + if !batch { + tui::show_pwm(&output_dir::title(&site_files), &pwm, args.window)?; } Ok(()) } + +/// The base counts around the sites of `site_files`, as one set, from +/// `genome` when given, else from `bam_files`. +fn collect( + args: &ScanPwmArgs, + site_files: &[Box], + genome: Option<&str>, + bam_files: &[Box], + quiet: bool, +) -> anyhow::Result> { + // The tables are independent: read them together. + let tables = site_files + .par_iter() + .map(|f| read_sites(f)) + .collect::>>()?; + let sites: Vec = tables.into_iter().flatten().collect(); + if site_files.len() > 1 { + info!( + "{} sites from {} tables, together", + sites.len(), + site_files.len() + ); + } + match genome { + Some(genome) => collect_from_reference(&sites, genome, args.window), + None => collect_from_reads(&sites, bam_files, args.window, quiet), + } +} diff --git a/src/site_analysis/scan_pwm/tests.rs b/src/site_analysis/scan_pwm/tests.rs new file mode 100644 index 0000000..ea5dbbc --- /dev/null +++ b/src/site_analysis/scan_pwm/tests.rs @@ -0,0 +1,95 @@ +use super::*; +use clap::FromArgMatches; + +fn parse(args: &[&str]) -> ScanPwmArgs { + let cmd = crate::faba_command() + .find_subcommand("pwm") + .unwrap() + .clone(); + let m = cmd + .try_get_matches_from(std::iter::once("pwm").chain(args.iter().copied())) + .unwrap(); + ScanPwmArgs::from_arg_matches(&m).unwrap() +} + +fn boxed(names: &[&str]) -> Vec> { + names.iter().map(|&n| Box::from(n)).collect() +} + +#[test] +fn bams_are_read_and_the_rest_are_sites() { + let args = parse(&[ + "-s", + "x.parquet", + "a.bam,B.CRAM", + "y.parquet", + "--batch-process", + ]); + let (sites, bams) = args.inputs().unwrap().unwrap(); + assert_eq!(sites, boxed(&["x.parquet", "y.parquet"]), "-s first"); + assert_eq!(bams, boxed(&["a.bam", "B.CRAM"])); + assert!(args.output.is_none(), "-o is optional"); +} + +#[test] +fn an_output_directory_or_its_run_record_gives_its_site_table() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + for f in ["m6a_sites.parquet", "pipeline_summary.json"] { + std::fs::write(dir.join(f), b"").unwrap(); + } + let table = dir.join("m6a_sites.parquet").to_string_lossy().into_owned(); + let record = dir.join("pipeline_summary.json"); + for given in [dir.to_path_buf(), record] { + let args = parse(&[&given.to_string_lossy(), "s.bam"]); + let (sites, bams) = args.inputs().unwrap().unwrap(); + assert_eq!(sites, boxed(&[&table])); + assert_eq!(bams, boxed(&["s.bam"])); + assert!(!args.batch_process, "the view by default"); + } + let args = parse(&[&dir.to_string_lossy(), "--modality", "atoi"]); + assert!(args.inputs().is_err(), "no A-to-I sites there"); +} + +#[test] +fn no_sites_in_batch_says_what_to_name() { + let err = parse(&["a.bam", "--batch-process"]).inputs().unwrap_err(); + assert!(err.to_string().contains("faba output directory"), "{err}"); +} + +#[test] +fn sam_and_cram_are_reads_too() { + assert!(is_bam("a.bam") && is_bam("b.CRAM") && is_bam("c.sam")); + assert!(!is_bam("m6a_sites.parquet") && !is_bam("out/")); +} + +#[test] +fn tables_called_against_different_genomes_need_one_named() { + let tmp = tempfile::tempdir().unwrap(); + // Two output folders, each with its sites and a run record naming a genome. + let table = |dir: &str, genome: &str| -> Box { + let d = tmp.path().join(dir); + std::fs::create_dir_all(&d).unwrap(); + let fa = tmp.path().join(genome); + std::fs::write(&fa, "").unwrap(); + let record = serde_json::json!({"inputs": {"genome": fa.to_str().unwrap()}}); + std::fs::write(d.join("pipeline_summary.json"), record.to_string()).unwrap(); + let sites = d.join("m6a_sites.parquet"); + std::fs::write(&sites, "").unwrap(); + sites.to_string_lossy().into() + }; + let (a, b, c) = ( + table("a", "hg.fa"), + table("b", "hg.fa"), + table("c", "mm.fa"), + ); + let same = genome_of(None, &[a.clone(), b]).unwrap(); + assert!(same.ends_with("hg.fa")); + let err = genome_of(None, &[a.clone(), c.clone()]).unwrap_err(); + assert!(err.to_string().contains("different genomes"), "{err}"); + assert_eq!( + &*genome_of(Some("x.fa"), &[a, c]).unwrap(), + "x.fa", + "--genome wins" + ); +} diff --git a/src/site_analysis/scan_pwm/tui.rs b/src/site_analysis/scan_pwm/tui.rs index 7beb05b..466e0e3 100644 --- a/src/site_analysis/scan_pwm/tui.rs +++ b/src/site_analysis/scan_pwm/tui.rs @@ -1,4 +1,4 @@ -//! Full-screen sequence logo for `faba pwm --interactive`. +//! Full-screen sequence logo for `faba pwm`. //! //! One stack of letters per position around the sites, tallest on top, in //! information content (bits) or frequency, in the usual logo colours; the diff --git a/src/site_analysis/tests/output_dir.rs b/src/site_analysis/tests/output_dir.rs index a9c8b25..4cb3ec5 100644 --- a/src/site_analysis/tests/output_dir.rs +++ b/src/site_analysis/tests/output_dir.rs @@ -35,3 +35,16 @@ fn a_directory_gives_its_site_matrices_by_batch_and_its_site_table() { let matrix = dir.join("b1_m6a.zarr.zip"); assert!(crate::run_record::output_dir_of(&matrix.to_string_lossy()).is_none()); } + +#[test] +fn the_default_output_and_title_follow_the_site_tables() { + let one: Vec> = vec!["out/m6a_sites.parquet".into()]; + assert_eq!(&*default_output(&one, ".pwm.tsv"), "m6a_sites.pwm.tsv"); + assert_eq!(title(&one), "m6a_sites.parquet"); + let two: Vec> = vec!["a/m6a_sites.parquet".into(), "b/atoi_sites.parquet".into()]; + assert_eq!( + &*default_output(&two, ".metagene.tsv"), + "m6a_sites_combined.metagene.tsv" + ); + assert_eq!(title(&two), "m6a_sites.parquet + atoi_sites.parquet"); +} diff --git a/src/tui.rs b/src/tui.rs index 7781811..5f3f94e 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -21,6 +21,19 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Clear, Paragraph}; use ratatui::Frame; +/// Whether to write and stop rather than open the view: when `asked` +/// (`--batch-process`), or when there is no terminal to show it in. +pub fn batch(asked: bool, command: &str) -> bool { + if asked { + return true; + } + let terminal = data_beans::interactive::tui_available(); + if !terminal { + log::info!("no terminal for the view: {command} runs as with --batch-process"); + } + !terminal +} + /// A `w` x `h` rectangle centred in `area`, shrunk to fit it. pub fn centered(area: Rect, w: u16, h: u16) -> Rect { let (w, h) = (w.min(area.width), h.min(area.height)); diff --git a/src/tui/busy.rs b/src/tui/busy.rs index fc2e266..5a7002c 100644 --- a/src/tui/busy.rs +++ b/src/tui/busy.rs @@ -2,7 +2,7 @@ //! terminal blank while it reads. use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::mpsc::{channel, Receiver, RecvTimeoutError}; +use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, TryRecvError}; use std::time::{Duration, Instant}; use data_beans::interactive::ui::{header, Screen, DIM, HIGHLIGHT}; @@ -24,6 +24,8 @@ struct Busy<'a, T> { start: Instant, rx: &'a Receiver, out: Option, + /// The work ended without a result: it panicked. + gone: bool, /// Files done, of `total`; no bar when `total` is 0. done: &'a AtomicUsize, total: usize, @@ -68,13 +70,15 @@ impl Screen for Busy<'_, T> { fn interrupt(&mut self) {} fn done(&self) -> bool { - self.out.is_some() + self.out.is_some() || self.gone } /// 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); + match self.rx.try_recv() { + Ok(out) => self.out = Some(out), + Err(TryRecvError::Disconnected) => self.gone = true, + Err(TryRecvError::Empty) => {} } true } @@ -123,6 +127,7 @@ pub fn busy_counting( start: Instant::now(), rx: &rx, out: None, + gone: false, done: &done, total, }; @@ -150,6 +155,7 @@ mod tests { start: Instant::now(), rx: &rx, out: None, + gone: false, done: &done, total: 3, }; @@ -165,6 +171,25 @@ mod tests { assert_eq!(b.out, Some(7)); } + #[test] + fn work_that_dies_ends_the_spinner() { + let (tx, rx) = channel::(); + let done = AtomicUsize::new(0); + let mut b = Busy { + badge: "pwm", + what: "collecting", + start: Instant::now(), + rx: &rx, + out: None, + gone: false, + done: &done, + total: 0, + }; + drop(tx); + b.tick(); + assert!(b.done() && b.out.is_none(), "not left spinning"); + } + #[test] fn without_showing_it_just_runs() { assert_eq!(busy(false, "x", "y", || Ok(3)).unwrap(), 3); From e56266cf23eeb1407228111366ff6606e4e4f287 Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 10:29:20 -0700 Subject: [PATCH 2/7] Drop the hidden -I/--interactive from pileup, metagene and pwm The view is the default; --batch-process is the only switch. --- docs/profiling-methods.md | 2 +- src/site_analysis/metagene.rs | 9 --------- src/site_analysis/metagene/tui.rs | 2 +- src/site_analysis/pileup.rs | 9 --------- src/site_analysis/pileup/picker.rs | 2 +- src/site_analysis/pileup/tui.rs | 2 +- src/site_analysis/scan_pwm.rs | 9 --------- 7 files changed, 4 insertions(+), 31 deletions(-) diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md index b9fb736..bc3b7de 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -642,7 +642,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. -The `--interactive` views of `pileup` and `metagene` draw the converted reads by default, where a +The 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/site_analysis/metagene.rs b/src/site_analysis/metagene.rs index 1f7f98d..d18c543 100644 --- a/src/site_analysis/metagene.rs +++ b/src/site_analysis/metagene.rs @@ -158,15 +158,6 @@ pub struct MetageneArgs { )] batch_process: bool, - /// The view is the default now; kept so older command lines still run. - #[arg( - short = 'I', - long = "interactive", - hide = true, - default_value_t = false - )] - interactive: bool, - #[arg( long = "max-width", default_value_t = 60, diff --git a/src/site_analysis/metagene/tui.rs b/src/site_analysis/metagene/tui.rs index 9944dd1..5b812a3 100644 --- a/src/site_analysis/metagene/tui.rs +++ b/src/site_analysis/metagene/tui.rs @@ -1,4 +1,4 @@ -//! Full-screen metagene profile for `faba metagene --interactive`. +//! Full-screen metagene profile for `faba metagene`. //! //! The coding track draws 5'UTR, CDS and 3'UTR bins end to end with the //! region boundaries labelled; the ncRNA track, when profiled, is its own diff --git a/src/site_analysis/pileup.rs b/src/site_analysis/pileup.rs index 3b8d825..c49ec97 100644 --- a/src/site_analysis/pileup.rs +++ b/src/site_analysis/pileup.rs @@ -221,15 +221,6 @@ pub struct PileupArgs { )] separate: bool, - /// The browser is the default now; kept so older command lines still run. - #[arg( - short = 'I', - long = "interactive", - hide = true, - default_value_t = false - )] - interactive: bool, - /////////////////////// // Miami figure mode // /////////////////////// diff --git a/src/site_analysis/pileup/picker.rs b/src/site_analysis/pileup/picker.rs index d7934e8..7d3eda6 100644 --- a/src/site_analysis/pileup/picker.rs +++ b/src/site_analysis/pileup/picker.rs @@ -1,4 +1,4 @@ -//! The gene list `faba pileup --interactive` opens when no gene or region +//! The gene list `faba pileup` opens when no gene or region //! was given: every gene with site rows in the inputs, read off the row //! names alone, filtered as you type (`/` starts over). A typed //! `chr:start-end` or `chr:pos` opens that locus. diff --git a/src/site_analysis/pileup/tui.rs b/src/site_analysis/pileup/tui.rs index 55327e2..ef2dcca 100644 --- a/src/site_analysis/pileup/tui.rs +++ b/src/site_analysis/pileup/tui.rs @@ -1,4 +1,4 @@ -//! Full-screen gene-body browser for `faba pileup --interactive`. Tracks share one axis, +//! Full-screen gene-body browser for `faba pileup`. Tracks share one axis, //! re-binned each frame (a bar per column, via [`BinEdges`]); the cursor is a genomic position. use super::{distinct_positions, fmt_thousands, BinEdges}; use super::{genes_to_draw, SharedModels}; diff --git a/src/site_analysis/scan_pwm.rs b/src/site_analysis/scan_pwm.rs index 1224694..f865c8b 100644 --- a/src/site_analysis/scan_pwm.rs +++ b/src/site_analysis/scan_pwm.rs @@ -104,15 +104,6 @@ pub struct ScanPwmArgs { sequence logo in a 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", - hide = true, - default_value_t = false - )] - interactive: bool, } /// Swap A<->T and G<->C counts to complement a DnaBaseCount. From 77303964c7e1f7923c0705e3f1f673b0c68258a6 Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 10:35:00 -0700 Subject: [PATCH 3/7] pwm: choose or download a genome when none is recorded In the view, with no -f and no recorded genome found here, a picker lists FASTA files; Tab downloads one from GENCODE or Ensembl into ~/faba_refs, as faba run does, starting on the source whose chromosome names match the sites. The download pop-ups now live beside the download code, shared by both. --- docs/profiling-methods.md | 4 +- src/main.rs | 3 +- src/pipeline/mod.rs | 2 +- src/pipeline/tui.rs | 2 +- src/pipeline/tui/draw.rs | 102 +------------- src/pipeline/tui/fetch.rs | 112 ++++++++++++++- src/pipeline/tui/inputs.rs | 8 +- src/site_analysis/input_picker.rs | 176 +++++++++++++++++++++++- src/site_analysis/scan_pwm.rs | 59 ++++---- src/site_analysis/scan_pwm/tests.rs | 13 +- src/site_analysis/tests/input_picker.rs | 28 +++- src/tui/browser.rs | 9 ++ 12 files changed, 381 insertions(+), 137 deletions(-) diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md index bc3b7de..2a58bf2 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -615,7 +615,9 @@ None of these fit a model or produce a p-value. reverse-complementing minus-strand sites. Bases come from the reference FASTA by default (`--source reference`) or from the reads (`--source reads`). The output is a base-frequency matrix, not a log-odds PWM. It takes a faba output directory for its site table, browses for - sites when none is named, and shows a sequence logo unless `--batch-process`. + sites when none is named, and shows a sequence logo unless `--batch-process`. With no genome + recorded (or the recorded one not on this machine), the view asks for a FASTA, or downloads one + from GENCODE or Ensembl into `~/faba_refs/` as `faba run` does. - **`pileup`** aggregates the matrix rows matched by `--genes` and/or `--regions` into one ASCII histogram, or (with `--gtf`, `--bam`, `--format`, `--svg` or `--png`) a faceted Miami plot: sites above, gene model in the middle, read depth below, one panel per cell type. diff --git a/src/main.rs b/src/main.rs index 5cf3f5d..4310957 100644 --- a/src/main.rs +++ b/src/main.rs @@ -209,7 +209,8 @@ Example:\n \ \n\ Give it a faba output directory (or a run record in it) and it reads\n\ that directory's site table of --modality against its recorded genome.\n\ - With no sites named, a browser asks for them. It then shows the PWM\n\ + With no sites named, a browser asks for them; with no genome recorded\n\ + (or found here), another asks for a FASTA, or downloads one. It then shows the PWM\n\ as a sequence logo full screen, unless --batch-process.", after_long_help = "\ Example:\n\ diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs index a47fd07..e50ffc1 100644 --- a/src/pipeline/mod.rs +++ b/src/pipeline/mod.rs @@ -12,7 +12,7 @@ pub mod run; /// The per-modality steps, in run order. mod steps; /// The `faba run` setup view. -mod tui; +pub(crate) mod tui; #[cfg(test)] #[path = "tests/args.rs"] diff --git a/src/pipeline/tui.rs b/src/pipeline/tui.rs index 1379200..649e7af 100644 --- a/src/pipeline/tui.rs +++ b/src/pipeline/tui.rs @@ -3,7 +3,7 @@ mod child; mod draw; -mod fetch; +pub(crate) mod fetch; mod form; mod inputs; mod keys; diff --git a/src/pipeline/tui/draw.rs b/src/pipeline/tui/draw.rs index dd76e5a..8ceb937 100644 --- a/src/pipeline/tui/draw.rs +++ b/src/pipeline/tui/draw.rs @@ -13,14 +13,12 @@ use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; use super::child::{ended_word, Failed}; -use super::fetch::{Catalogue, Fetch, Source, SOURCES}; use super::form::{self, Kind}; use super::inputs::{InputsFocus, OutState, Role, Row}; use super::steps::Step; use super::{script, App, Hit, Page, Target}; use crate::tui::{ - button_popup, filled, first_visible, marker, popup, popup_frame, tab_bar, tilde, window, - APPLY_KEYS, + button_popup, first_visible, marker, popup_frame, tab_bar, tilde, window, APPLY_KEYS, }; /// Width of the inputs panel on the Inputs screen. @@ -87,10 +85,10 @@ pub(super) fn draw(app: &App, frame: &mut Frame) { draw_picking(app, frame, body); } if let Some(c) = &app.catalogue { - draw_catalogue(c, frame, body); + c.draw(frame, body); } if let Some(f) = app.fetch.as_ref().filter(|_| app.fetch_shown) { - draw_fetch(f, frame, body); + f.draw(frame, body); } if app.preview { draw_preview(app, frame, body); @@ -533,100 +531,6 @@ fn draw_ended(app: &App, frame: &mut Frame, body: Rect) { button_popup(frame, body, &title, &[("ok", Hit::Ok)], lines, &app.hits); } -/// A download's pop-up: what, where, the step and how far through it. -fn draw_fetch(f: &Fetch, frame: &mut Frame, body: Rect) { - let (step, bytes) = f - .status - .lock() - .map_or_else(|_| (String::new(), None), |s| (s.step.clone(), s.bytes)); - let mb = |b: u64| b as f64 / f64::from(1 << 20); - let progress = match bytes { - Some((got, Some(total))) if total > 0 => { - let w = 36; - let full = filled(got, total, w); - format!( - "{}{} {:>3.0}% {:.0} / {:.0} MB", - "█".repeat(full), - "░".repeat(w - full), - got.min(total) as f64 / total as f64 * 100.0, - mb(got), - mb(total) - ) - } - Some((got, _)) => format!("{:.0} MB", mb(got)), - None => String::new(), - }; - let lines = vec![ - Line::styled(f.label.clone(), PLAIN), - Line::styled(format!("into {}", tilde(&f.dir)), DIM), - Line::raw(""), - Line::styled(step, HIGHLIGHT), - Line::styled(progress, HIGHLIGHT), - Line::raw(""), - Line::styled("the GTF, then the genome: fetched, unpacked, indexed", DIM), - ]; - popup(frame, body, " downloading a reference ", lines); -} - -/// The download pop-up: the sources, or one source's releases or species. -fn draw_catalogue(c: &Catalogue, frame: &mut Frame, body: Rect) { - let rows = usize::from(body.height.saturating_sub(10)).clamp(3, 16); - let mut lines: Vec = Vec::new(); - let title = match c.source { - None => { - lines.extend(SOURCES.iter().enumerate().map(|(i, (name, s))| { - let fits = match c.bam_chr { - Some(chr) if chr == s.chr_named() => " matches your BAMs", - _ => "", - }; - Line::from(vec![ - marker(i == c.at), - Span::raw(*name), - Span::styled(fits, DIM), - ]) - })); - " download a reference ".to_string() - } - Some(s) => { - let what = match s { - Source::Gencode(_) => "release", - Source::Ensembl => "species", - }; - if c.loading() { - lines.push(Line::styled("reading the FTP listing…", DIM)); - } else if let Some(e) = &c.error { - lines.push(Line::styled(format!("cannot list: {e}"), HIGHLIGHT)); - } else if c.items.is_empty() { - lines.push(Line::styled(c.items.empty_note(""), DIM)); - } else { - let items = - c.items.shown().enumerate().map(|(i, n)| { - Line::from(vec![marker(i == c.items.at), Span::raw(n.clone())]) - }); - lines.extend(window(items.collect(), c.items.at, rows)); - } - format!(" choose a {what} {}", c.items.find.tag()) - } - }; - lines.push(Line::raw("")); - lines.push(Line::styled( - "GENCODE names chromosomes chr1, Ensembl 1: take the one your BAMs use", - DIM, - )); - if let Some(chr) = c.bam_chr { - let named = if chr { "chr1" } else { "1" }; - lines.push(Line::styled( - format!("your first BAM names them {named}"), - DIM, - )); - } - lines.push(Line::styled( - "the GTF and genome come to about 1 GB, 3 GB unpacked", - DIM, - )); - popup(frame, body, &title, lines); -} - fn draw_preview(app: &App, frame: &mut Frame, body: Rect) { // What blocks the run and where the script goes come first: a long list // of BAMs must not push them out of the pop-up. diff --git a/src/pipeline/tui/fetch.rs b/src/pipeline/tui/fetch.rs index ad3864a..f608fff 100644 --- a/src/pipeline/tui/fetch.rs +++ b/src/pipeline/tui/fetch.rs @@ -14,8 +14,13 @@ use std::thread::JoinHandle; use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use data_beans::interactive::ui::{DIM, HIGHLIGHT, PLAIN}; +use ratatui::layout::Rect; +use ratatui::text::{Line, Span}; +use ratatui::Frame; + use super::child::Stopper; -use crate::tui::{moved, FindList}; +use crate::tui::{filled, marker, moved, popup, tilde, window, FindList}; const GENCODE: &str = "https://ftp.ebi.ac.uk/pub/databases/gencode"; const ENSEMBL: &str = "https://ftp.ensembl.org/pub/current"; @@ -145,6 +150,8 @@ pub struct Catalogue { pub error: Option, /// Whether the first BAM picked names chromosomes `chr1`, if known. pub bam_chr: Option, + /// Whose chromosome names those are, for the hints: "your BAMs". + pub chr_from: &'static str, } impl Catalogue { @@ -163,6 +170,7 @@ impl Catalogue { pending: None, error: None, bam_chr, + chr_from: "your BAMs", } } @@ -291,6 +299,108 @@ impl Pick { } } +/// A download's pop-up: what, where, the step and how far through it. +impl Fetch { + pub fn draw(&self, frame: &mut Frame, body: Rect) { + let f = self; + let (step, bytes) = f + .status + .lock() + .map_or_else(|_| (String::new(), None), |s| (s.step.clone(), s.bytes)); + let mb = |b: u64| b as f64 / f64::from(1 << 20); + let progress = match bytes { + Some((got, Some(total))) if total > 0 => { + let w = 36; + let full = filled(got, total, w); + format!( + "{}{} {:>3.0}% {:.0} / {:.0} MB", + "█".repeat(full), + "░".repeat(w - full), + got.min(total) as f64 / total as f64 * 100.0, + mb(got), + mb(total) + ) + } + Some((got, _)) => format!("{:.0} MB", mb(got)), + None => String::new(), + }; + let lines = vec![ + Line::styled(f.label.clone(), PLAIN), + Line::styled(format!("into {}", tilde(&f.dir)), DIM), + Line::raw(""), + Line::styled(step, HIGHLIGHT), + Line::styled(progress, HIGHLIGHT), + Line::raw(""), + Line::styled("the GTF, then the genome: fetched, unpacked, indexed", DIM), + ]; + popup(frame, body, " downloading a reference ", lines); + } +} + +/// The download pop-up: the sources, or one source's releases or species. +impl Catalogue { + pub fn draw(&self, frame: &mut Frame, body: Rect) { + let c = self; + let rows = usize::from(body.height.saturating_sub(10)).clamp(3, 16); + let mut lines: Vec = Vec::new(); + let title = match c.source { + None => { + lines.extend(SOURCES.iter().enumerate().map(|(i, (name, s))| { + let fits = match c.bam_chr { + Some(chr) if chr == s.chr_named() => format!(" matches {}", c.chr_from), + _ => String::new(), + }; + Line::from(vec![ + marker(i == c.at), + Span::raw(*name), + Span::styled(fits, DIM), + ]) + })); + " download a reference ".to_string() + } + Some(s) => { + let what = match s { + Source::Gencode(_) => "release", + Source::Ensembl => "species", + }; + if c.loading() { + lines.push(Line::styled("reading the FTP listing…", DIM)); + } else if let Some(e) = &c.error { + lines.push(Line::styled(format!("cannot list: {e}"), HIGHLIGHT)); + } else if c.items.is_empty() { + lines.push(Line::styled(c.items.empty_note(""), DIM)); + } else { + let items = c.items.shown().enumerate().map(|(i, n)| { + Line::from(vec![marker(i == c.items.at), Span::raw(n.clone())]) + }); + lines.extend(window(items.collect(), c.items.at, rows)); + } + format!(" choose a {what} {}", c.items.find.tag()) + } + }; + lines.push(Line::raw("")); + lines.push(Line::styled( + format!( + "GENCODE names chromosomes chr1, Ensembl 1: take the one {} use", + c.chr_from + ), + DIM, + )); + if let Some(chr) = c.bam_chr { + let named = if chr { "chr1" } else { "1" }; + lines.push(Line::styled( + format!("{} name them {named}", c.chr_from), + DIM, + )); + } + lines.push(Line::styled( + "the GTF and genome come to about 1 GB, 3 GB unpacked", + DIM, + )); + popup(frame, body, &title, lines); + } +} + /// A GENCODE release's primary-assembly annotation and genome. pub fn gencode_files(names: &[String]) -> Option<(String, String)> { let gtf = names diff --git a/src/pipeline/tui/inputs.rs b/src/pipeline/tui/inputs.rs index 8a85c4b..e03cb8c 100644 --- a/src/pipeline/tui/inputs.rs +++ b/src/pipeline/tui/inputs.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; -pub use crate::tui::browser::{is_annotation, normalize, Browser}; +pub use crate::tui::browser::{is_annotation, is_fasta, normalize, Browser}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Role { @@ -48,11 +48,7 @@ impl FileRow { pub fn browser(self, start: PathBuf) -> Browser { let keep: fn(&str) -> bool = match self { FileRow::Gff => is_annotation, - FileRow::Genome => |n| { - [".fa", ".fasta", ".fa.gz", ".fasta.gz"] - .iter() - .any(|x| n.ends_with(x)) - }, + FileRow::Genome => is_fasta, FileRow::KnownSnps => |n| { [".vcf", ".vcf.gz", ".bcf", ".parquet"] .iter() diff --git a/src/site_analysis/input_picker.rs b/src/site_analysis/input_picker.rs index c9614a8..200a868 100644 --- a/src/site_analysis/input_picker.rs +++ b/src/site_analysis/input_picker.rs @@ -11,9 +11,10 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; +use crate::pipeline::tui::fetch::{Catalogue, Fetch}; use crate::qc::layout::looks_like_faba_dir; use crate::qc::path_tui::faba_browser; -use crate::tui::browser::{Browser, Nav}; +use crate::tui::browser::{is_fasta, Browser, Nav}; use crate::tui::{is_apply, popup_frame, run_view, tilde, View, APPLY_KEYS}; /// What was chosen: the marked files, or one folder. @@ -210,6 +211,179 @@ impl Screen for InputPicker { } } +/// Choosing a genome FASTA, by Enter on one, or downloading one (Tab) +/// from the references `faba run` offers: for a command that found none +/// recorded, such as `pwm`. +pub struct GenomePicker { + browser: Browser, + command: &'static str, + /// Choosing a reference to download, or downloading it. + fetching: Fetching, + /// Whether the sites name chromosomes `chr1`, for the download list. + chr: Option, + /// Why the last download ended without a genome. + note: Option, + /// The genome; `Some(None)` when cancelled. + pub decision: Option>, +} + +enum Fetching { + No, + Choosing(Catalogue), + Downloading(Fetch), +} + +impl GenomePicker { + /// Listing the folders and FASTA files at `cwd`; `chr` is whether the + /// sites name chromosomes `chr1`, if known. + pub fn new(cwd: PathBuf, command: &'static str, chr: Option) -> Self { + Self { + browser: Browser::new(cwd, is_fasta, |_| false).opened(), + command, + fetching: Fetching::No, + chr, + note: None, + decision: None, + } + } + + fn key_choosing(&mut self, key: KeyEvent) { + let Fetching::Choosing(c) = &mut self.fetching else { + return; + }; + if c.key(&key) { + return; + } + match key.code { + KeyCode::Enter | KeyCode::Right => { + if let Some(pick) = c.enter() { + let base = crate::tui::home().unwrap_or_else(|| self.browser.cwd.clone()); + let dir = base.join("faba_refs").join(pick.dir_name()); + self.fetching = Fetching::Downloading(Fetch::start(pick, dir)); + } + } + KeyCode::Esc | KeyCode::Left if !c.back() => self.fetching = Fetching::No, + _ => {} + } + } +} + +impl View for GenomePicker {} + +impl Screen for GenomePicker { + fn done(&self) -> bool { + self.decision.is_some() + } + + fn interrupt(&mut self) { + self.decision = Some(None); + } + + fn handle_key(&mut self, key: KeyEvent) { + match &self.fetching { + Fetching::Choosing(_) => return self.key_choosing(key), + Fetching::Downloading(f) => { + // Esc stops it; what came stays for the next try. + if key.code == KeyCode::Esc { + f.stop(); + self.fetching = Fetching::No; + self.note = Some("download stopped; Tab picks up where it left".into()); + } + return; + } + Fetching::No => {} + } + if key.code == KeyCode::Tab { + let mut c = Catalogue::new(self.chr); + c.chr_from = "your sites"; + self.fetching = Fetching::Choosing(c); + self.note = None; + return; + } + match self.browser.key(key) { + Nav::Picked(file) => self.decision = Some(Some(file)), + Nav::Moved => {} + Nav::Ignored if key.code == KeyCode::Esc => self.decision = Some(None), + Nav::Ignored => {} + } + } + + /// Take a listing or the download's end as they come; redraw while + /// either is under way. + fn tick(&mut self) -> bool { + match &mut self.fetching { + Fetching::No => false, + Fetching::Choosing(c) => c.poll(), + Fetching::Downloading(f) => { + let done = f.status.lock().ok().and_then(|mut s| s.done.take()); + match done { + Some(Ok((_, genome))) => self.decision = Some(Some(genome)), + Some(Err(e)) => { + self.note = Some(format!("{} failed: {e}", f.label)); + self.fetching = Fetching::No; + } + None => {} + } + true + } + } + } + + fn render(&mut self, frame: &mut Frame) { + let area = frame.area(); + frame.render_widget(header(self.command, "choose the genome", ""), area); + let w = area.width.saturating_sub(4).clamp(20, 90); + let help = [ + help_line(&[ + ("type", "find"), + ("↑/↓", "move"), + ("→", "open"), + ("←", "up"), + ]), + help_line(&[ + ("Enter", "choose"), + ("Tab", "download one"), + ("Esc", "clear find / cancel"), + ]), + ]; + // Header, border and the note line, besides the list and the help. + let help_rows = help.len() as u16; + let rows = area.height.saturating_sub(7 + help_rows).clamp(3, 24); + let title = format!( + " no genome recorded: a FASTA · {} {}", + tilde(&self.browser.cwd), + self.browser.list.find.tag() + ); + let inner = popup_frame(frame, area, w, rows + 3 + help_rows, title); + let mut lines = self.browser.lines( + rows as usize, + inner.width as usize, + "", + "no subfolder or FASTA here", + ); + lines.push(match &self.note { + Some(why) => Line::from(Span::styled(format!(" {why}"), HIGHLIGHT)), + None => Line::from(""), + }); + lines.extend(help); + frame.render_widget(Paragraph::new(lines), inner); + match &self.fetching { + Fetching::No => {} + Fetching::Choosing(c) => c.draw(frame, area), + Fetching::Downloading(f) => f.draw(frame, area), + } + } +} + +/// A genome FASTA chosen in a browser at the working folder, or downloaded; +/// `Ok(None)` when the user cancels. `chr` is whether the sites name +/// chromosomes `chr1`, if known. +pub fn ask_genome(command: &'static str, chr: Option) -> anyhow::Result> { + let mut picker = GenomePicker::new(std::env::current_dir()?, command, chr); + run_view(&mut picker)?; + Ok(picker.decision.flatten()) +} + /// Where the last choices are kept, one line per working folder /// (`folderchoice`), so the picker opens there next time. fn memo_file() -> Option { diff --git a/src/site_analysis/scan_pwm.rs b/src/site_analysis/scan_pwm.rs index f865c8b..0fb5aaa 100644 --- a/src/site_analysis/scan_pwm.rs +++ b/src/site_analysis/scan_pwm.rs @@ -279,29 +279,23 @@ fn write_pwm(pwm: &[DnaBaseCount], window: i64, output: &str) -> anyhow::Result< } /// The genome to read every table's sites against: `explicit`, else the one -/// recorded next to each table, which must be the same for all. -fn genome_of(explicit: Option<&str>, site_files: &[Box]) -> anyhow::Result> { +/// recorded next to the tables (and still there), which must be the same +/// for all; `None` when none is. +fn genome_of(explicit: Option<&str>, site_files: &[Box]) -> anyhow::Result>> { if let Some(genome) = explicit { - return Ok(genome.into()); + return Ok(Some(genome.into())); } - let recorded = |f: &str| { - crate::run_record::explicit_or_recorded(None, f, "genome", "genome").ok_or_else(|| { - anyhow::anyhow!( - "--genome is required when --source reference: \ - no run record next to {f} names a genome" - ) - }) - }; - let first = recorded(&site_files[0])?; - for f in &site_files[1..] { - let this = recorded(f)?; - anyhow::ensure!( - this == first, - "the site tables were called against different genomes ({first}, {this}); \ - name one with --genome" - ); - } - Ok(first) + let mut found: Vec> = site_files + .iter() + .filter_map(|f| crate::run_record::explicit_or_recorded(None, f, "genome", "genome")) + .collect(); + found.dedup(); + anyhow::ensure!( + found.len() < 2, + "the site tables were called against different genomes ({}); name one with --genome", + found.join(", ") + ); + Ok(found.pop()) } /// Whether `path` names an alignment file (BAM, CRAM or SAM) rather than @@ -347,8 +341,28 @@ pub fn run_scan_pwm(args: &ScanPwmArgs) -> anyhow::Result<()> { ); } // A genome missing, or differing between tables, fails before the reading. + let batch = crate::tui::batch(args.batch_process, "pwm"); + // A genome missing, or differing between tables, is settled before the + // reading: in the view, by choosing a FASTA. let genome = match args.source { - PwmSource::Reference => Some(genome_of(args.genome_file.as_deref(), &site_files)?), + PwmSource::Reference => match genome_of(args.genome_file.as_deref(), &site_files)? { + Some(genome) => Some(genome), + None if batch => anyhow::bail!( + "--genome is required when --source reference: \ + no run record next to {} names a genome found here", + site_files[0] + ), + None => { + // The sites' chromosome names steer the download list. + let chr = read_sites(&site_files[0]) + .ok() + .and_then(|s| s.first().map(|s| s.chr.starts_with("chr"))); + let Some(fasta) = super::input_picker::ask_genome("pwm", chr)? else { + return Ok(()); + }; + Some(fasta.to_string_lossy().into()) + } + }, PwmSource::Reads => { anyhow::ensure!( !bam_files.is_empty(), @@ -357,7 +371,6 @@ pub fn run_scan_pwm(args: &ScanPwmArgs) -> anyhow::Result<()> { None } }; - let batch = crate::tui::batch(args.batch_process, "pwm"); let what = format!("collecting bases around {} site table(s)", site_files.len()); let pwm = crate::tui::busy(!batch, "pwm", &what, || { collect(args, &site_files, genome.as_deref(), &bam_files, !batch) diff --git a/src/site_analysis/scan_pwm/tests.rs b/src/site_analysis/scan_pwm/tests.rs index ea5dbbc..c68d6a4 100644 --- a/src/site_analysis/scan_pwm/tests.rs +++ b/src/site_analysis/scan_pwm/tests.rs @@ -83,13 +83,22 @@ fn tables_called_against_different_genomes_need_one_named() { table("b", "hg.fa"), table("c", "mm.fa"), ); - let same = genome_of(None, &[a.clone(), b]).unwrap(); + let same = genome_of(None, &[a.clone(), b]).unwrap().unwrap(); assert!(same.ends_with("hg.fa")); let err = genome_of(None, &[a.clone(), c.clone()]).unwrap_err(); assert!(err.to_string().contains("different genomes"), "{err}"); assert_eq!( - &*genome_of(Some("x.fa"), &[a, c]).unwrap(), + &*genome_of(Some("x.fa"), &[a, c]).unwrap().unwrap(), "x.fa", "--genome wins" ); } + +#[test] +fn no_genome_recorded_is_none_to_ask_for() { + let tmp = tempfile::tempdir().unwrap(); + let sites = tmp.path().join("m6a_sites.parquet"); + std::fs::write(&sites, "").unwrap(); + let sites: Box = sites.to_string_lossy().into(); + assert!(genome_of(None, &[sites]).unwrap().is_none()); +} diff --git a/src/site_analysis/tests/input_picker.rs b/src/site_analysis/tests/input_picker.rs index 78b2d31..adc4f51 100644 --- a/src/site_analysis/tests/input_picker.rs +++ b/src/site_analysis/tests/input_picker.rs @@ -1,7 +1,7 @@ use super::*; use ratatui::crossterm::event::KeyModifiers; -fn press(p: &mut InputPicker, code: KeyCode) { +fn press(p: &mut impl View, code: KeyCode) { crate::tui::feed(p, KeyEvent::new(code, KeyModifiers::NONE)); } @@ -148,3 +148,29 @@ fn the_last_choice_is_kept_per_working_folder() { let kept = std::fs::read_to_string(&memo).unwrap(); assert_eq!(kept.lines().count(), 2, "one line per folder"); } + +#[test] +fn the_genome_picker_lists_fasta_and_offers_a_download() { + let tmp = tempfile::tempdir().unwrap(); + for f in ["hg38.fa", "mm10.fasta.gz", "notes.txt"] { + std::fs::write(tmp.path().join(f), b"").unwrap(); + } + let mut p = GenomePicker::new(tmp.path().to_path_buf(), "pwm", Some(true)); + let names: Vec<&str> = p.browser.list.shown().map(|e| e.name.as_str()).collect(); + assert!(names.contains(&"hg38.fa") && names.contains(&"mm10.fasta.gz")); + assert!(!names.contains(&"notes.txt"), "{names:?}"); + + // Tab: the sources, on the one naming chromosomes chr1; Esc backs out. + press(&mut p, KeyCode::Tab); + assert!(matches!(&p.fetching, Fetching::Choosing(c) if c.bam_chr == Some(true))); + press(&mut p, KeyCode::Esc); + assert!(matches!(p.fetching, Fetching::No) && p.decision.is_none()); + + p.browser.list.select("hg38.fa"); + press(&mut p, KeyCode::Enter); + assert_eq!(p.decision, Some(Some(tmp.path().join("hg38.fa")))); + + let mut p = GenomePicker::new(tmp.path().to_path_buf(), "pwm", None); + press(&mut p, KeyCode::Esc); + assert_eq!(p.decision, Some(None), "Esc cancels"); +} diff --git a/src/tui/browser.rs b/src/tui/browser.rs index 249b761..63eece0 100644 --- a/src/tui/browser.rs +++ b/src/tui/browser.rs @@ -36,6 +36,15 @@ pub fn is_annotation(name: &str) -> bool { .any(|ext| name.ends_with(ext)) } +/// Whether `name` is a genome FASTA, gzipped or not, any case. +pub fn is_fasta(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + let name = name.strip_suffix(".gz").unwrap_or(&name); + [".fa", ".fasta", ".fna"] + .iter() + .any(|ext| name.ends_with(ext)) +} + /// One listed name. #[derive(Debug)] pub struct Entry { From 5d1f15fa8cffb16170ba891c678b7c400c94bb96 Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 10:43:36 -0700 Subject: [PATCH 4/7] pwm: read the reference in parallel, a FASTA reader per chunk of sites --- src/site_analysis/scan_pwm.rs | 83 +++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/src/site_analysis/scan_pwm.rs b/src/site_analysis/scan_pwm.rs index 0fb5aaa..bde46f3 100644 --- a/src/site_analysis/scan_pwm.rs +++ b/src/site_analysis/scan_pwm.rs @@ -116,49 +116,66 @@ fn complement_base_count(count: &DnaBaseCount) -> DnaBaseCount { out } -/// Collect PWM from reference FASTA sequence around each site. +/// Collect PWM from reference FASTA sequence around each site, in chunks +/// of sites across threads, each with its own FASTA reader (one cannot be +/// shared; opening one reads only the index). fn collect_from_reference( sites: &[GenomicSite], genome_file: &str, window: i64, ) -> anyhow::Result> { let width = (2 * window + 1) as usize; - let mut pwm: Vec = (0..width).map(|_| DnaBaseCount::new()).collect(); - - let faidx = util_htslib::load_fasta_index(genome_file)?; - - for site in sites { - let start = site.position - window; - let end = site.position + window; // inclusive - let seq = util_htslib::fetch_reference_seq(&faidx, &site.chr, start, end)?; - - if let Some(bases) = seq { - if bases.len() != width { - continue; // skip sites near chromosome boundaries - } - match site.strand { - Strand::Forward => { - for (j, base) in bases.iter().enumerate() { - pwm[j].add(Some(base), 1); - } + let empty = || (0..width).map(|_| DnaBaseCount::new()).collect::>(); + // Fail on an unreadable genome before splitting up the work. + util_htslib::load_fasta_index(genome_file)?; + let chunk = sites + .len() + .div_ceil(rayon::current_num_threads() * 4) + .max(1); + + sites + .par_chunks(chunk) + .map(|chunk| -> anyhow::Result> { + let faidx = util_htslib::load_fasta_index(genome_file)?; + let mut pwm = empty(); + for site in chunk { + let start = site.position - window; + let end = site.position + window; // inclusive + let Some(bases) = util_htslib::fetch_reference_seq(&faidx, &site.chr, start, end)? + else { + continue; + }; + if bases.len() != width { + continue; // skip sites near chromosome boundaries } - Strand::Backward => { - // Reverse complement: reverse order and complement each base - for (j, base) in bases.iter().rev().enumerate() { - let comp = match base { - Dna::A => Dna::T, - Dna::T => Dna::A, - Dna::G => Dna::C, - Dna::C => Dna::G, - }; - pwm[j].add(Some(&comp), 1); + match site.strand { + Strand::Forward => { + for (j, base) in bases.iter().enumerate() { + pwm[j].add(Some(base), 1); + } + } + Strand::Backward => { + // Reverse complement: reverse order and complement each base + for (j, base) in bases.iter().rev().enumerate() { + let comp = match base { + Dna::A => Dna::T, + Dna::T => Dna::A, + Dna::G => Dna::C, + Dna::C => Dna::G, + }; + pwm[j].add(Some(&comp), 1); + } } } } - } - } - - Ok(pwm) + Ok(pwm) + }) + .try_reduce(empty, |mut acc, local| { + for (a, b) in acc.iter_mut().zip(local.iter()) { + *a += b; + } + Ok(acc) + }) } /// Collect PWM from BAM read base frequencies around each site. From 0b596a91d8086b5bdbbd45dd596a8c3a42b1cc9c Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 10:47:17 -0700 Subject: [PATCH 5/7] apa records each site's strand; pwm orients APA sites by it apa_components.parquet gains a strand column (the UTR's). The site reader takes it, so pwm reverse-complements minus-strand APA sites as it does m6A and A-to-I ones; a table written before has none, read as forward with a warning to rerun faba apa. --- src/apa/cell_assign.rs | 6 +++++- src/apa/pdui/tests.rs | 1 + src/apa/pipeline.rs | 12 ++++++++++- src/site_analysis/site_io.rs | 16 +++++++++++++-- src/site_analysis/tests/site_io.rs | 32 ++++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/apa/cell_assign.rs b/src/apa/cell_assign.rs index fac73f9..e7d0671 100644 --- a/src/apa/cell_assign.rs +++ b/src/apa/cell_assign.rs @@ -1,7 +1,7 @@ use crate::apa::em::EmResult; use crate::apa::fragment::FragmentRecord; use crate::apa::utr_region::UtrRegion; -use genomic_data::sam::{CellBarcode, UmiBarcode}; +use genomic_data::sam::{CellBarcode, Strand, UmiBarcode}; use rustc_hash::FxHashMap as HashMap; /// A cell-level count at a specific pA site. @@ -22,6 +22,8 @@ pub struct ApaSiteAnnotation { pub site_id: Box, pub gene_name: Box, pub chr: Box, + /// The UTR's strand, so a reader can orient the site. + pub strand: Strand, pub genomic_alpha: i64, pub beta: f32, pub genomic_start: i64, @@ -141,6 +143,7 @@ pub fn assign_fragments_to_sites( site_id, gene_name: utr.name.clone(), chr: utr.chr.clone(), + strand: utr.strand, genomic_alpha, beta, genomic_start: gstart, @@ -205,6 +208,7 @@ pub fn assign_fragments_two_site_fast( site_id: crate::apa::site_id(&utr.name, &k.to_string()), gene_name: utr.name.clone(), chr: utr.chr.clone(), + strand: utr.strand, genomic_alpha, beta, genomic_start: gstart, diff --git a/src/apa/pdui/tests.rs b/src/apa/pdui/tests.rs index c8abeb4..ba400f7 100644 --- a/src/apa/pdui/tests.rs +++ b/src/apa/pdui/tests.rs @@ -6,6 +6,7 @@ fn make_annotation(site_id: &str, gene: &str, alpha: i64, weight: f32) -> ApaSit site_id: site_id.into(), gene_name: gene.into(), chr: "chr1".into(), + strand: Strand::Forward, genomic_alpha: alpha, beta: 30.0_f32, genomic_start: alpha - 50, diff --git a/src/apa/pipeline.rs b/src/apa/pipeline.rs index 9537d6b..e555a9f 100644 --- a/src/apa/pipeline.rs +++ b/src/apa/pipeline.rs @@ -883,10 +883,14 @@ fn compute_and_write_pdui( } /// Write APA site annotations to a Parquet file. -fn write_apa_annotations(annotations: &[ApaSiteAnnotation], path: &str) -> anyhow::Result<()> { +pub(crate) fn write_apa_annotations( + annotations: &[ApaSiteAnnotation], + path: &str, +) -> anyhow::Result<()> { let mut site_ids = Vec::with_capacity(annotations.len()); let mut gene_names = Vec::with_capacity(annotations.len()); let mut chrs = Vec::with_capacity(annotations.len()); + let mut strands = Vec::with_capacity(annotations.len()); let mut genomic_alphas = Vec::with_capacity(annotations.len()); let mut betas = Vec::with_capacity(annotations.len()); let mut genomic_starts = Vec::with_capacity(annotations.len()); @@ -899,6 +903,10 @@ fn write_apa_annotations(annotations: &[ApaSiteAnnotation], path: &str) -> anyho site_ids.push(a.site_id.as_ref()); gene_names.push(a.gene_name.as_ref()); chrs.push(a.chr.as_ref()); + strands.push(match a.strand { + Strand::Forward => "+", + Strand::Backward => "-", + }); genomic_alphas.push(a.genomic_alpha); betas.push(a.beta); genomic_starts.push(a.genomic_start); @@ -912,6 +920,7 @@ fn write_apa_annotations(annotations: &[ApaSiteAnnotation], path: &str) -> anyho arrow::datatypes::Field::new("site_id", arrow::datatypes::DataType::Utf8, false), arrow::datatypes::Field::new("gene_name", arrow::datatypes::DataType::Utf8, false), arrow::datatypes::Field::new("chr", arrow::datatypes::DataType::Utf8, false), + arrow::datatypes::Field::new("strand", arrow::datatypes::DataType::Utf8, false), arrow::datatypes::Field::new("genomic_alpha", arrow::datatypes::DataType::Int64, false), arrow::datatypes::Field::new("beta", arrow::datatypes::DataType::Float32, false), arrow::datatypes::Field::new("genomic_start", arrow::datatypes::DataType::Int64, false), @@ -931,6 +940,7 @@ fn write_apa_annotations(annotations: &[ApaSiteAnnotation], path: &str) -> anyho Arc::new(StringArray::from(site_ids)) as ArrayRef, Arc::new(StringArray::from(gene_names)) as ArrayRef, Arc::new(StringArray::from(chrs)) as ArrayRef, + Arc::new(StringArray::from(strands)) as ArrayRef, Arc::new(Int64Array::from(genomic_alphas)) as ArrayRef, Arc::new(Float32Array::from(betas)) as ArrayRef, Arc::new(Int64Array::from(genomic_starts)) as ArrayRef, diff --git a/src/site_analysis/site_io.rs b/src/site_analysis/site_io.rs index f2d25a4..7908549 100644 --- a/src/site_analysis/site_io.rs +++ b/src/site_analysis/site_io.rs @@ -138,20 +138,32 @@ pub fn read_sites(site_file: &str) -> anyhow::Result> { }); } } else if has_genomic_alpha { - // APA format: chr, genomic_alpha (no strand column) + // APA format: chr, genomic_alpha, and the UTR's strand (tables + // written before it was recorded have none: taken as forward). let pos_idx = field_names .iter() .position(|f| f.as_ref() == "genomic_alpha") .unwrap(); + let strand_idx = field_names.iter().position(|f| f.as_ref() == "strand"); + if strand_idx.is_none() { + log::warn!( + "{site_file} records no strand: every APA site is taken as on the forward \ + strand; rerun `faba apa` to record it" + ); + } for record in row_iter { let row = record?; let chr: Box = row.get_string(chr_idx)?.clone().into_boxed_str(); let position = row.get_long(pos_idx)?; + let strand = match strand_idx { + Some(i) => parse_strand(row.get_string(i)?), + None => Strand::Forward, + }; sites.push(GenomicSite { chr, position, - strand: Strand::Forward, + strand, }); } } else if has_primary_pos { diff --git a/src/site_analysis/tests/site_io.rs b/src/site_analysis/tests/site_io.rs index 1339c01..d2ec5b4 100644 --- a/src/site_analysis/tests/site_io.rs +++ b/src/site_analysis/tests/site_io.rs @@ -77,3 +77,35 @@ fn a_sites_reads_are_its_rows_summed_or_none_without_the_columns() { write(&path, &rows, false); assert!(read_site_reads(&file, &sites).unwrap().is_none()); } + +#[test] +fn apa_sites_keep_their_utr_strand() { + use crate::apa::cell_assign::ApaSiteAnnotation; + let site = |id: &str, alpha: i64, strand: Strand| ApaSiteAnnotation { + site_id: id.into(), + gene_name: "G".into(), + chr: "chr1".into(), + strand, + genomic_alpha: alpha, + beta: 30.0, + genomic_start: alpha - 50, + genomic_stop: alpha + 50, + pi_weight: 0.5, + expected_tail_length: 100.0, + utr_length: 1000, + }; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("apa_components.parquet"); + let path = path.to_str().unwrap(); + let written = [ + site("a", 100, Strand::Forward), + site("b", 200, Strand::Backward), + ]; + crate::apa::pipeline::write_apa_annotations(&written, path).unwrap(); + let read = read_sites(path).unwrap(); + let got: Vec<(i64, bool)> = read + .iter() + .map(|s| (s.position, matches!(s.strand, Strand::Forward))) + .collect(); + assert_eq!(got, [(100, true), (200, false)]); +} From facd758b3e41de95ee2d1ee5897f4112377db9da Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 10:54:56 -0700 Subject: [PATCH 6/7] pileup: choose or download an annotation when none is found The pwm genome picker becomes a reference picker for a genome or an annotation. In the pileup browser, with no --gtf and no recorded GFF on this machine, it lists GTF/GFF files; Tab downloads a GENCODE or Ensembl reference into ~/faba_refs. Esc browses on without gene models. --- docs/profiling-methods.md | 4 +- src/site_analysis/input_picker.rs | 94 +++++++++++++++++++------ src/site_analysis/pileup.rs | 24 +++++-- src/site_analysis/scan_pwm.rs | 5 +- src/site_analysis/tests/input_picker.rs | 31 +++++++- 5 files changed, 126 insertions(+), 32 deletions(-) diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md index 2a58bf2..83f2c46 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -183,7 +183,9 @@ The tools that read an output directory use the records to find the annotation a `pileup` takes the GFF for its gene models (browser and figure) when `--gtf` is not given, `metagene` takes it when `-g` is not given, and `pwm --source reference` takes the genome when `-f` is not given. The newest record in the directory that names an existing file wins, and the -file used is logged. Of the two paths, `path` is used while it still leads to `resolved`. If the +file used is logged. When none is found on this machine, the `pileup` browser asks for a GTF or +GFF and the `pwm` view for a FASTA; Tab in that pop-up downloads a GENCODE or Ensembl reference +(its GTF and genome together) into `~/faba_refs/`, as `faba run` does. Of the two paths, `path` is used while it still leads to `resolved`. If the link has been repointed since the run, or removed, `resolved` is used instead, because that is the file the outputs were made from, and a repointed link is warned about. `qc` does not copy the input's records, which describe the input directory, into the new fileset. Its own record names that directory as `fileset`, carries its `gff` and diff --git a/src/site_analysis/input_picker.rs b/src/site_analysis/input_picker.rs index 200a868..d9ba429 100644 --- a/src/site_analysis/input_picker.rs +++ b/src/site_analysis/input_picker.rs @@ -14,7 +14,7 @@ use ratatui::Frame; use crate::pipeline::tui::fetch::{Catalogue, Fetch}; use crate::qc::layout::looks_like_faba_dir; use crate::qc::path_tui::faba_browser; -use crate::tui::browser::{is_fasta, Browser, Nav}; +use crate::tui::browser::{is_annotation, is_fasta, Browser, Nav}; use crate::tui::{is_apply, popup_frame, run_view, tilde, View, APPLY_KEYS}; /// What was chosen: the marked files, or one folder. @@ -211,19 +211,54 @@ impl Screen for InputPicker { } } -/// Choosing a genome FASTA, by Enter on one, or downloading one (Tab) -/// from the references `faba run` offers: for a command that found none -/// recorded, such as `pwm`. -pub struct GenomePicker { +/// Which reference a [`ReferencePicker`] asks for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Reference { + /// A genome FASTA, as `pwm` reads. + Genome, + /// A GTF or GFF, as `pileup` draws its genes from. + Annotation, +} + +impl Reference { + fn keep(self) -> fn(&str) -> bool { + match self { + Reference::Genome => is_fasta, + Reference::Annotation => is_annotation, + } + } + + /// What it is, as the pop-up says. + fn what(self) -> &'static str { + match self { + Reference::Genome => "genome", + Reference::Annotation => "annotation", + } + } + + /// The files it lists. + fn files(self) -> &'static str { + match self { + Reference::Genome => "FASTA", + Reference::Annotation => "GTF or GFF", + } + } +} + +/// Choosing a reference, by Enter on one, or downloading one (Tab) from +/// the references `faba run` offers, which come as a GTF and a genome +/// together: for a command that found none recorded on this machine. +pub struct ReferencePicker { browser: Browser, command: &'static str, + kind: Reference, /// Choosing a reference to download, or downloading it. fetching: Fetching, /// Whether the sites name chromosomes `chr1`, for the download list. chr: Option, - /// Why the last download ended without a genome. + /// Why the last download ended without a reference. note: Option, - /// The genome; `Some(None)` when cancelled. + /// The reference; `Some(None)` when cancelled. pub decision: Option>, } @@ -233,13 +268,14 @@ enum Fetching { Downloading(Fetch), } -impl GenomePicker { - /// Listing the folders and FASTA files at `cwd`; `chr` is whether the - /// sites name chromosomes `chr1`, if known. - pub fn new(cwd: PathBuf, command: &'static str, chr: Option) -> Self { +impl ReferencePicker { + /// Listing the folders and the `kind`'s files at `cwd`; `chr` is + /// whether the sites name chromosomes `chr1`, if known. + pub fn new(cwd: PathBuf, command: &'static str, kind: Reference, chr: Option) -> Self { Self { - browser: Browser::new(cwd, is_fasta, |_| false).opened(), + browser: Browser::new(cwd, kind.keep(), |_| false).opened(), command, + kind, fetching: Fetching::No, chr, note: None, @@ -268,9 +304,9 @@ impl GenomePicker { } } -impl View for GenomePicker {} +impl View for ReferencePicker {} -impl Screen for GenomePicker { +impl Screen for ReferencePicker { fn done(&self) -> bool { self.decision.is_some() } @@ -317,7 +353,12 @@ impl Screen for GenomePicker { Fetching::Downloading(f) => { let done = f.status.lock().ok().and_then(|mut s| s.done.take()); match done { - Some(Ok((_, genome))) => self.decision = Some(Some(genome)), + Some(Ok((gtf, genome))) => { + self.decision = Some(Some(match self.kind { + Reference::Genome => genome, + Reference::Annotation => gtf, + })) + } Some(Err(e)) => { self.note = Some(format!("{} failed: {e}", f.label)); self.fetching = Fetching::No; @@ -331,7 +372,8 @@ impl Screen for GenomePicker { fn render(&mut self, frame: &mut Frame) { let area = frame.area(); - frame.render_widget(header(self.command, "choose the genome", ""), area); + let choose = format!("choose the {}", self.kind.what()); + frame.render_widget(header(self.command, &choose, ""), area); let w = area.width.saturating_sub(4).clamp(20, 90); let help = [ help_line(&[ @@ -350,7 +392,9 @@ impl Screen for GenomePicker { let help_rows = help.len() as u16; let rows = area.height.saturating_sub(7 + help_rows).clamp(3, 24); let title = format!( - " no genome recorded: a FASTA · {} {}", + " no {} found: a {} · {} {}", + self.kind.what(), + self.kind.files(), tilde(&self.browser.cwd), self.browser.list.find.tag() ); @@ -359,7 +403,7 @@ impl Screen for GenomePicker { rows as usize, inner.width as usize, "", - "no subfolder or FASTA here", + &format!("no subfolder or {} here", self.kind.files()), ); lines.push(match &self.note { Some(why) => Line::from(Span::styled(format!(" {why}"), HIGHLIGHT)), @@ -375,11 +419,15 @@ impl Screen for GenomePicker { } } -/// A genome FASTA chosen in a browser at the working folder, or downloaded; -/// `Ok(None)` when the user cancels. `chr` is whether the sites name -/// chromosomes `chr1`, if known. -pub fn ask_genome(command: &'static str, chr: Option) -> anyhow::Result> { - let mut picker = GenomePicker::new(std::env::current_dir()?, command, chr); +/// A `kind` of reference chosen in a browser at the working folder, or +/// downloaded; `Ok(None)` when the user cancels. `chr` is whether the +/// sites name chromosomes `chr1`, if known. +pub fn ask_reference( + command: &'static str, + kind: Reference, + chr: Option, +) -> anyhow::Result> { + let mut picker = ReferencePicker::new(std::env::current_dir()?, command, kind, chr); run_view(&mut picker)?; Ok(picker.decision.flatten()) } diff --git a/src/site_analysis/pileup.rs b/src/site_analysis/pileup.rs index c49ec97..57b5765 100644 --- a/src/site_analysis/pileup.rs +++ b/src/site_analysis/pileup.rs @@ -234,7 +234,8 @@ pub struct PileupArgs { long_help = "Gene annotation GTF/GFF for the middle gene-model track (exons, introns, strand).\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." + `*.run.json` next to the first matrix, when there is one; when that is\n\ + not on this machine either, the browser asks for one, or downloads one." )] gtf: Option>, @@ -984,13 +985,26 @@ pub fn run_pileup(args: &PileupArgs) -> anyhow::Result<()> { base.separate = chosen.separate; } let base = &base; - let source = Source::new(base)?; - let (args, groups) = (&source.args, &source.groups); + let mut source = Source::new(base)?; // 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 interactive = !figure_flags && !crate::tui::batch(args.batch_process, "pileup"); + let figure_flags = { + let a = &source.args; + !a.bam_files.is_empty() || a.format.is_some() || a.svg || a.png + }; + let interactive = !figure_flags && !crate::tui::batch(source.args.batch_process, "pileup"); + // In the browser, an annotation not given nor recorded here is asked + // for (or downloaded); any GTF of the assembly draws the genes. + if interactive && source.args.annotation().is_none() { + let ask = crate::site_analysis::input_picker::ask_reference( + "pileup", + crate::site_analysis::input_picker::Reference::Annotation, + None, + )?; + source.args.gtf = ask.map(|p| p.to_string_lossy().into()); + } + let (args, groups) = (&source.args, &source.groups); let figure_mode = !interactive && (args.gtf.is_some() || figure_flags); if interactive && args.genes.is_empty() && args.regions.is_empty() { return self::interactive(base, source, None); diff --git a/src/site_analysis/scan_pwm.rs b/src/site_analysis/scan_pwm.rs index bde46f3..34ca236 100644 --- a/src/site_analysis/scan_pwm.rs +++ b/src/site_analysis/scan_pwm.rs @@ -2,6 +2,7 @@ mod tests; mod tui; +use super::input_picker::Reference; use super::output_dir; use super::site_io::*; use crate::common::*; @@ -374,7 +375,9 @@ pub fn run_scan_pwm(args: &ScanPwmArgs) -> anyhow::Result<()> { let chr = read_sites(&site_files[0]) .ok() .and_then(|s| s.first().map(|s| s.chr.starts_with("chr"))); - let Some(fasta) = super::input_picker::ask_genome("pwm", chr)? else { + let Some(fasta) = + super::input_picker::ask_reference("pwm", Reference::Genome, chr)? + else { return Ok(()); }; Some(fasta.to_string_lossy().into()) diff --git a/src/site_analysis/tests/input_picker.rs b/src/site_analysis/tests/input_picker.rs index adc4f51..b1f24e0 100644 --- a/src/site_analysis/tests/input_picker.rs +++ b/src/site_analysis/tests/input_picker.rs @@ -155,7 +155,12 @@ fn the_genome_picker_lists_fasta_and_offers_a_download() { for f in ["hg38.fa", "mm10.fasta.gz", "notes.txt"] { std::fs::write(tmp.path().join(f), b"").unwrap(); } - let mut p = GenomePicker::new(tmp.path().to_path_buf(), "pwm", Some(true)); + let mut p = ReferencePicker::new( + tmp.path().to_path_buf(), + "pwm", + Reference::Genome, + Some(true), + ); let names: Vec<&str> = p.browser.list.shown().map(|e| e.name.as_str()).collect(); assert!(names.contains(&"hg38.fa") && names.contains(&"mm10.fasta.gz")); assert!(!names.contains(&"notes.txt"), "{names:?}"); @@ -170,7 +175,29 @@ fn the_genome_picker_lists_fasta_and_offers_a_download() { press(&mut p, KeyCode::Enter); assert_eq!(p.decision, Some(Some(tmp.path().join("hg38.fa")))); - let mut p = GenomePicker::new(tmp.path().to_path_buf(), "pwm", None); + let mut p = ReferencePicker::new(tmp.path().to_path_buf(), "pwm", Reference::Genome, None); press(&mut p, KeyCode::Esc); assert_eq!(p.decision, Some(None), "Esc cancels"); } + +#[test] +fn the_annotation_picker_lists_gtf_and_gff() { + let tmp = tempfile::tempdir().unwrap(); + for f in ["genes.gtf.gz", "genes.gff3", "hg38.fa"] { + std::fs::write(tmp.path().join(f), b"").unwrap(); + } + let mut p = ReferencePicker::new( + tmp.path().to_path_buf(), + "pileup", + Reference::Annotation, + None, + ); + let names: Vec<&str> = p.browser.list.shown().map(|e| e.name.as_str()).collect(); + assert!(names.contains(&"genes.gtf.gz") && names.contains(&"genes.gff3")); + assert!(!names.contains(&"hg38.fa"), "{names:?}"); + press(&mut p, KeyCode::Tab); + assert!( + matches!(p.fetching, Fetching::Choosing(_)), + "Tab downloads here too" + ); +} From b07b4307a02201ea31be4827582c8a30b16a2fd8 Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 11:11:37 -0700 Subject: [PATCH 7/7] One set of key rules across every view; footers never cut a key off - Enter opens or chooses and never quits (pileup, metagene, pwm quit on it). - Esc goes back one level: pileup's browser returns to its gene list; a view with nothing above it quits; q quits. - Ctrl-C asks first wherever q does: faba run (now also when any setting changed, not only with BAMs picked) and qc with changed thresholds. - Ctrl+R submits any open line: save names, pileup's search, qc's typed value, the gene list, the reference picker. - Downloads: s stops, Esc hides (Tab shows it again in the picker), the folder is named first, and the pop-up lists its keys. - metagene's + is finer and - coarser, as pileup's zoom; 0 fits. - m/M switch modality in pileup as in qc; mirror moves to v, the previous site to N, pwm's bits/frequency to b, qc's annotation browser to a. - R asks before resetting every flag; Space on a folder asks before dropping marked files; Enter on a file only says Space marks it. - The busy spinner says it cannot stop halfway when a key is pressed. - Footers wrap onto more lines at any width (tui::wrap_help), keeping each key with what it does; tests check every key shows at 80 (or 60) columns. --- docs/profiling-methods.md | 23 +++- src/figure/mod.rs | 5 + src/pipeline/tui.rs | 31 ++++- src/pipeline/tui/draw.rs | 22 ++-- src/pipeline/tui/fetch.rs | 7 ++ src/pipeline/tui/keys.rs | 25 ++-- src/pipeline/tui/tests/app.rs | 42 +++++++ src/qc/path_tui.rs | 6 +- src/qc/site_tui.rs | 149 ++++++++++++----------- src/qc/site_tui/draw.rs | 2 +- src/qc/tests/site_tui.rs | 41 ++++++- src/site_analysis/input_picker.rs | 120 +++++++++++++++--- src/site_analysis/metagene/tests/tui.rs | 36 ++++++ src/site_analysis/metagene/tui.rs | 47 ++++--- src/site_analysis/pileup/picker.rs | 40 ++++-- src/site_analysis/pileup/tests/picker.rs | 18 +++ src/site_analysis/pileup/tests/tui.rs | 37 +++++- src/site_analysis/pileup/tui.rs | 60 +++++---- src/site_analysis/scan_pwm/tests/tui.rs | 30 ++++- src/site_analysis/scan_pwm/tui.rs | 33 +++-- src/site_analysis/tests/input_picker.rs | 27 +++- src/tests/tui.rs | 26 ++++ src/tui.rs | 48 ++++++++ src/tui/busy.rs | 29 ++++- 24 files changed, 704 insertions(+), 200 deletions(-) diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md index 83f2c46..93e7e92 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -657,6 +657,18 @@ colours and the dividers at the start and stop codons; the regions are named und with no tick, since a tick would read as a position. `s`, in every view, asks for a name in a pop-up that lists the figures already saved where faba runs, newest first, with a thumbnail of each where the terminal draws images; the list is kept in `.faba-view/` there, so it lasts between sessions. +A name already taken is never overwritten: the prompt offers the next free one (`name_2`, …). + +Every view keeps the same key rules. Enter opens or chooses, and never quits or applies; Ctrl+R +applies, starts, or submits whatever line is open. Esc goes back one level (out of a pop-up, a +find, or from the `pileup` browser to its gene list) and quits a view that has nothing above it; +`q` quits. Where leaving would lose work (`faba run`'s setup, `qc`'s changed thresholds), `q` and +Ctrl-C both ask first. `s` saves the figure (and on `faba run`'s Run screen, stops the run, asking +first). `+`/`-` make the view finer and coarser (`pileup`'s zoom, `metagene`'s bins), `0` resets +it, `y` cycles the y scale, `c` what the bars count, `i` images and text, and `m`/`M` the +modality (`pileup` mirrors its first two tracks with `v`; `n`/`N` step through its sites). +Every footer wraps onto further lines rather than cut a key off, and while slow work runs, a key +pressed says it cannot stop halfway. --- @@ -711,7 +723,7 @@ A gene list at the bottom left shows each gene's kept and putative sites. Genes `--genes` come first, then the rest by kept sites, re-sorted as the thresholds move (the selection stays on its gene); `[` and `]` move through it and `/` filters it by symbol. Tab moves between the thresholds panel and the gene list, and the arrow -keys drive the one in focus; `m` switches the editing modality. The gene models and the metagene come from the annotation, read on a +keys drive the one in focus; `m` and `M` switch the editing modality. The gene models and the metagene come from the annotation, read on a thread once (`--gff`, or the one in the input directory's run record); without one, the gene plot spans the gene's own sites and the metagene is left out. Every count is decided by the same rule `qc` applies, so the view cannot disagree with the written fileset. Ctrl+R, or a click on the @@ -735,7 +747,7 @@ faba output directory (directories holding faba matrices or site tables are mark one, ← goes up, Space chooses the highlighted one, Ctrl+R the one being shown), then name the output, which starts as `{input}_qc` and must be new or empty. -Every file list (this one, the annotation browser `g` opens, and those of `faba run`) narrows as +Every file list (this one, the annotation browser `a` opens, and those of `faba run`) narrows as you type: the names holding the typed text, in any case, with the cursor on the first that starts with it. Typing a path walks it: `/` with nothing typed goes to the root, `~` home, and `/` after a name into that folder (the name as typed, else the highlighted folder), so `/data/runs/` lands in @@ -764,7 +776,7 @@ an Inputs screen (BAMs picked as foreground, passed positionally, or background, Flags screen, and, once a run has started, a Run screen. In the BAM list, Space takes the highlighted BAM from out of the run to fg, to bg, and back out, and typing finds and walks paths as in `qc`'s file lists; since letters type there, the screen keys (`c`, `p`, `q`, `1`-`4`) work -everywhere else, and Ctrl-C leaves. Tab goes from the BAM list to the inputs rows and on through +everywhere else, and Esc (with nothing typed) moves on to the inputs rows, where they do. Tab goes from the BAM list to the inputs rows and on through the screens; Shift+Tab goes back. Without an annotation or genome at hand, `d` on the inputs rows downloads a matching pair instead @@ -796,8 +808,9 @@ a child process whose log the Run screen shows; `s` asks first, `s` again interr further `s` kills it. When the run ends, a pop-up says so: finished, stopped or failed (with the reason it gave), how long it took and where its output is; the terminal also rings its bell and is asked for a desktop notification (OSC 9), for whoever is away. Enter, Esc or `[ ok ]` closes it, -onto the log. `q` is refused while a run is going; before any run, with BAMs picked, `q` asks -first and only a second `q` leaves, since the setup is not kept. `--batch-process` skips the view and +onto the log. `q` is refused while a run is going; before any run, with BAMs picked or anything +changed, `q` (or Ctrl-C) asks first and only a second leaves, since the setup is not kept. `R` on +the Flags screen resets every flag, likewise only on a second `R`. `--batch-process` skips the view and runs straight through from the flags given; it needs the BAMs, `-g`, `-f` and `-o`. The steps run in this order, and each one's output constrains the next: diff --git a/src/figure/mod.rs b/src/figure/mod.rs index 4066274..9f0a015 100644 --- a/src/figure/mod.rs +++ b/src/figure/mod.rs @@ -942,6 +942,11 @@ impl Controls { pub fn footer(&self) -> Option> { self.save.footer() } + + /// Whether the save prompt is open, where the apply key submits. + pub fn prompting(&self) -> bool { + self.save.active() + } } #[cfg(test)] diff --git a/src/pipeline/tui.rs b/src/pipeline/tui.rs index 649e7af..9a1668e 100644 --- a/src/pipeline/tui.rs +++ b/src/pipeline/tui.rs @@ -156,8 +156,13 @@ pub struct App { /// The program the run starts: this binary, or a stand-in in tests. pub program: PathBuf, pub quit: bool, - /// `q` was pressed once with a setup to lose: the next `q` leaves. + /// `q` (or Ctrl-C) was pressed once with a setup to lose: the next + /// one leaves. pub leaving: bool, + /// `R` was pressed once on the Flags screen: the next `R` resets them. + pub resetting: bool, + /// The command as it stood once set up, to tell when it has changed. + initial: Vec, /// `p` ended the session: the command to print once the terminal is /// back. pub printed: Option, @@ -201,6 +206,8 @@ impl App { program: std::env::current_exe().unwrap_or_else(|_| "faba".into()), quit: false, leaving: false, + resetting: false, + initial: Vec::new(), printed: None, verbose: false, end_drawn: false, @@ -209,6 +216,7 @@ impl App { checked: Checked::default(), }; app.refresh(); + app.initial = app.argv(); app } @@ -246,6 +254,24 @@ impl App { self.inputs.output = o.to_string(); } self.refresh(); + self.initial = self.argv(); + } + + /// Whether leaving now loses a setup: BAMs picked, or anything changed. + pub fn set_up(&self) -> bool { + self.job.is_none() && (!self.inputs.picked.is_empty() || self.argv() != self.initial) + } + + /// `q` or Ctrl-C: leave, but with a setup to lose only on the second. + pub fn ask_leave(&mut self, key: &str) { + if self.set_up() && !self.leaving { + self.leaving = true; + self.note = Some(format!( + "{key} again leaves, losing this setup; p prints its command" + )); + } else { + self.leave(); + } } /// Indices of the form rows shown: advanced ones only when asked for, @@ -540,8 +566,9 @@ impl Screen for App { self.key(key); } + /// Ctrl-C asks first, as `q` does. fn interrupt(&mut self) { - self.leave(); + self.ask_leave("Ctrl-C"); } fn done(&self) -> bool { diff --git a/src/pipeline/tui/draw.rs b/src/pipeline/tui/draw.rs index 8ceb937..3149ce4 100644 --- a/src/pipeline/tui/draw.rs +++ b/src/pipeline/tui/draw.rs @@ -29,11 +29,13 @@ const BAR: usize = 40; pub(super) fn draw(app: &App, frame: &mut Frame) { app.hits.clear(); let area = frame.area(); + // The footer takes as many lines as its keys need at this width. + let help = crate::tui::wrap_help(footer_line(app), area.width); let [top, tabs, body, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Fill(1), - Constraint::Length(1), + Constraint::Length(help.len() as u16), ]) .areas(area); @@ -96,7 +98,7 @@ pub(super) fn draw(app: &App, frame: &mut Frame) { if app.ended_shown { draw_ended(app, frame, body); } - frame.render_widget(footer_line(app), footer); + frame.render_widget(Paragraph::new(help), footer); } /// `hh:mm:ss`. @@ -570,7 +572,7 @@ fn draw_preview(app: &App, frame: &mut Frame, body: Rect) { button_popup(frame, body, " start this run? ", &buttons, lines, &app.hits); } -fn footer_line(app: &App) -> Line<'static> { +pub(super) fn footer_line(app: &App) -> Line<'static> { if let Some((target, line)) = &app.editing { let prompt = match target { Target::Output => match &app.note { @@ -654,14 +656,12 @@ fn footer_line(app: &App) -> Line<'static> { ("Enter/→", "open"), ("←", "up"), ]; - if !app.inputs.bams.list.find.text.is_empty() { - keys.push(("Esc", "clear find")); - } - keys.extend([ - ("Tab", "inputs"), - (APPLY_KEYS, "preview"), - ("Ctrl-C", "quit"), - ]); + keys.push(if app.inputs.bams.list.find.text.is_empty() { + ("Esc", "rows, where q quits") + } else { + ("Esc", "clear find") + }); + keys.extend([("Tab", "inputs"), (APPLY_KEYS, "preview")]); return help_line(&keys); } let mut keys: Vec<(&str, &str)> = match app.page { diff --git a/src/pipeline/tui/fetch.rs b/src/pipeline/tui/fetch.rs index f608fff..a894312 100644 --- a/src/pipeline/tui/fetch.rs +++ b/src/pipeline/tui/fetch.rs @@ -332,6 +332,13 @@ impl Fetch { Line::styled(progress, HIGHLIGHT), Line::raw(""), Line::styled("the GTF, then the genome: fetched, unpacked, indexed", DIM), + Line::raw(""), + Line::from(vec![ + Span::styled("s", HIGHLIGHT), + Span::styled(" stop (what came is kept) ", DIM), + Span::styled("Esc", HIGHLIGHT), + Span::styled(" hide (it carries on)", DIM), + ]), ]; popup(frame, body, " downloading a reference ", lines); } diff --git a/src/pipeline/tui/keys.rs b/src/pipeline/tui/keys.rs index 27360bb..bf69f8b 100644 --- a/src/pipeline/tui/keys.rs +++ b/src/pipeline/tui/keys.rs @@ -40,6 +40,7 @@ impl App { fn route(&mut self, key: KeyEvent) { self.note = None; let leaving = std::mem::take(&mut self.leaving); + let resetting = std::mem::take(&mut self.resetting); if self.editing.is_some() { return self.key_editing(key); } @@ -74,6 +75,11 @@ impl App { match self.inputs.bams.key(key) { Nav::Moved => return, Nav::Picked(_) => return self.enter_hint(), + // Esc with nothing typed: on to the rows, where letters act. + Nav::Ignored if key.code == KeyCode::Esc => { + self.inputs.focus = InputsFocus::Rows; + return; + } Nav::Ignored => {} } } @@ -91,20 +97,16 @@ impl App { return; } // A set-up run is lost on leaving: the first `q` only asks. - KeyCode::Char('q') - if !leaving && self.job.is_none() && !self.inputs.picked.is_empty() => - { - self.leaving = true; - self.note = Some("q again leaves, losing this setup; p prints its command".into()); - return; + KeyCode::Char('q') => { + self.leaving = leaving; + return self.ask_leave("q"); } - KeyCode::Char('q') => return self.leave(), _ => {} } match self.page { Page::Inputs => self.key_inputs(key), Page::Steps => self.key_steps(key), - Page::Flags => self.key_flags(key), + Page::Flags => self.key_flags(key, resetting), Page::Run => self.key_run(key), } } @@ -452,7 +454,7 @@ impl App { } } - fn key_flags(&mut self, key: KeyEvent) { + fn key_flags(&mut self, key: KeyEvent, resetting: bool) { let n = self.visible_flags().len(); let last = n.saturating_sub(1); let at = self.flags_at.min(last); @@ -472,6 +474,11 @@ impl App { self.advanced ^= true; self.flags_at = 0; } + // Every flag at once: the first `R` only asks. + KeyCode::Char('R') if !resetting => { + self.resetting = true; + self.note = Some("R again resets every flag to its default".into()); + } KeyCode::Char('R') => self.form.fields.iter_mut().for_each(|f| f.reset()), _ => { let Some(i) = row else { diff --git a/src/pipeline/tui/tests/app.rs b/src/pipeline/tui/tests/app.rs index 8830a7d..6784ba6 100644 --- a/src/pipeline/tui/tests/app.rs +++ b/src/pipeline/tui/tests/app.rs @@ -854,3 +854,45 @@ fn p_is_ignored_while_a_run_goes() { "p works once the run is over" ); } + +#[test] +fn every_footer_key_shows_at_80_columns() { + let (_t, mut a) = app_with_bams(); + for focus in [InputsFocus::Bams, InputsFocus::Rows] { + for page in [Page::Inputs, Page::Steps, Page::Flags, Page::Run] { + a.page = page; + a.inputs.focus = focus; + let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap(); + term.draw(|f| a.render(f)).unwrap(); + let screen: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + let missing = crate::tui::missing_keys(&draw::footer_line(&a), &screen); + assert!(missing.is_empty(), "{page:?}: {missing:?}"); + } + } +} + +#[test] +fn ctrl_c_and_reset_all_ask_first() { + let (_t, mut a) = app_with_bams(); + a.interrupt(); + assert!(!a.done() && a.leaving, "a setup to lose: Ctrl-C asks"); + a.interrupt(); + assert!(a.done(), "the second leaves"); + + let (_t, mut a) = app_with_bams(); + a.page = Page::Flags; + a.inputs.focus = InputsFocus::Rows; + let i = a.form.fields.iter().position(|f| f.is_default()).unwrap(); + a.form.fields[i].set_text("7"); + press(&mut a, KeyCode::Char('R')); + assert!(!a.form.fields[i].is_default(), "the first R only asks"); + assert!(a.note.as_deref().is_some_and(|n| n.contains("R again"))); + press(&mut a, KeyCode::Char('R')); + assert!(a.form.fields[i].is_default()); +} diff --git a/src/qc/path_tui.rs b/src/qc/path_tui.rs index 6b0db9b..700cc65 100644 --- a/src/qc/path_tui.rs +++ b/src/qc/path_tui.rs @@ -204,7 +204,9 @@ impl Screen for PathPicker { 3, ), }; - let inner = popup_frame(frame, area, w, rows + 4, title); + // The border takes two columns; the help wraps within the rest. + let help = crate::tui::wrap_help(help, w.saturating_sub(2)); + let inner = popup_frame(frame, area, w, rows + 3 + help.len() as u16, title); let mut lines = match &self.step { Step::Input => self.browser.lines( rows as usize, @@ -225,7 +227,7 @@ impl Screen for PathPicker { Some(why) => Line::from(Span::styled(format!(" {why}"), HIGHLIGHT)), None => Line::from(""), }); - lines.push(help); + lines.extend(help); frame.render_widget(Paragraph::new(lines), inner); } } diff --git a/src/qc/site_tui.rs b/src/qc/site_tui.rs index 2d2df0f..4be1b80 100644 --- a/src/qc/site_tui.rs +++ b/src/qc/site_tui.rs @@ -427,6 +427,66 @@ impl<'a> SitePicker<'a> { self.retally(); } + /// The footer: a prompt, a hint, or the keys of the mode. + fn footer_line(&self) -> Line<'static> { + match (&self.mode, self.controls.footer()) { + (_, Some(line)) => line, + (Mode::Edit(buf), None) => input_line( + &format!("{} {}: ", self.criterion().label(), self.criterion().flag()), + buf, + &[("Enter", "set"), ("Esc", "back")], + ), + (Mode::Confirm, None) => { + help_line(&[(APPLY_KEYS, "apply and write"), ("Esc/n", "back")]) + } + (Mode::Writing, None) => help_line(&[("", "writing; the view closes when done")]), + (Mode::Leave(picked), None) => help_line(&[ + match picked { + Picked::PrintOnly(_) => ("p", "print the flags and leave"), + _ => ("q", "leave without writing"), + }, + ("Esc/n", "back"), + ]), + (Mode::Gff(_), None) => help_line(&[ + ("type", "find"), + ("↑/↓", "move"), + ("Enter", "open / read"), + ("←", "up"), + ("Esc", "clear find / back"), + ]), + (Mode::Find, None) => input_line( + "gene: ", + &self.list.find, + &[("↑/↓", "move"), ("Enter", "focus"), ("Esc", "cancel")], + ), + (Mode::Browse, None) => { + let apply = (APPLY_KEYS, "apply"); + if self.enter_hint { + return Line::styled(format!(" {}", enter_hint("applies")), DIM); + } + let mut keys = vec![("Tab", "panel")]; + keys.extend(match self.panel { + Panel::Thresholds => [("↑/↓", "knob"), ("←/→", "bin")], + Panel::Genes => [("↑/↓", "gene"), ("/", "find")], + }); + // What matters most first. + keys.extend([apply, ("p", "print flags"), ("q", "cancel")]); + self.controls.help_keys(&mut keys); + keys.extend([ + ("m/M", "modality"), + ("[ ]", "gene"), + ("-/+", "±1"), + ("0-9/e", "type"), + ("o", "off"), + ("r", "reset"), + ("x/y", "scale"), + ("a", "annotation"), + ]); + help_line(&keys) + } + } + } + /// End the session, handing the terminal's keyboard back as it was. /// Leave as `picked` says; first ask, when the thresholds have changed. fn leave(&mut self, picked: Picked) { @@ -599,10 +659,14 @@ impl Screen for SitePicker<'_> { self.decision.is_some() } + /// Ctrl-C asks first when the thresholds have changed, as `q` does; + /// a second one leaves. A write under way finishes: stopping it would + /// leave half a fileset. fn interrupt(&mut self) { - // A write under way finishes: stopping it would leave half a fileset. - if !matches!(self.mode, Mode::Writing) { - self.decide(Picked::Cancelled); + match self.mode { + Mode::Writing => {} + Mode::Leave(_) => self.decide(Picked::Cancelled), + _ => self.leave(Picked::Cancelled), } } @@ -643,6 +707,14 @@ impl Screen for SitePicker<'_> { } match &mut self.mode { Mode::Edit(buf) => match key.code { + // The apply key sets the value, as Enter does. + _ if is_apply(&key) || key.code == KeyCode::Enter => { + let typed = buf.parse::().ok(); + self.mode = Mode::Browse; + if let Some(v) = typed { + self.set_threshold(v); + } + } KeyCode::Char(ch) if (ch.is_ascii_digit() || "-+.eEinf".contains(ch)) && buf.len() < 24 => { @@ -651,13 +723,6 @@ impl Screen for SitePicker<'_> { KeyCode::Backspace => { buf.pop(); } - KeyCode::Enter => { - let typed = buf.parse::().ok(); - self.mode = Mode::Browse; - if let Some(v) = typed { - self.set_threshold(v); - } - } KeyCode::Esc => self.mode = Mode::Browse, _ => {} }, @@ -705,7 +770,7 @@ impl Screen for SitePicker<'_> { KeyCode::Char(']') => self.step_gene(1), KeyCode::Char('/') => self.start_find(), KeyCode::Char('c') => self.switch_weight(), - KeyCode::Char('g') => self.open_gff_browser(), + KeyCode::Char('a') => self.open_gff_browser(), KeyCode::Up | KeyCode::Char('k') if self.panel == Panel::Genes => { self.step_gene(-1) } @@ -735,11 +800,13 @@ impl Screen for SitePicker<'_> { } fn render(&mut self, frame: &mut Frame) { + // The footer takes as many lines as its keys need at this width. + let help = crate::tui::wrap_help(self.footer_line(), frame.area().width); let [top, tabs, body, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Fill(1), - Constraint::Length(1), + Constraint::Length(help.len() as u16), ]) .areas(frame.area()); @@ -803,63 +870,7 @@ impl Screen for SitePicker<'_> { self.render_gene(frame, gene); self.render_meta(frame, meta); - let help = match (&self.mode, self.controls.footer()) { - (_, Some(line)) => line, - (Mode::Edit(buf), None) => input_line( - &format!("{} {}: ", self.criterion().label(), self.criterion().flag()), - buf, - &[("Enter", "set"), ("Esc", "back")], - ), - (Mode::Confirm, None) => { - help_line(&[(APPLY_KEYS, "apply and write"), ("Esc/n", "back")]) - } - (Mode::Writing, None) => help_line(&[("", "writing; the view closes when done")]), - (Mode::Leave(picked), None) => help_line(&[ - match picked { - Picked::PrintOnly(_) => ("p", "print the flags and leave"), - _ => ("q", "leave without writing"), - }, - ("Esc/n", "back"), - ]), - (Mode::Gff(_), None) => help_line(&[ - ("type", "find"), - ("↑/↓", "move"), - ("Enter", "open / read"), - ("←", "up"), - ("Esc", "clear find / back"), - ]), - (Mode::Find, None) => input_line( - "gene: ", - &self.list.find, - &[("↑/↓", "move"), ("Enter", "focus"), ("Esc", "cancel")], - ), - (Mode::Browse, None) => { - let apply = (APPLY_KEYS, "apply"); - if self.enter_hint { - let hint = Line::styled(format!(" {}", enter_hint("applies")), DIM); - return frame.render_widget(hint, footer); - } - let mut keys = vec![("Tab", "panel")]; - keys.extend(match self.panel { - Panel::Thresholds => [("↑/↓", "knob"), ("←/→", "bin")], - Panel::Genes => [("↑/↓", "gene"), ("/", "find")], - }); - // What matters most first: a narrow footer cuts from the end. - keys.extend([apply, ("p", "print flags"), ("q", "cancel")]); - self.controls.help_keys(&mut keys); - keys.extend([ - ("m", "modality"), - ("-/+", "±1"), - ("0-9", "type"), - ("o", "off"), - ("r", "reset"), - ("x/y", "scale"), - ("g", "annotation"), - ]); - help_line(&keys) - } - }; - frame.render_widget(help, footer); + frame.render_widget(Paragraph::new(help), footer); match self.mode { Mode::Confirm => self.render_confirm(frame, body), Mode::Writing => self.render_writing(frame, body), diff --git a/src/qc/site_tui/draw.rs b/src/qc/site_tui/draw.rs index 7f41557..d421a2c 100644 --- a/src/qc/site_tui/draw.rs +++ b/src/qc/site_tui/draw.rs @@ -340,7 +340,7 @@ impl<'a> SitePicker<'a> { let Some(m) = self.meta_counts() else { let mut lines = vec![Line::from(Span::styled(self.meta_status(), DIM))]; if !matches!(self.meta, Meta::Pending(_)) { - lines.push(help_line(&[("g", "choose an annotation (GTF/GFF)")])); + lines.push(help_line(&[("a", "choose an annotation (GTF/GFF)")])); } frame.render_widget(Paragraph::new(lines), inner); return; diff --git a/src/qc/tests/site_tui.rs b/src/qc/tests/site_tui.rs index 0156ce2..a7ed13c 100644 --- a/src/qc/tests/site_tui.rs +++ b/src/qc/tests/site_tui.rs @@ -1099,7 +1099,7 @@ fn g_browses_for_an_annotation_and_reads_the_one_picked() { // The browser opens where the sites are, since the named one is absent, // and lists directories and annotations only. - press(&mut p, KeyCode::Char('g')); + press(&mut p, KeyCode::Char('a')); let Mode::Gff(b) = &p.mode else { panic!("no browser") }; @@ -1126,7 +1126,7 @@ fn g_browses_for_an_annotation_and_reads_the_one_picked() { assert!(!p.meta_status().ends_with("not found")); // Esc leaves the browser without reading anything. - press(&mut p, KeyCode::Char('g')); + press(&mut p, KeyCode::Char('a')); press(&mut p, KeyCode::Esc); assert!(matches!(p.mode, Mode::Browse)); assert_eq!(p.gff(), Some(picked.to_str().unwrap())); @@ -1149,3 +1149,40 @@ fn a_pick_that_fails_to_read_keeps_the_annotation_given_for_the_record() { assert_eq!(p.meta_status(), "/nowhere/picked.gtf: not found"); assert_eq!(p.gff(), Some(&*given)); } + +#[test] +fn every_footer_key_shows_at_80_columns() { + let t = table(M6A, 600); + let start = SiteFilterArgs::default_values(); + let view = SiteView::new(&t, Some(cells(t.len())), &start); + let mut p = SitePicker::new("input", vec![view], start, Meta::Unavailable("none".into())); + for panel in [KeyCode::Tab, KeyCode::Tab] { + let mut term = Terminal::new(TestBackend::new(80, 30)).unwrap(); + term.draw(|f| p.render(f)).unwrap(); + let screen: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + let missing = crate::tui::missing_keys(&p.footer_line(), &screen); + assert!(missing.is_empty(), "{missing:?}"); + press(&mut p, panel); + } +} + +#[test] +fn ctrl_c_asks_first_as_q_does() { + let t = table(M6A, 600); + let start = SiteFilterArgs::default_values(); + let mut p = picker(&t, None, start); + press(&mut p, KeyCode::Char('+')); + p.interrupt(); + assert!( + matches!(p.mode, Mode::Leave(Picked::Cancelled)) && !p.done(), + "asks" + ); + p.interrupt(); + assert!(p.done(), "the second leaves"); +} diff --git a/src/site_analysis/input_picker.rs b/src/site_analysis/input_picker.rs index d9ba429..1914f83 100644 --- a/src/site_analysis/input_picker.rs +++ b/src/site_analysis/input_picker.rs @@ -5,13 +5,14 @@ use std::path::{Path, PathBuf}; -use data_beans::interactive::ui::{header, help_line, Screen, HIGHLIGHT}; -use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use data_beans::interactive::ui::{header, help_line, input_line, Screen, HIGHLIGHT}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; -use crate::pipeline::tui::fetch::{Catalogue, Fetch}; +use crate::figure::{Edit, LineInput}; +use crate::pipeline::tui::fetch::{Catalogue, Fetch, Pick}; use crate::qc::layout::looks_like_faba_dir; use crate::qc::path_tui::faba_browser; use crate::tui::browser::{is_annotation, is_fasta, Browser, Nav}; @@ -38,6 +39,9 @@ pub struct InputPicker { separate: Option, /// Why the last choice was refused. pub error: Option, + /// A folder Space was pressed on while files were marked: Space on it + /// again takes it, dropping them. + confirm_dir: Option, /// The choice; `Some(None)` when cancelled. pub decision: Option>, } @@ -67,6 +71,7 @@ impl InputPicker { what, separate: separable.then_some(false), error: None, + confirm_dir: None, decision: None, } } @@ -121,6 +126,7 @@ impl Screen for InputPicker { fn handle_key(&mut self, key: KeyEvent) { self.error = None; + let confirm_dir = self.confirm_dir.take(); if is_apply(&key) { let marked = self.browser.marked.take().unwrap_or_default(); if marked.is_empty() { @@ -130,7 +136,11 @@ impl Screen for InputPicker { return self.finish(marked); } match self.browser.key(key) { - Nav::Picked(file) => return self.browser.toggle_mark(file), + // Space marks, as in `faba run`'s BAM list; Enter only says so. + Nav::Picked(_) => { + self.error = Some(format!("Space marks a file; {APPLY_KEYS} reads the marked")); + return; + } Nav::Moved => return, Nav::Ignored => {} } @@ -140,9 +150,18 @@ impl Screen for InputPicker { return; }; let (path, dir, up) = (e.path.clone(), e.dir, e.name == ".."); + let n_marked = self.browser.marked.as_ref().map_or(0, Vec::len); if !dir { self.browser.toggle_mark(path); - } else if !up { + } else if up { + } else if n_marked > 0 && confirm_dir.as_ref() != Some(&path) { + // Taking a folder drops the marked files: ask first. + self.error = Some(format!( + "Space again takes this folder alone, dropping {n_marked} marked; \ + {APPLY_KEYS} reads the marked" + )); + self.confirm_dir = Some(path); + } else { self.choose_dir(path); } } @@ -265,7 +284,10 @@ pub struct ReferencePicker { enum Fetching { No, Choosing(Catalogue), - Downloading(Fetch), + /// A reference chosen, its folder being typed. + Target(Pick, LineInput), + /// Under way; shown, or hidden (Esc) while the browser stays in use. + Downloading(Fetch, bool), } impl ReferencePicker { @@ -293,9 +315,12 @@ impl ReferencePicker { match key.code { KeyCode::Enter | KeyCode::Right => { if let Some(pick) = c.enter() { + // Where it goes is said, and can be changed, first. let base = crate::tui::home().unwrap_or_else(|| self.browser.cwd.clone()); let dir = base.join("faba_refs").join(pick.dir_name()); - self.fetching = Fetching::Downloading(Fetch::start(pick, dir)); + let mut line = LineInput::new(512); + line.open(&tilde(&dir)); + self.fetching = Fetching::Target(pick, line); } } KeyCode::Esc | KeyCode::Left if !c.back() => self.fetching = Fetching::No, @@ -304,7 +329,12 @@ impl ReferencePicker { } } -impl View for ReferencePicker {} +impl View for ReferencePicker { + /// The apply key chooses the file, or confirms the folder typed. + fn takes_apply(&self) -> bool { + true + } +} impl Screen for ReferencePicker { fn done(&self) -> bool { @@ -316,17 +346,50 @@ impl Screen for ReferencePicker { } fn handle_key(&mut self, key: KeyEvent) { - match &self.fetching { + // The apply key acts as Enter: on a file, it chooses it. + let key = if is_apply(&key) { + KeyEvent::new(KeyCode::Enter, key.modifiers - KeyModifiers::CONTROL) + } else { + key + }; + match &mut self.fetching { Fetching::Choosing(_) => return self.key_choosing(key), - Fetching::Downloading(f) => { - // Esc stops it; what came stays for the next try. - if key.code == KeyCode::Esc { - f.stop(); - self.fetching = Fetching::No; - self.note = Some("download stopped; Tab picks up where it left".into()); + Fetching::Target(_, line) => { + match line.handle(key) { + Edit::Submitted(dir) if !dir.is_empty() => { + let Fetching::Target(pick, _) = + std::mem::replace(&mut self.fetching, Fetching::No) + else { + return; + }; + let fetch = Fetch::start(pick, crate::tui::typed_path(&dir)); + self.fetching = Fetching::Downloading(fetch, true); + } + Edit::Submitted(_) | Edit::Cancelled => self.fetching = Fetching::No, + Edit::Typing => {} } return; } + // As in `faba run`: s stops it, keeping what came for the next + // try; Esc hides it, and it carries on. + Fetching::Downloading(f, shown @ true) => { + match key.code { + KeyCode::Char('s') => { + f.stop(); + self.fetching = Fetching::No; + self.note = Some("download stopped; Tab picks up where it left".into()); + } + KeyCode::Esc => *shown = false, + _ => {} + } + return; + } + Fetching::Downloading(_, shown) => { + if key.code == KeyCode::Tab { + *shown = true; + return; + } + } Fetching::No => {} } if key.code == KeyCode::Tab { @@ -348,9 +411,9 @@ impl Screen for ReferencePicker { /// either is under way. fn tick(&mut self) -> bool { match &mut self.fetching { - Fetching::No => false, + Fetching::No | Fetching::Target(..) => false, Fetching::Choosing(c) => c.poll(), - Fetching::Downloading(f) => { + Fetching::Downloading(f, _) => { let done = f.status.lock().ok().and_then(|mut s| s.done.take()); match done { Some(Ok((gtf, genome))) => { @@ -384,7 +447,14 @@ impl Screen for ReferencePicker { ]), help_line(&[ ("Enter", "choose"), - ("Tab", "download one"), + ( + "Tab", + if matches!(self.fetching, Fetching::Downloading(..)) { + "show the download" + } else { + "download one" + }, + ), ("Esc", "clear find / cancel"), ]), ]; @@ -412,9 +482,19 @@ impl Screen for ReferencePicker { lines.extend(help); frame.render_widget(Paragraph::new(lines), inner); match &self.fetching { - Fetching::No => {} + Fetching::No | Fetching::Downloading(_, false) => {} Fetching::Choosing(c) => c.draw(frame, area), - Fetching::Downloading(f) => f.draw(frame, area), + Fetching::Target(pick, line) => { + let lines = vec![ + Line::from(pick.label()), + Line::from(""), + input_line("download into: ", line.text().unwrap_or(""), &[]), + Line::from(""), + help_line(&[(APPLY_KEYS, "start"), ("Esc", "back")]), + ]; + crate::tui::popup(frame, area, " download a reference ", lines); + } + Fetching::Downloading(f, true) => f.draw(frame, area), } } } diff --git a/src/site_analysis/metagene/tests/tui.rs b/src/site_analysis/metagene/tests/tui.rs index 5d03284..5c5f872 100644 --- a/src/site_analysis/metagene/tests/tui.rs +++ b/src/site_analysis/metagene/tests/tui.rs @@ -214,3 +214,39 @@ fn the_text_plot_names_regions_with_no_ticks_and_a_colour_key() { ); } } + +#[test] +fn every_footer_key_shows_at_80_columns() { + let h = hist(true); + let mut v = MetageneView::new("sites", &h); + let s = screen(&mut v, 80, 24); + for key in [ + "Home first", + "+/- finer/coarser", + "0 fit", + "Tab track", + "s save", + "q/Esc quit", + ] { + assert!(s.contains(key), "{key}\n{s}"); + } +} + +#[test] +fn enter_stays_and_plus_is_finer() { + let h = hist(true); + let mut v = MetageneView::new("sites", &h); + screen(&mut v, 100, 18); + press(&mut v, KeyCode::Enter); + assert!(!v.done(), "Enter never quits"); + press(&mut v, KeyCode::Char('-')); + let coarser = v.merge; + press(&mut v, KeyCode::Char('+')); + assert!( + v.merge < coarser, + "+ undoes -, finer: {:?} < {coarser:?}", + v.merge + ); + press(&mut v, KeyCode::Esc); + assert!(v.done()); +} diff --git a/src/site_analysis/metagene/tui.rs b/src/site_analysis/metagene/tui.rs index 5b812a3..5779c3f 100644 --- a/src/site_analysis/metagene/tui.rs +++ b/src/site_analysis/metagene/tui.rs @@ -340,7 +340,12 @@ impl<'a> MetageneView<'a> { } } -impl crate::tui::View for MetageneView<'_> {} +impl crate::tui::View for MetageneView<'_> { + /// The apply key submits the save name being typed. + fn takes_apply(&self) -> bool { + self.controls.prompting() + } +} impl Screen for MetageneView<'_> { fn done(&self) -> bool { @@ -362,23 +367,38 @@ impl Screen for MetageneView<'_> { KeyCode::Right | KeyCode::Char('l') => self.move_cursor(1), KeyCode::Home => self.cursor_to_start(), KeyCode::Tab | KeyCode::BackTab => self.switch_track(), - KeyCode::Char(']' | '+' | '=') => self.set_merge(1), - KeyCode::Char('[' | '-') => self.set_merge(-1), - KeyCode::Char('a') => self.merge = None, + // As pileup's zoom: `+` finer (fewer bins a bar), `-` coarser. + KeyCode::Char(']' | '+' | '=') => self.set_merge(-1), + KeyCode::Char('[' | '-') => self.set_merge(1), + KeyCode::Char('a' | '0') => self.merge = None, KeyCode::Char('y') => self.y_scale = self.y_scale.next(), KeyCode::Char('c') if self.hist.has_reads() => self.show = self.show.next(), - KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter => self.done = true, + KeyCode::Char('q') | KeyCode::Esc => self.done = true, _ => {} } } fn render(&mut self, frame: &mut Frame) { + let mut keys = vec![ + ("←/→", "bar"), + ("Home", "first"), + ("+/-", "finer/coarser"), + ("0", "fit"), + ("y", "scale"), + ]; + if self.has_non_coding() { + keys.push(("Tab", "track")); + } + self.controls.help_keys(&mut keys); + keys.push(("q/Esc", "quit")); + let help = self.controls.footer().unwrap_or_else(|| help_line(&keys)); + let help = crate::tui::wrap_help(help, frame.area().width); let [top, body, stats, summary, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Fill(1), Constraint::Length(1), Constraint::Length(1), - Constraint::Length(1), + Constraint::Length(help.len() as u16), ]) .areas(frame.area()); @@ -434,19 +454,8 @@ impl Screen for MetageneView<'_> { frame.render_widget(Paragraph::new(self.stats_line(&bars)), stats); frame.render_widget(Paragraph::new(self.summary_line()), summary); - let mut keys = vec![ - ("←/→", "bar"), - ("-/+", "merge"), - ("a", "fit"), - ("y", "scale"), - ]; - if self.has_non_coding() { - keys.push(("Tab", "track")); - } - self.controls.help_keys(&mut keys); - keys.push(("q", "quit")); - let help = self.controls.footer().unwrap_or_else(|| help_line(&keys)); - frame.render_widget(help, footer); + frame.render_widget(Paragraph::new(help), footer); + self.controls.render_save(frame, frame.area()); } } diff --git a/src/site_analysis/pileup/picker.rs b/src/site_analysis/pileup/picker.rs index 7d3eda6..51f2ff4 100644 --- a/src/site_analysis/pileup/picker.rs +++ b/src/site_analysis/pileup/picker.rs @@ -167,7 +167,12 @@ impl<'a> GenePicker<'a> { } } -impl crate::tui::View for GenePicker<'_> {} +impl crate::tui::View for GenePicker<'_> { + /// The apply key opens the gene or locus, as Enter does. + fn takes_apply(&self) -> bool { + true + } +} impl Screen for GenePicker<'_> { fn done(&self) -> bool { @@ -180,7 +185,12 @@ impl Screen for GenePicker<'_> { fn handle_key(&mut self, key: KeyEvent) { self.status = None; - match key.code { + let code = if crate::tui::is_apply(&key) { + KeyCode::Enter + } else { + key.code + }; + match code { KeyCode::Up => self.move_by(-1), KeyCode::Down => self.move_by(1), KeyCode::PageUp => self.move_by(-20), @@ -210,10 +220,24 @@ impl Screen for GenePicker<'_> { } fn render(&mut self, frame: &mut Frame) { + let line = match &self.status { + Some(msg) => status_line(msg), + None => input_line( + "search gene or chr:start-end: ", + &self.filter, + &[ + ("↑/↓", "move"), + ("PgUp/PgDn", "page"), + ("Enter", "open"), + ("Esc", "clear/quit"), + ], + ), + }; + let help = crate::tui::wrap_help(line, frame.area().width); let [top, body, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Fill(1), - Constraint::Length(1), + Constraint::Length(help.len() as u16), ]) .areas(frame.area()); frame.render_widget( @@ -256,15 +280,7 @@ impl Screen for GenePicker<'_> { ])); } frame.render_widget(Paragraph::new(lines), inner); - let line = match &self.status { - Some(msg) => status_line(msg), - None => input_line( - "search gene or chr:start-end: ", - &self.filter, - &[("↑/↓", "move"), ("Enter", "open"), ("Esc", "clear/quit")], - ), - }; - frame.render_widget(line, footer); + frame.render_widget(Paragraph::new(help), footer); } } diff --git a/src/site_analysis/pileup/tests/picker.rs b/src/site_analysis/pileup/tests/picker.rs index 41d555c..e2c6d81 100644 --- a/src/site_analysis/pileup/tests/picker.rs +++ b/src/site_analysis/pileup/tests/picker.rs @@ -105,3 +105,21 @@ fn slash_starts_a_fresh_search() { press(&mut p, KeyCode::Enter); assert_eq!(p.decision, Some(Choice::Gene(2))); } + +#[test] +fn every_footer_key_shows_at_60_columns() { + let c = catalog_from_rows(rows()); + let mut p = GenePicker::new(&c); + let mut term = Terminal::new(TestBackend::new(60, 12)).unwrap(); + term.draw(|f| p.render(f)).unwrap(); + let text: String = term + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + for key in ["↑/↓ move", "PgUp/PgDn page", "Enter open", "Esc clear/quit"] { + assert!(text.contains(key), "{key}\n{text}"); + } +} diff --git a/src/site_analysis/pileup/tests/tui.rs b/src/site_analysis/pileup/tests/tui.rs index 07917a4..9f0ac89 100644 --- a/src/site_analysis/pileup/tests/tui.rs +++ b/src/site_analysis/pileup/tests/tui.rs @@ -103,9 +103,9 @@ fn site_jumps_and_moves_stay_in_the_extent() { assert_eq!(v.cursor, 1_000_000, "starts at the first site"); press(&mut v, KeyCode::Char('n')); assert_eq!(v.cursor, 1_000_097); - press(&mut v, KeyCode::Char('p')); + press(&mut v, KeyCode::Char('N')); assert_eq!(v.cursor, 1_000_000); - press(&mut v, KeyCode::Char('p')); + press(&mut v, KeyCode::Char('N')); assert_eq!(v.cursor, 1_000_000, "no site before the first"); for _ in 0..500 { press(&mut v, KeyCode::Right); @@ -489,17 +489,17 @@ fn like_tracks_share_a_mirrored_row_until_split() { text.contains("wt above, mut below · methylated reads"), "{text}" ); - assert!(text.contains("m split"), "{text}"); + assert!(text.contains("v split"), "{text}"); assert!(v.figure().contains("wt above, mut below")); // Bars grow both ways from the zero line. assert!(text.contains('▀') || text.lines().any(|l| l.contains('█'))); - press(&mut v, KeyCode::Char('m')); + press(&mut v, KeyCode::Char('v')); assert_eq!( v.rows(), vec![Row::Contrast(DIFFERENCE), Row::Track(0), Row::Track(1)] ); - assert!(screen(&mut v, 160, 30).contains("m mirror")); + assert!(screen(&mut v, 160, 30).contains("v mirror")); } #[test] @@ -533,3 +533,30 @@ fn shift_m_switches_modality_only_where_it_can() { press(&mut v, KeyCode::Char('M')); assert_eq!(v.exit, Some(Exit::Modality { at: v.cursor })); } + +#[test] +fn every_footer_key_shows_at_80_columns() { + let m = positions(); + let half: Vec<(i64, f64)> = m.iter().map(|&(p, v)| (p, v * 2.0)).collect(); + let a = Track::single("wt", "sum", &m, false).with_total(&half); + let b = Track::single("mut", "sum", &half, false).with_total(&half); + let mut v = PileupView::new("GENE1", "chr1", vec![a, b], EXTENT); + v.switchable = true; + let screen = screen(&mut v, 80, 30); + let missing = crate::tui::missing_keys(&v.footer_line(), &screen); + assert!(missing.is_empty(), "{missing:?}\n{screen}"); +} + +#[test] +fn enter_stays_esc_goes_back_to_the_genes_and_q_quits() { + let (m, s) = (positions(), sites()); + let mut v = view(&m, &s); + screen(&mut v, 100, 24); + press(&mut v, KeyCode::Enter); + assert!(v.exit.is_none(), "Enter never quits"); + press(&mut v, KeyCode::Esc); + assert!(matches!(v.exit, Some(Exit::Genes))); + let mut v = view(&m, &s); + press(&mut v, KeyCode::Char('q')); + assert!(matches!(v.exit, Some(Exit::Quit))); +} diff --git a/src/site_analysis/pileup/tui.rs b/src/site_analysis/pileup/tui.rs index ef2dcca..dd6737f 100644 --- a/src/site_analysis/pileup/tui.rs +++ b/src/site_analysis/pileup/tui.rs @@ -673,7 +673,12 @@ impl<'a> PileupView<'a> { } } -impl crate::tui::View for PileupView<'_> {} +impl crate::tui::View for PileupView<'_> { + /// The apply key submits the search or the save name being typed. + fn takes_apply(&self) -> bool { + self.search.active() || self.controls.prompting() + } +} impl Screen for PileupView<'_> { fn done(&self) -> bool { @@ -714,8 +719,8 @@ impl Screen for PileupView<'_> { ..c }) } - KeyCode::Char('m') => self.mirror = !self.mirror, - KeyCode::Char('M') if self.switchable => { + KeyCode::Char('v') => self.mirror = !self.mirror, + KeyCode::Char('m' | 'M') if self.switchable => { self.exit = Some(Exit::Modality { at: self.cursor }) } KeyCode::Left | KeyCode::Char('h') => self.move_cursor(-1), @@ -723,13 +728,14 @@ impl Screen for PileupView<'_> { KeyCode::PageUp => self.move_cursor(-(self.columns as i64) / 2), KeyCode::PageDown => self.move_cursor(self.columns as i64 / 2), KeyCode::Char('n') => self.jump_site(true), - KeyCode::Char('p') => self.jump_site(false), + KeyCode::Char('N') => self.jump_site(false), KeyCode::Char('+' | '=') => self.zoom(0.5), 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), - KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter => self.exit = Some(Exit::Quit), + // Esc goes back one level: the gene list it came from. + KeyCode::Char('g') | KeyCode::Esc => self.exit = Some(Exit::Genes), + KeyCode::Char('q') => self.exit = Some(Exit::Quit), _ => {} } } @@ -744,7 +750,9 @@ impl Screen for PileupView<'_> { Row::Genes => Constraint::Length(genes_height), _ => Constraint::Ratio(1, n_plots), })); - layout.extend([Constraint::Length(1), Constraint::Length(1)]); + // The footer takes as many lines as its keys need at this width. + let help = crate::tui::wrap_help(self.footer_line(), frame.area().width); + layout.extend([Constraint::Length(1), Constraint::Length(help.len() as u16)]); let areas = Layout::vertical(layout).split(frame.area()); let (top, readout, footer) = (areas[0], areas[areas.len() - 2], areas[areas.len() - 1]); @@ -863,18 +871,7 @@ impl Screen for PileupView<'_> { self.plots = plots; frame.render_widget(Paragraph::new(self.readout(&bins)), readout); - let help = if let Some(buf) = self.search.text() { - input_line( - "search gene or chr:start-end: ", - buf, - &[("Enter", "go"), ("Esc", "back")], - ) - } else if let Some(msg) = &self.status { - status_line(msg) - } else { - self.controls.footer().unwrap_or_else(|| self.help()) - }; - frame.render_widget(help, footer); + frame.render_widget(Paragraph::new(help), footer); self.controls.render_save(frame, frame.area()); } } @@ -923,15 +920,32 @@ impl PileupView<'_> { } } + /// The footer: the search being typed, a status, the save prompt, or + /// the keys. + fn footer_line(&self) -> Line<'static> { + if let Some(buf) = self.search.text() { + input_line( + "search gene or chr:start-end: ", + buf, + &[("Enter", "go"), ("Esc", "back")], + ) + } else if let Some(msg) = &self.status { + status_line(msg) + } else { + self.controls.footer().unwrap_or_else(|| self.help()) + } + } + fn help(&self) -> Line<'static> { let mut keys = vec![ ("←/→", "bar"), - ("n/p", "next/prev site"), + ("PgUp/PgDn", "half a view"), + ("n/N", "next/prev site"), ("+/-", "zoom"), ("0", "whole"), ("/", "search"), ("y", "scale"), - ("g", "genes"), + ("g/Esc", "gene list"), ]; if self.tracks.iter().any(|t| t.is_reads()) { keys.push(("c", self.show.next().short())); @@ -940,10 +954,10 @@ impl PileupView<'_> { keys.push(("d", "difference/fold")); } if self.mirrorable() { - keys.push(("m", if self.mirror { "split" } else { "mirror" })); + keys.push(("v", if self.mirror { "split" } else { "mirror" })); } if self.switchable { - keys.push(("M", "m6A/A-to-I")); + keys.push(("m", "m6A/A-to-I")); } self.controls.help_keys(&mut keys); keys.push(("q", "quit")); diff --git a/src/site_analysis/scan_pwm/tests/tui.rs b/src/site_analysis/scan_pwm/tests/tui.rs index d57e8ee..b6dec2d 100644 --- a/src/site_analysis/scan_pwm/tests/tui.rs +++ b/src/site_analysis/scan_pwm/tests/tui.rs @@ -83,7 +83,7 @@ fn keys_move_the_cursor_and_switch_mode() { assert_eq!(v.cursor, 0); press(&mut v, KeyCode::Char('0')); assert_eq!(v.rel(v.cursor), 0); - press(&mut v, KeyCode::Char('m')); + press(&mut v, KeyCode::Char('b')); assert_eq!(v.mode, Mode::Freq); assert!(!v.done()); press(&mut v, KeyCode::Char('q')); @@ -99,7 +99,7 @@ fn renders_a_logo_with_the_site_labelled() { assert!(s.contains("IC 2.000 bits"), "{s}"); // In bits the uniform column is empty; in frequency it is full. let bits_letters = s.matches(['A', 'C', 'G', 'T']).count(); - press(&mut v, KeyCode::Char('m')); + press(&mut v, KeyCode::Char('b')); let freq_letters = screen(&mut v, 100, 20) .matches(['A', 'C', 'G', 'T']) .count(); @@ -156,3 +156,29 @@ fn draws_through_the_image_path() { let text = screen(&mut v, 60, 20); assert!(text.contains("image/text")); } + +#[test] +fn every_footer_key_shows_at_60_columns() { + let mut v = PwmView::new("x", &pwm(), 2); + let s = screen(&mut v, 60, 20); + for key in [ + "←/→ position", + "Home/End ends", + "0 site", + "b bits/frequency", + "s save", + "q/Esc quit", + ] { + assert!(s.contains(key), "{key}\n{s}"); + } +} + +#[test] +fn enter_stays_and_esc_leaves() { + let mut v = PwmView::new("x", &pwm(), 2); + screen(&mut v, 60, 20); + press(&mut v, KeyCode::Enter); + assert!(!v.done(), "Enter never quits"); + press(&mut v, KeyCode::Esc); + assert!(v.done()); +} diff --git a/src/site_analysis/scan_pwm/tui.rs b/src/site_analysis/scan_pwm/tui.rs index 466e0e3..d8b0e84 100644 --- a/src/site_analysis/scan_pwm/tui.rs +++ b/src/site_analysis/scan_pwm/tui.rs @@ -405,7 +405,12 @@ impl PwmView { } } -impl crate::tui::View for PwmView {} +impl crate::tui::View for PwmView { + /// The apply key submits the save name being typed. + fn takes_apply(&self) -> bool { + self.controls.prompting() + } +} impl Screen for PwmView { fn done(&self) -> bool { @@ -428,23 +433,35 @@ impl Screen for PwmView { KeyCode::Home => self.cursor = 0, KeyCode::End => self.cursor = self.columns.len().saturating_sub(1), KeyCode::Char('0') => self.cursor = (-self.first).max(0) as usize, - KeyCode::Char('m') => { + KeyCode::Char('b') => { self.mode = match self.mode { Mode::Bits => Mode::Freq, Mode::Freq => Mode::Bits, } } - KeyCode::Char('q') | KeyCode::Esc | KeyCode::Enter => self.done = true, + KeyCode::Char('q') | KeyCode::Esc => self.done = true, _ => {} } } fn render(&mut self, frame: &mut Frame) { + let help = self.controls.footer().unwrap_or_else(|| { + let mut keys = vec![ + ("←/→", "position"), + ("Home/End", "ends"), + ("0", "site"), + ("b", "bits/frequency"), + ]; + self.controls.help_keys(&mut keys); + keys.push(("q/Esc", "quit")); + help_line(&keys) + }); + let help = crate::tui::wrap_help(help, frame.area().width); let [top, body, stats, footer] = Layout::vertical([ Constraint::Length(1), Constraint::Fill(1), Constraint::Length(1), - Constraint::Length(1), + Constraint::Length(help.len() as u16), ]) .areas(frame.area()); let n = self.columns.len(); @@ -477,13 +494,7 @@ impl Screen for PwmView { self.render_logo(frame.buffer_mut(), inner); } frame.render_widget(Paragraph::new(self.stats_line()), stats); - let help = self.controls.footer().unwrap_or_else(|| { - let mut keys = vec![("←/→", "position"), ("0", "site"), ("m", "bits/frequency")]; - self.controls.help_keys(&mut keys); - keys.push(("q", "quit")); - help_line(&keys) - }); - frame.render_widget(help, footer); + frame.render_widget(Paragraph::new(help), footer); self.controls.render_save(frame, frame.area()); } } diff --git a/src/site_analysis/tests/input_picker.rs b/src/site_analysis/tests/input_picker.rs index b1f24e0..2001989 100644 --- a/src/site_analysis/tests/input_picker.rs +++ b/src/site_analysis/tests/input_picker.rs @@ -61,7 +61,7 @@ fn mark_files_from_a_folder_or_choose_the_folder() { 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), + ("a_m6a_site.zarr.zip", KeyCode::Char(' ')), ("b_m6a_site.zarr.zip", KeyCode::Char(' ')), ("b_m6a_site.zarr.zip", KeyCode::Char(' ')), ] { @@ -201,3 +201,28 @@ fn the_annotation_picker_lists_gtf_and_gff() { "Tab downloads here too" ); } + +#[test] +fn enter_only_hints_and_a_folder_asks_before_dropping_marks() { + let tmp = tree(); + let root = tmp.path().to_path_buf(); + let mut p = picker(root.join("out"), None); + assert!(p.browser.list.select("a_m6a_site.zarr.zip")); + press(&mut p, KeyCode::Enter); + assert!( + p.browser.marked.as_ref().is_some_and(Vec::is_empty), + "Enter does not mark" + ); + assert!(p + .error + .as_deref() + .is_some_and(|e| e.contains("Space marks"))); + press(&mut p, KeyCode::Char(' ')); + // Up to the root, where `out` is a faba folder. + press(&mut p, KeyCode::Left); + assert!(p.browser.list.select("out")); + press(&mut p, KeyCode::Char(' ')); + assert!(p.decision.is_none() && p.error.as_deref().is_some_and(|e| e.contains("again"))); + press(&mut p, KeyCode::Char(' ')); + assert!(p.decision.is_some(), "the second Space takes it"); +} diff --git a/src/tests/tui.rs b/src/tests/tui.rs index 54c2319..cc5e1b2 100644 --- a/src/tests/tui.rs +++ b/src/tests/tui.rs @@ -130,3 +130,29 @@ fn a_plain_axis_keeps_its_line_and_labels_but_not_its_ticks() { assert_eq!(row(0), "────────"); assert_eq!(row(1), " CDS "); } + +#[test] +fn help_wraps_whole_keys_within_the_width() { + use data_beans::interactive::ui::help_line; + let keys = [ + ("type", "find"), + ("↑/↓", "move"), + ("Space", "mark file / choose folder"), + ]; + let line = help_line(&keys); + assert_eq!( + wrap_help(line.clone(), 200).len(), + 1, + "all on one line when it fits" + ); + let lines = wrap_help(line, 40); + assert_eq!(lines.len(), 2); + assert!( + lines.iter().all(|l| l.width() <= 40), + "each within the width" + ); + assert_eq!( + lines[1].spans[1].content, "Space", + "a key moves with what it does" + ); +} diff --git a/src/tui.rs b/src/tui.rs index 5f3f94e..2753302 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -146,6 +146,54 @@ pub fn tab_bar( Line::from(spans) } +/// A footer `line` as lines no wider than `width`, so no key is cut off: +/// a help line ([`help_line`], [`input_line`]) is a lead span, then a key +/// and what it does, span by span, and a key is never parted from what it +/// does. A line that fits is left as it is. +/// +/// [`help_line`]: data_beans::interactive::ui::help_line +/// [`input_line`]: data_beans::interactive::ui::input_line +pub fn wrap_help(line: Line<'static>, width: u16) -> Vec> { + let width = usize::from(width); + if line.width() <= width { + return vec![line]; + } + // A key (drawn highlighted) starts a group with what follows it. + let mut groups: Vec>> = vec![Vec::new()]; + for span in line.spans { + if span.style == HIGHLIGHT { + groups.push(Vec::new()); + } + groups.last_mut().expect("one group at least").push(span); + } + let mut out = Vec::new(); + let mut row: Vec> = Vec::new(); + let mut used = 0; + for group in groups { + let w: usize = group.iter().map(Span::width).sum(); + if used + w > width && used > 1 { + out.push(Line::from(std::mem::take(&mut row))); + row.push(Span::raw(" ")); + used = 1; + } + used += w; + row.extend(group); + } + out.push(Line::from(row)); + out +} + +/// The keys and descriptions of `help` that `screen` does not show, as a +/// test reads a drawn terminal: empty when every one is there. +#[cfg(test)] +pub fn missing_keys(help: &Line, screen: &str) -> Vec { + help.spans + .iter() + .map(|s| s.content.trim().to_string()) + .filter(|t| !t.is_empty() && !screen.contains(t.as_str())) + .collect() +} + /// 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] { diff --git a/src/tui/busy.rs b/src/tui/busy.rs index 5a7002c..fa5584e 100644 --- a/src/tui/busy.rs +++ b/src/tui/busy.rs @@ -26,6 +26,8 @@ struct Busy<'a, T> { out: Option, /// The work ended without a result: it panicked. gone: bool, + /// A key (or Ctrl-C) came: say it cannot stop the work. + pressed: bool, /// Files done, of `total`; no bar when `total` is 0. done: &'a AtomicUsize, total: usize, @@ -41,9 +43,10 @@ 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(40) + .max(if self.pressed { 56 } else { 40 }) .min(area.width); - let inner = popup_frame(frame, area, w, 4, " working ".into()); + let rows = if self.pressed { 5 } else { 4 }; + let inner = popup_frame(frame, area, w, rows, " working ".into()); let secs = format!("{}s", elapsed.as_secs()); let second = if self.total == 0 { Line::from(Span::styled(format!(" {secs}"), DIM)) @@ -54,20 +57,31 @@ impl Screen for Busy<'_, T> { 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![ + let mut lines = vec![ Line::from(vec![ Span::styled(format!(" {spin} "), HIGHLIGHT), Span::raw(self.what.to_string()), ]), second, ]; + if self.pressed { + lines.push(Line::from(Span::styled( + " it cannot stop halfway; the view opens when done", + DIM, + ))); + } frame.render_widget(Paragraph::new(lines), inner); } - /// Keys wait: the work cannot be stopped halfway. - fn handle_key(&mut self, _key: KeyEvent) {} + /// Keys wait: the work cannot be stopped halfway, which the pop-up + /// then says. + fn handle_key(&mut self, _key: KeyEvent) { + self.pressed = true; + } - fn interrupt(&mut self) {} + fn interrupt(&mut self) { + self.pressed = true; + } fn done(&self) -> bool { self.out.is_some() || self.gone @@ -128,6 +142,7 @@ pub fn busy_counting( rx: &rx, out: None, gone: false, + pressed: false, done: &done, total, }; @@ -156,6 +171,7 @@ mod tests { rx: &rx, out: None, gone: false, + pressed: false, done: &done, total: 3, }; @@ -182,6 +198,7 @@ mod tests { rx: &rx, out: None, gone: false, + pressed: false, done: &done, total: 0, };