From 987643fd181cf1004f9659dbc913c074913133ec Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 13:09:03 -0700 Subject: [PATCH 1/4] Saved figures take their view's shape; drag and wheel pan pileup (0.16.9) - A figure saved from pileup (and each gene of a stack), metagene or pwm is sized from the area its view takes on screen, 6 x 12 points a cell (figure::page), so it keeps the view's aspect and its text sits about as large against the plots as the terminal's does. - In pileup, a press on the bars and a release elsewhere pans the window as if grabbed (a release in place is a click); the wheel and sideways swipes pan a tenth of the view a notch. In a stack, on the gene under the pointer. --- Cargo.toml | 2 +- docs/profiling-methods.md | 7 ++- src/figure/mod.rs | 16 +++++ src/site_analysis/metagene/tui.rs | 13 ++++- src/site_analysis/pileup/tests/stack.rs | 65 +++++++++++++++++++++ src/site_analysis/pileup/tui.rs | 78 +++++++++++++++++++++++-- src/site_analysis/pileup/tui/stack.rs | 32 ++++++---- src/site_analysis/scan_pwm/tui.rs | 14 ++++- 8 files changed, 200 insertions(+), 27 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3402830..a2207fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "faba" -version = "0.16.8" +version = "0.16.9" edition = "2021" rust-version = "1.91" license = "MIT" diff --git a/docs/profiling-methods.md b/docs/profiling-methods.md index 8bc919b..2be4926 100644 --- a/docs/profiling-methods.md +++ b/docs/profiling-methods.md @@ -658,7 +658,9 @@ with no tick, since a tick would read as a position. `s`, in every view, asks fo 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. Saves are PDF only. A name already taken is never overwritten: the prompt offers the next free -one (`name_2`, …). +one (`name_2`, …). A figure saved from `pileup`, `metagene` or `pwm` takes the shape its view has on +screen, each terminal cell 6 × 12 points, so its text sits about as large against the plots as the +terminal's does; in a stack, each gene keeps its pane's shape. 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` @@ -666,7 +668,8 @@ counts, mirrored or split, the contrast measure) carries from one gene to the ne 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 +gene to gene, a click on the bars moves that gene's cursor there, a drag (press, move, let go) +pans it as if grabbed, the wheel or a sideways swipe pans the gene under the pointer, 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, diff --git a/src/figure/mod.rs b/src/figure/mod.rs index 8c050ef..21a940e 100644 --- a/src/figure/mod.rs +++ b/src/figure/mod.rs @@ -215,6 +215,22 @@ impl Canvas { } } +/// Points per terminal cell in a figure saved from a view: a cell is about +/// twice as tall as wide, and the figures' 8- to 12-point text about a row +/// high, as the terminal's is. +pub const CELL: (f64, f64) = (6.0, 12.0); + +/// The size in points of a figure saved from a view that took `area` on +/// screen, so it keeps the view's shape; `None` before it was drawn. +pub fn page(area: Rect) -> Option<(f64, f64)> { + (area.width > 0 && area.height > 0).then(|| { + ( + f64::from(area.width) * CELL.0, + f64::from(area.height) * CELL.1, + ) + }) +} + /// 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 diff --git a/src/site_analysis/metagene/tui.rs b/src/site_analysis/metagene/tui.rs index 0c27d6a..d732a53 100644 --- a/src/site_analysis/metagene/tui.rs +++ b/src/site_analysis/metagene/tui.rs @@ -11,7 +11,7 @@ use data_beans::interactive::ui::{header, help_line, panel, Scale, Screen, DIM, HIGHLIGHT}; 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; @@ -127,6 +127,8 @@ fn fitting_merge(hist: &GeneFeatureHistogram, track: Track, width: usize) -> usi /// State of the profile view, independent of the terminal so it can be tested. pub struct MetageneView<'a> { + /// Where the last frame drew the view, for a saved figure's shape. + drawn: Rect, title: String, hist: &'a GeneFeatureHistogram, track: Track, @@ -165,6 +167,7 @@ impl<'a> MetageneView<'a> { Show::Sites }, controls: Controls::new("metagene"), + drawn: Rect::default(), plot: PlotImage::default(), done: false, }; @@ -235,7 +238,10 @@ impl<'a> MetageneView<'a> { /// The shown track as a figure, at the shown merge factor. fn figure(&self) -> String { - let mut c = Canvas::new(680.0, 300.0); + // The view's shape on screen, else a page of its own. + let (w, h) = crate::figure::page(self.drawn).unwrap_or((680.0, 300.0)); + let (w, h) = (w.max(360.0), h.max(200.0)); + let mut c = Canvas::new(w, h); c.bold(16.0, 22.0, &self.title, 12.0, Anchor::Start, INK); let totals: Vec = self .track @@ -252,7 +258,7 @@ impl<'a> MetageneView<'a> { MUTED, ); let title = format!("{} bin(s) per bar", self.shown_merge); - self.draw_track(&mut c, (8.0, 48.0, 664.0, 244.0), None, title); + self.draw_track(&mut c, (8.0, 48.0, w - 16.0, h - 56.0), None, title); c.finish() } @@ -380,6 +386,7 @@ impl Screen for MetageneView<'_> { } fn render(&mut self, frame: &mut Frame) { + self.drawn = frame.area(); let mut keys = vec![ ("←/→", "bar"), ("Home", "first"), diff --git a/src/site_analysis/pileup/tests/stack.rs b/src/site_analysis/pileup/tests/stack.rs index e1b836b..ecf9d06 100644 --- a/src/site_analysis/pileup/tests/stack.rs +++ b/src/site_analysis/pileup/tests/stack.rs @@ -188,6 +188,11 @@ fn a_click_gives_the_gene_the_keys_and_moves_its_cursor() { }; let before = s.views[1].cursor; assert!(s.mouse(click)); + // Let go where it was pressed: a click. + s.mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Left), + ..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); @@ -217,3 +222,63 @@ fn shifted_zoom_keys_zoom_every_gene() { assert_eq!(now, whole, "_ zooms every gene back out"); assert!(s.exit.is_none()); } + +#[test] +fn a_drag_or_the_wheel_pans_the_gene_under_the_pointer() { + 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); + // Zoomed in, so there is room to pan. + press(&mut s, KeyCode::Char('+')); + press(&mut s, KeyCode::Char('+')); + screen(&mut s, 100, 40); + let at = |kind, column, row| MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }; + let v = &s.views[1]; + let (x, y, before, bin) = (v.chart_x + 50, v.drawn.y + 3, v.window, v.bin_width()); + s.mouse(at(MouseEventKind::Down(MouseButton::Left), x, y)); + s.mouse(at(MouseEventKind::Up(MouseButton::Left), x - 10, y)); + assert_eq!(s.focus, 1); + let after = s.views[1].window; + assert_eq!( + after.0 - before.0, + 10 * bin, + "dragged left: the view moves right" + ); + assert_eq!(after.1 - after.0, before.1 - before.0, "the same width"); + + // The wheel pans the gene under it, keys or not. + let top = s.views[0].window; + let y0 = s.views[0].drawn.y + 3; + s.mouse(at(MouseEventKind::ScrollDown, x, y0)); + assert!(s.views[0].window.0 > top.0, "a notch to the right"); + s.mouse(at(MouseEventKind::ScrollLeft, x, y0)); + assert_eq!(s.views[0].window, top, "and back"); +} + +#[test] +fn a_saved_gene_keeps_its_shape_on_screen() { + let (a, b) = (positions(1.0), positions(3.0)); + let mut s = stack(&a, &b); + let size = |svg: &str| { + let get = |k: &str| -> f64 { + let at = svg.find(&format!(" {k}=\"")).unwrap() + k.len() + 3; + svg[at..].split('"').next().unwrap().parse().unwrap() + }; + (get("width"), get("height")) + }; + screen(&mut s, 120, 40); + let tall = size(&s.views[0].figure()); + press(&mut s, KeyCode::Char('|')); + screen(&mut s, 120, 40); + let wide = size(&s.views[0].figure()); + // One under another: each gene is the full width; side by side, half. + assert_eq!(tall.0, 120.0 * crate::figure::CELL.0); + assert_eq!(wide.0, 60.0 * crate::figure::CELL.0); + assert!(wide.1 > tall.1, "side by side, each gene is taller"); +} diff --git a/src/site_analysis/pileup/tui.rs b/src/site_analysis/pileup/tui.rs index 2fef5d1..e89671c 100644 --- a/src/site_analysis/pileup/tui.rs +++ b/src/site_analysis/pileup/tui.rs @@ -143,6 +143,9 @@ pub struct PileupView<'a> { /// at, for clicks. drawn: Rect, chart_x: u16, + /// The column a press on the bars came at, until it is let go: a drag + /// pans, a click moves the cursor. + grabbed: Option, exit: Option, } @@ -204,6 +207,7 @@ impl<'a> PileupView<'a> { badge: "pileup", drawn: Rect::default(), chart_x: 0, + grabbed: None, exit: None, } } @@ -494,6 +498,51 @@ impl<'a> PileupView<'a> { self.window = (lo, lo + w); } + /// Slide the window `bars` bars along, keeping its width and the cursor + /// in it. + fn pan(&mut self, bars: i64) { + let shift = bars * self.bin_width(); + self.window = (self.window.0 + shift, self.window.1 + shift); + self.clamp_window(); + self.cursor = self.cursor.clamp(self.window.0, self.window.1); + self.plots.iter_mut().for_each(PlotImage::invalidate); + } + + /// A press, release or wheel turn on the view: a press on the bars + /// grabs them, and letting go elsewhere pans as far (in the same place, + /// it moves the cursor there); the wheel, either way, pans a tenth of + /// the view a notch. Whether it changed. + pub(super) fn pointer(&mut self, m: ratatui::crossterm::event::MouseEvent) -> bool { + use ratatui::crossterm::event::{MouseButton, MouseEventKind}; + let notch = (self.columns as i64 / 10).max(1); + match m.kind { + MouseEventKind::Down(MouseButton::Left) => { + let over = + m.column >= self.chart_x && usize::from(m.column - self.chart_x) < self.columns; + self.grabbed = over.then_some(m.column); + false + } + MouseEventKind::Up(MouseButton::Left) => match self.grabbed.take() { + // Dragged: the bars follow the pointer, as when grabbed. + Some(from) if from.abs_diff(m.column) >= 2 => { + self.pan(i64::from(from) - i64::from(m.column)); + true + } + Some(from) => self.click_at(from), + None => false, + }, + MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => { + self.pan(-notch); + true + } + MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => { + self.pan(notch); + true + } + _ => false, + } + } + fn move_cursor(&mut self, bars: i64) { let (lo, hi) = self.extent; self.cursor = (self.cursor + bars * self.bin_width()).clamp(lo, hi); @@ -578,15 +627,32 @@ impl<'a> PileupView<'a> { fn figure(&self) -> String { let (lo, hi) = self.window; // 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 room = figure::tick_room(&self.tick_list(), true); let rows = self.rows(); let genes_h = 24.0 + GENE_LANE * self.lanes() as f64; + let n_plots = rows.iter().filter(|&&r| r != Row::Genes).count().max(1) as f64; + // The shape the view has on screen, its plots sharing what the title + // and the genes leave; else a page of its own. + let (width, panel_h) = match figure::page(self.drawn) { + Some((w, h)) => { + let genes = if rows.contains(&Row::Genes) { + genes_h + } else { + 0.0 + }; + ( + w.max(360.0), + ((h - 50.0 - genes) / n_plots).max(100.0 + room), + ) + } + None => (720.0, 170.0 + room), + }; let heights: Vec = rows .iter() .map(|&r| if r == Row::Genes { genes_h } else { panel_h }) .collect(); let total: f64 = heights.iter().sum(); - let mut c = Canvas::new(720.0, 50.0 + total); + let mut c = Canvas::new(width, 50.0 + total); c.bold(16.0, 22.0, &self.title, 12.0, Anchor::Start, INK); c.text( 16.0, @@ -606,7 +672,7 @@ impl<'a> PileupView<'a> { let mut y = 44.0; for (&row, &h) in rows.iter().zip(&heights) { let title = self.row_title(row); - self.draw_row(row, &bins, &mut c, (8.0, y, 704.0, h), None, title); + self.draw_row(row, &bins, &mut c, (8.0, y, width - 16.0, h), None, title); y += h; } @@ -764,10 +830,10 @@ impl Screen for PileupView<'_> { true } - /// A click on the bars moves the cursor there. + /// A click on the bars moves the cursor there; a drag or the wheel + /// pans (see [`PileupView::pointer`]). 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) + self.pointer(m) } fn done(&self) -> bool { diff --git a/src/site_analysis/pileup/tui/stack.rs b/src/site_analysis/pileup/tui/stack.rs index 9b10037..5be3d15 100644 --- a/src/site_analysis/pileup/tui/stack.rs +++ b/src/site_analysis/pileup/tui/stack.rs @@ -153,22 +153,30 @@ impl Screen for StackView<'_> { true } - /// A click gives the gene under it the keys, and over its bars moves - /// its cursor there. + /// A press gives the gene under it the keys; then, as in one gene's + /// browser, a click on its bars moves its cursor, a drag pans it, and + /// the wheel pans the gene under the pointer. 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; + let under = self.views.iter().position(|v| v.drawn.contains(at)); + let i = match m.kind { + // Let go anywhere: the gene that was grabbed. + MouseEventKind::Up(_) => self.focus, + MouseEventKind::Down(_) => { + let Some(i) = under else { return false }; + self.focus = i; + self.status = None; + self.mark_focus(); + i + } + _ => match under { + Some(i) => i, + None => return false, + }, }; - self.focus = i; - self.views[i].click_at(m.column); - self.status = None; - self.mark_focus(); - true + let changed = self.views[i].pointer(m); + changed || matches!(m.kind, MouseEventKind::Down(_)) } fn done(&self) -> bool { diff --git a/src/site_analysis/scan_pwm/tui.rs b/src/site_analysis/scan_pwm/tui.rs index d8b0e84..bfdef81 100644 --- a/src/site_analysis/scan_pwm/tui.rs +++ b/src/site_analysis/scan_pwm/tui.rs @@ -159,6 +159,8 @@ fn allot(heights: &[f64; 4], rows: f64) -> [usize; 4] { /// State of the logo view, independent of the terminal so it can be tested. pub struct PwmView { + /// Where the last frame drew the view, for a saved figure's shape. + drawn: Rect, title: String, /// Relative position of the first column. first: i64, @@ -188,6 +190,7 @@ impl PwmView { cursor: window.max(0) as usize, offset: 0, controls: Controls::new("pwm_logo"), + drawn: Rect::default(), plot: PlotImage::default(), done: false, } @@ -229,10 +232,14 @@ impl PwmView { /// The logo as a figure: letters stretched to their heights. fn figure(&self) -> String { let n = self.columns.len().max(1) as f64; - let width = 56.0 + (560.0 / n).clamp(8.0, 40.0) * n + 20.0; - let mut c = Canvas::new(width, 330.0); + // The view's shape on screen, else letters of a readable width. + let (width, height) = match crate::figure::page(self.drawn) { + Some((w, h)) => (w.max(300.0), h.max(200.0)), + None => (56.0 + (560.0 / n).clamp(8.0, 40.0) * n + 20.0, 330.0), + }; + let mut c = Canvas::new(width, height); c.bold(16.0, 22.0, &self.title, 12.0, Anchor::Start, INK); - self.draw_logo(&mut c, (0.0, 30.0, width, 300.0), None); + self.draw_logo(&mut c, (0.0, 30.0, width, height - 30.0), None); c.finish() } @@ -445,6 +452,7 @@ impl Screen for PwmView { } fn render(&mut self, frame: &mut Frame) { + self.drawn = frame.area(); let help = self.controls.footer().unwrap_or_else(|| { let mut keys = vec![ ("←/→", "position"), From 51f21c1970f257cd0658ebe488b02a59b10b131c Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 13:09:42 -0700 Subject: [PATCH 2/4] Tests: metagene's and pwm's saved figures take their view's shape --- src/site_analysis/metagene/tests/tui.rs | 22 ++++++++++++++++++++++ src/site_analysis/scan_pwm/tests/tui.rs | 15 +++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/site_analysis/metagene/tests/tui.rs b/src/site_analysis/metagene/tests/tui.rs index d9a2c12..dceedcb 100644 --- a/src/site_analysis/metagene/tests/tui.rs +++ b/src/site_analysis/metagene/tests/tui.rs @@ -250,3 +250,25 @@ fn enter_stays_and_plus_is_finer() { press(&mut v, KeyCode::Esc); assert!(v.done()); } + +#[test] +fn a_saved_figure_keeps_the_views_shape() { + let h = hist(true); + let mut v = MetageneView::new("sites", &h); + let size = |svg: &str| { + let get = |k: &str| -> f64 { + let at = svg.find(&format!(" {k}=\"")).unwrap() + k.len() + 3; + svg[at..].split('"').next().unwrap().parse().unwrap() + }; + (get("width"), get("height")) + }; + screen(&mut v, 150, 40); + let (cw, ch) = crate::figure::CELL; + assert_eq!(size(&v.figure()), (150.0 * cw, 40.0 * ch)); + screen(&mut v, 90, 50); + assert_eq!( + size(&v.figure()), + (90.0 * cw, 50.0 * ch), + "a taller view, a taller page" + ); +} diff --git a/src/site_analysis/scan_pwm/tests/tui.rs b/src/site_analysis/scan_pwm/tests/tui.rs index 1f6cc80..9a5999e 100644 --- a/src/site_analysis/scan_pwm/tests/tui.rs +++ b/src/site_analysis/scan_pwm/tests/tui.rs @@ -182,3 +182,18 @@ fn enter_stays_and_esc_leaves() { press(&mut v, KeyCode::Esc); assert!(v.done()); } + +#[test] +fn a_saved_logo_keeps_the_views_shape() { + let mut v = PwmView::new("x", &pwm(), 2); + let size = |svg: &str| { + let get = |k: &str| -> f64 { + let at = svg.find(&format!(" {k}=\"")).unwrap() + k.len() + 3; + svg[at..].split('"').next().unwrap().parse().unwrap() + }; + (get("width"), get("height")) + }; + screen(&mut v, 120, 30); + let (cw, ch) = crate::figure::CELL; + assert_eq!(size(&v.figure()), (120.0 * cw, 30.0 * ch)); +} From 7c02d14724a289c734df2e2355796cfa958e8f33 Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 13:18:09 -0700 Subject: [PATCH 3/4] Review fixes: exact pans, readable logo letters, grabs only on the bars - A drag or wheel notch pans by the window's span a column, as far as the pointer, not the rounded-up bases of a bar; a notch at an end of the span changes nothing and redraws nothing. - Only a press on the plots' bars grabs (not the header, genes or footer); any button clicks, as before; a press outside every gene of a stack lets go of a grab whose release was lost. - A saved logo keeps its letters 8 to 40 points wide whatever the terminal. - figure::page takes each view's minimum size; figure::svg_size reads a figure's size for tiling and tests alike. --- src/figure/mod.rs | 28 +++++++---- src/site_analysis/metagene/tests/tui.rs | 8 +--- src/site_analysis/metagene/tui.rs | 3 +- src/site_analysis/pileup/tests/stack.rs | 37 +++++++++++---- src/site_analysis/pileup/tests/tui.rs | 29 ++++++++++++ src/site_analysis/pileup/tui.rs | 62 ++++++++++++++++--------- src/site_analysis/pileup/tui/stack.rs | 6 ++- src/site_analysis/scan_pwm/tests/tui.rs | 25 ++++++---- src/site_analysis/scan_pwm/tui.rs | 8 ++-- 9 files changed, 142 insertions(+), 64 deletions(-) diff --git a/src/figure/mod.rs b/src/figure/mod.rs index 21a940e..e7e0f2a 100644 --- a/src/figure/mod.rs +++ b/src/figure/mod.rs @@ -222,27 +222,35 @@ pub const CELL: (f64, f64) = (6.0, 12.0); /// The size in points of a figure saved from a view that took `area` on /// screen, so it keeps the view's shape; `None` before it was drawn. -pub fn page(area: Rect) -> Option<(f64, f64)> { +/// No smaller than `min` (a view states its own), lest a narrow terminal +/// squeeze the text out. +pub fn page(area: Rect, min: (f64, f64)) -> Option<(f64, f64)> { (area.width > 0 && area.height > 0).then(|| { ( - f64::from(area.width) * CELL.0, - f64::from(area.height) * CELL.1, + (f64::from(area.width) * CELL.0).max(min.0), + (f64::from(area.height) * CELL.1).max(min.1), ) }) } +/// The `(width, height)` an SVG from [`Canvas::finish`] declares; zero +/// where one is missing. +pub(crate) fn svg_size(svg: &str) -> (f64, f64) { + let get = |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) + }; + (get("width"), get("height")) +} + /// 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 cell = svgs.iter().map(|s| svg_size(s).0).fold(0.0, f64::max); let mut y = 0.0; let mut body = String::new(); for row in svgs.chunks(cols) { @@ -251,7 +259,7 @@ pub fn tile_svgs(svgs: &[String], cols: usize) -> String { let x = cell * k as f64; body.push_str(&svg.replacen(" f64 { - let at = svg.find(&format!(" {k}=\"")).unwrap() + k.len() + 3; - svg[at..].split('"').next().unwrap().parse().unwrap() - }; - (get("width"), get("height")) - }; + let size = crate::figure::svg_size; screen(&mut v, 150, 40); let (cw, ch) = crate::figure::CELL; assert_eq!(size(&v.figure()), (150.0 * cw, 40.0 * ch)); diff --git a/src/site_analysis/metagene/tui.rs b/src/site_analysis/metagene/tui.rs index d732a53..42584e5 100644 --- a/src/site_analysis/metagene/tui.rs +++ b/src/site_analysis/metagene/tui.rs @@ -239,8 +239,7 @@ impl<'a> MetageneView<'a> { /// The shown track as a figure, at the shown merge factor. fn figure(&self) -> String { // The view's shape on screen, else a page of its own. - let (w, h) = crate::figure::page(self.drawn).unwrap_or((680.0, 300.0)); - let (w, h) = (w.max(360.0), h.max(200.0)); + let (w, h) = crate::figure::page(self.drawn, (360.0, 200.0)).unwrap_or((680.0, 300.0)); let mut c = Canvas::new(w, h); c.bold(16.0, 22.0, &self.title, 12.0, Anchor::Start, INK); let totals: Vec = self diff --git a/src/site_analysis/pileup/tests/stack.rs b/src/site_analysis/pileup/tests/stack.rs index ecf9d06..699c8b4 100644 --- a/src/site_analysis/pileup/tests/stack.rs +++ b/src/site_analysis/pileup/tests/stack.rs @@ -240,14 +240,16 @@ fn a_drag_or_the_wheel_pans_the_gene_under_the_pointer() { modifiers: KeyModifiers::NONE, }; let v = &s.views[1]; - let (x, y, before, bin) = (v.chart_x + 50, v.drawn.y + 3, v.window, v.bin_width()); + let (x, y, before) = (v.chart_x + 50, v.drawn.y + 3, v.window); + // As far as the pointer: the window's span a column. + let ten_columns = 10 * (before.1 - before.0) / v.columns as i64; s.mouse(at(MouseEventKind::Down(MouseButton::Left), x, y)); s.mouse(at(MouseEventKind::Up(MouseButton::Left), x - 10, y)); assert_eq!(s.focus, 1); let after = s.views[1].window; assert_eq!( after.0 - before.0, - 10 * bin, + ten_columns, "dragged left: the view moves right" ); assert_eq!(after.1 - after.0, before.1 - before.0, "the same width"); @@ -265,13 +267,7 @@ fn a_drag_or_the_wheel_pans_the_gene_under_the_pointer() { fn a_saved_gene_keeps_its_shape_on_screen() { let (a, b) = (positions(1.0), positions(3.0)); let mut s = stack(&a, &b); - let size = |svg: &str| { - let get = |k: &str| -> f64 { - let at = svg.find(&format!(" {k}=\"")).unwrap() + k.len() + 3; - svg[at..].split('"').next().unwrap().parse().unwrap() - }; - (get("width"), get("height")) - }; + let size = crate::figure::svg_size; screen(&mut s, 120, 40); let tall = size(&s.views[0].figure()); press(&mut s, KeyCode::Char('|')); @@ -282,3 +278,26 @@ fn a_saved_gene_keeps_its_shape_on_screen() { assert_eq!(wide.0, 60.0 * crate::figure::CELL.0); assert!(wide.1 > tall.1, "side by side, each gene is taller"); } + +#[test] +fn a_press_outside_every_gene_lets_go_of_a_grab() { + use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + let (a, b) = (positions(1.0), positions(3.0)); + let mut s = stack(&a, &b); + press(&mut s, KeyCode::Char('+')); + screen(&mut s, 100, 40); + let at = |kind, column, row| MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }; + let v = &s.views[0]; + let (x, y, before) = (v.chart_x + 30, v.drawn.y + 3, v.window); + // Grabbed, then its release lost (let go outside the terminal). + s.mouse(at(MouseEventKind::Down(MouseButton::Left), x, y)); + // A press and release on the footer, outside every gene. + s.mouse(at(MouseEventKind::Down(MouseButton::Left), x + 20, 39)); + s.mouse(at(MouseEventKind::Up(MouseButton::Left), x + 20, 39)); + assert_eq!(s.views[0].window, before, "no stale grab pans the gene"); +} diff --git a/src/site_analysis/pileup/tests/tui.rs b/src/site_analysis/pileup/tests/tui.rs index a6be0bd..371e23d 100644 --- a/src/site_analysis/pileup/tests/tui.rs +++ b/src/site_analysis/pileup/tests/tui.rs @@ -605,3 +605,32 @@ fn the_browser_draws_text_and_saves_turned_position_labels() { let _ = s; assert!(b.figure().contains("rotate(-90)"), "position labels turned"); } + +#[test] +fn only_the_bars_grab_and_any_button_clicks() { + use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + let (m, s) = (positions(), sites()); + let mut v = view(&m, &s); + press(&mut v, KeyCode::Char('+')); + screen(&mut v, 100, 30); + let at = |kind, column, row| MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }; + let (x, before) = (v.chart_x + 40, v.window); + // A drag from the header row does not pan. + v.mouse(at(MouseEventKind::Down(MouseButton::Left), x, 0)); + v.mouse(at(MouseEventKind::Up(MouseButton::Left), x - 10, 0)); + assert_eq!(v.window, before); + // A right click on the bars moves the cursor. + let y = v.chart_rows.0 + 2; + let cursor = v.cursor; + v.mouse(at(MouseEventKind::Down(MouseButton::Right), x, y)); + v.mouse(at(MouseEventKind::Up(MouseButton::Right), x, y)); + assert_ne!(v.cursor, cursor); + // The wheel at the start of the span changes nothing, and says so. + press(&mut v, KeyCode::Char('0')); + assert!(!v.mouse(at(MouseEventKind::ScrollUp, x, y))); +} diff --git a/src/site_analysis/pileup/tui.rs b/src/site_analysis/pileup/tui.rs index e89671c..17f404c 100644 --- a/src/site_analysis/pileup/tui.rs +++ b/src/site_analysis/pileup/tui.rs @@ -143,6 +143,8 @@ pub struct PileupView<'a> { /// at, for clicks. drawn: Rect, chart_x: u16, + /// The screen rows the plots (not the genes) take, for grabbing. + chart_rows: (u16, u16), /// The column a press on the bars came at, until it is let go: a drag /// pans, a click moves the cursor. grabbed: Option, @@ -207,6 +209,7 @@ impl<'a> PileupView<'a> { badge: "pileup", drawn: Rect::default(), chart_x: 0, + chart_rows: (0, 0), grabbed: None, exit: None, } @@ -500,12 +503,34 @@ impl<'a> PileupView<'a> { /// Slide the window `bars` bars along, keeping its width and the cursor /// in it. - fn pan(&mut self, bars: i64) { - let shift = bars * self.bin_width(); + /// Whether it moved: at an end of the view's span it cannot. + fn pan(&mut self, bars: i64) -> bool { + // As far as the bars travel: the window's span a column, not the + // whole bases a bar rounds up to. + let span = self.window.1 - self.window.0; + let shift = bars * span / self.columns.max(1) as i64; + let before = self.window; self.window = (self.window.0 + shift, self.window.1 + shift); self.clamp_window(); + if self.window == before { + return false; + } self.cursor = self.cursor.clamp(self.window.0, self.window.1); self.plots.iter_mut().for_each(PlotImage::invalidate); + true + } + + /// Whether screen cell `(x, y)` is over the plots' bars. + fn over_bars(&self, x: u16, y: u16) -> bool { + let (top, bottom) = self.chart_rows; + x >= self.chart_x + && usize::from(x - self.chart_x) < self.columns + && (top..bottom).contains(&y) + } + + /// Let go of a grab, as a press outside the view does. + pub(super) fn let_go(&mut self) { + self.grabbed = None; } /// A press, release or wheel turn on the view: a press on the bars @@ -513,32 +538,24 @@ impl<'a> PileupView<'a> { /// it moves the cursor there); the wheel, either way, pans a tenth of /// the view a notch. Whether it changed. pub(super) fn pointer(&mut self, m: ratatui::crossterm::event::MouseEvent) -> bool { - use ratatui::crossterm::event::{MouseButton, MouseEventKind}; + use ratatui::crossterm::event::MouseEventKind; let notch = (self.columns as i64 / 10).max(1); match m.kind { - MouseEventKind::Down(MouseButton::Left) => { - let over = - m.column >= self.chart_x && usize::from(m.column - self.chart_x) < self.columns; - self.grabbed = over.then_some(m.column); + // Any button: a click with any of them moves the cursor. + MouseEventKind::Down(_) => { + self.grabbed = self.over_bars(m.column, m.row).then_some(m.column); false } - MouseEventKind::Up(MouseButton::Left) => match self.grabbed.take() { + MouseEventKind::Up(_) => match self.grabbed.take() { // Dragged: the bars follow the pointer, as when grabbed. Some(from) if from.abs_diff(m.column) >= 2 => { - self.pan(i64::from(from) - i64::from(m.column)); - true + self.pan(i64::from(from) - i64::from(m.column)) } Some(from) => self.click_at(from), None => false, }, - MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => { - self.pan(-notch); - true - } - MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => { - self.pan(notch); - true - } + MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => self.pan(-notch), + MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => self.pan(notch), _ => false, } } @@ -633,17 +650,14 @@ impl<'a> PileupView<'a> { let n_plots = rows.iter().filter(|&&r| r != Row::Genes).count().max(1) as f64; // The shape the view has on screen, its plots sharing what the title // and the genes leave; else a page of its own. - let (width, panel_h) = match figure::page(self.drawn) { + let (width, panel_h) = match figure::page(self.drawn, (360.0, 0.0)) { Some((w, h)) => { let genes = if rows.contains(&Row::Genes) { genes_h } else { 0.0 }; - ( - w.max(360.0), - ((h - 50.0 - genes) / n_plots).max(100.0 + room), - ) + (w, ((h - 50.0 - genes) / n_plots).max(100.0 + room)) } None => (720.0, 170.0 + room), }; @@ -935,6 +949,8 @@ impl PileupView<'_> { 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); + let plot_rows = rows.iter().filter(|&&r| r != Row::Genes).count().max(1); + self.chart_rows = (areas[1].y, areas[plot_rows].bottom()); 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 5be3d15..f2947c1 100644 --- a/src/site_analysis/pileup/tui/stack.rs +++ b/src/site_analysis/pileup/tui/stack.rs @@ -164,7 +164,11 @@ impl Screen for StackView<'_> { // Let go anywhere: the gene that was grabbed. MouseEventKind::Up(_) => self.focus, MouseEventKind::Down(_) => { - let Some(i) = under else { return false }; + let Some(i) = under else { + // Nothing grabbed, so no later release pans. + self.views[self.focus].let_go(); + return false; + }; self.focus = i; self.status = None; self.mark_focus(); diff --git a/src/site_analysis/scan_pwm/tests/tui.rs b/src/site_analysis/scan_pwm/tests/tui.rs index 9a5999e..4ea5057 100644 --- a/src/site_analysis/scan_pwm/tests/tui.rs +++ b/src/site_analysis/scan_pwm/tests/tui.rs @@ -186,14 +186,21 @@ fn enter_stays_and_esc_leaves() { #[test] fn a_saved_logo_keeps_the_views_shape() { let mut v = PwmView::new("x", &pwm(), 2); - let size = |svg: &str| { - let get = |k: &str| -> f64 { - let at = svg.find(&format!(" {k}=\"")).unwrap() + k.len() + 3; - svg[at..].split('"').next().unwrap().parse().unwrap() - }; - (get("width"), get("height")) - }; + let size = crate::figure::svg_size; + // The height follows the view; five letters stop at 40 points each. + let (_, ch) = crate::figure::CELL; screen(&mut v, 120, 30); - let (cw, ch) = crate::figure::CELL; - assert_eq!(size(&v.figure()), (120.0 * cw, 30.0 * ch)); + assert_eq!(size(&v.figure()), (76.0 + 40.0 * 5.0, 30.0 * ch)); + screen(&mut v, 120, 40); + assert_eq!(size(&v.figure()).1, 40.0 * ch); +} + +#[test] +fn a_long_motif_keeps_readable_letters() { + // 41 positions saved from a narrow terminal: at least 8 points a letter. + let wide: Vec = (0..41).map(|_| count(5, 1, 1, 1)).collect(); + let mut v = PwmView::new("x", &wide, 20); + screen(&mut v, 60, 20); + let (w, _) = crate::figure::svg_size(&v.figure()); + assert!(w >= 76.0 + 8.0 * 41.0, "{w}"); } diff --git a/src/site_analysis/scan_pwm/tui.rs b/src/site_analysis/scan_pwm/tui.rs index bfdef81..a745503 100644 --- a/src/site_analysis/scan_pwm/tui.rs +++ b/src/site_analysis/scan_pwm/tui.rs @@ -232,9 +232,11 @@ impl PwmView { /// The logo as a figure: letters stretched to their heights. fn figure(&self) -> String { let n = self.columns.len().max(1) as f64; - // The view's shape on screen, else letters of a readable width. - let (width, height) = match crate::figure::page(self.drawn) { - Some((w, h)) => (w.max(300.0), h.max(200.0)), + // The view's shape on screen, its letters 8 to 40 points wide + // whatever the motif's length; else a page of readable letters. + let margins = 76.0; + let (width, height) = match crate::figure::page(self.drawn, (300.0, 200.0)) { + Some((w, h)) => (w.clamp(margins + 8.0 * n, margins + 40.0 * n), h), None => (56.0 + (560.0 / n).clamp(8.0, 40.0) * n + 20.0, 330.0), }; let mut c = Canvas::new(width, height); From d155b58439bd9031f090578becd8cf4400cd5c41 Mon Sep 17 00:00:00 2001 From: YPARK Date: Tue, 13 Oct 2026 13:19:44 -0700 Subject: [PATCH 4/4] Simplify: one bar-under-a-column test, one plot count, a stack press without let_go - PileupView::bar_at serves click_at and over_bars alike; plot_count replaces three copies of the plots-not-genes count. - A press outside every gene of a stack goes to the one with the keys, whose own press handling lets go of a grab; let_go is gone. - pwm's fallback page uses the same margins. --- src/site_analysis/pileup/tui.rs | 27 +++++++++++++++------------ src/site_analysis/pileup/tui/stack.rs | 17 ++++++++--------- src/site_analysis/scan_pwm/tui.rs | 2 +- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/site_analysis/pileup/tui.rs b/src/site_analysis/pileup/tui.rs index 17f404c..922b74d 100644 --- a/src/site_analysis/pileup/tui.rs +++ b/src/site_analysis/pileup/tui.rs @@ -237,10 +237,9 @@ 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 { + let Some(col) = self.bar_at(x) else { 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); @@ -523,14 +522,18 @@ impl<'a> PileupView<'a> { /// Whether screen cell `(x, y)` is over the plots' bars. fn over_bars(&self, x: u16, y: u16) -> bool { let (top, bottom) = self.chart_rows; - x >= self.chart_x - && usize::from(x - self.chart_x) < self.columns - && (top..bottom).contains(&y) + self.bar_at(x).is_some() && (top..bottom).contains(&y) + } + + /// The bar at screen column `x`, when it is over the bars. + fn bar_at(&self, x: u16) -> Option { + let col = usize::from(x.checked_sub(self.chart_x)?); + (col < self.columns).then_some(col) } - /// Let go of a grab, as a press outside the view does. - pub(super) fn let_go(&mut self) { - self.grabbed = None; + /// Rows of plots, as against the genes row. + fn plot_count(rows: &[Row]) -> usize { + rows.iter().filter(|&&r| r != Row::Genes).count().max(1) } /// A press, release or wheel turn on the view: a press on the bars @@ -647,7 +650,7 @@ impl<'a> PileupView<'a> { let room = figure::tick_room(&self.tick_list(), true); let rows = self.rows(); let genes_h = 24.0 + GENE_LANE * self.lanes() as f64; - let n_plots = rows.iter().filter(|&&r| r != Row::Genes).count().max(1) as f64; + let n_plots = Self::plot_count(&rows) as f64; // The shape the view has on screen, its plots sharing what the title // and the genes leave; else a page of its own. let (width, panel_h) = match figure::page(self.drawn, (360.0, 0.0)) { @@ -928,7 +931,8 @@ impl PileupView<'_> { 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; + let plot_rows = Self::plot_count(&rows); + let n_plots = plot_rows as u32; let genes_height = self.lanes() as u16 + 2; let mut layout = vec![Constraint::Length(1)]; layout.extend(rows.iter().map(|&r| match r { @@ -949,7 +953,6 @@ impl PileupView<'_> { 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); - let plot_rows = rows.iter().filter(|&&r| r != Row::Genes).count().max(1); self.chart_rows = (areas[1].y, areas[plot_rows].bottom()); self.clamp_window(); let (lo, hi) = self.window; diff --git a/src/site_analysis/pileup/tui/stack.rs b/src/site_analysis/pileup/tui/stack.rs index f2947c1..06c4672 100644 --- a/src/site_analysis/pileup/tui/stack.rs +++ b/src/site_analysis/pileup/tui/stack.rs @@ -163,16 +163,15 @@ impl Screen for StackView<'_> { let i = match m.kind { // Let go anywhere: the gene that was grabbed. MouseEventKind::Up(_) => self.focus, + // Outside every gene, the press still goes to the one with the + // keys: off its bars, it lets go of any grab. MouseEventKind::Down(_) => { - let Some(i) = under else { - // Nothing grabbed, so no later release pans. - self.views[self.focus].let_go(); - return false; - }; - self.focus = i; - self.status = None; - self.mark_focus(); - i + if let Some(i) = under { + self.focus = i; + self.status = None; + self.mark_focus(); + } + self.focus } _ => match under { Some(i) => i, diff --git a/src/site_analysis/scan_pwm/tui.rs b/src/site_analysis/scan_pwm/tui.rs index a745503..046c85e 100644 --- a/src/site_analysis/scan_pwm/tui.rs +++ b/src/site_analysis/scan_pwm/tui.rs @@ -237,7 +237,7 @@ impl PwmView { let margins = 76.0; let (width, height) = match crate::figure::page(self.drawn, (300.0, 200.0)) { Some((w, h)) => (w.clamp(margins + 8.0 * n, margins + 40.0 * n), h), - None => (56.0 + (560.0 / n).clamp(8.0, 40.0) * n + 20.0, 330.0), + None => (margins + (560.0 / n).clamp(8.0, 40.0) * n, 330.0), }; let mut c = Canvas::new(width, height); c.bold(16.0, 22.0, &self.title, 12.0, Anchor::Start, INK);