session 5: Phase A — Decorations consumption (diagnostics as fg)
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<Decoration>`, 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 <noreply@anthropic.com>
This commit is contained in:
parent
8db0485839
commit
2076a3682d
|
|
@ -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<StyleSpan>,
|
||||
/// 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<Decoration>,
|
||||
}
|
||||
|
||||
impl ApplicationHandler<AppEvent> 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<ViewportSend> {
|
||||
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<DecorationSegment>) {
|
||||
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<T: HasRange>` 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<DecorationSegment>) {
|
||||
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<u64> = 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<glyphon::Color> = 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<glyphon::Color> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue