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.5"
version = "0.16.6"
edition = "2021"
rust-version = "1.91"
license = "MIT"
Expand Down
42 changes: 27 additions & 15 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,29 +216,36 @@ Example:\n \
#[command(
name = "pileup",
alias = "inspect",
about = "ASCII pileup, or a faceted Miami plot (SVG/PDF) for a gene",
long_about = "Pileup plot for a gene's (or region's) modification sites.\n\
about = "Browse a gene's sites full screen; or an ASCII pileup, or a Miami plot (SVG/PDF)",
long_about = "Pileup of a gene's (or region's) modification sites.\n\
\n\
Selection is `-q/--genes` (symbols or Ensembl IDs) and/or `--regions chr:lb-ub`;\n\
at least one is required,\n\
and everything matched is aggregated into one pileup.\n\
Give it a faba output directory (or a run record in it) and it takes\n\
the site matrices of --modality (m6a, else atoi), one track per batch,\n\
with the site table and the recorded GFF; or name site matrices\n\
(zarr/h5, `{batch}_m6a_site`) yourself.\n\
\n\
ASCII mode (default):\n\
reads one or more sparse matrices (zarr/h5) from faba output,\n\
filters to the selection, bins positions along the gene body,\n\
and renders a vertical ASCII histogram.\n\
Browser (default): pan, zoom and jump between sites full screen,\n\
starting from a gene list unless `-q/--genes` (symbols or Ensembl IDs)\n\
or `--regions chr:lb-ub` picks one.\n\
\n\
ASCII mode (--batch-process, or without a terminal): needs --genes or\n\
--regions, aggregates everything matched into one pileup, bins\n\
positions along the gene body and prints a vertical histogram.\n\
Multiple files (e.g. replicates via a shell glob) are aggregated per position.\n\
\n\
Miami figure mode: passing --gtf, --bam, --format, --svg,\n\
or --png renders a publication SVG/PDF instead —\n\
Miami figure mode: passing --bam, --format, --svg or --png\n\
(or --gtf with --batch-process) renders a publication SVG/PDF instead —\n\
a mirrored Manhattan with epi sites up, a GTF gene model in the middle,\n\
and BAM read depth down,\n\
faceted into one panel per cell type (--cell-membership).",
after_long_help = "\
Examples:\n\
# ASCII histogram (unchanged)\n\
faba pileup out/s1_wt_m6a.zarr.zip -q GENE1\n\
faba pileup out/s*_wt_m6a.zarr.zip -q GENE1 -s out/m6a_sites.parquet\n\
# browse an output directory's m6A sites, from its gene list\n\
faba pileup out/\n\
faba pileup out/pipeline_summary.json -q GENE1 --modality atoi\n\
# ASCII histogram\n\
faba pileup out/s1_wt_m6a_site.zarr.zip -q GENE1 --batch-process\n\
faba pileup out/s*_wt_m6a_site.zarr.zip -q GENE1 -s out/m6a_sites.parquet --batch-process\n\
# Miami figure: epi sites / gene model / read depth, faceted by cell type\n\
faba pileup out/s1_wt_m6a.zarr.zip -q GENE1 \n\
--gtf gencode.gtf --bam sample.bam --cell-membership cells.tsv \n\
Expand Down Expand Up @@ -270,14 +277,19 @@ Example:\n \
so `--isoforms all` is that raw output. Its script then keeps the longest isoform\n\
per gene, which we do not: that drops sites on other isoforms' exons.\n\
--dist-measures is our name for the per-site table that script reads.\n\n\
Give it a faba output directory (or a run record in it) and it profiles that\n\
directory's site table of --modality against its recorded GFF. After writing the\n\
histogram it opens the profile full screen, unless --batch-process.\n\n\
See docs/profiling-methods.md sections 1.2 and 7.\n\n\
Reference:\n\
Olarerin-George and Jaffrey, \"MetaPlotR: a Perl/R pipeline for plotting metagenes of nucleotide modifications and other transcriptomic sites\",\n\
Bioinformatics, 33(10):1563-1564, 2017.\n\
https://doi.org/10.1093/bioinformatics/btx002",
after_long_help = "\
Example:\n\
faba metagene -s out/m6a_sites.parquet -g genes.gff -o metagene.tsv --print\n\
# an output directory's m6A sites against its recorded GFF, then the view\n\
faba metagene out/\n\
faba metagene -s out/m6a_sites.parquet -g genes.gff -o metagene.tsv --print --batch-process\n\
# write the table MetaPlotR's visualize_metagenes.R reads:\n\
faba metagene -s out/m6a_sites.parquet -g genes.gff -o metagene.tsv \n\
--dist-measures m6a.dist.measures.txt"
Expand Down
5 changes: 1 addition & 4 deletions src/pipeline/tui/child.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,15 +263,12 @@ pub fn follow(log: Option<impl std::io::Read>, mut each: impl FnMut(Said)) -> St
last
}

