From 70a26d4ac113260558b6f85917611784c319cee3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 12 Jun 2026 13:45:12 -0400 Subject: [PATCH 1/3] =?UTF-8?q?framing:=20GPU=20status=20band=20=E2=80=94?= =?UTF-8?q?=20local=20freshness=20+=20v8=20StatusFacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q#S1 splits facts by authority: L:C and scroll derive locally (the optimistic caret must not lag a round trip); name, modified, and exact diag counts arrive via a new additive StatusFacts variant (protocol v8). The reserved ModeLine(Vec) stays grid-shaped and unused. Q#S2 quad + second TextArea; Q#S3 one text_area_bottom helper for the five bottom-assuming geometry sites. Three bets. Co-Authored-By: Claude Fable 5 --- docs/pmacs-gpu-status-band-framing.md | 80 +++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/pmacs-gpu-status-band-framing.md diff --git a/docs/pmacs-gpu-status-band-framing.md b/docs/pmacs-gpu-status-band-framing.md new file mode 100644 index 0000000..e0bde1a --- /dev/null +++ b/docs/pmacs-gpu-status-band-framing.md @@ -0,0 +1,80 @@ +# pmacs-gpu status band — framing pass + +Date: 2026-06-12. The GPU window has no status surface: no buffer +name, no modified flag, no cursor position, no diagnostic counts +(the M4.6 parity session deferred counts here for exactly this +reason). Survey facts: `InstanceMessage::ModeLine(Vec)` has +sat reserved-but-unused since day one; `BufferSnapshot` carries no +name/modified; glyphon's `prepare()` takes `&[TextArea]` so a second +band area is structurally cheap; five geometry sites assume text +runs to the surface bottom (`estimated_visible_lines`, +`TextBounds.bottom`, the minimap height pair, the edge-scroll +bottom band). + +## Q#S1 — where status facts come from + +**Stance: split by authority and freshness.** + +- **Locally derived, per frame**: cursor L:C (from `own_cursor` × + `current_line_starts` — the *optimistic* caret, so the readout + tracks typing bursts instead of lagging a round trip behind + them), and the scroll indicator (`scroll_top` × visible × + total, the TUI's All/Top/Bot/NN% formula). +- **Instance-authoritative, on the wire**: buffer name, modified + flag, exact whole-file diagnostic counts. A new additive variant + (protocol v8, ladder continues on the v6 floor): + `StatusFacts { buffer_id, name, modified, diag_errors, + diag_warnings }`, emitted by the semantic producer when any fact + changes (cached-compare, like `FileStyleSummary`; counts piggyback + the diag-store epoch from the parity session). + +Rejected: populating the reserved `ModeLine(Vec)`. It is +grid-shaped — pre-formatted styled cells — which bakes the TUI's +layout into a frontend that does its own, and a daemon-formatted +L:C would visibly lag the optimistic caret. The variant stays +reserved for grid use. Also rejected: counting marked lines in +`FileStyleSummary` as the counts — that counts *lines*, the TUI +counts *diagnostics*; two frontends showing different numbers for +the same buffer reads as a bug. + +## Q#S2 — band rendering + +**Stance: quad + second `TextArea`.** A `STATUS_BAND_HEIGHT` (26px) +strip at the surface bottom: a background quad through the existing +quad pipeline, and a one-line cosmic-text `Buffer` shaped only when +the composed status string changes, passed as a second `TextArea` +in the same `prepare()` call. Left: `name *` (modified star). +Right: `E:n W:n L:C NN%` — diag counts colored by the severity +palette, omitted when zero. + +## Q#S3 — geometry + +**Stance: one helper, no scattered arithmetic.** `text_area_bottom +(surface_height) = height - STATUS_BAND_HEIGHT` feeds every site +that assumed the surface bottom: `estimated_visible_lines`, +`TextBounds.bottom`, the minimap height pair +(`minimap_band_contains` / `minimap_y_to_line` / painters), and the +edge-scroll bottom band. The minimap keeps its own `MINIMAP_BOTTOM` +inset *above* the band. + +## Predicted findings (categorical bets) + +1. **A missed bottom-assuming site**: something still measures to + the raw surface bottom — surfaces as content drawn under the + band, or a hit-test/scroll off-by-a-line at the window bottom. +2. **Visible-lines ripple**: shrinking `estimated_visible_lines` + perturbs viewport/scroll math tuned to the old height — surfaces + as a flickering scroll indicator or an unreachable last line. +3. **Optimistic L:C flicker**: the locally derived readout jumps + when a daemon `CursorByte` confirms through the floor-release + path mid-burst — cosmetic, but noticeable enough to get reported. + +## Session plan + +Order: geometry + band rendering with the local facts (L:C, scroll) +→ v8 `StatusFacts` wire + daemon emission + name/modified/counts → +tests/polish. Manual validation: resize the window, type a burst +mid-file (L:C tracks), scroll to both extremes (Top/Bot), introduce +and fix an error (counts appear/vanish), edit (modified star), and +re-run the minimap + edge auto-scroll gestures against the new +bottom edge. From a962d18d4dc8d0d143320412e8f9be6ad4f3e602 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 12 Jun 2026 13:57:17 -0400 Subject: [PATCH 2/3] 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] From 44430e83770f21419168421edb4bd3fe37fb7333 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 12 Jun 2026 15:17:07 -0400 Subject: [PATCH 3/3] StatusFacts (protocol v8): name, modified, exact diag counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire-authoritative half of the status band (Q#S1): an additive InstanceMessage::StatusFacts { buffer_id, name, modified, diag_errors, diag_warnings }, emitted by the semantic producer on change (cached-compare). Counts freeze at their last value while the diag store is stale — positions go wrong mid-edit but counts merely lag, and flickering to zero per keystroke would be worse. The daemon's write loop keeps the variant off wires negotiated < 8, the DispatchIdle gate shape; SUPPORTED grows to [6, 7, 8]. GPU side: the band's left shows name + modified dot, the right gains severity-colored E:n/W:n ahead of the local L:C/scroll readout (rich-text spans, change-detected per side). Co-Authored-By: Claude Fable 5 --- pmacs-gpu/src/main.rs | 195 ++++++++++++++++++++++++++++------ pmacs-protocol/src/message.rs | 33 +++++- src/daemon.rs | 11 ++ src/frontend.rs | 1 + src/protocol.rs | 16 +-- src/semantic_render.rs | 137 ++++++++++++++++++++++-- 6 files changed, 347 insertions(+), 46 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index f33d277..47a755e 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -474,11 +474,30 @@ struct State { /// The string `status_buffer` currently holds, for change /// detection. status_text: String, + /// Q#S2 — the band's left side (buffer name + modified dot), + /// its own buffer so it left-aligns independently of the + /// right-aligned readout. + status_left_buffer: Buffer, + /// Change-detection twin of `status_text` for the left side. + status_left_text: String, + /// Q#S1 — the wire-authoritative status facts (protocol v8). + status_facts: Option, /// Minimap vertex bytes cached by [`MinimapCacheKey`] — /// rebuilding rescanned every line shape per frame. minimap_cache: Option<(MinimapCacheKey, Vec)>, } +/// The wire-authoritative status facts (Q#S1, protocol v8), +/// mirrored from `InstanceMessage::StatusFacts`. +#[derive(Clone, Debug, PartialEq, Eq)] +struct StatusFactsLocal { + buffer_id: BufferId, + name: String, + modified: bool, + diag_errors: u32, + diag_warnings: u32, +} + /// pmacs-gpu's own cursor position, mirrored from `CursorByte`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct OwnCursor { @@ -1208,6 +1227,15 @@ impl State { Some(config.width as f32), Some(STATUS_BAND_HEIGHT), ); + let mut status_left_buffer = Buffer::new( + &mut font_system, + Metrics::new(STATUS_FONT_SIZE, STATUS_LINE_HEIGHT), + ); + status_left_buffer.set_size( + &mut font_system, + Some(config.width as f32), + Some(STATUS_BAND_HEIGHT), + ); buffer.set_text( &mut font_system, initial_text, @@ -1273,6 +1301,9 @@ impl State { minimap_vertex_buffer: ReusableVertexBuffer::new(), status_buffer, status_text: String::new(), + status_left_buffer, + status_left_text: String::new(), + status_facts: None, minimap_cache: None, } } @@ -1847,6 +1878,25 @@ impl State { self.apply_file_style_summary(buffer_id, generation, lines); None } + // Q#S1 (protocol v8) — the wire-authoritative half of the + // status band: name, modified, whole-file diag counts. + InstanceMessage::StatusFacts { + buffer_id, + name, + modified, + diag_errors, + diag_warnings, + } => { + self.status_facts = Some(StatusFactsLocal { + buffer_id, + name, + modified, + diag_errors, + diag_warnings, + }); + self.window.request_redraw(); + None + } // Session 9.3 — peer presence. The editing frontend's // cursor + selection drive the `CurrentLine` / `Selection` // washes for this read-only mirror (finding QB1). Store @@ -2290,14 +2340,32 @@ 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(); + /// Compose the status-band readout (Q#S1): diagnostic counts + /// (wire-authoritative, severity-colored, omitted when zero), + /// then cursor L:C from the *optimistic* caret (so it tracks + /// typing bursts instead of lagging a round trip), then the + /// All/Top/Bot/NN% scroll indicator. Returns the colored spans. + fn compose_status_spans(&self) -> Vec<(String, Option)> { + let mut spans: Vec<(String, Option)> = Vec::new(); + if let Some(facts) = self + .status_facts + .as_ref() + .filter(|f| Some(f.buffer_id) == self.current_buffer_id) + { + if facts.diag_errors > 0 { + spans.push(( + format!("E:{}", facts.diag_errors), + Some(Color::rgb(241, 76, 76)), + )); + } + if facts.diag_warnings > 0 { + spans.push(( + format!("W:{}", facts.diag_warnings), + Some(Color::rgb(245, 245, 67)), + )); + } + } + let mut readout = String::new(); let mut cursor_row = self.scroll_top; if let Some(own) = self.own_cursor && self.current_buffer_id == Some(own.buffer_id) @@ -2316,38 +2384,80 @@ impl State { .current_text .get(ls..byte) .map_or(0, |s| s.chars().count()); - out.push_str(&format!("L{}:C{}", line + 1, col + 1)); + readout.push_str(&format!("L{}:C{}", line + 1, col + 1)); + readout.push_str(" "); } - let scroll = format_scroll_indicator( + readout.push_str(&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 + )); + spans.push((readout, None)); + spans } - /// 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; + /// The band's left side: buffer name + modified dot, from the + /// v8 `StatusFacts` (empty until the daemon ships them). + fn compose_status_left(&self) -> String { + match self + .status_facts + .as_ref() + .filter(|f| Some(f.buffer_id) == self.current_buffer_id) + { + Some(facts) if facts.modified => format!("{} ●", facts.name), + Some(facts) => facts.name.clone(), + None => String::new(), + } + } + + /// Re-shape the status-band text iff the composed content + /// changed (short lines — shaping is trivial, but not free per + /// frame). + fn refresh_status_line(&mut self) { + let spans = self.compose_status_spans(); + let composed: String = spans + .iter() + .map(|(t, _)| t.as_str()) + .collect::>() + .join(" "); + let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono")); + if composed != self.status_text { + let mut rich: Vec<(&str, Attrs)> = Vec::new(); + for (i, (t, c)) in spans.iter().enumerate() { + if i > 0 { + rich.push((" ", default_attrs.clone())); + } + let attrs = match c { + Some(color) => default_attrs.clone().color(*color), + None => default_attrs.clone(), + }; + rich.push((t.as_str(), attrs)); + } + self.status_buffer.set_rich_text( + &mut self.font_system, + rich, + &default_attrs, + Shaping::Advanced, + None, + ); + self.status_buffer + .shape_until_scroll(&mut self.font_system, false); + self.status_text = composed; + } + let left = self.compose_status_left(); + if left != self.status_left_text { + self.status_left_buffer.set_text( + &mut self.font_system, + &left, + &default_attrs, + Shaping::Advanced, + None, + ); + self.status_left_buffer + .shape_until_scroll(&mut self.font_system, false); + self.status_left_text = left; } - 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 @@ -2656,6 +2766,11 @@ impl State { Some(width as f32), Some(STATUS_BAND_HEIGHT), ); + self.status_left_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(); @@ -2790,6 +2905,21 @@ impl State { default_color: Color::rgb(168, 168, 180), custom_glyphs: &[], }, + TextArea { + buffer: &self.status_left_buffer, + left: STATUS_TEXT_PAD, + top: status_top, + scale: 1.0, + bounds: TextBounds { + left: 0, + top: text_area_bottom(self.config.height).round() as i32, + // Stop before the right-aligned readout. + right: (status_left - STATUS_TEXT_PAD).max(0.0).round() as i32, + bottom: self.config.height.cast_signed(), + }, + default_color: Color::rgb(200, 200, 210), + custom_glyphs: &[], + }, ], &mut self.swash_cache, ) @@ -3593,6 +3723,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str { InstanceMessage::Decorations { .. } => "Decorations", InstanceMessage::InlineAdornments { .. } => "InlineAdornments", InstanceMessage::FileStyleSummary { .. } => "FileStyleSummary", + InstanceMessage::StatusFacts { .. } => "StatusFacts", InstanceMessage::BlockAdornments { .. } => "BlockAdornments", InstanceMessage::FoldState { .. } => "FoldState", InstanceMessage::ResourceOffer { .. } => "ResourceOffer", diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index e82bb91..a041ce0 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -724,6 +724,26 @@ pub enum InstanceMessage { /// from line 0. Empty when the buffer is empty. lines: Vec, }, + /// Q#S1 (status band, protocol v8) — instance-authoritative + /// status facts a semantic frontend cannot derive locally: + /// buffer name, modified flag, whole-file diagnostic counts. + /// Cursor position and scroll stay frontend-derived (the + /// optimistic caret must not lag a round trip). Emitted by the + /// semantic producer when any fact changes; kept off wires + /// negotiated `< 8` (additive variant — an older peer would + /// hard-error decoding it). + StatusFacts { + /// Buffer these facts describe. + buffer_id: crate::BufferId, + /// Buffer display name. + name: String, + /// Unsaved-changes flag. + modified: bool, + /// Whole-file `Error`-severity diagnostic count. + diag_errors: u32, + /// Whole-file `Warning`-severity diagnostic count. + diag_warnings: u32, + }, /// T M11.1 — diff zones, folded-region placeholders, anything /// occupying its own vertical band. Anchored to the offset of the /// line it precedes or replaces; the frontend allocates the @@ -1044,7 +1064,13 @@ pub enum ResourceBody { /// (sent only when the instance's `Hello.protocol_version >= 7`), /// so the compat ladder restarts on the v6 encoding floor — /// `SUPPORTED_PROTOCOL_VERSIONS` grows to `[6, 7]`. -pub const PROTOCOL_VERSION: u32 = 7; +/// +/// Q#S1 (status band): bumped from 7 to 8 for +/// [`InstanceMessage::StatusFacts`]. Additive again, gated in the +/// *daemon* this time (the variant travels instance→frontend): the +/// per-session filter keeps it off wires negotiated `< 8`, the same +/// shape as the `DispatchIdle` (v4) gate. +pub const PROTOCOL_VERSION: u32 = 8; /// T M10.5: the set of protocol versions a v1.0 binary accepts on /// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept @@ -1081,7 +1107,10 @@ pub const PROTOCOL_VERSION: u32 = 7; /// and frontend-gated (like `Pointer` itself at v5), so the ladder /// resumes: v6 and v7 binaries interoperate, with the new variant /// kept off wires whose instance negotiated `< 7`. -pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7]; +/// +/// Q#S1: extended to `[6, 7, 8]`. `InstanceMessage::StatusFacts` is +/// additive and daemon-gated per session. +pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8]; /// T M10.5: predicate for the handshake check. Returns `true` if /// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`]. diff --git a/src/daemon.rs b/src/daemon.rs index 86531a5..2365a36 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1015,7 +1015,18 @@ fn dispatcher_loop( if let Some(stream) = streams.get_mut(fid) && !write_failed { + // Q#S1 — `StatusFacts` is a v8 variant; an older peer + // would hard-error decoding it. Same per-session gate + // shape as `DispatchIdle` (v4). + let peer_knows_status_facts = session_registry + .session_state(*fid) + .is_some_and(|s| s.negotiated_protocol_version >= 8); for msg in &messages { + if !peer_knows_status_facts + && matches!(msg, InstanceMessage::StatusFacts { .. }) + { + continue; + } // T M10.10 Day 4 / M10.11 F2 — the criterion-1 // jitter site: render-write latency. // diff --git a/src/frontend.rs b/src/frontend.rs index 6a69ad3..ef40299 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -383,6 +383,7 @@ impl Frontend { | InstanceMessage::BlockAdornments { .. } | InstanceMessage::FoldState { .. } | InstanceMessage::FileStyleSummary { .. } + | InstanceMessage::StatusFacts { .. } | InstanceMessage::ResourceOffer { .. } // T M11.6 — DispatchIdle is consumed by `attach.rs`'s // optimistic-apply gate; if any reaches this render path diff --git a/src/protocol.rs b/src/protocol.rs index 4d75e84..5bb172e 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -1683,7 +1683,7 @@ mod tests { // --- M5.5a handshake & postcard round-trips --- #[test] - fn protocol_version_is_seven_for_triple_click() { + fn protocol_version_is_eight_for_status_facts() { // Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp / // PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the // SemanticFrame family + FrontendEvent::Viewport). T M11.6 @@ -1693,7 +1693,9 @@ mod tests { // bump that changed an existing struct's postcard encoding, // making v6 the ladder's encoding floor. Q#M4 bumped 6→7 // (`PointerKind::TripleDown`, additive + frontend-gated). - assert_eq!(PROTOCOL_VERSION, 7); + // Q#S1 bumped 7→8 (`InstanceMessage::StatusFacts`, additive + // + daemon-gated per session). + assert_eq!(PROTOCOL_VERSION, 8); } #[test] @@ -1702,14 +1704,16 @@ mod tests { // every cell-carrying message, ending the v1–v5 ladder — // pre-v6 peers are refused at the handshake (a clean // VersionMismatch) rather than garbling postcard mid-session. - // Q#M4: the ladder resumes above that floor — v7 is additive - // (`TripleDown`, frontend-gated), so v6 and v7 interoperate. + // Q#M4 / Q#S1: the ladder resumes above that floor — v7 + // (`TripleDown`, frontend-gated) and v8 (`StatusFacts`, + // daemon-gated) are additive, so v6 through v8 interoperate. assert!(is_supported_protocol_version(6)); assert!(is_supported_protocol_version(7)); - for rejected in [0, 1, 2, 3, 4, 5, 8, u32::MAX] { + assert!(is_supported_protocol_version(8)); + for rejected in [0, 1, 2, 3, 4, 5, 9, u32::MAX] { assert!( !is_supported_protocol_version(rejected), - "v{rejected} must be rejected by a v7 binary" + "v{rejected} must be rejected by a v8 binary" ); } } diff --git a/src/semantic_render.rs b/src/semantic_render.rs index ba77da7..01360fc 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -118,6 +118,9 @@ pub struct SemanticRenderState { /// bump, so the epoch half catches republishes (minimap marks, /// T M4.6 GPU parity). last_summary: HashMap, + /// `(name, modified, diag_errors, diag_warnings)` last emitted as + /// `StatusFacts` (Q#S1) — cached-compare suppression. + last_status: HashMap, /// `StyleSpans` recompute gate (perf). `scoped_style_spans` runs /// the tree-sitter highlights query over the *whole declared /// viewport* (which the GPU frontend sets to the entire buffer) @@ -187,6 +190,7 @@ impl SemanticRenderState { last_decorations: HashMap::new(), last_adornments: HashMap::new(), last_summary: HashMap::new(), + last_status: HashMap::new(), last_style_gate: HashMap::new(), diag_line_cache: HashMap::new(), } @@ -383,9 +387,70 @@ impl SemanticRenderState { out.extend(self.inline_adornments_msg(state, &vp)); // --- FileStyleSummary (minimap producer; Open Q#2) --- out.extend(self.file_style_summary_msg(state, vp.buffer_id, generation)); + // --- StatusFacts (status band; Q#S1, protocol v8) --- + out.extend(self.status_facts_msg(state, vp.buffer_id)); out } + /// The `StatusFacts` message for this frame, or `None` when + /// nothing changed. Carries the facts a semantic frontend cannot + /// derive locally: buffer name, modified flag, whole-file + /// diagnostic counts (errors / warnings). Counts freeze at their + /// last value while the diag store is stale — mid-edit positions + /// are wrong but *counts* merely lag, and flickering to zero on + /// every keystroke would be worse. The daemon's write loop keeps + /// the variant off wires negotiated `< 8`. + fn status_facts_msg( + &mut self, + state: &EditorState, + buffer_id: BufferId, + ) -> Option { + let (name, modified) = { + let core = state.core.borrow(); + let registry = core.registry.clone(); + let reg = registry.borrow(); + let buf = reg.get(buffer_id).ok()?; + (buf.name().to_owned(), buf.is_modified()) + }; + let counts = { + let core = state.core.borrow(); + buffer_file_uri(&core, buffer_id).and_then(|uri| { + let store = state.lsp_manager.borrow().diag_store(); + let guard = store.lock().expect("diag store mutex poisoned"); + if guard.is_stale(&uri) { + None // keep the cached counts + } else { + let mut errors = 0u32; + let mut warnings = 0u32; + for d in guard.for_uri(&uri) { + match d.severity { + crate::diag::DiagnosticSeverity::Error => errors += 1, + crate::diag::DiagnosticSeverity::Warning => warnings += 1, + _ => {} + } + } + Some((errors, warnings)) + } + }) + }; + let cached = self.last_status.get(&buffer_id); + let (diag_errors, diag_warnings) = + counts.unwrap_or_else(|| cached.map_or((0, 0), |c| (c.2, c.3))); + let facts = (name, modified, diag_errors, diag_warnings); + if cached == Some(&facts) { + return None; + } + let msg = InstanceMessage::StatusFacts { + buffer_id, + name: facts.0.clone(), + modified: facts.1, + diag_errors, + diag_warnings, + }; + self.last_status.insert(buffer_id, facts); + Some(msg) + } + /// The `InlineAdornments` message for this frame, or `None` when /// nothing should be sent. The wire variant has no /// `generation`/`full`/`segments`, so this is M11.2-level @@ -1368,9 +1433,10 @@ mod tests { } /// All `InstanceMessage` variants the semantic projection may - /// emit are `StyleSpans`, `Decorations`, `InlineAdornments`, or - /// `FileStyleSummary` — never `CellDelta`, grid `Cursor`, or the - /// still-unwired `BlockAdornments` / `FoldState` families. + /// emit are `StyleSpans`, `Decorations`, `InlineAdornments`, + /// `FileStyleSummary`, or `StatusFacts` (Q#S1) — never + /// `CellDelta`, grid `Cursor`, or the still-unwired + /// `BlockAdornments` / `FoldState` families. fn assert_semantic_only(msgs: &[InstanceMessage]) { for m in msgs { assert!( @@ -1380,6 +1446,7 @@ mod tests { | InstanceMessage::Decorations { .. } | InstanceMessage::InlineAdornments { .. } | InstanceMessage::FileStyleSummary { .. } + | InstanceMessage::StatusFacts { .. } ), "semantic projection emitted an unexpected variant: {m:?}" ); @@ -1478,12 +1545,13 @@ mod tests { // the first frame is a `full` resync for both diffable families // (the frontend clears its viewport), carrying empty segments. // FileStyleSummary also emits on the first frame for this buffer - // (post-M11 minimap producer, generation-keyed). + // (post-M11 minimap producer, generation-keyed), as does + // StatusFacts (Q#S1, cached-compare). let first = s.render_frame(&state); assert_eq!( first.len(), - 3, - "first frame ships StyleSpans + Decorations + FileStyleSummary" + 4, + "first frame ships StyleSpans + Decorations + FileStyleSummary + StatusFacts" ); assert_semantic_only(&first); let (style_full, _) = style_segments(&first).expect("StyleSpans present"); @@ -2806,6 +2874,63 @@ mod tests { assert_eq!(lines[3], Style::default(), "trailing empty line → default"); } + fn facts_of(msgs: &[InstanceMessage]) -> Option<(String, bool, u32, u32)> { + msgs.iter().find_map(|m| match m { + InstanceMessage::StatusFacts { + name, + modified, + diag_errors, + diag_warnings, + .. + } => Some((name.clone(), *modified, *diag_errors, *diag_warnings)), + _ => None, + }) + } + + #[test] + fn status_facts_emit_on_change_and_freeze_counts_while_stale() { + let state = empty_state(); + let mut s = local(); + let bid = active_buffer(&state); + // "abc\nde" + a Warning diagnostic + file path; the seeding + // edit flips `modified`. + seed_diagnostic(&state, bid); + s.set_viewport(bid, ByteRange { start: 0, end: 64 }, 0); + + let first = s.render_frame(&state); + let (_, modified, errors, warnings) = facts_of(&first).expect("first frame ships facts"); + assert!(modified, "the seeding edit dirtied the buffer"); + assert_eq!((errors, warnings), (0, 1)); + + // Nothing changed → suppressed. + assert!(facts_of(&s.render_frame(&state)).is_none()); + + // Republish as an Error → re-emit with new counts. + let uri = crate::lsp::path_to_file_uri(std::path::Path::new("/tmp/m114.rs")); + let store = state.lsp_manager.borrow().diag_store(); + store.lock().expect("diag store").set( + &uri, + vec![crate::diag::Diagnostic { + start_line: 0, + start_col: 0, + end_line: 0, + end_col: 3, + severity: crate::diag::DiagnosticSeverity::Error, + message: "boom".into(), + source: None, + code: None, + }], + ); + let (_, _, errors, warnings) = + facts_of(&s.render_frame(&state)).expect("republish re-emits"); + assert_eq!((errors, warnings), (1, 0)); + + // Stale store: counts freeze at the cached value instead of + // flickering to zero, so no re-emission either. + store.lock().expect("diag store").mark_stale(&uri); + assert!(facts_of(&s.render_frame(&state)).is_none()); + } + #[test] fn zero_width_diagnostics_widen_to_a_visible_byte() { // "abc\nde" — line starts [0, 4], source_len 6; line 0