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 Unverified
B5690EEEBB952194The 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 are | 100 | The HTML renderer compares every row with its committed shadow; damage values are | ||
| 101 | non-authoritative hints. `Prepare` permits one outstanding draw and requires an | 101 | 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-row | 103 | + | 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 safe | 106 | 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. It | 112 | 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, and | 114 | 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 bytes | 120 | Consumers remain responsible for transport and mapping events to terminal bytes | ||
| 113 | or application actions. Clipboard text is preserved unchanged, including | 121 | or application actions. Clipboard text is preserved unchanged, including | ||
| 114 | control bytes, so consumers forwarding paste events to a PTY must apply their | 122 | 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:docker | 162 | 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 by | 173 | Playwright 1.62.1 and its Chromium, Firefox, and WebKit revisions are pinned by | ||
| 158 | `package-lock.json`. The matching Playwright container provides the reproducible | 174 | `package-lock.json`. The matching Playwright container provides the reproducible | ||
| 159 | fallback when host libraries cannot run one of those browser builds. Production | 175 | 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, true | 39 | 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, false | 67 | 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 spans | 87 | return spans | ||
M ansi/delta_plan.go +36 -21
| @@ -1,5 +1,7 @@ | |||||
| 1 | package ansi | 1 | 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 int | 7 | Y int | ||
| @@ -31,35 +33,45 @@ type DeltaCandidate struct { | |||||
| 31 | frame Frame | 33 | 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 candidate | 44 | 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{}, err | 57 | 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}), nil | 61 | + | 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{}, err | 64 | 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])), nil | 68 | + | 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}), nil | 74 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}, scratch), nil |
| 63 | } | 75 | } | ||
| 64 | return DeltaCandidate{}, nil | 76 | 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 = &scroll | 83 | skip = &scroll | ||
| 72 | } else if hasScrollDamage(damage) { | 84 | } else if hasScrollDamage(damage) { | ||
| 73 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | 85 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil |
| 74 | } | 86 | } | ||
| 75 | 87 | ||||
| 76 | var spans []Span | 88 | 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}), nil | 96 | + | 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}), nil | 100 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil |
| 89 | } | 101 | } | ||
| 90 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}), nil | 102 | + | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}, scratch), nil |
| 91 | } | 103 | } | ||
| 92 | plan.Spans = spans | 104 | plan.Spans = spans | ||
| 93 | - | return newDeltaCandidate(frame, plan), nil | 105 | + | 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 plan | 117 | 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 []Span | 121 | var spans []Span | ||
| 122 | + | dense, isDense := asFrame(frame) | |||
| 110 | for y := range frame.Rows() { | 123 | for y := range frame.Rows() { | ||
| 111 | dirty := false | 124 | 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 = true | 127 | + | } else { |
| 115 | - | break | 128 | + | 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 | return | 215 | 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 | return | 221 | 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 nil | 36 | 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 lies | 297 | // A damage rect that ends on a wide head whose continuation lies | ||
| 298 | - | // outside the rect makes the terminal advance one column further than | 298 | + | // outside the rect is widened to cover the pair, so the terminal |
| 299 | - | // the rect width accounts for. Tracking must be dropped: the next | 299 | + | // 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 CUF | 316 | + | // 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 ansi | 1 | 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 int | 24 | height int | ||
| 25 | hasCommitted bool | 25 | hasCommitted bool | ||
| 26 | committed Frame | 26 | 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 *Renderer | 38 | renderer *Renderer | ||
| 33 | candidate DeltaCandidate | 39 | candidate DeltaCandidate | ||
| 34 | data []byte | 40 | data []byte | ||
| 41 | + | generation uint64 | |||
| 35 | commitOnce *sync.Once | 42 | 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 = 0 | 57 | r.width = 0 | ||
| 49 | r.height = 0 | 58 | r.height = 0 | ||
| 50 | r.hasCommitted = false | 59 | 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 after | 63 | // 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 the | 67 | // 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 | return | 76 | 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 makes | 81 | + | 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.frame | 83 | + | } |
| 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 only | 115 | // Prepare plans and encodes a transactional draw. The renderer advances only | ||
| 78 | // when the returned draw is committed. Keep at most one prepared draw | 116 | // 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 DeltaCandidate | 124 | var candidate DeltaCandidate | ||
| 82 | var err error | 125 | var err error | ||
| 83 | if err = validateCellSource(frame); err != nil { | 126 | if err = validateCellSource(frame); err != nil { | ||
| 84 | return PreparedDraw{}, err | 127 | 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{}, err | 140 | 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.Plan | 143 | 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, nil | 145 | return prepared, nil | ||
| @@ -149,17 +192,6 @@ func (r *Renderer) Draw(frame CellSource, damage []Damage) ([]byte, error) { | |||||
| 149 | return prepared.Bytes(), nil | 192 | 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 used | 195 | // 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 + x | 107 | 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 true | 117 | 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.Count | 145 | 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 []storedCell | 45 | + | cells []storedCell |
| 46 | - | rows []uint32 | 46 | + | rows []uint32 |
| 47 | - | styles []styleSlot | 47 | + | styles []styleSlot |
| 48 | - | styleIndex map[Style]uint32 | 48 | + | styleIndex map[Style]uint32 |
| 49 | - | freeStyles []uint32 | 49 | + | freeStyles []uint32 |
| 50 | - | styleCount uint32 | 50 | + | 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 []payloadSlot | 59 | payloads []payloadSlot | ||
| 52 | payloadIndex map[CellPayload]uint32 | 60 | payloadIndex map[CellPayload]uint32 | ||
| 53 | freePayloads []uint32 | 61 | 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].styleID | 274 | oldID := f.page.cells[index].styleID | ||
| 264 | styleID := oldID | 275 | styleID := oldID | ||
| 265 | - | // Repainting a cell usually keeps its style. Compare the canonical value | 276 | + | // 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) + x | 360 | + | for i := range row { |
| 349 | - | f.releasePayload(f.page.cells[index].payloadID) | 361 | + | cell := &row[i] |
| 350 | - | oldID := f.page.cells[index].styleID | 362 | + | 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 id | 383 | + | p.styles[p.styleCacheID].refs++ |
| 364 | - | } | 384 | + | return p.styleCacheID |
| 365 | - | var id uint32 | 385 | + | } |
| 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] = id | 405 | + | p.styleCache, p.styleCacheID, p.styleCacheOK = raw, id, true |
| 375 | - | f.page.styleCount++ | |||
| 376 | return id | 406 | 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 | return | 425 | 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 core | 1 | 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 writable | 19 | 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 need | 21 | 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 | ## Callbacks | 26 | ## Callbacks | ||
| 25 | 27 | ||||
| 26 | Callbacks run synchronously, before `Screen.Write` returns. Avoid slow work in | 28 | 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 | ## Styles | 47 | ## Styles | ||
| 30 | 48 | ||||
| 31 | Use `DefaultStyle()` to leave foreground and background colors at the terminal's | 49 | 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 the | 111 | // scene or any snapshot. Format preserves the encoded pixel layout for the | ||
| 112 | // renderer. Width and Height describe the decoded pixel extent used for | 112 | // renderer. Width and Height describe the decoded pixel extent used for | ||
| 113 | // clipping and quota accounting. DecodedPixels, when non-zero, must equal | 113 | // 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 []byte | 117 | Encoded []byte | ||
| 117 | Format AssetFormat | 118 | 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{}, err | 124 | 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, the | 146 | // 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.pixels | 179 | 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{}, err | 182 | return AssetID{}, err | ||
| 161 | } | 183 | } | ||
| @@ -389,7 +411,7 @@ func (s *Scene) Usage() Usage { | |||||
| 389 | return s.state.usage | 411 | 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.Encoded | 415 | 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}, nil | 439 | + | // 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 err | 530 | 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 * 1024 | 18 | 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 be | 25 | // 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 later | 26 | // 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 int | 48 | rows int | ||
| 43 | cells int | 49 | cells int | ||
| 44 | logicalBytes uint64 | 50 | 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 []*HistoryChunk | 95 | 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 - start | 124 | 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 = width | 343 | h.tailPageWidth = width | ||
| 302 | h.tailPageRows = 0 | 344 | h.tailPageRows = 0 | ||
| 303 | - | h.tailPageStyles = map[renderer.Style]struct{}{renderer.DefaultStyle(): {}} | 345 | + | h.resetTailPageSets() |
| 304 | - | h.tailPagePayloads = nil | 346 | + | 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.tailBytes | 377 | old := h.tailBytes | ||
| 320 | h.tailBytes = 0 | 378 | h.tailBytes = 0 | ||
| 321 | h.tailPageWidth = 0 | 379 | h.tailPageWidth = 0 | ||
| 322 | h.tailPageRows = 0 | 380 | h.tailPageRows = 0 | ||
| 323 | - | h.tailPageStyles = nil | 381 | + | 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 = nil | 556 | h.cachedTail = nil | ||
| 501 | - | h.tailCells = nil | 557 | + | // The sealed chunks hold compact copies (newHistoryChunks and the bounds and |
| 502 | - | h.tailBounds = nil | 558 | + | // ID slices are copied), and no view, snapshot or callback ever receives |
| 503 | - | h.tailIDs = nil | 559 | + | // 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 = 0 | 573 | h.tailBytes = 0 | ||
| 505 | h.tailPageWidth = 0 | 574 | h.tailPageWidth = 0 | ||
| 506 | h.tailPageRows = 0 | 575 | h.tailPageRows = 0 | ||
| 507 | - | h.tailPageStyles = nil | 576 | + | // Sets are cleared lazily by the next page; release oversized ones now. |
| 508 | - | h.tailPagePayloads = nil | 577 | + | 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] = nil | 627 | 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.cachedTail | 651 | 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 RowID | 238 | var maxID RowID | ||
| 239 | var chunks []*HistoryChunk | 239 | 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), bound | 294 | 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] = -1 | 302 | 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 EventKind | 40 | kind EventKind | ||
| 41 | json string | 41 | 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 << 20 | 26 | 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 use | 533 | + | // 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 the | 9 | // 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 damage | 11 | + | // 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 terminal | 16 | // 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 html | 1 | 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 Limits | 12 | + | limits Limits |
| 14 | - | generation uint64 | 13 | + | generation uint64 |
| 15 | - | pending *transaction | 14 | + | pending *transaction |
| 16 | - | committed core.Frame | 15 | + | 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 Cursor | 19 | cursor Cursor | ||
| 18 | initialized bool | 20 | 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, ErrPendingDraw | 68 | 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, err | 72 | 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, err | 77 | 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 = tx | 89 | r.pending = tx | ||
| 97 | return &PreparedDraw{tx: tx}, nil | 90 | 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.Height | 123 | 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 | continue | 151 | continue | ||
| 135 | } | 152 | } | ||
| @@ -137,28 +154,29 @@ func (r *Renderer) validateFrame(frame core.Frame) error { | |||||
| 137 | continue | 154 | 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 nil | 173 | + | return scratch, nil |
| 157 | } | 174 | } | ||
| 158 | 175 | ||||
| 159 | - | // materializeCellSource reads a source into an owned frame. A core.Frame | 176 | + | // 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(), nil | 188 | + | 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(), nil | 196 | + | 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, nil | 208 | 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 column | 224 | // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column | ||
| 194 | // produced after writing the last column with autowrap enabled onto the | 225 | // 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 false | 260 | 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 | continue | 309 | 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{}, err | 320 | 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, nil | 336 | return update, nil | ||
| 257 | } | 337 | } | ||
| 258 | 338 | ||||
| 259 | - | // encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on | 339 | + | // encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on |
| 260 | // the exact encoded size. The previous conservative estimate could reject | 340 | // 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, nil | 353 | 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 false | 358 | + | 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 | continue | 381 | 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] = styleID | 390 | 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 = 1 | 396 | + | 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, nil | 411 | + | 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 ErrStaleDraw | 498 | return ErrStaleDraw | ||
| 398 | } | 499 | } | ||
| 500 | + | r.spare = r.committed | |||
| 399 | r.committed = tx.frame | 501 | r.committed = tx.frame | ||
| 400 | r.cursor = tx.cursor | 502 | r.cursor = tx.cursor | ||
| 401 | r.initialized = true | 503 | 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 ErrStaleDraw | 520 | return ErrStaleDraw | ||
| 419 | } | 521 | } | ||
| 522 | + | r.spare = tx.frame | |||
| 420 | r.pending = nil | 523 | r.pending = nil | ||
| 421 | tx.state = transactionAborted | 524 | tx.state = transactionAborted | ||
| 422 | return nil | 525 | return nil | ||
| @@ -430,6 +533,7 @@ func (r *Renderer) Reset() { | |||||
| 430 | r.generation++ | 533 | r.generation++ | ||
| 431 | r.pending = nil | 534 | 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 = false | 538 | 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 = 1 | 11 | + | 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 uint8 | 14 | 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-cell | 83 | + | // 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 kittygraphics | 1 | 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 standard | 9 | // DecodeBase64 accepts both padded standard base64 and unpadded raw standard | ||
| 9 | // base64. It deliberately does not accept URL-safe alphabets or non-base64 | 10 | // 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, nil | 26 | + | 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, nil | 30 | + | // 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 a | 159 | // 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 Limits | 166 | 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{}, ErrPayloadTooLarge | 184 | return Command{}, ErrPayloadTooLarge | ||
| 181 | } | 185 | } | ||
| 182 | - | return Command{Controls: controls, Payload: append([]byte(nil), payload...)}, nil | 186 | + | 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{}, ErrInvalidAPC | 208 | return Command{}, ErrInvalidAPC | ||
| 189 | } | 209 | } | ||
| @@ -195,5 +215,5 @@ func ParseAPC(apc []byte, config ...Limits) (Command, error) { | |||||
| 195 | } else { | 215 | } else { | ||
| 196 | return Command{}, ErrAPCTruncated | 216 | 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 that | 163 | // 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{}, ErrNoScene | 173 | 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, nil | 303 | 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 the | 345 | // 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) + 5 | 19 | 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 bool | 77 | damageSaturated bool | ||
| 73 | damageFullRedrawSticky bool | 78 | damageFullRedrawSticky bool | ||
| 74 | escapeBuf []byte | 79 | escapeBuf []byte | ||
| 80 | + | combineBuf []byte | |||
| 81 | + | evictScratch []renderer.Cell | |||
| 75 | kittyDiscard bool | 82 | kittyDiscard bool | ||
| 76 | kittyDiscardEscaped bool | 83 | kittyDiscardEscaped bool | ||
| 77 | kittyPendingDisplay *kittygraphics.Controls | 84 | 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.escapeBuf | 181 | prefix := s.escapeBuf | ||
| 173 | s.escapeBuf = nil | 182 | 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 = combined | 207 | data = combined | ||
| 184 | - | s.escapeBuf = nil | 208 | + | 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 true | 277 | 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 + 1 | 316 | 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 + 2 | 321 | 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 nil | 329 | 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] == 0x1b | 335 | escaped = data[remaining-1] == 0x1b | ||
| 295 | } | 336 | } | ||
| 296 | s.kittyDiscardEscaped = escaped | 337 | 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 | return | 481 | 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 | return | 104 | 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 link | 115 | // 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] = view | 98 | 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 bounded | 174 | 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 false | 179 | return false | ||
| 180 | } | 180 | } | ||
| 181 | var maxID RowID | 181 | 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 false | 185 | 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 false | 190 | 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 > maxID | 196 | + | return view.nextRowID > maxID && seen.commit() |
| 198 | } | 197 | } | ||
| 199 | 198 | ||||
| 200 | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | 199 | // 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 | |||