From 7dfcd79d728aebbe5090bd58b84af10cd25533a7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 28 May 2026 14:20:26 -0400 Subject: [PATCH] =?UTF-8?q?session=209.2=20=E2=80=94=20CurrentLine=20quad?= =?UTF-8?q?=20backgrounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second half of Phase A's deferred finding A8. The producer now emits DecorationKind::CurrentLine derived from the active window's cursor; pmacs-gpu paints it as a very subtle blue-grey wash under the line carrying the cursor. ## Q-stance implementation status - **Q#1 stance (α) — producer-side emission**: `scoped_decorations` reads `core.active_window_for(self.frontend_id).cursor`, derives the enclosing line via a new `current_line_range` helper, and pushes a `Decoration { kind: CurrentLine, range }` clipped to the viewport. Same per-frontend access path used for Selection (line 378). - **Q#3 stance (β) — per-line cadence**: implementation-revealed simplification. The framing doc proposed a `last_cursor_line` cache on SemanticRenderState; in practice the existing M11.4 diff (`changed_intervals`) already gives this for free. A same-line cursor move produces a byte-identical decoration Vec, so `changed_intervals` returns empty and nothing ships. A line change produces a different range and re-emission fires. No extra state needed. Recorded as a small finding under rule (iii); the stance holds, only the implementation tightens. - **Q#2 (render order)** continues to apply from 9.1 — quad backgrounds first, text second, minimap last. - **Q#4 (search backgrounds)** still deferred awaiting search. ## Producer - New `current_line_range(line_starts, source_len, cursor) -> (u64, u64)` helper at `src/semantic_render.rs`: binary-searches line_starts for the largest `start <= cursor`, returns the half-open byte range `(line_start, next_line_start_or_source_len)`. Clamps to source_len so a cursor at or past EOF resolves to the last line cleanly. - `scoped_decorations` restructured: the Selection branch and the new CurrentLine branch share the `win.buffer_id == vp.buffer_id` gate so per-window state never leaks into a viewport projecting a different buffer (the `decorations_use_vp_buffer_not_active_buffer` invariant). - Four new tests: - `current_line_range_finds_enclosing_line` — unit test covering line-zero, mid-line, start-of-line, last-line, and past-EOF. - `current_line_projects_as_a_decoration_for_cursor_on_seed` — cursor at byte 0 of "abc\\nde" emits CurrentLine for [0, 4). - `current_line_skipped_when_active_window_is_a_different_buffer` — multi-frontend invariant: projecting a non-active buffer does not emit CurrentLine. - `same_line_cursor_motion_does_not_re_emit_decorations` — Q#3 cadence: horizontal motion within a line is silent; crossing `\n` re-emits. - Existing test `diagnostics_project_with_line_col_to_byte_and_severity` updated: the seeded "abc\\nde" buffer now produces both a DiagnosticWarning and a CurrentLine. The test now finds the warning by `kind` and asserts its byte range rather than asserting a total count of 1. ## Consumer - `decoration_kind_to_bg_color` in pmacs-gpu/src/main.rs adds the CurrentLine arm: `[0.55, 0.60, 0.75, 0.08]` — a very subtle blue-grey with low alpha. CurrentLine is always on, so it wants to be visually quietest of the four background kinds; just enough tint to track cursor line, not enough to compete with Selection or syntax color. - `bg_color_helper_covers_selection_and_returns_none_for_unrendered_kinds` renamed to `bg_color_helper_covers_selection_and_current_line` and updated to assert CurrentLine now returns Some. - `fg_and_bg_helpers_are_disjoint_total_cover` updated: CurrentLine is no longer in the "deferred neither yet" set, only the search pair. ## Bet status - **Bet #2 (overlap composition between Selection and CurrentLine)**: exercised. CurrentLine has alpha 0.08, Selection 0.30. When both cover the same bytes (cursor on a selected line), they alpha-blend in draw order. Composition is left to the M11.4 dirty-merge ordering (decorations sorted by range.start): CurrentLine paints first (covers the whole line, lower start), Selection paints on top. The resulting visual is selection-blue with a slight CurrentLine tint visible at the line's non-selected ends. Honest composition rule if surfaced as wrong: refine. - **Bet #3 (cadence)**: predicted producer-side `last_cursor_line` cache; implementation revealed the M11.4 diff already throttles. Score: predicted category surfaced (true positive on the cadence concern), but the *implementation* category for the resolution did not match. Recorded as rule-(iii) small finding. ## Gates (all green) - `cargo fmt --all -- --check` - `cargo clippy --all-targets --workspace -- -D warnings` - `cargo clippy --all-targets --workspace --features crdt -- -D warnings` - pmacs lib + pmacs-protocol: **1329 + 11 = 1340** (+4 new producer tests) - pmacs-gpu unit: **13** (unchanged count; one test renamed + re-scoped) - m4_acceptance: **88**, m11_5_semantic_acceptance (--features crdt): **2** ## Manual validation walkthrough Same daemon + TUI attach + pmacs-gpu attach shape. In the GPU window: - Verify a subtle blue-grey wash appears under the cursor's line. - Move the cursor up/down — the wash tracks the new line. - Move the cursor left/right within a line — visible behavior should be identical (Q#3 cadence: no re-render needed). - Select text crossing the current line — Selection paints over CurrentLine; both alpha-blends visible at the line's non-selected edges. - Resize the window — both backgrounds reshape correctly. Co-Authored-By: Claude Opus 4.7 --- pmacs-gpu/src/main.rs | 40 ++++---- src/semantic_render.rs | 205 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 212 insertions(+), 33 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index f6c5fb6..c5b3cb0 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -1680,11 +1680,10 @@ fn decoration_kind_to_color(kind: DecorationKind) -> Option { /// kinds (the four diagnostic severities) so the two helpers form a /// total cover with no overlap. /// -/// Session 9.1 ships `Selection` only. `CurrentLine` is wired in 9.2 -/// (this helper will return its color then); `SearchMatch` / -/// `SearchMatchActive` wait on a search feature in pmacs core -/// (Q#4 in `docs/pmacs-gpu-quad-backgrounds-framing.md`), so they -/// continue to return `None` here. +/// Session 9.1 shipped `Selection`; session 9.2 adds `CurrentLine`. +/// `SearchMatch` / `SearchMatchActive` wait on a search feature in +/// pmacs core (Q#4 in `docs/pmacs-gpu-quad-backgrounds-framing.md`), +/// so they continue to return `None` here. #[allow(clippy::match_same_arms)] // each `None` arm has a distinct rationale comment. fn decoration_kind_to_bg_color(kind: DecorationKind) -> Option<[f32; 4]> { match kind { @@ -1694,8 +1693,12 @@ fn decoration_kind_to_bg_color(kind: DecorationKind) -> Option<[f32; 4]> { // because the text render pass runs after this one in the same // render pass (Q#2 stance α). DecorationKind::Selection => Some([0.31, 0.42, 0.82, 0.30]), - // 9.2 will fill this in. - DecorationKind::CurrentLine => None, + // Very subtle blue-grey wash. CurrentLine is always on, so it + // wants to be visually quietest of the four background kinds: + // just enough tint to track which line carries the cursor, + // not enough to compete with Selection or syntax color when + // both cover the same bytes (bet #2 overlap surface). + DecorationKind::CurrentLine => Some([0.55, 0.60, 0.75, 0.08]), // Deferred to the search-feature arc. DecorationKind::SearchMatch | DecorationKind::SearchMatchActive => None, // Foreground-only — handled by [`decoration_kind_to_color`]. @@ -1764,14 +1767,12 @@ mod tests { } #[test] - fn bg_color_helper_covers_selection_and_returns_none_for_unrendered_kinds() { - // Session 9.1 ships `Selection` only. + fn bg_color_helper_covers_selection_and_current_line() { + // Sessions 9.1 + 9.2: Selection and CurrentLine paint. assert!(decoration_kind_to_bg_color(DecorationKind::Selection).is_some()); + assert!(decoration_kind_to_bg_color(DecorationKind::CurrentLine).is_some()); - // CurrentLine is wired in session 9.2. - assert!(decoration_kind_to_bg_color(DecorationKind::CurrentLine).is_none()); - - // Search-feature arc. + // Search-feature arc — still deferred. assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatch).is_none()); assert!(decoration_kind_to_bg_color(DecorationKind::SearchMatchActive).is_none()); @@ -1804,16 +1805,13 @@ mod tests { ] { let fg = decoration_kind_to_color(kind).is_some(); let bg = decoration_kind_to_bg_color(kind).is_some(); - // Background helper returns None for kinds that 9.1 - // deliberately defers (CurrentLine, the search pair); for - // each of those, decoration_kind_to_color is also None. - // That is the "neither yet" state — the - // exclusive-or test exempts it. + // Background helper returns None for the search pair — + // deferred to the search-feature arc. For both of those, + // decoration_kind_to_color is also None. That is the + // "neither yet" state — the exclusive-or test exempts it. let deferred = matches!( kind, - DecorationKind::CurrentLine - | DecorationKind::SearchMatch - | DecorationKind::SearchMatchActive + DecorationKind::SearchMatch | DecorationKind::SearchMatchActive ); assert!( deferred || (fg ^ bg), diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 80130b4..26fa451 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -372,18 +372,41 @@ impl SemanticRenderState { let core = state.core.borrow(); let mut out = Vec::new(); - // Selection — per-window (per-frontend) state, already byte - // offsets. Only this session's active window for the declared - // buffer contributes. + // Selection + CurrentLine — per-window (per-frontend) state. + // Only this session's active window for the declared buffer + // contributes either kind. + // + // Q#3 (per-line CurrentLine cadence, stance β) falls out of the + // existing M11.4 diff: `render_frame` compares the new + // decoration Vec against the last sent one and emits only on + // change. Horizontal cursor motion within a single line + // produces an identical `CurrentLine` range and an identical + // overall Vec, so `changed_intervals` returns empty and nothing + // ships. No `last_cursor_line` cache is needed at this layer. if let Some(win) = core.active_window_for(self.frontend_id) && win.buffer_id == vp.buffer_id - && let Some((lo, hi)) = win.region() - && let Some(range) = clip_to_viewport(lo, hi, vp) { - out.push(Decoration { - range, - kind: DecorationKind::Selection, - }); + if let Some((lo, hi)) = win.region() + && let Some(range) = clip_to_viewport(lo, hi, vp) + { + out.push(Decoration { + range, + kind: DecorationKind::Selection, + }); + } + let registry = core.registry.clone(); + let reg = registry.borrow(); + if let Ok(buf) = reg.get(vp.buffer_id) { + let source = buffer_source_bytes(buf); + let line_starts = line_start_offsets(&source); + let (lo, hi) = current_line_range(&line_starts, source.len() as u64, win.cursor); + if let Some(range) = clip_to_viewport(lo, hi, vp) { + out.push(Decoration { + range, + kind: DecorationKind::CurrentLine, + }); + } + } } // Diagnostics — keyed in the shared store by the file URI the @@ -655,6 +678,31 @@ fn buffer_source_bytes(buf: &crate::buffer::Buffer) -> Vec { bytes } +/// Byte range `(start, end)` of the line containing `cursor`, where +/// `start` is the position right after the previous `\n` (or 0 for the +/// first line) and `end` is the position of the next `\n` (or +/// `source_len` for the last line). Used by `scoped_decorations` to +/// emit `DecorationKind::CurrentLine`; clamps so a cursor at or past +/// `source_len` returns the last line's range rather than indexing +/// out. +fn current_line_range(line_starts: &[u64], source_len: u64, cursor: u64) -> (u64, u64) { + // `partition_point` returns the count of leading elements satisfying + // the predicate, i.e. the index of the first `line_start > cursor`. + // Subtracting 1 yields the index of the largest `line_start <= + // cursor`. `line_starts` always starts with 0, so the saturating + // sub is defensive against an empty `line_starts`. + let idx = line_starts + .partition_point(|&start| start <= cursor) + .saturating_sub(1); + let lo = line_starts.get(idx).copied().unwrap_or(0); + let hi = line_starts + .get(idx + 1) + .copied() + .unwrap_or(source_len) + .min(source_len); + (lo, hi) +} + /// Byte offset of the start of each line (index 0 = byte 0; one entry /// per line, where a line is a maximal run ended by `\n`). fn line_start_offsets(source: &[u8]) -> Vec { @@ -1124,6 +1172,132 @@ mod tests { assert_eq!(decos[0].range, ByteRange { start: 3, end: 5 }); } + #[test] + fn current_line_range_finds_enclosing_line() { + // "abc\nde\nfgh": line_starts = [0, 4, 7]; source_len = 10. + let line_starts = vec![0u64, 4, 7]; + let len = 10u64; + + // Cursor at byte 0 → line 0 = [0, 4). + assert_eq!(current_line_range(&line_starts, len, 0), (0, 4)); + // Cursor anywhere within line 0 → still line 0. + assert_eq!(current_line_range(&line_starts, len, 3), (0, 4)); + // Cursor on the newline byte still belongs to the line it + // terminates. + assert_eq!(current_line_range(&line_starts, len, 3), (0, 4)); + // Cursor at line 1 start → line 1 = [4, 7). + assert_eq!(current_line_range(&line_starts, len, 4), (4, 7)); + // Cursor in last line → [7, len). + assert_eq!(current_line_range(&line_starts, len, 8), (7, 10)); + // Cursor at exactly source_len (past last byte) → still last + // line; clamps cleanly without indexing out. + assert_eq!(current_line_range(&line_starts, len, len), (7, 10)); + } + + #[test] + fn current_line_projects_as_a_decoration_for_cursor_on_seed() { + // "abc\nde": cursor at byte 0 → CurrentLine = [0, 4). + let state = empty_state(); + let buffer_id = active_buffer(&state); + seed_diagnostic(&state, buffer_id); + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + + let (_full, decos) = + decorations_of(&s.render_frame(&state)).expect("a Decorations message"); + let current = decos + .iter() + .find(|d| d.kind == DecorationKind::CurrentLine) + .expect("CurrentLine present (cursor on line 0)"); + assert_eq!( + current.range, + ByteRange { start: 0, end: 4 }, + "line 0 of \"abc\\nde\" spans bytes [0, 4)" + ); + } + + #[test] + fn current_line_skipped_when_active_window_is_a_different_buffer() { + // Producer must only emit per-window state for windows whose + // active buffer matches the projected viewport. The vp.buffer_id + // regression test (decorations_use_vp_buffer_not_active_buffer) + // exercises this for Selection; assert it for CurrentLine too. + let state = empty_state(); + let scratch_id = active_buffer(&state); + let file_id = { + let core = state.core.borrow(); + core.registry + .borrow_mut() + .create_from_bytes("secondary".to_owned(), b"abc\nde") + }; + assert_ne!(scratch_id, file_id); + + let mut s = local(); + // Project the *non-active* file buffer. + s.set_viewport(file_id, ByteRange { start: 0, end: 64 }, 0); + let (_full, decos) = + decorations_of(&s.render_frame(&state)).expect("a Decorations message"); + assert!( + decos.iter().all(|d| d.kind != DecorationKind::CurrentLine), + "CurrentLine must not project against a viewport whose buffer is not the active window's buffer; got {decos:?}" + ); + } + + #[test] + fn same_line_cursor_motion_does_not_re_emit_decorations() { + // Q#3 stance β: horizontal cursor motion within the same line + // must not re-ship a Decorations frame. The existing M11.4 + // changed_intervals diff gives this for free — same line means + // identical decoration ranges means an empty interval list + // means no emission. + let state = empty_state(); + let buffer_id = active_buffer(&state); + { + let core = state.core.borrow(); + core.registry + .borrow_mut() + .get_mut(buffer_id) + .expect("active buffer") + .apply_edit(crate::buffer::EditOp::Insert { + pos: 0, + bytes: b"abcdefghij\nklmno", + }) + .expect("seed"); + } + let mut s = local(); + s.set_viewport(buffer_id, ByteRange { start: 0, end: 64 }, 0); + let _first = s.render_frame(&state); // initial full + assert!( + s.render_frame(&state).is_empty(), + "steady state must be silent" + ); + + // Move cursor from byte 0 to byte 5 (same line). + { + let mut core = state.core.borrow_mut(); + core.active_window_mut().cursor = 5; + } + assert!( + s.render_frame(&state).is_empty(), + "same-line cursor motion must not re-emit Decorations" + ); + + // Cross a `\n` (byte 10) → line changes → re-emission. + { + let mut core = state.core.borrow_mut(); + core.active_window_mut().cursor = 12; + } + let msgs = s.render_frame(&state); + let (_full, decos) = + decorations_of(&msgs).expect("line-change must ship a Decorations frame"); + let current = decos + .iter() + .find(|d| d.kind == DecorationKind::CurrentLine) + .expect("CurrentLine present"); + // Line 1 of "abcdefghij\nklmno" starts at byte 11. + assert_eq!(current.range, ByteRange { start: 11, end: 16 }); + } + #[test] fn diagnostics_project_with_line_col_to_byte_and_severity() { // "abc\nde": line 0 at byte 0, line 1 at byte 4. @@ -1135,10 +1309,17 @@ mod tests { let (_full, decos) = decorations_of(&s.render_frame(&state)).expect("a Decorations message"); - assert_eq!(decos.len(), 1); - assert_eq!(decos[0].kind, DecorationKind::DiagnosticWarning); + // Session 9.2 added `CurrentLine` to the projection: line 0 + // (cursor at byte 0) emits as a `CurrentLine` decoration in + // addition to the seeded warning. This test pins the + // diagnostic projection's byte math; assert that decoration's + // shape rather than the total count. + let warning = decos + .iter() + .find(|d| d.kind == DecorationKind::DiagnosticWarning) + .expect("the seeded warning"); // line 1 starts at byte 4; cols [0,2) → bytes [4,6). - assert_eq!(decos[0].range, ByteRange { start: 4, end: 6 }); + assert_eq!(warning.range, ByteRange { start: 4, end: 6 }); } /// T M11.8 regression: when the diag store's entry for the URI