Stage 4 of the QoL arc, framing revision 4 (approved). Under
`truncate`, text past the right edge was UNREACHABLE; moving the cursor
now brings it into view. Automatic only — no commands, no bindings, no
new interaction island (Q#HS2).
THE CONTRACT. `view_left` is an unsnapped per-window display column
(Q#HS7(a)), and each line derives its own effective edge during the
walk it already performs from column 0. Starting at 0 is not laziness:
tab expansion depends on the absolute column from the line start, so a
walk beginning at the edge would put tab stops in the wrong place. The
walk stays line-absolute and only the emit translates.
Where the edge bisects a wide glyph on a given line (Q#HS7(c′)), its
trailing cell paints a styled BLANK rather than a `Continuation` — that
glyph means "the cell before me is a wide glyph's head", and here that
cell is off-screen, so emitting it would name a cell nobody painted.
The mapping designates that cell to the glyph's START byte, which keeps
`byte_at_place` total over visible cells and makes the character the
user scrolled toward clickable. Tabs keep FORWARD rounding (Q#HS7(c″))
— preserved, not chosen.
DECORATIONS TRAVEL WITH THE TEXT. The first version of this commit
translated the base glyph walk and nothing else, which split the frame
in half: at `view_left = 10` a glyph from source column 10 painted at
screen column 0 while its syntax style, diagnostic underline, search
wash and `BufferStyleOverlay` span painted at screen column 10 — or
vanished. Decorations drifting off the characters they describe,
silently, and only once a window had been scrolled.
Every such site carried the same two lines (`start_col.min(max_cols)`,
`end_col.min(max_cols)`), correct only while the left edge was pinned
at zero. `Viewport::visible_cols` is now the one rule all FIVE adopters
share — syntax/LSP styling, diagnostic underlines, search washes,
`BufferStyleOverlay`, and the selection painter — so a future decorator
inherits the translation instead of re-deriving it. It also subsumes
the old `end_col <= start_col` guard rather than sitting beside it.
`StyleSpanOverlay` and `VirtualCellOverlay` are deliberately untouched:
they are documented as viewport-relative, so translating them would be
the mirror defect.
The selection painter was nearly a sixth site with its own copy of the
rule, which I justified by a width it supposedly needed and the
viewport lacked. That was FALSE — the render viewport's
`cell_size.cols` is already `rect.size.cols - gutter_w` and its origin
already sits past the gutter. It now takes that same viewport and drops
its `rect`/`gutter_w` parameters entirely. A canonical rule with one
honest exception is not canonical.
The selection painter had the same defect with a worse failure mode: it
asked `pos_to_display` through the LIVE context, which returns `None`
for a position left of the edge, so a selection beginning off-screen
and reaching into view took `continue` and painted NOTHING. That is the
common shape, not an edge case — select rightward from column 0 past
the window width and the view scrolls with the cursor.
TWO THINGS THE TESTS FOUND, both in `pos_to_display`. My framing note
said a caret sits between characters so never lands inside a glyph;
true for the caret, false for the DESIGNATION direction — the glyph's
start byte must map to its visible trailing cell, so `screen_col` needs
the straddle rule and not a bare subtraction. And the `take == 0` early
return short-circuited the translation entirely, so byte 0 looked
visible at every offset.
`view_left` is inert under `wrap` BY CONSTRUCTION —
`LayoutCtx::effective_left` and `Viewport::left_edge` return 0 while
wrapping — rather than by every caller remembering.
Persisted per leaf at DESKTOP_VERSION 1 (Q#HS5) with both approval
conditions: `#[serde(default)]` and a literal v1 JSON fixture omitting
the field, hand-written because a generated one would gain the field
and prove nothing.
Also: `view_left: window.view_left` in the render viewport, not a
literal 0. My mechanical fill put 0 there and it is EXACTLY the
`aa3cd4d` defect — coordinates and the indicator following the scroll
while the painter stays pinned at column 0.
BITE, per clause. Forcing `bisected = false` fails the multi-line
straddle witness; dropping the backward designation fails the
round-trip witness; removing `#[serde(default)]` fails the v1 fixture;
pinning `visible_cols` to an absolute clamp fails all three decorator
witnesses; restoring the selection painter's live-context lookup fails
the off-screen-start selection witness. Each alone. And with selection
now reading the shared helper, pinning `visible_cols` to an absolute
clamp fails the selection witnesses TOO — which is the check that the
duplication is really gone rather than merely reworded.
One unrelated red, logged as R7 in ci-red-signatures.md — the first
this session with a COMPLETE signature, so a matchable row rather than
a U note. `pmacs-gpu`'s managed-retry attach hit a BrokenPipe once
under full-sweep load and did not reproduce (6 isolated runs plus a
clean 113-target sweep). Per the rerun rule that is intermittence only,
and the row explicitly does not claim harmlessness. Not attributed to
this lane: Stage 4 touches no `pmacs-gpu` file and adds no wire
surface.
Gates: fmt; clippy --workspace --all-targets -D warnings, both
configurations; `cargo test --workspace --no-fail-fast -- --skip
basedpyright` 113 targets exit 0, and the same with --features crdt,
113 targets exit 0; git diff --check. No protocol change, so no version
bump and no protocol-bump matrix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Inert by construction. Adds the type and the Viewport field, sets all
31 construction sites to Truncate, and changes no rendering: --lib is
1900/0 and crdt 2085/0, the same counts as the parent commit.
The field is required rather than defaulted on purpose. A default would
have let 31 sites stay silent about which behavior they meant; a
required field makes each one state it, so the pre-existing sites now
read as deliberately unwrapped rather than merely untouched. The
compiler enumerated them, including five integration tests --- Viewport
is public API, so this is a real break, and the break is the point.
The render driver is pinned to Truncate too. The wrap path does not
exist yet, and exposing a mode before the cursor mapping honors it
would ship a setting that renders one thing and navigates another ---
the shape of defect this lane exists to remove, not add.
Two notes on getting here, since both were nearly landed:
The first mechanical patch matched every `folds,` line and put a wrap
field into function call sites and a FoldStore literal. Scoping the
insertion to Viewport literals cut it from 40 sites to 31. The compiler
caught it, but only because a struct field cannot be mistaken for an
argument; a same-arity call would have compiled.
While rewriting the character walk I changed the wide-character edge
case --- a double-width glyph with one cell left now breaking instead
of painting a lone lead cell. That is arguably better behavior and it
is NOT this commit's to make: Truncate must be byte-identical, and an
"improvement" smuggled in beside a refactor is how identity cases stop
being identity cases. Reverted; the walk is untouched.
Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1900/0,
crdt 2085/0, tab_width 2/0, listview 26/0, compile_mode 73/0,
folding 21/0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
Implements docs/folding-stage2-framing.md rev 4. The daemon grid
renderer now consults the fold store: hidden lines are omitted, rows
below shift up, and every consumer that assumed
`display_row = source_line - view_top` routes through one shared
projection. No wire schema change and no protocol bump (Bet B6) — the
collapse is entirely daemon-side; the GPU path is Stage 3.
The spine (Q#FD12) is `src/fold_view.rs`: a `VisibleLineMap` derived
from `FoldRegistry::folds` plus a window's line offsets and never
stored. Its unit is a merged **hidden component** — overlapping OR
adjacent hidden intervals unioned, each keeping the one visible
`head_line` and that line's exact `head_position`. Adjacent intervals
merge because the later fold's head is itself hidden, which is what
makes nesting, shared heads, and crossing overlap all resolve to a
head that can actually render (round-3 F2).
Instances are short-lived and built **per rendered window** and **per
command/event operation**, never once per frame: `paint_frame` renders
several windows that may show different buffers, so a singleton would
leak one pane's folds into another (round-2 F2). The render instance
rides on a lifetime-bearing `Viewport<'a>` as `Option<&'a
VisibleLineMap>` — a shared ref is `Copy`, so `Viewport` stays `Copy`
(Bet B7).
Rendering:
- `TextView::render` walks visible lines; the head line gets a
trailing content-area ellipsis (Q#FD13).
- The gutter walks visible lines too: Absolute keeps the raw `line+1`,
Relative/Hybrid measure VISIBLE distance anchored on the cursor's
visible head (Q#FD14). The fold glyph takes the col-0 sign cell only
when a gutter exists — line numbers default to Off, so with no gutter
the ellipsis is the sole marker (Q#FD20, round-1 F3). A diagnostic
clamped onto the head wins that cell by paint order.
- A diagnostic on a hidden line clamps its SIGN to the outermost
visible head (most-severe merge); the squiggle needs a real row, so
only the sign clamps (Q#FD15).
- Caret, local selection endpoints, and peer cursors project via
`visible_position_of` — the head row AND the head's end-of-content
column, never an arbitrary column (round-2 F3). Peer presence derives
the RECIPIENT window's map.
- Style/search/completion overlays route through
`Viewport::row_offset_of`; the mode-line indicator reckons in
visible-line space.
Command/event time is scoped per frontend (Q#FD21): a
`fold_projection` flag on `FrontendView`, set at attach from the
negotiated `semantic_render` bit (grid ⇒ true, semantic ⇒ false until
Stage 3, LOCAL ⇒ true) and never inferred from a `FrontendId` (Bet
B8). Without it, shared `EditorCore` motion would make a simultaneous
unfolded GPU session's cursor skip lines it still displays. The map's
two axes stay separate (round-3 F1): the acting frontend supplies the
policy, the operation's TARGET window supplies the buffer — a wheel
event names a pane without activating it.
Motion (Q#FD17, ruled: include), paging, wheel, the click inverse, and
the auto-scroll clamp all step by visible lines under that gate;
motion from a hidden logical cursor normalizes to the visible head
first. `view_top` stays a source-line index (Bet B5), set only via
`clamp_view_top` so it never rests hidden.
Unfold widening (Q#FD19): the pre-edit unfold moves to the top of
`apply_active_edit` — one funnel that subsumes the six primitives'
calls and covers yank + query-replace, both of which place point at
the edit site first. Interactive Lua mutators hook the common
`run_buffer_edit`, above the managed/bypass split, gated on
`InteractiveCommandOrigin` AND the edit targeting that frontend's
active-window buffer. The remote/optimistic-CRDT path stays excluded
(Stage 3); undo/redo unfold stays deferred.
Acceptance: `tests/folding_stage2_acceptance.rs`, 35 tests asserting
on the real `paint_frame` cell grid, covering framing items 1–14
including crossing folds, a nested deeply-hidden cursor, a split of
two different buffers with an inactive-pane wheel, and simultaneous
grid+semantic motion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5BkezMppbpCgGAYk2ftxV
Share one fixed eight-column tab-stop contract across core and GPU renderers. Consolidate byte-to-display-column accounting, expand GPU code tabs with source provenance, align caret/hit/decoration geometry, and refresh minimap projection on edits.
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
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
Sub-arc 2 of the UX arc, TUI half. When a window reserves a line-number
gutter, lines with diagnostics get a severity-colored sign glyph (E/W/I/H)
in the gutter's leading column — closing the last deferred Task #23 item.
No protocol/daemon change: the per-line severity is already frontend-side
(the diag store the DiagnosticView already reads).
- `Viewport` gains `gutter_w` so overlays can reach the gutter's leading
column at `cell_origin.col - gutter_w`; the text area is already shifted
past it, so viewport-relative painters stay gutter-agnostic.
- The gutter's number pass now runs *before* the overlays (was after), so
the DiagnosticView can draw its sign into the gutter's blanked leading
column without the number pass erasing it.
- DiagnosticView: with a gutter, draw the severity sign glyph colored by
`underline_color()`; without one, keep the legacy column-0 background
marker (the "fake gutter" that predates a real gutter column). Extracted
to `paint_line_markers` to keep `render` under the line cap.
The number never reaches column 0 (>=1 leading pad by construction), so
sign and number coexist. Diagnostic signs currently ride the line-number
gutter (visible when line numbers are on); a signs-without-numbers mode is
deferred.
Test: gutter_sign_replaces_the_column_marker_when_a_gutter_is_reserved.
Validated: fmt + clippy --all-targets clean both flavors; 1441 lib + 22
diag tests pass. Needs a TUI eyeball before the GPU half.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
M4.6 follow-up piece 2. `Style` gains `underline_color: Color`
(Default = follow the text color) so a diagnostic squiggle can be
red/yellow/cyan/gray without clobbering the syntax color of the
text it underlines — exactly why error_style() left its 'red'
unwired until now.
The wire consequence: Style rides inside Cell / CellDelta /
Snapshot / StyleSpans, so this is the protocol's first
encoding-breaking change. PROTOCOL_VERSION 5 → 6 and
SUPPORTED_PROTOCOL_VERSIONS narrows to [6]: postcard is not
self-describing, so no per-session send gate can keep a v5 peer
decoding v6 cells — a mismatched pair now fails the handshake with
a clean VersionMismatch instead of garbling mid-session. Version
policy tests rewritten to pin the new contract.
Surface wiring:
- diag.rs: per-severity underline_color (indexed 1/3/6/8).
- frontend.rs: kitty-style CSI 4:N for Double/Curly/Dotted/Dashed
(previously flattened to plain SGR 4) + SGR 58:5/58:2 emission.
- ansi.rs: parse SGR 58/59 with the 38/48 extended-color grammar.
- overlay.rs merge_styles: non-default-wins, like fg/bg/underline.
- lua_bindings.rs: underline_color on Lua style tables.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>