/// Frames of the spinners the workspace draws.
const SPINNER: &str = "⠁⠂⠄⡀⢀⠠⠐⠈";

/// A progress bar's frame, its elapsed time already gone: the bar of `#`
/// and `-`, `pos/len`, the time left in brackets, then what it counts. A
/// spinner's frame starts with one of its ticks.
pub fn progress_of(line: &str) -> Option<Progress> {
let (head, rest) = line.split_once(char::is_whitespace)?;
if head.chars().all(|c| SPINNER.contains(c)) {
if head.chars().all(|c| crate::tui::SPINNER.contains(c)) {
return Some(Progress {
what: rest.trim().to_string(),
..Progress::default()
Expand Down
18 changes: 10 additions & 8 deletions src/pipeline/tui/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,15 +464,17 @@ fn draw_run(app: &App, frame: &mut Frame, body: Rect) {
.map(|l| Line::raw(l.clone()))
.collect();
if let Some(p) = bar {
let filled = (p.len > 0).then(|| filled(p.pos, p.len, BAR));
lines.push(match filled {
Some(filled) => Line::from(vec![
Span::styled("█".repeat(filled), ACCENTED),
Span::styled("░".repeat(BAR.saturating_sub(filled)), DIM),
Span::raw(format!(" {}/{} {}", p.pos, p.len, p.what)),
]),
lines.push(match p.len > 0 {
true => {
let [on, off] = crate::tui::bar_spans(p.pos, p.len, BAR, ACCENTED);
Line::from(vec![
on,
off,
Span::raw(format!(" {}/{} {}", p.pos, p.len, p.what)),
])
}
// A spinner: no length to fill.
None => Line::from(vec![
false => Line::from(vec![
Span::styled("⠿ ", ACCENTED),
Span::raw(p.what.clone()),
]),
Expand Down
48 changes: 39 additions & 9 deletions src/qc/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub const MATRIX_KINDS: &[&str] = &[
];

/// Editing modalities with a shared `{modality}_sites.parquet` and per-batch
/// `_site` matrices.
/// `_site` matrices, in the order one is taken when none is asked for.
pub const SITE_MODALITIES: &[&str] = &[M6A, ATOI];

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -100,13 +100,45 @@ pub fn looks_like_faba_dir(dir: &Path) -> bool {
} else {
classify_matrix_name(name, false).is_some()
};
matrix
|| name
.strip_suffix("_sites.parquet")
matrix || site_table_modality(name).is_some()
})
}

/// Whether `name` is a `{batch}_{modality}_site` matrix's file name (a
/// `.zarr` store counts as a file).
pub fn is_site_matrix_name(name: &str) -> bool {
classify_matrix_name(name, true).is_some_and(|(stem, ..)| {
let (batch, kind) = split_stem(stem);
!batch.is_empty()
&& kind
.strip_suffix("_site")
.is_some_and(|m| SITE_MODALITIES.contains(&m))
})
}

/// The batch a `{batch}_{kind}` matrix's file name carries, when it has a
/// known kind.
pub fn matrix_batch(name: &str) -> Option<Box<str>> {
let (stem, ..) = classify_matrix_name(name, true)?;
let (batch, kind) = split_stem(stem);
(!kind.is_empty()).then_some(batch)
}

/// The site modality after `modality` (m6A and A-to-I swap); `None` for
/// one that is not a site modality.
pub fn other_site_modality(modality: &str) -> Option<&'static str> {
let i = SITE_MODALITIES
.iter()
.position(|m| m.eq_ignore_ascii_case(modality))?;
Some(SITE_MODALITIES[(i + 1) % SITE_MODALITIES.len()])
}

/// The modality of a `{modality}_sites.parquet` site table's file name.
pub fn site_table_modality(name: &str) -> Option<&str> {
name.strip_suffix("_sites.parquet")
.filter(|m| SITE_MODALITIES.contains(m))
}

/// Split a matrix file name into `(stem, backend, zipped)`; `None` when it is
/// not a sparse backend at all.
fn classify_matrix_name(name: &str, is_dir: bool) -> Option<(&str, SparseIoBackend, bool)> {
Expand Down Expand Up @@ -166,11 +198,9 @@ pub fn scan_input_dir(dir: &str) -> anyhow::Result<InputLayout> {
if is_dir {
continue;
}
if let Some(modality) = name.strip_suffix("_sites.parquet") {
if SITE_MODALITIES.contains(&modality) {
layout.site_tables.insert(modality.into(), path_str);
continue;
}
if let Some(modality) = site_table_modality(name) {
layout.site_tables.insert(modality.into(), path_str);
continue;
}
// A run record describes the directory it sits in; `qc` writes its
// own for the new fileset rather than carrying stale ones over.
Expand Down
10 changes: 5 additions & 5 deletions src/qc/path_tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,16 @@ struct PathPicker {
decision: Option<Option<(String, String)>>,
}

/// A browser of subdirectories only, tagging those that hold faba matrices
/// or site tables, each checked once.
fn faba_browser(cwd: PathBuf) -> Browser {
/// A browser of subdirectories and the files `keep` accepts, tagging the
/// folders that hold faba matrices or site tables, each checked once.
pub fn faba_browser(cwd: PathBuf, keep: impl Fn(&str) -> bool + 'static) -> Browser {
let mut seen: FxHashMap<PathBuf, bool> = FxHashMap::default();
let tag = move |dir: &Path| {
*seen
.entry(dir.to_path_buf())
.or_insert_with(|| looks_like_faba_dir(dir))
};
Browser::new(cwd, |_| false, tag)
Browser::new(cwd, keep, tag)
}

/// A directory next to `input` named after it, that does not exist yet.
Expand All @@ -63,7 +63,7 @@ impl PathPicker {
fn new(cwd: PathBuf, input: Option<PathBuf>, given_output: Option<String>) -> Self {
let mut p = Self {
step: Step::Input,
browser: faba_browser(cwd.clone()),
browser: faba_browser(cwd.clone(), |_| false),
error: None,
given_output,
decision: None,
Expand Down
4 changes: 2 additions & 2 deletions src/qc/site_tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ use super::progress::Progress;
use super::sites::{genomic_sites, Criterion, GeneSites, SiteTable};
use crate::tui::browser::{is_annotation, Browser, Nav};
use crate::tui::{
apply_key, button_popup, enter_hint, filled, first_visible, is_apply, popup, popup_frame,
run_view, tab_bar, wheel_key, Hits, View, APPLY_KEYS,
apply_key, button_popup, enter_hint, first_visible, is_apply, popup, popup_frame, run_view,
tab_bar, wheel_key, Hits, View, APPLY_KEYS,
};

mod annotation;
Expand Down
10 changes: 4 additions & 6 deletions src/qc/site_tui/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -716,16 +716,14 @@ impl<'a> SitePicker<'a> {
step = "drawing the figures".into();
}
const BAR: usize = 40;
let filled = filled(done as u64, total as u64, BAR);
let lines = vec![
Line::from(Span::styled("Writing the filtered fileset to ", DIM)),
Line::from(Span::styled(format!(" {}", self.writer.output), HIGHLIGHT)),
Line::from(""),
Line::from(vec![
Span::styled("█".repeat(filled), ACCENTED),
Span::styled("░".repeat(BAR.saturating_sub(filled)), DIM),
Span::raw(format!(" {done} / {total}")),
]),
{
let [on, off] = crate::tui::bar_spans(done as u64, total as u64, BAR, ACCENTED);
Line::from(vec![on, off, Span::raw(format!(" {done} / {total}"))])
},
Line::from(Span::styled(format!("{step:<BAR$}"), DIM)),
];
popup(frame, area, " writing ", lines);
Expand Down
35 changes: 26 additions & 9 deletions src/run_record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,15 +202,7 @@ fn find_recorded(near: &str, key: &str) -> Option<(String, PathBuf)> {
let dir = if p.is_dir() && !is_matrix_dir(p) {
p.to_path_buf()
} else {
p.parent()
.map(|d| {
if d.as_os_str().is_empty() {
Path::new(".")
} else {
d
}
})?
.to_path_buf()
folder_of(p)?
};
let mut records: Vec<(SystemTime, PathBuf)> = std::fs::read_dir(&dir)
.ok()?
Expand Down Expand Up @@ -305,6 +297,31 @@ pub fn explicit_or_recorded(
.or_else(|| find_input(near, key, what))
}

/// The output directory `p` names, when it names one: `p` itself when it is
/// a directory (but not a zarr store, which is one matrix), or the folder of
/// a run record (`*.run.json`, `pipeline_summary.json`).
pub fn output_dir_of(p: &str) -> Option<PathBuf> {
let path = Path::new(p);
if path.is_dir() && !is_matrix_dir(path) {
return Some(path.to_path_buf());
}
let name = path.file_name()?.to_string_lossy();
if !is_run_record(&name) || !path.is_file() {
return None;
}
folder_of(path)
}

/// The folder a file sits in, `.` for a bare name.
fn folder_of(p: &Path) -> Option<PathBuf> {
let dir = p.parent()?;
Some(if dir.as_os_str().is_empty() {
PathBuf::from(".")
} else {
dir.to_path_buf()
})
}

/// A zarr store is a directory, but it is one output, not an output directory.
fn is_matrix_dir(p: &Path) -> bool {
p.extension().is_some_and(|e| e == "zarr")
Expand Down
Loading
Loading