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