fix(compile): PR #113 round 6 — idempotent split-complete attachment, no-op edit guard, handle disposal
Finding-by-finding (framing revision 12; bites via scripts/bite
against a49adc2):
1. Render-view attachment is idempotent and split-complete. Overlays
expose overlay_identity (the span store's allocation address);
Window::ensure_overlay attaches a store-backed render view AT
MOST once per window — pre-fix every switch into the buffer
blindly pushed another copy onto EVERY matching window, so
passive panes accumulated duplicates, each cloning all spans and
rescanning the buffer per frame. A same-buffer split copies
clonable overlays to the new pane via clone_for_split (splits
fire no switch hook and started with an empty overlay list — the
new compilation pane rendered unstyled). Bites: the acceptance
test asserts both panes styled with exactly one attachment
IMMEDIATELY post-split (before any switch could heal the pane
through the attach-to-all path — the first draft asserted only
after bouncing and was vacuous against the split fix), then
re-asserts after three bounce cycles; fails against pre-fix
editor_core.rs (split half) and pre-fix mod.rs (accumulation
half) independently. Units pin ensure-once and split-copy/no-copy.
2. The translator ignores pure no-op edits (buffers deliberately
broadcast empty inserts/deletes for callers that count calls):
pre-fix each interior no-op split the containing span into two
adjacent fragments — unbounded list growth for repeated no-ops at
distinct positions, and a no-op at a UTF-8 continuation byte
minted a mid-codepoint span boundary. Units now cover genuine
EditOp::Insert (the round-5 "insertion" unit only replaced) and
no-ops at five interior positions including the continuation
byte; the Lua twin (r6f2) bites via the overlay.rs swap — as a
compile failure, since that file also carries the round-6
identity machinery (weaker evidence, per the bite script's
caveat; the in-crate unit pins the behavior directly).
3. StyleOverlayHandleLua retains the buffer and translator ViewId
and exposes idempotent dispose(): detaches the buffer-attached
translator (later edits stop paying for it) and removes every
window render view over the store. Documented lifetime contract:
one handle per buffer incarnation (the compile/REPL discipline)
needs no disposal — the buffer's death frees it; repeated
creation on a long-lived buffer must dispose retired handles.
Bite: r6f3 (translate → dispose → edit must NOT move the span,
render views gone, double-dispose safe) fails against pre-fix
mod.rs.
Gates: fmt; clippy workspace all-targets; lib 1534; crdt lib 1708;
compile acceptance 63; crdt acceptance 3; m4 101; m6.4 15; m6.5 11;
m6.8 8; GPU 59; workspace sweep 2523/0 (one m8-class flake, clean on
rerun); 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
a49adc2589
commit
fe04aa481b
|
|
@ -1,7 +1,33 @@
|
||||||
# Compile-mode — framing (Arc 5 stage 1, terminal)
|
# Compile-mode — framing (Arc 5 stage 1, terminal)
|
||||||
|
|
||||||
**Revision 11 — 2026-07-13. Status: implemented on branch
|
**Revision 12 — 2026-07-14. Status: implemented on branch
|
||||||
`compile-mode` (PR #113); revisions 7–11 fold in PR rounds 1–5.**
|
`compile-mode` (PR #113); revisions 7–12 fold in PR rounds 1–6.**
|
||||||
|
|
||||||
|
Revision 12 (PR #113 round 6, findings 1–3): render-view attachment
|
||||||
|
is idempotent and split-complete. Overlays expose an
|
||||||
|
`overlay_identity` (the span store's allocation address);
|
||||||
|
`Window::ensure_overlay` attaches a store-backed render view AT MOST
|
||||||
|
once per window — pre-fix every switch into the buffer blindly
|
||||||
|
pushed another copy onto EVERY matching window, so passive panes
|
||||||
|
accumulated duplicates, each cloning all spans and rescanning the
|
||||||
|
buffer per frame. A same-buffer split copies clonable overlays to
|
||||||
|
the new pane via `clone_for_split` (splits fire no switch hook and
|
||||||
|
started with an empty overlay list — the new compilation pane
|
||||||
|
rendered unstyled). The translator ignores pure no-op edits
|
||||||
|
(buffers deliberately broadcast them): pre-fix each interior no-op
|
||||||
|
split the containing span into adjacent fragments — unbounded list
|
||||||
|
growth, and a no-op at a UTF-8 continuation byte minted a
|
||||||
|
mid-codepoint span boundary. And the overlay handle has a teardown
|
||||||
|
path: `handle:dispose()` idempotently detaches the buffer-attached
|
||||||
|
translator and removes every window render view over its store —
|
||||||
|
the documented lifetime contract is one handle per buffer
|
||||||
|
incarnation (compile/REPL need no disposal; repeated creation on a
|
||||||
|
long-lived buffer must dispose retired handles or every edit keeps
|
||||||
|
paying for abandoned translators). Bites: split+bounce per-cell
|
||||||
|
acceptance (immediate post-split assert, before any switch could
|
||||||
|
heal the pane), no-op fragmentation Lua twin, dispose Lua twin;
|
||||||
|
units for genuine insertion, no-op ignore, split copy, and
|
||||||
|
ensure-once.
|
||||||
|
|
||||||
Revision 11 (PR #113 round 5, findings 1–3): style-span coordinate
|
Revision 11 (PR #113 round 5, findings 1–3): style-span coordinate
|
||||||
translation belongs to the BUFFER, not to views — a new
|
translation belongs to the BUFFER, not to views — a new
|
||||||
|
|
@ -240,8 +266,12 @@ Everything below was verified by reading the code, not the roadmap.
|
||||||
exactly once per edit — Revision 11) plus render-only window
|
exactly once per edit — Revision 11) plus render-only window
|
||||||
overlays on windows *currently showing* the buffer; buffer
|
overlays on windows *currently showing* the buffer; buffer
|
||||||
switches clear window overlays; `attach_style_overlay(buf,
|
switches clear window overlays; `attach_style_overlay(buf,
|
||||||
handle)` re-attaches the render view. The handle has `add`,
|
handle)` re-attaches the render view, idempotently per window via
|
||||||
`clear`, `clear_before`, `spans`.
|
the store identity, and same-buffer splits copy the render view to
|
||||||
|
the new pane (Revision 12). The handle has `add`, `clear`,
|
||||||
|
`clear_before`, `spans`, and idempotent `dispose` (teardown of the
|
||||||
|
translator + every window render view; one handle per buffer
|
||||||
|
incarnation needs no disposal).
|
||||||
- **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
|
||||||
|
|
|
||||||
|
|
@ -5778,6 +5778,68 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PR #113 round-6 finding 1: a same-buffer split copies
|
||||||
|
/// store-backed render overlays to the new pane (splits fire no
|
||||||
|
/// switch hook and started from an empty overlay list), and
|
||||||
|
/// per-window attachment is idempotent via the store identity.
|
||||||
|
#[test]
|
||||||
|
fn same_buffer_split_copies_style_overlays_and_attach_is_idempotent() {
|
||||||
|
use crate::overlay::{BufferStyleOverlay, SharedBufferStyleSpans};
|
||||||
|
use crate::window::Orientation;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
let s = fresh_with(b"hello\n");
|
||||||
|
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
{
|
||||||
|
let mut core = s.core.borrow_mut();
|
||||||
|
let win = core.active_window_mut();
|
||||||
|
win.ensure_overlay(Box::new(BufferStyleOverlay::new(Arc::clone(&store))));
|
||||||
|
// Second ensure over the SAME store: no duplicate.
|
||||||
|
win.ensure_overlay(Box::new(BufferStyleOverlay::new(Arc::clone(&store))));
|
||||||
|
assert_eq!(
|
||||||
|
win.overlay_kinds()
|
||||||
|
.iter()
|
||||||
|
.filter(|k| **k == "buffer_style_overlay")
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"ensure_overlay must be idempotent per store"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Same-buffer split: the new pane carries a copy.
|
||||||
|
let new_id = s
|
||||||
|
.core
|
||||||
|
.borrow_mut()
|
||||||
|
.split_active(Orientation::Horizontal, true);
|
||||||
|
{
|
||||||
|
let core = s.core.borrow();
|
||||||
|
let win = core.windows.get(&new_id).expect("split window");
|
||||||
|
assert_eq!(
|
||||||
|
win.overlay_kinds()
|
||||||
|
.iter()
|
||||||
|
.filter(|k| **k == "buffer_style_overlay")
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"a same-buffer split must copy the render overlay"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fresh-buffer split: no copy (different buffer, different
|
||||||
|
// styling).
|
||||||
|
let scratch_id = s
|
||||||
|
.core
|
||||||
|
.borrow_mut()
|
||||||
|
.split_active(Orientation::Horizontal, false);
|
||||||
|
let core = s.core.borrow();
|
||||||
|
let win = core.windows.get(&scratch_id).expect("scratch window");
|
||||||
|
assert_eq!(
|
||||||
|
win.overlay_kinds()
|
||||||
|
.iter()
|
||||||
|
.filter(|k| **k == "buffer_style_overlay")
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"a fresh-buffer split carries nothing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- T M2.12: mouse input ----------------------------------------------
|
// ---- T M2.12: mouse input ----------------------------------------------
|
||||||
|
|
||||||
fn mouse(kind: crossterm::event::MouseEventKind, row: u16, col: u16) -> MouseEvent {
|
fn mouse(kind: crossterm::event::MouseEventKind, row: u16, col: u16) -> MouseEvent {
|
||||||
|
|
|
||||||
|
|
@ -2109,9 +2109,21 @@ impl EditorCore {
|
||||||
(new_id, TextView::new(buf))
|
(new_id, TextView::new(buf))
|
||||||
};
|
};
|
||||||
let new_id = WindowId::next();
|
let new_id = WindowId::next();
|
||||||
let new_window = Window::new(new_id, buffer_id, text_view);
|
let mut new_window = Window::new(new_id, buffer_id, text_view);
|
||||||
self.windows.insert(new_id, new_window);
|
|
||||||
let active = self.active_window_id();
|
let active = self.active_window_id();
|
||||||
|
// A same-buffer split starts from an empty overlay list and
|
||||||
|
// fires no switch hook, so store-backed render overlays
|
||||||
|
// (ANSI styling on a compile buffer) would silently vanish
|
||||||
|
// from the new pane (PR #113 round-6 finding 1). Views that
|
||||||
|
// carry across splits say so via `clone_for_split`.
|
||||||
|
if same_buffer && let Some(src) = self.windows.get(&active) {
|
||||||
|
for overlay in &src.overlays {
|
||||||
|
if let Some(copy) = overlay.clone_for_split() {
|
||||||
|
new_window.overlays.push(copy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.windows.insert(new_id, new_window);
|
||||||
self.active_layout_mut()
|
self.active_layout_mut()
|
||||||
.split_window(active, orientation, new_id);
|
.split_window(active, orientation, new_id);
|
||||||
new_id
|
new_id
|
||||||
|
|
|
||||||
|
|
@ -1743,9 +1743,22 @@ pub struct InterceptHandleLua {
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
/// Lua handle for a shared buffer-byte style overlay.
|
/// Lua handle for a shared buffer-byte style overlay.
|
||||||
|
///
|
||||||
|
/// Lifetime contract (PR #113 round-6 finding 3): the buffer-attached
|
||||||
|
/// translator lives until the buffer dies OR `dispose()` is called.
|
||||||
|
/// One handle per buffer incarnation (the compile-mode and REPL
|
||||||
|
/// discipline) needs no disposal — the buffer's death frees it;
|
||||||
|
/// repeated `add_style_overlay` calls on a LONG-LIVED buffer must
|
||||||
|
/// `dispose()` retired handles, or every edit keeps paying for every
|
||||||
|
/// abandoned translator.
|
||||||
pub struct StyleOverlayHandleLua {
|
pub struct StyleOverlayHandleLua {
|
||||||
/// Shared style spans rendered by every attached overlay view.
|
/// Shared style spans rendered by every attached overlay view.
|
||||||
spans: crate::overlay::SharedBufferStyleSpans,
|
spans: crate::overlay::SharedBufferStyleSpans,
|
||||||
|
/// Buffer the translator was attached to.
|
||||||
|
buffer: BufferId,
|
||||||
|
/// The buffer-attached translator's view id — retained so
|
||||||
|
/// `dispose()` can detach it.
|
||||||
|
translator: crate::buffer::ViewId,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromLua for StyleOverlayHandleLua {
|
impl FromLua for StyleOverlayHandleLua {
|
||||||
|
|
@ -1821,6 +1834,29 @@ impl UserData for StyleOverlayHandleLua {
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Idempotent teardown (round-6 finding 3): detaches the
|
||||||
|
// buffer-attached translator (so later edits stop paying for
|
||||||
|
// it) and removes every window render view over this store.
|
||||||
|
// Without this, a retired handle's translator lived until
|
||||||
|
// the buffer died — permanent per-edit cost growth for
|
||||||
|
// repeated creation on a long-lived buffer. Safe to call
|
||||||
|
// twice; safe after the buffer is gone.
|
||||||
|
methods.add_method("dispose", |lua, this, ()| {
|
||||||
|
let id = crate::overlay::style_store_identity(&this.spans);
|
||||||
|
if let Some(core) = lua.app_data_ref::<SharedCore>() {
|
||||||
|
let mut core = core.borrow_mut();
|
||||||
|
for win in core.windows.values_mut() {
|
||||||
|
win.overlays.retain(|v| v.overlay_identity() != Some(id));
|
||||||
|
}
|
||||||
|
let registry = core.registry.clone();
|
||||||
|
let mut r = registry.borrow_mut();
|
||||||
|
if let Ok(buf) = r.get_mut(this.buffer) {
|
||||||
|
buf.detach_view(this.translator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2922,9 +2958,6 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
||||||
lua.create_function(
|
lua.create_function(
|
||||||
move |lua, id: BufferIdLua| -> mlua::Result<StyleOverlayHandleLua> {
|
move |lua, id: BufferIdLua| -> mlua::Result<StyleOverlayHandleLua> {
|
||||||
let spans = Arc::new(Mutex::new(Vec::new()));
|
let spans = Arc::new(Mutex::new(Vec::new()));
|
||||||
let handle = StyleOverlayHandleLua {
|
|
||||||
spans: Arc::clone(&spans),
|
|
||||||
};
|
|
||||||
// Coordinate translation lives on the BUFFER
|
// Coordinate translation lives on the BUFFER
|
||||||
// (PR #113 round-5 finding 1): buffer-attached
|
// (PR #113 round-5 finding 1): buffer-attached
|
||||||
// views see every edit exactly once — Lua bypass
|
// views see every edit exactly once — Lua bypass
|
||||||
|
|
@ -2933,13 +2966,18 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
||||||
// attachments below are render-only; per-window
|
// attachments below are render-only; per-window
|
||||||
// translation ran once per split and zero times
|
// translation ran once per split and zero times
|
||||||
// hidden.
|
// hidden.
|
||||||
{
|
let translator = {
|
||||||
let mut r = reg.borrow_mut();
|
let mut r = reg.borrow_mut();
|
||||||
let buf = resolve_mut(&mut r, id.0)?;
|
let buf = resolve_mut(&mut r, id.0)?;
|
||||||
buf.attach_view(Box::new(crate::overlay::BufferStyleSpanTranslator::new(
|
buf.attach_view(Box::new(crate::overlay::BufferStyleSpanTranslator::new(
|
||||||
Arc::clone(&spans),
|
Arc::clone(&spans),
|
||||||
)));
|
)))
|
||||||
}
|
};
|
||||||
|
let handle = StyleOverlayHandleLua {
|
||||||
|
spans: Arc::clone(&spans),
|
||||||
|
buffer: id.0,
|
||||||
|
translator,
|
||||||
|
};
|
||||||
attach_style_overlay_to_visible_windows(lua, id.0, &spans);
|
attach_style_overlay_to_visible_windows(lua, id.0, &spans);
|
||||||
Ok(handle)
|
Ok(handle)
|
||||||
},
|
},
|
||||||
|
|
@ -2987,7 +3025,12 @@ fn attach_style_overlay_to_visible_windows(
|
||||||
let mut core = core.borrow_mut();
|
let mut core = core.borrow_mut();
|
||||||
for win in core.windows.values_mut() {
|
for win in core.windows.values_mut() {
|
||||||
if win.buffer_id == buffer_id {
|
if win.buffer_id == buffer_id {
|
||||||
win.push_overlay(Box::new(crate::overlay::BufferStyleOverlay::new(
|
// ensure_overlay: idempotent per window via the store's
|
||||||
|
// identity (round-6 finding 1) — repeated switches into
|
||||||
|
// the buffer stacked duplicate render views on passive
|
||||||
|
// panes, each cloning every span and rescanning the
|
||||||
|
// buffer per frame.
|
||||||
|
win.ensure_overlay(Box::new(crate::overlay::BufferStyleOverlay::new(
|
||||||
Arc::clone(spans),
|
Arc::clone(spans),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,16 @@ pub struct BufferStyleSpan {
|
||||||
/// Shared span store used by Lua handles and render overlays.
|
/// Shared span store used by Lua handles and render overlays.
|
||||||
pub type SharedBufferStyleSpans = Arc<Mutex<Vec<BufferStyleSpan>>>;
|
pub type SharedBufferStyleSpans = Arc<Mutex<Vec<BufferStyleSpan>>>;
|
||||||
|
|
||||||
|
/// Identity of a span store: the allocation address, stable for the
|
||||||
|
/// `Arc`'s lifetime. Every overlay/translator over the same store
|
||||||
|
/// reports this via [`View::overlay_identity`], which is what makes
|
||||||
|
/// per-window attachment idempotent and disposal able to find every
|
||||||
|
/// window copy (PR #113 round-6 findings 1 and 3).
|
||||||
|
#[must_use]
|
||||||
|
pub fn style_store_identity(spans: &SharedBufferStyleSpans) -> usize {
|
||||||
|
Arc::as_ptr(spans) as usize
|
||||||
|
}
|
||||||
|
|
||||||
/// View that renders buffer-byte style annotations.
|
/// View that renders buffer-byte style annotations.
|
||||||
///
|
///
|
||||||
/// Unlike [`StyleSpanOverlay`], this overlay stores byte ranges rather
|
/// Unlike [`StyleSpanOverlay`], this overlay stores byte ranges rather
|
||||||
|
|
@ -241,6 +251,17 @@ impl View for BufferStyleSpanTranslator {
|
||||||
let old_end = edit.range.end;
|
let old_end = edit.range.end;
|
||||||
let old_len = old_end - old_start;
|
let old_len = old_end - old_start;
|
||||||
let new_len = edit.inserted_len;
|
let new_len = edit.inserted_len;
|
||||||
|
// Buffers deliberately broadcast no-op edits (empty insert /
|
||||||
|
// empty-range delete — buffer.rs's "callers that count the
|
||||||
|
// call" contract). Nothing moved, so there is nothing to
|
||||||
|
// translate; falling through would split any span containing
|
||||||
|
// the position into two adjacent fragments per call —
|
||||||
|
// unbounded growth for repeated no-ops, and a fragment
|
||||||
|
// boundary mid-codepoint for a no-op at a continuation byte
|
||||||
|
// (round-6 finding 2).
|
||||||
|
if old_len == 0 && new_len == 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
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 span in spans.drain(..) {
|
for span in spans.drain(..) {
|
||||||
|
|
@ -273,9 +294,25 @@ impl View for BufferStyleSpanTranslator {
|
||||||
fn kind(&self) -> &'static str {
|
fn kind(&self) -> &'static str {
|
||||||
"buffer_style_span_translator"
|
"buffer_style_span_translator"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn overlay_identity(&self) -> Option<usize> {
|
||||||
|
Some(style_store_identity(&self.spans))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl View for BufferStyleOverlay {
|
impl View for BufferStyleOverlay {
|
||||||
|
fn kind(&self) -> &'static str {
|
||||||
|
"buffer_style_overlay"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay_identity(&self) -> Option<usize> {
|
||||||
|
Some(style_store_identity(&self.spans))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clone_for_split(&self) -> Option<Box<dyn View>> {
|
||||||
|
Some(Box::new(self.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -850,4 +887,63 @@ mod tests {
|
||||||
"a fully-overwritten span produces no fragments"
|
"a fully-overwritten span produces no fragments"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translator_splits_a_span_around_a_genuine_insertion() {
|
||||||
|
// A real EditOp::Insert (not a replacement) inside a span:
|
||||||
|
// the left fragment stays, the right fragment shifts by the
|
||||||
|
// inserted length, and the inserted bytes inherit nothing.
|
||||||
|
use crate::buffer::EditOp;
|
||||||
|
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))));
|
||||||
|
buf.apply_edit(EditOp::Insert {
|
||||||
|
pos: 3,
|
||||||
|
bytes: b"XY",
|
||||||
|
})
|
||||||
|
.expect("insert applies");
|
||||||
|
assert_eq!(
|
||||||
|
spans_of(&store),
|
||||||
|
vec![(0, 3), (5, 8)],
|
||||||
|
"insertion splits the span; inserted bytes are unstyled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translator_ignores_pure_noop_edits() {
|
||||||
|
// Buffers deliberately broadcast no-op edits (empty insert /
|
||||||
|
// empty-range delete). PR #113 round-6 finding 2: falling
|
||||||
|
// through split a containing span into two adjacent
|
||||||
|
// fragments per call — unbounded growth for repeated no-ops
|
||||||
|
// at distinct positions, and a fragment boundary
|
||||||
|
// mid-codepoint for a no-op at a UTF-8 continuation byte.
|
||||||
|
use crate::buffer::EditOp;
|
||||||
|
use crate::rope::Range;
|
||||||
|
let mut buf = Buffer::from_bytes(BufferId::next(), "t", "ab\u{e9}def".as_bytes());
|
||||||
|
let store: SharedBufferStyleSpans = Arc::new(Mutex::new(vec![BufferStyleSpan {
|
||||||
|
start: 0,
|
||||||
|
end: 7,
|
||||||
|
style: red(),
|
||||||
|
}]));
|
||||||
|
buf.attach_view(Box::new(BufferStyleSpanTranslator::new(Arc::clone(&store))));
|
||||||
|
// Distinct interior positions, including 3 — the é's
|
||||||
|
// continuation byte.
|
||||||
|
for pos in [1, 2, 3, 4, 5] {
|
||||||
|
buf.apply_edit(EditOp::Insert { pos, bytes: b"" })
|
||||||
|
.expect("no-op insert applies");
|
||||||
|
buf.apply_edit(EditOp::Delete {
|
||||||
|
range: Range::new(pos, pos),
|
||||||
|
})
|
||||||
|
.expect("no-op delete applies");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
spans_of(&store),
|
||||||
|
vec![(0, 7)],
|
||||||
|
"no-op edits must not fragment or move spans"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/view.rs
21
src/view.rs
|
|
@ -236,6 +236,27 @@ pub trait View {
|
||||||
fn kind(&self) -> &'static str {
|
fn kind(&self) -> &'static str {
|
||||||
"unknown"
|
"unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Identity of the shared resource this overlay renders, if any
|
||||||
|
/// (PR #113 round-6 findings 1 and 3). Two overlay instances
|
||||||
|
/// backed by the same store report the same value, which lets a
|
||||||
|
/// window attach a resource-backed overlay AT MOST once
|
||||||
|
/// ([`crate::window::Window::ensure_overlay`]) and lets disposal
|
||||||
|
/// remove every window copy. The default `None` opts out: such
|
||||||
|
/// views are never deduplicated or bulk-removed.
|
||||||
|
fn overlay_identity(&self) -> Option<usize> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A copy of this overlay for a freshly split window showing the
|
||||||
|
/// same buffer (round-6 finding 1: a same-buffer split starts
|
||||||
|
/// with an empty overlay list and fires no switch hook, so
|
||||||
|
/// without this the new pane rendered unstyled). `None` (the
|
||||||
|
/// default) means the view does not carry across splits;
|
||||||
|
/// store-backed render overlays return a clone.
|
||||||
|
fn clone_for_split(&self) -> Option<Box<dyn View>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,25 @@ impl Window {
|
||||||
self.overlays.push(view);
|
self.overlays.push(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push `view` unless an overlay with the same
|
||||||
|
/// [`View::overlay_identity`] is already attached — attachment
|
||||||
|
/// of store-backed overlays must be idempotent per window
|
||||||
|
/// (PR #113 round-6 finding 1: repeated switches into a buffer
|
||||||
|
/// stacked duplicate render views on passive panes, each cloning
|
||||||
|
/// every span and rescanning the buffer per frame). Views
|
||||||
|
/// without an identity always push.
|
||||||
|
pub fn ensure_overlay(&mut self, view: Box<dyn View>) {
|
||||||
|
if let Some(id) = view.overlay_identity()
|
||||||
|
&& self
|
||||||
|
.overlays
|
||||||
|
.iter()
|
||||||
|
.any(|v| v.overlay_identity() == Some(id))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.overlays.push(view);
|
||||||
|
}
|
||||||
|
|
||||||
/// Stable kind identifiers of every overlay on this window, in
|
/// Stable kind identifiers of every overlay on this window, in
|
||||||
/// push order. Test seam used by `pmacs.window._overlay_kinds()`
|
/// push order. Test seam used by `pmacs.window._overlay_kinds()`
|
||||||
/// to verify that a specific overlay type actually attached
|
/// to verify that a specific overlay type actually attached
|
||||||
|
|
|
||||||
|
|
@ -2361,6 +2361,158 @@ fn r5f2_partial_rewrite_preserves_untouched_styling() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PR #113 round 6 — bite tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Count of `buffer_style_overlay` render views on the ACTIVE window.
|
||||||
|
fn active_style_overlay_count(s: &EditorState) -> i64 {
|
||||||
|
eval(
|
||||||
|
s,
|
||||||
|
r#"
|
||||||
|
local n = 0
|
||||||
|
for _, k in ipairs(pmacs.window._overlay_kinds()) do
|
||||||
|
if k == "buffer_style_overlay" then n = n + 1 end
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn r6f1_split_panes_stay_styled_with_one_attachment_each() {
|
||||||
|
use pmacs::cell::Color;
|
||||||
|
// A styled compilation split into two panes: the split copies
|
||||||
|
// the render view (splits fire no switch hook and pre-fix left
|
||||||
|
// the new pane unstyled), and repeated switches of one pane must
|
||||||
|
// not stack duplicates on the passive pane (pre-fix every
|
||||||
|
// re-attach blindly pushed to EVERY matching window — unbounded
|
||||||
|
// render cost). Round-6 finding 1.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let script = write_script(
|
||||||
|
dir.path(),
|
||||||
|
"red.sh",
|
||||||
|
"printf '\\033[31mhello\\033[0m world\\n'\n",
|
||||||
|
);
|
||||||
|
let mut s = editor();
|
||||||
|
compile_and_finish(&mut s, &format!("sh {script}"), dir.path());
|
||||||
|
exec(&s, "pmacs.window.split_horizontal()");
|
||||||
|
// IMMEDIATELY after the split — before any switch could heal it
|
||||||
|
// through the attach-to-all-matching-windows path — both panes
|
||||||
|
// must be styled with one attachment each. This is the split
|
||||||
|
// half of the finding: pre-fix the new pane had NO render view.
|
||||||
|
for pane in 0..2 {
|
||||||
|
assert_eq!(
|
||||||
|
active_style_overlay_count(&s),
|
||||||
|
1,
|
||||||
|
"pane {pane} post-split: exactly one render attachment"
|
||||||
|
);
|
||||||
|
let cells = render_active_window_to_grid(&mut s, 12, 60);
|
||||||
|
let row = styled_row(&cells, 12, 60, "hello world", 5);
|
||||||
|
assert!(
|
||||||
|
row.iter().all(|(_, fg)| *fg == Color::Indexed(1)),
|
||||||
|
"pane {pane} post-split: 'hello' renders red; got {row:?}"
|
||||||
|
);
|
||||||
|
exec(&s, "pmacs.window.focus_next()");
|
||||||
|
}
|
||||||
|
// Bounce the ACTIVE pane away and back three times; the passive
|
||||||
|
// pane keeps showing *compilation* through every re-attach. This
|
||||||
|
// is the accumulation half: pre-fix every re-attach blindly
|
||||||
|
// pushed another render view onto the passive pane.
|
||||||
|
exec(&s, r#"bounce_buf = pmacs.buffer.create("*bounce*")"#);
|
||||||
|
for _ in 0..3 {
|
||||||
|
exec(&s, "pmacs.window.switch_buffer(bounce_buf)");
|
||||||
|
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
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for pane in 0..2 {
|
||||||
|
assert_eq!(
|
||||||
|
active_style_overlay_count(&s),
|
||||||
|
1,
|
||||||
|
"pane {pane} post-bounce: exactly one render attachment"
|
||||||
|
);
|
||||||
|
let cells = render_active_window_to_grid(&mut s, 12, 60);
|
||||||
|
let row = styled_row(&cells, 12, 60, "hello world", 5);
|
||||||
|
assert!(
|
||||||
|
row.iter().all(|(_, fg)| *fg == Color::Indexed(1)),
|
||||||
|
"pane {pane} post-bounce: 'hello' renders red; got {row:?}"
|
||||||
|
);
|
||||||
|
exec(&s, "pmacs.window.focus_next()");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn r6f2_noop_edits_do_not_fragment_spans() {
|
||||||
|
// Buffers deliberately broadcast no-op edits; the translator
|
||||||
|
// must ignore them (round-6 finding 2). Pre-fix each interior
|
||||||
|
// no-op split the containing span into two adjacent fragments —
|
||||||
|
// unbounded growth, and position 3 (the é's continuation byte)
|
||||||
|
// minted a mid-codepoint span boundary.
|
||||||
|
let s = editor();
|
||||||
|
let (count, start, end): (i64, i64, i64) = eval(
|
||||||
|
&s,
|
||||||
|
r#"
|
||||||
|
local buf = pmacs.buffer.create("*noop-spans*")
|
||||||
|
local ov = pmacs.buffer.add_style_overlay(buf)
|
||||||
|
buf:insert(0, "ab\195\169def")
|
||||||
|
ov:add(0, 7, { fg = 1 })
|
||||||
|
for _, pos in ipairs({ 1, 2, 3, 4, 5 }) do
|
||||||
|
buf:insert(pos, "")
|
||||||
|
buf:delete(pos, pos)
|
||||||
|
end
|
||||||
|
local spans = ov:spans()
|
||||||
|
return #spans, spans[1].start, spans[1]["end"]
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(count, start, end),
|
||||||
|
(1, 0, 7),
|
||||||
|
"no-op edits must neither fragment nor move spans"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn r6f3_handle_dispose_detaches_translator_and_render_views() {
|
||||||
|
// Teardown path (round-6 finding 3): dispose() detaches the
|
||||||
|
// buffer-attached translator (later edits stop translating) and
|
||||||
|
// removes every window render view over the handle's store;
|
||||||
|
// calling it twice is safe. Pre-fix a dropped handle's
|
||||||
|
// translator lived until the buffer died.
|
||||||
|
let s = editor();
|
||||||
|
let (live, stale, render_views): (i64, i64, i64) = eval(
|
||||||
|
&s,
|
||||||
|
r#"
|
||||||
|
local buf = pmacs.buffer.create("*disposable*")
|
||||||
|
pmacs.window.switch_buffer(buf)
|
||||||
|
local ov = pmacs.buffer.add_style_overlay(buf)
|
||||||
|
buf:insert(0, "abcdef")
|
||||||
|
ov:add(0, 6, { fg = 1 })
|
||||||
|
buf:insert(0, "xx") -- live translator: span shifts
|
||||||
|
local live = ov:spans()[1].start
|
||||||
|
ov:dispose()
|
||||||
|
buf:insert(0, "yy") -- detached: span must NOT move
|
||||||
|
local stale = ov:spans()[1].start
|
||||||
|
ov:dispose() -- idempotent
|
||||||
|
local n = 0
|
||||||
|
for _, k in ipairs(pmacs.window._overlay_kinds()) do
|
||||||
|
if k == "buffer_style_overlay" then n = n + 1 end
|
||||||
|
end
|
||||||
|
return live, stale, n
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
assert_eq!(live, 2, "pre-dispose edits translate the span");
|
||||||
|
assert_eq!(stale, 2, "post-dispose edits must not reach the store");
|
||||||
|
assert_eq!(render_views, 0, "dispose removes the window render views");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn r5f3_tracked_line_start_matches_the_scan_across_transitions() {
|
fn r5f3_tracked_line_start_matches_the_scan_across_transitions() {
|
||||||
// Round-5 finding 3 is a performance fix — the per-CR/BS/erase
|
// Round-5 finding 3 is a performance fix — the per-CR/BS/erase
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue