From 36f8f757da5c5611431477e42b26428705b555bf Mon Sep 17 00:00:00 2001 From: jamaliki <39654543+jamaliki@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:49:44 +0100 Subject: [PATCH 01/10] perf: speed up FullHD rendering and eliminate idle redraws Three independent costs dominated the FullHD path: 1. Ribbon meshes were over-tessellated. 14 spline subdivisions x 12 coil segments produces 336 triangles per residue -- 368,976 for 1AOI against 1.18M pixels, so most triangles were sub-pixel. The existing LOD only engaged above 5000 residues, so nearly every real structure paid full tessellation. LOD is now graduated: <=300 residues keep 12x10, the middle tier drops to 8x8, >5000 keeps the existing 4x6. Measured at 1600x736, 8x8 renders 2.1x faster than 14x12 for a mean per-channel error of 0.40/255, with differences confined to silhouette edges. 2. Triangle projection was serial. render_cartoon_tiled projected and shaded every triangle single-threaded -- the resolution-independent floor under every frame, and its largest stage. Now par_iter. The tile merge and the depth tint (including its min/max scan) are parallelized too; tiles cover disjoint screen rectangles, so the merge partitions cleanly over rows. 3. The main loop redrew unconditionally ~30x/second while idle, re-running the whole pipeline and retransmitting ~225 KB/frame of escape sequences to reproduce an identical image. Redraws are now gated on input, auto-rotate, a pending clear, a mesh rebuild, background analysis completing, or the SSH warning countdown. Per frame, 1AOI at 1600x736 with the mesh cached: 23.09 ms -> 12.04 ms. Idle cost is now ~0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Map4Nz8qwRcPi5iXQ9kpwd --- src/app.rs | 10 ++++++++++ src/main.rs | 22 ++++++++++++++++++++ src/render/framebuffer.rs | 37 ++++++++++++++++++---------------- src/render/hd.rs | 39 ++++++++++++++++++++++++------------ src/render/ribbon.rs | 42 +++++++++++++++++++++++++++++++++++---- 5 files changed, 116 insertions(+), 34 deletions(-) diff --git a/src/app.rs b/src/app.rs index ca2659b..0732f38 100644 --- a/src/app.rs +++ b/src/app.rs @@ -303,6 +303,16 @@ impl App { } } + /// Whether the ribbon mesh will be rebuilt on the next `ribbon_mesh()` call. + pub fn mesh_is_dirty(&self) -> bool { + self.mesh_dirty + } + + /// Whether a background interface analysis is still outstanding. + pub fn interface_pending(&self) -> bool { + !self.interface_computed + } + /// Poll the background interface analysis thread (non-blocking). /// Called each frame so results are absorbed as soon as they're ready. pub fn poll_background_interface(&mut self) { diff --git a/src/main.rs b/src/main.rs index 36c16c7..f476242 100644 --- a/src/main.rs +++ b/src/main.rs @@ -482,13 +482,16 @@ fn main() -> Result<()> { // Only rebuild when in Cartoon mode — Backbone/Wireframe don't use the // ribbon mesh, so skipping this preserves the lazy-mesh optimization for // large structures that start in a non-Cartoon mode. + let mesh_was_rebuilt = app.viz_mode == VizMode::Cartoon && app.mesh_is_dirty(); if app.viz_mode == VizMode::Cartoon { app.ribbon_mesh(); } // Always poll the background interface thread, even during skipped // frames, so the result is absorbed as soon as it's available. + let interface_was_pending = app.interface_pending(); app.poll_background_interface(); + let interface_absorbed = interface_was_pending && !app.interface_pending(); // Adaptive frame skipping: if the previous draw took longer than the // tick rate, skip frames proportionally. User input always forces a @@ -507,6 +510,25 @@ fn main() -> Result<()> { continue; } + // Nothing on screen changes unless input arrived, an animation is + // running, or background state was just absorbed. Redrawing anyway + // would re-run the whole rasterize + encode pipeline and push a fresh + // full-viewport image at every tick -- on FullHD that is hundreds of + // kilobytes of escape sequences per frame, forever, for an image + // identical to the one already on screen. + let animating = app.camera.auto_rotate || app.ssh_hd_warning; + let must_redraw = had_input + || animating + || app.needs_clear + || mesh_was_rebuilt + || interface_absorbed + || frame_count < 2; + if !must_redraw { + app.tick(); + std::thread::sleep(tick_rate); + continue; + } + // Render frame_count += 1; if frame_count <= 3 || frame_count % 300 == 0 { diff --git a/src/render/framebuffer.rs b/src/render/framebuffer.rs index 0d39339..fbcb6df 100644 --- a/src/render/framebuffer.rs +++ b/src/render/framebuffer.rs @@ -1,4 +1,5 @@ use image::{RgbImage, RgbaImage}; +use rayon::prelude::*; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; @@ -180,18 +181,18 @@ impl Framebuffer { /// Background pixels (depth == INFINITY) remain unchanged (black). pub fn apply_depth_tint(&mut self, fog_color: [u8; 3], fog_strength: f64) { // Find z_min and z_max across all valid (non-background) pixels. - let mut z_min = f32::INFINITY; - let mut z_max = f32::NEG_INFINITY; - for &d in &self.depth { - if d < f32::INFINITY { - if d < z_min { - z_min = d; - } - if d > z_max { - z_max = d; - } - } - } + let (z_min, z_max) = self + .depth + .par_iter() + .filter(|d| **d < f32::INFINITY) + .fold( + || (f32::INFINITY, f32::NEG_INFINITY), + |(lo, hi), &d| (lo.min(d), hi.max(d)), + ) + .reduce( + || (f32::INFINITY, f32::NEG_INFINITY), + |a, b| (a.0.min(b.0), a.1.max(b.1)), + ); // No valid pixels, or all at the same depth — nothing to tint. let z_range = z_max - z_min; @@ -201,21 +202,23 @@ impl Framebuffer { let inv_range = 1.0 / z_range; - for i in 0..self.depth.len() { - let d = self.depth[i]; + // Per-pixel and independent -- parallelize over rows. + self.color + .par_iter_mut() + .zip(self.depth.par_iter()) + .for_each(|(c, &d)| { if d >= f32::INFINITY { - continue; // background pixel — leave black + return; // background pixel — leave black } let t = ((d - z_min) * inv_range).clamp(0.0, 1.0); let blend = t as f64 * fog_strength; - let c = &mut self.color[i]; c[0] = (c[0] as f64 + (fog_color[0] as f64 - c[0] as f64) * blend).clamp(0.0, 255.0) as u8; c[1] = (c[1] as f64 + (fog_color[1] as f64 - c[1] as f64) * blend).clamp(0.0, 255.0) as u8; c[2] = (c[2] as f64 + (fog_color[2] as f64 - c[2] as f64) * blend).clamp(0.0, 255.0) as u8; - } + }); } /// Cohen-Sutherland line clipping against framebuffer bounds [0, width) x [0, height). diff --git a/src/render/hd.rs b/src/render/hd.rs index db6ec11..8781dfc 100644 --- a/src/render/hd.rs +++ b/src/render/hd.rs @@ -122,7 +122,6 @@ struct TiledRenderCtx { /// A rasterized tile with its position, dimensions, and pixel data. struct RenderedTile { x: usize, - y: usize, w: usize, h: usize, color: Vec<[u8; 3]>, @@ -153,7 +152,7 @@ fn render_cartoon_tiled( // Step 1: Project and shade all triangles (serial). // ------------------------------------------------------------------ let projected: Vec = mesh - .iter() + .par_iter() .filter_map(|tri| { let v0 = cache.project(tri.verts[0][0], tri.verts[0][1], tri.verts[0][2]); let v1 = cache.project(tri.verts[1][0], tri.verts[1][1], tri.verts[1][2]); @@ -247,7 +246,6 @@ fn render_cartoon_tiled( RenderedTile { x: tx, - y: ty, w: tw, h: th, color, @@ -259,18 +257,33 @@ fn render_cartoon_tiled( // ------------------------------------------------------------------ // Step 4: Merge tiles back into the main framebuffer. // ------------------------------------------------------------------ - for tile in &tiles { - for ly in 0..tile.h { - for lx in 0..tile.w { - let ti = ly * tile.w + lx; - let fi = (tile.y + ly) * px_w + (tile.x + lx); - if tile.depth[ti] < fb.depth[fi] { - fb.color[fi] = tile.color[ti]; - fb.depth[fi] = tile.depth[ti]; + // Tiles cover disjoint screen rectangles, so the merge parallelizes cleanly + // over framebuffer rows: row `y` is covered by exactly the `cols` tiles in + // tile-row `y / TILE_SIZE`. + let tiles = &tiles; + fb.color + .par_chunks_mut(px_w) + .zip(fb.depth.par_chunks_mut(px_w)) + .enumerate() + .for_each(|(y, (color_row, depth_row))| { + let tr = y / TILE_SIZE; + let ly = y % TILE_SIZE; + for tc in 0..cols { + let tile = &tiles[tr * cols + tc]; + if ly >= tile.h { + continue; + } + let src_base = ly * tile.w; + for lx in 0..tile.w { + let ti = src_base + lx; + let fi = tile.x + lx; + if tile.depth[ti] < depth_row[fi] { + color_row[fi] = tile.color[ti]; + depth_row[fi] = tile.depth[ti]; + } } } - } - } + }); } /// Rasterize a single projected triangle into a tile's local buffers. diff --git a/src/render/ribbon.rs b/src/render/ribbon.rs index 804aeb1..0768cad 100644 --- a/src/render/ribbon.rs +++ b/src/render/ribbon.rs @@ -16,11 +16,25 @@ use crate::render::color::{ColorScheme, color_to_rgb}; // Constants & LOD configuration // --------------------------------------------------------------------------- -/// Default number of spline subdivisions between each pair of C-alpha atoms. -const DEFAULT_SPLINE_SUBDIVISIONS: usize = 14; +/// Spline subdivisions between each pair of C-alpha atoms, for small structures. +/// +/// Small structures cost little to tessellate whatever the setting, and are the +/// ones most likely to be zoomed in on, so they keep the finer mesh. +const SMALL_SPLINE_SUBDIVISIONS: usize = 12; + +/// Vertices around the coil/turn tube cross-section, for small structures. +const SMALL_COIL_SEGMENTS: usize = 10; + +/// Default spline subdivisions. +/// +/// Measured against a 14x12 mesh at 1600x736 (the largest framebuffer a FullHD +/// terminal viewport produces), 8x8 renders 2.1x faster for a mean per-channel +/// error of 0.40/255, with differences confined to silhouette edges. At braille +/// and HD resolutions the difference is smaller still. +const DEFAULT_SPLINE_SUBDIVISIONS: usize = 8; /// Default number of vertices around the coil/turn tube cross-section. -const DEFAULT_COIL_SEGMENTS: usize = 12; +const DEFAULT_COIL_SEGMENTS: usize = 8; /// Reduced spline subdivisions for large structures (>5000 residues). const LARGE_SPLINE_SUBDIVISIONS: usize = 4; @@ -28,6 +42,9 @@ const LARGE_SPLINE_SUBDIVISIONS: usize = 4; /// Reduced coil segments for large structures (>5000 residues). const LARGE_COIL_SEGMENTS: usize = 6; +/// Residue count below which the finer small-structure mesh is used. +const SMALL_STRUCTURE_THRESHOLD: usize = 300; + /// Level-of-detail configuration for ribbon mesh generation. /// Large structures use reduced subdivision counts to cut triangle count /// with zero visible difference at terminal resolution. @@ -38,6 +55,13 @@ struct LodConfig { } impl LodConfig { + fn small() -> Self { + Self { + spline_subdivisions: SMALL_SPLINE_SUBDIVISIONS, + coil_segments: SMALL_COIL_SEGMENTS, + } + } + fn normal() -> Self { Self { spline_subdivisions: DEFAULT_SPLINE_SUBDIVISIONS, @@ -56,6 +80,8 @@ impl LodConfig { fn for_residue_count(residue_count: usize) -> Self { if residue_count > crate::app::LARGE_STRUCTURE_THRESHOLD { Self::large() + } else if residue_count <= SMALL_STRUCTURE_THRESHOLD { + Self::small() } else { Self::normal() } @@ -483,8 +509,16 @@ fn resample_ring(ring: &[V3], target_count: usize) -> Vec { /// The returned triangles are in world space. The caller should project each /// vertex through the camera and then rasterize. pub fn generate_ribbon_mesh(protein: &Protein, color_scheme: &ColorScheme) -> Vec { - let mut triangles: Vec = Vec::new(); let lod = LodConfig::for_residue_count(protein.residue_count()); + generate_ribbon_mesh_with_lod(protein, color_scheme, lod) +} + +fn generate_ribbon_mesh_with_lod( + protein: &Protein, + color_scheme: &ColorScheme, + lod: LodConfig, +) -> Vec { + let mut triangles: Vec = Vec::new(); for chain in &protein.chains { match chain.molecule_type { From baeda14667d87dab5e7f175b587f23a1ebd67848 Mon Sep 17 00:00:00 2001 From: jamaliki <39654543+jamaliki@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:51:43 +0100 Subject: [PATCH 02/10] feat: add HDplus render mode - anti-aliased braille at close to HD's cost HD (HalfBlock) is left byte-identical; HDplus is a new tier alongside it, reached with `m` (Braille -> HD -> HDplus) or `--render hdplus` (aliases hd+, halfblockplus, half-block-plus). A braille cell addresses a fixed 2x4 dot grid, so extra spatial resolution is not available in this render path. What is available is a better decision about each dot. HDplus rasterizes into a 2x supersampled framebuffer and box-filters it back down: each dot is backed by a 2x2 sample block and lights at >=50% coverage rather than on a single sampled pixel, and the cell color averages every covered sample of its lit dots. This anti-aliases silhouettes and stops thin ribbons crawling between dots as the camera rotates. The emitted character grid is identical to HD's, so nothing extra reaches the terminal. Supersampling is close to free here because the HD framebuffer is small enough that cost is dominated by the resolution-independent per-triangle projection stage. Measured on 4HG6 (1402 residues, 11029 atoms) at 200x50: Cartoon HD 6.45 ms -> HDplus 6.75 ms Backbone HD 1.64 ms -> HDplus 2.63 ms Wireframe HD 3.34 ms -> HDplus 5.64 ms (33 ms frame budget) Two details that are easy to get wrong, both covered by tests: - Line thickness must be derived from the output resolution and only then scaled by the supersampling factor. Deriving it from the supersampled width lets the clamp(1.0, 3.0) floor absorb the factor and renders strokes thin. - The camera must be scaled by the same factor, because zoom and pan are in framebuffer pixel units. Otherwise the protein covers the same pixel count in a buffer twice as wide and downsamples to half the size of Braille and HD. Color quantization (8 over SSH, 4 local) merges run-length spans that supersampling would otherwise break, cutting SGR color escapes ~18% on 4HG6. Adds 8 tests, including a viewport-level test that drives render_viewport through a TestBackend so the camera-scaling wiring is covered, not just the rasterizer in isolation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Map4Nz8qwRcPi5iXQ9kpwd --- src/app.rs | 17 ++- src/main.rs | 3 +- src/render/framebuffer.rs | 225 +++++++++++++++++++++++++++++++++++--- src/render/hd.rs | 202 +++++++++++++++++++++++++++++++++- src/ui/help_overlay.rs | 2 +- src/ui/viewport.rs | 195 ++++++++++++++++++++++++++++++++- 6 files changed, 615 insertions(+), 29 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0732f38..867d09b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -47,6 +47,11 @@ pub enum RenderMode { /// HD-quality colored braille via software rasterizer (Lambert shading, /// z-buffer, depth fog). Fast everywhere including SSH. HalfBlock, + /// Same 2x4 braille grid as [`RenderMode::HalfBlock`], but rasterized into a + /// supersampled framebuffer and box-filtered back down, with color + /// quantization applied during the conversion. Anti-aliased silhouettes and + /// stable per-cell color, for the same number of characters on the wire. + HalfBlockPlus, /// Full pixel graphics via Sixel/Kitty/iTerm2 - best quality, high bandwidth FullHD, } @@ -56,6 +61,7 @@ impl RenderMode { match self { Self::Braille => "Braille", Self::HalfBlock => "HD", + Self::HalfBlockPlus => "HDplus", Self::FullHD => "FullHD", } } @@ -187,7 +193,7 @@ impl App { }; 0.9 * px_w.min(px_h) / (2.0 * radius) } - RenderMode::HalfBlock => { + RenderMode::HalfBlock | RenderMode::HalfBlockPlus => { let px_w = vp_cols * 2.0; let px_h = vp_rows * 4.0; 0.9 * px_w.min(px_h) / (2.0 * radius) @@ -503,19 +509,20 @@ impl App { (vp_cols * 2.0, vp_rows * 4.0) } } - RenderMode::HalfBlock => (vp_cols * 2.0, vp_rows * 4.0), + RenderMode::HalfBlock | RenderMode::HalfBlockPlus => (vp_cols * 2.0, vp_rows * 4.0), RenderMode::Braille => (vp_cols * 2.0, vp_rows * 4.0), }; self.camera.zoom = 0.9 * px_w.min(px_h) / (2.0 * radius); } - /// Cycle lower render tiers: Braille -> HalfBlock -> Braille. - /// From FullHD, steps down to HalfBlock (next lower tier). + /// Cycle lower render tiers: Braille -> HD -> HDplus -> Braille. + /// From FullHD, steps down to HD (next lower tier). /// Bound to `m`. pub fn toggle_hd(&mut self, term_cols: u16, term_rows: u16) { self.render_mode = match self.render_mode { RenderMode::Braille => RenderMode::HalfBlock, - RenderMode::HalfBlock => RenderMode::Braille, + RenderMode::HalfBlock => RenderMode::HalfBlockPlus, + RenderMode::HalfBlockPlus => RenderMode::Braille, RenderMode::FullHD => RenderMode::HalfBlock, }; // Dismiss any stale SSH warning (no longer in FullHD) diff --git a/src/main.rs b/src/main.rs index f476242..97c5d96 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,7 +48,7 @@ struct Cli { #[arg(long, alias = "pixel")] fullhd: bool, - /// Render mode: braille, halfblock (or hd), fullhd (or pixel) + /// Render mode: braille, halfblock (or hd), hdplus (or hd+), fullhd (or pixel) #[arg(long = "render", value_name = "MODE")] render_mode: Option, @@ -206,6 +206,7 @@ fn main() -> Result<()> { match mode_str.to_ascii_lowercase().as_str() { "braille" => RenderMode::Braille, "halfblock" | "hd" | "half-block" => RenderMode::HalfBlock, + "hdplus" | "hd+" | "halfblockplus" | "half-block-plus" => RenderMode::HalfBlockPlus, "fullhd" | "pixel" | "full-hd" => RenderMode::FullHD, _ => { eprintln!("Warning: unknown render mode '{}', using default", mode_str); diff --git a/src/render/framebuffer.rs b/src/render/framebuffer.rs index fbcb6df..6f51a80 100644 --- a/src/render/framebuffer.rs +++ b/src/render/framebuffer.rs @@ -772,15 +772,37 @@ pub fn framebuffer_to_widget(fb: &Framebuffer) -> Paragraph<'static> { /// per-cell (rather than per-pixel) coloring. /// /// Consecutive cells with the same foreground color are merged into a single -/// [`Span`] for performance (run-length encoding). Color quantization is -/// available via `quant_step` but currently disabled (`quant_step = 1`, a -/// no-op). Set it to e.g. 4 or 8 to reduce distinct colors and increase -/// run-length merging at the expense of color precision. -#[allow(clippy::needless_range_loop)] +/// [`Span`] for performance (run-length encoding). pub fn framebuffer_to_braille_widget(fb: &Framebuffer) -> Paragraph<'static> { + framebuffer_to_braille_widget_ssaa(fb, 1, 1) +} + +/// Supersampled variant of [`framebuffer_to_braille_widget`]. +/// +/// The framebuffer is expected to have dimensions `(cols * 2 * ssaa, rows * 4 * +/// ssaa)`, so that each braille dot is backed by an `ssaa x ssaa` block of +/// samples rather than a single pixel. Each block is box-filtered: the dot is +/// lit once the block is at least half covered, and the cell's foreground color +/// averages every covered sample of its lit dots. This anti-aliases silhouettes +/// and keeps colors stable as the camera rotates, at no cost in emitted bytes -- +/// the widget is still exactly `cols x rows` braille characters. +/// +/// `quant_step` rounds each channel to a multiple of `step` before run-length +/// merging; `1` disables it. Larger values merge many more cells into a single +/// [`Span`], which cuts the number of SGR color escapes written to the terminal +/// -- the dominant cost of this render path over SSH -- at a small loss of color +/// precision. +#[allow(clippy::needless_range_loop)] +pub fn framebuffer_to_braille_widget_ssaa( + fb: &Framebuffer, + ssaa: usize, + quant_step: u8, +) -> Paragraph<'static> { + let ssaa = ssaa.max(1); + // Terminal cell grid dimensions derived from the framebuffer. - let term_cols = fb.width.div_ceil(2); - let term_rows = fb.height.div_ceil(4); + let term_cols = fb.width.div_ceil(2 * ssaa); + let term_rows = fb.height.div_ceil(4 * ssaa); if term_cols == 0 || term_rows == 0 { return Paragraph::new(""); @@ -798,7 +820,11 @@ pub fn framebuffer_to_braille_widget(fb: &Framebuffer) -> Paragraph<'static> { [0x08, 0x10, 0x20, 0x80], // column 1: rows 0-3 ]; - let quant_step: u8 = 1; + // Samples backing one braille dot, and the coverage needed to light it. + // At `ssaa == 1` the threshold is 1, i.e. "any non-black pixel lights the + // dot" -- identical to the non-supersampled behaviour. + let samples_per_dot = ssaa * ssaa; + let coverage_threshold = samples_per_dot.div_ceil(2) as u32; let mut lines: Vec> = Vec::with_capacity(term_rows); @@ -833,22 +859,40 @@ pub fn framebuffer_to_braille_widget(fb: &Framebuffer) -> Paragraph<'static> { let mut on_count: u32 = 0; for (dx, col_bits) in BRAILLE_BITS.iter().enumerate() { - let px = px_base + dx; - if px >= fb.width { + let sx0 = (px_base + dx) * ssaa; + if sx0 >= fb.width { continue; } for (dy, &bit) in col_bits.iter().enumerate() { - let py = py_base + dy; - if py >= fb.height { + let sy0 = (py_base + dy) * ssaa; + if sy0 >= fb.height { continue; } - let c = fb.color[py * fb.width + px]; - if c != [0, 0, 0] { + + // Box-filter the sample block backing this dot. + let mut covered: u32 = 0; + let mut r: u32 = 0; + let mut g: u32 = 0; + let mut b: u32 = 0; + for sy in sy0..(sy0 + ssaa).min(fb.height) { + let row = sy * fb.width; + for sx in sx0..(sx0 + ssaa).min(fb.width) { + let c = fb.color[row + sx]; + if c != [0, 0, 0] { + covered += 1; + r += c[0] as u32; + g += c[1] as u32; + b += c[2] as u32; + } + } + } + + if covered >= coverage_threshold { bits |= bit; - r_sum += c[0] as u32; - g_sum += c[1] as u32; - b_sum += c[2] as u32; - on_count += 1; + r_sum += r; + g_sum += g; + b_sum += b; + on_count += covered; } } } @@ -1270,4 +1314,149 @@ mod tests { drawn ); } + + // --------------------------------------------------------------------- + // Supersampled braille conversion + // --------------------------------------------------------------------- + + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::widgets::Widget; + + /// Render a widget into a cell buffer so the emitted glyphs and colors can + /// be inspected directly. + fn render_cells(widget: Paragraph<'static>, w: u16, h: u16) -> Buffer { + let area = Rect::new(0, 0, w, h); + let mut buf = Buffer::empty(area); + widget.render(area, &mut buf); + buf + } + + /// Count foreground-color changes along each row. This is the quantity that + /// drives how many SGR escape sequences reach the terminal, i.e. the + /// bandwidth cost of a frame. + fn color_runs(buf: &Buffer, w: u16, h: u16) -> usize { + let mut runs = 0; + for y in 0..h { + let mut prev: Option = None; + for x in 0..w { + let fg = buf[(x, y)].fg; + if prev != Some(fg) { + runs += 1; + prev = Some(fg); + } + } + } + runs + } + + #[test] + fn ssaa_one_lights_a_dot_from_any_non_black_pixel() { + // At ssaa == 1 the coverage threshold is 1, preserving the original + // "any non-black pixel lights the dot" behaviour exactly. + let mut fb = Framebuffer::new(2, 4); + fb.color[0] = [255, 0, 0]; // dot (dx=0, dy=0) -> bit 0x01 + + let buf = render_cells(framebuffer_to_braille_widget(&fb), 1, 1); + let expected = char::from_u32(0x2800 + 0x01).unwrap().to_string(); + assert_eq!(buf[(0, 0)].symbol(), expected); + } + + #[test] + fn ssaa_dot_needs_half_coverage_to_light() { + // 4x8 framebuffer at ssaa = 2 is exactly one terminal cell: each of the + // 2x4 braille dots is backed by a 2x2 block of samples. + let mut fb = Framebuffer::new(4, 8); + + // Dot (dx=0, dy=0) covers samples x in [0,2), y in [0,2). + // One covered sample out of four is 25% -- below threshold, stays dark. + fb.color[0] = [255, 0, 0]; + + // Dot (dx=1, dy=0) covers samples x in [2,4), y in [0,2). + // Two covered samples out of four is 50% -- lights up (bit 0x08). + fb.color[2] = [0, 255, 0]; + fb.color[3] = [0, 255, 0]; + + let buf = render_cells(framebuffer_to_braille_widget_ssaa(&fb, 2, 1), 1, 1); + let expected = char::from_u32(0x2800 + 0x08).unwrap().to_string(); + assert_eq!( + buf[(0, 0)].symbol(), + expected, + "only the half-covered dot should light" + ); + } + + #[test] + fn ssaa_grid_maps_to_the_same_cell_dimensions() { + // A supersampled framebuffer must still produce cols x rows cells -- + // supersampling buys quality, never extra characters on the wire. + let (cols, rows, ssaa) = (7usize, 3usize, 2usize); + let fb = Framebuffer::new(cols * 2 * ssaa, rows * 4 * ssaa); + + let plain = Framebuffer::new(cols * 2, rows * 4); + let a = render_cells( + framebuffer_to_braille_widget_ssaa(&fb, ssaa, 1), + cols as u16, + rows as u16, + ); + let b = render_cells( + framebuffer_to_braille_widget(&plain), + cols as u16, + rows as u16, + ); + assert_eq!(a, b, "ssaa must not change the emitted cell grid"); + } + + #[test] + fn quantization_merges_color_runs() { + // Eight cells whose colors differ by only 2 per channel -- the kind of + // near-identical neighbours supersampled shading produces. + let cols = 8usize; + let mut fb = Framebuffer::new(cols * 2, 4); + for cell in 0..cols { + for dx in 0..2 { + for dy in 0..4 { + let idx = dy * fb.width + cell * 2 + dx; + fb.color[idx] = [100 + 2 * cell as u8, 150, 200]; + } + } + } + + let unquantized = color_runs( + &render_cells( + framebuffer_to_braille_widget_ssaa(&fb, 1, 1), + cols as u16, + 1, + ), + cols as u16, + 1, + ); + let quantized = color_runs( + &render_cells( + framebuffer_to_braille_widget_ssaa(&fb, 1, 8), + cols as u16, + 1, + ), + cols as u16, + 1, + ); + + assert_eq!(unquantized, cols, "every cell should differ without quantization"); + assert!( + quantized < unquantized, + "quantization should merge runs (got {quantized}, unquantized {unquantized})" + ); + } + + #[test] + fn quantization_never_darkens_a_lit_cell_to_black() { + // A very dark but non-black cell must not quantize to black, which + // would make lit geometry invisible. + let mut fb = Framebuffer::new(2, 4); + for i in 0..fb.color.len() { + fb.color[i] = [1, 1, 1]; + } + let buf = render_cells(framebuffer_to_braille_widget_ssaa(&fb, 1, 8), 1, 1); + assert_ne!(buf[(0, 0)].fg, Color::Rgb(0, 0, 0)); + } } diff --git a/src/render/hd.rs b/src/render/hd.rs index 8781dfc..84a3562 100644 --- a/src/render/hd.rs +++ b/src/render/hd.rs @@ -24,6 +24,41 @@ pub fn render_hd_framebuffer( mesh: &[RibbonTriangle], show_ligands: bool, interactions: &[Interaction], +) -> Framebuffer { + render_hd_framebuffer_ssaa( + protein, + camera, + color_scheme, + viz_mode, + width, + height, + mesh, + show_ligands, + interactions, + 1.0, + ) +} + +/// Like [`render_hd_framebuffer`], but aware that the caller intends to +/// downsample the result by a factor of `ssaa` before display. +/// +/// `width` / `height` are the *supersampled* framebuffer dimensions, i.e. the +/// output resolution already multiplied by `ssaa`. The factor is needed +/// separately because line thickness and circle radii must be derived from the +/// **output** resolution and then scaled up, so that features downsample to the +/// same apparent size rather than becoming proportionally thinner. +#[allow(clippy::too_many_arguments)] +pub fn render_hd_framebuffer_ssaa( + protein: &Protein, + camera: &Camera, + color_scheme: &ColorScheme, + viz_mode: VizMode, + width: f64, + height: f64, + mesh: &[RibbonTriangle], + show_ligands: bool, + interactions: &[Interaction], + ssaa: f64, ) -> Framebuffer { let px_w = width as usize; let px_h = height as usize; @@ -36,12 +71,23 @@ pub fn render_hd_framebuffer( let half_w = px_w as f64 / 2.0; let half_h = px_h as f64 / 2.0; - // Scale line thickness and circle radii relative to framebuffer size. + // Scale line thickness and circle radii relative to the *output* size. // Values were tuned at ~160px wide (braille resolution) where 1.5px // lines and circles look correct. At FullHD (~640px+) we scale up // proportionally. Floor of 1.0 preserves the original look at low // resolutions; ceiling of 3.0 caps growth on 4K terminals. - let ts = (px_w as f64 / 500.0).clamp(1.0, 3.0); + // + // When supersampling, the clamp must be evaluated against the resolution + // the user actually sees and only then multiplied by `ssaa`. Deriving it + // from the supersampled width instead would let the clamp floor absorb the + // factor and render features too thin once downsampled. + let ssaa = if ssaa.is_finite() && ssaa >= 1.0 { + ssaa + } else { + 1.0 + }; + let output_px_w = px_w as f64 / ssaa; + let ts = (output_px_w / 500.0).clamp(1.0, 3.0) * ssaa; // Pre-compute sin/cos once for the entire frame instead of per-vertex. let cache = camera.projection_cache(); @@ -564,3 +610,155 @@ fn interaction_color(t: InteractionType) -> [u8; 3] { InteractionType::Other => [160, 160, 160], // gray } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::protein::{Atom, Chain, MoleculeType, Residue, SecondaryStructure}; + use crate::render::color::ColorSchemeType; + + /// A short CA trace running diagonally across the view. + fn trace_protein() -> Protein { + let atom = |x: f64, y: f64| Atom { + name: "CA".to_string(), + element: "C".to_string(), + x, + y, + z: 0.0, + b_factor: 20.0, + is_backbone: true, + is_hetero: false, + }; + let residues = (0..12) + .map(|i| Residue { + name: "ALA".to_string(), + seq_num: i + 1, + insertion_code: None, + atoms: vec![atom(i as f64 * 6.0 - 33.0, i as f64 * 3.0 - 16.0)], + secondary_structure: SecondaryStructure::Coil, + }) + .collect(); + Protein { + name: "trace".to_string(), + chains: vec![Chain { + id: "A".to_string(), + residues, + molecule_type: MoleculeType::Protein, + }], + ligands: Vec::new(), + } + } + + /// Fraction of framebuffer pixels carrying geometry. + fn ink_fraction(fb: &Framebuffer) -> f64 { + let lit = fb.color.iter().filter(|c| **c != [0, 0, 0]).count(); + lit as f64 / fb.color.len() as f64 + } + + /// Bounding box of lit pixels, expressed as fractions of the framebuffer + /// dimensions. This is the protein's *apparent* size: what the viewer sees + /// once the buffer is downsampled onto the terminal cell grid. + fn normalized_bbox(fb: &Framebuffer) -> (f64, f64) { + let (mut min_x, mut max_x) = (usize::MAX, 0usize); + let (mut min_y, mut max_y) = (usize::MAX, 0usize); + for y in 0..fb.height { + for x in 0..fb.width { + if fb.color[y * fb.width + x] != [0, 0, 0] { + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + } + } + } + assert!(min_x != usize::MAX, "fixture should draw something"); + ( + (max_x - min_x) as f64 / fb.width as f64, + (max_y - min_y) as f64 / fb.height as f64, + ) + } + + /// Render the fixture the way the viewport does: the framebuffer is scaled + /// by `ssaa`, and the camera is scaled to match because zoom and pan are + /// both in framebuffer pixel units. + fn render_trace(out_w: f64, out_h: f64, ssaa: f64) -> Framebuffer { + let protein = trace_protein(); + let mut camera = Camera::default(); + camera.zoom = 4.0 * ssaa; + let scheme = ColorScheme::new(ColorSchemeType::Structure, 12); + render_hd_framebuffer_ssaa( + &protein, + &camera, + &scheme, + VizMode::Backbone, + out_w * ssaa, + out_h * ssaa, + &[], + false, + &[], + ssaa, + ) + } + + #[test] + fn supersampling_preserves_apparent_size() { + // HDplus must render the protein at the same apparent size as HD. + // Scaling the framebuffer by `ssaa` without scaling the camera would + // leave the protein covering the same pixel count in a buffer twice as + // wide, i.e. half the size once downsampled. + let (bw, bh) = normalized_bbox(&render_trace(400.0, 184.0, 1.0)); + let (pw, ph) = normalized_bbox(&render_trace(400.0, 184.0, 2.0)); + + assert!( + (pw - bw).abs() < 0.02 && (ph - bh).abs() < 0.02, + "supersampled extent ({pw:.3}, {ph:.3}) should match base ({bw:.3}, {bh:.3})" + ); + } + + #[test] + fn supersampling_preserves_apparent_line_thickness() { + // Line thickness must be derived from the *output* resolution and then + // scaled by the supersampling factor, so strokes occupy the same + // fraction of the frame. Deriving it from the supersampled width + // instead lets the clamp floor in `ts` absorb the factor and renders + // strokes too thin once downsampled. + let base = ink_fraction(&render_trace(400.0, 184.0, 1.0)); + let supersampled = ink_fraction(&render_trace(400.0, 184.0, 2.0)); + + let ratio = supersampled / base; + assert!( + (0.9..=1.1).contains(&ratio), + "supersampled ink fraction {supersampled:.4} should match base {base:.4} within 10% (ratio {ratio:.3})" + ); + } + + #[test] + fn ssaa_factor_is_ignored_when_invalid() { + // Guards against a NaN or sub-1 factor silently collapsing thickness. + let protein = trace_protein(); + let mut camera = Camera::default(); + camera.zoom = 4.0; + let scheme = ColorScheme::new(ColorSchemeType::Structure, 12); + let render = |ssaa: f64| { + render_hd_framebuffer_ssaa( + &protein, + &camera, + &scheme, + VizMode::Backbone, + 400.0, + 184.0, + &[], + false, + &[], + ssaa, + ) + }; + let expected = ink_fraction(&render(1.0)); + for bad in [f64::NAN, 0.0, -3.0] { + assert!( + (ink_fraction(&render(bad)) - expected).abs() < 1e-9, + "ssaa {bad} should fall back to 1.0" + ); + } + } +} diff --git a/src/ui/help_overlay.rs b/src/ui/help_overlay.rs index fcc05da..d168c2a 100644 --- a/src/ui/help_overlay.rs +++ b/src/ui/help_overlay.rs @@ -60,7 +60,7 @@ pub fn render_help_overlay(frame: &mut Frame, area: Rect) { ]), Line::from(vec![ Span::styled(" m ", Style::default().fg(Color::Yellow)), - Span::raw("Toggle Braille / HD"), + Span::raw("Cycle Braille / HD / HDplus"), ]), Line::from(vec![ Span::styled(" M ", Style::default().fg(Color::Yellow)), diff --git a/src/ui/viewport.rs b/src/ui/viewport.rs index cf39686..00ef8e6 100644 --- a/src/ui/viewport.rs +++ b/src/ui/viewport.rs @@ -4,13 +4,42 @@ use ratatui::layout::Rect; use ratatui_image::picker::ProtocolType; use ratatui_image::{Image, Resize}; -use crate::app::{App, RenderMode}; +use crate::app::{App, ConnectionType, RenderMode}; use crate::model::interface::Interaction; use crate::render::braille; -use crate::render::framebuffer::framebuffer_to_braille_widget; +use crate::render::framebuffer::{framebuffer_to_braille_widget, framebuffer_to_braille_widget_ssaa}; use crate::render::hd; use crate::render::kitty_png::KittyPngImage; +/// Supersampling factor for HDplus mode. +/// +/// A braille cell addresses a fixed 2x4 dot grid, so this buys no extra dots -- +/// it anti-aliases the silhouette and stabilises per-cell color instead. It is +/// close to free: the HD framebuffer is small enough that rasterization cost is +/// dominated by the per-triangle projection stage, which is resolution +/// independent, and the emitted character grid is unchanged. +const HD_SSAA: usize = 2; + +/// Color quantization step for HDplus mode over SSH. +/// +/// Supersampling makes neighbouring cell colors more continuous, which would +/// break more run-length spans and cost bytes on the wire. Rounding each +/// channel to a multiple of this merges them back together; the dominant cost of +/// this render path over SSH is the count of SGR color escapes, not pixels. +const HD_QUANT_STEP_SSH: u8 = 8; + +/// Color quantization step for HDplus on a local terminal, where bandwidth is +/// free but merging spans still reduces per-frame diffing work. +const HD_QUANT_STEP_LOCAL: u8 = 4; + +/// Quantization step to use for the current session. +fn hd_quant_step(connection: ConnectionType) -> u8 { + match connection { + ConnectionType::Ssh => HD_QUANT_STEP_SSH, + ConnectionType::Local => HD_QUANT_STEP_LOCAL, + } +} + /// Render the main 3D viewport pub fn render_viewport(frame: &mut Frame, area: Rect, app: &App) { let interactions: &[Interaction] = if app.show_interface && app.show_interactions { @@ -61,12 +90,57 @@ pub fn render_viewport(frame: &mut Frame, area: Rect, app: &App) { let widget = framebuffer_to_braille_widget(&fb); frame.render_widget(widget, area); } + RenderMode::HalfBlockPlus => { + render_hdplus_viewport(frame, area, app, interactions); + } RenderMode::FullHD => { render_fullhd_viewport(frame, area, app, interactions); } } } +/// Render the HDplus viewport: the same 2x4 braille cell grid as HD, rasterized +/// into a supersampled framebuffer and box-filtered back down. +/// +/// A braille cell addresses a fixed 2x4 dot grid, so supersampling buys no extra +/// dots. What it buys is an anti-aliased silhouette and a stable per-cell color: +/// each dot is lit on >= 50% coverage of its sample block instead of on a single +/// sampled pixel, which stops thin ribbons from crawling between dots as the +/// camera rotates. The emitted character grid is identical to HD's, so the cost +/// on the wire is unchanged. +fn render_hdplus_viewport(frame: &mut Frame, area: Rect, app: &App, interactions: &[Interaction]) { + let ssaa = HD_SSAA as f64; + + // The framebuffer is supersampled, so the camera must be scaled to match -- + // zoom and pan are both in framebuffer pixel units. Without this the + // protein would cover the same pixel count in a buffer twice as wide, and + // downsample to half its apparent size relative to Braille and HD. + let mut cam = app.camera.clone(); + cam.zoom *= ssaa; + cam.pan_x *= ssaa; + cam.pan_y *= ssaa; + + let width = area.width as f64 * 2.0 * ssaa; + let height = area.height as f64 * 4.0 * ssaa; + + let fb = hd::render_hd_framebuffer_ssaa( + &app.protein, + &cam, + &app.color_scheme, + app.viz_mode, + width, + height, + &app.mesh_cache, + app.show_ligands, + interactions, + ssaa, + ); + + let widget = + framebuffer_to_braille_widget_ssaa(&fb, HD_SSAA, hd_quant_step(app.connection_type)); + frame.render_widget(widget, area); +} + /// Render the FullHD viewport using graphics protocol (Sixel/Kitty/iTerm2) when /// available, falling back to colored braille characters otherwise. fn render_fullhd_viewport(frame: &mut Frame, area: Rect, app: &App, interactions: &[Interaction]) { @@ -151,3 +225,120 @@ fn render_fullhd_viewport(frame: &mut Frame, area: Rect, app: &App, interactions let widget = framebuffer_to_braille_widget(&fb); frame.render_widget(widget, area); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{AppConfig, VizMode}; + use crate::model::protein::{Atom, Chain, MoleculeType, Protein, Residue, SecondaryStructure}; + use crate::model::selection::ResidueColorOverrides; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; + use ratatui_image::picker::Picker; + + /// A CA trace spanning enough space to give a measurable extent. + fn fixture() -> Protein { + let residues = (0..16) + .map(|i| Residue { + name: "ALA".to_string(), + seq_num: i + 1, + insertion_code: None, + atoms: vec![Atom { + name: "CA".to_string(), + element: "C".to_string(), + x: i as f64 * 4.0 - 30.0, + y: (i as f64 * 0.7).sin() * 12.0, + z: 0.0, + b_factor: 20.0, + is_backbone: true, + is_hetero: false, + }], + secondary_structure: SecondaryStructure::Coil, + }) + .collect(); + Protein { + name: "trace".to_string(), + chains: vec![Chain { + id: "A".to_string(), + residues, + molecule_type: MoleculeType::Protein, + }], + ligands: Vec::new(), + } + } + + /// Drive the real viewport renderer through a test backend, exactly as the + /// main loop does, and return the resulting cell buffer. + fn draw(mode: RenderMode, cols: u16, rows: u16) -> Buffer { + draw_protein(fixture(), mode, cols, rows) + } + + fn draw_protein(protein: Protein, mode: RenderMode, cols: u16, rows: u16) -> Buffer { + let app = App::new( + protein, + AppConfig { + render_mode: mode, + viz_mode: VizMode::Backbone, + user_explicit_mode: true, + color_override: None, + residue_colors: ResidueColorOverrides::default(), + }, + cols, + rows, + Picker::halfblocks(), + ); + // The main layout reserves 4 rows of chrome around the viewport. + let area = Rect::new(0, 0, cols, rows - 4); + let mut term = Terminal::new(TestBackend::new(cols, rows)).unwrap(); + term.draw(|f| render_viewport(f, area, &app)).unwrap(); + term.backend().buffer().clone() + } + + /// Extent of drawn cells, in terminal cells. + fn extent(buf: &Buffer, cols: u16, rows: u16) -> (u16, u16) { + let (mut min_x, mut max_x) = (u16::MAX, 0u16); + let (mut min_y, mut max_y) = (u16::MAX, 0u16); + for y in 0..rows { + for x in 0..cols { + if buf[(x, y)].symbol().trim().is_empty() { + continue; + } + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + } + } + assert!(min_x != u16::MAX, "viewport should draw something"); + (max_x - min_x, max_y - min_y) + } + + #[test] + fn hdplus_renders_at_the_same_apparent_size_as_hd_and_braille() { + // HDplus rasterizes into a supersampled framebuffer, so the viewport has + // to scale the camera to match. If it does not, the protein downsamples + // to roughly half the size of the other modes. This drives the real + // `render_viewport` wiring rather than the rasterizer in isolation. + let (cols, rows) = (100u16, 40u16); + let braille = extent(&draw(RenderMode::Braille, cols, rows), cols, rows); + let hd = extent(&draw(RenderMode::HalfBlock, cols, rows), cols, rows); + let hdplus = extent(&draw(RenderMode::HalfBlockPlus, cols, rows), cols, rows); + + for (label, other) in [("HD", hd), ("Braille", braille)] { + assert!( + hdplus.0.abs_diff(other.0) <= 2 && hdplus.1.abs_diff(other.1) <= 2, + "HDplus extent {hdplus:?} should match {label} {other:?} within 2 cells" + ); + } + } + + #[test] + fn hdplus_emits_the_same_cell_grid_as_hd() { + // Supersampling must not change how many characters reach the terminal. + let (cols, rows) = (100u16, 40u16); + let hd = draw(RenderMode::HalfBlock, cols, rows); + let hdplus = draw(RenderMode::HalfBlockPlus, cols, rows); + assert_eq!(hd.area(), hdplus.area()); + } +} From 6269186e8e299b7b8895c7f0a11d789c76d9563a Mon Sep 17 00:00:00 2001 From: jamaliki <39654543+jamaliki@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:31:34 +0100 Subject: [PATCH 03/10] fix: stop braille cell colors blending into mud A braille cell carries a single foreground color, but its eight dots routinely straddle several structures at different depths. Averaging every covered sample blended a pink helix, a yellow sheet and a green coil into brown, so the interior of any dense cartoon render came out muddy in both HD and HDplus. The z-buffer already knows which fragment is in front, so the cell now takes the color of its frontmost covered sample instead of the mean. It falls back to the mean when no covered sample carried a finite depth, which keeps framebuffers written without z (as in tests) behaving as before. Measured on 4HG6 at 160x44, mean cell saturation: Braille 1.000 (flat and unshaded -- the reference for vividness) HD 0.872 -> 0.970 HDplus 0.859 -> 0.969 Lambert shading and the depth fog are unchanged, so the depth cue survives; only the choice of which covered sample colors the cell changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Map4Nz8qwRcPi5iXQ9kpwd --- src/render/framebuffer.rs | 125 ++++++++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 26 deletions(-) diff --git a/src/render/framebuffer.rs b/src/render/framebuffer.rs index 6f51a80..78feee3 100644 --- a/src/render/framebuffer.rs +++ b/src/render/framebuffer.rs @@ -537,14 +537,43 @@ impl Framebuffer { /// ratatui-image integration to send the framebuffer to the terminal via /// Sixel, Kitty, or other graphics protocols. pub fn to_rgb_image(&self) -> RgbImage { - let mut img = RgbImage::new(self.width as u32, self.height as u32); - for y in 0..self.height { - for x in 0..self.width { - let c = self.color[y * self.width + x]; - img.put_pixel(x as u32, y as u32, image::Rgb(c)); - } - } - img + let mut buf = vec![0u8; self.color.len() * 3]; + buf.par_chunks_mut(3) + .zip(self.color.par_iter()) + .for_each(|(out, c)| out.copy_from_slice(c)); + RgbImage::from_raw(self.width as u32, self.height as u32, buf) + .expect("buffer is exactly width * height * 3 bytes") + } + + /// Write this framebuffer into `dst` as RGBA8, one byte per channel. + /// + /// `dst` must be exactly `width * height * 4` bytes; the caller owns the + /// allocation, which lets the interactive path write straight into a shared + /// memory mapping instead of building an intermediate image. + /// + /// Background pixels (depth == INFINITY, colour == black) get alpha = 0 so + /// the terminal background shows through. Drawn pixels get alpha = 255. + /// + /// # Panics + /// + /// If `dst` is not exactly `width * height * 4` bytes long. + pub fn write_rgba(&self, dst: &mut [u8]) { + assert_eq!( + dst.len(), + self.color.len() * 4, + "destination must be exactly width * height * 4 bytes" + ); + // Per-pixel and independent; a full frame at Retina resolution is tens + // of megabytes, enough for the copy to be worth spreading over cores. + dst.par_chunks_mut(4) + .zip(self.color.par_iter()) + .zip(self.depth.par_iter()) + .for_each(|((out, c), d)| { + out[0] = c[0]; + out[1] = c[1]; + out[2] = c[2]; + out[3] = if *d >= f32::INFINITY { 0 } else { 255 }; + }); } /// Convert this framebuffer into an `image::RgbaImage` with transparency. @@ -552,20 +581,10 @@ impl Framebuffer { /// Background pixels (depth == INFINITY, color == black) get alpha = 0 so /// the terminal background shows through. Drawn pixels get alpha = 255. pub fn to_rgba_image(&self) -> RgbaImage { - let mut img = RgbaImage::new(self.width as u32, self.height as u32); - for y in 0..self.height { - for x in 0..self.width { - let idx = y * self.width + x; - let c = self.color[idx]; - let alpha = if self.depth[idx] >= f32::INFINITY { - 0 - } else { - 255 - }; - img.put_pixel(x as u32, y as u32, image::Rgba([c[0], c[1], c[2], alpha])); - } - } - img + let mut buf = vec![0u8; self.color.len() * 4]; + self.write_rgba(&mut buf); + RgbaImage::from_raw(self.width as u32, self.height as u32, buf) + .expect("buffer is exactly width * height * 4 bytes") } /// Draw a filled circle with a specific z-depth for z-buffer testing. @@ -857,6 +876,13 @@ pub fn framebuffer_to_braille_widget_ssaa( let mut g_sum: u32 = 0; let mut b_sum: u32 = 0; let mut on_count: u32 = 0; + // Color of the frontmost covered sample in the cell. A braille cell + // carries a single foreground color, but its eight dots can straddle + // several structures at different depths; averaging them blends a pink + // helix and a green coil into brown. Taking the nearest sample instead + // shows what is actually in front, and keeps colors saturated. + let mut cell_near_z = f32::INFINITY; + let mut cell_near_c = [0u8; 3]; for (dx, col_bits) in BRAILLE_BITS.iter().enumerate() { let sx0 = (px_base + dx) * ssaa; @@ -874,6 +900,8 @@ pub fn framebuffer_to_braille_widget_ssaa( let mut r: u32 = 0; let mut g: u32 = 0; let mut b: u32 = 0; + let mut dot_near_z = f32::INFINITY; + let mut dot_near_c = [0u8; 3]; for sy in sy0..(sy0 + ssaa).min(fb.height) { let row = sy * fb.width; for sx in sx0..(sx0 + ssaa).min(fb.width) { @@ -883,6 +911,11 @@ pub fn framebuffer_to_braille_widget_ssaa( r += c[0] as u32; g += c[1] as u32; b += c[2] as u32; + let z = fb.depth[row + sx]; + if z < dot_near_z { + dot_near_z = z; + dot_near_c = c; + } } } } @@ -893,6 +926,10 @@ pub fn framebuffer_to_braille_widget_ssaa( g_sum += g; b_sum += b; on_count += covered; + if dot_near_z < cell_near_z { + cell_near_z = dot_near_z; + cell_near_c = dot_near_c; + } } } } @@ -913,14 +950,18 @@ pub fn framebuffer_to_braille_widget_ssaa( } } else { // Compute average color of "on" pixels. - let avg = quantize_color( + // Fall back to the mean when no covered sample carried a finite + // depth -- a framebuffer whose colors were written without z. + let raw = if cell_near_z.is_finite() { + cell_near_c + } else { [ (r_sum / on_count) as u8, (g_sum / on_count) as u8, (b_sum / on_count) as u8, - ], - quant_step, - ); + ] + }; + let avg = quantize_color(raw, quant_step); let cell_color = Some(avg); let braille_char = char::from_u32(0x2800u32 + bits as u32).unwrap_or(' '); @@ -1459,4 +1500,36 @@ mod tests { let buf = render_cells(framebuffer_to_braille_widget_ssaa(&fb, 1, 8), 1, 1); assert_ne!(buf[(0, 0)].fg, Color::Rgb(0, 0, 0)); } + + #[test] + fn cell_color_takes_the_frontmost_sample_not_the_mean() { + // One cell whose dots straddle two structures at different depths. The + // cell carries a single foreground color, so averaging a red front and a + // green back yields a muddy olive; it must show the front color instead. + let mut fb = Framebuffer::new(2, 4); + fb.color[0] = [255, 0, 0]; + fb.depth[0] = 10.0; // far + fb.color[1] = [0, 255, 0]; + fb.depth[1] = 1.0; // near + + let buf = render_cells(framebuffer_to_braille_widget(&fb), 1, 1); + assert_eq!( + buf[(0, 0)].fg, + Color::Rgb(0, 255, 0), + "cell should take the nearer sample's color, not the blend" + ); + } + + #[test] + fn cell_color_falls_back_to_the_mean_without_depth() { + // A framebuffer whose colors were written without z has no frontmost + // sample to pick, so the mean is the only sensible answer. + let mut fb = Framebuffer::new(2, 4); + fb.color[0] = [255, 0, 0]; + fb.color[1] = [0, 255, 0]; + // depths left at INFINITY + + let buf = render_cells(framebuffer_to_braille_widget(&fb), 1, 1); + assert_eq!(buf[(0, 0)].fg, Color::Rgb(127, 127, 0)); + } } From ca137cfd37d8882ceaa22cd148ac5a41431e6c4d Mon Sep 17 00:00:00 2001 From: jamaliki <39654543+jamaliki@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:58:59 +0100 Subject: [PATCH 04/10] feat: make color palettes configurable from a TOML file Every fixed color was a `Color::Rgb(...)` literal buried in a match arm in color.rs, including the eight-entry chain cycle written out twice. They now come from a `Palette` resolved once at startup. ProteinView reads ~/.config/proteinview/palette.toml (or under $XDG_CONFIG_HOME) when it exists; --palette overrides that. Covers the Structure, Chain, Element, B-factor, pLDDT and Interface schemes, nucleic acid bases, and small molecules. Rainbow's HSV sweep and the depth fog are still fixed. Design notes: - Every key is optional and falls back to its built-in default, so a file containing one color is valid. Element symbols merge onto the CPK table rather than replacing it, so overriding carbon does not silently drop every other element; the chain list replaces outright, since it is an ordered cycle. - Unknown keys are rejected. A typo naming a color you cannot otherwise verify should say so, not quietly do nothing: `helics` reports "unknown field `helics`, expected one of `helix`, `sheet`, `turn`, `coil`". - Chain colors live under a [chain] section rather than a bare top-level `chains` key, because in TOML a bare key written after any [table] header binds to that table instead of the document root -- a trap the first draft of the example file fell into. A test covers it. - The palette is process-wide read-only state in a OnceLock rather than being threaded through every renderer, since element_color and plddt_color are associated functions with no self to hang it on. - The B-factor gradient is written as `low * (1 - t) + high * t` specifically so the default blue-to-red endpoints reproduce the previous hardcoded arithmetic bit for bit. Defaults reproduce the previous colors exactly: every pre-existing color assertion in color.rs passes untouched, and rendering 4HG6 with the fully populated docs/palette.example.toml is byte-identical to rendering it with no config at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Map4Nz8qwRcPi5iXQ9kpwd --- Cargo.lock | 60 +++++ Cargo.toml | 1 + README.md | 27 ++ docs/palette.example.toml | 97 +++++++ src/main.rs | 9 + src/render/color.rs | 158 ++++++----- src/render/mod.rs | 1 + src/render/palette.rs | 532 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 800 insertions(+), 85 deletions(-) create mode 100644 docs/palette.example.toml create mode 100644 src/render/palette.rs diff --git a/Cargo.lock b/Cargo.lock index 7857920..b5ce958 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1671,6 +1671,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "toml", ] [[package]] @@ -2130,6 +2131,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2450,6 +2460,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -3048,6 +3099,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index bbefc87..2f59fb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ flate2 = "1" rayon = "1" reqwest = { version = "0.12", features = ["blocking"], optional = true } serde = { version = "1", features = ["derive"] } +toml = "0.8" serde_json = "1" tempfile = { version = "3.27.0", optional = true } tokio = { version = "1", features = ["rt", "macros"], optional = true } diff --git a/README.md b/README.md index a53bf83..8177d28 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,33 @@ leave the prior frame, state, and revision unchanged. | **Rainbow** | N-terminus (blue) to C-terminus (red). | | **pLDDT** | AlphaFold confidence (blue=high, orange=low). | +### Customizing the palette + +Every fixed color above can be changed from a TOML file. ProteinView reads +`~/.config/proteinview/palette.toml` (or `$XDG_CONFIG_HOME/proteinview/palette.toml`) +when it exists, and `--palette ` overrides that: + +```bash +proteinview examples/1UBQ.pdb --palette my-palette.toml +``` + +Every key is optional — anything you leave out keeps its built-in default, so a +file this short is valid: + +```toml +[structure] +helix = "#00FFFF" +``` + +Colors are six hex digits, with or without a leading `#`, in either case. +Element symbols merge onto the built-in CPK table, so overriding carbon leaves +the rest alone, while `[chain] colors` replaces the chain cycle outright. Unknown +keys are rejected rather than ignored, so a typo tells you rather than silently +doing nothing. + +See [`docs/palette.example.toml`](docs/palette.example.toml) for a fully +commented file listing every setting at its default value. + ## Terminal Support | Terminal | Braille | HD | FullHD | diff --git a/docs/palette.example.toml b/docs/palette.example.toml new file mode 100644 index 0000000..7ad7b29 --- /dev/null +++ b/docs/palette.example.toml @@ -0,0 +1,97 @@ +# ProteinView color palette +# +# Copy to ~/.config/proteinview/palette.toml to have it picked up automatically, +# or point at it explicitly: +# +# proteinview structure.pdb --palette docs/palette.example.toml +# +# Every key is optional. Anything you leave out keeps its built-in default, so a +# file containing nothing but a single color is perfectly valid. Colors are six +# hexadecimal digits, with or without a leading '#', in either case. +# +# Unknown keys are an error rather than being ignored, so a typo tells you +# instead of silently doing nothing. + +# --- Structure scheme (the default, keyed by secondary structure) ------------ +[structure] +helix = "FF0080" +sheet = "FFC800" +turn = "6080FF" +coil = "00CC00" + +# --- Nucleic acid bases ------------------------------------------------------ +# Used for DNA/RNA residues under the Structure scheme. +[nucleotide] +adenine = "DC3C3C" +uracil = "3C3CDC" +thymine = "3C3CDC" +guanine = "3CB43C" +cytosine = "DCC828" +inosine = "9664B4" + +# --- Chain scheme ------------------------------------------------------------ +# Cycled by chain id. Order matters, and the list replaces the default outright +# rather than merging, so give it as many colors as you want in the rotation. +[chain] +colors = [ + "00B4FF", + "FF6400", + "00DC64", + "FF3296", + "B464FF", + "FFDC00", + "00C8C8", + "FF9696", +] + +# --- Element scheme (CPK) ---------------------------------------------------- +[element] +# Color for any element not listed below. +fallback = "C8C8C8" + +# These *merge* onto the built-in table, so overriding carbon leaves every other +# element alone. Keys are element symbols and are matched case-insensitively. +[element.symbols] +C = "909090" +N = "3050F8" +O = "FF0D0D" +S = "FFFF30" +H = "FFFFFF" +P = "FF8000" +FE = "E06633" +MG = "00B400" +ZN = "7D80B0" +CA = "3DFF00" +MN = "9C7AC7" +CO = "F090A0" +CU = "C88033" +NI = "50D050" +CL = "1FF01F" +BR = "A62929" + +# --- pLDDT scheme (AlphaFold confidence bands) ------------------------------- +[plddt] +very_high = "0053D6" # >= 90 +high = "65CBF3" # >= 70 +low = "FFDB13" # >= 50 +very_low = "FF7D45" # < 50 + +# --- B-factor scheme --------------------------------------------------------- +# Endpoints of a linear gradient across roughly the 5..80 B-factor range. +[bfactor] +low = "0000FF" +high = "FF0000" + +# --- Interface highlighting ('f') -------------------------------------------- +[interface] +focus_contact = "00FF64" # focus chain, touching a partner +focus_other = "28643C" # focus chain, away from the interface +partner_contact = "FFA500" # partner chain, touching the focus chain +partner_other = "64503C" # partner chain, away from the interface +ligand = "FFFFFF" # ligands, kept bright so they stay visible + +# --- Small molecules --------------------------------------------------------- +[ligand] +ligand = "FF00FF" +ion = "00FFFF" +rainbow = "FF00FF" # ligands under the Rainbow scheme diff --git a/src/main.rs b/src/main.rs index 97c5d96..729326e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,10 @@ struct Cli { #[arg(long, value_name = "SELECTOR=RRGGBB")] residue_color: Vec, + /// Palette file (TOML). Defaults to ~/.config/proteinview/palette.toml when present + #[arg(long, value_name = "FILE")] + palette: Option, + /// Visualization mode: cartoon, backbone, wireframe #[arg(long, default_value = "cartoon")] mode: String, @@ -128,6 +132,11 @@ struct Cli { fn main() -> Result<()> { let cli = Cli::parse(); + // Resolve the color palette before anything renders. A bad palette is a + // hard error rather than a silent fallback: ignoring a file the user wrote + // is worse than refusing to start. + render::palette::init(cli.palette.as_deref())?; + // Cap rayon thread pool. 4 threads is the sweet spot: the framebuffer // only has ~60 tiles (64x64) so more threads hit diminishing returns, // and 4 leaves cores free for the terminal emulator and OS. diff --git a/src/render/color.rs b/src/render/color.rs index ff1631a..eac07d0 100644 --- a/src/render/color.rs +++ b/src/render/color.rs @@ -3,8 +3,25 @@ use std::collections::HashSet; use crate::model::interface::InterfaceAnalysis; use crate::model::protein::{Atom, Chain, Ligand, LigandType, Residue, SecondaryStructure}; use crate::model::selection::ResidueColorOverrides; +use crate::render::palette::{Rgb, palette}; use ratatui::style::Color; +/// Convert a palette entry to a ratatui color. +#[inline] +fn rgb(color: Rgb) -> Color { + Color::Rgb(color.0[0], color.0[1], color.0[2]) +} + +/// Linear blend between two palette entries. +/// +/// Written as `low * (1 - t) + high * t` so that the default blue-to-red +/// endpoints reproduce the previous hardcoded arithmetic bit for bit. +#[inline] +fn lerp_rgb(low: Rgb, high: Rgb, t: f64) -> Color { + let channel = |i: usize| (low.0[i] as f64 * (1.0 - t) + high.0[i] as f64 * t) as u8; + Color::Rgb(channel(0), channel(1), channel(2)) +} + /// Available color schemes #[derive(Debug, Clone, Copy, PartialEq)] pub enum ColorSchemeType { @@ -107,7 +124,8 @@ impl ColorScheme { match self.scheme_type { ColorSchemeType::Structure => self.structure_color(residue), ColorSchemeType::Chain => self.chain_color(chain), - ColorSchemeType::Element => Color::Rgb(144, 144, 144), + // Residue-level stand-in; the Element scheme colors per atom. + ColorSchemeType::Element => rgb(palette().element.get("C")), ColorSchemeType::BFactor => self.bfactor_color(residue), ColorSchemeType::Rainbow => self.rainbow_color(residue), ColorSchemeType::Interface => self.interface_color(residue, chain), @@ -141,79 +159,50 @@ impl ColorScheme { )); let is_focus = chain.id == self.focus_chain_id; + let p = &palette().interface; match (is_focus, is_contact) { - (true, true) => Color::Rgb(0, 255, 100), // Bright green — antibody interface - (true, false) => Color::Rgb(40, 100, 60), // Dim green — antibody non-interface - (false, true) => Color::Rgb(255, 165, 0), // Bright orange — antigen interface - (false, false) => Color::Rgb(100, 80, 60), // Dim brown — antigen non-interface + (true, true) => rgb(p.focus_contact), + (true, false) => rgb(p.focus_other), + (false, true) => rgb(p.partner_contact), + (false, false) => rgb(p.partner_other), } } /// CPK-style element coloring pub fn element_color(atom: &Atom) -> Color { - match atom.element.trim() { - "C" => Color::Rgb(144, 144, 144), - "N" => Color::Rgb(48, 80, 248), - "O" => Color::Rgb(255, 13, 13), - "S" => Color::Rgb(255, 255, 48), - "H" => Color::Rgb(255, 255, 255), - "P" => Color::Rgb(255, 128, 0), - "FE" | "Fe" => Color::Rgb(224, 102, 51), - "MG" | "Mg" => Color::Rgb(0, 180, 0), // Magnesium — green - "ZN" | "Zn" => Color::Rgb(125, 128, 176), // Zinc — blue-gray - "CA" | "Ca" => Color::Rgb(61, 255, 0), // Calcium — green - "MN" | "Mn" => Color::Rgb(156, 122, 199), // Manganese — purple - "CO" | "Co" => Color::Rgb(240, 144, 160), // Cobalt — pink - "CU" | "Cu" => Color::Rgb(200, 128, 51), // Copper — brown-orange - "NI" | "Ni" => Color::Rgb(80, 208, 80), // Nickel — green - "CL" | "Cl" => Color::Rgb(31, 240, 31), // Chlorine — green - "BR" | "Br" => Color::Rgb(166, 41, 41), // Bromine — dark red - _ => Color::Rgb(200, 200, 200), - } + rgb(palette().element.get(&atom.element)) } /// Get base color for a ligand based on current scheme. pub fn ligand_color(&self, ligand: &Ligand) -> Color { match self.scheme_type { - ColorSchemeType::Structure => match ligand.ligand_type { - LigandType::Ligand => Color::Rgb(255, 0, 255), // magenta for ligands - LigandType::Ion => Color::Rgb(0, 255, 255), // cyan for ions - }, - ColorSchemeType::Element => Color::Rgb(144, 144, 144), // overridden per-atom + ColorSchemeType::Structure => Self::ligand_base_color(ligand), + // Overridden per-atom; carbon grey stands in for the whole molecule. + ColorSchemeType::Element => rgb(palette().element.get("C")), ColorSchemeType::BFactor => { let avg_b = if ligand.atoms.is_empty() { 0.0 } else { ligand.atoms.iter().map(|a| a.b_factor).sum::() / ligand.atoms.len() as f64 }; - let t = ((avg_b - 5.0) / 75.0).clamp(0.0, 1.0); - let r = (t * 255.0) as u8; - let b = ((1.0 - t) * 255.0) as u8; - Color::Rgb(r, 0, b) - } - ColorSchemeType::Chain => { - // Match parent chain's color using chain_id - let chain_colors = [ - Color::Rgb(0, 180, 255), - Color::Rgb(255, 100, 0), - Color::Rgb(0, 220, 100), - Color::Rgb(255, 50, 150), - Color::Rgb(180, 100, 255), - Color::Rgb(255, 220, 0), - Color::Rgb(0, 200, 200), - Color::Rgb(255, 150, 150), - ]; - let idx = - ligand.chain_id.bytes().next().unwrap_or(b'A') as usize % chain_colors.len(); - chain_colors[idx] + bfactor_gradient(avg_b) } - ColorSchemeType::Rainbow => Color::Rgb(255, 0, 255), - ColorSchemeType::Interface => Color::Rgb(255, 255, 255), // bright white to stand out + // Match parent chain's color using chain_id + ColorSchemeType::Chain => rgb(palette().chain(&ligand.chain_id)), + ColorSchemeType::Rainbow => rgb(palette().ligand.rainbow), + // Drawn bright so it stands out against the interface coloring. + ColorSchemeType::Interface => rgb(palette().interface.ligand), // pLDDT mode: fall back to Structure-mode colors for ligands - ColorSchemeType::Plddt => match ligand.ligand_type { - LigandType::Ligand => Color::Rgb(255, 0, 255), // magenta for ligands - LigandType::Ion => Color::Rgb(0, 255, 255), // cyan for ions - }, + ColorSchemeType::Plddt => Self::ligand_base_color(ligand), + } + } + + /// Palette color for a ligand by kind, shared by the Structure and pLDDT schemes. + fn ligand_base_color(ligand: &Ligand) -> Color { + let p = &palette().ligand; + match ligand.ligand_type { + LigandType::Ligand => rgb(p.ligand), + LigandType::Ion => rgb(p.ion), } } @@ -233,27 +222,17 @@ impl ColorScheme { return color; } + let p = &palette().structure; match residue.secondary_structure { - SecondaryStructure::Helix => Color::Rgb(255, 0, 128), - SecondaryStructure::Sheet => Color::Rgb(255, 200, 0), - SecondaryStructure::Turn => Color::Rgb(96, 128, 255), - SecondaryStructure::Coil => Color::Rgb(0, 204, 0), + SecondaryStructure::Helix => rgb(p.helix), + SecondaryStructure::Sheet => rgb(p.sheet), + SecondaryStructure::Turn => rgb(p.turn), + SecondaryStructure::Coil => rgb(p.coil), } } fn chain_color(&self, chain: &Chain) -> Color { - let chain_colors = [ - Color::Rgb(0, 180, 255), - Color::Rgb(255, 100, 0), - Color::Rgb(0, 220, 100), - Color::Rgb(255, 50, 150), - Color::Rgb(180, 100, 255), - Color::Rgb(255, 220, 0), - Color::Rgb(0, 200, 200), - Color::Rgb(255, 150, 150), - ]; - let idx = chain.id.bytes().next().unwrap_or(b'A') as usize % chain_colors.len(); - chain_colors[idx] + rgb(palette().chain(&chain.id)) } fn bfactor_color(&self, residue: &Residue) -> Color { @@ -262,10 +241,7 @@ impl ColorScheme { } else { residue.atoms.iter().map(|a| a.b_factor).sum::() / residue.atoms.len() as f64 }; - let t = ((avg_b - 5.0) / 75.0).clamp(0.0, 1.0); - let r = (t * 255.0) as u8; - let b = ((1.0 - t) * 255.0) as u8; - Color::Rgb(r, 0, b) + bfactor_gradient(avg_b) } fn rainbow_color(&self, residue: &Residue) -> Color { @@ -286,14 +262,15 @@ impl ColorScheme { /// - >= 50: yellow (low confidence) /// - < 50: orange (very low confidence) pub fn plddt_color(b_factor: f64) -> Color { + let p = &palette().plddt; if b_factor >= 90.0 { - Color::Rgb(0, 83, 214) + rgb(p.very_high) } else if b_factor >= 70.0 { - Color::Rgb(101, 203, 243) + rgb(p.high) } else if b_factor >= 50.0 { - Color::Rgb(255, 219, 19) + rgb(p.low) } else { - Color::Rgb(255, 125, 69) + rgb(p.very_low) } } @@ -308,15 +285,26 @@ impl ColorScheme { } } +/// Map a raw B-factor onto the configured cold-to-hot gradient. +/// +/// The 5..80 domain matches the range these files typically occupy; values +/// outside it clamp to the endpoints. +fn bfactor_gradient(b_factor: f64) -> Color { + let p = &palette().bfactor; + let t = ((b_factor - 5.0) / 75.0).clamp(0.0, 1.0); + lerp_rgb(p.low, p.high, t) +} + /// Returns a base-type color for nucleotide residues, or `None` for non-nucleotides. fn nucleotide_base_color(name: &str) -> Option { + let p = &palette().nucleotide; match name { - "A" | "DA" | "AMP" => Some(Color::Rgb(220, 60, 60)), // Adenine — red - "U" | "UMP" => Some(Color::Rgb(60, 60, 220)), // Uracil — blue - "T" | "DT" => Some(Color::Rgb(60, 60, 220)), // Thymine — blue - "G" | "DG" | "GMP" => Some(Color::Rgb(60, 180, 60)), // Guanine — green - "C" | "DC" | "CMP" => Some(Color::Rgb(220, 200, 40)), // Cytosine — yellow - "I" | "DI" => Some(Color::Rgb(150, 100, 180)), // Inosine — purple + "A" | "DA" | "AMP" => Some(rgb(p.adenine)), + "U" | "UMP" => Some(rgb(p.uracil)), + "T" | "DT" => Some(rgb(p.thymine)), + "G" | "DG" | "GMP" => Some(rgb(p.guanine)), + "C" | "DC" | "CMP" => Some(rgb(p.cytosine)), + "I" | "DI" => Some(rgb(p.inosine)), _ => None, } } diff --git a/src/render/mod.rs b/src/render/mod.rs index 3175305..58efc4c 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -5,5 +5,6 @@ pub mod color; pub mod framebuffer; pub mod hd; pub mod kitty_png; +pub mod palette; pub mod ribbon; pub mod snapshot; diff --git a/src/render/palette.rs b/src/render/palette.rs new file mode 100644 index 0000000..9440e26 --- /dev/null +++ b/src/render/palette.rs @@ -0,0 +1,532 @@ +//! User-configurable color palette. +//! +//! Every fixed color ProteinView draws comes from a [`Palette`]. The built-in +//! defaults reproduce the previously hardcoded colors exactly, so a user with no +//! config file sees no change. A TOML file may override any subset of them: +//! anything omitted keeps its default. +//! +//! The palette is resolved once at startup and read-only thereafter, so it lives +//! in a process-wide [`OnceLock`] rather than being threaded through every +//! renderer. Call [`init`] once from `main`; everything else reads [`palette`]. +//! +//! Procedural schemes (Rainbow's HSV sweep) and the depth fog are not covered +//! here yet. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +/// An RGB triple, deserialized from a hex string such as `"FF0080"` or `"#ff0080"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rgb(pub [u8; 3]); + +impl Rgb { + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self([r, g, b]) + } +} + +impl<'de> Deserialize<'de> for Rgb { + fn deserialize>(deserializer: D) -> Result { + use serde::de::Error; + let raw = String::deserialize(deserializer)?; + let hex = raw.strip_prefix('#').unwrap_or(&raw); + if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(D::Error::custom(format!( + "expected six hexadecimal digits such as \"FF0080\", got {raw:?}" + ))); + } + let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).map_err(D::Error::custom); + Ok(Rgb([byte(0)?, byte(2)?, byte(4)?])) + } +} + +// --------------------------------------------------------------------------- +// Sections +// --------------------------------------------------------------------------- + +/// Secondary-structure colors for the Structure scheme. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct StructurePalette { + pub helix: Rgb, + pub sheet: Rgb, + pub turn: Rgb, + pub coil: Rgb, +} + +impl Default for StructurePalette { + fn default() -> Self { + Self { + helix: Rgb::new(255, 0, 128), + sheet: Rgb::new(255, 200, 0), + turn: Rgb::new(96, 128, 255), + coil: Rgb::new(0, 204, 0), + } + } +} + +/// Per-base colors for nucleic acid residues. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct NucleotidePalette { + pub adenine: Rgb, + pub uracil: Rgb, + pub thymine: Rgb, + pub guanine: Rgb, + pub cytosine: Rgb, + pub inosine: Rgb, +} + +impl Default for NucleotidePalette { + fn default() -> Self { + Self { + adenine: Rgb::new(220, 60, 60), + uracil: Rgb::new(60, 60, 220), + thymine: Rgb::new(60, 60, 220), + guanine: Rgb::new(60, 180, 60), + cytosine: Rgb::new(220, 200, 40), + inosine: Rgb::new(150, 100, 180), + } + } +} + +/// CPK-style element colors. `symbols` is keyed by uppercase element symbol. +#[derive(Debug, Clone)] +pub struct ElementPalette { + pub fallback: Rgb, + pub symbols: HashMap, +} + +impl ElementPalette { + /// Color for an element symbol, case-insensitively. + pub fn get(&self, symbol: &str) -> Rgb { + self.symbols + .get(&symbol.trim().to_ascii_uppercase()) + .copied() + .unwrap_or(self.fallback) + } +} + +impl Default for ElementPalette { + fn default() -> Self { + const CPK: &[(&str, [u8; 3])] = &[ + ("C", [144, 144, 144]), + ("N", [48, 80, 248]), + ("O", [255, 13, 13]), + ("S", [255, 255, 48]), + ("H", [255, 255, 255]), + ("P", [255, 128, 0]), + ("FE", [224, 102, 51]), + ("MG", [0, 180, 0]), + ("ZN", [125, 128, 176]), + ("CA", [61, 255, 0]), + ("MN", [156, 122, 199]), + ("CO", [240, 144, 160]), + ("CU", [200, 128, 51]), + ("NI", [80, 208, 80]), + ("CL", [31, 240, 31]), + ("BR", [166, 41, 41]), + ]; + Self { + fallback: Rgb::new(200, 200, 200), + symbols: CPK + .iter() + .map(|(s, c)| ((*s).to_string(), Rgb(*c))) + .collect(), + } + } +} + +/// AlphaFold pLDDT confidence bands. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct PlddtPalette { + /// pLDDT >= 90 + pub very_high: Rgb, + /// pLDDT >= 70 + pub high: Rgb, + /// pLDDT >= 50 + pub low: Rgb, + /// pLDDT < 50 + pub very_low: Rgb, +} + +impl Default for PlddtPalette { + fn default() -> Self { + Self { + very_high: Rgb::new(0, 83, 214), + high: Rgb::new(101, 203, 243), + low: Rgb::new(255, 219, 19), + very_low: Rgb::new(255, 125, 69), + } + } +} + +/// Endpoints of the B-factor gradient. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct BFactorPalette { + /// Color at the cold end of the range. + pub low: Rgb, + /// Color at the hot end of the range. + pub high: Rgb, +} + +impl Default for BFactorPalette { + fn default() -> Self { + Self { + low: Rgb::new(0, 0, 255), + high: Rgb::new(255, 0, 0), + } + } +} + +/// Interface-highlighting colors. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct InterfacePalette { + /// Focus chain, in contact with a partner. + pub focus_contact: Rgb, + /// Focus chain, away from the interface. + pub focus_other: Rgb, + /// Partner chain, in contact with the focus chain. + pub partner_contact: Rgb, + /// Partner chain, away from the interface. + pub partner_other: Rgb, + /// Ligands, drawn bright so they stand out against the interface coloring. + pub ligand: Rgb, +} + +impl Default for InterfacePalette { + fn default() -> Self { + Self { + focus_contact: Rgb::new(0, 255, 100), + focus_other: Rgb::new(40, 100, 60), + partner_contact: Rgb::new(255, 165, 0), + partner_other: Rgb::new(100, 80, 60), + ligand: Rgb::new(255, 255, 255), + } + } +} + +/// Small-molecule colors. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct LigandPalette { + pub ligand: Rgb, + pub ion: Rgb, + /// Ligands under the Rainbow scheme, which has no per-residue value for them. + pub rainbow: Rgb, +} + +impl Default for LigandPalette { + fn default() -> Self { + Self { + ligand: Rgb::new(255, 0, 255), + ion: Rgb::new(0, 255, 255), + rainbow: Rgb::new(255, 0, 255), + } + } +} + +// --------------------------------------------------------------------------- +// Palette +// --------------------------------------------------------------------------- + +/// The resolved palette: built-in defaults with any configured overrides applied. +#[derive(Debug, Clone)] +pub struct Palette { + pub structure: StructurePalette, + pub nucleotide: NucleotidePalette, + /// Cycled by chain in order; always at least one entry. + pub chains: Vec, + pub element: ElementPalette, + pub plddt: PlddtPalette, + pub bfactor: BFactorPalette, + pub interface: InterfacePalette, + pub ligand: LigandPalette, +} + +impl Default for Palette { + fn default() -> Self { + Self { + structure: StructurePalette::default(), + nucleotide: NucleotidePalette::default(), + chains: vec![ + Rgb::new(0, 180, 255), + Rgb::new(255, 100, 0), + Rgb::new(0, 220, 100), + Rgb::new(255, 50, 150), + Rgb::new(180, 100, 255), + Rgb::new(255, 220, 0), + Rgb::new(0, 200, 200), + Rgb::new(255, 150, 150), + ], + element: ElementPalette::default(), + plddt: PlddtPalette::default(), + bfactor: BFactorPalette::default(), + interface: InterfacePalette::default(), + ligand: LigandPalette::default(), + } + } +} + +impl Palette { + /// Color for the chain whose id starts with `id`, cycling through `chains`. + pub fn chain(&self, id: &str) -> Rgb { + let idx = id.bytes().next().unwrap_or(b'A') as usize % self.chains.len(); + self.chains[idx] + } +} + +// --------------------------------------------------------------------------- +// Config file +// --------------------------------------------------------------------------- + +/// Element section as written in the file. `symbols` is *merged* onto the +/// built-in CPK table rather than replacing it, so overriding carbon does not +/// silently drop every other element. +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct ElementFile { + /// Color for elements not listed in `symbols`. + fallback: Option, + symbols: HashMap, +} + +/// Chain section. A section rather than a bare top-level `chains` key, because +/// in TOML a bare key written after any `[table]` header silently binds to that +/// table instead of the document root. +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct ChainFile { + /// Replaces the default cycle outright; order is significant. + colors: Option>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct PaletteFile { + structure: StructurePalette, + nucleotide: NucleotidePalette, + chain: ChainFile, + element: ElementFile, + plddt: PlddtPalette, + bfactor: BFactorPalette, + interface: InterfacePalette, + ligand: LigandPalette, +} + +impl PaletteFile { + fn resolve(self) -> Result { + let mut palette = Palette { + structure: self.structure, + nucleotide: self.nucleotide, + chains: Palette::default().chains, + element: ElementPalette::default(), + plddt: self.plddt, + bfactor: self.bfactor, + interface: self.interface, + ligand: self.ligand, + }; + + if let Some(colors) = self.chain.colors { + if colors.is_empty() { + anyhow::bail!("`chain.colors` must list at least one color"); + } + palette.chains = colors; + } + if let Some(fallback) = self.element.fallback { + palette.element.fallback = fallback; + } + for (symbol, color) in self.element.symbols { + palette + .element + .symbols + .insert(symbol.trim().to_ascii_uppercase(), color); + } + + Ok(palette) + } +} + +/// Parse a palette from TOML text, filling anything omitted from the defaults. +pub fn parse(text: &str) -> Result { + toml::from_str::(text)?.resolve() +} + +/// Read and parse a palette file. +pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("cannot read palette file {}", path.display()))?; + parse(&text).with_context(|| format!("invalid palette file {}", path.display())) +} + +/// The default palette file location: `$XDG_CONFIG_HOME/proteinview/palette.toml`, +/// falling back to `~/.config/proteinview/palette.toml`. +pub fn default_config_path() -> Option { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?; + Some(base.join("proteinview").join("palette.toml")) +} + +static PALETTE: OnceLock = OnceLock::new(); + +/// The active palette. Defaults are used if [`init`] was never called. +pub fn palette() -> &'static Palette { + PALETTE.get_or_init(Palette::default) +} + +/// Resolve the palette once, from `explicit` if given, else from the default +/// config path if it exists, else from the built-in defaults. +/// +/// An explicit path that cannot be read is an error; so is a malformed file in +/// either location, since silently ignoring a palette the user wrote is worse +/// than refusing to start. Returns the path that was loaded, if any. +pub fn init(explicit: Option<&Path>) -> Result> { + let chosen = match explicit { + Some(path) => Some(path.to_path_buf()), + None => default_config_path().filter(|p| p.is_file()), + }; + + let (resolved, loaded) = match &chosen { + Some(path) => (load(path)?, Some(path.clone())), + None => (Palette::default(), None), + }; + + let _ = PALETTE.set(resolved); + Ok(loaded) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_config_is_the_default_palette() { + let p = parse("").unwrap(); + let d = Palette::default(); + assert_eq!(p.structure.helix, d.structure.helix); + assert_eq!(p.chains, d.chains); + assert_eq!(p.element.get("ZN"), d.element.get("ZN")); + assert_eq!(p.plddt.very_high, d.plddt.very_high); + } + + #[test] + fn overrides_apply_and_the_rest_stays_default() { + let p = parse( + r##" + [structure] + helix = "#112233" + "##, + ) + .unwrap(); + assert_eq!(p.structure.helix, Rgb::new(0x11, 0x22, 0x33)); + // Untouched fields keep their defaults. + assert_eq!(p.structure.sheet, StructurePalette::default().sheet); + assert_eq!(p.structure.coil, StructurePalette::default().coil); + } + + #[test] + fn element_symbols_merge_rather_than_replace() { + // Overriding carbon must not drop the rest of the CPK table. + let p = parse( + r#" + [element.symbols] + C = "010203" + "#, + ) + .unwrap(); + assert_eq!(p.element.get("C"), Rgb::new(1, 2, 3)); + assert_eq!(p.element.get("ZN"), Rgb::new(125, 128, 176)); + assert_eq!(p.element.get("FE"), Rgb::new(224, 102, 51)); + } + + #[test] + fn element_lookup_is_case_insensitive_both_ways() { + let p = parse( + r#" + [element.symbols] + se = "0A0B0C" + "#, + ) + .unwrap(); + assert_eq!(p.element.get("SE"), Rgb::new(10, 11, 12)); + assert_eq!(p.element.get("Se"), Rgb::new(10, 11, 12)); + assert_eq!(p.element.get(" fe "), Rgb::new(224, 102, 51)); + } + + #[test] + fn chains_are_replaced_wholesale_and_cycle() { + let p = parse("[chain]\ncolors = [\"FF0000\", \"00FF00\"]").unwrap(); + assert_eq!(p.chains.len(), 2); + // b'A' = 65, so 65 % 2 = 1 picks the second entry. + assert_eq!(p.chain("A"), Rgb::new(0, 255, 0)); + assert_eq!(p.chain("B"), Rgb::new(255, 0, 0)); + assert_eq!(p.chain("A"), p.chain("C")); + } + + #[test] + fn empty_chain_list_is_rejected() { + let err = parse("[chain]\ncolors = []").unwrap_err().to_string(); + assert!(err.contains("at least one"), "unhelpful error: {err}"); + } + + #[test] + fn chain_colors_survive_being_written_after_other_sections() { + // A bare top-level `chains = [...]` would bind to whichever [table] + // preceded it. Keeping it in its own section makes order irrelevant. + let p = parse( + "[structure]\nhelix = \"FF0000\"\n\n[chain]\ncolors = [\"00FF00\"]", + ) + .unwrap(); + assert_eq!(p.chains, vec![Rgb::new(0, 255, 0)]); + assert_eq!(p.structure.helix, Rgb::new(255, 0, 0)); + } + + #[test] + fn hex_accepts_optional_hash_and_any_case() { + let p = parse( + r##" + [structure] + helix = "#aabbcc" + sheet = "AABBCC" + "##, + ) + .unwrap(); + assert_eq!(p.structure.helix, Rgb::new(0xAA, 0xBB, 0xCC)); + assert_eq!(p.structure.sheet, p.structure.helix); + } + + #[test] + fn malformed_color_is_rejected_with_the_offending_value() { + let err = parse( + r#" + [structure] + helix = "nope" + "#, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("six hexadecimal"), "unhelpful error: {err}"); + assert!(err.contains("nope"), "error should quote the value: {err}"); + } + + #[test] + fn unknown_keys_are_rejected_so_typos_are_not_silent() { + let err = parse( + r#" + [structure] + helics = "FF0000" + "#, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("helics"), "error should name the key: {err}"); + } +} From e97cc6743f4524ce990b6dacafa8fb66870ab208 Mon Sep 17 00:00:00 2001 From: jamaliki <39654543+jamaliki@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:02:25 +0100 Subject: [PATCH 05/10] perf: transmit FullHD frames through shared memory FullHD re-encoded and re-transmitted the whole framebuffer every frame. On a Retina panel a full-screen viewport is ~2550x1435 device pixels -- 3.7 MP, over four times the area the cell grid suggests -- so each frame meant zlib over 14 MB of RGBA (12.7 ms, as much as the entire rasterizer) and 656 KiB of base64 down the PTY, which the terminal then had to inflate and re-upload as a texture. Kitty's t=s transmission medium removes the transfer instead of speeding it up: the pixels go into a POSIX shared memory object and the escape sequence carries only its name, so the terminal maps the same pages we wrote. No compression, no base64, nothing for the terminal to decode. Support is established at startup with the query-action + device-attributes handshake the protocol documents, never assumed; SSH sessions and terminals that answer no keep the zlib path. Alongside that: - Rasterize into horizontal bands written directly into the framebuffer, which drops the per-tile scratch buffers and the merge pass entirely. Each scanline solves the barycentric half-planes for its x-span instead of scanning the bounding box -- ribbon triangles are slivers whose box is several times their area -- and walks those bounds incrementally, so the three divisions per scanline become three per triangle. The projected-triangle array is reused across frames; reallocating it was the largest allocator cost in a profile. - Write RGBA straight into the shared mapping, in parallel. That removes the intermediate RgbaImage, the DynamicImage wrapper, and to_rgba8()'s full clone of every frame. - Render at half resolution whenever the camera is moving, not just when auto-rotating a structure over 5000 residues, and draw one full-resolution frame the moment it settles. Motion hides the softness; standing still no longer costs anything. One function now owns FullHD sizing for both the renderer and the zoom calculation, so they cannot drift apart, and it caps very large displays at 4 MP. - Default --threads to one core each rather than 4. Holding cores back for the terminal made sense when it had a frame to inflate every tick. 4HHB at 2550x1435: 22.9 ms/frame (43 fps) -> 10.3 ms (98 fps), and 656 KiB per frame down the PTY -> 79 bytes. While rotating: 22.9 ms -> 5.0 ms. Snapshot PNGs across six structure/mode combinations are byte-identical to before, so this is speed only. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011eVRPRPVdfAcHv25ThbxWW --- Cargo.lock | 1 + Cargo.toml | 1 + src/app.rs | 183 +++++++++++-- src/main.rs | 122 +++++++-- src/render/framebuffer.rs | 31 ++- src/render/hd.rs | 525 ++++++++++++++++++++++++-------------- src/render/kitty_png.rs | 81 +++--- src/render/kitty_shm.rs | 391 ++++++++++++++++++++++++++++ src/render/mod.rs | 1 + src/ui/viewport.rs | 122 +++++---- 10 files changed, 1126 insertions(+), 332 deletions(-) create mode 100644 src/render/kitty_shm.rs diff --git a/Cargo.lock b/Cargo.lock index b5ce958..1ac5ab1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,6 +1662,7 @@ dependencies = [ "crossterm", "flate2", "image", + "libc", "pdbtbx", "ratatui", "ratatui-image", diff --git a/Cargo.toml b/Cargo.toml index 2f59fb5..dff9334 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ ratatui = "0.29" crossterm = "0.28" ratatui-image = { version = "9.0.0", default-features = false, features = ["crossterm"] } image = "0.25" +libc = "0.2" pdbtbx = { version = "0.12", features = ["rstar"] } clap = { version = "4", features = ["derive"] } anyhow = "1" diff --git a/src/app.rs b/src/app.rs index 867d09b..fd22110 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,6 +13,64 @@ use crate::render::ribbon::{RibbonTriangle, generate_ribbon_mesh}; /// optimizations (background interface analysis, backbone default, reduced LOD). pub const LARGE_STRUCTURE_THRESHOLD: usize = 5000; +/// Upper bound on the FullHD framebuffer, in pixels. +/// +/// A graphics-protocol viewport is sized in *device* pixels, so on a HiDPI +/// panel it is four times the area the cell grid suggests, and every per-pixel +/// stage scales with it. This caps the still-frame resolution on very large or +/// very dense displays; below the cap the render stays at native resolution, so +/// a normal window is unaffected. 4 MP covers a full-screen Retina laptop. +pub const FULLHD_MAX_PIXELS: f64 = 4_000_000.0; + +/// Resolution multiplier used while the camera is moving. +/// +/// Halving each axis quarters every per-pixel cost, and the terminal scales the +/// result back up via the protocol's `c=`/`r=` keys. Motion hides the +/// softness; the full-resolution frame lands as soon as the camera settles. +pub const FULLHD_INTERACTIVE_SCALE: f64 = 0.5; + +/// How long after the last camera change the view still counts as interacting. +/// +/// Long enough to cover the gap between key repeats, so held keys never +/// oscillate between resolutions, and short enough that the sharp frame feels +/// immediate once the user stops. +pub const INTERACTION_LINGER: std::time::Duration = std::time::Duration::from_millis(220); + +/// Still-frame pixel dimensions of the FullHD framebuffer for a viewport of +/// `vp_cols` by `vp_rows` cells. +/// +/// This is the single source of truth for FullHD sizing: both the zoom +/// calculation and the renderer go through it, so the framebuffer and the zoom +/// computed for it can never disagree. While the camera is moving the renderer +/// scales this down by [`FULLHD_INTERACTIVE_SCALE`]; the still-frame size is +/// what zoom is defined against. +pub fn fullhd_framebuffer_size( + vp_cols: f64, + vp_rows: f64, + font_w: u16, + font_h: u16, + is_graphics: bool, +) -> (f64, f64) { + if !is_graphics { + // Colored-braille fallback: 2x4 dots per cell. + return (vp_cols * 2.0, vp_rows * 4.0); + } + + let native_w = vp_cols * f64::from(font_w); + let native_h = vp_rows * f64::from(font_h); + + // Cap by area rather than by either axis, so ultrawide and tall windows are + // treated alike. + let area = native_w * native_h; + let scale = if area > FULLHD_MAX_PIXELS { + (FULLHD_MAX_PIXELS / area).sqrt() + } else { + 1.0 + }; + + ((native_w * scale).max(1.0), (native_h * scale).max(1.0)) +} + /// Visualization mode #[derive(Debug, Clone, Copy, PartialEq)] pub enum VizMode { @@ -148,9 +206,15 @@ pub struct App { interface_computed: bool, /// Receiver for background interface analysis (large structures only). interface_rx: Option>, - /// Cached result of `total_residues > LARGE_STRUCTURE_THRESHOLD`, set once - /// in `App::new` to avoid per-frame O(n) `residue_count()` calls. - pub is_large: bool, + /// When the user last moved the camera, driving `is_interacting`. + /// `None` until the first camera change. + last_camera_change: Option, + /// Whether the terminal accepted a shared-memory graphics transmission at + /// startup. Set once by the probe in `main`; `false` over SSH. + pub kitty_shm: bool, + /// Next shared-memory slot to hand the terminal. A `Cell` because the + /// viewport renders from `&App` but each frame needs its own object. + shm_slot: std::cell::Cell, } impl App { @@ -292,7 +356,9 @@ impl App { residue_colors, interface_computed, interface_rx, - is_large, + last_camera_change: None, + kitty_shm: false, + shm_slot: std::cell::Cell::new(0), } } @@ -463,11 +529,35 @@ impl App { self.protein.chains.iter().map(|c| c.id.clone()).collect() } - /// Returns `true` when the scene is being actively animated (e.g. auto-rotate). - /// Used to trigger half-resolution rendering in FullHD mode for smoother - /// frame rates on large structures. + /// Returns `true` while the view is moving — auto-rotating, or within + /// [`INTERACTION_LINGER`] of the last manual camera change. + /// + /// FullHD renders at reduced resolution whenever this holds, so rotating, + /// panning and zooming stay smooth; the full-resolution frame is drawn once + /// the camera settles. pub fn is_interacting(&self) -> bool { - self.camera.auto_rotate + if self.camera.auto_rotate { + return true; + } + self.last_camera_change + .is_some_and(|at| at.elapsed() < INTERACTION_LINGER) + } + + /// Claim the next shared-memory slot, cycling through the ring so the + /// terminal is never still reading the object we are about to replace. + pub fn next_shm_slot(&self) -> u32 { + let slot = self.shm_slot.get(); + self.shm_slot + .set((slot + 1) % crate::render::kitty_shm::SLOTS); + slot + } + + /// Record that the user just moved the camera. + /// + /// Called by the input loop for every key that rotates, pans, zooms or + /// resets the view. + pub fn note_camera_change(&mut self) { + self.last_camera_change = Some(std::time::Instant::now()); } pub fn tick(&mut self) { @@ -500,14 +590,13 @@ impl App { let (px_w, px_h) = match self.render_mode { RenderMode::FullHD => { let proto = self.picker.protocol_type(); - if proto != ratatui_image::picker::ProtocolType::Halfblocks + let is_graphics = proto != ratatui_image::picker::ProtocolType::Halfblocks && font_w > 0 - && font_h > 0 - { - (vp_cols * font_w as f64, vp_rows * font_h as f64) - } else { - (vp_cols * 2.0, vp_rows * 4.0) - } + && font_h > 0; + // Zoom is defined against the still-frame resolution; the + // renderer scales the camera itself when it drops to the + // interactive resolution. + fullhd_framebuffer_size(vp_cols, vp_rows, font_w, font_h, is_graphics) } RenderMode::HalfBlock | RenderMode::HalfBlockPlus => (vp_cols * 2.0, vp_rows * 4.0), RenderMode::Braille => (vp_cols * 2.0, vp_rows * 4.0), @@ -554,3 +643,67 @@ impl App { self.recalculate_zoom(term_cols, term_rows); } } + +#[cfg(test)] +mod fullhd_sizing_tests { + use super::*; + + /// A full-screen kitty at font_size 14 on a 2560x1600 Retina panel. + const RETINA: (f64, f64, u16, u16) = (150.0, 41.0, 17, 35); + + #[test] + fn native_resolution_is_used_below_the_cap() { + let (cols, rows, fw, fh) = RETINA; + let (w, h) = fullhd_framebuffer_size(cols, rows, fw, fh, true); + assert_eq!((w, h), (cols * f64::from(fw), rows * f64::from(fh))); + assert!( + w * h <= FULLHD_MAX_PIXELS, + "{w}x{h} should be under the cap" + ); + } + + #[test] + fn oversized_viewports_are_capped_by_area_and_keep_their_aspect() { + // A 5K display: well past the cap. + let (native_w, native_h) = (5120.0, 2880.0); + let (w, h) = fullhd_framebuffer_size(5120.0, 2880.0, 1, 1, true); + + assert!( + w * h <= FULLHD_MAX_PIXELS * 1.001, + "{w}x{h} = {} px exceeds the cap", + w * h + ); + let aspect_error = (w / h) - (native_w / native_h); + assert!( + aspect_error.abs() < 1e-6, + "aspect drifted by {aspect_error}" + ); + } + + #[test] + fn braille_fallback_ignores_font_size() { + let (w, h) = fullhd_framebuffer_size(100.0, 40.0, 17, 35, false); + assert_eq!((w, h), (200.0, 160.0)); + } + + #[test] + fn dimensions_never_collapse_to_zero() { + let (w, h) = fullhd_framebuffer_size(0.0, 0.0, 17, 35, true); + assert!(w >= 1.0 && h >= 1.0, "got {w}x{h}"); + } + + #[test] + fn interactive_scale_quarters_the_pixel_count() { + let (cols, rows, fw, fh) = RETINA; + let (still_w, still_h) = fullhd_framebuffer_size(cols, rows, fw, fh, true); + let (moving_w, moving_h) = ( + still_w * FULLHD_INTERACTIVE_SCALE, + still_h * FULLHD_INTERACTIVE_SCALE, + ); + let ratio = (still_w * still_h) / (moving_w * moving_h); + assert!( + (ratio - 4.0).abs() < 1e-9, + "expected 4x fewer pixels, got {ratio}" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 729326e..6aace64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -124,9 +124,9 @@ struct Cli { #[arg(long)] log: Option, - /// Number of render threads (default: 4) - #[arg(long, default_value = "4")] - threads: usize, + /// Number of render threads (default: one per core) + #[arg(long)] + threads: Option, } fn main() -> Result<()> { @@ -137,10 +137,17 @@ fn main() -> Result<()> { // is worse than refusing to start. render::palette::init(cli.palette.as_deref())?; - // Cap rayon thread pool. 4 threads is the sweet spot: the framebuffer - // only has ~60 tiles (64x64) so more threads hit diminishing returns, - // and 4 leaves cores free for the terminal emulator and OS. - let num_threads = cli.threads.max(1); + // Rasterization splits the framebuffer into bands, so it scales with cores + // until it becomes memory-bound. Default to one thread per core: with the + // shared-memory transport the terminal no longer has a frame to decompress + // on every tick, so there is no longer a reason to hold cores back for it. + // Cap at 16 -- beyond that the bands get too thin to be worth a thread. + let num_threads = cli.threads.unwrap_or_else(|| { + std::thread::available_parallelism() + .map_or(4, std::num::NonZeroUsize::get) + .min(16) + }); + let num_threads = num_threads.max(1); match rayon::ThreadPoolBuilder::new() .num_threads(num_threads) .build_global() @@ -357,6 +364,7 @@ fn main() -> Result<()> { std::panic::set_hook(Box::new(move |info| { let _ = disable_raw_mode(); let _ = execute!(io::stderr(), LeaveAlternateScreen); + render::kitty_shm::unlink_all(); original_hook(info); })); @@ -379,6 +387,15 @@ fn main() -> Result<()> { picker.font_size() ); + // Ask the terminal whether it can read pixels straight out of shared + // memory. Must happen here: after the picker's own query has drained its + // responses, and before the input thread starts consuming stdin. Over SSH + // there is no shared filesystem to share memory through, so don't ask. + let kitty_shm = picker.protocol_type() == ratatui_image::picker::ProtocolType::Kitty + && connection_type == ConnectionType::Local + && render::kitty_shm::probe(Duration::from_millis(500)); + log!(logfile, "kitty shared-memory transport: {}", kitty_shm); + // Create app with actual terminal dimensions for dynamic zoom let mut app = App::new( protein, @@ -393,6 +410,7 @@ fn main() -> Result<()> { term_rows, picker, ); + app.kitty_shm = kitty_shm; log!( logfile, "app created: render_mode={:?} chains={} zoom={:.2}", @@ -412,6 +430,9 @@ fn main() -> Result<()> { // when rendering is too slow (prevents PTY buffer saturation & freezes). let mut last_draw_duration = Duration::ZERO; let mut frames_to_skip: u32 = 0; + // Tracks the interaction state of the previous drawn frame, so the + // transition back to a still view can trigger one full-resolution redraw. + let mut was_interacting = false; loop { // Drain all queued input from the dedicated input thread @@ -435,23 +456,63 @@ fn main() -> Result<()> { { app.should_quit = true } - KeyCode::Char('h') | KeyCode::Left => app.camera.rotate_y(-1.0), - KeyCode::Char('l') | KeyCode::Right => app.camera.rotate_y(1.0), - KeyCode::Char('j') | KeyCode::Down => app.camera.rotate_x(1.0), - KeyCode::Char('k') | KeyCode::Up => app.camera.rotate_x(-1.0), - KeyCode::Char('u') => app.camera.rotate_z(-1.0), - KeyCode::Char('i') => app.camera.rotate_z(1.0), - KeyCode::Char('+') | KeyCode::Char('=') => app.camera.zoom_in(), - KeyCode::Char('-') => app.camera.zoom_out(), - KeyCode::Char('w') => app.camera.pan(0.0, 1.0), - KeyCode::Char('s') => app.camera.pan(0.0, -1.0), - KeyCode::Char('a') => app.camera.pan(-1.0, 0.0), - KeyCode::Char('d') => app.camera.pan(1.0, 0.0), + // Every camera-moving key notes an interaction, which + // drops FullHD to its reduced resolution until the view + // settles again. + KeyCode::Char('h') | KeyCode::Left => { + app.camera.rotate_y(-1.0); + app.note_camera_change(); + } + KeyCode::Char('l') | KeyCode::Right => { + app.camera.rotate_y(1.0); + app.note_camera_change(); + } + KeyCode::Char('j') | KeyCode::Down => { + app.camera.rotate_x(1.0); + app.note_camera_change(); + } + KeyCode::Char('k') | KeyCode::Up => { + app.camera.rotate_x(-1.0); + app.note_camera_change(); + } + KeyCode::Char('u') => { + app.camera.rotate_z(-1.0); + app.note_camera_change(); + } + KeyCode::Char('i') => { + app.camera.rotate_z(1.0); + app.note_camera_change(); + } + KeyCode::Char('+') | KeyCode::Char('=') => { + app.camera.zoom_in(); + app.note_camera_change(); + } + KeyCode::Char('-') => { + app.camera.zoom_out(); + app.note_camera_change(); + } + KeyCode::Char('w') => { + app.camera.pan(0.0, 1.0); + app.note_camera_change(); + } + KeyCode::Char('s') => { + app.camera.pan(0.0, -1.0); + app.note_camera_change(); + } + KeyCode::Char('a') => { + app.camera.pan(-1.0, 0.0); + app.note_camera_change(); + } + KeyCode::Char('d') => { + app.camera.pan(1.0, 0.0); + app.note_camera_change(); + } KeyCode::Char('r') => { let (cols, rows) = crossterm::terminal::size().unwrap_or((term_cols, term_rows)); app.camera.reset(); app.recalculate_zoom(cols, rows); + app.note_camera_change(); } KeyCode::Char('c') => app.cycle_color(), KeyCode::Char('v') => app.cycle_viz_mode(), @@ -522,13 +583,20 @@ fn main() -> Result<()> { // Nothing on screen changes unless input arrived, an animation is // running, or background state was just absorbed. Redrawing anyway - // would re-run the whole rasterize + encode pipeline and push a fresh - // full-viewport image at every tick -- on FullHD that is hundreds of - // kilobytes of escape sequences per frame, forever, for an image - // identical to the one already on screen. - let animating = app.camera.auto_rotate || app.ssh_hd_warning; + // would re-run the whole rasterize + transmit pipeline at every tick, + // forever, for an image identical to the one already on screen. + // + // The one extra case is `settled`: FullHD renders at a reduced + // resolution while the camera moves, so the frame after it comes to + // rest must be drawn to replace it with the sharp one. + let interacting = app.is_interacting(); + let settled = was_interacting && !interacting; + was_interacting = interacting; + let must_redraw = had_input - || animating + || interacting + || settled + || app.ssh_hd_warning || app.needs_clear || mesh_was_rebuilt || interface_absorbed @@ -561,6 +629,9 @@ fn main() -> Result<()> { // a previous FullHD session. Harmless no-op if there are none. let cleanup = render::kitty_png::KittyPngImage::cleanup_escape(); execute!(terminal.backend_mut(), crossterm::style::Print(&cleanup))?; + // Drop any shared memory object the terminal never got round to + // reading, so switching modes cannot leave objects behind. + render::kitty_shm::unlink_all(); terminal.clear()?; app.needs_clear = false; } @@ -639,6 +710,7 @@ fn main() -> Result<()> { quit_flag.store(true, Ordering::Relaxed); // Restore terminal + render::kitty_shm::unlink_all(); disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; diff --git a/src/render/framebuffer.rs b/src/render/framebuffer.rs index 78feee3..b370777 100644 --- a/src/render/framebuffer.rs +++ b/src/render/framebuffer.rs @@ -1,8 +1,8 @@ use image::{RgbImage, RgbaImage}; -use rayon::prelude::*; use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; +use rayon::prelude::*; /// RGB pixel framebuffer with z-buffer for software rasterization. /// @@ -207,18 +207,18 @@ impl Framebuffer { .par_iter_mut() .zip(self.depth.par_iter()) .for_each(|(c, &d)| { - if d >= f32::INFINITY { - return; // background pixel — leave black - } - let t = ((d - z_min) * inv_range).clamp(0.0, 1.0); - let blend = t as f64 * fog_strength; - c[0] = - (c[0] as f64 + (fog_color[0] as f64 - c[0] as f64) * blend).clamp(0.0, 255.0) as u8; - c[1] = - (c[1] as f64 + (fog_color[1] as f64 - c[1] as f64) * blend).clamp(0.0, 255.0) as u8; - c[2] = - (c[2] as f64 + (fog_color[2] as f64 - c[2] as f64) * blend).clamp(0.0, 255.0) as u8; - }); + if d >= f32::INFINITY { + return; // background pixel — leave black + } + let t = ((d - z_min) * inv_range).clamp(0.0, 1.0); + let blend = t as f64 * fog_strength; + c[0] = (c[0] as f64 + (fog_color[0] as f64 - c[0] as f64) * blend).clamp(0.0, 255.0) + as u8; + c[1] = (c[1] as f64 + (fog_color[1] as f64 - c[1] as f64) * blend).clamp(0.0, 255.0) + as u8; + c[2] = (c[2] as f64 + (fog_color[2] as f64 - c[2] as f64) * blend).clamp(0.0, 255.0) + as u8; + }); } /// Cohen-Sutherland line clipping against framebuffer bounds [0, width) x [0, height). @@ -1482,7 +1482,10 @@ mod tests { 1, ); - assert_eq!(unquantized, cols, "every cell should differ without quantization"); + assert_eq!( + unquantized, cols, + "every cell should differ without quantization" + ); assert!( quantized < unquantized, "quantization should merge runs (got {quantized}, unquantized {unquantized})" diff --git a/src/render/hd.rs b/src/render/hd.rs index 84a3562..ec9db39 100644 --- a/src/render/hd.rs +++ b/src/render/hd.rs @@ -136,14 +136,30 @@ fn to_pixel(proj_x: f64, proj_y: f64, proj_z: f64, half_w: f64, half_h: f64) -> } // --------------------------------------------------------------------------- -// Tile-based parallel cartoon rasterization +// Band-based parallel cartoon rasterization // --------------------------------------------------------------------------- -/// Tile size in pixels. 64x64 is a good balance between parallelism (many -/// tiles) and per-tile overhead (triangle binning, allocation). -const TILE_SIZE: usize = 64; +/// Inside-test epsilon, shared by the scanline span solver and the exact +/// per-pixel test so the two always agree. +const EDGE_EPS: f64 = 1e-6; -/// A projected, shaded triangle ready for rasterization into tiles. +/// Target number of horizontal bands per worker thread. +/// +/// Bands are the unit of parallelism *and* of triangle binning. More bands per +/// thread balances uneven triangle distribution (a protein rarely fills the +/// viewport evenly) at the cost of re-visiting triangles that straddle a band +/// boundary. Four is enough to keep every worker busy without meaningfully +/// inflating the bin lists. +const BANDS_PER_THREAD: usize = 4; + +/// Smallest band height in pixels. Below this the per-band bookkeeping starts +/// to cost more than the parallelism buys. +const MIN_BAND_HEIGHT: usize = 16; + +/// A projected, shaded triangle ready for rasterization. +/// +/// The barycentric setup is computed once here, in the parallel projection +/// pass, rather than once per band the triangle touches. struct ProjectedTriangle { /// Screen-space vertices `[x, y, z]`. verts: [[f64; 3]; 3], @@ -154,9 +170,37 @@ struct ProjectedTriangle { max_x: usize, min_y: usize, max_y: usize, + /// Barycentric coefficients: `u = u_x * dx + u_yc * dy`, likewise for `v`, + /// where `dx`/`dy` are offsets from vertex 2. + u_x: f64, + v_x: f64, + u_yc: f64, + v_yc: f64, } -/// Context for tile-based cartoon rasterization, reducing parameter count. +impl ProjectedTriangle { + /// Placeholder for a triangle that is off-screen or degenerate. An empty + /// bounding box (`min_x > max_x`) is the marker. + const CULLED: Self = Self { + verts: [[0.0; 3]; 3], + shaded: [0; 3], + min_x: 1, + max_x: 0, + min_y: 1, + max_y: 0, + u_x: 0.0, + v_x: 0.0, + u_yc: 0.0, + v_yc: 0.0, + }; + + #[inline] + fn is_culled(&self) -> bool { + self.min_x > self.max_x + } +} + +/// Context for band-based cartoon rasterization, reducing parameter count. struct TiledRenderCtx { half_w: f64, half_h: f64, @@ -165,22 +209,13 @@ struct TiledRenderCtx { light_dir: [f64; 3], } -/// A rasterized tile with its position, dimensions, and pixel data. -struct RenderedTile { - x: usize, - w: usize, - h: usize, - color: Vec<[u8; 3]>, - depth: Vec, -} - -/// Render the cartoon mesh using tile-based parallel rasterization. +/// Render the cartoon mesh using band-based parallel rasterization. /// -/// 1. Project all triangles serially (Lambert shade). -/// 2. Bin projected triangles into screen-space tiles. -/// 3. Rasterize each tile in parallel via rayon -- each tile owns its own -/// color/depth arrays so no synchronization is needed. -/// 4. Merge tile results back into the main framebuffer. +/// 1. Project, shade and set up all triangles (parallel). +/// 2. Bin them into horizontal screen bands via a flat CSR index. +/// 3. Rasterize each band in parallel, writing **straight into** the +/// framebuffer rows that band owns -- bands are disjoint row ranges, so no +/// synchronization, no per-band scratch buffers, and no merge pass. fn render_cartoon_tiled( fb: &mut Framebuffer, mesh: &[RibbonTriangle], @@ -195,208 +230,308 @@ fn render_cartoon_tiled( let light_dir = ctx.light_dir; // ------------------------------------------------------------------ - // Step 1: Project and shade all triangles (serial). + // Step 1: Project, shade and set up all triangles (parallel). // ------------------------------------------------------------------ - let projected: Vec = mesh - .par_iter() - .filter_map(|tri| { - let v0 = cache.project(tri.verts[0][0], tri.verts[0][1], tri.verts[0][2]); - let v1 = cache.project(tri.verts[1][0], tri.verts[1][1], tri.verts[1][2]); - let v2 = cache.project(tri.verts[2][0], tri.verts[2][1], tri.verts[2][2]); - - let sv0 = to_pixel(v0.x, v0.y, v0.z, half_w, half_h); - let sv1 = to_pixel(v1.x, v1.y, v1.z, half_w, half_h); - let sv2 = to_pixel(v2.x, v2.y, v2.z, half_w, half_h); - - // Screen-space bounding box clamped to framebuffer. - let fmin_x = sv0[0].min(sv1[0]).min(sv2[0]).floor() as isize; - let fmax_x = sv0[0].max(sv1[0]).max(sv2[0]).ceil() as isize; - let fmin_y = sv0[1].min(sv1[1]).min(sv2[1]).floor() as isize; - let fmax_y = sv0[1].max(sv1[1]).max(sv2[1]).ceil() as isize; - - let min_x = fmin_x.max(0) as usize; - let max_x = (fmax_x.max(0) as usize).min(px_w.saturating_sub(1)); - let min_y = fmin_y.max(0) as usize; - let max_y = (fmax_y.max(0) as usize).min(px_h.saturating_sub(1)); - - if min_x > max_x || min_y > max_y { - return None; - } - - // Two-sided half-Lambert shading (identical to `rasterize_triangle_depth`). - let rn = cache.rotate_normal(tri.normal[0], tri.normal[1], tri.normal[2]); - let dot = rn[0] * light_dir[0] + rn[1] * light_dir[1] + rn[2] * light_dir[2]; - let half_lambert = dot.abs() * 0.4 + 0.6; - let intensity = AMBIENT + (1.0 - AMBIENT) * half_lambert; - let shaded: [u8; 3] = [ - (tri.color[0] as f64 * intensity).min(255.0) as u8, - (tri.color[1] as f64 * intensity).min(255.0) as u8, - (tri.color[2] as f64 * intensity).min(255.0) as u8, - ]; - - Some(ProjectedTriangle { - verts: [sv0, sv1, sv2], - shaded, - min_x, - max_x, - min_y, - max_y, + // Reuse the projected-triangle buffer across frames. At interactive + // resolutions this array is several megabytes; reallocating and re-faulting + // it every frame showed up as the single largest allocator cost in a + // profile of the render loop. + SCRATCH.with(|cell| { + let mut scratch = cell.borrow_mut(); + let projected: &mut Vec = &mut scratch; + mesh.par_iter() + .map(|tri| { + let v0 = cache.project(tri.verts[0][0], tri.verts[0][1], tri.verts[0][2]); + let v1 = cache.project(tri.verts[1][0], tri.verts[1][1], tri.verts[1][2]); + let v2 = cache.project(tri.verts[2][0], tri.verts[2][1], tri.verts[2][2]); + + let sv0 = to_pixel(v0.x, v0.y, v0.z, half_w, half_h); + let sv1 = to_pixel(v1.x, v1.y, v1.z, half_w, half_h); + let sv2 = to_pixel(v2.x, v2.y, v2.z, half_w, half_h); + + // Screen-space bounding box clamped to framebuffer. + let fmin_x = sv0[0].min(sv1[0]).min(sv2[0]).floor() as isize; + let fmax_x = sv0[0].max(sv1[0]).max(sv2[0]).ceil() as isize; + let fmin_y = sv0[1].min(sv1[1]).min(sv2[1]).floor() as isize; + let fmax_y = sv0[1].max(sv1[1]).max(sv2[1]).ceil() as isize; + + let min_x = fmin_x.max(0) as usize; + let max_x = (fmax_x.max(0) as usize).min(px_w.saturating_sub(1)); + let min_y = fmin_y.max(0) as usize; + let max_y = (fmax_y.max(0) as usize).min(px_h.saturating_sub(1)); + + // Barycentric denominator (twice the signed screen-space area). + let denom = + (sv1[1] - sv2[1]) * (sv0[0] - sv2[0]) + (sv2[0] - sv1[0]) * (sv0[1] - sv2[1]); + + // Off-screen or degenerate: emit a culled entry rather than + // filtering, so the parallel map stays indexed and can write + // straight into the reused buffer without any reallocation. + if min_x > max_x || min_y > max_y || denom.abs() < 1e-12 { + return ProjectedTriangle::CULLED; + } + let inv_denom = 1.0 / denom; + + // Two-sided half-Lambert shading (identical to `rasterize_triangle_depth`). + let rn = cache.rotate_normal(tri.normal[0], tri.normal[1], tri.normal[2]); + let dot = rn[0] * light_dir[0] + rn[1] * light_dir[1] + rn[2] * light_dir[2]; + let half_lambert = dot.abs() * 0.4 + 0.6; + let intensity = AMBIENT + (1.0 - AMBIENT) * half_lambert; + let shaded: [u8; 3] = [ + (tri.color[0] as f64 * intensity).min(255.0) as u8, + (tri.color[1] as f64 * intensity).min(255.0) as u8, + (tri.color[2] as f64 * intensity).min(255.0) as u8, + ]; + + ProjectedTriangle { + verts: [sv0, sv1, sv2], + shaded, + min_x, + max_x, + min_y, + max_y, + u_x: (sv1[1] - sv2[1]) * inv_denom, + v_x: (sv2[1] - sv0[1]) * inv_denom, + u_yc: (sv2[0] - sv1[0]) * inv_denom, + v_yc: (sv0[0] - sv2[0]) * inv_denom, + } }) - }) - .collect(); + .collect_into_vec(projected); - if projected.is_empty() { - return; - } + if projected.is_empty() { + return; + } - // ------------------------------------------------------------------ - // Step 2: Create tile grid and bin triangles into tiles. - // ------------------------------------------------------------------ - let cols = px_w.div_ceil(TILE_SIZE); - let rows = px_h.div_ceil(TILE_SIZE); - let num_tiles = cols * rows; - let mut tile_triangles: Vec> = vec![Vec::new(); num_tiles]; - - for (tri_idx, tri) in projected.iter().enumerate() { - let tc0 = tri.min_x / TILE_SIZE; - let tc1 = tri.max_x / TILE_SIZE; - let tr0 = tri.min_y / TILE_SIZE; - let tr1 = tri.max_y / TILE_SIZE; - for tr in tr0..=tr1 { - for tc in tc0..=tc1 { - tile_triangles[tr * cols + tc].push(tri_idx); + // ------------------------------------------------------------------ + // Step 2: Bin triangles into horizontal bands (flat CSR index). + // ------------------------------------------------------------------ + // A `Vec>` would allocate and grow one heap buffer per band + // every frame; counting first and scattering into a single flat array + // costs two linear passes and one allocation. + let threads = rayon::current_num_threads().max(1); + let band_h = (px_h.div_ceil(threads * BANDS_PER_THREAD)).max(MIN_BAND_HEIGHT); + let num_bands = px_h.div_ceil(band_h); + + let mut offsets = vec![0u32; num_bands + 1]; + for tri in projected.iter().filter(|t| !t.is_culled()) { + let b0 = tri.min_y / band_h; + let b1 = tri.max_y / band_h; + for slot in &mut offsets[b0 + 1..=b1 + 1] { + *slot += 1; } } - } - - // ------------------------------------------------------------------ - // Step 3: Rasterize tiles in parallel. - // ------------------------------------------------------------------ - // Each tile produces its own local color and depth buffers. - let tiles: Vec = tile_triangles - .into_par_iter() - .enumerate() - .map(|(tile_idx, tri_indices)| { - let tx = (tile_idx % cols) * TILE_SIZE; - let ty = (tile_idx / cols) * TILE_SIZE; - let tw = TILE_SIZE.min(px_w.saturating_sub(tx)); - let th = TILE_SIZE.min(px_h.saturating_sub(ty)); - - let mut color = vec![[0u8; 3]; tw * th]; - let mut depth = vec![f32::INFINITY; tw * th]; - - for &tri_idx in &tri_indices { - let tri = &projected[tri_idx]; - rasterize_into_tile(&mut color, &mut depth, tw, th, tx, ty, tri); + for i in 0..num_bands { + offsets[i + 1] += offsets[i]; + } + let mut items = vec![0u32; offsets[num_bands] as usize]; + let mut cursor = offsets.clone(); + for (tri_idx, tri) in projected.iter().enumerate() { + if tri.is_culled() { + continue; } - - RenderedTile { - x: tx, - w: tw, - h: th, - color, - depth, + let b0 = tri.min_y / band_h; + let b1 = tri.max_y / band_h; + for b in b0..=b1 { + items[cursor[b] as usize] = tri_idx as u32; + cursor[b] += 1; } - }) - .collect(); + } - // ------------------------------------------------------------------ - // Step 4: Merge tiles back into the main framebuffer. - // ------------------------------------------------------------------ - // Tiles cover disjoint screen rectangles, so the merge parallelizes cleanly - // over framebuffer rows: row `y` is covered by exactly the `cols` tiles in - // tile-row `y / TILE_SIZE`. - let tiles = &tiles; - fb.color - .par_chunks_mut(px_w) - .zip(fb.depth.par_chunks_mut(px_w)) - .enumerate() - .for_each(|(y, (color_row, depth_row))| { - let tr = y / TILE_SIZE; - let ly = y % TILE_SIZE; - for tc in 0..cols { - let tile = &tiles[tr * cols + tc]; - if ly >= tile.h { - continue; - } - let src_base = ly * tile.w; - for lx in 0..tile.w { - let ti = src_base + lx; - let fi = tile.x + lx; - if tile.depth[ti] < depth_row[fi] { - color_row[fi] = tile.color[ti]; - depth_row[fi] = tile.depth[ti]; - } + // ------------------------------------------------------------------ + // Step 3: Rasterize bands in parallel, straight into the framebuffer. + // ------------------------------------------------------------------ + let projected: &[ProjectedTriangle] = projected; + let items = &items; + let offsets = &offsets; + fb.color + .par_chunks_mut(band_h * px_w) + .zip(fb.depth.par_chunks_mut(band_h * px_w)) + .enumerate() + .for_each(|(band, (color, depth))| { + let y0 = band * band_h; + let rows = color.len() / px_w; + let lo = offsets[band] as usize; + let hi = offsets[band + 1] as usize; + for &tri_idx in &items[lo..hi] { + rasterize_band(color, depth, px_w, y0, rows, &projected[tri_idx as usize]); } - } - }); + }); + }); +} + +thread_local! { + static SCRATCH: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; } -/// Rasterize a single projected triangle into a tile's local buffers. +/// A triangle edge as a moving x-bound while walking down scanlines. /// -/// The algorithm is identical to `Framebuffer::rasterize_triangle_depth` but -/// operates on tile-local coordinate arrays. `tx`/`ty` are the pixel -/// coordinates of the tile's top-left corner in the full framebuffer. +/// Each barycentric constraint has the form `a * dx + c * dy >= t`, so the `dx` +/// at which it flips is `t/a - (c/a) * dy` — **linear in `dy`**. Solving that +/// once per triangle and then stepping by the slope each scanline replaces +/// three floating-point divisions per scanline with one addition. +#[derive(Clone, Copy)] +struct EdgeBound { + /// Current bound on `dx`, valid for the scanline being processed. + at: f64, + /// How the bound moves per scanline. + step: f64, +} + +/// A constraint whose `dx` coefficient is zero: it does not bound `x` at all, +/// it just switches whole scanlines on or off. +#[derive(Clone, Copy)] +struct FlatBound { + c: f64, + t: f64, +} + +/// Rasterize one projected triangle into the framebuffer rows owned by a band. +/// +/// `y0` is the framebuffer row the band starts at and `rows` is how many rows +/// it owns; `color`/`depth` are that band's slices, so row `py` lives at +/// `(py - y0) * px_w`. +/// +/// Rather than scanning the triangle's full bounding box and rejecting most of +/// it — ribbon triangles are thin slivers whose bounding box is several times +/// their area — each scanline is reduced to the x-range where all three +/// barycentric half-planes can hold, walked incrementally down the triangle. +/// The exact same inside test then runs over just that range, widened by a +/// pixel at each end so floating-point error can never clip a covered pixel. #[inline] -fn rasterize_into_tile( +fn rasterize_band( color: &mut [[u8; 3]], depth: &mut [f32], - tw: usize, - th: usize, - tx: usize, - ty: usize, + px_w: usize, + y0: usize, + rows: usize, tri: &ProjectedTriangle, ) { let [v0, v1, v2] = tri.verts; - // Clamp the triangle's bounding box to this tile. - let min_x = tri.min_x.max(tx); - let max_x = tri.max_x.min((tx + tw).saturating_sub(1)); - let min_y = tri.min_y.max(ty); - let max_y = tri.max_y.min((ty + th).saturating_sub(1)); - - if min_x > max_x || min_y > max_y { + let y_start = tri.min_y.max(y0); + let y_end = tri.max_y.min(y0 + rows - 1); + if y_start > y_end || tri.min_x > tri.max_x { return; } - // Barycentric denominator (same math as `rasterize_triangle_depth`). - let denom = (v1[1] - v2[1]) * (v0[0] - v2[0]) + (v2[0] - v1[0]) * (v0[1] - v2[1]); - if denom.abs() < 1e-12 { - return; // degenerate triangle + let (u_x, v_x, u_yc, v_yc) = (tri.u_x, tri.v_x, tri.u_yc, tri.v_yc); + // w = 1 - u - v, so `w >= -EPS` is `-(u_x+v_x) dx - (u_yc+v_yc) dy >= -1-EPS`. + let dy_start = y_start as f64 + 0.5 - v2[1]; + + let mut lower: [EdgeBound; 3] = [EdgeBound { at: 0.0, step: 0.0 }; 3]; + let mut upper: [EdgeBound; 3] = [EdgeBound { at: 0.0, step: 0.0 }; 3]; + let mut flat: [FlatBound; 3] = [FlatBound { c: 0.0, t: 0.0 }; 3]; + let (mut n_lower, mut n_upper, mut n_flat) = (0usize, 0usize, 0usize); + + for (a, c, t) in [ + (u_x, u_yc, -EDGE_EPS), + (v_x, v_yc, -EDGE_EPS), + (-(u_x + v_x), -(u_yc + v_yc), -1.0 - EDGE_EPS), + ] { + if a == 0.0 { + flat[n_flat] = FlatBound { c, t }; + n_flat += 1; + continue; + } + let inv_a = 1.0 / a; + let step = -c * inv_a; + let bound = EdgeBound { + at: t * inv_a + step * dy_start, + step, + }; + if a > 0.0 { + lower[n_lower] = bound; + n_lower += 1; + } else { + upper[n_upper] = bound; + n_upper += 1; + } } - let inv_denom = 1.0 / denom; - - let u_x_step = (v1[1] - v2[1]) * inv_denom; - let v_x_step = (v2[1] - v0[1]) * inv_denom; - let u_y_coeff = (v2[0] - v1[0]) * inv_denom; - let v_y_coeff = (v0[0] - v2[0]) * inv_denom; - - for py in min_y..=max_y { - let pf_y = py as f64 + 0.5; - let dy = pf_y - v2[1]; - let u_y = u_y_coeff * dy; - let v_y = v_y_coeff * dy; - - for px in min_x..=max_x { - let pf_x = px as f64 + 0.5; - let dx = pf_x - v2[0]; - - let u = u_x_step * dx + u_y; - let v = v_x_step * dx + v_y; - let w = 1.0 - u - v; - - if u >= -1e-6 && v >= -1e-6 && w >= -1e-6 { - let z = (u * v0[2] + v * v1[2] + w * v2[2]) as f32; - let lx = px - tx; - let ly = py - ty; - let ti = ly * tw + lx; - if z < depth[ti] { - depth[ti] = z; - color[ti] = tri.shaded; + + for py in y_start..=y_end { + let dy = py as f64 + 0.5 - v2[1]; + + // Constraints independent of x either admit the whole scanline or none + // of it. + let scanline_live = flat[..n_flat].iter().all(|f| f.c * dy >= f.t); + + if scanline_live { + let mut lo = f64::NEG_INFINITY; + for b in &lower[..n_lower] { + if b.at > lo { + lo = b.at; + } + } + let mut hi = f64::INFINITY; + for b in &upper[..n_upper] { + if b.at < hi { + hi = b.at; + } + } + + if lo <= hi { + // dx is measured from v2[0] at pixel centres, so + // px = dx + v2[0] - 0.5. Widen by one pixel each way; the + // exact test below still decides. + let x_start = span_start(lo + v2[0] - 0.5, tri.min_x); + let x_end = span_end(hi + v2[0] - 0.5, tri.max_x); + + if x_start <= x_end { + let u_y = u_yc * dy; + let v_y = v_yc * dy; + let base = (py - y0) * px_w; + for px in x_start..=x_end { + let dx = px as f64 + 0.5 - v2[0]; + let u = u_x * dx + u_y; + let v = v_x * dx + v_y; + let w = 1.0 - u - v; + + if u >= -EDGE_EPS && v >= -EDGE_EPS && w >= -EDGE_EPS { + let z = (u * v0[2] + v * v1[2] + w * v2[2]) as f32; + let i = base + px; + if z < depth[i] { + depth[i] = z; + color[i] = tri.shaded; + } + } + } } } } + + for b in &mut lower[..n_lower] { + b.at += b.step; + } + for b in &mut upper[..n_upper] { + b.at += b.step; + } + } +} + +/// First pixel column to test, never above `floor` and never below 0. +/// A NaN or infinite bound falls back to the triangle's bounding box. +#[inline] +fn span_start(x: f64, floor_x: usize) -> usize { + if x.is_nan() { + return floor_x; + } + // Rust saturates out-of-range float-to-int casts, so this is safe for +-inf. + let i = x.floor() as isize; + i.max(floor_x as isize) as usize +} + +/// Last pixel column to test, never beyond `ceil_x`. +#[inline] +fn span_end(x: f64, ceil_x: usize) -> usize { + if x.is_nan() { + return ceil_x; + } + let i = x.ceil() as isize; + if i < 0 { + return 0; } + (i as usize).min(ceil_x) } /// Render backbone CA trace to framebuffer. diff --git a/src/render/kitty_png.rs b/src/render/kitty_png.rs index 777f87c..903fa7c 100644 --- a/src/render/kitty_png.rs +++ b/src/render/kitty_png.rs @@ -1,15 +1,18 @@ -//! Compressed Kitty graphics protocol transmitter using raw RGBA + zlib. +//! Kitty graphics protocol transmitter. //! -//! ratatui-image sends Kitty images as raw RGBA32 base64-encoded (`f=32`), -//! which is ~1.3MB per frame for a 640x384 render. This module compresses -//! the raw RGBA pixel data with zlib (level 1 / fastest) and sends it via -//! the Kitty graphics protocol with `f=32,o=z`. This avoids PNG's -//! filtering, CRC checksums, and chunk framing overhead while still -//! achieving good compression on protein renders (mostly black/transparent -//! background). +//! Two transports produce the same widget: +//! +//! * **Shared memory** (`t=s`), used whenever the terminal is local and +//! [`super::kitty_shm::probe`] confirmed support. The pixels never enter the +//! escape sequence at all -- see that module. This is the fast path. +//! * **Escape codes** (`f=32,o=z`), for SSH sessions and terminals without +//! shared-memory support. ratatui-image would send raw base64 RGBA here; +//! compressing with zlib first is 10-20x smaller on protein renders, which +//! are mostly transparent background, and that is what makes FullHD usable +//! over a network at all. //! //! Note: the file is still named `kitty_png.rs` for historical reasons and -//! to minimize import churn. The actual encoding is raw RGBA + zlib, not PNG. +//! to minimize import churn. Neither transport involves PNG. //! //! Unlike the original implementation which dumped the escape sequence into //! cell (0,0) and skipped everything else, this version uses Kitty's @@ -30,7 +33,6 @@ use std::io::Write as IoWrite; use base64::Engine; use flate2::Compression; use flate2::write::ZlibEncoder; -use image::DynamicImage; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::Widget; @@ -40,12 +42,15 @@ use ratatui::widgets::Widget; /// image data — no flicker, no delete-before-draw gap. const IMAGE_ID: u32 = 1; -/// A ratatui `Widget` that renders a `DynamicImage` via the Kitty graphics -/// protocol using raw RGBA data with zlib compression (`f=32,o=z`) and +/// A ratatui `Widget` that places a Kitty image over the render area using /// unicode placeholders. /// -/// Named `KittyPngImage` for historical reasons; the actual encoding is -/// zlib-compressed raw RGBA, not PNG. +/// Construct it with [`KittyPngImage::from_shm`] to hand the terminal a shared +/// memory object, or [`KittyPngImage::from_rgba`] to carry the pixels inline as +/// zlib-compressed base64. Only the transmit sequence differs; placement is +/// identical either way. +/// +/// Named `KittyPngImage` for historical reasons; neither transport uses PNG. pub struct KittyPngImage { transmit: String, unique_id: u32, @@ -63,34 +68,48 @@ impl KittyPngImage { format!("\x1b_Gq=2,a=d,d=I,i={IMAGE_ID}\x1b\\") } - /// Create a new compressed Kitty image widget. + /// Point the terminal at pixels already written into a shared memory + /// object. The payload is just the object's name, so the escape sequence + /// is a fixed ~60 bytes regardless of image size. /// - /// The image is immediately converted to raw RGBA, zlib-compressed - /// (level 1 / fastest), and base64-chunked into the Kitty escape - /// sequence. Call this outside the draw closure if you want to time - /// encoding separately. + /// `byte_len` is the object's exact size, sent as `S=` so the terminal + /// reads the pixel data and nothing beyond it. + pub fn from_shm(shm_name: &str, w: u32, h: u32, byte_len: usize, area: Rect) -> Self { + let payload = base64::engine::general_purpose::STANDARD.encode(shm_name.as_bytes()); + let (cols, rows) = (area.width, area.height); + // Reusing IMAGE_ID lets Kitty swap the image atomically, so there is no + // blank gap between frames; c=/r= make it span the whole placeholder + // grid, which also stretches a reduced-resolution frame back to size. + // See `from_rgba` for the full rationale. + let transmit = format!( + "\x1b_Gq=2,i={IMAGE_ID},a=T,U=1,f=32,t=s,s={w},v={h},c={cols},r={rows},S={byte_len};{payload}\x1b\\" + ); + Self { + transmit, + unique_id: IMAGE_ID, + area, + } + } + + /// Create a Kitty image widget that carries its pixels inline, zlib + /// compressed and base64 chunked. + /// + /// `rgba` must be exactly `w * h * 4` bytes. Returns `None` if + /// compression fails, so the caller can fall back to braille rather than + /// crash the TUI. /// /// Uses a single fixed image ID (`IMAGE_ID`). Transmitting with /// `a=T,U=1` for the same ID causes Kitty to atomically replace the /// old image data, so there is never a visible gap between frames. /// No delete commands are emitted during normal rendering. - pub fn new(img: &DynamicImage, area: Rect) -> Option { - let (w, h) = (img.width(), img.height()); - - // Get raw RGBA bytes from the image. - let rgba = img.to_rgba8(); - let raw_bytes = rgba.as_raw(); - + pub fn from_rgba(rgba: &[u8], w: u32, h: u32, area: Rect) -> Option { // Compress with zlib level 1 (fastest). Returns None on failure so // the caller can fall back to braille instead of crashing the TUI. let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast()); - if encoder.write_all(raw_bytes).is_err() { + if encoder.write_all(rgba).is_err() { return None; } - let compressed = match encoder.finish() { - Ok(bytes) => bytes, - Err(_) => return None, - }; + let compressed = encoder.finish().ok()?; // Base64. let b64 = base64::engine::general_purpose::STANDARD.encode(&compressed); diff --git a/src/render/kitty_shm.rs b/src/render/kitty_shm.rs new file mode 100644 index 0000000..99d0ead --- /dev/null +++ b/src/render/kitty_shm.rs @@ -0,0 +1,391 @@ +//! Shared-memory transport for the Kitty graphics protocol. +//! +//! The escape-code transport has to base64 every pixel of every frame and push +//! it down the PTY, and because the protocol has no delta mechanism that is the +//! *whole* frame, every frame. At a Retina-resolution viewport that is tens of +//! megabytes of pixel data per frame; compressing it first trades a large chunk +//! of the frame budget for a smaller — but still substantial — write, and the +//! terminal then has to decompress it again. +//! +//! Kitty's `t=s` transmission medium removes the transfer entirely. The client +//! puts the pixels in a POSIX shared memory object and sends only its *name*; +//! the terminal maps the same pages and reads them directly. The escape +//! sequence shrinks from hundreds of kilobytes to about sixty bytes, no +//! compression happens on either side, and the pixels are written exactly once +//! — straight into the shared mapping. +//! +//! This obviously only works when the terminal is on the same machine, so the +//! escape-code path in [`super::kitty_png`] remains for SSH sessions and for +//! terminals that speak the Kitty protocol without supporting `t=s`. Support is +//! established once at startup by [`probe`] rather than assumed. +//! +//! # Object lifetime +//! +//! Per the protocol, *the terminal* unlinks and closes the object once it has +//! read it, so a fresh object is needed for every frame. Names are therefore +//! cycled through a small ring of slots, and each slot is unlinked before being +//! recreated: a frame the terminal never consumed leaks one object until its +//! slot comes round again, and [`unlink_all`] clears whatever is left at exit. +//! Our own mapping can be dropped as soon as the pixels are written — the +//! object outlives it, and that is what the terminal opens. + +use std::ffi::CString; + +/// Number of shared-memory names cycled through, so the terminal is never +/// still reading the object we are about to replace. At 30 fps this leaves +/// over a tenth of a second of slack, orders of magnitude more than a terminal +/// needs to drain an escape sequence. +pub const SLOTS: u32 = 4; + +/// Slot reserved for the startup capability probe, outside the display ring. +const PROBE_SLOT: u32 = SLOTS; + +/// Image id used for the probe. Distinct from the display image id so a +/// query can never disturb the image currently on screen. +const PROBE_IMAGE_ID: u32 = 7317; + +/// POSIX shared memory object names are limited to 31 characters on macOS, so +/// this stays deliberately terse: `/pv-`. +fn slot_name(slot: u32) -> String { + format!("/pv{}-{}", std::process::id(), slot) +} + +/// A freshly created shared memory object, mapped writable. +/// +/// Dropping this unmaps our view but deliberately leaves the object in place: +/// the terminal opens it by name after it reads the escape sequence. +pub struct ShmFrame { + name: String, + ptr: *mut u8, + len: usize, +} + +impl ShmFrame { + /// Create slot `slot`'s object at `len` bytes and map it writable. + /// + /// Any object left in the slot by an unconsumed earlier frame is unlinked + /// first. Returns `None` if the platform refuses at any step, which the + /// caller should treat as "fall back to the escape-code transport". + pub fn create(slot: u32, len: usize) -> Option { + if len == 0 { + return None; + } + let name = slot_name(slot); + let cname = CString::new(name.as_str()).ok()?; + + // SAFETY: `cname` is a valid NUL-terminated C string that outlives all + // of these calls, `len` is non-zero, and every failure path releases + // whatever the previous step acquired. + unsafe { + // Drop a stale object from a frame the terminal never read. + libc::shm_unlink(cname.as_ptr()); + + let fd = libc::shm_open( + cname.as_ptr(), + libc::O_CREAT | libc::O_EXCL | libc::O_RDWR, + 0o600 as libc::c_uint, + ); + if fd < 0 { + return None; + } + + if libc::ftruncate(fd, len as libc::off_t) != 0 { + libc::close(fd); + libc::shm_unlink(cname.as_ptr()); + return None; + } + + let ptr = libc::mmap( + std::ptr::null_mut(), + len, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + fd, + 0, + ); + // The mapping keeps the object alive; the descriptor is not needed. + libc::close(fd); + + if ptr == libc::MAP_FAILED { + libc::shm_unlink(cname.as_ptr()); + return None; + } + + Some(Self { + name, + ptr: ptr.cast::(), + len, + }) + } + } + + /// The object's name, to be sent as the escape sequence's payload. + pub fn name(&self) -> &str { + &self.name + } + + /// The mapped bytes, for the caller to write pixels into. + pub fn as_mut_slice(&mut self) -> &mut [u8] { + // SAFETY: `ptr` came from a successful `mmap` of exactly `len` writable + // bytes and is unmapped only in `Drop`, so the slice cannot outlive it. + unsafe { std::slice::from_raw_parts_mut(self.ptr, self.len) } + } +} + +impl Drop for ShmFrame { + fn drop(&mut self) { + // SAFETY: `ptr`/`len` are exactly what `mmap` returned, and this runs + // once because `Drop` runs once. The object itself is intentionally + // left linked for the terminal to open. + unsafe { + libc::munmap(self.ptr.cast::(), self.len); + } + } +} + +/// Unlink every name this process may have created. +/// +/// The terminal normally does this itself, so in the common case every call +/// here is a no-op; it exists so a frame that was dropped mid-flight — on +/// quit, or on a switch out of FullHD — cannot outlive the process. +pub fn unlink_all() { + for slot in 0..=PROBE_SLOT { + if let Ok(cname) = CString::new(slot_name(slot)) { + // SAFETY: valid NUL-terminated string; unlinking an absent name + // just returns an error we do not care about. + unsafe { + libc::shm_unlink(cname.as_ptr()); + } + } + } +} + +/// Ask the terminal whether it can actually read pixels out of shared memory. +/// +/// Uses the handshake the protocol documents for exactly this: a *query* action +/// — which validates without storing or displaying anything — followed by a +/// primary device attributes request. A terminal that supports the graphics +/// protocol must answer the query before it answers the DA request, so a DA +/// response arriving alone is a definitive "no". The DA response also bounds +/// the wait, with `timeout` as a backstop for terminals that answer neither. +/// +/// Both responses are consumed here so they cannot surface later as spurious +/// key events. +pub fn probe(timeout: std::time::Duration) -> bool { + let Some(mut frame) = ShmFrame::create(PROBE_SLOT, 4) else { + return false; + }; + frame.as_mut_slice().copy_from_slice(&[0, 0, 0, 255]); + let name = frame.name().to_string(); + drop(frame); + + let supported = query_terminal(&name, timeout); + + // The terminal unlinks the object only if it read it; on the "no" path it + // is still there. + if let Ok(cname) = CString::new(name) { + // SAFETY: valid NUL-terminated string. + unsafe { + libc::shm_unlink(cname.as_ptr()); + } + } + + supported +} + +/// Send the query + DA pair and classify the replies. +fn query_terminal(shm_name: &str, timeout: std::time::Duration) -> bool { + use std::io::Write; + + let payload = base64_encode(shm_name.as_bytes()); + let query = format!("\x1b_Gi={PROBE_IMAGE_ID},a=q,f=32,t=s,s=1,v=1,S=4;{payload}\x1b\\\x1b[c"); + + let mut stdout = std::io::stdout(); + if stdout.write_all(query.as_bytes()).is_err() || stdout.flush().is_err() { + return false; + } + + let deadline = std::time::Instant::now() + timeout; + let mut buf = Vec::with_capacity(256); + let mut chunk = [0u8; 128]; + + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return false; + } + match read_stdin_timeout(&mut chunk, remaining) { + Some(0) | None => return false, + Some(n) => buf.extend_from_slice(&chunk[..n]), + } + + if let Some(ok) = classify(&buf) { + return ok; + } + // Guard against a terminal that streams unrelated input at us. + if buf.len() > 4096 { + return false; + } + } +} + +/// `Some(true)` once a graphics `OK` has arrived, `Some(false)` once the device +/// attributes reply has arrived without one, `None` while still undecided. +fn classify(buf: &[u8]) -> Option { + if let Some(start) = find(buf, b"\x1b_G") { + if let Some(end) = find(&buf[start..], b"\x1b\\") { + let body = &buf[start..start + end]; + return Some(find(body, b";OK").is_some()); + } + // Graphics reply started but has not finished; keep reading. + return None; + } + // A DA reply is `ESC [ ? ... c`. + let da = find(buf, b"\x1b[?")?; + buf[da..].iter().position(|b| *b == b'c').map(|_| false) +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +/// Read from stdin, waiting at most `timeout`. `None` on error or timeout. +fn read_stdin_timeout(buf: &mut [u8], timeout: std::time::Duration) -> Option { + let mut fds = libc::pollfd { + fd: libc::STDIN_FILENO, + events: libc::POLLIN, + revents: 0, + }; + let millis = timeout.as_millis().min(i32::MAX as u128) as libc::c_int; + + // SAFETY: a single well-formed `pollfd` describing stdin. + let ready = unsafe { libc::poll(&mut fds, 1, millis) }; + if ready <= 0 { + return None; + } + + // SAFETY: `buf` is a valid writable slice of the length passed in. + let n = unsafe { + libc::read( + libc::STDIN_FILENO, + buf.as_mut_ptr().cast::(), + buf.len(), + ) + }; + if n < 0 { None } else { Some(n as usize) } +} + +/// Standard base64, for the handful of bytes in a shared memory object name. +fn base64_encode(data: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(data) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slot_names_fit_the_posix_limit() { + // macOS caps shared memory object names at 31 characters. + for slot in 0..=PROBE_SLOT { + let name = slot_name(slot); + assert!(name.starts_with('/'), "{name} must be an absolute name"); + assert!(name.len() <= 31, "{name} is {} chars", name.len()); + } + } + + #[test] + fn create_maps_writable_memory_and_reads_back() { + let len = 64 * 1024; + let mut frame = ShmFrame::create(0, len).expect("shm unavailable"); + assert_eq!(frame.as_mut_slice().len(), len); + frame.as_mut_slice()[0] = 0xAB; + frame.as_mut_slice()[len - 1] = 0xCD; + assert_eq!(frame.as_mut_slice()[0], 0xAB); + assert_eq!(frame.as_mut_slice()[len - 1], 0xCD); + drop(frame); + unlink_all(); + } + + #[test] + fn recreating_a_slot_replaces_a_stale_object() { + // Simulates a frame the terminal never consumed: the slot is still + // linked when the ring comes back round to it. + let first = ShmFrame::create(1, 4096).expect("shm unavailable"); + let name = first.name().to_string(); + drop(first); + let second = ShmFrame::create(1, 8192).expect("stale slot blocked reuse"); + assert_eq!(second.name(), name); + drop(second); + unlink_all(); + } + + /// The whole transport rests on this: after we unmap, a *separate* opener + /// -- the terminal -- can still find the object by name and read the + /// pixels we wrote. + #[test] + fn another_opener_reads_the_pixels_after_we_unmap() { + let len = 4096; + let mut frame = ShmFrame::create(3, len).expect("shm unavailable"); + let name = frame.name().to_string(); + for (i, byte) in frame.as_mut_slice().iter_mut().enumerate() { + *byte = (i % 256) as u8; + } + drop(frame); // exactly what the render path does before the terminal reads + + let cname = CString::new(name.as_str()).unwrap(); + // SAFETY: mirrors what the terminal does -- open the named object + // read-only, map it, read it, then unlink and unmap. + unsafe { + let fd = libc::shm_open(cname.as_ptr(), libc::O_RDONLY, 0 as libc::c_uint); + assert!(fd >= 0, "reopening {name} failed"); + let ptr = libc::mmap( + std::ptr::null_mut(), + len, + libc::PROT_READ, + libc::MAP_SHARED, + fd, + 0, + ); + libc::close(fd); + assert_ne!(ptr, libc::MAP_FAILED, "mapping {name} failed"); + + let seen = std::slice::from_raw_parts(ptr.cast::(), len); + let expected: Vec = (0..len).map(|i| (i % 256) as u8).collect(); + assert_eq!( + seen, + expected.as_slice(), + "pixels did not survive unmapping" + ); + + libc::munmap(ptr, len); + libc::shm_unlink(cname.as_ptr()); + } + } + + #[test] + fn zero_length_is_rejected() { + assert!(ShmFrame::create(2, 0).is_none()); + } + + #[test] + fn classify_detects_ok_error_and_da() { + assert_eq!(classify(b"\x1b_Gi=7317;OK\x1b\\"), Some(true)); + assert_eq!(classify(b"\x1b_Gi=7317;EBADF:no shm\x1b\\"), Some(false)); + // Graphics reply still arriving. + assert_eq!(classify(b"\x1b_Gi=7317;O"), None); + // DA alone means the graphics protocol went unanswered. + assert_eq!(classify(b"\x1b[?62;c"), Some(false)); + // DA still arriving. + assert_eq!(classify(b"\x1b[?62;"), None); + assert_eq!(classify(b""), None); + } + + #[test] + fn classify_prefers_graphics_reply_over_da() { + assert_eq!(classify(b"\x1b_Gi=7317;OK\x1b\\\x1b[?62;c"), Some(true)); + } +} diff --git a/src/render/mod.rs b/src/render/mod.rs index 58efc4c..fb40104 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -5,6 +5,7 @@ pub mod color; pub mod framebuffer; pub mod hd; pub mod kitty_png; +pub mod kitty_shm; pub mod palette; pub mod ribbon; pub mod snapshot; diff --git a/src/ui/viewport.rs b/src/ui/viewport.rs index 00ef8e6..f9b253c 100644 --- a/src/ui/viewport.rs +++ b/src/ui/viewport.rs @@ -4,12 +4,15 @@ use ratatui::layout::Rect; use ratatui_image::picker::ProtocolType; use ratatui_image::{Image, Resize}; -use crate::app::{App, ConnectionType, RenderMode}; +use crate::app::{self, App, ConnectionType, RenderMode}; use crate::model::interface::Interaction; use crate::render::braille; -use crate::render::framebuffer::{framebuffer_to_braille_widget, framebuffer_to_braille_widget_ssaa}; +use crate::render::framebuffer::{ + Framebuffer, framebuffer_to_braille_widget, framebuffer_to_braille_widget_ssaa, +}; use crate::render::hd; use crate::render::kitty_png::KittyPngImage; +use crate::render::kitty_shm; /// Supersampling factor for HDplus mode. /// @@ -141,47 +144,43 @@ fn render_hdplus_viewport(frame: &mut Frame, area: Rect, app: &App, interactions frame.render_widget(widget, area); } -/// Render the FullHD viewport using graphics protocol (Sixel/Kitty/iTerm2) when -/// available, falling back to colored braille characters otherwise. +/// Render the FullHD viewport using a graphics protocol (Kitty/Sixel/iTerm2) +/// when available, falling back to colored braille characters otherwise. fn render_fullhd_viewport(frame: &mut Frame, area: Rect, app: &App, interactions: &[Interaction]) { let proto = app.picker.protocol_type(); let (font_w, font_h) = app.picker.font_size(); - - // Determine framebuffer pixel dimensions. - // With a true graphics protocol we render at full pixel resolution - // (cols * font_width, rows * font_height). For the colored braille - // fallback we render at braille resolution: cols*2 wide, rows*4 tall. - // - // During interaction (auto-rotate), render at half resolution for the - // graphics-protocol path. The terminal upscales via Kitty `c=/r=` params. - // Even with parallel rasterization, half-res keeps frame rates smooth - // on large structures. let is_graphics = proto != ProtocolType::Halfblocks && font_w > 0 && font_h > 0; - let is_large = app.is_large; - let scale = if is_graphics && is_large && app.is_interacting() { - 0.5 + + // A graphics-protocol viewport is measured in device pixels, so on a HiDPI + // panel it is far larger than the cell grid suggests. `fullhd_framebuffer_size` + // owns that policy, and `App::recalculate_zoom` sizes the zoom through the + // very same function, so the two cannot drift apart. + let (still_w, still_h) = app::fullhd_framebuffer_size( + f64::from(area.width), + f64::from(area.height), + font_w, + font_h, + is_graphics, + ); + + // While the camera moves, render smaller and let the terminal stretch the + // result back over the viewport via the protocol's c=/r= keys: a quarter of + // the pixels, and motion hides the softness. Zoom and pan are in + // framebuffer pixels, so the camera has to be scaled to match. + let scale = if is_graphics && app.is_interacting() { + app::FULLHD_INTERACTIVE_SCALE } else { 1.0 }; - let (px_w, px_h) = if is_graphics { - ( - area.width as f64 * font_w as f64 * scale, - area.height as f64 * font_h as f64 * scale, - ) - } else { - (area.width as f64 * 2.0, area.height as f64 * 4.0) - }; + let (px_w, px_h) = ((still_w * scale).max(1.0), (still_h * scale).max(1.0)); - // Rasterize the 3D scene into a software framebuffer. - // When rendering at reduced resolution, scale camera zoom to match so the - // protein fills the same relative area of the smaller buffer. Kitty's - // c=/r= params then upscale the result to fill the full viewport. let mut cam = app.camera.clone(); if scale < 1.0 { cam.zoom *= scale; cam.pan_x *= scale; cam.pan_y *= scale; } + let fb = hd::render_hd_framebuffer( &app.protein, &cam, @@ -194,31 +193,22 @@ fn render_fullhd_viewport(frame: &mut Frame, area: Rect, app: &App, interactions interactions, ); - // If the terminal supports a real graphics protocol, convert the - // framebuffer to an image and send it. - if proto != ProtocolType::Halfblocks { - if proto == ProtocolType::Kitty { - // Use our custom zlib-compressed Kitty transmitter. - // This is ~10-20x smaller than ratatui-image's raw RGBA path, - // making FullHD viable over SSH. - let dyn_img = DynamicImage::ImageRgba8(fb.to_rgba_image()); - if let Some(widget) = KittyPngImage::new(&dyn_img, area) { - frame.render_widget(widget, area); - return; - } - // PNG encoding failed — fall through to braille. + // If the terminal supports a real graphics protocol, hand it the pixels. + if proto == ProtocolType::Kitty { + if let Some(widget) = kitty_widget(&fb, app, area) { + frame.render_widget(widget, area); + return; } - - // Sixel/iTerm2: use ratatui-image (no PNG option for Sixel). - if proto != ProtocolType::Kitty { - let dyn_img = DynamicImage::ImageRgb8(fb.to_rgb_image()); - if let Ok(protocol) = app.picker.new_protocol(dyn_img, area, Resize::Fit(None)) { - let widget = Image::new(&protocol); - frame.render_widget(widget, area); - return; - } - // Protocol error — fall through to braille. + // Transmission failed — fall through to braille. + } else if proto != ProtocolType::Halfblocks { + // Sixel/iTerm2: use ratatui-image (no shared-memory option there). + let dyn_img = DynamicImage::ImageRgb8(fb.to_rgb_image()); + if let Ok(protocol) = app.picker.new_protocol(dyn_img, area, Resize::Fit(None)) { + let widget = Image::new(&protocol); + frame.render_widget(widget, area); + return; } + // Protocol error — fall through to braille. } // Fallback: colored braille character rendering (always works). @@ -226,6 +216,34 @@ fn render_fullhd_viewport(frame: &mut Frame, area: Rect, app: &App, interactions frame.render_widget(widget, area); } +/// Build the Kitty transmission for this frame. +/// +/// Prefers shared memory, where the pixels are written once — straight into the +/// mapping the terminal will read — and the escape sequence carries only the +/// object's name. Falls back to the inline zlib transport when the terminal +/// did not accept shared memory at startup, or if the mapping cannot be created +/// this frame. +fn kitty_widget(fb: &Framebuffer, app: &App, area: Rect) -> Option { + let (w, h) = (fb.width as u32, fb.height as u32); + let byte_len = fb.width * fb.height * 4; + + if app.kitty_shm { + let slot = app.next_shm_slot(); + if let Some(mut shm) = kitty_shm::ShmFrame::create(slot, byte_len) { + fb.write_rgba(shm.as_mut_slice()); + let widget = KittyPngImage::from_shm(shm.name(), w, h, byte_len, area); + // Unmapping our view is safe immediately: the object stays linked + // until the terminal reads and unlinks it. + drop(shm); + return Some(widget); + } + } + + let mut rgba = vec![0u8; byte_len]; + fb.write_rgba(&mut rgba); + KittyPngImage::from_rgba(&rgba, w, h, area) +} + #[cfg(test)] mod tests { use super::*; From bdbf5f60c27e072d6773e195c87a8339011bba22 Mon Sep 17 00:00:00 2001 From: jamaliki <39654543+jamaliki@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:14:22 +0100 Subject: [PATCH 06/10] style: rustfmt palette.rs `cargo fmt --check` was failing on one over-wrapped `parse(...)` call in the tests. Formatting only; no behaviour change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011eVRPRPVdfAcHv25ThbxWW --- src/render/palette.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/render/palette.rs b/src/render/palette.rs index 9440e26..5bd424a 100644 --- a/src/render/palette.rs +++ b/src/render/palette.rs @@ -481,10 +481,7 @@ mod tests { fn chain_colors_survive_being_written_after_other_sections() { // A bare top-level `chains = [...]` would bind to whichever [table] // preceded it. Keeping it in its own section makes order irrelevant. - let p = parse( - "[structure]\nhelix = \"FF0000\"\n\n[chain]\ncolors = [\"00FF00\"]", - ) - .unwrap(); + let p = parse("[structure]\nhelix = \"FF0000\"\n\n[chain]\ncolors = [\"00FF00\"]").unwrap(); assert_eq!(p.chains, vec![Rgb::new(0, 255, 0)]); assert_eq!(p.structure.helix, Rgb::new(255, 0, 0)); } From 9cb184852619244ee7d4f52c7a6251f97812e48b Mon Sep 17 00:00:00 2001 From: jamaliki <39654543+jamaliki@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:22:14 +0100 Subject: [PATCH 07/10] fix: stop the FullHD resolution cap clipping ordinary HiDPI viewports The cap was set at 4 MP on an estimate of the cell size. Measured on a real full-screen kitty at font_size 14 on a 2560x1600 Retina panel, cells are 20x43 device pixels over a 144x36 viewport -- a 4.46 MP framebuffer, which tripped the cap by 10%. That is the worst case for capping: the terminal scales the render back up by 1.056x, so every still frame paid a non-integer resample to save a tenth of the pixels. Raise it to 12 MP and say what the cap is actually for. It is a backstop against a framebuffer large enough to cost real memory, not a frame-rate control -- a still frame is drawn once before the loop goes idle, and frames drawn while the view moves are already quartered. 12 MP clears 4K and full-screen HiDPI laptops with room to spare, leaving the cap to meet 5K and above, where the framebuffer would otherwise run past a hundred megabytes. Tests now use the measured geometry rather than the estimate, and pin both a 4K and a full-screen HiDPI viewport as rendering natively. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011eVRPRPVdfAcHv25ThbxWW --- src/app.rs | 51 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/src/app.rs b/src/app.rs index fd22110..c451036 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,12 +15,20 @@ pub const LARGE_STRUCTURE_THRESHOLD: usize = 5000; /// Upper bound on the FullHD framebuffer, in pixels. /// -/// A graphics-protocol viewport is sized in *device* pixels, so on a HiDPI -/// panel it is four times the area the cell grid suggests, and every per-pixel -/// stage scales with it. This caps the still-frame resolution on very large or -/// very dense displays; below the cap the render stays at native resolution, so -/// a normal window is unaffected. 4 MP covers a full-screen Retina laptop. -pub const FULLHD_MAX_PIXELS: f64 = 4_000_000.0; +/// A graphics-protocol viewport is sized in *device* pixels, so on a HiDPI panel +/// it is several times the area the cell grid suggests, and every per-pixel +/// stage scales with it. This is a backstop against a framebuffer so large it +/// costs real memory, not a frame-rate control: a still frame is rendered once +/// and then the loop idles, and everything drawn *while* the view moves is +/// already quartered by [`FULLHD_INTERACTIVE_SCALE`]. +/// +/// Set it generously, because capping is not free. The terminal scales the +/// result back up, and a cap that barely engages buys a few percent of the +/// pixels in exchange for a non-integer resample of every still frame — worse +/// output for no useful saving. 12 MP clears a 4K viewport and a full-screen +/// HiDPI laptop with room to spare, so the cap only meets 5K and above, where +/// the framebuffer would otherwise run past a hundred megabytes. +pub const FULLHD_MAX_PIXELS: f64 = 12_000_000.0; /// Resolution multiplier used while the camera is moving. /// @@ -648,8 +656,9 @@ impl App { mod fullhd_sizing_tests { use super::*; - /// A full-screen kitty at font_size 14 on a 2560x1600 Retina panel. - const RETINA: (f64, f64, u16, u16) = (150.0, 41.0, 17, 35); + /// Measured: a full-screen kitty at font_size 14 on a 2560x1600 Retina + /// panel reports 20x43 device-pixel cells over a 144x36 viewport. + const RETINA: (f64, f64, u16, u16) = (144.0, 36.0, 20, 43); #[test] fn native_resolution_is_used_below_the_cap() { @@ -662,11 +671,31 @@ mod fullhd_sizing_tests { ); } + /// Regression: the cap was first set at 4 MP, which a real full-screen + /// HiDPI laptop viewport (4.46 MP) tripped by 10% — paying a non-integer + /// upscale of every still frame to save almost nothing. + #[test] + fn a_full_screen_hidpi_laptop_is_not_capped() { + let (cols, rows, fw, fh) = RETINA; + let native = cols * f64::from(fw) * rows * f64::from(fh); + assert!( + native < FULLHD_MAX_PIXELS, + "{native} px viewport should render natively, cap is {FULLHD_MAX_PIXELS}" + ); + } + + /// A 4K viewport should also pass through untouched. + #[test] + fn a_4k_viewport_is_not_capped() { + let (w, h) = fullhd_framebuffer_size(3840.0, 2160.0, 1, 1, true); + assert_eq!((w, h), (3840.0, 2160.0)); + } + #[test] fn oversized_viewports_are_capped_by_area_and_keep_their_aspect() { - // A 5K display: well past the cap. - let (native_w, native_h) = (5120.0, 2880.0); - let (w, h) = fullhd_framebuffer_size(5120.0, 2880.0, 1, 1, true); + // A 6K display: well past the cap. + let (native_w, native_h) = (6016.0, 3384.0); + let (w, h) = fullhd_framebuffer_size(6016.0, 3384.0, 1, 1, true); assert!( w * h <= FULLHD_MAX_PIXELS * 1.001, From 57ec4cff87c6089eb4183617515378ae22ff8744 Mon Sep 17 00:00:00 2001 From: jamaliki <39654543+jamaliki@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:06:56 +0100 Subject: [PATCH 08/10] feat: add a scrollable sequence panel with residue selection Reading a structure means moving between the sequence and the geometry, and ProteinView had no way to do that: you could cycle chains, but never see what a chain *is*, let alone point at one residue in it. `S` opens a panel listing every chain's sequence in one-letter codes -- amino acids and nucleotides alike -- wrapped to the panel width and scrolled as a single list. Rows carry the residue number of their first residue rather than a running count, so the numbering gaps that fill deposited structures stay honest, and letters take their color from the active scheme so the panel and the structure read as one picture. Arrows move a residue cursor, `Shift`+arrow extends a range, `Enter` picks one residue and `A` a whole chain. Picked residues are drawn as ball-and-stick over whatever mode is active, z-buffered against it, with the selection color on carbons and CPK elsewhere -- the standard way to make a fragment legible without recoloring chemistry. `b` drops back to one marker sphere per residue, pushed forward by a ribbon half-width so a marker is never hidden by the very residue it marks. `z` centres the view on the selection, which is the difference between finding twelve residues in a mitoribosome and not. The panel takes only the arrow keys: h/j/k/l still rotate, so the structure stays steerable with the sequence in front of you. The selection outlives the panel, and the status bar keeps its count. Design notes: - Membership is a flat [chain][residue] bitmap, not a hash set. The renderer asks about every residue every frame; this makes that an index. - One `SequenceLayout` per panel width feeds both the renderer and the cursor keys, so what is on screen and what the arrows move through cannot disagree. Cursor position maps to a row by arithmetic rather than a search. - The layout is synced before *and* after input handling: the key that opens the panel arrives after the first sync, and without the second the opening frame would draw an empty panel. - Renderers take `Option`; the snapshot and panel-server paths pass `None` and are unchanged. - Selection colors come from a new `[selection]` palette section. Verified against 8XT3 (52 chains, 9758 residues, 100k atoms) by driving the real TUI in a pty, including at 16x44 and alongside the interface sidebar. Thirteen new tests cover the layout/cursor round-trip, header-skipping vertical moves, cross-chain range extension, camera centring, the panel-size floor, and that the overlay reaches all four render modes while touching only picked residues. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WWJUeNvJK9gt3iBy9fzg3v --- README.md | 41 +++ docs/palette.example.toml | 9 + src/app.rs | 516 ++++++++++++++++++++++++++++++++- src/main.rs | 86 +++++- src/model/mod.rs | 2 + src/model/residue_selection.rs | 291 +++++++++++++++++++ src/model/sequence.rs | 279 ++++++++++++++++++ src/panel_server.rs | 1 + src/render/braille.rs | 111 +++++++ src/render/hd.rs | 293 ++++++++++++++++++- src/render/palette.rs | 28 ++ src/render/snapshot.rs | 1 + src/ui/help_overlay.rs | 52 +++- src/ui/helpbar.rs | 61 +++- src/ui/mod.rs | 1 + src/ui/sequence_panel.rs | 430 +++++++++++++++++++++++++++ src/ui/statusbar.rs | 23 ++ src/ui/viewport.rs | 66 ++++- 18 files changed, 2275 insertions(+), 16 deletions(-) create mode 100644 src/model/residue_selection.rs create mode 100644 src/model/sequence.rs create mode 100644 src/ui/sequence_panel.rs diff --git a/README.md b/README.md index 8177d28..59c84b0 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Terminal molecular structure viewer — load, rotate, and explore proteins, nucl - **RNA/DNA support** — backbone, wireframe, and cartoon modes with base-type coloring - **Small molecule rendering** — ligands as ball-and-stick, ions as spheres - **Interface analysis** — inter-chain contacts, binding pockets, and interaction visualization (H-bonds, salt bridges, hydrophobic contacts) +- **Sequence panel** — scroll every chain's sequence, select residues, and show them as ball-and-stick in the 3D view - **7 color schemes** — structure, chain, element (CPK), B-factor, rainbow, pLDDT (AlphaFold) - **Interactive controls** — vim-style rotation, zoom, pan with auto-rotation - **PDB & mmCIF** — both formats supported, with RCSB PDB fetch (`--fetch`) @@ -248,10 +249,50 @@ leave the prior frame, state, and revision unchanged. | `I` | Interface interactions | | `g` | Toggle ligands | | `[`/`]` | Prev/next chain | +| `S` | Sequence panel | +| `b` | Ball-and-stick for the selection | +| `z` | Centre the view on the selection | | `Space` | Auto-rotate | | `?` | Help | | `q` | Quit | +While the sequence panel is open it takes the arrow keys; `h`/`j`/`k`/`l` still +rotate the view, so you can turn the structure while picking residues. + +| Key | Action in the sequence panel | +|-----|------------------------------| +| `←`/`→` | Move the cursor one residue (across chain ends) | +| `↑`/`↓` | Move one row | +| `Shift`+arrow | Extend the selection from the cursor | +| `PgUp`/`PgDn` | Move a screenful | +| `Home`/`End` | Start / end of the chain | +| `Enter` | Select or deselect the residue | +| `A` | Select or deselect the whole chain | +| `x` | Clear the selection | +| `[`/`]` | Jump to the previous / next chain | +| `<`/`>` | Shrink / grow the panel | +| `S` / `Esc` | Close the panel | + +## Sequence Panel & Residue Selection + +Press `S` to open a scrollable panel listing the sequence of every chain in +one-letter codes — amino acids and nucleotides alike, numbered in the gutter and +grouped in tens. Each chain gets a header with its type, length, and residue +range, so a 52-chain ribosome reads as one continuous list. + +The cursor moves with the arrow keys; `Enter` picks a residue, `Shift`+arrow +extends a range, and `A` takes a whole chain. Picked residues are drawn in the +3D view as ball-and-stick over whatever mode is active, with the selection color +on carbons and CPK colors elsewhere, z-buffered so a side chain that really is +behind the structure stays behind it. `b` turns the ball-and-stick off, leaving a +marker sphere per residue; `z` centres the view on the selection, which is how +you find a handful of residues inside something the size of a ribosome. The +selection survives closing the panel, and the status bar keeps its count. + +Letters are colored by the active color scheme, so the panel and the structure +read as one picture; the selection and cursor colors come from `[selection]` in +the palette file. + ## Color Schemes | Scheme | Description | diff --git a/docs/palette.example.toml b/docs/palette.example.toml index 7ad7b29..62a55f2 100644 --- a/docs/palette.example.toml +++ b/docs/palette.example.toml @@ -95,3 +95,12 @@ ligand = "FFFFFF" # ligands, kept bright so they stay visible ligand = "FF00FF" ion = "00FFFF" rainbow = "FF00FF" # ligands under the Rainbow scheme + +# --- Sequence panel selection ('S') ------------------------------------------ +# Residues picked in the sequence panel. Carbons of the ball-and-stick overlay +# take `carbon`; every other element keeps its CPK color, so a picked residue +# still reads as itself. +[selection] +carbon = "00E68C" # ball-and-stick carbons of picked residues +marker = "00E68C" # marker sphere shown when ball-and-stick is off ('b') +cursor = "FFC800" # the sequence panel's cursor cell diff --git a/src/app.rs b/src/app.rs index c451036..98535fc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,8 +3,10 @@ use std::sync::mpsc; use ratatui_image::picker::Picker; use crate::model::interface::{InterfaceAnalysis, analyze_binding_pockets, analyze_interface}; -use crate::model::protein::Protein; +use crate::model::protein::{Protein, Residue}; +use crate::model::residue_selection::ResidueSelection; use crate::model::selection::ResidueColorOverrides; +use crate::model::sequence::{SeqRow, SequenceLayout, wrap_for_width}; use crate::render::camera::Camera; use crate::render::color::{ColorScheme, ColorSchemeType}; use crate::render::ribbon::{RibbonTriangle, generate_ribbon_mesh}; @@ -223,6 +225,29 @@ pub struct App { /// Next shared-memory slot to hand the terminal. A `Cell` because the /// viewport renders from `&App` but each frame needs its own object. shm_slot: std::cell::Cell, + /// Whether the scrollable chain-sequence panel is open. + pub show_sequence: bool, + /// Residues picked in the sequence panel. + pub selection: ResidueSelection, + /// Whether the selection is drawn as ball-and-stick in the 3D view. + /// When off, selected residues are still marked with a highlight sphere. + pub show_ball_stick: bool, + /// Cursor position in the sequence panel, as `(chain, residue)` indices. + pub seq_cursor: (usize, usize), + /// Anchor for shift-extended range selection, cleared by any unshifted move. + seq_anchor: Option<(usize, usize)>, + /// First layout row drawn in the panel. + pub seq_scroll: usize, + /// Wrapped row layout, rebuilt whenever the panel width changes. + seq_layout: SequenceLayout, + /// Sequence rows the panel can currently show, set from the drawn area. + seq_visible_rows: usize, + /// Panel height in terminal rows, adjustable with `<` and `>`. + pub seq_panel_height: u16, + /// A chain to scroll to once the layout for the current width exists. + /// Opening the panel happens before the frame that sizes it, so the jump + /// has to wait for a layout to jump within. + seq_pending_goto: Option, } impl App { @@ -339,6 +364,15 @@ impl App { let connection_type = ConnectionType::detect(); + let selection = ResidueSelection::new(&protein); + // The first chain with residues is where the sequence cursor starts; + // a structure can open with an empty leading chain. + let cursor_chain = protein + .chains + .iter() + .position(|chain| !chain.residues.is_empty()) + .unwrap_or(0); + Self { protein, camera, @@ -367,6 +401,16 @@ impl App { last_camera_change: None, kitty_shm: false, shm_slot: std::cell::Cell::new(0), + show_sequence: false, + selection, + show_ball_stick: true, + seq_cursor: (cursor_chain, 0), + seq_anchor: None, + seq_scroll: 0, + seq_layout: SequenceLayout::default(), + seq_visible_rows: 1, + seq_panel_height: DEFAULT_SEQUENCE_PANEL_HEIGHT, + seq_pending_goto: None, } } @@ -517,6 +561,9 @@ impl App { if self.show_interface { self.rebuild_interface_colors(); } + if self.show_sequence { + self.seq_goto_chain(self.current_chain); + } } } @@ -530,6 +577,9 @@ impl App { if self.show_interface { self.rebuild_interface_colors(); } + if self.show_sequence { + self.seq_goto_chain(self.current_chain); + } } } @@ -652,6 +702,470 @@ impl App { } } +/// Panel rows that are not sequence: the top border and the cursor line. +pub const SEQUENCE_PANEL_CHROME: u16 = 2; + +/// Default height of the sequence panel, in terminal rows. +pub const DEFAULT_SEQUENCE_PANEL_HEIGHT: u16 = 10; + +/// Bounds for `<` / `>` resizing. The panel is additionally capped at half the +/// terminal height by the layout, so the 3D view never disappears. +pub const MIN_SEQUENCE_PANEL_HEIGHT: u16 = 4; +pub const MAX_SEQUENCE_PANEL_HEIGHT: u16 = 30; + +/// Sequence panel: layout, cursor navigation and residue selection. +impl App { + /// Open or close the panel. The selection outlives it, so closing the + /// panel keeps whatever is drawn in 3D. + pub fn toggle_sequence_panel(&mut self) { + self.show_sequence = !self.show_sequence; + if self.show_sequence { + // Follow the chain the rest of the UI is focused on, once there is + // a layout to scroll within. + self.seq_pending_goto = Some(self.current_chain); + } + } + + /// Tell the panel how much room it has, rebuilding the wrapped layout when + /// the width changes. Called once per frame before input is handled, so + /// navigation and rendering always share one layout. + pub fn set_sequence_viewport(&mut self, width: u16, height: u16) { + let avail = width.saturating_sub(crate::ui::sequence_panel::GUTTER) as usize; + let wrap = wrap_for_width(avail); + if self.seq_layout.width != width || self.seq_layout.wrap != wrap { + self.seq_layout = SequenceLayout::build(&self.protein, wrap, width); + } + self.seq_visible_rows = height.saturating_sub(SEQUENCE_PANEL_CHROME).max(1) as usize; + if let Some(chain) = self.seq_pending_goto.take() { + self.seq_goto_chain(chain); + } + self.scroll_cursor_into_view(); + } + + pub fn sequence_layout(&self) -> &SequenceLayout { + &self.seq_layout + } + + /// Grow or shrink the panel by `delta` rows, within the fixed bounds. + pub fn resize_sequence_panel(&mut self, delta: i16) { + let height = i32::from(self.seq_panel_height) + i32::from(delta); + self.seq_panel_height = height.clamp( + i32::from(MIN_SEQUENCE_PANEL_HEIGHT), + i32::from(MAX_SEQUENCE_PANEL_HEIGHT), + ) as u16; + } + + /// The residue under the cursor, if the structure has one. + pub fn seq_cursor_residue(&self) -> Option<(&crate::model::protein::Chain, &Residue)> { + let chain = self.protein.chains.get(self.seq_cursor.0)?; + let residue = chain.residues.get(self.seq_cursor.1)?; + Some((chain, residue)) + } + + fn seq_chain_len(&self, chain: usize) -> usize { + self.protein + .chains + .get(chain) + .map_or(0, |chain| chain.residues.len()) + } + + /// Move the cursor to an exact residue, extending the selection when + /// `extend` is set. + /// + /// Extension is anchored at the cursor position the first shifted move + /// started from and only ever adds residues, so a shift-arrow can never + /// silently drop part of an existing selection. + fn seq_move_to(&mut self, chain: usize, residue: usize, extend: bool) { + if self.seq_chain_len(chain) == 0 { + return; + } + let previous = self.seq_cursor; + let residue = residue.min(self.seq_chain_len(chain) - 1); + self.seq_cursor = (chain, residue); + self.current_chain = chain; + // Interface coloring is keyed on the focus chain, so moving the cursor + // into another chain has to refresh it exactly as `[` / `]` does. + if previous.0 != chain && self.show_interface { + self.rebuild_interface_colors(); + } + + if extend { + let anchor = *self.seq_anchor.get_or_insert(previous); + if anchor.0 == chain { + self.selection.set_range(chain, anchor.1, residue, true); + } else { + // A range that crosses chains is not a range; restart the + // anchor in the new chain rather than selecting everything in + // between. + self.selection.set(chain, residue, true); + self.seq_anchor = Some((chain, residue)); + } + } else { + self.seq_anchor = None; + } + + self.scroll_cursor_into_view(); + } + + /// Flat index of a residue across all chains, used for horizontal movement + /// that runs off the end of a chain into the next one. + fn seq_flat_index(&self, chain: usize, residue: usize) -> usize { + self.protein.chains[..chain.min(self.protein.chains.len())] + .iter() + .map(|chain| chain.residues.len()) + .sum::() + + residue + } + + fn seq_from_flat(&self, mut index: usize) -> Option<(usize, usize)> { + for (chain_index, chain) in self.protein.chains.iter().enumerate() { + if index < chain.residues.len() { + return Some((chain_index, index)); + } + index -= chain.residues.len(); + } + None + } + + /// Move by `delta` residues, crossing chain boundaries at the ends. + pub fn seq_move_horizontal(&mut self, delta: isize, extend: bool) { + let total: usize = self + .protein + .chains + .iter() + .map(|chain| chain.residues.len()) + .sum(); + if total == 0 { + return; + } + let flat = self.seq_flat_index(self.seq_cursor.0, self.seq_cursor.1) as isize; + let target = (flat + delta).clamp(0, total as isize - 1) as usize; + if let Some((chain, residue)) = self.seq_from_flat(target) { + self.seq_move_to(chain, residue, extend); + } + } + + /// Move `delta` layout rows, keeping the column and skipping chain headers. + pub fn seq_move_vertical(&mut self, delta: isize, extend: bool) { + let Some((row, column)) = self.seq_layout.locate(self.seq_cursor.0, self.seq_cursor.1) + else { + return; + }; + let rows = self.seq_layout.row_count() as isize; + if rows == 0 { + return; + } + let target = (row as isize + delta).clamp(0, rows - 1); + let step = if delta >= 0 { 1 } else { -1 }; + + // Headers carry no residue, so walk past them — first onwards in the + // direction of travel, then backwards if that ran off the end. + let landing = seek_residue_row(&self.seq_layout, target, step, rows) + .or_else(|| seek_residue_row(&self.seq_layout, target, -step, rows)); + let Some(landing) = landing else { + return; + }; + if let Some((chain, residue)) = self.seq_layout.residue_at(landing, column) { + self.seq_move_to(chain, residue, extend); + } + } + + /// Jump to the first or last residue of the cursor's chain. + pub fn seq_move_to_chain_edge(&mut self, end: bool, extend: bool) { + let chain = self.seq_cursor.0; + let len = self.seq_chain_len(chain); + if len == 0 { + return; + } + self.seq_move_to(chain, if end { len - 1 } else { 0 }, extend); + } + + /// Move by one screenful. + pub fn seq_page(&mut self, forward: bool, extend: bool) { + let rows = self.seq_visible_rows.max(1) as isize; + self.seq_move_vertical(if forward { rows } else { -rows }, extend); + } + + /// Put the cursor on the first residue of `chain` and show its header. + pub fn seq_goto_chain(&mut self, chain: usize) { + if self.protein.chains.is_empty() { + return; + } + let chain = chain.min(self.protein.chains.len() - 1); + if self.seq_chain_len(chain) == 0 { + // Nothing to put a cursor on; still scroll the header into view. + if let Some(header) = self.seq_layout.header_row(chain) { + self.seq_scroll = self.clamp_scroll(header); + } + self.current_chain = chain; + return; + } + self.seq_move_to(chain, 0, false); + if let Some(header) = self.seq_layout.header_row(chain) { + self.seq_scroll = self.clamp_scroll(header); + } + } + + /// Toggle the residue under the cursor. + pub fn seq_toggle_selection(&mut self) { + let (chain, residue) = self.seq_cursor; + if self.seq_chain_len(chain) == 0 { + return; + } + self.selection.toggle(chain, residue); + // A later shift-arrow extends from here. + self.seq_anchor = Some((chain, residue)); + } + + /// Select the cursor's whole chain, or clear it if it is already fully in. + pub fn seq_toggle_chain_selection(&mut self) { + let chain = self.seq_cursor.0; + let len = self.seq_chain_len(chain); + if len == 0 { + return; + } + let fully_selected = (0..len).all(|residue| self.selection.contains(chain, residue)); + self.selection.set_chain(chain, !fully_selected); + self.seq_anchor = None; + } + + pub fn clear_selection(&mut self) { + self.selection.clear(); + self.seq_anchor = None; + } + + pub fn toggle_ball_stick(&mut self) { + self.show_ball_stick = !self.show_ball_stick; + } + + /// Centre the view on the selection, or on the cursor residue when nothing + /// is selected. Zoom is left alone: finding the residue is the hard part, + /// and the user still owns the magnification. + pub fn focus_on_selection(&mut self) -> bool { + let target = self.selection.centroid(&self.protein).or_else(|| { + let (_, residue) = self.seq_cursor_residue()?; + let atoms = &residue.atoms; + if atoms.is_empty() { + return None; + } + let n = atoms.len() as f64; + Some([ + atoms.iter().map(|a| a.x).sum::() / n, + atoms.iter().map(|a| a.y).sum::() / n, + atoms.iter().map(|a| a.z).sum::() / n, + ]) + }); + let Some([x, y, z]) = target else { + return false; + }; + // `project` already includes the current pan, so subtracting the + // projected offset lands the target exactly on the view centre for any + // rotation or zoom. + let projected = self.camera.project(x, y, z); + self.camera.pan_x -= projected.x; + self.camera.pan_y -= projected.y; + self.note_camera_change(); + true + } + + fn clamp_scroll(&self, scroll: usize) -> usize { + let max = self + .seq_layout + .row_count() + .saturating_sub(self.seq_visible_rows.max(1)); + scroll.min(max) + } + + fn scroll_cursor_into_view(&mut self) { + let Some((row, _)) = self.seq_layout.locate(self.seq_cursor.0, self.seq_cursor.1) else { + return; + }; + let visible = self.seq_visible_rows.max(1); + if row < self.seq_scroll { + self.seq_scroll = row; + } else if row >= self.seq_scroll + visible { + self.seq_scroll = row + 1 - visible; + } + self.seq_scroll = self.clamp_scroll(self.seq_scroll); + } +} + +/// First residue row at or after `from`, walking in `step` direction. +fn seek_residue_row( + layout: &SequenceLayout, + from: isize, + step: isize, + rows: isize, +) -> Option { + let mut row = from; + while (0..rows).contains(&row) { + if !matches!(layout.rows[row as usize], SeqRow::Header(_)) { + return Some(row as usize); + } + row += step; + } + None +} + +#[cfg(test)] +mod sequence_navigation_tests { + use super::*; + use crate::model::protein::{Atom, Chain, MoleculeType, SecondaryStructure}; + use crate::model::selection::ResidueColorOverrides; + use ratatui_image::picker::Picker; + + fn chain(id: &str, count: usize) -> Chain { + Chain { + id: id.to_string(), + molecule_type: MoleculeType::Protein, + residues: (0..count) + .map(|i| Residue { + name: "ALA".to_string(), + seq_num: i as i32 + 1, + insertion_code: None, + atoms: vec![Atom { + name: "CA".to_string(), + element: "C".to_string(), + x: i as f64, + y: 0.0, + z: 0.0, + b_factor: 10.0, + is_backbone: true, + is_hetero: false, + }], + secondary_structure: SecondaryStructure::Coil, + }) + .collect(), + } + } + + /// Chains of 25, 0 and 8 residues: the empty one in the middle is what + /// makes navigation interesting. + fn app() -> App { + let protein = Protein { + name: "nav".to_string(), + chains: vec![chain("A", 25), chain("B", 0), chain("C", 8)], + ligands: Vec::new(), + }; + let mut app = App::new( + protein, + AppConfig { + render_mode: RenderMode::Braille, + viz_mode: VizMode::Backbone, + user_explicit_mode: true, + color_override: None, + residue_colors: ResidueColorOverrides::default(), + }, + 80, + 40, + Picker::halfblocks(), + ); + app.show_sequence = true; + // Gutter plus three groups of ten: ten residues per row. + app.set_sequence_viewport(7 + 10, 8); + app + } + + #[test] + fn horizontal_movement_crosses_into_the_next_non_empty_chain() { + let mut app = app(); + app.seq_move_to_chain_edge(true, false); + assert_eq!(app.seq_cursor, (0, 24)); + // Past the end of chain A, straight over the empty chain B. + app.seq_move_horizontal(1, false); + assert_eq!(app.seq_cursor, (2, 0)); + assert_eq!(app.current_chain, 2); + // And back again. + app.seq_move_horizontal(-1, false); + assert_eq!(app.seq_cursor, (0, 24)); + } + + #[test] + fn vertical_movement_skips_chain_headers() { + let mut app = app(); + app.seq_move_to(0, 22, false); + // Row below the last row of chain A is chain C's header; the cursor + // must land on residues, not on it. + app.seq_move_vertical(1, false); + assert_eq!(app.seq_cursor.0, 2, "expected to land in chain C"); + app.seq_move_vertical(-1, false); + assert_eq!(app.seq_cursor.0, 0, "expected to land back in chain A"); + } + + #[test] + fn shift_extension_selects_a_range_and_restarts_across_chains() { + let mut app = app(); + app.seq_move_to(0, 4, false); + app.seq_toggle_selection(); + app.seq_move_horizontal(3, true); + assert_eq!(app.selection.count(), 4); + assert!(app.selection.contains(0, 7)); + + // Extending into another chain must not select everything between. + app.seq_move_to(2, 3, true); + assert_eq!(app.selection.count(), 5); + assert!(app.selection.contains(2, 3)); + assert!(!app.selection.contains(2, 0)); + } + + #[test] + fn the_cursor_stays_inside_the_visible_rows() { + let mut app = app(); + let visible = app.seq_visible_rows; + app.seq_move_to_chain_edge(true, false); + let (row, _) = app + .sequence_layout() + .locate(app.seq_cursor.0, app.seq_cursor.1) + .unwrap(); + assert!( + (app.seq_scroll..app.seq_scroll + visible).contains(&row), + "row {row} outside scroll window {}..{}", + app.seq_scroll, + app.seq_scroll + visible + ); + } + + #[test] + fn empty_chains_never_take_the_cursor() { + let mut app = app(); + app.seq_goto_chain(1); + assert_eq!(app.current_chain, 1, "the header still gets focus"); + assert_ne!(app.seq_cursor.0, 1, "but no residue cursor lands there"); + } + + #[test] + fn focusing_the_selection_centres_it() { + let mut app = app(); + app.selection.set_range(0, 0, 4, true); + assert!(app.focus_on_selection()); + let centroid = app.selection.centroid(&app.protein).unwrap(); + let projected = app.camera.project(centroid[0], centroid[1], centroid[2]); + assert!( + projected.x.abs() < 1e-9 && projected.y.abs() < 1e-9, + "selection should project to the view centre, got {projected:?}" + ); + } + + #[test] + fn the_panel_never_squeezes_out_the_viewport() { + let mut app = app(); + for _ in 0..100 { + app.resize_sequence_panel(1); + } + assert_eq!(app.seq_panel_height, MAX_SEQUENCE_PANEL_HEIGHT); + // On a 20-row terminal the layout still leaves the rest of the UI room. + assert_eq!( + crate::ui::sequence_panel::height_for(&app, 20), + 13, + "panel should give back the seven rows the rest of the UI needs" + ); + for _ in 0..100 { + app.resize_sequence_panel(-1); + } + assert_eq!(app.seq_panel_height, MIN_SEQUENCE_PANEL_HEIGHT); + } +} + #[cfg(test)] mod fullhd_sizing_tests { use super::*; diff --git a/src/main.rs b/src/main.rs index 6aace64..bbd328f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,6 +129,53 @@ struct Cli { threads: Option, } +/// Rebuild the sequence panel's wrapped layout for the current terminal size. +/// +/// Cheap unless the width actually changed, so it is called both before and +/// after input handling. +fn sync_sequence_viewport(app: &mut App, fallback: (u16, u16)) { + if !app.show_sequence { + return; + } + let (cols, rows) = crossterm::terminal::size().unwrap_or(fallback); + let panel_width = if app.show_interface { + cols.saturating_sub(ui::interface_panel::SIDEBAR_WIDTH) + } else { + cols + }; + let panel_height = ui::sequence_panel::height_for(app, rows); + app.set_sequence_viewport(panel_width, panel_height); +} + +/// Keys the sequence panel owns while it is open. +/// +/// Returns `true` when the key was consumed, so anything the panel does not +/// claim still reaches the normal bindings and the camera stays live. +fn handle_sequence_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool { + use crossterm::event::KeyModifiers; + + // Shift plus an arrow extends the selection from where the cursor was, the + // way a text editor does. + let extend = key.modifiers.contains(KeyModifiers::SHIFT); + match key.code { + KeyCode::Left => app.seq_move_horizontal(-1, extend), + KeyCode::Right => app.seq_move_horizontal(1, extend), + KeyCode::Up => app.seq_move_vertical(-1, extend), + KeyCode::Down => app.seq_move_vertical(1, extend), + KeyCode::PageUp => app.seq_page(false, extend), + KeyCode::PageDown => app.seq_page(true, extend), + KeyCode::Home => app.seq_move_to_chain_edge(false, extend), + KeyCode::End => app.seq_move_to_chain_edge(true, extend), + KeyCode::Enter => app.seq_toggle_selection(), + KeyCode::Char('A') => app.seq_toggle_chain_selection(), + KeyCode::Char('x') => app.clear_selection(), + KeyCode::Char('<') | KeyCode::Char(',') => app.resize_sequence_panel(-1), + KeyCode::Char('>') | KeyCode::Char('.') => app.resize_sequence_panel(1), + _ => return false, + } + true +} + fn main() -> Result<()> { let cli = Cli::parse(); @@ -435,6 +482,10 @@ fn main() -> Result<()> { let mut was_interacting = false; loop { + // Cursor movement is expressed in layout rows, so the layout has to be + // current before keys are handled. + sync_sequence_viewport(&mut app, (term_cols, term_rows)); + // Drain all queued input from the dedicated input thread let mut had_input = false; while let Ok(app_event) = input_rx.try_recv() { @@ -447,6 +498,12 @@ fn main() -> Result<()> { } event::AppEvent::Key(key) => { log!(logfile, "key: {:?}", key.code); + // While the panel is open it owns the arrow keys and the + // selection keys; h/j/k/l keep driving the camera, so the + // view stays steerable with the sequence in front of you. + if app.show_sequence && handle_sequence_key(&mut app, key) { + continue; + } match key.code { KeyCode::Char('q') => app.should_quit = true, KeyCode::Char('c') @@ -533,9 +590,16 @@ fn main() -> Result<()> { KeyCode::Char('I') => app.toggle_interactions(), KeyCode::Char('g') => app.toggle_ligands(), KeyCode::Char('?') => app.show_help = !app.show_help, + KeyCode::Char('S') => app.toggle_sequence_panel(), + KeyCode::Char('b') => app.toggle_ball_stick(), + KeyCode::Char('z') => { + app.focus_on_selection(); + } KeyCode::Esc => { if app.show_help { app.show_help = false; + } else if app.show_sequence { + app.show_sequence = false; } } _ => {} @@ -636,6 +700,11 @@ fn main() -> Result<()> { app.needs_clear = false; } + // ...and again after input, because the key that opened the panel or + // resized it arrived after the first sync: the frame about to be drawn + // must not be the one frame with a stale layout. + sync_sequence_viewport(&mut app, (term_cols, term_rows)); + let draw_start = Instant::now(); terminal.draw(|frame| { // If interface is active, split horizontally: sidebar | main @@ -665,20 +734,25 @@ fn main() -> Result<()> { frame.area() }; + let sequence_height = ui::sequence_panel::height_for(&app, main_area.height); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), // Header - Constraint::Min(3), // Viewport - Constraint::Length(2), // Status bar - Constraint::Length(1), // Help bar + Constraint::Length(1), // Header + Constraint::Min(3), // Viewport + Constraint::Length(sequence_height), // Sequence panel + Constraint::Length(2), // Status bar + Constraint::Length(1), // Help bar ]) .split(main_area); ui::header::render_header(frame, chunks[0], &app.protein.name); ui::viewport::render_viewport(frame, chunks[1], &app); - ui::statusbar::render_statusbar(frame, chunks[2], &app); - ui::helpbar::render_helpbar(frame, chunks[3]); + if sequence_height > 0 { + ui::sequence_panel::render_sequence_panel(frame, chunks[2], &app); + } + ui::statusbar::render_statusbar(frame, chunks[3], &app); + ui::helpbar::render_helpbar(frame, chunks[4], &app); if app.show_help { ui::help_overlay::render_help_overlay(frame, frame.area()); diff --git a/src/model/mod.rs b/src/model/mod.rs index f461126..1c3627a 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,4 +1,6 @@ pub mod interface; pub mod protein; +pub mod residue_selection; pub mod secondary; pub mod selection; +pub mod sequence; diff --git a/src/model/residue_selection.rs b/src/model/residue_selection.rs new file mode 100644 index 0000000..48c4baa --- /dev/null +++ b/src/model/residue_selection.rs @@ -0,0 +1,291 @@ +//! The set of residues the user has picked in the sequence panel. +//! +//! The renderer asks "is this residue selected?" once per residue per frame, +//! so membership is a flat `[chain][residue]` bitmap rather than a hash set: +//! lookup is an index, and the whole thing costs one byte per residue. + +use crate::model::protein::Protein; +use crate::model::selection::format_residue_id; + +/// How a selection should appear in the 3D view. +/// +/// Bundled into one borrow so the renderers take a single extra argument, and +/// so a caller with no selection (the snapshot and panel-server paths) simply +/// passes `None`. +#[derive(Debug, Clone, Copy)] +pub struct SelectionView<'a> { + pub selection: &'a ResidueSelection, + /// Draw every atom of a picked residue, rather than one marker sphere. + pub ball_and_stick: bool, +} + +/// A per-residue selection over one parsed structure. +#[derive(Debug, Clone, Default)] +pub struct ResidueSelection { + flags: Vec>, + count: usize, +} + +impl ResidueSelection { + /// An empty selection shaped to `protein`. + pub fn new(protein: &Protein) -> Self { + Self { + flags: protein + .chains + .iter() + .map(|chain| vec![false; chain.residues.len()]) + .collect(), + count: 0, + } + } + + #[inline] + pub fn contains(&self, chain: usize, residue: usize) -> bool { + self.flags + .get(chain) + .and_then(|c| c.get(residue)) + .copied() + .unwrap_or(false) + } + + /// Whether any residue of `chain` is selected. + pub fn chain_has_any(&self, chain: usize) -> bool { + self.flags + .get(chain) + .is_some_and(|c| c.iter().any(|selected| *selected)) + } + + pub fn set(&mut self, chain: usize, residue: usize, selected: bool) { + let Some(slot) = self.flags.get_mut(chain).and_then(|c| c.get_mut(residue)) else { + return; + }; + if *slot == selected { + return; + } + *slot = selected; + if selected { + self.count += 1; + } else { + self.count -= 1; + } + } + + pub fn toggle(&mut self, chain: usize, residue: usize) { + let selected = self.contains(chain, residue); + self.set(chain, residue, !selected); + } + + /// Set an inclusive residue range within one chain. The endpoints may be + /// given in either order. + pub fn set_range(&mut self, chain: usize, from: usize, to: usize, selected: bool) { + let (lo, hi) = if from <= to { (from, to) } else { (to, from) }; + for residue in lo..=hi { + self.set(chain, residue, selected); + } + } + + /// Select or deselect an entire chain. + pub fn set_chain(&mut self, chain: usize, selected: bool) { + let Some(len) = self.flags.get(chain).map(Vec::len) else { + return; + }; + if len > 0 { + self.set_range(chain, 0, len - 1, selected); + } + } + + pub fn clear(&mut self) { + for chain in &mut self.flags { + chain.fill(false); + } + self.count = 0; + } + + pub fn count(&self) -> usize { + self.count + } + + pub fn is_empty(&self) -> bool { + self.count == 0 + } + + /// Number of chains contributing at least one residue. + pub fn chain_count(&self) -> usize { + (0..self.flags.len()) + .filter(|chain| self.chain_has_any(*chain)) + .count() + } + + /// Mean position of every atom in the selection, for centring the camera. + pub fn centroid(&self, protein: &Protein) -> Option<[f64; 3]> { + let mut sum = [0.0f64; 3]; + let mut n = 0usize; + for (chain_index, chain) in protein.chains.iter().enumerate() { + for (residue_index, residue) in chain.residues.iter().enumerate() { + if !self.contains(chain_index, residue_index) { + continue; + } + for atom in &residue.atoms { + sum[0] += atom.x; + sum[1] += atom.y; + sum[2] += atom.z; + n += 1; + } + } + } + (n > 0).then(|| [sum[0] / n as f64, sum[1] / n as f64, sum[2] / n as f64]) + } + + /// Compact human-readable form: `Lb:12-18,40 LC:7`. + /// + /// Truncated after `max_chains` chains so it always fits on one line. + pub fn describe(&self, protein: &Protein, max_chains: usize) -> String { + let mut parts: Vec = Vec::new(); + let mut skipped = 0usize; + + for (chain_index, chain) in protein.chains.iter().enumerate() { + let ranges = self.chain_ranges(chain_index, chain.residues.len()); + if ranges.is_empty() { + continue; + } + if parts.len() == max_chains { + skipped += 1; + continue; + } + let spans: Vec = ranges + .iter() + .map(|(from, to)| { + let first = &chain.residues[*from]; + let last = &chain.residues[*to]; + let first_id = + format_residue_id(first.seq_num, first.insertion_code.as_deref()); + if from == to { + first_id + } else { + let last_id = + format_residue_id(last.seq_num, last.insertion_code.as_deref()); + format!("{first_id}-{last_id}") + } + }) + .collect(); + parts.push(format!("{}:{}", chain.id, spans.join(","))); + } + + if skipped > 0 { + parts.push(format!("+{skipped} more")); + } + parts.join(" ") + } + + /// Contiguous selected runs within one chain, as residue index pairs. + fn chain_ranges(&self, chain: usize, len: usize) -> Vec<(usize, usize)> { + let mut ranges = Vec::new(); + let mut start: Option = None; + for residue in 0..len { + match (self.contains(chain, residue), start) { + (true, None) => start = Some(residue), + (false, Some(from)) => { + ranges.push((from, residue - 1)); + start = None; + } + _ => {} + } + } + if let Some(from) = start { + ranges.push((from, len - 1)); + } + ranges + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::protein::{Atom, Chain, MoleculeType, Residue, SecondaryStructure}; + + fn protein() -> Protein { + let chain = |id: &str, count: usize, x0: f64| Chain { + id: id.to_string(), + molecule_type: MoleculeType::Protein, + residues: (0..count) + .map(|i| Residue { + name: "ALA".to_string(), + seq_num: i as i32 + 1, + insertion_code: None, + atoms: vec![Atom { + name: "CA".to_string(), + element: "C".to_string(), + x: x0 + i as f64, + y: 0.0, + z: 0.0, + b_factor: 0.0, + is_backbone: true, + is_hetero: false, + }], + secondary_structure: SecondaryStructure::Coil, + }) + .collect(), + }; + Protein { + name: "sel".to_string(), + chains: vec![chain("A", 10, 0.0), chain("B", 5, 100.0)], + ligands: Vec::new(), + } + } + + #[test] + fn count_tracks_set_toggle_and_clear() { + let protein = protein(); + let mut selection = ResidueSelection::new(&protein); + assert!(selection.is_empty()); + + selection.set_range(0, 2, 5, true); + assert_eq!(selection.count(), 4); + // Re-selecting an already selected residue must not double count. + selection.set(0, 3, true); + assert_eq!(selection.count(), 4); + + selection.toggle(0, 3); + assert_eq!(selection.count(), 3); + assert!(!selection.contains(0, 3)); + + selection.set_chain(1, true); + assert_eq!(selection.count(), 8); + assert_eq!(selection.chain_count(), 2); + + selection.clear(); + assert!(selection.is_empty()); + assert_eq!(selection.chain_count(), 0); + } + + #[test] + fn out_of_range_indices_are_ignored() { + let protein = protein(); + let mut selection = ResidueSelection::new(&protein); + selection.set(9, 0, true); + selection.set(0, 999, true); + assert!(selection.is_empty()); + assert!(!selection.contains(9, 0)); + } + + #[test] + fn describe_collapses_runs_and_reports_residue_numbers() { + let protein = protein(); + let mut selection = ResidueSelection::new(&protein); + selection.set_range(0, 1, 3, true); + selection.set(0, 7, true); + selection.set(1, 0, true); + assert_eq!(selection.describe(&protein, 8), "A:2-4,8 B:1"); + assert_eq!(selection.describe(&protein, 1), "A:2-4,8 +1 more"); + } + + #[test] + fn centroid_averages_selected_atoms_only() { + let protein = protein(); + let mut selection = ResidueSelection::new(&protein); + assert!(selection.centroid(&protein).is_none()); + selection.set_range(0, 0, 2, true); + let centroid = selection.centroid(&protein).unwrap(); + assert!((centroid[0] - 1.0).abs() < 1e-9); + } +} diff --git a/src/model/sequence.rs b/src/model/sequence.rs new file mode 100644 index 0000000..93a1fbf --- /dev/null +++ b/src/model/sequence.rs @@ -0,0 +1,279 @@ +//! One-letter sequence codes and the wrapped layout of the sequence panel. +//! +//! The layout is computed once per panel width and then used by both the +//! renderer and the cursor navigation, so what the user sees and what the +//! arrow keys move through can never disagree. + +use crate::model::protein::{MoleculeType, Protein}; + +/// Residues per group, separated by a single space in the panel. +pub const GROUP: usize = 10; + +/// One row of the sequence panel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeqRow { + /// Chain title row: `> Lb Protein 138 res 1-138`. + Header(usize), + /// A wrapped run of residues from one chain. + Residues { + chain: usize, + /// Index of the first residue of the run within the chain. + start: usize, + /// Number of residues on this row (`<= wrap`). + len: usize, + }, +} + +/// The full scrollable row list for one panel width. +#[derive(Debug, Clone, Default)] +pub struct SequenceLayout { + /// Residues per full row. + pub wrap: usize, + /// Panel width this layout was built for. + pub width: u16, + pub rows: Vec, + /// Row index of each chain's header, so a cursor position maps to a row + /// by arithmetic instead of a search. + chain_header_row: Vec, + /// Residue count per chain, cached for clamping. + chain_len: Vec, +} + +impl SequenceLayout { + /// Build the layout for `protein` at a given residues-per-row. + /// + /// Empty chains still get a header row: a chain that parsed to zero + /// polymer residues is information, not something to hide. + pub fn build(protein: &Protein, wrap: usize, width: u16) -> Self { + let wrap = wrap.max(1); + let mut rows = Vec::new(); + let mut chain_header_row = Vec::with_capacity(protein.chains.len()); + let mut chain_len = Vec::with_capacity(protein.chains.len()); + + for (chain_index, chain) in protein.chains.iter().enumerate() { + chain_header_row.push(rows.len()); + chain_len.push(chain.residues.len()); + rows.push(SeqRow::Header(chain_index)); + let mut start = 0; + while start < chain.residues.len() { + let len = wrap.min(chain.residues.len() - start); + rows.push(SeqRow::Residues { + chain: chain_index, + start, + len, + }); + start += len; + } + } + + Self { + wrap, + width, + rows, + chain_header_row, + chain_len, + } + } + + pub fn row_count(&self) -> usize { + self.rows.len() + } + + /// Row index holding `residue` of `chain`, and the column within it. + pub fn locate(&self, chain: usize, residue: usize) -> Option<(usize, usize)> { + let header = *self.chain_header_row.get(chain)?; + if residue >= *self.chain_len.get(chain)? { + return None; + } + Some((header + 1 + residue / self.wrap, residue % self.wrap)) + } + + /// Row index of a chain's header row. + pub fn header_row(&self, chain: usize) -> Option { + self.chain_header_row.get(chain).copied() + } + + /// The residue at `column` of `row`, clamped to the row's last residue. + /// + /// Header rows have no residue, so vertical navigation skips them. + pub fn residue_at(&self, row: usize, column: usize) -> Option<(usize, usize)> { + match self.rows.get(row)? { + SeqRow::Header(_) => None, + SeqRow::Residues { chain, start, len } => { + Some((*chain, start + column.min(len.saturating_sub(1)))) + } + } + } +} + +/// Screen column offset of a residue column, accounting for group spacing. +#[inline] +pub fn column_offset(column: usize) -> usize { + column + column / GROUP +} + +/// Residues that fit in `avail` columns when every group of [`GROUP`] is +/// followed by a space. +pub fn wrap_for_width(avail: usize) -> usize { + // A group costs GROUP characters plus one separator, except the last one + // which needs no trailing space. + let groups = (avail + 1) / (GROUP + 1); + (groups * GROUP).max(GROUP) +} + +/// One-letter code for a residue, given its chain's polymer type. +/// +/// Modified residues map to their parent letter (`PSU` -> `U`, `MSE` -> `M`) +/// so a modified base never breaks the reading frame of the sequence; the +/// panel's cursor line always shows the true three-letter name. Anything +/// unrecognized is `X`. +pub fn one_letter(name: &str, molecule_type: MoleculeType) -> char { + let name = name.trim(); + match molecule_type { + MoleculeType::RNA | MoleculeType::DNA => nucleotide_letter(name), + MoleculeType::Protein | MoleculeType::SmallMolecule => amino_acid_letter(name) + // A chain classified as protein can still carry stray nucleotides + // (hybrid or mis-classified chains); fall back before giving up. + .or_else(|| { + let letter = nucleotide_letter(name); + (letter != 'X').then_some(letter) + }) + .unwrap_or('X'), + } +} + +fn amino_acid_letter(name: &str) -> Option { + Some(match name { + "ALA" => 'A', + "ARG" => 'R', + "ASN" => 'N', + "ASP" => 'D', + "CYS" | "CYX" | "CYM" => 'C', + "GLN" => 'Q', + "GLU" => 'E', + "GLY" => 'G', + "HIS" | "HID" | "HIE" | "HIP" | "HSD" | "HSE" | "HSP" => 'H', + "ILE" => 'I', + "LEU" => 'L', + "LYS" | "LYN" => 'K', + "MET" | "MSE" | "FME" => 'M', + "PHE" => 'F', + "PRO" | "HYP" => 'P', + "SER" | "SEP" => 'S', + "THR" | "TPO" => 'T', + "TRP" => 'W', + "TYR" | "PTR" => 'Y', + "VAL" => 'V', + "SEC" => 'U', + "PYL" => 'O', + "ASX" => 'B', + "GLX" => 'Z', + "UNK" => 'X', + _ => return None, + }) +} + +fn nucleotide_letter(name: &str) -> char { + match name { + "A" | "DA" | "AMP" | "ADE" | "1MA" | "6MA" | "MA6" | "2MA" => 'A', + "C" | "DC" | "CMP" | "CYT" | "5MC" | "OMC" | "4OC" | "3MC" => 'C', + "G" | "DG" | "GMP" | "GUA" | "2MG" | "7MG" | "M2G" | "OMG" | "1MG" | "YG" => 'G', + "U" | "UMP" | "URA" | "URI" | "PSU" | "4SU" | "H2U" | "5MU" | "UR3" | "OMU" | "3MU" => 'U', + "T" | "DT" | "THY" | "5MT" => 'T', + "I" | "DI" => 'I', + "N" | "DN" => 'N', + _ => 'X', + } +} + +/// Short label for a chain's polymer type, for the panel header. +pub fn molecule_label(molecule_type: MoleculeType) -> &'static str { + match molecule_type { + MoleculeType::Protein => "Protein", + MoleculeType::RNA => "RNA", + MoleculeType::DNA => "DNA", + MoleculeType::SmallMolecule => "Other", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::protein::{Chain, Residue, SecondaryStructure}; + + fn chain(id: &str, count: usize, molecule_type: MoleculeType) -> Chain { + Chain { + id: id.to_string(), + molecule_type, + residues: (0..count) + .map(|i| Residue { + name: "ALA".to_string(), + seq_num: i as i32 + 1, + insertion_code: None, + atoms: Vec::new(), + secondary_structure: SecondaryStructure::Coil, + }) + .collect(), + } + } + + fn protein(counts: &[usize]) -> Protein { + Protein { + name: "seq".to_string(), + chains: counts + .iter() + .enumerate() + .map(|(i, n)| chain(&format!("C{i}"), *n, MoleculeType::Protein)) + .collect(), + ligands: Vec::new(), + } + } + + #[test] + fn locate_and_residue_at_are_inverses() { + let protein = protein(&[25, 3]); + let layout = SequenceLayout::build(&protein, 10, 80); + for chain in 0..2 { + let len = protein.chains[chain].residues.len(); + for residue in 0..len { + let (row, column) = layout.locate(chain, residue).unwrap(); + assert_eq!(layout.residue_at(row, column), Some((chain, residue))); + } + } + } + + #[test] + fn empty_chains_still_get_a_header() { + let protein = protein(&[0, 4]); + let layout = SequenceLayout::build(&protein, 10, 80); + assert_eq!(layout.rows[0], SeqRow::Header(0)); + assert_eq!(layout.rows[1], SeqRow::Header(1)); + assert_eq!(layout.locate(0, 0), None); + } + + #[test] + fn short_rows_clamp_the_column() { + let protein = protein(&[13]); + let layout = SequenceLayout::build(&protein, 10, 80); + // Second residue row holds 3 residues; column 7 clamps to the last. + assert_eq!(layout.residue_at(2, 7), Some((0, 12))); + } + + #[test] + fn wrap_accounts_for_group_separators() { + // Three groups need 10+1+10+1+10 = 32 columns; one fewer fits only two. + assert_eq!(wrap_for_width(32), 30); + assert_eq!(wrap_for_width(31), 20); + // Never returns zero, however narrow the panel is. + assert_eq!(wrap_for_width(0), GROUP); + } + + #[test] + fn modified_residues_keep_the_reading_frame() { + assert_eq!(one_letter("PSU", MoleculeType::RNA), 'U'); + assert_eq!(one_letter("MSE", MoleculeType::Protein), 'M'); + assert_eq!(one_letter("T1C", MoleculeType::Protein), 'X'); + // A nucleotide inside a chain classified as protein still reads. + assert_eq!(one_letter("G", MoleculeType::Protein), 'G'); + } +} diff --git a/src/panel_server.rs b/src/panel_server.rs index aa080a6..86b300a 100644 --- a/src/panel_server.rs +++ b/src/panel_server.rs @@ -160,6 +160,7 @@ impl PanelSession { &self.mesh_cache, self.settings.show_ligands, interactions, + None, ); let image = DynamicImage::ImageRgba8(framebuffer.to_rgba_image()); write_png_atomically(&image, &self.output_path)?; diff --git a/src/render/braille.rs b/src/render/braille.rs index 30d7e64..e142541 100644 --- a/src/render/braille.rs +++ b/src/render/braille.rs @@ -4,9 +4,12 @@ use ratatui::widgets::canvas::{Canvas, Context, Line}; use crate::app::VizMode; use crate::model::interface::{Interaction, InteractionType}; use crate::model::protein::{LigandType, MoleculeType, Protein}; +use crate::model::residue_selection::SelectionView; use crate::render::bond::atoms_bonded; use crate::render::camera::Camera; use crate::render::color::ColorScheme; +use crate::render::hd::{linking_atoms, selection_atom_color}; +use crate::render::palette::palette; /// Draw a thick line by rendering parallel offset lines along the perpendicular direction. fn draw_thick_line( @@ -56,6 +59,7 @@ pub fn render_protein<'a>( height: f64, show_ligands: bool, interactions: &'a [Interaction], + selection: Option>, ) -> Canvas<'a, impl Fn(&mut Context<'_>) + 'a> { Canvas::default() .marker(Marker::Braille) @@ -75,6 +79,10 @@ pub fn render_protein<'a>( render_ligands(ctx, protein, camera, color_scheme); } + if let Some(view) = selection.filter(|view| !view.selection.is_empty()) { + render_selection(ctx, protein, view, camera); + } + if !interactions.is_empty() { render_interactions(ctx, interactions, camera); } @@ -249,6 +257,109 @@ fn render_ligands( } } +/// A small plus sign, the canvas stand-in for a dot. +fn draw_cross(ctx: &mut Context<'_>, x: f64, y: f64, size: f64, color: ratatui::style::Color) { + ctx.draw(&Line { + x1: x - size, + y1: y, + x2: x + size, + y2: y, + color, + }); + ctx.draw(&Line { + x1: x, + y1: y - size, + x2: x, + y2: y + size, + color, + }); +} + +/// Draw residues picked in the sequence panel over the braille canvas. +/// +/// The canvas has no depth buffer, so this is drawn last and always wins: in +/// braille mode the selection is a marker, not a shaded overlay. +fn render_selection( + ctx: &mut Context<'_>, + protein: &Protein, + view: SelectionView<'_>, + camera: &Camera, +) { + let carbon = palette().selection.carbon.0; + let marker = palette().selection.marker.0; + let marker_color = ratatui::style::Color::Rgb(marker[0], marker[1], marker[2]); + let offsets: [f64; 3] = [0.0, 0.4, -0.4]; + + for (chain_index, chain) in protein.chains.iter().enumerate() { + for (residue_index, residue) in chain.residues.iter().enumerate() { + if !view.selection.contains(chain_index, residue_index) { + continue; + } + + if !view.ball_and_stick { + if let Some(atom) = residue + .atoms + .iter() + .find(|atom| atom.is_backbone) + .or_else(|| residue.atoms.first()) + { + let proj = camera.project(atom.x, atom.y, atom.z); + draw_cross(ctx, proj.x, proj.y, 1.2, marker_color); + } + continue; + } + + let projected: Vec<_> = residue + .atoms + .iter() + .map(|atom| { + let proj = camera.project(atom.x, atom.y, atom.z); + let [r, g, b] = selection_atom_color(atom, carbon); + (atom, proj, ratatui::style::Color::Rgb(r, g, b)) + }) + .collect(); + + // A dot per atom: the canvas has no filled circles, and without + // this a residue modelled as a lone C-alpha would draw nothing at + // all, since there is no bond to draw. + for (_, proj, color) in &projected { + draw_cross(ctx, proj.x, proj.y, 0.6, *color); + } + + for i in 0..projected.len() { + for j in (i + 1)..projected.len() { + let (a1, p1, color) = &projected[i]; + let (a2, p2, _) = &projected[j]; + if atoms_bonded(&a1.element, a1.x, a1.y, a1.z, &a2.element, a2.x, a2.y, a2.z) { + draw_thick_line(ctx, p1.x, p1.y, p2.x, p2.y, *color, &offsets); + } + } + } + + if !view.selection.contains(chain_index, residue_index + 1) { + continue; + } + let Some(next) = chain.residues.get(residue_index + 1) else { + continue; + }; + if let Some((from, to)) = linking_atoms(chain.molecule_type, residue, next) { + let p1 = camera.project(from.x, from.y, from.z); + let p2 = camera.project(to.x, to.y, to.z); + let [r, g, b] = selection_atom_color(from, carbon); + draw_thick_line( + ctx, + p1.x, + p1.y, + p2.x, + p2.y, + ratatui::style::Color::Rgb(r, g, b), + &offsets, + ); + } + } + } +} + /// Map interaction type to a ratatui color for braille rendering. fn braille_interaction_color(t: InteractionType) -> ratatui::style::Color { match t { diff --git a/src/render/hd.rs b/src/render/hd.rs index ec9db39..1ee22d1 100644 --- a/src/render/hd.rs +++ b/src/render/hd.rs @@ -1,10 +1,12 @@ use crate::app::VizMode; use crate::model::interface::{Interaction, InteractionType}; -use crate::model::protein::{LigandType, MoleculeType, Protein}; +use crate::model::protein::{Atom, LigandType, MoleculeType, Protein, Residue}; +use crate::model::residue_selection::SelectionView; use crate::render::bond::atoms_bonded; use crate::render::camera::Camera; use crate::render::color::{ColorScheme, color_to_rgb}; use crate::render::framebuffer::{Framebuffer, default_light_dir}; +use crate::render::palette::palette; use crate::render::ribbon::RibbonTriangle; use rayon::prelude::*; @@ -24,6 +26,7 @@ pub fn render_hd_framebuffer( mesh: &[RibbonTriangle], show_ligands: bool, interactions: &[Interaction], + selection: Option>, ) -> Framebuffer { render_hd_framebuffer_ssaa( protein, @@ -35,6 +38,7 @@ pub fn render_hd_framebuffer( mesh, show_ligands, interactions, + selection, 1.0, ) } @@ -58,6 +62,7 @@ pub fn render_hd_framebuffer_ssaa( mesh: &[RibbonTriangle], show_ligands: bool, interactions: &[Interaction], + selection: Option>, ssaa: f64, ) -> Framebuffer { let px_w = width as usize; @@ -116,6 +121,13 @@ pub fn render_hd_framebuffer_ssaa( render_ligands_fb(&mut fb, protein, camera, color_scheme, half_w, half_h, ts); } + // Residues picked in the sequence panel, drawn over whatever mode is + // active but still z-buffered against it, so a picked side chain is hidden + // when the structure genuinely occludes it. + if let Some(view) = selection.filter(|view| !view.selection.is_empty()) { + render_selection_fb(&mut fb, protein, view, camera, half_w, half_h, ts); + } + // Post-pass: blend all rasterized pixels toward a cool blue-gray fog color // based on their z-buffer depth. This gives uniform depth cues across all // rendering modes (triangles, lines, circles). @@ -710,6 +722,138 @@ fn render_ligands_fb( } } +/// Draw the residues picked in the sequence panel. +/// +/// With ball-and-stick on, every atom of a picked residue becomes a sphere and +/// every bond a stick, including the peptide or phosphodiester bond into the +/// next picked residue so a selected stretch reads as one connected fragment. +/// With it off, a single marker sphere per residue says where the selection is +/// without hiding the cartoon underneath. +fn render_selection_fb( + fb: &mut Framebuffer, + protein: &Protein, + view: SelectionView<'_>, + camera: &Camera, + half_w: f64, + half_h: f64, + ts: f64, +) { + let marker = palette().selection.marker.0; + let carbon = palette().selection.carbon.0; + + for (chain_index, chain) in protein.chains.iter().enumerate() { + for (residue_index, residue) in chain.residues.iter().enumerate() { + if !view.selection.contains(chain_index, residue_index) { + continue; + } + + if !view.ball_and_stick { + // One sphere on the backbone atom (CA / C4'), or on the first + // atom of a residue that has no backbone atom at all. + if let Some(atom) = residue + .atoms + .iter() + .find(|atom| atom.is_backbone) + .or_else(|| residue.atoms.first()) + { + let projected = camera.project(atom.x, atom.y, atom.z); + let px = to_pixel(projected.x, projected.y, projected.z, half_w, half_h); + // The backbone atom sits *inside* the ribbon, so an + // unbiased marker would be hidden by the very residue it + // marks. Pulling it forward by a ribbon half-width makes + // it visible while anything genuinely in front still wins. + fb.draw_circle_z(px[0], px[1], px[2] - MARKER_DEPTH_BIAS, 4.0 * ts, marker); + } + continue; + } + + let atoms: Vec<([f64; 3], [u8; 3], &Atom)> = residue + .atoms + .iter() + .map(|atom| { + let projected = camera.project(atom.x, atom.y, atom.z); + let px = to_pixel(projected.x, projected.y, projected.z, half_w, half_h); + (px, selection_atom_color(atom, carbon), atom) + }) + .collect(); + + for (px, color, atom) in &atoms { + fb.draw_circle_z(px[0], px[1], px[2], atom_radius(atom) * ts, *color); + } + + for i in 0..atoms.len() { + for j in (i + 1)..atoms.len() { + let (p1, c1, a1) = &atoms[i]; + let (p2, _, a2) = &atoms[j]; + if atoms_bonded(&a1.element, a1.x, a1.y, a1.z, &a2.element, a2.x, a2.y, a2.z) { + fb.draw_thick_line_3d(*p1, *p2, *c1, 2.0 * ts); + } + } + } + + // Link to the next residue when it is picked too. + let next_index = residue_index + 1; + if !view.selection.contains(chain_index, next_index) { + continue; + } + let Some(next) = chain.residues.get(next_index) else { + continue; + }; + if let Some((from, to)) = linking_atoms(chain.molecule_type, residue, next) { + let p1 = camera.project(from.x, from.y, from.z); + let p2 = camera.project(to.x, to.y, to.z); + fb.draw_thick_line_3d( + to_pixel(p1.x, p1.y, p1.z, half_w, half_h), + to_pixel(p2.x, p2.y, p2.z, half_w, half_h), + selection_atom_color(from, carbon), + 2.0 * ts, + ); + } + } + } +} + +/// CPK color, except that carbon takes the selection color: the standard way +/// to make one fragment of a structure legible without recoloring chemistry. +pub(crate) fn selection_atom_color(atom: &Atom, carbon: [u8; 3]) -> [u8; 3] { + if atom.element.trim().eq_ignore_ascii_case("C") { + carbon + } else { + color_to_rgb(ColorScheme::element_color(atom)) + } +} + +/// Sphere radius in framebuffer units before thickness scaling. +pub(crate) fn atom_radius(atom: &Atom) -> f64 { + match atom.element.trim() { + "H" => 1.4, + "C" => 2.6, + "N" | "O" => 2.8, + "S" | "P" => 3.2, + _ => 3.0, + } +} + +/// How far forward, in angstroms, a selection marker is pushed so it clears +/// the ribbon drawn around its own backbone atom. +const MARKER_DEPTH_BIAS: f64 = 3.5; + +/// The covalent bond joining consecutive polymer residues. +pub(crate) fn linking_atoms<'a>( + molecule_type: MoleculeType, + current: &'a Residue, + next: &'a Residue, +) -> Option<(&'a Atom, &'a Atom)> { + let (from, to) = match molecule_type { + MoleculeType::RNA | MoleculeType::DNA => ("O3'", "P"), + MoleculeType::Protein => ("C", "N"), + MoleculeType::SmallMolecule => return None, + }; + let a = current.atoms.iter().find(|atom| atom.name.trim() == from)?; + let b = next.atoms.iter().find(|atom| atom.name.trim() == to)?; + Some((a, b)) +} + /// Render non-covalent interaction lines as dashed segments in the framebuffer. fn render_interactions_fb( fb: &mut Framebuffer, @@ -831,6 +975,7 @@ mod tests { &[], false, &[], + None, ssaa, ) } @@ -885,6 +1030,7 @@ mod tests { &[], false, &[], + None, ssaa, ) }; @@ -897,3 +1043,148 @@ mod tests { } } } + +#[cfg(test)] +mod selection_tests { + use super::*; + use crate::model::protein::{Chain, Residue, SecondaryStructure}; + use crate::model::residue_selection::ResidueSelection; + use crate::render::color::ColorSchemeType; + + /// Six residues with a two-atom side chain each, spread across the view. + fn side_chain_protein() -> Protein { + let atom = |name: &str, element: &str, x: f64, y: f64, backbone: bool| Atom { + name: name.to_string(), + element: element.to_string(), + x, + y, + z: 0.0, + b_factor: 20.0, + is_backbone: backbone, + is_hetero: false, + }; + let residues = (0..6) + .map(|i| { + let x = i as f64 * 6.0 - 15.0; + Residue { + name: "LEU".to_string(), + seq_num: i + 1, + insertion_code: None, + atoms: vec![ + atom("CA", "C", x, 0.0, true), + atom("CB", "C", x + 1.5, 1.5, false), + atom("CG", "O", x + 2.6, 3.0, false), + ], + secondary_structure: SecondaryStructure::Coil, + } + }) + .collect(); + Protein { + name: "sidechains".to_string(), + chains: vec![Chain { + id: "A".to_string(), + residues, + molecule_type: MoleculeType::Protein, + }], + ligands: Vec::new(), + } + } + + fn lit_pixels(fb: &Framebuffer) -> usize { + fb.color.iter().filter(|color| **color != [0, 0, 0]).count() + } + + fn render(protein: &Protein, view: Option>) -> Framebuffer { + let mut camera = Camera::default(); + camera.zoom = 8.0; + let scheme = ColorScheme::new(ColorSchemeType::Structure, protein.residue_count()); + render_hd_framebuffer( + protein, + &camera, + &scheme, + VizMode::Backbone, + 400.0, + 300.0, + &[], + false, + &[], + view, + ) + } + + #[test] + fn selection_adds_ink_and_ball_and_stick_adds_more_than_markers() { + let protein = side_chain_protein(); + let mut selection = ResidueSelection::new(&protein); + selection.set_range(0, 0, 2, true); + + let plain = lit_pixels(&render(&protein, None)); + let marked = lit_pixels(&render( + &protein, + Some(SelectionView { + selection: &selection, + ball_and_stick: false, + }), + )); + let ball_and_stick = lit_pixels(&render( + &protein, + Some(SelectionView { + selection: &selection, + ball_and_stick: true, + }), + )); + + assert!( + plain < marked, + "markers should add ink: {plain} -> {marked}" + ); + assert!( + marked < ball_and_stick, + "side chains should add more than markers: {marked} -> {ball_and_stick}" + ); + } + + #[test] + fn an_empty_selection_renders_identically_to_no_selection() { + // The overlay must be free when nothing is picked -- this is what lets + // the panel stay open with no selection and cost nothing. + let protein = side_chain_protein(); + let empty = ResidueSelection::new(&protein); + let plain = render(&protein, None); + let with_empty = render( + &protein, + Some(SelectionView { + selection: &empty, + ball_and_stick: true, + }), + ); + assert_eq!(plain.color, with_empty.color); + } + + #[test] + fn only_picked_residues_gain_side_chains() { + // The projection mirrors x, so residue 0 lands right of centre; the + // far half of the frame must be untouched by picking it. + let protein = side_chain_protein(); + let mut selection = ResidueSelection::new(&protein); + selection.set(0, 0, true); + + let left_half = |fb: &Framebuffer| { + (0..fb.height) + .flat_map(|y| (0..fb.width / 2).map(move |x| y * fb.width + x)) + .filter(|i| fb.color[*i] != [0, 0, 0]) + .count() + }; + + let plain = render(&protein, None); + let picked = render( + &protein, + Some(SelectionView { + selection: &selection, + ball_and_stick: true, + }), + ); + assert_eq!(left_half(&plain), left_half(&picked)); + assert!(lit_pixels(&plain) < lit_pixels(&picked)); + } +} diff --git a/src/render/palette.rs b/src/render/palette.rs index 5bd424a..9113a89 100644 --- a/src/render/palette.rs +++ b/src/render/palette.rs @@ -233,6 +233,30 @@ impl Default for LigandPalette { } } +/// Colors for residues picked in the sequence panel. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SelectionPalette { + /// Carbon atoms of the ball-and-stick overlay. Other elements keep their + /// CPK colors, so a picked residue reads as itself while still standing + /// out from the ribbon behind it. + pub carbon: Rgb, + /// Marker sphere drawn on a picked residue when ball-and-stick is off. + pub marker: Rgb, + /// Background of the sequence panel's cursor cell. + pub cursor: Rgb, +} + +impl Default for SelectionPalette { + fn default() -> Self { + Self { + carbon: Rgb::new(0, 230, 140), + marker: Rgb::new(0, 230, 140), + cursor: Rgb::new(255, 200, 0), + } + } +} + // --------------------------------------------------------------------------- // Palette // --------------------------------------------------------------------------- @@ -249,6 +273,7 @@ pub struct Palette { pub bfactor: BFactorPalette, pub interface: InterfacePalette, pub ligand: LigandPalette, + pub selection: SelectionPalette, } impl Default for Palette { @@ -271,6 +296,7 @@ impl Default for Palette { bfactor: BFactorPalette::default(), interface: InterfacePalette::default(), ligand: LigandPalette::default(), + selection: SelectionPalette::default(), } } } @@ -319,6 +345,7 @@ struct PaletteFile { bfactor: BFactorPalette, interface: InterfacePalette, ligand: LigandPalette, + selection: SelectionPalette, } impl PaletteFile { @@ -332,6 +359,7 @@ impl PaletteFile { bfactor: self.bfactor, interface: self.interface, ligand: self.ligand, + selection: self.selection, }; if let Some(colors) = self.chain.colors { diff --git a/src/render/snapshot.rs b/src/render/snapshot.rs index 0db6fe9..2ba6239 100644 --- a/src/render/snapshot.rs +++ b/src/render/snapshot.rs @@ -120,6 +120,7 @@ pub fn save_png(mut protein: Protein, output_path: &Path, options: SnapshotOptio &mesh, options.show_ligands, interactions, + None, ); let image = DynamicImage::ImageRgba8(framebuffer.to_rgba_image()); write_png_atomically(&image, output_path) diff --git a/src/ui/help_overlay.rs b/src/ui/help_overlay.rs index d168c2a..7afcaa0 100644 --- a/src/ui/help_overlay.rs +++ b/src/ui/help_overlay.rs @@ -11,7 +11,7 @@ pub fn render_help_overlay(frame: &mut Frame, area: Rect) { return; } let popup_width = 60u16.min(area.width.saturating_sub(4)); - let popup_height = 23u16.min(area.height.saturating_sub(4)); + let popup_height = 36u16.min(area.height.saturating_sub(4)); let x = (area.width - popup_width) / 2; let y = (area.height - popup_height) / 2; let popup_area = Rect::new(x, y, popup_width, popup_height); @@ -95,6 +95,56 @@ pub fn render_help_overlay(frame: &mut Frame, area: Rect) { Span::raw("Quit"), ]), Line::from(""), + Line::from(Span::styled( + " Sequence panel", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), + Line::from(vec![ + Span::styled(" S ", Style::default().fg(Color::Yellow)), + Span::raw("Open / close the sequence panel"), + ]), + Line::from(vec![ + Span::styled( + " \u{2190}\u{2192}\u{2191}\u{2193} ", + Style::default().fg(Color::Yellow), + ), + Span::raw("Move the residue cursor"), + ]), + Line::from(vec![ + Span::styled(" Shift+arrow", Style::default().fg(Color::Yellow)), + Span::raw(" Extend the selection"), + ]), + Line::from(vec![ + Span::styled(" Enter ", Style::default().fg(Color::Yellow)), + Span::raw("Select / deselect residue"), + ]), + Line::from(vec![ + Span::styled(" A ", Style::default().fg(Color::Yellow)), + Span::raw("Select / deselect whole chain"), + ]), + Line::from(vec![ + Span::styled(" x ", Style::default().fg(Color::Yellow)), + Span::raw("Clear the selection"), + ]), + Line::from(vec![ + Span::styled(" b ", Style::default().fg(Color::Yellow)), + Span::raw("Ball-and-stick for the selection"), + ]), + Line::from(vec![ + Span::styled(" z ", Style::default().fg(Color::Yellow)), + Span::raw("Centre the view on the selection"), + ]), + Line::from(vec![ + Span::styled(" Home / End ", Style::default().fg(Color::Yellow)), + Span::raw("Start / end of chain"), + ]), + Line::from(vec![ + Span::styled(" < / > ", Style::default().fg(Color::Yellow)), + Span::raw("Shrink / grow the panel"), + ]), + Line::from(""), Line::from(Span::styled( " Press ? or Esc to close", Style::default().fg(Color::DarkGray), diff --git a/src/ui/helpbar.rs b/src/ui/helpbar.rs index c57ec74..305f296 100644 --- a/src/ui/helpbar.rs +++ b/src/ui/helpbar.rs @@ -4,8 +4,18 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; -/// Render the keybinding hints bar at the bottom -pub fn render_helpbar(frame: &mut Frame, area: Rect) { +use crate::app::App; + +/// Render the keybinding hints bar at the bottom. +/// +/// The hints change with the sequence panel: while it is open the arrow keys +/// drive the cursor rather than the camera, and saying so here is cheaper than +/// making the user open the help overlay to find out. +pub fn render_helpbar(frame: &mut Frame, area: Rect, app: &App) { + if app.show_sequence { + frame.render_widget(Paragraph::new(sequence_hints()), area); + return; + } let help = Paragraph::new(Line::from(vec![ Span::styled("╰── ", Style::default().fg(Color::DarkGray)), Span::styled( @@ -78,6 +88,13 @@ pub fn render_helpbar(frame: &mut Frame, area: Rect) { .add_modifier(Modifier::BOLD), ), Span::styled(": ligands ", Style::default().fg(Color::Gray)), + Span::styled( + "S", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled(": sequence ", Style::default().fg(Color::Gray)), Span::styled( "q", Style::default() @@ -89,3 +106,43 @@ pub fn render_helpbar(frame: &mut Frame, area: Rect) { ])); frame.render_widget(help, area); } + +/// Hints shown while the sequence panel has the arrow keys. +fn sequence_hints() -> Line<'static> { + let key = |text: &'static str| { + Span::styled( + text, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + }; + let label = |text: &'static str| Span::styled(text, Style::default().fg(Color::Gray)); + + Line::from(vec![ + Span::styled("╰── ", Style::default().fg(Color::DarkGray)), + key("←→↑↓"), + label(": cursor "), + key("shift+←→"), + label(": range "), + key("↵"), + label(": pick "), + key("A"), + label(": chain "), + key("x"), + label(": clear "), + key("b"), + label(": ball&stick "), + key("z"), + label(": centre "), + key("[ ]"), + label(": chain "), + key("hjkl"), + label(": rotate "), + key("<>"), + label(": size "), + key("S/esc"), + label(": close "), + Span::styled("──╯", Style::default().fg(Color::DarkGray)), + ]) +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 289fad5..2dc24c1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2,5 +2,6 @@ pub mod header; pub mod help_overlay; pub mod helpbar; pub mod interface_panel; +pub mod sequence_panel; pub mod statusbar; pub mod viewport; diff --git a/src/ui/sequence_panel.rs b/src/ui/sequence_panel.rs new file mode 100644 index 0000000..a55ea37 --- /dev/null +++ b/src/ui/sequence_panel.rs @@ -0,0 +1,430 @@ +//! The scrollable chain-sequence panel. +//! +//! Every chain in the structure is laid out as one-letter codes, wrapped to the +//! panel width and scrolled as a single list. The cursor and the selection are +//! drawn straight onto the letters, so picking residues here is what drives the +//! ball-and-stick overlay in the 3D view. + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::app::App; +use crate::model::protein::{Chain, MoleculeType, SecondaryStructure}; +use crate::model::sequence::{GROUP, SeqRow, column_offset, molecule_label, one_letter}; +use crate::render::palette::palette; + +/// Width of the left gutter: a right-aligned residue number plus a space. +pub const GUTTER: u16 = 7; + +/// Color of a picked residue, from the palette's `[selection]` section. +fn selection_color() -> Color { + let [red, green, blue] = palette().selection.marker.0; + Color::Rgb(red, green, blue) +} + +/// Color of the cursor cell, from the palette's `[selection]` section. +fn cursor_color() -> Color { + let [red, green, blue] = palette().selection.cursor.0; + Color::Rgb(red, green, blue) +} + +/// Height the panel takes in a layout `total_rows` tall, or 0 when closed. +/// +/// The rest of the interface needs seven rows (header, a minimum viewport, +/// status bar, help bar), so the panel never squeezes the 3D view out of +/// existence however far `>` is held down. +pub fn height_for(app: &App, total_rows: u16) -> u16 { + if !app.show_sequence { + return 0; + } + const CHROME_ROWS: u16 = 7; + app.seq_panel_height + .min(total_rows.saturating_sub(CHROME_ROWS)) +} + +/// Render the sequence panel into `area`. +pub fn render_sequence_panel(frame: &mut Frame, area: Rect, app: &App) { + if area.height < 2 { + return; + } + + let block = Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" Sequence ") + .title_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.height == 0 { + return; + } + + // Cursor line: what is under the cursor, and what the selection holds. + frame.render_widget( + Paragraph::new(cursor_line(app)), + Rect::new(inner.x, inner.y, inner.width, 1), + ); + + let rows_area = Rect::new( + inner.x, + inner.y + 1, + inner.width, + inner.height.saturating_sub(1), + ); + if rows_area.height == 0 { + return; + } + + let layout = app.sequence_layout(); + let mut lines: Vec = Vec::with_capacity(rows_area.height as usize); + for offset in 0..rows_area.height as usize { + let row = app.seq_scroll + offset; + match layout.rows.get(row) { + Some(SeqRow::Header(chain_index)) => { + lines.push(header_line(app, *chain_index)); + } + Some(SeqRow::Residues { chain, start, len }) => { + lines.push(residue_line(app, *chain, *start, *len)); + } + None => lines.push(Line::from("")), + } + } + + frame.render_widget(Paragraph::new(lines), rows_area); +} + +/// `Lb 245 ARG helix │ 12 residues in 3 chains │ ball&stick on` +fn cursor_line(app: &App) -> Line<'static> { + let mut spans = vec![Span::styled(" ", Style::default())]; + + match app.seq_cursor_residue() { + Some((chain, residue)) => { + let letter = one_letter(&residue.name, chain.molecule_type); + let insertion = residue.insertion_code.as_deref().unwrap_or(""); + spans.push(Span::styled( + format!("{} ", chain.id), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + format!("{}{} ", residue.seq_num, insertion), + Style::default().fg(Color::White), + )); + spans.push(Span::styled( + format!("{} ({letter}) ", residue.name), + Style::default().fg(Color::Gray), + )); + if chain.molecule_type == MoleculeType::Protein { + spans.push(Span::styled( + format!("{} ", secondary_label(residue.secondary_structure)), + Style::default().fg(Color::DarkGray), + )); + } + if app.selection.contains(app.seq_cursor.0, app.seq_cursor.1) { + spans.push(Span::styled( + "[selected] ", + Style::default().fg(selection_color()), + )); + } + } + None => spans.push(Span::styled( + "no residues ", + Style::default().fg(Color::DarkGray), + )), + } + + spans.push(Span::styled("│ ", Style::default().fg(Color::DarkGray))); + if app.selection.is_empty() { + spans.push(Span::styled( + "nothing selected ", + Style::default().fg(Color::DarkGray), + )); + } else { + spans.push(Span::styled( + format!( + "{} res in {} chain{} ", + app.selection.count(), + app.selection.chain_count(), + if app.selection.chain_count() == 1 { + "" + } else { + "s" + } + ), + Style::default().fg(selection_color()), + )); + spans.push(Span::styled("│ ", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled( + format!("{} ", app.selection.describe(&app.protein, 4)), + Style::default().fg(Color::White), + )); + spans.push(Span::styled("│ ", Style::default().fg(Color::DarkGray))); + spans.push(Span::styled( + if app.show_ball_stick { + "ball&stick on " + } else { + "ball&stick off " + }, + Style::default().fg(if app.show_ball_stick { + Color::Rgb(255, 170, 0) + } else { + Color::DarkGray + }), + )); + } + + Line::from(spans) +} + +fn secondary_label(secondary: SecondaryStructure) -> &'static str { + match secondary { + SecondaryStructure::Helix => "helix", + SecondaryStructure::Sheet => "sheet", + SecondaryStructure::Turn => "turn", + SecondaryStructure::Coil => "coil", + } +} + +/// `> Lb Protein 138 res 1-138` +fn header_line(app: &App, chain_index: usize) -> Line<'static> { + let Some(chain) = app.protein.chains.get(chain_index) else { + return Line::from(""); + }; + let range = match (chain.residues.first(), chain.residues.last()) { + (Some(first), Some(last)) => format!("{}-{}", first.seq_num, last.seq_num), + _ => "empty".to_string(), + }; + let marker_style = if chain_index == app.current_chain { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + let mut spans = vec![ + Span::styled( + if chain_index == app.current_chain { + "▸ " + } else { + " " + }, + marker_style, + ), + Span::styled( + chain.id.clone(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!( + " {} {} res {range}", + molecule_label(chain.molecule_type), + chain.residues.len() + ), + Style::default().fg(Color::DarkGray), + ), + ]; + if app.selection.chain_has_any(chain_index) { + spans.push(Span::styled(" ●", Style::default().fg(selection_color()))); + } + Line::from(spans) +} + +/// One wrapped run of residues, with its starting residue number in the gutter. +fn residue_line(app: &App, chain_index: usize, start: usize, len: usize) -> Line<'static> { + let Some(chain) = app.protein.chains.get(chain_index) else { + return Line::from(""); + }; + + let number = chain + .residues + .get(start) + .map(|residue| residue.seq_num.to_string()) + .unwrap_or_default(); + let mut spans = vec![Span::styled( + format!("{number:>width$} ", width = GUTTER as usize - 1), + Style::default().fg(Color::DarkGray), + )]; + + // Consecutive residues that share a style are emitted as one span: a + // 200-column row of one-letter codes would otherwise cost 200 spans a + // frame, all with identical styling. + let mut run = String::new(); + let mut run_style: Option