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:
parent
da6faeff31
commit
7dfcd79d72
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue