session 9.3 — peer-presence cursor/selection (fixes QB1)

Manual validation of 9.2 surfaced finding QB1: the CurrentLine wash
never appeared, and 9.1's Selection never actually rendered either.

Root cause: `Selection` and `CurrentLine` are the only two
per-WINDOW-state decorations; every other rendered family (StyleSpans,
diagnostics, inlay hints, minimap) is keyed to the shared BUFFER. The
producer emits both from the viewing frontend's own window
(`active_window_for(self.frontend_id)`). pmacs-gpu is a read-only
mirror with no input path — it never sends Key/cursor events, so its
own window's cursor stays pinned at 0 and its selection stays None.
Both decorations are therefore inert in pmacs-gpu: CurrentLine paints
a static line-0 wash (invisible at alpha 0.08) and Selection never
appears. What the user actually watches is the *editing* frontend's
(their TUI's) cursor — which is peer presence.

Fix is consumer-only — no producer or protocol change. The wire
already carries it: `InstanceMessage::PresenceUpdate { frontend_id,
buffer_id, cursor, selection }` is broadcast by the daemon to every
`multi_frontend` recipient, pmacs-gpu already negotiates
`multi_frontend: true`, and it was simply dropping the message at its
`_ => None` catch-all.

Q#5 (recorded in the framing doc): peer presence is the authoritative
cursor/selection source for a read-only mirror.

- New `peer_presences: HashMap<FrontendId, PeerPresence>` state,
  cleared on BufferSnapshot (peer offsets are prior-buffer-relative).
- New `PresenceUpdate` arm stores per-peer (buffer_id, cursor,
  selection) and requests a redraw.
- `peer_background_rects` replaces the old
  `decoration_background_rects`: renders `CurrentLine` over the source
  line holding each peer's cursor (`source_line_range`) and
  `Selection` over each peer's selected range, both via the shared
  `push_glyph_extent_rects` (the former inline glyph-overlap loop,
  extracted). Own-window Selection/CurrentLine in `current_decorations`
  are no longer drawn as backgrounds — they're inert for a read-only
  mirror. Diagnostic (foreground) decorations are untouched.
- The producer keeps emitting own-window Selection/CurrentLine (9.1/
  9.2) unchanged — correct and forward-looking for when pmacs-gpu
  gains its own input in Phase B; simply unconsumed-for-backgrounds by
  the mirror today.

Deferred within the stance (documented): per-peer stable colors (single
peer reuses the Selection/CurrentLine colors), peer caret glyph +
"user N" label, and own-vs-peer cursor merge once input lands.

New tests: `source_line_range_locates_enclosing_line` +
`source_line_range_handles_empty_and_leading_newline`. The peer
rect generation itself needs a laid-out buffer (font system) and is
covered by the manual probe.

Two pre-existing functions tipped past clippy's 100-line limit by the
additions (`State::new` 101, `apply_attach_message` 116, a per-variant
match dispatcher); both get `#[allow(clippy::too_many_lines)]`,
matching the precedent on `semantic_render::render_frame`.

Gates green:
- cargo fmt --all -- --check
- cargo clippy --all-targets --workspace -- -D warnings
- cargo clippy --all-targets --workspace --features crdt -- -D warnings
- pmacs lib 1329 + pmacs-protocol 11
- pmacs-gpu unit 15 (+2 source_line_range tests)
- m4_acceptance 88, m11_5_semantic_acceptance (--features crdt) 2

Manual probe: daemon + TUI attach + pmacs-gpu attach. Move the cursor
in the TUI — pmacs-gpu's CurrentLine wash should track the TUI's line.
Select text in the TUI — the Selection wash should mirror it. Both
should now actually appear and follow the editing frontend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-28 14:42:52 -04:00
parent 7dfcd79d72
commit 57feae1e2d
2 changed files with 228 additions and 45 deletions

View File

@ -188,6 +188,59 @@ Phase A finalization closes with `SearchMatch{,Active}` rendering
documented-but-not-implemented; the rule-(iii) classification is "small
finding deferred awaiting upstream feature."
### Q#5 — read-only mirror cursor source: stance (peer-presence)
**Surfaced during 9.2 manual validation (structural finding QB1).**
Sessions 9.1 and 9.2 emit `Selection` / `CurrentLine` from the
*viewing* frontend's own window
(`scoped_decorations` reads `active_window_for(self.frontend_id)`).
pmacs-gpu is a read-only viewer with no input path: it never sends
`Key`/cursor events, so its own window's `cursor` stays pinned at 0
and its `selection` stays `None`. The two per-window decoration kinds
are therefore **inert** in pmacs-gpu — `CurrentLine` paints a static
line-0 wash and `Selection` never appears. Every other rendered
family (`StyleSpans`, diagnostics, inlay hints, minimap) is keyed to
the *buffer* (shared), which is why only these two are affected.
**Stance: peer presence is the authoritative cursor/selection source
for a read-only mirror.** What the user watches in pmacs-gpu is the
*editing* frontend's (their TUI's) cursor and selection. That is
`PresenceUpdate` — already on the wire (`InstanceMessage::PresenceUpdate
{ frontend_id, buffer_id, cursor, selection }`), already broadcast by
the daemon to every `multi_frontend` recipient, and pmacs-gpu already
negotiates `multi_frontend: true`. It simply drops the message at its
`_ => None` catch-all today.
The fix is **consumer-only** — no producer or protocol change:
- pmacs-gpu consumes `PresenceUpdate`, storing per-peer
`(buffer_id, cursor, selection)`.
- The quad-background path renders `Selection` / `CurrentLine` washes
from peer presence (the editing peer's cursor line + selection)
rather than from the inert own-window `current_decorations` of those
two kinds. Diagnostic (foreground) decorations are buffer-keyed and
unaffected.
- The producer keeps emitting own-window `Selection` / `CurrentLine`
(9.1/9.2) unchanged — correct and forward-looking for when pmacs-gpu
gains its own input in Phase B; simply unconsumed-for-backgrounds by
the mirror today.
Deliberately deferred within this stance:
- **Per-peer stable colors** (the audit's `PresenceUpdate`
color-stability item). The single-peer mirror reuses the `Selection`
/ `CurrentLine` colors so the visual reads as "my editing, mirrored."
Multiple distinct peers each getting a stable color is a later
refinement.
- **Peer caret glyph + label** ("user N editing here"). This session
renders the line/selection *backgrounds* only; the caret bar and
name label are future presence work.
- **Own-cursor vs peer-cursor merge.** Once pmacs-gpu has input, its
own `CurrentLine` (now meaningful) and peer presences coexist with
distinct colors. Out of scope until input lands.
This is session 9.3.
## Finding feedback loop
Rule (iii) from `pmacs-gpu-design.md` carries forward unchanged:

View File

@ -24,6 +24,7 @@
mod attach;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
@ -33,7 +34,8 @@ use glyphon::{
};
use pmacs_protocol::{
AdornmentContent, AdornmentPlacement, BufferId, ByteRange, Decoration, DecorationKind,
DecorationSegment, InlineAdornment, InstanceMessage, StyleSegment, StyleSpan,
DecorationSegment, FrontendId, InlineAdornment, InstanceMessage, SelectionSnapshot,
StyleSegment, StyleSpan,
cell::{Color as CellColor, Style as CellStyle},
};
use wgpu::MultisampleState;
@ -268,6 +270,28 @@ struct State {
/// arrives, matching the ownership rule used by style spans,
/// decorations, and inline adornments.
current_summary: Option<FileStyleSummaryState>,
/// Peer presence (session 9.3), keyed by source frontend id. Each
/// entry is one *other* attached frontend's cursor + selection,
/// delivered via `InstanceMessage::PresenceUpdate`. A read-only
/// mirror has no cursor of its own (no input path), so its own
/// `Selection` / `CurrentLine` decorations are inert; the editing
/// peer's presence is what the user actually watches. The quad-
/// background path renders `Selection` / `CurrentLine` washes from
/// these entries rather than from `current_decorations`. Sender
/// exclusion at the daemon means our own id never appears here.
peer_presences: HashMap<FrontendId, PeerPresence>,
}
/// One peer frontend's cursor + selection in a buffer, from
/// `InstanceMessage::PresenceUpdate`. Byte offsets are in the buffer's
/// coordinate space; the renderer maps them to glyph rectangles via
/// the local layout and clamps to text length, so a presence that
/// briefly lags an edit can never index out.
#[derive(Clone, Copy, Debug)]
struct PeerPresence {
buffer_id: BufferId,
cursor: u64,
selection: Option<SelectionSnapshot>,
}
struct QuadRenderer {
@ -428,6 +452,7 @@ impl QuadRenderer {
}
impl State {
#[allow(clippy::too_many_lines)] // linear GPU/font/surface setup; splitting would obscure ordering.
fn new(event_loop: &ActiveEventLoop, initial_text: &str) -> Self {
let window = Arc::new(
event_loop
@ -537,6 +562,7 @@ impl State {
current_decorations: Vec::new(),
current_adornments: Vec::new(),
current_summary: None,
peer_presences: HashMap::new(),
}
}
@ -593,9 +619,11 @@ impl State {
/// - `Goodbye` — surfaced via the reader thread's clean-EOF path,
/// not handled here.
///
/// Remaining semantic variants plus the grid variants (`CellDelta`,
/// `Cursor`, `CursorByte`) and presence updates are ignored in
/// session 7 — they land in subsequent Phase A sessions.
/// The grid variants (`CellDelta`, `Cursor`, `CursorByte`) are
/// ignored — pmacs-gpu lays out locally and tracks the cursor via
/// `PresenceUpdate` (session 9.3). Remaining semantic variants land
/// in subsequent Phase A sessions.
#[allow(clippy::too_many_lines)] // per-variant match dispatcher; one arm per InstanceMessage.
fn apply_attach_message(&mut self, msg: InstanceMessage) -> Option<ViewportSend> {
match msg {
InstanceMessage::BufferSnapshot {
@ -618,6 +646,11 @@ impl State {
self.current_decorations.clear();
self.current_adornments.clear();
self.current_summary = None;
// Peer cursors are anchored in the prior buffer's
// coordinate space; drop them so a stale offset can't
// paint against the new rope before the next
// PresenceUpdate arrives.
self.peer_presences.clear();
if !self.set_text(&text) {
self.reshape();
}
@ -727,6 +760,29 @@ impl State {
self.apply_file_style_summary(buffer_id, generation, lines);
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
// per source frontend; a redraw recomputes the background
// rects from `peer_presences`. We never receive our own
// (daemon sender exclusion).
InstanceMessage::PresenceUpdate {
frontend_id,
buffer_id,
cursor,
selection,
} => {
self.peer_presences.insert(
frontend_id,
PeerPresence {
buffer_id,
cursor,
selection,
},
);
self.window.request_redraw();
None
}
_ => None,
}
}
@ -1093,59 +1149,95 @@ impl State {
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
/// Vertex bytes for quad-pipeline background rectangles covering
/// every background-bearing decoration in `current_decorations`.
/// Walks `cosmic_text::Buffer::layout_runs()` to map each
/// decoration's `ByteRange` into per-visual-line pixel rectangles:
/// a multi-line selection produces one rect per layout run that
/// carries at least one glyph whose `[start, end)` overlaps the
/// decoration. Returns an empty `Vec` when no background-bearing
/// decoration intersects any laid-out glyph.
/// Vertex bytes for quad-pipeline background rectangles. Session
/// 9.3 sources `CurrentLine` / `Selection` washes from peer
/// presence (the editing frontend's cursor + selection) rather
/// than from `current_decorations`: this is a read-only mirror, so
/// its own per-window `Selection` / `CurrentLine` decorations are
/// inert (cursor pinned at 0, no selection). See finding QB1 in
/// `docs/pmacs-gpu-quad-backgrounds-framing.md`.
fn decoration_background_vertex_bytes(&self) -> Vec<u8> {
let rects = self.decoration_background_rects();
let rects = self.peer_background_rects();
rects_to_vertex_bytes(&rects, self.config.width, self.config.height)
}
fn decoration_background_rects(&self) -> Vec<MinimapRect> {
/// Background rectangles for every peer's cursor line + selection
/// in the current buffer. `CurrentLine` covers the source line
/// holding the peer cursor; `Selection` covers the peer's selected
/// byte range. Both map byte ranges to per-visual-line glyph
/// extents via `peer_glyph_extent_rects`. Single-peer mirrors reuse
/// the `Selection` / `CurrentLine` colors so the visual reads as
/// "my editing, mirrored"; per-peer distinct colors are deferred.
fn peer_background_rects(&self) -> Vec<MinimapRect> {
let Some(buffer_id) = self.current_buffer_id else {
return Vec::new();
};
let text_len = self.current_text.len() as u64;
let mut rects = Vec::new();
for d in &self.current_decorations {
let Some(color) = decoration_kind_to_bg_color(d.kind) else {
continue;
};
let lo = d.range.start;
let hi = d.range.end;
if hi <= lo {
for presence in self.peer_presences.values() {
if presence.buffer_id != buffer_id {
continue;
}
for run in self.buffer.layout_runs() {
let mut min_x: Option<f32> = None;
let mut max_x: Option<f32> = None;
for glyph in run.glyphs {
let g_start = glyph.start as u64;
let g_end = glyph.end as u64;
if g_end <= lo || g_start >= hi {
continue;
}
let x0 = glyph.x;
let x1 = glyph.x + glyph.w;
min_x = Some(min_x.map_or(x0, |v| v.min(x0)));
max_x = Some(max_x.map_or(x1, |v| v.max(x1)));
}
if let (Some(x0), Some(x1)) = (min_x, max_x)
&& x1 > x0
{
rects.push(MinimapRect {
x: TEXT_LEFT + x0,
y: TEXT_TOP + run.line_top,
w: x1 - x0,
h: run.line_height,
color,
});
// CurrentLine: the source line containing the peer cursor.
if let Some(color) = decoration_kind_to_bg_color(DecorationKind::CurrentLine) {
let (lo, hi) = source_line_range(&self.current_text, presence.cursor);
self.push_glyph_extent_rects(&mut rects, lo, hi, color);
}
// Selection: the peer's selected byte range, normalized.
if let Some(sel) = presence.selection
&& let Some(color) = decoration_kind_to_bg_color(DecorationKind::Selection)
{
let lo = sel.anchor.min(sel.active).min(text_len);
let hi = sel.anchor.max(sel.active).min(text_len);
if hi > lo {
self.push_glyph_extent_rects(&mut rects, lo, hi, color);
}
}
}
rects
}
/// Push one rect per visual line whose glyphs overlap the byte
/// range `[lo, hi)`, spanning the matching glyphs' horizontal
/// extent. A range crossing visual-line boundaries (wrapped or
/// multi-line) fans out into one rect per run.
fn push_glyph_extent_rects(
&self,
rects: &mut Vec<MinimapRect>,
lo: u64,
hi: u64,
color: [f32; 4],
) {
if hi <= lo {
return;
}
for run in self.buffer.layout_runs() {
let mut min_x: Option<f32> = None;
let mut max_x: Option<f32> = None;
for glyph in run.glyphs {
let g_start = glyph.start as u64;
let g_end = glyph.end as u64;
if g_end <= lo || g_start >= hi {
continue;
}
let x0 = glyph.x;
let x1 = glyph.x + glyph.w;
min_x = Some(min_x.map_or(x0, |v| v.min(x0)));
max_x = Some(max_x.map_or(x1, |v| v.max(x1)));
}
if let (Some(x0), Some(x1)) = (min_x, max_x)
&& x1 > x0
{
rects.push(MinimapRect {
x: TEXT_LEFT + x0,
y: TEXT_TOP + run.line_top,
w: x1 - x0,
h: run.line_height,
color,
});
}
}
}
}
#[derive(Clone, Copy, Debug)]
@ -1425,6 +1517,19 @@ fn rgb_to_minimap_color(r: u8, g: u8, b: u8) -> [f32; 4] {
]
}
/// Byte range `[start, end)` of the source line containing `cursor`:
/// `start` is just after the previous `\n` (or 0), `end` is just after
/// the next `\n` (or text length). Mirrors the producer's
/// `current_line_range` so the rendered `CurrentLine` wash covers the
/// same bytes the producer would. `cursor` is clamped to the text
/// length so a peer presence that briefly lags an edit is safe.
fn source_line_range(text: &str, cursor: u64) -> (u64, u64) {
let c = (cursor as usize).min(text.len());
let start = text[..c].rfind('\n').map_or(0, |i| i + 1);
let end = text[c..].find('\n').map_or(text.len(), |i| c + i + 1);
(start as u64, end as u64)
}
fn rects_to_vertex_bytes(
rects: &[MinimapRect],
surface_width: u32,
@ -1766,6 +1871,31 @@ mod tests {
chunks.iter().map(|chunk| chunk.text.as_str()).collect()
}
#[test]
fn source_line_range_locates_enclosing_line() {
// "abc\nde\nfgh": newlines at byte 3 and 6; len = 10.
let text = "abc\nde\nfgh";
// Cursor on line 0 → [0, 4) (includes the trailing \n).
assert_eq!(source_line_range(text, 0), (0, 4));
assert_eq!(source_line_range(text, 2), (0, 4));
// Start of line 1 → [4, 7).
assert_eq!(source_line_range(text, 4), (4, 7));
assert_eq!(source_line_range(text, 5), (4, 7));
// Last line has no trailing \n → [7, 10).
assert_eq!(source_line_range(text, 8), (7, 10));
// Cursor past end clamps to the last line, never indexes out.
assert_eq!(source_line_range(text, 99), (7, 10));
}
#[test]
fn source_line_range_handles_empty_and_leading_newline() {
assert_eq!(source_line_range("", 0), (0, 0));
// "\nx": cursor 0 is on the empty first line [0, 1).
assert_eq!(source_line_range("\nx", 0), (0, 1));
// cursor 1 is on line 1 → [1, 2).
assert_eq!(source_line_range("\nx", 1), (1, 2));
}
#[test]
fn bg_color_helper_covers_selection_and_current_line() {
// Sessions 9.1 + 9.2: Selection and CurrentLine paint.