From a962d18d4dc8d0d143320412e8f9be6ad4f3e602 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 12 Jun 2026 13:57:17 -0400 Subject: [PATCH] status band: geometry + local L:C / scroll readout (Q#S2, Q#S3) A 26px strip reserved at the surface bottom: background quad riding the bg batch, one-line cosmic-text buffer as a second TextArea in the same prepare pass, right-aligned by measured width. L:C derives from the optimistic caret (it must not lag a round trip during typing bursts) and the scroll readout is the TUI's All/Top/Bot/NN% formula, ported verbatim and pinned. text_area_bottom() is now the single source for every bottom-of-text computation: visible-line estimate, text clip bounds, the minimap height (via a minimap_height() helper), and the edge-scroll bottom band all subtract the strip through it. Co-Authored-By: Claude Fable 5 --- pmacs-gpu/src/main.rs | 256 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 226 insertions(+), 30 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 4f63b9c..f33d277 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -101,6 +101,14 @@ const EDGE_SCROLL_TICK: std::time::Duration = std::time::Duration::from_millis(3 /// flash. Short enough to read as instantaneous when styling never /// arrives (plain-text buffers). const JUMP_STYLE_HOLD: std::time::Duration = std::time::Duration::from_millis(25); +/// Status band (Q#S2): one-line strip reserved at the surface +/// bottom — buffer name + modified star on the left, diagnostics / +/// cursor / scroll readout on the right. +const STATUS_BAND_HEIGHT: f32 = 26.0; +const STATUS_BAND_BG: [f32; 4] = [0.105, 0.105, 0.145, 1.0]; +const STATUS_TEXT_PAD: f32 = 10.0; +const STATUS_FONT_SIZE: f32 = 13.0; +const STATUS_LINE_HEIGHT: f32 = 18.0; const QUAD_SHADER: &str = r" struct VertexOut { @builtin(position) pos: vec4, @@ -459,6 +467,13 @@ struct State { bg_vertex_buffer: ReusableVertexBuffer, caret_vertex_buffer: ReusableVertexBuffer, minimap_vertex_buffer: ReusableVertexBuffer, + /// Q#S2 — the status band's one-line text. Shaped only when the + /// composed status string changes; rendered as a second + /// `TextArea` in the same prepare pass as the main buffer. + status_buffer: Buffer, + /// The string `status_buffer` currently holds, for change + /// detection. + status_text: String, /// Minimap vertex bytes cached by [`MinimapCacheKey`] — /// rebuilding rescanned every line shape per frame. minimap_cache: Option<(MinimapCacheKey, Vec)>, @@ -1184,6 +1199,15 @@ impl State { Some(config.width as f32), Some(config.height as f32), ); + let mut status_buffer = Buffer::new( + &mut font_system, + Metrics::new(STATUS_FONT_SIZE, STATUS_LINE_HEIGHT), + ); + status_buffer.set_size( + &mut font_system, + Some(config.width as f32), + Some(STATUS_BAND_HEIGHT), + ); buffer.set_text( &mut font_system, initial_text, @@ -1247,6 +1271,8 @@ impl State { bg_vertex_buffer: ReusableVertexBuffer::new(), caret_vertex_buffer: ReusableVertexBuffer::new(), minimap_vertex_buffer: ReusableVertexBuffer::new(), + status_buffer, + status_text: String::new(), minimap_cache: None, } } @@ -2264,6 +2290,79 @@ impl State { self.window.request_redraw(); } + /// Compose the status-band readout from the locally fresh facts + /// (Q#S1): cursor L:C from the *optimistic* caret (so it tracks + /// typing bursts instead of lagging a round trip) and the + /// All/Top/Bot/NN% scroll indicator. The wire-authoritative + /// facts (name, modified star, diagnostic counts) join via the + /// v8 `StatusFacts` pass. + fn compose_status_line(&self) -> String { + let mut out = String::new(); + let mut cursor_row = self.scroll_top; + if let Some(own) = self.own_cursor + && self.current_buffer_id == Some(own.buffer_id) + { + let byte = floor_char_boundary( + &self.current_text, + (own.byte as usize).min(self.current_text.len()), + ); + let line = self + .current_line_starts + .partition_point(|&s| s as usize <= byte) + .saturating_sub(1); + cursor_row = line; + let ls = self.current_line_starts.get(line).copied().unwrap_or(0) as usize; + let col = self + .current_text + .get(ls..byte) + .map_or(0, |s| s.chars().count()); + out.push_str(&format!("L{}:C{}", line + 1, col + 1)); + } + let scroll = format_scroll_indicator( + self.scroll_top, + estimated_visible_lines(self.config.height), + self.current_line_starts.len(), + cursor_row, + ); + if !out.is_empty() { + out.push_str(" "); + } + out.push_str(&scroll); + out + } + + /// Re-shape the status-band text iff the composed string changed + /// (one short line — shaping is trivial, but not free per frame). + fn refresh_status_line(&mut self) { + let composed = self.compose_status_line(); + if composed == self.status_text { + return; + } + self.status_buffer.set_text( + &mut self.font_system, + &composed, + &Attrs::new().family(Family::Name("JetBrains Mono")), + Shaping::Advanced, + None, + ); + self.status_buffer + .shape_until_scroll(&mut self.font_system, false); + self.status_text = composed; + } + + /// The status band's background quad (Q#S2): a full-width strip + /// under the band text. + fn status_band_vertex_bytes(&self) -> Vec { + let rect = MinimapRect { + x: 0.0, + y: text_area_bottom(self.config.height), + w: self.config.width as f32, + h: STATUS_BAND_HEIGHT, + color: STATUS_BAND_BG, + }; + rects_to_vertex_bytes(&[rect], self.config.width, self.config.height) + } + /// Bookkeeping for an outgoing Pointer event: it supersedes any /// unconfirmed optimistic-cursor prediction (the daemon's answer /// will be the click position, not the typing prediction), and @@ -2552,6 +2651,11 @@ impl State { Some(width as f32), Some(height as f32), ); + self.status_buffer.set_size( + &mut self.font_system, + Some(width as f32), + Some(STATUS_BAND_HEIGHT), + ); // A taller/shorter window changes the visible line count, so the // slice + scoped viewport change (session S1). self.reshape(); @@ -2579,7 +2683,11 @@ impl State { .texture .create_view(&wgpu::TextureViewDescriptor::default()); let frame_start = debug_frame().then(std::time::Instant::now); - let bg_vertices = self.decoration_background_vertex_bytes(); + self.refresh_status_line(); + // The band's strip rides the bg quad batch so it draws under + // the band text (text renders after the first quad draw). + let mut bg_vertices = self.decoration_background_vertex_bytes(); + bg_vertices.extend(self.status_band_vertex_bytes()); let bg_vertex_count = (bg_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let bg_buffer = self .bg_vertex_buffer @@ -2632,6 +2740,17 @@ impl State { let after_minimap = debug_frame().then(std::time::Instant::now); let text_bounds_right = self.text_bounds_right(); + // Right-align the status readout: measure the shaped width + // and place the area flush to the right pad (Q#S2). + let status_width = self + .status_buffer + .layout_runs() + .map(|r| r.line_w) + .fold(0.0_f32, f32::max); + let status_left = + (self.config.width as f32 - STATUS_TEXT_PAD - status_width).max(TEXT_LEFT); + let status_top = + text_area_bottom(self.config.height) + (STATUS_BAND_HEIGHT - STATUS_LINE_HEIGHT) / 2.0; self.text_renderer .prepare( &self.device, @@ -2639,20 +2758,39 @@ impl State { &mut self.font_system, &mut self.atlas, &self.viewport, - [TextArea { - buffer: &self.buffer, - left: TEXT_LEFT, - top: TEXT_TOP, - scale: 1.0, - bounds: TextBounds { - left: 0, - top: 0, - right: text_bounds_right, - bottom: self.config.height.cast_signed(), + [ + TextArea { + buffer: &self.buffer, + left: TEXT_LEFT, + top: TEXT_TOP, + scale: 1.0, + bounds: TextBounds { + left: 0, + top: 0, + right: text_bounds_right, + // Clip at the status band (Q#S3): a final + // partially-visible line must not bleed + // into the band. + bottom: text_area_bottom(self.config.height).round() as i32, + }, + default_color: Color::rgb(230, 230, 235), + custom_glyphs: &[], }, - default_color: Color::rgb(230, 230, 235), - custom_glyphs: &[], - }], + TextArea { + buffer: &self.status_buffer, + left: status_left, + top: status_top, + scale: 1.0, + bounds: TextBounds { + left: 0, + top: text_area_bottom(self.config.height).round() as i32, + right: self.config.width.cast_signed(), + bottom: self.config.height.cast_signed(), + }, + default_color: Color::rgb(168, 168, 180), + custom_glyphs: &[], + }, + ], &mut self.swash_cache, ) .expect("text_renderer prepare"); @@ -3074,8 +3212,20 @@ fn minimap_left(surface_width: u32) -> Option { (x > TEXT_LEFT + TEXT_RIGHT_GAP).then_some(x) } +/// Where editor content stops and the status band begins (Q#S3) — +/// the single source for every bottom-of-text computation. +fn text_area_bottom(surface_height: u32) -> f32 { + (surface_height as f32 - STATUS_BAND_HEIGHT).max(0.0) +} + +/// The minimap's drawable height: the text area minus its own +/// top/bottom insets. +fn minimap_height(surface_height: u32) -> f32 { + text_area_bottom(surface_height) - MINIMAP_TOP - MINIMAP_BOTTOM +} + fn estimated_visible_lines(surface_height: u32) -> usize { - ((surface_height as f32 - TEXT_TOP.max(0.0)) / CODE_LINE_HEIGHT) + ((text_area_bottom(surface_height) - TEXT_TOP.max(0.0)) / CODE_LINE_HEIGHT) .ceil() .max(1.0) as usize } @@ -3087,7 +3237,7 @@ fn minimap_band_contains(x: f32, y: f32, surface_width: u32, surface_height: u32 let Some(left) = minimap_left(surface_width) else { return false; }; - let height = surface_height as f32 - MINIMAP_TOP - MINIMAP_BOTTOM; + let height = minimap_height(surface_height); height > 0.0 && x >= left && x < surface_width as f32 - MINIMAP_RIGHT @@ -3095,13 +3245,41 @@ fn minimap_band_contains(x: f32, y: f32, surface_width: u32, surface_height: u32 && y < MINIMAP_TOP + height } +/// The TUI mode line's scroll readout, ported verbatim (Q#S1): "All" +/// when the buffer fits, "Top"/"Bot" at the extremes, else the cursor +/// row as a percentage of the file. +fn format_scroll_indicator( + view_top: usize, + visible: usize, + total_lines: usize, + cursor_row: usize, +) -> String { + if total_lines <= 1 { + return "All".to_string(); + } + if visible > 0 { + if visible >= total_lines { + return "All".to_string(); + } + if view_top == 0 { + return "Top".to_string(); + } + if view_top.saturating_add(visible) >= total_lines { + return "Bot".to_string(); + } + } + let pct = (cursor_row + 1).saturating_mul(100) / total_lines; + format!("{pct}%") +} + /// Q#M7 — which way (if any) a drag at pixel `y` should auto-scroll: /// `-1` in the band hugging the text area's top, `+1` in the band at -/// the surface bottom, `None` in the interior. +/// the text area's bottom (above the status band), `None` in the +/// interior. fn edge_scroll_direction(y: f32, surface_height: u32) -> Option { if y < TEXT_TOP + EDGE_SCROLL_BAND { Some(-1) - } else if y > surface_height as f32 - EDGE_SCROLL_BAND { + } else if y > text_area_bottom(surface_height) - EDGE_SCROLL_BAND { Some(1) } else { None @@ -3116,7 +3294,7 @@ fn minimap_y_to_line(y: f32, surface_height: u32, total_lines: usize) -> Option< if total_lines == 0 { return None; } - let height = surface_height as f32 - MINIMAP_TOP - MINIMAP_BOTTOM; + let height = minimap_height(surface_height); if height <= 0.0 { return None; } @@ -3135,10 +3313,10 @@ fn minimap_rects( let Some(x) = minimap_left(surface_width) else { return Vec::new(); }; - if lines.is_empty() || surface_height as f32 <= MINIMAP_TOP + MINIMAP_BOTTOM { + if lines.is_empty() || minimap_height(surface_height) <= 0.0 { return Vec::new(); } - let height = surface_height as f32 - MINIMAP_TOP - MINIMAP_BOTTOM; + let height = minimap_height(surface_height); let pixel_rows = height.round().max(1.0) as usize; let mut rects = Vec::new(); rects.push(MinimapRect { @@ -5151,8 +5329,10 @@ mod tests { #[test] fn minimap_band_and_inverse_line_mapping() { - // 800×600 surface: band x = [800-12-48, 800-12) = [740, 788), - // y = [12, 588). + // 800×600 surface: band x = [800-12-48, 800-12) = [740, 788). + // The status band reserves 26px (Q#S3), so the text area + // ends at 574 and the minimap column is y = [12, 562) + // (height 550). assert!(minimap_band_contains(750.0, 100.0, 800, 600)); assert!( !minimap_band_contains(739.0, 100.0, 800, 600), @@ -5163,15 +5343,18 @@ mod tests { "right of band" ); assert!(!minimap_band_contains(750.0, 5.0, 800, 600), "above band"); - assert!(!minimap_band_contains(750.0, 590.0, 800, 600), "below band"); + assert!( + !minimap_band_contains(750.0, 563.0, 800, 600), + "below band (status strip)" + ); // Too-narrow surfaces have no minimap at all. assert!(!minimap_band_contains(100.0, 100.0, 150, 600)); - // Inverse mapping: height = 576; 100 lines. Top → line 0, + // Inverse mapping: height = 550; 100 lines. Top → line 0, // bottom → last line, midpoint → ~half. assert_eq!(minimap_y_to_line(12.0, 600, 100), Some(0)); - assert_eq!(minimap_y_to_line(587.9, 600, 100), Some(99)); - assert_eq!(minimap_y_to_line(12.0 + 288.0, 600, 100), Some(50)); + assert_eq!(minimap_y_to_line(561.9, 600, 100), Some(99)); + assert_eq!(minimap_y_to_line(12.0 + 275.0, 600, 100), Some(50)); // Out-of-band y clamps rather than panics (scrubbing wanders). assert_eq!(minimap_y_to_line(0.0, 600, 100), Some(0)); assert_eq!(minimap_y_to_line(9999.0, 600, 100), Some(99)); @@ -5180,17 +5363,30 @@ mod tests { #[test] fn edge_scroll_direction_bands() { - // 600px surface: up-band y < 16 + 24 = 40; down-band y > 576. + // 600px surface: up-band y < 16 + 24 = 40; the text area + // ends at 574 (status band, Q#S3), so the down-band is + // y > 574 - 24 = 550. assert_eq!(edge_scroll_direction(10.0, 600), Some(-1)); assert_eq!(edge_scroll_direction(39.9, 600), Some(-1)); assert_eq!(edge_scroll_direction(40.0, 600), None, "interior"); assert_eq!(edge_scroll_direction(300.0, 600), None); assert_eq!( - edge_scroll_direction(576.0, 600), + edge_scroll_direction(550.0, 600), None, "band edge exclusive" ); - assert_eq!(edge_scroll_direction(577.0, 600), Some(1)); + assert_eq!(edge_scroll_direction(551.0, 600), Some(1)); + } + + #[test] + fn scroll_indicator_matches_tui_formula() { + // Verbatim port of the TUI's format (Q#S1) — both frontends + // must read the same. + assert_eq!(format_scroll_indicator(0, 10, 1, 0), "All"); + assert_eq!(format_scroll_indicator(0, 50, 30, 10), "All"); + assert_eq!(format_scroll_indicator(0, 10, 100, 5), "Top"); + assert_eq!(format_scroll_indicator(90, 10, 100, 95), "Bot"); + assert_eq!(format_scroll_indicator(40, 10, 100, 49), "50%"); } #[test]