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..93e7e92 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 @@ -614,7 +616,10 @@ 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`. 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. @@ -641,7 +646,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` @@ -652,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. --- @@ -706,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 @@ -730,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 @@ -759,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 @@ -791,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/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/figure/mod.rs b/src/figure/mod.rs index c2555f2..9f0a015 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")]), @@ -898,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/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..4310957 100644 --- a/src/main.rs +++ b/src/main.rs @@ -205,10 +205,18 @@ 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; 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\ - 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/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..9a1668e 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; @@ -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 dd76e5a..3149ce4 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. @@ -31,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); @@ -87,10 +87,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); @@ -98,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`. @@ -533,100 +533,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. @@ -666,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 { @@ -750,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 ad3864a..a894312 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,115 @@ 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), + 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); + } +} + +/// 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/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 93a0632..1914f83 100644 --- a/src/site_analysis/input_picker.rs +++ b/src/site_analysis/input_picker.rs @@ -5,15 +5,17 @@ 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::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::{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. @@ -37,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>, } @@ -66,6 +71,7 @@ impl InputPicker { what, separate: separable.then_some(false), error: None, + confirm_dir: None, decision: None, } } @@ -120,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() { @@ -129,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 => {} } @@ -139,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); } } @@ -159,7 +179,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 +191,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,27 +225,293 @@ 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" + lines.extend(help); + frame.render_widget(Paragraph::new(lines), inner); + } +} + +/// 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 reference. + note: Option, + /// The reference; `Some(None)` when cancelled. + pub decision: Option>, +} + +enum Fetching { + No, + Choosing(Catalogue), + /// 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 { + /// 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, kind.keep(), |_| false).opened(), + command, + kind, + 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() { + // 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()); + 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, + _ => {} + } + } +} + +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 { + self.decision.is_some() + } + + fn interrupt(&mut self) { + self.decision = Some(None); + } + + fn handle_key(&mut self, key: KeyEvent) { + // 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 { - "read the marked" + key }; - 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)); + match &mut self.fetching { + Fetching::Choosing(_) => return self.key_choosing(key), + 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 { + 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 | Fetching::Target(..) => 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((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; + } + None => {} + } + true + } } - keys.extend([(APPLY_KEYS, apply), ("Esc", "clear find / cancel")]); - lines.push(help_line(&keys)); + } + + fn render(&mut self, frame: &mut Frame) { + let area = frame.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(&[ + ("type", "find"), + ("↑/↓", "move"), + ("→", "open"), + ("←", "up"), + ]), + help_line(&[ + ("Enter", "choose"), + ( + "Tab", + if matches!(self.fetching, Fetching::Downloading(..)) { + "show the download" + } else { + "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 {} found: a {} · {} {}", + self.kind.what(), + self.kind.files(), + 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, + "", + &format!("no subfolder or {} here", self.kind.files()), + ); + 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::Downloading(_, false) => {} + Fetching::Choosing(c) => c.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), + } } } +/// 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()) +} + /// 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/metagene.rs b/src/site_analysis/metagene.rs index bc2850c..d18c543 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}; @@ -157,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, @@ -932,38 +924,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 +934,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 +950,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/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 9944dd1..5779c3f 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 @@ -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/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..57b5765 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 // /////////////////////// @@ -243,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>, @@ -993,18 +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 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 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 interactive = browse_it && terminal; + 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/pileup/picker.rs b/src/site_analysis/pileup/picker.rs index d7934e8..51f2ff4 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. @@ -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 55327e2..dd6737f 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}; @@ -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.rs b/src/site_analysis/scan_pwm.rs index 62b6513..34ca236 100644 --- a/src/site_analysis/scan_pwm.rs +++ b/src/site_analysis/scan_pwm.rs @@ -1,5 +1,9 @@ +#[cfg(test)] +mod tests; mod tui; +use super::input_picker::Reference; +use super::output_dir; use super::site_io::*; use crate::common::*; use crate::data::dna::{Dna, DnaBaseCount}; @@ -20,19 +24,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,18 +88,23 @@ 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: Box, + output: Option>, #[arg( - short = 'I', - long = "interactive", + long = "batch-process", + visible_alias = "batch-mode", default_value_t = false, - help = "After writing, show the PWM as a sequence logo full screen (needs a terminal)" + 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)." )] - interactive: bool, + batch_process: bool, } /// Swap A<->T and G<->C counts to complement a DnaBaseCount. @@ -94,56 +117,75 @@ 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. +/// `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 +194,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 +213,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 +236,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 +296,145 @@ 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 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(Some(genome.into())); + } + 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 +/// 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)? - } - PwmSource::Reads => { - if args.bam_files.is_empty() { - return Err(anyhow::anyhow!("BAM file(s) required when --source reads")); + 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 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 => 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_reference("pwm", Reference::Genome, chr)? + else { + return Ok(()); + }; + Some(fasta.to_string_lossy().into()) } - collect_from_reads(&sites, &args.bam_files, args.window)? + }, + PwmSource::Reads => { + 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 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..c68d6a4 --- /dev/null +++ b/src/site_analysis/scan_pwm/tests.rs @@ -0,0 +1,104 @@ +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().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().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/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 7beb05b..d8b0e84 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 @@ -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/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/input_picker.rs b/src/site_analysis/tests/input_picker.rs index 78b2d31..2001989 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)); } @@ -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(' ')), ] { @@ -148,3 +148,81 @@ 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 = 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:?}"); + + // 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 = 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" + ); +} + +#[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/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/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)]); +} 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 7781811..2753302 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)); @@ -133,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/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 { diff --git a/src/tui/busy.rs b/src/tui/busy.rs index fc2e266..fa5584e 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,10 @@ struct Busy<'a, T> { start: Instant, rx: &'a Receiver, 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, @@ -39,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)) @@ -52,29 +57,42 @@ 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.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 +141,8 @@ pub fn busy_counting( start: Instant::now(), rx: &rx, out: None, + gone: false, + pressed: false, done: &done, total, }; @@ -150,6 +170,8 @@ mod tests { start: Instant::now(), rx: &rx, out: None, + gone: false, + pressed: false, done: &done, total: 3, }; @@ -165,6 +187,26 @@ 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, + pressed: 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);