fix(compile): PR #113 round 5 — buffer-level span translation, fragment preservation, tracked line start
Finding-by-finding (framing revision 11; bites via scripts/bite
against 6793edc):
1. Style-span coordinate translation belongs to the BUFFER. A new
BufferStyleSpanTranslator is attached by
pmacs.buffer.add_style_overlay and sees every edit exactly once —
bypass writes, undo/redo, remote CRDT ops — independent of window
count or visibility; the window-attached BufferStyleOverlay
copies are render-only (on_edit removed). Pre-fix each attached
view translated the shared store: start_run's explicit attach
duplicated the after-switch hook's (switch_buffer fires it
synchronously), so the normal path shifted later spans TWICE per
byte-delta rewrite, splits multiplied further, and a hidden
buffer shifted ZERO times. The redundant attach is removed;
correctness no longer depends on attachment discipline. Bites:
per-cell rendered assertions active (red a, blue bc, CR, red é →
é red, b/c blue) and hidden (run finishes with the buffer in no
window; switch back renders true colors); three direct units pin
exactly-once with extra render views attached.
2. Translation preserves the untouched fragments of a partially
overlapped span: left of the replaced range keeps its styling,
right of it shifts by the length delta, only the rewritten bytes
lose theirs (the writer styles what it writes; inserted bytes
inherit nothing). Pre-fix any overlap dropped the WHOLE span —
red abc, SGR reset, CR, X left bc unstyled; zero translation
painted the default X red instead. Bite: exact (glyph, fg) cells
X=default, b/c=red — any_styled_cell cannot see either failure.
3. The per-CR/BS/erase-line whole-prefix scan is gone:
slot.line_start is tracked — advanced at every \n (append helper
+ the mid-line newline branch), read O(1) by the rewind paths,
reset on run start/resync/raw marker appends. Measured on 2 MB of
output + 3000 CR updates (release): 2.52s pre-fix → 0.67s
post-fix (remainder is fixture-bound; pre-fix cost grows with
buffer size). No correctness bite is possible for a pure perf fix
— the committed test pins the tracked value's behavior across
multi-line appends, batch-boundary CR, repeated CR, erase-line,
and recovery paths, and passes on both implementations by design.
Gates: fmt; clippy workspace all-targets; lib 1531; crdt lib 1705;
compile acceptance 60; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2517/0; git diff --check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
parent
6793edcfc7
commit
a49adc2589
|
|
@ -318,6 +318,8 @@ local function resync(slot)
|
||||||
local len = buf:len()
|
local len = buf:len()
|
||||||
buf:insert(len, DESYNC_MARKER, { bypass_intercept = true })
|
buf:insert(len, DESYNC_MARKER, { bypass_intercept = true })
|
||||||
slot.out_pos = buf:len()
|
slot.out_pos = buf:len()
|
||||||
|
-- The marker ends with \n, so the fresh epoch starts a new line.
|
||||||
|
slot.line_start = slot.out_pos
|
||||||
slot.parse_line_start = slot.out_pos
|
slot.parse_line_start = slot.out_pos
|
||||||
slot.next_row = count_newlines(buf:slice(0, slot.parse_line_start))
|
slot.next_row = count_newlines(buf:slice(0, slot.parse_line_start))
|
||||||
slot.expected_rev = buf:revision()
|
slot.expected_rev = buf:revision()
|
||||||
|
|
@ -383,6 +385,28 @@ local function codepoint_prefix_bytes(s, n)
|
||||||
return i
|
return i
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Track the current line's start as bytes land (round-5 finding 3):
|
||||||
|
-- `slot.line_start` is the byte offset where the line containing
|
||||||
|
-- `out_pos` begins. CR, backspace, and erase-line rewinds read it in
|
||||||
|
-- O(1); the old per-event scan materialized and walked the ENTIRE
|
||||||
|
-- preceding buffer (buf:slice(0, pos)) on every CR — quadratic for a
|
||||||
|
-- progress-heavy command behind megabytes of output. The value
|
||||||
|
-- advances wherever a \n lands (this helper for appended text; the
|
||||||
|
-- mid-line newline branch inline) and resets on the recovery paths
|
||||||
|
-- (run start, resync, raw marker appends). Rewinds never cross it,
|
||||||
|
-- so it is always ≤ out_pos and always a line start.
|
||||||
|
local function note_appended(slot, base, text)
|
||||||
|
local last = nil
|
||||||
|
local search = 1
|
||||||
|
while true do
|
||||||
|
local idx = text:find("\n", search, true)
|
||||||
|
if not idx then break end
|
||||||
|
last = idx
|
||||||
|
search = idx + 1
|
||||||
|
end
|
||||||
|
if last then slot.line_start = base + last end
|
||||||
|
end
|
||||||
|
|
||||||
-- Append `text` at the tracked output position with overwrite
|
-- Append `text` at the tracked output position with overwrite
|
||||||
-- semantics (CR progress bars rewrite the current line in place).
|
-- semantics (CR progress bars rewrite the current line in place).
|
||||||
--
|
--
|
||||||
|
|
@ -418,6 +442,7 @@ local function emit_text(slot, text)
|
||||||
local rest = text:sub(idx)
|
local rest = text:sub(idx)
|
||||||
buf:insert(len, rest, { bypass_intercept = true })
|
buf:insert(len, rest, { bypass_intercept = true })
|
||||||
slot.out_pos = len + #rest
|
slot.out_pos = len + #rest
|
||||||
|
note_appended(slot, len, rest)
|
||||||
add_style_span(slot, len, len + #rest)
|
add_style_span(slot, len, len + #rest)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
@ -428,6 +453,7 @@ local function emit_text(slot, text)
|
||||||
-- over it.
|
-- over it.
|
||||||
buf:insert(len, "\n", { bypass_intercept = true })
|
buf:insert(len, "\n", { bypass_intercept = true })
|
||||||
slot.out_pos = len + 1
|
slot.out_pos = len + 1
|
||||||
|
slot.line_start = len + 1
|
||||||
idx = idx + 1
|
idx = idx + 1
|
||||||
else
|
else
|
||||||
local seg = text:sub(idx, (nl or #text + 1) - 1)
|
local seg = text:sub(idx, (nl or #text + 1) - 1)
|
||||||
|
|
@ -444,27 +470,11 @@ local function emit_text(slot, text)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Byte offset where the line containing `out_pos` starts. Scanned
|
-- The current unterminated line runs from the tracked
|
||||||
-- from the buffer (the REPL's `_current_line_start` discipline) —
|
-- `slot.line_start` (per-slot, updated at every \n — NOT
|
||||||
-- NOT `parse_line_start`, which only advances once per batch: a CR
|
-- `parse_line_start`, which only advances once per batch: a CR
|
||||||
-- arriving in the same batch as earlier completed lines must rewind
|
-- arriving in the same batch as earlier completed lines must rewind
|
||||||
-- to the start of the CURRENT line, not to the batch's first line
|
-- to the start of the CURRENT line, not the batch's first line) to
|
||||||
-- (using the stale value let a progress line overwrite everything
|
|
||||||
-- emitted earlier in the batch).
|
|
||||||
local function current_line_start(slot)
|
|
||||||
local pos = math.min(slot.out_pos, slot.buf:len())
|
|
||||||
local prefix = slot.buf:slice(0, pos)
|
|
||||||
local start = 0
|
|
||||||
local search = 1
|
|
||||||
while true do
|
|
||||||
local idx = prefix:find("\n", search, true)
|
|
||||||
if not idx then return start end
|
|
||||||
start = idx
|
|
||||||
search = idx + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The current unterminated line runs from its scanned start to
|
|
||||||
-- buf:len() — no newline ever exists past out_pos (output is
|
-- buf:len() — no newline ever exists past out_pos (output is
|
||||||
-- append-only except CR/BS rewinds within the current line).
|
-- append-only except CR/BS rewinds within the current line).
|
||||||
local function apply_events(slot, events)
|
local function apply_events(slot, events)
|
||||||
|
|
@ -476,12 +486,12 @@ local function apply_events(slot, events)
|
||||||
elseif kind == "set_style" then
|
elseif kind == "set_style" then
|
||||||
slot.cur_style = ev.style
|
slot.cur_style = ev.style
|
||||||
elseif kind == "carriage_return" then
|
elseif kind == "carriage_return" then
|
||||||
slot.out_pos = current_line_start(slot)
|
slot.out_pos = slot.line_start
|
||||||
elseif kind == "backspace" then
|
elseif kind == "backspace" then
|
||||||
-- Step back over one whole CODEPOINT, not one byte — a
|
-- Step back over one whole CODEPOINT, not one byte — a
|
||||||
-- mid-codepoint out_pos would make the next overwrite split
|
-- mid-codepoint out_pos would make the next overwrite split
|
||||||
-- the character (round-3 finding 1).
|
-- the character (round-3 finding 1).
|
||||||
local ls = current_line_start(slot)
|
local ls = slot.line_start
|
||||||
if slot.out_pos > ls then
|
if slot.out_pos > ls then
|
||||||
local prefix = slot.buf:slice(ls, slot.out_pos)
|
local prefix = slot.buf:slice(ls, slot.out_pos)
|
||||||
local i = #prefix
|
local i = #prefix
|
||||||
|
|
@ -496,7 +506,7 @@ local function apply_events(slot, events)
|
||||||
buf:delete(slot.out_pos, len, { bypass_intercept = true })
|
buf:delete(slot.out_pos, len, { bypass_intercept = true })
|
||||||
end
|
end
|
||||||
elseif kind == "erase_line" then
|
elseif kind == "erase_line" then
|
||||||
local ls = current_line_start(slot)
|
local ls = slot.line_start
|
||||||
local len = buf:len()
|
local len = buf:len()
|
||||||
if ls < len then
|
if ls < len then
|
||||||
buf:delete(ls, len, { bypass_intercept = true })
|
buf:delete(ls, len, { bypass_intercept = true })
|
||||||
|
|
@ -628,8 +638,10 @@ end
|
||||||
-- pump cleanup/forget ran (round-1 finding 5).
|
-- pump cleanup/forget ran (round-1 finding 5).
|
||||||
local function emit_text_raw(slot, text)
|
local function emit_text_raw(slot, text)
|
||||||
local buf = slot.buf
|
local buf = slot.buf
|
||||||
buf:insert(buf:len(), text, { bypass_intercept = true })
|
local base = buf:len()
|
||||||
|
buf:insert(base, text, { bypass_intercept = true })
|
||||||
slot.out_pos = buf:len()
|
slot.out_pos = buf:len()
|
||||||
|
note_appended(slot, base, text)
|
||||||
if slot.parse_line_start > slot.out_pos then
|
if slot.parse_line_start > slot.out_pos then
|
||||||
slot.parse_line_start = slot.out_pos
|
slot.parse_line_start = slot.out_pos
|
||||||
end
|
end
|
||||||
|
|
@ -764,6 +776,8 @@ local function start_run(slot, cmdline, opts)
|
||||||
local header = string.format("$ %s\nDirectory: %s\n\n", cmdline, cwd or "(unknown)")
|
local header = string.format("$ %s\nDirectory: %s\n\n", cmdline, cwd or "(unknown)")
|
||||||
buf:insert(0, header, { bypass_intercept = true })
|
buf:insert(0, header, { bypass_intercept = true })
|
||||||
slot.out_pos = buf:len()
|
slot.out_pos = buf:len()
|
||||||
|
-- The header ends with \n, so output starts on a fresh line.
|
||||||
|
slot.line_start = slot.out_pos
|
||||||
slot.parse_line_start = slot.out_pos
|
slot.parse_line_start = slot.out_pos
|
||||||
slot.next_row = count_newlines(header)
|
slot.next_row = count_newlines(header)
|
||||||
slot.expected_rev = buf:revision()
|
slot.expected_rev = buf:revision()
|
||||||
|
|
@ -786,8 +800,12 @@ local function start_run(slot, cmdline, opts)
|
||||||
}
|
}
|
||||||
if cwd then spec.cwd = cwd end
|
if cwd then spec.cwd = cwd end
|
||||||
local ok, proc = pcall(pmacs.process.spawn, spec)
|
local ok, proc = pcall(pmacs.process.spawn, spec)
|
||||||
|
-- switch_buffer synchronously fires buffer.after-switch, whose
|
||||||
|
-- subscription above attaches the overlay — a second explicit
|
||||||
|
-- attach here stacked a duplicate render view per run (round-5
|
||||||
|
-- finding 1; translation itself is buffer-level and unaffected by
|
||||||
|
-- attachment count).
|
||||||
pmacs.window.switch_buffer(slot.buf)
|
pmacs.window.switch_buffer(slot.buf)
|
||||||
pcall(pmacs.buffer.attach_style_overlay, slot.buf, slot.overlay)
|
|
||||||
if not ok then
|
if not ok then
|
||||||
emit_text_raw(slot, string.format("[%s spawn failed: %s]\n", slot.label, tostring(proc)))
|
emit_text_raw(slot, string.format("[%s spawn failed: %s]\n", slot.label, tostring(proc)))
|
||||||
slot.expected_rev = buf:revision()
|
slot.expected_rev = buf:revision()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,31 @@
|
||||||
# Compile-mode — framing (Arc 5 stage 1, terminal)
|
# Compile-mode — framing (Arc 5 stage 1, terminal)
|
||||||
|
|
||||||
**Revision 10 — 2026-07-13. Status: implemented on branch
|
**Revision 11 — 2026-07-13. Status: implemented on branch
|
||||||
`compile-mode` (PR #113); revisions 7–10 fold in PR rounds 1–4.**
|
`compile-mode` (PR #113); revisions 7–11 fold in PR rounds 1–5.**
|
||||||
|
|
||||||
|
Revision 11 (PR #113 round 5, findings 1–3): style-span coordinate
|
||||||
|
translation belongs to the BUFFER, not to views — a new
|
||||||
|
`BufferStyleSpanTranslator` is attached to the buffer by
|
||||||
|
`pmacs.buffer.add_style_overlay`, sees every edit exactly once
|
||||||
|
(bypass writes, undo/redo, remote CRDT ops) regardless of window
|
||||||
|
count or visibility, and the window-attached `BufferStyleOverlay`
|
||||||
|
copies are render-only. Pre-fix each attached view translated the
|
||||||
|
shared store from its own `on_edit`: the duplicate attachment in
|
||||||
|
`start_run` (switch_buffer's after-switch hook already attaches)
|
||||||
|
made byte-delta rewrites shift later spans TWICE on the normal path,
|
||||||
|
splits multiplied further, and a hidden buffer shifted ZERO times.
|
||||||
|
Translation also preserves the untouched fragments of a partially
|
||||||
|
overlapped span (finding 2): left of the replaced range keeps its
|
||||||
|
styling, right of it shifts by the length delta, and only the bytes
|
||||||
|
actually rewritten lose theirs — `red abc, reset, CR, X` renders a
|
||||||
|
default X followed by red `bc`, where the old translation dropped
|
||||||
|
any overlapping span whole. Per-cell rendered assertions
|
||||||
|
(glyph, fg) pin both — `any_styled_cell` cannot see a wrong color on
|
||||||
|
the right cell. And the per-event whole-prefix line-start scan is
|
||||||
|
gone (finding 3): `slot.line_start` is tracked — advanced at every
|
||||||
|
\n, reset on recovery paths — making CR/BS/erase-line O(1); measured
|
||||||
|
2.52s → 0.67s on 2 MB + 3000 CRs (the pin is behavioral, not timed —
|
||||||
|
timing bounds flake on slow CI).
|
||||||
|
|
||||||
Revision 10 (PR #113 round 4, findings 1–2): CR rewrites are
|
Revision 10 (PR #113 round 4, findings 1–2): CR rewrites are
|
||||||
COLUMN-counted and newline-segmented, not byte-counted — each
|
COLUMN-counted and newline-segmented, not byte-counted — each
|
||||||
|
|
@ -211,10 +235,13 @@ Everything below was verified by reading the code, not the roadmap.
|
||||||
teardown point calling `pmacs.process.forget` only after the
|
teardown point calling `pmacs.process.forget` only after the
|
||||||
terminal event (`:782-822`, the M6.9 no-leak discipline).
|
terminal event (`:782-822`, the M6.9 no-leak discipline).
|
||||||
- **Style overlay window semantics** (`src/lua_bindings/mod.rs:
|
- **Style overlay window semantics** (`src/lua_bindings/mod.rs:
|
||||||
2907-2933`, `:1756-1799`): `add_style_overlay` attaches only to
|
2907-2933`, `:1756-1799`): `add_style_overlay` attaches a
|
||||||
windows *currently showing* the buffer; buffer switches clear
|
buffer-level `BufferStyleSpanTranslator` (coordinate translation,
|
||||||
window overlays; `attach_style_overlay(buf, handle)` re-attaches.
|
exactly once per edit — Revision 11) plus render-only window
|
||||||
The handle has `add`, `clear`, `clear_before`, `spans`.
|
overlays on windows *currently showing* the buffer; buffer
|
||||||
|
switches clear window overlays; `attach_style_overlay(buf,
|
||||||
|
handle)` re-attaches the render view. The handle has `add`,
|
||||||
|
`clear`, `clear_before`, `spans`.
|
||||||
- **Buffer-switch hooks**: `buffer.after-switch` exists and fires on
|
- **Buffer-switch hooks**: `buffer.after-switch` exists and fires on
|
||||||
the ordinary switch paths (recentf subscribes,
|
the ordinary switch paths (recentf subscribes,
|
||||||
`builtin/runtime/recentf.lua:54`). **`pmacs.editor.jump_back` does
|
`builtin/runtime/recentf.lua:54`). **`pmacs.editor.jump_back` does
|
||||||
|
|
@ -604,14 +631,17 @@ invoke time, so commands/runtime load order stays irrelevant for it.
|
||||||
raises.
|
raises.
|
||||||
- **Style overlay discipline:** one overlay handle per generated
|
- **Style overlay discipline:** one overlay handle per generated
|
||||||
buffer, created once and retained; `overlay:clear()` on each run
|
buffer, created once and retained; `overlay:clear()` on each run
|
||||||
reset; `pmacs.buffer.attach_style_overlay(buf, handle)` after
|
reset; render re-attach rides **one** `buffer.after-switch`
|
||||||
every switch the module performs into the buffer, **and (Revision
|
subscription that fires whenever any switch path lands on one of
|
||||||
3) from a `buffer.after-switch` subscription that re-attaches
|
its buffers (Revision 3) — `start_run`'s own switch included, so
|
||||||
whenever any switch path lands on one of its buffers** — combined
|
the former explicit attach after it stacked a duplicate render
|
||||||
with the jump-back parity fix (additions #3), this covers `C-x b`
|
view per run and is removed (Revision 11) — combined with the
|
||||||
returns and the primary RET → `M-,` workflow. The former
|
jump-back parity fix (additions #3), this covers `C-x b` returns
|
||||||
"user-initiated switch loses styling" deferral is withdrawn as
|
and the primary RET → `M-,` workflow. The former "user-initiated
|
||||||
covered.
|
switch loses styling" deferral is withdrawn as covered.
|
||||||
|
Coordinate translation never depends on any of this: it lives on
|
||||||
|
the buffer (Revision 11), exactly once per edit, hidden or split
|
||||||
|
or not attached at all.
|
||||||
- No marks needed: one append point, tracked as plain integers, with
|
- No marks needed: one append point, tracked as plain integers, with
|
||||||
the revision guard above as the honesty check.
|
the revision guard above as the honesty check.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2916,6 +2916,7 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
|
let reg = registry.clone();
|
||||||
buffer.set(
|
buffer.set(
|
||||||
"add_style_overlay",
|
"add_style_overlay",
|
||||||
lua.create_function(
|
lua.create_function(
|
||||||
|
|
@ -2924,6 +2925,21 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
||||||
let handle = StyleOverlayHandleLua {
|
let handle = StyleOverlayHandleLua {
|
||||||
spans: Arc::clone(&spans),
|
spans: Arc::clone(&spans),
|
||||||
};
|
};
|
||||||
|
// Coordinate translation lives on the BUFFER
|
||||||
|
// (PR #113 round-5 finding 1): buffer-attached
|
||||||
|
// views see every edit exactly once — Lua bypass
|
||||||
|
// writes, undo/redo, remote CRDT ops — whether or
|
||||||
|
// not any window shows the buffer. The window
|
||||||
|
// attachments below are render-only; per-window
|
||||||
|
// translation ran once per split and zero times
|
||||||
|
// hidden.
|
||||||
|
{
|
||||||
|
let mut r = reg.borrow_mut();
|
||||||
|
let buf = resolve_mut(&mut r, id.0)?;
|
||||||
|
buf.attach_view(Box::new(crate::overlay::BufferStyleSpanTranslator::new(
|
||||||
|
Arc::clone(&spans),
|
||||||
|
)));
|
||||||
|
}
|
||||||
attach_style_overlay_to_visible_windows(lua, id.0, &spans);
|
attach_style_overlay_to_visible_windows(lua, id.0, &spans);
|
||||||
Ok(handle)
|
Ok(handle)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
209
src/overlay.rs
209
src/overlay.rs
|
|
@ -186,6 +186,14 @@ pub type SharedBufferStyleSpans = Arc<Mutex<Vec<BufferStyleSpan>>>;
|
||||||
/// than viewport cell ranges. That is the right shape for stream
|
/// than viewport cell ranges. That is the right shape for stream
|
||||||
/// consumers such as the REPL: ANSI SGR applies to bytes as they land
|
/// consumers such as the REPL: ANSI SGR applies to bytes as they land
|
||||||
/// in the rope, and render maps the surviving ranges into visible cells.
|
/// in the rope, and render maps the surviving ranges into visible cells.
|
||||||
|
///
|
||||||
|
/// RENDER-ONLY (PR #113 round-5 finding 1): this view deliberately
|
||||||
|
/// does not implement `on_edit`. Every window showing the buffer
|
||||||
|
/// holds its own copy over the SAME shared store, so a per-view
|
||||||
|
/// translation runs once per attached window — twice under a split,
|
||||||
|
/// zero times while the buffer is hidden. Coordinate translation
|
||||||
|
/// belongs to [`BufferStyleSpanTranslator`], attached to the buffer
|
||||||
|
/// itself.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct BufferStyleOverlay {
|
pub struct BufferStyleOverlay {
|
||||||
spans: SharedBufferStyleSpans,
|
spans: SharedBufferStyleSpans,
|
||||||
|
|
@ -199,7 +207,35 @@ impl BufferStyleOverlay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl View for BufferStyleOverlay {
|
/// Buffer-attached edit translator for a shared span store.
|
||||||
|
///
|
||||||
|
/// Keeps the byte coordinates in a [`SharedBufferStyleSpans`] store
|
||||||
|
/// in sync with buffer edits, EXACTLY ONCE per edit, independent of
|
||||||
|
/// how many windows currently render the buffer (PR #113 round-5
|
||||||
|
/// finding 1). Buffer-attached views receive `on_edit` on every
|
||||||
|
/// mutation path — intercept-skipping Lua writes, undo/redo, and
|
||||||
|
/// remote CRDT ops — whether or not the buffer is displayed
|
||||||
|
/// anywhere; window-attached [`BufferStyleOverlay`] copies are
|
||||||
|
/// render-only.
|
||||||
|
///
|
||||||
|
/// Translation preserves the untouched fragments of a span that
|
||||||
|
/// partially overlaps the edit (round-5 finding 2): bytes before the
|
||||||
|
/// replaced range keep their styling, bytes at/after it keep theirs
|
||||||
|
/// shifted by the edit's length delta, and only the bytes actually
|
||||||
|
/// replaced lose styling — the writer styles what it writes.
|
||||||
|
pub struct BufferStyleSpanTranslator {
|
||||||
|
spans: SharedBufferStyleSpans,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BufferStyleSpanTranslator {
|
||||||
|
/// Construct a translator over `spans`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(spans: SharedBufferStyleSpans) -> Self {
|
||||||
|
Self { spans }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl View for BufferStyleSpanTranslator {
|
||||||
fn on_edit(&mut self, _buf: &Buffer, edit: &Edit) -> Result<(), crate::buffer::BufferError> {
|
fn on_edit(&mut self, _buf: &Buffer, edit: &Edit) -> Result<(), crate::buffer::BufferError> {
|
||||||
let old_start = edit.range.start;
|
let old_start = edit.range.start;
|
||||||
let old_end = edit.range.end;
|
let old_end = edit.range.end;
|
||||||
|
|
@ -207,22 +243,39 @@ impl View for BufferStyleOverlay {
|
||||||
let new_len = edit.inserted_len;
|
let new_len = edit.inserted_len;
|
||||||
let mut spans = self.spans.lock().expect("style spans mutex poisoned");
|
let mut spans = self.spans.lock().expect("style spans mutex poisoned");
|
||||||
let mut adjusted = Vec::with_capacity(spans.len());
|
let mut adjusted = Vec::with_capacity(spans.len());
|
||||||
for mut span in spans.drain(..) {
|
for span in spans.drain(..) {
|
||||||
if span.end <= old_start {
|
// Left fragment: bytes strictly before the replaced
|
||||||
adjusted.push(span);
|
// range are untouched by the edit.
|
||||||
} else if span.start >= old_end {
|
if span.start < old_start {
|
||||||
span.start = shift_pos(span.start, old_end, old_len, new_len);
|
adjusted.push(BufferStyleSpan {
|
||||||
span.end = shift_pos(span.end, old_end, old_len, new_len);
|
start: span.start,
|
||||||
adjusted.push(span);
|
end: span.end.min(old_start),
|
||||||
|
style: span.style,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Overlapping spans are dropped. REPL style spans are append-only
|
// Right fragment: bytes at/after the replaced range
|
||||||
// and scrollback truncation deletes whole old blocks, so a
|
// survive, shifted by the length delta. (`pos >= old_end
|
||||||
// conservative drop is simpler and avoids half-styled fragments.
|
// >= old_len`, so the subtraction cannot underflow.)
|
||||||
|
if span.end > old_end {
|
||||||
|
adjusted.push(BufferStyleSpan {
|
||||||
|
start: span.start.max(old_end) - old_len + new_len,
|
||||||
|
end: span.end - old_len + new_len,
|
||||||
|
style: span.style,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A span entirely inside the replaced range produces
|
||||||
|
// neither fragment and is dropped.
|
||||||
}
|
}
|
||||||
*spans = adjusted;
|
*spans = adjusted;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> &'static str {
|
||||||
|
"buffer_style_span_translator"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl View for BufferStyleOverlay {
|
||||||
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
fn render(&mut self, buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||||
let spans = self
|
let spans = self
|
||||||
.spans
|
.spans
|
||||||
|
|
@ -243,15 +296,6 @@ impl View for BufferStyleOverlay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shift_pos(pos: u64, old_end: u64, old_len: u64, new_len: u64) -> u64 {
|
|
||||||
if new_len >= old_len {
|
|
||||||
pos + (new_len - old_len)
|
|
||||||
} else {
|
|
||||||
pos.saturating_sub(old_end)
|
|
||||||
.saturating_add(old_end - (old_len - new_len))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_line_offsets(buf: &Buffer) -> Vec<u64> {
|
fn compute_line_offsets(buf: &Buffer) -> Vec<u64> {
|
||||||
let mut offsets = vec![0];
|
let mut offsets = vec![0];
|
||||||
let rope = buf.snapshot_rope();
|
let rope = buf.snapshot_rope();
|
||||||
|
|
@ -681,4 +725,129 @@ mod tests {
|
||||||
});
|
});
|
||||||
virt.render(&buf, viewport(1, 5), &mut grid);
|
virt.render(&buf, viewport(1, 5), &mut grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn red() -> Style {
|
||||||
|
Style {
|
||||||
|
fg: crate::cell::Color::Indexed(1),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spans_of(store: &SharedBufferStyleSpans) -> Vec<(u64, u64)> {
|
||||||
|
store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|s| (s.start, s.end))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translator_shifts_spans_exactly_once_regardless_of_render_views() {
|
||||||
|
// PR #113 round-5 finding 1: N windows over the same store
|
||||||
|
// must not translate N times, and zero windows must not mean
|
||||||
|
// zero translations. The render-only overlays contribute
|
||||||
|
// nothing to on_edit; the single buffer-attached translator
|
||||||
|
// does it all.
|
||||||
|
use crate::buffer::EditOp;
|
||||||
|
use crate::rope::Range;
|
||||||
|
let mut buf = Buffer::from_bytes(BufferId::next(), "t", b"abc");
|
||||||
|
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
|
||||||
|
start: 1,
|
||||||
|
end: 3,
|
||||||
|
style: red(),
|
||||||
|
}]));
|
||||||
|
buf.attach_view(Box::new(BufferStyleSpanTranslator::new(Arc::clone(&store))));
|
||||||
|
// Two render copies attached to the same buffer — the
|
||||||
|
// split-window shape. Their on_edit is the default no-op.
|
||||||
|
buf.attach_view(Box::new(BufferStyleOverlay::new(Arc::clone(&store))));
|
||||||
|
buf.attach_view(Box::new(BufferStyleOverlay::new(Arc::clone(&store))));
|
||||||
|
// Replace byte 0 with two bytes: delta +1, span after the
|
||||||
|
// edit shifts by exactly one.
|
||||||
|
buf.apply_edit(EditOp::Replace {
|
||||||
|
range: Range::new(0, 1),
|
||||||
|
bytes: b"XY",
|
||||||
|
})
|
||||||
|
.expect("edit applies");
|
||||||
|
assert_eq!(
|
||||||
|
spans_of(&store),
|
||||||
|
vec![(2, 4)],
|
||||||
|
"one translator, one shift — attachment count is irrelevant"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translator_preserves_untouched_span_fragments() {
|
||||||
|
// PR #113 round-5 finding 2: a partial overwrite must keep
|
||||||
|
// styling on the bytes it never wrote. Replacing byte 0 of a
|
||||||
|
// red [0,3) span (same length) leaves [1,3) red; the written
|
||||||
|
// byte's styling is the writer's business.
|
||||||
|
use crate::buffer::EditOp;
|
||||||
|
use crate::rope::Range;
|
||||||
|
let mut buf = Buffer::from_bytes(BufferId::next(), "t", b"abc");
|
||||||
|
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
|
||||||
|
start: 0,
|
||||||
|
end: 3,
|
||||||
|
style: red(),
|
||||||
|
}]));
|
||||||
|
buf.attach_view(Box::new(BufferStyleSpanTranslator::new(Arc::clone(&store))));
|
||||||
|
buf.apply_edit(EditOp::Replace {
|
||||||
|
range: Range::new(0, 1),
|
||||||
|
bytes: b"X",
|
||||||
|
})
|
||||||
|
.expect("edit applies");
|
||||||
|
assert_eq!(
|
||||||
|
spans_of(&store),
|
||||||
|
vec![(1, 3)],
|
||||||
|
"the untouched right fragment survives a same-length rewrite"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translator_splits_a_span_around_an_interior_edit() {
|
||||||
|
// Both fragments survive an interior replacement; the
|
||||||
|
// replaced middle loses styling. Also pins the insertion
|
||||||
|
// case: bytes inserted INSIDE a span do not inherit style.
|
||||||
|
use crate::buffer::EditOp;
|
||||||
|
use crate::rope::Range;
|
||||||
|
let mut buf = Buffer::from_bytes(BufferId::next(), "t", b"abcdef");
|
||||||
|
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
|
||||||
|
start: 0,
|
||||||
|
end: 6,
|
||||||
|
style: red(),
|
||||||
|
}]));
|
||||||
|
buf.attach_view(Box::new(BufferStyleSpanTranslator::new(Arc::clone(&store))));
|
||||||
|
// Replace "cd" with "Z": left [0,2) intact, right [4,6)
|
||||||
|
// shifts to [3,5).
|
||||||
|
buf.apply_edit(EditOp::Replace {
|
||||||
|
range: Range::new(2, 4),
|
||||||
|
bytes: b"Z",
|
||||||
|
})
|
||||||
|
.expect("edit applies");
|
||||||
|
assert_eq!(
|
||||||
|
spans_of(&store),
|
||||||
|
vec![(0, 2), (3, 5)],
|
||||||
|
"left kept, right shifted by the length delta"
|
||||||
|
);
|
||||||
|
// A span wholly inside a replaced range is dropped.
|
||||||
|
{
|
||||||
|
let mut spans = store.lock().unwrap();
|
||||||
|
spans.clear();
|
||||||
|
spans.push(BufferStyleSpan {
|
||||||
|
start: 1,
|
||||||
|
end: 2,
|
||||||
|
style: red(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
buf.apply_edit(EditOp::Replace {
|
||||||
|
range: Range::new(0, 4),
|
||||||
|
bytes: b"....",
|
||||||
|
})
|
||||||
|
.expect("edit applies");
|
||||||
|
assert_eq!(
|
||||||
|
spans_of(&store),
|
||||||
|
Vec::<(u64, u64)>::new(),
|
||||||
|
"a fully-overwritten span produces no fragments"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -269,6 +269,35 @@ fn any_styled_cell(cells: &[pmacs::cell::Cell]) -> bool {
|
||||||
.any(|c| c.style != pmacs::cell::Style::default())
|
.any(|c| c.style != pmacs::cell::Style::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `(glyph, fg)` for the first `n` cells of the grid row whose glyphs
|
||||||
|
/// spell `prefix`; panics if no row matches. Round-5 tests pin exact
|
||||||
|
/// per-cell colors — `any_styled_cell` cannot see a wrong color on
|
||||||
|
/// the right glyph.
|
||||||
|
fn styled_row(
|
||||||
|
cells: &[pmacs::cell::Cell],
|
||||||
|
rows: u32,
|
||||||
|
cols: u32,
|
||||||
|
prefix: &str,
|
||||||
|
n: usize,
|
||||||
|
) -> Vec<(char, pmacs::cell::Color)> {
|
||||||
|
let glyph_at = |r: u32, c: u32| match cells[(r * cols + c) as usize].glyph {
|
||||||
|
pmacs::cell::Glyph::Char(ch) => ch,
|
||||||
|
_ => ' ',
|
||||||
|
};
|
||||||
|
for r in 0..rows {
|
||||||
|
let line: String = (0..cols).map(|c| glyph_at(r, c)).collect();
|
||||||
|
if line.starts_with(prefix) {
|
||||||
|
return (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let cell = &cells[(r * cols) as usize + i];
|
||||||
|
(glyph_at(r, i as u32), cell.style.fg)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("no rendered row starts with {prefix:?}");
|
||||||
|
}
|
||||||
|
|
||||||
const DESYNC: &str = "[output desynced by external edit]";
|
const DESYNC: &str = "[output desynced by external edit]";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -2212,3 +2241,154 @@ fn r4f2_alt_screen_style_desync_resynced_on_exit_and_finish() {
|
||||||
though the internal style is already default"
|
though the internal style is already default"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PR #113 round 5 — bite tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn r5f1_span_translation_is_exactly_once_for_the_active_buffer() {
|
||||||
|
use pmacs::cell::Color;
|
||||||
|
// Red 'a', blue 'bc', then CR and a red 2-byte é overwriting the
|
||||||
|
// 'a' (length delta +1): the blue span must shift exactly ONCE
|
||||||
|
// (round-5 finding 1). Pre-fix every attached view translated
|
||||||
|
// the shared store — the duplicate attachment made it twice on
|
||||||
|
// the normal path, splits multiplied it further — leaving 'b'
|
||||||
|
// unstyled and the span end past the buffer.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let script = write_script(
|
||||||
|
dir.path(),
|
||||||
|
"spans.sh",
|
||||||
|
"printf '\\033[31ma\\033[34mbc\\r\\033[31m\\303\\251\\n'\n",
|
||||||
|
);
|
||||||
|
let mut s = editor();
|
||||||
|
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
|
||||||
|
let cells = render_active_window_to_grid(&mut s, 12, 60);
|
||||||
|
let row = styled_row(&cells, 12, 60, "\u{e9}bc", 3);
|
||||||
|
assert_eq!(
|
||||||
|
row,
|
||||||
|
vec![
|
||||||
|
('\u{e9}', Color::Indexed(1)),
|
||||||
|
('b', Color::Indexed(4)),
|
||||||
|
('c', Color::Indexed(4)),
|
||||||
|
],
|
||||||
|
"the blue span shifts exactly once past the 1-to-2-byte rewrite"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn r5f1_hidden_buffer_spans_still_translate() {
|
||||||
|
use pmacs::cell::Color;
|
||||||
|
// The compile buffer sits in NO window while the rewrite
|
||||||
|
// arrives: pre-fix, no view received on_edit and the spans were
|
||||||
|
// never adjusted at all. Translation is buffer-level now,
|
||||||
|
// independent of visibility.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let script = write_script(
|
||||||
|
dir.path(),
|
||||||
|
"hidden.sh",
|
||||||
|
concat!(
|
||||||
|
"printf '\\033[31ma\\033[34mbc'\n",
|
||||||
|
"sleep 0.5\n",
|
||||||
|
"printf '\\r\\033[31m\\303\\251\\n'\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let mut s = editor();
|
||||||
|
compile_run(&s, &format!("sh {script}"), dir.path());
|
||||||
|
assert!(
|
||||||
|
pump_until(&mut s, 5_000, |s| compilation_text(s).contains("abc")),
|
||||||
|
"styled prefix lands first"
|
||||||
|
);
|
||||||
|
exec(
|
||||||
|
&s,
|
||||||
|
r#"pmacs.window.switch_buffer(pmacs.buffer.create("*elsewhere*"))"#,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pump_until(&mut s, 10_000, |s| named_text(s, "*compilation*")
|
||||||
|
.contains("[compile ")),
|
||||||
|
"run finishes while hidden"
|
||||||
|
);
|
||||||
|
// Switch back; the after-switch hook re-attaches the render view.
|
||||||
|
exec(
|
||||||
|
&s,
|
||||||
|
r#"
|
||||||
|
for _, id in ipairs(pmacs.buffer.list()) do
|
||||||
|
if pmacs.describe.buffer(id).name == "*compilation*" then
|
||||||
|
pmacs.window.switch_buffer(id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let cells = render_active_window_to_grid(&mut s, 12, 60);
|
||||||
|
let row = styled_row(&cells, 12, 60, "\u{e9}bc", 3);
|
||||||
|
assert_eq!(
|
||||||
|
row,
|
||||||
|
vec![
|
||||||
|
('\u{e9}', Color::Indexed(1)),
|
||||||
|
('b', Color::Indexed(4)),
|
||||||
|
('c', Color::Indexed(4)),
|
||||||
|
],
|
||||||
|
"spans shifted while hidden; the re-shown buffer renders true colors"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn r5f2_partial_rewrite_preserves_untouched_styling() {
|
||||||
|
use pmacs::cell::Color;
|
||||||
|
// SGR red; abc; SGR reset; CR; X — the default-styled X
|
||||||
|
// overwrites only 'a'; 'bc' keeps its red (round-5 finding 2).
|
||||||
|
// Pre-fix any overlap dropped the WHOLE span (bc lost its
|
||||||
|
// color); with no translation at all the stale span painted the
|
||||||
|
// default X red instead. Exact per-cell colors on both sides.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let script = write_script(
|
||||||
|
dir.path(),
|
||||||
|
"frag.sh",
|
||||||
|
"printf '\\033[31mabc\\033[0m\\rX\\n'\n",
|
||||||
|
);
|
||||||
|
let mut s = editor();
|
||||||
|
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
|
||||||
|
let cells = render_active_window_to_grid(&mut s, 12, 60);
|
||||||
|
let row = styled_row(&cells, 12, 60, "Xbc", 3);
|
||||||
|
assert_eq!(
|
||||||
|
row,
|
||||||
|
vec![
|
||||||
|
('X', Color::Default),
|
||||||
|
('b', Color::Indexed(1)),
|
||||||
|
('c', Color::Indexed(1)),
|
||||||
|
],
|
||||||
|
"the rewritten cell is default-styled; the untouched suffix keeps red"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn r5f3_tracked_line_start_matches_the_scan_across_transitions() {
|
||||||
|
// Round-5 finding 3 is a performance fix — the per-CR/BS/erase
|
||||||
|
// whole-prefix scan became a tracked byte. These are the
|
||||||
|
// correctness pins for that tracked value across every
|
||||||
|
// transition: a multi-line append, CR after a batch boundary,
|
||||||
|
// repeated CR on one line, erase-line, and a fresh line after
|
||||||
|
// each. Behavior must be identical to the old scan (this test
|
||||||
|
// passes on both by design — the perf win is measured, not
|
||||||
|
// asserted; a timing bound here would flake on slow CI).
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let script = write_script(
|
||||||
|
dir.path(),
|
||||||
|
"track.sh",
|
||||||
|
concat!(
|
||||||
|
"printf 'l1\\nl2 partial'\n",
|
||||||
|
"sleep 0.3\n",
|
||||||
|
"printf '\\rL2 done!!!\\n'\n",
|
||||||
|
"printf 'p 1\\rp 2\\rp 22\\n'\n",
|
||||||
|
"printf 'erase me\\033[2K'\n",
|
||||||
|
"printf 'clean\\n'\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let mut s = editor();
|
||||||
|
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
|
||||||
|
let text = compilation_text(&s);
|
||||||
|
assert!(
|
||||||
|
text.contains("\nl1\nL2 done!!!\np 22\nclean\n"),
|
||||||
|
"every rewind lands at the tracked line start; buffer:\n{text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue