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..8bc919b 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -657,7 +657,22 @@ 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, 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 +gene to gene, a click on the bars moves that gene's cursor there, how the bars are drawn applies to +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. 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 +751,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..8c050ef 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()); @@ -91,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!( @@ -134,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, @@ -206,6 +215,36 @@ impl Canvas { } } +/// 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 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 row in svgs.chunks(cols) { + for (k, svg) in row.iter().enumerate() { + // Each nested at its offset, as it was drawn. + let x = cell * k as f64; + 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); @@ -253,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); @@ -332,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, @@ -361,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<'_> { @@ -378,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) { @@ -429,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); @@ -499,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, @@ -539,6 +618,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 +651,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)?]) +} + +/// 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 the picture the PNG holds. +/// [`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 +815,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 +951,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 +1033,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/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 fdd08ac..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, @@ -61,16 +62,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] @@ -106,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, @@ -184,3 +185,37 @@ 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(" 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#" 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/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/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.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..18a9a47 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 { @@ -204,6 +229,10 @@ impl Screen for GenePicker<'_> { 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(""), @@ -211,6 +240,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,7 +269,25 @@ impl Screen for GenePicker<'_> { ("↑/↓", "move"), ("PgUp/PgDn", "page"), ("Enter", "open"), - ("Esc", "clear/quit"), + ("Space", "mark"), + ( + crate::tui::APPLY_KEYS, + if self.marked.is_empty() { + "open" + } else { + "open the marked together" + }, + ), + ( + "Esc", + if !self.filter.is_empty() { + "clear search" + } else if !self.marked.is_empty() { + "clear all marks" + } else { + "quit" + }, + ), ], ), }; @@ -244,7 +302,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 +332,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..a78bd19 100644 --- a/src/site_analysis/pileup/tests/picker.rs +++ b/src/site_analysis/pileup/tests/picker.rs @@ -119,7 +119,62 @@ 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}"); } } + +#[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))); +} + +#[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 new file mode 100644 index 0000000..e1b836b --- /dev/null +++ b/src/site_analysis/pileup/tests/stack.rs @@ -0,0 +1,219 @@ +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" + ); +} + +#[test] +fn every_gene_redraws_when_the_scale_or_bars_change() { + let (a, b) = (positions(1.0), positions(3.0)); + let mut s = stack(&a, &b); + screen(&mut s, 100, 40); + // Drawn once: no plot waits to be redrawn. + for v in &mut s.views { + v.plots.iter_mut().for_each(|p| *p = Default::default()); + } + press(&mut s, KeyCode::Tab); + press(&mut s, KeyCode::Char('y')); + let other = &s.views[0]; + assert_ne!(other.y_scale, Scale::Linear); + assert!( + other + .plots + .iter() + .all(crate::figure::term::PlotImage::is_stale), + "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)); +} + +#[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/tests/tui.rs b/src/site_analysis/pileup/tests/tui.rs index 9f0ac89..a6be0bd 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] @@ -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"); } @@ -560,3 +564,44 @@ 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"); +} + +#[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 dd6737f..2fef5d1 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; @@ -67,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)] @@ -133,9 +135,29 @@ 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, + /// Where the last frame drew the view, and the column its bars start + /// at, for clicks. + drawn: Rect, + chart_x: u16, 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, PartialEq)] +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 +200,58 @@ impl<'a> PileupView<'a> { genes: Vec::new(), pending_genes: None, switchable: false, + y_lock: None, + badge: "pileup", + drawn: Rect::default(), + chart_x: 0, exit: None, } } + /// 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); + 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); + } + if self.settings() != before { + self.plots.iter_mut().for_each(PlotImage::invalidate); + } + } + + /// 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 { + 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 { @@ -198,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 @@ -321,7 +396,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)), } } @@ -502,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 @@ -556,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, @@ -576,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, @@ -603,6 +681,7 @@ impl<'a> PileupView<'a> { } }; figure::Mirror { + rotated: true, up: half(a), down: half(b), y_scale: self.y_scale, @@ -681,6 +760,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() } @@ -733,6 +822,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 +838,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 +856,36 @@ 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); + // 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!( - "{}:{}-{} {} 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 +986,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()); + } } } @@ -905,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. @@ -945,6 +1063,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 +1091,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; @@ -982,8 +1107,25 @@ pub fn show_pileup(view: View, tracks: Vec, status: Option) -> an 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 +} + +/// 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..9b10037 --- /dev/null +++ b/src/site_analysis/pileup/tui/stack.rs @@ -0,0 +1,314 @@ +//! 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"; + +/// 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, + 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, + arrange: Arrange::Column, + 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 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::tile_svgs(&figures, self.arrange.cols(figures.len())) + } + + 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/click", "next gene"), + ("|", self.arrange.next().name()), + ("←/→", "bar"), + ("n/N", "next/prev site"), + ("=/-", "zoom"), + ("+/_", "zoom all"), + ("0", "whole"), + (")", "all 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 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() + } + + 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::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), + // 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() => { + 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 [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), footer); + 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); + // 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); + } + 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()); }