session 9.2 — CurrentLine quad backgrounds

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 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-05-28 14:20:26 -04:00
parent da6faeff31
commit 7dfcd79d72
2 changed files with 212 additions and 33 deletions

View File

@ -1680,11 +1680,10 @@ fn decoration_kind_to_color(kind: DecorationKind) -> Option<glyphon::Color> {
/// 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),

View File

@ -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<u8> {
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<u64> {
@ -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