From 5605fa474627d49413a846992e3ec2e8a107d7eb Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 12:18:49 -0700 Subject: [PATCH 1/5] pileup: settings carry across genes, Y locks the axis, marked genes stack; saves are PDF only (0.16.8) - How the bars are drawn (y scale, what c counts, mirror, contrast measure, images) carries from one gene to the next. - Y locks the y-axis maximum for every gene opened after; a taller gene still shows its bars whole. - In the gene list, Space marks genes and Ctrl+R opens them stacked top to bottom: Tab moves the keys, settings apply to all, Y locks every gene to the tallest, s saves the stack as one PDF (figure::stack_svgs). - Every figure save writes a PDF only; the gallery thumbnail is drawn in memory. --- Cargo.toml | 2 +- docs/profiling-methods.md | 16 +- src/figure/mod.rs | 74 ++++++-- src/figure/tests/figure.rs | 9 +- src/qc/tests/site_tui.rs | 2 +- src/site_analysis/metagene/tests/tui.rs | 2 +- src/site_analysis/pileup.rs | 84 ++++++++- src/site_analysis/pileup/picker.rs | 60 ++++++- src/site_analysis/pileup/tests/picker.rs | 29 +++ src/site_analysis/pileup/tests/stack.rs | 127 +++++++++++++ src/site_analysis/pileup/tests/tui.rs | 20 ++- src/site_analysis/pileup/tui.rs | 119 +++++++++++-- src/site_analysis/pileup/tui/stack.rs | 216 +++++++++++++++++++++++ src/site_analysis/scan_pwm/tests/tui.rs | 2 +- 14 files changed, 712 insertions(+), 50 deletions(-) create mode 100644 src/site_analysis/pileup/tests/stack.rs create mode 100644 src/site_analysis/pileup/tui/stack.rs diff --git a/Cargo.toml b/Cargo.toml index 8f82faf..3402830 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "faba" -version = "0.16.7" +version = "0.16.8" edition = "2021" rust-version = "1.91" license = "MIT" diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md index 93e7e92..64904b6 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -657,7 +657,17 @@ 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`, …). +Saves are PDF only. A name already taken is never overwritten: the prompt offers the next free +one (`name_2`, …). + +In the `pileup` browser, how the bars are drawn (the y scale, what `c` counts, mirrored or split, +the contrast measure, images or text) carries from one gene to the next. `Y` locks the y-axis +maximum for every gene opened after, so they compare; a gene taller than the lock still shows its +bars whole, its axis raised to fit. In the gene list, Space marks genes and Ctrl+R opens the marked +ones stacked top to bottom, each with its tracks: Tab moves the keys from gene to gene, how the bars +are drawn applies to all of them, `Y` locks every gene to the tallest one's axis, and `s` saves the +stack as one PDF, the genes one under another. A search, the other modality or zooming out past a +gene needs one gene on its own: `g` (or Esc) goes back to the list, where the marks are kept. 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 @@ -736,8 +746,8 @@ Ctrl+Enter, so a stray key cannot start a write; Enter there shows a hint naming gives the command to run instead. Once applied, the fileset is written with the view still up, under a pop-up showing each file as it goes. The view's figure for every modality and every knob, as set when the cut was applied (scales, selected gene, bar measure), -is saved beside it in `qc_plots/{modality}_{knob}.pdf` and `.png`. `p` prints the matching -`faba qc --batch-process` command, with every cell, feature and site option, and writes nothing; `s` saves the view as a PDF and PNG under a name it asks for, in a pop-up listing the figures saved before (§7). +is saved beside it in `qc_plots/{modality}_{knob}.pdf`. `p` prints the matching +`faba qc --batch-process` command, with every cell, feature and site option, and writes nothing; `s` saves the view as a PDF under a name it asks for, in a pop-up listing the figures saved before (§7). `q` leaves without writing. With any threshold changed, `q` and `p` first ask, and only the same key again leaves; Esc closes pop-ups and never leaves the view. Nothing is written until the thresholds are applied. diff --git a/src/figure/mod.rs b/src/figure/mod.rs index 9f0a015..a066c8d 100644 --- a/src/figure/mod.rs +++ b/src/figure/mod.rs @@ -34,9 +34,6 @@ pub const FAINT: &str = "#cfcfcf"; pub const ACCENT: &str = "#d97757"; const FONT: &str = "Helvetica, Arial, 'DejaVu Sans', sans-serif"; -/// PNG pixels per SVG unit (the SVG is laid out in points: 3x is 216 dpi). -const PNG_SCALE: f32 = 3.0; - /// Escape text for SVG content and attributes. pub(crate) fn esc(s: &str) -> String { let mut out = String::with_capacity(s.len()); @@ -206,6 +203,29 @@ impl Canvas { } } +/// Figures from [`Canvas::finish`] stacked top to bottom into one, as wide +/// as the widest. +pub fn stack_svgs(svgs: &[String]) -> String { + let size = |svg: &str, key: &str| -> f64 { + let at = svg.find(&format!(" {key}=\"")).map(|i| i + key.len() + 3); + at.and_then(|i| svg[i..].split('"').next()?.parse().ok()) + .unwrap_or(0.0) + }; + let width = svgs.iter().map(|s| size(s, "width")).fold(0.0, f64::max); + let mut y = 0.0; + let mut body = String::new(); + for svg in svgs { + // Each nested at its offset, as it was drawn. + body.push_str(&svg.replacen(" +{body} +"# + ) +} + /// A `w` x `h` drawing made by `draw`, as SVG. pub fn svg(w: f64, h: f64, draw: impl FnOnce(&mut Canvas)) -> String { let mut c = Canvas::new(w, h); @@ -539,6 +559,11 @@ fn saved_paths(prefix: &str) -> [String; 2] { [format!("{prefix}.pdf"), format!("{prefix}.png")] } +/// The PDF a save under `prefix` writes. +fn pdf_path(prefix: &str) -> String { + format!("{}.pdf", strip_extension(prefix)) +} + /// Whether a save under `prefix` would replace a file already there. fn taken(prefix: &str) -> bool { saved_paths(prefix) @@ -567,25 +592,31 @@ fn options() -> usvg::Options<'static> { } } -/// Write `svg` as `{prefix}.pdf` (vector) and `{prefix}.png`; returns the -/// paths written. +/// Write `svg` as `{prefix}.pdf` (vector); returns the path written. pub fn save(svg: &str, prefix: &str) -> anyhow::Result> { - save_picture(svg, prefix).map(|(paths, _)| paths) + let tree = usvg::Tree::from_str(svg, &options())?; + Ok(vec![write_pdf(&tree, prefix)?]) } -/// [`save`], and the picture the PNG holds. +/// The PDF of `tree` under `prefix`. +fn write_pdf(tree: &usvg::Tree, prefix: &str) -> anyhow::Result { + let path = pdf_path(prefix); + let pdf = svg2pdf::to_pdf(tree, Default::default(), Default::default()) + .map_err(|e| anyhow::anyhow!("PDF conversion failed: {e:?}"))?; + std::fs::write(&path, pdf)?; + Ok(path) +} + +/// [`save`], and a picture of it for the gallery's thumbnail, drawn in +/// memory only. fn save_picture(svg: &str, prefix: &str) -> anyhow::Result<(Vec, image::RgbaImage)> { - 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:?}"))?; - std::fs::write(&pdf_path, pdf)?; - let pixmap = term::pixmap(&tree, PNG_SCALE)?; - pixmap.save_png(&png_path)?; + let pdf = write_pdf(&tree, prefix)?; + let pixmap = term::pixmap(&tree, 1.0)?; let (w, h) = (pixmap.width(), pixmap.height()); let picture = image::RgbaImage::from_raw(w, h, pixmap.take()) .ok_or_else(|| anyhow::anyhow!("pixel buffer size mismatch"))?; - Ok((vec![pdf_path, png_path], picture)) + Ok((vec![pdf], picture)) } /// What a key did to a [`LineInput`]. @@ -725,7 +756,7 @@ impl SavePrompt { pub fn footer(&self) -> Option> { if let Some(buf) = self.input.text() { return Some(input_line( - "save as (.pdf + .png): ", + "save as (.pdf): ", buf, &[("Enter", "save"), ("Esc", "back")], )); @@ -861,7 +892,7 @@ impl Controls { let [left, right] = Layout::horizontal([Constraint::Length(strip), Constraint::Fill(1)]).areas(inner); let lines = vec![ - Line::from(Span::styled(" save as (.pdf + .png)", DIM)), + Line::from(Span::styled(" save as (.pdf)", DIM)), Line::from(""), Line::from(Span::styled(format!(" {name}▏"), HIGHLIGHT)), match self.save.taken() { @@ -943,6 +974,17 @@ impl Controls { self.save.footer() } + /// Whether plots draw as images (`i`), where the terminal can. + pub fn images_on(&self) -> bool { + self.images + } + + /// Draw plots as images or not, as a view kept from before; text + /// only where the terminal has no images. + pub fn set_images(&mut self, on: bool) { + self.images = on && self.picker.is_some(); + } + /// Whether the save prompt is open, where the apply key submits. pub fn prompting(&self) -> bool { self.save.active() diff --git a/src/figure/tests/figure.rs b/src/figure/tests/figure.rs index fdd08ac..aaf61b2 100644 --- a/src/figure/tests/figure.rs +++ b/src/figure/tests/figure.rs @@ -61,16 +61,15 @@ fn svg_is_well_formed_and_escaped() { } #[test] -fn save_writes_pdf_and_png() { +fn save_writes_a_pdf_only() { let dir = tempfile::tempdir().unwrap(); let prefix = dir.path().join("fig1.pdf"); let paths = save(&panel(), prefix.to_str().unwrap()).unwrap(); - assert_eq!(paths.len(), 2); + assert_eq!(paths.len(), 1); let pdf = std::fs::read(&paths[0]).unwrap(); assert!(pdf.starts_with(b"%PDF"), "{}", paths[0]); - let png = std::fs::read(&paths[1]).unwrap(); - assert!(png.starts_with(b"\x89PNG"), "{}", paths[1]); - assert!(paths[0].ends_with("fig1.pdf") && paths[1].ends_with("fig1.png")); + assert!(paths[0].ends_with("fig1.pdf")); + assert!(!dir.path().join("fig1.png").exists(), "no PNG"); } #[test] diff --git a/src/qc/tests/site_tui.rs b/src/qc/tests/site_tui.rs index a7ed13c..92a5a6d 100644 --- a/src/qc/tests/site_tui.rs +++ b/src/qc/tests/site_tui.rs @@ -405,7 +405,7 @@ fn saves_the_view_as_pdf_and_png() { } press(&mut p, KeyCode::Enter); assert!(dir.path().join("view1.pdf").exists()); - assert!(dir.path().join("view1.png").exists()); + assert!(!dir.path().join("view1.png").exists(), "PDF only"); assert!(!p.done(), "saving does not leave the view"); let svg = p.figure(); assert!(svg.contains("kept") && svg.contains(figure::ACCENT)); diff --git a/src/site_analysis/metagene/tests/tui.rs b/src/site_analysis/metagene/tests/tui.rs index 5c5f872..d9a2c12 100644 --- a/src/site_analysis/metagene/tests/tui.rs +++ b/src/site_analysis/metagene/tests/tui.rs @@ -147,7 +147,7 @@ fn saves_the_profile() { press(&mut v, KeyCode::Char(ch)); } press(&mut v, KeyCode::Enter); - assert!(dir.path().join("mg1.pdf").exists() && dir.path().join("mg1.png").exists()); + assert!(dir.path().join("mg1.pdf").exists() && !dir.path().join("mg1.png").exists()); } #[test] diff --git a/src/site_analysis/pileup.rs b/src/site_analysis/pileup.rs index 57b5765..dac6946 100644 --- a/src/site_analysis/pileup.rs +++ b/src/site_analysis/pileup.rs @@ -601,16 +601,50 @@ fn browse( genes: Option<&SharedModels>, status: Option, switchable: bool, + settings: &mut tui::Settings, ) -> anyhow::Result { - let is_log = matches!(args.signal, PileupSignal::Log10Sum); + let stacked_name = stacked_name(args, loaded); + let tracks = tracks_of(args, loaded, &stacked_name); + let view = view_of(loaded, genes, switchable); + tui::show_pileup(view, tracks, status, settings) +} + +/// Browse `loaded`'s genes stacked top to bottom, as [`browse`] does one. +fn browse_stack( + args: &PileupArgs, + loaded: &[Loaded], + genes: Option<&SharedModels>, + settings: &mut tui::Settings, +) -> anyhow::Result { + let names: Vec = loaded.iter().map(|l| stacked_name(args, l)).collect(); + let views = loaded + .iter() + .zip(&names) + .map(|(l, name)| (view_of(l, genes, false), tracks_of(args, l, name))) + .collect(); + tui::show_stack(views, settings) +} + +/// What a read track with a total is titled: its two channels and signal. +fn stacked_name(args: &PileupArgs, loaded: &Loaded) -> String { let (on, off) = channel_names(&loaded.modality); - let stacked_name = format!("{on} / {off} ({})", args.signal.name()); + format!("{on} / {off} ({})", args.signal.name()) +} + +/// `loaded`'s tracks as the browser draws them, those with a total titled +/// `stacked_name`, and its depth. +fn tracks_of<'a>( + args: &'a PileupArgs, + loaded: &'a Loaded, + stacked_name: &'a str, +) -> Vec> { + let is_log = matches!(args.signal, PileupSignal::Log10Sum); let mut tracks: Vec = loaded .tracks .iter() .map(|t| { let name = match t.total { - Some(_) => &stacked_name, + Some(_) => stacked_name, None => args.signal.name(), }; let track = tui::Track::single(&t.label, name, &t.converted, is_log); @@ -623,7 +657,17 @@ fn browse( if !args.depth_files.is_empty() { tracks.push(tui::Track::depth("depth", &loaded.depth)); } - let view = tui::View { + tracks +} + +/// What the browser shows of `loaded` besides its tracks. +fn view_of<'a>( + loaded: &'a Loaded, + genes: Option<&SharedModels>, + switchable: bool, +) -> tui::View<'a> { + let (on, off) = channel_names(&loaded.modality); + tui::View { title: &loaded.gene, chr: &loaded.chr, extent: loaded.extent, @@ -633,8 +677,7 @@ fn browse( genes: genes.cloned(), cursor: loaded.cursor, switchable, - }; - tui::show_pileup(view, tracks, status) + } } /// Bases shown around a single searched position. @@ -818,7 +861,7 @@ fn switch_modality( /// `M` switches between the m6A and A-to-I sites of the output folders /// among `base`'s inputs. fn interactive(base: &PileupArgs, source: Source, first: Option) -> anyhow::Result<()> { - let genes = start_gene_models(source.args.annotation().as_deref()); + let models = start_gene_models(source.args.annotation().as_deref()); let switchable = base .data_files .iter() @@ -826,8 +869,12 @@ fn interactive(base: &PileupArgs, source: Source, first: Option) -> anyh let mut sources = vec![source]; let mut shown = 0; let mut filter = String::new(); + // The genes marked in the list, kept while browsing. + let mut marked: Vec = Vec::new(); let mut current = first; let mut status: Option = None; + // How the bars are drawn carries from one gene to the next. + let mut settings = tui::Settings::default(); loop { let Source { args, @@ -841,9 +888,11 @@ fn interactive(base: &PileupArgs, source: Source, first: Option) -> anyh let genes = catalog.get()?; let mut list = picker::GenePicker::new(genes); list.set_filter(&filter); + list.set_marked(&marked); list.set_status(status.take()); let choice = list.pick()?; filter = list.filter().to_string(); + marked = list.marked().to_vec(); let query = match choice { picker::Choice::Quit => return Ok(()), picker::Choice::Gene(i) => { @@ -852,6 +901,18 @@ fn interactive(base: &PileupArgs, source: Source, first: Option) -> anyh current = Some(loading(true, args, groups, &selector, None, gene)?); continue; } + picker::Choice::Genes(ids) => { + let mut stacked = Vec::with_capacity(ids.len()); + for &i in &ids { + let gene = &genes[i].gene; + let selector = Selector::exact(gene); + stacked.push(loading(true, args, groups, &selector, None, gene)?); + } + match browse_stack(args, &stacked, models.as_ref(), &mut settings)? { + tui::Exit::Quit => return Ok(()), + _ => continue, + } + } picker::Choice::Locus(q) => q, }; match search(args, groups, catalog, &query) { @@ -864,7 +925,14 @@ fn interactive(base: &PileupArgs, source: Source, first: Option) -> anyh } } }; - match browse(args, &loaded, genes.as_ref(), status.take(), switchable)? { + match browse( + args, + &loaded, + models.as_ref(), + status.take(), + switchable, + &mut settings, + )? { tui::Exit::Quit => return Ok(()), tui::Exit::Genes => {} tui::Exit::Locus { lo, hi, at } => { diff --git a/src/site_analysis/pileup/picker.rs b/src/site_analysis/pileup/picker.rs index 51f2ff4..14b277c 100644 --- a/src/site_analysis/pileup/picker.rs +++ b/src/site_analysis/pileup/picker.rs @@ -93,6 +93,8 @@ pub fn gene_catalog(files: &[Box]) -> anyhow::Result> { pub enum Choice { /// An entry, by index. Gene(usize), + /// The marked entries, by index in the order marked, stacked in one view. + Genes(Vec), /// A typed `chr:start-end` or `chr:pos`. Locus(String), Quit, @@ -111,6 +113,8 @@ pub struct GenePicker<'a> { offset: usize, /// A message for the footer, until the next key. status: Option, + /// Entries marked (Space), by index in the order marked. + marked: Vec, decision: Option, } @@ -124,10 +128,25 @@ impl<'a> GenePicker<'a> { selected: 0, offset: 0, status: None, + marked: Vec::new(), decision: None, } } + /// The entries marked, by index in the order marked. + pub fn marked(&self) -> &[usize] { + &self.marked + } + + /// Mark these entries again, as when the list was left. + pub fn set_marked(&mut self, marked: &[usize]) { + self.marked = marked + .iter() + .copied() + .filter(|&i| i < self.entries.len()) + .collect(); + } + /// Case-insensitive substring match on the gene key or chromosome. fn refilter(&mut self) { let f = self.filter.to_lowercase(); @@ -185,6 +204,12 @@ impl Screen for GenePicker<'_> { fn handle_key(&mut self, key: KeyEvent) { self.status = None; + // The apply key opens the marked genes together; with none, it + // opens the highlighted one, as Enter does. + if crate::tui::is_apply(&key) && !self.marked.is_empty() { + self.decision = Some(Choice::Genes(self.marked.clone())); + return; + } let code = if crate::tui::is_apply(&key) { KeyCode::Enter } else { @@ -211,6 +236,17 @@ impl Screen for GenePicker<'_> { self.filter.pop(); self.refilter(); } + // Space marks the highlighted gene (no gene or locus has one). + KeyCode::Char(' ') => { + if let Some(&i) = self.shown.get(self.selected) { + match self.marked.iter().position(|&m| m == i) { + Some(at) => { + self.marked.remove(at); + } + None => self.marked.push(i), + } + } + } KeyCode::Char(c) if !c.is_control() => { self.filter.push(c); self.refilter(); @@ -229,6 +265,15 @@ impl Screen for GenePicker<'_> { ("↑/↓", "move"), ("PgUp/PgDn", "page"), ("Enter", "open"), + ("Space", "mark"), + ( + crate::tui::APPLY_KEYS, + if self.marked.is_empty() { + "open" + } else { + "open the marked together" + }, + ), ("Esc", "clear/quit"), ], ), @@ -244,7 +289,14 @@ impl Screen for GenePicker<'_> { header( "pileup", "pick a gene", - &format!("{} of {} genes", self.shown.len(), self.entries.len()), + &match self.marked.len() { + 0 => format!("{} of {} genes", self.shown.len(), self.entries.len()), + n => format!( + "{} of {} genes · {n} marked", + self.shown.len(), + self.entries.len() + ), + }, ), top, ); @@ -267,8 +319,14 @@ impl Screen for GenePicker<'_> { let e = &self.entries[i]; let focused = k == self.selected; let style = if focused { HIGHLIGHT } else { PLAIN }; + let mark = match self.marked.contains(&i) { + true => "[x] ", + false if self.marked.is_empty() => "", + false => "[ ] ", + }; lines.push(Line::from(vec![ Span::styled(if focused { "▸ " } else { " " }, HIGHLIGHT), + Span::styled(mark, HIGHLIGHT), Span::styled(format!("{:<32}", e.gene), style), Span::raw(format!(" {:>7} ", e.sites)), dim(format!( diff --git a/src/site_analysis/pileup/tests/picker.rs b/src/site_analysis/pileup/tests/picker.rs index e2c6d81..d105a85 100644 --- a/src/site_analysis/pileup/tests/picker.rs +++ b/src/site_analysis/pileup/tests/picker.rs @@ -123,3 +123,32 @@ fn every_footer_key_shows_at_60_columns() { assert!(text.contains(key), "{key}\n{text}"); } } + +#[test] +fn space_marks_genes_and_the_apply_key_opens_them_together() { + let c = catalog_from_rows(rows()); + let mut p = GenePicker::new(&c); + press(&mut p, KeyCode::Char(' ')); + press(&mut p, KeyCode::Down); + press(&mut p, KeyCode::Down); + press(&mut p, KeyCode::Char(' ')); + assert_eq!(p.marked(), [0, 2], "in the order marked"); + assert!(p.filter.is_empty(), "Space does not type"); + crate::tui::feed( + &mut p, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), + ); + assert_eq!(p.decision, Some(Choice::Genes(vec![0, 2]))); + + // Space again unmarks; with none marked, the apply key opens one gene. + let mut p = GenePicker::new(&c); + p.set_marked(&[1]); + press(&mut p, KeyCode::Down); + press(&mut p, KeyCode::Char(' ')); + assert!(p.marked().is_empty()); + crate::tui::feed( + &mut p, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), + ); + assert_eq!(p.decision, Some(Choice::Gene(1))); +} diff --git a/src/site_analysis/pileup/tests/stack.rs b/src/site_analysis/pileup/tests/stack.rs new file mode 100644 index 0000000..aede624 --- /dev/null +++ b/src/site_analysis/pileup/tests/stack.rs @@ -0,0 +1,127 @@ +use super::*; +use crate::site_analysis::pileup::tui::Track; +use data_beans::interactive::ui::Scale; +use ratatui::backend::TestBackend; +use ratatui::crossterm::event::KeyModifiers; +use ratatui::Terminal; + +/// Sites every 97 bp over a 100 kb gene body, `scale` reads each. +fn positions(scale: f64) -> Vec<(i64, f64)> { + (0..1000).map(|i| (1_000_000 + i * 97, scale)).collect() +} + +const EXTENT: (i64, i64) = (1_000_000, 1_100_000); + +fn stack<'a>(a: &'a [(i64, f64)], b: &'a [(i64, f64)]) -> StackView<'a> { + let one = |name: &str, m: &'a [(i64, f64)]| { + PileupView::new( + name, + "chr1", + vec![Track::single("matrix", "sum", m, false)], + EXTENT, + ) + }; + StackView::new(vec![one("GENE1", a), one("GENE2", b)]) +} + +fn press(s: &mut StackView, code: KeyCode) { + crate::tui::feed(s, KeyEvent::new(code, KeyModifiers::NONE)); +} + +fn screen(s: &mut StackView, w: u16, h: u16) -> String { + let mut term = Terminal::new(TestBackend::new(w, h)).unwrap(); + term.draw(|f| s.render(f)).unwrap(); + term.backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect() +} + +#[test] +fn genes_stack_top_to_bottom_with_every_key_shown() { + let (a, b) = (positions(1.0), positions(3.0)); + let mut s = stack(&a, &b); + let text = screen(&mut s, 80, 40); + let (one, two) = (text.find("GENE1").unwrap(), text.find("GENE2").unwrap()); + assert!(one < two, "the first marked on top"); + assert!(text.contains("▸ keys"), "says which gene has the keys"); + let missing = crate::tui::missing_keys(&s.footer_line(), &text); + assert!(missing.is_empty(), "{missing:?}"); +} + +#[test] +fn keys_go_to_one_gene_and_how_bars_draw_to_all() { + let (a, b) = (positions(1.0), positions(3.0)); + let mut s = stack(&a, &b); + screen(&mut s, 80, 40); + press(&mut s, KeyCode::Char('y')); + assert!(s.views.iter().all(|v| v.y_scale == s.views[0].y_scale)); + assert_ne!( + s.views[1].y_scale, + Scale::Linear, + "the scale applies to both" + ); + + // Tab moves the keys on; `Y` locks one y axis for both. + press(&mut s, KeyCode::Tab); + assert_eq!(s.focus, 1); + let before = s.views[0].cursor; + press(&mut s, KeyCode::Right); + assert_eq!( + s.views[0].cursor, before, + "only the gene with the keys moves" + ); + press(&mut s, KeyCode::Char('Y')); + let lock = s.views[1].y_lock; + assert!( + lock.is_some() && s.views[0].y_lock == lock, + "one axis for both" + ); + + press(&mut s, KeyCode::Char('/')); + assert!( + s.status.is_some() && s.exit.is_none(), + "searching needs one gene" + ); + press(&mut s, KeyCode::Esc); + assert!(matches!(s.exit, Some(Exit::Genes))); +} + +#[test] +fn the_figure_is_the_genes_one_under_another() { + let (a, b) = (positions(1.0), positions(3.0)); + let s = stack(&a, &b); + let svg = s.figure(); + assert!(svg.contains("GENE1") && svg.contains("GENE2")); + let one = s.views[0].figure(); + let height = |svg: &str| -> f64 { + let at = svg.find(" height=\"").unwrap() + 9; + svg[at..].split('"').next().unwrap().parse().unwrap() + }; + let two = s.views[1].figure(); + assert_eq!(height(&svg), height(&one) + height(&two)); + // It renders, as a save would. + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("both"); + crate::figure::save(&svg, out.to_str().unwrap()).unwrap(); + assert!(dir.path().join("both.pdf").exists()); +} + +#[test] +fn y_locks_every_gene_to_the_tallest() { + let (a, b) = (positions(1.0), positions(3.0)); + let mut s = stack(&a, &b); + screen(&mut s, 100, 40); + let tallest = s.views[1].bins().shared; + // From the shorter gene, the lock is still the taller one's height. + press(&mut s, KeyCode::Char('Y')); + assert_eq!(s.views[0].bins().shared, tallest); + assert_eq!(s.views[1].bins().shared, tallest); + press(&mut s, KeyCode::Char('Y')); + assert!( + s.views.iter().all(|v| v.y_lock.is_none()), + "Y again unlocks all" + ); +} diff --git a/src/site_analysis/pileup/tests/tui.rs b/src/site_analysis/pileup/tests/tui.rs index 9f0ac89..d22d6fb 100644 --- a/src/site_analysis/pileup/tests/tui.rs +++ b/src/site_analysis/pileup/tests/tui.rs @@ -163,7 +163,7 @@ fn saves_the_window() { press(&mut v, KeyCode::Char(ch)); } press(&mut v, KeyCode::Enter); - assert!(dir.path().join("pile1.pdf").exists() && dir.path().join("pile1.png").exists()); + assert!(dir.path().join("pile1.pdf").exists() && !dir.path().join("pile1.png").exists()); } #[test] @@ -560,3 +560,21 @@ fn enter_stays_esc_goes_back_to_the_genes_and_q_quits() { press(&mut v, KeyCode::Char('q')); assert!(matches!(v.exit, Some(Exit::Quit))); } + +#[test] +fn how_bars_draw_carries_to_the_next_gene() { + let (m, s) = (positions(), sites()); + let mut v = view(&m, &s); + screen(&mut v, 100, 24); + press(&mut v, KeyCode::Char('y')); + press(&mut v, KeyCode::Char('Y')); + let kept = v.settings(); + assert!(kept.y_lock.is_some()); + let mut next = view(&m, &s); + next.apply(&kept); + assert_eq!(next.y_scale, v.y_scale, "the y scale carries over"); + assert_eq!(next.y_lock, v.y_lock, "and the locked y maximum"); + assert_eq!(next.bins().shared, v.y_lock); + press(&mut next, KeyCode::Char('Y')); + assert!(next.y_lock.is_none(), "Y again unlocks"); +} diff --git a/src/site_analysis/pileup/tui.rs b/src/site_analysis/pileup/tui.rs index dd6737f..dbb7897 100644 --- a/src/site_analysis/pileup/tui.rs +++ b/src/site_analysis/pileup/tui.rs @@ -13,13 +13,15 @@ use data_beans::interactive::ui::{ }; use genomic_data::coordinates::chr_eq; use ratatui::crossterm::event::{KeyCode, KeyEvent}; -use ratatui::layout::{Constraint, Layout}; +use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; +mod stack; mod track; +pub use stack::show_stack; pub use track::*; use crate::figure::GUTTER; @@ -133,9 +135,25 @@ pub struct PileupView<'a> { pending_genes: Option, /// Whether `M` switches to the other modality's sites. switchable: bool, + /// The y-axis maximum `Y` locked, kept for every gene opened after. + y_lock: Option, + /// The header's badge: `pileup`, or in a stack, which gene has the keys. + badge: &'static str, exit: Option, } +/// What the browser keeps from one gene to the next: how the bars are +/// drawn, not where. `None` leaves a view's own default. +#[derive(Debug, Clone, Copy, Default)] +pub struct Settings { + pub y_scale: Option, + pub show: Option, + pub mirror: Option, + pub measure: Option, + pub y_lock: Option, + pub images: Option, +} + impl<'a> PileupView<'a> { pub fn new(title: &str, chr: &str, tracks: Vec>, extent: (i64, i64)) -> Self { let mut sites: Vec = tracks @@ -178,10 +196,38 @@ impl<'a> PileupView<'a> { genes: Vec::new(), pending_genes: None, switchable: false, + y_lock: None, + badge: "pileup", exit: None, } } + /// Draw as `s` keeps from the gene before. + pub fn apply(&mut self, s: &Settings) { + self.y_scale = s.y_scale.unwrap_or(self.y_scale); + self.show = s.show.unwrap_or(self.show); + self.mirror = s.mirror.unwrap_or(self.mirror); + if let (Some(c), Some(measure)) = (&mut self.contrast, s.measure) { + c.measure = measure; + } + self.y_lock = s.y_lock; + if let Some(on) = s.images { + self.controls.set_images(on); + } + } + + /// How this view draws, to keep for the next gene. + pub fn settings(&self) -> Settings { + Settings { + y_scale: Some(self.y_scale), + show: Some(self.show), + mirror: Some(self.mirror), + measure: self.contrast.map(|c| c.measure), + y_lock: self.y_lock, + images: Some(self.controls.images_on()), + } + } + /// Take this chromosome's genes within the extent once the annotation has arrived. fn take_genes(&mut self) { let Some(read) = self.pending_genes.as_ref().and_then(|m| m.get()) else { @@ -321,7 +367,7 @@ impl<'a> PileupView<'a> { edges, tracks, shown, - shared: (tallest > 0.0).then_some(tallest), + shared: self.y_lock.or((tallest > 0.0).then_some(tallest)), } } @@ -733,6 +779,14 @@ impl Screen for PileupView<'_> { KeyCode::Char('-') => self.zoom_out(), KeyCode::Char('0') => self.window = self.extent, KeyCode::Char('y') => self.y_scale = self.y_scale.next(), + // Keep this y-axis maximum for every gene opened after, so they + // compare; again, back to each its own. + KeyCode::Char('Y') => { + self.y_lock = match self.y_lock { + Some(_) => None, + None => self.bins().shared, + } + } // 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), @@ -741,6 +795,14 @@ impl Screen for PileupView<'_> { } fn render(&mut self, frame: &mut Frame) { + self.render_in(frame, frame.area(), true); + } +} + +impl PileupView<'_> { + /// Draw in `area`: the header, the rows and the readout, and with + /// `footer` the keys and the save pop-up (a stack draws those once). + pub(super) fn render_in(&mut self, frame: &mut Frame, area: Rect, footer: bool) { self.take_genes(); let rows = self.rows(); let n_plots = rows.iter().filter(|&&r| r != Row::Genes).count().max(1) as u32; @@ -751,26 +813,34 @@ impl Screen for PileupView<'_> { _ => Constraint::Ratio(1, n_plots), })); // 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 help = match footer { + true => crate::tui::wrap_help(self.footer_line(), area.width), + false => Vec::new(), + }; 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]); + let areas = Layout::vertical(layout).split(area); + let (top, readout, footer_area) = + (areas[0], areas[areas.len() - 2], areas[areas.len() - 1]); let inner_width = areas[1].width.saturating_sub(2 + GUTTER); self.columns = (inner_width as usize).max(1); self.clamp_window(); let (lo, hi) = self.window; let extra = format!( - "{}:{}-{} {} bp/bar · {} · y {}{}", + "{}:{}-{} {} bp/bar · {} · y {}{}{}", self.chr, fmt_thousands(lo), fmt_thousands(hi), self.bin_width(), self.show.label(self.on, self.off), self.y_scale.name(), + match self.y_lock { + Some(max) => format!(" ≤ {max:.0} locked"), + None => String::new(), + }, self.controls.tag() ); - frame.render_widget(header("pileup", &self.title, &extra), top); + frame.render_widget(header(self.badge, &self.title, &extra), top); let every = TICK_SPACING.max(self.columns / 5); let ticks: Vec> = (0..self.columns) @@ -871,8 +941,10 @@ impl Screen for PileupView<'_> { self.plots = plots; frame.render_widget(Paragraph::new(self.readout(&bins)), readout); - frame.render_widget(Paragraph::new(help), footer); - self.controls.render_save(frame, frame.area()); + if footer { + frame.render_widget(Paragraph::new(help), footer_area); + self.controls.render_save(frame, frame.area()); + } } } @@ -945,6 +1017,14 @@ impl PileupView<'_> { ("0", "whole"), ("/", "search"), ("y", "scale"), + ( + "Y", + if self.y_lock.is_some() { + "unlock y" + } else { + "lock y" + }, + ), ("g/Esc", "gene list"), ]; if self.tracks.iter().any(|t| t.is_reads()) { @@ -965,9 +1045,8 @@ impl PileupView<'_> { } } -/// Browse until the user quits, asks for the gene list, or searches outside the view. -/// `status` shows on the footer first. -pub fn show_pileup(view: View, tracks: Vec, status: Option) -> anyhow::Result { +/// The browser for `view`'s `tracks`, `status` on its footer first. +fn browser<'a>(view: View<'a>, tracks: Vec>, status: Option) -> PileupView<'a> { let mut browser = PileupView::new(view.title, view.chr, tracks, view.extent); browser.on = view.on; browser.off = view.off; @@ -983,7 +1062,23 @@ pub fn show_pileup(view: View, tracks: Vec, status: Option) -> an loading.then(|| LOADING.to_string()) }); browser.controls = Controls::new(&format!("pileup_{}", view.title)).detect(); + browser +} + +/// Browse until the user quits, asks for the gene list, or searches outside the view. +/// `status` shows on the footer first. +/// `settings` holds how the gene before was drawn: this one starts so, and +/// leaves it as this one was. +pub fn show_pileup( + view: View, + tracks: Vec, + status: Option, + settings: &mut Settings, +) -> anyhow::Result { + let mut browser = browser(view, tracks, status); + browser.apply(settings); crate::tui::run_view(&mut browser)?; + *settings = browser.settings(); Ok(browser.exit.unwrap_or(Exit::Quit)) } diff --git a/src/site_analysis/pileup/tui/stack.rs b/src/site_analysis/pileup/tui/stack.rs new file mode 100644 index 0000000..053dc6d --- /dev/null +++ b/src/site_analysis/pileup/tui/stack.rs @@ -0,0 +1,216 @@ +//! Several genes' browsers stacked top to bottom, for the genes marked in +//! the gene list: one takes the keys (Tab moves on), how the bars are drawn +//! applies to all of them, and `s` saves them all as one figure. + +use data_beans::interactive::ui::{help_line, Screen}; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::layout::{Constraint, Layout}; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::{browser, Exit, PileupView, Settings, Track, View}; +use crate::figure::{status_line, Controls, Key}; + +/// What a stack says when a key needs one gene to itself. +const ONE_GENE: &str = "that works on one gene: g goes back to the list to open it alone"; + +pub struct StackView<'a> { + views: Vec>, + /// The view the keys go to. + focus: usize, + /// Saving the whole stack, and images for all. + controls: Controls, + status: Option, + exit: Option, +} + +impl<'a> StackView<'a> { + pub fn new(views: Vec>) -> Self { + let controls = Controls::new(&save_name(&views)); + let mut stack = Self { + views, + focus: 0, + controls, + status: None, + exit: None, + }; + stack.mark_focus(); + stack + } + + /// The badge says which gene has the keys. + fn mark_focus(&mut self) { + for (i, v) in self.views.iter_mut().enumerate() { + v.badge = if i == self.focus { + "▸ keys" + } else { + "pileup" + }; + } + } + + /// Every gene drawn as the one with the keys is, so they compare. + fn share_settings(&mut self) { + let mut s = self.views[self.focus].settings(); + s.images = Some(self.controls.images_on()); + for v in &mut self.views { + v.apply(&s); + } + } + + /// The genes' figures, top to bottom, as one. + pub(super) fn figure(&self) -> String { + let figures: Vec = self.views.iter().map(PileupView::figure).collect(); + crate::figure::stack_svgs(&figures) + } + + pub(super) fn footer_line(&self) -> Line<'static> { + if let Some(msg) = &self.status { + return status_line(msg); + } + if let Some(line) = self.controls.footer() { + return line; + } + let v = &self.views[self.focus]; + let mut keys = vec![ + ("Tab", "next gene"), + ("←/→", "bar"), + ("n/N", "next/prev site"), + ("+/-", "zoom"), + ("0", "whole"), + ("y", "scale"), + ( + "Y", + if v.y_lock.is_some() { + "unlock y" + } else { + "lock y (all genes)" + }, + ), + ]; + if v.tracks.iter().any(|t| t.is_reads()) { + keys.push(("c", v.show.next().short())); + } + self.controls.help_keys(&mut keys); + keys.extend([("g/Esc", "gene list"), ("q", "quit")]); + help_line(&keys) + } +} + +impl crate::tui::View for StackView<'_> { + /// The apply key submits the save name being typed. + fn takes_apply(&self) -> bool { + self.controls.prompting() + } +} + +impl Screen for StackView<'_> { + fn done(&self) -> bool { + self.exit.is_some() + } + + fn interrupt(&mut self) { + self.exit = Some(Exit::Quit); + } + + /// The annotation arriving redraws every gene. + fn tick(&mut self) -> bool { + // Every gene takes its annotation, so none may be skipped. + let mut any = false; + for v in &mut self.views { + any |= v.tick(); + } + any + } + + fn handle_key(&mut self, key: KeyEvent) { + self.status = None; + match self.controls.key(key) { + Key::Used => return self.share_settings(), + Key::Save(prefix) => return self.controls.save(&self.figure(), &prefix), + Key::Pass => {} + } + let n = self.views.len(); + match key.code { + KeyCode::Tab => self.focus = (self.focus + 1) % n, + KeyCode::BackTab => self.focus = (self.focus + n - 1) % n, + KeyCode::Char('q') => self.exit = Some(Exit::Quit), + KeyCode::Char('g') | KeyCode::Esc => self.exit = Some(Exit::Genes), + // A search or the other modality opens one gene, not a stack. + KeyCode::Char('/' | 'm' | 'M') => self.status = Some(ONE_GENE.into()), + // One y axis for every gene: the tallest of them, as a locked + // maximum only ever raises an axis (no bar is cut off). + KeyCode::Char('Y') if self.views[self.focus].y_lock.is_none() => { + let tallest = self + .views + .iter() + .filter_map(|v| v.bins().shared) + .fold(0.0, f64::max); + self.views[self.focus].y_lock = (tallest > 0.0).then_some(tallest); + self.share_settings(); + } + _ => { + let v = &mut self.views[self.focus]; + v.handle_key(key); + // Zooming out past the gene reloads one gene's span. + if v.exit.take().is_some() { + self.status = Some(ONE_GENE.into()); + } + self.share_settings(); + } + } + self.mark_focus(); + } + + fn render(&mut self, frame: &mut Frame) { + let area = frame.area(); + let help = crate::tui::wrap_help(self.footer_line(), area.width); + let n = self.views.len() as u32; + let mut layout: Vec = (0..n).map(|_| Constraint::Ratio(1, n)).collect(); + layout.push(Constraint::Length(help.len() as u16)); + let areas = Layout::vertical(layout).split(area); + for (v, &a) in self.views.iter_mut().zip(areas.iter()) { + v.render_in(frame, a, false); + } + frame.render_widget(Paragraph::new(help), areas[areas.len() - 1]); + self.controls.render_save(frame, area); + } +} + +/// The name a save suggests: `pileup_` and the first genes' names. +fn save_name(views: &[PileupView]) -> String { + let mut names: Vec<&str> = views.iter().map(|v| v.title.as_str()).take(3).collect(); + if views.len() > 3 { + names.push("etc"); + } + format!("pileup_{}", names.join("_")) +} + +/// Browse the genes of `views` stacked, each with its tracks, until the +/// user goes back to the gene list or quits. `settings` holds how the gene +/// before was drawn, and is left as the stack was. +pub fn show_stack(views: Vec<(View, Vec)>, settings: &mut Settings) -> anyhow::Result { + let views: Vec = views + .into_iter() + .map(|(view, tracks)| { + let mut b = browser(view, tracks, None); + b.apply(settings); + b + }) + .collect(); + anyhow::ensure!(!views.is_empty(), "no gene to show"); + let mut stack = StackView::new(views); + stack.controls = Controls::new(&save_name(&stack.views)).detect(); + if let Some(on) = settings.images { + stack.controls.set_images(on); + } + stack.share_settings(); + crate::tui::run_view(&mut stack)?; + *settings = stack.views[stack.focus].settings(); + Ok(stack.exit.unwrap_or(Exit::Quit)) +} + +#[cfg(test)] +#[path = "../tests/stack.rs"] +mod tests; diff --git a/src/site_analysis/scan_pwm/tests/tui.rs b/src/site_analysis/scan_pwm/tests/tui.rs index b6dec2d..1f6cc80 100644 --- a/src/site_analysis/scan_pwm/tests/tui.rs +++ b/src/site_analysis/scan_pwm/tests/tui.rs @@ -133,7 +133,7 @@ fn saves_the_logo() { press(&mut v, KeyCode::Char(ch)); } press(&mut v, KeyCode::Enter); - assert!(dir.path().join("logo1.pdf").exists() && dir.path().join("logo1.png").exists()); + assert!(dir.path().join("logo1.pdf").exists() && !dir.path().join("logo1.png").exists()); assert!(!v.done()); } From a860e81d51496f4b7a08b6c98c706197214d52b2 Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 12:25:35 -0700 Subject: [PATCH 2/5] pileup stack: every gene redraws on a shared change; no zero-height rects - A setting applied from the gene with the keys (y, c, Y) redraws the other genes' image plots too; they kept the old picture. - Canvas::rect skips a shape that would print as 0.00 (or is not finite), which usvg warned about and skipped. --- src/figure/mod.rs | 3 ++- src/figure/term.rs | 6 ++++++ src/figure/tests/figure.rs | 17 +++++++++++++++++ src/site_analysis/pileup/tests/stack.rs | 22 ++++++++++++++++++++++ src/site_analysis/pileup/tui.rs | 9 +++++++-- 5 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/figure/mod.rs b/src/figure/mod.rs index a066c8d..f68817a 100644 --- a/src/figure/mod.rs +++ b/src/figure/mod.rs @@ -88,7 +88,8 @@ impl Canvas { /// A filled rectangle; empty ones (zero or negative size) are skipped, /// as SVG renderers reject them. pub fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, fill: &str) { - if !(w > 0.0 && h > 0.0) { + // Written to two decimals: what would read as zero is skipped too. + if !(w >= 0.005 && h >= 0.005 && w.is_finite() && h.is_finite()) { return; } let _ = writeln!( diff --git a/src/figure/term.rs b/src/figure/term.rs index 2a96003..e1b66da 100644 --- a/src/figure/term.rs +++ b/src/figure/term.rs @@ -107,6 +107,12 @@ impl PlotImage { self.stale = true; } + /// Whether the next frame rebuilds it. + #[cfg(test)] + pub fn is_stale(&self) -> bool { + self.stale + } + /// Draw into `area`. `draw(width, height)` returns the SVG, in figure /// units, for an area that size. False when no image could be made, so /// the caller can fall back to text. diff --git a/src/figure/tests/figure.rs b/src/figure/tests/figure.rs index aaf61b2..67a46e2 100644 --- a/src/figure/tests/figure.rs +++ b/src/figure/tests/figure.rs @@ -183,3 +183,20 @@ fn the_prompt_never_replaces_a_saved_figure() { assert_eq!(p.handle(key(KeyCode::Enter)), Some(at("logo_3"))); assert!(p.taken().is_none()); } + +#[test] +fn a_bar_too_thin_to_write_is_skipped() { + let mut c = Canvas::new(100.0, 100.0); + c.rect(1.0, 1.0, 10.0, 0.004, INK); + c.rect(1.0, 1.0, 0.001, 10.0, INK); + c.rect(1.0, 1.0, 10.0, f64::INFINITY, INK); + c.rect(1.0, 1.0, 10.0, 0.5, INK); + let svg = c.finish(); + assert!( + !svg.contains(r#"height="0.00""#) && !svg.contains(r#"width="0.00""#), + "{svg}" + ); + assert!(!svg.contains("inf"), "{svg}"); + // The background, and the one bar thick enough to see. + assert_eq!(svg.matches(" { /// What the browser keeps from one gene to the next: how the bars are /// drawn, not where. `None` leaves a view's own default. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct Settings { pub y_scale: Option, pub show: Option, @@ -202,8 +202,10 @@ impl<'a> PileupView<'a> { } } - /// Draw as `s` keeps from the gene before. + /// Draw as `s` keeps from the gene before (or another in a stack); a + /// plot drawn as an image is redrawn when that changes it. pub fn apply(&mut self, s: &Settings) { + let before = self.settings(); self.y_scale = s.y_scale.unwrap_or(self.y_scale); self.show = s.show.unwrap_or(self.show); self.mirror = s.mirror.unwrap_or(self.mirror); @@ -214,6 +216,9 @@ impl<'a> PileupView<'a> { if let Some(on) = s.images { self.controls.set_images(on); } + if self.settings() != before { + self.plots.iter_mut().for_each(PlotImage::invalidate); + } } /// How this view draws, to keep for the next gene. From fd16a88e46bc15ca5ba66cc83069e675eebe36de Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 12:30:04 -0700 Subject: [PATCH 3/5] pileup stack: | lays genes out in a column, a row or a grid; clicks pick a gene and a bar; Esc clears marks - | cycles one under another, side by side and a grid, on screen and in the saved PDF (figure::tile_svgs). - A click gives the gene under it the keys, and over its bars moves its cursor there; in the one-gene browser, a click moves the cursor. - In the gene list, Esc clears the search, then every mark, then leaves. --- docs/profiling-methods.md | 8 +- src/figure/mod.rs | 23 ++++-- src/figure/tests/figure.rs | 17 ++++ src/site_analysis/pileup/picker.rs | 15 +++- src/site_analysis/pileup/tests/picker.rs | 28 ++++++- src/site_analysis/pileup/tests/stack.rs | 46 +++++++++++ src/site_analysis/pileup/tui.rs | 31 ++++++++ src/site_analysis/pileup/tui/stack.rs | 98 +++++++++++++++++++++--- 8 files changed, 243 insertions(+), 23 deletions(-) diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md index 64904b6..0ee4672 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -664,9 +664,11 @@ In the `pileup` browser, how the bars are drawn (the y scale, what `c` counts, m the contrast measure, images or text) carries from one gene to the next. `Y` locks the y-axis maximum for every gene opened after, so they compare; a gene taller than the lock still shows its bars whole, its axis raised to fit. In the gene list, Space marks genes and Ctrl+R opens the marked -ones stacked top to bottom, each with its tracks: Tab moves the keys from gene to gene, how the bars -are drawn applies to all of them, `Y` locks every gene to the tallest one's axis, and `s` saves the -stack as one PDF, the genes one under another. A search, the other modality or zooming out past a +ones stacked top to bottom, each with its tracks: Tab (or a click on a gene) moves the keys from +gene to gene, a click on the bars moves that gene's cursor there, how the bars are drawn applies to +all of them, `Y` locks every gene to the tallest one's axis, `|` lays them out one under another, +side by side, or in a grid, and `s` saves them as one PDF laid out as on screen. Esc in the list +clears the search, then every mark, then leaves. A search, the other modality or zooming out past a gene needs one gene on its own: `g` (or Esc) goes back to the list, where the marks are kept. Every view keeps the same key rules. Enter opens or chooses, and never quits or applies; Ctrl+R diff --git a/src/figure/mod.rs b/src/figure/mod.rs index f68817a..c2b82ca 100644 --- a/src/figure/mod.rs +++ b/src/figure/mod.rs @@ -204,22 +204,29 @@ impl Canvas { } } -/// Figures from [`Canvas::finish`] stacked top to bottom into one, as wide -/// as the widest. -pub fn stack_svgs(svgs: &[String]) -> String { +/// Figures from [`Canvas::finish`] tiled into one, `cols` to a row, in +/// order: one column stacks them top to bottom, as many columns as figures +/// lays them side by side. Each cell is as wide as the widest figure and +/// each row as tall as its tallest. +pub fn tile_svgs(svgs: &[String], cols: usize) -> String { let size = |svg: &str, key: &str| -> f64 { let at = svg.find(&format!(" {key}=\"")).map(|i| i + key.len() + 3); at.and_then(|i| svg[i..].split('"').next()?.parse().ok()) .unwrap_or(0.0) }; - let width = svgs.iter().map(|s| size(s, "width")).fold(0.0, f64::max); + let cols = cols.max(1); + let cell = svgs.iter().map(|s| size(s, "width")).fold(0.0, f64::max); let mut y = 0.0; let mut body = String::new(); - for svg in svgs { - // Each nested at its offset, as it was drawn. - body.push_str(&svg.replacen(" {body} diff --git a/src/figure/tests/figure.rs b/src/figure/tests/figure.rs index 67a46e2..f787449 100644 --- a/src/figure/tests/figure.rs +++ b/src/figure/tests/figure.rs @@ -200,3 +200,20 @@ fn a_bar_too_thin_to_write_is_skipped() { // The background, and the one bar thick enough to see. assert_eq!(svg.matches(" f64 { + let at = svg.find(&format!(" {k}=\"")).unwrap() + k.len() + 3; + svg[at..].split('"').next().unwrap().parse().unwrap() + }; + (get("width"), get("height")) + }; + assert_eq!(size(&tile_svgs(&figs, 1)), (100.0, 160.0), "a column"); + assert_eq!(size(&tile_svgs(&figs, 3)), (300.0, 70.0), "a row"); + assert_eq!(size(&tile_svgs(&figs, 2)), (200.0, 110.0), "two to a row"); + assert!(tile_svgs(&figs, 2).contains(r#" { self.decision = Some(Choice::Gene(i)); } } + // Esc backs out a step at a time: the search, the marks, the list. + KeyCode::Esc if self.filter.is_empty() && !self.marked.is_empty() => { + self.marked.clear() + } KeyCode::Esc if self.filter.is_empty() => self.decision = Some(Choice::Quit), // Esc, and `/` as in the browser, start the search over. KeyCode::Esc | KeyCode::Char('/') => self.set_filter(""), @@ -274,7 +278,16 @@ impl Screen for GenePicker<'_> { "open the marked together" }, ), - ("Esc", "clear/quit"), + ( + "Esc", + if !self.filter.is_empty() { + "clear search" + } else if !self.marked.is_empty() { + "clear all marks" + } else { + "quit" + }, + ), ], ), }; diff --git a/src/site_analysis/pileup/tests/picker.rs b/src/site_analysis/pileup/tests/picker.rs index d105a85..a78bd19 100644 --- a/src/site_analysis/pileup/tests/picker.rs +++ b/src/site_analysis/pileup/tests/picker.rs @@ -119,7 +119,13 @@ fn every_footer_key_shows_at_60_columns() { .iter() .map(|c| c.symbol()) .collect(); - for key in ["↑/↓ move", "PgUp/PgDn page", "Enter open", "Esc clear/quit"] { + for key in [ + "↑/↓ move", + "PgUp/PgDn page", + "Enter open", + "Space mark", + "Esc quit", + ] { assert!(text.contains(key), "{key}\n{text}"); } } @@ -152,3 +158,23 @@ fn space_marks_genes_and_the_apply_key_opens_them_together() { ); assert_eq!(p.decision, Some(Choice::Gene(1))); } + +#[test] +fn esc_clears_the_search_then_the_marks_then_quits() { + let c = catalog_from_rows(rows()); + let mut p = GenePicker::new(&c); + p.set_marked(&[0, 1]); + press(&mut p, KeyCode::Char('g')); + press(&mut p, KeyCode::Esc); + assert!( + p.filter.is_empty() && p.marked().len() == 2, + "first the search" + ); + press(&mut p, KeyCode::Esc); + assert!( + p.marked().is_empty() && p.decision.is_none(), + "then every mark" + ); + press(&mut p, KeyCode::Esc); + assert_eq!(p.decision, Some(Choice::Quit)); +} diff --git a/src/site_analysis/pileup/tests/stack.rs b/src/site_analysis/pileup/tests/stack.rs index 61761d2..61b9931 100644 --- a/src/site_analysis/pileup/tests/stack.rs +++ b/src/site_analysis/pileup/tests/stack.rs @@ -147,3 +147,49 @@ fn every_gene_redraws_when_the_scale_or_bars_change() { "the gene without the keys redraws too" ); } + +#[test] +fn bar_cycles_the_layout_and_the_figure_follows() { + let (a, b) = (positions(1.0), positions(3.0)); + let mut s = stack(&a, &b); + let width = |svg: &str| -> f64 { + let at = svg.find(" width=\"").unwrap() + 8; + svg[at..].split('"').next().unwrap().parse().unwrap() + }; + let one = width(&s.views[0].figure()); + assert_eq!(width(&s.figure()), one, "one under another"); + press(&mut s, KeyCode::Char('|')); + assert_eq!(s.arrange, Arrange::Row); + assert_eq!(width(&s.figure()), 2.0 * one, "side by side"); + // Side by side on screen, too: GENE2 to the right of GENE1, same rows. + let text = screen(&mut s, 160, 30); + let row_of = |i: usize| i / 160; + let (g1, g2) = (text.find("GENE1").unwrap(), text.find("GENE2").unwrap()); + assert_eq!(row_of(g1), row_of(g2), "{text}"); + press(&mut s, KeyCode::Char('|')); + assert_eq!(s.arrange, Arrange::Grid); + press(&mut s, KeyCode::Char('|')); + assert_eq!(s.arrange, Arrange::Column); +} + +#[test] +fn a_click_gives_the_gene_the_keys_and_moves_its_cursor() { + use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + let (a, b) = (positions(1.0), positions(3.0)); + let mut s = stack(&a, &b); + screen(&mut s, 100, 40); + let lower = s.views[1].drawn; + let x = s.views[1].chart_x + 40; + let click = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: x, + row: lower.y + 3, + modifiers: KeyModifiers::NONE, + }; + let before = s.views[1].cursor; + assert!(s.mouse(click)); + assert_eq!(s.focus, 1, "the clicked gene has the keys"); + assert_ne!(s.views[1].cursor, before, "its cursor moved to the bar"); + let (lo, hi) = s.views[1].bar_range(40); + assert!((lo..=hi).contains(&s.views[1].cursor)); +} diff --git a/src/site_analysis/pileup/tui.rs b/src/site_analysis/pileup/tui.rs index 6c3523a..37b2235 100644 --- a/src/site_analysis/pileup/tui.rs +++ b/src/site_analysis/pileup/tui.rs @@ -139,6 +139,10 @@ pub struct PileupView<'a> { y_lock: Option, /// The header's badge: `pileup`, or in a stack, which gene has the keys. badge: &'static str, + /// Where the last frame drew the view, and the column its bars start + /// at, for clicks. + drawn: Rect, + chart_x: u16, exit: Option, } @@ -198,6 +202,8 @@ impl<'a> PileupView<'a> { switchable: false, y_lock: None, badge: "pileup", + drawn: Rect::default(), + chart_x: 0, exit: None, } } @@ -221,6 +227,19 @@ impl<'a> PileupView<'a> { } } + /// A click at screen column `x`: the cursor to the bar there, when it + /// is over the bars; whether it moved. + pub(super) fn click_at(&mut self, x: u16) -> bool { + let col = usize::from(x.saturating_sub(self.chart_x)); + if x < self.chart_x || col >= self.columns { + return false; + } + let (start, stop) = self.bar_range(col); + self.cursor = (start + (stop - start) / 2).clamp(self.extent.0, self.extent.1); + self.plots.iter_mut().for_each(PlotImage::invalidate); + true + } + /// How this view draws, to keep for the next gene. pub fn settings(&self) -> Settings { Settings { @@ -732,6 +751,16 @@ impl crate::tui::View for PileupView<'_> { } impl Screen for PileupView<'_> { + fn takes_mouse(&self) -> bool { + true + } + + /// A click on the bars moves the cursor there. + fn mouse(&mut self, m: ratatui::crossterm::event::MouseEvent) -> bool { + use ratatui::crossterm::event::MouseEventKind; + matches!(m.kind, MouseEventKind::Down(_)) && self.click_at(m.column) + } + fn done(&self) -> bool { self.exit.is_some() } @@ -829,6 +858,8 @@ impl PileupView<'_> { let inner_width = areas[1].width.saturating_sub(2 + GUTTER); self.columns = (inner_width as usize).max(1); + // Inside the panel's border, past the y axis. + (self.drawn, self.chart_x) = (area, areas[1].x + 1 + GUTTER); self.clamp_window(); let (lo, hi) = self.window; let extra = format!( diff --git a/src/site_analysis/pileup/tui/stack.rs b/src/site_analysis/pileup/tui/stack.rs index 053dc6d..4dbfea8 100644 --- a/src/site_analysis/pileup/tui/stack.rs +++ b/src/site_analysis/pileup/tui/stack.rs @@ -15,10 +15,49 @@ use crate::figure::{status_line, Controls, Key}; /// What a stack says when a key needs one gene to itself. const ONE_GENE: &str = "that works on one gene: g goes back to the list to open it alone"; +/// How the genes are laid out (`|` cycles), on screen and in the figure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Arrange { + /// One under another. + Column, + /// Side by side. + Row, + /// About as many across as down. + Grid, +} + +impl Arrange { + fn next(self) -> Self { + match self { + Arrange::Column => Arrange::Row, + Arrange::Row => Arrange::Grid, + Arrange::Grid => Arrange::Column, + } + } + + /// Genes to a row, of `n`. + fn cols(self, n: usize) -> usize { + match self { + Arrange::Column => 1, + Arrange::Row => n.max(1), + Arrange::Grid => (n as f64).sqrt().ceil().max(1.0) as usize, + } + } + + fn name(self) -> &'static str { + match self { + Arrange::Column => "one under another", + Arrange::Row => "side by side", + Arrange::Grid => "a grid", + } + } +} + pub struct StackView<'a> { views: Vec>, /// The view the keys go to. focus: usize, + arrange: Arrange, /// Saving the whole stack, and images for all. controls: Controls, status: Option, @@ -31,6 +70,7 @@ impl<'a> StackView<'a> { let mut stack = Self { views, focus: 0, + arrange: Arrange::Column, controls, status: None, exit: None, @@ -59,10 +99,10 @@ impl<'a> StackView<'a> { } } - /// The genes' figures, top to bottom, as one. + /// The genes' figures as one, laid out as on screen. pub(super) fn figure(&self) -> String { let figures: Vec = self.views.iter().map(PileupView::figure).collect(); - crate::figure::stack_svgs(&figures) + crate::figure::tile_svgs(&figures, self.arrange.cols(figures.len())) } pub(super) fn footer_line(&self) -> Line<'static> { @@ -74,7 +114,8 @@ impl<'a> StackView<'a> { } let v = &self.views[self.focus]; let mut keys = vec![ - ("Tab", "next gene"), + ("Tab/click", "next gene"), + ("|", self.arrange.next().name()), ("←/→", "bar"), ("n/N", "next/prev site"), ("+/-", "zoom"), @@ -106,6 +147,28 @@ impl crate::tui::View for StackView<'_> { } impl Screen for StackView<'_> { + fn takes_mouse(&self) -> bool { + true + } + + /// A click gives the gene under it the keys, and over its bars moves + /// its cursor there. + fn mouse(&mut self, m: ratatui::crossterm::event::MouseEvent) -> bool { + use ratatui::crossterm::event::MouseEventKind; + if !matches!(m.kind, MouseEventKind::Down(_)) { + return false; + } + let at = ratatui::layout::Position::new(m.column, m.row); + let Some(i) = self.views.iter().position(|v| v.drawn.contains(at)) else { + return false; + }; + self.focus = i; + self.views[i].click_at(m.column); + self.status = None; + self.mark_focus(); + true + } + fn done(&self) -> bool { self.exit.is_some() } @@ -134,6 +197,14 @@ impl Screen for StackView<'_> { let n = self.views.len(); match key.code { KeyCode::Tab => self.focus = (self.focus + 1) % n, + KeyCode::Char('|') => { + self.arrange = self.arrange.next(); + for v in &mut self.views { + v.plots + .iter_mut() + .for_each(crate::figure::term::PlotImage::invalidate); + } + } KeyCode::BackTab => self.focus = (self.focus + n - 1) % n, KeyCode::Char('q') => self.exit = Some(Exit::Quit), KeyCode::Char('g') | KeyCode::Esc => self.exit = Some(Exit::Genes), @@ -166,14 +237,21 @@ impl Screen for StackView<'_> { fn render(&mut self, frame: &mut Frame) { let area = frame.area(); let help = crate::tui::wrap_help(self.footer_line(), area.width); - let n = self.views.len() as u32; - let mut layout: Vec = (0..n).map(|_| Constraint::Ratio(1, n)).collect(); - layout.push(Constraint::Length(help.len() as u16)); - let areas = Layout::vertical(layout).split(area); - for (v, &a) in self.views.iter_mut().zip(areas.iter()) { - v.render_in(frame, a, false); + let [body, footer] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(help.len() as u16)]) + .areas(area); + let n = self.views.len(); + let cols = self.arrange.cols(n); + let rows = n.div_ceil(cols); + let row_areas = Layout::vertical(vec![Constraint::Ratio(1, rows as u32); rows]).split(body); + for (r, chunk) in self.views.chunks_mut(cols).enumerate() { + let cells = Layout::horizontal(vec![Constraint::Ratio(1, cols as u32); cols]) + .split(row_areas[r]); + for (v, &cell) in chunk.iter_mut().zip(cells.iter()) { + v.render_in(frame, cell, false); + } } - frame.render_widget(Paragraph::new(help), areas[areas.len() - 1]); + frame.render_widget(Paragraph::new(help), footer); self.controls.render_save(frame, area); } } From f3fc21534b786cea34e8cd3c3aff5420e5ae987b Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 12:35:00 -0700 Subject: [PATCH 4/5] pileup: text plots on screen, turned position labels, the whole gene body, thicker exons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The browser and the stack draw text plots only; the saved figure is the picture (no image mode, no i). - Saved position ticks are turned 90° counter-clockwise (Bars, Mirror and Diverging take rotated); panels grow so the bars keep their height. - A gene opens on its whole body, TSS to TES, once the annotation has arrived (unless already zoomed), rather than on its sites' span. - Exons draw as full blocks on screen and a taller band in figures. --- docs/profiling-methods.md | 5 +- src/figure/mod.rs | 79 ++++++++++++++++++++++----- src/figure/tests/figure.rs | 2 + src/qc/site_tui/draw.rs | 2 + src/site_analysis/metagene/tui.rs | 1 + src/site_analysis/pileup/tests/tui.rs | 29 +++++++++- src/site_analysis/pileup/tui.rs | 21 +++++-- src/site_analysis/pileup/tui/stack.rs | 3 +- 8 files changed, 119 insertions(+), 23 deletions(-) diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md index 0ee4672..d688f29 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -660,8 +660,9 @@ the terminal draws images; the list is kept in `.faba-view/` there, so it lasts Saves are PDF only. A name already taken is never overwritten: the prompt offers the next free one (`name_2`, …). -In the `pileup` browser, how the bars are drawn (the y scale, what `c` counts, mirrored or split, -the contrast measure, images or text) carries from one gene to the next. `Y` locks the y-axis +In the `pileup` browser, plots are always drawn as text, and only a saved figure is a picture: a +PDF, its position labels turned to read bottom to top. How the bars are drawn (the y scale, what `c` +counts, mirrored or split, the contrast measure) carries from one gene to the next. `Y` locks the y-axis maximum for every gene opened after, so they compare; a gene taller than the lock still shows its bars whole, its axis raised to fit. In the gene list, Space marks genes and Ctrl+R opens the marked ones stacked top to bottom, each with its tracks: Tab (or a click on a gene) moves the keys from diff --git a/src/figure/mod.rs b/src/figure/mod.rs index c2b82ca..8c050ef 100644 --- a/src/figure/mod.rs +++ b/src/figure/mod.rs @@ -132,6 +132,17 @@ impl Canvas { ); } + /// Text turned 90° counter-clockwise, reading bottom to top, anchored + /// at `(x, y)` as `anchor` says along its length. + pub fn rotated(&mut self, x: f64, y: f64, s: &str, size: f64, anchor: Anchor, fill: &str) { + let _ = writeln!( + self.body, + r#"{}"#, + anchor.svg(), + esc(s) + ); + } + pub fn bold(&mut self, x: f64, y: f64, s: &str, size: f64, anchor: Anchor, fill: &str) { let _ = writeln!( self.body, @@ -281,11 +292,14 @@ pub struct Bars<'a> { pub title: String, pub x_title: String, pub y_title: String, + /// Tick labels turned 90° counter-clockwise, for long ones (positions). + pub rotated: bool, } impl Bars<'_> { pub fn draw(&self, c: &mut Canvas, x: f64, y: f64, w: f64, h: f64) { - let (left, right, top, bottom) = (52.0, 12.0, 20.0, 38.0); + let (left, right, top) = (52.0, 12.0, 20.0); + let bottom = 38.0 + tick_room(&self.ticks, self.rotated); let (px, py, pw, ph) = (x + left, y + top, w - left - right, h - top - bottom); c.bold(x + left, y + 13.0, &self.title, 11.0, Anchor::Start, INK); let n = self.values.len().max(1); @@ -360,13 +374,8 @@ impl Bars<'_> { let dx = px + d as f64 * bw; c.dashed(dx, py, dx, py + ph + 4.0, MUTED, 0.8); } - for (i, label) in &self.ticks { - let tx = px + (*i as f64 + 0.5) * bw; - if self.dividers.is_empty() { - c.line(tx, py + ph, tx, py + ph + 4.0, INK, 0.6); - } - c.text(tx, py + ph + 13.0, label, 8.0, Anchor::Middle, INK); - } + let stub = self.dividers.is_empty(); + x_ticks(c, &self.ticks, (px, bw, py + ph), stub, self.rotated); c.text( px + pw / 2.0, y + h - 6.0, @@ -389,6 +398,8 @@ pub struct Diverging<'a> { pub y_title: String, /// Label for a value on the y axis. pub label: &'a dyn Fn(f64) -> String, + /// Tick labels turned, as [`Bars::rotated`]. + pub rotated: bool, } impl Diverging<'_> { @@ -406,11 +417,52 @@ impl Diverging<'_> { title: self.title.clone(), x_title: self.x_title.clone(), y_title: self.y_title.clone(), + rotated: self.rotated, } .draw(c, x, y, w, h); } } +/// Height of an 8-point character's advance, roughly, for laying out +/// turned labels. +const CHAR_W: f64 = 4.6; + +/// Room below the axis that turned tick labels take beyond flat ones. +pub(crate) fn tick_room(ticks: &[(usize, String)], rotated: bool) -> f64 { + if !rotated { + return 0.0; + } + let longest = ticks + .iter() + .map(|(_, l)| l.chars().count()) + .max() + .unwrap_or(0); + (longest as f64 * CHAR_W - 6.0).max(0.0) +} + +/// The x ticks under the axis at `base`, bar `i` at `px + (i + ½)·bw`: +/// a stub (with `stub`) and the label, flat and centred, or turned 90° +/// counter-clockwise to end just under the stub. +fn x_ticks( + c: &mut Canvas, + ticks: &[(usize, String)], + (px, bw, base): (f64, f64, f64), + stub: bool, + rotated: bool, +) { + for (i, label) in ticks { + let tx = px + (*i as f64 + 0.5) * bw; + if stub { + c.line(tx, base, tx, base + 4.0, INK, 0.6); + } + if rotated { + c.rotated(tx + 3.0, base + 6.0, label, 8.0, Anchor::End, INK); + } else { + c.text(tx, base + 13.0, label, 8.0, Anchor::Middle, INK); + } + } +} + /// Signed values split into their positive and negative parts, as sizes; /// `None` is zero on both. pub fn split_signed(values: &[Option]) -> (Vec, Vec) { @@ -457,11 +509,14 @@ pub struct Mirror<'a> { pub title: String, pub x_title: String, pub y_title: String, + /// Tick labels turned, as [`Bars::rotated`]. + pub rotated: bool, } impl Mirror<'_> { pub fn draw(&self, c: &mut Canvas, x: f64, y: f64, w: f64, h: f64) { - let (left, right, top, bottom) = (52.0, 12.0, 20.0, 38.0); + let (left, right, top) = (52.0, 12.0, 20.0); + let bottom = 38.0 + tick_room(&self.ticks, self.rotated); let (px, py, pw, ph) = (x + left, y + top, w - left - right, h - top - bottom); c.bold(x + left, y + 13.0, &self.title, 11.0, Anchor::Start, INK); let n = self.up.values.len().max(self.down.values.len()).max(1); @@ -527,11 +582,7 @@ impl Mirror<'_> { c.line(px, zero, px + pw, zero, INK, 0.6); c.line(px, py, px, py + ph, INK, 0.6); let base = py + ph; - for (i, label) in &self.ticks { - let tx = px + (*i as f64 + 0.5) * bw; - c.line(tx, base, tx, base + 4.0, INK, 0.6); - c.text(tx, base + 13.0, label, 8.0, Anchor::Middle, INK); - } + x_ticks(c, &self.ticks, (px, bw, base), true, self.rotated); c.text( px + pw / 2.0, y + h - 6.0, diff --git a/src/figure/tests/figure.rs b/src/figure/tests/figure.rs index f787449..80e2ba7 100644 --- a/src/figure/tests/figure.rs +++ b/src/figure/tests/figure.rs @@ -10,6 +10,7 @@ fn panel() -> String { let front = [0.0, 1.0, 8.0, 4.0, 0.0]; let mut c = Canvas::new(300.0, 160.0); Bars { + rotated: false, values: &values, front: Some(&front), accent: &|i| i < 2, @@ -105,6 +106,7 @@ fn empty_shapes_are_not_written() { c.rect(1.0, 1.0, 10.0, f64::NAN, INK); let values = [Some(0.0), Some(1.5), None, Some(-0.5), Some(0.0)]; Diverging { + rotated: false, values: &values, ticks: Vec::new(), pointer: None, diff --git a/src/qc/site_tui/draw.rs b/src/qc/site_tui/draw.rs index d421a2c..2f8d772 100644 --- a/src/qc/site_tui/draw.rs +++ b/src/qc/site_tui/draw.rs @@ -123,6 +123,7 @@ fn kept_bars( y_title: &str, ) { Bars { + rotated: false, values: kept, front: None, accent: &|_| false, @@ -546,6 +547,7 @@ impl<'a> SitePicker<'a> { let values: Vec = col.hist.counts.iter().map(|&v| v as f64).collect(); let front: Vec = self.tally.subset.iter().map(|&v| v as f64).collect(); Bars { + rotated: false, values: &values, front: Some(&front), accent: &dropped, diff --git a/src/site_analysis/metagene/tui.rs b/src/site_analysis/metagene/tui.rs index 5779c3f..0c27d6a 100644 --- a/src/site_analysis/metagene/tui.rs +++ b/src/site_analysis/metagene/tui.rs @@ -273,6 +273,7 @@ impl<'a> MetageneView<'a> { .filter(|&i| i > 0) .collect(); Bars { + rotated: false, values: &values, front: front.as_deref(), accent: &|_| false, diff --git a/src/site_analysis/pileup/tests/tui.rs b/src/site_analysis/pileup/tests/tui.rs index d22d6fb..a6be0bd 100644 --- a/src/site_analysis/pileup/tests/tui.rs +++ b/src/site_analysis/pileup/tests/tui.rs @@ -358,7 +358,7 @@ fn genes_row_stacks_overlapping_genes() { let text = screen(&mut v, 110, 34); assert!(text.contains("GENE1") && text.contains("GENE2"), "{text}"); assert!( - text.contains('━') && text.contains('›') && text.contains('‹'), + text.contains('█') && text.contains('›') && text.contains('‹'), "{text}" ); let svg = v.figure(); @@ -445,6 +445,10 @@ fn the_genes_model_widens_the_view_to_its_tss_and_tes() { ])); v.tick(); assert_eq!(v.extent, (950_000, 1_149_999), "GENE1 only, TSS to TES"); + assert_eq!( + v.window, v.extent, + "not zoomed yet: it opens on the whole gene" + ); press(&mut v, KeyCode::Char('0')); assert_eq!(v.window, v.extent, "zooming all the way out shows it"); } @@ -578,3 +582,26 @@ fn how_bars_draw_carries_to_the_next_gene() { press(&mut next, KeyCode::Char('Y')); assert!(next.y_lock.is_none(), "Y again unlocks"); } + +#[test] +fn the_browser_draws_text_and_saves_turned_position_labels() { + let (m, s) = (positions(), sites()); + let view = View { + title: "GENE1", + chr: "chr1", + extent: EXTENT, + on: "methylated", + off: "unmethylated", + keys: None, + genes: None, + cursor: None, + switchable: false, + }; + let tracks = vec![Track::single("matrix", "sum", &m, false)]; + let mut b = browser(view, tracks, None); + assert!(b.controls.images().is_none(), "no image mode in pileup"); + let text = screen(&mut b, 100, 24); + assert!(!text.contains("image/text"), "{text}"); + let _ = s; + assert!(b.figure().contains("rotate(-90)"), "position labels turned"); +} diff --git a/src/site_analysis/pileup/tui.rs b/src/site_analysis/pileup/tui.rs index 37b2235..2fef5d1 100644 --- a/src/site_analysis/pileup/tui.rs +++ b/src/site_analysis/pileup/tui.rs @@ -69,9 +69,9 @@ const LANE_GAP: f64 = 0.08; const MAX_LANES: usize = 4; /// Exon height and lane pitch of the gene models in figures. -const GENE_BAND: f64 = 8.0; +const GENE_BAND: f64 = 12.0; -const GENE_LANE: f64 = 18.0; +const GENE_LANE: f64 = 22.0; /// How the user left the browser. #[derive(Debug, Clone, PartialEq, Eq)] @@ -268,10 +268,15 @@ impl<'a> PileupView<'a> { // (no keys) keeps its bounds. if self.keys.is_some() { let (lo, hi) = self.extent; + // Not zoomed yet: the window follows to the whole gene. + let whole = self.window == self.extent; let here = drawn.iter().filter(|g| chr_eq(&g.chr, &self.chr)); self.extent = here .filter(|g| g.hi > lo && g.lo <= hi) .fold((lo, hi), |(a, b), g| (a.min(g.lo), b.max(g.hi - 1))); + if whole { + self.window = self.extent; + } } let (lo, hi) = self.extent; self.genes = drawn @@ -572,7 +577,8 @@ impl<'a> PileupView<'a> { /// The visible window as a figure, one panel per track. fn figure(&self) -> String { let (lo, hi) = self.window; - let panel_h = 170.0; + // Turned position labels take room under the axis: the bars keep theirs. + let panel_h = 170.0 + figure::tick_room(&self.tick_list(), true); let rows = self.rows(); let genes_h = 24.0 + GENE_LANE * self.lanes() as f64; let heights: Vec = rows @@ -626,6 +632,7 @@ impl<'a> PileupView<'a> { let shown = &bins.shown[i]; let marks = t.sites_in(lo, hi).into_iter(); Bars { + rotated: true, values: &shown.values, front: shown.front.as_deref(), accent: &|_| shown.accent, @@ -646,6 +653,7 @@ impl<'a> PileupView<'a> { let values = self.contrast_values(contrast, bins); let measure = contrast.measure; figure::Diverging { + rotated: true, values: &values, ticks: self.tick_list(), pointer, @@ -673,6 +681,7 @@ impl<'a> PileupView<'a> { } }; figure::Mirror { + rotated: true, up: half(a), down: half(b), y_scale: self.y_scale, @@ -1013,7 +1022,8 @@ impl PileupView<'_> { continue; } for x in col(es)..=col(ee - 1).min(right - 1) { - buf[(x, y)].set_symbol("━").set_style(PLAIN); + // A full block: an exon reads thick against the intron line. + buf[(x, y)].set_symbol("█").set_style(PLAIN); } } // The symbol after the gene if it fits, else before it. @@ -1097,7 +1107,8 @@ fn browser<'a>(view: View<'a>, tracks: Vec>, status: Option) - let loading = browser.pending_genes.is_some(); loading.then(|| LOADING.to_string()) }); - browser.controls = Controls::new(&format!("pileup_{}", view.title)).detect(); + // Text plots on screen; only the saved figure is drawn as a picture. + browser.controls = Controls::new(&format!("pileup_{}", view.title)); browser } diff --git a/src/site_analysis/pileup/tui/stack.rs b/src/site_analysis/pileup/tui/stack.rs index 4dbfea8..0f2cbdf 100644 --- a/src/site_analysis/pileup/tui/stack.rs +++ b/src/site_analysis/pileup/tui/stack.rs @@ -279,7 +279,8 @@ pub fn show_stack(views: Vec<(View, Vec)>, settings: &mut Settings) -> an .collect(); anyhow::ensure!(!views.is_empty(), "no gene to show"); let mut stack = StackView::new(views); - stack.controls = Controls::new(&save_name(&stack.views)).detect(); + // Text plots on screen, as one gene's browser; the saved figure is a picture. + stack.controls = Controls::new(&save_name(&stack.views)); if let Some(on) = settings.images { stack.controls.set_images(on); } From 2fad1e7db936f8616778ae826fba7cad29f014b2 Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 12:38:15 -0700 Subject: [PATCH 5/5] pileup stack: shifted zoom keys act on every gene = and - zoom the gene with the keys; + and _ (Shift) zoom every gene, and ) (Shift+0) shows every gene whole. --- docs/profiling-methods.md | 4 +++- src/site_analysis/pileup/tests/stack.rs | 24 ++++++++++++++++++++++++ src/site_analysis/pileup/tui/stack.rs | 21 ++++++++++++++++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md index d688f29..8bc919b 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -667,7 +667,9 @@ maximum for every gene opened after, so they compare; a gene taller than the loc bars whole, its axis raised to fit. In the gene list, Space marks genes and Ctrl+R opens the marked ones stacked top to bottom, each with its tracks: Tab (or a click on a gene) moves the keys from gene to gene, a click on the bars moves that gene's cursor there, how the bars are drawn applies to -all of them, `Y` locks every gene to the tallest one's axis, `|` lays them out one under another, +all of them, `=`/`-` zoom the gene with the keys while their shifted keys `+`/`_` zoom every +gene (and `)` shows every gene whole, as `0` does one), `Y` locks every gene to the tallest one's +axis, `|` lays them out one under another, side by side, or in a grid, and `s` saves them as one PDF laid out as on screen. Esc in the list clears the search, then every mark, then leaves. A search, the other modality or zooming out past a gene needs one gene on its own: `g` (or Esc) goes back to the list, where the marks are kept. diff --git a/src/site_analysis/pileup/tests/stack.rs b/src/site_analysis/pileup/tests/stack.rs index 61b9931..e1b836b 100644 --- a/src/site_analysis/pileup/tests/stack.rs +++ b/src/site_analysis/pileup/tests/stack.rs @@ -193,3 +193,27 @@ fn a_click_gives_the_gene_the_keys_and_moves_its_cursor() { let (lo, hi) = s.views[1].bar_range(40); assert!((lo..=hi).contains(&s.views[1].cursor)); } + +#[test] +fn shifted_zoom_keys_zoom_every_gene() { + let (a, b) = (positions(1.0), positions(3.0)); + let mut s = stack(&a, &b); + screen(&mut s, 100, 40); + let whole: Vec<_> = s.views.iter().map(|v| v.window).collect(); + press(&mut s, KeyCode::Char('=')); + assert_ne!( + s.views[0].window, whole[0], + "= zooms the gene with the keys" + ); + assert_eq!(s.views[1].window, whole[1], "and only it"); + press(&mut s, KeyCode::Char('+')); + assert_ne!(s.views[1].window, whole[1], "+ zooms every gene"); + press(&mut s, KeyCode::Char(')')); + let now: Vec<_> = s.views.iter().map(|v| v.window).collect(); + assert_eq!(now, whole, ") shows every gene whole"); + press(&mut s, KeyCode::Char('+')); + press(&mut s, KeyCode::Char('_')); + let now: Vec<_> = s.views.iter().map(|v| v.window).collect(); + assert_eq!(now, whole, "_ zooms every gene back out"); + assert!(s.exit.is_none()); +} diff --git a/src/site_analysis/pileup/tui/stack.rs b/src/site_analysis/pileup/tui/stack.rs index 0f2cbdf..9b10037 100644 --- a/src/site_analysis/pileup/tui/stack.rs +++ b/src/site_analysis/pileup/tui/stack.rs @@ -118,8 +118,10 @@ impl<'a> StackView<'a> { ("|", self.arrange.next().name()), ("←/→", "bar"), ("n/N", "next/prev site"), - ("+/-", "zoom"), + ("=/-", "zoom"), + ("+/_", "zoom all"), ("0", "whole"), + (")", "all whole"), ("y", "scale"), ( "Y", @@ -210,6 +212,23 @@ impl Screen for StackView<'_> { KeyCode::Char('g') | KeyCode::Esc => self.exit = Some(Exit::Genes), // A search or the other modality opens one gene, not a stack. KeyCode::Char('/' | 'm' | 'M') => self.status = Some(ONE_GENE.into()), + // Shifted, the zoom keys act on every gene: `+` (Shift+=) in, + // `_` (Shift+-) out, `)` (Shift+0) each whole. + KeyCode::Char(c @ ('+' | '_' | ')')) => { + let one = match c { + '+' => '=', + '_' => '-', + _ => '0', + }; + let key = KeyEvent::new(KeyCode::Char(one), key.modifiers); + for v in &mut self.views { + v.handle_key(key); + // Out past a gene would reload one gene's span alone. + if v.exit.take().is_some() { + self.status = Some(ONE_GENE.into()); + } + } + } // One y axis for every gene: the tallest of them, as a locked // maximum only ever raises an axis (no bar is cut off). KeyCode::Char('Y') if self.views[self.focus].y_lock.is_none() => {