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..e7e0f2a 100644 --- a/src/figure/mod.rs +++ b/src/figure/mod.rs @@ -215,18 +215,42 @@ 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. +/// 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).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) { @@ -235,7 +259,7 @@ pub fn tile_svgs(svgs: &[String], cols: usize) -> String { let x = cell * k as f64; body.push_str(&svg.replacen(" 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,9 @@ 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, (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 .track @@ -252,7 +257,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 +385,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..699c8b4 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,82 @@ 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) = (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, + ten_columns, + "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 = crate::figure::svg_size; + 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"); +} + +#[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 2fef5d1..922b74d 100644 --- a/src/site_analysis/pileup/tui.rs +++ b/src/site_analysis/pileup/tui.rs @@ -143,6 +143,11 @@ 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, exit: Option, } @@ -204,6 +209,8 @@ impl<'a> PileupView<'a> { badge: "pileup", drawn: Rect::default(), chart_x: 0, + chart_rows: (0, 0), + grabbed: None, exit: None, } } @@ -230,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); @@ -494,6 +500,69 @@ impl<'a> PileupView<'a> { self.window = (lo, lo + w); } + /// Slide the window `bars` bars along, keeping its width and the cursor + /// in it. + /// 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; + 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) + } + + /// 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 + /// 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::MouseEventKind; + let notch = (self.columns as i64 / 10).max(1); + match m.kind { + // 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(_) => 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)) + } + Some(from) => self.click_at(from), + None => false, + }, + MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => self.pan(-notch), + MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => self.pan(notch), + _ => 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 +647,29 @@ 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 = 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)) { + Some((w, h)) => { + let genes = if rows.contains(&Row::Genes) { + genes_h + } else { + 0.0 + }; + (w, ((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 +689,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 +847,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 { @@ -848,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 { @@ -869,6 +953,7 @@ 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); + 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 9b10037..06c4672 100644 --- a/src/site_analysis/pileup/tui/stack.rs +++ b/src/site_analysis/pileup/tui/stack.rs @@ -153,22 +153,33 @@ 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, + // Outside every gene, the press still goes to the one with the + // keys: off its bars, it lets go of any grab. + MouseEventKind::Down(_) => { + if let Some(i) = under { + self.focus = i; + self.status = None; + self.mark_focus(); + } + self.focus + } + _ => 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/tests/tui.rs b/src/site_analysis/scan_pwm/tests/tui.rs index 1f6cc80..4ea5057 100644 --- a/src/site_analysis/scan_pwm/tests/tui.rs +++ b/src/site_analysis/scan_pwm/tests/tui.rs @@ -182,3 +182,25 @@ 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 = 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); + 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 d8b0e84..046c85e 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,16 @@ 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, 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 => (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); - 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 +454,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"),