Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "faba"
version = "0.16.6"
version = "0.16.7"
edition = "2021"
rust-version = "1.91"
license = "MIT"
Expand Down
34 changes: 26 additions & 8 deletions docs/profiling-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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`
Expand All @@ -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.

---

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion src/apa/cell_assign.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -22,6 +22,8 @@ pub struct ApaSiteAnnotation {
pub site_id: Box<str>,
pub gene_name: Box<str>,
pub chr: Box<str>,
/// 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/apa/pdui/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion src/apa/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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);
Expand All @@ -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),
Expand All @@ -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,
Expand Down
63 changes: 56 additions & 7 deletions src/figure/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -549,13 +575,11 @@ pub fn save(svg: &str, prefix: &str) -> anyhow::Result<Vec<String>> {

/// [`save`], and the picture the PNG holds.
fn save_picture(svg: &str, prefix: &str) -> anyhow::Result<(Vec<String>, 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());
Expand Down Expand Up @@ -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<Result<String, String>>,
default: String,
/// Why the prompt is still open: the name tried was taken.
taken: Option<String>,
}

impl SavePrompt {
Expand All @@ -639,6 +667,7 @@ impl SavePrompt {
input: LineInput::new(512),
status: None,
default: default.to_string(),
taken: None,
}
}

Expand All @@ -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<String> {
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,
Expand All @@ -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<Line<'static>> {
if let Some(buf) = self.input.text() {
Expand Down Expand Up @@ -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")]),
Expand Down Expand Up @@ -898,6 +942,11 @@ impl Controls {
pub fn footer(&self) -> Option<Line<'static>> {
self.save.footer()
}

/// Whether the save prompt is open, where the apply key submits.
pub fn prompting(&self) -> bool {
self.save.active()
}
}

#[cfg(test)]
Expand Down
11 changes: 0 additions & 11 deletions src/figure/term.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
28 changes: 28 additions & 0 deletions src/figure/tests/figure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
12 changes: 10 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion src/pipeline/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading
Loading