From 2076a3682df6210fa18e8cede8509110199e0296 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 20 May 2026 20:40:10 -0400 Subject: [PATCH] =?UTF-8?q?session=205:=20Phase=20A=20=E2=80=94=20Decorati?= =?UTF-8?q?ons=20consumption=20(diagnostics=20as=20fg)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second Phase A session. pmacs-gpu now consumes `InstanceMessage::Decorations` with the same M11.4 dirty-merge shape as `StyleSpans`. Diagnostic kinds render as foreground color overrides; background-needing kinds (selection, search match, current line) accumulate in state but stay unpainted pending a quad pipeline. What's wired - `State.current_decorations: Vec`, sorted by `range.start`, cleared on `BufferSnapshot` like `current_spans`. - `apply_attach_message` gains a `Decorations` arm — `full=true` → `replace_decorations`, `full=false` → `merge_decorations` (M11.4 clip/drop/split, structurally identical to `merge_style_spans`). - `reshape()` rewritten as a sorted-boundary sweep over both spans and decorations: every coverage edge becomes a chunk break. Effective fg color = first matching decoration with a renderable color, else span color, else default. - `decoration_kind_to_color`: red error / yellow warning / blue info / dim hint; selection/search/current-line return `None`. Session-5 findings (rule iii, both deferred) - **M11.4 merge logic duplicated** between `StyleSpan` and `Decoration`. Structural-but-minor; defer until a third instance surfaces (peer-cursor decorations from `PresenceUpdate` are the likely third point) so the generic shape is inducted from three examples, not two. - **Background-kind decorations need a wgpu quad pipeline**. glyphon 0.11 / cosmic-text 0.18 `Attrs` is foreground-only. Structural — new render pass + composition story with text. Its own session, not absorbed into Phase A. Adversarial-verification framing Probe #5 (active diagnostics + multi-frontend `PresenceUpdate` overlap) — the diagnostics half is exercised; PresenceUpdate is its own family and isn't consumed yet. Probe #3 (viewport-boundary edges) gets re-tested: `merge_decorations` is the same code shape as `merge_style_spans`, so an edge-case finding there would replicate. Gates `cargo fmt`; `cargo clippy --all-targets --workspace -D warnings` clean; lib 1303 + protocol 11 = 1314; m4 83; m11_5 (--features crdt) 2. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 --- pmacs-gpu/src/main.rs | 254 +++++++++++++++++++++++++++++++++++------- 1 file changed, 216 insertions(+), 38 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 3dedba7..6d82a8a 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -32,7 +32,8 @@ use glyphon::{ TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, }; use pmacs_protocol::{ - BufferId, ByteRange, InstanceMessage, StyleSegment, StyleSpan, cell::Color as CellColor, + BufferId, ByteRange, Decoration, DecorationKind, DecorationSegment, InstanceMessage, + StyleSegment, StyleSpan, cell::Color as CellColor, }; use wgpu::MultisampleState; use winit::application::ApplicationHandler; @@ -189,6 +190,19 @@ struct State { /// straddling a dirty edge get clipped to outside the dirty /// range). current_spans: Vec, + /// Sorted-by-`range.start` decorations for `current_buffer_id`. + /// Same M11.4 dirty-merge semantics as `current_spans`: `Decorations + /// { full: true, .. }` replaces; `full: false` clips/replaces per + /// segment range. + /// + /// Composition with `current_spans` in `reshape`: a decoration's + /// color override beats the span's `style.fg` for the bytes it + /// covers (semantic signal — a diagnostic — outranks syntactic + /// signal). Decoration kinds whose visual is a background + /// (`Selection`, `SearchMatch`, `SearchMatchActive`, `CurrentLine`) + /// are not rendered in session 5; see the session-5 design note + /// for the deferred quad-pipeline finding. + current_decorations: Vec, } impl ApplicationHandler for App { @@ -387,6 +401,7 @@ impl State { loro_doc: None, current_buffer_id: None, current_spans: Vec::new(), + current_decorations: Vec::new(), } } @@ -415,7 +430,8 @@ impl State { /// `ViewportSend` if the message requires the main loop to fire /// one back at the daemon. /// - /// Session 4 handles four variants: + /// Session 4 introduced four variants; session 5 adds + /// `Decorations`: /// - `BufferSnapshot` — bootstrap a fresh `LoroDoc`, extract text, /// request the daemon scope styling to the new buffer (return a /// Viewport send-back). @@ -423,13 +439,18 @@ impl State { /// re-extracted. /// - `StyleSpans` — replace or merge per the M11.4 dirty-segment /// rule; reshape the rich-text rendering. + /// - `Decorations` — same M11.4 shape as `StyleSpans` but for the + /// `DecorationKind` set (diagnostics, selection, current line, + /// search match). Session 5 renders diagnostic kinds as fg color + /// overrides; background-kind decorations are accumulated but + /// not painted (see session 5's deferred quad-pipeline finding). /// - `Goodbye` — surfaced via the reader thread's clean-EOF path, /// not handled here. /// - /// Other `SemanticFrame` variants (`Decorations`, `InlineAdornments`, + /// Remaining `SemanticFrame` variants (`InlineAdornments`, /// `FileStyleSummary`) plus the grid variants (`CellDelta`, /// `Cursor`, `CursorByte`) and presence updates are ignored in - /// session 4 — they land in subsequent Phase A sessions. + /// session 5 — they land in subsequent Phase A sessions. fn apply_attach_message(&mut self, msg: InstanceMessage) -> Option { match msg { InstanceMessage::BufferSnapshot { @@ -445,9 +466,11 @@ impl State { let text_len = text.len() as u64; self.loro_doc = Some(doc); self.current_buffer_id = Some(buffer_id); - // New buffer ⇒ drop any prior styling; the next - // StyleSpans frame for this buffer is authoritative. + // New buffer ⇒ drop any prior styling/decorations; + // the next StyleSpans / Decorations frame for this + // buffer is authoritative. self.current_spans.clear(); + self.current_decorations.clear(); self.set_text(&text); Some(ViewportSend { buffer_id, @@ -496,6 +519,23 @@ impl State { self.reshape(); None } + InstanceMessage::Decorations { + buffer_id, + generation: _, + full, + segments, + } => { + if self.current_buffer_id != Some(buffer_id) { + return None; + } + if full { + self.replace_decorations(segments); + } else { + self.merge_decorations(segments); + } + self.reshape(); + None + } _ => None, } } @@ -573,47 +613,150 @@ impl State { self.current_spans.sort_by_key(|s| s.range.start); } + /// `Decorations { full: true, .. }` path — exactly the + /// `replace_style_spans` shape for decorations. The wire structure + /// is intentionally symmetric (`DecorationSegment` ↔ `StyleSegment`). + fn replace_decorations(&mut self, segments: Vec) { + self.current_decorations.clear(); + for seg in segments { + self.current_decorations.extend(seg.decorations); + } + self.current_decorations.sort_by_key(|d| d.range.start); + } + + /// `Decorations { full: false, .. }` path — M11.4 dirty-merge for + /// decorations. Structurally identical to [`Self::merge_style_spans`] + /// — same edge-clip/drop/split logic, same trailing append + + /// re-sort. + /// + /// **Recorded session-5 finding (rule iii, deferred):** this + /// duplication of the M11.4 merge algorithm across two + /// `(range, T)`-shaped types invites a generic + /// `merge_dirty_segments` helper. The refactor is + /// minor in lines but touches a load-bearing invariant; deferring + /// until at least a third instance arrives (e.g. peer-cursor + /// decorations from `PresenceUpdate`) so the abstraction is + /// inducted from three points rather than two. + fn merge_decorations(&mut self, segments: Vec) { + for seg in &segments { + let dirty = seg.range; + let mut kept = Vec::with_capacity(self.current_decorations.len()); + for d in self.current_decorations.drain(..) { + if d.range.end <= dirty.start || d.range.start >= dirty.end { + kept.push(d); + } else if d.range.start < dirty.start && d.range.end > dirty.end { + kept.push(Decoration { + range: ByteRange { + start: d.range.start, + end: dirty.start, + }, + kind: d.kind, + }); + kept.push(Decoration { + range: ByteRange { + start: dirty.end, + end: d.range.end, + }, + kind: d.kind, + }); + } else if d.range.start < dirty.start { + kept.push(Decoration { + range: ByteRange { + start: d.range.start, + end: dirty.start, + }, + kind: d.kind, + }); + } else if d.range.end > dirty.end { + kept.push(Decoration { + range: ByteRange { + start: dirty.end, + end: d.range.end, + }, + kind: d.kind, + }); + } + } + self.current_decorations = kept; + } + for seg in segments { + self.current_decorations.extend(seg.decorations); + } + self.current_decorations.sort_by_key(|d| d.range.start); + } + /// Re-build the cosmic-text Buffer from `current_text` + - /// `current_spans`. Walks the text byte-by-byte, emitting - /// `(substr, Attrs)` chunks at every span boundary — text not - /// covered by any span uses the default Attrs (terminal default - /// color). Final call: `set_rich_text` + `shape_until_scroll`. + /// `current_spans` + `current_decorations`. Computes a sorted + /// boundary list (every span and decoration edge, plus 0 and + /// `text_len`) and emits one chunk per `[boundary_i, boundary_{i+1})` + /// interval. Effective color picks the first matching decoration + /// kind with a renderable color (diagnostics in session 5; the + /// background-needing kinds — `Selection` / `SearchMatch` / + /// `SearchMatchActive` / `CurrentLine` — produce `None` and fall + /// through to span color). The decoration override means a + /// diagnostic squiggle's color beats syntax color for the bytes + /// it covers, which matches user expectation across both + /// reference editors and the pmacs TUI. + /// + /// Complexity is O(B × (S + D)) per reshape where B is the boundary + /// count and S+D is spans+decorations. For viewport-scoped data + /// this is bounded by visible bytes. A sweep-line refactor with + /// active-set pointers is the obvious upgrade if reshape cost + /// surfaces in profile data — recorded but not done in session 5. fn reshape(&mut self) { let default_attrs = Attrs::new().family(Family::Name("JetBrains Mono")); let text_len = self.current_text.len() as u64; - let mut chunks: Vec<(String, Attrs<'static>)> = Vec::new(); - let mut pos: u64 = 0; + + // Collect every interesting byte position. Clamp to text_len + // so a stale span/decoration past EOF (CrdtOp→next-frame race) + // can't index out. + let mut boundaries: Vec = vec![0, text_len]; for sp in &self.current_spans { - // Unstyled gap before this span (could be empty). - if pos < sp.range.start { - let end = sp.range.start.min(text_len) as usize; - chunks.push(( - self.current_text[pos as usize..end].to_owned(), - default_attrs.clone(), - )); - pos = sp.range.start; + boundaries.push(sp.range.start.min(text_len)); + boundaries.push(sp.range.end.min(text_len)); + } + for d in &self.current_decorations { + boundaries.push(d.range.start.min(text_len)); + boundaries.push(d.range.end.min(text_len)); + } + boundaries.sort_unstable(); + boundaries.dedup(); + + let mut chunks: Vec<(String, Attrs<'static>)> = Vec::new(); + for w in boundaries.windows(2) { + let (a, b) = (w[0], w[1]); + if a >= b { + continue; } - // Styled run, clipped to text_len so a stale span past EOF - // can't index out. - let end = sp.range.end.min(text_len) as usize; - if end > pos as usize { - let mut attrs = default_attrs.clone(); - if let Some(color) = cell_color_to_glyphon(sp.style.fg) { - attrs = attrs.color(color); + // Pick the effective color at byte `a` (which is also the + // color for every byte in `[a, b)` since boundaries + // bracket every coverage change). + let mut color: Option = None; + for d in &self.current_decorations { + if d.range.start <= a + && a < d.range.end + && let Some(c) = decoration_kind_to_color(d.kind) + { + color = Some(c); + break; } - chunks.push((self.current_text[pos as usize..end].to_owned(), attrs)); - pos = sp.range.end; } + if color.is_none() { + for sp in &self.current_spans { + if sp.range.start <= a && a < sp.range.end { + color = cell_color_to_glyphon(sp.style.fg); + break; + } + } + } + let mut attrs = default_attrs.clone(); + if let Some(c) = color { + attrs = attrs.color(c); + } + chunks.push((self.current_text[a as usize..b as usize].to_owned(), attrs)); } - // Trailing unstyled tail. - if pos < text_len { - chunks.push(( - self.current_text[pos as usize..].to_owned(), - default_attrs.clone(), - )); - } - // No spans + empty text ⇒ feed one empty chunk so set_rich_text - // has something to draw. + // No spans / decorations + empty text ⇒ feed one empty chunk + // so set_rich_text has something to draw. if chunks.is_empty() { chunks.push((String::new(), default_attrs.clone())); } @@ -775,3 +918,38 @@ fn indexed_to_glyphon(idx: u8) -> glyphon::Color { let level = 8 + 10 * (idx - 232); glyphon::Color::rgb(level, level, level) } + +/// Map a [`DecorationKind`] to a foreground color override, or `None` +/// for kinds whose visual is a background and can't be expressed in +/// the current `Attrs`-only rendering pipeline. +/// +/// Session 5 ships **fg-only** decoration rendering. The four +/// background-needing kinds (`Selection`, `SearchMatch`, +/// `SearchMatchActive`, `CurrentLine`) accumulate in +/// `current_decorations` but produce `None` here — they're recorded +/// for the future quad-pipeline session. This is the rule-(iii) +/// structural finding documented at session-5 framing: rendering +/// backgrounds needs a wgpu quad pipeline + composition story, which +/// is its own session, not absorbed into Phase A. +/// +/// Color choices match the conventional editor palette (red errors, +/// yellow warnings, light blue info, dim hints) so the GPU window's +/// visual matches what the pmacs TUI paints via terminal color codes. +fn decoration_kind_to_color(kind: DecorationKind) -> Option { + match kind { + // ANSI bright red — matches TUI diagnostic-error palette. + DecorationKind::DiagnosticError => Some(glyphon::Color::rgb(241, 76, 76)), + // ANSI bright yellow. + DecorationKind::DiagnosticWarning => Some(glyphon::Color::rgb(245, 245, 67)), + // ANSI bright blue. + DecorationKind::DiagnosticInfo => Some(glyphon::Color::rgb(59, 142, 234)), + // ANSI bright black (dim gray — hints should be visible but + // visually quietest of the diagnostic four). + DecorationKind::DiagnosticHint => Some(glyphon::Color::rgb(102, 102, 102)), + // Background-needing kinds — deferred until quad pipeline. + DecorationKind::Selection + | DecorationKind::SearchMatch + | DecorationKind::SearchMatchActive + | DecorationKind::CurrentLine => None, + } +}