detached 15288c6

perf: allocation pass and HTML text-run updates (schema v2) (#36)

* test(oracle): add differential oracle and isolate benchmark setup

Seeded workloads drive the public VT, history, codec, ANSI and HTML pipeline and hash every observable result into a golden file. Benchmarks no longer allocate a Screen or input slices inside the measured loop.

* perf(html): remove per-row allocations and reflective JSON encoding in Prepare

* test(html): guard JSON encoder against update field drift

Add a reflection-filled test comparing appendUpdateJSON with json.Marshal
and pinning field counts. Invalid color kinds now return the same
json.MarshalerError text as before. Fix dead frame.Set and U+FFFD comment;
compare invalid UTF-8 bytes directly.

* perf(core): cache last interned style and speed up row blanking

Skip canonicalization and map hashing when Set repeats the raw style, fill blanked rows by copy-doubling, and double style slice capacity (bounded by cell count) to cut growth allocations.

* feat(core): add Frame.CopyFrom and RowsEqual helpers

CopyFrom copies a frame's compact page (cells, row descriptors, style and
payload tables, dictionaries) into the destination's existing storage, so
repeated copies allocate nothing once sized. IDs and row rotation are copied
verbatim, like Clone, so page-local style/payload IDs stay consistent.

RowsEqual compares one logical row of two frames directly on stored cells,
resolving page-local style/payload IDs semantically only when needed, so
frames with different ID tables or row rotation still compare correctly.

* perf(ansi): reuse renderer-owned frame buffers instead of cloning per Prepare

Prepare used to Clone the whole source frame (cells, rows, styles, cloned
style map, payloads) on every draw that had work to commit, which was ~99% of
allocated bytes. The renderer now owns two private frames: committed and
scratch. Prepare copies the source into scratch with Frame.CopyFrom (reusing
capacity; never aliasing caller storage, so callers may keep mutating one
Frame in place). Commit either swaps scratch and committed (snapshot plans) or
applies the span/scroll delta from scratch into committed. This double buffer
is the simplest design that keeps the transactional contract: discarding a
PreparedDraw leaves committed untouched because only scratch is written, and
PreparedDraw.Bytes never references frame storage. A generation counter makes
a draw superseded by a later Prepare a no-op on Commit, since its snapshot has
been overwritten (at most one prepared draw may be outstanding). Reset keeps
the buffers and only clears hasCommitted.

PlanDelta remains public with identical semantics: it still returns a
candidate owning a fresh snapshot (internal planDelta takes the scratch).

buildDirtyLinePlan uses core.RowsEqual when both sides are core.Frame or
*core.Frame, falling back to the generic Cell().Equal path for other
CellSource implementations. Output bytes are unchanged.

Adds ownership tests: caller mutation after commit / between Prepare and
Commit, discard then re-Prepare, retained Bytes after later draws, mixed
resize/reset/scroll sequences, and a steady-state allocation bound.

* perf(history): detect duplicate row IDs without a per-ID map

Row IDs are not required to be monotonic: AppendWithID accepts any unused
ID and the codec only rejects zero/reserved values and duplicates, so an
ordering check would change accept/reject results. Replace the
map[RowID]struct{} in parseHistory and validateRestoredHistoryView with
rowIDSet, an exact set that keeps IDs in an ascending slice while views
arrive in increasing order and falls back to a map on interleaving.
Per-chunk IDs are sorted and checked, then committed, so the accepted
inputs are identical (duplicate rows are still rejected before any cell
of the chunk is decoded).

* perf(history): recycle mutable tail storage and style sets across seals

sealTail dropped tailCells/tail/bounds/IDs and the per-page style maps, so
every chunk reallocated up to 32K 104-byte cells (85% of build bytes).
Nothing outside History can alias that storage: sealed chunks and the
cached tail are compact copies built by newHistoryChunks (bounds and IDs
are copied), SnapshotView deep-copies its tail, Range/Row return decoded
copies, and tests-only code touches h.tail. Seal now keeps the backing
arrays at len 0 (clearing cells so payload strings are not pinned) and
releases oversized wide-row storage as before.

Also reuse tailPageStyles/tailPagePayloads via clear() (dropped if they
grew past 1024 entries), reuse one lastStyleRow scratch map across tail
chunks, and skip Canonical/map writes for runs of identical raw styles.
Tests pin that views, snapshot tails and Range rows taken before seals stay
unchanged and that logical-byte accounting is unaffected.

* perf(kittygraphics): decode base64 once and avoid payload copies

DecodeBase64 picks padded or raw decoding from the presence of '=' and decodes
straight into a right-sized buffer, with identical accept/reject set and error
text (property and fuzz tested against the previous implementation). Add
ParseAPCBorrowed and Scene.AddAssetOwned/ReplaceAssetOwned so the freshly
decoded image bytes move into the scene without extra copies; public copying
entry points are unchanged.

* perf(screen): skip unused eviction copies and reuse Kitty escape buffers

emitLineEvicted returns early without history or OnLineEvicted and otherwise
builds the row in a Screen-owned scratch buffer; OnLineEvicted still receives
a fresh copy. Fragmented Kitty APCs reuse a bounded escapeBuf/combine buffer
and dispatch borrowed APC bytes, since the session copies what it retains.

* fix(core): copy style cache fields in Frame.CopyFrom

After merging the page style cache, CopyFrom left the destination's cache
pointing at a slot that now holds a different style, so a later Set with the
old style reused the wrong ID. CopyFrom now copies styleCache/ID/OK together
with the style table. Adds a stale-cache regression test, a reflect-based
guard that fails when cellPage gains a field CopyFrom does not handle, and
RowsEqualAt for comparing differently indexed rows of two frames.

* fix(history): clear evicted tail cells, bound scratch maps, harden tests

- evictOldest clears the evicted row's cells before reslicing tailCells so
  recycled backing storage no longer pins their payload strings.
- newHistoryChunksScratch takes a *map and replaces/drops it whenever any
  chunk (not only the last) grew it past maxRetainedStyleScratch; sealTail
  also releases oversized tailPageStyles/tailPagePayloads immediately.
- rowIDSet.begin resets the collect scratch at the start of each view so an
  early invalid return cannot leak IDs into a later commit.
- Accounting test is no longer circular: non-divisible chunks, row/byte
  eviction, SetLimits shrink, payload rows, width changes and >1024-style
  pages, checked after every append against chunk metadata and an
  independent rebuild of every live chunk. (A fresh history re-fed the same
  rows can legitimately differ since pages depend on seal boundaries; this
  is unchanged from the base commit.) Added eviction-prefix, scratch-drop
  and rowIDSet.begin tests.

* fix(screen): make escape buffer reuse safe under re-entrant Write

Detach combineBuf while the outer Write parses it so callbacks calling Write
cannot overwrite it, drop oversized escapeBuf on the combine path, and document
the Process/AddAssetOwned ownership contracts. Add re-entrancy and caller-buffer
aliasing tests.

* fix(ansi): drop committed shadow when a superseded draw is committed

A PreparedDraw superseded by a later Prepare cannot be applied (its snapshot
buffer was reused), but its bytes may already have been written to the
terminal. Commit now clears hasCommitted so the next Prepare emits a full
snapshot instead of diffing against a shadow that may no longer match.
Documented on Commit; tested by replaying outputs through a VT screen.

Also moves test-only setCommittedFrame/committedFrame/replaceFrame helpers
into a _test.go file and fixes the misplaced compactFrames doc comment.

* perf(ansi): compare whole retained rows before scroll applicability checks

canApplyDenseScrollAgainst walked every retained cell through Frame.At. For
full-width scrolls it now first compares the committed row (y+offset) with
the target row via core.RowsEqualAt and only falls back to the per-cell damage
coverage check for rows that differ.

* test(ansi): property test comparing renderer shadow against clone-based reference

Random edits of one in-place-mutated frame, under-reported damage, scrolls,
resets, resizes, discarded and stale-committed draws, with Frame, *Frame and
generic CellSource inputs. Compares the plan, prepared snapshot and committed
shadow with the public PlanDelta/Commit reference.

* fix(ansi): snapshot when rows outside the chosen scroll region moved

A damage batch with two scroll regions was planned as one scroll plus text spans, leaving the second region shifted on the client. The scroll fast path now also requires rows outside its region to match the committed shadow or be covered by text damage, and falls back to a snapshot otherwise. The differential oracle now fails on any ANSI replay mismatch.

* fix(ansi): widen damage spans that split a wide rune

Text damage recorded before a scroll could start on the right half of a wide rune moved under it; the renderer then wrote over that half and erased the rune on the client. Planned spans now cover whole wide pairs, which also keeps cursor tracking exact. The oracle replays ANSI-256 output too, and a seeded scroll workload test replays real VT damage for every CellSource shape.

* fix(vt): address wrap-up review of the allocation pass

ansi: a stale commit or Reset now invalidates every outstanding draw, so committing a newer delta cannot re-establish a shadow that disagrees with the terminal; docs match. Drop core.RowsEqual (RowsEqualAt covers it), bounds-check payload IDs in RowsEqualAt, route Frame dispatch through asFrame.

graphics: owned assets with large spare capacity are copied to their exact length, so base64 input padded with CR/LF or zlib read buffers cannot retain memory beyond MaxEncodedBytes.

history: recycle tail storage for default chunks up to 512 columns. vt: drop eviction scratch sized for a much wider past geometry.

html: invalid color kinds return a plain error and invalid UTF-8 tests compare decoded text, so tests no longer depend on the encoding/json implementation; cap the output buffer hint at the byte limit; add FuzzAppendJSONString; drop a duplicate aliasing test.

oracle: one digest per layer (input, vt, graphics, history, ansi, ansi256, html), per-scenario comparison naming the moved layers, refuse partial -oracle.update, run a sixth of the scenarios under -race. The regenerated golden is identical before and after these fixes. Document the oracle, the borrowed/owned byte APIs and the tail recycling decision.

* fix(html): avoid size-hint overflow for MaxInt generated-bytes limit

Cap the encode buffer hint at the limit instead of limit+1, which wrapped to a negative capacity when MaxGeneratedBytes was math.MaxInt. The oracle now reports malformed golden lines instead of panicking.

* perf(html): encode rows as styled text runs (update schema v2)

Adjacent printable ASCII cells sharing a style become one CellUpdate whose
width equals its length; wide and non-ASCII characters stay single entries so
column alignment never depends on a fallback font. A 189x57 history scroll
frame drops from 482 KB / 10,584 entries to about 15 KB / 57 entries, and the
browser builds one span per run instead of one per character.

The browser runtime accepts runs (width > 2 must be printable ASCII, one
character per column) and gains applyAll, which validates a queue of updates
and merges them so each changed row is rebuilt once per presentation.

Schema version 2 is a breaking change for update and event consumers. The
oracle golden only changes in its html digest; a transcript diff confirmed
all 10,046 updates expand to the previous cell list.

* fix(html): keep applyAll atomic and the merged cursor current

applyAll now validates the whole geometry chain against the mounted terminal
before any DOM change and applies exactly one merged update, so invalid
batches no longer leave a partial mutation. The merged update always carries
the latest cursor (a trailing cursor-only update was previously dropped).
Style remapping caches per source array instead of stringifying per cell, and
a batch whose merged styles exceed maxStyles falls back to ordered apply.

The Go renderer reuses its run scratch buffers (bounded to 240x80) and sizes
the cell slice for one row: snapshots allocate 33-57% fewer objects, one-row
updates go from 14 to 15. CellUpdate documents that texts share storage, the
README notes the monospace and maxTextBytes requirements of text runs, and new
tests cover multi-row incremental slicing and the run coverage invariants.

* fix(core): release CopyFrom storage when the source shrinks

CopyFrom replaces a destination page more than four times larger than the
source with a clone, so a renderer scratch frame no longer pins its largest
size after a resize. Document the CopyFrom aliasing rule and RowsEqualAt in
the ownership guide, merge the duplicate RowsEqualAt tests, flatten the
payload index reset and fix two misplaced test comments.

* perf(html): skip unchanged rows before reading them and reuse frame pages

Incremental Prepare compares each row with the committed shadow through
core.RowsEqualAt and reads only changed rows. Prepare copies core.Frame
sources into a spare page that Commit and Abort hand back, instead of cloning
every draw. One-row updates at 80x24 drop from 111us/47.8KB to 51us/14KB, and
from 1.10ms/351KB to 0.49ms/39KB at 240x80.

The run property test now expands every entry back into cells and checks text,
style and width against the frame, also on incremental updates. The ANSI shadow
property test writes wide pairs. README states the schema version shared by
updates and events, and the retained row-start scratch is bounded too.

* fix(html): keep long text runs on the column grid in Chromium

Chromium rounds grid column widths to 1/64 px while text advances by the
exact glyph width, so a 240-column run overshot its box by up to 3.7 px at
1.25 zoom and clipped its last glyph. The terminal measures the advance on
mount, resize and font load and sets letter-spacing to the difference; the
drift is under 0.05 px. A spec checks a 238-column run against the next cell.

applyAll's in-order fallback now replays the retained-text budget for the
whole batch before applying, so an overflow leaves the DOM unchanged. A spec
covers the maxStyles fallback, the overflow, empty and non-array batches and
an incremental first update. Remove the space-merge loop that schema v2 made
unreachable.

* fix(html): retain 240x80 encoder scratch and clear stale payload slots

The row-start scratch is sized for up to 80 rows up front, so frames taller
than 64 rows no longer drop the encoder scratch on every update. CopyFrom
clears the reused payload tail so graphemes and hyperlinks from a larger
earlier frame are not kept alive. The size probe is excluded from selection,
and copyFrame drops a branch that could not run.

A property test drives one renderer through edits with payloads and
equivalent styles, aborts, resets, stale commits and failed prepares, and
checks each update against a cell-by-cell diff of the committed shadow.

* docs(perf): add a benchmark and profiling guide

docs/performance.md lists the benchmarks per area, the baseline and benchstat
workflow, CPU and memory profiles, escape analysis, and the oracle and
property checks that a performance change must keep passing.

* test(oracle): report oracle dump write errors

The transcript dump now reports Flush and Close errors at the end of each
scenario instead of discarding them, which golangci-lint's errcheck flagged.
commit
15288c65f97a57e232db097583f1bed612e788de
author
bnema · · 2026-10-02 18:01 UTC
committer
GitHub · · 2026-10-02 18:01 UTC
parent
8f2d811
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
54 files · +5212 -317
M README.md +20 -2
@@ -100,15 +100,23 @@ optional compression during idle time.
100 The HTML renderer compares every row with its committed shadow; damage values are100 The HTML renderer compares every row with its committed shadow; damage values are
101 non-authoritative hints. `Prepare` permits one outstanding draw and requires an101 non-authoritative hints. `Prepare` permits one outstanding draw and requires an
102 explicit `Commit` or `Abort`. `Reset` invalidates retained prepared draws.102 explicit `Commit` or `Abort`. `Reset` invalidates retained prepared draws.
103-Updates are immutable, schema-versioned JSON-compatible values. Complete-row103+Updates are immutable JSON-compatible values at schema version 2; browser
104+events share that version, so a host must serve the matching `terminal.js`.
105+Complete-row
104 replacement preserves wide-cell atomicity, and scroll damage uses a safe106 replacement preserves wide-cell atomicity, and scroll damage uses a safe
105-snapshot fallback. `html.DefaultLimits()` documents the default 1,000,000-cell,107+snapshot fallback. Rows are encoded as text runs: adjacent printable ASCII
108+cells with one style form a single entry, while wide and non-ASCII characters
109+stay single entries so column alignment never depends on fallback fonts. `html.DefaultLimits()` documents the default 1,000,000-cell,
106 10,000-row, 64 MiB generated-update, and 65,536-style bounds.110 10,000-row, 64 MiB generated-update, and 65,536-style bounds.
107 111
108 The browser adapter builds DOM nodes with `textContent` and fixed classes. It112 The browser adapter builds DOM nodes with `textContent` and fixed classes. It
109 provides a labeled input proxy, synchronized plain-text accessible output,113 provides a labeled input proxy, synchronized plain-text accessible output,
110 typed CSS themes, IME-aware text input, keys, paste, pointer, wheel, resize, and114 typed CSS themes, IME-aware text input, keys, paste, pointer, wheel, resize, and
111 focus events. A synchronous consumer callback decides default prevention.115 focus events. A synchronous consumer callback decides default prevention.
116+`applyAll` validates a queue of updates and merges them before touching the
117+DOM, so a consumer that falls behind rebuilds each changed row once. Text runs
118+assume a monospace primary font whose ASCII advance is `1ch`, and
119+`maxTextBytes` bounds one run, so keep it at least as large as the widest row.
112 Consumers remain responsible for transport and mapping events to terminal bytes120 Consumers remain responsible for transport and mapping events to terminal bytes
113 or application actions. Clipboard text is preserved unchanged, including121 or application actions. Clipboard text is preserved unchanged, including
114 control bytes, so consumers forwarding paste events to a PTY must apply their122 control bytes, so consumers forwarding paste events to a PTY must apply their
@@ -154,6 +162,14 @@ npm run test:browser
154 npm run test:browser:docker162 npm run test:browser:docker
155 ```163 ```
156 164
165+`TestDifferentialOracle` replays seeded terminal workloads through the public
166+VT, history, graphics, ANSI and HTML APIs and compares one digest per layer with
167+`testdata/oracle/golden.txt`. Keep it unchanged for refactors and performance
168+work. For a deliberate behavior change, regenerate it on a full run with
169+`go test -run TestDifferentialOracle -oracle.update .` and explain the changed
170+columns. To locate a divergence, run `-oracle.dump=DIR` on both trees and diff
171+the scenario transcripts.
172+
157 Playwright 1.62.1 and its Chromium, Firefox, and WebKit revisions are pinned by173 Playwright 1.62.1 and its Chromium, Firefox, and WebKit revisions are pinned by
158 `package-lock.json`. The matching Playwright container provides the reproducible174 `package-lock.json`. The matching Playwright container provides the reproducible
159 fallback when host libraries cannot run one of those browser builds. Production175 fallback when host libraries cannot run one of those browser builds. Production
@@ -161,3 +177,5 @@ Go packages retain the module's standard-library-only dependency boundary apart
161 from `vev-vt/core`.177 from `vev-vt/core`.
162 178
163 [Storage benchmarks and design decisions →](docs/storage-optimization.md)179 [Storage benchmarks and design decisions →](docs/storage-optimization.md)
180+
181+[Measuring and profiling performance changes →](docs/performance.md)
M ansi/damage_plan.go +17 -2
@@ -38,7 +38,7 @@ func buildDamagePlan(frame CellSource, damage []Damage, skip *Damage) ([]Span, b
38 if len(spans) == maxPlannedDamageSpans {38 if len(spans) == maxPlannedDamageSpans {
39 return nil, true39 return nil, true
40 }40 }
41- spans = append(spans, Span{Y: row, X: x, Width: width})41+ spans = append(spans, wideAlignedSpan(frame, row, x, width))
42 }42 }
43 }43 }
44 44
@@ -62,11 +62,26 @@ func buildSingleDamageSpans(frame CellSource, d Damage) ([]Span, bool) {
62 }62 }
63 spans := make([]Span, height)63 spans := make([]Span, height)
64 for row := range height {64 for row := range height {
65- spans[row] = Span{Y: y + row, X: x, Width: width}65+ spans[row] = wideAlignedSpan(frame, y+row, x, width)
66 }66 }
67 return spans, false67 return spans, false
68 }68 }
69 69
70+// wideAlignedSpan widens a clamped span so it never splits a wide rune.
71+// Damage can name only one half of a pair, for example when text damage was
72+// recorded before a scroll moved a wide rune under it. Writing from the right
73+// half would erase the rune on the client, and the shadow must hold whole pairs.
74+func wideAlignedSpan(frame CellSource, y, x, width int) Span {
75+ if x > 0 && frame.Cell(x, y).Continuation {
76+ x--
77+ width++
78+ }
79+ if end := x + width; end < frame.Columns() && frame.Cell(end, y).Continuation {
80+ width++
81+ }
82+ return Span{Y: y, X: x, Width: width}
83+}
84+
70 func mergeDamageSpans(spans []Span) []Span {85 func mergeDamageSpans(spans []Span) []Span {
71 if len(spans) < 2 {86 if len(spans) < 2 {
72 return spans87 return spans
M ansi/delta_plan.go +36 -21
@@ -1,5 +1,7 @@
1 package ansi1 package ansi
2 2
3+import "github.com/bnema/vev-vt/core"
4+
3 // Span identifies a changed horizontal range in a logical frame row.5 // Span identifies a changed horizontal range in a logical frame row.
4 type Span struct {6 type Span struct {
5 Y int7 Y int
@@ -31,35 +33,45 @@ type DeltaCandidate struct {
31 frame Frame33 frame Frame
32 }34 }
33 35
34-func newDeltaCandidate(frame CellSource, plan DeltaPlan) DeltaCandidate {36+// newDeltaCandidate builds a candidate whose snapshot, when the plan has work to
37+// commit, is copied into scratch (reusing its storage) or into a fresh frame
38+// when scratch is nil.
39+func newDeltaCandidate(frame CellSource, plan DeltaPlan, scratch *Frame) DeltaCandidate {
35 candidate := DeltaCandidate{Plan: plan}40 candidate := DeltaCandidate{Plan: plan}
36 if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {41 if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
37- candidate.frame = cloneCellSource(frame)42+ candidate.frame = snapshotCellSource(scratch, frame)
38 }43 }
39 return candidate44 return candidate
40 }45 }
41 46
42-// PlanDelta prepares a non-mutating update from committed to frame.47+// PlanDelta prepares a non-mutating update from committed to frame. The
48+// returned candidate owns an independent snapshot of frame.
43 func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {49 func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {
50+ return planDelta(frame, damage, committed, reset, nil)
51+}
52+
53+// planDelta implements PlanDelta. A non-nil scratch receives the candidate's
54+// snapshot in place of a fresh allocation; the caller then owns its lifetime.
55+func planDelta(frame CellSource, damage []Damage, committed Frame, reset bool, scratch *Frame) (DeltaCandidate, error) {
44 if err := validateCellSource(frame); err != nil {56 if err := validateCellSource(frame); err != nil {
45 return DeltaCandidate{}, err57 return DeltaCandidate{}, err
46 }58 }
47 59
48 if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() {60 if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() {
49- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil61+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil
50 }62 }
51 if err := committed.Validate(); err != nil {63 if err := committed.Validate(); err != nil {
52 return DeltaCandidate{}, err64 return DeltaCandidate{}, err
53 }65 }
54 66
55 if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {67 if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
56- return newDeltaCandidate(frame, planSingleDamage(frame, damage[0])), nil68+ return newDeltaCandidate(frame, planSingleDamage(frame, damage[0]), scratch), nil
57 }69 }
58 70
59 if needsFull(damage) {71 if needsFull(damage) {
60 dirty, full := buildDirtyLinePlan(frame, committed)72 dirty, full := buildDirtyLinePlan(frame, committed)
61 if len(dirty) > 0 || full {73 if len(dirty) > 0 || full {
62- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}), nil74+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}, scratch), nil
63 }75 }
64 return DeltaCandidate{}, nil76 return DeltaCandidate{}, nil
65 }77 }
@@ -70,7 +82,7 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (
70 plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown}82 plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown}
71 skip = &scroll83 skip = &scroll
72 } else if hasScrollDamage(damage) {84 } else if hasScrollDamage(damage) {
73- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil85+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil
74 }86 }
75 87
76 var spans []Span88 var spans []Span
@@ -81,16 +93,16 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (
81 spans, full = buildDamagePlan(frame, damage, skip)93 spans, full = buildDamagePlan(frame, damage, skip)
82 }94 }
83 if full {95 if full {
84- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil96+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil
85 }97 }
86 if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) {98 if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) {
87 if plan.Scroll.Height != 0 {99 if plan.Scroll.Height != 0 {
88- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil100+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil
89 }101 }
90- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}), nil102+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}, scratch), nil
91 }103 }
92 plan.Spans = spans104 plan.Spans = spans
93- return newDeltaCandidate(frame, plan), nil105+ return newDeltaCandidate(frame, plan, scratch), nil
94 }106 }
95 107
96 func planSingleDamage(frame CellSource, d Damage) DeltaPlan {108 func planSingleDamage(frame CellSource, d Damage) DeltaPlan {
@@ -105,14 +117,19 @@ func planSingleDamage(frame CellSource, d Damage) DeltaPlan {
105 return plan117 return plan
106 }118 }
107 119
108-func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) {120+func buildDirtyLinePlan(frame CellSource, committed Frame) ([]Span, bool) {
109 var spans []Span121 var spans []Span
122+ dense, isDense := asFrame(frame)
110 for y := range frame.Rows() {123 for y := range frame.Rows() {
111 dirty := false124 dirty := false
112- for x := range frame.Columns() {125+ if isDense {
113- if !frame.Cell(x, y).Equal(committed.Cell(x, y)) {126+ dirty = !core.RowsEqualAt(dense, y, committed, y)
114- dirty = true127+ } else {
115- break128+ for x := range frame.Columns() {
129+ if !frame.Cell(x, y).Equal(committed.Cell(x, y)) {
130+ dirty = true
131+ break
132+ }
116 }133 }
117 }134 }
118 if dirty {135 if dirty {
@@ -198,7 +215,9 @@ func (c DeltaCandidate) Commit(dst *Frame) {
198 return215 return
199 }216 }
200 if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height {217 if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height {
201- replaceFrame(dst, c.frame)218+ // dst may be shared with the caller, so give it a fresh page rather
219+ // than overwriting storage other Frame values might still reference.
220+ dst.Replace(c.frame)
202 return221 return
203 }222 }
204 if c.Plan.Scroll.Height != 0 {223 if c.Plan.Scroll.Height != 0 {
@@ -215,7 +234,3 @@ func (c DeltaCandidate) Commit(dst *Frame) {
215 }234 }
216 }235 }
217 }236 }
218-
219-func replaceFrame(dst *Frame, src Frame) {
220- dst.Replace(src)
221-}
M ansi/frame.go +39 -6
@@ -30,20 +30,53 @@ func validateCellSource(source CellSource) error {
30 if source.Columns() <= 0 || source.Rows() <= 0 {30 if source.Columns() <= 0 || source.Rows() <= 0 {
31 return fmt.Errorf("invalid cell source size %dx%d", source.Columns(), source.Rows())31 return fmt.Errorf("invalid cell source size %dx%d", source.Columns(), source.Rows())
32 }32 }
33- switch frame := source.(type) {33+ if frame, ok := asFrame(source); ok {
34- case Frame:
35- return frame.Validate()
36- case *Frame:
37 return frame.Validate()34 return frame.Validate()
38 }35 }
39 return nil36 return nil
40 }37 }
41 38
42-func cloneCellSource(source CellSource) Frame {39+// snapshotCellSource returns an independent Frame holding source's cells. When
40+// scratch is non-nil the snapshot is written into scratch's existing storage
41+// (allocating only to grow or on first use) and the returned Frame shares that
42+// storage; the caller owns scratch and must not hand it to anyone else while
43+// the snapshot is live. A nil scratch allocates a fresh frame.
44+func snapshotCellSource(scratch *Frame, source CellSource) Frame {
45+ if scratch == nil {
46+ return cloneCellSource(source)
47+ }
48+ if frame, ok := asFrame(source); ok {
49+ scratch.CopyFrom(frame)
50+ return *scratch
51+ }
52+ columns, rows := source.Columns(), source.Rows()
53+ if scratch.Width != columns || scratch.Height != rows || scratch.Validate() != nil {
54+ *scratch = NewFrame(columns, rows)
55+ }
56+ for y := range rows {
57+ for x := range columns {
58+ scratch.Set(x, y, source.Cell(x, y))
59+ }
60+ }
61+ return *scratch
62+}
63+
64+// asFrame reports whether source is a compact core.Frame, directly or through
65+// a non-nil pointer, enabling stored-cell paths that bypass CellSource.
66+func asFrame(source CellSource) (Frame, bool) {
43 switch frame := source.(type) {67 switch frame := source.(type) {
44 case Frame:68 case Frame:
45- return frame.Clone()69+ return frame, true
46 case *Frame:70 case *Frame:
71+ if frame != nil {
72+ return *frame, true
73+ }
74+ }
75+ return Frame{}, false
76+}
77+
78+func cloneCellSource(source CellSource) Frame {
79+ if frame, ok := asFrame(source); ok {
47 return frame.Clone()80 return frame.Clone()
48 }81 }
49 clone := NewFrame(source.Columns(), source.Rows())82 clone := NewFrame(source.Columns(), source.Rows())
M ansi/output_min_test.go +6 -8
@@ -293,11 +293,10 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
293 }293 }
294 })294 })
295 295
296- t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {296+ t.Run("rect splitting a wide pair is widened to the whole pair", func(t *testing.T) {
297 // A damage rect that ends on a wide head whose continuation lies297 // A damage rect that ends on a wide head whose continuation lies
298- // outside the rect makes the terminal advance one column further than298+ // outside the rect is widened to cover the pair, so the terminal
299- // the rect width accounts for. Tracking must be dropped: the next299+ // advance matches the span and tracking stays exact.
300- // rect must be positioned with absolute CUP, not a relative move.
301 r := New(Capabilities{})300 r := New(Capabilities{})
302 base := NewFrame(8, 1)301 base := NewFrame(8, 1)
303 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {302 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
@@ -314,9 +313,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
314 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},313 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
315 }314 }
316 got := drawGolden(t, r, frame, damage)315 got := drawGolden(t, r, frame, damage)
317- // After A你 the real cursor is at col 3, not col 2: a relative CUF316+ // After A你 the cursor is known at col 3, so one CUF reaches col 4.
318- // would be off by one, so the second rect must use absolute CUP.317+ want := "\x1b[1;1HA你\x1b[CB\x1b[0m"
319- want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"
320 if got != want {318 if got != want {
321 t.Fatalf("output = %q, want %q", got, want)319 t.Fatalf("output = %q, want %q", got, want)
322 }320 }
@@ -425,7 +423,7 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
425 if err != nil {423 if err != nil {
426 t.Fatal(err)424 t.Fatal(err)
427 }425 }
428- if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want {426+ if got, want := string(out), "\x1b[1;1HA你\x1b[CB\x1b[0m"; got != want {
429 t.Fatalf("output = %q, want %q", got, want)427 t.Fatalf("output = %q, want %q", got, want)
430 }428 }
431 }429 }
M ansi/prepared_ownership_test.go +182 -0
@@ -1,6 +1,7 @@
1 package ansi1 package ansi
2 2
3 import (3 import (
4+ "runtime"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
@@ -23,3 +24,184 @@ func TestPreparedSnapshotCommitTransfersOnlyPrivatelyOwnedCells(t *testing.T) {
23 require.Equal(t, 'b', r.committed.Cell(0, 0).Rune, "a retained draw copy cannot recommit stale cells")24 require.Equal(t, 'b', r.committed.Cell(0, 0).Rune, "a retained draw copy cannot recommit stale cells")
24 require.Equal(t, encoded, alias.Bytes())25 require.Equal(t, encoded, alias.Bytes())
25 }26 }
27+
28+func TestRendererNeverAliasesCallerFrame(t *testing.T) {
29+ r := New(Capabilities{})
30+ frame := NewFrame(6, 3)
31+ markFrame(&frame)
32+ _, err := r.Draw(frame, []Damage{FullRedraw()})
33+ require.NoError(t, err)
34+
35+ // Mutate the caller's frame in place after commit without drawing, then
36+ // restore it: the renderer's committed state must not have followed it.
37+ original := frame.Cell(2, 1)
38+ frame.Set(2, 1, Cell{Rune: 'Q', Style: Style{Bold: true, Foreground: 1, Background: -1}})
39+ require.Equal(t, original.Rune, r.committed.Cell(2, 1).Rune)
40+ frame.Set(2, 1, original)
41+ prepared, err := r.Prepare(frame, nil, false)
42+ require.NoError(t, err)
43+ require.Empty(t, prepared.Bytes(), "committed state followed caller mutation")
44+ prepared.Commit()
45+
46+ // Mutate after Prepare and before Commit: the commit keeps the prepared
47+ // content, so the next draw re-emits the difference.
48+ frame.Set(0, 0, Cell{Rune: 'P', Style: DefaultStyle()})
49+ prepared, err = r.Prepare(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
50+ require.NoError(t, err)
51+ frame.Set(0, 0, Cell{Rune: 'R', Style: DefaultStyle()})
52+ prepared.Commit()
53+ require.Equal(t, 'P', r.committed.Cell(0, 0).Rune)
54+ out, err := r.Draw(frame, nil)
55+ require.NoError(t, err)
56+ require.Contains(t, string(out), "R")
57+ require.Equal(t, 'R', r.committed.Cell(0, 0).Rune)
58+ require.NoError(t, r.committed.CheckInvariants())
59+}
60+
61+func TestDiscardedPreparedDrawLeavesCommittedStateUnchanged(t *testing.T) {
62+ r := New(Capabilities{})
63+ frame := NewFrame(5, 3)
64+ markFrame(&frame)
65+ _, err := r.Draw(frame, []Damage{FullRedraw()})
66+ require.NoError(t, err)
67+ committed := r.committed.Clone()
68+
69+ for _, damage := range [][]Damage{
70+ {FullRedraw()},
71+ {{Kind: DamageText, X: 0, Y: 1, Width: 5, Height: 1}},
72+ nil,
73+ } {
74+ frame.Set(1, 1, Cell{Rune: '#', Style: Style{Italic: true, Foreground: 2, Background: -1}})
75+ discarded, err := r.Prepare(frame, damage, false)
76+ require.NoError(t, err)
77+ require.NotEmpty(t, discarded.Bytes())
78+ for y := range committed.Height {
79+ require.Equal(t, committed.Row(y), r.committed.Row(y))
80+ }
81+ // Preparing again after a discard produces the same bytes.
82+ again, err := r.Prepare(frame, damage, false)
83+ require.NoError(t, err)
84+ require.Equal(t, discarded.Bytes(), again.Bytes())
85+ discarded.Commit() // superseded by a later Prepare: must not apply
86+ for y := range committed.Height {
87+ require.Equal(t, committed.Row(y), r.committed.Row(y))
88+ }
89+ // The stale commit invalidated every outstanding draw, so committing
90+ // the newer one cannot re-establish a shadow; the next draw is a
91+ // full snapshot that leaves the shadow exact.
92+ again.Commit()
93+ require.False(t, r.hasCommitted)
94+ snapshot, err := r.Draw(frame, nil)
95+ require.NoError(t, err)
96+ require.NotEmpty(t, snapshot)
97+ require.Equal(t, '#', r.committed.Cell(1, 1).Rune)
98+ require.NoError(t, r.committed.CheckInvariants())
99+
100+ // Restore for the next scenario.
101+ frame.Set(1, 1, committed.Cell(1, 1))
102+ _, err = r.Draw(frame, nil)
103+ require.NoError(t, err)
104+ require.Equal(t, committed.Cell(1, 1).Rune, r.committed.Cell(1, 1).Rune)
105+ }
106+}
107+
108+func TestPreparedBytesSurviveLaterDraws(t *testing.T) {
109+ r := New(Capabilities{})
110+ frame := NewFrame(8, 2)
111+ markFrame(&frame)
112+ first, err := r.Prepare(frame, []Damage{FullRedraw()}, false)
113+ require.NoError(t, err)
114+ want := append([]byte(nil), first.Bytes()...)
115+ first.Commit()
116+ for i := range 5 {
117+ frame.Set(i, 0, Cell{Rune: rune('v' + i%3), Style: DefaultStyle()})
118+ _, err = r.Draw(frame, []Damage{{Kind: DamageText, X: i, Y: 0, Width: 1, Height: 1}})
119+ require.NoError(t, err)
120+ }
121+ require.Equal(t, want, first.Bytes())
122+}
123+
124+func TestRendererRepeatedDrawsMatchFreshRenderer(t *testing.T) {
125+ // A long mixed sequence on one renderer (buffer reuse, resizes, reset,
126+ // scroll) must keep emitting exactly what a renderer with an exact
127+ // reference committed frame would: full redraw after each step equals
128+ // the fresh-renderer snapshot, and no-damage diffs are empty.
129+ r := New(Capabilities{})
130+ frame := NewFrame(7, 4)
131+ markFrame(&frame)
132+ steps := []func(){
133+ func() { frame.Set(3, 2, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 4, Background: -1}}) },
134+ func() { frame.ScrollUp(0, 3, 1); frame.FillRow(3, 0, 7, Cell{Rune: 'n', Style: DefaultStyle()}) },
135+ func() { frame = NewFrame(9, 5); markFrame(&frame) },
136+ func() { frame.Set(8, 4, Cell{Rune: 'z', Style: DefaultStyle()}) },
137+ func() {
138+ frame = NewFrame(7, 4)
139+ markFrame(&frame)
140+ frame.Set(0, 0, Cell{Rune: 'k', Style: DefaultStyle()})
141+ },
142+ }
143+ for i, step := range steps {
144+ step()
145+ if i == 3 {
146+ r.Reset()
147+ }
148+ _, err := r.Draw(frame, nil)
149+ require.NoError(t, err)
150+ for y := range frame.Height {
151+ require.Equal(t, frame.Row(y), r.committed.Row(y), "step %d row %d", i, y)
152+ }
153+ out, err := r.Draw(frame, nil)
154+ require.NoError(t, err)
155+ require.Empty(t, out, "step %d", i)
156+ // Reset keeps equivalence with a brand new renderer.
157+ r2 := New(Capabilities{})
158+ want, err := r2.Draw(frame, nil)
159+ require.NoError(t, err)
160+ r.Reset()
161+ got, err := r.Draw(frame, nil)
162+ require.NoError(t, err)
163+ require.Equal(t, want, got, "step %d", i)
164+ }
165+}
166+
167+func TestPrepareCommitDoesNotAllocateFrameStorage(t *testing.T) {
168+ r := New(Capabilities{})
169+ frame := NewFrame(120, 40)
170+ markFrame(&frame)
171+ _, err := r.Draw(frame, []Damage{FullRedraw()})
172+ require.NoError(t, err)
173+ _, err = r.Draw(frame, []Damage{FullRedraw()})
174+ require.NoError(t, err)
175+
176+ // A whole frame is ~76 KB; steady-state draws must stay far below it.
177+ toggled := false
178+ damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}
179+ allocs := testing.AllocsPerRun(20, func() {
180+ toggled = !toggled
181+ ch := 'X'
182+ if toggled {
183+ ch = 'Y'
184+ }
185+ frame.Set(60, 20, Cell{Rune: ch, Style: DefaultStyle()})
186+ prepared, err := r.Prepare(frame, damage, false)
187+ if err != nil {
188+ t.Fatal(err)
189+ }
190+ prepared.Commit()
191+ })
192+ require.LessOrEqual(t, allocs, 8.0)
193+ var m1, m2 runtime.MemStats
194+ runtime.ReadMemStats(&m1)
195+ for range 50 {
196+ frame.Set(60, 20, Cell{Rune: 'W', Style: DefaultStyle()})
197+ prepared, err := r.Prepare(frame, damage, false)
198+ require.NoError(t, err)
199+ prepared.Commit()
200+ frame.Set(60, 20, Cell{Rune: 'V', Style: DefaultStyle()})
201+ prepared, err = r.Prepare(frame, damage, false)
202+ require.NoError(t, err)
203+ prepared.Commit()
204+ }
205+ runtime.ReadMemStats(&m2)
206+ require.Less(t, (m2.TotalAlloc-m1.TotalAlloc)/100, uint64(8*1024), "per-draw bytes allocated")
207+}
M ansi/renderer.go +58 -26
@@ -24,6 +24,12 @@ type Renderer struct {
24 height int24 height int
25 hasCommitted bool25 hasCommitted bool
26 committed Frame26 committed Frame
27+ // scratch receives the snapshot of the frame being prepared. It never
28+ // aliases committed or caller storage: a snapshot commit swaps the two so
29+ // both buffers are reused across draws without allocating.
30+ scratch Frame
31+ // generation identifies the latest prepared draw that owns scratch.
32+ generation uint64
27 }33 }
28 34
29 // PreparedDraw owns encoded output and its transactional delta until Commit.35 // PreparedDraw owns encoded output and its transactional delta until Commit.
@@ -32,6 +38,7 @@ type PreparedDraw struct {
32 renderer *Renderer38 renderer *Renderer
33 candidate DeltaCandidate39 candidate DeltaCandidate
34 data []byte40 data []byte
41+ generation uint64
35 commitOnce *sync.Once42 commitOnce *sync.Once
36 }43 }
37 44
@@ -44,11 +51,13 @@ func NewWithColorProfile(caps Capabilities, profile ColorProfile) *Renderer {
44 return &Renderer{caps: caps, colorProfile: profile}51 return &Renderer{caps: caps, colorProfile: profile}
45 }52 }
46 53
54+// Reset forgets the committed shadow and invalidates every outstanding
55+// prepared draw. The private frame buffers are kept for reuse.
47 func (r *Renderer) Reset() {56 func (r *Renderer) Reset() {
48 r.width = 057 r.width = 0
49 r.height = 058 r.height = 0
50 r.hasCommitted = false59 r.hasCommitted = false
51- r.committed = Frame{}60+ r.generation++
52 }61 }
53 62
54 // Bytes returns the prepared ANSI output. The returned bytes remain valid after63 // Bytes returns the prepared ANSI output. The returned bytes remain valid after
@@ -57,46 +66,80 @@ func (p PreparedDraw) Bytes() []byte { return p.data }
57 66
58 // Commit applies the prepared delta exactly once. Discarding it leaves the67 // Commit applies the prepared delta exactly once. Discarding it leaves the
59 // renderer's committed state unchanged.68 // renderer's committed state unchanged.
69+//
70+// Committing a draw that a later Prepare has superseded does not apply it:
71+// the renderer instead drops its committed state, so the next Prepare
72+// re-emits a full snapshot instead of diffing against a shadow that may no
73+// longer match the terminal. Draws without output are unaffected.
60 func (p *PreparedDraw) Commit() {74 func (p *PreparedDraw) Commit() {
61 if p == nil || p.renderer == nil || p.commitOnce == nil {75 if p == nil || p.renderer == nil || p.commitOnce == nil {
62 return76 return
63 }77 }
64 p.commitOnce.Do(func() {78 p.commitOnce.Do(func() {
65- committed := p.renderer.committedFrame()79+ r := p.renderer
66- if p.candidate.Plan.Snapshot {80+ plan := p.candidate.Plan
67- // PreparedDraw privately owns this snapshot. Its shared Once makes81+ if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
68- // transfer safe even when the caller retains copies of the draw.82+ return
69- committed = p.candidate.frame83+ }
84+ if p.generation != r.generation {
85+ // A later Prepare or Reset reused the scratch frame this draw's
86+ // snapshot lives in, so it cannot be applied. Its bytes may
87+ // nevertheless have reached the terminal, which would then differ
88+ // from the committed shadow in unknown ways: forget the shadow and
89+ // invalidate every outstanding draw, so even a delta prepared
90+ // after this one cannot re-establish a shadow on commit and the
91+ // next Prepare emits a full snapshot.
92+ r.hasCommitted = false
93+ r.generation++
94+ return
95+ }
96+ if !plan.Snapshot && !r.hasCommitted {
97+ // Unreachable for current draws: Prepare plans a snapshot whenever
98+ // no shadow exists, and every path that drops the shadow also
99+ // advances the generation. Keep the shadow dropped defensively.
100+ return
101+ }
102+ if plan.Snapshot {
103+ // The snapshot lives in the renderer's scratch buffer; promote it
104+ // and recycle the previous committed buffer as the next scratch.
105+ r.committed, r.scratch = p.candidate.frame, r.committed
70 } else {106 } else {
71- p.candidate.Commit(&committed)107+ p.candidate.Commit(&r.committed)
72 }108 }
73- p.renderer.setCommittedFrame(committed)109+ r.width = r.committed.Width
110+ r.height = r.committed.Height
111+ r.hasCommitted = true
74 })112 })
75 }113 }
76 114
77 // Prepare plans and encodes a transactional draw. The renderer advances only115 // Prepare plans and encodes a transactional draw. The renderer advances only
78 // when the returned draw is committed. Keep at most one prepared draw116 // when the returned draw is committed. Keep at most one prepared draw
79-// outstanding; commit or discard it before calling Prepare again.117+// outstanding; commit or discard it before calling Prepare again. A draw
118+// superseded by a later Prepare is not applied: committing it forces the next
119+// Prepare to emit a full snapshot.
120+//
121+// The renderer copies frame into reusable private storage, so the caller may
122+// keep mutating frame between draws.
80 func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) {123 func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) {
81 var candidate DeltaCandidate124 var candidate DeltaCandidate
82 var err error125 var err error
83 if err = validateCellSource(frame); err != nil {126 if err = validateCellSource(frame); err != nil {
84 return PreparedDraw{}, err127 return PreparedDraw{}, err
85 }128 }
129+ // Any earlier prepared draw loses its claim on the scratch snapshot, even
130+ // if this Prepare fails after partially rewriting it.
131+ r.generation++
86 columns, rows := frame.Columns(), frame.Rows()132 columns, rows := frame.Columns(), frame.Rows()
87 if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {133 if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
88 plan := planSingleDamage(frame, damage[0])134 plan := planSingleDamage(frame, damage[0])
89- candidate = DeltaCandidate{Plan: plan}135+ candidate = newDeltaCandidate(frame, plan, &r.scratch)
90- if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
91- candidate.frame = cloneCellSource(frame)
92- }
93 } else {136 } else {
94- candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted)137+ candidate, err = planDelta(frame, damage, r.committed, reset || !r.hasCommitted, &r.scratch)
95 }138 }
96 if err != nil {139 if err != nil {
97 return PreparedDraw{}, err140 return PreparedDraw{}, err
98 }141 }
99- prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)}142+ prepared := PreparedDraw{renderer: r, candidate: candidate, generation: r.generation, commitOnce: new(sync.Once)}
100 plan := candidate.Plan143 plan := candidate.Plan
101 if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {144 if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
102 return prepared, nil145 return prepared, nil
@@ -149,17 +192,6 @@ func (r *Renderer) Draw(frame CellSource, damage []Damage) ([]byte, error) {
149 return prepared.Bytes(), nil192 return prepared.Bytes(), nil
150 }193 }
151 194
152-func (r *Renderer) committedFrame() Frame {
153- return r.committed
154-}
155-
156-func (r *Renderer) setCommittedFrame(frame Frame) {
157- r.width = frame.Width
158- r.height = frame.Height
159- r.hasCommitted = true
160- r.committed = frame
161-}
162-
163 // copyBytes copies the buffer contents into a fresh byte slice and is used195 // copyBytes copies the buffer contents into a fresh byte slice and is used
164 // to return output that is independent of the pooled scratch buffer.196 // to return output that is independent of the pooled scratch buffer.
165 func copyBytes(buf *bytes.Buffer) []byte {197 func copyBytes(buf *bytes.Buffer) []byte {
A ansi/replay_property_test.go +115 -0
@@ -0,0 +1,115 @@
1+package ansi_test
2+
3+import (
4+ "fmt"
5+ "math/rand/v2"
6+ "strings"
7+ "testing"
8+
9+ vt "github.com/bnema/vev-vt"
10+ "github.com/bnema/vev-vt/ansi"
11+ core "github.com/bnema/vev-vt/core"
12+)
13+
14+type hiddenCellSource struct{ core.CellSource }
15+
16+// Random VT scroll workloads rendered with real screen damage must replay to
17+// the exact source grid for every CellSource shape the renderer dispatches on.
18+func TestRandomScrollWorkloadsReplayExactly(t *testing.T) {
19+ seeds := 400
20+ if testing.Short() {
21+ seeds = 60
22+ }
23+ for _, kind := range []string{"screen", "frame", "pointer", "hidden"} {
24+ t.Run(kind, func(t *testing.T) {
25+ for seed := range seeds {
26+ runReplayWorkload(t, kind, uint64(seed))
27+ }
28+ })
29+ }
30+}
31+
32+func runReplayWorkload(t *testing.T, kind string, seed uint64) {
33+ t.Helper()
34+ r := rand.New(rand.NewPCG(seed, 0x5c4011))
35+ cols, rows := 4+r.IntN(14), 3+r.IntN(10)
36+ s := vt.NewScreen(cols, rows)
37+ renderer := ansi.New(ansi.Capabilities{})
38+ replay := vt.NewScreen(cols, rows)
39+ mirror := core.NewFrame(cols, rows)
40+ for step := range 30 {
41+ input := randomScrollInput(r, cols, rows)
42+ s.Write(input)
43+ var source core.CellSource = s
44+ if kind != "screen" {
45+ for y := range rows {
46+ for x := range cols {
47+ mirror.Set(x, y, s.Cell(x, y))
48+ }
49+ }
50+ switch kind {
51+ case "frame":
52+ source = mirror
53+ case "pointer":
54+ source = &mirror
55+ default:
56+ source = hiddenCellSource{mirror}
57+ }
58+ }
59+ prepared, err := renderer.Prepare(source, s.Damage(), false)
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ prepared.Commit()
64+ replay.Write(prepared.Bytes())
65+ s.ClearDamage()
66+ for y := range rows {
67+ for x := range cols {
68+ want, got := s.Cell(x, y), replay.Cell(x, y)
69+ if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) {
70+ t.Fatalf("seed %d step %d input %q: cell (%d,%d) = %+v, want %+v", seed, step, input, x, y, got, want)
71+ }
72+ }
73+ }
74+ }
75+}
76+
77+func randomScrollInput(r *rand.Rand, cols, rows int) []byte {
78+ var b strings.Builder
79+ for range 1 + r.IntN(12) {
80+ switch r.IntN(14) {
81+ case 0:
82+ top := 1 + r.IntN(rows)
83+ fmt.Fprintf(&b, "\x1b[%d;%dr", top, top+r.IntN(rows-top+1))
84+ case 1:
85+ b.WriteString("\x1b[r")
86+ case 2:
87+ b.WriteString("\x1bD")
88+ case 3:
89+ b.WriteString("\x1bM")
90+ case 4:
91+ fmt.Fprintf(&b, "\x1b[%dS", 1+r.IntN(3))
92+ case 5:
93+ fmt.Fprintf(&b, "\x1b[%dT", 1+r.IntN(3))
94+ case 6:
95+ fmt.Fprintf(&b, "\x1b[%dL", 1+r.IntN(3))
96+ case 7:
97+ fmt.Fprintf(&b, "\x1b[%dM", 1+r.IntN(3))
98+ case 8:
99+ b.WriteString("\r\n")
100+ case 9:
101+ b.WriteString([]string{"\x1b[K", "\x1b[1K", "\x1b[2K", "\x1b[J"}[r.IntN(4)])
102+ case 10:
103+ fmt.Fprintf(&b, "\x1b[%dm", []int{0, 1, 7, 31, 42}[r.IntN(5)])
104+ case 11:
105+ fmt.Fprintf(&b, "\x1b[%d;%dH", 1+r.IntN(rows), 1+r.IntN(cols))
106+ case 12:
107+ b.WriteString("界")
108+ default:
109+ for range 1 + r.IntN(cols) {
110+ b.WriteByte(byte('a' + r.IntN(26)))
111+ }
112+ }
113+ }
114+ return []byte(b.String())
115+}
M ansi/scroll.go +39 -5
@@ -3,6 +3,8 @@ package ansi
3 import (3 import (
4 "bytes"4 "bytes"
5 "strconv"5 "strconv"
6+
7+ "github.com/bnema/vev-vt/core"
6 )8 )
7 9
8 func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) {10 func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) {
@@ -67,11 +69,15 @@ func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, com
67 }69 }
68 }70 }
69 }71 }
70- switch frame := frame.(type) {72+ // The plan emits one scroll plus text/clear spans. Rows outside the chosen
71- case Frame:73+ // region must therefore already match the committed shadow wherever damage
72- return canApplyDenseScrollAgainst(frame, scroll, damage, committed)74+ // does not repaint them; another scroll region in the same batch moves rows
73- case *Frame:75+ // without text damage and must fall back to a snapshot.
74- return canApplyDenseScrollAgainst(*frame, scroll, damage, committed)76+ if !outsideScrollMatches(frame, scroll, damage, committed) {
77+ return false
78+ }
79+ if dense, ok := asFrame(frame); ok {
80+ return canApplyDenseScrollAgainst(dense, scroll, damage, committed)
75 }81 }
76 start, end, offset := scrollRetainedRows(scroll)82 start, end, offset := scrollRetainedRows(scroll)
77 for y := start; y < end; y++ {83 for y := start; y < end; y++ {
@@ -92,6 +98,11 @@ func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, com
92 func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {98 func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {
93 start, end, offset := scrollRetainedRows(scroll)99 start, end, offset := scrollRetainedRows(scroll)
94 for y := start; y < end; y++ {100 for y := start; y < end; y++ {
101+ // isSafeScroll guarantees full-width scrolls, so a retained row that
102+ // matches whole needs no per-cell damage lookups.
103+ if core.RowsEqualAt(committed, y+offset, frame, y) {
104+ continue
105+ }
95 for x := range scroll.Width {106 for x := range scroll.Width {
96 column := scroll.X + x107 column := scroll.X + x
97 committedCell, frameCell := committed.At(column, y+offset), frame.At(column, y)108 committedCell, frameCell := committed.At(column, y+offset), frame.At(column, y)
@@ -106,6 +117,29 @@ func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, com
106 return true117 return true
107 }118 }
108 119
120+func outsideScrollMatches(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool {
121+ dense, isDense := asFrame(frame)
122+ columns := frame.Columns()
123+ for y := range frame.Rows() {
124+ if y >= scroll.Y && y < scroll.Y+scroll.Height {
125+ continue
126+ }
127+ if isDense && core.RowsEqualAt(committed, y, dense, y) {
128+ continue
129+ }
130+ for x := range columns {
131+ committedCell, frameCell := committed.Cell(x, y), frame.Cell(x, y)
132+ if committedCell == frameCell || committedCell.Equal(frameCell) {
133+ continue
134+ }
135+ if !damageCoversCell(damage, x, y) {
136+ return false
137+ }
138+ }
139+ }
140+ return true
141+}
142+
109 func scrollRetainedRows(scroll Damage) (start, end, offset int) {143 func scrollRetainedRows(scroll Damage) (start, end, offset int) {
110 if scroll.Kind == DamageScrollDown {144 if scroll.Kind == DamageScrollDown {
111 return scroll.Y + scroll.Count, scroll.Y + scroll.Height, -scroll.Count145 return scroll.Y + scroll.Count, scroll.Y + scroll.Height, -scroll.Count
M ansi/scroll_down_test.go +109 -0
@@ -77,3 +77,112 @@ func TestDownwardScrollMismatchFallsBack(t *testing.T) {
77 }77 }
78 }78 }
79 }79 }
80+
81+// A stale commit followed by the current draw's commit, or a Reset between
82+// Prepare and Commit, must never leave a shadow that disagrees with the
83+// terminal: the next Prepare is a full snapshot.
84+func TestCommitAfterStaleCommitOrResetKeepsTerminalExact(t *testing.T) {
85+ for _, tc := range []string{"stale-then-current", "reset-between"} {
86+ t.Run(tc, func(t *testing.T) {
87+ frame := vt.NewFrame(10, 4)
88+ for y := range frame.Height {
89+ frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: rune('a' + y), Style: vt.DefaultStyle()})
90+ }
91+ r := ansi.New(ansi.Capabilities{})
92+ terminal := vt.NewScreen(frame.Width, frame.Height)
93+ initial, err := r.Draw(frame, nil)
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ terminal.Write(initial)
98+
99+ frame.Set(1, 1, vt.Cell{Rune: 'A', Style: vt.DefaultStyle()})
100+ a, err := r.Prepare(frame, []vt.Damage{{Kind: vt.DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false)
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+ terminal.Write(a.Bytes())
105+ // The caller reverts the cell before the next draw; only row 2 is damaged.
106+ frame.Set(1, 1, vt.Cell{Rune: 'b', Style: vt.DefaultStyle()})
107+ frame.Set(2, 2, vt.Cell{Rune: 'B', Style: vt.DefaultStyle()})
108+ if tc == "reset-between" {
109+ r.Reset()
110+ a.Commit()
111+ } else {
112+ b, err := r.Prepare(frame, []vt.Damage{{Kind: vt.DamageText, X: 2, Y: 2, Width: 1, Height: 1}}, false)
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+ terminal.Write(b.Bytes())
117+ a.Commit()
118+ b.Commit()
119+ }
120+ next, err := r.Prepare(frame, nil, false)
121+ if err != nil {
122+ t.Fatal(err)
123+ }
124+ terminal.Write(next.Bytes())
125+ next.Commit()
126+ for y := range frame.Height {
127+ for x := range frame.Width {
128+ if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) {
129+ t.Fatalf("cell %d,%d = %+v, want %+v", x, y, terminal.Cell(x, y), frame.Cell(x, y))
130+ }
131+ }
132+ }
133+ })
134+ }
135+}
136+
137+func TestStaleCommitForcesSnapshotAndReplaysCorrectly(t *testing.T) {
138+ for _, deliverB := range []bool{false, true} {
139+ frame := vt.NewFrame(10, 4)
140+ for y := range frame.Height {
141+ frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: rune('a' + y), Style: vt.DefaultStyle()})
142+ }
143+ r := ansi.New(ansi.Capabilities{})
144+ terminal := vt.NewScreen(frame.Width, frame.Height)
145+ initial, err := r.Draw(frame, nil)
146+ if err != nil {
147+ t.Fatal(err)
148+ }
149+ terminal.Write(initial)
150+
151+ frame.Set(1, 1, vt.Cell{Rune: 'A', Style: vt.DefaultStyle()})
152+ a, err := r.Prepare(frame, []vt.Damage{{Kind: vt.DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false)
153+ if err != nil {
154+ t.Fatal(err)
155+ }
156+ terminal.Write(a.Bytes())
157+ frame.Set(2, 2, vt.Cell{Rune: 'B', Style: vt.Style{Bold: true, Foreground: 2, Background: -1}})
158+ b, err := r.Prepare(frame, []vt.Damage{{Kind: vt.DamageText, X: 2, Y: 2, Width: 1, Height: 1}}, false)
159+ if err != nil {
160+ t.Fatal(err)
161+ }
162+ if deliverB {
163+ terminal.Write(b.Bytes())
164+ }
165+ a.Commit() // superseded by b: must drop the committed shadow
166+
167+ next, err := r.Prepare(frame, nil, false)
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ if len(next.Bytes()) == 0 || !bytes.Contains(next.Bytes(), []byte("a")) || !bytes.Contains(next.Bytes(), []byte("d")) {
172+ t.Fatalf("deliverB=%v: next prepare is not a full snapshot: %q", deliverB, next.Bytes())
173+ }
174+ terminal.Write(next.Bytes())
175+ next.Commit()
176+ for y := range frame.Height {
177+ for x := range frame.Width {
178+ if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) {
179+ t.Fatalf("deliverB=%v: cell %d,%d = %+v, want %+v", deliverB, x, y, terminal.Cell(x, y), frame.Cell(x, y))
180+ }
181+ }
182+ }
183+ unchanged, err := r.Draw(frame, nil)
184+ if err != nil || len(unchanged) != 0 {
185+ t.Fatalf("committed shadow differs after snapshot: %q, %v", unchanged, err)
186+ }
187+ }
188+}
A ansi/scroll_multi_region_test.go +83 -0
@@ -0,0 +1,83 @@
1+package ansi_test
2+
3+import (
4+ "testing"
5+
6+ vt "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev-vt/ansi"
8+ core "github.com/bnema/vev-vt/core"
9+)
10+
11+// Two scroll regions in one damage batch cannot be expressed as one scroll
12+// plus text spans; the renderer must still reproduce the source exactly.
13+func TestTwoScrollRegionsInOneBatchReplayExactly(t *testing.T) {
14+ for _, sourceKind := range []string{"screen", "frame", "pointer"} {
15+ t.Run(sourceKind, func(t *testing.T) {
16+ s := vt.NewScreen(10, 8)
17+ s.Write([]byte("\x1b[1;1Ha0\r\na1\r\na2\r\na3\r\nb4\r\nb5\r\nb6\r\nb7"))
18+ r := ansi.New(ansi.Capabilities{})
19+ replay := vt.NewScreen(10, 8)
20+ draw := func() {
21+ var source core.CellSource = s
22+ if sourceKind != "screen" {
23+ frame := core.NewFrame(10, 8)
24+ for y := range 8 {
25+ for x := range 10 {
26+ frame.Set(x, y, s.Cell(x, y))
27+ }
28+ }
29+ source = frame
30+ if sourceKind == "pointer" {
31+ source = &frame
32+ }
33+ }
34+ prepared, err := r.Prepare(source, s.Damage(), false)
35+ if err != nil {
36+ t.Fatal(err)
37+ }
38+ prepared.Commit()
39+ replay.Write(prepared.Bytes())
40+ s.ClearDamage()
41+ }
42+ draw()
43+ s.Write([]byte("\x1b[1;4r\x1b[4;1H\nX\x1b[5;8r\x1b[8;1H\n\nY\x1b[r"))
44+ draw()
45+ requireReplayMatches(t, s, replay)
46+ })
47+ }
48+}
49+
50+// Text damage recorded before a scroll can start on the right half of a wide
51+// rune that the scroll moved there; the renderer must repaint the rune.
52+func TestSpanStartingOnWideContinuationRepaintsHead(t *testing.T) {
53+ s := vt.NewScreen(6, 3)
54+ r := ansi.New(ansi.Capabilities{})
55+ replay := vt.NewScreen(6, 3)
56+ draw := func() {
57+ prepared, err := r.Prepare(s, s.Damage(), false)
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ prepared.Commit()
62+ replay.Write(prepared.Bytes())
63+ s.ClearDamage()
64+ }
65+ draw()
66+ s.Write([]byte("\x1b[2;1H界"))
67+ draw()
68+ s.Write([]byte("\x1b[1;2Hab\x1b[1;2r\x1b[2;1H\n\x1b[r"))
69+ draw()
70+ requireReplayMatches(t, s, replay)
71+}
72+
73+func requireReplayMatches(t *testing.T, source, replay *vt.Screen) {
74+ t.Helper()
75+ for y := range source.Rows() {
76+ for x := range source.Columns() {
77+ want, got := source.Cell(x, y), replay.Cell(x, y)
78+ if want.Rune != got.Rune || want.Continuation != got.Continuation || !want.Style.Equal(got.Style) {
79+ t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want)
80+ }
81+ }
82+ }
83+}
A ansi/shadow_helpers_test.go +16 -0
@@ -0,0 +1,16 @@
1+package ansi
2+
3+// committedFrame returns the renderer's committed frame (sharing its storage).
4+func (r *Renderer) committedFrame() Frame { return r.committed }
5+
6+// setCommittedFrame installs frame as the committed shadow.
7+func (r *Renderer) setCommittedFrame(frame Frame) {
8+ r.width = frame.Width
9+ r.height = frame.Height
10+ r.hasCommitted = true
11+ r.committed = frame
12+}
13+
14+// replaceFrame overwrites dst's own storage with a copy of src, reusing
15+// capacity.
16+func replaceFrame(dst *Frame, src Frame) { dst.CopyFrom(src) }
A ansi/shadow_property_test.go +231 -0
@@ -0,0 +1,231 @@
1+package ansi
2+
3+import (
4+ "fmt"
5+ "math/rand/v2"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/vev-vt/core"
10+)
11+
12+// genericSource hides the concrete core.Frame type so the renderer must use
13+// the generic CellSource paths.
14+type genericSource struct{ f Frame }
15+
16+func (g genericSource) Columns() int { return g.f.Columns() }
17+func (g genericSource) Rows() int { return g.f.Rows() }
18+func (g genericSource) Cell(x, y int) Cell { return g.f.Cell(x, y) }
19+
20+var propertyStyles = []Style{
21+ DefaultStyle(),
22+ {Bold: true, Foreground: 1, Background: -1},
23+ {Italic: true, Foreground: -1, Background: 4},
24+ {Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: core.RGB{R: 9, G: 8, B: 7}},
25+ {Inverse: true, Foreground: 2, Background: 3},
26+}
27+
28+func propertyCell(r *rand.Rand) Cell {
29+ c := Cell{Rune: rune('a' + r.IntN(6)), Style: propertyStyles[r.IntN(len(propertyStyles))]}
30+ if r.IntN(12) == 0 {
31+ if p, err := core.NewCellPayload("e\u0301", "https://example.test/"+string(rune('a'+r.IntN(3)))); err == nil {
32+ c.Payload = p
33+ }
34+ }
35+ return c
36+}
37+
38+// propertyWrite writes one random cell at (x, y). Some writes are a wide
39+// pair, so span widening runs against the reused shadow buffers. A write that
40+// lands on half of an existing pair blanks the other half to keep the frame
41+// valid.
42+func propertyWrite(r *rand.Rand, frame *Frame, w, h int) {
43+ x, y := r.IntN(w), r.IntN(h)
44+ clearPair := func(x int) {
45+ if x < 0 || x >= w {
46+ return
47+ }
48+ cell := frame.Cell(x, y)
49+ if cell.Continuation && x > 0 {
50+ frame.Set(x-1, y, Cell{Rune: ' '})
51+ frame.Set(x, y, Cell{Rune: ' '})
52+ } else if core.RuneWidth(cell.Rune) == 2 && x+1 < w {
53+ frame.Set(x, y, Cell{Rune: ' '})
54+ frame.Set(x+1, y, Cell{Rune: ' '})
55+ }
56+ }
57+ if r.IntN(4) == 0 && x+1 < w {
58+ clearPair(x)
59+ clearPair(x + 1)
60+ style := propertyStyles[r.IntN(len(propertyStyles))]
61+ frame.Set(x, y, Cell{Rune: '界', Style: style})
62+ frame.Set(x+1, y, Cell{Continuation: true, Style: style})
63+ return
64+ }
65+ clearPair(x)
66+ frame.Set(x, y, propertyCell(r))
67+}
68+
69+func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage {
70+ d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)}
71+ d.Width = 1 + r.IntN(w-d.X)
72+ d.Height = 1 + r.IntN(h-d.Y)
73+ if kind == DamageScrollUp || kind == DamageScrollDown {
74+ d.X, d.Width = 0, w
75+ d.Count = 1 + r.IntN(max(1, d.Height))
76+ }
77+ return d
78+}
79+
80+func propertyDamage(r *rand.Rand, w, h int) []Damage {
81+ switch r.IntN(6) {
82+ case 0:
83+ return nil
84+ case 1:
85+ return []Damage{FullRedraw()}
86+ case 2:
87+ return []Damage{propertyRect(r, w, h, DamageText)}
88+ case 3:
89+ return []Damage{propertyRect(r, w, h, DamageClear)}
90+ case 4:
91+ return []Damage{propertyRect(r, w, h, DamageScrollUp), propertyRect(r, w, h, DamageText)}
92+ default:
93+ out := []Damage{propertyRect(r, w, h, DamageScrollDown)}
94+ for range r.IntN(4) {
95+ out = append(out, propertyRect(r, w, h, DamageText))
96+ }
97+ return out
98+ }
99+}
100+
101+// TestShadowMatchesCloneReference drives one renderer (reused buffers) and a
102+// reference built from the public clone-based PlanDelta/Commit pair through
103+// random edits, under-reported damage, scrolls, resets, resizes, discarded
104+// draws and stale commits. The committed shadows must always agree.
105+func TestShadowMatchesCloneReference(t *testing.T) {
106+ for seed := range uint64(40) {
107+ t.Run(fmt.Sprint(seed), func(t *testing.T) {
108+ r := rand.New(rand.NewPCG(seed, 0xa11ce))
109+ rend := New(Capabilities{})
110+ var ref Frame
111+ refHas := false
112+ w, h := 3+r.IntN(10), 2+r.IntN(8)
113+ frame := NewFrame(w, h)
114+ var outstanding *PreparedDraw // last prepared draw, for stale commits
115+
116+ for step := range 150 {
117+ // Mutate the caller's single frame in place.
118+ switch r.IntN(8) {
119+ case 0:
120+ w, h = 3+r.IntN(10), 2+r.IntN(8)
121+ frame = NewFrame(w, h)
122+ case 1:
123+ top := r.IntN(h)
124+ bottom := top + r.IntN(h-top)
125+ n := 1 + r.IntN(bottom-top+1)
126+ if r.IntN(2) == 0 {
127+ frame.ScrollUp(top, bottom, n)
128+ } else {
129+ frame.ScrollDown(top, bottom, n)
130+ }
131+ }
132+ for range r.IntN(12) {
133+ propertyWrite(r, &frame, w, h)
134+ }
135+ damage := propertyDamage(r, w, h)
136+ reset := r.IntN(15) == 0
137+
138+ var source CellSource = frame
139+ switch r.IntN(4) {
140+ case 0:
141+ source = &frame
142+ case 1:
143+ source = genericSource{frame}
144+ }
145+
146+ // Reference plan against a clone-based committed shadow.
147+ refReset := reset || !refHas
148+ want, err := PlanDelta(source, damage, ref, refReset)
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ prepared, err := rend.Prepare(source, damage, reset)
153+ if err != nil {
154+ t.Fatal(err)
155+ }
156+ if !reflect.DeepEqual(prepared.candidate.Plan, want.Plan) {
157+ t.Fatalf("step %d: plan %+v, reference %+v", step, prepared.candidate.Plan, want.Plan)
158+ }
159+ if prepared.candidate.frame.Width != 0 && !framesEqual(prepared.candidate.frame, want.frame) {
160+ t.Fatalf("step %d: prepared snapshot differs from reference snapshot", step)
161+ }
162+
163+ // Caller keeps mutating after Prepare, before Commit.
164+ snapshotMutation := r.IntN(3) == 0
165+ if snapshotMutation {
166+ propertyWrite(r, &frame, w, h)
167+ }
168+
169+ switch k := r.IntN(10); {
170+ case k < 6: // commit
171+ prepared.Commit()
172+ want.Commit(&ref)
173+ refHas = refHas || want.frame.Width != 0
174+ if want.frame.Width == 0 && !refHas {
175+ // A no-op candidate with no shadow cannot occur: reset snapshots.
176+ t.Fatalf("step %d: empty plan without committed state", step)
177+ }
178+ outstanding = nil
179+ case k < 8: // discard
180+ outstanding = &prepared
181+ default: // stale commit of the previous discarded draw
182+ if outstanding != nil {
183+ if hasWork(outstanding.candidate.Plan) {
184+ outstanding.Commit()
185+ refHas = false
186+ } else {
187+ outstanding.Commit()
188+ }
189+ outstanding = nil
190+ }
191+ }
192+ if r.IntN(25) == 0 {
193+ rend.Reset()
194+ refHas = false
195+ ref = Frame{}
196+ }
197+
198+ if rend.hasCommitted != refHas {
199+ t.Fatalf("step %d: hasCommitted=%v, reference %v", step, rend.hasCommitted, refHas)
200+ }
201+ if refHas {
202+ if !framesEqual(rend.committed, ref) {
203+ t.Fatalf("step %d: committed shadow differs from reference", step)
204+ }
205+ if err := rend.committed.CheckInvariants(); err != nil {
206+ t.Fatalf("step %d: %v", step, err)
207+ }
208+ if rend.width != ref.Width || rend.height != ref.Height {
209+ t.Fatalf("step %d: renderer size %dx%d, reference %dx%d", step, rend.width, rend.height, ref.Width, ref.Height)
210+ }
211+ }
212+ }
213+ })
214+ }
215+}
216+
217+func hasWork(p DeltaPlan) bool { return p.Snapshot || p.Scroll.Height != 0 || len(p.Spans) != 0 }
218+
219+func framesEqual(a, b Frame) bool {
220+ if a.Width != b.Width || a.Height != b.Height {
221+ return false
222+ }
223+ for y := range a.Height {
224+ for x := range a.Width {
225+ if !a.Cell(x, y).Equal(b.Cell(x, y)) {
226+ return false
227+ }
228+ }
229+ }
230+ return true
231+}
M benchmark_test.go +6 -4
@@ -248,8 +248,8 @@ func BenchmarkScreenKittyAPC(b *testing.B) {
248 b.ReportAllocs()248 b.ReportAllocs()
249 for b.Loop() {249 for b.Loop() {
250 if fragmented {250 if fragmented {
251- for _, part := range apc {251+ for i := range apc {
252- s.Write([]byte{part})252+ s.Write(apc[i : i+1 : i+1])
253 }253 }
254 } else {254 } else {
255 s.Write(apc)255 s.Write(apc)
@@ -301,9 +301,10 @@ func BenchmarkScreenCaptureDamage(b *testing.B) {
301 301
302 func BenchmarkScreenShellRedrawBurst(b *testing.B) {302 func BenchmarkScreenShellRedrawBurst(b *testing.B) {
303 chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")303 chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")
304+ s := NewScreen(120, 40)
305+ s.ClearDamage()
304 b.ReportAllocs()306 b.ReportAllocs()
305 for b.Loop() {307 for b.Loop() {
306- s := NewScreen(120, 40)
307 for range 200 {308 for range 200 {
308 s.Write(chunk)309 s.Write(chunk)
309 s.ClearDamage()310 s.ClearDamage()
@@ -313,9 +314,10 @@ func BenchmarkScreenShellRedrawBurst(b *testing.B) {
313 314
314 func BenchmarkScreenFullscreenScrollRegion(b *testing.B) {315 func BenchmarkScreenFullscreenScrollRegion(b *testing.B) {
315 chunk := []byte("\x1b[2;39r\x1b[39;1Hline\n")316 chunk := []byte("\x1b[2;39r\x1b[39;1Hline\n")
317+ s := NewScreen(120, 40)
318+ s.ClearDamage()
316 b.ReportAllocs()319 b.ReportAllocs()
317 for b.Loop() {320 for b.Loop() {
318- s := NewScreen(120, 40)
319 for range 500 {321 for range 500 {
320 s.Write(chunk)322 s.Write(chunk)
321 s.ClearDamage()323 s.ClearDamage()
M core/frame.go +75 -30
@@ -42,12 +42,20 @@ type styleSlot struct {
42 }42 }
43 43
44 type cellPage struct {44 type cellPage struct {
45- cells []storedCell45+ cells []storedCell
46- rows []uint3246+ rows []uint32
47- styles []styleSlot47+ styles []styleSlot
48- styleIndex map[Style]uint3248+ styleIndex map[Style]uint32
49- freeStyles []uint3249+ freeStyles []uint32
50- styleCount uint3250+ styleCount uint32
51+ // styleCache remembers the most recent raw Style passed to internStyle and
52+ // the live slot it resolved to. It is a pure accelerator: it is cleared
53+ // whenever that slot is freed, and a fresh page starts with it invalid.
54+ // Clone copies it along with the style slots it refers to, so a clone stays
55+ // reflect.DeepEqual to its source.
56+ styleCache Style
57+ styleCacheID uint32
58+ styleCacheOK bool
51 payloads []payloadSlot59 payloads []payloadSlot
52 payloadIndex map[CellPayload]uint3260 payloadIndex map[CellPayload]uint32
53 freePayloads []uint3261 freePayloads []uint32
@@ -101,6 +109,9 @@ func (f Frame) Clone() Frame {
101 styleIndex: maps.Clone(f.page.styleIndex),109 styleIndex: maps.Clone(f.page.styleIndex),
102 freeStyles: append([]uint32(nil), f.page.freeStyles...),110 freeStyles: append([]uint32(nil), f.page.freeStyles...),
103 styleCount: f.page.styleCount,111 styleCount: f.page.styleCount,
112+ styleCache: f.page.styleCache,
113+ styleCacheID: f.page.styleCacheID,
114+ styleCacheOK: f.page.styleCacheOK,
104 payloads: append([]payloadSlot(nil), f.page.payloads...),115 payloads: append([]payloadSlot(nil), f.page.payloads...),
105 payloadIndex: maps.Clone(f.page.payloadIndex),116 payloadIndex: maps.Clone(f.page.payloadIndex),
106 freePayloads: append([]uint32(nil), f.page.freePayloads...),117 freePayloads: append([]uint32(nil), f.page.freePayloads...),
@@ -262,9 +273,10 @@ func (f Frame) Set(x, y int, cell Cell) {
262 f.releasePayload(f.page.cells[index].payloadID)273 f.releasePayload(f.page.cells[index].payloadID)
263 oldID := f.page.cells[index].styleID274 oldID := f.page.cells[index].styleID
264 styleID := oldID275 styleID := oldID
265- // Repainting a cell usually keeps its style. Compare the canonical value276+ // Repainting a cell usually keeps its style. Stored styles are canonical,
266- // before hashing it and changing references in the page-local dictionary.277+ // so a raw match proves the canonical form matches too; this skips
267- if f.page.styles[oldID].style != cell.Style.Canonical() {278+ // canonicalization and the dictionary for the common case.
279+ if f.page.styles[oldID].style != cell.Style {
268 styleID = f.internStyle(cell.Style)280 styleID = f.internStyle(cell.Style)
269 f.releaseStyle(oldID)281 f.releaseStyle(oldID)
270 }282 }
@@ -344,44 +356,77 @@ func (f Frame) offset(x, y int) int {
344 }356 }
345 357
346 func (f Frame) blankPhysicalRow(offset uint32) {358 func (f Frame) blankPhysicalRow(offset uint32) {
347- for x := range f.Width {359+ row := f.page.cells[offset : int(offset)+f.Width]
348- index := int(offset) + x360+ for i := range row {
349- f.releasePayload(f.page.cells[index].payloadID)361+ cell := &row[i]
350- oldID := f.page.cells[index].styleID362+ if cell.payloadID != 0 {
351- if oldID != 0 {363+ f.releasePayload(cell.payloadID)
364+ }
365+ if oldID := cell.styleID; oldID != 0 {
352 f.page.styles[0].refs++366 f.page.styles[0].refs++
353 f.releaseStyle(oldID)367 f.releaseStyle(oldID)
354 }368 }
355- f.page.cells[index] = storedCell{rune: ' '}369+ }
370+ if len(row) == 0 {
371+ return
372+ }
373+ row[0] = storedCell{rune: ' '}
374+ for filled := 1; filled < len(row); filled *= 2 {
375+ copy(row[filled:], row[:filled])
356 }376 }
357 }377 }
358 378
359-func (f Frame) internStyle(style Style) uint32 {379+// internStyle returns the page-local ID for style and acquires one reference.
360- style = style.Canonical()380+func (f Frame) internStyle(raw Style) uint32 {
361- if id, ok := f.page.styleIndex[style]; ok {381+ p := f.page
362- f.page.styles[id].refs++382+ if p.styleCacheOK && raw == p.styleCache {
363- return id383+ p.styles[p.styleCacheID].refs++
364- }384+ return p.styleCacheID
365- var id uint32385+ }
366- if n := len(f.page.freeStyles); n != 0 {386+ style := raw.Canonical()
367- id = f.page.freeStyles[n-1]387+ id, ok := p.styleIndex[style]
368- f.page.freeStyles = f.page.freeStyles[:n-1]388+ if ok {
369- f.page.styles[id] = styleSlot{style: style, refs: 1, used: true}389+ p.styles[id].refs++
370 } else {390 } else {
371- id = uint32(len(f.page.styles))391+ if n := len(p.freeStyles); n != 0 {
372- f.page.styles = append(f.page.styles, styleSlot{style: style, refs: 1, used: true})392+ id = p.freeStyles[n-1]
393+ p.freeStyles = p.freeStyles[:n-1]
394+ p.styles[id] = styleSlot{style: style, refs: 1, used: true}
395+ } else {
396+ id = uint32(len(p.styles))
397+ if len(p.styles) == cap(p.styles) {
398+ p.styles = f.growStyles()
399+ }
400+ p.styles = append(p.styles, styleSlot{style: style, refs: 1, used: true})
401+ }
402+ p.styleIndex[style] = id
403+ p.styleCount++
373 }404 }
374- f.page.styleIndex[style] = id405+ p.styleCache, p.styleCacheID, p.styleCacheOK = raw, id, true
375- f.page.styleCount++
376 return id406 return id
377 }407 }
378 408
409+// growStyles doubles style capacity instead of Go's gentler large-slice growth,
410+// roughly halving total bytes allocated by high-cardinality frames. Capacity
411+// never exceeds the one-style-per-cell bound, so small frames stay small.
412+func (f Frame) growStyles() []styleSlot {
413+ old := f.page.styles
414+ limit := len(f.page.cells) + 1
415+ newCap := min(max(2*cap(old), 4), max(limit, len(old)+1))
416+ grown := make([]styleSlot, len(old), newCap)
417+ copy(grown, old)
418+ return grown
419+}
420+
379 func (f Frame) releaseStyle(id uint32) {421 func (f Frame) releaseStyle(id uint32) {
380 slot := &f.page.styles[id]422 slot := &f.page.styles[id]
381 slot.refs--423 slot.refs--
382 if id == 0 || slot.refs != 0 {424 if id == 0 || slot.refs != 0 {
383 return425 return
384 }426 }
427+ if f.page.styleCacheID == id {
428+ f.page.styleCacheOK = false
429+ }
385 delete(f.page.styleIndex, slot.style)430 delete(f.page.styleIndex, slot.style)
386 *slot = styleSlot{}431 *slot = styleSlot{}
387 f.page.freeStyles = append(f.page.freeStyles, id)432 f.page.freeStyles = append(f.page.freeStyles, id)
A core/frame_copy.go +119 -0
@@ -0,0 +1,119 @@
1+package core
2+
3+import "maps"
4+
5+// CopyFrom makes f an independent structural copy of src, like Replace, but
6+// reuses f's existing page storage (cells, row descriptors, style and payload
7+// tables and dictionaries) when f already owns a page. It therefore performs no
8+// allocation once f's page has grown to src's size. A page more than four times
9+// larger than src is replaced by a clone, so shrinking a terminal releases the
10+// old storage.
11+//
12+// Page-local style and payload IDs and physical row rotation are copied
13+// verbatim, exactly as Clone does. When the page is reused, every Frame value
14+// sharing f's page observes the overwrite. An invalid or empty source leaves f
15+// unchanged. Copying a frame onto itself only normalizes the dimensions.
16+func (f *Frame) CopyFrom(src Frame) {
17+ if f == nil {
18+ return
19+ }
20+ if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 {
21+ return
22+ }
23+ if f.page == src.page {
24+ f.Width, f.Height = src.Width, src.Height
25+ return
26+ }
27+ if f.page == nil || cap(f.page.cells) > 4*len(src.page.cells) {
28+ *f = src.Clone()
29+ return
30+ }
31+ f.Width, f.Height = src.Width, src.Height
32+ dst, from := f.page, src.page
33+ dst.cells = append(dst.cells[:0], from.cells...)
34+ dst.rows = append(dst.rows[:0], from.rows...)
35+ dst.styles = append(dst.styles[:0], from.styles...)
36+ dst.freeStyles = append(dst.freeStyles[:0], from.freeStyles...)
37+ dst.styleCount = from.styleCount
38+ // The cache refers to a slot in the style table copied above, so copying it
39+ // keeps it valid. Leaving the destination's old cache would point at a slot
40+ // that may now hold a different style.
41+ dst.styleCache, dst.styleCacheID, dst.styleCacheOK = from.styleCache, from.styleCacheID, from.styleCacheOK
42+ if dst.styleIndex == nil {
43+ dst.styleIndex = make(map[Style]uint32, len(from.styleIndex))
44+ } else {
45+ clear(dst.styleIndex)
46+ }
47+ maps.Copy(dst.styleIndex, from.styleIndex)
48+ // Payloads hold strings: clear the reused tail so stale graphemes and
49+ // hyperlinks are not kept alive by the backing array.
50+ oldPayloads := len(dst.payloads)
51+ dst.payloads = append(dst.payloads[:0], from.payloads...)
52+ if oldPayloads > len(dst.payloads) {
53+ clear(dst.payloads[len(dst.payloads):oldPayloads])
54+ }
55+ dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...)
56+ dst.payloadBytes = from.payloadBytes
57+ if dst.payloadIndex == nil && len(from.payloadIndex) > 0 {
58+ dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex))
59+ } else {
60+ clear(dst.payloadIndex)
61+ }
62+ maps.Copy(dst.payloadIndex, from.payloadIndex)
63+}
64+
65+// RowsEqualAt reports whether logical row ya of a and logical row yb of b hold
66+// semantically equal cells (the same comparison as Cell.Equal for every
67+// column). It compares compact stored cells directly and resolves page-local
68+// style and payload IDs only when they are not both the default, so it does
69+// not allocate and does not require the two frames to share ID tables. Frames
70+// of different width, an out-of-range row, or inconsistent storage compare
71+// unequal.
72+func RowsEqualAt(a Frame, ya int, b Frame, yb int) bool {
73+ if a.Width != b.Width || a.Width <= 0 || a.page == nil || b.page == nil ||
74+ ya < 0 || ya >= a.Height || yb < 0 || yb >= b.Height ||
75+ ya >= len(a.page.rows) || yb >= len(b.page.rows) {
76+ return false
77+ }
78+ width := a.Width
79+ offA, offB := int(a.page.rows[ya]), int(b.page.rows[yb])
80+ if offA+width > len(a.page.cells) || offB+width > len(b.page.cells) {
81+ return false
82+ }
83+ rowA := a.page.cells[offA : offA+width]
84+ rowB := b.page.cells[offB : offB+width]
85+
86+ // Remember the last style ID pair that compared equal so runs of one style
87+ // cost a single table lookup.
88+ var lastStyleA, lastStyleB uint32
89+ for x := range rowA {
90+ ca, cb := rowA[x], rowB[x]
91+ if ca.rune != cb.rune || ca.flags != cb.flags {
92+ return false
93+ }
94+ if ca.styleID != 0 || cb.styleID != 0 {
95+ if ca.styleID != lastStyleA || cb.styleID != lastStyleB {
96+ if !a.storedStylesEqual(b, ca.styleID, cb.styleID) {
97+ return false
98+ }
99+ lastStyleA, lastStyleB = ca.styleID, cb.styleID
100+ }
101+ }
102+ if ca.payloadID != 0 || cb.payloadID != 0 {
103+ if ca.payloadID == 0 || cb.payloadID == 0 ||
104+ int(ca.payloadID) > len(a.page.payloads) || int(cb.payloadID) > len(b.page.payloads) ||
105+ a.payload(ca.payloadID) != b.payload(cb.payloadID) {
106+ return false
107+ }
108+ }
109+ }
110+ return true
111+}
112+
113+func (f Frame) storedStylesEqual(other Frame, id, otherID uint32) bool {
114+ if int(id) >= len(f.page.styles) || int(otherID) >= len(other.page.styles) {
115+ return false
116+ }
117+ sa, sb := f.page.styles[id].style, other.page.styles[otherID].style
118+ return sa == sb || sa.Equal(sb)
119+}
A core/frame_copy_test.go +334 -0
@@ -0,0 +1,334 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "reflect"
6+ "testing"
7+)
8+
9+// cellPageFieldCount is the number of cellPage fields CopyFrom handles. Adding
10+// a field to cellPage fails TestCopyFromHandlesEveryPageField until CopyFrom
11+// (and this count) are updated.
12+const cellPageFieldCount = 13
13+
14+func copyTestPayload(t *testing.T, grapheme, link string) CellPayload {
15+ t.Helper()
16+ p, err := NewCellPayload(grapheme, link)
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ return p
21+}
22+
23+func copyTestFrame(t *testing.T, width, height int, seed rune) Frame {
24+ t.Helper()
25+ f := NewFrame(width, height)
26+ styles := []Style{DefaultStyle(), {Bold: true, Foreground: 3, Background: -1}, {Italic: true, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}, Background: -1}}
27+ for y := range height {
28+ for x := range width {
29+ c := Cell{Rune: seed + rune((x+y)%7), Style: styles[(x+2*y)%len(styles)]}
30+ if (x+y)%5 == 0 {
31+ c.Payload = copyTestPayload(t, "e\u0301", "https://example.test/"+string(rune('a'+y%3)))
32+ }
33+ f.Set(x, y, c)
34+ }
35+ }
36+ if height > 2 {
37+ f.ScrollUp(0, height-1, 1)
38+ }
39+ return f
40+}
41+
42+func requireFramesEqual(t *testing.T, got, want Frame) {
43+ t.Helper()
44+ if got.Width != want.Width || got.Height != want.Height {
45+ t.Fatalf("size %dx%d, want %dx%d", got.Width, got.Height, want.Width, want.Height)
46+ }
47+ for y := range want.Height {
48+ if !reflect.DeepEqual(got.Row(y), want.Row(y)) {
49+ t.Fatalf("row %d = %+v, want %+v", y, got.Row(y), want.Row(y))
50+ }
51+ }
52+ if err := got.CheckInvariants(); err != nil {
53+ t.Fatal(err)
54+ }
55+}
56+
57+func TestCopyFromIsIndependentAndReusesStorage(t *testing.T) {
58+ src := copyTestFrame(t, 9, 5, 'a')
59+ var dst Frame
60+ dst.CopyFrom(src)
61+ requireFramesEqual(t, dst, src)
62+ if dst.page == src.page {
63+ t.Fatal("copy aliases source page")
64+ }
65+
66+ // Mutating the source must not affect the copy.
67+ before := dst.Clone()
68+ src.Set(0, 0, Cell{Rune: 'Z', Style: Style{Bold: true, Foreground: 9, Background: -1}})
69+ requireFramesEqual(t, dst, before)
70+
71+ // Copy over a different, larger-state destination: the page pointer and
72+ // backing arrays are reused and stale styles/payloads do not leak.
73+ other := copyTestFrame(t, 9, 5, 'k')
74+ other.Set(1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}})
75+ page := dst.page
76+ cells := &dst.page.cells[0]
77+ dst.CopyFrom(other)
78+ if dst.page != page || &dst.page.cells[0] != cells {
79+ t.Fatal("CopyFrom did not reuse destination storage")
80+ }
81+ requireFramesEqual(t, dst, other)
82+ if dst.LogicalBytes() != other.LogicalBytes() || dst.StyleCount() != other.StyleCount() {
83+ t.Fatalf("accounting mismatch: %d/%d vs %d/%d", dst.LogicalBytes(), dst.StyleCount(), other.LogicalBytes(), other.StyleCount())
84+ }
85+
86+ // The copy stays a fully functional, independent frame.
87+ dst.Set(2, 2, Cell{Rune: 'n', Style: DefaultStyle()})
88+ dst.ScrollDown(0, 4, 2)
89+ if err := dst.CheckInvariants(); err != nil {
90+ t.Fatal(err)
91+ }
92+ requireFramesEqual(t, other, withCell(copyTestFrame(t, 9, 5, 'k'), 1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}}))
93+}
94+
95+func withCell(f Frame, x, y int, c Cell) Frame { f.Set(x, y, c); return f }
96+
97+func TestCopyFromResizesAndIgnoresInvalidSource(t *testing.T) {
98+ dst := copyTestFrame(t, 4, 3, 'a')
99+ small := copyTestFrame(t, 2, 2, 'x')
100+ dst.CopyFrom(small)
101+ requireFramesEqual(t, dst, small)
102+ large := copyTestFrame(t, 12, 7, 'm')
103+ dst.CopyFrom(large)
104+ requireFramesEqual(t, dst, large)
105+
106+ before := dst.Clone()
107+ dst.CopyFrom(Frame{})
108+ dst.CopyFrom(Frame{Width: 3, Height: 3})
109+ requireFramesEqual(t, dst, before)
110+ dst.CopyFrom(dst)
111+ requireFramesEqual(t, dst, before)
112+ var nilFrame *Frame
113+ nilFrame.CopyFrom(large)
114+}
115+
116+func TestCopyFromDoesNotAllocateOnceSized(t *testing.T) {
117+ src := copyTestFrame(t, 40, 10, 'a')
118+ dst := src.Clone()
119+ dst.CopyFrom(src)
120+ if n := testing.AllocsPerRun(20, func() { dst.CopyFrom(src) }); n != 0 {
121+ t.Fatalf("CopyFrom allocated %v times", n)
122+ }
123+}
124+
125+func TestCopyFromReleasesStorageWhenSourceShrinks(t *testing.T) {
126+ dst := copyTestFrame(t, 240, 80, 'a')
127+ small := copyTestFrame(t, 20, 5, 'b')
128+ dst.CopyFrom(small)
129+ if got := cap(dst.page.cells); got > 4*len(small.page.cells) {
130+ t.Fatalf("CopyFrom kept %d cells for a %d-cell source", got, len(small.page.cells))
131+ }
132+ for y := range small.Height {
133+ if !RowsEqualAt(dst, y, small, y) {
134+ t.Fatalf("row %d differs after shrinking copy", y)
135+ }
136+ }
137+}
138+
139+func TestCopyFromClearsStalePayloadSlots(t *testing.T) {
140+ src := copyTestFrame(t, 8, 2, 'a')
141+ dst := NewFrame(8, 2)
142+ for x := range 8 {
143+ dst.Set(x, 0, Cell{Rune: 'x', Payload: copyTestPayload(t, "", "https://example.test/"+string(rune('a'+x)))})
144+ }
145+ old := len(dst.page.payloads)
146+ dst.CopyFrom(src)
147+ if len(dst.page.payloads) >= old {
148+ t.Fatalf("test needs a source with fewer payload slots: %d >= %d", len(dst.page.payloads), old)
149+ }
150+ for i, slot := range dst.page.payloads[len(dst.page.payloads):old] {
151+ if slot != (payloadSlot{}) {
152+ t.Fatalf("stale payload slot %d still holds %+v", len(dst.page.payloads)+i, slot.value)
153+ }
154+ }
155+}
156+
157+func TestRowsEqualAt(t *testing.T) {
158+ a := copyTestFrame(t, 10, 4, 'a')
159+ b := a.Clone()
160+ for y := range a.Height {
161+ if !RowsEqualAt(a, y, b, y) {
162+ t.Fatalf("clone row %d differs", y)
163+ }
164+ }
165+
166+ // Equal content with different physical rotation and different ID tables.
167+ c := NewFrame(a.Width, a.Height)
168+ // Intern an unrelated style first so IDs differ from a's.
169+ c.Set(0, 0, Cell{Rune: 'x', Style: Style{Inverse: true}})
170+ for y := range a.Height {
171+ for x := range a.Width {
172+ c.Set(x, y, a.Cell(x, y))
173+ }
174+ }
175+ for y := range a.Height {
176+ if !RowsEqualAt(a, y, c, y) {
177+ t.Fatalf("semantically equal row %d reported different", y)
178+ }
179+ if got, want := RowsEqualAt(a, y, c, y), reflect.DeepEqual(a.Row(y), c.Row(y)); got != want {
180+ t.Fatalf("row %d: RowsEqualAt=%v DeepEqual=%v", y, got, want)
181+ }
182+ }
183+
184+ // Each kind of difference is detected on the right row only.
185+ diffs := []Cell{
186+ {Rune: 'Q', Style: a.Cell(3, 1).Style},
187+ {Rune: a.Cell(3, 1).Rune, Style: Style{Bold: true, Italic: true, Foreground: 5, Background: -1}},
188+ {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Payload: copyTestPayload(t, "z", "")},
189+ {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Continuation: true},
190+ }
191+ for i, d := range diffs {
192+ m := a.Clone()
193+ m.Set(3, 1, d)
194+ for y := range a.Height {
195+ want := y != 1
196+ if got := RowsEqualAt(a, y, m, y); got != want {
197+ t.Fatalf("diff %d row %d: got %v want %v", i, y, got, want)
198+ }
199+ }
200+ }
201+
202+ // Payload present on one side only, and differing payloads.
203+ m := a.Clone()
204+ px := -1
205+ for x := range a.Width {
206+ if a.Cell(x, 0).Payload != (CellPayload{}) {
207+ px = x
208+ break
209+ }
210+ }
211+ if px < 0 {
212+ t.Fatal("test frame row 0 has no payload")
213+ }
214+ m.Set(px, 0, Cell{Rune: a.Cell(px, 0).Rune, Style: a.Cell(px, 0).Style})
215+ if RowsEqualAt(a, 0, m, 0) {
216+ t.Fatal("payload removal not detected")
217+ }
218+
219+ // Inactive RGB fields and other equivalent spellings compare equal.
220+ e1, e2 := NewFrame(2, 1), NewFrame(2, 1)
221+ s1 := Style{Bold: true, Foreground: 2, Background: -1}
222+ s2 := s1
223+ s2.ForegroundRGB = RGB{R: 9}
224+ e1.Set(0, 0, Cell{Rune: 'a', Style: s1})
225+ e2.Set(0, 0, Cell{Rune: 'a', Style: s2})
226+ if !RowsEqualAt(e1, 0, e2, 0) {
227+ t.Fatal("equivalent styles reported different")
228+ }
229+
230+ // A row compares equal at a different index after scrolling.
231+ shifted := a.Clone()
232+ shifted.ScrollUp(0, a.Height-1, 1)
233+ for y := range a.Height - 1 {
234+ if !RowsEqualAt(a, y+1, shifted, y) {
235+ t.Fatalf("row %d of a != row %d of scrolled copy", y+1, y)
236+ }
237+ }
238+
239+ // Mismatched shape or range is unequal and never panics.
240+ if RowsEqualAt(a, 0, NewFrame(a.Width+1, a.Height), 0) || RowsEqualAt(a, -1, b, -1) || RowsEqualAt(a, a.Height, b, a.Height) || RowsEqualAt(Frame{}, 0, Frame{}, 0) {
241+ t.Fatal("invalid comparison reported equal")
242+ }
243+}
244+
245+func TestRowsEqualAtDoesNotAllocate(t *testing.T) {
246+ a := copyTestFrame(t, 80, 4, 'a')
247+ b := a.Clone()
248+ if n := testing.AllocsPerRun(20, func() { RowsEqualAt(a, 2, b, 2) }); n != 0 {
249+ t.Fatalf("RowsEqualAt allocated %v times", n)
250+ }
251+}
252+
253+func TestCopyFromHandlesEveryPageField(t *testing.T) {
254+ typ := reflect.TypeOf(cellPage{})
255+ if typ.NumField() != cellPageFieldCount {
256+ t.Fatalf("cellPage has %d fields, CopyFrom handles %d: update CopyFrom and cellPageFieldCount", typ.NumField(), cellPageFieldCount)
257+ }
258+
259+ // Build a source that populates every field, and a destination whose every
260+ // field holds different, stale state.
261+ src := copyTestFrame(t, 9, 5, 'a')
262+ src.Set(0, 0, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 7, Background: -1}})
263+ dst := copyTestFrame(t, 9, 5, 'k')
264+ dst.Set(4, 4, Cell{Rune: 'f', Style: Style{Italic: true, Foreground: 1, Background: -1}})
265+ dst.Set(4, 4, Cell{Rune: 'f', Style: DefaultStyle()}) // frees a style slot
266+ dst.CopyFrom(src)
267+
268+ sp, dp := reflect.ValueOf(*src.page), reflect.ValueOf(*dst.page)
269+ populated := 0
270+ for i := range typ.NumField() {
271+ name := typ.Field(i).Name
272+ a, b := sp.Field(i), dp.Field(i)
273+ if !a.IsZero() {
274+ populated++
275+ }
276+ if (a.Kind() == reflect.Slice || a.Kind() == reflect.Map) && a.Len() == 0 && b.Len() == 0 {
277+ continue // nil and empty are equivalent
278+ }
279+ // Printing works on unexported fields and treats nil and empty alike;
280+ // fmt sorts map keys, so the rendering is deterministic.
281+ if got, want := fmt.Sprintf("%#v", b), fmt.Sprintf("%#v", a); got != want {
282+ t.Errorf("cellPage.%s = %s, want %s", name, got, want)
283+ }
284+ }
285+ if populated < typ.NumField()-2 { // freeStyles/freePayloads may legitimately be empty
286+ t.Errorf("source populates only %d of %d page fields; strengthen the test", populated, typ.NumField())
287+ }
288+}
289+
290+func TestCopyFromDropsStaleStyleCache(t *testing.T) {
291+ styleA := Style{Bold: true, Foreground: 1, Background: -1}
292+ styleB := Style{Italic: true, Foreground: 2, Background: -1}
293+
294+ // dst: style A lives in slot k and is cached by the last Set.
295+ dst := NewFrame(4, 2)
296+ dst.Set(0, 0, Cell{Rune: 'a', Style: styleA})
297+ k := dst.page.styleCacheID
298+ if !dst.page.styleCacheOK || dst.page.styles[k].style != styleA {
299+ t.Fatalf("setup: cache = (%v,%d)", dst.page.styleCacheOK, k)
300+ }
301+
302+ // src: a different style occupies the same slot k, and no cache.
303+ src := NewFrame(4, 2)
304+ src.Set(0, 0, Cell{Rune: 'b', Style: styleB})
305+ src.page.styleCacheOK = false
306+ if src.page.styleIndex[styleB] != k {
307+ t.Fatalf("setup: style B in slot %d, want %d", src.page.styleIndex[styleB], k)
308+ }
309+
310+ dst.CopyFrom(src)
311+ if dst.page.styleCacheOK && dst.page.styles[dst.page.styleCacheID].style != dst.page.styleCache {
312+ t.Fatal("CopyFrom left a stale style cache")
313+ }
314+ cell := Cell{Rune: 'c', Style: styleA}
315+ dst.Set(1, 1, cell)
316+ if got := dst.Cell(1, 1); !got.Equal(cell) {
317+ t.Fatalf("cell = %+v, want %+v", got, cell)
318+ }
319+ if got := dst.Cell(0, 0); got.Style != styleB {
320+ t.Fatalf("copied cell style = %+v, want %+v", got.Style, styleB)
321+ }
322+ if err := dst.CheckInvariants(); err != nil {
323+ t.Fatal(err)
324+ }
325+}
326+
327+func TestRowsEqualAtRejectsCorruptPayloadIDs(t *testing.T) {
328+ a := copyTestFrame(t, 4, 1, 'a')
329+ b := a.Clone()
330+ b.page.cells[b.page.rows[0]].payloadID = uint32(len(b.page.payloads) + 5)
331+ if RowsEqualAt(a, 0, b, 0) || RowsEqualAt(b, 0, a, 0) {
332+ t.Fatal("corrupt payload ID reported equal")
333+ }
334+}
M core/frame_style_reuse_test.go +159 -1
@@ -1,6 +1,9 @@
1 package core1 package core
2 2
3-import "testing"3+import (
4+ "reflect"
5+ "testing"
6+)
4 7
5 func TestSetStyleReuse(t *testing.T) {8 func TestSetStyleReuse(t *testing.T) {
6 for _, rotated := range []bool{false, true} {9 for _, rotated := range []bool{false, true} {
@@ -50,3 +53,158 @@ func BenchmarkFrameRewriteSameStyle(b *testing.B) {
50 }53 }
51 }54 }
52 }55 }
56+
57+func TestStyleCacheSurvivesSlotFreeAndReuse(t *testing.T) {
58+ a := Style{Foreground: 1}
59+ b := Style{Foreground: 2, Bold: true}
60+ f := NewFrame(4, 2)
61+ check := func(step string) {
62+ t.Helper()
63+ if err := f.CheckInvariants(); err != nil {
64+ t.Fatalf("%s: %v", step, err)
65+ }
66+ }
67+
68+ f.Set(0, 0, Cell{Rune: 'a', Style: a})
69+ check("set a")
70+ idA := f.page.cells[f.offset(0, 0)].styleID
71+ if !f.page.styleCacheOK || f.page.styleCacheID != idA {
72+ t.Fatalf("cache = (%v,%d), want live slot %d", f.page.styleCacheOK, f.page.styleCacheID, idA)
73+ }
74+
75+ // Overwrite the only A cell so its slot is freed; the cache must not keep it.
76+ f.Set(0, 0, BlankCell())
77+ check("free a")
78+ if f.page.styleCacheOK && !f.page.styles[f.page.styleCacheID].used {
79+ t.Fatal("cache points at a freed style slot")
80+ }
81+
82+ // Reuse the freed slot for B, then request A again: it must not alias B.
83+ f.Set(1, 0, Cell{Rune: 'b', Style: b})
84+ check("reuse slot for b")
85+ if got := f.page.cells[f.offset(1, 0)].styleID; got != idA {
86+ t.Fatalf("expected freed slot %d to be reused, got %d", idA, got)
87+ }
88+ f.Set(2, 0, Cell{Rune: 'a', Style: a})
89+ check("set a again")
90+ if got := f.Cell(2, 0).Style; got != a {
91+ t.Fatalf("style after reuse = %+v, want %+v", got, a)
92+ }
93+ if got := f.Cell(1, 0).Style; got != b {
94+ t.Fatalf("style b = %+v, want %+v", got, b)
95+ }
96+ if f.StyleCount() != 3 {
97+ t.Fatalf("StyleCount = %d, want 3", f.StyleCount())
98+ }
99+
100+ // Repeating the cached raw style must bump references exactly once per cell.
101+ f.Set(3, 0, Cell{Rune: 'a', Style: a})
102+ f.Set(0, 1, Cell{Rune: 'a', Style: a})
103+ check("repeat a")
104+ f.Set(2, 0, BlankCell())
105+ f.Set(3, 0, BlankCell())
106+ f.Set(0, 1, BlankCell())
107+ check("release a")
108+ if f.StyleCount() != 2 {
109+ t.Fatalf("StyleCount = %d, want 2", f.StyleCount())
110+ }
111+}
112+
113+func TestStyleCacheNonCanonicalInput(t *testing.T) {
114+ f := NewFrame(3, 1)
115+ rgbWithStaleIndex := Style{Foreground: 7, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1}, Background: -1}
116+ canonical := rgbWithStaleIndex.Canonical()
117+ f.Set(0, 0, Cell{Rune: 'x', Style: rgbWithStaleIndex})
118+ f.Set(1, 0, Cell{Rune: 'y', Style: canonical})
119+ f.Set(2, 0, Cell{Rune: 'z', Style: rgbWithStaleIndex})
120+ if err := f.CheckInvariants(); err != nil {
121+ t.Fatal(err)
122+ }
123+ if f.StyleCount() != 2 {
124+ t.Fatalf("StyleCount = %d, want 2", f.StyleCount())
125+ }
126+ for x := range 3 {
127+ if got := f.Cell(x, 0).Style; got != canonical {
128+ t.Fatalf("cell %d style = %+v, want %+v", x, got, canonical)
129+ }
130+ }
131+ // Rewriting with the non-canonical form must keep reference counts exact.
132+ f.Set(0, 0, Cell{Rune: 'x', Style: rgbWithStaleIndex})
133+ if err := f.CheckInvariants(); err != nil {
134+ t.Fatal(err)
135+ }
136+}
137+
138+func TestStyleCacheCloneAndReplaceIndependence(t *testing.T) {
139+ a := Style{Foreground: 1}
140+ b := Style{Foreground: 2}
141+ src := NewFrame(2, 1)
142+ src.Set(0, 0, Cell{Rune: 'a', Style: a})
143+ clone := src.Clone()
144+ if !reflect.DeepEqual(src, clone) {
145+ t.Fatal("clone is not DeepEqual to its source")
146+ }
147+
148+ // Free A in the source and reuse its slot for B; the clone keeps A.
149+ src.Set(0, 0, BlankCell())
150+ src.Set(1, 0, Cell{Rune: 'b', Style: b})
151+ clone.Set(1, 0, Cell{Rune: 'a', Style: a})
152+ for name, f := range map[string]Frame{"src": src, "clone": clone} {
153+ if err := f.CheckInvariants(); err != nil {
154+ t.Fatalf("%s: %v", name, err)
155+ }
156+ }
157+ if got := clone.Cell(0, 0).Style; got != a {
158+ t.Fatalf("clone cell 0 = %+v, want %+v", got, a)
159+ }
160+ if got := clone.Cell(1, 0).Style; got != a {
161+ t.Fatalf("clone cell 1 = %+v, want %+v", got, a)
162+ }
163+ if got := src.Cell(1, 0).Style; got != b {
164+ t.Fatalf("src cell 1 = %+v, want %+v", got, b)
165+ }
166+
167+ replaced := NewFrame(2, 1)
168+ replaced.Set(0, 0, Cell{Rune: 'q', Style: b})
169+ replaced.Replace(clone)
170+ replaced.Set(0, 0, Cell{Rune: 'q', Style: b})
171+ replaced.Set(1, 0, Cell{Rune: 'q', Style: b})
172+ if err := replaced.CheckInvariants(); err != nil {
173+ t.Fatal(err)
174+ }
175+ if err := clone.CheckInvariants(); err != nil || clone.Cell(0, 0).Style != a {
176+ t.Fatalf("clone changed by replaced frame: %v", err)
177+ }
178+}
179+
180+func TestBlankPhysicalRowReleasesStylesAndPayloads(t *testing.T) {
181+ const width = 7
182+ f := NewFrame(width, 3)
183+ payload, err := NewCellPayload("é", "")
184+ if err != nil {
185+ t.Fatal(err)
186+ }
187+ for x := range width {
188+ f.Set(x, 0, Cell{Rune: 'x', Style: Style{Foreground: x % 3}, Payload: payload})
189+ f.Set(x, 1, Cell{Rune: 'y', Style: Style{Foreground: x % 3}})
190+ }
191+ f.ScrollUp(0, 2, 1)
192+ if err := f.CheckInvariants(); err != nil {
193+ t.Fatal(err)
194+ }
195+ for x := range width {
196+ if got := f.Cell(x, 2); got != (Cell{Rune: ' ', Style: DefaultStyle()}) {
197+ t.Fatalf("blank row cell %d = %+v", x, got)
198+ }
199+ if got := f.Cell(x, 0); got.Rune != 'y' || !got.Payload.Empty() {
200+ t.Fatalf("shifted row cell %d = %+v", x, got)
201+ }
202+ }
203+ f.ScrollDown(0, 2, 3)
204+ if err := f.CheckInvariants(); err != nil {
205+ t.Fatal(err)
206+ }
207+ if f.StyleCount() != 1 {
208+ t.Fatalf("StyleCount = %d, want 1", f.StyleCount())
209+ }
210+}
M docs/ownership.md +19 -1
@@ -19,13 +19,31 @@ should use these methods rather than assuming a particular memory layout.
19 Rows returned by public extraction methods are copies. For a writable19 Rows returned by public extraction methods are copies. For a writable
20 `core.Frame`, use `Set`, `WriteRow`, `FillRow`, `CopyRow` and the scroll operations.20 `core.Frame`, use `Set`, `WriteRow`, `FillRow`, `CopyRow` and the scroll operations.
21 A plain Go assignment of a `Frame` shares its storage; use `Clone()` when you need21 A plain Go assignment of a `Frame` shares its storage; use `Clone()` when you need
22-an independent grid.22+an independent grid. `CopyFrom` reuses the destination's storage, so every
23+`Frame` assigned from it sees the overwrite: call it only on a frame you own
24+exclusively. `core.RowsEqualAt` compares rows of two frames without copying.
23 25
24 ## Callbacks26 ## Callbacks
25 27
26 Callbacks run synchronously, before `Screen.Write` returns. Avoid slow work in28 Callbacks run synchronously, before `Screen.Write` returns. Avoid slow work in
27 them. Copy response bytes during the callback if you need to retain them.29 them. Copy response bytes during the callback if you need to retain them.
28 30
31+## Borrowed and owned byte slices
32+
33+A few entry points skip a copy when the caller can promise exclusive ownership:
34+
35+- `kittygraphics.ParseAPCBorrowed` returns a command whose payload aliases the
36+ input. Keep the input unchanged while the command is in use.
37+ `Session.Process` never retains a command's payload, so the input may be
38+ reused once it returns.
39+- `graphics.Scene.AddAssetOwned` and `ReplaceAssetOwned` keep `Encoded` instead
40+ of copying it. Do not read or modify it afterwards. A slice with large spare
41+ capacity is still copied, so the scene retains only accounted bytes. On error
42+ the scene keeps nothing.
43+
44+The copying variants (`ParseAPC`, `AddAsset`, `ReplaceAsset`) remain the safe
45+default.
46+
29 ## Styles47 ## Styles
30 48
31 Use `DefaultStyle()` to leave foreground and background colors at the terminal's49 Use `DefaultStyle()` to leave foreground and background colors at the terminal's
A docs/performance.md +96 -0
@@ -0,0 +1,96 @@
1+# Performance work
2+
3+How to measure CPU time and allocations before and after a change, and how to
4+check that the change kept behavior.
5+
6+## Workflow
7+
8+1. Pick the benchmark that covers the code you change (table below).
9+2. Record a baseline on the base branch.
10+3. Make the change, then record the same benchmark again.
11+4. Compare with `benchstat`; keep only wins that are stable across runs.
12+5. Run the safety checks below before you commit.
13+
14+```sh
15+# Baseline, from a worktree of the base branch
16+go test ./html -run '^$' -bench '^BenchmarkRenderer$' -benchmem -count=10 > /tmp/old.txt
17+# After the change
18+go test ./html -run '^$' -bench '^BenchmarkRenderer$' -benchmem -count=10 > /tmp/new.txt
19+benchstat /tmp/old.txt /tmp/new.txt
20+```
21+
22+`benchstat` comes from `go install golang.org/x/perf/cmd/benchstat@latest`. Use
23+`-count=10` so it can report variation; a `~` in its output means no
24+significant change.
25+
26+## Benchmarks
27+
28+| Area | Package | Benchmarks |
29+|---|---|---|
30+| VT parsing and screen writes | `.` | `BenchmarkScreenPrintableASCII`, `BenchmarkScreenMixedUTF8`, `BenchmarkScreenCSIHeavy`, `BenchmarkScreenShellRedrawBurst`, `BenchmarkScreenFullscreenScrollRegion`, `BenchmarkScreenKittyAPC` |
31+| Damage capture and resize | `.` | `BenchmarkScreenCaptureDamage`, `BenchmarkScreenResizeReflowViewport` |
32+| History build, views and storage | `.` | `BenchmarkHistoryBuild10Kx120`, `BenchmarkHistoryView10KRows*`, `BenchmarkHistoryRetained10Kx120`, see [storage-optimization.md](storage-optimization.md) |
33+| ANSI renderer | `./ansi` | `BenchmarkRendererFullFrameDraw`, `BenchmarkRendererIncrementalOneCell`, `BenchmarkRendererFragmentedDamage`, `BenchmarkRendererBroadRegularDamage`, `BenchmarkRendererIncrementalNoBytePrepareCommit` |
34+| HTML renderer | `./html` | `BenchmarkRenderer` (`snapshot-*` and `one-row-*` at 80x24, 120x40, 240x80) |
35+
36+`one-row-*` is the common interactive case: one changed line in a full frame.
37+It must stay cheaper than `snapshot-*` at the same size.
38+
39+## Profiles
40+
41+Use a CPU or memory profile to find where the time or bytes go:
42+
43+```sh
44+go test ./html -run '^$' -bench '^BenchmarkRenderer$/one-row-240x80' -benchmem \
45+ -cpuprofile /tmp/cpu.out -memprofile /tmp/mem.out
46+go tool pprof -top /tmp/cpu.out
47+go tool pprof -sample_index=alloc_space -top /tmp/mem.out
48+go tool pprof -http=:0 /tmp/cpu.out # flame graph in the browser
49+```
50+
51+To see why a value escapes to the heap:
52+
53+```sh
54+go build -gcflags=-m ./html 2>&1 | grep -v inlin
55+```
56+
57+Tests can pin an allocation result with `testing.AllocsPerRun`; several
58+packages already do, for example `TestCopyFromDoesNotAllocateOnceSized` in
59+`core`.
60+
61+## Safety checks
62+
63+A faster change must produce the same output:
64+
65+```sh
66+go test ./... # includes TestDifferentialOracle
67+go test -race ./...
68+go vet ./... && staticcheck ./...
69+```
70+
71+`TestDifferentialOracle` digests the output of every layer (VT, history,
72+graphics, ANSI, HTML) for seeded workloads. A performance change must leave
73+`testdata/oracle/golden.txt` unchanged. The property tests in `ansi` and `html`
74+compare reused-buffer renderers with clone-based references through random
75+edits, aborts and resets.
76+
77+When a change reuses buffers, check:
78+
79+- Nothing returned to a caller still points into a reused buffer.
80+- Retained scratch is bounded, so one huge frame does not pin memory.
81+- Reused slices of strings or pointers are cleared, so they do not keep old
82+ values alive.
83+
84+## Browser rendering
85+
86+For the HTML frontend, the browser's style, layout and paint usually cost more
87+than the Go side. The Playwright performance gate in
88+[`internal/htmlharness`](../internal/htmlharness/README.md) bounds sustained
89+snapshot and row work in Chromium:
90+
91+```sh
92+npx playwright test internal/htmlharness/performance.spec.js --project=chromium
93+```
94+
95+Measure user-visible scrolling in the host application, with a real GPU and the
96+user's zoom level: headless Chromium does not reproduce compositor costs.
M docs/storage-optimization.md +1 -0
@@ -50,6 +50,7 @@ latency or claiming a precise speedup.
50 | Geometry-bounded tail preallocation | Accepted | Large reproducible allocation reduction for small chunks; no ownership changes. |50 | Geometry-bounded tail preallocation | Accepted | Large reproducible allocation reduction for small chunks; no ownership changes. |
51 | Change default logical chunk rows | Rejected for now | Corrected throughput samples overlap. 256 rows uses fewer allocations/dictionaries than smaller chunks. |51 | Change default logical chunk rows | Rejected for now | Corrected throughput samples overlap. 256 rows uses fewer allocations/dictionaries than smaller chunks. |
52 | Pool tail/page memory | Not justified yet | The immediate cause was excessive requested capacity. Sealed pages may outlive History through borrowed views, making recycling ownership-sensitive; pools can also retain cold memory. |52 | Pool tail/page memory | Not justified yet | The immediate cause was excessive requested capacity. Sealed pages may outlive History through borrowed views, making recycling ownership-sensitive; pools can also retain cold memory. |
53+| Recycle the private mutable tail across seals | Accepted | Only History reads its tail: sealed chunks and views hold compact copies. Reusing that storage removed about 85% of the bytes allocated by `BenchmarkHistoryBuild10Kx120`. Retention is bounded: at most 128K cells (a default chunk up to 512 columns), and cleared cells, so payload strings are not pinned. Style scratch maps are dropped above 1,024 entries. Sealed pages are never pooled. |
53 | mmap/decommit | Not justified | Go GC already releases dropped page references; no measured syscall/RSS benefit supports platform-specific backing, finalization and failure paths. |54 | mmap/decommit | Not justified | Go GC already releases dropped page references; no measured syscall/RSS benefit supports platform-specific backing, finalization and failure paths. |
54 | SIMD | Not justified | The measured work is semantic cell writes and dictionary lookup, not a demonstrated bulk transform suited to SIMD. Native copy/clear remains the baseline. |55 | SIMD | Not justified | The measured work is semantic cell writes and dictionary lookup, not a demonstrated bulk transform suited to SIMD. Native copy/clear remains the baseline. |
55 | Fixed physical page alignment | Rejected | No measured benefit. Chunk rows are logical grouping, not an OS page-size commitment. |56 | Fixed physical page alignment | Rejected | No measured benefit. Chunk rows are logical grouping, not an OS page-size commitment. |
M graphics/graphics.go +2 -1
@@ -111,7 +111,8 @@ const (
111 // scene or any snapshot. Format preserves the encoded pixel layout for the111 // scene or any snapshot. Format preserves the encoded pixel layout for the
112 // renderer. Width and Height describe the decoded pixel extent used for112 // renderer. Width and Height describe the decoded pixel extent used for
113 // clipping and quota accounting. DecodedPixels, when non-zero, must equal113 // clipping and quota accounting. DecodedPixels, when non-zero, must equal
114-// Width*Height; accounting always uses the checked product.114+// Width*Height; accounting always uses the checked product. Scene.AddAssetOwned
115+// and ReplaceAssetOwned instead take ownership of Encoded without copying it.
115 type AssetBlob struct {116 type AssetBlob struct {
116 Encoded []byte117 Encoded []byte
117 Format AssetFormat118 Format AssetFormat
M graphics/scene.go +35 -5
@@ -101,13 +101,25 @@ func (s *Scene) Limits() Limits {
101 101
102 // AddAsset copies and registers an encoded asset, returning an opaque ID.102 // AddAsset copies and registers an encoded asset, returning an opaque ID.
103 func (s *Scene) AddAsset(blob AssetBlob) (AssetID, error) {103 func (s *Scene) AddAsset(blob AssetBlob) (AssetID, error) {
104+ return s.addAsset(blob, true)
105+}
106+
107+// AddAssetOwned is AddAsset for callers that hand over blob.Encoded: the scene
108+// keeps the slice without copying, so the caller must not read or modify it
109+// afterwards. Snapshots and AssetView still return copies. On error the scene
110+// does not retain blob.Encoded, so the caller keeps ownership of it.
111+func (s *Scene) AddAssetOwned(blob AssetBlob) (AssetID, error) {
112+ return s.addAsset(blob, false)
113+}
114+
115+func (s *Scene) addAsset(blob AssetBlob, copyEncoded bool) (AssetID, error) {
104 if s == nil {116 if s == nil {
105 return AssetID{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)117 return AssetID{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
106 }118 }
107 s.mu.Lock()119 s.mu.Lock()
108 defer s.mu.Unlock()120 defer s.mu.Unlock()
109 candidate := cloneState(s.state)121 candidate := cloneState(s.state)
110- record, err := s.prepareAsset(blob, candidate)122+ record, err := s.prepareAsset(blob, candidate, copyEncoded)
111 if err != nil {123 if err != nil {
112 return AssetID{}, err124 return AssetID{}, err
113 }125 }
@@ -134,6 +146,16 @@ func (s *Scene) RegisterAsset(blob AssetBlob) (AssetID, error) { return s.AddAss
134 // reference it. If validation or resource limits reject the new asset, the146 // reference it. If validation or resource limits reject the new asset, the
135 // existing asset and placements remain unchanged.147 // existing asset and placements remain unchanged.
136 func (s *Scene) ReplaceAsset(id AssetID, blob AssetBlob) (AssetID, error) {148 func (s *Scene) ReplaceAsset(id AssetID, blob AssetBlob) (AssetID, error) {
149+ return s.replaceAsset(id, blob, true)
150+}
151+
152+// ReplaceAssetOwned is ReplaceAsset for callers that hand over blob.Encoded;
153+// see AddAssetOwned. On error blob.Encoded is not retained.
154+func (s *Scene) ReplaceAssetOwned(id AssetID, blob AssetBlob) (AssetID, error) {
155+ return s.replaceAsset(id, blob, false)
156+}
157+
158+func (s *Scene) replaceAsset(id AssetID, blob AssetBlob, copyEncoded bool) (AssetID, error) {
137 if s == nil {159 if s == nil {
138 return AssetID{}, fmt.Errorf("replace asset: %w", ErrAssetNotFound)160 return AssetID{}, fmt.Errorf("replace asset: %w", ErrAssetNotFound)
139 }161 }
@@ -155,7 +177,7 @@ func (s *Scene) ReplaceAsset(id AssetID, blob AssetBlob) (AssetID, error) {
155 candidate.usage.Assets--177 candidate.usage.Assets--
156 candidate.usage.EncodedBytes -= uint64(len(old.encoded))178 candidate.usage.EncodedBytes -= uint64(len(old.encoded))
157 candidate.usage.DecodedPixels -= old.pixels179 candidate.usage.DecodedPixels -= old.pixels
158- replacement, err := s.prepareAsset(blob, candidate)180+ replacement, err := s.prepareAsset(blob, candidate, copyEncoded)
159 if err != nil {181 if err != nil {
160 return AssetID{}, err182 return AssetID{}, err
161 }183 }
@@ -389,7 +411,7 @@ func (s *Scene) Usage() Usage {
389 return s.state.usage411 return s.state.usage
390 }412 }
391 413
392-func (s *Scene) prepareAsset(blob AssetBlob, state *sceneState) (assetRecord, error) {414+func (s *Scene) prepareAsset(blob AssetBlob, state *sceneState, copyEncoded bool) (assetRecord, error) {
393 data := blob.Encoded415 data := blob.Encoded
394 if blob.Width <= 0 || blob.Height <= 0 {416 if blob.Width <= 0 || blob.Height <= 0 {
395 return assetRecord{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)417 return assetRecord{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
@@ -414,7 +436,15 @@ func (s *Scene) prepareAsset(blob AssetBlob, state *sceneState) (assetRecord, er
414 if !within(state.usage.DecodedPixels, pixels, s.limits.MaxDecodedPixels) {436 if !within(state.usage.DecodedPixels, pixels, s.limits.MaxDecodedPixels) {
415 return assetRecord{}, fmt.Errorf("add asset: %w", ErrDecodedPixelBudget)437 return assetRecord{}, fmt.Errorf("add asset: %w", ErrDecodedPixelBudget)
416 }438 }
417- return assetRecord{encoded: append([]byte(nil), data...), format: blob.Format, width: blob.Width, height: blob.Height, pixels: pixels}, nil439+ // Budgets count len(data). An owned slice with spare capacity would keep
440+ // unaccounted memory alive (for example base64 input padded with CR/LF
441+ // or a zlib read buffer), so it is trimmed by copying.
442+ if copyEncoded || cap(data)-len(data) > len(data)/8 {
443+ exact := make([]byte, len(data))
444+ copy(exact, data)
445+ data = exact
446+ }
447+ return assetRecord{encoded: data, format: blob.Format, width: blob.Width, height: blob.Height, pixels: pixels}, nil
418 }448 }
419 449
420 func (s *Scene) preparePlacement(state *sceneState, spec PlacementSpec, existing *placementRecord) (placementRecord, error) {450 func (s *Scene) preparePlacement(state *sceneState, spec PlacementSpec, existing *placementRecord) (placementRecord, error) {
@@ -495,7 +525,7 @@ func removeAsset(state *sceneState, id AssetID, cascade bool) error {
495 func (s *Scene) applyOperation(state *sceneState, operation Operation) error {525 func (s *Scene) applyOperation(state *sceneState, operation Operation) error {
496 switch operation.Kind {526 switch operation.Kind {
497 case OperationAddAsset:527 case OperationAddAsset:
498- record, err := s.prepareAsset(operation.Blob, state)528+ record, err := s.prepareAsset(operation.Blob, state, true)
499 if err != nil {529 if err != nil {
500 return err530 return err
501 }531 }
M graphics/scene_test.go +35 -0
@@ -28,6 +28,41 @@ func TestRectOperationsRejectOverflow(t *testing.T) {
28 }28 }
29 }29 }
30 30
31+// Owned assets keep only accounted memory: a slice with large spare capacity
32+// is trimmed, a tight one is kept as is, and a rejected blob is not retained.
33+func TestOwnedAssetsRetainOnlyAccountedBytes(t *testing.T) {
34+ scene := NewScene(Limits{MaxEncodedBytes: 1 << 20, MaxDecodedPixels: 1 << 20})
35+ loose := make([]byte, 4, 1<<16)
36+ id, err := scene.AddAssetOwned(testAsset(t, loose, 1, 1))
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ if got := scene.state.assets[id].encoded; cap(got) != len(got) || &got[0] == &loose[0] {
41+ t.Fatalf("loose owned slice retained with cap %d for len %d", cap(got), len(got))
42+ }
43+ tight := []byte{1, 2, 3, 4}
44+ id, err = scene.ReplaceAssetOwned(id, testAsset(t, tight, 1, 1))
45+ if err != nil {
46+ t.Fatal(err)
47+ }
48+ if got := scene.state.assets[id].encoded; &got[0] != &tight[0] {
49+ t.Fatal("tight owned slice was copied")
50+ }
51+ before := scene.Snapshot()
52+ rejected := make([]byte, 2<<20)
53+ if _, err := scene.AddAssetOwned(testAsset(t, rejected, 1, 1)); !errors.Is(err, ErrEncodedBudget) {
54+ t.Fatalf("over-budget owned asset error = %v", err)
55+ }
56+ for _, asset := range scene.state.assets {
57+ if len(asset.encoded) > 0 && &asset.encoded[0] == &rejected[0] {
58+ t.Fatal("rejected owned blob retained")
59+ }
60+ }
61+ if scene.Snapshot().Usage() != before.Usage() {
62+ t.Fatal("rejected owned blob changed usage")
63+ }
64+}
65+
31 func TestAssetBlobOwnershipAndSnapshotLifecycle(t *testing.T) {66 func TestAssetBlobOwnershipAndSnapshotLifecycle(t *testing.T) {
32 scene := NewScene(Limits{MaxEncodedBytes: 32, MaxDecodedPixels: 64})67 scene := NewScene(Limits{MaxEncodedBytes: 32, MaxDecodedPixels: 64})
33 encoded := []byte{1, 2, 3}68 encoded := []byte{1, 2, 3}
M history.go +91 -14
@@ -17,6 +17,11 @@ var errInvalidHistoryRowID = errors.New("invalid history row ID")
17 17
18 const maxTailPreallocCells = 32 * 102418 const maxTailPreallocCells = 32 * 1024
19 19
20+// maxRetainedTailCells bounds tail storage recycled across seals. It covers a
21+// full default chunk of terminals up to 512 columns, so wide terminals reuse
22+// their tail too, while rare very wide rows are still released.
23+const maxRetainedTailCells = 4 * maxTailPreallocCells
24+
20 // History stores terminal rows in immutable chunks. It is intended to be25 // History stores terminal rows in immutable chunks. It is intended to be
21 // mutated by the owner of a Screen; views are safe to retain after later26 // mutated by the owner of a Screen; views are safe to retain after later
22 // appends.27 // appends.
@@ -39,6 +44,7 @@ type History struct {
39 styleScratch map[renderer.Style]struct{}44 styleScratch map[renderer.Style]struct{}
40 payloadScratch map[renderer.CellPayload]struct{}45 payloadScratch map[renderer.CellPayload]struct{}
41 tailPagePayloads map[renderer.CellPayload]struct{}46 tailPagePayloads map[renderer.CellPayload]struct{}
47+ styleRowScratch map[renderer.Style]int
42 rows int48 rows int
43 cells int49 cells int
44 logicalBytes uint6450 logicalBytes uint64
@@ -62,6 +68,28 @@ type HistoryChunk struct {
62 }68 }
63 69
64 func newHistoryChunks(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID) []*HistoryChunk {70 func newHistoryChunks(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID) []*HistoryChunk {
71+ return newHistoryChunksScratch(rows, bounds, rowIDs, nil)
72+}
73+
74+// maxRetainedStyleScratch bounds the style-set capacity kept between uses so
75+// one pathological high-cardinality page cannot pin a large map forever.
76+const maxRetainedStyleScratch = 1024
77+
78+// newHistoryChunksScratch is newHistoryChunks with an optional caller-owned
79+// map (scratch may be nil) reused, cleared, across chunks instead of allocating
80+// one per chunk. The map is replaced before any reuse if the previous chunk
81+// grew it past maxRetainedStyleScratch, and dropped on return if the last one
82+// did, so one high-cardinality chunk cannot pin a large map.
83+func newHistoryChunksScratch(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID, scratch *map[renderer.Style]int) []*HistoryChunk {
84+ var local map[renderer.Style]int
85+ if scratch == nil {
86+ scratch = &local
87+ }
88+ defer func() {
89+ if len(*scratch) > maxRetainedStyleScratch {
90+ *scratch = nil
91+ }
92+ }()
65 bounds = growBounds(bounds, len(rows))93 bounds = growBounds(bounds, len(rows))
66 rowIDs = growRowIDs(rowIDs, len(rows))94 rowIDs = growRowIDs(rowIDs, len(rows))
67 var chunks []*HistoryChunk95 var chunks []*HistoryChunk
@@ -73,12 +101,26 @@ func newHistoryChunks(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID
73 end++101 end++
74 }102 }
75 frame := renderer.NewFrame(width, end-start)103 frame := renderer.NewFrame(width, end-start)
76- lastStyleRow := make(map[renderer.Style]int)104+ if *scratch == nil || len(*scratch) > maxRetainedStyleScratch {
105+ *scratch = make(map[renderer.Style]int)
106+ } else {
107+ clear(*scratch)
108+ }
109+ lastStyleRow := *scratch
110+ defaultStyle := renderer.DefaultStyle()
77 for i := start; i < end; i++ {111 for i := start; i < end; i++ {
78 frame.WriteRow(i-start, 0, rows[i])112 frame.WriteRow(i-start, 0, rows[i])
79- for _, cell := range rows[i] {113+ // A run of identical raw styles canonicalizes identically and is
114+ // already recorded for this row, so only the first cell of a run
115+ // pays for Canonical and the map write.
116+ var previous renderer.Style
117+ for j, cell := range rows[i] {
118+ if j > 0 && cell.Style == previous {
119+ continue
120+ }
121+ previous = cell.Style
80 style := cell.Style.Canonical()122 style := cell.Style.Canonical()
81- if style != renderer.DefaultStyle() {123+ if style != defaultStyle {
82 lastStyleRow[style] = i - start124 lastStyleRow[style] = i - start
83 }125 }
84 }126 }
@@ -300,8 +342,8 @@ func (h *History) recordTailRowStyles(width int) {
300 if h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width) {342 if h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width) {
301 h.tailPageWidth = width343 h.tailPageWidth = width
302 h.tailPageRows = 0344 h.tailPageRows = 0
303- h.tailPageStyles = map[renderer.Style]struct{}{renderer.DefaultStyle(): {}}345+ h.resetTailPageSets()
304- h.tailPagePayloads = nil346+ h.tailPageStyles[renderer.DefaultStyle()] = struct{}{}
305 }347 }
306 for p := range h.payloadScratch {348 for p := range h.payloadScratch {
307 if h.tailPagePayloads == nil {349 if h.tailPagePayloads == nil {
@@ -315,13 +357,28 @@ func (h *History) recordTailRowStyles(width int) {
315 h.tailPageRows++357 h.tailPageRows++
316 }358 }
317 359
360+// resetTailPageSets empties the per-page style and payload sets, keeping their
361+// storage for the next page unless a pathological page grew them past
362+// maxRetainedStyleScratch. The style set is always non-nil afterwards.
363+func (h *History) resetTailPageSets() {
364+ if h.tailPageStyles == nil || len(h.tailPageStyles) > maxRetainedStyleScratch {
365+ h.tailPageStyles = make(map[renderer.Style]struct{})
366+ } else {
367+ clear(h.tailPageStyles)
368+ }
369+ if len(h.tailPagePayloads) > maxRetainedStyleScratch {
370+ h.tailPagePayloads = nil
371+ } else {
372+ clear(h.tailPagePayloads)
373+ }
374+}
375+
318 func (h *History) rebuildTailAccounting() {376 func (h *History) rebuildTailAccounting() {
319 old := h.tailBytes377 old := h.tailBytes
320 h.tailBytes = 0378 h.tailBytes = 0
321 h.tailPageWidth = 0379 h.tailPageWidth = 0
322 h.tailPageRows = 0380 h.tailPageRows = 0
323- h.tailPageStyles = nil381+ h.resetTailPageSets()
324- h.tailPagePayloads = nil
325 for _, row := range h.tail {382 for _, row := range h.tail {
326 h.prepareRowStyles(row)383 h.prepareRowStyles(row)
327 delta := h.tailAppendDelta(len(row))384 delta := h.tailAppendDelta(len(row))
@@ -496,16 +553,33 @@ func (h *History) sealTail() {
496 chunks := h.tailViewChunks()553 chunks := h.tailViewChunks()
497 h.chunks = append(h.chunks, chunks...)554 h.chunks = append(h.chunks, chunks...)
498 h.logicalBytes = h.logicalBytes - h.tailBytes + historyChunksLogicalBytes(chunks)555 h.logicalBytes = h.logicalBytes - h.tailBytes + historyChunksLogicalBytes(chunks)
499- h.tail = nil
500 h.cachedTail = nil556 h.cachedTail = nil
501- h.tailCells = nil557+ // The sealed chunks hold compact copies (newHistoryChunks and the bounds and
502- h.tailBounds = nil558+ // ID slices are copied), and no view, snapshot or callback ever receives
503- h.tailIDs = nil559+ // h.tail rows or h.tailCells. The mutable-tail storage is therefore private
560+ // and can be recycled for the next chunk instead of reallocated. Cells are
561+ // cleared so recycled storage does not pin payload strings. Oversized
562+ // storage (wide rows) is released as before.
563+ clear(h.tail)
564+ h.tail = h.tail[:0]
565+ if cap(h.tailCells) > maxRetainedTailCells {
566+ h.tailCells = nil
567+ } else {
568+ clear(h.tailCells)
569+ h.tailCells = h.tailCells[:0]
570+ }
571+ h.tailBounds = h.tailBounds[:0]
572+ h.tailIDs = h.tailIDs[:0]
504 h.tailBytes = 0573 h.tailBytes = 0
505 h.tailPageWidth = 0574 h.tailPageWidth = 0
506 h.tailPageRows = 0575 h.tailPageRows = 0
507- h.tailPageStyles = nil576+ // Sets are cleared lazily by the next page; release oversized ones now.
508- h.tailPagePayloads = nil577+ if len(h.tailPageStyles) > maxRetainedStyleScratch {
578+ h.tailPageStyles = nil
579+ }
580+ if len(h.tailPagePayloads) > maxRetainedStyleScratch {
581+ h.tailPagePayloads = nil
582+ }
509 }583 }
510 584
511 // evictFor discards oldest rows until row can fit both retention budgets.585 // evictFor discards oldest rows until row can fit both retention budgets.
@@ -552,6 +626,9 @@ func (h *History) evictOldest() {
552 h.cells -= len(row)626 h.cells -= len(row)
553 h.tail[0] = nil627 h.tail[0] = nil
554 h.tail = h.tail[1:]628 h.tail = h.tail[1:]
629+ // The evicted prefix stays in the recycled backing array; clear it so its
630+ // payload strings are not pinned until the next seal.
631+ clear(row)
555 h.tailCells = h.tailCells[len(row):]632 h.tailCells = h.tailCells[len(row):]
556 h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]633 h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
557 h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:]634 h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:]
@@ -569,7 +646,7 @@ func (h *History) SealAndView() HistoryView {
569 646
570 func (h *History) tailViewChunks() []*HistoryChunk {647 func (h *History) tailViewChunks() []*HistoryChunk {
571 if h.cachedTail == nil && len(h.tail) > 0 {648 if h.cachedTail == nil && len(h.tail) > 0 {
572- h.cachedTail = newHistoryChunks(h.tail, h.tailBounds, h.tailIDs)649+ h.cachedTail = newHistoryChunksScratch(h.tail, h.tailBounds, h.tailIDs, &h.styleRowScratch)
573 }650 }
574 return h.cachedTail651 return h.cachedTail
575 }652 }
M history_codec.go +7 -5
@@ -234,7 +234,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
234 return invalid()234 return invalid()
235 }235 }
236 stats := historyDecodeStats{chunks: uint64(chunkCount)}236 stats := historyDecodeStats{chunks: uint64(chunkCount)}
237- seenIDs := make(map[RowID]struct{})237+ var seenIDs rowIDSet
238 var maxID RowID238 var maxID RowID
239 var chunks []*HistoryChunk239 var chunks []*HistoryChunk
240 if populate {240 if populate {
@@ -280,6 +280,7 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
280 return invalid()280 return invalid()
281 }281 }
282 usedPayloads := make([]bool, npayloads)282 usedPayloads := make([]bool, npayloads)
283+ seenIDs.begin(int(rows))
283 rowIDs := make([]RowID, rows)284 rowIDs := make([]RowID, rows)
284 bounds := make([]LineBound, rows)285 bounds := make([]LineBound, rows)
285 for row := range rows {286 for row := range rows {
@@ -288,13 +289,14 @@ func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats,
288 if !ok || !bok || id == 0 || id >= math.MaxUint64-1 || !validHistoryBound(bound, int(width)) {289 if !ok || !bok || id == 0 || id >= math.MaxUint64-1 || !validHistoryBound(bound, int(width)) {
289 return invalid()290 return invalid()
290 }291 }
291- if _, duplicate := seenIDs[RowID(id)]; duplicate {292+ seenIDs.collect(RowID(id))
292- return invalid()
293- }
294- seenIDs[RowID(id)] = struct{}{}
295 maxID = max(maxID, RowID(id))293 maxID = max(maxID, RowID(id))
296 rowIDs[row], bounds[row] = RowID(id), bound294 rowIDs[row], bounds[row] = RowID(id), bound
297 }295 }
296+ // Reject duplicate IDs before decoding any cells, as the map did.
297+ if !seenIDs.commit() {
298+ return invalid()
299+ }
298 lastStyleRow := make([]int, nstyles)300 lastStyleRow := make([]int, nstyles)
299 for i := range lastStyleRow {301 for i := range lastStyleRow {
300 lastStyleRow[i] = -1302 lastStyleRow[i] = -1
A history_rowids.go +64 -0
@@ -0,0 +1,64 @@
1+package vt
2+
3+import "slices"
4+
5+// rowIDSet detects duplicate row IDs across one or more decoded blobs without
6+// a hash map in the common case. Row IDs are NOT required to be monotonic
7+// (History.AppendWithID accepts any unused ID, so persisted blobs may list IDs
8+// out of order), so this is an exact set, not an ordering check: it accepts
9+// and rejects precisely the inputs a map[RowID]struct{} would.
10+//
11+// While every view contributes IDs strictly above everything seen so far, IDs
12+// are kept in an ascending slice. The first view that interleaves with earlier
13+// IDs converts the set to a map; from then on it behaves like a plain set.
14+type rowIDSet struct {
15+ sorted []RowID
16+ members map[RowID]struct{}
17+ scratch []RowID
18+}
19+
20+// begin discards any IDs collected but not committed and reserves room for n.
21+func (s *rowIDSet) begin(n int) { s.scratch = slices.Grow(s.scratch[:0], n) }
22+
23+// reserve makes room for n more collected IDs.
24+func (s *rowIDSet) reserve(n int) { s.scratch = slices.Grow(s.scratch, n) }
25+
26+// collect records one ID of the view being collected.
27+func (s *rowIDSet) collect(id RowID) { s.scratch = append(s.scratch, id) }
28+
29+// commit reports whether the collected IDs are distinct from each other and
30+// from every previously committed ID, and if so adds them to the set.
31+func (s *rowIDSet) commit() bool {
32+ ids := s.scratch
33+ s.scratch = s.scratch[:0]
34+ if len(ids) == 0 {
35+ return true
36+ }
37+ slices.Sort(ids) // the scratch copy is private, so sorting in place is safe
38+
39+ for i := 1; i < len(ids); i++ {
40+ if ids[i] == ids[i-1] {
41+ return false
42+ }
43+ }
44+ if s.members == nil {
45+ if len(s.sorted) == 0 || ids[0] > s.sorted[len(s.sorted)-1] {
46+ s.sorted = append(s.sorted, ids...)
47+ return true
48+ }
49+ s.members = make(map[RowID]struct{}, len(s.sorted)+len(ids))
50+ for _, id := range s.sorted {
51+ s.members[id] = struct{}{}
52+ }
53+ s.sorted = nil
54+ }
55+ for _, id := range ids {
56+ if _, duplicate := s.members[id]; duplicate {
57+ return false
58+ }
59+ }
60+ for _, id := range ids {
61+ s.members[id] = struct{}{}
62+ }
63+ return true
64+}
A history_tail_reuse_test.go +330 -0
@@ -0,0 +1,330 @@
1+package vt
2+
3+import (
4+ "fmt"
5+ "math/rand"
6+ "testing"
7+
8+ renderer "github.com/bnema/vev-vt/core"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func styledRow(text string, fg int) []renderer.Cell {
13+ row := historyRow(text)
14+ for i := range row {
15+ row[i].Style = renderer.Style{Bold: true, Foreground: fg}
16+ }
17+ return row
18+}
19+
20+func rangeTexts(t *testing.T, v HistoryView) []string {
21+ t.Helper()
22+ var out []string
23+ require.NoError(t, v.Range(func(row []renderer.Cell) bool {
24+ out = append(out, rowText(row))
25+ return true
26+ }))
27+ return out
28+}
29+
30+// Sealing recycles the private tail storage. No previously handed-out view,
31+// snapshot tail or Range row may observe later appends or seals.
32+func TestHistoryTailStorageReuseKeepsViewsStable(t *testing.T) {
33+ h := NewHistory(HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 4})
34+ appendRow := func(i int) {
35+ require.NoError(t, h.Append(styledRow(fmt.Sprintf("r%03d", i), i%5+1), LineBound{End: 4}))
36+ }
37+ for i := range 2 {
38+ appendRow(i)
39+ }
40+ partial := h.View()
41+ snap := h.SnapshotView()
42+ var ranged [][]renderer.Cell
43+ require.NoError(t, partial.Range(func(row []renderer.Cell) bool { ranged = append(ranged, row); return true }))
44+
45+ for i := 2; i < 14; i++ { // seals three times, reusing storage each time
46+ appendRow(i)
47+ }
48+ sealedView := h.SealAndView()
49+ appendRow(14)
50+ appendRow(15)
51+
52+ require.Equal(t, []string{"r000", "r001"}, rangeTexts(t, partial))
53+ require.Equal(t, []string{"r000", "r001"}, rangeTexts(t, snap.Tail()))
54+ require.Equal(t, "r000", rowText(ranged[0]))
55+ require.Equal(t, "r001", rowText(ranged[1]))
56+ require.Equal(t, 14, sealedView.Len())
57+ texts := rangeTexts(t, sealedView)
58+ for i, got := range texts {
59+ require.Equal(t, fmt.Sprintf("r%03d", i), got)
60+ }
61+ live := rangeTexts(t, h.View())
62+ require.Len(t, live, 16)
63+ require.Equal(t, "r015", live[15])
64+ for _, c := range h.chunks {
65+ require.NoError(t, c.CheckInvariants())
66+ }
67+}
68+
69+func TestHistoryTailStorageIsRecycledAcrossSeals(t *testing.T) {
70+ h := NewHistory(HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 4})
71+ for i := range 4 {
72+ require.NoError(t, h.Append(historyRow(fmt.Sprintf("r%03d", i)), LineBound{End: 4}))
73+ }
74+ require.Empty(t, h.tail)
75+ require.Zero(t, len(h.tailCells))
76+ require.NotZero(t, cap(h.tailCells), "seal must keep tail storage for the next chunk")
77+ first := &h.tailCells[:1][0]
78+ for i := 4; i < 8; i++ {
79+ require.NoError(t, h.Append(historyRow(fmt.Sprintf("r%03d", i)), LineBound{End: 4}))
80+ }
81+ require.Same(t, first, &h.tailCells[:1][0], "storage reallocated across seals")
82+ // A reused backing array must not pin payload strings of sealed rows.
83+ payload, err := renderer.NewCellPayload("é\u0301", "https://example.test")
84+ require.NoError(t, err)
85+ row := historyRow("abcd")
86+ row[0].Payload = payload
87+ for range 4 {
88+ require.NoError(t, h.Append(row, LineBound{End: 4}))
89+ }
90+ for _, c := range h.tailCells[:cap(h.tailCells)] {
91+ require.True(t, c.Payload.Empty())
92+ }
93+ require.NoError(t, h.chunks[len(h.chunks)-1].CheckInvariants())
94+}
95+
96+// Default chunks of wide terminals (256 rows × 200 columns) must recycle their
97+// tail storage too.
98+func TestHistoryTailStorageIsRecycledForWideTerminals(t *testing.T) {
99+ const width = 200
100+ h := NewHistory(HistoryConfig{MaxRows: 4096, MaxBytes: 1 << 30})
101+ row := make([]renderer.Cell, width)
102+ for i := range row {
103+ row[i] = renderer.Cell{Rune: 'w', Style: renderer.DefaultStyle()}
104+ }
105+ for range 256 {
106+ require.NoError(t, h.Append(row, LineBound{End: width}))
107+ }
108+ require.Empty(t, h.tail)
109+ require.GreaterOrEqual(t, cap(h.tailCells), 256*width, "seal released wide-terminal tail storage")
110+}
111+
112+// Eviction from a partial tail must not leave the evicted rows' payload
113+// strings pinned in the recycled backing array's prefix.
114+func TestHistoryTailEvictionClearsEvictedPrefix(t *testing.T) {
115+ payload, err := renderer.NewCellPayload("é\u0301", "https://example.test")
116+ require.NoError(t, err)
117+ rowFor := func(i int) []renderer.Cell {
118+ row := historyRow("abcd")
119+ row[i%4].Payload = payload
120+ return row
121+ }
122+ // The byte budget evicts from the tail (ChunkRows is large, so it never
123+ // seals); the 400-cell preallocation leaves room for many evictions in
124+ // one backing array before it would be reallocated.
125+ h := NewHistory(HistoryConfig{MaxRows: 100, MaxBytes: 1500, ChunkRows: 100})
126+ require.NoError(t, h.Append(rowFor(0), LineBound{End: 4}))
127+ orig := h.tailCells[:cap(h.tailCells)] // full original backing array
128+ evicted := 0
129+ for i := 1; i < 40 && len(h.chunks) == 0; i++ {
130+ before := h.Len()
131+ require.NoError(t, h.Append(rowFor(i), LineBound{End: 4}))
132+ if cap(h.tailCells) > len(orig) || len(h.tailCells) == 0 {
133+ break
134+ }
135+ liveStart := len(orig) - cap(h.tailCells)
136+ if &orig[liveStart] != &h.tailCells[:1][0] {
137+ break // storage was reallocated; the old array is no longer ours
138+ }
139+ if h.Len() <= before {
140+ evicted++
141+ }
142+ liveEnd := liveStart + len(h.tailCells)
143+ for j, c := range orig {
144+ if j < liveStart || j >= liveEnd {
145+ require.True(t, c.Payload.Empty(), "cell %d outside live window [%d,%d) pins payload", j, liveStart, liveEnd)
146+ }
147+ }
148+ }
149+ require.GreaterOrEqual(t, evicted, 2, "test did not exercise tail eviction")
150+}
151+
152+// checkHistoryAccounting compares the incrementally maintained LogicalBytes
153+// with (1) the sum of the live chunks' own metadata and (2) an independent
154+// recomputation: every live chunk is rebuilt from its decoded rows with a
155+// fresh newHistoryChunks call (no recycled scratch state). Logical bytes depend
156+// on the page layout, so the reference keeps each chunk's boundaries.
157+func checkHistoryAccounting(t *testing.T, h *History) {
158+ t.Helper()
159+ view := h.View()
160+ require.Equal(t, historyChunksLogicalBytes(view.chunks), h.LogicalBytes(), "rows=%d", h.Len())
161+ require.Equal(t, h.Len(), view.Len())
162+ var rebuilt uint64
163+ for _, c := range view.chunks {
164+ rows := make([][]renderer.Cell, c.len())
165+ for i := range rows {
166+ rows[i] = c.row(i)
167+ }
168+ rebuilt += historyChunksLogicalBytes(newHistoryChunks(rows, c.bounds, c.rowIDs))
169+ }
170+ require.Equal(t, rebuilt, h.LogicalBytes(), "independent rebuild rows=%d", h.Len())
171+}
172+
173+// Accounting must stay exact after seals recycle the style and payload sets,
174+// through eviction, SetLimits shrinking, payload rows, width changes and pages
175+// with more than maxRetainedStyleScratch distinct styles.
176+func TestHistoryLogicalBytesIndependentOfSetReuse(t *testing.T) {
177+ payload, err := renderer.NewCellPayload("x", "https://example.test")
178+ require.NoError(t, err)
179+ payload2, err := renderer.NewCellPayload("y", "")
180+ require.NoError(t, err)
181+ manyStyles := func(i int) []renderer.Cell { // 1100 distinct styles in one row
182+ row := make([]renderer.Cell, 1100)
183+ for x := range row {
184+ row[x] = renderer.Cell{Rune: 'z', Style: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: uint8(x), G: uint8(x >> 8), B: uint8(i)}}}
185+ }
186+ return row
187+ }
188+ rowFor := func(i int) []renderer.Cell {
189+ switch {
190+ case i%11 == 5:
191+ return manyStyles(i)
192+ case i%4 == 0:
193+ row := styledRow("abcd", i%7+1)
194+ row[1].Payload = payload
195+ row[2].Payload = payload2
196+ return row
197+ case i%3 == 0:
198+ return styledRow("abcdef", i%5+1) // width change forces a new page
199+ }
200+ return styledRow("abcd", i%7+1)
201+ }
202+ for _, tc := range []struct {
203+ name string
204+ cfg HistoryConfig
205+ }{
206+ {"non-divisible chunks", HistoryConfig{MaxRows: 200, MaxBytes: 1 << 30, ChunkRows: 7}},
207+ {"row eviction", HistoryConfig{MaxRows: 9, MaxBytes: 1 << 30, ChunkRows: 4}},
208+ {"byte eviction", HistoryConfig{MaxRows: 200, MaxBytes: 30 << 10, ChunkRows: 5}},
209+ {"tiny bytes", HistoryConfig{MaxRows: 200, MaxBytes: 1500, ChunkRows: 3}},
210+ } {
211+ t.Run(tc.name, func(t *testing.T) {
212+ h := NewHistory(tc.cfg)
213+ appendAt := func(i int) {
214+ row := rowFor(i)
215+ if err := h.Append(row, LineBound{End: len(row)}); err != nil {
216+ require.ErrorIs(t, err, ErrHistoryRowTooLarge)
217+ }
218+ checkHistoryAccounting(t, h)
219+ }
220+ for i := range 60 {
221+ appendAt(i)
222+ }
223+ shrunk := HistoryConfig{MaxRows: 5, MaxBytes: tc.cfg.MaxBytes, ChunkRows: 2}
224+ require.NoError(t, h.SetLimits(shrunk))
225+ checkHistoryAccounting(t, h)
226+ for i := 60; i < 75; i++ {
227+ appendAt(i)
228+ }
229+ })
230+ }
231+}
232+
233+// rowIDSet must accept and reject exactly what a plain map would, including
234+// out-of-order IDs and duplicates across commits.
235+func TestRowIDSetMatchesMap(t *testing.T) {
236+ rng := rand.New(rand.NewSource(1))
237+ for iter := 0; iter < 5000; iter++ {
238+ var set rowIDSet
239+ ref := map[RowID]struct{}{}
240+ for commit := 0; commit < 1+rng.Intn(4); commit++ {
241+ n := rng.Intn(6)
242+ ids := make([]RowID, n)
243+ base := RowID(rng.Intn(30))
244+ for i := range ids {
245+ if rng.Intn(3) == 0 {
246+ ids[i] = RowID(1 + rng.Intn(30))
247+ } else {
248+ ids[i] = base + RowID(i) + 1
249+ }
250+ }
251+ if rng.Intn(4) == 0 && n > 1 {
252+ ids[0], ids[n-1] = ids[n-1], ids[0]
253+ }
254+ want := true
255+ seen := map[RowID]struct{}{}
256+ for _, id := range ids {
257+ if _, dup := ref[id]; dup {
258+ want = false
259+ }
260+ if _, dup := seen[id]; dup {
261+ want = false
262+ }
263+ seen[id] = struct{}{}
264+ }
265+ set.reserve(n)
266+ for _, id := range ids {
267+ set.collect(id)
268+ }
269+ require.Equal(t, want, set.commit(), "iter %d ids %v", iter, ids)
270+ if !want {
271+ break
272+ }
273+ for _, id := range ids {
274+ ref[id] = struct{}{}
275+ }
276+ }
277+ }
278+}
279+
280+func TestRestoreAcceptsOutOfOrderDistinctRowIDsAcrossBlobs(t *testing.T) {
281+ src := NewHistory(HistoryConfig{MaxRows: 16, ChunkRows: 2})
282+ for _, id := range []RowID{100, 7, 8, 9} {
283+ require.NoError(t, src.AppendWithID(historyRow("abcd"), LineBound{End: 4}, id))
284+ }
285+ sealed, tail, err := MarshalSealedHistory(src.SealAndView())
286+ require.NoError(t, err)
287+ restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 16, ChunkRows: 2}, sealed, tail)
288+ require.NoError(t, err)
289+ require.Equal(t, 4, restored.Len())
290+ // Same IDs in two blobs are still rejected, in either order.
291+ _, err = HistoryFromBlobs(HistoryConfig{MaxRows: 16, ChunkRows: 2}, [][]byte{sealed[0], sealed[0]}, tail)
292+ require.Error(t, err)
293+ _, err = HistoryFromBlobs(HistoryConfig{MaxRows: 16, ChunkRows: 2}, [][]byte{sealed[1], sealed[0], sealed[1]}, tail)
294+ require.Error(t, err)
295+}
296+
297+// A chunk with more than maxRetainedStyleScratch styles must not leave its
298+// large scratch map pinned, whether it is the last or an earlier chunk.
299+func TestHistoryStyleScratchDroppedAfterHighCardinalityChunk(t *testing.T) {
300+ row := make([]renderer.Cell, 1100)
301+ for x := range row {
302+ row[x] = renderer.Cell{Rune: 'z', Style: renderer.Style{HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: uint8(x), G: uint8(x >> 8), B: 1}}}
303+ }
304+ h := NewHistory(HistoryConfig{MaxRows: 100, MaxBytes: 1 << 30, ChunkRows: 4})
305+ for range 4 { // seals one high-cardinality chunk
306+ require.NoError(t, h.Append(row, LineBound{End: len(row)}))
307+ }
308+ require.LessOrEqual(t, len(h.styleRowScratch), maxRetainedStyleScratch)
309+ require.LessOrEqual(t, len(h.tailPageStyles), maxRetainedStyleScratch+1)
310+ for range 3 {
311+ require.NoError(t, h.Append(historyRow("abcd"), LineBound{End: 4}))
312+ }
313+ _ = h.View()
314+ require.LessOrEqual(t, len(h.styleRowScratch), maxRetainedStyleScratch)
315+}
316+
317+// Early false returns from validation must not leak collected IDs into a later
318+// commit.
319+func TestRowIDSetBeginDiscardsUncommittedIDs(t *testing.T) {
320+ var set rowIDSet
321+ set.begin(0)
322+ set.collect(1)
323+ set.collect(2) // abandoned without commit, as on an early invalid return
324+ set.begin(0)
325+ set.collect(2)
326+ require.True(t, set.commit())
327+ set.begin(0)
328+ set.collect(1)
329+ require.True(t, set.commit())
330+}
M html/browser/consumer_external_test.go +1 -1
@@ -10,7 +10,7 @@ func TestExternalBrowserConsumerUsesEmbeddedRuntimeAndNeutralEvents(t *testing.T
10 if browser.JavaScript() == "" {10 if browser.JavaScript() == "" {
11 t.Fatal("embedded browser runtime is empty")11 t.Fatal("embedded browser runtime is empty")
12 }12 }
13- event, err := browser.DecodeEvent([]byte(`{"schemaVersion":1,"type":"focus","focused":true}`), browser.EventLimits{})13+ event, err := browser.DecodeEvent([]byte(`{"schemaVersion":2,"type":"focus","focused":true}`), browser.EventLimits{})
14 if err != nil {14 if err != nil {
15 t.Fatal(err)15 t.Fatal(err)
16 }16 }
M html/browser/event_test.go +27 -27
@@ -9,26 +9,26 @@ import (
9 )9 )
10 10
11 func TestDecodeEventValidatesTheClosedSchema(t *testing.T) {11 func TestDecodeEventValidatesTheClosedSchema(t *testing.T) {
12- event, err := DecodeEvent([]byte(`{"schemaVersion":1,"type":"text","text":"é"}`), EventLimits{})12+ event, err := DecodeEvent([]byte(`{"schemaVersion":2,"type":"text","text":"é"}`), EventLimits{})
13 require.NoError(t, err)13 require.NoError(t, err)
14 require.Equal(t, EventText, event.Kind)14 require.Equal(t, EventText, event.Kind)
15 require.Equal(t, "é", event.Text.Text)15 require.Equal(t, "é", event.Text.Text)
16 16
17- _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"text","text":"x","unknown":true}`), EventLimits{})17+ _, err = DecodeEvent([]byte(`{"schemaVersion":2,"type":"text","text":"x","unknown":true}`), EventLimits{})
18 require.Error(t, err)18 require.Error(t, err)
19 19
20- _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"paste","text":"toolong"}`), EventLimits{MaxPasteBytes: 3})20+ _, err = DecodeEvent([]byte(`{"schemaVersion":2,"type":"paste","text":"toolong"}`), EventLimits{MaxPasteBytes: 3})
21 require.ErrorIs(t, err, ErrEventLimit)21 require.ErrorIs(t, err, ErrEventLimit)
22 22
23 _, err = DecodeEvent([]byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}, EventLimits{})23 _, err = DecodeEvent([]byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}, EventLimits{})
24 require.ErrorContains(t, err, "not valid UTF-8")24 require.ErrorContains(t, err, "not valid UTF-8")
25 25
26- _, err = DecodeEvent([]byte(`{"schemaVersion":1,"type":"resize","columns":80,"rows":24,"pixelWidth":800,"pixelHeight":480,"cellWidth":10,"cellHeight":20,"devicePixelRatio":1}`), EventLimits{})26+ _, err = DecodeEvent([]byte(`{"schemaVersion":2,"type":"resize","columns":80,"rows":24,"pixelWidth":800,"pixelHeight":480,"cellWidth":10,"cellHeight":20,"devicePixelRatio":1}`), EventLimits{})
27 require.NoError(t, err)27 require.NoError(t, err)
28 28
29 for _, data := range []string{29 for _, data := range []string{
30- `{"schemaVersion":1,"type":"focus"}`,30+ `{"schemaVersion":2,"type":"focus"}`,
31- `{"schemaVersion":1,"type":"wheel"}`,31+ `{"schemaVersion":2,"type":"wheel"}`,
32 } {32 } {
33 _, err = DecodeEvent([]byte(data), EventLimits{})33 _, err = DecodeEvent([]byte(data), EventLimits{})
34 require.Error(t, err)34 require.Error(t, err)
@@ -40,11 +40,11 @@ func TestDecodeEventAcceptsRuntimeEventKinds(t *testing.T) {
40 kind EventKind40 kind EventKind
41 json string41 json string
42 }{42 }{
43- {EventKey, `{"schemaVersion":1,"type":"key","key":"ArrowUp","code":"ArrowUp","alt":false,"ctrl":false,"meta":false,"shift":false,"repeat":false,"location":0}`},43+ {EventKey, `{"schemaVersion":2,"type":"key","key":"ArrowUp","code":"ArrowUp","alt":false,"ctrl":false,"meta":false,"shift":false,"repeat":false,"location":0}`},
44- {EventPaste, `{"schemaVersion":1,"type":"paste","text":"hello"}`},44+ {EventPaste, `{"schemaVersion":2,"type":"paste","text":"hello"}`},
45- {EventPointer, `{"schemaVersion":1,"type":"pointer","action":"down","button":0,"buttons":1,"row":0,"column":0,"x":1,"y":2,"alt":false,"ctrl":false,"meta":false,"shift":false}`},45+ {EventPointer, `{"schemaVersion":2,"type":"pointer","action":"down","button":0,"buttons":1,"row":0,"column":0,"x":1,"y":2,"alt":false,"ctrl":false,"meta":false,"shift":false}`},
46- {EventWheel, `{"schemaVersion":1,"type":"wheel","deltaX":1,"deltaY":2,"deltaMode":0,"row":0,"column":0,"alt":false,"ctrl":false,"meta":false,"shift":false}`},46+ {EventWheel, `{"schemaVersion":2,"type":"wheel","deltaX":1,"deltaY":2,"deltaMode":0,"row":0,"column":0,"alt":false,"ctrl":false,"meta":false,"shift":false}`},
47- {EventFocus, `{"schemaVersion":1,"type":"focus","focused":true}`},47+ {EventFocus, `{"schemaVersion":2,"type":"focus","focused":true}`},
48 }48 }
49 for _, test := range tests {49 for _, test := range tests {
50 t.Run(string(test.kind), func(t *testing.T) {50 t.Run(string(test.kind), func(t *testing.T) {
@@ -84,12 +84,12 @@ func TestNormalizeEventLimitsRejectsImpossibleEnvelope(t *testing.T) {
84 84
85 func TestDecodeEventRejectsMissingRequiredFields(t *testing.T) {85 func TestDecodeEventRejectsMissingRequiredFields(t *testing.T) {
86 tests := []string{86 tests := []string{
87- `{"schemaVersion":1,"type":"text"}`,87+ `{"schemaVersion":2,"type":"text"}`,
88- `{"schemaVersion":1,"type":"key","code":"ArrowUp"}`,88+ `{"schemaVersion":2,"type":"key","code":"ArrowUp"}`,
89- `{"schemaVersion":1,"type":"pointer","action":"down"}`,89+ `{"schemaVersion":2,"type":"pointer","action":"down"}`,
90- `{"schemaVersion":1,"type":"wheel"}`,90+ `{"schemaVersion":2,"type":"wheel"}`,
91- `{"schemaVersion":1,"type":"resize","columns":80,"rows":24}`,91+ `{"schemaVersion":2,"type":"resize","columns":80,"rows":24}`,
92- `{"schemaVersion":1,"type":"focus"}`,92+ `{"schemaVersion":2,"type":"focus"}`,
93 }93 }
94 for _, data := range tests {94 for _, data := range tests {
95 _, err := DecodeEvent([]byte(data), EventLimits{})95 _, err := DecodeEvent([]byte(data), EventLimits{})
@@ -99,13 +99,13 @@ func TestDecodeEventRejectsMissingRequiredFields(t *testing.T) {
99 99
100 func TestDecodeEventRejectsInvalidRuntimePayloads(t *testing.T) {100 func TestDecodeEventRejectsInvalidRuntimePayloads(t *testing.T) {
101 tests := map[string]string{101 tests := map[string]string{
102- "schema": `{"schemaVersion":2,"type":"focus","focused":true}`,102+ "schema": `{"schemaVersion":3,"type":"focus","focused":true}`,
103- "pointer button": `{"schemaVersion":1,"type":"pointer","action":"down","button":5,"buttons":1,"row":0,"column":0,"x":1,"y":2}`,103+ "pointer button": `{"schemaVersion":2,"type":"pointer","action":"down","button":5,"buttons":1,"row":0,"column":0,"x":1,"y":2}`,
104- "pointer buttons": `{"schemaVersion":1,"type":"pointer","action":"down","button":0,"buttons":32,"row":0,"column":0,"x":1,"y":2}`,104+ "pointer buttons": `{"schemaVersion":2,"type":"pointer","action":"down","button":0,"buttons":32,"row":0,"column":0,"x":1,"y":2}`,
105- "wheel mode": `{"schemaVersion":1,"type":"wheel","deltaX":1,"deltaY":2,"deltaMode":3,"row":0,"column":0}`,105+ "wheel mode": `{"schemaVersion":2,"type":"wheel","deltaX":1,"deltaY":2,"deltaMode":3,"row":0,"column":0}`,
106- "resize dimension": `{"schemaVersion":1,"type":"resize","columns":0,"rows":24,"pixelWidth":800,"pixelHeight":480,"cellWidth":10,"cellHeight":20,"devicePixelRatio":1}`,106+ "resize dimension": `{"schemaVersion":2,"type":"resize","columns":0,"rows":24,"pixelWidth":800,"pixelHeight":480,"cellWidth":10,"cellHeight":20,"devicePixelRatio":1}`,
107- "non-finite coordinate": `{"schemaVersion":1,"type":"pointer","action":"move","button":-1,"buttons":0,"row":0,"column":0,"x":1e999,"y":2}`,107+ "non-finite coordinate": `{"schemaVersion":2,"type":"pointer","action":"move","button":-1,"buttons":0,"row":0,"column":0,"x":1e999,"y":2}`,
108- "trailing value": `{"schemaVersion":1,"type":"focus","focused":true} {}`,108+ "trailing value": `{"schemaVersion":2,"type":"focus","focused":true} {}`,
109 }109 }
110 for name, data := range tests {110 for name, data := range tests {
111 t.Run(name, func(t *testing.T) {111 t.Run(name, func(t *testing.T) {
@@ -116,10 +116,10 @@ func TestDecodeEventRejectsInvalidRuntimePayloads(t *testing.T) {
116 }116 }
117 117
118 func FuzzDecodeEvent(f *testing.F) {118 func FuzzDecodeEvent(f *testing.F) {
119- f.Add([]byte(`{"schemaVersion":1,"type":"focus","focused":true}`))119+ f.Add([]byte(`{"schemaVersion":2,"type":"focus","focused":true}`))
120- f.Add([]byte(`{"schemaVersion":1,"type":"text","text":"hello"}`))120+ f.Add([]byte(`{"schemaVersion":2,"type":"text","text":"hello"}`))
121 f.Add([]byte(`not json`))121 f.Add([]byte(`not json`))
122- f.Add([]byte(`{"schemaVersion":1,"type":"text","text":"` + strings.Repeat("x", 4<<10) + `"}`))122+ f.Add([]byte(`{"schemaVersion":2,"type":"text","text":"` + strings.Repeat("x", 4<<10) + `"}`))
123 f.Fuzz(func(t *testing.T, data []byte) {123 f.Fuzz(func(t *testing.T, data []byte) {
124 event, err := DecodeEvent(data, EventLimits{MaxEventBytes: 4 << 10, MaxTextBytes: 512, MaxPasteBytes: 512})124 event, err := DecodeEvent(data, EventLimits{MaxEventBytes: 4 << 10, MaxTextBytes: 512, MaxPasteBytes: 512})
125 if len(data) > 4<<10 {125 if len(data) > 4<<10 {
M html/browser/terminal.js +137 -25
@@ -13,9 +13,9 @@
13 /** @typedef {{maxCells?: number, maxRowsPerUpdate?: number, maxColumns?: number, maxStyles?: number, maxTextBytes?: number, maxUpdateBytes?: number, maxPasteBytes?: number}} BrowserLimits */13 /** @typedef {{maxCells?: number, maxRowsPerUpdate?: number, maxColumns?: number, maxStyles?: number, maxTextBytes?: number, maxUpdateBytes?: number, maxPasteBytes?: number}} BrowserLimits */
14 /** @typedef {{foreground: RGB, background: RGB, cursor: RGB, selection: RGB, selectionText: RGB, palette: RGB[]}} TerminalTheme */14 /** @typedef {{foreground: RGB, background: RGB, cursor: RGB, selection: RGB, selectionText: RGB, palette: RGB[]}} TerminalTheme */
15 /** @typedef {{label: string, decide?: (event: BrowserEvent) => CaptureDecision, send?: (event: BrowserEvent) => void, limits?: BrowserLimits}} MountOptions */15 /** @typedef {{label: string, decide?: (event: BrowserEvent) => CaptureDecision, send?: (event: BrowserEvent) => void, limits?: BrowserLimits}} MountOptions */
16- /** @typedef {{apply(update: Update): void, focus(): void, setMouseCapture(enabled: boolean): void, setTheme(theme: TerminalTheme): void, destroy(): void}} Terminal */16+ /** @typedef {{apply(update: Update): void, applyAll(updates: Update[]): void, focus(): void, setMouseCapture(enabled: boolean): void, setTheme(theme: TerminalTheme): void, destroy(): void}} Terminal */
17 17
18- const SCHEMA_VERSION = 1;18+ const SCHEMA_VERSION = 2;
19 const DEFAULT_LIMITS = Object.freeze({19 const DEFAULT_LIMITS = Object.freeze({
20 maxCells: 1_000_000,20 maxCells: 1_000_000,
21 maxRowsPerUpdate: 10_000,21 maxRowsPerUpdate: 10_000,
@@ -26,6 +26,7 @@
26 maxPasteBytes: 1 << 2026 maxPasteBytes: 1 << 20
27 });27 });
28 const encoder = new TextEncoder();28 const encoder = new TextEncoder();
29+ const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
29 30
30 function fail(message) {31 function fail(message) {
31 throw new TypeError(`VevTerminal: ${message}`);32 throw new TypeError(`VevTerminal: ${message}`);
@@ -56,7 +57,7 @@
56 /** @returns {{value: string, byteLength: number}} */57 /** @returns {{value: string, byteLength: number}} */
57 function boundedString(value, limit, name) {58 function boundedString(value, limit, name) {
58 if (typeof value !== 'string') fail(`${name} must be a string`);59 if (typeof value !== 'string') fail(`${name} must be a string`);
59- const byteLength = encoder.encode(value).byteLength;60+ const byteLength = PRINTABLE_ASCII.test(value) ? value.length : encoder.encode(value).byteLength;
60 if (byteLength > limit) fail(`${name} exceeds its byte limit`);61 if (byteLength > limit) fail(`${name} exceeds its byte limit`);
61 return { value, byteLength };62 return { value, byteLength };
62 }63 }
@@ -148,10 +149,13 @@
148 object(candidate, name);149 object(candidate, name);
149 keys(candidate, ['column', 'width', 'text', 'style'], name);150 keys(candidate, ['column', 'width', 'text', 'style'], name);
150 const current = integer(candidate.column, 0, width - 1, `${name}.column`);151 const current = integer(candidate.column, 0, width - 1, `${name}.column`);
151- const cellWidth = integer(candidate.width, 1, 2, `${name}.width`);152+ const cellWidth = integer(candidate.width, 1, configured.maxColumns, `${name}.width`);
152 if (current !== column) fail(`row coverage is not contiguous at row ${row} column ${column}`);153 if (current !== column) fail(`row coverage is not contiguous at row ${row} column ${column}`);
153 if (current + cellWidth > width) fail(`row coverage exceeds width at row ${row}`);154 if (current + cellWidth > width) fail(`row coverage exceeds width at row ${row}`);
154 const text = boundedString(candidate.text, configured.maxTextBytes, `${name}.text`);155 const text = boundedString(candidate.text, configured.maxTextBytes, `${name}.text`);
156+ // Entries wider than one wide character are text runs: printable ASCII,
157+ // one character per column, so grid columns and font advance agree.
158+ if (cellWidth > 2 && (text.value.length !== cellWidth || !PRINTABLE_ASCII.test(text.value))) fail(`${name} text run does not match its width`);
155 const cell = {159 const cell = {
156 column: current,160 column: current,
157 width: cellWidth,161 width: cellWidth,
@@ -198,6 +202,59 @@
198 return { schemaVersion: SCHEMA_VERSION, width, height, snapshot, rows, styles, cursor: validateCursor(value.cursor, width, height) };202 return { schemaVersion: SCHEMA_VERSION, width, height, snapshot, rows, styles, cursor: validateCursor(value.cursor, width, height) };
199 }203 }
200 204
205+ /**
206+ * Merge validated updates into one equivalent update, or return null when
207+ * the merged styles would exceed maxStyles (callers then apply in order).
208+ * Every incremental update must match the geometry established by the
209+ * mounted terminal or the latest snapshot, exactly as sequential apply
210+ * requires, so a protocol error is reported before any DOM mutation.
211+ * The merged update keeps the latest snapshot flag, geometry and cursor;
212+ * rows are last-writer-wins and style ids are remapped.
213+ */
214+ function coalesce(updates, mounted, maxStyles) {
215+ let geometry = mounted;
216+ for (const update of updates) {
217+ if (update.snapshot) geometry = { width: update.width, height: update.height };
218+ else if (!geometry || update.width !== geometry.width || update.height !== geometry.height) fail('incremental update dimensions do not match the mounted snapshot');
219+ }
220+ if (updates.length === 1) return updates[0];
221+ let start = 0;
222+ for (let index = 0; index < updates.length; index += 1) if (updates[index].snapshot) start = index;
223+ const tail = updates.slice(start);
224+ const last = tail[tail.length - 1];
225+ const rows = new Map();
226+ for (const update of tail) for (const row of update.rows) rows.set(row.row, { row, styles: update.styles });
227+ const styles = [];
228+ const ids = new Map();
229+ const remaps = new Map();
230+ const merged = [];
231+ for (const key of [...rows.keys()].sort((left, right) => left - right)) {
232+ const { row, styles: source } = rows.get(key);
233+ let remap = remaps.get(source);
234+ if (!remap) {
235+ remap = new Array(source.length);
236+ remaps.set(source, remap);
237+ }
238+ const cells = row.cells.map(cell => {
239+ let id = remap[cell.style];
240+ if (id === undefined) {
241+ const styleKey = JSON.stringify(source[cell.style]);
242+ id = ids.get(styleKey);
243+ if (id === undefined) {
244+ id = styles.length;
245+ ids.set(styleKey, id);
246+ styles.push(source[cell.style]);
247+ }
248+ remap[cell.style] = id;
249+ }
250+ return id === cell.style ? cell : { ...cell, style: id };
251+ });
252+ merged.push({ row: row.row, bytes: row.bytes, cells });
253+ }
254+ if (styles.length > maxStyles) return null;
255+ return { schemaVersion: SCHEMA_VERSION, width: last.width, height: last.height, snapshot: tail[0].snapshot, rows: merged, styles, cursor: last.cursor };
256+ }
257+
201 function xtermColor(index) {258 function xtermColor(index) {
202 if (index < 16) return `var(--vev-color-${index})`;259 if (index < 16) return `var(--vev-color-${index})`;
203 if (index < 232) {260 if (index < 232) {
@@ -236,23 +293,8 @@
236 node.className = 'vev-terminal__row';293 node.className = 'vev-terminal__row';
237 node.dataset.row = String(row.row);294 node.dataset.row = String(row.row);
238 let text = '';295 let text = '';
239- for (let index = 0; index < row.cells.length; index += 1) {296+ for (const cell of row.cells) {
240- let cell = row.cells[index];
241 const style = styles[cell.style];297 const style = styles[cell.style];
242- // A run of spaces needs one grid item, not one item per column. Only
243- // merge plain spaces: arbitrary text still needs explicit cell widths.
244- if (cell.text === ' ' && cell.width === 1 && !style.underline && !style.strikethrough) {
245- let width = cell.width;
246- let spaces = cell.text;
247- while (index + 1 < row.cells.length) {
248- const next = row.cells[index + 1];
249- if (next.text !== ' ' || next.width !== 1 || next.style !== cell.style || next.column !== cell.column + width) break;
250- width += next.width;
251- spaces += next.text;
252- index += 1;
253- }
254- cell = { ...cell, width, text: spaces };
255- }
256 const child = document.createElement('span');298 const child = document.createElement('span');
257 child.className = 'vev-terminal__cell';299 child.className = 'vev-terminal__cell';
258 child.dataset.column = String(cell.column);300 child.dataset.column = String(cell.column);
@@ -345,7 +387,15 @@
345 accessible.setAttribute('aria-live', 'off');387 accessible.setAttribute('aria-live', 'off');
346 accessible.setAttribute('aria-label', `${label} output`);388 accessible.setAttribute('aria-label', `${label} output`);
347 389
348- root.append(input, viewport, accessible);390+ // Grid tracks round the column width (Chromium: to 1/64 px) while text
391+ // advances by the exact glyph width, so a long text run drifts out of its
392+ // columns. The probe measures both and letter-spacing absorbs the gap.
393+ const probe = document.createElement('span');
394+ probe.className = 'vev-terminal__measure';
395+ probe.setAttribute('aria-hidden', 'true');
396+ probe.textContent = 'x'.repeat(64);
397+
398+ root.append(input, viewport, accessible, probe);
349 399
350 let destroyed = false;400 let destroyed = false;
351 let composing = false;401 let composing = false;
@@ -363,6 +413,7 @@
363 let pointerFrame = 0;413 let pointerFrame = 0;
364 let pendingPointer = null;414 let pendingPointer = null;
365 let lastResize = '';415 let lastResize = '';
416+ let letterSpacing = '';
366 const previousProperties = new Map();417 const previousProperties = new Map();
367 418
368 function setOwnedProperty(name, value) {419 function setOwnedProperty(name, value) {
@@ -416,7 +467,48 @@
416 /** @param {Update} value */467 /** @param {Update} value */
417 function apply(value) {468 function apply(value) {
418 alive();469 alive();
419- const update = validateUpdate(value, configured);470+ applyValidated(validateUpdate(value, configured));
471+ }
472+
473+ /**
474+ * Validate a queue of updates and their geometry chain, then apply one
475+ * merged update: a snapshot supersedes everything before it and later
476+ * rows win. A consumer that falls behind rebuilds each changed row once
477+ * instead of once per queued update. Invalid input throws before the DOM
478+ * changes.
479+ * @param {Update[]} values
480+ */
481+ function applyAll(values) {
482+ alive();
483+ if (!Array.isArray(values)) fail('updates must be an array');
484+ if (values.length === 0) return;
485+ const updates = values.map(value => validateUpdate(value, configured));
486+ const mounted = rowNodes.length === 0 ? null : { width, height };
487+ const merged = coalesce(updates, mounted, configured.maxStyles);
488+ if (merged) applyValidated(merged);
489+ else {
490+ // Rare: the merged rows need more distinct styles than one update may
491+ // carry. Each update is valid on its own and the geometry chain was
492+ // checked above. Replay the retained-text budget too, then apply them
493+ // in order, so a failure still leaves the DOM unchanged.
494+ let bytes = rowBytes.slice();
495+ let total = retainedBytes;
496+ for (const update of updates) {
497+ if (update.snapshot) {
498+ bytes = [];
499+ total = 0;
500+ }
501+ for (const row of update.rows) {
502+ total += row.bytes - (bytes[row.row] || 0);
503+ bytes[row.row] = row.bytes;
504+ }
505+ if (total > configured.maxUpdateBytes) fail('retained text exceeds its aggregate byte limit');
506+ }
507+ for (const update of updates) applyValidated(update);
508+ }
509+ }
510+
511+ function applyValidated(update) {
420 if (!update.snapshot && (update.width !== width || update.height !== height || rowNodes.length === 0)) fail('incremental update dimensions do not match the mounted snapshot');512 if (!update.snapshot && (update.width !== width || update.height !== height || rowNodes.length === 0)) fail('incremental update dimensions do not match the mounted snapshot');
421 if (!update.snapshot) {513 if (!update.snapshot) {
422 for (let row = 0; row < height; row += 1) {514 for (let row = 0; row < height; row += 1) {
@@ -438,8 +530,8 @@
438 const built = update.rows.map(row => {530 const built = update.rows.map(row => {
439 const old = previousRows[row.row];531 const old = previousRows[row.row];
440 const node = rowNodes[row.row];532 const node = rowNodes[row.row];
441- // Text-only updates preserve geometry and styling. Space runs use533+ // Text-only updates preserve geometry and styling. Blank cells skip
442- // merged nodes and deliberately take the full rebuilding path.534+ // clipping, so a change to or from blanks takes the rebuilding path.
443 const reuse = reusable && node?.parentNode === viewport && old &&535 const reuse = reusable && node?.parentNode === viewport && old &&
444 old.cells.length === row.cells.length && node.children.length === row.cells.length &&536 old.cells.length === row.cells.length && node.children.length === row.cells.length &&
445 row.cells.every((cell, index) => {537 row.cells.every((cell, index) => {
@@ -538,10 +630,26 @@
538 dispatch(payload, nativeEvent);630 dispatch(payload, nativeEvent);
539 }631 }
540 632
633+ function measureAdvance() {
634+ const column = probe.getBoundingClientRect().width;
635+ const css = parseFloat(getComputedStyle(probe).width);
636+ const range = document.createRange();
637+ range.selectNodeContents(probe);
638+ const advance = range.getBoundingClientRect().width / probe.textContent.length;
639+ if (!(column > 0 && css > 0 && advance > 0)) return;
640+ const spacing = `${((column - advance) * css / column).toFixed(4)}px`;
641+ if (spacing !== letterSpacing) {
642+ letterSpacing = spacing;
643+ setOwnedProperty('--vev-letter-spacing', spacing);
644+ }
645+ }
646+
541 function scheduleResize() {647 function scheduleResize() {
542- if (resizeFrame || width === 0 || height === 0) return;648+ if (resizeFrame) return;
543 resizeFrame = requestAnimationFrame(() => {649 resizeFrame = requestAnimationFrame(() => {
544 resizeFrame = 0;650 resizeFrame = 0;
651+ measureAdvance();
652+ if (width === 0 || height === 0) return;
545 const bounds = viewport.getBoundingClientRect();653 const bounds = viewport.getBoundingClientRect();
546 const cellWidth = bounds.width / width;654 const cellWidth = bounds.width / width;
547 const cellHeight = bounds.height / height;655 const cellHeight = bounds.height / height;
@@ -646,9 +754,11 @@
646 754
647 const observer = new ResizeObserver(scheduleResize);755 const observer = new ResizeObserver(scheduleResize);
648 observer.observe(root);756 observer.observe(root);
757+ document.fonts?.addEventListener('loadingdone', scheduleResize, { signal });
649 758
650 return Object.freeze({759 return Object.freeze({
651 apply,760 apply,
761+ applyAll,
652 focus() { alive(); input.focus(); },762 focus() { alive(); input.focus(); },
653 setMouseCapture(enabled) {763 setMouseCapture(enabled) {
654 alive();764 alive();
@@ -678,6 +788,7 @@
678 input.remove();788 input.remove();
679 viewport.remove();789 viewport.remove();
680 accessible.remove();790 accessible.remove();
791+ probe.remove();
681 rowNodes = [];792 rowNodes = [];
682 rowText = [];793 rowText = [];
683 rowBytes = [];794 rowBytes = [];
@@ -688,6 +799,7 @@
688 width = 0;799 width = 0;
689 height = 0;800 height = 0;
690 lastResize = '';801 lastResize = '';
802+ letterSpacing = '';
691 decide = () => ({ emit: false, preventDefault: false });803 decide = () => ({ emit: false, preventDefault: false });
692 send = () => {};804 send = () => {};
693 for (const [name, previous] of previousProperties) {805 for (const [name, previous] of previousProperties) {
M html/doc.go +3 -1
@@ -8,7 +8,9 @@
8 //8 //
9 // Damage values are non-authoritative hints: every row is compared with the9 // Damage values are non-authoritative hints: every row is compared with the
10 // committed shadow, so mutations outside reported damage remain observable.10 // committed shadow, so mutations outside reported damage remain observable.
11-// Updates replace complete rows to preserve wide-cell atomicity. Scroll damage11+// Updates replace complete rows to preserve wide-cell atomicity. Rows are
12+// encoded as text runs of same-style printable ASCII; wide and non-ASCII
13+// characters stay single entries. Scroll damage
12 // uses a full snapshot until browser measurements justify a more complex plan.14 // uses a full snapshot until browser measurements justify a more complex plan.
13 //15 //
14 // The package emits typed data and structural CSS; it never converts terminal16 // The package emits typed data and structural CSS; it never converts terminal
A html/encode.go +231 -0
@@ -0,0 +1,231 @@
1+package html
2+
3+import (
4+ "fmt"
5+ "strconv"
6+ "unicode/utf8"
7+)
8+
9+// appendUpdateJSON appends the canonical JSON encoding of update to dst. The
10+// bytes are identical to encoding/json.Marshal(update) (including HTML
11+// escaping of text), but it writes into one caller-sized buffer instead of
12+// going through reflection and an intermediate copy.
13+func appendUpdateJSON(dst []byte, update Update) ([]byte, error) {
14+ dst = append(dst, `{"schemaVersion":`...)
15+ dst = strconv.AppendUint(dst, uint64(update.SchemaVersion), 10)
16+ dst = append(dst, `,"width":`...)
17+ dst = strconv.AppendInt(dst, int64(update.Width), 10)
18+ dst = append(dst, `,"height":`...)
19+ dst = strconv.AppendInt(dst, int64(update.Height), 10)
20+ dst = append(dst, `,"snapshot":`...)
21+ dst = strconv.AppendBool(dst, update.Snapshot)
22+ dst = append(dst, `,"rows":`...)
23+ if update.Rows == nil {
24+ dst = append(dst, "null"...)
25+ } else {
26+ dst = append(dst, '[')
27+ for i := range update.Rows {
28+ if i > 0 {
29+ dst = append(dst, ',')
30+ }
31+ dst = appendRowJSON(dst, &update.Rows[i])
32+ }
33+ dst = append(dst, ']')
34+ }
35+ dst = append(dst, `,"styles":`...)
36+ if update.Styles == nil {
37+ dst = append(dst, "null"...)
38+ } else {
39+ dst = append(dst, '[')
40+ for i := range update.Styles {
41+ if i > 0 {
42+ dst = append(dst, ',')
43+ }
44+ var err error
45+ if dst, err = appendStyleJSON(dst, &update.Styles[i]); err != nil {
46+ return nil, err
47+ }
48+ }
49+ dst = append(dst, ']')
50+ }
51+ dst = append(dst, `,"cursor":{"row":`...)
52+ dst = strconv.AppendInt(dst, int64(update.Cursor.Row), 10)
53+ dst = append(dst, `,"column":`...)
54+ dst = strconv.AppendInt(dst, int64(update.Cursor.Column), 10)
55+ dst = append(dst, `,"visible":`...)
56+ dst = strconv.AppendBool(dst, update.Cursor.Visible)
57+ dst = append(dst, `,"style":`...)
58+ dst = strconv.AppendUint(dst, uint64(update.Cursor.Style), 10)
59+ dst = append(dst, `,"styleSet":`...)
60+ dst = strconv.AppendBool(dst, update.Cursor.StyleSet)
61+ dst = append(dst, "}}"...)
62+ return dst, nil
63+}
64+
65+func appendRowJSON(dst []byte, row *RowUpdate) []byte {
66+ dst = append(dst, `{"row":`...)
67+ dst = strconv.AppendInt(dst, int64(row.Row), 10)
68+ dst = append(dst, `,"cells":`...)
69+ if row.Cells == nil {
70+ return append(dst, "null}"...)
71+ }
72+ dst = append(dst, '[')
73+ for i := range row.Cells {
74+ cell := &row.Cells[i]
75+ if i > 0 {
76+ dst = append(dst, ',')
77+ }
78+ dst = append(dst, `{"column":`...)
79+ dst = strconv.AppendInt(dst, int64(cell.Column), 10)
80+ dst = append(dst, `,"width":`...)
81+ dst = strconv.AppendInt(dst, int64(cell.Width), 10)
82+ dst = append(dst, `,"text":`...)
83+ dst = appendJSONString(dst, cell.Text)
84+ dst = append(dst, `,"style":`...)
85+ dst = strconv.AppendInt(dst, int64(cell.Style), 10)
86+ dst = append(dst, '}')
87+ }
88+ return append(dst, "]}"...)
89+}
90+
91+func appendStyleJSON(dst []byte, style *Style) ([]byte, error) {
92+ dst = append(dst, '{')
93+ first := true
94+ flag := func(name string, set bool) {
95+ if !set {
96+ return
97+ }
98+ if !first {
99+ dst = append(dst, ',')
100+ }
101+ first = false
102+ dst = append(dst, '"')
103+ dst = append(dst, name...)
104+ dst = append(dst, `":true`...)
105+ }
106+ flag("bold", style.Bold)
107+ flag("italic", style.Italic)
108+ flag("inverse", style.Inverse)
109+ flag("dim", style.Dim)
110+ flag("blink", style.Blink)
111+ flag("strikethrough", style.Strikethrough)
112+ flag("underline", style.Underline)
113+ if style.UnderlineStyle != 0 {
114+ if !first {
115+ dst = append(dst, ',')
116+ }
117+ first = false
118+ dst = append(dst, `"underlineStyle":`...)
119+ dst = strconv.AppendUint(dst, uint64(style.UnderlineStyle), 10)
120+ }
121+ if !first {
122+ dst = append(dst, ',')
123+ }
124+ var err error
125+ dst = append(dst, `"foreground":`...)
126+ if dst, err = appendColorJSON(dst, style.Foreground); err != nil {
127+ return nil, err
128+ }
129+ dst = append(dst, `,"background":`...)
130+ if dst, err = appendColorJSON(dst, style.Background); err != nil {
131+ return nil, err
132+ }
133+ dst = append(dst, `,"underlineColor":`...)
134+ if dst, err = appendColorJSON(dst, style.UnderlineColor); err != nil {
135+ return nil, err
136+ }
137+ return append(dst, '}'), nil
138+}
139+
140+// appendColorJSON mirrors Color.MarshalJSON.
141+func appendColorJSON(dst []byte, color Color) ([]byte, error) {
142+ switch color.Kind {
143+ case ColorDefault:
144+ return append(dst, `{"kind":0}`...), nil
145+ case ColorIndexed:
146+ dst = append(dst, `{"kind":1,"index":`...)
147+ dst = strconv.AppendUint(dst, uint64(color.Index), 10)
148+ return append(dst, '}'), nil
149+ case ColorRGB:
150+ dst = append(dst, `{"kind":2,"rgb":{"r":`...)
151+ dst = strconv.AppendUint(dst, uint64(color.RGB.R), 10)
152+ dst = append(dst, `,"g":`...)
153+ dst = strconv.AppendUint(dst, uint64(color.RGB.G), 10)
154+ dst = append(dst, `,"b":`...)
155+ dst = strconv.AppendUint(dst, uint64(color.RGB.B), 10)
156+ return append(dst, "}}"...), nil
157+ default:
158+ return nil, fmt.Errorf("html: invalid color kind %d", color.Kind)
159+ }
160+}
161+
162+const hexDigits = "0123456789abcdef"
163+
164+// appendJSONString appends s as a JSON string using encoding/json's default
165+// escaping: quotes, backslashes, control characters, <, >, & and U+2028/2029
166+// are escaped. Cell text is always valid UTF-8; an invalid byte, should one
167+// appear, is replaced by U+FFFD so the output stays valid JSON.
168+func appendJSONString(dst []byte, s string) []byte {
169+ dst = append(dst, '"')
170+ start := 0
171+ for i := 0; i < len(s); {
172+ b := s[i]
173+ if b < utf8.RuneSelf {
174+ if b >= 0x20 && b != '"' && b != '\\' && b != '<' && b != '>' && b != '&' {
175+ i++
176+ continue
177+ }
178+ dst = append(dst, s[start:i]...)
179+ switch b {
180+ case '"', '\\':
181+ dst = append(dst, '\\', b)
182+ case '\b':
183+ dst = append(dst, '\\', 'b')
184+ case '\f':
185+ dst = append(dst, '\\', 'f')
186+ case '\n':
187+ dst = append(dst, '\\', 'n')
188+ case '\r':
189+ dst = append(dst, '\\', 'r')
190+ case '\t':
191+ dst = append(dst, '\\', 't')
192+ default:
193+ dst = append(dst, '\\', 'u', '0', '0', hexDigits[b>>4], hexDigits[b&0xF])
194+ }
195+ i++
196+ start = i
197+ continue
198+ }
199+ r, size := utf8.DecodeRuneInString(s[i:])
200+ if r == utf8.RuneError && size == 1 {
201+ dst = append(dst, s[start:i]...)
202+ dst = append(dst, "\uFFFD"...)
203+ i += size
204+ start = i
205+ continue
206+ }
207+ if r == '\u2028' || r == '\u2029' {
208+ dst = append(dst, s[start:i]...)
209+ dst = append(dst, '\\', 'u', '2', '0', '2', hexDigits[r&0xF])
210+ i += size
211+ start = i
212+ continue
213+ }
214+ i += size
215+ }
216+ dst = append(dst, s[start:]...)
217+ return append(dst, '"')
218+}
219+
220+// estimateUpdateJSONSize returns a capacity hint close to the encoded size so
221+// the output buffer normally grows zero times.
222+func estimateUpdateJSONSize(update Update) int {
223+ size := 256 + 256*len(update.Styles)
224+ for i := range update.Rows {
225+ size += 32 + 48*len(update.Rows[i].Cells)
226+ for j := range update.Rows[i].Cells {
227+ size += len(update.Rows[i].Cells[j].Text)
228+ }
229+ }
230+ return size
231+}
A html/encode_test.go +224 -0
@@ -0,0 +1,224 @@
1+package html
2+
3+import (
4+ "encoding/json"
5+ "math/rand"
6+ "reflect"
7+ "testing"
8+ "unicode/utf8"
9+
10+ "github.com/bnema/vev-vt/core"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestAppendJSONStringMatchesEncodingJSON(t *testing.T) {
15+ cases := []string{
16+ "", " ", "A", `"`, `\`, "<", ">", "&", "\x00", "\x1f", "\x7f", "\b\f\n\r\t",
17+ "\u2028", "\u2029", "界", "😀", "e\u0301", "a<b>&c\"d\\e\u2028f",
18+ }
19+ for r := rune(0); r < 0x300; r++ {
20+ cases = append(cases, string(r))
21+ }
22+ for _, text := range cases {
23+ want, err := json.Marshal(text)
24+ require.NoError(t, err)
25+ require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text)
26+ }
27+}
28+
29+// Invalid UTF-8 never reaches the encoder (cells are validated). Should it,
30+// the output must stay valid JSON decoding to the same text encoding/json
31+// would produce, whatever escaping style the toolchain's encoder uses.
32+func TestAppendJSONStringInvalidUTF8StaysValidJSON(t *testing.T) {
33+ for _, text := range []string{"\xff", "a\xc3", "\xed\xa0\x80", "\xe2\x80", "ok\xf0\x9f"} {
34+ want, err := json.Marshal(text)
35+ require.NoError(t, err)
36+ var wantText, gotText string
37+ require.NoError(t, json.Unmarshal(want, &wantText))
38+ require.NoError(t, json.Unmarshal(appendJSONString(nil, text), &gotText), "%q", text)
39+ require.Equal(t, wantText, gotText, "%q", text)
40+ }
41+}
42+
43+func FuzzAppendJSONString(f *testing.F) {
44+ for _, seed := range []string{"", "a<b>&c", "\u2028\u2029", "界😀", "\x00\x1f\x7f"} {
45+ f.Add(seed)
46+ }
47+ f.Fuzz(func(t *testing.T, text string) {
48+ if !utf8.ValidString(text) {
49+ t.Skip("cell text is always valid UTF-8")
50+ }
51+ want, err := json.Marshal(text)
52+ require.NoError(t, err)
53+ require.Equal(t, string(want), string(appendJSONString(nil, text)))
54+ })
55+}
56+
57+func randomColor(rng *rand.Rand) Color {
58+ switch rng.Intn(3) {
59+ case 0:
60+ return Color{Kind: ColorDefault}
61+ case 1:
62+ return Color{Kind: ColorIndexed, Index: uint8(rng.Intn(256))}
63+ }
64+ return Color{Kind: ColorRGB, RGB: RGB{R: uint8(rng.Intn(256)), G: uint8(rng.Intn(256)), B: uint8(rng.Intn(256))}}
65+}
66+
67+func TestAppendUpdateJSONMatchesEncodingJSON(t *testing.T) {
68+ rng := rand.New(rand.NewSource(1))
69+ texts := []string{" ", "A", "<", "&", `"`, `\`, "界", "😀", "\x01", "\u2028"}
70+ for range 300 {
71+ update := Update{
72+ SchemaVersion: UpdateSchemaVersion,
73+ Width: rng.Intn(500),
74+ Height: rng.Intn(500),
75+ Snapshot: rng.Intn(2) == 0,
76+ Cursor: Cursor{
77+ Row: rng.Intn(100), Column: rng.Intn(100), Visible: rng.Intn(2) == 0,
78+ Style: CursorStyle(rng.Intn(7)), StyleSet: rng.Intn(2) == 0,
79+ },
80+ }
81+ if rng.Intn(5) != 0 {
82+ update.Rows = make([]RowUpdate, rng.Intn(4))
83+ for i := range update.Rows {
84+ update.Rows[i].Row = rng.Intn(1000)
85+ if rng.Intn(5) != 0 {
86+ update.Rows[i].Cells = make([]CellUpdate, rng.Intn(5))
87+ for j := range update.Rows[i].Cells {
88+ update.Rows[i].Cells[j] = CellUpdate{Column: rng.Intn(300), Width: 1 + rng.Intn(2), Text: texts[rng.Intn(len(texts))], Style: rng.Intn(10)}
89+ }
90+ }
91+ }
92+ }
93+ if rng.Intn(5) != 0 {
94+ update.Styles = make([]Style, rng.Intn(4))
95+ for i := range update.Styles {
96+ update.Styles[i] = Style{
97+ Bold: rng.Intn(2) == 0, Italic: rng.Intn(2) == 0, Inverse: rng.Intn(2) == 0,
98+ Dim: rng.Intn(2) == 0, Blink: rng.Intn(2) == 0, Strikethrough: rng.Intn(2) == 0,
99+ Underline: rng.Intn(2) == 0, UnderlineStyle: core.UnderlineStyle(rng.Intn(6)),
100+ Foreground: randomColor(rng), Background: randomColor(rng), UnderlineColor: randomColor(rng),
101+ }
102+ }
103+ }
104+ want, err := json.Marshal(update)
105+ require.NoError(t, err)
106+ got, err := appendUpdateJSON(nil, update)
107+ require.NoError(t, err)
108+ require.Equal(t, string(want), string(got))
109+ }
110+}
111+
112+func TestAppendUpdateJSONRejectsInvalidColorKind(t *testing.T) {
113+ update := Update{Styles: []Style{{Foreground: Color{Kind: 9}}}}
114+ _, err := appendUpdateJSON(nil, update)
115+ require.ErrorContains(t, err, "invalid color kind 9")
116+}
117+
118+func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) {
119+ frame := core.NewFrame(6, 2)
120+ style := core.DefaultStyle()
121+ style.Bold = true
122+ style.HasForegroundRGB, style.ForegroundRGB = true, core.RGB{R: 1, G: 2, B: 3}
123+ style.Background = 200
124+ frame.Set(0, 0, core.Cell{Rune: '<', Style: style})
125+ frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()})
126+ frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
127+ frame.Set(3, 0, core.Cell{Rune: '😀', Style: core.DefaultStyle()})
128+ frame.Set(4, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()})
129+ frame.Set(5, 0, core.Cell{Rune: '&', Style: core.DefaultStyle()})
130+ frame.Set(0, 1, core.Cell{Rune: '"', Style: style})
131+
132+ renderer, err := New(Options{})
133+ require.NoError(t, err)
134+ prepared, err := renderer.Prepare(frame, nil, true, Cursor{Column: 2, Visible: true, Style: 3, StyleSet: true})
135+ require.NoError(t, err)
136+ want, err := json.Marshal(prepared.tx.update)
137+ require.NoError(t, err)
138+ require.Equal(t, string(want), string(prepared.JSON()))
139+}
140+
141+// fillAll sets every field reachable from v to a distinctive non-zero value
142+// (negative for signed integers) so a field added to the update types without
143+// a matching encoder change shows up as a JSON mismatch.
144+func fillAll(t *testing.T, v reflect.Value, colorKind ColorKind, counter *int) {
145+ t.Helper()
146+ *counter++
147+ n := *counter
148+ if v.Type() == reflect.TypeOf(Color{}) {
149+ v.FieldByName("Kind").SetUint(uint64(colorKind))
150+ v.FieldByName("Index").SetUint(uint64(100 + n%100))
151+ fillAll(t, v.FieldByName("RGB"), colorKind, counter)
152+ return
153+ }
154+ switch v.Kind() {
155+ case reflect.Bool:
156+ v.SetBool(true)
157+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
158+ v.SetInt(-int64(n) - 1)
159+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
160+ v.SetUint(uint64(1 + n%120))
161+ case reflect.String:
162+ v.SetString("t<&>\"\\界\u2028" + string(rune('a'+n%26)))
163+ case reflect.Slice:
164+ v.Set(reflect.MakeSlice(v.Type(), 2, 2))
165+ for i := range 2 {
166+ fillAll(t, v.Index(i), colorKind, counter)
167+ }
168+ case reflect.Struct:
169+ for i := range v.NumField() {
170+ require.True(t, v.Field(i).CanSet(), "%s.%s must be settable", v.Type(), v.Type().Field(i).Name)
171+ fillAll(t, v.Field(i), colorKind, counter)
172+ }
173+ default:
174+ t.Fatalf("fillAll: unsupported kind %s in %s", v.Kind(), v.Type())
175+ }
176+}
177+
178+func requireNoZeroFields(t *testing.T, v reflect.Value) {
179+ t.Helper()
180+ switch v.Kind() {
181+ case reflect.Struct:
182+ for i := range v.NumField() {
183+ requireNoZeroFields(t, v.Field(i))
184+ }
185+ case reflect.Slice:
186+ require.NotZero(t, v.Len())
187+ for i := range v.Len() {
188+ requireNoZeroFields(t, v.Index(i))
189+ }
190+ default:
191+ require.False(t, v.IsZero(), "zero %s left in filled value", v.Type())
192+ }
193+}
194+
195+// TestEncoderCoversEveryUpdateField fails when a field is added to (or removed
196+// from) the update types without updating appendUpdateJSON and this test.
197+func TestEncoderCoversEveryUpdateField(t *testing.T) {
198+ for typ, fields := range map[reflect.Type]int{
199+ reflect.TypeOf(Update{}): 7,
200+ reflect.TypeOf(RowUpdate{}): 2,
201+ reflect.TypeOf(CellUpdate{}): 4,
202+ reflect.TypeOf(Style{}): 11,
203+ reflect.TypeOf(Cursor{}): 5,
204+ reflect.TypeOf(RGB{}): 3,
205+ reflect.TypeOf(Color{}): 3,
206+ } {
207+ require.Equal(t, fields, typ.NumField(), "%s field count changed: update appendUpdateJSON", typ)
208+ }
209+
210+ for _, kind := range []ColorKind{ColorDefault, ColorIndexed, ColorRGB} {
211+ var update Update
212+ counter := 0
213+ fillAll(t, reflect.ValueOf(&update).Elem(), kind, &counter)
214+ update.Cursor.Style = 6 // keep within the documented DECSCUSR range
215+ if kind != ColorDefault {
216+ requireNoZeroFields(t, reflect.ValueOf(update))
217+ }
218+ want, err := json.Marshal(update)
219+ require.NoError(t, err)
220+ got, err := appendUpdateJSON(nil, update)
221+ require.NoError(t, err)
222+ require.Equal(t, string(want), string(got), "color kind %d", kind)
223+ }
224+}
M html/renderer.go +174 -70
@@ -1,7 +1,6 @@
1 package html1 package html
2 2
3 import (3 import (
4- "encoding/json"
5 "fmt"4 "fmt"
6 "math"5 "math"
7 "unicode/utf8"6 "unicode/utf8"
@@ -10,12 +9,19 @@ import (
10 )9 )
11 10
12 type Renderer struct {11 type Renderer struct {
13- limits Limits12+ limits Limits
14- generation uint6413+ generation uint64
15- pending *transaction14+ pending *transaction
16- committed core.Frame15+ committed core.Frame
16+ // spare is a frame page no update or transaction references. Prepare
17+ // copies a core.Frame source into it; Commit and Abort hand a page back.
18+ spare core.Frame
17 cursor Cursor19 cursor Cursor
18 initialized bool20 initialized bool
21+ // Encoder scratch reused across updates; never referenced by an Update.
22+ ends []int
23+ text []byte
24+ rowStarts []int
19 }25 }
20 26
21 // CellSource is the read-only semantic grid consumed by HTML rendering.27 // CellSource is the read-only semantic grid consumed by HTML rendering.
@@ -61,26 +67,13 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool,
61 if r.pending != nil {67 if r.pending != nil {
62 return nil, ErrPendingDraw68 return nil, ErrPendingDraw
63 }69 }
64- frame, err := materializeCellSource(source)70+ frame, err := r.materializeCellSource(source)
65- if err != nil {
66- return nil, err
67- }
68- if err := r.validateFrame(frame); err != nil {
69- return nil, err
70- }
71- cursor = normalizeWrapPendingCursor(cursor, frame.Width)
72- if err := validateCursor(cursor, frame.Width, frame.Height); err != nil {
73- return nil, err
74- }
75-
76- snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height)
77- candidate := frame.Clone()
78- update, err := r.buildUpdate(candidate, snapshot, cursor)
79 if err != nil {71 if err != nil {
80 return nil, err72 return nil, err
81 }73 }
82- encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes)74+ update, encoded, err := r.prepareUpdate(frame, damage, reset, &cursor)
83 if err != nil {75 if err != nil {
76+ r.spare = frame
84 return nil, err77 return nil, err
85 }78 }
86 79
@@ -90,46 +83,70 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool,
90 state: transactionPending,83 state: transactionPending,
91 update: update,84 update: update,
92 json: encoded,85 json: encoded,
93- frame: candidate,86+ frame: frame,
94 cursor: cursor,87 cursor: cursor,
95 }88 }
96 r.pending = tx89 r.pending = tx
97 return &PreparedDraw{tx: tx}, nil90 return &PreparedDraw{tx: tx}, nil
98 }91 }
99 92
100-func (r *Renderer) validateFrame(frame core.Frame) error {93+func (r *Renderer) prepareUpdate(frame core.Frame, damage []core.Damage, reset bool, cursor *Cursor) (Update, []byte, error) {
94+ scratch, err := r.validateFrame(frame)
95+ if err != nil {
96+ return Update{}, nil, err
97+ }
98+ *cursor = normalizeWrapPendingCursor(*cursor, frame.Width)
99+ if err := validateCursor(*cursor, frame.Width, frame.Height); err != nil {
100+ return Update{}, nil, err
101+ }
102+ snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height)
103+ update, err := r.buildUpdate(frame, scratch, snapshot, *cursor)
104+ if err != nil {
105+ return Update{}, nil, err
106+ }
107+ encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes)
108+ if err != nil {
109+ return Update{}, nil, err
110+ }
111+ return update, encoded, nil
112+}
113+
114+// validateFrame checks limits and cell invariants. It returns a one-row
115+// scratch buffer that the caller may reuse for the rest of this Prepare.
116+func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) {
101 if frame.Width <= 0 || frame.Height <= 0 {117 if frame.Width <= 0 || frame.Height <= 0 {
102- return fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height)118+ return nil, fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height)
103 }119 }
104 if frame.Width > math.MaxInt/frame.Height {120 if frame.Width > math.MaxInt/frame.Height {
105- return fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded)121+ return nil, fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded)
106 }122 }
107 cells := frame.Width * frame.Height123 cells := frame.Width * frame.Height
108 if cells > r.limits.MaxCells {124 if cells > r.limits.MaxCells {
109- return fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells)125+ return nil, fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells)
110 }126 }
111 if frame.Height > r.limits.MaxRowsPerUpdate {127 if frame.Height > r.limits.MaxRowsPerUpdate {
112- return fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate)128+ return nil, fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate)
113 }129 }
114 if err := frame.Validate(); err != nil {130 if err := frame.Validate(); err != nil {
115- return fmt.Errorf("html: validate frame: %w", err)131+ return nil, fmt.Errorf("html: validate frame: %w", err)
116 }132 }
133+ scratch := make([]core.Cell, frame.Width)
117 for y := range frame.Height {134 for y := range frame.Height {
118- row := frame.Row(y)135+ row := readRow(frame, y, scratch)
119 for x := 0; x < frame.Width; x++ {136 for x := 0; x < frame.Width; x++ {
120 cell := row[x]137 cell := row[x]
121 if err := validateCoreStyle(cell.Style); err != nil {138 if err := validateCoreStyle(cell.Style); err != nil {
122- return fmt.Errorf("html: cell (%d,%d): %w", x, y, err)139+ return nil, fmt.Errorf("html: cell (%d,%d): %w", x, y, err)
123 }140 }
124 if cell.Continuation {141 if cell.Continuation {
125 if cell.Rune != 0 {142 if cell.Rune != 0 {
126- return fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y)143+ return nil, fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y)
127 }144 }
128 if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 {145 if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 {
129- return fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y)146+ return nil, fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y)
130 }147 }
131 if !cell.Style.Equal(row[x-1].Style) {148 if !cell.Style.Equal(row[x-1].Style) {
132- return fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y)149+ return nil, fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y)
133 }150 }
134 continue151 continue
135 }152 }
@@ -137,28 +154,29 @@ func (r *Renderer) validateFrame(frame core.Frame) error {
137 continue154 continue
138 }155 }
139 if !utf8.ValidRune(cell.Rune) {156 if !utf8.ValidRune(cell.Rune) {
140- return fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y)157+ return nil, fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y)
141 }158 }
142 width := core.RuneWidth(cell.Rune)159 width := core.RuneWidth(cell.Rune)
143 switch width {160 switch width {
144 case 1:161 case 1:
145 case 2:162 case 2:
146 if x+1 >= frame.Width || !row[x+1].Continuation {163 if x+1 >= frame.Width || !row[x+1].Continuation {
147- return fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y)164+ return nil, fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y)
148 }165 }
149 case 0:166 case 0:
150- return fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y)167+ return nil, fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y)
151 default:168 default:
152- return fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width)169+ return nil, fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width)
153 }170 }
154 }171 }
155 }172 }
156- return nil173+ return scratch, nil
157 }174 }
158 175
159-// materializeCellSource reads a source into an owned frame. A core.Frame176+// materializeCellSource reads a source into an owned frame. A core.Frame is
160-// takes the fast clone path; any other source is copied cell by cell.177+// copied into the renderer's spare page; any other source is copied cell by
161-func materializeCellSource(source CellSource) (core.Frame, error) {178+// cell.
179+func (r *Renderer) materializeCellSource(source CellSource) (core.Frame, error) {
162 if source == nil {180 if source == nil {
163 return core.Frame{}, fmt.Errorf("html: nil cell source")181 return core.Frame{}, fmt.Errorf("html: nil cell source")
164 }182 }
@@ -167,7 +185,7 @@ func materializeCellSource(source CellSource) (core.Frame, error) {
167 if err := frame.Validate(); err != nil {185 if err := frame.Validate(); err != nil {
168 return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)186 return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
169 }187 }
170- return frame.Clone(), nil188+ return r.copyFrame(frame), nil
171 case *core.Frame:189 case *core.Frame:
172 if frame == nil {190 if frame == nil {
173 return core.Frame{}, fmt.Errorf("html: nil cell source")191 return core.Frame{}, fmt.Errorf("html: nil cell source")
@@ -175,7 +193,7 @@ func materializeCellSource(source CellSource) (core.Frame, error) {
175 if err := frame.Validate(); err != nil {193 if err := frame.Validate(); err != nil {
176 return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)194 return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
177 }195 }
178- return frame.Clone(), nil196+ return r.copyFrame(*frame), nil
179 }197 }
180 width, height := source.Columns(), source.Rows()198 width, height := source.Columns(), source.Rows()
181 if width <= 0 || height <= 0 {199 if width <= 0 || height <= 0 {
@@ -190,6 +208,19 @@ func materializeCellSource(source CellSource) (core.Frame, error) {
190 return clone, nil208 return clone, nil
191 }209 }
192 210
211+// copyFrame copies a validated frame into the spare page, which the returned
212+// frame then owns. CopyFrom always succeeds here: src passed Validate.
213+func (r *Renderer) copyFrame(src core.Frame) core.Frame {
214+ frame := r.spare
215+ r.spare = core.Frame{}
216+ frame.CopyFrom(src)
217+ return frame
218+}
219+
220+// maxRetainedRows bounds the row count whose encoder scratch the renderer
221+// keeps between updates.
222+const maxRetainedRows = 80
223+
193 // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column224 // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column
194 // produced after writing the last column with autowrap enabled onto the225 // produced after writing the last column with autowrap enabled onto the
195 // last visible column. It leaves every in-bounds cursor untouched.226 // last visible column. It leaves every in-bounds cursor untouched.
@@ -229,7 +260,17 @@ func damageRequiresSnapshot(damage []core.Damage, width, height int) bool {
229 return false260 return false
230 }261 }
231 262
232-func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) (Update, error) {263+// readRow copies logical row y into buf and returns the filled prefix. It is
264+// the allocation-free equivalent of core.Frame.Row.
265+func readRow(frame core.Frame, y int, buf []core.Cell) []core.Cell {
266+ row := buf[:frame.Width]
267+ for x := range row {
268+ row[x] = frame.Cell(x, y)
269+ }
270+ return row
271+}
272+
273+func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot bool, cursor Cursor) (Update, error) {
233 update := Update{274 update := Update{
234 SchemaVersion: UpdateSchemaVersion,275 SchemaVersion: UpdateSchemaVersion,
235 Width: frame.Width,276 Width: frame.Width,
@@ -240,27 +281,69 @@ func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) (
240 Cursor: cursor,281 Cursor: cursor,
241 }282 }
242 styleIDs := make(map[Style]int)283 styleIDs := make(map[Style]int)
284+ // Every row appends its runs to cells and their text to text; ends[i]
285+ // is the text offset after cells[i]. Rows and texts are sliced out once
286+ // at the end, so an update costs one cell slice and one string.
287+ // cells is owned by the update and sized for one full row (an
288+ // incremental update usually changes one or a few rows). The other
289+ // buffers are renderer scratch: the text is copied into one string.
290+ cells := make([]CellUpdate, 0, frame.Width)
291+ ends, text, rowStarts := r.ends[:0], r.text[:0], r.rowStarts[:0]
292+ if cap(ends) < frame.Width {
293+ ends = make([]int, 0, frame.Width)
294+ text = make([]byte, 0, frame.Width)
295+ }
296+ if cap(rowStarts) < min(frame.Height, maxRetainedRows) {
297+ rowStarts = make([]int, 0, min(frame.Height, maxRetainedRows))
298+ }
299+ defer func() {
300+ // Keep scratch for frames up to the common 240x80 size only, so one
301+ // huge snapshot does not pin its buffers for the renderer lifetime.
302+ const maxRetainedCells = 240 * maxRetainedRows
303+ if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= maxRetainedRows {
304+ r.ends, r.text, r.rowStarts = ends, text, rowStarts
305+ }
306+ }()
243 for y := range frame.Height {307 for y := range frame.Height {
244- if !snapshot && rowsEqual(frame.Row(y), r.committed.Row(y)) {308+ if !snapshot && core.RowsEqualAt(frame, y, r.committed, y) {
245 continue309 continue
246 }310 }
311+ row := readRow(frame, y, scratch)
247 if len(update.Rows) >= r.limits.MaxRowsPerUpdate {312 if len(update.Rows) >= r.limits.MaxRowsPerUpdate {
248 return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate)313 return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate)
249 }314 }
250- row, err := encodeRow(y, frame.Row(y), &update.Styles, styleIDs, r.limits.MaxStyles)315+ rowStarts = append(rowStarts, len(cells))
316+ update.Rows = append(update.Rows, RowUpdate{Row: y})
317+ var err error
318+ cells, ends, text, err = encodeRow(row, cells, ends, text, &update.Styles, styleIDs, r.limits.MaxStyles)
251 if err != nil {319 if err != nil {
252 return Update{}, err320 return Update{}, err
253 }321 }
254- update.Rows = append(update.Rows, row)322+ }
323+ all := string(text)
324+ offset := 0
325+ for i := range cells {
326+ cells[i].Text = all[offset:ends[i]]
327+ offset = ends[i]
328+ }
329+ for i := range update.Rows {
330+ next := len(cells)
331+ if i+1 < len(rowStarts) {
332+ next = rowStarts[i+1]
333+ }
334+ update.Rows[i].Cells = cells[rowStarts[i]:next:next]
255 }335 }
256 return update, nil336 return update, nil
257 }337 }
258 338
259-// encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on339+// encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on
260 // the exact encoded size. The previous conservative estimate could reject340 // the exact encoded size. The previous conservative estimate could reject
261 // compact updates well below the configured limit.341 // compact updates well below the configured limit.
262 func encodeBoundedUpdate(update Update, limit int) ([]byte, error) {342 func encodeBoundedUpdate(update Update, limit int) ([]byte, error) {
263- encoded, err := json.Marshal(update)343+ // The hint never exceeds limit, so an oversized update cannot reserve its
344+ // full estimate before the limit rejects it. limit+1 would overflow for
345+ // math.MaxInt.
346+ encoded, err := appendUpdateJSON(make([]byte, 0, min(estimateUpdateJSONSize(update), limit)), update)
264 if err != nil {347 if err != nil {
265 return nil, fmt.Errorf("html: encode update: %w", err)348 return nil, fmt.Errorf("html: encode update: %w", err)
266 }349 }
@@ -270,22 +353,30 @@ func encodeBoundedUpdate(update Update, limit int) ([]byte, error) {
270 return encoded, nil353 return encoded, nil
271 }354 }
272 355
273-func rowsEqual(left, right []core.Cell) bool {356+// runText returns the single-column printable ASCII text of cell, treating a
274- if len(left) != len(right) {357+// blank cell as a space, or false when the cell cannot join a text run.
275- return false358+func runText(cell core.Cell) (byte, bool) {
359+ switch {
360+ case cell.Continuation:
361+ return 0, false
362+ case cell.Rune == 0:
363+ return ' ', true
364+ case cell.Rune >= 0x20 && cell.Rune < 0x7f:
365+ return byte(cell.Rune), true
366+ default:
367+ return 0, false
276 }368 }
277- for i := range left {
278- if !left[i].Equal(right[i]) {
279- return false
280- }
281- }
282- return true
283 }369 }
284 370
285-func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) {371+// encodeRow appends one CellUpdate per text run of a row to cells, its text
286- row := RowUpdate{Row: y, Cells: make([]CellUpdate, 0, len(cells))}372+// to text, and the text end offset to ends. Adjacent printable ASCII or blank
287- for x := 0; x < len(cells); x++ {373+// cells with equal styles merge into one run whose Width equals its byte
288- cell := cells[x]374+// length. Every other cell (wide or non-ASCII) stays a single entry, so
375+// browsers never rely on a fallback font's advance for column alignment.
376+// Text fields are filled by the caller.
377+func encodeRow(row []core.Cell, cells []CellUpdate, ends []int, text []byte, styles *[]Style, styleIDs map[Style]int, maxStyles int) ([]CellUpdate, []int, []byte, error) {
378+ for x := 0; x < len(row); x++ {
379+ cell := row[x]
289 if cell.Continuation {380 if cell.Continuation {
290 continue381 continue
291 }382 }
@@ -293,21 +384,31 @@ func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int
293 styleID, ok := styleIDs[style]384 styleID, ok := styleIDs[style]
294 if !ok {385 if !ok {
295 if len(*styles) >= maxStyles {386 if len(*styles) >= maxStyles {
296- return RowUpdate{}, fmt.Errorf("%w: update styles exceed limit %d", ErrLimitExceeded, maxStyles)387+ return cells, ends, text, fmt.Errorf("%w: update styles exceed limit %d", ErrLimitExceeded, maxStyles)
297 }388 }
298 styleID = len(*styles)389 styleID = len(*styles)
299 styleIDs[style] = styleID390 styleIDs[style] = styleID
300 *styles = append(*styles, style)391 *styles = append(*styles, style)
301 }392 }
302- width := core.RuneWidth(cell.Rune)393+ if first, ok := runText(cell); ok {
303- text := string(cell.Rune)394+ start := x
304- if cell.Rune == 0 {395+ text = append(text, first)
305- width = 1396+ for x+1 < len(row) && row[x+1].Style.Equal(cell.Style) {
306- text = " "397+ next, ok := runText(row[x+1])
398+ if !ok {
399+ break
400+ }
401+ text = append(text, next)
402+ x++
403+ }
404+ cells = append(cells, CellUpdate{Column: start, Width: x - start + 1, Style: styleID})
405+ } else {
406+ text = utf8.AppendRune(text, cell.Rune)
407+ cells = append(cells, CellUpdate{Column: x, Width: core.RuneWidth(cell.Rune), Style: styleID})
307 }408 }
308- row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID})409+ ends = append(ends, len(text))
309 }410 }
310- return row, nil411+ return cells, ends, text, nil
311 }412 }
312 413
313 func validateCoreStyle(style core.Style) error {414 func validateCoreStyle(style core.Style) error {
@@ -396,6 +497,7 @@ func (p *PreparedDraw) Commit() error {
396 if tx.generation != r.generation || r.pending != tx {497 if tx.generation != r.generation || r.pending != tx {
397 return ErrStaleDraw498 return ErrStaleDraw
398 }499 }
500+ r.spare = r.committed
399 r.committed = tx.frame501 r.committed = tx.frame
400 r.cursor = tx.cursor502 r.cursor = tx.cursor
401 r.initialized = true503 r.initialized = true
@@ -417,6 +519,7 @@ func (p *PreparedDraw) Abort() error {
417 if tx.generation != r.generation || r.pending != tx {519 if tx.generation != r.generation || r.pending != tx {
418 return ErrStaleDraw520 return ErrStaleDraw
419 }521 }
522+ r.spare = tx.frame
420 r.pending = nil523 r.pending = nil
421 tx.state = transactionAborted524 tx.state = transactionAborted
422 return nil525 return nil
@@ -430,6 +533,7 @@ func (r *Renderer) Reset() {
430 r.generation++533 r.generation++
431 r.pending = nil534 r.pending = nil
432 r.committed = core.Frame{}535 r.committed = core.Frame{}
536+ r.spare = core.Frame{}
433 r.cursor = Cursor{}537 r.cursor = Cursor{}
434 r.initialized = false538 r.initialized = false
435 }539 }
M html/renderer_test.go +312 -1
@@ -2,8 +2,11 @@ package html
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "math"
6+ "math/rand"
5 "os"7 "os"
6 "testing"8 "testing"
9+ "unicode/utf8"
7 10
8 "github.com/bnema/vev-vt/core"11 "github.com/bnema/vev-vt/core"
9 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
@@ -72,7 +75,7 @@ func TestRendererAcceptsCellSourceWithoutFrameCopy(t *testing.T) {
72 }, nil, false, Cursor{})75 }, nil, false, Cursor{})
73 require.NoError(t, err)76 require.NoError(t, err)
74 require.True(t, prepared.Update().Snapshot)77 require.True(t, prepared.Update().Snapshot)
75- require.Equal(t, "A", prepared.Update().Rows[0].Cells[0].Text)78+ require.Equal(t, []CellUpdate{{Column: 0, Width: 2, Text: "A ", Style: 0}}, prepared.Update().Rows[0].Cells)
76 require.NoError(t, prepared.Commit())79 require.NoError(t, prepared.Commit())
77 80
78 _, err = renderer.Prepare(nil, nil, false, Cursor{})81 _, err = renderer.Prepare(nil, nil, false, Cursor{})
@@ -127,6 +130,151 @@ func TestRendererEnforcesExactGeneratedByteLimit(t *testing.T) {
127 require.ErrorContains(t, err, "generated update is")130 require.ErrorContains(t, err, "generated update is")
128 }131 }
129 132
133+func TestRendererMergesASCIICellsIntoStyledTextRuns(t *testing.T) {
134+ plain := core.DefaultStyle()
135+ bold := core.DefaultStyle()
136+ bold.Bold = true
137+ frame := core.NewFrame(12, 1)
138+ for x, r := range "ab" {
139+ frame.Set(x, 0, core.Cell{Rune: r, Style: plain})
140+ }
141+ // x=2 stays blank (Rune 0) and joins the plain run as a space.
142+ frame.Set(3, 0, core.Cell{Rune: '界', Style: plain})
143+ frame.Set(4, 0, core.Cell{Continuation: true, Style: plain})
144+ frame.Set(5, 0, core.Cell{Rune: 'é', Style: plain})
145+ frame.Set(6, 0, core.Cell{Rune: 'c', Style: bold})
146+ frame.Set(7, 0, core.Cell{Rune: 'd', Style: bold})
147+ frame.Set(8, 0, core.Cell{Rune: '~', Style: plain})
148+
149+ renderer, err := New(Options{})
150+ require.NoError(t, err)
151+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
152+ require.NoError(t, err)
153+ require.Equal(t, []CellUpdate{
154+ {Column: 0, Width: 3, Text: "ab ", Style: 0},
155+ {Column: 3, Width: 2, Text: "界", Style: 0},
156+ {Column: 5, Width: 1, Text: "é", Style: 0},
157+ {Column: 6, Width: 2, Text: "cd", Style: 1},
158+ {Column: 8, Width: 4, Text: "~ ", Style: 0},
159+ }, prepared.Update().Rows[0].Cells)
160+ require.NoError(t, prepared.Commit())
161+}
162+
163+func TestRendererIncrementalRunsSliceChangedRowsIndependently(t *testing.T) {
164+ plain := core.DefaultStyle()
165+ frame := core.NewFrame(4, 3)
166+ renderer, err := New(Options{})
167+ require.NoError(t, err)
168+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
169+ require.NoError(t, err)
170+ require.NoError(t, first.Commit())
171+
172+ frame.Set(0, 0, core.Cell{Rune: 'a', Style: plain})
173+ frame.Set(1, 2, core.Cell{Rune: '界', Style: plain})
174+ frame.Set(2, 2, core.Cell{Continuation: true, Style: plain})
175+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
176+ require.NoError(t, err)
177+ rows := prepared.Update().Rows
178+ require.Len(t, rows, 2)
179+ require.Equal(t, RowUpdate{Row: 0, Cells: []CellUpdate{{Column: 0, Width: 4, Text: "a ", Style: 0}}}, rows[0])
180+ require.Equal(t, RowUpdate{Row: 2, Cells: []CellUpdate{
181+ {Column: 0, Width: 1, Text: " ", Style: 0},
182+ {Column: 1, Width: 2, Text: "界", Style: 0},
183+ {Column: 3, Width: 1, Text: " ", Style: 0},
184+ }}, rows[1])
185+ require.NoError(t, prepared.Commit())
186+}
187+
188+// TestRendererRunsCoverEveryRowExactly checks the run invariants on random
189+// frames: entries are contiguous, widths sum to the frame width, and every
190+// entry wider than one cell is printable ASCII with one byte per column.
191+func TestRendererRunsCoverEveryRowExactly(t *testing.T) {
192+ rng := rand.New(rand.NewSource(1))
193+ runes := []rune{0, 'a', 'b', ' ', '~', 'é', '✓', '界'}
194+ styles := []core.Style{core.DefaultStyle(), func() core.Style { s := core.DefaultStyle(); s.Bold = true; return s }()}
195+ for iteration := range 200 {
196+ width, height := 1+rng.Intn(20), 1+rng.Intn(4)
197+ frame := core.NewFrame(width, height)
198+ for y := range height {
199+ for x := 0; x < width; x++ {
200+ r := runes[rng.Intn(len(runes))]
201+ style := styles[rng.Intn(len(styles))]
202+ if core.RuneWidth(r) == 2 {
203+ if x+1 >= width {
204+ r = 'w'
205+ } else {
206+ frame.Set(x, y, core.Cell{Rune: r, Style: style})
207+ frame.Set(x+1, y, core.Cell{Continuation: true, Style: style})
208+ x++
209+ continue
210+ }
211+ }
212+ frame.Set(x, y, core.Cell{Rune: r, Style: style})
213+ }
214+ }
215+ renderer, err := New(Options{})
216+ require.NoError(t, err)
217+ prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
218+ require.NoError(t, err, "iteration %d", iteration)
219+ requireRunsMatchFrame(t, prepared.Update(), frame, iteration)
220+
221+ // An incremental update after one changed cell decodes the same way,
222+ // through the renderer's reused frame page.
223+ require.NoError(t, prepared.Commit())
224+ y := rng.Intn(height)
225+ frame.Set(0, y, core.Cell{Rune: 'Z', Style: styles[1]})
226+ if width > 1 && frame.Cell(1, y).Continuation {
227+ frame.Set(1, y, core.Cell{Rune: ' '})
228+ }
229+ prepared, err = renderer.Prepare(frame, nil, false, Cursor{})
230+ require.NoError(t, err, "iteration %d", iteration)
231+ update := prepared.Update()
232+ require.False(t, update.Snapshot)
233+ require.Len(t, update.Rows, 1)
234+ requireRunsMatchFrame(t, update, frame, iteration)
235+ }
236+}
237+
238+// requireRunsMatchFrame expands every entry back into cells and compares
239+// each column's text, style and width with the source frame.
240+func requireRunsMatchFrame(t *testing.T, update Update, frame core.Frame, iteration int) {
241+ t.Helper()
242+ for _, row := range update.Rows {
243+ column := 0
244+ for _, cell := range row.Cells {
245+ require.Equal(t, column, cell.Column, "iteration %d row %d", iteration, row.Row)
246+ source := frame.Cell(column, row.Row)
247+ require.Equal(t, styleFromCore(source.Style), update.Styles[cell.Style], "iteration %d (%d,%d)", iteration, column, row.Row)
248+ if r, _ := utf8.DecodeRuneInString(cell.Text); r >= 0x80 {
249+ require.Equal(t, string(source.Rune), cell.Text, "iteration %d (%d,%d)", iteration, column, row.Row)
250+ require.Equal(t, core.RuneWidth(source.Rune), cell.Width, "iteration %d (%d,%d)", iteration, column, row.Row)
251+ column += cell.Width
252+ continue
253+ }
254+ require.Len(t, cell.Text, cell.Width, "iteration %d text %q", iteration, cell.Text)
255+ for i := range len(cell.Text) {
256+ source := frame.Cell(column+i, row.Row)
257+ want := source.Rune
258+ if want == 0 {
259+ want = ' '
260+ }
261+ require.Equal(t, string(want), cell.Text[i:i+1], "iteration %d (%d,%d)", iteration, column+i, row.Row)
262+ require.Equal(t, styleFromCore(source.Style), update.Styles[cell.Style], "iteration %d (%d,%d)", iteration, column+i, row.Row)
263+ }
264+ column += cell.Width
265+ }
266+ require.Equal(t, frame.Width, column, "iteration %d row %d", iteration, row.Row)
267+ }
268+}
269+
270+func TestRendererAcceptsMaxIntGeneratedBytesLimit(t *testing.T) {
271+ renderer, err := New(Options{Limits: Limits{MaxGeneratedBytes: math.MaxInt}})
272+ require.NoError(t, err)
273+ prepared, err := renderer.Prepare(core.NewFrame(2, 1), nil, true, Cursor{})
274+ require.NoError(t, err)
275+ require.NoError(t, prepared.Commit())
276+}
277+
130 func TestRendererUsesIncrementalDamageWithNormalCounts(t *testing.T) {278 func TestRendererUsesIncrementalDamageWithNormalCounts(t *testing.T) {
131 frame := core.NewFrame(2, 2)279 frame := core.NewFrame(2, 2)
132 renderer, err := New(Options{})280 renderer, err := New(Options{})
@@ -228,3 +376,166 @@ func TestRendererInvalidatesCopiedDrawOnReset(t *testing.T) {
228 require.True(t, next.Update().Snapshot)376 require.True(t, next.Update().Snapshot)
229 require.NoError(t, next.Commit())377 require.NoError(t, next.Commit())
230 }378 }
379+
380+func TestPreparedDrawIsIndependentOfLaterPrepares(t *testing.T) {
381+ frame := core.NewFrame(4, 3)
382+ frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()})
383+ renderer, err := New(Options{})
384+ require.NoError(t, err)
385+
386+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
387+ require.NoError(t, err)
388+ firstUpdate, firstJSON := first.Update(), first.JSON()
389+ require.NoError(t, first.Commit())
390+
391+ // The caller mutates its frame in place; neither the earlier draw nor the
392+ // committed shadow may observe it.
393+ frame.Set(1, 1, core.Cell{Rune: 'B', Style: core.DefaultStyle()})
394+ second, err := renderer.Prepare(frame, nil, false, Cursor{})
395+ require.NoError(t, err)
396+ require.Equal(t, []int{1}, rowIndexes(second.Update()))
397+ secondUpdate, secondJSON := second.Update(), second.JSON()
398+ require.NoError(t, second.Commit())
399+
400+ frame.Set(2, 2, core.Cell{Rune: 'C', Style: core.DefaultStyle()})
401+ third, err := renderer.Prepare(frame, nil, false, Cursor{})
402+ require.NoError(t, err)
403+ require.Equal(t, []int{2}, rowIndexes(third.Update()))
404+ require.NoError(t, third.Commit())
405+
406+ require.Equal(t, firstUpdate, first.Update())
407+ require.Equal(t, firstJSON, first.JSON())
408+ require.Equal(t, secondUpdate, second.Update())
409+ require.Equal(t, secondJSON, second.JSON())
410+}
411+
412+func TestAbortThenPrepareKeepsCommittedShadow(t *testing.T) {
413+ frame := core.NewFrame(3, 2)
414+ renderer, err := New(Options{})
415+ require.NoError(t, err)
416+ first, err := renderer.Prepare(frame, nil, false, Cursor{})
417+ require.NoError(t, err)
418+ require.NoError(t, first.Commit())
419+
420+ frame.Set(0, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()})
421+ aborted, err := renderer.Prepare(frame, nil, false, Cursor{})
422+ require.NoError(t, err)
423+ abortedJSON := aborted.JSON()
424+ require.NoError(t, aborted.Abort())
425+
426+ retry, err := renderer.Prepare(frame, nil, false, Cursor{})
427+ require.NoError(t, err)
428+ require.False(t, retry.Update().Snapshot)
429+ require.Equal(t, []int{1}, rowIndexes(retry.Update()))
430+ require.Equal(t, abortedJSON, retry.JSON())
431+ require.NoError(t, retry.Commit())
432+
433+ steady, err := renderer.Prepare(frame, nil, false, Cursor{})
434+ require.NoError(t, err)
435+ require.Empty(t, steady.Update().Rows)
436+}
437+
438+func TestRendererKeepsEncoderScratchAt240x80(t *testing.T) {
439+ frames := [2]core.Frame{core.NewFrame(240, 80), core.NewFrame(240, 80)}
440+ for y := range 80 {
441+ for x := range 240 {
442+ frames[0].Set(x, y, core.Cell{Rune: 'a'})
443+ frames[1].Set(x, y, core.Cell{Rune: 'b'})
444+ }
445+ }
446+ renderer, err := New(Options{})
447+ require.NoError(t, err)
448+ for step := range 2 {
449+ prepared, err := renderer.Prepare(frames[step], nil, false, Cursor{})
450+ require.NoError(t, err)
451+ require.NoError(t, prepared.Commit())
452+ }
453+ require.GreaterOrEqual(t, cap(renderer.rowStarts), 80, "row-start scratch was not retained")
454+ require.GreaterOrEqual(t, cap(renderer.ends), 240, "run-end scratch was not retained")
455+}
456+
457+// TestRendererMatchesFreshRendererAcrossTransactions drives one renderer
458+// (reused spare pages and scratch) through random edits with payloads and
459+// equivalent styles, aborts, resets, stale commits and failed prepares. Most
460+// steps commit and keep editing, so the shadow and spare pages alternate.
461+// Every update must list exactly the rows that differ from a cloned committed
462+// shadow (compared cell by cell with Cell.Equal) and encode the source frame.
463+func TestRendererMatchesFreshRendererAcrossTransactions(t *testing.T) {
464+ rng := rand.New(rand.NewSource(7))
465+ link := func(i int) core.CellPayload {
466+ payload, err := core.NewCellPayload("", "https://example.test/"+string(rune('a'+i)))
467+ require.NoError(t, err)
468+ return payload
469+ }
470+ bold := core.DefaultStyle()
471+ bold.Bold = true
472+ equivalent := bold
473+ equivalent.ForegroundRGB = core.RGB{R: 1} // inactive RGB: same style
474+ for seed := range 30 {
475+ width, height := 2+rng.Intn(10), 1+rng.Intn(5)
476+ frame := core.NewFrame(width, height)
477+ renderer, err := New(Options{})
478+ require.NoError(t, err)
479+ var committed core.Frame
480+ var stale *PreparedDraw
481+ for step := range 60 {
482+ for range rng.Intn(4) {
483+ cell := core.Cell{Rune: rune('a' + rng.Intn(4))}
484+ switch rng.Intn(4) {
485+ case 0:
486+ cell.Style = bold
487+ case 1:
488+ cell.Style = equivalent
489+ case 2:
490+ cell.Payload = link(rng.Intn(3))
491+ }
492+ frame.Set(rng.Intn(width), rng.Intn(height), cell)
493+ }
494+ if stale != nil && rng.Intn(3) == 0 {
495+ require.ErrorIs(t, stale.Commit(), ErrStaleDraw)
496+ stale = nil
497+ }
498+ if rng.Intn(10) == 0 {
499+ // A failed prepare must not lose or corrupt the spare page.
500+ _, err := renderer.Prepare(frame, nil, false, Cursor{Row: height})
501+ require.Error(t, err)
502+ }
503+
504+ want := []int{}
505+ for y := range height {
506+ for x := range width {
507+ if committed.Width == 0 || !frame.Cell(x, y).Equal(committed.Cell(x, y)) {
508+ want = append(want, y)
509+ break
510+ }
511+ }
512+ }
513+ got, err := renderer.Prepare(frame, nil, false, Cursor{})
514+ require.NoError(t, err, "seed %d step %d", seed, step)
515+ update := got.Update()
516+ require.Equal(t, committed.Width == 0, update.Snapshot, "seed %d step %d", seed, step)
517+ require.Equal(t, want, rowIndexes(update), "seed %d step %d", seed, step)
518+ requireRunsMatchFrame(t, update, frame, step)
519+
520+ switch k := rng.Intn(20); {
521+ case k == 0:
522+ renderer.Reset()
523+ committed = core.Frame{}
524+ stale = got
525+ case k < 4:
526+ require.NoError(t, got.Abort())
527+ default:
528+ require.NoError(t, got.Commit())
529+ committed = frame.Clone()
530+ }
531+ }
532+ }
533+}
534+
535+func rowIndexes(update Update) []int {
536+ rows := make([]int, 0, len(update.Rows))
537+ for _, row := range update.Rows {
538+ rows = append(rows, row.Row)
539+ }
540+ return rows
541+}
M html/terminal.css +13 -0
@@ -64,6 +64,19 @@
64 color: var(--vev-cell-fg, var(--vev-fg));64 color: var(--vev-cell-fg, var(--vev-fg));
65 background: var(--vev-cell-bg, var(--vev-bg));65 background: var(--vev-cell-bg, var(--vev-bg));
66 text-decoration-color: var(--vev-cell-underline, currentColor);66 text-decoration-color: var(--vev-cell-underline, currentColor);
67+ letter-spacing: var(--vev-letter-spacing, 0);
68+}
69+
70+.vev-terminal__measure {
71+ position: absolute;
72+ top: 0;
73+ left: 0;
74+ display: block;
75+ width: var(--vev-cell-width);
76+ white-space: pre;
77+ visibility: hidden;
78+ pointer-events: none;
79+ user-select: none;
67 }80 }
68 81
69 .vev-terminal__cell--bold { font-weight: 700; }82 .vev-terminal__cell--bold { font-weight: 700; }
M html/update.go +7 -3
@@ -8,7 +8,7 @@ import (
8 )8 )
9 9
10 // UpdateSchemaVersion identifies the browser update contract.10 // UpdateSchemaVersion identifies the browser update contract.
11-const UpdateSchemaVersion uint16 = 111+const UpdateSchemaVersion uint16 = 2
12 12
13 // CursorStyle is a DECSCUSR cursor shape value from 0 through 6.13 // CursorStyle is a DECSCUSR cursor shape value from 0 through 6.
14 type CursorStyle uint814 type CursorStyle uint8
@@ -80,8 +80,12 @@ type Style struct {
80 UnderlineColor Color `json:"underlineColor"`80 UnderlineColor Color `json:"underlineColor"`
81 }81 }
82 82
83-// CellUpdate occupies Width terminal columns beginning at Column. Wide-cell83+// CellUpdate occupies Width terminal columns beginning at Column. It is
84-// continuation markers are represented by the preceding cell's width.84+// either a text run of Width printable ASCII characters (blank cells are
85+// spaces) sharing one style, or one non-ASCII character of width 1 or 2.
86+// Wide-cell continuation markers are represented by the preceding cell's
87+// width. Text values of one update may share backing storage, so retaining
88+// one keeps that update's text alive.
85 type CellUpdate struct {89 type CellUpdate struct {
86 Column int `json:"column"`90 Column int `json:"column"`
87 Width int `json:"width"`91 Width int `json:"width"`
M internal/htmlharness/performance.spec.js +1 -1
@@ -7,7 +7,7 @@ const root = process.cwd();
7 function snapshot(width, height, text) {7 function snapshot(width, height, text) {
8 const cells = Array.from({ length: width }, (_, column) => ({ column, width: 1, text, style: 0 }));8 const cells = Array.from({ length: width }, (_, column) => ({ column, width: 1, text, style: 0 }));
9 return {9 return {
10- schemaVersion: 1,10+ schemaVersion: 2,
11 width,11 width,
12 height,12 height,
13 snapshot: true,13 snapshot: true,
M internal/htmlharness/terminal.spec.js +216 -13
@@ -81,7 +81,7 @@ test('default colors inherit without redundant per-cell properties', async ({ pa
81 test('plain spaces retain grid boxes without per-cell clipping', async ({ page }) => {81 test('plain spaces retain grid boxes without per-cell clipping', async ({ page }) => {
82 await mount(page);82 await mount(page);
83 await page.evaluate(() => terminal.apply({83 await page.evaluate(() => terminal.apply({
84- schemaVersion: 1, width: 5, height: 1, snapshot: true,84+ schemaVersion: 2, width: 5, height: 1, snapshot: true,
85 styles: [85 styles: [
86 { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} },86 { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} },
87 { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0}, underline:true }87 { foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0}, underline:true }
@@ -90,8 +90,7 @@ test('plain spaces retain grid boxes without per-cell clipping', async ({ page }
90 {column:0,width:1,text:' ',style:0},90 {column:0,width:1,text:' ',style:0},
91 {column:1,width:1,text:'A',style:0},91 {column:1,width:1,text:'A',style:0},
92 {column:2,width:1,text:' ',style:1},92 {column:2,width:1,text:' ',style:1},
93- {column:3,width:1,text:' ',style:0},93+ {column:3,width:2,text:' ',style:0}
94- {column:4,width:1,text:' ',style:0}
95 ]}],94 ]}],
96 cursor: {row:0,column:0,visible:false,style:0,styleSet:false}95 cursor: {row:0,column:0,visible:false,style:0,styleSet:false}
97 }));96 }));
@@ -109,7 +108,7 @@ test('keeps wide blanks separate from adjacent space runs', async ({ page }) =>
109 await mount(page);108 await mount(page);
110 const geometry = await page.evaluate(() => {109 const geometry = await page.evaluate(() => {
111 terminal.apply({110 terminal.apply({
112- schemaVersion: 1, width: 4, height: 1, snapshot: true,111+ schemaVersion: 2, width: 4, height: 1, snapshot: true,
113 styles: [{ foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} }],112 styles: [{ foreground: {kind:0}, background: {kind:0}, underlineColor: {kind:0} }],
114 rows: [{row:0, cells:[113 rows: [{row:0, cells:[
115 {column:0,width:1,text:' ',style:0},114 {column:0,width:1,text:' ',style:0},
@@ -134,7 +133,7 @@ test('applies typed rows without interpreting terminal text as markup', async ({
134 await mount(page);133 await mount(page);
135 const hostile = '<img src=x onerror="globalThis.injected=true">';134 const hostile = '<img src=x onerror="globalThis.injected=true">';
136 const update = {135 const update = {
137- schemaVersion: 1,136+ schemaVersion: 2,
138 width: 3,137 width: 3,
139 height: 1,138 height: 1,
140 snapshot: true,139 snapshot: true,
@@ -167,7 +166,7 @@ test('keeps explicit terminal columns under fallback fonts and zoom', async ({ p
167 document.body.style.zoom = '125%';166 document.body.style.zoom = '125%';
168 document.querySelector('#terminal').style.fontFamily = 'serif';167 document.querySelector('#terminal').style.fontFamily = 'serif';
169 terminal.apply({168 terminal.apply({
170- schemaVersion: 1, width: 4, height: 1, snapshot: true,169+ schemaVersion: 2, width: 4, height: 1, snapshot: true,
171 styles: [{170 styles: [{
172 bold: true, italic: true, dim: true, blink: true, strikethrough: true,171 bold: true, italic: true, dim: true, blink: true, strikethrough: true,
173 underline: true, underlineStyle: 3,172 underline: true, underlineStyle: 3,
@@ -195,7 +194,7 @@ test('keeps explicit terminal columns under fallback fonts and zoom', async ({ p
195 test('rejects a malformed update before changing the DOM', async ({ page }) => {194 test('rejects a malformed update before changing the DOM', async ({ page }) => {
196 await mount(page);195 await mount(page);
197 const snapshot = {196 const snapshot = {
198- schemaVersion: 1, width: 1, height: 1, snapshot: true,197+ schemaVersion: 2, width: 1, height: 1, snapshot: true,
199 styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],198 styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],
200 rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],199 rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
201 cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }200 cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
@@ -214,7 +213,7 @@ test('rejects a malformed update before changing the DOM', async ({ page }) => {
214 await expect(page.locator('.vev-terminal__cell')).toHaveText('A');213 await expect(page.locator('.vev-terminal__cell')).toHaveText('A');
215 const staleVersion = await page.evaluate(value => {214 const staleVersion = await page.evaluate(value => {
216 try {215 try {
217- terminal.apply({ ...value, schemaVersion: 2 });216+ terminal.apply({ ...value, schemaVersion: 3 });
218 return '';217 return '';
219 } catch (error) {218 } catch (error) {
220 return error.message;219 return error.message;
@@ -233,13 +232,217 @@ test('rejects a malformed update before changing the DOM', async ({ page }) => {
233 expect(duplicateMount).toContain('already owns a terminal');232 expect(duplicateMount).toContain('already owns a terminal');
234 });233 });
235 234
235+test('renders ASCII text runs on the column grid and rejects mismatched runs', async ({ page }) => {
236+ await mount(page);
237+ const result = await page.evaluate(() => {
238+ const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
239+ const bold = { ...plain, bold: true };
240+ terminal.apply({
241+ schemaVersion: 2, width: 12, height: 1, snapshot: true, styles: [plain, bold],
242+ rows: [{ row: 0, cells: [
243+ { column: 0, width: 5, text: 'hello', style: 0 },
244+ { column: 5, width: 2, text: '界', style: 0 },
245+ { column: 7, width: 3, text: 'ab ', style: 1 },
246+ { column: 10, width: 2, text: ' ', style: 0 }
247+ ] }],
248+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
249+ });
250+ const cells = [...document.querySelectorAll('.vev-terminal__cell')];
251+ const unit = cells[0].getBoundingClientRect().width / 5;
252+ const geometry = cells.map(node => {
253+ const box = node.getBoundingClientRect();
254+ return [Math.round((box.x - cells[0].getBoundingClientRect().x) / unit), Math.round(box.width / unit)];
255+ });
256+ const reject = cells => {
257+ try {
258+ terminal.apply({
259+ schemaVersion: 2, width: 4, height: 1, snapshot: true, styles: [plain],
260+ rows: [{ row: 0, cells }], cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
261+ });
262+ return '';
263+ } catch (error) {
264+ return error.message;
265+ }
266+ };
267+ return {
268+ texts: cells.map(node => node.textContent),
269+ geometry,
270+ output: document.querySelector('.vev-terminal__accessible-output').textContent,
271+ short: reject([{ column: 0, width: 4, text: 'abc', style: 0 }]),
272+ wide: reject([{ column: 0, width: 4, text: '界界', style: 0 }])
273+ };
274+ });
275+ expect(result.texts).toEqual(['hello', '界', 'ab ', ' ']);
276+ expect(result.geometry).toEqual([[0, 5], [5, 2], [7, 3], [10, 2]]);
277+ expect(result.output).toBe('hello界ab ');
278+ expect(result.short).toContain('text run does not match its width');
279+ expect(result.wide).toContain('text run does not match its width');
280+});
281+
282+test('keeps long text runs on the column grid at any font size and zoom', async ({ page }) => {
283+ await mount(page);
284+ const drift = await page.evaluate(async () => {
285+ const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
286+ const root = document.querySelector('#terminal');
287+ const results = [];
288+ for (const [size, zoom] of [[13, 1], [14, 1], [16, 1], [14, 1.25]]) {
289+ root.style.setProperty('--vev-font-size', `${size}px`);
290+ document.body.style.zoom = String(zoom);
291+ terminal.apply({
292+ schemaVersion: 2, width: 240, height: 1, snapshot: true, styles: [plain],
293+ rows: [{ row: 0, cells: [
294+ { column: 0, width: 238, text: 'x'.repeat(238), style: 0 },
295+ { column: 238, width: 2, text: '界', style: 0 }
296+ ] }],
297+ cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
298+ });
299+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
300+ const [run, wide] = document.querySelectorAll('.vev-terminal__cell');
301+ const range = document.createRange();
302+ range.selectNodeContents(run);
303+ results.push(Math.abs(range.getBoundingClientRect().right - wide.getBoundingClientRect().left));
304+ }
305+ return results;
306+ });
307+ for (const value of drift) expect(value).toBeLessThan(0.5);
308+});
309+
310+test('applyAll coalesces queued updates into the same final DOM as sequential apply', async ({ page }) => {
311+ await mount(page);
312+ const result = await page.evaluate(() => {
313+ const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
314+ const bold = { ...plain, bold: true };
315+ const red = { ...plain, foreground: { kind: 1, index: 1 } };
316+ const cursor = column => ({ row: 0, column, visible: true, style: 0, styleSet: false });
317+ const row = (index, text, style) => ({ row: index, cells: [{ column: 0, width: 4, text, style }] });
318+ const snapshot = { schemaVersion: 2, width: 4, height: 3, snapshot: true, styles: [plain], rows: [row(0, 'aaaa', 0), row(1, 'bbbb', 0), row(2, 'cccc', 0)], cursor: cursor(0) };
319+ const queue = [
320+ { schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [bold], rows: [row(1, 'B1 ', 0)], cursor: cursor(1) },
321+ { schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [red, plain], rows: [row(1, 'B2 ', 1), row(2, 'C2 ', 0)], cursor: cursor(2) },
322+ { schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [bold], rows: [row(0, 'A3 ', 0)], cursor: cursor(3) }
323+ ];
324+ const capture = () => ({
325+ html: document.querySelector('.vev-terminal__viewport').innerHTML,
326+ text: document.querySelector('.vev-terminal__accessible-output').textContent
327+ });
328+ terminal.apply(snapshot);
329+ for (const update of queue) terminal.apply(update);
330+ const sequential = capture();
331+ terminal.apply(snapshot);
332+ const nodes = [...document.querySelectorAll('.vev-terminal__row')];
333+ terminal.applyAll(queue);
334+ const merged = capture();
335+ const rebuilt = [...document.querySelectorAll('.vev-terminal__row')].map((node, index) => node !== nodes[index]);
336+ terminal.applyAll([queue[0], snapshot, queue[2]]);
337+ const afterSnapshot = capture().text;
338+ const cursorAt = () => {
339+ const node = document.querySelector('.vev-terminal__cursor');
340+ return [node.style.top, node.style.left];
341+ };
342+ const cursorOnly = column => ({ schemaVersion: 2, width: 4, height: 3, snapshot: false, styles: [], rows: [], cursor: { ...cursor(column), row: 2 } });
343+ terminal.apply(snapshot);
344+ for (const update of [queue[0], cursorOnly(3)]) terminal.apply(update);
345+ const cursorSequential = cursorAt();
346+ terminal.apply(snapshot);
347+ terminal.applyAll([queue[0], cursorOnly(3)]);
348+ const cursorMerged = cursorAt();
349+ terminal.applyAll([snapshot, cursorOnly(2), cursorOnly(1)]);
350+ const cursorAfterSnapshot = cursorAt();
351+ const before = capture();
352+ const failures = [
353+ [{ ...snapshot, width: 5, rows: snapshot.rows.map(r => ({ row: r.row, cells: [{ column: 0, width: 5, text: 'zzzzz', style: 0 }] })) }, queue[0]],
354+ [queue[0], { ...cursorOnly(0), width: 5 }, snapshot]
355+ ].map(batch => {
356+ try { terminal.applyAll(batch); return ''; } catch (error) { return error.message; }
357+ });
358+ return { sequential, merged, rebuilt, afterSnapshot, cursorSequential, cursorMerged, cursorAfterSnapshot, failures, unchanged: JSON.stringify(capture()) === JSON.stringify(before) };
359+ });
360+ expect(result.merged).toEqual(result.sequential);
361+ expect(result.rebuilt).toEqual([true, true, true]);
362+ expect(result.afterSnapshot).toBe('A3 \nbbbb\ncccc');
363+ expect(result.cursorMerged).toEqual(result.cursorSequential);
364+ expect(result.cursorAfterSnapshot[1]).toContain('1 *');
365+ expect(result.failures.every(message => message.includes('dimensions do not match'))).toBe(true);
366+ expect(result.unchanged).toBe(true);
367+});
368+
369+test('applyAll applies in order when merged styles exceed maxStyles, atomically', async ({ page }) => {
370+ await page.setContent('<main><div id="terminal"></div></main>');
371+ await page.addStyleTag({ path: path.join(root, 'html/terminal.css') });
372+ await page.addScriptTag({ path: path.join(root, 'html/browser/terminal.js') });
373+ const result = await page.evaluate(() => {
374+ const plain = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
375+ const bold = { ...plain, bold: true };
376+ const cursor = column => ({ row: 0, column, visible: true, style: 0, styleSet: false });
377+ const update = (snapshot, rows, style, column) => ({
378+ schemaVersion: 2, width: 4, height: 2, snapshot, styles: [style],
379+ rows: rows.map(([row, text]) => ({ row, cells: [{ column: 0, width: 4, text, style: 0 }] })),
380+ cursor: cursor(column)
381+ });
382+ const capture = () => ({
383+ html: document.querySelector('.vev-terminal__viewport').innerHTML,
384+ text: document.querySelector('.vev-terminal__accessible-output').textContent
385+ });
386+ const mount = maxUpdateBytes => VevTerminal.mount(document.querySelector('#terminal'), {
387+ label: 'Fallback terminal', limits: { maxStyles: 1, maxUpdateBytes }
388+ });
389+ const snapshot = update(true, [[0, 'aaaa'], [1, 'bbbb']], plain, 0);
390+ const queue = [update(false, [[0, 'A1 ']], plain, 1), update(false, [[1, 'B2 ']], bold, 2)];
391+
392+ let terminal = mount(16);
393+ terminal.apply(snapshot);
394+ for (const value of queue) terminal.apply(value);
395+ const sequential = capture();
396+ terminal.destroy();
397+
398+ terminal = mount(16);
399+ terminal.apply(snapshot);
400+ terminal.applyAll(queue);
401+ const merged = capture();
402+ const errors = [];
403+ const attempt = values => {
404+ try { terminal.applyAll(values); return ''; } catch (error) { return error.message; }
405+ };
406+ errors.push(attempt([]), attempt('nope'));
407+ const beforeOverflow = capture();
408+ terminal.destroy();
409+
410+ // A row starting with 'é' holds 5 bytes. The snapshot retains 8 of 9
411+ // bytes; each update alone retains 9, but the batch reaches 10 on its
412+ // second update, after the first would already have changed the DOM.
413+ const accented = (row, style) => ({
414+ schemaVersion: 2, width: 4, height: 2, snapshot: false, styles: [style],
415+ rows: [{ row, cells: [{ column: 0, width: 1, text: 'é', style: 0 }, { column: 1, width: 3, text: ' ', style: 0 }] }],
416+ cursor: cursor(0)
417+ });
418+ terminal = mount(9);
419+ terminal.apply(snapshot);
420+ const before = capture();
421+ const overflow = attempt([accented(0, plain), accented(1, bold)]);
422+ const unchanged = JSON.stringify(capture()) === JSON.stringify(before);
423+ terminal.destroy();
424+
425+ terminal = mount(16);
426+ const unmounted = attempt([queue[0]]);
427+ terminal.destroy();
428+ return { sequential, merged, errors, beforeOverflow, overflow, unchanged, unmounted };
429+ });
430+ expect(result.merged).toEqual(result.sequential);
431+ expect(result.errors[0]).toBe('');
432+ expect(result.errors[1]).toContain('updates must be an array');
433+ expect(result.beforeOverflow).toEqual(result.merged);
434+ expect(result.overflow).toContain('retained text exceeds');
435+ expect(result.unchanged).toBe(true);
436+ expect(result.unmounted).toContain('dimensions do not match');
437+});
438+
236 test('rejects colors with a payload from another color kind', async ({ page }) => {439 test('rejects colors with a payload from another color kind', async ({ page }) => {
237 await mount(page);440 await mount(page);
238 const message = await page.evaluate(() => {441 const message = await page.evaluate(() => {
239 const color = { kind: 1, index: 4, rgb: { r: 1, g: 2, b: 3 } };442 const color = { kind: 1, index: 4, rgb: { r: 1, g: 2, b: 3 } };
240 try {443 try {
241 terminal.apply({444 terminal.apply({
242- schemaVersion: 1, width: 1, height: 1, snapshot: true,445+ schemaVersion: 2, width: 1, height: 1, snapshot: true,
243 styles: [{ foreground: color, background: { kind: 0 }, underlineColor: { kind: 0 } }],446 styles: [{ foreground: color, background: { kind: 0 }, underlineColor: { kind: 0 } }],
244 rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],447 rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
245 cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }448 cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
@@ -323,7 +526,7 @@ test('rejects oversized input without throwing from DOM callbacks', async ({ pag
323 test('emits one composed text event and bounded pointer, wheel, resize, and focus events', async ({ page }) => {526 test('emits one composed text event and bounded pointer, wheel, resize, and focus events', async ({ page }) => {
324 await mount(page);527 await mount(page);
325 const snapshot = {528 const snapshot = {
326- schemaVersion: 1, width: 2, height: 1, snapshot: true,529+ schemaVersion: 2, width: 2, height: 1, snapshot: true,
327 styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],530 styles: [{ foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } }],
328 rows: [{ row: 0, cells: [531 rows: [{ row: 0, cells: [
329 { column: 0, width: 1, text: 'A', style: 0 },532 { column: 0, width: 1, text: 'A', style: 0 },
@@ -392,7 +595,7 @@ test('applies dynamic terminal styles under a self-only CSP', async ({ page }) =
392 await page.evaluate(() => {595 await page.evaluate(() => {
393 globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), { label: 'CSP terminal' });596 globalThis.terminal = VevTerminal.mount(document.querySelector('#terminal'), { label: 'CSP terminal' });
394 terminal.apply({597 terminal.apply({
395- schemaVersion: 1, width: 1, height: 1, snapshot: true,598+ schemaVersion: 2, width: 1, height: 1, snapshot: true,
396 styles: [{599 styles: [{
397 foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },600 foreground: { kind: 2, rgb: { r: 1, g: 2, b: 3 } },
398 background: { kind: 1, index: 4 },601 background: { kind: 1, index: 4 },
@@ -428,12 +631,12 @@ test('updates complete rows, applies typed themes, and destroys owned state', as
428 await mount(page);631 await mount(page);
429 const style = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };632 const style = { foreground: { kind: 0 }, background: { kind: 0 }, underlineColor: { kind: 0 } };
430 await page.evaluate(value => terminal.apply(value), {633 await page.evaluate(value => terminal.apply(value), {
431- schemaVersion: 1, width: 1, height: 1, snapshot: true, styles: [style],634+ schemaVersion: 2, width: 1, height: 1, snapshot: true, styles: [style],
432 rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],635 rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'A', style: 0 }] }],
433 cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }636 cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
434 });637 });
435 await page.evaluate(value => terminal.apply(value), {638 await page.evaluate(value => terminal.apply(value), {
436- schemaVersion: 1, width: 1, height: 1, snapshot: false, styles: [style],639+ schemaVersion: 2, width: 1, height: 1, snapshot: false, styles: [style],
437 rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'B', style: 0 }] }],640 rows: [{ row: 0, cells: [{ column: 0, width: 1, text: 'B', style: 0 }] }],
438 cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }641 cursor: { row: 0, column: 0, visible: false, style: 0, styleSet: false }
439 });642 });
M internal/htmlharness/testdata/snapshot.json +1 -1
@@ -1,5 +1,5 @@
1 {1 {
2- "schemaVersion": 1,2+ "schemaVersion": 2,
3 "width": 3,3 "width": 3,
4 "height": 1,4 "height": 1,
5 "snapshot": true,5 "snapshot": true,
A oracle_norace_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package vt_test
4+
5+const raceEnabled = false
A oracle_race_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build race
2+
3+package vt_test
4+
5+const raceEnabled = true
A oracle_test.go +680 -0
@@ -0,0 +1,680 @@
1+package vt_test
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "errors"
9+ "flag"
10+ "fmt"
11+ "hash"
12+ "math/rand/v2"
13+ "os"
14+ "path/filepath"
15+ "strings"
16+ "testing"
17+
18+ vt "github.com/bnema/vev-vt"
19+ "github.com/bnema/vev-vt/ansi"
20+ core "github.com/bnema/vev-vt/core"
21+ "github.com/bnema/vev-vt/graphics"
22+ "github.com/bnema/vev-vt/html"
23+)
24+
25+// The differential oracle drives seeded terminal workloads through the public
26+// pipeline (VT parsing, damage, history, codecs, ANSI and HTML renderers) and
27+// hashes every observable result at checkpoints. Performance work must keep
28+// the golden file byte-identical; behavior changes must regenerate it with
29+// -oracle.update and justify the difference.
30+var (
31+ oracleUpdate = flag.Bool("oracle.update", false, "rewrite testdata/oracle/golden.txt")
32+ oracleDump = flag.String("oracle.dump", "", "write full plain-text transcripts to this directory")
33+)
34+
35+const (
36+ oracleScenarios = 36
37+ oracleSteps = 240
38+ oracleCheckEvery = 20
39+ oracleGolden = "testdata/oracle/golden.txt"
40+)
41+
42+type oracleScenario struct {
43+ seed uint64
44+ cols int
45+ rows int
46+ history *vt.HistoryConfig
47+ byteSplit bool
48+ evictHook bool
49+}
50+
51+func oracleScenarioFor(i int) oracleScenario {
52+ r := rand.New(rand.NewPCG(uint64(i), 0x5eed))
53+ sc := oracleScenario{seed: uint64(i), cols: 8 + r.IntN(110), rows: 3 + r.IntN(40)}
54+ switch i % 4 {
55+ case 1:
56+ sc.history = &vt.HistoryConfig{MaxRows: 50 + r.IntN(400), ChunkRows: 1 + r.IntN(16)}
57+ case 2:
58+ sc.history = &vt.HistoryConfig{MaxBytes: uint64(64<<10 + r.IntN(512<<10)), ChunkRows: 1 + r.IntN(64)}
59+ case 3:
60+ sc.history = &vt.HistoryConfig{MaxRows: 2000, MaxBytes: 4 << 20}
61+ }
62+ sc.byteSplit = i%5 == 0
63+ sc.evictHook = i%3 == 0
64+ return sc
65+}
66+
67+type oracleRun struct {
68+ t *testing.T
69+ sc oracleScenario
70+ r *rand.Rand
71+ screen *vt.Screen
72+ ansiR *ansi.Renderer
73+ ansi256 *ansi.Renderer
74+ htmlR *html.Renderer
75+ replay *vt.Screen
76+ // replay256 receives the ANSI-256 renderer output. Colors are projected,
77+ // so only text and wide-cell structure are compared.
78+ replay256 *vt.Screen
79+ // mirror reproduces the vev daemon consumer: one core.Frame reused and
80+ // mutated in place between draws, so renderer shadows must not alias it.
81+ mirror core.Frame
82+ // hashes holds one digest per observable concern, so an intentional
83+ // change to one layer (for example ANSI bytes) leaves the others'
84+ // golden columns untouched and auditable.
85+ hashes map[string]hash.Hash
86+ dump *bufio.Writer
87+ events []string
88+}
89+
90+// oracleConcerns are the golden columns, in output order. "input" covers the
91+// generated workload and VT callbacks; the others cover one layer each.
92+var oracleConcerns = []string{"input", "vt", "graphics", "history", "ansi", "ansi256", "html"}
93+
94+func (o *oracleRun) emit(concern, format string, args ...any) {
95+ line := fmt.Sprintf(format, args...)
96+ h := o.hashes[concern]
97+ if h == nil {
98+ o.t.Fatalf("unknown oracle concern %q", concern)
99+ }
100+ h.Write([]byte(line))
101+ h.Write([]byte{'\n'})
102+ if o.dump != nil {
103+ // bufio.Writer keeps the first error; the deferred Flush reports it.
104+ _, _ = fmt.Fprintf(o.dump, "%s: %s\n", concern, line)
105+ }
106+}
107+
108+func (o *oracleRun) digest() string {
109+ var b strings.Builder
110+ for i, concern := range oracleConcerns {
111+ if i > 0 {
112+ b.WriteByte(' ')
113+ }
114+ b.WriteString(concern)
115+ b.WriteByte('=')
116+ b.WriteString(hex.EncodeToString(o.hashes[concern].Sum(nil))[:12])
117+ }
118+ return b.String()
119+}
120+
121+func formatCell(c core.Cell) string {
122+ return fmt.Sprintf("%q|%+v|%q|%q|%t", c.Rune, c.Style, c.Payload.Grapheme(), c.Payload.Hyperlink(), c.Continuation)
123+}
124+
125+// formatRow is a compact exact digest of a row: every Cell field contributes.
126+// Full text is only produced for dumps, where it is needed for diagnosis.
127+func formatRow(row []core.Cell) string {
128+ h := sha256.New()
129+ var buf []byte
130+ for _, c := range row {
131+ buf = fmt.Appendf(buf[:0], "%d|%+v|%s|%s|%t;", c.Rune, c.Style, c.Payload.Grapheme(), c.Payload.Hyperlink(), c.Continuation)
132+ h.Write(buf)
133+ }
134+ return hex.EncodeToString(h.Sum(nil)[:12])
135+}
136+
137+func rowText(row []core.Cell) string {
138+ var b strings.Builder
139+ for _, c := range row {
140+ if c.Continuation {
141+ continue
142+ }
143+ if g := c.Payload.Grapheme(); g != "" {
144+ b.WriteString(g)
145+ } else if c.Rune != 0 {
146+ b.WriteRune(c.Rune)
147+ }
148+ }
149+ return b.String()
150+}
151+
152+func (o *oracleRun) genChunk() []byte {
153+ r := o.r
154+ var b bytes.Buffer
155+ for range 1 + r.IntN(6) {
156+ switch op := r.IntN(100); {
157+ case op < 22:
158+ words := []string{"hello", "world", "vev", "ls -la", "❯", "foo/bar", "λx", "0123456789"}
159+ for range 1 + r.IntN(8) {
160+ b.WriteString(words[r.IntN(len(words))])
161+ b.WriteByte(' ')
162+ }
163+ if r.IntN(2) == 0 {
164+ b.WriteString("\r\n")
165+ }
166+ case op < 30:
167+ uni := []string{"界", "🙂", "é", "e\u0301", "한국어", "👍🏽", "a\u200db", "ff"}
168+ for range 1 + r.IntN(10) {
169+ b.WriteString(uni[r.IntN(len(uni))])
170+ }
171+ case op < 42:
172+ b.WriteString(randomSGR(r))
173+ case op < 50:
174+ fmt.Fprintf(&b, "\x1b[%d;%dH", 1+r.IntN(o.sc.rows+2), 1+r.IntN(o.sc.cols+2))
175+ case op < 56:
176+ seqs := []string{"\x1b[K", "\x1b[1K", "\x1b[2K", "\x1b[J", "\x1b[2J", "\x1b[3@", "\x1b[2P", "\x1b[L", "\x1b[2M", "\x1b[4X", "\x1b[S", "\x1b[2T", "\x1bM", "\x1bD", "\x1bE", "\t", "\b"}
177+ b.WriteString(seqs[r.IntN(len(seqs))])
178+ case op < 64:
179+ for range 1 + r.IntN(3*o.sc.rows) {
180+ fmt.Fprintf(&b, "line %d %s\r\n", r.IntN(1000), strings.Repeat("=", r.IntN(o.sc.cols+5)))
181+ }
182+ case op < 68:
183+ top := 1 + r.IntN(o.sc.rows)
184+ bottom := top + r.IntN(o.sc.rows-top+1)
185+ fmt.Fprintf(&b, "\x1b[%d;%dr\x1b[%d;1H", top, bottom, bottom)
186+ for range 1 + r.IntN(6) {
187+ b.WriteString("region\n")
188+ }
189+ if r.IntN(2) == 0 {
190+ b.WriteString("\x1b[r")
191+ }
192+ case op < 71:
193+ if r.IntN(2) == 0 {
194+ b.WriteString("\x1b[?1049h")
195+ } else {
196+ b.WriteString("\x1b[?1049l")
197+ }
198+ case op < 74:
199+ fmt.Fprintf(&b, "\x1b]8;;https://example.invalid/%d\x1b\\link\x1b]8;;\x1b\\", r.IntN(9))
200+ case op < 76:
201+ fmt.Fprintf(&b, "\x1b]0;title %d\x07", r.IntN(99))
202+ case op < 81:
203+ b.WriteString(randomKitty(r))
204+ case op < 84:
205+ queries := []string{"\x1b[6n", "\x1b[c", "\x1b[?2004$p", "\x1b[5n"}
206+ b.WriteString(queries[r.IntN(len(queries))])
207+ case op < 86:
208+ if r.IntN(2) == 0 {
209+ b.WriteString("\x1b[?7l")
210+ } else {
211+ b.WriteString("\x1b[?7h")
212+ }
213+ case op < 91:
214+ // High-cardinality truecolor burst.
215+ for range 1 + r.IntN(40) {
216+ fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm#", r.IntN(256), r.IntN(256), r.IntN(256))
217+ }
218+ b.WriteString("\x1b[0m")
219+ case op < 94:
220+ // Styled blanks and erases inherit the current background.
221+ fmt.Fprintf(&b, "\x1b[48;5;%dm\x1b[K \x1b[0m", r.IntN(256))
222+ default:
223+ fmt.Fprintf(&b, "\r\x1b[K❯ abc\x1b[90m suggestion\x1b[39m\r\x1b[%dC", r.IntN(8))
224+ }
225+ }
226+ return b.Bytes()
227+}
228+
229+func randomSGR(r *rand.Rand) string {
230+ parts := []string{"0", "1", "2", "3", "4", "4:3", "5", "7", "9", "22", "23", "24", "27", "39", "49", "59"}
231+ switch r.IntN(6) {
232+ case 0:
233+ return fmt.Sprintf("\x1b[%dm", 30+r.IntN(8))
234+ case 1:
235+ return fmt.Sprintf("\x1b[38;5;%d;48;5;%dm", r.IntN(256), r.IntN(256))
236+ case 2:
237+ return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", r.IntN(256), r.IntN(256), r.IntN(256))
238+ case 3:
239+ return fmt.Sprintf("\x1b[58;2;%d;%d;%d;4:%dm", r.IntN(256), r.IntN(256), r.IntN(256), r.IntN(6))
240+ default:
241+ return "\x1b[" + parts[r.IntN(len(parts))] + "m"
242+ }
243+}
244+
245+func randomKitty(r *rand.Rand) string {
246+ switch r.IntN(4) {
247+ case 0:
248+ w, h := 1+r.IntN(4), 1+r.IntN(4)
249+ pixels := make([]byte, w*h*4)
250+ for i := range pixels {
251+ pixels[i] = byte(r.IntN(256))
252+ }
253+ enc := encodeBase64(pixels, r.IntN(2) == 0)
254+ return fmt.Sprintf("\x1b_Ga=T,i=%d,f=32,s=%d,v=%d,c=%d,r=%d,q=%d;%s\x1b\\", 1+r.IntN(4), w, h, 1+r.IntN(3), 1+r.IntN(2), r.IntN(3), enc)
255+ case 1:
256+ // Chunked transmission.
257+ pixels := make([]byte, 4*4*4)
258+ for i := range pixels {
259+ pixels[i] = byte(r.IntN(256))
260+ }
261+ enc := encodeBase64(pixels, true)
262+ mid := (len(enc) / 8) * 4
263+ return fmt.Sprintf("\x1b_Ga=t,i=%d,f=32,s=4,v=4,m=1;%s\x1b\\\x1b_Gm=0;%s\x1b\\\x1b_Ga=p,i=%d\x1b\\", 5+r.IntN(3), enc[:mid], enc[mid:], 5+r.IntN(3))
264+ case 2:
265+ return fmt.Sprintf("\x1b_Ga=d,d=%s\x1b\\", []string{"a", "A", "i", "I"}[r.IntN(4)])
266+ default:
267+ // Invalid base64 must be rejected identically.
268+ return "\x1b_Ga=T,f=32,s=1,v=1;!!!\x1b\\"
269+ }
270+}
271+
272+func encodeBase64(data []byte, padded bool) string {
273+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
274+ var b strings.Builder
275+ for i := 0; i < len(data); i += 3 {
276+ var chunk [3]byte
277+ n := copy(chunk[:], data[i:])
278+ v := uint(chunk[0])<<16 | uint(chunk[1])<<8 | uint(chunk[2])
279+ for j := range 4 {
280+ if j <= n {
281+ b.WriteByte(alphabet[(v>>(18-6*j))&63])
282+ } else if padded {
283+ b.WriteByte('=')
284+ }
285+ }
286+ }
287+ return b.String()
288+}
289+
290+func (o *oracleRun) write(data []byte) {
291+ if o.sc.byteSplit {
292+ for i := range data {
293+ o.screen.Write(data[i : i+1 : i+1])
294+ }
295+ return
296+ }
297+ for len(data) > 0 {
298+ n := 1 + o.r.IntN(len(data))
299+ if o.r.IntN(3) != 0 {
300+ n = len(data)
301+ }
302+ o.screen.Write(data[:n:n])
303+ data = data[n:]
304+ }
305+}
306+
307+func (o *oracleRun) maybeResize(step int) {
308+ if o.r.IntN(40) != 0 {
309+ return
310+ }
311+ cols, rows := 8+o.r.IntN(110), 3+o.r.IntN(40)
312+ o.emit("input", "step %d resize %dx%d", step, cols, rows)
313+ o.screen.Resize(cols, rows)
314+}
315+
316+func (o *oracleRun) checkScreen() {
317+ s := o.screen
318+ o.emit("vt", "screen %dx%d cursor=%d,%d vis=%t title=%q alt=%t", s.Columns(), s.Rows(), s.CursorRow(), s.CursorCol(), s.CursorVisible(), s.TerminalTitle(), s.AltScreenActive())
319+ snap := s.Snapshot()
320+ o.emit("vt", "modes %+v cursor %+v next=%d", snap.Modes(), snap.Cursor(), snap.NextRowID())
321+ for y := range s.Rows() {
322+ o.emit("vt", "row %d id=%d bound=%+v %s %q", y, s.RowID(y), snap.Bound(y), formatRow(s.RowCells(y)), rowText(s.RowCells(y)))
323+ snapRow := snap.Row(y)
324+ if formatRow(snapRow) != formatRow(s.RowCells(y)) {
325+ o.t.Fatalf("seed %d: snapshot row %d differs from live row", o.sc.seed, y)
326+ }
327+ }
328+ o.emit("vt", "bounds %+v", s.LineBounds())
329+}
330+
331+func (o *oracleRun) checkGraphics() {
332+ g := o.screen.GraphicsSnapshot()
333+ if g == nil {
334+ o.emit("graphics", "graphics nil")
335+ return
336+ }
337+ o.emit("graphics", "graphics gen=%d usage=%+v", g.Generation(), g.Usage())
338+ for _, a := range g.Assets() {
339+ blob := a.Blob()
340+ sum := sha256.Sum256(blob.Encoded)
341+ o.emit("graphics", "asset fmt=%v %dx%d px=%d sha=%x", blob.Format, blob.Width, blob.Height, blob.DecodedPixels, sum[:8])
342+ }
343+ for _, p := range g.Placements() {
344+ o.emit("graphics", "placement %+v", p.Spec())
345+ }
346+ o.emit("graphics", "fragments %+v", g.VisibleCellFragments(graphics.CellRect{Width: int64(o.screen.Columns()), Height: int64(o.screen.Rows())}))
347+}
348+
349+func (o *oracleRun) checkHistory(deep bool) {
350+ h := o.screen.History()
351+ if h == nil {
352+ return
353+ }
354+ o.emit("history", "history len=%d cells=%d bytes=%d next=%d stats=%+v", h.Len(), h.Cells(), h.LogicalBytes(), h.NextRowID(), h.CompressionStats())
355+ if !deep {
356+ return
357+ }
358+ snapView := h.SnapshotView()
359+ tail, err := vt.MarshalHistoryTail(snapView)
360+ if err != nil {
361+ o.t.Fatalf("seed %d: MarshalHistoryTail: %v", o.sc.seed, err)
362+ }
363+ sum := sha256.Sum256(tail)
364+ o.emit("history", "snapview len=%d chunks=%d tail=%x", snapView.Len(), snapView.ChunkCount(), sum)
365+ var sealed [][]byte
366+ for i := range snapView.ChunkCount() {
367+ blob, err := vt.MarshalHistoryChunk(snapView.Chunk(i))
368+ if err != nil {
369+ o.t.Fatalf("seed %d: MarshalHistoryChunk: %v", o.sc.seed, err)
370+ }
371+ sum := sha256.Sum256(blob)
372+ o.emit("history", "chunk %d %x", i, sum)
373+ sealed = append(sealed, blob)
374+ }
375+ view := h.View()
376+ var rows []string
377+ var texts []string
378+ if err := view.Range(func(row []core.Cell) bool {
379+ rows = append(rows, formatRow(row))
380+ texts = append(texts, rowText(row))
381+ return true
382+ }); err != nil {
383+ o.t.Fatalf("seed %d: history Range: %v", o.sc.seed, err)
384+ }
385+ for i, row := range rows {
386+ o.emit("history", "hrow %d id=%d bound=%+v %s %q", i, view.RowID(i), view.Bound(i), row, texts[i])
387+ if got := formatRow(view.Row(i)); got != row {
388+ o.t.Fatalf("seed %d: history Row(%d) differs from Range", o.sc.seed, i)
389+ }
390+ dst := make([]core.Cell, view.RowWidth(i))
391+ view.CopyRow(i, dst)
392+ if formatRow(dst) != row {
393+ o.t.Fatalf("seed %d: history CopyRow(%d) differs from Range", o.sc.seed, i)
394+ }
395+ }
396+ restored, err := vt.HistoryFromBlobs(h.Limits(), sealed, tail)
397+ if err != nil {
398+ o.t.Fatalf("seed %d: HistoryFromBlobs: %v", o.sc.seed, err)
399+ }
400+ rview := restored.View()
401+ if rview.Len() != view.Len() || restored.NextRowID() != h.NextRowID() {
402+ o.t.Fatalf("seed %d: restored history len=%d next=%d want len=%d next=%d", o.sc.seed, rview.Len(), restored.NextRowID(), view.Len(), h.NextRowID())
403+ }
404+ for i, row := range rows {
405+ if formatRow(rview.Row(i)) != row || rview.RowID(i) != view.RowID(i) || rview.Bound(i) != view.Bound(i) {
406+ o.t.Fatalf("seed %d: restored history row %d differs", o.sc.seed, i)
407+ }
408+ }
409+ transcript, err := o.screen.RecoveryTranscriptSnapshot().Marshal()
410+ if err != nil {
411+ o.t.Fatalf("seed %d: recovery transcript: %v", o.sc.seed, err)
412+ }
413+ tsum := sha256.Sum256(transcript)
414+ o.emit("history", "transcript %x", tsum)
415+ recovered, err := vt.NewScreenWithRecoveryTranscript(o.screen.Columns(), o.screen.Rows(), h.Limits(), sealed, tail, transcript)
416+ if err != nil {
417+ o.t.Fatalf("seed %d: NewScreenWithRecoveryTranscript: %v", o.sc.seed, err)
418+ }
419+ rh := recovered.History()
420+ o.emit("history", "recovered len=%d next=%d", rh.Len(), rh.NextRowID())
421+ if rh.Len() > 0 {
422+ rv := rh.View()
423+ o.emit("history", "recovered last %s", formatRow(rv.Row(rv.Len()-1)))
424+ }
425+}
426+
427+func (o *oracleRun) render(step int) {
428+ s := o.screen
429+ damage := append([]core.Damage(nil), s.Damage()...)
430+ capture := s.CaptureDamage()
431+ if len(capture.Damage) != len(damage) {
432+ o.t.Fatalf("seed %d: CaptureDamage length %d want %d", o.sc.seed, len(capture.Damage), len(damage))
433+ }
434+ o.emit("vt", "damage %+v", damage)
435+
436+ if o.mirror.Width != s.Columns() || o.mirror.Height != s.Rows() {
437+ o.mirror = core.NewFrame(s.Columns(), s.Rows())
438+ }
439+ for y := range s.Rows() {
440+ for x := range s.Columns() {
441+ o.mirror.Set(x, y, s.Cell(x, y))
442+ }
443+ }
444+ var source core.CellSource = o.mirror
445+ switch o.sc.seed % 3 {
446+ case 1:
447+ source = &o.mirror
448+ case 2:
449+ source = s // generic CellSource path
450+ }
451+ prepared, err := o.ansiR.Prepare(source, damage, false)
452+ if err != nil {
453+ o.t.Fatalf("seed %d step %d: ansi Prepare: %v", o.sc.seed, step, err)
454+ }
455+ out := prepared.Bytes()
456+ o.emit("ansi", "ansi %q", out)
457+ if o.r.IntN(7) == 0 {
458+ // Discarded draw: renderer must keep its committed shadow and damage
459+ // must remain pending for the next draw.
460+ o.emit("ansi", "ansi discard")
461+ } else {
462+ prepared.Commit()
463+ o.replay = o.applyReplay(o.replay, out, step, true)
464+ if !s.AcknowledgeDamage(capture.Generation) {
465+ o.t.Fatalf("seed %d: acknowledge failed", o.sc.seed)
466+ }
467+ }
468+ other, err := o.ansi256.Draw(source, damage)
469+ if err != nil {
470+ o.t.Fatalf("seed %d: ansi256 Draw: %v", o.sc.seed, err)
471+ }
472+ o.emit("ansi256", "ansi256 %q", other)
473+ o.replay256 = o.applyReplay(o.replay256, other, step, false)
474+
475+ snap := s.Snapshot()
476+ cur := snap.Cursor()
477+ var htmlSource core.CellSource = o.mirror
478+ if o.sc.seed%3 == 1 {
479+ htmlSource = snap
480+ }
481+ hp, err := o.htmlR.Prepare(htmlSource, damage, false, html.Cursor{Row: cur.Row, Column: cur.Col, Visible: cur.Visible, Style: html.CursorStyle(cur.Style), StyleSet: cur.StyleSet})
482+ if err != nil {
483+ o.emit("html", "html err %v", err)
484+ return
485+ }
486+ o.emit("html", "html %s", hp.JSON())
487+ if o.r.IntN(6) == 0 {
488+ if err := hp.Abort(); err != nil {
489+ o.t.Fatalf("seed %d: html abort: %v", o.sc.seed, err)
490+ }
491+ o.emit("html", "html abort")
492+ } else if err := hp.Commit(); err != nil {
493+ o.t.Fatalf("seed %d: html commit: %v", o.sc.seed, err)
494+ }
495+}
496+
497+// applyReplay feeds committed ANSI output to an independent VT and requires
498+// the visible grid to match the source. This semantic check does not depend on
499+// the golden file.
500+func (o *oracleRun) applyReplay(replay *vt.Screen, out []byte, step int, styles bool) *vt.Screen {
501+ s := o.screen
502+ if replay == nil || replay.Columns() != s.Columns() || replay.Rows() != s.Rows() {
503+ replay = vt.NewScreen(s.Columns(), s.Rows())
504+ }
505+ replay.Write(out)
506+ for y := range s.Rows() {
507+ for x := range s.Columns() {
508+ want, got := s.Cell(x, y), replay.Cell(x, y)
509+ if want.Rune != got.Rune || want.Continuation != got.Continuation || (styles && !want.Style.Equal(got.Style)) {
510+ o.t.Fatalf("seed %d step %d: ANSI replay (styles=%t) mismatch at (%d,%d): got %s want %s", o.sc.seed, step, styles, x, y, formatCell(got), formatCell(want))
511+ }
512+ }
513+ }
514+ return replay
515+}
516+
517+func runOracleScenario(t *testing.T, idx int) string {
518+ sc := oracleScenarioFor(idx)
519+ o := &oracleRun{t: t, sc: sc, r: rand.New(rand.NewPCG(sc.seed, 0xdecaf)), hashes: map[string]hash.Hash{}}
520+ for _, concern := range oracleConcerns {
521+ o.hashes[concern] = sha256.New()
522+ }
523+ if *oracleDump != "" {
524+ if err := os.MkdirAll(*oracleDump, 0o755); err != nil {
525+ t.Fatal(err)
526+ }
527+ f, err := os.Create(filepath.Join(*oracleDump, fmt.Sprintf("scenario-%02d.txt", idx)))
528+ if err != nil {
529+ t.Fatal(err)
530+ }
531+ o.dump = bufio.NewWriter(f)
532+ defer func() {
533+ if err := errors.Join(o.dump.Flush(), f.Close()); err != nil {
534+ t.Errorf("write oracle dump: %v", err)
535+ }
536+ }()
537+ }
538+ if sc.history != nil {
539+ o.screen = vt.NewScreenWithHistory(sc.cols, sc.rows, *sc.history)
540+ } else {
541+ o.screen = vt.NewScreen(sc.cols, sc.rows)
542+ }
543+ o.screen.OnResponse = func(b []byte) { o.events = append(o.events, fmt.Sprintf("response %q", b)) }
544+ o.screen.OnBell = func() { o.events = append(o.events, "bell") }
545+ if sc.evictHook {
546+ // The callback owns each row: retain it, verify it is never rewritten by
547+ // later evictions, then scribble on it to prove VT kept its own copy.
548+ var retained []core.Cell
549+ var retainedDigest string
550+ o.screen.OnLineEvicted = func(row []core.Cell) {
551+ if retained != nil && formatRow(retained) != retainedDigest {
552+ t.Fatalf("seed %d: evicted row storage was reused after the callback", sc.seed)
553+ }
554+ digest := formatRow(row)
555+ o.events = append(o.events, "evicted "+digest+" "+rowText(row))
556+ if prev := retained; prev != nil {
557+ for i := range prev {
558+ prev[i] = core.Cell{Rune: 'X'}
559+ }
560+ }
561+ retained, retainedDigest = row, digest
562+ }
563+ }
564+ o.ansiR = ansi.New(ansi.Capabilities{SynchronizedOutput: idx%2 == 0})
565+ o.ansi256 = ansi.NewWithColorProfile(ansi.Capabilities{}, ansi.ColorProfileANSI256)
566+ var err error
567+ o.htmlR, err = html.New(html.Options{})
568+ if err != nil {
569+ t.Fatal(err)
570+ }
571+ var historyConfig vt.HistoryConfig
572+ if sc.history != nil {
573+ historyConfig = *sc.history
574+ }
575+ o.emit("input", "scenario %d %dx%d history=%+v byteSplit=%t evictHook=%t", idx, sc.cols, sc.rows, historyConfig, sc.byteSplit, sc.evictHook)
576+
577+ var digests []string
578+ for step := range oracleSteps {
579+ o.maybeResize(step)
580+ chunk := o.genChunk()
581+ o.emit("input", "step %d write %q", step, chunk)
582+ o.write(chunk)
583+ for _, e := range o.events {
584+ o.emit("input", "%s", e)
585+ }
586+ o.events = o.events[:0]
587+ o.render(step)
588+ if step%oracleCheckEvery == oracleCheckEvery-1 {
589+ o.checkScreen()
590+ o.checkGraphics()
591+ o.checkHistory(step%(2*oracleCheckEvery) == 2*oracleCheckEvery-1)
592+ digests = append(digests, fmt.Sprintf("%02d/%03d %s", idx, step, o.digest()))
593+ }
594+ }
595+ return strings.Join(digests, "\n")
596+}
597+
598+// TestDifferentialOracle compares every checkpoint against
599+// testdata/oracle/golden.txt. Each golden line holds one digest per concern
600+// (see oracleConcerns), so an intentional output change shows which layers
601+// moved. Regenerate with -oracle.update only for a deliberate behavior change,
602+// on a full run, and justify the changed columns; diagnose a divergence by
603+// running -oracle.dump=DIR on both trees and diffing the scenario transcript.
604+// Under -race only every sixth scenario runs: scenarios are single-goroutine,
605+// so the detector adds cost without coverage.
606+func TestDifferentialOracle(t *testing.T) {
607+ if testing.Short() {
608+ t.Skip("differential oracle skipped in -short mode")
609+ }
610+ got := make([]string, oracleScenarios)
611+ t.Run("scenarios", func(t *testing.T) {
612+ for i := range oracleScenarios {
613+ t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) {
614+ if raceEnabled && i%6 != 0 {
615+ t.Skip("reduced oracle under -race")
616+ }
617+ t.Parallel()
618+ got[i] = runOracleScenario(t, i)
619+ })
620+ }
621+ })
622+ if t.Failed() {
623+ return
624+ }
625+ ran := 0
626+ for _, digests := range got {
627+ if digests != "" {
628+ ran++
629+ }
630+ }
631+ if *oracleUpdate {
632+ if ran != oracleScenarios {
633+ t.Fatalf("-oracle.update needs every scenario; only %d of %d ran (remove -run filters and -race)", ran, oracleScenarios)
634+ }
635+ if err := os.MkdirAll(filepath.Dir(oracleGolden), 0o755); err != nil {
636+ t.Fatal(err)
637+ }
638+ if err := os.WriteFile(oracleGolden, []byte(strings.Join(got, "\n")+"\n"), 0o644); err != nil {
639+ t.Fatal(err)
640+ }
641+ return
642+ }
643+ golden, err := os.ReadFile(oracleGolden)
644+ if err != nil {
645+ t.Fatalf("read golden (run with -oracle.update on a trusted baseline): %v", err)
646+ }
647+ want := map[string][]string{}
648+ for _, line := range strings.Split(strings.TrimSpace(string(golden)), "\n") {
649+ if len(line) < 3 {
650+ t.Fatalf("malformed golden line %q (regenerate with -oracle.update on a trusted baseline)", line)
651+ }
652+ want[line[:2]] = append(want[line[:2]], line)
653+ }
654+ for i, digests := range got {
655+ if digests == "" {
656+ continue
657+ }
658+ compareOracleScenario(t, fmt.Sprintf("%02d", i), strings.Split(digests, "\n"), want[fmt.Sprintf("%02d", i)])
659+ }
660+}
661+
662+func compareOracleScenario(t *testing.T, id string, got, want []string) {
663+ t.Helper()
664+ for i := range min(len(got), len(want)) {
665+ if got[i] == want[i] {
666+ continue
667+ }
668+ gotFields, wantFields := strings.Fields(got[i]), strings.Fields(want[i])
669+ var moved []string
670+ for j := 1; j < min(len(gotFields), len(wantFields)); j++ {
671+ if gotFields[j] != wantFields[j] {
672+ moved = append(moved, strings.SplitN(gotFields[j], "=", 2)[0])
673+ }
674+ }
675+ t.Fatalf("scenario %s diverged at checkpoint %s in %v\n got %s\n want %s\nrerun with -oracle.dump=DIR on both trees and diff the transcript", id, gotFields[0], moved, got[i], want[i])
676+ }
677+ if len(got) != len(want) {
678+ t.Fatalf("scenario %s checkpoint count differs: got %d want %d", id, len(got), len(want))
679+ }
680+}
M protocol/kittygraphics/base64.go +18 -5
@@ -1,6 +1,7 @@
1 package kittygraphics1 package kittygraphics
2 2
3 import (3 import (
4+ "bytes"
4 "encoding/base64"5 "encoding/base64"
5 "fmt"6 "fmt"
6 )7 )
@@ -8,14 +9,26 @@ import (
8 // DecodeBase64 accepts both padded standard base64 and unpadded raw standard9 // DecodeBase64 accepts both padded standard base64 and unpadded raw standard
9 // base64. It deliberately does not accept URL-safe alphabets or non-base6410 // base64. It deliberately does not accept URL-safe alphabets or non-base64
10 // whitespace other than the CR/LF ignored by the standard encoding.11 // whitespace other than the CR/LF ignored by the standard encoding.
12+//
13+// The returned slice is freshly allocated and owned by the caller.
11 func DecodeBase64(encoded []byte) ([]byte, error) {14 func DecodeBase64(encoded []byte) ([]byte, error) {
12- decoded, err := base64.StdEncoding.DecodeString(string(encoded))15+ // RawStdEncoding rejects '=' and otherwise accepts every input
16+ // StdEncoding does (padded input has no '=' only when its length is a
17+ // multiple of four, where both encodings agree), so the alphabet used is
18+ // chosen by looking for padding rather than by decoding twice.
19+ enc := base64.RawStdEncoding
20+ if bytes.IndexByte(encoded, '=') >= 0 {
21+ enc = base64.StdEncoding
22+ }
23+ decoded := make([]byte, enc.DecodedLen(len(encoded)))
24+ n, err := enc.Decode(decoded, encoded)
13 if err == nil {25 if err == nil {
14- return decoded, nil26+ return decoded[:n], nil
15 }27 }
16- decoded, rawErr := base64.RawStdEncoding.DecodeString(string(encoded))28+ if enc == base64.RawStdEncoding {
17- if rawErr == nil {29+ // Report the StdEncoding error, as the padded decoder is the
18- return decoded, nil30+ // documented primary format. This only runs for rejected input.
31+ _, err = base64.StdEncoding.Decode(make([]byte, base64.StdEncoding.DecodedLen(len(encoded))), encoded)
19 }32 }
20 return nil, fmt.Errorf("%w: %v", ErrInvalidBase64, err)33 return nil, fmt.Errorf("%w: %v", ErrInvalidBase64, err)
21 }34 }
A protocol/kittygraphics/base64_equiv_test.go +103 -0
@@ -0,0 +1,103 @@
1+package kittygraphics
2+
3+import (
4+ "bytes"
5+ "encoding/base64"
6+ "fmt"
7+ "math/rand"
8+ "testing"
9+)
10+
11+// legacyDecodeBase64 is the original two-pass implementation, kept as the
12+// accept/reject and error-text oracle for DecodeBase64.
13+func legacyDecodeBase64(encoded []byte) ([]byte, error) {
14+ decoded, err := base64.StdEncoding.DecodeString(string(encoded))
15+ if err == nil {
16+ return decoded, nil
17+ }
18+ decoded, rawErr := base64.RawStdEncoding.DecodeString(string(encoded))
19+ if rawErr == nil {
20+ return decoded, nil
21+ }
22+ return nil, fmt.Errorf("%w: %v", ErrInvalidBase64, err)
23+}
24+
25+func checkBase64Equivalent(t testing.TB, input []byte) {
26+ t.Helper()
27+ want, wantErr := legacyDecodeBase64(input)
28+ got, gotErr := DecodeBase64(input)
29+ if (wantErr == nil) != (gotErr == nil) {
30+ t.Fatalf("DecodeBase64(%q) error = %v, legacy error = %v", input, gotErr, wantErr)
31+ }
32+ if wantErr != nil {
33+ if gotErr.Error() != wantErr.Error() {
34+ t.Fatalf("DecodeBase64(%q) error = %q, legacy %q", input, gotErr, wantErr)
35+ }
36+ if !errorsIs(gotErr, ErrInvalidBase64) {
37+ t.Fatalf("DecodeBase64(%q) error %v does not wrap ErrInvalidBase64", input, gotErr)
38+ }
39+ return
40+ }
41+ if !bytes.Equal(got, want) || (got == nil) != (want == nil) {
42+ t.Fatalf("DecodeBase64(%q) = %x, legacy %x", input, got, want)
43+ }
44+}
45+
46+func TestDecodeBase64MatchesLegacyRandom(t *testing.T) {
47+ rng := rand.New(rand.NewSource(1))
48+ alphabets := []string{
49+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
50+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\r\n",
51+ "AAAA==\r\n=-_ \t\x00\xff",
52+ }
53+ for i := 0; i < 200000; i++ {
54+ alphabet := alphabets[rng.Intn(len(alphabets))]
55+ input := make([]byte, rng.Intn(24))
56+ for j := range input {
57+ input[j] = alphabet[rng.Intn(len(alphabet))]
58+ }
59+ checkBase64Equivalent(t, input)
60+ }
61+ // Valid encodings, padded and raw, with and without line breaks.
62+ for n := 0; n < 40; n++ {
63+ raw := make([]byte, n)
64+ rng.Read(raw)
65+ padded := base64.StdEncoding.EncodeToString(raw)
66+ unpadded := base64.RawStdEncoding.EncodeToString(raw)
67+ checkBase64Equivalent(t, []byte(padded))
68+ checkBase64Equivalent(t, []byte(unpadded))
69+ checkBase64Equivalent(t, []byte(unpadded+"\r\n"))
70+ if len(padded) > 2 {
71+ checkBase64Equivalent(t, []byte(padded[:2]+"\n"+padded[2:]))
72+ checkBase64Equivalent(t, []byte(padded[:len(padded)-1]))
73+ }
74+ }
75+ checkBase64Equivalent(t, nil)
76+ checkBase64Equivalent(t, []byte{})
77+}
78+
79+func FuzzDecodeBase64MatchesLegacy(f *testing.F) {
80+ for _, seed := range []string{"", "AAAA", "AAA=", "AAA", "AA==", "AA", "A", "AA=", "=", "AAAA\n", "AA\r\nAA", "-_-_"} {
81+ f.Add([]byte(seed))
82+ }
83+ f.Fuzz(func(t *testing.T, input []byte) { checkBase64Equivalent(t, input) })
84+}
85+
86+func TestParseAPCBorrowedAliasesAndParseAPCCopies(t *testing.T) {
87+ apc := []byte("\x1b_Ga=t,i=1;QUJD\x1b\\")
88+ copied, err := ParseAPC(apc)
89+ if err != nil {
90+ t.Fatal(err)
91+ }
92+ borrowed, err := ParseAPCBorrowed(apc)
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ apc[len("\x1b_Ga=t,i=1;")] = 'Z'
97+ if string(copied.Payload) != "QUJD" {
98+ t.Fatalf("ParseAPC payload aliased input: %q", copied.Payload)
99+ }
100+ if string(borrowed.Payload) != "ZUJD" {
101+ t.Fatalf("ParseAPCBorrowed payload = %q, want aliased", borrowed.Payload)
102+ }
103+}
M protocol/kittygraphics/parser.go +22 -2
@@ -159,6 +159,10 @@ func (p *Parser) commandEvent() Event {
159 // exported separately so adapters can validate commands without maintaining a159 // exported separately so adapters can validate commands without maintaining a
160 // stream parser.160 // stream parser.
161 func ParseCommand(body []byte, config ...Limits) (Command, error) {161 func ParseCommand(body []byte, config ...Limits) (Command, error) {
162+ return parseCommand(body, true, config)
163+}
164+
165+func parseCommand(body []byte, copyPayload bool, config []Limits) (Command, error) {
162 var l Limits166 var l Limits
163 if len(config) != 0 {167 if len(config) != 0 {
164 l = config[0]168 l = config[0]
@@ -179,11 +183,27 @@ func ParseCommand(body []byte, config ...Limits) (Command, error) {
179 if uint64(len(payload)) > l.MaxPayloadBytes {183 if uint64(len(payload)) > l.MaxPayloadBytes {
180 return Command{}, ErrPayloadTooLarge184 return Command{}, ErrPayloadTooLarge
181 }185 }
182- return Command{Controls: controls, Payload: append([]byte(nil), payload...)}, nil186+ if copyPayload {
187+ payload = append([]byte(nil), payload...)
188+ }
189+ return Command{Controls: controls, Payload: payload}, nil
183 }190 }
184 191
185 // ParseAPC parses a complete ESC _ G ... ST sequence.192 // ParseAPC parses a complete ESC _ G ... ST sequence.
186 func ParseAPC(apc []byte, config ...Limits) (Command, error) {193 func ParseAPC(apc []byte, config ...Limits) (Command, error) {
194+ return parseAPC(apc, true, config)
195+}
196+
197+// ParseAPCBorrowed is ParseAPC without copying the payload: the returned
198+// Command.Payload aliases apc. The caller must not modify apc while the
199+// command is in use and must not retain the command beyond the lifetime of
200+// apc. Session.Process copies whatever it keeps from a command's payload, so
201+// a borrowed command may be passed to it and apc reused afterwards.
202+func ParseAPCBorrowed(apc []byte, config ...Limits) (Command, error) {
203+ return parseAPC(apc, false, config)
204+}
205+
206+func parseAPC(apc []byte, copyPayload bool, config []Limits) (Command, error) {
187 if len(apc) < 4 || apc[0] != 0x1b || apc[1] != '_' || apc[2] != 'G' {207 if len(apc) < 4 || apc[0] != 0x1b || apc[1] != '_' || apc[2] != 'G' {
188 return Command{}, ErrInvalidAPC208 return Command{}, ErrInvalidAPC
189 }209 }
@@ -195,5 +215,5 @@ func ParseAPC(apc []byte, config ...Limits) (Command, error) {
195 } else {215 } else {
196 return Command{}, ErrAPCTruncated216 return Command{}, ErrAPCTruncated
197 }217 }
198- return ParseCommand(body, config...)218+ return parseCommand(body, copyPayload, config)
199 }219 }
M protocol/kittygraphics/session.go +15 -7
@@ -162,6 +162,12 @@ func (s *Session) Finish() (Result, error) {
162 162
163 // Process applies one already parsed command. It is useful for callers that163 // Process applies one already parsed command. It is useful for callers that
164 // have their own stream framing but want this package's strict adapter.164 // have their own stream framing but want this package's strict adapter.
165+//
166+// Process never retains command.Payload (or the APC bytes it was parsed from)
167+// beyond the call: whatever must outlive it, such as a pending chunked upload
168+// or a committed asset, is copied or freshly decoded. Callers may therefore pass
169+// a command from ParseAPCBorrowed and reuse or overwrite the underlying bytes
170+// as soon as Process returns.
165 func (s *Session) Process(command Command) (Result, error) {171 func (s *Session) Process(command Command) (Result, error) {
166 if s == nil {172 if s == nil {
167 return Result{}, ErrNoScene173 return Result{}, ErrNoScene
@@ -287,14 +293,16 @@ func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation,
287 s.upload = &upload{293 s.upload = &upload{
288 controls: c,294 controls: c,
289 imageID: imageID,295 imageID: imageID,
290- payload: append([]byte(nil), command.Payload...),296+ // The session keeps the payload across Process calls, so it must
291- chunks: 1,297+ // not alias the caller's (possibly borrowed) bytes.
292- display: display,298+ payload: append([]byte(nil), command.Payload...),
293- origin: s.origin,299+ chunks: 1,
300+ display: display,
301+ origin: s.origin,
294 }302 }
295 return nil, nil, nil303 return nil, nil, nil
296 }304 }
297- return s.commitUpload(upload{controls: c, imageID: imageID, payload: append([]byte(nil), command.Payload...), chunks: 1, display: display, origin: s.origin}, display)305+ return s.commitUpload(upload{controls: c, imageID: imageID, payload: command.Payload, chunks: 1, display: display, origin: s.origin}, display)
298 }306 }
299 307
300 func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, error) {308 func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, error) {
@@ -336,14 +344,14 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
336 if replacing {344 if replacing {
337 // Scene replacement is copy-on-write: a rejected new asset leaves the345 // Scene replacement is copy-on-write: a rejected new asset leaves the
338 // old asset and every placement that references it untouched.346 // old asset and every placement that references it untouched.
339- assetID, err = s.scene.ReplaceAsset(old, graphics.AssetBlob{347+ assetID, err = s.scene.ReplaceAssetOwned(old, graphics.AssetBlob{
340 Encoded: decoded,348 Encoded: decoded,
341 Format: assetFormat(format),349 Format: assetFormat(format),
342 Width: width,350 Width: width,
343 Height: height,351 Height: height,
344 })352 })
345 } else {353 } else {
346- assetID, err = s.scene.AddAsset(graphics.AssetBlob{354+ assetID, err = s.scene.AddAssetOwned(graphics.AssetBlob{
347 Encoded: decoded,355 Encoded: decoded,
348 Format: assetFormat(format),356 Format: assetFormat(format),
349 Width: width,357 Width: width,
M screen.go +75 -18
@@ -18,6 +18,11 @@ const maxEscapeBufferLen = 128 * 1024
18 // through the text path or truncated at the OSC clipboard bound.18 // through the text path or truncated at the OSC clipboard bound.
19 const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 519 const maxKittyEscapeBufferLen = int(kittygraphics.DefaultMaxAPCBytes) + 5
20 20
21+// maxRetainedEscapeBufferCap bounds the capacity of the escape scratch buffers
22+// kept between writes so that a single huge APC does not pin its memory, while
23+// ordinary fragmented uploads still reuse their buffer.
24+const maxRetainedEscapeBufferCap = 1 << 20
25+
21 const (26 const (
22 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.27 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
23 ColorSchemeReportDark = "\x1b[?997;1n"28 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -72,6 +77,8 @@ type Screen struct {
72 damageSaturated bool77 damageSaturated bool
73 damageFullRedrawSticky bool78 damageFullRedrawSticky bool
74 escapeBuf []byte79 escapeBuf []byte
80+ combineBuf []byte
81+ evictScratch []renderer.Cell
75 kittyDiscard bool82 kittyDiscard bool
76 kittyDiscardEscaped bool83 kittyDiscardEscaped bool
77 kittyPendingDisplay *kittygraphics.Controls84 kittyPendingDisplay *kittygraphics.Controls
@@ -168,7 +175,9 @@ func (s *Screen) LineBounds() []LineBound {
168 }175 }
169 176
170 func (s *Screen) Write(data []byte) {177 func (s *Screen) Write(data []byte) {
171- if len(s.escapeBuf) > 0 && isKittyEscapeContinuation(s.escapeBuf, data) {178+ if len(s.escapeBuf) == 0 {
179+ s.trimEscapeBuffers()
180+ } else if isKittyEscapeContinuation(s.escapeBuf, data) {
172 prefix := s.escapeBuf181 prefix := s.escapeBuf
173 s.escapeBuf = nil182 s.escapeBuf = nil
174 data = s.continueKittyEscape(prefix, data)183 data = s.continueKittyEscape(prefix, data)
@@ -177,11 +186,30 @@ func (s *Screen) Write(data []byte) {
177 }186 }
178 }187 }
179 if len(s.escapeBuf) > 0 {188 if len(s.escapeBuf) > 0 {
180- combined := make([]byte, 0, len(s.escapeBuf)+len(data))189+ // Reuse a bounded scratch buffer: data is consumed before Write returns
190+ // and nothing retains slices of it (the partial-escape path copies).
191+ // Detach it while in use: callbacks invoked below may call Write
192+ // re-entrantly, and that inner Write must not overwrite bytes this
193+ // Write is still reading.
194+ need := len(s.escapeBuf) + len(data)
195+ combined := s.combineBuf[:0]
196+ s.combineBuf = nil
197+ if cap(combined) < need {
198+ combined = make([]byte, 0, need)
199+ }
181 combined = append(combined, s.escapeBuf...)200 combined = append(combined, s.escapeBuf...)
182 combined = append(combined, data...)201 combined = append(combined, data...)
202+ defer func() {
203+ if s.combineBuf == nil && cap(combined) <= maxRetainedEscapeBufferCap {
204+ s.combineBuf = combined[:0]
205+ }
206+ }()
183 data = combined207 data = combined
184- s.escapeBuf = nil208+ if cap(s.escapeBuf) > maxRetainedEscapeBufferCap {
209+ s.escapeBuf = nil
210+ } else {
211+ s.escapeBuf = s.escapeBuf[:0]
212+ }
185 }213 }
186 if s.kittyDiscard {214 if s.kittyDiscard {
187 consumed := s.consumeKittyDiscard(data)215 consumed := s.consumeKittyDiscard(data)
@@ -249,42 +277,55 @@ func isKittyEscapeContinuation(prefix, data []byte) bool {
249 return true277 return true
250 }278 }
251 279
280+// trimEscapeBuffers drops oversized idle escape scratch buffers.
281+func (s *Screen) trimEscapeBuffers() {
282+ if cap(s.escapeBuf) > maxRetainedEscapeBufferCap {
283+ s.escapeBuf = nil
284+ }
285+ if cap(s.combineBuf) > maxRetainedEscapeBufferCap {
286+ s.combineBuf = nil
287+ }
288+}
289+
290+// recycleEscapeBuf keeps buf's capacity for the next fragmented escape when no
291+// other buffer has been installed meanwhile. Callers must be done with buf.
292+func (s *Screen) recycleEscapeBuf(buf []byte) {
293+ if s.escapeBuf == nil && cap(buf) <= maxRetainedEscapeBufferCap {
294+ s.escapeBuf = buf[:0]
295+ }
296+}
297+
298+// continueKittyEscape completes or extends the pending Kitty APC in prefix,
299+// which is the Screen's former escapeBuf (s.escapeBuf is nil on entry). The
300+// protocol adapter copies whatever it retains from a dispatched APC, so prefix
301+// is recycled afterwards instead of being reallocated for every APC.
252 func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {302 func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
253 if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {303 if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
254 if len(prefix)+1 <= maxKittyEscapeBufferLen {304 if len(prefix)+1 <= maxKittyEscapeBufferLen {
255 apc := append(prefix, data[:1]...)305 apc := append(prefix, data[:1]...)
256 s.dispatchKittyGraphics(apc)306 s.dispatchKittyGraphics(apc)
307+ s.recycleEscapeBuf(apc)
257 } else {308 } else {
258 s.abortKittyPendingDisplay()309 s.abortKittyPendingDisplay()
310+ s.recycleEscapeBuf(prefix)
259 }311 }
260 return data[1:]312 return data[1:]
261 }313 }
262 for i := 0; i < len(data); i++ {314 for i := 0; i < len(data); i++ {
263 if data[i] == 0x9c {315 if data[i] == 0x9c {
264 end := i + 1316 end := i + 1
265- if len(prefix)+end <= maxKittyEscapeBufferLen {317+ s.finishKittyEscape(prefix, data[:end])
266- apc := append(prefix, data[:end]...)
267- s.dispatchKittyGraphics(apc)
268- } else {
269- s.abortKittyPendingDisplay()
270- }
271 return data[end:]318 return data[end:]
272 }319 }
273 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {320 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
274 end := i + 2321 end := i + 2
275- if len(prefix)+end <= maxKittyEscapeBufferLen {322+ s.finishKittyEscape(prefix, data[:end])
276- apc := append(prefix, data[:end]...)
277- s.dispatchKittyGraphics(apc)
278- } else {
279- s.abortKittyPendingDisplay()
280- }
281 return data[end:]323 return data[end:]
282 }324 }
283 }325 }
284 remaining := maxKittyEscapeBufferLen - len(prefix)326 remaining := maxKittyEscapeBufferLen - len(prefix)
285 if len(data) <= remaining {327 if len(data) <= remaining {
286- returnData := append(prefix, data...)328+ s.escapeBuf = append(prefix, data...)
287- s.escapeBuf = returnData
288 return nil329 return nil
289 }330 }
290 s.abortKittyPendingDisplay()331 s.abortKittyPendingDisplay()
@@ -294,10 +335,24 @@ func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
294 escaped = data[remaining-1] == 0x1b335 escaped = data[remaining-1] == 0x1b
295 }336 }
296 s.kittyDiscardEscaped = escaped337 s.kittyDiscardEscaped = escaped
338+ s.recycleEscapeBuf(prefix)
297 tail := data[remaining:]339 tail := data[remaining:]
298 return tail[s.consumeKittyDiscard(tail):]340 return tail[s.consumeKittyDiscard(tail):]
299 }341 }
300 342
343+// finishKittyEscape dispatches prefix+terminated, or aborts when the framed
344+// APC would exceed the Kitty escape bound, then recycles prefix.
345+func (s *Screen) finishKittyEscape(prefix, terminated []byte) {
346+ if len(prefix)+len(terminated) > maxKittyEscapeBufferLen {
347+ s.abortKittyPendingDisplay()
348+ s.recycleEscapeBuf(prefix)
349+ return
350+ }
351+ apc := append(prefix, terminated...)
352+ s.dispatchKittyGraphics(apc)
353+ s.recycleEscapeBuf(apc)
354+}
355+
301 func (s *Screen) consumeKittyDiscard(data []byte) int {356 func (s *Screen) consumeKittyDiscard(data []byte) int {
302 for i, b := range data {357 for i, b := range data {
303 if s.kittyDiscardEscaped {358 if s.kittyDiscardEscaped {
@@ -418,7 +473,9 @@ func (s *Screen) consumeKittyGraphics(data []byte) (consumed int, partial bool)
418 }473 }
419 474
420 func (s *Screen) dispatchKittyGraphics(apc []byte) {475 func (s *Screen) dispatchKittyGraphics(apc []byte) {
421- command, err := kittygraphics.ParseAPC(apc)476+ // The session copies any payload it retains, so the APC bytes may be
477+ // borrowed for the duration of this call only.
478+ command, err := kittygraphics.ParseAPCBorrowed(apc)
422 if err != nil {479 if err != nil {
423 s.abortKittyPendingDisplay()480 s.abortKittyPendingDisplay()
424 return481 return
A screen_evict_scratch_test.go +27 -0
@@ -0,0 +1,27 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt/core"
7+)
8+
9+// OnLineEvicted callers may retain the slice: successive evictions must not
10+// share the Screen's internal scratch row. The oracle checks the same rule
11+// across scenarios but is skipped under -short; this is the -short guard.
12+func TestOnLineEvictedRowsAreIndependentCopies(t *testing.T) {
13+ s := NewScreen(4, 2)
14+ var rows [][]renderer.Cell
15+ s.OnLineEvicted = func(row []renderer.Cell) { rows = append(rows, row) }
16+ s.Write([]byte("aaaa\r\nbbbb\r\ncccc\r\ndddd"))
17+ if len(rows) != 2 {
18+ t.Fatalf("evicted %d rows, want 2", len(rows))
19+ }
20+ if rows[0][0].Rune != 'a' || rows[1][0].Rune != 'b' {
21+ t.Fatalf("evicted rows = %q, %q", rows[0][0].Rune, rows[1][0].Rune)
22+ }
23+ rows[0][0].Rune = 'z'
24+ if rows[1][0].Rune != 'b' {
25+ t.Fatal("evicted rows alias each other")
26+ }
27+}
M screen_graphics_test.go +132 -0
@@ -3,7 +3,9 @@ package vt
3 import (3 import (
4 "encoding/base64"4 "encoding/base64"
5 "math"5 "math"
6+ "runtime"
6 "strconv"7 "strconv"
8+ "strings"
7 "testing"9 "testing"
8 10
9 "github.com/bnema/vev-vt/graphics"11 "github.com/bnema/vev-vt/graphics"
@@ -331,3 +333,133 @@ func TestScreenKittyPlacementScrollSkipsBottomEdgeOverflow(t *testing.T) {
331 placement = screen.GraphicsSnapshot().Placements()[0]333 placement = screen.GraphicsSnapshot().Placements()[0]
332 require.Equal(t, nearLimit, placement.Destination())334 require.Equal(t, nearLimit, placement.Destination())
333 }335 }
336+
337+// Fragmented APCs reuse the Screen's escape buffer, so retained graphics state
338+// (assets and pending chunked uploads) must not alias it.
339+func TestScreenKittyFragmentedAPCsDoNotAliasEscapeBuffer(t *testing.T) {
340+ screen := NewScreen(16, 3)
341+ writeByteWise := func(data []byte) {
342+ for i := range data {
343+ screen.Write(data[i : i+1])
344+ }
345+ }
346+ enc := func(b ...byte) string { return base64.StdEncoding.EncodeToString(b) }
347+ writeByteWise([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;" + enc(1, 2, 3, 4) + "\x1b\\"))
348+ // Chunked upload interleaved with other escape sequences reusing the buffer.
349+ writeByteWise([]byte("\x1b_Ga=t,i=2,f=32,s=1,v=1,m=1;" + enc(9, 9, 9, 9)[:4] + "\x1b\\"))
350+ writeByteWise([]byte("\x1b[1mtext\x1b[0m"))
351+ writeByteWise([]byte("\x1b_Gm=0;" + enc(9, 9, 9, 9)[4:] + "\x1b\\"))
352+
353+ snapshot := screen.GraphicsSnapshot()
354+ require.NotNil(t, snapshot)
355+ var got [][]byte
356+ for _, asset := range snapshot.Assets() {
357+ got = append(got, asset.Bytes())
358+ }
359+ require.ElementsMatch(t, [][]byte{{1, 2, 3, 4}, {9, 9, 9, 9}}, got)
360+}
361+
362+func screenAssetBytes(t *testing.T, screen *Screen) [][]byte {
363+ t.Helper()
364+ snapshot := screen.GraphicsSnapshot()
365+ require.NotNil(t, snapshot)
366+ var got [][]byte
367+ for _, asset := range snapshot.Assets() {
368+ got = append(got, asset.Bytes())
369+ }
370+ return got
371+}
372+
373+// A callback may call Write re-entrantly; the inner Write must not clobber the
374+// reusable combine buffer the outer Write is still parsing.
375+func TestScreenWriteReentrantFromCallbackKeepsOuterStreamIntact(t *testing.T) {
376+ t.Run("split CSI then DA reply writes text", func(t *testing.T) {
377+ screen := NewScreen(40, 3)
378+ fired := 0
379+ screen.OnResponse = func([]byte) {
380+ fired++
381+ // Leave a partial escape, then complete it: the second inner
382+ // Write takes the combine path while the outer Write is mid-parse.
383+ screen.Write([]byte("\x1b["))
384+ screen.Write([]byte("1mZZZZZZZZZZ"))
385+ }
386+ screen.Write([]byte("\x1b[1"))
387+ screen.Write([]byte("m\x1b[cBCDEFGH"))
388+ require.Equal(t, 1, fired)
389+ require.Equal(t, "ZZZZZZZZZZBCDEFGH", strings.TrimRight(rowText(screen.Snapshot().Row(0)), " "))
390+ })
391+ t.Run("split APC response writes upload", func(t *testing.T) {
392+ screen := NewScreen(40, 3)
393+ inner := false
394+ screen.OnResponse = func([]byte) {
395+ if inner {
396+ return
397+ }
398+ inner = true
399+ for _, b := range []byte("\x1b_Ga=t,f=32,s=1,v=1;CQkJCQ==\x1b\\") {
400+ screen.Write([]byte{b})
401+ }
402+ }
403+ screen.Write([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;AQID"))
404+ screen.Write([]byte("BA==\x1b\\XYZ"))
405+ require.Equal(t, "XYZ", strings.TrimRight(rowText(screen.Snapshot().Row(0)), " "))
406+ require.ElementsMatch(t, [][]byte{{1, 2, 3, 4}, {9, 9, 9, 9}}, screenAssetBytes(t, screen))
407+ })
408+}
409+
410+// Base64 payloads padded with CR/LF decode into a buffer sized for the raw
411+// input. The scene must not retain that slack beyond its accounted bytes,
412+// otherwise untrusted output could hold memory past MaxEncodedBytes.
413+func TestScreenKittyNewlinePaddedPayloadRetainsOnlyAccountedBytes(t *testing.T) {
414+ const uploads, padding = 16, 1 << 20
415+ screen := NewScreen(16, 3)
416+ pad := strings.Repeat("\n", padding)
417+ var before, after runtime.MemStats
418+ runtime.GC()
419+ runtime.ReadMemStats(&before)
420+ for i := range uploads {
421+ screen.Write([]byte("\x1b_Ga=t,i=" + strconv.Itoa(i+1) + ",f=32,s=1,v=1;AQID" + pad + "BA==\x1b\\"))
422+ }
423+ runtime.GC()
424+ runtime.ReadMemStats(&after)
425+ require.Equal(t, uint64(4*uploads), screen.graphics.scene.Usage().EncodedBytes)
426+ for _, asset := range screenAssetBytes(t, screen) {
427+ require.Equal(t, []byte{1, 2, 3, 4}, asset)
428+ }
429+ // Untrimmed decode buffers would retain about 0.75 MiB per upload.
430+ growth := int64(after.HeapAlloc) - int64(before.HeapAlloc)
431+ require.Less(t, growth, int64(4<<20), "heap retained by %d padded uploads", uploads)
432+ runtime.KeepAlive(screen)
433+}
434+
435+// The Screen borrows the caller's Write buffer while parsing; nothing it
436+// retains may alias that buffer once Write returns.
437+func TestScreenKittyGraphicsDoesNotAliasCallerWriteBuffer(t *testing.T) {
438+ scribble := func(b []byte) {
439+ for i := range b {
440+ b[i] = 0xff
441+ }
442+ }
443+ t.Run("single-shot transmit and display", func(t *testing.T) {
444+ screen := NewScreen(16, 3)
445+ buf := []byte("\x1b_Ga=T,i=1,f=32,s=1,v=1;AQIDBA==\x1b\\")
446+ screen.Write(buf)
447+ scribble(buf)
448+ require.Equal(t, [][]byte{{1, 2, 3, 4}}, screenAssetBytes(t, screen))
449+ })
450+ t.Run("first and final chunk in one write", func(t *testing.T) {
451+ screen := NewScreen(16, 3)
452+ buf := []byte("\x1b_Ga=t,i=1,f=32,s=1,v=1,m=1;AQID\x1b\\\x1b_Gm=0;BA==\x1b\\")
453+ screen.Write(buf)
454+ scribble(buf)
455+ require.Equal(t, [][]byte{{1, 2, 3, 4}}, screenAssetBytes(t, screen))
456+ })
457+ t.Run("first chunk write then buffer reuse before final chunk", func(t *testing.T) {
458+ screen := NewScreen(16, 3)
459+ buf := []byte("\x1b_Ga=t,i=1,f=32,s=1,v=1,m=1;AQID\x1b\\")
460+ screen.Write(buf)
461+ scribble(buf)
462+ screen.Write([]byte("\x1b_Gm=0;BA==\x1b\\"))
463+ require.Equal(t, [][]byte{{1, 2, 3, 4}}, screenAssetBytes(t, screen))
464+ })
465+}
M screen_scroll.go +18 -1
@@ -103,10 +103,27 @@ func (s *Screen) emitLineEvicted(top, n int) {
103 if s.alternate != nil || top != 0 {103 if s.alternate != nil || top != 0 {
104 return104 return
105 }105 }
106+ // Without a history or an observer nothing consumes the evicted rows, so
107+ // skip materialising them.
108+ if s.history == nil && s.OnLineEvicted == nil {
109+ return
110+ }
111+ // Drop scratch sized for a much wider past geometry.
112+ if cap(s.evictScratch) > 2*s.frame.Width {
113+ s.evictScratch = nil
114+ }
106 // Read boundaries and IDs before the caller rotates the frame: a soft link115 // Read boundaries and IDs before the caller rotates the frame: a soft link
107 // belongs to the row it follows, and rotation reassigns row indices.116 // belongs to the row it follows, and rotation reassigns row indices.
108 for y := top; y < top+n; y++ {117 for y := top; y < top+n; y++ {
109- s.recordEvicted(s.frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y])118+ // The scratch row is only valid for this call: History copies appended
119+ // rows and OnLineEvicted receives its own copy (see recordEvicted).
120+ row := s.evictScratch[:0]
121+ for x := range s.frame.Width {
122+ row = append(row, s.frame.Cell(x, y))
123+ }
124+ s.evictScratch = row
125+ s.recordEvicted(row, s.buffer.bound(y), s.buffer.rowIDs[y])
126+ clear(s.evictScratch)
110 }127 }
111 }128 }
112 129
M snapshot.go +9 -10
@@ -88,22 +88,22 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist
88 }88 }
89 89
90 func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryView) ([]HistoryView, HistoryView, error) {90 func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryView) ([]HistoryView, HistoryView, error) {
91- seen := make(map[RowID]struct{})91+ var seen rowIDSet
92 sealedViews := make([]HistoryView, len(sealed))92 sealedViews := make([]HistoryView, len(sealed))
93 for i, blob := range sealed {93 for i, blob := range sealed {
94 view, err := UnmarshalHistory(blob)94 view, err := UnmarshalHistory(blob)
95- if err != nil || len(view.chunks) != 1 || view.chunks[0].len() == 0 || !validateRestoredHistoryView(view, seen) {95+ if err != nil || len(view.chunks) != 1 || view.chunks[0].len() == 0 || !validateRestoredHistoryView(view, &seen) {
96 return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory)96 return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
97 }97 }
98 sealedViews[i] = view98 sealedViews[i] = view
99 }99 }
100 100
101 tailView, err := UnmarshalHistory(tail)101 tailView, err := UnmarshalHistory(tail)
102- if err != nil || !validateRestoredHistoryView(tailView, seen) {102+ if err != nil || !validateRestoredHistoryView(tailView, &seen) {
103 return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory)103 return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory)
104 }104 }
105 for _, view := range extra {105 for _, view := range extra {
106- if !validateRestoredHistoryView(view, seen) {106+ if !validateRestoredHistoryView(view, &seen) {
107 return nil, HistoryView{}, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)107 return nil, HistoryView{}, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)
108 }108 }
109 }109 }
@@ -174,27 +174,26 @@ func (h *History) boundedRestoredChunk(chunk *HistoryChunk) *HistoryChunk {
174 return bounded174 return bounded
175 }175 }
176 176
177-func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool {177+func validateRestoredHistoryView(view HistoryView, seen *rowIDSet) bool {
178 if view.nextRowID == 0 || view.nextRowID == ^RowID(0) {178 if view.nextRowID == 0 || view.nextRowID == ^RowID(0) {
179 return false179 return false
180 }180 }
181 var maxID RowID181 var maxID RowID
182+ seen.begin(0)
182 for _, chunk := range view.chunks {183 for _, chunk := range view.chunks {
183 if chunk == nil || chunk.len() != len(chunk.rowIDs) || chunk.len() != len(chunk.bounds) {184 if chunk == nil || chunk.len() != len(chunk.rowIDs) || chunk.len() != len(chunk.bounds) {
184 return false185 return false
185 }186 }
187+ seen.reserve(len(chunk.rowIDs))
186 for _, id := range chunk.rowIDs {188 for _, id := range chunk.rowIDs {
187 if id == 0 || id >= ^RowID(0)-1 {189 if id == 0 || id >= ^RowID(0)-1 {
188 return false190 return false
189 }191 }
190- if _, duplicate := seen[id]; duplicate {192+ seen.collect(id)
191- return false
192- }
193- seen[id] = struct{}{}
194 maxID = max(maxID, id)193 maxID = max(maxID, id)
195 }194 }
196 }195 }
197- return view.nextRowID > maxID196+ return view.nextRowID > maxID && seen.commit()
198 }197 }
199 198
200 // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose199 // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose
A testdata/oracle/golden.txt +432 -0
@@ -0,0 +1,432 @@
1+00/019 input=34fc58104b22 vt=bb9b826cf048 graphics=c1313fa59940 history=e3b0c44298fc ansi=558b1d465cb9 ansi256=7c62df9f88a6 html=089c336c055a
2+00/039 input=957f13b6dc6d vt=d580503f0b0c graphics=d9a508723211 history=e3b0c44298fc ansi=d8801a468cd2 ansi256=4e4fbb5a75ae html=d8e1bb77bf69
3+00/059 input=02ca4e7f645d vt=8f0ec905b4dd graphics=ca7e3df19c59 history=e3b0c44298fc ansi=22e9db71432b ansi256=c94537d35e21 html=c6415e69c0a4
4+00/079 input=e7459a0424b7 vt=658a98490259 graphics=2294ead167f8 history=e3b0c44298fc ansi=a769731d8a6b ansi256=dfaab9ed690b html=6643b5bb55f8
5+00/099 input=65861d80c11d vt=3dff2abbe571 graphics=49a531a5cac2 history=e3b0c44298fc ansi=b9610fa412a9 ansi256=d9a001c8d0d3 html=e974c5a205b1
6+00/119 input=b7bbd6cab918 vt=802dc307f264 graphics=4dca1d671bb5 history=e3b0c44298fc ansi=94ed76fcf0d5 ansi256=cdeba8de8f99 html=f6a001f04d84
7+00/139 input=e1957aa581c4 vt=e59eed292827 graphics=c7edbf6bb08c history=e3b0c44298fc ansi=bf1cd5adba0c ansi256=4b00e8ef793c html=4390bf9591a3
8+00/159 input=72d2e266bbee vt=ce72286ff858 graphics=9d08071cbfe7 history=e3b0c44298fc ansi=3473f0b036d2 ansi256=7caddc9fa2d8 html=eeb8a79679a4
9+00/179 input=5f03942bb446 vt=909794a3080a graphics=c4be9e09c930 history=e3b0c44298fc ansi=43919248cf80 ansi256=15e239bd6430 html=3a1093ff03ab
10+00/199 input=537e6ea131ba vt=ed5b1e654b14 graphics=be52bd862e04 history=e3b0c44298fc ansi=77868d0f1ea5 ansi256=91351ed686ae html=bd818b02e6f5
11+00/219 input=0d15f7a3b67c vt=4fa431325c0d graphics=79964969d8b4 history=e3b0c44298fc ansi=1dfbb31f7937 ansi256=0929b8057746 html=6a0de30fcbb1
12+00/239 input=7c8d6d2373ed vt=bb9c175f8153 graphics=9e5082990084 history=e3b0c44298fc ansi=2ccb101439ce ansi256=7d50c7faac48 html=9ba22418c9c3
13+01/019 input=cbc8877bed63 vt=22ab1acff1bf graphics=c5b8fe57cfa6 history=fa5d8f18f68d ansi=afdab3ddfac9 ansi256=48c65d2c43e6 html=f4b4d477f95b
14+01/039 input=d0cb0bd869c9 vt=f58c883883ec graphics=22628079d8b2 history=386f8088a60f ansi=d592741d1509 ansi256=fac3d9aee1ab html=44685aecaf23
15+01/059 input=e25ff0ff1a54 vt=f329087d99d8 graphics=033cd4eb10d0 history=241434fd3fed ansi=d6547cc1881f ansi256=6e78b18137e7 html=9deadb6f5ae1
16+01/079 input=4de36294e2e7 vt=0b491efe74c5 graphics=e3e861b21b99 history=3e3751cacfa6 ansi=36b333e14ee3 ansi256=f581315254ff html=76778478b41d
17+01/099 input=2b7fa8b73e54 vt=cdcf0feb0ab2 graphics=7b1785222ace history=6de5f14c9b3b ansi=318c8c763741 ansi256=edeaafe2bf97 html=215c330cbbea
18+01/119 input=ed4026577ede vt=47cfe6da14c5 graphics=1f4e90d00dbe history=9fc31e684946 ansi=511a067be740 ansi256=075aaf679ea9 html=9181eac13792
19+01/139 input=16d884839d5e vt=b47acf684680 graphics=aba320d6ac2d history=c694cebc9c6d ansi=a21c3e6b2c56 ansi256=d66750dd1f13 html=f5a06b3ad355
20+01/159 input=ae726a283ab9 vt=124d96154247 graphics=7c92abaf9499 history=1b394f493a6e ansi=e9bb095ab5e8 ansi256=c6e07402bca0 html=fb013b8dbd86
21+01/179 input=4002bcdef56c vt=ae28edda6428 graphics=874c3d3632c4 history=f97e91c814bf ansi=1476dc66f1dc ansi256=e3d9c1c5f152 html=cbc59d21acdb
22+01/199 input=bcdb0bb395af vt=6c6a86726ea6 graphics=408aa5c32aed history=cc247d46dc5d ansi=3900e39db02f ansi256=a1003c8fd5ad html=8da61d8f5751
23+01/219 input=b57a1b43c9ed vt=33212d08cac6 graphics=915c35a9e421 history=55df44fba064 ansi=a697afce6763 ansi256=4afd404912f3 html=cef0f5a71bde
24+01/239 input=5a6d7e54e43f vt=064445a04eb4 graphics=08fa69ab8d7e history=ae06c8614ee7 ansi=ec529ccb7d82 ansi256=da4537b03b71 html=aabd84eaf112
25+02/019 input=e128155ff5ee vt=6165a679180d graphics=9258c91bf1d0 history=417faca887a1 ansi=c76da783dd51 ansi256=ade5d4699a5a html=c9b1d5a2a343
26+02/039 input=f55b6f5f0ae5 vt=6c13343e5e72 graphics=ec94ae6cc7b9 history=a98f1d645add ansi=9d23bdd6b42f ansi256=34b777e364eb html=f0adcc3a9475
27+02/059 input=efecbfa91a37 vt=f15094e108a2 graphics=2cc2c4f89334 history=c354c025cf5c ansi=31a27bd463dc ansi256=f7cb938291b9 html=69c18784fbc3
28+02/079 input=5569bc9cc096 vt=d49d527484f7 graphics=20fb47c1a763 history=5ee55bc12a28 ansi=2dc804e97027 ansi256=6f3132f4c824 html=9cfa349b105c
29+02/099 input=818abcb54d25 vt=79b2c236e761 graphics=2988687caeb9 history=772aefdee759 ansi=eaa23edb44d4 ansi256=f0c638352747 html=a7c605065255
30+02/119 input=0953c14a91ab vt=3255a5b7edd4 graphics=66394393cb3e history=40e37b86d83b ansi=ac12ac48a6d9 ansi256=786be89622e7 html=d586806fbd37
31+02/139 input=097eb426b6af vt=cd9241f4e821 graphics=985e13b357ae history=d0c1aa3cc624 ansi=81d1a99de839 ansi256=465cdb5a2331 html=622548996465
32+02/159 input=38bd63bbb187 vt=c6a6eaea70b2 graphics=aa9d4f7d2ef7 history=0de5f5ec693d ansi=6ac3fb8d4c15 ansi256=6d6993b9a7fc html=802320b5c19a
33+02/179 input=8ae111814233 vt=8fbc292c149b graphics=411e2f4f1d49 history=fdf4efdc191b ansi=abf0b4964a01 ansi256=2a7e59c820a8 html=474a30754595
34+02/199 input=4213f245636c vt=c1d2f7fb395a graphics=5bfa4713d56a history=f5e53a839d08 ansi=7e4c5ba3abb2 ansi256=d2aa15e8b380 html=46502c6e826a
35+02/219 input=931fd5b7147d vt=c659827ccda1 graphics=1651f31f454f history=98009e23090b ansi=fe65923b0e7d ansi256=c7537bce4e9d html=a9044cae47f7
36+02/239 input=8176a5e301c3 vt=c9d8342717ba graphics=fa8374156c82 history=09807ad0d1da ansi=8f8756e5bd05 ansi256=b6612363d590 html=f1f88ba61457
37+03/019 input=77c9efeb3d0d vt=9dd67d6ea65f graphics=32c8e22c58c9 history=9b00e5d5c883 ansi=bd1117ecc4ef ansi256=215c0a0c57b1 html=9782cdf7cf43
38+03/039 input=935cc14c51ee vt=b936be15b17e graphics=fa83d34babef history=53a5b6b99603 ansi=4dbccd5407c6 ansi256=1a21508da4df html=301d72bff7ad
39+03/059 input=ecb91d159cb1 vt=e4fceadf53cc graphics=6ee88ceadd09 history=6f799dd4518c ansi=c7f5ad8c750c ansi256=ba17a1f0e9e4 html=c7cdba4c4787
40+03/079 input=cc2cd5bf9438 vt=5dd5e31dc91c graphics=a6faff22c0fb history=4c160d4c8f45 ansi=6eb8fa2cbfe8 ansi256=f575b7123b3e html=7229ef77dbe6
41+03/099 input=34c42410c4ec vt=89b335bd0924 graphics=ca98374af1a4 history=a1f40bab9f65 ansi=9286621ea5a3 ansi256=13c85dde6f2b html=104721016a8b
42+03/119 input=699e8fffe957 vt=dcffdbdd8be9 graphics=cb0060e5f99a history=9abbb7c01a69 ansi=ee808efefd2c ansi256=9817a2a6e477 html=e29ae3b5a099
43+03/139 input=54491f16453e vt=0cda44af54d3 graphics=c1b2ccb71270 history=45387defc739 ansi=abe60f4d1602 ansi256=d6bc368e5286 html=07df11d13d70
44+03/159 input=36e653d4e326 vt=88d69714c293 graphics=171fbbb1cccd history=233f4b0c2757 ansi=e2a055de3f87 ansi256=3a40282667b5 html=8ebc1f87e76e
45+03/179 input=abdc7322ccd7 vt=83b89bd0be8a graphics=c26da5657c11 history=f56741685971 ansi=d5ccdbca449a ansi256=dd0425640064 html=c99954f24155
46+03/199 input=70dc8e14e40d vt=368c5ce18628 graphics=d5de3993daba history=31647d60d27e ansi=39a12dd48435 ansi256=6b0c5ab24849 html=b6f40afdf05f
47+03/219 input=19fb2dbf2b72 vt=5560f3c4e401 graphics=65eb029b7820 history=035984b53269 ansi=76e9d9396925 ansi256=fb130cac7f93 html=2afb96491c19
48+03/239 input=7c0c5314aeb3 vt=a0dfae216688 graphics=5c6b4ae18b60 history=16d405a75917 ansi=70a2961a921f ansi256=0d5cef42c7e7 html=b42bc55d7fac
49+04/019 input=ac95ee6bd1c4 vt=75730da64833 graphics=950dc65636f9 history=e3b0c44298fc ansi=1d40192d7f5e ansi256=7dd8c5f69449 html=b60ec69d4fa9
50+04/039 input=b16461f2d459 vt=89fde5beaa91 graphics=dff172a47e3b history=e3b0c44298fc ansi=189928fc25b8 ansi256=66a31fd27570 html=06836c676591
51+04/059 input=3dbd041c8a93 vt=46c05b268911 graphics=e8b76a1b9359 history=e3b0c44298fc ansi=80563f330836 ansi256=dc1a4011688e html=4ce55cf3da59
52+04/079 input=6eb4f8e4532a vt=350a5e00a351 graphics=22f91ab97776 history=e3b0c44298fc ansi=0925fc7cb5c2 ansi256=45ee9ea10f8a html=1d746274bc81
53+04/099 input=db3717e615c0 vt=107edd2cc2a8 graphics=c34235f9e530 history=e3b0c44298fc ansi=af0374be4eda ansi256=151e18f23ddc html=c0ee4f10da7e
54+04/119 input=cea55fc507f8 vt=b574c0bff677 graphics=93af9ac5666c history=e3b0c44298fc ansi=5add5259e40e ansi256=6138ac10783b html=985a353387a6
55+04/139 input=6955544376f2 vt=4f1f8af871a1 graphics=f538e5cd130e history=e3b0c44298fc ansi=f2c8d68bc14c ansi256=6c4c3b4fbd78 html=60afdc995c07
56+04/159 input=b9a9ad2510b6 vt=8c8645285af7 graphics=3b4e5f278af3 history=e3b0c44298fc ansi=0b0a39ce76c3 ansi256=54414e73c0da html=fcbb42127a91
57+04/179 input=211613aa2202 vt=c94fe03d96b5 graphics=d96a2ae371a5 history=e3b0c44298fc ansi=0adeddeaefc3 ansi256=9cc4ecb5bffe html=c786476b64b0
58+04/199 input=58e91ff2f4ab vt=6490ae61b95d graphics=d2752175246b history=e3b0c44298fc ansi=63f461c41790 ansi256=3c1d924c1e80 html=63651875ac7c
59+04/219 input=0eba9ec5a46c vt=1d18d28ac035 graphics=fc388d3d53a8 history=e3b0c44298fc ansi=680f2d93282c ansi256=2f45b31b9744 html=639e28fadf36
60+04/239 input=e7f7c681f5b9 vt=0279ccd298ba graphics=81e51316a2a4 history=e3b0c44298fc ansi=7f4446d48723 ansi256=caa0483de38f html=50a15e38179e
61+05/019 input=f708f21d5048 vt=41bb0068b9d5 graphics=54d51f156f82 history=07dd8ff61a7d ansi=72c3abe84900 ansi256=9d5c3edf7855 html=5e3c0b6d8f5a
62+05/039 input=85ab36e8b650 vt=efaa87c37a99 graphics=532ada5ceafc history=3f002ee29e52 ansi=17da07e9f85a ansi256=2e2edf5b0f11 html=5dbb1b79aeee
63+05/059 input=2e2be67ca1d8 vt=2d832e81234a graphics=93c5f3db8810 history=26ad83f6ee57 ansi=49df0f3f3645 ansi256=f0b65aeb31f1 html=d26bda36d08d
64+05/079 input=ba87d248dd45 vt=b4b08d9512f9 graphics=34b65f470628 history=9114e0fa08e7 ansi=09a785da16ea ansi256=194216597b68 html=c79050afee6f
65+05/099 input=e10211359c74 vt=3ceccf69da23 graphics=dda37883cced history=73a01cd06763 ansi=850eda258fcf ansi256=5dd5fa36a9d2 html=82494484858f
66+05/119 input=fffb660dfb77 vt=4c267790e36d graphics=250e1fb7980e history=2f84702229a3 ansi=27896d56c106 ansi256=67417b228a96 html=dd9fe0485715
67+05/139 input=11becbe9e073 vt=4896be991e3e graphics=b9946b6cbb9c history=d83501ac272e ansi=d665657c3a5b ansi256=f4e6bac4daf8 html=e5aa04a63cd0
68+05/159 input=13ccb02683b9 vt=641ab8e1a9f5 graphics=fd8edc008758 history=1e949032c82f ansi=1c055d639abd ansi256=c39256b8bb32 html=7d55008a07de
69+05/179 input=391e22e966cb vt=1c6ed4e827b0 graphics=67ec83f5987e history=4c5309acfd04 ansi=133414fa03a3 ansi256=ac1eccd3b707 html=ca28ec566369
70+05/199 input=7eb6729857bb vt=d7257566974a graphics=fe1fbdd5b0b4 history=4c0bd02293b1 ansi=4f242a4aca0c ansi256=a7c99d8be3da html=5e8b04f4b7bd
71+05/219 input=ba08f58d01d5 vt=1cd8cd975688 graphics=f59bb471eb52 history=534f3354524d ansi=b3db0423a107 ansi256=2188a64e0992 html=3ec517b33883
72+05/239 input=366f9db6155b vt=b075af8eef2b graphics=89b9e8b5c474 history=c162856ede0d ansi=6ab7cf4895ed ansi256=40d312f0e070 html=eb2a7c236afe
73+06/019 input=74524b6e33c7 vt=48a669c6fac9 graphics=9258c91bf1d0 history=898655683e17 ansi=4713ac9daf80 ansi256=67a882e6ad82 html=aea88a93a61c
74+06/039 input=65197226e07a vt=f6995273dbeb graphics=ec94ae6cc7b9 history=5362c829d6ad ansi=2e6a4fe4092e ansi256=cab88d1949fa html=f63ddc28d704
75+06/059 input=0059c87f2ba3 vt=aa1cd4936ce4 graphics=fa4b1bdcb736 history=8d74567c7596 ansi=47ddcf9ea936 ansi256=3cf60f5f6c0b html=8a17b7495efc
76+06/079 input=d1e20fd8eb50 vt=9ec6d176dcc8 graphics=8183b528dbca history=4d405f4fb2ca ansi=9659e34e0243 ansi256=0edab42116ad html=62dfd24647b6
77+06/099 input=cafcb126860a vt=5afe741a2678 graphics=fac73a1bb510 history=13c0d17ff5bd ansi=f55dd08f48f4 ansi256=e644288fb538 html=533e055e0f1f
78+06/119 input=a80f65f5b0a8 vt=ee42a12d024e graphics=97f45805af9a history=967e9dfc2073 ansi=cd0494e91242 ansi256=1d6db9d74155 html=6955dfb35625
79+06/139 input=62d38f2be4a0 vt=238cb53ef640 graphics=f9e4dbc600c0 history=1a9c7f633db0 ansi=ef51adddf2f0 ansi256=7e7e85043c92 html=482d160d3d10
80+06/159 input=1cb1815e815b vt=831a04bbada5 graphics=7bd60ce8809b history=716ecc549fb4 ansi=97b20d9d7b63 ansi256=f9473ebaf756 html=7086bcc7441a
81+06/179 input=5badf3d2dff5 vt=c620f20feba4 graphics=1698c6765d18 history=f4624d43f8fe ansi=394a6010d7c3 ansi256=c0412c491438 html=94eb780da668
82+06/199 input=d3d3d2ce734a vt=28cd61f9633f graphics=09f725b24161 history=3807f05bc93c ansi=bfef2232dc63 ansi256=023386bab8cb html=1e40b054a6bf
83+06/219 input=8506dd47df97 vt=b148edd65135 graphics=19818e5dfbce history=cef46ab3f84f ansi=673db820b25a ansi256=912deffb9972 html=f0624d650e63
84+06/239 input=077726566d29 vt=064f0e868a78 graphics=cd062ea3471e history=3b8a6b0106d8 ansi=506bf9a5f620 ansi256=80c6f5141a3f html=c41c5aad070e
85+07/019 input=fae29ee9e551 vt=4d8363b27bf0 graphics=e93052db1f72 history=a016741d0c17 ansi=fb6cbbd2bbb1 ansi256=b6407309d580 html=aa5dabef9e2e
86+07/039 input=dc25e2be6a38 vt=f0b1efcfcfa2 graphics=50e4979362e6 history=493077d95eaa ansi=fe6fa23f8ed3 ansi256=a10d6bbfa9cc html=149320fcfb1b
87+07/059 input=7975bd5ebcd1 vt=fac6dce4c0d8 graphics=57703a717cd1 history=3f97ffdc2dec ansi=25965c7ad385 ansi256=3da98bce60a1 html=31b80c27ee0c
88+07/079 input=9c6219207676 vt=c94ffbffb325 graphics=fc80a80a4994 history=c02df921f9d1 ansi=a5d71cafb48f ansi256=3c6df957f87f html=00aab4567a96
89+07/099 input=92186125c91e vt=9e6e590d719c graphics=b9fe79beddae history=3f28dfa7b563 ansi=95707765409a ansi256=1bdc74a4465d html=5494d3d8e30d
90+07/119 input=8fd98cda3883 vt=1fbec962753f graphics=c37f447943f5 history=0d80f172198d ansi=f55cac729412 ansi256=bbc080afd759 html=0d4856ac1767
91+07/139 input=388a492ea7fe vt=bb6eefe94a6f graphics=5ad6c9e6e516 history=044cb3ac10ba ansi=0ffee86f12c9 ansi256=12f550934278 html=d3f687643349
92+07/159 input=2d0089ab5f6a vt=034e8c8d8411 graphics=109355c3ac63 history=c359ce2d2381 ansi=4111ff4d3cda ansi256=16385f645c5e html=c5f62df911de
93+07/179 input=9e4cb64f8a8c vt=1e5650773386 graphics=9a017a89f0dc history=591f5b1ff1ae ansi=b2cda00f5d7b ansi256=f31f5aedab35 html=6cf05664d193
94+07/199 input=4353da204940 vt=f3b87f13eb10 graphics=71f7c96bfdae history=50ee391fa45a ansi=21971bfbc100 ansi256=e9ebfde84853 html=a16d44322275
95+07/219 input=dbf5ec27883f vt=69598cc4d0e7 graphics=710c0213a1fe history=d693455d4f7e ansi=53c3b90f9274 ansi256=8a2a3ca793cd html=3d824b5bd23c
96+07/239 input=ee738b75ee7b vt=e8eb1c81e3f2 graphics=c9d2c2dc189f history=3064233b1b2c ansi=2da069a3b0ab ansi256=2652868cc89a html=140de656fed8
97+08/019 input=ffb611ad4389 vt=e72fb0e18d1b graphics=0af58323e07f history=e3b0c44298fc ansi=eba47ed76a95 ansi256=61978201c3de html=93320268dea4
98+08/039 input=cec6bf13be3c vt=3515ec5c5c8c graphics=b5ac885200ab history=e3b0c44298fc ansi=efc869104b50 ansi256=3c21d039ec78 html=09ce8b0e5826
99+08/059 input=687fc086986c vt=f60a863259ce graphics=dae0356b61d7 history=e3b0c44298fc ansi=135df0896a7e ansi256=7b605cee5329 html=fb30a14f5bab
100+08/079 input=26b30a7bfe76 vt=3c80b0ac0d77 graphics=7ab6e8e4205d history=e3b0c44298fc ansi=9ffdabb3d74e ansi256=384c10e114b0 html=0f0c9de323ea
101+08/099 input=85d377028faa vt=7258eb053a31 graphics=1b7d1f50bca5 history=e3b0c44298fc ansi=0f3c263fb26d ansi256=6302751823d5 html=f6763041bb24
102+08/119 input=721048e0006f vt=8c18883d7799 graphics=933354e913ef history=e3b0c44298fc ansi=80e418ed9332 ansi256=6f9633182d67 html=2a905aba4a22
103+08/139 input=e89bacfc0b2a vt=2175ffd19d2d graphics=76585c8b0c85 history=e3b0c44298fc ansi=444b4cff6b95 ansi256=caf0f0407661 html=716af36a0433
104+08/159 input=6bcd5944fc82 vt=3f8142208b56 graphics=ac884b93caf7 history=e3b0c44298fc ansi=887116ca8f6e ansi256=b317af8348e6 html=6d773012be0b
105+08/179 input=5c4c5729bd97 vt=af526eeb4ae5 graphics=223826b2fa2e history=e3b0c44298fc ansi=8d1309edf72a ansi256=f1a71a58ee69 html=ae4cf7fa9ed6
106+08/199 input=219d070f7cd5 vt=ff2bfd155de5 graphics=edb68bafb771 history=e3b0c44298fc ansi=3b50eb64330d ansi256=624d1973d4c1 html=ca4cf6eb6e52
107+08/219 input=9a8b9ab14cf8 vt=6e6f6c767761 graphics=1d9ff4825974 history=e3b0c44298fc ansi=a02880b1f6d8 ansi256=2d8685209b13 html=5ee0c1e12b3f
108+08/239 input=d5a0eabb673d vt=59a1e70c0ae1 graphics=7c4d6749fdab history=e3b0c44298fc ansi=ee7fc1249130 ansi256=827ebce60d16 html=dc1f8772bd1e
109+09/019 input=375c31d38e01 vt=e61ab6ec4c87 graphics=9258c91bf1d0 history=ae793708b2e3 ansi=9933eb508d02 ansi256=825a3a7b06cc html=39820a00874f
110+09/039 input=51adeda97c8a vt=8538ff184ac7 graphics=ec94ae6cc7b9 history=3355696a7a17 ansi=8d7ca8687c2b ansi256=8104cf283c3f html=3a7a8c727717
111+09/059 input=26704fddad28 vt=784b54585b46 graphics=04ecffba52a2 history=398a3fca14d9 ansi=7ffb8815309d ansi256=1e5a60233787 html=e5a8224668a7
112+09/079 input=a2c65542686b vt=95266b5f896f graphics=429dd0371fdc history=74dbda994020 ansi=5412731ac28c ansi256=626afcad4b76 html=5ee3b59d07cf
113+09/099 input=63f82eef3142 vt=e1628289d678 graphics=b012cf49350e history=e653fd8f57b2 ansi=1990868a0a83 ansi256=154cb27c6867 html=798e35937b71
114+09/119 input=50f417492a89 vt=95ccdf0c4556 graphics=3c082a76bd09 history=eca74c726b29 ansi=25f847e9a0a1 ansi256=8bbc1364711b html=caeaf888a3a3
115+09/139 input=f22701044a4e vt=7228dceef0d8 graphics=5016c8d979ca history=555cc174c81c ansi=d92b9305a595 ansi256=ec316e005b62 html=bc8f044736ba
116+09/159 input=f4cde1fc68a6 vt=ca04021ddae7 graphics=44017697e035 history=b51ca289a2cd ansi=3537fc95a66f ansi256=bd90418c5f00 html=c967d12919c3
117+09/179 input=fb22f401fa59 vt=700c026a33bc graphics=889a00c22cfb history=f07dc27c1831 ansi=ab274c4be5dd ansi256=495917d23e1b html=da50b8205551
118+09/199 input=9ebd3391a785 vt=3693180d540a graphics=8441eff04541 history=9f926ff96f78 ansi=4a7c3681294d ansi256=f9b30e3b20cb html=870126d93f17
119+09/219 input=7c79ac16673d vt=b9a4c0603935 graphics=a83ab7751bc5 history=a03bc237d484 ansi=48dc26e77ed3 ansi256=040865fea0e2 html=cf34ce1397c9
120+09/239 input=33d1b8f46fc6 vt=5bf2f76601b2 graphics=d4f2f55f7ecc history=105ae31c67f2 ansi=c4ad351e161e ansi256=8ed3feb18879 html=a1a4f1688621
121+10/019 input=4a773fa88d0e vt=e56acbaa3c3c graphics=f8979b697ae6 history=9a1d180260d9 ansi=b2b0a3acf112 ansi256=e99773dd4ed9 html=dab6d4faf486
122+10/039 input=5a19d9d4c2d6 vt=7ffdb9e95420 graphics=896174966c8e history=091b178e1831 ansi=7d1e36e89a84 ansi256=e415fef4821c html=2c5dc4901961
123+10/059 input=3d61fb352e87 vt=156ac33a85b0 graphics=60644c95d91b history=06f1f1fab2f4 ansi=a2d1a144f34a ansi256=6e2066d8c58b html=0ca46fcef12a
124+10/079 input=d5182a61be8c vt=cd5fdb02e924 graphics=309679508d19 history=5fa6d8aeda89 ansi=2a0e3196bfd6 ansi256=9e1147ef50bd html=7fa02aea9ca0
125+10/099 input=66f393c8e024 vt=2e7994bde84b graphics=dbc14a5ae241 history=04998276902f ansi=aee487949558 ansi256=c87b7581c162 html=36678145fb1d
126+10/119 input=b49c6e9c2784 vt=c46e5f7f618b graphics=33cd5cd651da history=5c9911da4a8f ansi=6084136b1e33 ansi256=a33ff57d23c0 html=d616be275a4f
127+10/139 input=b1b06a0b58cd vt=b88fda26708b graphics=b9cd90b1ed9d history=41f382e35a07 ansi=01c25d2d4537 ansi256=9d617a7f0a07 html=f72a72992da9
128+10/159 input=fbaf8971741d vt=19ec261f5b77 graphics=bcf25cdb2e51 history=c5cfe985bd1b ansi=4ae61c21451f ansi256=9c3dc8f71556 html=013bae0e404c
129+10/179 input=b02f77b5cd73 vt=5375dd4ed7f6 graphics=f591bfded96c history=c4fcf5031cbb ansi=500ee209d80a ansi256=ce2b80daad73 html=c63146008cf9
130+10/199 input=cc1128412f8b vt=7e4d2086c5bb graphics=b4956c877d8c history=a2b67591b711 ansi=00bd358eb62d ansi256=f141b4f09b2d html=add16175d243
131+10/219 input=2347ed53f13f vt=0af90759155f graphics=b19cc5de1ed4 history=88d94c1e29e9 ansi=ad17609946ad ansi256=737472c30aeb html=0ed6e3d3080b
132+10/239 input=8a8aa55dce6b vt=7a75f8f03acb graphics=b5618c41bc19 history=5d74197f29fc ansi=e33bf0b866fd ansi256=43b6ad691359 html=c87faf04884f
133+11/019 input=9265dacd75c2 vt=375101271ee1 graphics=f8979b697ae6 history=711a38708632 ansi=84ce9b2e5448 ansi256=f7d6444a0ec2 html=5ba1387502c4
134+11/039 input=fa84608e0849 vt=2a74efb6ab39 graphics=5dbcfed44856 history=e862c7fa8409 ansi=0497214221c4 ansi256=329e82145d64 html=ba773178391d
135+11/059 input=55f99f6d98ed vt=2a5bd217b354 graphics=7cc33ba32eea history=f6afc9616658 ansi=23f55173e472 ansi256=e8d2f6e6ed2b html=11eb8167734a
136+11/079 input=a9b3a0df8858 vt=7d9930235196 graphics=6919c4d26b60 history=c5fe90406ec8 ansi=829d8d678f5a ansi256=000d15ca2660 html=7773d0ba6a3b
137+11/099 input=ae76386fd7d1 vt=9cd4bda7b6f5 graphics=d455141d92f7 history=ead2e3f30d49 ansi=82a2bb0a9bdb ansi256=91509db5578f html=50bb739ad1aa
138+11/119 input=9af26788e7bc vt=a3c03f3376e8 graphics=15a3c905b21d history=481784c95062 ansi=bb43cb4d0950 ansi256=f2f5c846ddf5 html=f844f09a17a0
139+11/139 input=f9baf0d2fb38 vt=ab6a31624261 graphics=a221841874c6 history=4248d3856c10 ansi=46d0c42a80c6 ansi256=0e32105ecd9e html=1e56d16fd049
140+11/159 input=95a95c43643e vt=d046ac47c2f6 graphics=d4f6f0ba7eb7 history=1fc26a2b22ca ansi=56ab2524964e ansi256=ad04371dbbc8 html=f297a61f9281
141+11/179 input=dcc8364da3f4 vt=2129ba5cc2d2 graphics=f865953d3b1d history=598bb57229f6 ansi=adca037af52b ansi256=47bfdd0b6b2b html=2fd114cff392
142+11/199 input=203eef9c539c vt=d90f5a5f35fa graphics=cb2ad40dabec history=a23d1435d01d ansi=1f57d064d798 ansi256=7175de8c5a24 html=2d976ddea703
143+11/219 input=32e59dd96f51 vt=66a86a72207f graphics=d19ae5484f1b history=a0c79bc4d577 ansi=124273bc600c ansi256=0eaeecc77e90 html=f46e1ea0615a
144+11/239 input=9fa7f4fdcde3 vt=7a764cea4e94 graphics=1ac611e08f86 history=793108a49d50 ansi=dc3e80fe3499 ansi256=4bfe0f0735c4 html=977f78afa9a0
145+12/019 input=0cd53f8dc866 vt=94283af4d7bd graphics=aa1966378332 history=e3b0c44298fc ansi=9635b13597ab ansi256=9020c0176325 html=d136fa944cf0
146+12/039 input=a4adb8bd74ff vt=1e4fa3c28868 graphics=446b136e5620 history=e3b0c44298fc ansi=846ae78c61c0 ansi256=61463b656f85 html=692b71da21c2
147+12/059 input=8e1743411c94 vt=f2e40d527f1f graphics=383509aa2748 history=e3b0c44298fc ansi=e43feca44dd4 ansi256=5592050ecb33 html=1b5244afb7c1
148+12/079 input=24b7b3927733 vt=ca5d80c350c7 graphics=adb60fbc77df history=e3b0c44298fc ansi=54cf7d6a19d2 ansi256=e8bc1048d1cb html=f0606b21a47a
149+12/099 input=084e6637aff7 vt=5364ef66f04e graphics=8aee9666cf7e history=e3b0c44298fc ansi=3c9190df845e ansi256=df24c0122682 html=e7a58215367f
150+12/119 input=dfad7236783e vt=b47b5f66a92f graphics=fc1ca2755ec5 history=e3b0c44298fc ansi=28f7789859cb ansi256=bfe43afcf74c html=a0cba6410e13
151+12/139 input=868d9b85b00d vt=b35476bcaf77 graphics=71e72390b15a history=e3b0c44298fc ansi=7a113a99cba4 ansi256=154812340ca6 html=32eaeb7d5a8b
152+12/159 input=079a8d8cf303 vt=d5c21fc98ebd graphics=8e59b761a886 history=e3b0c44298fc ansi=633f2c29a069 ansi256=0a5d8a4bf2cc html=0e35e049c148
153+12/179 input=b04504a5e95e vt=dc59f7f4f179 graphics=0c74926a3adc history=e3b0c44298fc ansi=6616cc37f757 ansi256=8bc4de6a188b html=459941d5527a
154+12/199 input=087381b37012 vt=2dfb9fe9cb33 graphics=fb30a203819e history=e3b0c44298fc ansi=0ab48534e4f0 ansi256=376f1c085a06 html=6ac39cca84f3
155+12/219 input=1c3f8730674c vt=af87d933c49b graphics=28a7ba5996a9 history=e3b0c44298fc ansi=2c88572f8243 ansi256=50eb8105b067 html=7ca55741ac11
156+12/239 input=01426d60b04b vt=b39a82a17c4c graphics=3a167d043f4a history=e3b0c44298fc ansi=e63cf3dfdbc2 ansi256=e5a6efa00e87 html=0eac32bba211
157+13/019 input=7520f8daaf0c vt=acafc9adc02c graphics=f6ba63ec3717 history=46adc8d8dbc3 ansi=a96cee0c7c56 ansi256=1ff37de7ca5a html=e1360d945474
158+13/039 input=0b6c937d49bb vt=f0da4cf5bffe graphics=80775244c49f history=a68772b5b2a4 ansi=a605a2ddaaa4 ansi256=6c548f01ade8 html=d9c415f50a58
159+13/059 input=73c036bb83ab vt=cccfcabc9883 graphics=e654e9345138 history=fe83f6679c59 ansi=dec1eee5f6b7 ansi256=46df57dbc15f html=9dda3e704c8a
160+13/079 input=1740e9668bd9 vt=2af93a316eea graphics=3c8abf2d09db history=59798eb0f0c0 ansi=c3f28ae5b5c4 ansi256=e17cc886a56c html=162f9067beb9
161+13/099 input=8bee21358f91 vt=e5a61a289e0a graphics=3bf53336708e history=92cf7dbf6937 ansi=3162b5f25c5f ansi256=f762ea238db0 html=7625c399ab38
162+13/119 input=4a7587dd0528 vt=a42eceae253a graphics=f3248511fb51 history=43d954a57008 ansi=22700a2fb762 ansi256=ab2e8af6974c html=7084daee211c
163+13/139 input=a013d2d99b71 vt=c56c6570ce4d graphics=56cf16bd1b92 history=2ed23713a723 ansi=09aebb4d1635 ansi256=1c5bb54127d9 html=58ab301d0410
164+13/159 input=671dfaa83917 vt=d53289c06948 graphics=d145457d4b59 history=80fa71b1a3aa ansi=d40af80aa84d ansi256=b5417d3c6341 html=8e2033458000
165+13/179 input=4ac1c3073a32 vt=8dc458197d00 graphics=e8ea9c343f0d history=7d8a9ea0b656 ansi=64beed089e47 ansi256=1808932f9729 html=ccade24e7a2e
166+13/199 input=8db1eaec3f54 vt=5c71eaa36a21 graphics=c27ebde9c961 history=b19a73a76ecf ansi=b8592ddd4d3b ansi256=00729536519a html=758afa4b816b
167+13/219 input=9fb709c6f4f5 vt=5887f955f492 graphics=f81762265c3c history=ebf71b617ff3 ansi=580afcb45172 ansi256=ae3d6515c35b html=a18596840497
168+13/239 input=607893ccc66e vt=9b45c6f4ad16 graphics=57aad1c476be history=7525380830a1 ansi=34ebc3bbd92c ansi256=0403d973c622 html=c191a3b3290a
169+14/019 input=a7f334c12c30 vt=9bcdf21a28d1 graphics=81c725928397 history=2385a3e8dab8 ansi=c0c9f7a64341 ansi256=b31486c31b86 html=624866ab22c0
170+14/039 input=cb163ae3c433 vt=5753f4daeff1 graphics=d7c6ebd7cfc0 history=587cae73034f ansi=2d4925a35af4 ansi256=f66845c5742b html=4004db29e75b
171+14/059 input=f56d76937fd0 vt=57b2f629970a graphics=8a7d860c2c19 history=2fbcda1cb486 ansi=c6cb6b834b4e ansi256=845612b63a58 html=6800dec3587c
172+14/079 input=f33604d23f28 vt=c974e326a90b graphics=6f6c28b5e14f history=9c6e93bf5ae0 ansi=a264f3287c53 ansi256=aa0e7995674c html=1a0879e6b6ce
173+14/099 input=66e2c12b7a2c vt=21ae28e601f7 graphics=c841084c011b history=77fcecaf9198 ansi=f88e07c8b8e7 ansi256=f61593ae5173 html=7f469c764132
174+14/119 input=2423dd867693 vt=260dca79f790 graphics=13c1d848ef7c history=0578cc790f11 ansi=d6cd6e0d3a39 ansi256=a7030caa2248 html=ebf032ec15b9
175+14/139 input=6bba00dd2011 vt=a272f823ab24 graphics=ff9f2a7413a2 history=e88c4115b82b ansi=219f25d864eb ansi256=a895895e4810 html=8c033c8aeff5
176+14/159 input=bd6974df946a vt=04ddc0b7cc5a graphics=d0cc3cf7f292 history=115133c655d6 ansi=f5a8ffdb271b ansi256=18c35ff238cd html=26ab94daecff
177+14/179 input=37e67a5d9bbb vt=0fde96b6035a graphics=d7a2abfae087 history=e67566a79d14 ansi=527202ca46b4 ansi256=8a82a2201ac1 html=71ae7c285edf
178+14/199 input=e37a2b467fbe vt=b24e698bc457 graphics=b8031e6234b1 history=6c6cd77efa68 ansi=bb95fe893d2c ansi256=b167c3b0e86e html=3502a075f2b6
179+14/219 input=1029130bfbb3 vt=e88b9860fbf3 graphics=334fcad630b4 history=601f1eaad212 ansi=34f9eee0f681 ansi256=a7a0e8e22cf3 html=bbdbcb570ce0
180+14/239 input=4f4b33115d02 vt=665ba03c5b8d graphics=bcdf3ceb7d32 history=64972943269c ansi=abf6d0a0365e ansi256=568e79b90695 html=3e9f9706fe44
181+15/019 input=adebbbf2b67e vt=592cebb0eae3 graphics=9258c91bf1d0 history=9f7f72048c7e ansi=a847879b9e08 ansi256=7289d4ca24b0 html=6bba05924f28
182+15/039 input=2f24c044f13b vt=9b88bb5525f8 graphics=7273f3c7ea47 history=10b4c3487308 ansi=d813c264ae04 ansi256=513731bb2435 html=bdb9c226b5b4
183+15/059 input=3f7f0171fcbc vt=f9c33f6d70ca graphics=65b90ea66aa7 history=61984d2b07f3 ansi=11c2dc4fbbdd ansi256=a295c8d95c3d html=27917bd88c28
184+15/079 input=73c002f7f158 vt=24e15581eb38 graphics=3cb55010354a history=d1eb9f880a9f ansi=26e7212c03ac ansi256=1c68f8acd6ac html=92e07e4d2696
185+15/099 input=d0d48ac2b495 vt=9a76e08324cd graphics=ed4caac777f5 history=0c0c27929ec9 ansi=82c96808d0b9 ansi256=f675fc6e8262 html=26e8fa371929
186+15/119 input=3fa81ee4d3ba vt=a81be1aa206e graphics=ff117fead386 history=20d00a7e251f ansi=3aba1781bad7 ansi256=07e4c09ffdb7 html=71ddb87b03c6
187+15/139 input=68408fbbf5ee vt=a32e7bbf0c27 graphics=0054f2fa69dc history=580f23cdf9fd ansi=ac525261d78b ansi256=b10a40001689 html=77e1c19a630a
188+15/159 input=ba1363213435 vt=3619f3845be9 graphics=78ed8fc1091e history=b7aa7e1d1cad ansi=28499c397587 ansi256=89ad14bc135b html=dd9ade2377b8
189+15/179 input=33e641fc5d55 vt=f64b9094c4dd graphics=ef3c8c1e57a0 history=186362c6d3a3 ansi=4b9d9655854c ansi256=a1c9511e4cd2 html=a945bdf7171b
190+15/199 input=ef88153e80d9 vt=0801d3286cdd graphics=2d4906246230 history=fd3b0095a05e ansi=00e0730b7f8f ansi256=4324a4e945bd html=9859c329ccbc
191+15/219 input=fddb2208db96 vt=1369dc45a440 graphics=b012b26b3b66 history=2febb7f5b494 ansi=1413b3804af6 ansi256=c0d3e8b8786e html=0f4ba90c6e14
192+15/239 input=6a6b1380bfa1 vt=0ae81863dd42 graphics=701820e5764c history=4f15a73b2b68 ansi=303f8cd3cc58 ansi256=520deee2c650 html=7c49ec45117a
193+16/019 input=86c2dda4db03 vt=6d7415e4ab74 graphics=f8979b697ae6 history=e3b0c44298fc ansi=76a6fbc5b6c3 ansi256=ff3164ac5487 html=b66c2da27d67
194+16/039 input=55ed57bbec1d vt=c7de0211b8a3 graphics=982379a6cc3d history=e3b0c44298fc ansi=0a5c39595e6e ansi256=d0cb3aa4a078 html=07660540bc45
195+16/059 input=75c0a5c47c7c vt=41df5e7c69c2 graphics=842a2ffbda7d history=e3b0c44298fc ansi=7e4f99dcb027 ansi256=c9183d6491e2 html=92f6593ee1ed
196+16/079 input=e47d941b4795 vt=29818752a1ca graphics=83bad9ddc481 history=e3b0c44298fc ansi=1c402472c1c7 ansi256=0b8c9fbd2c3c html=6cf54aa99777
197+16/099 input=1d5b3f836ac9 vt=fa20f6be77fc graphics=a76562326d86 history=e3b0c44298fc ansi=56aac95f1834 ansi256=fe63446dcca4 html=a62772bcb17f
198+16/119 input=3732e6080846 vt=79f64e3b5554 graphics=14f68a7bb6bb history=e3b0c44298fc ansi=cb0bb8b4fadc ansi256=afa479443df7 html=a78d901ffc40
199+16/139 input=1c79946f3ea3 vt=c45bb228fa62 graphics=2403dce32ba9 history=e3b0c44298fc ansi=1ca0a48540d9 ansi256=1096ad83e268 html=60bf3d5135a8
200+16/159 input=b2687f8c50d3 vt=1501b13122dc graphics=3c87413b9ae6 history=e3b0c44298fc ansi=34725ee3e966 ansi256=f85d124e4f37 html=eae681dd600a
201+16/179 input=e6f371d2288b vt=f22a83e34a23 graphics=1d16a3c1ae70 history=e3b0c44298fc ansi=1e5b0b8d3682 ansi256=a80b1cb97d9e html=5ac3e753f8e6
202+16/199 input=ef6ef7381c1f vt=e6c36ee8cb16 graphics=629add49f562 history=e3b0c44298fc ansi=d8e1c77f2871 ansi256=5118043a76e9 html=5ec84c5dab81
203+16/219 input=a70b8d880419 vt=f6f6b6285356 graphics=379bd66ce1f5 history=e3b0c44298fc ansi=c9a98c855aba ansi256=548576b0b5cf html=461fe37dacec
204+16/239 input=e318eee4b6f3 vt=39841be2f7a7 graphics=d1fb1b1764e1 history=e3b0c44298fc ansi=d33af6657153 ansi256=a292cd0f0bc3 html=9379c8b70e6d
205+17/019 input=58c9a4153140 vt=324b438d3711 graphics=f8979b697ae6 history=711a38708632 ansi=5c9302e7cb6b ansi256=85a7583c5c6c html=c950759970ac
206+17/039 input=db579e65ea7d vt=6fbfe2c1e929 graphics=5dbcfed44856 history=90f1132b4c8b ansi=d33d277dddb9 ansi256=877081d93305 html=c3d389874978
207+17/059 input=f1499c3938fb vt=eed0360845e2 graphics=e7bc26dbbc26 history=c11561ff594b ansi=fe2e1e877cac ansi256=d77c223b95c9 html=6df62da401a7
208+17/079 input=9ec0bad9b8da vt=854ea5835b4c graphics=4e43c7db5d2b history=86f74a48ec8e ansi=233882d7d87d ansi256=b333d4c639b3 html=91abb5967424
209+17/099 input=b1a45d5c094d vt=fb72368bcf60 graphics=7b45a01a7c43 history=864baf9e2a68 ansi=c5171a082201 ansi256=19ce8af4292d html=cf95c189fd1e
210+17/119 input=630c816a56f6 vt=7f3660b85dac graphics=5b2757212ce6 history=b1f20dbcbf8a ansi=137fbfd28076 ansi256=13c827cb60a9 html=0fab40417e2e
211+17/139 input=22d78f18ce2e vt=ab7b75a6d8f9 graphics=b437e11b41ee history=ad9bf3567e67 ansi=4b9c15d40677 ansi256=cc3881dfbffd html=0283cbf8bcfd
212+17/159 input=4ef271e495f0 vt=d00fe585dc54 graphics=6fc51315d33b history=d0783031468e ansi=e2224565fe65 ansi256=45f3942d9b49 html=8cb10ed21aee
213+17/179 input=e9b418a8b2ef vt=24199de84f03 graphics=a0cfd961aa4e history=4557d790c7b4 ansi=fba711ad72c5 ansi256=a9694cf6a10c html=11340a9ac372
214+17/199 input=61ca70a2bf80 vt=b1839e18ae6f graphics=92f8577c9b42 history=b81d21bcc4d5 ansi=6c5c1bb9cad5 ansi256=406444ebbca7 html=315fe4f62ac9
215+17/219 input=ff5c94c9b013 vt=5d22d9894727 graphics=7bc5dede1a89 history=4b4ab2d37875 ansi=dc67081dc05a ansi256=95c664bbd051 html=28781309a907
216+17/239 input=8cb41999a222 vt=dc320c39f15c graphics=7de85cd30f7a history=f0da9faa34f7 ansi=b53f7cb0427d ansi256=23ef37e00200 html=a6bfa83de069
217+18/019 input=1a8fb352acf4 vt=8c78c5a6e699 graphics=f8979b697ae6 history=cd2c313ff542 ansi=2a1160b2c9ef ansi256=8c5b08c915f6 html=9c37e62d4105
218+18/039 input=120fd08d580e vt=9d25a6b19337 graphics=896174966c8e history=875839281971 ansi=e78d4972dd5f ansi256=93a1db68bce5 html=7f8eb4ffe4a4
219+18/059 input=82098b093bfc vt=6c75370c7db9 graphics=207a60bb91d0 history=24174fce0b1f ansi=66d2d608a1f1 ansi256=158b0e0a39ec html=a0218d72224b
220+18/079 input=cbeb78d372be vt=d27386b7d77b graphics=7de8d4796cb6 history=59795806ccc0 ansi=03e7a153c2ee ansi256=0b42ab8e910c html=4f2181e01a05
221+18/099 input=fe6eb5467cf1 vt=0784720cea47 graphics=e25a6167d75f history=1d9cfd1abb4e ansi=75bbd8201a08 ansi256=a34e0ca1feb4 html=0f5d7215a577
222+18/119 input=95f0f611f332 vt=d017ed80bcab graphics=2049bdbbab3e history=a63d9aeb1f01 ansi=94ba30222542 ansi256=aa123dff5919 html=aa255c42831a
223+18/139 input=3c2f99ffc023 vt=75e8e55898a9 graphics=f0595f3aa950 history=9d638c447b01 ansi=fa31decdd13e ansi256=6b7d5c6d8d68 html=bd676e45f460
224+18/159 input=9ad591a6fb7d vt=4269793feb5b graphics=310714a28461 history=4758ee28b6bd ansi=91e9125972f8 ansi256=797e0afe0aee html=cf78edfa9ff0
225+18/179 input=4cb34afc6104 vt=0485fb01b5dc graphics=145af612aaae history=e3bf8800fc5b ansi=ac0b90d29af0 ansi256=6f5f308aa309 html=2b3538b75c23
226+18/199 input=30c450be5b97 vt=13406cc1f87e graphics=de90792e0729 history=241a96bae4c7 ansi=fb45d99a1b40 ansi256=a895d173ea7c html=93f029ca163e
227+18/219 input=98e3ba6d672b vt=5786a38e5eca graphics=d73ed6f6b01f history=679dde0ae545 ansi=45503c8e4ceb ansi256=5e823b3c23fe html=fb1636afedc9
228+18/239 input=4cdc68b810b1 vt=11f8526bd447 graphics=c934b313196f history=5f8e6cd1474f ansi=a4e37289b509 ansi256=d17b781bf646 html=be4e56348b14
229+19/019 input=f025072611f9 vt=98568b096752 graphics=ceeffa9ccef0 history=223962168d6a ansi=982272b5b3fe ansi256=e8fa54fc8107 html=82aa3f112ae9
230+19/039 input=a0fb77925963 vt=bc9062ea2ed5 graphics=6bbb371766da history=b4f46e999dc1 ansi=5ea1a0292697 ansi256=2945a8344189 html=8c2f2fe08c83
231+19/059 input=3143a584229f vt=5cfca7c8b962 graphics=a0f8cb27ebae history=bb35f1365c5c ansi=7fe21ad4ea22 ansi256=8c0a1fe0ff92 html=aa92d3516ed7
232+19/079 input=4d3dff9e2a08 vt=98bf07c97ece graphics=00fce6dcd131 history=a677db84e2e6 ansi=14ec6eb04e0c ansi256=3ae2236e1d07 html=2c9fca31684d
233+19/099 input=a19d75d8d10d vt=650ec28e93e2 graphics=59ddaa7408c9 history=9d586b3ecfa3 ansi=87ba403a3b61 ansi256=14bb2fab1628 html=aa36dba56b64
234+19/119 input=dfd4382938c3 vt=800f59356c9d graphics=4e3403b66904 history=4a12e0d293d7 ansi=c06123578fca ansi256=14e2b9939b37 html=4102271a70be
235+19/139 input=d9daa2a5883f vt=4da1fe870181 graphics=b6b22e3b11c4 history=fc9604300c0f ansi=b45b6aaefb3c ansi256=3763b0de69f2 html=874edfbd39bb
236+19/159 input=45fee457c34d vt=c150ddb3ac4e graphics=49f24bd1c839 history=996c5b447767 ansi=dbab6aaa9d86 ansi256=c3a8bee718ab html=fc4c5ad5f36f
237+19/179 input=3f763216e7cd vt=ea4eb51b5f00 graphics=fe3c10d6141b history=d4d85159b904 ansi=01a5e9d16446 ansi256=dcae4df49cdd html=df611651a813
238+19/199 input=16fa624597c7 vt=c9df47e68a7c graphics=9dd86c09229e history=5142585fde71 ansi=f98e9a365f26 ansi256=89d119043450 html=9f580b5529b2
239+19/219 input=5db9cbb2203d vt=6af15b8cec32 graphics=bc944b4862e6 history=9553e7f99699 ansi=2554dbc19ef5 ansi256=f147b4cad322 html=737837acb850
240+19/239 input=ff65425ea000 vt=14e5cf5f064a graphics=786652c8dcaa history=93b9446a5724 ansi=3e8b96b5b855 ansi256=c35e2ebba309 html=2d7fd3dd6288
241+20/019 input=16ab1b41f873 vt=1499bc214fc5 graphics=9258c91bf1d0 history=e3b0c44298fc ansi=6e012cb0dccf ansi256=da37e6c9d339 html=ea9bb8d056ce
242+20/039 input=7fdb1cd71af0 vt=b0db232b4e0c graphics=ec94ae6cc7b9 history=e3b0c44298fc ansi=3119d279d44b ansi256=53b0a1780247 html=35a9fcd0f066
243+20/059 input=97bbd10bdc02 vt=4072b59cc2ed graphics=7bdd0ed29a7a history=e3b0c44298fc ansi=fe617dc64f1d ansi256=a5be42706dca html=8b7467f003f7
244+20/079 input=4643c53e7d5f vt=258eb8d27202 graphics=484b3575b9e4 history=e3b0c44298fc ansi=b58678e2d51e ansi256=f1a4cbf04a19 html=b874f6977d1f
245+20/099 input=5c6ba398d86c vt=b3e2901f9ce0 graphics=3a68ef622ce1 history=e3b0c44298fc ansi=24622d01be50 ansi256=453ef09e3e72 html=c70bc509e9ba
246+20/119 input=bf82e477a33c vt=127461ab7faa graphics=dc64f3a0bcf8 history=e3b0c44298fc ansi=811ab8b1df46 ansi256=1fc38e82a4a2 html=755e992f586e
247+20/139 input=4713957fa737 vt=25e0887161c7 graphics=3bcd0d1750d2 history=e3b0c44298fc ansi=0e872d556857 ansi256=2f37f4d66894 html=1bdba5af890c
248+20/159 input=4ae9c4eeb18a vt=709bfec3b6fc graphics=793c21562ca1 history=e3b0c44298fc ansi=ecb61bffd21e ansi256=ed97a1f1aab9 html=e024f49f976e
249+20/179 input=c7321f26aa4b vt=b2f88ab421ac graphics=6d505782c178 history=e3b0c44298fc ansi=e514cec2d55f ansi256=2f05ce045a65 html=760e0670abf5
250+20/199 input=1e64f0e712cc vt=459e975efafb graphics=0f85c60d8bcb history=e3b0c44298fc ansi=56387c036e8e ansi256=bfeda84d3504 html=de91aba53c9d
251+20/219 input=c61722782b3a vt=9190bfc820a1 graphics=f067ec496b5f history=e3b0c44298fc ansi=45bf0573bb99 ansi256=6cbb9c7975c0 html=acb8d08d88cb
252+20/239 input=c65e79b596b7 vt=d3c8edcf5fd1 graphics=a3a95cd20f28 history=e3b0c44298fc ansi=59e10a802b95 ansi256=dab2ffea8d09 html=9107c60cfbfb
253+21/019 input=6d8b599454bb vt=e50e4850cdce graphics=9258c91bf1d0 history=6ebc4b61f2b4 ansi=d14fad767c53 ansi256=9e4839c11686 html=91bf1b2aee35
254+21/039 input=2f3792505a5c vt=3f8418dcf338 graphics=edd491a142d6 history=f505b2de2e1a ansi=9f4b37c1b289 ansi256=b0c92a0cf22d html=8401e25edd69
255+21/059 input=50ba70575923 vt=596598247f57 graphics=174ea50ec6ff history=2cde5b0f65b4 ansi=11d6f5602c6b ansi256=3fa666485317 html=fe9c9ac770af
256+21/079 input=1a42a368e14e vt=3be5c7a9966f graphics=c426645bbdd2 history=dbe1817e0470 ansi=ef4d959165ee ansi256=07ba166dcf3a html=4074c037d648
257+21/099 input=63df54056256 vt=b295b0f8bf2d graphics=36e802fe6bff history=22e67a9b0953 ansi=1a30d1904101 ansi256=d0d28a777232 html=8916164d3fa1
258+21/119 input=79df89b8fff0 vt=0db1c329e0d3 graphics=c8b780d37f64 history=ab7add1cd086 ansi=2cf9f805abf6 ansi256=3535c3153101 html=775e644a78c8
259+21/139 input=ea612f912526 vt=c752acdac1a8 graphics=0227de479c40 history=b10111c570bf ansi=72518d3e245e ansi256=029947f1bff3 html=23e078796f04
260+21/159 input=aeec6f11002c vt=ac7d2948b0b1 graphics=467a390aa711 history=24ec315d181b ansi=fa7beae868b5 ansi256=fb0946d83c29 html=46996b58aa5c
261+21/179 input=24c60e60e3b1 vt=947c888b5fce graphics=efb40f7c49d2 history=798d584342c8 ansi=df547f10afec ansi256=5bbbdd3ca038 html=8f2c5e0f666a
262+21/199 input=547dccaa57f2 vt=d641d1cb2344 graphics=ff664e7569f7 history=2fa0eac3eb0a ansi=c153660c3195 ansi256=de9744c0df8b html=fef9544e4c00
263+21/219 input=580f2ee8bf6b vt=c24cc36b68cb graphics=b37f93b7361c history=e6d0f07fe46d ansi=1425654efbe1 ansi256=9f0b0970721b html=82a83298788a
264+21/239 input=f1652e1ff7e7 vt=0d7501b75864 graphics=47ada5c8972d history=de469d69d99b ansi=bacf8fe6257b ansi256=d568d80203ef html=f98e13b8faa1
265+22/019 input=01ba17ac85c4 vt=496cb010d953 graphics=f22af68f3b72 history=b7f0f7e99584 ansi=6eb40193751c ansi256=1c451df21b16 html=e477bdf6697f
266+22/039 input=42f3c147fdba vt=a3c945d1206f graphics=ccb9f1f8aec3 history=c7e0e31fcf0e ansi=d90cd8db8990 ansi256=2f717b9573f0 html=4c661bc8771d
267+22/059 input=b5272cf6b2c0 vt=d0e9e33e4c1d graphics=e6e24428355e history=aaeb10ce7c44 ansi=c85a689382eb ansi256=bb35fa6ad750 html=80709231da2d
268+22/079 input=df11fc0ea107 vt=0ef28c94e4c7 graphics=8a631615821d history=37f5de85229a ansi=ef6cd391b992 ansi256=b5de86d12a9f html=c8049fe56360
269+22/099 input=c4176d57bf31 vt=1c723153a857 graphics=481ee10342ab history=a250b6417499 ansi=aa77d5716663 ansi256=d57198ace643 html=957d4fd04142
270+22/119 input=1081c942b525 vt=b1b795f197d6 graphics=02b28bf3aa32 history=1bdee1ef0937 ansi=b6ea7c081f1c ansi256=ede92df0ea04 html=3087fdc2075b
271+22/139 input=cd85644bd3fa vt=be34e6a836e9 graphics=432fedf48712 history=6f54299845b5 ansi=51f3c1f48e96 ansi256=53a36e5a7b0c html=f49480808ebe
272+22/159 input=94e080045d0a vt=a63997ef6095 graphics=bae0c9f75b53 history=f4e3d587bc1d ansi=f6581e7652a1 ansi256=356dc4385c7b html=281b9e1ca5a5
273+22/179 input=67502dd99a94 vt=0d12c6645323 graphics=e1fa932c581d history=48618f2eb924 ansi=e62c948f7b61 ansi256=2081f7d8fd89 html=d4b53a405025
274+22/199 input=1a875e7489aa vt=30f0de781b81 graphics=79bbcc4a697b history=d74cb6820539 ansi=aafe502a3368 ansi256=803867175a8a html=269da56f7e67
275+22/219 input=97cd8f5e7b40 vt=329131c2c98c graphics=5d057491e906 history=761f4a4c1a0a ansi=8ce94a66a529 ansi256=30c70089306d html=6d1f222aee83
276+22/239 input=74ac88bf42f0 vt=6302e4338e3e graphics=8248f4385dc8 history=057a1e67dc5c ansi=26f0bf26e9d6 ansi256=b8b0feffbd10 html=3f0d12817f79
277+23/019 input=1ac834dd66ad vt=0703283520c4 graphics=9258c91bf1d0 history=d5e92dfbe7ad ansi=59ff2eeabc7d ansi256=4d98e3b459c4 html=758784684781
278+23/039 input=52c7c552e18a vt=c7c6f6a3b87e graphics=762d16b098e3 history=4b446bb67c28 ansi=4f977bf9ba80 ansi256=62ce599a19a8 html=d8c8912b0087
279+23/059 input=c728326cd041 vt=53fdafdd6168 graphics=15ef31cb4efb history=dfd246f6a230 ansi=259d4cb646a3 ansi256=afd261fca4fe html=50112a65038b
280+23/079 input=72846e647366 vt=64fac99bac94 graphics=212e4c08e2cd history=bea125990222 ansi=1080f1358b3f ansi256=cd3bfdd4c44b html=76d220c1e425
281+23/099 input=6b584e536c32 vt=76fa20f1b2f7 graphics=279ae557dad6 history=1e92e99bf77b ansi=b3e9a6850cc5 ansi256=d1a86aac3718 html=09795c8d450e
282+23/119 input=d1256672f808 vt=5fe9b2f6c14c graphics=52845616bc40 history=318e8e7794f5 ansi=4ea8c0bfe1e2 ansi256=0fb5fd863f3e html=2925de362c58
283+23/139 input=55d0a8e00404 vt=3f27f2fbead5 graphics=991903976c4a history=8423992a5268 ansi=e9e74ee182ab ansi256=ab9911e6e52a html=d32fbdb6015a
284+23/159 input=3a92a89887d8 vt=405b4ec5829d graphics=a94c75564483 history=fcb99eb85aa8 ansi=a64c8cfbcf82 ansi256=10cbc31927b9 html=cd34aa925b91
285+23/179 input=0d5a633cd93a vt=219797ad4bde graphics=cdbd6f5d52b2 history=002c612acebb ansi=c91018b65a7d ansi256=cdd0b78340e3 html=5cd0cb469ed2
286+23/199 input=c9c456495830 vt=fc7556378cef graphics=3bda366a4653 history=6a4964108626 ansi=6c0ae68c7db9 ansi256=a451eedd0781 html=c712acae1c6c
287+23/219 input=8c41bfb460a3 vt=144903b07554 graphics=bb4b123e09d3 history=9b0ea55ebf1e ansi=2dc10f02a8d5 ansi256=17ab91693041 html=8fe62b538dfd
288+23/239 input=ccd5bae9d0d4 vt=3ee3e4bcf2c9 graphics=fdb9c488c44f history=433238a91160 ansi=b8a97608d2ef ansi256=52ef6c545a86 html=a1432b7e3ece
289+24/019 input=148102104127 vt=e22fcc59d5b8 graphics=f8979b697ae6 history=e3b0c44298fc ansi=40b1b6544cc6 ansi256=636b355a35cf html=a40b0f7ea9df
290+24/039 input=018bead9b7ae vt=205c10c84d59 graphics=3342f04c7491 history=e3b0c44298fc ansi=ef8059fcac20 ansi256=1f000c7cc8c5 html=e4289f83c3b1
291+24/059 input=319607f49e0a vt=9cfdb3b9dd63 graphics=2b7e7b47aa81 history=e3b0c44298fc ansi=349f9af1db55 ansi256=8ea789af1a5c html=f5a2a5a8209b
292+24/079 input=e6926c76740e vt=517f29d0eba1 graphics=edc4d4e288c8 history=e3b0c44298fc ansi=5210df2c3aeb ansi256=42d8552bc074 html=352b846be3bd
293+24/099 input=c18757c3b799 vt=b3a83e738a66 graphics=c60ca86be87a history=e3b0c44298fc ansi=66b4681f9721 ansi256=a2b0bced4166 html=4401ba0947cb
294+24/119 input=9861958c685f vt=b6e7b01496c9 graphics=c709a324064f history=e3b0c44298fc ansi=e3ce39e0ae12 ansi256=8674e90c5215 html=e72953ead1c8
295+24/139 input=05cdfc1f5efe vt=038e0c505a83 graphics=193805159acf history=e3b0c44298fc ansi=3bb5071299e4 ansi256=822567388615 html=7d7558b1555a
296+24/159 input=8e607996da30 vt=106b19e6ec5a graphics=8d6375572fa1 history=e3b0c44298fc ansi=d6f1c798ec9b ansi256=bba4bb627baf html=7f920f0c0e6f
297+24/179 input=dfe92c6f2bad vt=a9348116c0bd graphics=cc914d760012 history=e3b0c44298fc ansi=1789b413341e ansi256=40f767a2dc82 html=7b4b65133093
298+24/199 input=4c2553c2517c vt=02e131e2d775 graphics=7bb2aa5d8d6e history=e3b0c44298fc ansi=48beec3c3118 ansi256=d6123686c1d3 html=116dad2d7221
299+24/219 input=11342eff7098 vt=8a82550741ad graphics=6d72ab39e211 history=e3b0c44298fc ansi=5c8067ce0751 ansi256=c61a4dc165c3 html=844644f97c7f
300+24/239 input=7701b418b2c4 vt=29f384a3aac7 graphics=483f93084782 history=e3b0c44298fc ansi=76141e442411 ansi256=60b08d80a715 html=a97fb61c1e3a
301+25/019 input=52d18b6088cd vt=fca038dd5083 graphics=9258c91bf1d0 history=028563431d2c ansi=de864fbef6ad ansi256=da875ca937d7 html=35ce39cdfca6
302+25/039 input=9371bf89fcd5 vt=8254bb5172ef graphics=ec94ae6cc7b9 history=c21a1e2aff02 ansi=e6312cb66040 ansi256=2de9bb4807d5 html=a4b5d67b421e
303+25/059 input=648f90254a8c vt=7bf7a9fe2551 graphics=f3e88db7524a history=2a7d136dc209 ansi=11532c02d511 ansi256=ce3e2ce9182e html=ad030f95009b
304+25/079 input=ac8a64fd0669 vt=9055770e92e0 graphics=03c96da25a3b history=695aba930edc ansi=909609df56dc ansi256=c698724afe00 html=2782dddc6683
305+25/099 input=b9cf01acdb26 vt=8dd2058f433e graphics=21a529189863 history=5238bbd06b82 ansi=b5d4088e160c ansi256=0ff96150e2be html=4fa96475aa88
306+25/119 input=410378a33ad3 vt=dace7518854d graphics=671c04cdbb2e history=ba8ffc54516d ansi=9daa743e922f ansi256=0f6bdecf771b html=87e8c7ed55ed
307+25/139 input=d5c58840cccb vt=a863354d2e79 graphics=774c3af4a9d7 history=fc832c985019 ansi=fd8a33c8643f ansi256=248acfdd5b79 html=d682ccc98c28
308+25/159 input=c779a92ffff8 vt=59722e5604cc graphics=90138c047ccc history=784062fd993e ansi=4fd68de4eb47 ansi256=47fac1db6a5e html=7660b94c2181
309+25/179 input=0f10e10ba19e vt=35008d097bad graphics=893057b33dd8 history=16953fbaeb54 ansi=3b27b5e8053d ansi256=7da8c6e6e855 html=e8145b206f38
310+25/199 input=0ca98a998041 vt=8efd480ed24b graphics=63413593bec4 history=47776a85fe59 ansi=7d7d4c66a6e8 ansi256=5bdb0d8d7bbf html=cb8aea5d6a06
311+25/219 input=de28db3b700a vt=17daa62c7cfb graphics=dd49376c4da5 history=15a33d7773c3 ansi=ae80596f4399 ansi256=82c08aa54c2c html=f7ec0c353da4
312+25/239 input=7376fea910e5 vt=fbb1d592f8d4 graphics=91fce35d7281 history=9386c1fadc98 ansi=6d4a3a73dc23 ansi256=868fb03a774e html=cf54d6e6ef44
313+26/019 input=fda9cdcb5b52 vt=facd51ef20c8 graphics=382c68b55f88 history=77c1e92b28ce ansi=2ca00c5ec060 ansi256=d468b0312f32 html=0dc24aeb7e4b
314+26/039 input=45dde463af51 vt=6351e7ebe54f graphics=2b4e93560776 history=6d6bea4b9262 ansi=27f8e6f34ff1 ansi256=5497e744c3f1 html=ab547a8f2abf
315+26/059 input=7da19667b980 vt=d0db0647cfe6 graphics=5489354feff2 history=2a69dea37d81 ansi=a198b376a5f7 ansi256=b9ea808c723b html=131019becb23
316+26/079 input=32a6d6a2b1bc vt=cf96bfc4e6a1 graphics=3f478b3003e5 history=9127b57731bd ansi=47fe1fddd752 ansi256=8c4757767979 html=8b7545ed3ce1
317+26/099 input=6f6ab6c33f6e vt=8d0bd32b9860 graphics=41c51ddaef04 history=e1fc29444d46 ansi=ad7570e6e1a9 ansi256=54ae825a2d51 html=6c2656c63f45
318+26/119 input=7268ff0f428b vt=3570eaaa8987 graphics=1536a47eea05 history=d4ca4f97cc2d ansi=6f955f7061fa ansi256=9b376649c4a3 html=9097059436e3
319+26/139 input=a035d95e4133 vt=31fd63dcc343 graphics=56623268bf21 history=9e9e09147d59 ansi=367469570f8c ansi256=c1d131bf9d73 html=8e6b53b98941
320+26/159 input=13dac00ab83f vt=f3886857efe2 graphics=9c93b507d033 history=1d61914d4451 ansi=a70745216d22 ansi256=38f3aa51849a html=60682bd1a380
321+26/179 input=b74e39ac0058 vt=cdd2058ecb63 graphics=24b3d853ce9c history=40a77b124d6c ansi=2f8eeb70c459 ansi256=292c6f4b1128 html=19fd23f9e12a
322+26/199 input=644a9acc3e7c vt=4d3b7e94ad9d graphics=105f7d3ad244 history=bd347f14b6ed ansi=db8bc20b0152 ansi256=db77852447fc html=a94abf989880
323+26/219 input=3a73386e101b vt=32bafcc85079 graphics=29eb959b5720 history=69a44f4a9e2a ansi=433ef0c96908 ansi256=ffc502920bda html=6ff4b02d0106
324+26/239 input=41caba3e4176 vt=72cdbafbedfc graphics=f103e171dc0a history=e01e78beb0e4 ansi=42e5a332cf10 ansi256=ffc9fdbf7eaf html=ba4c9427aab3
325+27/019 input=a0ab158021a4 vt=4c016f9ab6b8 graphics=869d15133c58 history=b02d2b5c146d ansi=a66af5fe25e3 ansi256=028c81af44b0 html=aee9fd49c8e2
326+27/039 input=619d48ca4875 vt=24a873ab19d8 graphics=e591a2412917 history=93d77aa8b991 ansi=9627caaffa74 ansi256=7f76f10a45e8 html=4110805335c8
327+27/059 input=37323836c954 vt=a7361b81ab9d graphics=27fc3572e7b4 history=30619de4a0ee ansi=786e44106197 ansi256=bf3797a8c835 html=7c7e0c9a27ea
328+27/079 input=9a19b64b0196 vt=f1baaa7f0a85 graphics=ffb49c684293 history=37e041c6b820 ansi=f7f24c82de96 ansi256=a5f5110890de html=943e0e6b0d3e
329+27/099 input=d12989c27d01 vt=a8fc3484ba05 graphics=c8e28e457332 history=a8279232604b ansi=98ffda5977b0 ansi256=534a060f046f html=441757fa747c
330+27/119 input=0ac9080d8928 vt=b83980a4008a graphics=da849ea45a90 history=5c3d8be7a9e4 ansi=2b2e932c973a ansi256=21b8da618178 html=38b4a396109e
331+27/139 input=282ef624dfad vt=d6b004fd262d graphics=12a2e8a9f60d history=1329220e670f ansi=2298fb0d8a5b ansi256=11e812bae71f html=6ab198ad7d62
332+27/159 input=a01c716c963c vt=13e121a5ed05 graphics=e6459b86b3cb history=3eb84b471759 ansi=f690218fe3b4 ansi256=fc21b25f6924 html=20543aa8668a
333+27/179 input=20f00a694447 vt=94321f06be09 graphics=a3c4c9c6f33f history=42d4520330a0 ansi=a2a46388663f ansi256=a376fcbb8164 html=f452adb7c099
334+27/199 input=fdb67ef93b9d vt=1838510afa9c graphics=d29b34fbd961 history=df63294e32fc ansi=827143c01f7e ansi256=b85859dcbdb9 html=a0270a6ff48a
335+27/219 input=8ddfbab95f21 vt=e45174a8db12 graphics=c97d30325b64 history=62fa448733e2 ansi=32121881889d ansi256=4b64757e726c html=e783d8d52fcc
336+27/239 input=753e7bf06dbc vt=60f1105433d5 graphics=efbf1f464724 history=4a90c407f9c4 ansi=c4071852a228 ansi256=1f57fb29fec1 html=58e396789f49
337+28/019 input=391f94dcf6a1 vt=39ec4af818c0 graphics=9258c91bf1d0 history=e3b0c44298fc ansi=ed7d4a6a2cf5 ansi256=f02d1647a112 html=a6821c989a88
338+28/039 input=a2dc4b392a48 vt=b8e0c66df113 graphics=83d520edbb4f history=e3b0c44298fc ansi=5c501391013a ansi256=048ac49fbd32 html=9df692cce880
339+28/059 input=9512e505bd51 vt=3fd50ae5cb19 graphics=2a04a7e76271 history=e3b0c44298fc ansi=38d1d62d4cdc ansi256=7341cbe0d85b html=0f973c22ced8
340+28/079 input=b4f33aa964fd vt=e8a914500faa graphics=b84eb0ef5e85 history=e3b0c44298fc ansi=5d58e0f3cc04 ansi256=0bef41c71264 html=834d4572a93e
341+28/099 input=42c86901a419 vt=53cfab76eaf2 graphics=e919013fbcfe history=e3b0c44298fc ansi=d60a7106690d ansi256=674909bf361d html=09dd174bc0c2
342+28/119 input=e00da70055da vt=7de1f675883b graphics=f2ef9f6ac5a5 history=e3b0c44298fc ansi=2aabcf8d8eee ansi256=168b25bf2b19 html=3efa90487f52
343+28/139 input=d750a1ac07d4 vt=b315c0f1df33 graphics=ab0ed3a804ae history=e3b0c44298fc ansi=e9d617aa4089 ansi256=fae71b6133c8 html=0b5f9a6a19ce
344+28/159 input=3fc7f46eabe8 vt=ccffd168fd63 graphics=ec03ac4ca0cd history=e3b0c44298fc ansi=11a6c758321d ansi256=e80ed90c3bcb html=9bde62877bc9
345+28/179 input=1d4acd9c4af7 vt=8bf36c9b23ee graphics=1911cb9145c5 history=e3b0c44298fc ansi=56763f90b125 ansi256=d9a87c01b2b1 html=92ff406e50df
346+28/199 input=1856f88796c2 vt=0abd510a59d6 graphics=057f85e08328 history=e3b0c44298fc ansi=b0d6f894f6da ansi256=aeec9cff2fcf html=dcc54270c89d
347+28/219 input=cd27a5fd4004 vt=4ac4f7028196 graphics=a5316d8dcd5e history=e3b0c44298fc ansi=aab02f46cf70 ansi256=5b2060872b3f html=a8fe9ba0e37c
348+28/239 input=1670f4358e8c vt=89328e3b3947 graphics=d91c8b353650 history=e3b0c44298fc ansi=c75943df0217 ansi256=919688b315c1 html=57e659453318
349+29/019 input=d2b5871bb414 vt=c052ae09b304 graphics=9258c91bf1d0 history=d3fd1a0b8244 ansi=91c870d1933c ansi256=f4e29987bead html=fb3670f0566c
350+29/039 input=5b0a2705ef4d vt=0d059bd5f42f graphics=8142d34d582c history=92f9707352a1 ansi=b43e08309a86 ansi256=19b9af5d6ae0 html=4fe9bc6d4d45
351+29/059 input=47ada4f1019c vt=b40f6e177d1d graphics=1e0eb8920f76 history=6db573d69703 ansi=41ccfb4ac2df ansi256=649bc6b5da2f html=656f15c2d920
352+29/079 input=8bc9e83b5a81 vt=374c8594de2c graphics=0532805773dc history=46255be21ecb ansi=765157fbeff5 ansi256=b88f874e8c6b html=ab13c12b0bff
353+29/099 input=9f1e5c464abd vt=aa59b0b22cf3 graphics=d2d3cdc33260 history=37760bb8481b ansi=989b753bd4f7 ansi256=7050ab6e10f1 html=b0bf20c85237
354+29/119 input=cfc6143e2e7b vt=1acd379f3bed graphics=c7f90dde3e58 history=067212979178 ansi=b0093eb9ed2a ansi256=0219a9bbbc74 html=aeb7c81d051c
355+29/139 input=6953c4cb1ff3 vt=da2df4505df2 graphics=d87e27f1fedd history=bbbd1009cb0a ansi=45612d00f8ae ansi256=aa563cf04415 html=c1d02dad5c04
356+29/159 input=7625b8495e7f vt=865ffd108fda graphics=b39ba83c432b history=3556ac8bf0d7 ansi=df732e1bf1d2 ansi256=ec385b448b4f html=185239d52d6b
357+29/179 input=b321a53fe360 vt=69d2a8985995 graphics=58400326653e history=8f690969e6f2 ansi=a64950b43d96 ansi256=d7489c15a4fc html=5d5c7fc673ac
358+29/199 input=37191c6fa49b vt=51dd26cf6374 graphics=c98569c74a00 history=a2bf34248497 ansi=f956527071d1 ansi256=4a45079fe8e9 html=f590f50e4086
359+29/219 input=55e0459a5d06 vt=570ab6ceac22 graphics=5ffd6c200c89 history=52e3c5d61685 ansi=17dc0c38ee41 ansi256=5ddb6f47a5fd html=cc454c9fde4f
360+29/239 input=3423aac00df5 vt=1c71bdbec8c6 graphics=66cfc819e87f history=0d94a263950c ansi=37c75f4e6ee9 ansi256=9cbe40931eff html=fc6d9ac222ac
361+30/019 input=81b751d4ee69 vt=cd8eb7cdf707 graphics=c8d8b5e0d84c history=2c3d149755a5 ansi=46df2034c3bb ansi256=5c4635e74a7b html=26ac662414fb
362+30/039 input=f2ea78ebc633 vt=32f6e4b794b5 graphics=0c4a8732135c history=d2fd3d71ad52 ansi=5d06683bca49 ansi256=2e3c3f8e2573 html=e50b5f832f2f
363+30/059 input=b5b88be76b35 vt=a89c35759631 graphics=0a16a95018e3 history=dd5821495eb2 ansi=8212058f2c16 ansi256=bf999a03348f html=29efef390894
364+30/079 input=aab80cdb1a9d vt=bad39e3cd313 graphics=fbde544e97db history=a6b95b04c20e ansi=bb5e412c315f ansi256=d3806cd75402 html=5aee5ea0fa52
365+30/099 input=fecff317770d vt=dcfed4e41776 graphics=62e6fadbeb33 history=f0143cbc9235 ansi=372309dc5b18 ansi256=a1a2575fa6fc html=e5c90bd65ed7
366+30/119 input=f36a0921e2c1 vt=c1c87f7aa7d5 graphics=0e5650a6b594 history=2539236c8d07 ansi=b44c19672ced ansi256=e63496a082c5 html=39b24981cea4
367+30/139 input=1235b90e027c vt=92378b77cee0 graphics=6570bcfcc284 history=29f785a303ff ansi=f95d045ca021 ansi256=96ba741a02df html=fe3e1d3c4335
368+30/159 input=0a9c4944432e vt=c1c4f164e2ed graphics=9655c95832a3 history=5cab22d70e85 ansi=4d8b73aa1781 ansi256=d017ea26c731 html=841c8db13e76
369+30/179 input=8188ef7c5aea vt=9df594a81c83 graphics=6135d5ea25a8 history=2171fb6763dc ansi=746fdb74c702 ansi256=4403fc8df293 html=1871eea3dccd
370+30/199 input=4abad5b6d552 vt=e0ffd787a8fa graphics=c3ff71dd99e8 history=8c073ea7f31b ansi=877f98e30188 ansi256=8a662a41ab7b html=e7b4682a813c
371+30/219 input=c466091ffc3e vt=6fef0ac7c1b5 graphics=92faa40c0c50 history=de0ca81512fe ansi=cf605527be7f ansi256=d982cf06fa77 html=227f67d9cd13
372+30/239 input=4145ebca2134 vt=43dee83cd99c graphics=a10996d846e9 history=9127a917775a ansi=48031030271c ansi256=644c27c658cc html=d4672d580959
373+31/019 input=4ffdd107e130 vt=a8f488f70e96 graphics=54a47f32d294 history=1363d4610810 ansi=d12a1938e477 ansi256=b3d7ac12ba37 html=d6f9da234b14
374+31/039 input=580dc6809479 vt=26d09ae8088c graphics=932b610185a6 history=546bb0ec07c3 ansi=3d73d7b4ece4 ansi256=c9f2585ef571 html=bd25f5216874
375+31/059 input=d55047314620 vt=ba8944f37704 graphics=93f9c09cc57f history=13263e91f3a0 ansi=b7e22aa137a1 ansi256=ac0dee7439a1 html=956cc406dfd0
376+31/079 input=481041bbdeb3 vt=7e2b3392c536 graphics=96a7f4c0e9cc history=b4e2710a711f ansi=5bd2e666b439 ansi256=f75b592537b4 html=2a3fd5342fc7
377+31/099 input=a0faf263fb01 vt=b8a49978e022 graphics=8102f1a4335e history=2fe4211ff34a ansi=be22be7e7f00 ansi256=879a70e29f04 html=9ba58685ff6b
378+31/119 input=187877e7efa8 vt=73e5614e80b2 graphics=9569fd17b61e history=241eea6af7a7 ansi=8bc768ccea64 ansi256=a5c49983195b html=e69d0b65abd9
379+31/139 input=de7b035ba613 vt=9dd8609cafec graphics=d564f22f470b history=0184633328b3 ansi=4bdf20abbc23 ansi256=330f23d0aefb html=fe2ea29ccaa0
380+31/159 input=b6fd5e5706dc vt=797000293a6b graphics=7a52d325bebe history=7610ffcf2c68 ansi=b3fd23a46a82 ansi256=eccb38052f3f html=a7f9631b2c88
381+31/179 input=8c6baa09d601 vt=14bcb2ac1ccb graphics=c47105a03d8f history=bd6755a03aeb ansi=f5bad7cc857f ansi256=f4e17410acaa html=a0a79004841f
382+31/199 input=e93f7c591ad3 vt=6d96a17aba6f graphics=2f720cb29179 history=3a9736691489 ansi=460307957329 ansi256=80e672ca3ac0 html=1e568267c0f3
383+31/219 input=79773cf0de2d vt=186bf619a34a graphics=f49eef257c76 history=5aa2cfa1b249 ansi=ae7c27b07155 ansi256=7c18b74d2046 html=4d61d2bbf6c3
384+31/239 input=6c9d844e01b7 vt=54d6672972da graphics=121fb62a4ac9 history=973c3872c5f5 ansi=0d6dc977f68d ansi256=8dfe6cc64918 html=fda3886406ae
385+32/019 input=f1b9f49825d1 vt=b896febefb87 graphics=5b44277a67f0 history=e3b0c44298fc ansi=a59d647eab6b ansi256=dac7099fe2ea html=7826b887c494
386+32/039 input=2a33a289496a vt=b5916d7070fa graphics=26d5bc17baae history=e3b0c44298fc ansi=7f524a65348d ansi256=0db81005952b html=a77b46397a7d
387+32/059 input=c744aeba9d35 vt=1f7ab0ed0a85 graphics=19bea48d4bbe history=e3b0c44298fc ansi=48f20dcecafe ansi256=cba05808b822 html=b06fe59398d0
388+32/079 input=a291715ca604 vt=02dae80575f6 graphics=cc3ccd321b8f history=e3b0c44298fc ansi=e60a2f443845 ansi256=fe695b1b74da html=6b20357c444c
389+32/099 input=0185338fbcae vt=8fc5a3e651b4 graphics=2f6b60f4c1bd history=e3b0c44298fc ansi=6bbbc41196a6 ansi256=d1c3be6963f5 html=fe45e309448e
390+32/119 input=5bdba58e56ef vt=fae5a689ad92 graphics=ad43c49b7a64 history=e3b0c44298fc ansi=0ce313ad92c0 ansi256=c13d348e44ee html=6251460bdb27
391+32/139 input=c1db2991e5c9 vt=54093b9b2429 graphics=3df6b10ab88c history=e3b0c44298fc ansi=e31e25be2279 ansi256=f64244a7e151 html=ef6eab2d901d
392+32/159 input=591789c42d4b vt=c24909ea2afe graphics=c236f799ecaa history=e3b0c44298fc ansi=022f0da69021 ansi256=239a7a465997 html=425f201de6c8
393+32/179 input=65675fdd79cc vt=19f4b67d3b4e graphics=261327b79d5c history=e3b0c44298fc ansi=a3b2504f775a ansi256=afb363d453b9 html=9a0e34c3de6c
394+32/199 input=05390be08cb1 vt=4bc9f1107e39 graphics=fa6550382379 history=e3b0c44298fc ansi=89d28c0df975 ansi256=805ad4ff7ce5 html=2c4f044e9974
395+32/219 input=80d7f0067852 vt=d430bffec709 graphics=5c1d55bc5c72 history=e3b0c44298fc ansi=06fda943d841 ansi256=a76bc4ca9ca4 html=6456853f45ea
396+32/239 input=9971f0ae2fa1 vt=9ab36840c246 graphics=aa7fa2241277 history=e3b0c44298fc ansi=1c7e054f03bd ansi256=f377130452c0 html=452e676574cd
397+33/019 input=4d8ec4a4118a vt=223f264f2447 graphics=88d9ff5277da history=711a38708632 ansi=080247111071 ansi256=db0ad0bd8b9a html=dad9d89a88d9
398+33/039 input=72bd61b19dd6 vt=525fb700abae graphics=cdb95740eded history=0d7c8ec46243 ansi=292ed59cca0f ansi256=5370dc6a7441 html=7898eb99bcdc
399+33/059 input=230c9af2f983 vt=e49b983f0af7 graphics=a9ef4a02744c history=b77790113127 ansi=cb2676a2038e ansi256=adc4188bb526 html=ffa8d80f79a7
400+33/079 input=4df91df6e37c vt=fb750cb35392 graphics=818dbc92bea3 history=23a3886975c8 ansi=13f3aa9761bb ansi256=064a59ef4fc6 html=022acfaa4566
401+33/099 input=678d23120ba3 vt=6d6b38fb0388 graphics=92ab0d9966af history=2ca97bd48c2f ansi=d0fbb5b13d15 ansi256=c0d1412e905a html=2590e2cbe635
402+33/119 input=bcc5b1e12cec vt=3f46ce9c8abf graphics=ec245d1603aa history=5032daa96a10 ansi=83456a35b8e7 ansi256=7860a7f16450 html=0d53a4ac208b
403+33/139 input=3adcc6ae924e vt=b9fde658c5a9 graphics=5ad851d25e4d history=3a916388dafb ansi=f3cf449bbd60 ansi256=b72ae24101a9 html=a99bb68d05bc
404+33/159 input=fb7643e0bce4 vt=74c506f158c0 graphics=5b509853d2db history=877c1724a1fb ansi=fa29a37cf87f ansi256=fcac20dc9549 html=84d0b0fcee46
405+33/179 input=caa52db0cbdf vt=e75162de1b89 graphics=d949a79b005e history=bef9891a91f3 ansi=1b75efb5c608 ansi256=fc91fc6f771a html=67c7a0c1b852
406+33/199 input=c2671cae6a9f vt=e7b36b067938 graphics=d6eb2ebcb29b history=7334f6641140 ansi=0b8aa22b4190 ansi256=49b67dd6fae1 html=ad8ef1b74291
407+33/219 input=e7cea0a5df37 vt=1de3013b4d64 graphics=cf342952544e history=f0e0478fb828 ansi=23b4346842b6 ansi256=1866c47845b6 html=49665b234d90
408+33/239 input=5e15a796e7a7 vt=9665e614c843 graphics=94587b3237d4 history=6be392d9a77c ansi=f69f3eaf9883 ansi256=4275a0f5d1ea html=07ea899a4957
409+34/019 input=178da8bbf142 vt=205910068395 graphics=dbfcd55d0b33 history=7502eeba6bb8 ansi=8163280cad67 ansi256=159b42c6fac6 html=d73a1d1e83d2
410+34/039 input=a1bc6765e27f vt=48042beef7b7 graphics=29f7f17a132f history=6ec8d7f5ecca ansi=245569305424 ansi256=13017bde2cac html=3ad4002d1259
411+34/059 input=949398406918 vt=90cc3b7424e8 graphics=8c7bfaa71c9d history=8638567d108a ansi=e21a53a5acc1 ansi256=d4d06f3e4053 html=4e55ba25dea9
412+34/079 input=9d2e927f6a2a vt=3d041543a44b graphics=8cffef3c56ba history=79b1b371a8e3 ansi=082ee3283735 ansi256=8ed8beda9444 html=4bc9aed3e24e
413+34/099 input=28020b63bbd6 vt=32c06be61be0 graphics=20301a3655ce history=6679d91bbfd4 ansi=42bd595e6e1e ansi256=afade28b969a html=2929815d7d52
414+34/119 input=daf68c036c3c vt=af9c947f7422 graphics=5441da3a6af7 history=adbdbeea589e ansi=93a79bdb653b ansi256=82786c1b4fa3 html=1d5e7e843242
415+34/139 input=6d25662acf3a vt=5cdb4756e6b8 graphics=ae0700fd7a09 history=acd95dfdc46d ansi=0b761099f515 ansi256=0ee389babd62 html=86f5f689f9b1
416+34/159 input=b65ddcd437b9 vt=540ab8b4a6d9 graphics=71afa643a19e history=95e19294a4cb ansi=e1cdb7e662c3 ansi256=733d11b8083f html=026d7d079fc2
417+34/179 input=57f1f81acb3c vt=41ece03f7923 graphics=7dc07edadc7a history=ce5abe9435b7 ansi=7d026cc1a611 ansi256=15f91df0de54 html=9070de5dbdbd
418+34/199 input=3a7b3c2440bf vt=a9150ba7a4cb graphics=52db9cf3851a history=e14f94ccdd58 ansi=2a2474a89a60 ansi256=0537f46cd650 html=deef629c975d
419+34/219 input=76c4c4ccc65d vt=ac0da0319944 graphics=59162dcc7a6c history=3f8c45dc183b ansi=49a40bf933cf ansi256=15bd13cb4377 html=9d1996d432ac
420+34/239 input=74a38b340785 vt=754f065a06b1 graphics=784b8e4a91be history=8af536dda7f7 ansi=a858ae00e59d ansi256=dd5c4ca64682 html=efbc1cff92fd
421+35/019 input=e01695bf4e50 vt=8d5a98309b21 graphics=023952274cf9 history=2659734a87a8 ansi=b024edea8c61 ansi256=d5f38420be8b html=0e3a30bba33c
422+35/039 input=93665be63313 vt=a35f7be82f67 graphics=18b07a725813 history=7e0e18d77c74 ansi=390a8472c45c ansi256=6f6a93364ee3 html=ad85f0d84907
423+35/059 input=dd1cc03b3572 vt=6afa913190d6 graphics=14547246cfc9 history=be2e3ffa555b ansi=31f3ce2ba63c ansi256=3884c9872e16 html=79f5ee94dc0b
424+35/079 input=44796a406918 vt=6ce22280a030 graphics=8244d770854a history=30ab815d28f1 ansi=02a0bdfbbcd6 ansi256=8eb3b5dd6ca5 html=fd6d3994ce58
425+35/099 input=43ec30d13ab6 vt=13cafa9a15c4 graphics=c8de2510382b history=de35b2cda662 ansi=18856574b289 ansi256=803599ee7085 html=bd5bb7685683
426+35/119 input=51d29207da68 vt=cc52140c5b87 graphics=569a06ba1c26 history=ae9194ed626a ansi=3d3a69a1cec5 ansi256=a619ec705a9b html=6c98f3a4d068
427+35/139 input=b6e1eac19c21 vt=87bc9c96ee66 graphics=a761438c266b history=c565c9eb5e67 ansi=8b92e0851a34 ansi256=4ffa0b70b26c html=5274c4085723
428+35/159 input=b720c6dc8986 vt=5460691c9f91 graphics=1b8ce71ffdce history=6dfd322c6901 ansi=1728d0c18bca ansi256=b7b1df41146f html=58f827dcf566
429+35/179 input=0341f9cb3b4d vt=7336edcff9a8 graphics=ff521966f503 history=c556c9bddb2d ansi=df31635476c2 ansi256=2259657c5fb8 html=f2c63f49d36d
430+35/199 input=1f9be1f8195d vt=e1efb6275a87 graphics=806918fdf91b history=c577342a7558 ansi=0d61fc403ed4 ansi256=3dfe6c2d3f95 html=e1b9f7636d44
431+35/219 input=1706e3dc7ba6 vt=4797a5f1c73a graphics=9c0eb19d32f4 history=9448c87c1fde ansi=4093919d80c9 ansi256=e8391c2d9989 html=c8bd26392776
432+35/239 input=880a5d50813f vt=06531473f963 graphics=0dc2013efaf5 history=449d96644a4f ansi=250e7eeabc3c ansi256=4c6fafe74d13 html=430af5b9d865

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close