detached 4a922d9

feat(storage): compact terminal storage and bounded scrollback (#27)

* feat(core): define canonical style semantics

* docs(storage): adopt alpha breaking-change policy

* refactor(storage): hide mutable screen cells

* fix(core): preserve aliased frame replacement

* feat(storage): add compact page-local cells

* fix(core): harden compact page invariants

* feat(history): store sealed rows in compact slabs

* feat(history): bound scrollback by logical bytes

* feat(history): replace legacy persistence with compact dictionaries

* refactor(storage): retain compact snapshots and expose scalar history reads

* feat(storage): add bounded page-local exceptional payloads

* feat(history): compress cold sealed pages incrementally

* perf(history): bound tail preallocation by measured geometry

* feat(history): expose validated byte and optional line limits

* refactor(history): leave retention policy to the application

* docs(readme): lead with practical usage and move internals to guides

* fix(snapshot): expose allocation-free aggregate budget sizing

* chore(git): keep agent review notes local

* fix(storage): address verified review safety and retention findings

* perf(ansi): transfer privately owned prepared snapshots on commit

* fix(storage): handle verified final review findings
commit
4a922d97ce46cc5c41af66dc843ae687b0d29d4f
author
bnema · · 2026-09-05 10:10 UTC
committer
GitHub · · 2026-09-05 10:10 UTC
parent
1ffae4c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
93 files · +4844 -1538
M .gitignore +3 -0
@@ -3,6 +3,9 @@
33 /coverage.out
44 /testdata/generated/
55
6+# Local agent review notes
7+/.agent-reviews/
8+
69 # Editor and OS files
710 *.swp
811 .DS_Store
M README.md +87 -72
@@ -1,87 +1,102 @@
11 # vev-vt
22
3-`github.com/bnema/vev-vt` is a frontend-neutral VT terminal engine for Go.
4-It provides the terminal screen, scrollback history, immutable snapshots,
5-stable history chunks, VTH3 history bytes, and the reusable cell/frame model.
6-The `ansi` package turns core frames and damage into transactional ANSI output.
3+A Go library for reading terminal output and keeping track of what should appear
4+on screen. It powers [vev](https://github.com/bnema/vev).
75
8-## Packages
6+Feed it the bytes from a shell or command. It handles text, colors, cursor
7+movement, scrolling and resizing. You can then read the screen, save its history,
8+or use the `ansi` package to draw it in a terminal.
9+
10+This library does **not** start processes or manage a PTY. Your application does
11+that and passes the output to vev-vt.
912
10-- The module root (`github.com/bnema/vev-vt`) owns VT parsing, screen state,
11- history, snapshots, callbacks, and the public model aliases (`Cell`, `Style`,
12- `RGB`, `Frame`, `Damage`, and `RuneWidth`).
13-- `github.com/bnema/vev-vt/core` owns the frontend-neutral cell, style, frame,
14- damage, and width implementation. It has no terminal, renderer, transport,
15- or application dependencies.
16-- `github.com/bnema/vev-vt/ansi` is the concrete ANSI output package. It consumes
17- core frames and damage; it does not define a renderer-backend interface.
18-- `github.com/bnema/vev-vt/graphics` owns bounded renderer-neutral raster assets,
19- sparse placements, clipping fragments, and immutable scene snapshots. It has no
20- Kitty, ANSI, VT-policy, or transport dependency.
21-- `github.com/bnema/vev-vt/protocol/kittygraphics` parses bounded Kitty graphics
22- APC commands and translates the supported static/direct subset into `graphics`.
23-
24-## Ownership contract
25-
26-Stateful values are single-owner and are not internally synchronized. Serialize
27-screen writes, resizes, snapshots, and history mutations in one owner. Parsing
28-and callbacks are synchronous: callbacks run before `Write` returns and follow
29-parser event order. Consume or copy response bytes during the callback.
30-
31-`Snapshot`, `HistorySnapshotView`, and `Row` methods provide owned captures or
32-copies. `BorrowedRow`, `Frame.Row`, and `HistoryView.Range` expose borrowed
33-storage with the lifetimes documented by their APIs; borrowed storage must not
34-be mutated or retained beyond its documented lifetime. Sealed `HistoryChunk`
35-identity is stable for the lifetime of a view.
36-
37-## Compatibility
38-
39-VTH3 history bytes are canonical and are decoded strictly, including malformed,
40-truncated, and trailing input rejection. VEVS is an application-owned outer
41-snapshot envelope and is intentionally not implemented here.
42-
43-The module has no production dependencies. `github.com/stretchr/testify` is
44-used only by the test suite. Keep the public v0.x API and byte formats immutable
45-once released; behavior changes require explicit versioning and compatibility
46-evidence.
47-
48-## Kitty graphics subset
49-
50-The VT accepts bounded static direct transmissions used by current
51-`kitten icat --transfer-mode=stream`: PNG, RGB, and RGBA assets; transmit,
52-transmit-and-display, place, query, and supported delete operations; chunked
53-Base64 uploads with optional zlib compression; source rectangles; cell extents;
54-pixel offsets; z-index; and cursor movement policy. Assets and decoded pixels
55-are bounded by explicit scene and parser limits. File, temporary-file,
56-shared-memory, animation, composition, relative placement, and
57-Unicode-placeholder commands remain unsupported.
58-
59-`Screen` allocates graphics state only after a Kitty APC. Graphics snapshots are
60-separate from cells and history bytes; VTH3 remains text/history-only. Static
61-placements move with terminal row scrolling and are clipped by the active
62-viewport; reflow and relative-placement movement remain unsupported.
63-
64-### Headless graphics harness
65-
66-`cmd/graphics-harness` feeds a terminal byte capture into a fresh `Screen` and
67-writes its active graphics snapshot as a PNG using an internal reference
68-compositor. It is a development inspection tool, not a terminal emulator.
13+## Install
14+
15+Requires Go 1.27 or newer.
6916
7017 ```sh
71-go run ./cmd/graphics-harness \
72- -input internal/graphicsharness/testdata/demo.apc \
73- -output /tmp/graphics-harness.png \
74- -cols 4 -rows 4 -pixel-width 4 -pixel-height 4 -scale 64
18+go get github.com/bnema/vev-vt
7519 ```
7620
77-The included demo has an opaque blue background and a semi-transparent red
78-overlay. Its output is suitable for direct image inspection.
21+**Alpha software:** APIs and saved-history formats can change between versions.
22+The current history format does not read older VTH3 data.
23+
24+## Read terminal output
25+
26+```go
27+package main
28+
29+import (
30+ "fmt"
31+
32+ vt "github.com/bnema/vev-vt"
33+)
7934
80-## Checks
35+func main() {
36+ screen := vt.NewScreen(80, 24)
37+ screen.Write([]byte("Hello, \x1b[31mworld\x1b[0m!"))
38+
39+ // Colors and escape sequences are processed, not included in the text.
40+ for x := 0; x < 13; x++ {
41+ fmt.Printf("%c", screen.Cell(x, 0).Rune)
42+ }
43+ fmt.Println() // Hello, world!
44+}
45+```
46+
47+Use `screen.Resize(columns, rows)` when the terminal size changes.
48+Use `screen.Snapshot()` when you need a copy that stays unchanged as new output
49+arrives.
50+
51+## Keep scrollback
52+
53+Scrollback is the text that has moved above the visible screen.
54+
55+```go
56+// Your application chooses these limits; vev-vt has no default budget.
57+config := vt.HistoryConfig{
58+ MaxBytes: 20_000_000, // At most 20 MB of history data.
59+ MaxRows: 5_000, // Optional: also keep at most 5,000 lines.
60+}
61+screen := vt.NewScreenWithHistory(80, 24, config)
62+```
63+
64+vev-vt enforces the limits your application supplies. The PTY transports bytes;
65+it does not store scrollback. The oldest lines are removed when a supplied limit
66+is reached. Limits apply to each
67+screen separately and exclude the visible screen. The byte limit measures
68+uncompressed history data, **not total process memory**.
69+
70+[History guide →](docs/history.md): limit settings, saving/restoring history and
71+optional compression during idle time.
72+
73+## A few rules
74+
75+- Serialize `Write`, `Resize`, `Snapshot`, reads and history mutations in one
76+ goroutine, or protect them with your own lock.
77+- Rows returned by the API are copies. Changing one does not change the screen.
78+- Use `vt.DefaultStyle()` for terminal-default colors, not `vt.Style{}`.
79+- Callbacks run during `screen.Write`; keep them short.
80+
81+[API ownership and styles →](docs/ownership.md)
82+
83+## Packages
84+
85+| Package | Use it for |
86+| --- | --- |
87+| `vev-vt` | Parse terminal output, read the screen and manage history. |
88+| `vev-vt/core` | Work with cells, styles and writable grids. |
89+| `vev-vt/ansi` | Render a screen or grid as ANSI terminal output. |
90+| `vev-vt/graphics` | Read supported terminal images and their positions. |
91+
92+[Supported image features →](docs/graphics.md)
93+
94+## Development
8195
8296 ```sh
8397 go test ./...
8498 go test ./... -race
8599 go vet ./...
86-go test ./... -run '^$' -bench='.' -benchmem
87100 ```
101+
102+[Storage benchmarks and design decisions →](docs/storage-optimization.md)
M ansi/benchmark_test.go +2 -3
@@ -206,9 +206,8 @@ func BenchmarkRendererScrollFastPath(b *testing.B) {
206206 }
207207
208208 scrolled := NewFrame(120, 40)
209- copy(scrolled.Cells[0:39*120], frame.Cells[120:40*120])
210- for i := 39 * 120; i < 40*120; i++ {
211- scrolled.Cells[i] = BlankCell()
209+ for y := 0; y < 39; y++ {
210+ scrolled.WriteRow(y, 0, frame.Row(y+1))
212211 }
213212 for x, r := range []rune("new output line") {
214213 scrolled.Set(x, 39, Cell{Rune: r, Style: DefaultStyle()})
M ansi/consumer_external_test.go +14 -0
@@ -4,10 +4,24 @@ import (
44 "bytes"
55 "testing"
66
7+ vt "github.com/bnema/vev-vt"
78 "github.com/bnema/vev-vt/ansi"
89 "github.com/bnema/vev-vt/core"
910 )
1011
12+func TestExternalANSIConsumesScreenCellSource(t *testing.T) {
13+ screen := vt.NewScreen(2, 1)
14+ screen.Write([]byte("OK"))
15+
16+ output, err := ansi.New(ansi.Capabilities{}).Draw(screen, []core.Damage{core.FullRedraw()})
17+ if err != nil {
18+ t.Fatalf("Draw: %v", err)
19+ }
20+ if !bytes.Contains(output, []byte("OK")) {
21+ t.Fatalf("ANSI output = %q, want visible screen content", output)
22+ }
23+}
24+
1125 func TestExternalANSIConsumesCoreFrameIndependently(t *testing.T) {
1226 frame := core.NewFrame(2, 1)
1327 frame.Set(0, 0, core.Cell{Rune: 'O', Style: core.DefaultStyle()})
M ansi/damage_plan.go +2 -2
@@ -10,7 +10,7 @@ const maxPlannedDamageSpans = 4096
1010 // buildDamagePlan creates the bounded, canonical view of text and clear damage
1111 // used for terminal emission. The source damage remains untouched because the
1212 // scroll and shadow paths need its original structural information.
13-func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool) {
13+func buildDamagePlan(frame CellSource, damage []Damage, skip *Damage) ([]Span, bool) {
1414 if len(damage) == 1 {
1515 d := damage[0]
1616 if skip != nil && sameDamage(d, *skip) {
@@ -52,7 +52,7 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool)
5252 return mergeDamageSpans(spans), false
5353 }
5454
55-func buildSingleDamageSpans(frame Frame, d Damage) ([]Span, bool) {
55+func buildSingleDamageSpans(frame CellSource, d Damage) ([]Span, bool) {
5656 x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
5757 if !ok {
5858 return nil, false
M ansi/delta_plan.go +33 -32
@@ -30,21 +30,21 @@ type DeltaCandidate struct {
3030 frame Frame
3131 }
3232
33-func newDeltaCandidate(frame Frame, plan DeltaPlan) DeltaCandidate {
33+func newDeltaCandidate(frame CellSource, plan DeltaPlan) DeltaCandidate {
3434 candidate := DeltaCandidate{Plan: plan}
3535 if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
36- candidate.frame = frame.Clone()
36+ candidate.frame = cloneCellSource(frame)
3737 }
3838 return candidate
3939 }
4040
4141 // PlanDelta prepares a non-mutating update from committed to frame.
42-func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {
43- if err := frame.Validate(); err != nil {
42+func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {
43+ if err := validateCellSource(frame); err != nil {
4444 return DeltaCandidate{}, err
4545 }
4646
47- if reset || frame.Width != committed.Width || frame.Height != committed.Height {
47+ if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() {
4848 return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil
4949 }
5050 if err := committed.Validate(); err != nil {
@@ -92,7 +92,7 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta
9292 return newDeltaCandidate(frame, plan), nil
9393 }
9494
95-func planSingleDamage(frame Frame, d Damage) DeltaPlan {
95+func planSingleDamage(frame CellSource, d Damage) DeltaPlan {
9696 spans, full := buildSingleDamageSpans(frame, d)
9797 if full {
9898 return DeltaPlan{Snapshot: true}
@@ -104,14 +104,12 @@ func planSingleDamage(frame Frame, d Damage) DeltaPlan {
104104 return plan
105105 }
106106
107-func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) {
107+func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) {
108108 var spans []Span
109- for y := range frame.Height {
110- frameRow := frame.Row(y)
111- committedRow := committed.Row(y)
109+ for y := range frame.Rows() {
112110 dirty := false
113- for x := range frame.Width {
114- if !frameRow[x].Equal(committedRow[x]) {
111+ for x := range frame.Columns() {
112+ if !frame.Cell(x, y).Equal(committed.Cell(x, y)) {
115113 dirty = true
116114 break
117115 }
@@ -120,14 +118,14 @@ func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) {
120118 if len(spans) == maxPlannedDamageSpans {
121119 return nil, true
122120 }
123- spans = append(spans, Span{Y: y, Width: frame.Width})
121+ spans = append(spans, Span{Y: y, Width: frame.Columns()})
124122 }
125123 }
126124 return spans, false
127125 }
128126
129-func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool {
130- if !hasScroll && frame.Height > 1 && len(spans) == 1 {
127+func deltaCostsSnapshot(frame CellSource, spans []Span, hasScroll bool) bool {
128+ if !hasScroll && frame.Rows() > 1 && len(spans) == 1 {
131129 return false
132130 }
133131
@@ -139,11 +137,11 @@ func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool {
139137 deltaCost += 12 + int64(span.Width)
140138 }
141139
142- minimumSnapshotCost := int64(frame.Height) * (12 + int64(frame.Width))
140+ minimumSnapshotCost := int64(frame.Rows()) * (12 + int64(frame.Columns()))
143141 if deltaCost < minimumSnapshotCost {
144142 return false
145143 }
146- maximumSnapshotCost := int64(frame.Height) * (8 + 5*int64(frame.Width))
144+ maximumSnapshotCost := int64(frame.Rows()) * (8 + 5*int64(frame.Columns()))
147145 if deltaCost >= maximumSnapshotCost {
148146 return true
149147 }
@@ -152,40 +150,43 @@ func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool {
152150 }
153151
154152 for _, span := range spans {
155- deltaCost += 4 * int64(styleRunCount(frame.Row(span.Y)[span.X:span.X+span.Width])-1)
153+ deltaCost += 4 * int64(styleRunCountIn(frame, span.Y, span.X, span.Width)-1)
156154 }
157155 var snapshotCost int64
158- for y := range frame.Height {
159- snapshotCost += spanCost(frame, y, 0, frame.Width)
156+ for y := range frame.Rows() {
157+ snapshotCost += spanCost(frame, y, 0, frame.Columns())
160158 }
161159 return deltaCost >= snapshotCost
162160 }
163161
164-func fullWidthRows(frame Frame, spans []Span) bool {
165- if len(spans) != frame.Height {
162+func fullWidthRows(frame CellSource, spans []Span) bool {
163+ if len(spans) != frame.Rows() {
166164 return false
167165 }
168166 for y, span := range spans {
169- if span.Y != y || span.X != 0 || span.Width != frame.Width {
167+ if span.Y != y || span.X != 0 || span.Width != frame.Columns() {
170168 return false
171169 }
172170 }
173171 return true
174172 }
175173
176-func spanCost(frame Frame, y, x, width int) int64 {
177- return 8 + int64(width) + 4*int64(styleRunCount(frame.Row(y)[x:x+width]))
174+func spanCost(frame CellSource, y, x, width int) int64 {
175+ return 8 + int64(width) + 4*int64(styleRunCountIn(frame, y, x, width))
178176 }
179177
180-func styleRunCount(cells []Cell) int {
181- if len(cells) == 0 {
178+func styleRunCountIn(frame CellSource, y, x, width int) int {
179+ if width == 0 {
182180 return 0
183181 }
184182 runs := 1
185- for i := 1; i < len(cells); i++ {
186- if cells[i-1].Style != cells[i].Style && !cells[i-1].Style.Equal(cells[i].Style) {
183+ previous := frame.Cell(x, y).Style
184+ for column := x + 1; column < x+width; column++ {
185+ style := frame.Cell(column, y).Style
186+ if previous != style && !previous.Equal(style) {
187187 runs++
188188 }
189+ previous = style
189190 }
190191 return runs
191192 }
@@ -204,9 +205,9 @@ func (c DeltaCandidate) Commit(dst *Frame) {
204205 dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)
205206 }
206207 for _, span := range c.Plan.Spans {
207- dstRow := dst.Row(span.Y)
208- srcRow := c.frame.Row(span.Y)
209- copy(dstRow[span.X:span.X+span.Width], srcRow[span.X:span.X+span.Width])
208+ for x := span.X; x < span.X+span.Width; x++ {
209+ dst.Set(x, span.Y, c.frame.Cell(x, span.Y))
210+ }
210211 }
211212 }
212213
M ansi/delta_plan_test.go +3 -8
@@ -169,7 +169,7 @@ func TestNoOpDeltaCandidateDoesNotCloneFrame(t *testing.T) {
169169 if err != nil {
170170 t.Fatalf("PlanDelta() error = %v", err)
171171 }
172- if candidate.frame.Width != 0 || candidate.frame.Height != 0 || candidate.frame.Cells != nil {
172+ if candidate.frame.Width != 0 || candidate.frame.Height != 0 {
173173 t.Fatal("no-op candidate owns frame storage")
174174 }
175175
@@ -202,12 +202,11 @@ func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) {
202202 }
203203 }
204204
205-func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) {
205+func TestDeltaCandidateCommitCopiesLogicalRows(t *testing.T) {
206206 frame := testFrame("aaaa", "bbbb", "cccc", "dddd")
207207 frame.ScrollUp(0, 3, 1)
208208 fillFrameRow(frame, 3, "new!")
209209 committed := NewFrame(4, 4)
210- cellsBase := &committed.Cells[0]
211210
212211 candidate, err := PlanDelta(frame, nil, committed, true)
213212 if err != nil {
@@ -215,9 +214,6 @@ func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) {
215214 }
216215 candidate.Commit(&committed)
217216
218- if &committed.Cells[0] != cellsBase {
219- t.Fatal("Commit replaced same-sized cell storage")
220- }
221217 for y := range frame.Height {
222218 if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) {
223219 t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y))
@@ -229,8 +225,7 @@ func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) {
229225 }
230226
231227 func TestPlanDeltaRejectsInvalidFrame(t *testing.T) {
232- frame := NewFrame(2, 1)
233- frame.Cells = nil
228+ frame := Frame{Width: 2, Height: 1}
234229 if _, err := PlanDelta(frame, nil, Frame{}, false); err == nil {
235230 t.Fatal("PlanDelta accepted an invalid frame")
236231 }
M ansi/doc.go +2 -2
@@ -2,8 +2,8 @@
22 // github.com/bnema/vev-vt/core model.
33 //
44 // The package owns output planning, ANSI encoding, and a transactional renderer
5-// shadow. It does not own Cell, Style, RGB, Frame, Damage, or RuneWidth; those
6-// values are aliases of the core package and are owned by core.
5+// shadow. Renderers consume the read-only CellSource contract. Cell, Style, RGB,
6+// Frame, CellSource, Damage, and RuneWidth are aliases of the core package.
77 //
88 // [New] preserves the historical truecolor behavior. A renderer created by
99 // [NewWithColorProfile] projects every color-bearing style field to its chosen
M ansi/draw_state.go +9 -8
@@ -165,22 +165,23 @@ const (
165165 // entirely blank full-width line may instead use a vertical-only move
166166 // plus 2K when that encoding is cheaper. Both clears require the default
167167 // pen (BCE: terminals fill cleared cells with the pen background).
168-func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st *drawState) {
168+func (r *Renderer) emitSpan(out *bytes.Buffer, frame CellSource, y, x, width int, st *drawState) {
169169 end := x + width
170+ columns := frame.Columns()
170171 // Trailing blank suffix is EL-eligible only when the span reaches the
171172 // frame's right edge (EL clears to the end of the line, nothing less).
172173 t := end
173- if end == frame.Width {
174- for t > x && isBlank(frame.At(t-1, y)) {
174+ if end == columns {
175+ for t > x && isBlank(frame.Cell(t-1, y)) {
175176 t--
176177 }
177178 }
178- useEL := end == frame.Width && end-t >= elLen
179+ useEL := end == columns && end-t >= elLen
179180 if !useEL {
180181 t = end
181182 }
182183
183- if useEL && t == x && x == 0 && width == frame.Width {
184+ if useEL && t == x && x == 0 && width == columns {
184185 // Entire line blank: compare "move to col 0 + EL" against
185186 // "vertical-only move + 2K" (2K clears the whole line from any
186187 // column, so a cheap same-column CUD/CUU can be reused).
@@ -195,7 +196,7 @@ func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st
195196
196197 st.moveTo(out, y, x)
197198 for col := x; col < t; col++ {
198- cell := frame.At(col, y)
199+ cell := frame.Cell(col, y)
199200 // Continuation cells are the right half of a wide rune. Emit nothing:
200201 // the terminal already advanced two columns for the left cell's rune.
201202 if cell.Continuation {
@@ -222,7 +223,7 @@ func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st
222223 out.WriteString("\x1b[K")
223224 // EL does not move the cursor: it stays at the start of the blanks.
224225 st.setCursor(y, t)
225- } else if end >= frame.Width {
226+ } else if end >= columns {
226227 st.invalidateCursor()
227228 } else {
228229 st.setCursor(y, end)
@@ -234,7 +235,7 @@ func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st
234235 // malformed inputs the VT layer should never produce, but tracking must
235236 // stay certain, so drop it. (t is never a continuation on the EL path —
236237 // blanks are not continuations — so this check is exact for both paths.)
237- if frame.At(x, y).Continuation || (t < frame.Width && frame.At(t, y).Continuation) {
238+ if frame.Cell(x, y).Continuation || (t < columns && frame.Cell(t, y).Continuation) {
238239 st.invalidateCursor()
239240 }
240241 }
M ansi/frame.go +48 -1
@@ -1,9 +1,56 @@
11 package ansi
22
3-import "github.com/bnema/vev-vt/core"
3+import (
4+ "fmt"
5+ "reflect"
6+
7+ "github.com/bnema/vev-vt/core"
8+)
49
510 // Frame is an alias for the frontend-neutral terminal grid. ANSI rendering
611 // consumes it but does not own its model or storage policy.
712 type Frame = core.Frame
813
14+// CellSource is the read-only semantic grid consumed by ANSI rendering.
15+type CellSource = core.CellSource
16+
917 var NewFrame = core.NewFrame
18+
19+func validateCellSource(source CellSource) error {
20+ if source == nil {
21+ return fmt.Errorf("nil cell source")
22+ }
23+ value := reflect.ValueOf(source)
24+ switch value.Kind() {
25+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
26+ if value.IsNil() {
27+ return fmt.Errorf("nil cell source")
28+ }
29+ }
30+ if source.Columns() <= 0 || source.Rows() <= 0 {
31+ return fmt.Errorf("invalid cell source size %dx%d", source.Columns(), source.Rows())
32+ }
33+ switch frame := source.(type) {
34+ case Frame:
35+ return frame.Validate()
36+ case *Frame:
37+ return frame.Validate()
38+ }
39+ return nil
40+}
41+
42+func cloneCellSource(source CellSource) Frame {
43+ switch frame := source.(type) {
44+ case Frame:
45+ return frame.Clone()
46+ case *Frame:
47+ return frame.Clone()
48+ }
49+ clone := NewFrame(source.Columns(), source.Rows())
50+ for y := range source.Rows() {
51+ for x := range source.Columns() {
52+ clone.Set(x, y, source.Cell(x, y))
53+ }
54+ }
55+ return clone
56+}
A ansi/frame_nil_test.go +50 -0
@@ -0,0 +1,50 @@
1+package ansi
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+type nilPointerSource struct{}
10+
11+func (*nilPointerSource) Columns() int { panic("nil source method called") }
12+func (*nilPointerSource) Rows() int { panic("nil source method called") }
13+func (*nilPointerSource) Cell(int, int) Cell { panic("nil source method called") }
14+
15+type nilSliceSource []int
16+
17+func (nilSliceSource) Columns() int { panic("nil source method called") }
18+func (nilSliceSource) Rows() int { panic("nil source method called") }
19+func (nilSliceSource) Cell(int, int) Cell { panic("nil source method called") }
20+
21+type nilMapSource map[int]int
22+
23+func (nilMapSource) Columns() int { panic("nil source method called") }
24+func (nilMapSource) Rows() int { panic("nil source method called") }
25+func (nilMapSource) Cell(int, int) Cell { panic("nil source method called") }
26+
27+type nilFuncSource func()
28+
29+func (nilFuncSource) Columns() int { panic("nil source method called") }
30+func (nilFuncSource) Rows() int { panic("nil source method called") }
31+func (nilFuncSource) Cell(int, int) Cell { panic("nil source method called") }
32+
33+type nilChanSource chan int
34+
35+func (nilChanSource) Columns() int { panic("nil source method called") }
36+func (nilChanSource) Rows() int { panic("nil source method called") }
37+func (nilChanSource) Cell(int, int) Cell { panic("nil source method called") }
38+
39+func TestRendererRejectsTypedNilSourcesBeforeCallingMethods(t *testing.T) {
40+ for name, source := range map[string]CellSource{
41+ "interface": nil, "frame": (*Frame)(nil), "pointer": (*nilPointerSource)(nil),
42+ "slice": nilSliceSource(nil), "map": nilMapSource(nil),
43+ "function": nilFuncSource(nil), "channel": nilChanSource(nil),
44+ } {
45+ t.Run(name, func(t *testing.T) {
46+ _, err := New(Capabilities{}).Prepare(source, nil, false)
47+ require.EqualError(t, err, "nil cell source")
48+ })
49+ }
50+}
A ansi/prepared_ownership_test.go +25 -0
@@ -0,0 +1,25 @@
1+package ansi
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestPreparedSnapshotCommitTransfersOnlyPrivatelyOwnedCells(t *testing.T) {
10+ r := New(Capabilities{})
11+ source := NewFrame(2, 1)
12+ source.Set(0, 0, Cell{Rune: 'a', Style: DefaultStyle()})
13+ prepared, err := r.Prepare(source, nil, true)
14+ require.NoError(t, err)
15+ alias := prepared
16+ encoded := append([]byte(nil), prepared.Bytes()...)
17+ source.Set(0, 0, Cell{Rune: 'b', Style: DefaultStyle()})
18+ prepared.Commit()
19+ require.Equal(t, 'a', r.committed.Cell(0, 0).Rune)
20+ _, err = r.Draw(source, []Damage{FullRedraw()})
21+ require.NoError(t, err)
22+ alias.Commit()
23+ 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+}
M ansi/renderer.go +31 -22
@@ -22,7 +22,7 @@ type Renderer struct {
2222 colorProfile ColorProfile
2323 width int
2424 height int
25- shadow []Cell
25+ hasCommitted bool
2626 committed Frame
2727 }
2828
@@ -47,7 +47,7 @@ func NewWithColorProfile(caps Capabilities, profile ColorProfile) *Renderer {
4747 func (r *Renderer) Reset() {
4848 r.width = 0
4949 r.height = 0
50- r.shadow = nil
50+ r.hasCommitted = false
5151 r.committed = Frame{}
5252 }
5353
@@ -63,7 +63,13 @@ func (p *PreparedDraw) Commit() {
6363 }
6464 p.commitOnce.Do(func() {
6565 committed := p.renderer.committedFrame()
66- p.candidate.Commit(&committed)
66+ if p.candidate.Plan.Snapshot {
67+ // PreparedDraw privately owns this snapshot. Its shared Once makes
68+ // transfer safe even when the caller retains copies of the draw.
69+ committed = p.candidate.frame
70+ } else {
71+ p.candidate.Commit(&committed)
72+ }
6773 p.renderer.setCommittedFrame(committed)
6874 })
6975 }
@@ -71,19 +77,21 @@ func (p *PreparedDraw) Commit() {
7177 // Prepare plans and encodes a transactional draw. The renderer advances only
7278 // when the returned draw is committed. Keep at most one prepared draw
7379 // outstanding; commit or discard it before calling Prepare again.
74-func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDraw, error) {
80+func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) {
7581 var candidate DeltaCandidate
7682 var err error
77- if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
78- if err = frame.Validate(); err == nil {
79- plan := planSingleDamage(frame, damage[0])
80- candidate = DeltaCandidate{Plan: plan}
81- if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
82- candidate.frame = frame.Clone()
83- }
83+ if err = validateCellSource(frame); err != nil {
84+ return PreparedDraw{}, err
85+ }
86+ 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) {
88+ plan := planSingleDamage(frame, damage[0])
89+ candidate = DeltaCandidate{Plan: plan}
90+ if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
91+ candidate.frame = cloneCellSource(frame)
8492 }
8593 } else {
86- candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0)
94+ candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted)
8795 }
8896 if err != nil {
8997 return PreparedDraw{}, err
@@ -114,7 +122,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr
114122 } else {
115123 if plan.Scroll.Height != 0 {
116124 scroll := plan.Scroll
117- emitScrollUp(buf, Damage{Kind: DamageScrollUp, X: 0, Y: scroll.Y, Width: frame.Width, Height: scroll.Height, Count: scroll.Count})
125+ emitScrollUp(buf, Damage{Kind: DamageScrollUp, X: 0, Y: scroll.Y, Width: columns, Height: scroll.Height, Count: scroll.Count})
118126 }
119127 for _, span := range plan.Spans {
120128 r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st)
@@ -128,7 +136,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr
128136 return prepared, nil
129137 }
130138
131-func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
139+func (r *Renderer) Draw(frame CellSource, damage []Damage) ([]byte, error) {
132140 prepared, err := r.Prepare(frame, damage, false)
133141 if err != nil {
134142 return nil, err
@@ -144,7 +152,7 @@ func (r *Renderer) committedFrame() Frame {
144152 func (r *Renderer) setCommittedFrame(frame Frame) {
145153 r.width = frame.Width
146154 r.height = frame.Height
147- r.shadow = frame.Cells
155+ r.hasCommitted = true
148156 r.committed = frame
149157 }
150158
@@ -185,22 +193,23 @@ func hasScrollDamage(damage []Damage) bool {
185193 return false
186194 }
187195
188-func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
189- for y := range frame.Height {
190- r.emitSpan(out, frame, y, 0, frame.Width, st)
196+func (r *Renderer) writeFull(out *bytes.Buffer, frame CellSource, st *drawState) {
197+ columns := frame.Columns()
198+ for y := range frame.Rows() {
199+ r.emitSpan(out, frame, y, 0, columns, st)
191200 }
192201 out.WriteString("\x1b[0m")
193202 }
194203
195-func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []Span, st *drawState) {
204+func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame CellSource, spans []Span, st *drawState) {
196205 for _, span := range spans {
197206 r.emitSpan(out, frame, span.Y, span.X, span.Width, st)
198207 }
199208 out.WriteString("\x1b[0m")
200209 }
201-func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {
202- x, width, okX := clampRange(x, width, frame.Width)
203- y, height, okY := clampRange(y, height, frame.Height)
210+func clampRect(frame CellSource, x, y, width, height int) (int, int, int, int, bool) {
211+ x, width, okX := clampRange(x, width, frame.Columns())
212+ y, height, okY := clampRange(y, height, frame.Rows())
204213 if !okX || !okY {
205214 return 0, 0, 0, 0, false
206215 }
M ansi/renderer_test.go +16 -19
@@ -146,7 +146,7 @@ func TestPrepareNoOpDoesNotCloneFrame(t *testing.T) {
146146 if err != nil {
147147 t.Fatal(err)
148148 }
149- if prepared.candidate.frame.Width != 0 || prepared.candidate.frame.Height != 0 || prepared.candidate.frame.Cells != nil {
149+ if prepared.candidate.frame.Width != 0 || prepared.candidate.frame.Height != 0 {
150150 t.Fatal("no-op prepared draw owns frame storage")
151151 }
152152 prepared.Commit()
@@ -193,10 +193,8 @@ func TestStyleResetDiscipline(t *testing.T) {
193193
194194 // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage.
195195 frame2 := NewFrame(4, 3)
196- copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2
197- for i := 8; i < 12; i++ {
198- frame2.Cells[i] = BlankCell() // bottom row blank
199- }
196+ frame2.WriteRow(0, 0, frame.Row(1))
197+ frame2.WriteRow(1, 0, frame.Row(2))
200198 frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row
201199
202200 damage := []Damage{
@@ -353,9 +351,8 @@ func TestScrollFastPath(t *testing.T) {
353351
354352 // Build a new frame that has been scrolled up by 1 (like the VT model does).
355353 scrolled := NewFrame(5, 4)
356- copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3
357- for i := 15; i < 20; i++ {
358- scrolled.Cells[i] = BlankCell() // row 3 blanked
354+ for y := 0; y < 3; y++ {
355+ scrolled.WriteRow(y, 0, frame.Row(y+1))
359356 }
360357 scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()})
361358 scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()})
@@ -567,8 +564,8 @@ func TestReset(t *testing.T) {
567564
568565 r.Reset()
569566
570- if r.width != 0 || r.height != 0 || r.shadow != nil {
571- t.Fatal("Reset should clear width/height/shadow")
567+ if r.width != 0 || r.height != 0 || r.hasCommitted {
568+ t.Fatal("Reset should clear committed state")
572569 }
573570
574571 // After reset, the next draw should be a full draw.
@@ -692,10 +689,8 @@ func TestScrollOnlyDamage(t *testing.T) {
692689
693690 // Scroll up 1 but the VT model would also emit DamageText for the new bottom row.
694691 scrolled := NewFrame(4, 3)
695- copy(scrolled.Cells[0:8], frame.Cells[4:12])
696- for i := 8; i < 12; i++ {
697- scrolled.Cells[i] = BlankCell()
698- }
692+ scrolled.WriteRow(0, 0, frame.Row(1))
693+ scrolled.WriteRow(1, 0, frame.Row(2))
699694 scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()})
700695
701696 damage := []Damage{
@@ -838,7 +833,7 @@ func TestFrameValidate(t *testing.T) {
838833 }{
839834 {name: "valid frame", frame: NewFrame(5, 3), wantErr: false},
840835 {name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true},
841- {name: "wrong cell count", frame: Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}, wantErr: true},
836+ {name: "missing storage", frame: Frame{Width: 2, Height: 2}, wantErr: true},
842837 }
843838
844839 for _, tt := range tests {
@@ -995,9 +990,11 @@ func TestDrawFallbackAndClampingEdgeCases(t *testing.T) {
995990 if rows := strings.Count(string(out), "H"); rows < changed.Height {
996991 t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out))
997992 }
998- for i := range changed.Cells {
999- if !r.shadow[i].Equal(changed.Cells[i]) {
1000- t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i])
993+ for y := range changed.Height {
994+ for x := range changed.Width {
995+ if !r.committed.Cell(x, y).Equal(changed.Cell(x, y)) {
996+ t.Fatalf("committed cell (%d,%d) = %+v, want %+v", x, y, r.committed.Cell(x, y), changed.Cell(x, y))
997+ }
1001998 }
1002999 }
10031000 },
@@ -1400,7 +1397,7 @@ func TestRendererPrepare(t *testing.T) {
14001397
14011398 func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {
14021399 frame := NewFrame(2, 1)
1403- committed := Frame{Width: 2, Height: 1, Cells: make([]Cell, 2)}
1400+ committed := Frame{Width: 2, Height: 1}
14041401
14051402 if _, err := PlanDelta(frame, nil, committed, false); err == nil {
14061403 t.Fatal("PlanDelta accepted an invalid committed frame")
M ansi/scroll.go +27 -7
@@ -5,7 +5,7 @@ import (
55 "strconv"
66 )
77
8-func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) {
8+func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) {
99 for _, d := range damage {
1010 if d.Kind == DamageScrollUp && isSafeScroll(frame, d) {
1111 return d, true
@@ -14,8 +14,8 @@ func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) {
1414 return Damage{}, false
1515 }
1616
17-func isSafeScroll(frame Frame, d Damage) bool {
18- return d.X == 0 && d.Width == frame.Width && d.Y >= 0 && d.Y <= frame.Height && d.Height > 0 && d.Height <= frame.Height-d.Y && d.Count > 0 && d.Count < d.Height
17+func isSafeScroll(frame CellSource, d Damage) bool {
18+ return d.X == 0 && d.Width == frame.Columns() && d.Y >= 0 && d.Y <= frame.Rows() && d.Height > 0 && d.Height <= frame.Rows()-d.Y && d.Count > 0 && d.Count < d.Height
1919 }
2020
2121 func emitScrollUp(out *bytes.Buffer, d Damage) {
@@ -42,13 +42,33 @@ func emitScrollUp(out *bytes.Buffer, d Damage) {
4242 out.WriteString("\x1b[r")
4343 }
4444
45-func canApplyScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {
45+func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool {
46+ switch frame := frame.(type) {
47+ case Frame:
48+ return canApplyDenseScrollAgainst(frame, scroll, damage, committed)
49+ case *Frame:
50+ return canApplyDenseScrollAgainst(*frame, scroll, damage, committed)
51+ }
52+ for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {
53+ for x := range scroll.Width {
54+ column := scroll.X + x
55+ committedCell, frameCell := committed.Cell(column, y+scroll.Count), frame.Cell(column, y)
56+ if committedCell == frameCell || committedCell.Equal(frameCell) {
57+ continue
58+ }
59+ if !damageCoversCell(damage, column, y) {
60+ return false
61+ }
62+ }
63+ }
64+ return true
65+}
66+
67+func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {
4668 for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {
47- frameRow := frame.Row(y)
48- committedRow := committed.Row(y + scroll.Count)
4969 for x := range scroll.Width {
5070 column := scroll.X + x
51- committedCell, frameCell := committedRow[column], frameRow[column]
71+ committedCell, frameCell := committed.At(column, y+scroll.Count), frame.At(column, y)
5272 if committedCell == frameCell || committedCell.Equal(frameCell) {
5373 continue
5474 }
M benchmark_test.go +2 -2
@@ -32,8 +32,8 @@ func TestHistoryViewPartialTailAllocationBounded(t *testing.T) {
3232
3333 // Copying a partial tail costs one allocation per row plus a fixed set of
3434 // headers: the chunk slice, its growth for the tail chunk, the chunk itself,
35- // the row slice, and the bounds slice parallel to it.
36- const tailHeaders = 4
35+ // the row slice, bounds, and logical-style metadata parallel to it.
36+ const tailHeaders = 6
3737
3838 sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() })
3939 partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() })
M buffer.go +20 -25
@@ -24,13 +24,9 @@ func newBuffer(width, height int) *buffer {
2424 return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height), rowIDs: make([]RowID, height)}
2525 }
2626
27-func bufferFromFrame(frame renderer.Frame) *buffer {
28- return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height), rowIDs: make([]RowID, frame.Height)}
29-}
30-
3127 func (b *buffer) clone() *buffer {
3228 out := &buffer{
33- frame: cloneFrame(b.frame),
29+ frame: b.frame.Clone(),
3430 boundaries: append([]LineBound(nil), b.boundaries...),
3531 rowIDs: append([]RowID(nil), b.rowIDs...),
3632 }
@@ -169,7 +165,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([]
169165 evictedBounds := make([]LineBound, 0, shift)
170166 evictedIDs := make([]RowID, 0, shift)
171167 for y := range shift {
172- evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
168+ evicted = append(evicted, b.frame.Row(y))
173169 // The evicted row keeps its source width, so its extent needs no clamping.
174170 evictedBounds = append(evictedBounds, b.boundaries[y])
175171 evictedIDs = append(evictedIDs, b.rowIDs[y])
@@ -190,7 +186,9 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([]
190186 if sy >= b.frame.Height {
191187 break
192188 }
193- copy(next.frame.Row(y), b.frame.Row(sy))
189+ for x := range min(width, b.frame.Width) {
190+ next.frame.Set(x, y, b.frame.At(x, sy))
191+ }
194192 next.boundaries[y] = b.boundaries[sy]
195193 next.rowIDs[y] = b.rowIDs[sy]
196194 next.boundaries[y].End = min(next.boundaries[y].End, width)
@@ -312,23 +310,24 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
312310 dst.rowIDs[outputRow-shift] = id
313311 }
314312 }
315- blankEvicted := func(outputRow int) []renderer.Cell {
313+ blankOutput := func(outputRow int) {
316314 if outputRow < shift {
317315 out := evicted[outputRow*width : (outputRow+1)*width]
318316 for i := range out {
319317 out[i] = renderer.BlankCell()
320318 }
321- return out
319+ }
320+ }
321+ setOutput := func(outputRow, column int, cell renderer.Cell) {
322+ if outputRow < shift {
323+ evicted[outputRow*width+column] = cell
324+ return
322325 }
323326 if dst != nil && outputRow < shift+dst.frame.Height {
324- return dst.frame.Row(outputRow - shift)
327+ dst.frame.Set(column, outputRow-shift, cell)
325328 }
326- return nil
327- }
328- var output []renderer.Cell
329- if dst != nil && (shift > 0 || dst.frame.Height > 0) {
330- output = blankEvicted(0)
331329 }
330+ blankOutput(0)
332331 finishRow := func(soft bool) {
333332 // An output row belongs either to history or to the retained viewport, and
334333 // only this pass knows where it ended, so both destinations are written here.
@@ -344,7 +343,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
344343 row++
345344 col = 0
346345 if dst != nil {
347- output = blankEvicted(row)
346+ blankOutput(row)
348347 }
349348 }
350349 setPoint := func(line, offset, pointRow, pointCol int) {
@@ -381,17 +380,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
381380 }
382381 truncated := false
383382 for y := start; y <= end && !truncated; y++ {
384- cells := b.frame.Row(y)
385383 limit := b.boundaries[y].End
386384 for x := 0; x < limit; {
387- cell := cells[x]
385+ cell := b.frame.At(x, y)
388386 if cell.Continuation { // Repair malformed rows by dropping orphaned tails.
389387 setPoint(start, offset, row, col)
390388 x++
391389 offset++
392390 continue
393391 }
394- wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation
392+ wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && b.frame.At(x+1, y).Continuation
395393 w := 1
396394 if wide && width >= 2 {
397395 w = 2
@@ -406,9 +404,8 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
406404 }
407405 setPoint(start, offset, row, col)
408406 if wide && width >= 2 {
409- if output != nil {
410- output[col], output[col+1] = cell, cells[x+1]
411- }
407+ setOutput(row, col, cell)
408+ setOutput(row, col+1, b.frame.At(x+1, y))
412409 setPoint(start, offset+1, row, col+1)
413410 col += 2
414411 x += 2
@@ -419,9 +416,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
419416 cell.Rune = '\uFFFD'
420417 cell.Continuation = false
421418 }
422- if output != nil {
423- output[col] = cell
424- }
419+ setOutput(row, col, cell)
425420 col++
426421 x++
427422 offset++
M core/cell.go +2 -1
@@ -9,11 +9,12 @@ package core
99 type Cell struct {
1010 Rune rune
1111 Style Style
12+ Payload CellPayload
1213 Continuation bool
1314 }
1415
1516 func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} }
1617
1718 func (c Cell) Equal(other Cell) bool {
18- return c.Rune == other.Rune && c.Continuation == other.Continuation && c.Style.Equal(other.Style)
19+ return c.Rune == other.Rune && c.Continuation == other.Continuation && c.Payload == other.Payload && c.Style.Equal(other.Style)
1920 }
A core/cell_test.go +69 -0
@@ -0,0 +1,69 @@
1+package core
2+
3+import "testing"
4+
5+func TestBlankCellIsSpaceWithDefaultStyle(t *testing.T) {
6+ want := Cell{Rune: ' ', Style: DefaultStyle()}
7+ if got := BlankCell(); got != want {
8+ t.Fatalf("BlankCell() = %+v, want %+v", got, want)
9+ }
10+ if BlankCell() == (Cell{}) {
11+ t.Fatal("blank cell unexpectedly equals the zero Cell")
12+ }
13+}
14+
15+func TestCellEqualUsesSemanticStyleEquality(t *testing.T) {
16+ leftStyle := Style{
17+ Foreground: 7,
18+ HasForegroundRGB: true,
19+ ForegroundRGB: RGB{R: 1, G: 2, B: 3},
20+ Background: -1,
21+ BackgroundRGB: RGB{R: 4, G: 5, B: 6},
22+ UnderlineColor: 9,
23+ UnderlineColorRGB: RGB{R: 7, G: 8, B: 9},
24+ }
25+ rightStyle := leftStyle
26+ rightStyle.Foreground = 99
27+ rightStyle.BackgroundRGB = RGB{R: 10, G: 11, B: 12}
28+ rightStyle.UnderlineColor = 42
29+
30+ left := Cell{Rune: 'x', Style: leftStyle}
31+ right := Cell{Rune: 'x', Style: rightStyle}
32+ if !left.Equal(right) {
33+ t.Fatal("cells with equal active style fields must compare equal")
34+ }
35+ if left == right {
36+ t.Fatal("fixture must differ in inactive raw style fields")
37+ }
38+}
39+
40+func TestCellEqualIncludesPayload(t *testing.T) {
41+ payload, err := NewCellPayload("e\u0301", "https://example.com")
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ plain := Cell{Rune: 'e', Style: DefaultStyle()}
46+ rich := plain
47+ rich.Payload = payload
48+ if plain.Equal(rich) || rich.Equal(plain) {
49+ t.Fatal("different payloads must not compare equal")
50+ }
51+ if !rich.Equal(rich) {
52+ t.Fatal("payload cell must equal itself")
53+ }
54+}
55+
56+func TestContinuationCellSemanticsRemainExplicit(t *testing.T) {
57+ style := DefaultStyle()
58+ head := Cell{Rune: '界', Style: style}
59+ tail := Cell{Style: style, Continuation: true}
60+ if head.Continuation || head.Rune != '界' {
61+ t.Fatalf("wide head = %+v", head)
62+ }
63+ if !tail.Continuation || tail.Rune != 0 {
64+ t.Fatalf("wide tail = %+v", tail)
65+ }
66+ if head.Equal(tail) {
67+ t.Fatal("wide head and continuation tail unexpectedly compare equal")
68+ }
69+}
M core/doc.go +4 -3
@@ -1,7 +1,8 @@
11 // Package vtcore defines the frontend-neutral terminal model shared by the VT
22 // emulator and concrete ANSI renderers.
33 //
4-// Frame and other mutable values are single-owner. Frame.Row returns a backing
5-// slice that is valid until the next scroll or resize; Frame.Clone returns
6-// independent storage. The package has no frontend, transport, or vev policy.
4+// Frame and other mutable values are single-owner. CellSource provides
5+// storage-independent semantic reads, Frame.Row returns owned storage, and
6+// Frame.Clone returns an independent frame. The package has no frontend,
7+// transport, or vev policy.
78 package core
M core/frame.go +323 -104
@@ -1,169 +1,388 @@
11 package core
22
3-import "fmt"
4-
5-// Frame is a fixed-size grid of cells. Logical rows are decoupled from their
6-// physical storage through lineOffset: logical row y lives at
7-// Cells[lineOffset[y] : lineOffset[y]+Width]. This indirection lets full-width
8-// scrolls rotate offsets instead of copying cell memory (NeoVim's grid
9-// technique; cf. nvim grid.c line_offset[]). All cell access must go through
10-// the accessors (At/Set/Row) so callers never assume a physical layout.
3+import (
4+ "fmt"
5+ "maps"
6+ "math"
7+)
8+
9+// CellSource exposes semantic terminal cells without exposing mutable storage.
10+// Implementations may use any physical layout; callers must use Cell for reads.
11+type CellSource interface {
12+ Columns() int
13+ Rows() int
14+ Cell(x, y int) Cell
15+}
16+
17+const (
18+ // StoredCellLogicalBytes is the deterministic uncompressed size of one
19+ // compact cell, independent of the Go allocator and host architecture.
20+ StoredCellLogicalBytes uint64 = 16
21+ // RowDescriptorLogicalBytes is the deterministic size of one compact row
22+ // descriptor.
23+ RowDescriptorLogicalBytes uint64 = 4
24+ // StyleRecordLogicalBytes is the deterministic size of one canonical
25+ // page-local style record.
26+ StyleRecordLogicalBytes uint64 = 32
27+
28+ continuationFlag = uint8(1)
29+)
30+
31+type storedCell struct {
32+ rune int32
33+ styleID uint32
34+ payloadID uint32
35+ flags uint8
36+}
37+
38+type styleSlot struct {
39+ style Style
40+ refs uint32
41+ used bool
42+}
43+
44+type cellPage struct {
45+ cells []storedCell
46+ rows []uint32
47+ styles []styleSlot
48+ styleIndex map[Style]uint32
49+ freeStyles []uint32
50+ styleCount uint32
51+ payloads []payloadSlot
52+ payloadIndex map[CellPayload]uint32
53+ freePayloads []uint32
54+ payloadBytes uint64
55+}
56+
57+// Frame is a fixed-size semantic grid backed by one compact page. Cells carry
58+// page-local style IDs; callers never observe those IDs or the physical row
59+// layout. Logical rows are mapped through compact row descriptors so full-width
60+// scrolling rotates descriptors instead of copying cells.
1161 type Frame struct {
1262 Width int
1363 Height int
14- Cells []Cell
15- // lineOffset maps a logical row index to its physical base index into
16- // Cells. It is always a permutation of the canonical offsets y*Width.
17- lineOffset []int
64+ page *cellPage
1865 }
1966
2067 func NewFrame(width, height int) Frame {
21- cells := make([]Cell, width*height)
22- for i := range cells {
23- cells[i] = BlankCell()
68+ cellCount, ok := frameCellCount(width, height)
69+ if !ok {
70+ panic("frame cell count exceeds page limit")
71+ }
72+ page := &cellPage{
73+ cells: make([]storedCell, cellCount),
74+ rows: make([]uint32, height),
75+ styles: []styleSlot{{style: DefaultStyle(), refs: uint32(cellCount), used: true}},
76+ styleIndex: map[Style]uint32{DefaultStyle(): 0},
77+ styleCount: 1,
78+ }
79+ for i := range page.cells {
80+ page.cells[i].rune = ' '
2481 }
25- lineOffset := make([]int, height)
26- for y := range lineOffset {
27- lineOffset[y] = y * width
82+ for y := range page.rows {
83+ page.rows[y] = uint32(y * width)
2884 }
29- return Frame{Width: width, Height: height, Cells: cells, lineOffset: lineOffset}
85+ return Frame{Width: width, Height: height, page: page}
3086 }
3187
32-// Clone returns an independent copy preserving logical row contents. The clone
33-// uses canonical row storage so later scrolls or writes to the source frame do
34-// not affect it.
88+// Clone returns an independent compact page preserving physical row rotation
89+// and page-local style IDs.
3590 func (f Frame) Clone() Frame {
36- clone := NewFrame(f.Width, f.Height)
37- for y := range f.Height {
38- copy(clone.Row(y), f.Row(y))
91+ if f.page == nil {
92+ return Frame{Width: f.Width, Height: f.Height}
93+ }
94+ return Frame{
95+ Width: f.Width,
96+ Height: f.Height,
97+ page: &cellPage{
98+ cells: append([]storedCell(nil), f.page.cells...),
99+ rows: append([]uint32(nil), f.page.rows...),
100+ styles: append([]styleSlot(nil), f.page.styles...),
101+ styleIndex: maps.Clone(f.page.styleIndex),
102+ freeStyles: append([]uint32(nil), f.page.freeStyles...),
103+ styleCount: f.page.styleCount,
104+ payloads: append([]payloadSlot(nil), f.page.payloads...),
105+ payloadIndex: maps.Clone(f.page.payloadIndex),
106+ freePayloads: append([]uint32(nil), f.page.freePayloads...),
107+ payloadBytes: f.page.payloadBytes,
108+ },
39109 }
40- return clone
41110 }
42111
43-// Replace copies src into f while reusing canonical storage when dimensions
44-// permit. src is read through logical rows so its physical row rotation is
45-// preserved in the resulting contents without exposing layout internals.
112+// Replace replaces f with an independent structural copy of src's compact page.
113+// An invalid or empty source leaves the destination unchanged.
46114 func (f *Frame) Replace(src Frame) {
47115 if f == nil {
48116 return
49117 }
50- if src.Width <= 0 || src.Height <= 0 || len(src.Cells) != src.Width*src.Height {
51- *f = Frame{}
118+ if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 {
52119 return
53120 }
121+ *f = src.Clone()
122+}
54123
55- sameDimensions := f.Width == src.Width && f.Height == src.Height
56- cellCount := src.Width * src.Height
57- if sameDimensions && cap(f.Cells) >= cellCount {
58- f.Cells = f.Cells[:cellCount]
59- } else {
60- f.Cells = make([]Cell, cellCount)
124+func frameCellCount(width, height int) (int, bool) {
125+ if width < 0 || height < 0 {
126+ return 0, false
61127 }
62- if sameDimensions && cap(f.lineOffset) >= src.Height {
63- f.lineOffset = f.lineOffset[:src.Height]
64- } else {
65- f.lineOffset = make([]int, src.Height)
128+ limit := min(uint64(math.MaxUint32), uint64(math.MaxInt))
129+ w, h := uint64(width), uint64(height)
130+ if w > limit || h > limit || w != 0 && w*h > limit {
131+ return 0, false
132+ }
133+ return int(w * h), true
134+}
135+
136+func (f Frame) validateStorage() error {
137+ if f.page == nil {
138+ return fmt.Errorf("missing frame page")
66139 }
67- f.Width = src.Width
68- f.Height = src.Height
69- for y := range f.lineOffset {
70- f.lineOffset[y] = y * f.Width
140+ cellCount, ok := frameCellCount(f.Width, f.Height)
141+ if !ok {
142+ return fmt.Errorf("frame cell count exceeds page limit")
71143 }
72- for y := range src.Height {
73- copy(f.Row(y), src.Row(y))
144+ if len(f.page.cells) != cellCount {
145+ return fmt.Errorf("invalid cell count: got %d want %d", len(f.page.cells), cellCount)
74146 }
147+ if len(f.page.rows) != f.Height {
148+ return fmt.Errorf("row descriptor count: got %d want %d", len(f.page.rows), f.Height)
149+ }
150+ return nil
75151 }
76152
77153 func (f Frame) Validate() error {
78154 if f.Width <= 0 || f.Height <= 0 {
79155 return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height)
80156 }
81- if len(f.Cells) != f.Width*f.Height {
82- return fmt.Errorf("invalid cell count: got %d want %d", len(f.Cells), f.Width*f.Height)
83- }
84- if len(f.lineOffset) != f.Height {
85- return fmt.Errorf("invalid lineOffset length: got %d want %d", len(f.lineOffset), f.Height)
86- }
87- return nil
157+ return f.validateStorage()
88158 }
89159
90-// CheckInvariants verifies that lineOffset is a permutation of the canonical
91-// row offsets: every entry is a non-negative multiple of Width within bounds,
92-// and every logical row maps to a distinct physical row. It is used by tests
93-// and assertions; the hot path uses the lighter Validate.
160+// CheckInvariants validates row ownership, style references, dictionary keys,
161+// and exact reference counts. It is intended for tests and assertions.
94162 func (f Frame) CheckInvariants() error {
95- if f.Width <= 0 {
96- return fmt.Errorf("invalid width %d", f.Width)
163+ if err := f.validateStorage(); err != nil {
164+ return err
97165 }
98- if len(f.lineOffset) != f.Height {
99- return fmt.Errorf("lineOffset length %d != height %d", len(f.lineOffset), f.Height)
166+ seenRows := make([]bool, f.Height)
167+ refs := make([]uint32, len(f.page.styles))
168+ for y, offset := range f.page.rows {
169+ if f.Width == 0 {
170+ if offset != 0 {
171+ return fmt.Errorf("row %d: nonzero offset %d for zero width", y, offset)
172+ }
173+ continue
174+ }
175+ if offset%uint32(f.Width) != 0 {
176+ return fmt.Errorf("row %d: offset %d is not a multiple of width %d", y, offset, f.Width)
177+ }
178+ physical := int(offset) / f.Width
179+ if physical >= f.Height || seenRows[physical] {
180+ return fmt.Errorf("row %d: invalid or duplicate physical row %d", y, physical)
181+ }
182+ seenRows[physical] = true
183+ }
184+ for i, cell := range f.page.cells {
185+ if int(cell.styleID) >= len(f.page.styles) || !f.page.styles[cell.styleID].used {
186+ return fmt.Errorf("cell %d: unresolved style ID %d", i, cell.styleID)
187+ }
188+ refs[cell.styleID]++
100189 }
101- seen := make([]bool, f.Height)
102- for y, off := range f.lineOffset {
103- if off < 0 || off%f.Width != 0 {
104- return fmt.Errorf("row %d: offset %d is not a non-negative multiple of width %d", y, off, f.Width)
190+ free := make([]bool, len(f.page.styles))
191+ for position, id := range f.page.freeStyles {
192+ if int(id) >= len(f.page.styles) {
193+ return fmt.Errorf("free style entry %d has out-of-range ID %d", position, id)
105194 }
106- phys := off / f.Width
107- if phys >= f.Height {
108- return fmt.Errorf("row %d: offset %d maps to physical row %d out of range [0,%d)", y, off, phys, f.Height)
195+ if f.page.styles[id].used {
196+ return fmt.Errorf("free style entry %d identifies used style %d", position, id)
109197 }
110- if seen[phys] {
111- return fmt.Errorf("row %d: physical row %d already mapped by another logical row", y, phys)
198+ if free[id] {
199+ return fmt.Errorf("free style ID %d appears more than once", id)
112200 }
113- seen[phys] = true
201+ free[id] = true
114202 }
115- return nil
203+ var used uint32
204+ for id, slot := range f.page.styles {
205+ if !slot.used {
206+ if slot.refs != 0 {
207+ return fmt.Errorf("free style %d has %d references", id, slot.refs)
208+ }
209+ if !free[id] {
210+ return fmt.Errorf("unused style %d is missing from free list", id)
211+ }
212+ continue
213+ }
214+ used++
215+ if slot.refs != refs[id] {
216+ return fmt.Errorf("style %d references = %d want %d", id, slot.refs, refs[id])
217+ }
218+ if slot.style != slot.style.Canonical() {
219+ return fmt.Errorf("style %d is not canonical", id)
220+ }
221+ if indexed, ok := f.page.styleIndex[slot.style]; !ok || indexed != uint32(id) {
222+ return fmt.Errorf("style %d missing from index", id)
223+ }
224+ }
225+ if len(f.page.styles) == 0 || !f.page.styles[0].used || f.page.styles[0].style != DefaultStyle() {
226+ return fmt.Errorf("style ID zero is not the default style")
227+ }
228+ if used != f.page.styleCount || len(f.page.styleIndex) != int(used) {
229+ return fmt.Errorf("style count = %d/%d, index entries %d", used, f.page.styleCount, len(f.page.styleIndex))
230+ }
231+ return f.checkPayloadInvariants()
116232 }
117233
118-func (f Frame) At(x, y int) Cell {
119- return f.Cells[f.lineOffset[y]+x]
234+// LogicalBytes returns deterministic uncompressed page bytes independent of Go
235+// pointer size, map capacity, and allocator overhead.
236+func (f Frame) LogicalBytes() uint64 {
237+ if f.page == nil {
238+ return 0
239+ }
240+ return uint64(len(f.page.cells))*StoredCellLogicalBytes + uint64(len(f.page.rows))*RowDescriptorLogicalBytes + uint64(f.page.styleCount)*StyleRecordLogicalBytes + f.page.payloadBytes
120241 }
121242
243+// StyleCount returns the number of live page-local styles, including default ID zero.
244+func (f Frame) StyleCount() int {
245+ if f.page == nil {
246+ return 0
247+ }
248+ return int(f.page.styleCount)
249+}
250+
251+func (f Frame) Columns() int { return f.Width }
252+func (f Frame) Rows() int { return f.Height }
253+func (f Frame) Cell(x, y int) Cell {
254+ stored := f.page.cells[f.offset(x, y)]
255+ return Cell{Rune: rune(stored.rune), Style: f.page.styles[stored.styleID].style, Payload: f.payload(stored.payloadID), Continuation: stored.flags&continuationFlag != 0}
256+}
257+func (f Frame) At(x, y int) Cell { return f.Cell(x, y) }
258+
122259 func (f Frame) Set(x, y int, cell Cell) {
123- f.Cells[f.lineOffset[y]+x] = cell
260+ index := f.offset(x, y)
261+ payloadID := f.internPayload(cell.Payload)
262+ f.releasePayload(f.page.cells[index].payloadID)
263+ oldID := f.page.cells[index].styleID
264+ styleID := f.internStyle(cell.Style)
265+ if oldID == styleID {
266+ f.releaseStyle(styleID) // cancel internStyle's temporary reference
267+ } else {
268+ f.releaseStyle(oldID)
269+ }
270+ flags := uint8(0)
271+ if cell.Continuation {
272+ flags = continuationFlag
273+ }
274+ f.page.cells[index] = storedCell{rune: int32(cell.Rune), styleID: styleID, payloadID: payloadID, flags: flags}
124275 }
125276
126-// Row returns the backing slice for logical row y. Mutating the returned slice
127-// mutates the frame in place. The slice is valid until the next scroll or
128-// resize that reassigns offsets.
129277 func (f Frame) Row(y int) []Cell {
130- base := f.lineOffset[y]
131- return f.Cells[base : base+f.Width]
278+ if y < 0 || y >= f.Height {
279+ panic("frame row out of range")
280+ }
281+ row := make([]Cell, f.Width)
282+ for x := range row {
283+ row[x] = f.Cell(x, y)
284+ }
285+ return row
286+}
287+
288+func (f Frame) WriteRow(y, x int, cells []Cell) int {
289+ count := min(len(cells), f.Width-x)
290+ for i := range count {
291+ f.Set(x+i, y, cells[i])
292+ }
293+ return count
294+}
295+
296+func (f Frame) CopyRow(y, dst, src, count int) {
297+ if count <= 0 || dst == src {
298+ return
299+ }
300+ if dst < src {
301+ for i := range count {
302+ f.Set(dst+i, y, f.Cell(src+i, y))
303+ }
304+ return
305+ }
306+ for i := count - 1; i >= 0; i-- {
307+ f.Set(dst+i, y, f.Cell(src+i, y))
308+ }
309+}
310+
311+func (f Frame) FillRow(y, start, end int, cell Cell) {
312+ for x := max(0, start); x < min(f.Width, end); x++ {
313+ f.Set(x, y, cell)
314+ }
132315 }
133316
134-// ScrollUp scrolls the logical rows in [top,bottom] up by n lines by rotating
135-// their physical offsets: no per-cell copying. Rows scrolled off the top are
136-// recycled to the bottom and blanked in place. It assumes the full frame width
137-// and 0 <= top <= bottom < Height and 0 < n <= bottom-top+1 (the caller
138-// clamps). The receiver is a value, but lineOffset and Cells alias the caller's
139-// backing arrays, so in-place mutations are visible to the caller.
140317 func (f Frame) ScrollUp(top, bottom, n int) {
141318 for ; n > 0; n-- {
142- recycled := f.lineOffset[top]
143- for y := top; y < bottom; y++ {
144- f.lineOffset[y] = f.lineOffset[y+1]
145- }
146- f.lineOffset[bottom] = recycled
147- blankRow(f.Cells[recycled : recycled+f.Width])
319+ recycled := f.page.rows[top]
320+ copy(f.page.rows[top:bottom], f.page.rows[top+1:bottom+1])
321+ f.page.rows[bottom] = recycled
322+ f.blankPhysicalRow(recycled)
148323 }
149324 }
150325
151-// ScrollDown scrolls the logical rows in [top,bottom] down by n lines by
152-// rotating their physical offsets. Rows scrolled off the bottom are recycled
153-// to the top and blanked in place. See ScrollUp for preconditions.
154326 func (f Frame) ScrollDown(top, bottom, n int) {
155327 for ; n > 0; n-- {
156- recycled := f.lineOffset[bottom]
157- for y := bottom; y > top; y-- {
158- f.lineOffset[y] = f.lineOffset[y-1]
328+ recycled := f.page.rows[bottom]
329+ copy(f.page.rows[top+1:bottom+1], f.page.rows[top:bottom])
330+ f.page.rows[top] = recycled
331+ f.blankPhysicalRow(recycled)
332+ }
333+}
334+
335+func (f Frame) offset(x, y int) int {
336+ if x < 0 || x >= f.Width {
337+ panic("frame column out of range")
338+ }
339+ if y < 0 || y >= f.Height {
340+ panic("frame row out of range")
341+ }
342+ return int(f.page.rows[y]) + x
343+}
344+
345+func (f Frame) blankPhysicalRow(offset uint32) {
346+ for x := range f.Width {
347+ index := int(offset) + x
348+ f.releasePayload(f.page.cells[index].payloadID)
349+ oldID := f.page.cells[index].styleID
350+ if oldID != 0 {
351+ f.page.styles[0].refs++
352+ f.releaseStyle(oldID)
159353 }
160- f.lineOffset[top] = recycled
161- blankRow(f.Cells[recycled : recycled+f.Width])
354+ f.page.cells[index] = storedCell{rune: ' '}
162355 }
163356 }
164357
165-func blankRow(row []Cell) {
166- for i := range row {
167- row[i] = BlankCell()
358+func (f Frame) internStyle(style Style) uint32 {
359+ style = style.Canonical()
360+ if id, ok := f.page.styleIndex[style]; ok {
361+ f.page.styles[id].refs++
362+ return id
363+ }
364+ var id uint32
365+ if n := len(f.page.freeStyles); n != 0 {
366+ id = f.page.freeStyles[n-1]
367+ f.page.freeStyles = f.page.freeStyles[:n-1]
368+ f.page.styles[id] = styleSlot{style: style, refs: 1, used: true}
369+ } else {
370+ id = uint32(len(f.page.styles))
371+ f.page.styles = append(f.page.styles, styleSlot{style: style, refs: 1, used: true})
372+ }
373+ f.page.styleIndex[style] = id
374+ f.page.styleCount++
375+ return id
376+}
377+
378+func (f Frame) releaseStyle(id uint32) {
379+ slot := &f.page.styles[id]
380+ slot.refs--
381+ if id == 0 || slot.refs != 0 {
382+ return
168383 }
384+ delete(f.page.styleIndex, slot.style)
385+ *slot = styleSlot{}
386+ f.page.freeStyles = append(f.page.freeStyles, id)
387+ f.page.styleCount--
169388 }
A core/frame_benchmark_test.go +21 -0
@@ -0,0 +1,21 @@
1+package core
2+
3+import "testing"
4+
5+func BenchmarkCompactFrameBuild10Kx120(b *testing.B) {
6+ const rows, columns = 10_000, 120
7+ style := Style{Bold: true, Foreground: 4}
8+ b.ReportAllocs()
9+ b.ReportMetric(rows*columns, "cells/op")
10+ var logicalBytes uint64
11+ for b.Loop() {
12+ frame := NewFrame(columns, rows)
13+ for y := range rows {
14+ for x := range columns {
15+ frame.Set(x, y, Cell{Rune: 'x', Style: style})
16+ }
17+ }
18+ logicalBytes = frame.LogicalBytes()
19+ }
20+ b.ReportMetric(float64(logicalBytes), "logical-B/op")
21+}
A core/frame_bounds_test.go +69 -0
@@ -0,0 +1,69 @@
1+package core
2+
3+import (
4+ "math"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestFrameColumnsCannotAddressAdjacentRows(t *testing.T) {
11+ for _, x := range []int{-1, 3, 4, math.MaxInt} {
12+ f := NewFrame(3, 3)
13+ f.Set(0, 1, Cell{Rune: 'x', Style: DefaultStyle()})
14+ f.ScrollUp(0, 2, 1)
15+ before := f.Clone()
16+ for y := range f.Height {
17+ require.Panics(t, func() { f.Cell(x, y) })
18+ require.Panics(t, func() { f.Set(x, y, Cell{Rune: 'z'}) })
19+ }
20+ for y := range f.Height {
21+ require.Equal(t, before.Row(y), f.Row(y))
22+ }
23+ require.NoError(t, f.CheckInvariants())
24+ }
25+}
26+
27+func TestFrameRejectsInvalidRows(t *testing.T) {
28+ for _, width := range []int{0, 3} {
29+ f := NewFrame(width, 3)
30+ require.Len(t, f.Row(1), width)
31+ for _, y := range []int{-1, 3, math.MaxInt} {
32+ require.Panics(t, func() { f.Row(y) })
33+ require.Panics(t, func() { f.Cell(0, y) })
34+ require.Panics(t, func() { f.Set(0, y, BlankCell()) })
35+ }
36+ require.NoError(t, f.CheckInvariants())
37+ }
38+}
39+
40+func TestFillRowClipsColumns(t *testing.T) {
41+ f := NewFrame(3, 3)
42+ f.ScrollDown(0, 2, 1)
43+ f.FillRow(1, math.MinInt, math.MaxInt, Cell{Rune: 'x', Style: DefaultStyle()})
44+ for y := range f.Height {
45+ for x := range f.Width {
46+ want := rune(' ')
47+ if y == 1 {
48+ want = 'x'
49+ }
50+ require.Equal(t, want, f.Cell(x, y).Rune)
51+ }
52+ }
53+ require.NoError(t, f.CheckInvariants())
54+}
55+
56+func TestReplaceInvalidSourcePreservesDestination(t *testing.T) {
57+ for _, src := range []Frame{{}, {Width: 2, Height: 2}, NewFrame(0, 2), NewFrame(2, 0)} {
58+ dst := NewFrame(2, 2)
59+ dst.Set(1, 1, Cell{Rune: 'x', Style: DefaultStyle()})
60+ before := dst.Clone()
61+ dst.Replace(src)
62+ require.Equal(t, before.Width, dst.Width)
63+ require.Equal(t, before.Height, dst.Height)
64+ for y := range dst.Height {
65+ require.Equal(t, before.Row(y), dst.Row(y))
66+ }
67+ require.NoError(t, dst.CheckInvariants())
68+ }
69+}
M core/frame_test.go +206 -6
@@ -1,12 +1,16 @@
11 package core
22
3-import "testing"
3+import (
4+ "math"
5+ "reflect"
6+ "testing"
7+)
48
59 func TestFrameCanonicalOffsetsAndInvariants(t *testing.T) {
610 f := NewFrame(4, 3)
711 for y := range f.Height {
8- if f.lineOffset[y] != y*f.Width {
9- t.Fatalf("lineOffset[%d] = %d, want %d", y, f.lineOffset[y], y*f.Width)
12+ if f.page.rows[y] != uint32(y*f.Width) {
13+ t.Fatalf("row offset %d = %d, want %d", y, f.page.rows[y], y*f.Width)
1014 }
1115 }
1216 if err := f.CheckInvariants(); err != nil {
@@ -14,26 +18,222 @@ func TestFrameCanonicalOffsetsAndInvariants(t *testing.T) {
1418 }
1519 }
1620
21+func TestFrameRowReturnsOwnedCells(t *testing.T) {
22+ f := NewFrame(2, 1)
23+ f.Set(0, 0, Cell{Rune: 'A'})
24+
25+ row := f.Row(0)
26+ row[0] = BlankCell()
27+
28+ if got := f.Cell(0, 0).Rune; got != 'A' {
29+ t.Fatalf("mutating Row result changed frame cell to %q", got)
30+ }
31+}
32+
33+func TestFrameRowMutationOperationsUseLogicalRows(t *testing.T) {
34+ f := NewFrame(5, 2)
35+ f.WriteRow(0, 0, []Cell{{Rune: 'A'}, {Rune: 'B'}, {Rune: 'C'}, {Rune: 'D'}, {Rune: 'E'}})
36+ f.ScrollDown(0, 1, 1)
37+
38+ f.CopyRow(1, 2, 0, 3)
39+ f.FillRow(1, 0, 2, BlankCell())
40+
41+ got := f.Row(1)
42+ want := []rune{' ', ' ', 'A', 'B', 'C'}
43+ for x, r := range want {
44+ if got[x].Rune != r {
45+ t.Fatalf("cell %d = %q, want %q", x, got[x].Rune, r)
46+ }
47+ }
48+}
49+
50+func TestStoredCellFitsCompactTarget(t *testing.T) {
51+ if got := reflect.TypeOf(storedCell{}).Size(); got < 8 || got > 16 {
52+ t.Fatalf("storedCell size = %d bytes, want 8..16", got)
53+ }
54+}
55+
56+func TestFrameInternsCanonicalStylesLocally(t *testing.T) {
57+ f := NewFrame(3, 1)
58+ first := Style{Bold: true, Foreground: 2, ForegroundRGB: RGB{R: 9, G: 8, B: 7}}
59+ second := first
60+ second.ForegroundRGB = RGB{R: 1, G: 2, B: 3}
61+
62+ f.Set(0, 0, Cell{Rune: 'a', Style: first})
63+ f.Set(1, 0, Cell{Rune: 'b', Style: second})
64+ f.Set(2, 0, Cell{Rune: 'c', Style: Style{}})
65+
66+ if f.page.cells[0].styleID != f.page.cells[1].styleID {
67+ t.Fatal("semantically equal styles received different page-local IDs")
68+ }
69+ if f.page.cells[2].styleID == 0 {
70+ t.Fatal("zero Style collapsed into default style ID zero")
71+ }
72+ if got := f.StyleCount(); got != 3 {
73+ t.Fatalf("StyleCount() = %d, want default plus two semantic styles", got)
74+ }
75+ if err := f.CheckInvariants(); err != nil {
76+ t.Fatalf("frame invariants: %v", err)
77+ }
78+}
79+
80+func TestFrameStyleChurnReusesUnreferencedSlots(t *testing.T) {
81+ f := NewFrame(1, 1)
82+ for i := range 1000 {
83+ f.Set(0, 0, Cell{Rune: 'x', Style: Style{Foreground: i}})
84+ }
85+ if got := f.StyleCount(); got != 2 {
86+ t.Fatalf("StyleCount() after churn = %d, want default plus live style", got)
87+ }
88+ if got := len(f.page.styles); got > 3 {
89+ t.Fatalf("style slots after churn = %d, want at most 3", got)
90+ }
91+ f.Set(0, 0, BlankCell())
92+ if got := f.StyleCount(); got != 1 {
93+ t.Fatalf("StyleCount() after clear = %d, want default only", got)
94+ }
95+ if err := f.CheckInvariants(); err != nil {
96+ t.Fatalf("frame invariants: %v", err)
97+ }
98+}
99+
100+func TestFrameCrossPageWriteRemapsStyles(t *testing.T) {
101+ src := NewFrame(2, 1)
102+ src.Set(0, 0, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 4}})
103+ src.Set(1, 0, Cell{Rune: 'b', Style: Style{Italic: true, Background: 7}})
104+ dst := NewFrame(2, 1)
105+ dst.Set(0, 0, Cell{Rune: 'x', Style: Style{Foreground: 99}})
106+
107+ dst.WriteRow(0, 0, src.Row(0))
108+
109+ for x := range 2 {
110+ if !dst.Cell(x, 0).Equal(src.Cell(x, 0)) {
111+ t.Fatalf("cell %d changed crossing pages: got %+v want %+v", x, dst.Cell(x, 0), src.Cell(x, 0))
112+ }
113+ }
114+ if err := dst.CheckInvariants(); err != nil {
115+ t.Fatalf("destination invariants: %v", err)
116+ }
117+}
118+
119+func TestFrameClonePreservesStructureWithoutAliasing(t *testing.T) {
120+ f := NewFrame(3, 2)
121+ f.ScrollUp(0, 1, 1)
122+ f.Set(0, 0, Cell{Rune: 'a', Style: Style{Foreground: 1}})
123+ f.Set(1, 0, Cell{Rune: 'b', Style: Style{Foreground: 2}})
124+ f.Set(1, 0, BlankCell()) // leave one reusable style slot
125+
126+ clone := f.Clone()
127+ if clone.page == f.page || &clone.page.cells[0] == &f.page.cells[0] || &clone.page.rows[0] == &f.page.rows[0] {
128+ t.Fatal("Clone shares page slice storage")
129+ }
130+ if clone.page.rows[0] != f.page.rows[0] || clone.page.cells[clone.offset(0, 0)].styleID != f.page.cells[f.offset(0, 0)].styleID {
131+ t.Fatal("Clone changed row rotation or page-local style IDs")
132+ }
133+ clone.Set(0, 0, Cell{Rune: 'x', Style: Style{Bold: true}})
134+ if f.Cell(0, 0).Rune != 'a' {
135+ t.Fatal("mutating clone changed source cells")
136+ }
137+ if _, ok := f.page.styleIndex[Style{Bold: true}.Canonical()]; ok {
138+ t.Fatal("mutating clone changed source style map")
139+ }
140+ if err := clone.CheckInvariants(); err != nil {
141+ t.Fatalf("clone invariants: %v", err)
142+ }
143+}
144+
145+func TestCheckInvariantsValidatesFreeStyleList(t *testing.T) {
146+ newFrameWithFreeStyle := func() Frame {
147+ f := NewFrame(1, 1)
148+ f.Set(0, 0, Cell{Style: Style{Foreground: 1}})
149+ f.Set(0, 0, BlankCell())
150+ return f
151+ }
152+ tests := map[string]func(*Frame){
153+ "duplicate": func(f *Frame) { f.page.freeStyles = append(f.page.freeStyles, f.page.freeStyles[0]) },
154+ "missing": func(f *Frame) { f.page.freeStyles = nil },
155+ "used": func(f *Frame) { f.page.freeStyles = append(f.page.freeStyles, 0) },
156+ "out of range": func(f *Frame) {
157+ f.page.freeStyles = append(f.page.freeStyles, uint32(len(f.page.styles)))
158+ },
159+ }
160+ for name, corrupt := range tests {
161+ t.Run(name, func(t *testing.T) {
162+ f := newFrameWithFreeStyle()
163+ corrupt(&f)
164+ if err := f.CheckInvariants(); err == nil {
165+ t.Fatal("CheckInvariants accepted corrupt free style list")
166+ }
167+ })
168+ }
169+}
170+
171+func TestFrameCellCountRejectsPortableOverflow(t *testing.T) {
172+ if _, ok := frameCellCount(math.MaxInt, 2); ok {
173+ t.Fatal("accepted dimensions whose product exceeds int or uint32")
174+ }
175+ if _, ok := frameCellCount(65_536, 65_536); ok {
176+ t.Fatal("accepted dimensions whose product exceeds uint32")
177+ }
178+}
179+
180+func TestFrameLogicalBytesAreDeterministic(t *testing.T) {
181+ f := NewFrame(3, 2)
182+ want := uint64(6*StoredCellLogicalBytes + 2*RowDescriptorLogicalBytes + StyleRecordLogicalBytes)
183+ if got := f.LogicalBytes(); got != want {
184+ t.Fatalf("LogicalBytes() = %d, want %d", got, want)
185+ }
186+ f.Set(0, 0, Cell{Rune: 'x', Style: Style{Bold: true}})
187+ if got := f.LogicalBytes(); got != want+StyleRecordLogicalBytes {
188+ t.Fatalf("LogicalBytes() with one interned style = %d, want %d", got, want+StyleRecordLogicalBytes)
189+ }
190+}
191+
17192 func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) {
18193 f := NewFrame(4, 3)
19- f.lineOffset[1] = f.lineOffset[0]
194+ f.page.rows[1] = f.page.rows[0]
20195 if err := f.CheckInvariants(); err == nil {
21196 t.Fatal("expected duplicate physical row to be rejected")
22197 }
23198
24199 f = NewFrame(4, 3)
25- f.lineOffset[0] = 1
200+ f.page.rows[0] = 1
26201 if err := f.CheckInvariants(); err == nil {
27202 t.Fatal("expected non-multiple offset to be rejected")
28203 }
29204
30205 f = NewFrame(4, 3)
31- f.lineOffset[2] = 99
206+ f.page.rows[2] = 99
32207 if err := f.CheckInvariants(); err == nil {
33208 t.Fatal("expected out-of-range offset to be rejected")
34209 }
35210 }
36211
212+func TestReplacePreservesSelfAliasedScrolledRows(t *testing.T) {
213+ f := NewFrame(3, 3)
214+ for y, row := range []string{"abc", "def", "ghi"} {
215+ for x, r := range row {
216+ f.Set(x, y, Cell{Rune: r})
217+ }
218+ }
219+ f.ScrollUp(0, 2, 1)
220+ want := [][]Cell{f.Row(0), f.Row(1), f.Row(2)}
221+
222+ f.Replace(f)
223+
224+ for y := range want {
225+ got := f.Row(y)
226+ for x := range want[y] {
227+ if !got[x].Equal(want[y][x]) {
228+ t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got[x], want[y][x])
229+ }
230+ }
231+ }
232+ if err := f.CheckInvariants(); err != nil {
233+ t.Fatalf("replaced frame invariants: %v", err)
234+ }
235+}
236+
37237 func TestReplacePreservesLogicalRows(t *testing.T) {
38238 dst := NewFrame(3, 3)
39239 dst.Set(0, 0, Cell{Rune: 'x'})
A core/payload.go +159 -0
@@ -0,0 +1,159 @@
1+package core
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "strings"
7+ "unicode/utf8"
8+)
9+
10+const (
11+ MaxGraphemeBytes = 128
12+ MaxHyperlinkBytes = 4096
13+ // PayloadRecordLogicalBytes accounts for two fixed-width string descriptors.
14+ PayloadRecordLogicalBytes uint64 = 16
15+)
16+
17+var ErrInvalidCellPayload = errors.New("invalid cell payload")
18+
19+// CellPayload is an immutable semantic exceptional value. Its zero value is
20+// empty. Storage support does not imply parsing support for additional terminal
21+// protocols; a payload never exposes a page-local handle.
22+type CellPayload struct {
23+ grapheme string
24+ hyperlink string
25+}
26+
27+// NewCellPayload validates bounded UTF-8 strings without terminal controls.
28+// It copies nonempty strings so a small value cannot retain an oversized input.
29+func NewCellPayload(grapheme, hyperlink string) (CellPayload, error) {
30+ if !validPayloadString(grapheme, MaxGraphemeBytes) || !validPayloadString(hyperlink, MaxHyperlinkBytes) {
31+ return CellPayload{}, ErrInvalidCellPayload
32+ }
33+ return CellPayload{grapheme: strings.Clone(grapheme), hyperlink: strings.Clone(hyperlink)}, nil
34+}
35+
36+func validPayloadString(value string, limit int) bool {
37+ if len(value) > limit || !utf8.ValidString(value) {
38+ return false
39+ }
40+ for _, r := range value {
41+ if r < 0x20 || r >= 0x7f && r <= 0x9f {
42+ return false
43+ }
44+ }
45+ return true
46+}
47+
48+func (p CellPayload) Grapheme() string { return p.grapheme }
49+func (p CellPayload) Hyperlink() string { return p.hyperlink }
50+func (p CellPayload) Empty() bool { return p == CellPayload{} }
51+func (p CellPayload) LogicalBytes() uint64 {
52+ if p.Empty() {
53+ return 0
54+ }
55+ return PayloadRecordLogicalBytes + uint64(len(p.grapheme)) + uint64(len(p.hyperlink))
56+}
57+
58+type payloadSlot struct {
59+ value CellPayload
60+ refs uint32
61+}
62+
63+// internPayload returns a page-local handle and acquires one reference. Slot
64+// zero is the implicit empty payload and requires no dictionary allocation.
65+func (f Frame) internPayload(value CellPayload) uint32 {
66+ if value.Empty() {
67+ return 0
68+ }
69+ p := f.page
70+ if id, ok := p.payloadIndex[value]; ok {
71+ p.payloads[id-1].refs++
72+ return id
73+ }
74+ if p.payloadIndex == nil {
75+ p.payloadIndex = make(map[CellPayload]uint32)
76+ }
77+ var id uint32
78+ if n := len(p.freePayloads); n > 0 {
79+ id = p.freePayloads[n-1]
80+ p.freePayloads = p.freePayloads[:n-1]
81+ p.payloads[id-1] = payloadSlot{value: value, refs: 1}
82+ } else {
83+ p.payloads = append(p.payloads, payloadSlot{value: value, refs: 1})
84+ id = uint32(len(p.payloads))
85+ }
86+ p.payloadIndex[value] = id
87+ p.payloadBytes += value.LogicalBytes()
88+ return id
89+}
90+
91+func (f Frame) releasePayload(id uint32) {
92+ if id == 0 {
93+ return
94+ }
95+ p := f.page
96+ slot := &p.payloads[id-1]
97+ slot.refs--
98+ if slot.refs != 0 {
99+ return
100+ }
101+ delete(p.payloadIndex, slot.value)
102+ p.payloadBytes -= slot.value.LogicalBytes()
103+ *slot = payloadSlot{}
104+ p.freePayloads = append(p.freePayloads, id)
105+}
106+
107+func (f Frame) payload(id uint32) CellPayload {
108+ if id == 0 {
109+ return CellPayload{}
110+ }
111+ return f.page.payloads[id-1].value
112+}
113+
114+func (f Frame) checkPayloadInvariants() error {
115+ p := f.page
116+ refs := make([]uint32, len(p.payloads))
117+ for _, cell := range p.cells {
118+ id := cell.payloadID
119+ if id == 0 {
120+ continue
121+ }
122+ if uint64(id) > uint64(len(refs)) {
123+ return fmt.Errorf("unresolved payload ID %d", id)
124+ }
125+ refs[id-1]++
126+ }
127+ free := make([]bool, len(p.payloads))
128+ for _, id := range p.freePayloads {
129+ if id == 0 || uint64(id) > uint64(len(free)) || free[id-1] {
130+ return fmt.Errorf("invalid free payload ID %d", id)
131+ }
132+ free[id-1] = true
133+ }
134+ var total uint64
135+ used := 0
136+ for i, slot := range p.payloads {
137+ if slot.refs != refs[i] {
138+ return fmt.Errorf("payload %d reference mismatch", i+1)
139+ }
140+ if slot.refs == 0 {
141+ if !free[i] || !slot.value.Empty() {
142+ return fmt.Errorf("unreclaimed payload %d", i+1)
143+ }
144+ continue
145+ }
146+ if free[i] || slot.value.Empty() || p.payloadIndex[slot.value] != uint32(i+1) {
147+ return fmt.Errorf("invalid payload dictionary entry %d", i+1)
148+ }
149+ if !validPayloadString(slot.value.grapheme, MaxGraphemeBytes) || !validPayloadString(slot.value.hyperlink, MaxHyperlinkBytes) {
150+ return ErrInvalidCellPayload
151+ }
152+ total += slot.value.LogicalBytes()
153+ used++
154+ }
155+ if total != p.payloadBytes || used != len(p.payloadIndex) {
156+ return fmt.Errorf("payload accounting mismatch")
157+ }
158+ return nil
159+}
A core/payload_test.go +98 -0
@@ -0,0 +1,98 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestCellPayloadValidation(t *testing.T) {
12+ for _, tt := range []struct {
13+ name, grapheme, link string
14+ valid bool
15+ }{
16+ {"empty", "", "", true},
17+ {"grapheme and link", "e\u0301", "https://example.com", true},
18+ {"invalid utf8", "\xff", "", false},
19+ {"control", "a\n", "", false},
20+ {"OSC injection", "", "https://x/\x1b\\", false},
21+ {"C1", "", "x\u009c", false},
22+ {"grapheme limit", strings.Repeat("x", MaxGraphemeBytes+1), "", false},
23+ {"link limit", "", strings.Repeat("x", MaxHyperlinkBytes+1), false},
24+ } {
25+ t.Run(tt.name, func(t *testing.T) {
26+ p, err := NewCellPayload(tt.grapheme, tt.link)
27+ if !tt.valid {
28+ require.ErrorIs(t, err, ErrInvalidCellPayload)
29+ return
30+ }
31+ require.NoError(t, err)
32+ require.Equal(t, tt.grapheme, p.Grapheme())
33+ require.Equal(t, tt.link, p.Hyperlink())
34+ })
35+ }
36+}
37+
38+func TestPayloadOwnershipAndReclamation(t *testing.T) {
39+ value, err := NewCellPayload("e\u0301", "https://example.com")
40+ require.NoError(t, err)
41+ cell := Cell{Rune: 'e', Style: DefaultStyle(), Payload: value}
42+ source := NewFrame(3, 2)
43+ baseline := source.LogicalBytes()
44+ source.Set(0, 0, cell)
45+ source.Set(1, 0, cell)
46+ require.Len(t, source.page.payloadIndex, 1)
47+ require.Equal(t, baseline+value.LogicalBytes(), source.LogicalBytes())
48+ clone := source.Clone()
49+ dst := NewFrame(3, 2)
50+ other, err := NewCellPayload("", "https://other.example")
51+ require.NoError(t, err)
52+ dst.Set(0, 0, Cell{Rune: 'x', Payload: other})
53+ dst.Set(1, 0, source.Cell(0, 0))
54+ require.Equal(t, cell, dst.Cell(1, 0))
55+ source.FillRow(0, 0, 3, BlankCell())
56+ require.Empty(t, source.page.payloadIndex)
57+ require.Equal(t, baseline, source.LogicalBytes())
58+ require.Equal(t, cell, clone.Cell(0, 0))
59+ require.Equal(t, cell, dst.Cell(1, 0))
60+ for _, f := range []Frame{source, clone, dst} {
61+ require.NoError(t, f.CheckInvariants())
62+ }
63+ clone.ScrollUp(0, 1, 1)
64+ require.Empty(t, clone.page.payloadIndex)
65+ require.NoError(t, clone.CheckInvariants())
66+}
67+
68+func TestPayloadChurnIsBounded(t *testing.T) {
69+ f := NewFrame(1, 1)
70+ for i := range 1000 {
71+ p, err := NewCellPayload("", fmt.Sprintf("https://example.com/%d", i))
72+ require.NoError(t, err)
73+ f.Set(0, 0, Cell{Rune: 'x', Payload: p})
74+ }
75+ require.LessOrEqual(t, len(f.page.payloads), 2)
76+ require.Len(t, f.page.payloadIndex, 1)
77+ require.NoError(t, f.CheckInvariants())
78+ f.page.cells[0].payloadID = 99
79+ require.Error(t, f.CheckInvariants())
80+}
81+
82+func BenchmarkPayloadOverwrite(b *testing.B) {
83+ values := make([]CellPayload, 128)
84+ for i := range values {
85+ var err error
86+ values[i], err = NewCellPayload("e\u0301", fmt.Sprintf("https://example.com/%d", i))
87+ if err != nil {
88+ b.Fatal(err)
89+ }
90+ }
91+ frame := NewFrame(1, 1)
92+ i := 0
93+ b.ReportAllocs()
94+ for b.Loop() {
95+ frame.Set(0, 0, Cell{Rune: 'e', Style: DefaultStyle(), Payload: values[i%len(values)]})
96+ i++
97+ }
98+}
M core/style.go +27 -0
@@ -48,6 +48,33 @@ type Style struct {
4848
4949 func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} }
5050
51+// Canonical returns the unique representation of s under Equal. Fields that
52+// are inactive for the selected color form are cleared. Canonical does not
53+// treat the zero Style as the default style: indexed color zero remains
54+// distinct from the unset colors returned by DefaultStyle.
55+func (s Style) Canonical() Style {
56+ if s.HasForegroundRGB {
57+ s.Foreground = 0
58+ } else {
59+ s.ForegroundRGB = RGB{}
60+ }
61+ if s.HasBackgroundRGB {
62+ s.Background = 0
63+ } else {
64+ s.BackgroundRGB = RGB{}
65+ }
66+ if s.HasUnderlineColorRGB {
67+ s.HasUnderlineColor = false
68+ s.UnderlineColor = 0
69+ } else {
70+ s.UnderlineColorRGB = RGB{}
71+ if !s.HasUnderlineColor {
72+ s.UnderlineColor = 0
73+ }
74+ }
75+ return s
76+}
77+
5178 func (s Style) Equal(other Style) bool {
5279 if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse || s.Attrs != other.Attrs || s.UnderlineStyle != other.UnderlineStyle {
5380 return false
A core/style_test.go +115 -0
@@ -0,0 +1,115 @@
1+package core
2+
3+import (
4+ "testing"
5+ "testing/quick"
6+)
7+
8+func TestStyleCanonicalClearsInactiveColorPayloads(t *testing.T) {
9+ style := Style{
10+ Foreground: 7,
11+ Background: 8,
12+ HasForegroundRGB: true,
13+ ForegroundRGB: RGB{R: 1, G: 2, B: 3},
14+ BackgroundRGB: RGB{R: 4, G: 5, B: 6},
15+ UnderlineColor: 9,
16+ UnderlineColorRGB: RGB{R: 7, G: 8, B: 9},
17+ HasUnderlineColorRGB: true,
18+ }
19+
20+ got := style.Canonical()
21+ if got.Foreground != 0 {
22+ t.Fatalf("inactive indexed foreground = %d, want 0", got.Foreground)
23+ }
24+ if got.BackgroundRGB != (RGB{}) {
25+ t.Fatalf("inactive RGB background = %+v, want zero", got.BackgroundRGB)
26+ }
27+ if got.UnderlineColor != 0 || got.HasUnderlineColor {
28+ t.Fatalf("inactive indexed underline = (%d, %t), want zero and false", got.UnderlineColor, got.HasUnderlineColor)
29+ }
30+}
31+
32+func TestStyleCanonicalCollapsesEveryInactiveVariant(t *testing.T) {
33+ tests := []struct {
34+ name string
35+ left Style
36+ right Style
37+ }{
38+ {
39+ name: "indexed colors ignore RGB payloads and absent underline payloads",
40+ left: Style{Foreground: 1, Background: 2},
41+ right: Style{
42+ Foreground: 1,
43+ ForegroundRGB: RGB{R: 1, G: 2, B: 3},
44+ Background: 2,
45+ BackgroundRGB: RGB{R: 4, G: 5, B: 6},
46+ UnderlineColor: 3,
47+ UnderlineColorRGB: RGB{R: 7, G: 8, B: 9},
48+ },
49+ },
50+ {
51+ name: "RGB colors ignore indexed payloads",
52+ left: Style{
53+ HasForegroundRGB: true,
54+ ForegroundRGB: RGB{R: 1, G: 2, B: 3},
55+ HasBackgroundRGB: true,
56+ BackgroundRGB: RGB{R: 4, G: 5, B: 6},
57+ HasUnderlineColorRGB: true,
58+ UnderlineColorRGB: RGB{R: 7, G: 8, B: 9},
59+ },
60+ right: Style{
61+ Foreground: 1,
62+ Background: 2,
63+ HasForegroundRGB: true,
64+ ForegroundRGB: RGB{R: 1, G: 2, B: 3},
65+ HasBackgroundRGB: true,
66+ BackgroundRGB: RGB{R: 4, G: 5, B: 6},
67+ HasUnderlineColor: true,
68+ UnderlineColor: 3,
69+ HasUnderlineColorRGB: true,
70+ UnderlineColorRGB: RGB{R: 7, G: 8, B: 9},
71+ },
72+ },
73+ }
74+
75+ for _, test := range tests {
76+ t.Run(test.name, func(t *testing.T) {
77+ if !test.left.Equal(test.right) {
78+ t.Fatal("fixture styles must be semantically equal")
79+ }
80+ if test.left.Canonical() != test.right.Canonical() {
81+ t.Fatalf("canonical styles differ: %+v != %+v", test.left.Canonical(), test.right.Canonical())
82+ }
83+ })
84+ }
85+}
86+
87+func TestStyleCanonicalExactlyMatchesEqual(t *testing.T) {
88+ property := func(a, b Style) bool {
89+ return a.Equal(b) == (a.Canonical() == b.Canonical())
90+ }
91+ if err := quick.Check(property, &quick.Config{MaxCount: 10_000}); err != nil {
92+ t.Fatal(err)
93+ }
94+}
95+
96+func TestStyleCanonicalIsIdempotent(t *testing.T) {
97+ property := func(style Style) bool {
98+ canonical := style.Canonical()
99+ return canonical.Canonical() == canonical
100+ }
101+ if err := quick.Check(property, &quick.Config{MaxCount: 10_000}); err != nil {
102+ t.Fatal(err)
103+ }
104+}
105+
106+func TestZeroAndDefaultStylesRemainDistinct(t *testing.T) {
107+ zero := Style{}
108+ defaultStyle := DefaultStyle()
109+ if zero.Equal(defaultStyle) {
110+ t.Fatal("zero Style unexpectedly equals DefaultStyle")
111+ }
112+ if zero.Canonical() == defaultStyle.Canonical() {
113+ t.Fatal("canonicalization collapsed indexed color zero into unset default colors")
114+ }
115+}
M core_model.go +7 -4
@@ -6,11 +6,13 @@ import "github.com/bnema/vev-vt/core"
66 // history, and renderer consumers share one cell/style/frame representation.
77 // The implementation and storage policy remain owned by the core package.
88 type Cell = core.Cell
9+type CellPayload = core.CellPayload
910 type RGB = core.RGB
1011 type StyleAttrs = core.StyleAttrs
1112 type UnderlineStyle = core.UnderlineStyle
1213 type Style = core.Style
1314 type Frame = core.Frame
15+type CellSource = core.CellSource
1416
1517 type DamageKind = core.DamageKind
1618 type Damage = core.Damage
@@ -35,10 +37,11 @@ const (
3537 )
3638
3739 var (
38- BlankCell = core.BlankCell
39- DefaultStyle = core.DefaultStyle
40- FullRedraw = core.FullRedraw
41- NewFrame = core.NewFrame
40+ BlankCell = core.BlankCell
41+ DefaultStyle = core.DefaultStyle
42+ FullRedraw = core.FullRedraw
43+ NewFrame = core.NewFrame
44+ NewCellPayload = core.NewCellPayload
4245 )
4346
4447 func RuneWidth(r rune) int { return core.RuneWidth(r) }
M doc.go +5 -6
@@ -15,10 +15,9 @@
1515 // or strings owned by the callback.
1616 //
1717 // Snapshot and HistorySnapshotView capture owned state without sealing or
18-// mutating the live owner. Row methods return owned copies. BorrowedRow and
19-// HistoryView.Range expose immutable backing storage: callers must not mutate
20-// it, and Range callbacks must not retain their row after returning. A
21-// HistoryChunk pointer has stable identity for the lifetime of the view, so
22-// consumers can reuse unchanged sealed chunks. Direct Frame.Row access is a
23-// mutable borrow valid only until the frame scrolls or resizes.
18+// mutating the live owner. Screen, ScreenSnapshot, and core.Frame expose
19+// storage-independent CellSource reads, and Row methods return owned copies.
20+// HistoryView.Row and HistoryView.Range decode owned semantic rows from compact
21+// slabs. A HistoryChunk pointer has stable identity for the lifetime of the
22+// view, so consumers can reuse unchanged sealed chunks.
2423 package vt
A docs/graphics.md +45 -0
@@ -0,0 +1,45 @@
1+# Terminal images
2+
3+[Back to the README](../README.md)
4+
5+vev-vt supports a subset of the Kitty graphics protocol for static images sent
6+directly through terminal output, including the form used by:
7+
8+```sh
9+kitten icat --transfer-mode=stream image.png
10+```
11+
12+Your application reads the result through `Screen.GraphicsSnapshot()` and chooses
13+how to display it. This library does not open a window.
14+
15+## Supported
16+
17+- PNG, RGB and RGBA images, with explicit resource limits.
18+- Direct image transmission, display, placement, queries and supported deletion.
19+- Chunked Base64 data, with optional zlib compression.
20+- Cropping, cell-based placement, pixel offsets and z-index.
21+- Movement and clipping as terminal rows scroll.
22+
23+Images are stored separately from text history. The `graphics` package contains
24+the image model; `protocol/kittygraphics` reads Kitty commands and updates it.
25+Graphics state is allocated only when a screen receives a graphics command.
26+
27+## Not supported
28+
29+File or shared-memory transfers, animation, image composition, relative
30+placements and Unicode placeholders are not implemented. Image placements do
31+not support resize reflow.
32+
33+## Inspect a capture without a display
34+
35+The development harness reads captured terminal bytes and writes a PNG:
36+
37+```sh
38+go run ./cmd/graphics-harness \
39+ -input internal/graphicsharness/testdata/demo.apc \
40+ -output /tmp/graphics-harness.png \
41+ -cols 4 -rows 4 -pixel-width 4 -pixel-height 4 -scale 64
42+```
43+
44+The included example produces a blue background with a translucent red overlay.
45+This harness is an inspection tool, not an interactive terminal emulator.
A docs/history.md +104 -0
@@ -0,0 +1,104 @@
1+# Working with history
2+
3+[Back to the README](../README.md)
4+
5+History contains the lines that have scrolled off the visible screen. Each
6+`Screen` has its own history; the library does not share one limit across panes
7+or sessions.
8+
9+## Choose limits
10+
11+Your application chooses the budget and passes it in `HistoryConfig`.
12+vev-vt provides no default byte or line budget. It only measures the stored
13+history and removes the oldest lines when a supplied limit is reached.
14+The PTY transports terminal bytes; the application's history policy is separate.
15+
16+| Setting | Meaning |
17+| --- | --- |
18+| `MaxBytes` | Maximum uncompressed history data. Zero omits this ceiling. This is not a process-RSS limit. |
19+| `MaxRows` | Maximum lines. Set to zero with positive `MaxBytes` for a byte-only limit. |
20+| Both limits zero | Disable history. |
21+| Positive `MaxRows`, zero `MaxBytes` | Limit only the number of lines. No byte budget is invented. |
22+| `ChunkRows` | Internal grouping size, 1–256. Usually leave it at zero for the default, 256. |
23+
24+The visible primary and alternate screens do not count toward these limits.
25+Grouping rows does not grant extra space beyond the limit.
26+
27+If settings come from user input, call `config.Validate()` before creating a
28+screen. Invalid settings make constructors panic. To change an existing history,
29+call `history.SetLimits(config)`: it returns an error for invalid input and
30+removes excess old rows immediately after a valid reduction.
31+
32+Use `history.Limits()` to read the effective settings, `Len()` for the number of
33+lines and `LogicalBytes()` for the amount charged to the byte limit.
34+
35+## Read without exposing writable storage
36+
37+- `history.View()` captures the current contents. Later writes cannot change it.
38+- `view.Row(y)` returns a copy of a line.
39+- `view.Cell(x, y)` reads one cell; `RowWidth(y)` gets its width without copying it.
40+- `view.CopyRow(y, destination)` reuses a slice supplied by your application.
41+- `view.Range(callback)` walks lines in order. Each callback receives its own row.
42+ Check its returned error: a corrupt compressed page stops iteration rather
43+ than silently yielding incomplete data.
44+
45+Older groups of rows, called chunks, can be shared between views. Their identity
46+stays stable, which lets applications avoid saving or processing them repeatedly.
47+
48+## Save and restore
49+
50+For a single history blob, use `MarshalHistory` and `UnmarshalHistory`.
51+For incremental snapshots, use `SnapshotView`, `MarshalHistoryChunk` and
52+`MarshalHistoryTail`; restore them with `HistoryFromBlobs`.
53+
54+A snapshot includes row identities and line-wrap information as well as cells.
55+The copied snapshot tail is reusable: asking for `Tail()` repeatedly does not
56+copy it again.
57+
58+The format is **VTC1**. Older VTH3 data is rejected, not migrated. Check decode
59+errors and do not overwrite existing saved data after a failed restore. Application
60+snapshot envelopes such as vev's VEVS format are outside this library.
61+
62+Each blob is limited to 12,000 rows and 1,920,000 cells. Exceptional payload
63+records and strings have a separate 64 MiB limit. Chunk-by-chunk persistence
64+avoids putting an entire large history in one blob. The decoder checks the
65+complete blob before allocating decoded grids, including references, duplicate
66+IDs, string limits, truncation and extra trailing bytes.
67+
68+## Compress during idle time
69+
70+Compression is optional. From the same goroutine or lock that owns the history,
71+call `history.CompressIdle(1)` on an idle timer to visit at most one old page per
72+call. The library does not start a timer or goroutine for you.
73+
74+A page is compressed only after two visits with no read between them. The newest
75+sealed page and the unfinished tail stay uncompressed. Reading an older page
76+restores it automatically. This changes memory use, not how many lines are kept.
77+
78+Restored pages are cached until later idle visits. Searches through `Range` and
79+history serialization restore pages temporarily instead of caching every page.
80+Use `CompressionStats()` to inspect compressed bytes, cold pages and restores.
81+Its resident-byte estimate excludes Go allocator and map overhead.
82+
83+Corrupted compressed data is never replaced with blank text. To handle errors
84+before reading, call `chunk.Restore()` and check for `ErrHistoryCorrupt`. An
85+ordinary read panics with that error if the library's private backing is corrupt.
86+Restore checks length, checksum, page size and the usual VTC1 validation rules.
87+
88+## How byte accounting works
89+
90+These implementation details matter when interpreting `LogicalBytes()`:
91+
92+| Item | Charged size |
93+| --- | ---: |
94+| Stored cell | 16 bytes |
95+| Row descriptor | 4 bytes |
96+| Distinct style in a page, including default | 32 bytes |
97+| Distinct nonempty exceptional payload | 16 bytes plus its UTF-8 strings |
98+
99+Styles and exceptional values are shared within a page. Compression does not
100+change these charges. The wire encoding is separate: ordinary cells use 9 bytes,
101+and cells with an exceptional payload add a 4-byte reference. Only semantic
102+values are serialized, never raw Go page memory or internal handles.
103+
104+[Benchmarks and optimization choices](storage-optimization.md)
A docs/ownership.md +59 -0
@@ -0,0 +1,59 @@
1+# Safe API use
2+
3+[Back to the README](../README.md)
4+
5+## Screens and snapshots
6+
7+A `Screen` changes as bytes arrive. Keep writes, resizes, reads and snapshot
8+capture in one goroutine, or use a lock in your application. The library does
9+not provide a screen-wide lock.
10+
11+`screen.Snapshot()` makes an independent screen copy. You can keep that snapshot
12+while the live screen changes. History views similarly preserve the rows they
13+captured. Old, finished history chunks are shared instead of copied repeatedly.
14+
15+`Screen`, `ScreenSnapshot` and `core.Frame` all support `core.CellSource`: read
16+dimensions with `Columns()` and `Rows()`, and a cell with `Cell(x, y)`. Renderers
17+should use these methods rather than assuming a particular memory layout.
18+
19+Rows returned by public extraction methods are copies. For a writable
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
22+an independent grid.
23+
24+## Callbacks
25+
26+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.
28+
29+## Styles
30+
31+Use `DefaultStyle()` to leave foreground and background colors at the terminal's
32+defaults. `Style{}` is different: its zero color indexes are active colors.
33+
34+For style comparison, use `Style.Equal`. `Style.Canonical()` removes fields that
35+do not affect the displayed style, such as an indexed foreground when an RGB
36+foreground is active. Canonical styles can also be compared with Go's `==`.
37+This lets storage share identical styles without keeping irrelevant differences.
38+
39+## Exceptional cell data
40+
41+`core.NewCellPayload(grapheme, hyperlink)` creates a read-only value for
42+`Cell.Payload`. It checks UTF-8, rejects terminal control characters and limits
43+strings to 128 grapheme bytes and 4,096 hyperlink bytes.
44+
45+Pages share equal values locally and reclaim entries after overwrite or clear.
46+Copies, resize/reflow, snapshots, history and persistence preserve those values.
47+Callers never receive internal page handles.
48+
49+**This is storage support only.** It does not add full grapheme segmentation or
50+an OSC 8 hyperlink parser. The VT parser and ANSI renderer still use their
51+existing rune-based text behavior.
52+
53+## Stability and dependencies
54+
55+vev-vt is v0.x software. APIs and saved-history formats may change without
56+compatibility adapters. Check the history guide before updating an application
57+that persists terminal state.
58+
59+There are no third-party production dependencies. `testify` is used by tests.
A docs/storage-optimization.md +77 -0
@@ -0,0 +1,77 @@
1+# Storage optimization evaluation (#19)
2+
3+## Decision
4+
5+Keep Go-owned slices/maps, 256-row default chunks and standard-library zlib.
6+Do not add pooling, mmap/decommit, SIMD or physical page alignment. Accept one
7+measured allocation fix: bound initial mutable-tail capacity by the actual
8+`width × ChunkRows`, in addition to the existing byte/preallocation ceilings.
9+
10+This is an evaluation, not a claim that unimplemented techniques were benchmarked.
11+No assembly, unsafe code, OS-specific allocator, dependency or background worker
12+was introduced.
13+
14+## Measurements
15+
16+September 4, 2026; Linux amd64, AMD Ryzen 5 7535HS, Go 1.27.1. Chunk-size figures
17+are two one-iteration samples, so small timing differences are not significant.
18+All variants use 10,000 rows × 120 columns with repeated plain text.
19+
20+| Chunk rows | Before: ms/op | Before: allocated MB/op | After capacity fix: ms/op | After: allocated MB/op |
21+| --- | ---: | ---: | ---: | ---: |
22+| 32 | 184–197 | 1,089 | 120–127 | 148.0 |
23+| 64 | 161–163 | 555.9 | 126–130 | 146.9 |
24+| 128 | 137–139 | 290.0 | 125–129 | 146.9 |
25+| 256 | 125 | 157.0 | 124–128 | 148.5 |
26+
27+The old fixed initial capacity overallocated each short tail. A history configured
28+for four three-cell rows retained 3,408,840 bytes after one append. The corrected
29+capacity retains 2,248 bytes in the same benchmark. A regression test pins the
30+initial capacity to at most twelve cells for that case.
31+
32+For the default 256-row configuration after the fix:
33+
34+- Uncompressed plain-ASCII history retains **22.8 MB**, versus the original
35+ **95.3 MB** baseline: the required 4× reduction still holds.
36+- Two cold-maintenance passes retain **4.34 MB**, with 38 cold pages and 173,190
37+ compressed bytes. The newest page and mutable tail remain hot.
38+- This is synthetic retained Go heap, not a real-daemon RSS measurement.
39+
40+A three-iteration CPU/allocation profile before the capacity fix attributed about
41+86.8% of allocated bytes to `reserveTailRow`. CPU samples were concentrated in
42+compact cell writes, canonical dictionaries and chunk construction. The short
43+profile supports identifying the allocation source, not predicting production
44+latency or claiming a precise speedup.
45+
46+## Options considered
47+
48+| Option | Result | Reason |
49+| --- | --- | --- |
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. |
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+| 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+| Fixed physical page alignment | Rejected | No measured benefit. Chunk rows are logical grouping, not an OS page-size commitment. |
56+
57+Cold random-access restore is cached until later idle visits. Streaming history
58+search and persistence use transient restores, so scanning does not make every
59+cold page resident. Restoring the 16×80 test page, including cache release, measured
60+about 123 microseconds and 92.8 KB allocated; use the benchmark below for current
61+results rather than treating that sample as a latency guarantee.
62+
63+## Reproduce
64+
65+```sh
66+go test . -run '^$' -bench '^(BenchmarkHistoryChunkSize|BenchmarkSmallHistoryRetained)$' -benchtime=1x -count=2 -benchmem
67+go test . -run '^$' -bench '^(BenchmarkHistoryRetained10Kx120|BenchmarkColdHistoryRetained10Kx120)/plain-ascii$' -benchtime=1x -benchmem
68+go test . -run '^$' -bench '^BenchmarkColdHistoryRestore$' -benchtime=100ms -benchmem
69+go test . -o /tmp/vev-vt-profile.test -run '^$' -bench '^BenchmarkHistoryBuild10Kx120/plain-ascii$' -benchtime=3x -cpuprofile /tmp/vev-vt-storage.cpu -memprofile /tmp/vev-vt-storage.mem
70+go tool pprof -top /tmp/vev-vt-profile.test /tmp/vev-vt-storage.cpu
71+go tool pprof -top -alloc_space /tmp/vev-vt-profile.test /tmp/vev-vt-storage.mem
72+```
73+
74+Keep the 4× original retained-memory gate, ownership/identity tests, strict codec
75+validation, race tests and 386 portability checks for future experiments. A
76+production workload/RSS study remains necessary before selecting any rejected
77+low-level technique.
M extraction_contract_external_test.go +13 -7
@@ -12,15 +12,24 @@ func TestExternalScreenParserPreservesPublicTerminalModel(t *testing.T) {
1212 screen := vt.NewScreen(6, 2)
1313 screen.Write([]byte("A界"))
1414
15- if got := screen.Frame.At(0, 0); got.Rune != 'A' {
15+ var source vt.CellSource = screen
16+ if source.Columns() != 6 || source.Rows() != 2 {
17+ t.Fatalf("screen dimensions = %dx%d, want 6x2", source.Columns(), source.Rows())
18+ }
19+ if got := source.Cell(0, 0); got.Rune != 'A' {
1620 t.Fatalf("ASCII cell = %#v, want A", got)
1721 }
18- if got := screen.Frame.At(1, 0); got.Rune != '界' || got.Continuation {
22+ if got := source.Cell(1, 0); got.Rune != '界' || got.Continuation {
1923 t.Fatalf("wide-rune lead cell = %#v, want 界 lead", got)
2024 }
21- if got := screen.Frame.At(2, 0); !got.Continuation {
25+ if got := source.Cell(2, 0); !got.Continuation {
2226 t.Fatalf("wide-rune continuation = %#v, want continuation", got)
2327 }
28+ owned := screen.RowCells(0)
29+ owned[0] = core.BlankCell()
30+ if got := source.Cell(0, 0).Rune; got != 'A' {
31+ t.Fatalf("mutating RowCells result changed live screen cell to %q", got)
32+ }
2433 if got := vt.RuneWidth('界'); got != 2 {
2534 t.Fatalf("RuneWidth(界) = %d, want 2", got)
2635 }
@@ -29,7 +38,7 @@ func TestExternalScreenParserPreservesPublicTerminalModel(t *testing.T) {
2938 // provide arbitrary PTY chunk boundaries.
3039 screen.Write([]byte("\x1b[2;2H\x1b[3"))
3140 screen.Write([]byte("1mX"))
32- cell := screen.Frame.At(1, 1)
41+ cell := screen.Cell(1, 1)
3342 if cell.Rune != 'X' || cell.Style.Foreground != 1 {
3443 t.Fatalf("split ANSI write = %#v, want red X", cell)
3544 }
@@ -133,9 +142,6 @@ func TestExternalSnapshotsAndHistoryRespectOwnershipContracts(t *testing.T) {
133142 if got := before.Row(0)[0].Rune; got != 'a' {
134143 t.Fatalf("owned history row mutation changed view to %q", got)
135144 }
136- if borrowed := before.BorrowedRow(0); &borrowed[0] == &copyOfRow[0] {
137- t.Fatal("Row returned borrowed history storage instead of an owned copy")
138- }
139145
140146 appendHistoryRow(t, history, 'c', 12)
141147 after := history.View()
M extraction_line_callback_external_test.go +1 -1
@@ -26,7 +26,7 @@ func TestExternalLineEvictionCallbackReceivesStableCopy(t *testing.T) {
2626 t.Fatalf("evicted rows = %#v, want one stable AAAA row", evicted)
2727 }
2828 evicted[0][0] = core.Cell{Rune: 'X'}
29- if got := screen.Frame.At(0, 0).Rune; got != 'B' {
29+ if got := screen.Cell(0, 0).Rune; got != 'B' {
3030 t.Fatalf("mutating callback row changed live frame to %q", got)
3131 }
3232 if got := evicted[0][0].Rune; got != 'X' {
D fixture_compatibility_test.go +0 -47
@@ -1,47 +0,0 @@
1-package vt_test
2-
3-import (
4- "bytes"
5- "embed"
6- "encoding/binary"
7- "encoding/hex"
8- "hash/crc32"
9- "strings"
10- "testing"
11-)
12-
13-//go:embed testdata/vevs-v4-restore.golden.hex
14-var fixtures embed.FS
15-
16-func TestVEVSV4FixtureIsDeterministic(t *testing.T) {
17- encoded, err := fixtures.ReadFile("testdata/vevs-v4-restore.golden.hex")
18- if err != nil {
19- t.Fatal(err)
20- }
21- data, err := hex.DecodeString(strings.TrimSpace(string(encoded)))
22- if err != nil {
23- t.Fatalf("decode fixture: %v", err)
24- }
25- if len(data) < 16 || !bytes.Equal(data[:4], []byte("VEVS")) {
26- t.Fatalf("fixture header = %x, want VEVS", data[:min(len(data), 4)])
27- }
28- if version := binary.BigEndian.Uint16(data[4:6]); version != 4 {
29- t.Fatalf("fixture version = %d, want 4", version)
30- }
31- if bodyLength := binary.BigEndian.Uint32(data[8:12]); int(bodyLength) != len(data)-16 {
32- t.Fatalf("fixture body length = %d, want %d", bodyLength, len(data)-16)
33- }
34- if got := binary.BigEndian.Uint32(data[12:16]); got != crc32.ChecksumIEEE(data[16:]) {
35- t.Fatalf("fixture CRC = %#x, want %#x", got, crc32.ChecksumIEEE(data[16:]))
36- }
37- if got := bytes.Count(data, []byte("VTH1")); got != 3 {
38- t.Fatalf("embedded VTH3 marker count = %d, want 3", got)
39- }
40-}
41-
42-func min(a, b int) int {
43- if a < b {
44- return a
45- }
46- return b
47-}
M history.go +485 -208
@@ -2,106 +2,346 @@ package vt
22
33 import (
44 "errors"
5+ "fmt"
56 "math"
67 "slices"
78
89 renderer "github.com/bnema/vev-vt/core"
910 )
1011
11-// ErrHistoryRowTooWide is returned when a row cannot fit within the configured
12-// cell budget. The history is not modified when this error is returned.
13-var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity")
12+// ErrHistoryRowTooLarge is returned when one row cannot fit within the
13+// configured logical-byte budget or compact page bounds. History is unchanged.
14+var ErrHistoryRowTooLarge = errors.New("history row exceeds logical byte capacity")
1415
1516 var errInvalidHistoryRowID = errors.New("invalid history row ID")
1617
17-// HistoryConfig controls the bounded terminal history retained by a Screen.
18-type HistoryConfig struct {
19- MaxRows int
20- MaxCells int
21- ChunkRows int
22-}
18+const maxTailPreallocCells = 32 * 1024
2319
2420 // History stores terminal rows in immutable chunks. It is intended to be
2521 // mutated by the owner of a Screen; views are safe to retain after later
2622 // appends.
2723 type History struct {
28- maxRows int
29- maxCells int
30- chunkRows int
31- chunks []*HistoryChunk
32- tail [][]renderer.Cell
33- tailBounds []LineBound
34- tailIDs []RowID
35- rows int
36- cells int
37- nextRowID RowID
38-}
39-
40-// HistoryChunk is an immutable group of history rows. Its identity is stable
41-// and can be used by consumers to avoid copying unchanged sealed chunks.
42-// bounds is parallel to rows and always has the same length.
24+ maxRows int
25+ rowCeiling int
26+ maxBytes uint64
27+ byteCeiling uint64
28+ chunkRows int
29+ chunks []*HistoryChunk
30+ tail [][]renderer.Cell
31+ cachedTail []*HistoryChunk
32+ tailCells []renderer.Cell
33+ tailBounds []LineBound
34+ tailIDs []RowID
35+ tailBytes uint64
36+ tailPageWidth int
37+ tailPageRows int
38+ tailPageStyles map[renderer.Style]struct{}
39+ styleScratch map[renderer.Style]struct{}
40+ payloadScratch map[renderer.CellPayload]struct{}
41+ tailPagePayloads map[renderer.CellPayload]struct{}
42+ rows int
43+ cells int
44+ logicalBytes uint64
45+ nextRowID RowID
46+ compressionCursor int
47+}
48+
49+// HistoryChunk is an immutable compact slab of equal-width history rows. Its
50+// identity is stable and can be used by consumers to reuse unchanged chunks.
4351 type HistoryChunk struct {
44- rows [][]renderer.Cell
45- bounds []LineBound
46- rowIDs []RowID
52+ page *sealedPage
53+ start int
54+ count int
55+ width int
56+ bounds []LineBound
57+ rowIDs []RowID
58+ styleDrops []uint64
59+ styleCount uint64
60+ payloadDrops []uint64
61+ payloadBytes uint64
62+}
63+
64+func newHistoryChunks(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID) []*HistoryChunk {
65+ bounds = growBounds(bounds, len(rows))
66+ rowIDs = growRowIDs(rowIDs, len(rows))
67+ var chunks []*HistoryChunk
68+ for start := 0; start < len(rows); {
69+ width := len(rows[start])
70+ limit := start + min(len(rows)-start, maxHistorySlabRows(width))
71+ end := start + 1
72+ for end < limit && len(rows[end]) == width {
73+ end++
74+ }
75+ frame := renderer.NewFrame(width, end-start)
76+ lastStyleRow := make(map[renderer.Style]int)
77+ for i := start; i < end; i++ {
78+ frame.WriteRow(i-start, 0, rows[i])
79+ for _, cell := range rows[i] {
80+ style := cell.Style.Canonical()
81+ if style != renderer.DefaultStyle() {
82+ lastStyleRow[style] = i - start
83+ }
84+ }
85+ }
86+ styleDrops := make([]uint64, end-start)
87+ for _, row := range lastStyleRow {
88+ styleDrops[row]++
89+ }
90+ chunks = append(chunks, &HistoryChunk{
91+ page: newSealedPage(frame),
92+ count: end - start,
93+ width: width,
94+ bounds: append([]LineBound(nil), bounds[start:end]...),
95+ rowIDs: append([]RowID(nil), rowIDs[start:end]...),
96+ styleDrops: styleDrops,
97+ styleCount: uint64(len(lastStyleRow)) + 1,
98+ })
99+ chunks[len(chunks)-1].recordPayloads()
100+ start = end
101+ }
102+ return chunks
103+}
104+
105+func maxHistorySlabRows(width int) int {
106+ if width <= 0 {
107+ return math.MaxInt
108+ }
109+ rows := min(uint64(math.MaxUint32)/uint64(width), uint64(math.MaxInt))
110+ return max(int(rows), 1)
111+}
112+
113+func (c *HistoryChunk) len() int {
114+ if c == nil {
115+ return 0
116+ }
117+ return c.count
118+}
119+
120+func (c *HistoryChunk) row(i int) []renderer.Cell {
121+ if c == nil || i < 0 || i >= c.count {
122+ return nil
123+ }
124+ return c.frameView().Row(c.start + i)
125+}
126+
127+func (c *HistoryChunk) cell(row, column int) renderer.Cell {
128+ return c.frameView().Cell(column, c.start+row)
129+}
130+
131+func (c *HistoryChunk) cells() int { return c.count * c.width }
132+
133+// CheckInvariants validates one compact immutable history slab.
134+func (c *HistoryChunk) CheckInvariants() error {
135+ if c == nil || c.page == nil {
136+ return fmt.Errorf("nil history chunk backing")
137+ }
138+ frame, err := c.page.readFrame(true)
139+ if err != nil {
140+ return err
141+ }
142+ if c.start < 0 || c.count <= 0 || c.width < 0 || c.start > frame.Height-c.count {
143+ return fmt.Errorf("invalid history chunk range start=%d count=%d", c.start, c.count)
144+ }
145+ if frame.Width != c.width || len(c.bounds) != c.count || len(c.rowIDs) != c.count || len(c.styleDrops) != frame.Height {
146+ return fmt.Errorf("history chunk metadata is not parallel")
147+ }
148+ styles := uint64(1)
149+ for _, dropped := range c.styleDrops[c.start:] {
150+ styles += dropped
151+ }
152+ if c.styleCount != styles {
153+ return fmt.Errorf("history chunk style count = %d want %d", c.styleCount, styles)
154+ }
155+ var payloadBytes uint64
156+ if len(c.payloadDrops) != 0 {
157+ if len(c.payloadDrops) != frame.Height {
158+ return fmt.Errorf("history payload metadata is not parallel")
159+ }
160+ for _, dropped := range c.payloadDrops[c.start:] {
161+ payloadBytes += dropped
162+ }
163+ }
164+ if payloadBytes != c.payloadBytes {
165+ return fmt.Errorf("history payload accounting mismatch")
166+ }
167+ if err := frame.CheckInvariants(); err != nil {
168+ return fmt.Errorf("history chunk frame: %w", err)
169+ }
170+ for i, id := range c.rowIDs {
171+ if id == 0 || id >= ^RowID(0)-1 {
172+ return fmt.Errorf("history row %d has invalid ID %d", i, id)
173+ }
174+ if !validHistoryBound(c.bounds[i], c.width) {
175+ return fmt.Errorf("history row %d has invalid bound", i)
176+ }
177+ }
178+ return nil
47179 }
48180
49181 // HistoryView is an immutable snapshot of history. Row returns a copy so the
50182 // storage behind a sealed chunk remains owned by VT.
51183 type HistoryView struct {
52- chunks []*HistoryChunk
53- rows int
54- cells int
55- nextRowID RowID
184+ chunks []*HistoryChunk
185+ rows int
186+ cells int
187+ logicalBytes uint64
188+ nextRowID RowID
56189 }
57190
58191 // HistorySnapshotView captures sealed history chunks and the mutable tail
59192 // independently. Sealed chunks are shared by identity; Tail is owned by the
60193 // view and can be serialized without rotating the live tail into a chunk.
61194 type HistorySnapshotView struct {
62- chunks []*HistoryChunk
63- tail [][]renderer.Cell
64- tailBounds []LineBound
65- tailIDs []RowID
66- rows int
67- cells int
68- nextRowID RowID
195+ chunks []*HistoryChunk
196+ tail HistoryView
197+ rows int
198+ cells int
199+ logicalBytes uint64
200+ nextRowID RowID
201+}
202+
203+func historyRowBaseLogicalBytes(width int) uint64 {
204+ return uint64(width)*renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes
69205 }
70206
71-func NewHistory(config HistoryConfig) *History {
72- if config.MaxRows <= 0 {
73- return &History{}
207+func addLogicalBytes(total, value uint64) uint64 {
208+ if value > math.MaxUint64-total {
209+ return math.MaxUint64
74210 }
75- chunkRows := config.ChunkRows
76- if chunkRows <= 0 {
77- chunkRows = 256
211+ return total + value
212+}
213+
214+func historyChunkLogicalBytes(chunk *HistoryChunk) uint64 {
215+ if chunk == nil {
216+ return 0
78217 }
79- chunkRows = min(chunkRows, 256)
80- chunkRows = min(chunkRows, config.MaxRows)
81- maxCells := config.MaxCells
82- if maxCells <= 0 {
83- maxCells = defaultHistoryMaxCells(config.MaxRows)
218+ return uint64(chunk.len())*historyRowBaseLogicalBytes(chunk.width) + chunk.styleCount*renderer.StyleRecordLogicalBytes + chunk.payloadBytes
219+}
220+
221+func historyChunksLogicalBytes(chunks []*HistoryChunk) uint64 {
222+ var total uint64
223+ for _, chunk := range chunks {
224+ total = addLogicalBytes(total, historyChunkLogicalBytes(chunk))
84225 }
85- return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows, nextRowID: 1}
226+ return total
86227 }
87228
88-func defaultHistoryMaxCells(maxRows int) int {
89- if maxRows > math.MaxInt/160 {
90- return math.MaxInt
229+func (h *History) prepareRowStyles(row []renderer.Cell) uint64 {
230+ clear(h.payloadScratch)
231+ if h.styleScratch == nil {
232+ h.styleScratch = make(map[renderer.Style]struct{})
233+ } else {
234+ clear(h.styleScratch)
235+ }
236+ if len(row) > 0 {
237+ firstRaw := row[0].Style
238+ same := true
239+ for _, cell := range row {
240+ h.recordPayloadScratch(cell.Payload)
241+ if cell.Style != firstRaw {
242+ same = false
243+ }
244+ }
245+ if same {
246+ first := firstRaw.Canonical()
247+ if first != renderer.DefaultStyle() {
248+ h.styleScratch[first] = struct{}{}
249+ }
250+ } else {
251+ for _, cell := range row {
252+ style := cell.Style.Canonical()
253+ if style != renderer.DefaultStyle() {
254+ h.styleScratch[style] = struct{}{}
255+ }
256+ }
257+ }
258+ }
259+ return historyRowBaseLogicalBytes(len(row)) + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()
260+}
261+
262+func (h *History) prepareChunkRowStyles(chunk *HistoryChunk, row int) uint64 {
263+ clear(h.payloadScratch)
264+ if h.styleScratch == nil {
265+ h.styleScratch = make(map[renderer.Style]struct{})
266+ } else {
267+ clear(h.styleScratch)
268+ }
269+ for column := range chunk.width {
270+ cell := chunk.cell(row, column)
271+ h.recordPayloadScratch(cell.Payload)
272+ style := cell.Style.Canonical()
273+ if style != renderer.DefaultStyle() {
274+ h.styleScratch[style] = struct{}{}
275+ }
276+ }
277+ return historyRowBaseLogicalBytes(chunk.width) + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()
278+}
279+
280+func (h *History) tailAppendDelta(width int) uint64 {
281+ newPage := h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width)
282+ delta := historyRowBaseLogicalBytes(width)
283+ if newPage {
284+ return delta + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes()
285+ }
286+ for style := range h.styleScratch {
287+ if _, ok := h.tailPageStyles[style]; !ok {
288+ delta += renderer.StyleRecordLogicalBytes
289+ }
290+ }
291+ for p := range h.payloadScratch {
292+ if _, ok := h.tailPagePayloads[p]; !ok {
293+ delta += p.LogicalBytes()
294+ }
91295 }
92- return maxRows * 160
296+ return delta
297+}
298+
299+func (h *History) recordTailRowStyles(width int) {
300+ if h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width) {
301+ h.tailPageWidth = width
302+ h.tailPageRows = 0
303+ h.tailPageStyles = map[renderer.Style]struct{}{renderer.DefaultStyle(): {}}
304+ h.tailPagePayloads = nil
305+ }
306+ for p := range h.payloadScratch {
307+ if h.tailPagePayloads == nil {
308+ h.tailPagePayloads = make(map[renderer.CellPayload]struct{})
309+ }
310+ h.tailPagePayloads[p] = struct{}{}
311+ }
312+ for style := range h.styleScratch {
313+ h.tailPageStyles[style] = struct{}{}
314+ }
315+ h.tailPageRows++
316+}
317+
318+func (h *History) rebuildTailAccounting() {
319+ old := h.tailBytes
320+ h.tailBytes = 0
321+ h.tailPageWidth = 0
322+ h.tailPageRows = 0
323+ h.tailPageStyles = nil
324+ h.tailPagePayloads = nil
325+ for _, row := range h.tail {
326+ h.prepareRowStyles(row)
327+ delta := h.tailAppendDelta(len(row))
328+ h.tailBytes += delta
329+ h.recordTailRowStyles(len(row))
330+ }
331+ h.logicalBytes = h.logicalBytes - old + h.tailBytes
93332 }
94333
95334 // Append records a copy of row along with its logical extent and an
96335 // automatically allocated nonzero ID. Once a chunk is full it is sealed
97-// forever. Rows wider than the total cell capacity are rejected without
336+// forever. Rows larger than the logical-byte capacity are rejected without
98337 // mutation.
99338 func (h *History) Append(row []renderer.Cell, bound LineBound) error {
100339 if h == nil || h.maxRows == 0 {
101340 return nil
102341 }
103- if len(row) > h.maxCells {
104- return ErrHistoryRowTooWide
342+ defer func() { clear(h.payloadScratch) }()
343+ if uint64(len(row)) > uint64(math.MaxUint32) || historyRowBaseLogicalBytes(len(row))+renderer.StyleRecordLogicalBytes > h.maxBytes || h.prepareRowStyles(row) > h.maxBytes {
344+ return ErrHistoryRowTooLarge
105345 }
106346 id, err := h.allocateRowID()
107347 if err != nil {
@@ -119,8 +359,9 @@ func (h *History) AppendWithID(row []renderer.Cell, bound LineBound, id RowID) e
119359 if h == nil || h.maxRows == 0 {
120360 return nil
121361 }
122- if len(row) > h.maxCells {
123- return ErrHistoryRowTooWide
362+ defer func() { clear(h.payloadScratch) }()
363+ if uint64(len(row)) > uint64(math.MaxUint32) || historyRowBaseLogicalBytes(len(row))+renderer.StyleRecordLogicalBytes > h.maxBytes || h.prepareRowStyles(row) > h.maxBytes {
364+ return ErrHistoryRowTooLarge
124365 }
125366 if h.hasRowID(id) {
126367 return errInvalidHistoryRowID
@@ -132,19 +373,20 @@ func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) erro
132373 if h.nextRowID == ^RowID(0) {
133374 return errInvalidHistoryRowID
134375 }
135- if len(row) > h.maxCells {
136- return ErrHistoryRowTooWide
137- }
138376 if id >= ^RowID(0)-1 {
139377 return errInvalidHistoryRowID
140378 }
141379 if id >= h.nextRowID {
142380 h.nextRowID = id + 1
143381 }
144- // Make room before adding so Cells cannot overflow even when the default
145- // capacity is MaxInt.
146- h.evictFor(len(row))
147- h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
382+ // Make room before adding so accounting cannot overflow even when the
383+ // configured capacities use their maximum values.
384+ h.evictFor(row)
385+ delta := h.tailAppendDelta(len(row))
386+ h.appendTailRow(row)
387+ h.recordTailRowStyles(len(row))
388+ h.tailBytes += delta
389+ h.logicalBytes += delta
148390 h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))
149391 h.tailIDs = append(h.tailIDs, id)
150392 h.rows++
@@ -205,90 +447,115 @@ func growRowIDs(ids []RowID, rows int) []RowID {
205447 return out
206448 }
207449
208-func cloneRowIDs(ids []RowID, rows int) []RowID {
209- out := make([]RowID, rows)
210- copy(out, ids)
211- return out
450+func (h *History) reserveTailRow(width int) (start, end int) {
451+ h.cachedTail = nil
452+ start = len(h.tailCells)
453+ end = start + width
454+ if end > cap(h.tailCells) {
455+ capacity := end
456+ if cap(h.tailCells) <= (math.MaxInt-1)/2 {
457+ capacity = max(capacity, cap(h.tailCells)*2)
458+ }
459+ if len(h.tailCells) == 0 {
460+ prealloc := uint64(maxTailPreallocCells)
461+ prealloc = min(prealloc, h.maxBytes/renderer.StoredCellLogicalBytes, uint64(width)*uint64(h.chunkRows))
462+ capacity = max(capacity, max(width, int(prealloc)))
463+ }
464+ cells := make([]renderer.Cell, len(h.tailCells), capacity)
465+ copy(cells, h.tailCells)
466+ h.tailCells = cells
467+ offset := 0
468+ for i := range h.tail {
469+ rowWidth := len(h.tail[i])
470+ h.tail[i] = h.tailCells[offset : offset+rowWidth : offset+rowWidth]
471+ offset += rowWidth
472+ }
473+ }
474+ h.tailCells = h.tailCells[:end]
475+ return start, end
476+}
477+
478+func (h *History) appendTailRow(row []renderer.Cell) {
479+ start, end := h.reserveTailRow(len(row))
480+ copy(h.tailCells[start:end], row)
481+ h.tail = append(h.tail, h.tailCells[start:end:end])
482+}
483+
484+func (h *History) appendTailChunkRow(chunk *HistoryChunk, row int) {
485+ start, end := h.reserveTailRow(chunk.width)
486+ for x := range chunk.width {
487+ h.tailCells[start+x] = chunk.cell(row, x)
488+ }
489+ h.tail = append(h.tail, h.tailCells[start:end:end])
212490 }
213491
214492 func (h *History) sealTail() {
215493 if len(h.tail) == 0 {
216494 return
217495 }
218- h.chunks = append(h.chunks, &HistoryChunk{
219- rows: h.tail,
220- bounds: growBounds(h.tailBounds, len(h.tail)),
221- rowIDs: h.tailIDs,
222- })
496+ chunks := h.tailViewChunks()
497+ h.chunks = append(h.chunks, chunks...)
498+ h.logicalBytes = h.logicalBytes - h.tailBytes + historyChunksLogicalBytes(chunks)
223499 h.tail = nil
500+ h.cachedTail = nil
501+ h.tailCells = nil
224502 h.tailBounds = nil
225503 h.tailIDs = nil
226-}
227-
228-// normalizeTail seals complete chunks so the mutable tail remains shorter than
229-// chunkRows. Restored snapshots may have been written with a different chunk
230-// size than the current history configuration.
231-func (h *History) normalizeTail() {
232- h.tailIDs = growRowIDs(h.tailIDs, len(h.tail))
233- for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {
234- bounds := growBounds(h.tailBounds, len(h.tail))
235- h.chunks = append(h.chunks, &HistoryChunk{
236- rows: h.tail[:h.chunkRows],
237- bounds: bounds[:h.chunkRows],
238- rowIDs: h.tailIDs[:h.chunkRows],
239- })
240- h.tail = h.tail[h.chunkRows:]
241- h.tailBounds = bounds[h.chunkRows:]
242- h.tailIDs = h.tailIDs[h.chunkRows:]
243- }
244-}
245-
246-func (h *History) evict() {
247- h.evictUntil(0, 0)
248-}
249-
250-// evictFor discards oldest rows until another row using cellCount cells can fit.
251-func (h *History) evictFor(cellCount int) {
252- h.evictUntil(1, cellCount)
253-}
254-
255-// evictUntil makes room for rowCount rows and cellCount cells without
256-// overflowing either accounting total.
257-func (h *History) evictUntil(rowCount, cellCount int) {
258- for h.rows > h.maxRows-rowCount || h.cells > h.maxCells-cellCount {
259- if len(h.chunks) > 0 {
260- chunk := h.chunks[0]
261- row := chunk.rows[0]
262- h.rows--
263- h.cells -= len(row)
264- if len(chunk.rows) == 1 {
265- copy(h.chunks, h.chunks[1:])
266- h.chunks[len(h.chunks)-1] = nil
267- h.chunks = h.chunks[:len(h.chunks)-1]
268- } else {
269- // Preserve cell storage while replacing only the chunk wrapper: a
270- // retained view may still refer to the original immutable chunk.
271- bounds := growBounds(chunk.bounds, len(chunk.rows))
272- rowIDs := growRowIDs(chunk.rowIDs, len(chunk.rows))
273- h.chunks[0] = &HistoryChunk{
274- rows: chunk.rows[1:],
275- bounds: bounds[1:],
276- rowIDs: rowIDs[1:],
277- }
278- }
279- continue
280- }
281- if len(h.tail) == 0 {
504+ h.tailBytes = 0
505+ h.tailPageWidth = 0
506+ h.tailPageRows = 0
507+ h.tailPageStyles = nil
508+ h.tailPagePayloads = nil
509+}
510+
511+// evictFor discards oldest rows until row can fit both retention budgets.
512+func (h *History) evictFor(row []renderer.Cell) {
513+ for {
514+ delta := h.tailAppendDelta(len(row))
515+ if h.rows <= h.maxRows-1 && h.logicalBytes <= h.maxBytes-delta {
282516 return
283517 }
284- row := h.tail[0]
518+ h.evictOldest()
519+ h.prepareRowStyles(row)
520+ }
521+}
522+
523+func (h *History) evictOldest() {
524+ if h.rows == 0 {
525+ return
526+ }
527+ if len(h.chunks) > 0 {
528+ chunk := h.chunks[0]
529+ oldBytes := historyChunkLogicalBytes(chunk)
285530 h.rows--
286- h.cells -= len(row)
287- h.tail[0] = nil
288- h.tail = h.tail[1:]
289- h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
290- h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:]
531+ h.cells -= chunk.width
532+ if chunk.count == 1 {
533+ h.logicalBytes -= oldBytes
534+ copy(h.chunks, h.chunks[1:])
535+ h.chunks[len(h.chunks)-1] = nil
536+ h.chunks = h.chunks[:len(h.chunks)-1]
537+ } else {
538+ // Preserve cell storage while replacing only the chunk wrapper: a
539+ // retained view may still refer to the original immutable chunk. The
540+ // wrapper tracks only styles still used by its retained suffix.
541+ h.chunks[0] = chunk.withoutFirstRow()
542+ h.logicalBytes -= oldBytes - historyChunkLogicalBytes(h.chunks[0])
543+ }
544+ return
291545 }
546+ if len(h.tail) == 0 {
547+ return
548+ }
549+ h.cachedTail = nil
550+ row := h.tail[0]
551+ h.rows--
552+ h.cells -= len(row)
553+ h.tail[0] = nil
554+ h.tail = h.tail[1:]
555+ h.tailCells = h.tailCells[len(row):]
556+ h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
557+ h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:]
558+ h.rebuildTailAccounting()
292559 }
293560
294561 // SealAndView rotates the mutable tail into an immutable chunk, then captures
@@ -300,8 +567,15 @@ func (h *History) SealAndView() HistoryView {
300567 return h.View()
301568 }
302569
303-// View captures the current history. Sealed chunks are shared by identity; a
304-// partially-filled tail is copied into a new immutable chunk for this view.
570+func (h *History) tailViewChunks() []*HistoryChunk {
571+ if h.cachedTail == nil && len(h.tail) > 0 {
572+ h.cachedTail = newHistoryChunks(h.tail, h.tailBounds, h.tailIDs)
573+ }
574+ return h.cachedTail
575+}
576+
577+// View captures the current history. Sealed chunks are shared by identity;
578+// the immutable tail copy is reused until the mutable tail changes.
305579 func (h *History) View() HistoryView {
306580 if h == nil {
307581 return HistoryView{}
@@ -312,13 +586,9 @@ func (h *History) View() HistoryView {
312586 chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1)
313587 copy(chunks, h.chunks)
314588 if len(h.tail) > 0 {
315- chunks = append(chunks, &HistoryChunk{
316- rows: cloneHistoryRows(h.tail),
317- bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
318- rowIDs: cloneRowIDs(h.tailIDs, len(h.tail)),
319- })
589+ chunks = append(chunks, h.tailViewChunks()...)
320590 }
321- return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID}
591+ return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, logicalBytes: h.logicalBytes, nextRowID: h.nextRowID}
322592 }
323593
324594 // SnapshotView captures history for persistence without sealing the mutable
@@ -330,28 +600,23 @@ func (h *History) SnapshotView() HistorySnapshotView {
330600 if h.rows == 0 {
331601 return HistorySnapshotView{nextRowID: h.nextRowID}
332602 }
603+ tailChunks := newHistoryChunks(h.tail, h.tailBounds, h.tailIDs)
333604 return HistorySnapshotView{
334- chunks: append([]*HistoryChunk(nil), h.chunks...),
335- tail: cloneHistoryRows(h.tail),
336- tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
337- tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)),
338- rows: h.rows,
339- cells: h.cells,
340- nextRowID: h.nextRowID,
605+ chunks: append([]*HistoryChunk(nil), h.chunks...),
606+ tail: HistoryView{
607+ chunks: tailChunks,
608+ rows: len(h.tail),
609+ cells: historyRowsCells(h.tail),
610+ logicalBytes: historyChunksLogicalBytes(tailChunks),
611+ nextRowID: h.nextRowID,
612+ },
613+ rows: h.rows,
614+ cells: h.cells,
615+ logicalBytes: h.logicalBytes,
616+ nextRowID: h.nextRowID,
341617 }
342618 }
343619
344-func cloneHistoryRows(rows [][]renderer.Cell) [][]renderer.Cell {
345- if len(rows) == 0 {
346- return nil
347- }
348- copyRows := make([][]renderer.Cell, len(rows))
349- for i, row := range rows {
350- copyRows[i] = append([]renderer.Cell(nil), row...)
351- }
352- return copyRows
353-}
354-
355620 // Len returns the currently retained history row count.
356621 func (h *History) Len() int {
357622 if h == nil {
@@ -368,6 +633,15 @@ func (h *History) Cells() int {
368633 return h.cells
369634 }
370635
636+// LogicalBytes returns deterministic uncompressed bytes retained by history.
637+// It excludes the live primary and alternate screens and has no page allowance.
638+func (h *History) LogicalBytes() uint64 {
639+ if h == nil {
640+ return 0
641+ }
642+ return h.logicalBytes
643+}
644+
371645 // NextRowID returns the next identity allocated by this history.
372646 func (h *History) NextRowID() RowID {
373647 if h == nil || h.nextRowID == 0 {
@@ -376,24 +650,32 @@ func (h *History) NextRowID() RowID {
376650 return h.nextRowID
377651 }
378652
379-// Cap returns the configured bounded row capacity.
653+// Cap returns the configured row ceiling; zero means no independent row
654+// ceiling, or disabled history when ByteCap is also zero.
380655 func (h *History) Cap() int {
381656 if h == nil {
382657 return 0
383658 }
384- return h.maxRows
659+ return h.rowCeiling
385660 }
386661
387-// CellCap returns the configured bounded cell capacity.
388-func (h *History) CellCap() int {
662+// ByteCap returns the configured uncompressed byte ceiling. Zero means no
663+// independent byte ceiling, or disabled history when Cap is also zero.
664+func (h *History) ByteCap() uint64 {
389665 if h == nil {
390666 return 0
391667 }
392- return h.maxCells
668+ return h.byteCeiling
393669 }
394670
395-func (v HistoryView) Len() int { return v.rows }
396-func (v HistoryView) Cells() int { return v.cells }
671+func (v HistoryView) Len() int { return v.rows }
672+func (v HistoryView) Cells() int { return v.cells }
673+func (v HistoryView) LogicalBytes() uint64 {
674+ if v.logicalBytes != 0 || len(v.chunks) == 0 {
675+ return v.logicalBytes
676+ }
677+ return historyChunksLogicalBytes(v.chunks)
678+}
397679 func (v HistoryView) ChunkCount() int { return len(v.chunks) }
398680
399681 // NextRowID returns the next identity recorded by this view.
@@ -416,37 +698,32 @@ func (v HistoryView) Chunk(i int) *HistoryChunk {
416698 return v.chunks[i]
417699 }
418700
419-// Range calls yield for each row in oldest-first order. The row is borrowed
420-// immutable storage: yield must not mutate or retain it after returning.
421-func (v HistoryView) Range(yield func([]renderer.Cell) bool) {
701+// Range calls yield with a decoded owned row in oldest-first order. Returning
702+// false stops iteration successfully; a backing restore failure returns an error.
703+func (v HistoryView) Range(yield func([]renderer.Cell) bool) error {
422704 for _, chunk := range v.chunks {
423- for _, row := range chunk.rows {
424- if !yield(row) {
425- return
705+ // Streaming search restores at most one transient page at a time and
706+ // does not populate the random-access cache of every cold page.
707+ frame, err := chunk.page.readFrame(false)
708+ if err != nil {
709+ return err
710+ }
711+ for i := range chunk.len() {
712+ if !yield(frame.Row(chunk.start + i)) {
713+ return nil
426714 }
427715 }
428716 }
717+ return nil
429718 }
430719
431-// Row returns a copy of the row at i, or nil when i is out of range.
720+// Row returns a decoded owned copy of the row at i, or nil when out of range.
432721 func (v HistoryView) Row(i int) []renderer.Cell {
433- return append([]renderer.Cell(nil), v.BorrowedRow(i)...)
434-}
435-
436-// BorrowedRow returns immutable storage for the row at i, or nil when i is out
437-// of range. The caller must not mutate or retain the result after it no longer
438-// retains v. Consumers that need ownership should use Row instead.
439-func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
440- if i < 0 {
722+ chunk, row := v.locateRow(i)
723+ if chunk == nil {
441724 return nil
442725 }
443- for _, chunk := range v.chunks {
444- if i < len(chunk.rows) {
445- return chunk.rows[i]
446- }
447- i -= len(chunk.rows)
448- }
449- return nil
726+ return chunk.row(row)
450727 }
451728
452729 // RowID returns the identity of the row at i, or zero when i is out of range.
@@ -455,13 +732,13 @@ func (v HistoryView) RowID(i int) RowID {
455732 return 0
456733 }
457734 for _, chunk := range v.chunks {
458- if i < len(chunk.rows) {
735+ if i < chunk.len() {
459736 if i < len(chunk.rowIDs) {
460737 return chunk.rowIDs[i]
461738 }
462739 return 0
463740 }
464- i -= len(chunk.rows)
741+ i -= chunk.len()
465742 }
466743 return 0
467744 }
@@ -473,7 +750,7 @@ func (v HistoryView) FindRowID(id RowID) int {
473750 }
474751 index := 0
475752 for _, chunk := range v.chunks {
476- for row := range chunk.rows {
753+ for row := range chunk.len() {
477754 if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id {
478755 return index
479756 }
@@ -498,19 +775,25 @@ func (v HistoryView) Bound(i int) LineBound {
498775 return LineBound{}
499776 }
500777 for _, chunk := range v.chunks {
501- if i < len(chunk.rows) {
778+ if i < chunk.len() {
502779 if i < len(chunk.bounds) {
503780 return chunk.bounds[i]
504781 }
505782 return LineBound{}
506783 }
507- i -= len(chunk.rows)
784+ i -= chunk.len()
508785 }
509786 return LineBound{}
510787 }
511788
512-func (v HistorySnapshotView) Len() int { return v.rows }
513-func (v HistorySnapshotView) Cells() int { return v.cells }
789+func (v HistorySnapshotView) Len() int { return v.rows }
790+func (v HistorySnapshotView) Cells() int { return v.cells }
791+func (v HistorySnapshotView) LogicalBytes() uint64 {
792+ if v.logicalBytes != 0 || v.rows == 0 {
793+ return v.logicalBytes
794+ }
795+ return addLogicalBytes(historyChunksLogicalBytes(v.chunks), v.tail.LogicalBytes())
796+}
514797 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
515798
516799 // NextRowID returns the next identity recorded by this snapshot.
@@ -531,15 +814,9 @@ func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {
531814
532815 // Tail returns an immutable view of the copied mutable tail.
533816 func (v HistorySnapshotView) Tail() HistoryView {
534- if len(v.tail) == 0 {
535- return HistoryView{nextRowID: v.nextRowID}
536- }
537- return HistoryView{
538- chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}},
539- rows: len(v.tail),
540- cells: historyRowsCells(v.tail),
541- nextRowID: v.nextRowID,
542- }
817+ tail := v.tail
818+ tail.nextRowID = v.nextRowID
819+ return tail
543820 }
544821
545822 func historyRowsCells(rows [][]renderer.Cell) int {
M history_acceptance_test.go +1 -1
@@ -84,7 +84,7 @@ func TestSnapshotCodecsPreserveEveryTerminalCellAcrossHistoryAndRecoveryTranscri
8484 screen := NewScreen(4, 2)
8585 want := [][]renderer.Cell{makeRow('D', rgb), makeRow('E', indexed)}
8686 for y, row := range want {
87- copy(screen.Frame.Row(y), row)
87+ screen.frame.WriteRow(y, 0, row)
8888 screen.buffer.boundaries[y] = LineBound{End: len(row)}
8989 }
9090
A history_bytes_test.go +130 -0
@@ -0,0 +1,130 @@
1+package vt
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ renderer "github.com/bnema/vev-vt/core"
8+)
9+
10+func TestHistoryBoundsRetainedLogicalBytes(t *testing.T) {
11+ const budget = 2*(2*renderer.StoredCellLogicalBytes+renderer.RowDescriptorLogicalBytes) + renderer.StyleRecordLogicalBytes
12+ history := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: budget, ChunkRows: 4})
13+ for _, text := range []string{"aa", "bb", "cc"} {
14+ requireHistoryAppend(t, history, historyRow(text))
15+ }
16+
17+ if got, want := historyViewTexts(history.View()), []string{"bb", "cc"}; !equalStrings(got, want) {
18+ t.Fatalf("retained rows = %#v, want %#v", got, want)
19+ }
20+ if got, want := history.LogicalBytes(), budget; got != want {
21+ t.Fatalf("logical bytes = %d, want %d", got, want)
22+ }
23+ if got, want := history.View().LogicalBytes(), budget; got != want {
24+ t.Fatalf("view logical bytes = %d, want %d", got, want)
25+ }
26+ if got, want := history.ByteCap(), budget; got != want {
27+ t.Fatalf("byte capacity = %d, want %d", got, want)
28+ }
29+}
30+
31+func TestHistoryLogicalBytesDeduplicateCanonicalStylesPerSlab(t *testing.T) {
32+ const budget = 2*(renderer.StoredCellLogicalBytes+renderer.RowDescriptorLogicalBytes) + 2*renderer.StyleRecordLogicalBytes
33+ history := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: budget, ChunkRows: 2})
34+ style := renderer.Style{Bold: true}
35+ for _, r := range []rune{'a', 'b'} {
36+ if err := history.Append([]renderer.Cell{{Rune: r, Style: style}}, LineBound{End: 1}); err != nil {
37+ t.Fatalf("append %q: %v", r, err)
38+ }
39+ }
40+
41+ if got, want := history.LogicalBytes(), budget; got != want {
42+ t.Fatalf("logical bytes = %d, want %d", got, want)
43+ }
44+ if got := history.SnapshotView().LogicalBytes(); got != history.LogicalBytes() {
45+ t.Fatalf("snapshot logical bytes = %d, want %d", got, history.LogicalBytes())
46+ }
47+ if got := history.SealAndView().LogicalBytes(); got != history.LogicalBytes() {
48+ t.Fatalf("sealed view logical bytes = %d, want %d", got, history.LogicalBytes())
49+ }
50+}
51+
52+func TestHistoryLogicalBytesRemainStableAfterStyledRowEvictionAndCodecRoundTrip(t *testing.T) {
53+ history := NewHistory(HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 3})
54+ styles := []renderer.Style{{Bold: true}, renderer.DefaultStyle(), renderer.DefaultStyle(), renderer.DefaultStyle()}
55+ for i, style := range styles {
56+ if err := history.Append([]renderer.Cell{{Rune: rune('a' + i), Style: style}}, LineBound{End: 1}); err != nil {
57+ t.Fatalf("append row %d: %v", i, err)
58+ }
59+ }
60+
61+ before := history.View()
62+ blob, err := MarshalHistory(before)
63+ if err != nil {
64+ t.Fatalf("marshal history: %v", err)
65+ }
66+ after, err := UnmarshalHistory(blob)
67+ if err != nil {
68+ t.Fatalf("unmarshal history: %v", err)
69+ }
70+ if got, want := after.LogicalBytes(), before.LogicalBytes(); got != want {
71+ t.Fatalf("round-trip logical bytes = %d, want %d", got, want)
72+ }
73+}
74+
75+func TestHistoryRejectsSingleRowLargerThanLogicalByteBudget(t *testing.T) {
76+ history := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: 79, ChunkRows: 4})
77+ row := []renderer.Cell{{Rune: 'x', Style: renderer.Style{Bold: true}}}
78+
79+ err := history.Append(row, LineBound{End: 1})
80+ if !errors.Is(err, ErrHistoryRowTooLarge) {
81+ t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooLarge", err)
82+ }
83+ if history.Len() != 0 || history.LogicalBytes() != 0 {
84+ t.Fatalf("rejected append mutated history: rows=%d bytes=%d", history.Len(), history.LogicalBytes())
85+ }
86+}
87+
88+func BenchmarkHistoryByteLimitedAppend(b *testing.B) {
89+ const rows, width = 256, 120
90+ maxBytes := uint64(rows)*(uint64(width)*renderer.StoredCellLogicalBytes+renderer.RowDescriptorLogicalBytes) + renderer.StyleRecordLogicalBytes
91+ history := NewHistory(HistoryConfig{MaxRows: rows * 2, MaxBytes: maxBytes, ChunkRows: rows})
92+ row := make([]renderer.Cell, width)
93+ for range rows {
94+ requireHistoryAppend(b, history, row)
95+ }
96+
97+ b.ReportAllocs()
98+ b.SetBytes(int64(width) * int64(renderer.StoredCellLogicalBytes))
99+ b.ResetTimer()
100+ for b.Loop() {
101+ requireHistoryAppend(b, history, row)
102+ }
103+ b.StopTimer()
104+ if history.LogicalBytes() > maxBytes {
105+ b.Fatalf("logical bytes = %d, exceeds capacity %d", history.LogicalBytes(), maxBytes)
106+ }
107+}
108+
109+func TestHistoryRestoreEvictsToLogicalByteBudget(t *testing.T) {
110+ source := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: 1 << 20, ChunkRows: 4})
111+ for _, text := range []string{"aa", "bb", "cc"} {
112+ requireHistoryAppend(t, source, historyRow(text))
113+ }
114+ sealed, tail, err := MarshalSealedHistory(source.SealAndView())
115+ if err != nil {
116+ t.Fatalf("marshal history: %v", err)
117+ }
118+
119+ const budget = 2*(2*renderer.StoredCellLogicalBytes+renderer.RowDescriptorLogicalBytes) + renderer.StyleRecordLogicalBytes
120+ restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 8, MaxBytes: budget, ChunkRows: 4}, sealed, tail)
121+ if err != nil {
122+ t.Fatalf("restore history: %v", err)
123+ }
124+ if got, want := historyViewTexts(restored.View()), []string{"bb", "cc"}; !equalStrings(got, want) {
125+ t.Fatalf("restored rows = %#v, want %#v", got, want)
126+ }
127+ if got := restored.LogicalBytes(); got > restored.ByteCap() {
128+ t.Fatalf("restored logical bytes = %d, exceeds capacity %d", got, restored.ByteCap())
129+ }
130+}
M history_codec.go +251 -333
@@ -5,108 +5,185 @@ import (
55 "errors"
66 "fmt"
77 "math"
8- "slices"
9- "unicode/utf8"
108
119 renderer "github.com/bnema/vev-vt/core"
1210 )
1311
1412 const (
15- historyMagic = "VTH1"
16- historyVersion = 3
17- historyCellBytes = 41
18- historyBoundBytes = 5
19- maxHistoryChunkRows = 256
20-
21- // The daemon retains 10,000 history rows. Support a 20% row margin and
22- // 160-cell rows, which leaves headroom above the representative 10k×120
23- // terminal while bounding snapshot allocation.
24- maxHistoryChunks = 12_000
25- maxHistoryRows = 12_000
26- maxHistoryRowCells = 160
27- maxHistoryCells = maxHistoryRows * maxHistoryRowCells
28-
29- maxHistoryDecodeStyles = maxHistoryCells
30- maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes
13+ historyMagic = "VTC1"
14+ historyHeaderBytes = 16
15+ historyChunkHeaderBytes = 16
16+ historyStoredCellBytes = 9
17+ historyStyleBytes = 37
18+ historyRowBytes = 13
19+ historyBoundBytes = 5
20+ maxHistoryChunkRows = 256
21+
22+ // Aggregate resource ceilings allow wide rows without tying persistence to
23+ // a particular terminal geometry. They apply before compact allocation.
24+ maxHistoryChunks = 12_000
25+ maxHistoryRows = 12_000
26+ maxHistoryRowCells = 160
27+ maxHistoryCells = maxHistoryRows * maxHistoryRowCells
28+ maxHistoryStylesPerChunk = maxHistoryCells + 1
29+ maxHistoryDecodeStyles = maxHistoryCells + maxHistoryChunks
30+ maxHistoryPayloadBytes = 64 << 20
31+ maxHistoryDecodedBytes = maxHistoryCells*renderer.StoredCellLogicalBytes +
32+ maxHistoryRows*renderer.RowDescriptorLogicalBytes +
33+ maxHistoryDecodeStyles*renderer.StyleRecordLogicalBytes + maxHistoryPayloadBytes
3134 )
3235
3336 var errInvalidHistory = errors.New("invalid history payload")
3437
35-// MarshalHistory encodes a HistoryView in a deterministic, self-contained
36-// format. It preserves chunk boundaries as well as every Cell and Style field.
38+type historyDecodeStats struct {
39+ chunks, rows, cells, styles, bytes uint64
40+ payloadBytes uint64
41+}
42+
43+// MarshalHistory serializes semantic cells with canonical chunk-local style
44+// dictionaries. Internal style IDs and page memory never cross this boundary.
3745 func MarshalHistory(view HistoryView) ([]byte, error) {
38- if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks {
46+ if view.rows < 0 || view.rows > maxHistoryRows || view.rows != historyViewRowCount(view) || len(view.chunks) > maxHistoryChunks {
3947 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
4048 }
41- nextRowID, ok := historyViewNextRowID(view)
49+ nextID, ok := historyViewNextRowID(view)
4250 if !ok {
4351 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
4452 }
45- stats := historyDecodeStats{}
46- seen := make(map[RowID]struct{}, view.rows)
53+ type encodedChunk struct {
54+ chunk *HistoryChunk
55+ styles []renderer.Style
56+ ids map[renderer.Style]uint32
57+ payloads []renderer.CellPayload
58+ payloadIDs map[renderer.CellPayload]uint32
59+ }
60+ encoded := make([]encodedChunk, 0, len(view.chunks))
61+ size := uint64(historyHeaderBytes)
62+ stats := historyDecodeStats{chunks: uint64(len(view.chunks))}
4763 for _, chunk := range view.chunks {
48- if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) > maxHistoryChunkRows ||
49- !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) {
64+ if chunk == nil || chunk.page == nil || chunk.len() == 0 || chunk.len() > maxHistoryChunkRows ||
65+ chunk.len() != len(chunk.rowIDs) || chunk.len() != len(chunk.bounds) || chunk.width < 0 ||
66+ uint64(chunk.width) > math.MaxUint32 {
5067 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
5168 }
52- for i, row := range chunk.rows {
53- if uint64(len(row)) > math.MaxUint32 {
54- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
55- }
56- id := chunk.rowIDs[i]
57- if id == 0 || id >= ^RowID(0)-1 {
58- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
59- }
60- if _, duplicate := seen[id]; duplicate {
61- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
62- }
63- seen[id] = struct{}{}
64- cellCount := uint64(len(row))
65- cellBytes, ok := historyCellByteCount(cellCount)
66- if !ok ||
67- !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) ||
68- !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) ||
69- !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
70- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
71- }
72- bound := chunkBound(chunk, i)
73- if !validHistoryBound(bound, len(row)) {
69+ cells := uint64(chunk.width) * uint64(chunk.len())
70+ if !addHistoryDecodeBudget(&stats.rows, uint64(chunk.len()), maxHistoryRows) ||
71+ !addHistoryDecodeBudget(&stats.cells, cells, maxHistoryCells) {
72+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
73+ }
74+ frame, err := chunk.page.readFrame(false)
75+ if err != nil {
76+ return nil, fmt.Errorf("marshal history: %w", err)
77+ }
78+ if frame.Width != chunk.width || chunk.start < 0 || chunk.start > frame.Height-chunk.count {
79+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
80+ }
81+ styles := []renderer.Style{renderer.DefaultStyle()}
82+ ids := map[renderer.Style]uint32{renderer.DefaultStyle(): 0}
83+ var payloads []renderer.CellPayload
84+ var payloadIDs map[renderer.CellPayload]uint32
85+ var payloadWireBytes uint64
86+ for row := range chunk.len() {
87+ if !validHistoryBound(chunk.bounds[row], chunk.width) {
7488 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
7589 }
76- for _, cell := range row {
90+ for x := range chunk.width {
91+ cell := frame.Cell(x, chunk.start+row)
7792 if !validHistoryCell(cell) {
7893 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
7994 }
95+ if !cell.Payload.Empty() {
96+ payloadWireBytes += 4 // The handle is present only for exceptional cells.
97+ if payloadIDs == nil {
98+ payloadIDs = make(map[renderer.CellPayload]uint32)
99+ }
100+ if _, exists := payloadIDs[cell.Payload]; !exists {
101+ n := cell.Payload.LogicalBytes()
102+ if !addHistoryDecodeBudget(&stats.payloadBytes, n, maxHistoryPayloadBytes) || !addHistoryDecodeBudget(&stats.bytes, n, maxHistoryDecodedBytes) {
103+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
104+ }
105+ payloads = append(payloads, cell.Payload)
106+ payloadIDs[cell.Payload] = uint32(len(payloads))
107+ payloadWireBytes += 8 + uint64(len(cell.Payload.Grapheme())+len(cell.Payload.Hyperlink()))
108+ }
109+ }
110+ style := cell.Style.Canonical()
111+ if _, exists := ids[style]; !exists {
112+ ids[style] = uint32(len(styles))
113+ styles = append(styles, style)
114+ }
80115 }
81116 }
117+ if !addHistoryDecodeBudget(&stats.styles, uint64(len(styles)), maxHistoryDecodeStyles) ||
118+ !addHistoryLogicalBytes(&stats, cells, uint64(chunk.len()), uint64(len(styles))) {
119+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
120+ }
121+ size += historyChunkHeaderBytes + uint64(len(styles)-1)*historyStyleBytes + uint64(chunk.len())*historyRowBytes + cells*historyStoredCellBytes + payloadWireBytes
122+ encoded = append(encoded, encodedChunk{chunk: chunk, styles: styles, ids: ids, payloads: payloads, payloadIDs: payloadIDs})
82123 }
83- if nextRowID <= maxHistoryRowID(seen) {
84- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
85- }
86-
87- out := make([]byte, 0, 17)
124+ // Aggregate budgets bound size well below MaxInt even on 32-bit hosts.
125+ out := make([]byte, 0, int(size))
88126 out = append(out, historyMagic...)
89- out = append(out, historyVersion)
90- out = appendUint32(out, uint32(len(view.chunks)))
91- out = binary.BigEndian.AppendUint64(out, uint64(nextRowID))
92- for _, chunk := range view.chunks {
93- out = appendUint32(out, uint32(len(chunk.rows)))
94- for i, row := range chunk.rows {
95- out = binary.BigEndian.AppendUint64(out, uint64(chunk.rowIDs[i]))
96- out = appendUint32(out, uint32(len(row)))
97- for _, cell := range row {
98- out = appendHistoryCell(out, cell)
127+ out = binary.BigEndian.AppendUint32(out, uint32(len(encoded)))
128+ out = binary.BigEndian.AppendUint64(out, uint64(nextID))
129+ for _, entry := range encoded {
130+ chunk, styles, ids := entry.chunk, entry.styles, entry.ids
131+ frame, err := chunk.page.readFrame(false)
132+ if err != nil {
133+ return nil, fmt.Errorf("marshal history: %w", err)
134+ }
135+ out = binary.BigEndian.AppendUint32(out, uint32(chunk.width))
136+ out = binary.BigEndian.AppendUint32(out, uint32(chunk.len()))
137+ out = binary.BigEndian.AppendUint32(out, uint32(len(styles)))
138+ out = binary.BigEndian.AppendUint32(out, uint32(len(entry.payloads)))
139+ for _, style := range styles[1:] {
140+ out = appendHistoryStyle(out, style)
141+ }
142+ for _, p := range entry.payloads {
143+ out = appendHistoryPayload(out, p)
144+ }
145+ for row, id := range chunk.rowIDs {
146+ out = binary.BigEndian.AppendUint64(out, uint64(id))
147+ out = appendHistoryBound(out, chunk.bounds[row])
148+ }
149+ for row := range chunk.len() {
150+ for x := range chunk.width {
151+ cell := frame.Cell(x, chunk.start+row)
152+ out = binary.BigEndian.AppendUint32(out, uint32(cell.Rune))
153+ out = binary.BigEndian.AppendUint32(out, ids[cell.Style.Canonical()])
154+ flag := byte(0)
155+ if cell.Continuation {
156+ flag = 1
157+ }
158+ if !cell.Payload.Empty() {
159+ flag |= 2
160+ }
161+ out = append(out, flag)
162+ if !cell.Payload.Empty() {
163+ out = binary.BigEndian.AppendUint32(out, entry.payloadIDs[cell.Payload])
164+ }
99165 }
100- out = appendHistoryBound(out, chunkBound(chunk, i))
101166 }
102167 }
103168 return out, nil
104169 }
105170
171+func historyViewRowCount(view HistoryView) int {
172+ n := 0
173+ for _, chunk := range view.chunks {
174+ if chunk == nil || chunk.len() > math.MaxInt-n {
175+ return -1
176+ }
177+ n += chunk.len()
178+ }
179+ return n
180+}
181+
106182 func historyViewNextRowID(view HistoryView) (RowID, bool) {
107183 seen := make(map[RowID]struct{}, view.rows)
184+ var maxID RowID
108185 for _, chunk := range view.chunks {
109- if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) {
186+ if chunk == nil || chunk.len() != len(chunk.rowIDs) {
110187 return 0, false
111188 }
112189 for _, id := range chunk.rowIDs {
@@ -117,110 +194,22 @@ func historyViewNextRowID(view HistoryView) (RowID, bool) {
117194 return 0, false
118195 }
119196 seen[id] = struct{}{}
197+ maxID = max(maxID, id)
120198 }
121199 }
122200 next := view.nextRowID
123201 if next == 0 {
124- maxID := maxHistoryRowID(seen)
125- if maxID == ^RowID(0) {
126- return 0, false
127- }
128202 next = maxID + 1
129203 }
130- if next == 0 || next == ^RowID(0) || next <= maxHistoryRowID(seen) {
131- return 0, false
132- }
133- return next, true
134-}
135-
136-func maxHistoryRowID(ids map[RowID]struct{}) RowID {
137- var maxID RowID
138- for id := range ids {
139- if id > maxID {
140- maxID = id
141- }
142- }
143- return maxID
144-}
145-
146-func historyViewRowCount(view HistoryView) int {
147- n := 0
148- for _, chunk := range view.chunks {
149- if chunk != nil && len(chunk.rows) <= math.MaxInt-n {
150- n += len(chunk.rows)
151- continue
152- }
153- return -1
154- }
155- return n
156-}
157-
158-func appendUint32(dst []byte, v uint32) []byte {
159- return binary.BigEndian.AppendUint32(dst, v)
160-}
161-
162-func appendHistoryCell(dst []byte, cell renderer.Cell) []byte {
163- style := cell.Style
164- dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune))
165- flags := byte(0)
166- if cell.Continuation {
167- flags |= 1 << 0
168- }
169- if style.Bold {
170- flags |= 1 << 1
171- }
172- if style.Italic {
173- flags |= 1 << 2
174- }
175- if style.Inverse {
176- flags |= 1 << 3
177- }
178- if style.HasForegroundRGB {
179- flags |= 1 << 4
180- }
181- if style.HasBackgroundRGB {
182- flags |= 1 << 5
183- }
184- if style.HasUnderlineColor {
185- flags |= 1 << 6
186- }
187- if style.HasUnderlineColorRGB {
188- flags |= 1 << 7
189- }
190- dst = append(dst, flags)
191- dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs))
192- dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground)))
193- dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background)))
194- dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B)
195- dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B)
196- dst = append(dst, byte(style.UnderlineStyle))
197- dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor)))
198- return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)
199-}
200-
201-func chunkBound(chunk *HistoryChunk, i int) LineBound {
202- if i < 0 || i >= len(chunk.bounds) {
203- return LineBound{}
204- }
205- return chunk.bounds[i]
206-}
207-
208-func appendHistoryBound(dst []byte, b LineBound) []byte {
209- dst = binary.BigEndian.AppendUint32(dst, uint32(b.End))
210- if b.Soft {
211- return append(dst, 1)
212- }
213- return append(dst, 0)
214-}
215-
216-// validHistoryBound rejects extents that cannot describe their own row.
217-func validHistoryBound(b LineBound, cells int) bool {
218- return b.End >= 0 && b.End <= cells
204+ return next, next > maxID && next < ^RowID(0)
219205 }
220206
221-// UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects
222-// malformed declarations, cells, truncated data, and trailing bytes.
207+// UnmarshalHistory accepts only VTC1. Validation precedes all decoded frame
208+// allocations, including validation of later chunks in the same payload.
223209 func UnmarshalHistory(data []byte) (HistoryView, error) {
210+ if _, _, ok := parseHistory(data, false); !ok {
211+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
212+ }
224213 view, _, ok := parseHistory(data, true)
225214 if !ok {
226215 return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
@@ -228,127 +217,139 @@ func UnmarshalHistory(data []byte) (HistoryView, error) {
228217 return view, nil
229218 }
230219
231-type historyDecodeStats struct {
232- chunks uint64
233- rows uint64
234- cells uint64
235- styles uint64
236- bytes uint64
237-}
238-
239-// preflightHistory validates a history blob without allocating decoded rows.
240-// It shares parseHistory with UnmarshalHistory so both paths enforce the same
241-// structural, cell, and aggregate-budget constraints.
242220 func preflightHistory(data []byte) (historyDecodeStats, bool) {
243221 _, stats, ok := parseHistory(data, false)
244222 return stats, ok
245223 }
246224
247-// parseHistory is the authoritative history payload validation path. populate
248-// controls whether validated cells are retained in a HistoryView.
249225 func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) {
250- if len(data) < 17 || string(data[:4]) != historyMagic || data[4] != historyVersion {
251- return HistoryView{}, historyDecodeStats{}, false
252- }
253-
254- p := historyParser{data: data[5:]}
255- chunkCount, ok := p.uint32()
256- if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 {
257- return HistoryView{}, historyDecodeStats{}, false
226+ invalid := func() (HistoryView, historyDecodeStats, bool) { return HistoryView{}, historyDecodeStats{}, false }
227+ if len(data) < historyHeaderBytes || string(data[:4]) != historyMagic {
228+ return invalid()
258229 }
259- nextRowID, ok := p.uint64()
260- if !ok || nextRowID == 0 || nextRowID == uint64(^RowID(0)) {
261- return HistoryView{}, historyDecodeStats{}, false
230+ p := historyParser{data: data[4:]}
231+ chunkCount, _ := p.uint32()
232+ nextID, _ := p.uint64()
233+ if chunkCount > maxHistoryChunks || uint64(chunkCount)*historyChunkHeaderBytes > uint64(len(p.data)) || nextID == 0 || nextID == math.MaxUint64 {
234+ return invalid()
262235 }
263236 stats := historyDecodeStats{chunks: uint64(chunkCount)}
264- var seenIDs [maxHistoryRows]RowID
265- seenCount := 0
237+ seenIDs := make(map[RowID]struct{})
266238 var maxID RowID
267239 var chunks []*HistoryChunk
268240 if populate {
269241 chunks = make([]*HistoryChunk, 0, chunkCount)
270242 }
271243 for range chunkCount {
272- rowCount, ok := p.uint32()
273- if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||
274- uint64(rowCount) > uint64(len(p.data))/(8+4+historyBoundBytes) ||
275- !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {
276- return HistoryView{}, historyDecodeStats{}, false
244+ width, wok := p.uint32()
245+ rows, rok := p.uint32()
246+ nstyles, sok := p.uint32()
247+ npayloads, pok := p.uint32()
248+ if !wok || !rok || !sok || !pok || rows == 0 || rows > maxHistoryChunkRows || uint64(width) > uint64(math.MaxInt) || nstyles == 0 || nstyles > maxHistoryStylesPerChunk {
249+ return invalid()
277250 }
278-
279- var rows [][]renderer.Cell
280- var bounds []LineBound
281- var rowIDs []RowID
282- if populate {
283- rows = make([][]renderer.Cell, 0, rowCount)
284- bounds = make([]LineBound, 0, rowCount)
285- rowIDs = make([]RowID, 0, rowCount)
251+ cells := uint64(width) * uint64(rows)
252+ if uint64(nstyles) > cells+1 || uint64(npayloads) > cells ||
253+ !addHistoryDecodeBudget(&stats.rows, uint64(rows), maxHistoryRows) ||
254+ !addHistoryDecodeBudget(&stats.cells, cells, maxHistoryCells) ||
255+ !addHistoryDecodeBudget(&stats.styles, uint64(nstyles), maxHistoryDecodeStyles) ||
256+ !addHistoryLogicalBytes(&stats, cells, uint64(rows), uint64(nstyles)) {
257+ return invalid()
286258 }
287- for range rowCount {
288- id, ok := p.uint64()
289- if !ok || id == 0 || id >= uint64(^RowID(0)-1) {
290- return HistoryView{}, historyDecodeStats{}, false
259+ // Counts have now passed aggregate budgets. This sum cannot overflow.
260+ payload := uint64(nstyles-1)*historyStyleBytes + uint64(npayloads)*8 + uint64(rows)*historyRowBytes + cells*historyStoredCellBytes
261+ if payload > uint64(len(p.data)) {
262+ return invalid()
263+ }
264+ styles := make([]renderer.Style, nstyles)
265+ styles[0] = renderer.DefaultStyle()
266+ seenStyles := map[renderer.Style]struct{}{renderer.DefaultStyle(): {}}
267+ for id := uint32(1); id < nstyles; id++ {
268+ style, ok := p.style()
269+ if !ok || style != style.Canonical() || style.UnderlineStyle > renderer.UnderlineDashed {
270+ return invalid()
291271 }
292- rowID := RowID(id)
293- seenIDs[seenCount] = rowID
294- seenCount++
295- if rowID > maxID {
296- maxID = rowID
272+ if _, duplicate := seenStyles[style]; duplicate {
273+ return invalid()
297274 }
298- cellCount, ok := p.uint32()
299- cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))
300- if !ok || !validCellBytes ||
301- !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) ||
302- !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||
303- !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) ||
304- uint64(cellCount) > uint64(len(p.data))/historyCellBytes {
305- return HistoryView{}, historyDecodeStats{}, false
275+ seenStyles[style] = struct{}{}
276+ styles[id] = style
277+ }
278+ payloads, ok := p.payloadDictionary(npayloads, &stats)
279+ if !ok {
280+ return invalid()
281+ }
282+ usedPayloads := make([]bool, npayloads)
283+ rowIDs := make([]RowID, rows)
284+ bounds := make([]LineBound, rows)
285+ for row := range rows {
286+ id, ok := p.uint64()
287+ bound, bok := p.bound()
288+ if !ok || !bok || id == 0 || id >= math.MaxUint64-1 || !validHistoryBound(bound, int(width)) {
289+ return invalid()
306290 }
307-
308- var row []renderer.Cell
309- if populate {
310- row = make([]renderer.Cell, cellCount)
291+ if _, duplicate := seenIDs[RowID(id)]; duplicate {
292+ return invalid()
311293 }
312- for i := range cellCount {
313- cell, ok := p.cell()
314- if !ok || !validHistoryCell(cell) {
315- return HistoryView{}, historyDecodeStats{}, false
294+ seenIDs[RowID(id)] = struct{}{}
295+ maxID = max(maxID, RowID(id))
296+ rowIDs[row], bounds[row] = RowID(id), bound
297+ }
298+ lastStyleRow := make([]int, nstyles)
299+ for i := range lastStyleRow {
300+ lastStyleRow[i] = -1
301+ }
302+ var frame renderer.Frame
303+ if populate {
304+ frame = renderer.NewFrame(int(width), int(rows))
305+ }
306+ for row := range rows {
307+ for x := range width {
308+ r, id, payloadID, continuation, ok := p.storedCell(nstyles, npayloads)
309+ if !ok {
310+ return invalid()
311+ }
312+ lastStyleRow[id] = int(row)
313+ var value renderer.CellPayload
314+ if payloadID != 0 {
315+ usedPayloads[payloadID-1] = true
316+ value = payloads[payloadID-1]
316317 }
317318 if populate {
318- row[i] = cell
319+ frame.Set(int(x), int(row), renderer.Cell{Rune: r, Style: styles[id], Payload: value, Continuation: continuation})
319320 }
320321 }
321- bound, ok := p.bound()
322- if !ok || !validHistoryBound(bound, int(cellCount)) {
323- return HistoryView{}, historyDecodeStats{}, false
322+ }
323+ for id := uint32(1); id < nstyles; id++ {
324+ if lastStyleRow[id] < 0 {
325+ return invalid()
324326 }
325- if populate {
326- rows = append(rows, row)
327- bounds = append(bounds, bound)
328- rowIDs = append(rowIDs, rowID)
327+ }
328+ for _, used := range usedPayloads {
329+ if !used {
330+ return invalid()
329331 }
330332 }
331333 if populate {
332- chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs})
333- }
334- }
335- slices.Sort(seenIDs[:seenCount])
336- for i := 1; i < seenCount; i++ {
337- if seenIDs[i] == seenIDs[i-1] {
338- return HistoryView{}, historyDecodeStats{}, false
334+ drops := make([]uint64, rows)
335+ for id := uint32(1); id < nstyles; id++ {
336+ drops[lastStyleRow[id]]++
337+ }
338+ chunk := &HistoryChunk{page: newSealedPage(frame), count: int(rows), width: int(width), bounds: bounds, rowIDs: rowIDs, styleDrops: drops, styleCount: uint64(nstyles)}
339+ chunk.recordPayloads()
340+ chunks = append(chunks, chunk)
339341 }
340342 }
341- if len(p.data) != 0 || RowID(nextRowID) <= maxID {
342- return HistoryView{}, historyDecodeStats{}, false
343+ if len(p.data) != 0 || RowID(nextID) <= maxID {
344+ return invalid()
343345 }
344- return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells), nextRowID: RowID(nextRowID)}, stats, true
346+ return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells), logicalBytes: stats.bytes, nextRowID: RowID(nextID)}, stats, true
345347 }
346348
347-func historyCellByteCount(cellCount uint64) (uint64, bool) {
348- if cellCount > math.MaxUint64/historyCellBytes {
349- return 0, false
350- }
351- return cellCount * historyCellBytes, true
349+func addHistoryLogicalBytes(stats *historyDecodeStats, cells, rows, styles uint64) bool {
350+ // Callers first bound all three counts by the aggregate decode ceilings.
351+ amount := cells*renderer.StoredCellLogicalBytes + rows*renderer.RowDescriptorLogicalBytes + styles*renderer.StyleRecordLogicalBytes
352+ return addHistoryDecodeBudget(&stats.bytes, amount, maxHistoryDecodedBytes)
352353 }
353354
354355 func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool {
@@ -359,89 +360,6 @@ func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool {
359360 return true
360361 }
361362
362-type historyParser struct{ data []byte }
363-
364-func (p *historyParser) uint32() (uint32, bool) {
365- if len(p.data) < 4 {
366- return 0, false
367- }
368- v := binary.BigEndian.Uint32(p.data)
369- p.data = p.data[4:]
370- return v, true
371-}
372-
373-func (p *historyParser) uint64() (uint64, bool) {
374- if len(p.data) < 8 {
375- return 0, false
376- }
377- v := binary.BigEndian.Uint64(p.data)
378- p.data = p.data[8:]
379- return v, true
380-}
381-
382-func (p *historyParser) cell() (renderer.Cell, bool) {
383- if len(p.data) < historyCellBytes {
384- return renderer.Cell{}, false
385- }
386- b := p.data[:historyCellBytes]
387- p.data = p.data[historyCellBytes:]
388- foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15]))
389- if !ok {
390- return renderer.Cell{}, false
391- }
392- background, ok := historyInt(binary.BigEndian.Uint64(b[15:23]))
393- if !ok {
394- return renderer.Cell{}, false
395- }
396- underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38]))
397- if !ok {
398- return renderer.Cell{}, false
399- }
400- flags := b[4]
401- return renderer.Cell{
402- Rune: rune(binary.BigEndian.Uint32(b[0:4])),
403- Continuation: flags&1 != 0,
404- Style: renderer.Style{
405- Bold: flags&(1<<1) != 0,
406- Italic: flags&(1<<2) != 0,
407- Inverse: flags&(1<<3) != 0,
408- Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])),
409- Foreground: foreground,
410- Background: background,
411- HasForegroundRGB: flags&(1<<4) != 0,
412- ForegroundRGB: renderer.RGB{R: b[23], G: b[24], B: b[25]},
413- HasBackgroundRGB: flags&(1<<5) != 0,
414- BackgroundRGB: renderer.RGB{R: b[26], G: b[27], B: b[28]},
415- UnderlineStyle: renderer.UnderlineStyle(b[29]),
416- HasUnderlineColor: flags&(1<<6) != 0,
417- UnderlineColor: underlineColor,
418- HasUnderlineColorRGB: flags&(1<<7) != 0,
419- UnderlineColorRGB: renderer.RGB{R: b[38], G: b[39], B: b[40]},
420- },
421- }, true
422-}
423-
424-func (p *historyParser) bound() (LineBound, bool) {
425- if len(p.data) < historyBoundBytes {
426- return LineBound{}, false
427- }
428- end := binary.BigEndian.Uint32(p.data[:4])
429- flag := p.data[4]
430- p.data = p.data[historyBoundBytes:]
431- if flag > 1 || uint64(end) > math.MaxInt32 {
432- return LineBound{}, false
433- }
434- return LineBound{End: int(end), Soft: flag == 1}, true
435-}
436-
437-func historyInt(raw uint64) (int, bool) {
438- v := int64(raw)
439- if int64(int(v)) != v {
440- return 0, false
441- }
442- return int(v), true
443-}
444-
445-func validHistoryCell(cell renderer.Cell) bool {
446- return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed
363+func validHistoryBound(bound LineBound, cells int) bool {
364+ return bound.End >= 0 && bound.End <= cells
447365 }
M history_codec_bound_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010
1111 func TestHistoryCodecRejectsMalformedBounds(t *testing.T) {
1212 blob, err := MarshalHistory(HistoryView{
13- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}, rowIDs: []RowID{1}}},
13+ chunks: []*HistoryChunk{testHistoryChunk([][]renderer.Cell{historyRow("abcd")}, []LineBound{{End: 4, Soft: true}}, []RowID{1})},
1414 rows: 1,
1515 cells: 4,
1616 nextRowID: 2,
A history_codec_limits_test.go +25 -0
@@ -0,0 +1,25 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev-vt/core"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestHistoryRetentionCanExceedSingleBlobLimits(t *testing.T) {
11+ // Retention policy is application-owned; codec ceilings apply to each blob.
12+ h := NewHistory(HistoryConfig{MaxRows: maxHistoryRows + 1, ChunkRows: 256})
13+ for range maxHistoryRows + 1 {
14+ require.NoError(t, h.Append([]core.Cell{{Rune: 'x', Style: core.DefaultStyle()}}, LineBound{End: 1}))
15+ }
16+ view := h.SealAndView()
17+ require.Equal(t, maxHistoryRows+1, view.Len())
18+ _, err := MarshalHistory(view)
19+ require.Error(t, err, "one aggregate blob is intentionally bounded")
20+ chunks, tail, err := MarshalSealedHistory(view)
21+ require.NoError(t, err, "per-chunk persistence must preserve larger retained history")
22+ restored, err := HistoryFromBlobs(h.Limits(), chunks, tail)
23+ require.NoError(t, err)
24+ require.Equal(t, h.Len(), restored.Len())
25+}
A history_codec_payload.go +49 -0
@@ -0,0 +1,49 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+
6+ renderer "github.com/bnema/vev-vt/core"
7+)
8+
9+func appendHistoryPayload(dst []byte, value renderer.CellPayload) []byte {
10+ dst = binary.BigEndian.AppendUint32(dst, uint32(len(value.Grapheme())))
11+ dst = binary.BigEndian.AppendUint32(dst, uint32(len(value.Hyperlink())))
12+ dst = append(dst, value.Grapheme()...)
13+ return append(dst, value.Hyperlink()...)
14+}
15+
16+func (p *historyParser) payloadDictionary(count uint32, stats *historyDecodeStats) ([]renderer.CellPayload, bool) {
17+ if count == 0 {
18+ return nil, true
19+ }
20+ // The record budget bounds the table before allocating any string values.
21+ if uint64(count)*renderer.PayloadRecordLogicalBytes > maxHistoryPayloadBytes-stats.payloadBytes {
22+ return nil, false
23+ }
24+ values := make([]renderer.CellPayload, count)
25+ seen := make(map[renderer.CellPayload]struct{})
26+ for id := range count {
27+ grapheme, gok := p.uint32()
28+ link, lok := p.uint32()
29+ length := uint64(grapheme) + uint64(link)
30+ if !gok || !lok || grapheme > renderer.MaxGraphemeBytes || link > renderer.MaxHyperlinkBytes || length == 0 || length > uint64(len(p.data)) {
31+ return nil, false
32+ }
33+ bytes := length + renderer.PayloadRecordLogicalBytes
34+ if !addHistoryDecodeBudget(&stats.payloadBytes, bytes, maxHistoryPayloadBytes) || !addHistoryDecodeBudget(&stats.bytes, bytes, maxHistoryDecodedBytes) {
35+ return nil, false
36+ }
37+ value, err := renderer.NewCellPayload(string(p.data[:grapheme]), string(p.data[grapheme:length]))
38+ if err != nil {
39+ return nil, false
40+ }
41+ p.data = p.data[length:]
42+ if _, duplicate := seen[value]; duplicate {
43+ return nil, false
44+ }
45+ seen[value] = struct{}{}
46+ values[id] = value
47+ }
48+ return values, true
49+}
A history_codec_records.go +135 -0
@@ -0,0 +1,135 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "math"
6+ "unicode/utf8"
7+
8+ renderer "github.com/bnema/vev-vt/core"
9+)
10+
11+func appendHistoryStyle(dst []byte, style renderer.Style) []byte {
12+ style = style.Canonical()
13+ flags := byte(0)
14+ for i, set := range [...]bool{style.Bold, style.Italic, style.Inverse, style.HasForegroundRGB, style.HasBackgroundRGB, style.HasUnderlineColor, style.HasUnderlineColorRGB} {
15+ if set {
16+ flags |= 1 << i
17+ }
18+ }
19+ dst = append(dst, flags)
20+ dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs))
21+ dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground)))
22+ dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background)))
23+ dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B)
24+ dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B)
25+ dst = append(dst, byte(style.UnderlineStyle))
26+ dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor)))
27+ return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)
28+}
29+
30+func appendHistoryBound(dst []byte, bound LineBound) []byte {
31+ dst = binary.BigEndian.AppendUint32(dst, uint32(bound.End))
32+ if bound.Soft {
33+ return append(dst, 1)
34+ }
35+ return append(dst, 0)
36+}
37+
38+type historyParser struct{ data []byte }
39+
40+func (p *historyParser) uint32() (uint32, bool) {
41+ if len(p.data) < 4 {
42+ return 0, false
43+ }
44+ value := binary.BigEndian.Uint32(p.data)
45+ p.data = p.data[4:]
46+ return value, true
47+}
48+
49+func (p *historyParser) uint64() (uint64, bool) {
50+ if len(p.data) < 8 {
51+ return 0, false
52+ }
53+ value := binary.BigEndian.Uint64(p.data)
54+ p.data = p.data[8:]
55+ return value, true
56+}
57+
58+func (p *historyParser) style() (renderer.Style, bool) {
59+ if len(p.data) < historyStyleBytes {
60+ return renderer.Style{}, false
61+ }
62+ b := p.data[:historyStyleBytes]
63+ p.data = p.data[historyStyleBytes:]
64+ flags := b[0]
65+ if flags&0x80 != 0 {
66+ return renderer.Style{}, false
67+ }
68+ foreground, fok := historyInt(binary.BigEndian.Uint64(b[3:11]))
69+ background, bok := historyInt(binary.BigEndian.Uint64(b[11:19]))
70+ underline, uok := historyInt(binary.BigEndian.Uint64(b[26:34]))
71+ if !fok || !bok || !uok {
72+ return renderer.Style{}, false
73+ }
74+ return renderer.Style{
75+ Bold: flags&1 != 0,
76+ Italic: flags&2 != 0,
77+ Inverse: flags&4 != 0,
78+ Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[1:3])),
79+ Foreground: foreground,
80+ Background: background,
81+ HasForegroundRGB: flags&8 != 0,
82+ ForegroundRGB: renderer.RGB{R: b[19], G: b[20], B: b[21]},
83+ HasBackgroundRGB: flags&16 != 0,
84+ BackgroundRGB: renderer.RGB{R: b[22], G: b[23], B: b[24]},
85+ UnderlineStyle: renderer.UnderlineStyle(b[25]),
86+ HasUnderlineColor: flags&32 != 0,
87+ UnderlineColor: underline,
88+ HasUnderlineColorRGB: flags&64 != 0,
89+ UnderlineColorRGB: renderer.RGB{R: b[34], G: b[35], B: b[36]},
90+ }, true
91+}
92+
93+func (p *historyParser) bound() (LineBound, bool) {
94+ if len(p.data) < historyBoundBytes {
95+ return LineBound{}, false
96+ }
97+ end := binary.BigEndian.Uint32(p.data[:4])
98+ flag := p.data[4]
99+ p.data = p.data[historyBoundBytes:]
100+ if flag > 1 || uint64(end) > uint64(math.MaxInt) {
101+ return LineBound{}, false
102+ }
103+ return LineBound{End: int(end), Soft: flag == 1}, true
104+}
105+
106+func (p *historyParser) storedCell(styles, payloads uint32) (rune, uint32, uint32, bool, bool) {
107+ if len(p.data) < historyStoredCellBytes {
108+ return 0, 0, 0, false, false
109+ }
110+ b := p.data[:historyStoredCellBytes]
111+ p.data = p.data[historyStoredCellBytes:]
112+ r := rune(binary.BigEndian.Uint32(b[:4]))
113+ id := binary.BigEndian.Uint32(b[4:8])
114+ if !utf8.ValidRune(r) || id >= styles || b[8] > 3 {
115+ return 0, 0, 0, false, false
116+ }
117+ var payloadID uint32
118+ if b[8]&2 != 0 {
119+ var ok bool
120+ payloadID, ok = p.uint32()
121+ if !ok || payloadID == 0 || payloadID > payloads {
122+ return 0, 0, 0, false, false
123+ }
124+ }
125+ return r, id, payloadID, b[8]&1 != 0, true
126+}
127+
128+func historyInt(raw uint64) (int, bool) {
129+ value := int64(raw)
130+ return int(value), int64(int(value)) == value
131+}
132+
133+func validHistoryCell(cell renderer.Cell) bool {
134+ return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed
135+}
M history_codec_test.go +47 -58
@@ -10,9 +10,9 @@ import (
1010 renderer "github.com/bnema/vev-vt/core"
1111 )
1212
13-func TestHistoryCodecV3Bytes(t *testing.T) {
14- h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
15- if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil {
13+func TestHistoryCodecCompactBytes(t *testing.T) {
14+ h := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: 1 << 20, ChunkRows: 2})
15+ if err := h.Append([]renderer.Cell{{Rune: 'a', Style: renderer.DefaultStyle()}}, LineBound{End: 1, Soft: true}); err != nil {
1616 t.Fatalf("append: %v", err)
1717 }
1818 got, err := MarshalHistory(h.SealAndView())
@@ -21,23 +21,17 @@ func TestHistoryCodecV3Bytes(t *testing.T) {
2121 }
2222
2323 want := []byte{
24- 'V', 'T', 'H', '1', 3, // magic, version
25- 0, 0, 0, 1, // chunk count
24+ 'V', 'T', 'C', '1',
25+ 0, 0, 0, 1, // chunks
2626 0, 0, 0, 0, 0, 0, 0, 2, // next row ID
27- 0, 0, 0, 1, // row count
27+ 0, 0, 0, 1, // width
28+ 0, 0, 0, 1, // rows
29+ 0, 0, 0, 1, // implicit default style only
30+ 0, 0, 0, 0, // no exceptional payloads
2831 0, 0, 0, 0, 0, 0, 0, 1, // row ID
29- 0, 0, 0, 1, // cell count
30- 0, 0, 0, 'a', // Rune
31- 0, // flags
32- 0, 0, // Attrs
33- 0, 0, 0, 0, 0, 0, 0, 0, // Foreground
34- 0, 0, 0, 0, 0, 0, 0, 0, // Background
35- 0, 0, 0, // ForegroundRGB
36- 0, 0, 0, // BackgroundRGB
37- 0, // UnderlineStyle
38- 0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor
39- 0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here
40- 0, 0, 0, 1, 1, // bound: End=1, Soft=true
32+ 0, 0, 0, 1, 1, // bound
33+ 0, 0, 0, 'a', // rune
34+ 0, 0, 0, 0, 0, // style ID and continuation
4135 }
4236 if !bytes.Equal(got, want) {
4337 t.Fatalf("MarshalHistory() = % x, want % x", got, want)
@@ -45,7 +39,7 @@ func TestHistoryCodecV3Bytes(t *testing.T) {
4539 }
4640
4741 func TestHistoryCodecRoundTripsBounds(t *testing.T) {
48- h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
42+ h := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: 1 << 20, ChunkRows: 2})
4943 bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}}
5044 for _, b := range bounds {
5145 if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil {
@@ -81,7 +75,7 @@ func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) {
8175 0, 0, 0,
8276 0, 0, 0, 1, 1,
8377 }
84- for _, version := range []byte{1, 2} {
78+ for _, version := range []byte{1, 2, 3} {
8579 data := append([]byte(nil), legacy...)
8680 data[4] = version
8781 if _, err := UnmarshalHistory(data); err == nil {
@@ -165,11 +159,11 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
165159
166160 for i := range len(encoded) {
167161 if _, err := UnmarshalHistory(encoded[:i]); err == nil {
168- t.Fatalf("accepted truncated version 3 prefix of length %d", i)
162+ t.Fatalf("accepted truncated compact payload prefix of length %d", i)
169163 }
170164 }
171165 if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil {
172- t.Fatal("accepted trailing garbage after version 3 payload")
166+ t.Fatal("accepted trailing garbage after compact payload")
173167 }
174168 }
175169
@@ -294,33 +288,30 @@ func historyViewWithDimensions(rowCount, width int) HistoryView {
294288 rowIDs[i] = rowID
295289 rowID++
296290 }
297- chunks = append(chunks, &HistoryChunk{rows: rows, rowIDs: rowIDs})
291+ chunks = append(chunks, testHistoryChunk(rows, nil, rowIDs))
298292 rowCount -= chunkRows
299293 }
300294 return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID}
301295 }
302296
303297 func historyPayloadWithDimensions(rowCount, width int) []byte {
304- data := make([]byte, 17)
305- copy(data, historyMagic)
306- data[4] = historyVersion
307- chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows
308- binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
309- binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1))
310- rowID := uint64(1)
298+ chunks := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows
299+ data := binary.BigEndian.AppendUint32([]byte(historyMagic), uint32(chunks))
300+ data = binary.BigEndian.AppendUint64(data, uint64(rowCount+1))
301+ id := uint64(1)
311302 for rowCount > 0 {
312- chunkRows := min(rowCount, maxHistoryChunkRows)
313- data = binary.BigEndian.AppendUint32(data, uint32(chunkRows))
314- for range chunkRows {
315- data = binary.BigEndian.AppendUint64(data, rowID)
316- rowID++
317- data = binary.BigEndian.AppendUint32(data, uint32(width))
318- //nolint:makezero // The header prefix is retained while zeroed cell records are appended.
319- data = append(data, make([]byte, width*historyCellBytes)...)
320- data = binary.BigEndian.AppendUint32(data, uint32(width))
321- data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended.
303+ rows := min(rowCount, maxHistoryChunkRows)
304+ data = binary.BigEndian.AppendUint32(data, uint32(width))
305+ data = binary.BigEndian.AppendUint32(data, uint32(rows))
306+ data = binary.BigEndian.AppendUint32(data, 1)
307+ data = binary.BigEndian.AppendUint32(data, 0)
308+ for range rows {
309+ data = binary.BigEndian.AppendUint64(data, id)
310+ id++
311+ data = appendHistoryBound(data, LineBound{End: width})
322312 }
323- rowCount -= chunkRows
313+ data = append(data, make([]byte, rows*width*historyStoredCellBytes)...)
314+ rowCount -= rows
324315 }
325316 return data
326317 }
@@ -329,29 +320,27 @@ func historyPayloadWithDimensions(rowCount, width int) []byte {
329320 // budget, but its cells exceed the aggregate budget. It deliberately omits
330321 // cell payload bytes: the aggregate limit must reject it before allocation.
331322 func aggregateCellLimitDeclaration() []byte {
332- data := make([]byte, 17, 29)
333- copy(data, historyMagic)
334- data[4] = historyVersion
335- binary.BigEndian.PutUint32(data[5:], 1)
336- binary.BigEndian.PutUint64(data[9:], 2)
323+ data := binary.BigEndian.AppendUint32([]byte(historyMagic), 1)
324+ data = binary.BigEndian.AppendUint64(data, 2)
325+ data = binary.BigEndian.AppendUint32(data, maxHistoryCells+1)
326+ data = binary.BigEndian.AppendUint32(data, 1)
337327 data = binary.BigEndian.AppendUint32(data, 1)
338- data = binary.BigEndian.AppendUint64(data, 1)
339- return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1))
328+ return binary.BigEndian.AppendUint32(data, 0)
340329 }
341330
342331 func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {
343- data := make([]byte, 17, 17+chunkCount*(4+rowCount*12))
344- copy(data, historyMagic)
345- data[4] = historyVersion
346- binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
347- binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1))
332+ data := binary.BigEndian.AppendUint32([]byte(historyMagic), uint32(chunkCount))
333+ data = binary.BigEndian.AppendUint64(data, uint64(chunkCount*rowCount+1))
348334 id := uint64(1)
349335 for range chunkCount {
336+ data = binary.BigEndian.AppendUint32(data, 0)
350337 data = binary.BigEndian.AppendUint32(data, uint32(rowCount))
338+ data = binary.BigEndian.AppendUint32(data, 1)
339+ data = binary.BigEndian.AppendUint32(data, 0)
351340 for range rowCount {
352341 data = binary.BigEndian.AppendUint64(data, id)
353342 id++
354- data = binary.BigEndian.AppendUint32(data, 0)
343+ data = appendHistoryBound(data, LineBound{})
355344 }
356345 }
357346 return data
@@ -363,9 +352,9 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
363352 t.Fatalf("marshal valid history: %v", err)
364353 }
365354 invalidRune := append([]byte(nil), valid...)
366- binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0))
355+ binary.BigEndian.PutUint32(invalidRune[len(valid)-historyStoredCellBytes:], ^uint32(0))
367356 invalidUnderlineStyle := append([]byte(nil), valid...)
368- invalidUnderlineStyle[33+29] = byte(renderer.UnderlineDashed + 1)
357+ invalidUnderlineStyle[historyHeaderBytes+historyChunkHeaderBytes+25] = byte(renderer.UnderlineDashed + 1)
369358
370359 tests := []struct {
371360 name string
@@ -379,7 +368,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
379368 {name: "invalid underline style", data: invalidUnderlineStyle},
380369 {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)},
381370 {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()},
382- {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0}},
371+ {name: "zero row count", data: hostileHistoryDeclarations(1, 0)},
383372 }
384373 for _, tt := range tests {
385374 t.Run(tt.name, func(t *testing.T) {
@@ -398,7 +387,7 @@ func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) {
398387 f.Fatalf("marshal valid history: %v", err)
399388 }
400389 invalidRune := append([]byte(nil), valid...)
401- binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0))
390+ binary.BigEndian.PutUint32(invalidRune[len(valid)-historyStoredCellBytes:], ^uint32(0))
402391 f.Add(valid)
403392 f.Add(invalidRune)
404393 f.Add([]byte("VTH1\x01\x00\x00\x00\x00"))
A history_compact_codec_test.go +143 -0
@@ -0,0 +1,143 @@
1+package vt
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "testing"
7+
8+ renderer "github.com/bnema/vev-vt/core"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestCompactHistoryDictionaryAndAccounting(t *testing.T) {
13+ h := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 2})
14+ style := renderer.DefaultStyle()
15+ style.Bold = true
16+ style.HasForegroundRGB = true
17+ style.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
18+ row := []renderer.Cell{{Rune: 'a', Style: style}, {Rune: 'b', Style: style}}
19+ for range 2 {
20+ require.NoError(t, h.Append(row, LineBound{End: 2}))
21+ }
22+ view := h.SealAndView()
23+ blob, err := MarshalHistory(view)
24+ require.NoError(t, err)
25+ require.True(t, bytes.HasPrefix(blob, []byte("VTC1")))
26+ require.Len(t, blob, historyHeaderBytes+historyChunkHeaderBytes+historyStyleBytes+2*historyRowBytes+4*historyStoredCellBytes)
27+ stats, err := PreflightHistoryBlob(blob)
28+ require.NoError(t, err)
29+ require.Equal(t, uint64(2), stats.Styles)
30+ require.Equal(t, view.LogicalBytes(), stats.Bytes)
31+ decoded, err := UnmarshalHistory(blob)
32+ require.NoError(t, err)
33+ require.Equal(t, view.LogicalBytes(), decoded.LogicalBytes())
34+ require.NoError(t, decoded.Chunk(0).CheckInvariants())
35+ again, err := MarshalHistory(decoded)
36+ require.NoError(t, err)
37+ require.Equal(t, blob, again)
38+}
39+
40+func TestCompactHistoryRejectsMalformedReferences(t *testing.T) {
41+ plain := historyPayloadWithDimensions(1, 2)
42+ const rowOffset = historyHeaderBytes + historyChunkHeaderBytes
43+ const cellOffset = rowOffset + historyRowBytes
44+ for _, tt := range []struct {
45+ name string
46+ mutate func([]byte)
47+ }{
48+ {"style reference", func(b []byte) { binary.BigEndian.PutUint32(b[cellOffset+4:], 1) }},
49+ {"continuation flags", func(b []byte) { b[cellOffset+8] = 2 }},
50+ {"invalid rune", func(b []byte) { binary.BigEndian.PutUint32(b[cellOffset:], 0xd800) }},
51+ {"zero style count", func(b []byte) { clear(b[24:28]) }},
52+ {"excessive style count", func(b []byte) { binary.BigEndian.PutUint32(b[24:], ^uint32(0)) }},
53+ {"excessive width", func(b []byte) { binary.BigEndian.PutUint32(b[16:], ^uint32(0)) }},
54+ {"bound extent", func(b []byte) { binary.BigEndian.PutUint32(b[rowOffset+8:], 3) }},
55+ {"bound flag", func(b []byte) { b[rowOffset+12] = 2 }},
56+ } {
57+ t.Run(tt.name, func(t *testing.T) {
58+ b := bytes.Clone(plain)
59+ tt.mutate(b)
60+ _, err := PreflightHistoryBlob(b)
61+ require.Error(t, err)
62+ _, err = UnmarshalHistory(b)
63+ require.Error(t, err)
64+ })
65+ }
66+}
67+
68+func TestCompactHistoryRejectsMalformedDictionaries(t *testing.T) {
69+ style := renderer.DefaultStyle()
70+ style.Bold = true
71+ other := style
72+ other.Italic = true
73+ makeBlob := func(styles []renderer.Style, refs ...uint32) []byte {
74+ b := binary.BigEndian.AppendUint32([]byte(historyMagic), 1)
75+ b = binary.BigEndian.AppendUint64(b, 2)
76+ b = binary.BigEndian.AppendUint32(b, uint32(len(refs)))
77+ b = binary.BigEndian.AppendUint32(b, 1)
78+ b = binary.BigEndian.AppendUint32(b, uint32(len(styles)+1))
79+ b = binary.BigEndian.AppendUint32(b, 0)
80+ for _, s := range styles {
81+ b = appendHistoryStyle(b, s)
82+ }
83+ b = binary.BigEndian.AppendUint64(b, 1)
84+ b = appendHistoryBound(b, LineBound{End: len(refs)})
85+ for _, ref := range refs {
86+ b = binary.BigEndian.AppendUint32(b, 'x')
87+ b = binary.BigEndian.AppendUint32(b, ref)
88+ b = append(b, 0)
89+ }
90+ return b
91+ }
92+ valid := makeBlob([]renderer.Style{style, other}, 1, 2)
93+ _, err := UnmarshalHistory(valid)
94+ require.NoError(t, err)
95+ noncanonical := bytes.Clone(valid)
96+ // Unused underline color is required to be canonical zero.
97+ noncanonical[historyHeaderBytes+historyChunkHeaderBytes+33] = 7
98+ badFlags := bytes.Clone(valid)
99+ badFlags[historyHeaderBytes+historyChunkHeaderBytes] |= 0x80
100+ for _, tt := range []struct {
101+ name string
102+ data []byte
103+ }{
104+ {"duplicate default", makeBlob([]renderer.Style{renderer.DefaultStyle()}, 1)},
105+ {"duplicate style", makeBlob([]renderer.Style{style, style}, 1, 2)},
106+ {"unused style", makeBlob([]renderer.Style{style, other}, 1, 1)},
107+ {"inactive fields", noncanonical},
108+ {"reserved flags", badFlags},
109+ } {
110+ t.Run(tt.name, func(t *testing.T) {
111+ _, err := PreflightHistoryBlob(tt.data)
112+ require.Error(t, err)
113+ _, err = UnmarshalHistory(tt.data)
114+ require.Error(t, err)
115+ })
116+ }
117+}
118+
119+func BenchmarkCompactHistoryCodec(b *testing.B) {
120+ view := historyViewWithDimensions(10_000, 120)
121+ blob, err := MarshalHistory(view)
122+ if err != nil {
123+ b.Fatal(err)
124+ }
125+ b.Run("encode", func(b *testing.B) {
126+ b.ReportAllocs()
127+ b.ReportMetric(float64(len(blob)), "encoded-B")
128+ for b.Loop() {
129+ if _, err := MarshalHistory(view); err != nil {
130+ b.Fatal(err)
131+ }
132+ }
133+ })
134+ b.Run("decode", func(b *testing.B) {
135+ b.ReportAllocs()
136+ b.SetBytes(int64(len(blob)))
137+ for b.Loop() {
138+ if _, err := UnmarshalHistory(blob); err != nil {
139+ b.Fatal(err)
140+ }
141+ }
142+ })
143+}
A history_config.go +85 -0
@@ -0,0 +1,85 @@
1+package vt
2+
3+import (
4+ "errors"
5+ "math"
6+)
7+
8+var ErrInvalidHistoryConfig = errors.New("invalid history configuration")
9+
10+// HistoryConfig enforces limits selected by the application. The library
11+// supplies no byte or line defaults. Each positive limit is an independent
12+// ceiling; zero omits that ceiling. Both zero disable history. Live grids are
13+// excluded. ChunkRows is an internal grouping hint in [1,256]; zero selects
14+// 256 and does not grant space beyond the application's retention limits.
15+type HistoryConfig struct {
16+ MaxRows int
17+ MaxBytes uint64
18+ ChunkRows int
19+}
20+
21+func (c HistoryConfig) Validate() error {
22+ if c.MaxRows < 0 || c.ChunkRows < 0 || c.ChunkRows > maxHistoryChunkRows {
23+ return ErrInvalidHistoryConfig
24+ }
25+ return nil
26+}
27+
28+// NewHistory constructs owned bounded history. Invalid programmer-supplied
29+// configuration panics; user-input boundaries should call Validate first.
30+func NewHistory(config HistoryConfig) *History {
31+ h := &History{nextRowID: 1}
32+ if err := h.SetLimits(config); err != nil {
33+ panic(err)
34+ }
35+ return h
36+}
37+
38+// SetLimits validates first, then updates limits and evicts oldest rows before
39+// returning. Existing borrowed views remain valid. Disabling history releases
40+// its owned backing but preserves the next row identity. The owner must serialize
41+// this operation with all other History mutations.
42+func (h *History) SetLimits(config HistoryConfig) error {
43+ if h == nil {
44+ return ErrInvalidHistoryConfig
45+ }
46+ if err := config.Validate(); err != nil {
47+ return err
48+ }
49+ if config.MaxRows == 0 && config.MaxBytes == 0 {
50+ *h = History{nextRowID: h.NextRowID()}
51+ return nil
52+ }
53+ rows := config.MaxRows
54+ if rows == 0 {
55+ rows = math.MaxInt
56+ }
57+ chunkRows := config.ChunkRows
58+ if chunkRows == 0 {
59+ chunkRows = maxHistoryChunkRows
60+ }
61+ chunkRows = min(chunkRows, rows)
62+ bytes := config.MaxBytes
63+ if bytes == 0 {
64+ bytes = math.MaxUint64
65+ }
66+ if len(h.tail) >= chunkRows {
67+ h.sealTail()
68+ }
69+ h.maxRows, h.rowCeiling, h.maxBytes, h.chunkRows = rows, config.MaxRows, bytes, chunkRows
70+ h.byteCeiling = config.MaxBytes
71+ for h.rows > rows || h.logicalBytes > bytes {
72+ h.evictOldest()
73+ }
74+ clear(h.payloadScratch)
75+ return nil
76+}
77+
78+// Limits reports the application's policy. A zero limit is omitted; both
79+// zero mean history is disabled. ChunkRows reports the resolved grouping size.
80+func (h *History) Limits() HistoryConfig {
81+ if h == nil {
82+ return HistoryConfig{}
83+ }
84+ return HistoryConfig{MaxRows: h.rowCeiling, MaxBytes: h.byteCeiling, ChunkRows: h.chunkRows}
85+}
A history_config_test.go +75 -0
@@ -0,0 +1,75 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt/core"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestHistoryConfigurationUsesOnlyApplicationLimits(t *testing.T) {
11+ for _, tt := range []struct {
12+ name string
13+ config HistoryConfig
14+ valid bool
15+ }{
16+ {"disabled", HistoryConfig{}, true},
17+ {"both explicit", HistoryConfig{MaxRows: 20, MaxBytes: 4096}, true},
18+ {"bytes only", HistoryConfig{MaxBytes: 1024}, true},
19+ {"rows only", HistoryConfig{MaxRows: 4}, true},
20+ {"negative rows", HistoryConfig{MaxRows: -1}, false},
21+ {"negative grouping", HistoryConfig{ChunkRows: -1}, false},
22+ {"oversized grouping", HistoryConfig{ChunkRows: 257}, false},
23+ } {
24+ t.Run(tt.name, func(t *testing.T) {
25+ if tt.valid {
26+ require.NoError(t, tt.config.Validate())
27+ return
28+ }
29+ require.ErrorIs(t, tt.config.Validate(), ErrInvalidHistoryConfig)
30+ require.Panics(t, func() { NewHistory(tt.config) })
31+ _, err := HistoryFromBlobs(tt.config, nil, nil)
32+ require.ErrorIs(t, err, ErrInvalidHistoryConfig)
33+ })
34+ }
35+ require.Zero(t, NewHistory(HistoryConfig{MaxRows: 4}).ByteCap(), "library must not supply a byte policy")
36+ disabled := NewHistory(HistoryConfig{})
37+ require.NoError(t, disabled.Append(historyRow("ignored"), LineBound{End: 7}))
38+ require.Zero(t, disabled.Len())
39+}
40+
41+func TestHistoryByteOnlyLimit(t *testing.T) {
42+ const budget = 2*(renderer.StoredCellLogicalBytes+renderer.RowDescriptorLogicalBytes) + renderer.StyleRecordLogicalBytes
43+ h := NewHistory(HistoryConfig{MaxBytes: budget})
44+ for _, text := range []string{"a", "b", "c"} {
45+ require.NoError(t, h.Append(historyRow(text), LineBound{End: 1}))
46+ }
47+ require.Zero(t, h.Cap(), "no independent line ceiling")
48+ require.Equal(t, budget, h.ByteCap())
49+ require.Equal(t, []string{"b", "c"}, historyViewTexts(h.View()))
50+ require.Equal(t, budget, h.LogicalBytes())
51+ require.Zero(t, h.Limits().MaxRows)
52+}
53+
54+func TestHistoryLimitsReloadIsValidatedAndPreservesViews(t *testing.T) {
55+ h := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: 1 << 20, ChunkRows: 8})
56+ for _, text := range []string{"a", "b", "c", "d"} {
57+ require.NoError(t, h.Append(historyRow(text), LineBound{End: 1}))
58+ }
59+ view := h.View()
60+ before := h.Limits()
61+ require.ErrorIs(t, h.SetLimits(HistoryConfig{MaxRows: -1}), ErrInvalidHistoryConfig)
62+ require.Equal(t, before, h.Limits())
63+ require.Equal(t, 4, h.Len())
64+ require.NoError(t, h.SetLimits(HistoryConfig{MaxRows: 2, MaxBytes: 1 << 20, ChunkRows: 1}))
65+ require.Equal(t, []string{"c", "d"}, historyViewTexts(h.View()))
66+ require.Equal(t, []string{"a", "b", "c", "d"}, historyViewTexts(view))
67+ next := h.NextRowID()
68+ require.NoError(t, h.SetLimits(HistoryConfig{}))
69+ require.Zero(t, h.Len())
70+ require.Zero(t, h.LogicalBytes())
71+ require.Equal(t, next, h.NextRowID())
72+ require.NoError(t, h.SetLimits(HistoryConfig{MaxBytes: 4096}))
73+ require.NoError(t, h.Append(historyRow("new"), LineBound{End: 3}))
74+ require.Equal(t, next, h.View().RowID(0))
75+}
M history_final_acceptance_test.go +2 -2
@@ -8,7 +8,7 @@ import (
88 )
99
1010 func TestHistoryAcceptanceKeepsBookmarksAcrossLiveScrollEvictionAndRestore(t *testing.T) {
11- history := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 32, ChunkRows: 2})
11+ history := NewHistory(HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 2})
1212 for _, text := range []string{"live-1", "live-2", "live-3"} {
1313 require.NoError(t, history.Append(historyAcceptanceRow(text), LineBound{End: len(text)}))
1414 }
@@ -28,7 +28,7 @@ func TestHistoryAcceptanceKeepsBookmarksAcrossLiveScrollEvictionAndRestore(t *te
2828
2929 sealed, tail, err := MarshalSealedHistory(history.SealAndView())
3030 require.NoError(t, err)
31- restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 3, MaxCells: 32, ChunkRows: 2}, sealed, tail)
31+ restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 2}, sealed, tail)
3232 require.NoError(t, err)
3333 restoredView := restored.View()
3434 require.Equal(t, afterScroll.Len(), restoredView.Len())
A history_measure.go +70 -0
@@ -0,0 +1,70 @@
1+package vt
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/vev-vt/core"
7+)
8+
9+// MeasureHistoryBlob scans resource declarations without allocating. It is a
10+// sizing pass, NOT semantic validation: callers must still use
11+// PreflightHistoryBlob or UnmarshalHistory after accepting their aggregate
12+// budget. In particular, duplicate IDs/dictionary entries are not checked here.
13+func MeasureHistoryBlob(data []byte) (DecodeStats, error) {
14+ invalid := func() (DecodeStats, error) { return DecodeStats{}, errInvalidHistory }
15+ if len(data) < historyHeaderBytes || string(data[:4]) != historyMagic {
16+ return invalid()
17+ }
18+ p := historyParser{data: data[4:]}
19+ chunks, _ := p.uint32()
20+ next, _ := p.uint64()
21+ if chunks > maxHistoryChunks || uint64(chunks)*historyChunkHeaderBytes > uint64(len(p.data)) || next == 0 || next == math.MaxUint64 {
22+ return invalid()
23+ }
24+ stats := historyDecodeStats{chunks: uint64(chunks)}
25+ for range chunks {
26+ width, wok := p.uint32()
27+ rows, rok := p.uint32()
28+ styles, sok := p.uint32()
29+ payloads, pok := p.uint32()
30+ cells := uint64(width) * uint64(rows)
31+ if !wok || !rok || !sok || !pok || rows == 0 || rows > maxHistoryChunkRows || uint64(width) > uint64(math.MaxInt) || styles == 0 || styles > maxHistoryStylesPerChunk || uint64(styles) > cells+1 || uint64(payloads) > cells {
32+ return invalid()
33+ }
34+ if !addHistoryDecodeBudget(&stats.rows, uint64(rows), maxHistoryRows) || !addHistoryDecodeBudget(&stats.cells, cells, maxHistoryCells) || !addHistoryDecodeBudget(&stats.styles, uint64(styles), maxHistoryDecodeStyles) || !addHistoryLogicalBytes(&stats, cells, uint64(rows), uint64(styles)) {
35+ return invalid()
36+ }
37+ styleBytes := uint64(styles-1) * historyStyleBytes
38+ if styleBytes > uint64(len(p.data)) {
39+ return invalid()
40+ }
41+ p.data = p.data[styleBytes:]
42+ for range payloads {
43+ grapheme, gok := p.uint32()
44+ link, lok := p.uint32()
45+ length := uint64(grapheme) + uint64(link)
46+ if !gok || !lok || grapheme > core.MaxGraphemeBytes || link > core.MaxHyperlinkBytes || length == 0 || length > uint64(len(p.data)) {
47+ return invalid()
48+ }
49+ bytes := length + core.PayloadRecordLogicalBytes
50+ if !addHistoryDecodeBudget(&stats.payloadBytes, bytes, maxHistoryPayloadBytes) || !addHistoryDecodeBudget(&stats.bytes, bytes, maxHistoryDecodedBytes) {
51+ return invalid()
52+ }
53+ p.data = p.data[length:]
54+ }
55+ rowBytes := uint64(rows) * historyRowBytes
56+ if rowBytes > uint64(len(p.data)) {
57+ return invalid()
58+ }
59+ p.data = p.data[rowBytes:]
60+ for range cells {
61+ if _, _, _, _, ok := p.storedCell(styles, payloads); !ok {
62+ return invalid()
63+ }
64+ }
65+ }
66+ if len(p.data) != 0 {
67+ return invalid()
68+ }
69+ return DecodeStats{Chunks: stats.chunks, Rows: stats.rows, Cells: stats.cells, Styles: stats.styles, Bytes: stats.bytes}, nil
70+}
A history_measure_test.go +68 -0
@@ -0,0 +1,68 @@
1+package vt
2+
3+import (
4+ "encoding/binary"
5+ "testing"
6+
7+ "github.com/bnema/vev-vt/core"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestMeasureHistoryBlobMatchesValidatedResources(t *testing.T) {
12+ payload, err := core.NewCellPayload("e\u0301", "https://example.org")
13+ require.NoError(t, err)
14+ for _, rows := range []int{0, 1, 8} {
15+ h := NewHistory(HistoryConfig{MaxRows: 10, ChunkRows: 2})
16+ for i := range rows {
17+ style := core.DefaultStyle()
18+ style.Foreground = i
19+ require.NoError(t, h.Append([]core.Cell{{Rune: 'e', Style: style, Payload: payload}}, LineBound{End: 1}))
20+ }
21+ data, err := MarshalHistory(h.SealAndView())
22+ require.NoError(t, err)
23+ want, err := PreflightHistoryBlob(data)
24+ require.NoError(t, err)
25+ got, err := MeasureHistoryBlob(data)
26+ require.NoError(t, err)
27+ require.Equal(t, want, got)
28+ require.Zero(t, testing.AllocsPerRun(20, func() {
29+ if _, err := MeasureHistoryBlob(data); err != nil {
30+ panic(err)
31+ }
32+ }))
33+ for n := range len(data) {
34+ _, err := MeasureHistoryBlob(data[:n])
35+ require.Error(t, err)
36+ }
37+ _, err = MeasureHistoryBlob(append(append([]byte(nil), data...), 0))
38+ require.Error(t, err)
39+ }
40+}
41+
42+func TestMeasureHistoryBlobDoesNotReplaceValidation(t *testing.T) {
43+ h := NewHistory(HistoryConfig{MaxRows: 1})
44+ require.NoError(t, h.Append([]core.Cell{{Rune: 'a', Style: core.DefaultStyle()}}, LineBound{End: 1}))
45+ data, err := MarshalHistory(h.SealAndView())
46+ require.NoError(t, err)
47+ binary.BigEndian.PutUint64(data[historyHeaderBytes+historyChunkHeaderBytes:], 0)
48+ _, err = MeasureHistoryBlob(data)
49+ require.NoError(t, err, "resource sizing deliberately does not validate row identities")
50+ _, err = PreflightHistoryBlob(data)
51+ require.Error(t, err, "semantic validation must still reject the invalid identity")
52+}
53+
54+func FuzzMeasureHistoryBlob(f *testing.F) {
55+ empty, err := MarshalEmptyHistoryTail()
56+ if err != nil {
57+ f.Fatal(err)
58+ }
59+ f.Add(empty)
60+ f.Fuzz(func(t *testing.T, data []byte) {
61+ got, measured := MeasureHistoryBlob(data)
62+ want, validated := PreflightHistoryBlob(data)
63+ if validated == nil {
64+ require.NoError(t, measured)
65+ require.Equal(t, want, got)
66+ }
67+ })
68+}
A history_payload.go +62 -0
@@ -0,0 +1,62 @@
1+package vt
2+
3+import renderer "github.com/bnema/vev-vt/core"
4+
5+func (c *HistoryChunk) recordPayloads() {
6+ frame := c.frameView()
7+ var last map[renderer.CellPayload]int
8+ for row := range c.count {
9+ for x := range c.width {
10+ p := frame.Cell(x, c.start+row).Payload
11+ if p.Empty() {
12+ continue
13+ }
14+ if last == nil {
15+ last = make(map[renderer.CellPayload]int)
16+ }
17+ last[p] = c.start + row
18+ }
19+ }
20+ c.payloadBytes = 0
21+ c.payloadDrops = nil
22+ if len(last) == 0 {
23+ return
24+ }
25+ c.payloadDrops = make([]uint64, frame.Height)
26+ for value, row := range last {
27+ bytes := value.LogicalBytes()
28+ c.payloadBytes += bytes
29+ c.payloadDrops[row] += bytes
30+ }
31+}
32+
33+func (c *HistoryChunk) withoutFirstRow() *HistoryChunk {
34+ payloadBytes := c.payloadBytes
35+ if len(c.payloadDrops) != 0 {
36+ payloadBytes -= c.payloadDrops[c.start]
37+ }
38+ return &HistoryChunk{
39+ page: c.page, start: c.start + 1, count: c.count - 1, width: c.width,
40+ bounds: c.bounds[1:], rowIDs: c.rowIDs[1:],
41+ styleDrops: c.styleDrops, styleCount: c.styleCount - c.styleDrops[c.start],
42+ payloadDrops: c.payloadDrops, payloadBytes: payloadBytes,
43+ }
44+}
45+
46+func (h *History) recordPayloadScratch(p renderer.CellPayload) {
47+ if p.Empty() {
48+ return
49+ }
50+ if h.payloadScratch == nil {
51+ h.payloadScratch = make(map[renderer.CellPayload]struct{})
52+ }
53+ h.payloadScratch[p] = struct{}{}
54+}
55+
56+func (h *History) rowPayloadBytes() uint64 {
57+ var n uint64
58+ for p := range h.payloadScratch {
59+ n += p.LogicalBytes()
60+ }
61+ return n
62+}
A history_payload_test.go +111 -0
@@ -0,0 +1,111 @@
1+package vt
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "testing"
7+
8+ renderer "github.com/bnema/vev-vt/core"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestPayloadSurvivesSnapshotResizeAndRecovery(t *testing.T) {
13+ payload, err := renderer.NewCellPayload("e\u0301", "https://example.com")
14+ require.NoError(t, err)
15+ s := NewScreen(4, 2)
16+ s.Write([]byte("ex"))
17+ cell := s.Cell(0, 0)
18+ cell.Payload = payload
19+ s.frame.Set(0, 0, cell)
20+ snapshot := s.Snapshot()
21+ recovery := s.RecoveryTranscriptSnapshot()
22+ s.Resize(2, 3)
23+ require.Equal(t, payload, s.Cell(0, 0).Payload)
24+ s.Write([]byte("\x1b[2J"))
25+ require.Equal(t, payload, snapshot.Cell(0, 0).Payload)
26+ blob, err := recovery.Marshal()
27+ require.NoError(t, err)
28+ view, err := UnmarshalHistory(blob)
29+ require.NoError(t, err)
30+ require.Equal(t, payload, view.Cell(0, 0).Payload)
31+}
32+
33+func TestHistoryPayloadRoundTripEvictionAndRestore(t *testing.T) {
34+ payload, err := renderer.NewCellPayload("e\u0301", "https://example.com")
35+ require.NoError(t, err)
36+ h := NewHistory(HistoryConfig{MaxRows: 2, MaxBytes: 1 << 20, ChunkRows: 2})
37+ row := []renderer.Cell{{Rune: 'e', Style: renderer.DefaultStyle(), Payload: payload}}
38+ require.NoError(t, h.Append(row, LineBound{End: 1}))
39+ require.NoError(t, h.Append(historyRow("x"), LineBound{End: 1}))
40+ before := h.View()
41+ encoded, err := MarshalHistory(before)
42+ require.NoError(t, err)
43+ decoded, err := UnmarshalHistory(encoded)
44+ require.NoError(t, err)
45+ require.Equal(t, payload, decoded.Cell(0, 0).Payload)
46+ require.Equal(t, before.LogicalBytes(), decoded.LogicalBytes())
47+ require.NoError(t, decoded.Chunk(0).CheckInvariants())
48+ require.NoError(t, h.Append(historyRow("y"), LineBound{End: 1}))
49+ require.Equal(t, uint64(0), h.View().Chunk(0).payloadBytes)
50+ require.Equal(t, payload, before.Cell(0, 0).Payload, "eviction must not mutate a sealed borrowed view")
51+ sealed, tail, err := MarshalSealedHistory(before)
52+ require.NoError(t, err)
53+ restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 1, MaxBytes: 1 << 20}, sealed, tail)
54+ require.NoError(t, err)
55+ require.Equal(t, 'x', restored.View().Cell(0, 0).Rune)
56+ require.True(t, restored.View().Cell(0, 0).Payload.Empty())
57+ require.NoError(t, restored.View().Chunk(0).CheckInvariants())
58+}
59+
60+func TestHistoryPayloadBudgetRejectsBeforeMutation(t *testing.T) {
61+ payload, err := renderer.NewCellPayload("e\u0301", "https://example.com")
62+ require.NoError(t, err)
63+ rowBytes := renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes + renderer.StyleRecordLogicalBytes
64+ h := NewHistory(HistoryConfig{MaxRows: 2, MaxBytes: rowBytes + payload.LogicalBytes() - 1})
65+ err = h.Append([]renderer.Cell{{Rune: 'e', Style: renderer.DefaultStyle(), Payload: payload}}, LineBound{End: 1})
66+ require.ErrorIs(t, err, ErrHistoryRowTooLarge)
67+ require.Zero(t, h.Len())
68+ require.Zero(t, h.LogicalBytes())
69+}
70+
71+func TestPayloadCodecRejectsInvalidDictionariesAndReferences(t *testing.T) {
72+ a, err := renderer.NewCellPayload("", "https://a")
73+ require.NoError(t, err)
74+ b, err := renderer.NewCellPayload("", "https://b")
75+ require.NoError(t, err)
76+ h := NewHistory(HistoryConfig{MaxRows: 1, MaxBytes: 1 << 20})
77+ require.NoError(t, h.Append([]renderer.Cell{
78+ {Rune: 'a', Style: renderer.DefaultStyle(), Payload: a},
79+ {Rune: 'b', Style: renderer.DefaultStyle(), Payload: b},
80+ }, LineBound{End: 2}))
81+ valid, err := MarshalHistory(h.View())
82+ require.NoError(t, err)
83+ const dict = historyHeaderBytes + historyChunkHeaderBytes
84+ firstLen := 8 + len(a.Hyperlink())
85+ for _, tt := range []struct {
86+ name string
87+ mutate func([]byte)
88+ }{
89+ {"missing payload reference", func(v []byte) { binary.BigEndian.PutUint32(v[len(v)-4:], 3) }},
90+ {"zero explicit payload reference", func(v []byte) { clear(v[len(v)-4:]) }},
91+ {"unused payload", func(v []byte) { binary.BigEndian.PutUint32(v[len(v)-4:], 1) }},
92+ {"duplicate payload", func(v []byte) { copy(v[dict+firstLen:dict+2*firstLen], v[dict:dict+firstLen]) }},
93+ {"control injection", func(v []byte) { v[dict+8] = 0x1b }},
94+ {"invalid UTF8", func(v []byte) { v[dict+8] = 0xff }},
95+ {"oversized string", func(v []byte) { binary.BigEndian.PutUint32(v[dict+4:], renderer.MaxHyperlinkBytes+1) }},
96+ {"excessive dictionary", func(v []byte) { binary.BigEndian.PutUint32(v[28:], ^uint32(0)) }},
97+ } {
98+ t.Run(tt.name, func(t *testing.T) {
99+ v := bytes.Clone(valid)
100+ tt.mutate(v)
101+ _, err := PreflightHistoryBlob(v)
102+ require.Error(t, err)
103+ _, err = UnmarshalHistory(v)
104+ require.Error(t, err)
105+ })
106+ }
107+ for end := range len(valid) {
108+ _, err := UnmarshalHistory(valid[:end])
109+ require.Error(t, err)
110+ }
111+}
A history_range_error_test.go +24 -0
@@ -0,0 +1,24 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev-vt/core"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestHistoryRangeReportsCorruptionWithoutPanicking(t *testing.T) {
11+ h := compressionFixture(t)
12+ compressFixture(t, h)
13+ view := h.View()
14+ page := view.Chunk(1).page
15+ page.compressed[len(page.compressed)-1] ^= 0xff
16+ calls := 0
17+ err := view.Range(func([]core.Cell) bool { calls++; return true })
18+ require.ErrorIs(t, err, ErrHistoryCorrupt)
19+ require.Equal(t, 16, calls, "iteration stops before yielding any row from the corrupt page")
20+ calls = 0
21+ require.NoError(t, view.Range(func([]core.Cell) bool { calls++; return false }))
22+ require.Equal(t, 1, calls, "early cancellation must not restore later pages")
23+ require.NoError(t, (HistoryView{}).Range(nil))
24+}
A history_read.go +52 -0
@@ -0,0 +1,52 @@
1+package vt
2+
3+import renderer "github.com/bnema/vev-vt/core"
4+
5+// RowWidth returns the retained width of row y, or zero outside the view.
6+// Unlike Row, it does not allocate or decode a semantic row.
7+func (v HistoryView) RowWidth(y int) int {
8+ chunk, _ := v.locateRow(y)
9+ if chunk == nil {
10+ return 0
11+ }
12+ return chunk.width
13+}
14+
15+// Cell returns one semantic cell without allocating a row. Coordinates follow
16+// Screen.Cell: x is the column and y is the row. Invalid coordinates return the
17+// canonical blank cell. Variable-width history does not implement CellSource.
18+func (v HistoryView) Cell(x, y int) renderer.Cell {
19+ chunk, row := v.locateRow(y)
20+ if chunk == nil || x < 0 || x >= chunk.width {
21+ return renderer.BlankCell()
22+ }
23+ return chunk.cell(row, x)
24+}
25+
26+// CopyRow copies up to len(dst) cells into caller-owned storage and returns the
27+// number copied. It neither allocates a row nor exposes mutable page storage.
28+func (v HistoryView) CopyRow(y int, dst []renderer.Cell) int {
29+ chunk, row := v.locateRow(y)
30+ if chunk == nil {
31+ return 0
32+ }
33+ n := min(len(dst), chunk.width)
34+ frame := chunk.frameView()
35+ for x := range n {
36+ dst[x] = frame.Cell(x, chunk.start+row)
37+ }
38+ return n
39+}
40+
41+func (v HistoryView) locateRow(y int) (*HistoryChunk, int) {
42+ if y < 0 {
43+ return nil, 0
44+ }
45+ for _, chunk := range v.chunks {
46+ if y < chunk.len() {
47+ return chunk, y
48+ }
49+ y -= chunk.len()
50+ }
51+ return nil, 0
52+}
A history_read_test.go +58 -0
@@ -0,0 +1,58 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ renderer "github.com/bnema/vev-vt/core"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestHistoryScalarReadsAndCopyDoNotAllocate(t *testing.T) {
11+ h := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1})
12+ require.NoError(t, h.Append(historyRow("abc"), LineBound{End: 3}))
13+ require.NoError(t, h.Append(historyRow("de"), LineBound{End: 2}))
14+ view := h.View()
15+ require.Equal(t, 3, view.RowWidth(0))
16+ require.Equal(t, 2, view.RowWidth(1))
17+ for _, y := range []int{-1, 2} {
18+ require.Zero(t, view.RowWidth(y))
19+ require.Equal(t, renderer.BlankCell(), view.Cell(0, y))
20+ }
21+ require.Equal(t, renderer.BlankCell(), view.Cell(-1, 0))
22+ require.Equal(t, renderer.BlankCell(), view.Cell(3, 0))
23+ dst := make([]renderer.Cell, 2)
24+ require.Equal(t, 2, view.CopyRow(0, dst))
25+ require.Equal(t, 'a', dst[0].Rune)
26+ dst[0].Rune = 'X'
27+ require.Equal(t, 'a', view.Cell(0, 0).Rune)
28+ require.Zero(t, testing.AllocsPerRun(100, func() {
29+ _ = view.RowWidth(1)
30+ _ = view.Cell(1, 1)
31+ view.CopyRow(0, dst)
32+ }))
33+}
34+
35+func TestSnapshotTailReusesItsCompactCapture(t *testing.T) {
36+ h := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 4})
37+ require.NoError(t, h.Append(historyRow("ab"), LineBound{End: 2}))
38+ snapshot := h.SnapshotView()
39+ tail := snapshot.Tail()
40+ require.Same(t, tail.Chunk(0), snapshot.Tail().Chunk(0))
41+ require.Zero(t, testing.AllocsPerRun(100, func() { _ = snapshot.Tail() }))
42+ require.NoError(t, h.Append(historyRow("cd"), LineBound{End: 2}))
43+ require.Equal(t, 1, tail.Len())
44+ require.Equal(t, 'a', tail.Cell(0, 0).Rune)
45+ require.NoError(t, tail.Chunk(0).CheckInvariants())
46+}
47+
48+func BenchmarkRecoveryTranscriptCompactCapture(b *testing.B) {
49+ s := NewScreen(120, 40)
50+ for y := range s.Rows() {
51+ s.frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
52+ s.buffer.boundaries[y] = LineBound{End: 1}
53+ }
54+ b.ReportAllocs()
55+ for b.Loop() {
56+ recoveryTranscriptSnapshotSink = s.RecoveryTranscriptSnapshot()
57+ }
58+}
A history_storage.go +222 -0
@@ -0,0 +1,222 @@
1+package vt
2+
3+import (
4+ "bytes"
5+ "compress/zlib"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "sync"
10+
11+ renderer "github.com/bnema/vev-vt/core"
12+)
13+
14+// ErrHistoryCorrupt reports corruption of an internally compressed sealed page.
15+// Public history decoders reject malformed external data before installing it.
16+var ErrHistoryCorrupt = errors.New("corrupt compressed history page")
17+
18+const maxHistoryPageEncodedBytes = 256 << 20
19+
20+// sealedPage owns immutable semantic contents and mutable physical backing.
21+// Evicted-prefix wrappers and borrowed views share this owner. A returned Frame
22+// is an internal read-only borrow: dropping the cached pointer cannot invalidate
23+// an in-flight reader because Go keeps that reader's page reference alive.
24+type sealedPage struct {
25+ mu sync.Mutex
26+ frame renderer.Frame
27+ compressed []byte
28+ encodedSize int
29+ width, height int
30+ reads, observed uint64
31+ observedOnce bool
32+ incompressible bool
33+ restores uint64
34+}
35+
36+func newSealedPage(frame renderer.Frame) *sealedPage {
37+ return &sealedPage{frame: frame, width: frame.Width, height: frame.Height}
38+}
39+
40+func (p *sealedPage) readFrame(cache bool) (renderer.Frame, error) {
41+ p.mu.Lock()
42+ defer p.mu.Unlock()
43+ p.reads++
44+ if p.frame.Height != 0 {
45+ return p.frame, nil
46+ }
47+ frame, err := p.decode()
48+ if err != nil {
49+ return renderer.Frame{}, err
50+ }
51+ p.restores++
52+ if cache {
53+ p.frame = frame
54+ }
55+ return frame, nil
56+}
57+
58+// decode is called with the page lock held. The exact uncompressed length and
59+// zlib checksum are validated before VTC1's full semantic preflight runs.
60+func (p *sealedPage) decode() (renderer.Frame, error) {
61+ invalid := func(err error) (renderer.Frame, error) {
62+ return renderer.Frame{}, fmt.Errorf("%w: %v", ErrHistoryCorrupt, err)
63+ }
64+ if p.encodedSize < historyHeaderBytes || p.encodedSize > maxHistoryPageEncodedBytes || len(p.compressed) == 0 {
65+ return invalid(errors.New("missing backing"))
66+ }
67+ source := bytes.NewReader(p.compressed)
68+ r, err := zlib.NewReader(source)
69+ if err != nil {
70+ return invalid(err)
71+ }
72+ data, readErr := io.ReadAll(io.LimitReader(r, int64(p.encodedSize)+1))
73+ closeErr := r.Close()
74+ if readErr != nil {
75+ return invalid(readErr)
76+ }
77+ if closeErr != nil {
78+ return invalid(closeErr)
79+ }
80+ if len(data) != p.encodedSize || source.Len() != 0 {
81+ return invalid(errors.New("backing length mismatch"))
82+ }
83+ view, err := UnmarshalHistory(data)
84+ if err != nil {
85+ return invalid(err)
86+ }
87+ if len(view.chunks) != 1 {
88+ return invalid(errors.New("unexpected page count"))
89+ }
90+ frame := view.chunks[0].page.frame
91+ if frame.Width != p.width || frame.Height != p.height {
92+ return invalid(errors.New("page geometry mismatch"))
93+ }
94+ return frame, nil
95+}
96+
97+func (c *HistoryChunk) frameView() renderer.Frame {
98+ frame, err := c.page.readFrame(true)
99+ if err != nil {
100+ panic(err)
101+ } // Never substitute blank data for corrupted history.
102+ return frame
103+}
104+
105+// Restore validates and caches the chunk's backing before a caller starts a
106+// read transaction. Ordinary reads restore transparently; if private backing is
107+// corrupted they panic with ErrHistoryCorrupt rather than silently losing text.
108+func (c *HistoryChunk) Restore() error {
109+ if c == nil {
110+ return nil
111+ }
112+ _, err := c.page.readFrame(true)
113+ return err
114+}
115+
116+func (p *sealedPage) compressIfIdle() (bool, error) {
117+ p.mu.Lock()
118+ defer p.mu.Unlock()
119+ if !p.observedOnce || p.observed != p.reads {
120+ p.observed, p.observedOnce = p.reads, true
121+ return false, nil
122+ }
123+ if p.frame.Height == 0 || p.incompressible {
124+ return false, nil
125+ }
126+ if len(p.compressed) != 0 {
127+ p.frame = renderer.Frame{}
128+ return true, nil
129+ }
130+ // Encode the full backing, not an evicted-prefix wrapper. Synthetic IDs are
131+ // local to this private envelope; public stable IDs remain on the wrappers.
132+ chunk := &HistoryChunk{page: newSealedPage(p.frame), count: p.height, width: p.width,
133+ bounds: make([]LineBound, p.height), rowIDs: make([]RowID, p.height)}
134+ for y := range p.height {
135+ chunk.bounds[y] = LineBound{End: p.width}
136+ chunk.rowIDs[y] = RowID(y + 1)
137+ }
138+ raw, err := MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: p.height, nextRowID: RowID(p.height + 1)})
139+ if err != nil {
140+ return false, err
141+ }
142+ if len(raw) > maxHistoryPageEncodedBytes {
143+ return false, errors.New("history page exceeds encoded backing limit")
144+ }
145+ var out bytes.Buffer
146+ w, err := zlib.NewWriterLevel(&out, zlib.BestSpeed)
147+ if err != nil {
148+ return false, err
149+ }
150+ if _, err := w.Write(raw); err != nil {
151+ _ = w.Close()
152+ return false, err
153+ }
154+ if err := w.Close(); err != nil {
155+ return false, err
156+ }
157+ if out.Len() >= len(raw) {
158+ p.incompressible = true
159+ return false, nil
160+ }
161+ p.compressed = make([]byte, out.Len())
162+ copy(p.compressed, out.Bytes())
163+ p.encodedSize = len(raw)
164+ p.frame = renderer.Frame{}
165+ return true, nil
166+}
167+
168+// CompressIdle visits at most maxPages sealed pages. Call it from the history
169+// owner's idle scheduler, never concurrently with append/eviction. A page must
170+// remain unread across two visits; the newest sealed chunk and mutable tail are
171+// kept hot. There are no internal goroutines, clocks, pooling or mmap mappings.
172+func (h *History) CompressIdle(maxPages int) (int, error) {
173+ if h == nil || maxPages <= 0 || len(h.chunks) < 2 {
174+ return 0, nil
175+ }
176+ eligible := len(h.chunks) - 1
177+ compressed := 0
178+ for range min(maxPages, eligible) {
179+ h.compressionCursor %= eligible
180+ chunk := h.chunks[h.compressionCursor]
181+ h.compressionCursor++
182+ didCompress, err := chunk.page.compressIfIdle()
183+ if err != nil {
184+ return compressed, err
185+ }
186+ if didCompress {
187+ compressed++
188+ }
189+ }
190+ return compressed, nil
191+}
192+
193+// HistoryCompressionStats describes retained physical backing, not process RSS.
194+// ResidentLogicalBytes excludes Go allocator/map overhead; CompressedBytes is
195+// the encoded backing size. Retention remains governed by LogicalBytes alone.
196+type HistoryCompressionStats struct {
197+ ColdPages int
198+ CompressedBytes uint64
199+ ResidentLogicalBytes uint64
200+ Restores uint64
201+}
202+
203+func (h *History) CompressionStats() HistoryCompressionStats {
204+ var stats HistoryCompressionStats
205+ if h == nil {
206+ return stats
207+ }
208+ stats.ResidentLogicalBytes = h.tailBytes
209+ for _, chunk := range h.chunks {
210+ p := chunk.page
211+ p.mu.Lock()
212+ if p.frame.Height == 0 {
213+ stats.ColdPages++
214+ } else {
215+ stats.ResidentLogicalBytes += p.frame.LogicalBytes()
216+ }
217+ stats.CompressedBytes += uint64(len(p.compressed))
218+ stats.Restores += p.restores
219+ p.mu.Unlock()
220+ }
221+ return stats
222+}
A history_storage_test.go +174 -0
@@ -0,0 +1,174 @@
1+package vt
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "sync"
7+ "testing"
8+
9+ renderer "github.com/bnema/vev-vt/core"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func compressionFixture(t testing.TB) *History {
14+ t.Helper()
15+ h := NewHistory(HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 16})
16+ row := historyRow(string(bytes.Repeat([]byte("a"), 80)))
17+ payload, err := renderer.NewCellPayload("e\u0301", "https://example.com")
18+ require.NoError(t, err)
19+ row[0].Payload = payload
20+ for range 48 {
21+ require.NoError(t, h.Append(row, LineBound{End: 80, Soft: true}))
22+ }
23+ return h
24+}
25+
26+func compressFixture(t testing.TB, h *History) {
27+ t.Helper()
28+ n, err := h.CompressIdle(100)
29+ require.NoError(t, err)
30+ require.Zero(t, n, "first observation must not compress a recently read page")
31+ n, err = h.CompressIdle(100)
32+ require.NoError(t, err)
33+ require.Equal(t, 2, n)
34+}
35+
36+func TestColdHistoryPreservesIdentityAccountingAndContents(t *testing.T) {
37+ h := compressionFixture(t)
38+ view := h.View()
39+ chunk := view.Chunk(0)
40+ logical := h.LogicalBytes()
41+ encoded, err := MarshalHistory(view)
42+ require.NoError(t, err)
43+ compressFixture(t, h)
44+ for _, sealed := range h.chunks {
45+ require.Equal(t, len(sealed.page.compressed), cap(sealed.page.compressed), "compressed backing must not retain buffer slack")
46+ }
47+ stats := h.CompressionStats()
48+ require.Equal(t, 2, stats.ColdPages)
49+ require.Positive(t, stats.CompressedBytes)
50+ require.Less(t, stats.CompressedBytes+stats.ResidentLogicalBytes, logical)
51+ require.Equal(t, logical, h.LogicalBytes())
52+ require.Same(t, chunk, h.View().Chunk(0))
53+ // Persistence and streaming search do not fill random-access caches.
54+ again, err := MarshalHistory(view)
55+ require.NoError(t, err)
56+ require.Equal(t, encoded, again)
57+ rows := 0
58+ require.NoError(t, view.Range(func(row []renderer.Cell) bool {
59+ require.Equal(t, 'a', row[0].Rune)
60+ require.Equal(t, "e\u0301", row[0].Payload.Grapheme())
61+ rows++
62+ return true
63+ }))
64+ require.Equal(t, 48, rows)
65+ require.Equal(t, 2, h.CompressionStats().ColdPages)
66+ require.Equal(t, 'a', view.Cell(0, 0).Rune)
67+ require.Equal(t, 1, h.CompressionStats().ColdPages)
68+ require.Equal(t, RowID(1), view.RowID(0))
69+ require.Equal(t, LineBound{End: 80, Soft: true}, view.Bound(0))
70+ require.NoError(t, chunk.CheckInvariants())
71+ _, err = h.CompressIdle(100)
72+ require.NoError(t, err)
73+ n, err := h.CompressIdle(100)
74+ require.NoError(t, err)
75+ require.Equal(t, 1, n, "an unread restored cache is released without recompression")
76+}
77+
78+func TestColdHistoryIncrementalBudgetAndEviction(t *testing.T) {
79+ h := compressionFixture(t)
80+ before := h.View()
81+ for range 4 {
82+ n, err := h.CompressIdle(1)
83+ require.NoError(t, err)
84+ require.LessOrEqual(t, n, 1)
85+ }
86+ require.Equal(t, 2, h.CompressionStats().ColdPages)
87+ for range 20 {
88+ require.NoError(t, h.Append(historyRow("new"), LineBound{End: 3}))
89+ }
90+ require.Equal(t, 64, h.Len())
91+ require.Equal(t, RowID(5), h.View().RowID(0))
92+ require.Equal(t, RowID(1), before.RowID(0))
93+ require.Equal(t, 'a', before.Cell(0, 0).Rune)
94+ require.NoError(t, h.View().Chunk(0).CheckInvariants())
95+}
96+
97+func TestColdHistoryRejectsCorruptedBacking(t *testing.T) {
98+ h := compressionFixture(t)
99+ compressFixture(t, h)
100+ chunk := h.View().Chunk(0)
101+ chunk.page.compressed[len(chunk.page.compressed)-1] ^= 0xff
102+ require.ErrorIs(t, chunk.Restore(), ErrHistoryCorrupt)
103+ _, err := MarshalHistory(h.View())
104+ require.ErrorIs(t, err, ErrHistoryCorrupt)
105+ func() {
106+ defer func() {
107+ value := recover()
108+ err, ok := value.(error)
109+ require.True(t, ok)
110+ require.True(t, errors.Is(err, ErrHistoryCorrupt))
111+ }()
112+ _ = h.View().Cell(0, 0)
113+ t.Fatal("corrupt read silently succeeded")
114+ }()
115+}
116+
117+func TestColdHistoryRejectsBackingLengthCorruption(t *testing.T) {
118+ for _, tt := range []struct {
119+ name string
120+ mutate func(*sealedPage)
121+ }{
122+ {"trailing data", func(p *sealedPage) { p.compressed = append(p.compressed, 0) }},
123+ {"truncated data", func(p *sealedPage) { p.compressed = p.compressed[:len(p.compressed)-1] }},
124+ {"short decoded length", func(p *sealedPage) { p.encodedSize-- }},
125+ {"oversized decoded length", func(p *sealedPage) { p.encodedSize = 257 << 20 }},
126+ } {
127+ t.Run(tt.name, func(t *testing.T) {
128+ h := compressionFixture(t)
129+ compressFixture(t, h)
130+ chunk := h.View().Chunk(0)
131+ tt.mutate(chunk.page)
132+ require.ErrorIs(t, chunk.Restore(), ErrHistoryCorrupt)
133+ })
134+ }
135+}
136+
137+func TestColdHistoryBorrowedReadConcurrentWithMaintenance(t *testing.T) {
138+ h := compressionFixture(t)
139+ view := h.View()
140+ var wg sync.WaitGroup
141+ wg.Add(1)
142+ go func() {
143+ defer wg.Done()
144+ for range 100 {
145+ if view.Cell(0, 0).Rune != 'a' {
146+ t.Error("borrowed contents changed")
147+ return
148+ }
149+ }
150+ }()
151+ for range 100 {
152+ _, err := h.CompressIdle(2)
153+ require.NoError(t, err)
154+ }
155+ wg.Wait()
156+}
157+
158+func BenchmarkColdHistoryRestore(b *testing.B) {
159+ h := compressionFixture(b)
160+ compressFixture(b, h)
161+ chunk := h.View().Chunk(0)
162+ b.ReportAllocs()
163+ for b.Loop() {
164+ if err := chunk.Restore(); err != nil {
165+ b.Fatal(err)
166+ }
167+ if _, err := h.CompressIdle(100); err != nil {
168+ b.Fatal(err)
169+ }
170+ if _, err := h.CompressIdle(100); err != nil {
171+ b.Fatal(err)
172+ }
173+ }
174+}
M history_test.go +92 -17
@@ -183,8 +183,8 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
183183 for _, tt := range tests {
184184 t.Run(tt.name, func(t *testing.T) {
185185 s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8, ChunkRows: 2})
186- for y := range s.Frame.Height {
187- copy(s.Frame.Row(y), historyRow(string([]byte{byte('A' + y), byte('A' + y), byte('A' + y), byte('A' + y)})))
186+ for y := range s.frame.Height {
187+ s.frame.WriteRow(y, 0, historyRow(string([]byte{byte('A' + y), byte('A' + y), byte('A' + y), byte('A' + y)})))
188188 }
189189 events := 0
190190 s.OnLineEvicted = func([]renderer.Cell) { events++ }
@@ -201,8 +201,9 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
201201 }
202202 }
203203
204-func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {
205- history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})
204+func TestHistoryBoundsRowsAndBytesWithExactRowEviction(t *testing.T) {
205+ const budget = 3*renderer.StoredCellLogicalBytes + 2*renderer.RowDescriptorLogicalBytes + 2*renderer.StyleRecordLogicalBytes
206+ history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: budget, ChunkRows: 2})
206207 for _, text := range []string{"aa", "bbb", "c", "dd"} {
207208 if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil {
208209 t.Fatalf("append %q: %v", text, err)
@@ -246,8 +247,9 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
246247 }
247248 }
248249
249-func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
250- history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})
250+func TestHistoryRejectsRowLargerThanByteBudgetWithoutMutation(t *testing.T) {
251+ const budget = 2*renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes + renderer.StyleRecordLogicalBytes
252+ history := NewHistory(HistoryConfig{MaxRows: 2, MaxBytes: budget, ChunkRows: 2})
251253 kept := historyRow("ok")
252254 if err := history.Append(kept, LineBound{End: len(kept)}); err != nil {
253255 t.Fatalf("append retained row: %v", err)
@@ -256,8 +258,8 @@ func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
256258
257259 wide := historyRow("wide")
258260 err := history.Append(wide, LineBound{End: len(wide)})
259- if !errors.Is(err, ErrHistoryRowTooWide) {
260- t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)
261+ if !errors.Is(err, ErrHistoryRowTooLarge) {
262+ t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooLarge", err)
261263 }
262264 if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) {
263265 t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want)
@@ -268,11 +270,11 @@ func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
268270 }
269271
270272 func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) {
271- screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxCells: 3})
273+ screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxBytes: 83})
272274 var events []string
273275 screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) }
274- copy(screen.Frame.Row(0), historyRow("AAAA"))
275- copy(screen.Frame.Row(1), historyRow("BBBB"))
276+ screen.frame.WriteRow(0, 0, historyRow("AAAA"))
277+ screen.frame.WriteRow(1, 0, historyRow("BBBB"))
276278 screen.scrollUpRegion(0, 1, 1)
277279
278280 if got := screen.History().Len(); got != 0 {
@@ -314,7 +316,7 @@ func TestHistoryAppendRetainsBounds(t *testing.T) {
314316
315317 for _, tc := range tests {
316318 t.Run(tc.name, func(t *testing.T) {
317- h := NewHistory(HistoryConfig{MaxRows: 16, MaxCells: 1024, ChunkRows: tc.chunkRows})
319+ h := NewHistory(HistoryConfig{MaxRows: 16, MaxBytes: 1 << 20, ChunkRows: tc.chunkRows})
318320 for i, b := range tc.bounds {
319321 if err := h.Append(row("abc"[:b.End]), b); err != nil {
320322 t.Fatalf("append %d: %v", i, err)
@@ -331,7 +333,7 @@ func TestHistoryAppendRetainsBounds(t *testing.T) {
331333 }
332334
333335 func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) {
334- h := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 1024, ChunkRows: 2})
336+ h := NewHistory(HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 2})
335337 for i := range 5 {
336338 cells := []renderer.Cell{{Rune: rune('a' + i)}}
337339 if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil {
@@ -345,15 +347,88 @@ func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) {
345347 // Rows 2, 3, 4 survived; their Soft flags were true, false, true.
346348 for i, want := range []bool{true, false, true} {
347349 if got := view.Bound(i).Soft; got != want {
348- t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.BorrowedRow(i)[0].Rune)
350+ t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.Row(i)[0].Rune)
349351 }
350352 }
351353 }
352354
353-func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) {
355+func TestHistorySlabAndTailAllocationBounds(t *testing.T) {
356+ if uint64(math.MaxInt) >= uint64(math.MaxUint32) {
357+ maxWidth := uint64(math.MaxUint32)
358+ width := int(maxWidth)
359+ if got := maxHistorySlabRows(width); got != 1 {
360+ t.Fatalf("maxHistorySlabRows(MaxUint32) = %d, want 1", got)
361+ }
362+ if got := maxHistorySlabRows(width / 2); got != 2 {
363+ t.Fatalf("maxHistorySlabRows(MaxUint32/2) = %d, want 2", got)
364+ }
365+ }
366+
367+ history := NewHistory(HistoryConfig{MaxRows: 256, MaxBytes: 1 << 30, ChunkRows: 256})
368+ row := make([]renderer.Cell, 10_000)
369+ if err := history.Append(row, LineBound{}); err != nil {
370+ t.Fatal(err)
371+ }
372+ if got, max := cap(history.tailCells), max(len(row), maxTailPreallocCells); got > max {
373+ t.Fatalf("wide-row tail capacity = %d, want at most %d", got, max)
374+ }
375+}
376+
377+func TestHistorySealsCompactContiguousSlabs(t *testing.T) {
378+ const rows, columns = 256, 120
379+ history := NewHistory(HistoryConfig{MaxRows: rows, MaxBytes: 1 << 30, ChunkRows: rows})
380+ row := make([]renderer.Cell, columns)
381+ for x := range row {
382+ row[x] = renderer.Cell{Rune: 'x', Style: renderer.Style{Bold: true, Foreground: 4}}
383+ }
384+ for range rows {
385+ if err := history.Append(row, LineBound{End: columns}); err != nil {
386+ t.Fatal(err)
387+ }
388+ }
389+
390+ view := history.View()
391+ chunk := view.Chunk(0)
392+ if view.ChunkCount() != 1 || chunk == nil || chunk.count != rows || chunk.width != columns {
393+ t.Fatalf("compact chunk = %#v, chunks %d", chunk, view.ChunkCount())
394+ }
395+ // The original inline Cell occupied 72 bytes; retain the 4x reduction gate.
396+ if got, max := chunk.frameView().LogicalBytes(), uint64(rows*columns*72/4); got >= max {
397+ t.Fatalf("compact slab logical bytes = %d, want below %d", got, max)
398+ }
399+ if got := chunk.frameView().StyleCount(); got != 2 {
400+ t.Fatalf("page-local style count = %d, want default plus repeated style", got)
401+ }
402+ if err := chunk.CheckInvariants(); err != nil {
403+ t.Fatalf("compact chunk invariants: %v", err)
404+ }
405+}
406+
407+func TestHistorySplitsCompactSlabsWhenRowWidthChanges(t *testing.T) {
408+ history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 4})
409+ for _, text := range []string{"aa", "bb", "ccc", "ddd"} {
410+ if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil {
411+ t.Fatal(err)
412+ }
413+ }
414+ view := history.View()
415+ if got := view.ChunkCount(); got != 2 {
416+ t.Fatalf("ChunkCount() = %d, want one slab per consecutive width", got)
417+ }
418+ for i, want := range []string{"aa", "bb", "ccc", "ddd"} {
419+ if got := rowText(view.Row(i)); got != want {
420+ t.Fatalf("Row(%d) = %q, want %q", i, got, want)
421+ }
422+ }
423+}
424+
425+func TestHistoryLargeRowLimitDoesNotInventByteBudget(t *testing.T) {
354426 history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})
355- if got, want := history.CellCap(), math.MaxInt; got != want {
356- t.Fatalf("default cell cap = %d, want %d", got, want)
427+ if got := history.ByteCap(); got != 0 {
428+ t.Fatalf("implicit byte capacity = %d, want no byte ceiling", got)
429+ }
430+ if got := history.Cap(); got != math.MaxInt {
431+ t.Fatalf("row ceiling = %d", got)
357432 }
358433 }
359434
A history_view_cache_test.go +50 -0
@@ -0,0 +1,50 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev-vt/core"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestHistoryViewReusesUnchangedTail(t *testing.T) {
11+ h := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 4})
12+ require.NoError(t, h.Append([]core.Cell{{Rune: 'a', Style: core.DefaultStyle()}}, LineBound{End: 1}))
13+ first := h.View()
14+ require.Same(t, first.chunks[0], h.View().chunks[0])
15+ require.LessOrEqual(t, testing.AllocsPerRun(20, func() { _ = h.View() }), float64(1))
16+ require.Same(t, first.chunks[0], h.SealAndView().chunks[0])
17+}
18+
19+func TestHistoryTailCacheInvalidationPreservesRetainedViews(t *testing.T) {
20+ for _, tt := range []struct {
21+ name string
22+ mutate func(*History) error
23+ want int
24+ }{
25+ {"append", func(h *History) error {
26+ return h.Append([]core.Cell{{Rune: 'c', Style: core.DefaultStyle()}}, LineBound{End: 1})
27+ }, 3},
28+ {"byte eviction", func(h *History) error {
29+ return h.SetLimits(HistoryConfig{MaxBytes: core.StoredCellLogicalBytes + core.RowDescriptorLogicalBytes + core.StyleRecordLogicalBytes, ChunkRows: 4})
30+ }, 1},
31+ {"disable", func(h *History) error { return h.SetLimits(HistoryConfig{}) }, 0},
32+ {"seal and row eviction", func(h *History) error { return h.SetLimits(HistoryConfig{MaxRows: 1, ChunkRows: 1}) }, 1},
33+ } {
34+ t.Run(tt.name, func(t *testing.T) {
35+ h := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 4})
36+ for _, r := range "ab" {
37+ require.NoError(t, h.Append([]core.Cell{{Rune: r, Style: core.DefaultStyle()}}, LineBound{End: 1}))
38+ }
39+ before := h.View()
40+ snapshot := h.SnapshotView()
41+ require.NoError(t, tt.mutate(h))
42+ require.Equal(t, tt.want, h.View().Len())
43+ require.Equal(t, 2, before.Len())
44+ require.Equal(t, 'a', before.Cell(0, 0).Rune)
45+ require.Equal(t, 'b', before.Cell(0, 1).Rune)
46+ require.Equal(t, 'a', snapshot.Tail().Cell(0, 0).Rune)
47+ require.Equal(t, 'b', snapshot.Tail().Cell(0, 1).Rune)
48+ })
49+ }
50+}
D history_vth3_golden_external_test.go +0 -150
@@ -1,150 +0,0 @@
1-package vt_test
2-
3-import (
4- "bytes"
5- "encoding/binary"
6- "encoding/hex"
7- "testing"
8-
9- vt "github.com/bnema/vev-vt"
10- "github.com/bnema/vev-vt/core"
11-)
12-
13-func TestExternalVTH3GoldenVectors(t *testing.T) {
14- vectors := []struct {
15- name string
16- hex string
17- stats vt.DecodeStats
18- }{
19- {
20- name: "empty",
21- hex: "565448310300000000000000000000002a",
22- stats: vt.DecodeStats{},
23- },
24- {
25- name: "plain and continuation cells",
26- hex: "565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff0000000000000200",
27- stats: vt.DecodeStats{Chunks: 1, Rows: 1, Cells: 2, Styles: 2, Bytes: 82},
28- },
29- {
30- name: "all style fields",
31- hex: "565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff70708090000000101",
32- stats: vt.DecodeStats{Chunks: 1, Rows: 1, Cells: 1, Styles: 1, Bytes: 41},
33- },
34- }
35-
36- for _, vector := range vectors {
37- t.Run(vector.name, func(t *testing.T) {
38- data, err := hex.DecodeString(vector.hex)
39- if err != nil {
40- t.Fatal(err)
41- }
42- stats, err := vt.PreflightHistoryBlob(data)
43- if err != nil {
44- t.Fatalf("PreflightHistoryBlob: %v", err)
45- }
46- if stats != vector.stats {
47- t.Fatalf("decode stats = %#v, want %#v", stats, vector.stats)
48- }
49-
50- view, err := vt.UnmarshalHistory(data)
51- if err != nil {
52- t.Fatalf("UnmarshalHistory: %v", err)
53- }
54- reencoded, err := vt.MarshalHistory(view)
55- if err != nil {
56- t.Fatalf("MarshalHistory: %v", err)
57- }
58- if !bytes.Equal(reencoded, data) {
59- t.Fatalf("canonical bytes changed:\n got %x\nwant %x", reencoded, data)
60- }
61-
62- switch vector.name {
63- case "empty":
64- if view.Len() != 0 || view.ChunkCount() != 0 || view.NextRowID() != 42 {
65- t.Fatalf("empty view = len %d chunks %d next %d", view.Len(), view.ChunkCount(), view.NextRowID())
66- }
67- case "plain and continuation cells":
68- if view.Len() != 1 || view.RowID(0) != 41 || view.Bound(0) != (vt.LineBound{End: 2}) || view.NextRowID() != 42 {
69- t.Fatalf("plain view = len %d row %d bound %#v next %d", view.Len(), view.RowID(0), view.Bound(0), view.NextRowID())
70- }
71- row := view.Row(0)
72- if len(row) != 2 || row[0].Rune != 'A' || row[1].Rune != 0 || !row[1].Continuation {
73- t.Fatalf("plain row = %#v", row)
74- }
75- case "all style fields":
76- wantStyle := core.Style{
77- Bold: true,
78- Italic: true,
79- Inverse: true,
80- Attrs: core.StyleAttrs(0x000f),
81- Foreground: -7,
82- Background: 257,
83- HasForegroundRGB: true,
84- ForegroundRGB: core.RGB{R: 1, G: 2, B: 3},
85- HasBackgroundRGB: true,
86- BackgroundRGB: core.RGB{R: 4, G: 5, B: 6},
87- UnderlineStyle: core.UnderlineDashed,
88- UnderlineColor: -9,
89- HasUnderlineColorRGB: true,
90- UnderlineColorRGB: core.RGB{R: 7, G: 8, B: 9},
91- }
92- if view.Len() != 1 || view.RowID(0) != 42 || view.Bound(0) != (vt.LineBound{End: 1, Soft: true}) || view.NextRowID() != 43 {
93- t.Fatalf("styled view = len %d row %d bound %#v next %d", view.Len(), view.RowID(0), view.Bound(0), view.NextRowID())
94- }
95- row := view.Row(0)
96- if len(row) != 1 || row[0].Rune != '界' || !row[0].Style.Equal(wantStyle) {
97- t.Fatalf("styled row = %#v, want %#v", row, wantStyle)
98- }
99- }
100- })
101- }
102-}
103-
104-func TestExternalVTH3RejectsMalformedVectors(t *testing.T) {
105- valid, err := hex.DecodeString("565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff0000000000000200")
106- if err != nil {
107- t.Fatal(err)
108- }
109-
110- mutate := func(fn func([]byte)) []byte {
111- out := append([]byte(nil), valid...)
112- fn(out)
113- return out
114- }
115- const (
116- rowIDOffset = 21
117- cellOffset = 33
118- boundOffset = 115
119- )
120- cases := []struct {
121- name string
122- data []byte
123- }{
124- {name: "truncated", data: valid[:len(valid)-1]},
125- {name: "trailing bytes", data: append(append([]byte(nil), valid...), 0)},
126- {name: "bad magic", data: mutate(func(data []byte) { data[0] ^= 0xff })},
127- {name: "bad version", data: mutate(func(data []byte) { data[4] = 2 })},
128- {name: "zero next row ID", data: mutate(func(data []byte) { clear(data[9:17]) })},
129- {name: "zero row ID", data: mutate(func(data []byte) { clear(data[rowIDOffset : rowIDOffset+8]) })},
130- {name: "invalid rune", data: mutate(func(data []byte) { binary.BigEndian.PutUint32(data[cellOffset:], ^uint32(0)) })},
131- {name: "invalid underline style", data: mutate(func(data []byte) { data[cellOffset+29] = byte(core.UnderlineDashed + 1) })},
132- {name: "bound exceeds row", data: mutate(func(data []byte) { binary.BigEndian.PutUint32(data[boundOffset:], 3) })},
133- }
134-
135- for _, test := range cases {
136- t.Run(test.name, func(t *testing.T) {
137- if _, err := vt.PreflightHistoryBlob(test.data); err == nil {
138- t.Fatal("PreflightHistoryBlob accepted malformed data")
139- }
140- if _, err := vt.UnmarshalHistory(test.data); err == nil {
141- t.Fatal("UnmarshalHistory accepted malformed data")
142- }
143- })
144- }
145- for length := 0; length < len(valid); length++ {
146- if _, err := vt.UnmarshalHistory(valid[:length]); err == nil {
147- t.Fatalf("accepted truncated prefix of length %d", length)
148- }
149- }
150-}
M recovery_transcript.go +62 -78
@@ -2,123 +2,107 @@ package vt
22
33 import renderer "github.com/bnema/vev-vt/core"
44
5-// RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport
6-// rows that should be replayed after retained terminal history during recovery.
5+// RecoveryTranscriptSnapshot owns compact viewport pages to replay after
6+// retained history. No semantic row slices are retained by the snapshot.
77 type RecoveryTranscriptSnapshot struct {
88 segments []recoveryTranscriptSegment
99 nextRowID RowID
1010 }
1111
1212 type recoveryTranscriptSegment struct {
13- rows [][]renderer.Cell
13+ frame renderer.Frame
1414 bounds []LineBound
1515 rowIDs []RowID
1616 }
1717
18-// RecoveryTranscriptSnapshot captures the active primary viewport, or the
19-// saved primary viewport followed by the active alternate viewport.
18+// RecoveryTranscriptSnapshot captures the primary viewport, followed by the
19+// alternate viewport when active. Untouched trailing rows are omitted and each
20+// viewport ends with a hard line, independently of physical row rotation.
2021 func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot {
2122 if s == nil {
2223 return RecoveryTranscriptSnapshot{}
2324 }
24-
2525 buffers := []*buffer{s.buffer}
2626 if s.alternate != nil {
2727 buffers = []*buffer{s.alternate.buffer, s.buffer}
2828 }
29-
30- nextRowID := s.nextRowID
31- if nextRowID < ^RowID(0) {
32- nextRowID++
29+ nextID := s.nextRowID
30+ if nextID < ^RowID(0) {
31+ nextID++
3332 }
34- snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID}
33+ snapshot := RecoveryTranscriptSnapshot{nextRowID: nextID, segments: make([]recoveryTranscriptSegment, 0, len(buffers))}
3534 for _, b := range buffers {
36- segment := captureRecoveryTranscriptSegment(b)
37- if len(segment.rows) > 0 {
38- snapshot.segments = append(snapshot.segments, segment)
35+ if b == nil {
36+ continue
37+ }
38+ rows := b.frame.Height
39+ for rows > 0 && recoveryTranscriptFrameRowUntouched(b.frame, rows-1, b.bound(rows-1)) {
40+ rows--
41+ }
42+ if rows == 0 {
43+ continue
44+ }
45+ frame := renderer.NewFrame(b.frame.Width, rows)
46+ for y := range rows {
47+ for x := range frame.Width {
48+ frame.Set(x, y, b.frame.Cell(x, y))
49+ }
3950 }
51+ bounds := append([]LineBound(nil), b.boundaries[:rows]...)
52+ bounds[rows-1].Soft = false
53+ snapshot.segments = append(snapshot.segments, recoveryTranscriptSegment{
54+ frame: frame, bounds: bounds, rowIDs: append([]RowID(nil), b.rowIDs[:rows]...),
55+ })
4056 }
4157 return snapshot
4258 }
4359
44-func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment {
45- if b == nil {
46- return recoveryTranscriptSegment{}
47- }
48-
49- rowCount := b.frame.Height
50- for rowCount > 0 && recoveryTranscriptRowUntouched(b.frame.Row(rowCount-1), b.bound(rowCount-1)) {
51- rowCount--
52- }
53- if rowCount == 0 {
54- return recoveryTranscriptSegment{}
55- }
56-
57- segment := recoveryTranscriptSegment{
58- rows: make([][]renderer.Cell, rowCount),
59- bounds: append([]LineBound(nil), b.boundaries[:rowCount]...),
60- rowIDs: append([]RowID(nil), b.rowIDs[:rowCount]...),
61- }
62- cells := make([]renderer.Cell, rowCount*b.frame.Width)
63- for y := range rowCount {
64- start := y * b.frame.Width
65- end := start + b.frame.Width
66- segment.rows[y] = cells[start:end:end]
67- copy(segment.rows[y], b.frame.Row(y))
68- }
69- segment.bounds[rowCount-1].Soft = false
70- return segment
71-}
72-
73-func recoveryTranscriptRowUntouched(row []renderer.Cell, bound LineBound) bool {
60+func recoveryTranscriptFrameRowUntouched(frame renderer.Frame, y int, bound LineBound) bool {
7461 if bound.End != 0 || bound.Soft {
7562 return false
7663 }
7764 blank := renderer.BlankCell()
78- for _, cell := range row {
79- if !cell.Equal(blank) {
65+ for x := range frame.Width {
66+ if !frame.Cell(x, y).Equal(blank) {
8067 return false
8168 }
8269 }
8370 return true
8471 }
8572
86-// Marshal encodes the captured rows as canonical terminal history without
87-// consulting or mutating live Screen history.
73+// Marshal encodes the compact capture without consulting or mutating live state.
8874 func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
89- rows := 0
90- cells := 0
75+ view := HistoryView{nextRowID: snapshot.nextRowID}
9176 for _, segment := range snapshot.segments {
92- rows += len(segment.rows)
93- for _, row := range segment.rows {
94- cells += len(row)
95- }
96- }
97-
98- view := HistoryView{rows: rows, cells: cells, nextRowID: snapshot.nextRowID}
99- for _, segment := range snapshot.segments {
100- for start := 0; start < len(segment.rows); {
101- space := maxHistoryChunkRows
102- if len(view.chunks) > 0 {
103- last := view.chunks[len(view.chunks)-1]
104- if len(last.rows) < maxHistoryChunkRows {
105- space = maxHistoryChunkRows - len(last.rows)
106- end := min(start+space, len(segment.rows))
107- last.rows = append(last.rows, segment.rows[start:end]...)
108- last.bounds = append(last.bounds, segment.bounds[start:end]...)
109- last.rowIDs = append(last.rowIDs, segment.rowIDs[start:end]...)
110- start = end
111- continue
77+ for start := 0; start < segment.frame.Height; {
78+ count := min(segment.frame.Height-start, maxHistoryChunkRows, maxHistorySlabRows(segment.frame.Width))
79+ frame := renderer.NewFrame(segment.frame.Width, count)
80+ lastStyleRow := make(map[renderer.Style]int)
81+ for y := range count {
82+ for x := range frame.Width {
83+ cell := segment.frame.Cell(x, start+y)
84+ frame.Set(x, y, cell)
85+ style := cell.Style.Canonical()
86+ if style != renderer.DefaultStyle() {
87+ lastStyleRow[style] = y
88+ }
11289 }
11390 }
114-
115- end := min(start+space, len(segment.rows))
116- view.chunks = append(view.chunks, &HistoryChunk{
117- rows: append([][]renderer.Cell(nil), segment.rows[start:end]...),
118- bounds: append([]LineBound(nil), segment.bounds[start:end]...),
119- rowIDs: append([]RowID(nil), segment.rowIDs[start:end]...),
120- })
121- start = end
91+ drops := make([]uint64, count)
92+ for _, y := range lastStyleRow {
93+ drops[y]++
94+ }
95+ chunk := &HistoryChunk{
96+ page: newSealedPage(frame), count: count, width: frame.Width,
97+ bounds: segment.bounds[start : start+count], rowIDs: segment.rowIDs[start : start+count],
98+ styleDrops: drops, styleCount: uint64(len(lastStyleRow)) + 1,
99+ }
100+ chunk.recordPayloads()
101+ view.chunks = append(view.chunks, chunk)
102+ view.rows += count
103+ view.cells += count * frame.Width
104+ view.logicalBytes += historyChunkLogicalBytes(chunk)
105+ start += count
122106 }
123107 }
124108 return MarshalHistory(view)
M recovery_transcript_test.go +30 -30
@@ -17,7 +17,7 @@ func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) {
1717 s.Write([]byte("keep"))
1818
1919 snapshot := s.RecoveryTranscriptSnapshot()
20- s.Frame.Set(0, 0, renderer.BlankCell())
20+ s.frame.Set(0, 0, renderer.BlankCell())
2121 s.buffer.boundaries[0] = LineBound{}
2222
2323 view := decodeRecoveryTranscript(t, snapshot)
@@ -44,7 +44,7 @@ func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *test
4444 newRetainedScreen := func(height int) *Screen {
4545 screen := NewScreen(4, height)
4646 for y := range height {
47- screen.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
47+ screen.frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
4848 screen.buffer.boundaries[y] = LineBound{End: 1}
4949 }
5050 return screen
@@ -73,7 +73,7 @@ func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSea
7373 s.buffer.boundaries[0].Soft = true
7474
7575 snapshot := s.RecoveryTranscriptSnapshot()
76- s.Frame.Set(0, 0, renderer.BlankCell())
76+ s.frame.Set(0, 0, renderer.BlankCell())
7777 s.buffer.boundaries[0] = LineBound{}
7878 s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell())
7979 s.alternate.buffer.boundaries[0] = LineBound{}
@@ -147,9 +147,9 @@ func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T)
147147 for _, tt := range tests {
148148 t.Run(tt.name, func(t *testing.T) {
149149 s := NewScreen(3, 3)
150- s.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
150+ s.frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
151151 s.buffer.boundaries[0] = LineBound{End: 1}
152- s.Frame.Set(0, 1, tt.candidateCell)
152+ s.frame.Set(0, 1, tt.candidateCell)
153153 s.buffer.boundaries[1] = tt.candidateBound
154154
155155 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
@@ -165,7 +165,7 @@ func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T)
165165
166166 func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) {
167167 s := NewScreen(3, 4)
168- s.Frame.Set(0, 2, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
168+ s.frame.Set(0, 2, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
169169 s.buffer.boundaries[2] = LineBound{End: 1}
170170
171171 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
@@ -200,8 +200,8 @@ func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) {
200200
201201 func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) {
202202 s := NewScreen(1, 257)
203- for y := range s.Frame.Height {
204- s.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
203+ for y := range s.frame.Height {
204+ s.frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
205205 s.buffer.boundaries[y] = LineBound{End: 1, Soft: true}
206206 }
207207
@@ -209,8 +209,8 @@ func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T
209209
210210 require.Equal(t, 257, view.Len())
211211 require.Equal(t, 2, view.ChunkCount())
212- require.Len(t, view.Chunk(0).rows, 256)
213- require.Len(t, view.Chunk(1).rows, 1)
212+ require.Equal(t, 256, view.Chunk(0).len())
213+ require.Equal(t, 1, view.Chunk(1).len())
214214 require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam")
215215 require.False(t, view.Bound(256).Soft, "the transcript's final seam must be hard")
216216 }
@@ -218,11 +218,11 @@ func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T
218218 func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) {
219219 s := NewScreen(4, 2)
220220 s.Write([]byte("界"))
221- want := append([]renderer.Cell(nil), s.Frame.Row(0)...)
221+ want := append([]renderer.Cell(nil), s.frame.Row(0)...)
222222
223223 snapshot := s.RecoveryTranscriptSnapshot()
224- for x := range s.Frame.Width {
225- s.Frame.Set(x, 0, renderer.BlankCell())
224+ for x := range s.frame.Width {
225+ s.frame.Set(x, 0, renderer.BlankCell())
226226 }
227227
228228 view := decodeRecoveryTranscript(t, snapshot)
@@ -238,16 +238,16 @@ func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBl
238238 []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}},
239239 )
240240
241- screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxCells: 128, ChunkRows: 2}, sealed, tail, transcript)
241+ screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxBytes: 1 << 20, ChunkRows: 2}, sealed, tail, transcript)
242242 require.NoError(t, err)
243243
244244 view := screen.History().View()
245245 require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view))
246246 require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3))
247247 require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam")
248- for y := range screen.Frame.Height {
249- for x := range screen.Frame.Width {
250- require.True(t, screen.Frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y)
248+ for y := range screen.frame.Height {
249+ for x := range screen.frame.Width {
250+ require.True(t, screen.frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y)
251251 }
252252 }
253253 require.Equal(t, []LineBound{{}, {}}, screen.LineBounds())
@@ -263,14 +263,14 @@ func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing.
263263 }{
264264 {
265265 name: "row budget",
266- config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2},
266+ config: HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 2},
267267 history: []string{"old-1", "old-2", "old-3"},
268268 transcript: []string{"new-1", "new-2"},
269269 want: []string{"old-3", "new-1", "new-2"},
270270 },
271271 {
272- name: "cell budget",
273- config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4},
272+ name: "byte budget",
273+ config: HistoryConfig{MaxRows: 10, MaxBytes: 3*renderer.StoredCellLogicalBytes + 2*renderer.RowDescriptorLogicalBytes + 2*renderer.StyleRecordLogicalBytes, ChunkRows: 4},
274274 history: []string{"aa", "b", "ccc"},
275275 transcript: []string{"d", "ee"},
276276 want: []string{"d", "ee"},
@@ -293,9 +293,9 @@ func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing
293293 sealed, tail := recoveryHistoryBlobs(t, []string{"ok"})
294294 transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil)
295295
296- screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxCells: 3}, sealed, tail, transcript)
296+ screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxBytes: 83}, sealed, tail, transcript)
297297
298- require.ErrorIs(t, err, ErrHistoryRowTooWide)
298+ require.ErrorIs(t, err, ErrHistoryRowTooLarge)
299299 require.Nil(t, screen)
300300 }
301301
@@ -331,11 +331,11 @@ func BenchmarkRecoveryTranscriptSnapshot(b *testing.B) {
331331 }
332332
333333 func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) {
334- for y := range screen.Frame.Height {
335- for x := range screen.Frame.Width {
336- screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()})
334+ for y := range screen.frame.Height {
335+ for x := range screen.frame.Width {
336+ screen.frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()})
337337 }
338- screen.buffer.boundaries[y] = LineBound{End: screen.Frame.Width}
338+ screen.buffer.boundaries[y] = LineBound{End: screen.frame.Width}
339339 }
340340 }
341341
@@ -343,14 +343,14 @@ func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) {
343343 const rows = maxHistoryRows
344344 source := NewScreen(1, rows)
345345 for y := range rows {
346- source.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
346+ source.frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
347347 source.buffer.boundaries[y] = LineBound{End: 1}
348348 }
349349 transcript, err := source.RecoveryTranscriptSnapshot().Marshal()
350350 require.NoError(b, err)
351351 tail, err := MarshalEmptyHistoryTail()
352352 require.NoError(b, err)
353- config := HistoryConfig{MaxRows: rows, MaxCells: rows, ChunkRows: maxHistoryChunkRows}
353+ config := HistoryConfig{MaxRows: rows, MaxBytes: 1 << 30, ChunkRows: maxHistoryChunkRows}
354354
355355 b.ReportAllocs()
356356 b.ReportMetric(float64(rows), "rows/restore")
@@ -366,7 +366,7 @@ func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) {
366366
367367 func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) {
368368 t.Helper()
369- history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2})
369+ history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxBytes: 1 << 20, ChunkRows: 2})
370370 for _, text := range texts {
371371 require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)}))
372372 }
@@ -402,7 +402,7 @@ func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []
402402 }
403403 view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))}
404404 if len(rows) > 0 {
405- view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds, rowIDs: rowIDs}}
405+ view.chunks = newHistoryChunks(rows, bounds, rowIDs)
406406 }
407407 blob, err := MarshalHistory(view)
408408 require.NoError(t, err)
M region_reflow_test.go +3 -3
@@ -42,9 +42,9 @@ func TestResizeSeversSoftWrapsAtRegionalScrollEdges(t *testing.T) {
4242
4343 s.Resize(3, 6)
4444
45- got := make([]string, s.Frame.Height)
46- for y := range s.Frame.Height {
47- got[y] = rowString(s.Frame.Row(y))
45+ got := make([]string, s.frame.Height)
46+ for y := range s.frame.Height {
47+ got[y] = rowString(s.frame.Row(y))
4848 }
4949 require.Equal(t, tt.want, got)
5050 })
M resize_reflow_regression_test.go +4 -4
@@ -13,12 +13,12 @@ func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) {
1313 if len(s.escapeBuf) != 0 {
1414 t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf)
1515 }
16- if s.Frame.Width != 0 || s.Frame.Height != 0 {
17- t.Fatalf("active frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height)
16+ if s.frame.Width != 0 || s.frame.Height != 0 {
17+ t.Fatalf("active frame = %dx%d, want 0x0", s.frame.Width, s.frame.Height)
1818 }
1919 s.Write([]byte("\x1b[?1049l"))
20- if s.Frame.Width != 0 || s.Frame.Height != 0 {
21- t.Fatalf("saved primary frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height)
20+ if s.frame.Width != 0 || s.frame.Height != 0 {
21+ t.Fatalf("saved primary frame = %dx%d, want 0x0", s.frame.Width, s.frame.Height)
2222 }
2323 }
2424
M row_id_persistence_test.go +10 -9
@@ -15,16 +15,17 @@ func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) {
1515 encoded, err := MarshalHistory(history.SealAndView())
1616 require.NoError(t, err)
1717
18- firstIDOffset := 17 + 4
19- secondIDOffset := firstIDOffset + 8 + 4 + historyCellBytes + historyBoundBytes
18+ styles := int(binary.BigEndian.Uint32(encoded[24:28]))
19+ firstIDOffset := historyHeaderBytes + historyChunkHeaderBytes + (styles-1)*historyStyleBytes
20+ secondIDOffset := firstIDOffset + historyRowBytes
2021 for _, test := range []struct {
2122 name string
2223 mutate func([]byte)
2324 }{
2425 {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }},
2526 {name: "duplicate row ID", mutate: func(data []byte) { copy(data[secondIDOffset:secondIDOffset+8], data[firstIDOffset:firstIDOffset+8]) }},
26- {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], 2) }},
27- {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], ^uint64(0)) }},
27+ {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[8:16], 2) }},
28+ {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[8:16], ^uint64(0)) }},
2829 } {
2930 t.Run(test.name, func(t *testing.T) {
3031 data := append([]byte(nil), encoded...)
@@ -135,7 +136,7 @@ func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) {
135136 allocate()
136137 row := historyRow("x")
137138 screen.buffer.rowIDs[0] = test.want
138- screen.buffer.frame.Row(0)[0] = row[0]
139+ screen.buffer.frame.Set(0, 0, row[0])
139140 screen.buffer.boundaries[0] = LineBound{End: 1}
140141 transcript, err := screen.RecoveryTranscriptSnapshot().Marshal()
141142 require.NoError(t, err)
@@ -154,7 +155,7 @@ func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) {
154155
155156 func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) {
156157 _, err := MarshalHistory(HistoryView{
157- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}},
158+ chunks: []*HistoryChunk{testHistoryChunk([][]renderer.Cell{historyRow("row")}, []LineBound{{End: 3}}, nil)},
158159 rows: 1,
159160 })
160161 require.Error(t, err)
@@ -166,7 +167,7 @@ func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) {
166167 sealed, tail, err := MarshalSealedHistory(history.SealAndView())
167168 require.NoError(t, err)
168169 transcript, err := MarshalHistory(HistoryView{
169- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{50}}},
170+ chunks: []*HistoryChunk{testHistoryChunk([][]renderer.Cell{historyRow("live")}, []LineBound{{End: 4}}, []RowID{50})},
170171 rows: 1,
171172 nextRowID: 51,
172173 })
@@ -186,7 +187,7 @@ func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) {
186187
187188 func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) {
188189 sealedView := HistoryView{
189- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("old")}, bounds: []LineBound{{End: 3}}, rowIDs: []RowID{7}}},
190+ chunks: []*HistoryChunk{testHistoryChunk([][]renderer.Cell{historyRow("old")}, []LineBound{{End: 3}}, []RowID{7})},
190191 rows: 1,
191192 nextRowID: 8,
192193 }
@@ -195,7 +196,7 @@ func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *t
195196 tail, err := MarshalEmptyHistoryTail()
196197 require.NoError(t, err)
197198 transcript, err := MarshalHistory(HistoryView{
198- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{7}}},
199+ chunks: []*HistoryChunk{testHistoryChunk([][]renderer.Cell{historyRow("live")}, []LineBound{{End: 4}}, []RowID{7})},
199200 rows: 1,
200201 nextRowID: 8,
201202 })
M row_id_test.go +3 -3
@@ -98,9 +98,9 @@ func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) {
9898
9999 func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) {
100100 b := newBuffer(8, 3)
101- copy(b.frame.Row(0), historyRow("abcdefgh"))
102- copy(b.frame.Row(1), historyRow("ijkl"))
103- copy(b.frame.Row(2), historyRow("mnop"))
101+ b.frame.WriteRow(0, 0, historyRow("abcdefgh"))
102+ b.frame.WriteRow(1, 0, historyRow("ijkl"))
103+ b.frame.WriteRow(2, 0, historyRow("mnop"))
104104 b.boundaries[0] = LineBound{End: 8, Soft: true}
105105 b.boundaries[1] = LineBound{End: 4}
106106 b.boundaries[2] = LineBound{End: 4}
M screen.go +38 -8
@@ -26,7 +26,7 @@ const (
2626 )
2727
2828 type Screen struct {
29- Frame renderer.Frame
29+ frame renderer.Frame
3030 Row int
3131 Col int
3232 Style renderer.Style
@@ -107,7 +107,7 @@ func NewScreen(width, height int) *Screen {
107107 geometry: Geometry{Cols: width, Rows: height},
108108 }
109109 s.buffer = s.newBuffer(width, height)
110- s.Frame = s.buffer.frame
110+ s.frame = s.buffer.frame
111111 s.resetScrollRegion()
112112 return s
113113 }
@@ -124,6 +124,36 @@ func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
124124 // configured with NewScreenWithHistory.
125125 func (s *Screen) History() *History { return s.history }
126126
127+// Columns returns the visible grid width.
128+func (s *Screen) Columns() int {
129+ if s == nil || s.buffer == nil {
130+ return 0
131+ }
132+ return s.buffer.frame.Width
133+}
134+
135+// Rows returns the visible grid height.
136+func (s *Screen) Rows() int {
137+ if s == nil || s.buffer == nil {
138+ return 0
139+ }
140+ return s.buffer.frame.Height
141+}
142+
143+// Cell returns the semantic cell at x, y. Coordinates must be inside the
144+// visible grid.
145+func (s *Screen) Cell(x, y int) renderer.Cell {
146+ return s.buffer.frame.At(x, y)
147+}
148+
149+// RowCells returns an owned copy of visible row y, or nil when y is out of range.
150+func (s *Screen) RowCells(y int) []renderer.Cell {
151+ if s == nil || s.buffer == nil || y < 0 || y >= s.buffer.frame.Height {
152+ return nil
153+ }
154+ return s.buffer.frame.Row(y)
155+}
156+
127157 // LineBounds returns an owned copy of the live grid's per-row logical extents,
128158 // indexed like Frame rows. It returns nil when the screen has no buffer.
129159 func (s *Screen) LineBounds() []LineBound {
@@ -440,10 +470,10 @@ func (s *Screen) kittyCursorX() uint64 {
440470 }
441471
442472 func (s *Screen) kittyCursorColumn() int {
443- if s.Frame.Width <= 0 {
473+ if s.frame.Width <= 0 {
444474 return 0
445475 }
446- return clamp(s.Col, 0, s.Frame.Width-1)
476+ return clamp(s.Col, 0, s.frame.Width-1)
447477 }
448478
449479 func (s *Screen) kittyCursorY() uint64 {
@@ -467,12 +497,12 @@ func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
467497 }
468498
469499 func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) {
470- if c.HasCursor && c.Cursor == 1 || s.Frame.Width <= 0 || s.Frame.Height <= 0 {
500+ if c.HasCursor && c.Cursor == 1 || s.frame.Width <= 0 || s.frame.Height <= 0 {
471501 return
472502 }
473503 columns, rows := s.kittyPlacementGridSize(c, mutation)
474504 targetColumn := s.kittyCursorColumn() + columns
475- wraps := targetColumn >= s.Frame.Width
505+ wraps := targetColumn >= s.frame.Width
476506 rowMoves := max(rows-1, 0)
477507 if wraps {
478508 rowMoves++
@@ -486,7 +516,7 @@ func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kit
486516 }
487517
488518 func (s *Screen) advanceKittyCursorRows(rows int) {
489- if rows <= 0 || s.Frame.Height <= 0 {
519+ if rows <= 0 || s.frame.Height <= 0 {
490520 return
491521 }
492522 if s.Row >= s.scrollTop && s.Row <= s.scrollBottom {
@@ -499,7 +529,7 @@ func (s *Screen) advanceKittyCursorRows(rows int) {
499529 s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll)
500530 return
501531 }
502- s.Row = min(s.Row+rows, s.Frame.Height-1)
532+ s.Row = min(s.Row+rows, s.frame.Height-1)
503533 }
504534
505535 func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {
M screen_csi.go +29 -39
@@ -122,9 +122,9 @@ func (s *Screen) applyCSI(params string, cmd byte) {
122122 case 'B':
123123 s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
124124 case 'C':
125- s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)
125+ s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.frame.Width-1)
126126 case 'D':
127- s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)
127+ s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.frame.Width-1)
128128 case 'E':
129129 s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
130130 s.Col = 0
@@ -133,7 +133,7 @@ func (s *Screen) applyCSI(params string, cmd byte) {
133133 s.Col = 0
134134 case 'G':
135135 col := firstPositive(parts, 1)
136- s.Col = clamp(col-1, 0, s.Frame.Width-1)
136+ s.Col = clamp(col-1, 0, s.frame.Width-1)
137137 case 'd':
138138 row := firstPositive(parts, 1)
139139 s.Row = s.addressedRow(row)
@@ -335,23 +335,18 @@ func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
335335 }
336336
337337 func (s *Screen) insertChars(n int) {
338- if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
338+ if s.Row < 0 || s.Row >= s.frame.Height || s.Col >= s.frame.Width || n <= 0 {
339339 return
340340 }
341- w := s.Frame.Width
341+ w := s.frame.Width
342342 if n > w-s.Col {
343343 n = w - s.Col
344344 }
345- row := s.Frame.Row(s.Row)
346345 // A wide left half at Col-1 whose continuation sits at Col will be orphaned
347346 // by the shift; its repair falls outside the default damage rect.
348- leftSplit := s.Col > 0 && row[s.Col].Continuation
349- for x := w - 1; x >= s.Col+n; x-- {
350- row[x] = row[x-n]
351- }
352- for x := s.Col; x < s.Col+n; x++ {
353- row[x] = renderer.BlankCell()
354- }
347+ leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation
348+ s.frame.CopyRow(s.Row, s.Col+n, s.Col, w-s.Col-n)
349+ s.frame.FillRow(s.Row, s.Col, s.Col+n, renderer.BlankCell())
355350 s.repairRow(s.Row)
356351 dmgX := s.Col
357352 if leftSplit {
@@ -361,23 +356,18 @@ func (s *Screen) insertChars(n int) {
361356 }
362357
363358 func (s *Screen) deleteChars(n int) {
364- if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
359+ if s.Row < 0 || s.Row >= s.frame.Height || s.Col >= s.frame.Width || n <= 0 {
365360 return
366361 }
367- w := s.Frame.Width
362+ w := s.frame.Width
368363 if n > w-s.Col {
369364 n = w - s.Col
370365 }
371- row := s.Frame.Row(s.Row)
372366 // A wide left half at Col-1 whose continuation sits at Col will be orphaned
373367 // by the shift; its repair falls outside the default damage rect.
374- leftSplit := s.Col > 0 && row[s.Col].Continuation
375- for x := s.Col; x < w-n; x++ {
376- row[x] = row[x+n]
377- }
378- for x := w - n; x < w; x++ {
379- row[x] = renderer.BlankCell()
380- }
368+ leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation
369+ s.frame.CopyRow(s.Row, s.Col, s.Col+n, w-s.Col-n)
370+ s.frame.FillRow(s.Row, w-n, w, renderer.BlankCell())
381371 s.repairRow(s.Row)
382372 dmgX := s.Col
383373 if leftSplit {
@@ -404,34 +394,34 @@ func (s *Screen) clearScreenMode(mode int) {
404394 s.clampCursor()
405395 switch mode {
406396 case 1:
407- for y := range min(s.Row+1, s.Frame.Height) {
408- end := s.Frame.Width
397+ for y := range min(s.Row+1, s.frame.Height) {
398+ end := s.frame.Width
409399 if y == s.Row {
410- end = min(s.Col+1, s.Frame.Width)
400+ end = min(s.Col+1, s.frame.Width)
411401 }
412402 s.clearRow(y, 0, end)
413403 }
414- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: min(s.Row+1, s.Frame.Height), Count: 1})
404+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.frame.Width, Height: min(s.Row+1, s.frame.Height), Count: 1})
415405 case 2, 3:
416406 blank := s.eraseCell()
417- for i := range s.Frame.Cells {
418- s.Frame.Cells[i] = blank
407+ for y := range s.frame.Height {
408+ s.frame.FillRow(y, 0, s.frame.Width, blank)
419409 }
420410 for y := range s.buffer.boundaries {
421411 s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}
422412 s.buffer.rowIDs[y] = s.nextRowIDValue()
423413 }
424414 s.graphicsClearPlacements()
425- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
415+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.frame.Width, Height: s.frame.Height, Count: 1})
426416 default:
427- for y := s.Row; y < s.Frame.Height; y++ {
417+ for y := s.Row; y < s.frame.Height; y++ {
428418 start := 0
429419 if y == s.Row {
430420 start = s.Col
431421 }
432- s.clearRow(y, start, s.Frame.Width)
422+ s.clearRow(y, start, s.frame.Width)
433423 }
434- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1})
424+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.frame.Width, Height: s.frame.Height - s.Row, Count: 1})
435425 }
436426 }
437427
@@ -439,13 +429,13 @@ func (s *Screen) clearLineMode(mode int) {
439429 s.clampCursor()
440430 switch mode {
441431 case 1:
442- start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.Frame.Width))
432+ start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.frame.Width))
443433 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
444434 case 2:
445- start, width := s.clearRow(s.Row, 0, s.Frame.Width)
435+ start, width := s.clearRow(s.Row, 0, s.frame.Width)
446436 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
447437 default:
448- start, width := s.clearRow(s.Row, s.Col, s.Frame.Width)
438+ start, width := s.clearRow(s.Row, s.Col, s.frame.Width)
449439 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
450440 }
451441 }
@@ -455,7 +445,7 @@ func (s *Screen) eraseChars(n int) {
455445 return
456446 }
457447 s.clampCursor()
458- if remaining := s.Frame.Width - s.Col; n > remaining {
448+ if remaining := s.frame.Width - s.Col; n > remaining {
459449 n = remaining
460450 }
461451 end := s.Col + n
@@ -466,8 +456,8 @@ func (s *Screen) eraseChars(n int) {
466456 }
467457
468458 func (s *Screen) clampCursor() {
469- s.Row = clamp(s.Row, 0, s.Frame.Height-1)
470- s.Col = clamp(s.Col, 0, s.Frame.Width-1)
459+ s.Row = clamp(s.Row, 0, s.frame.Height-1)
460+ s.Col = clamp(s.Col, 0, s.frame.Width-1)
471461 }
472462
473463 func (s *Screen) parseCSIInts(params string) []int {
M screen_csi_test.go +1 -1
@@ -731,7 +731,7 @@ func TestBackgroundColorErase(t *testing.T) {
731731 for y := range tt.height {
732732 for x := tt.startX; x < tt.width; x++ {
733733 cell := cellAt(s, x, y)
734- if cell.Rune != ' ' || cell.Style != tt.wantStyle {
734+ if cell.Rune != ' ' || !cell.Style.Equal(tt.wantStyle) {
735735 t.Errorf("cell(%d,%d) = %+v, want space with style %+v", x, y, cell, tt.wantStyle)
736736 }
737737 }
M screen_cursor.go +7 -7
@@ -25,28 +25,28 @@ func (s *Screen) cursorReportRow() int {
2525
2626 func (s *Screen) cursorMinRow() int {
2727 if s.originMode {
28- return clamp(s.scrollTop, 0, s.Frame.Height-1)
28+ return clamp(s.scrollTop, 0, s.frame.Height-1)
2929 }
3030 return 0
3131 }
3232
3333 func (s *Screen) cursorMaxRow() int {
3434 if s.originMode {
35- return clamp(s.scrollBottom, 0, s.Frame.Height-1)
35+ return clamp(s.scrollBottom, 0, s.frame.Height-1)
3636 }
37- return max(s.Frame.Height-1, 0)
37+ return max(s.frame.Height-1, 0)
3838 }
3939
4040 func (s *Screen) addressedRow(row int) int {
4141 if s.originMode {
4242 return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
4343 }
44- return clamp(row-1, 0, s.Frame.Height-1)
44+ return clamp(row-1, 0, s.frame.Height-1)
4545 }
4646
4747 func (s *Screen) addressCursor(row, col int) {
4848 s.Row = s.addressedRow(row)
49- s.Col = clamp(col-1, 0, s.Frame.Width-1)
49+ s.Col = clamp(col-1, 0, s.frame.Width-1)
5050 }
5151
5252 func (s *Screen) homeCursor() {
@@ -81,8 +81,8 @@ func (s *Screen) restoreCursor() {
8181 if !s.savedCursor.saved {
8282 return
8383 }
84- s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1)
85- s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1)
84+ s.Row = clamp(s.savedCursor.row, 0, s.frame.Height-1)
85+ s.Col = clamp(s.savedCursor.col, 0, s.frame.Width-1)
8686 s.Style = s.savedCursor.style
8787 s.originMode = s.savedCursor.originMode
8888 s.insertMode = s.savedCursor.insertMode
M screen_helpers_test.go +15 -7
@@ -25,7 +25,15 @@ func hasDamageKind(d []renderer.Damage, kind renderer.DamageKind) bool {
2525 }
2626
2727 func cellAt(s *Screen, x, y int) renderer.Cell {
28- return s.Frame.At(x, y)
28+ return s.frame.At(x, y)
29+}
30+
31+func testHistoryChunk(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID) *HistoryChunk {
32+ chunks := newHistoryChunks(rows, bounds, rowIDs)
33+ if len(chunks) != 1 {
34+ panic("test history chunk rows must have one width")
35+ }
36+ return chunks[0]
2937 }
3038
3139 func assertFramesEqual(t *testing.T, got, want renderer.Frame) {
@@ -70,8 +78,8 @@ func assertBlank(t *testing.T, s *Screen, x, y int) {
7078
7179 func assertNoOrphanWideCells(t *testing.T, s *Screen) {
7280 t.Helper()
73- for y := range s.Frame.Height {
74- for x := range s.Frame.Width {
81+ for y := range s.frame.Height {
82+ for x := range s.frame.Width {
7583 c := cellAt(s, x, y)
7684 if c.Continuation {
7785 if x == 0 || cellAt(s, x-1, y).Continuation || renderer.RuneWidth(cellAt(s, x-1, y).Rune) != 2 {
@@ -79,7 +87,7 @@ func assertNoOrphanWideCells(t *testing.T, s *Screen) {
7987 }
8088 continue
8189 }
82- if renderer.RuneWidth(c.Rune) == 2 && (x+1 >= s.Frame.Width || !cellAt(s, x+1, y).Continuation) {
90+ if renderer.RuneWidth(c.Rune) == 2 && (x+1 >= s.frame.Width || !cellAt(s, x+1, y).Continuation) {
8391 t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune)
8492 }
8593 }
@@ -87,9 +95,9 @@ func assertNoOrphanWideCells(t *testing.T, s *Screen) {
8795 }
8896
8997 func lineText(s *Screen, y int) string {
90- out := make([]rune, s.Frame.Width)
91- for x := range s.Frame.Width {
92- out[x] = s.Frame.At(x, y).Rune
98+ out := make([]rune, s.frame.Width)
99+ for x := range s.frame.Width {
100+ out[x] = s.frame.At(x, y).Rune
93101 }
94102 return string(out)
95103 }
M screen_integration_test.go +9 -9
@@ -11,13 +11,13 @@ func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) {
1111 r := renderer.New(renderer.Capabilities{})
1212
1313 s.Write([]byte("❯ "))
14- if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
14+ if _, err := r.Draw(s.frame, s.Damage()); err != nil {
1515 t.Fatal(err)
1616 }
1717 s.ClearDamage()
1818
1919 s.Write([]byte("h"))
20- if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
20+ if _, err := r.Draw(s.frame, s.Damage()); err != nil {
2121 t.Fatal(err)
2222 }
2323 s.ClearDamage()
@@ -25,16 +25,16 @@ func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) {
2525 s.Write([]byte("\x1b[31mh\x1b[39m"))
2626 s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m"))
2727
28- for y := range s.Frame.Height {
29- for x := range s.Frame.Width {
30- r := s.Frame.At(x, y).Rune
28+ for y := range s.frame.Height {
29+ for x := range s.frame.Width {
30+ r := s.frame.At(x, y).Rune
3131 if r == '\r' || r == '\n' {
3232 t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r)
3333 }
3434 }
3535 }
3636
37- out, err := r.Draw(s.Frame, s.Damage())
37+ out, err := r.Draw(s.frame, s.Damage())
3838 if err != nil {
3939 t.Fatal(err)
4040 }
@@ -56,18 +56,18 @@ func TestIntegrationScenarios(t *testing.T) {
5656 s := NewScreen(8, 2)
5757 r := renderer.New(renderer.Capabilities{})
5858 s.Write([]byte("abcdef"))
59- if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
59+ if _, err := r.Draw(s.frame, s.Damage()); err != nil {
6060 t.Fatal(err)
6161 }
6262 s.ClearDamage()
6363
6464 s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
65- if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
65+ if _, err := r.Draw(s.frame, s.Damage()); err != nil {
6666 t.Fatal(err)
6767 }
6868 s.ClearDamage()
6969
70- out, err := r.Draw(s.Frame, nil)
70+ out, err := r.Draw(s.frame, nil)
7171 if err != nil {
7272 t.Fatal(err)
7373 }
M screen_modes.go +13 -19
@@ -6,7 +6,6 @@ import (
66 )
77
88 type screenState struct {
9- frame renderer.Frame
109 buffer *buffer
1110 row int
1211 col int
@@ -92,8 +91,8 @@ func (s *Screen) colorSchemeReport() []byte {
9291 }
9392
9493 func (s *Screen) reset() {
95- s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)
96- s.Frame = s.buffer.frame
94+ s.buffer = s.newBuffer(s.frame.Width, s.frame.Height)
95+ s.frame = s.buffer.frame
9796 s.Row, s.Col = 0, 0
9897 s.Style = renderer.DefaultStyle()
9998 s.escapeBuf = s.escapeBuf[:0]
@@ -117,10 +116,10 @@ func (s *Screen) reset() {
117116 }
118117
119118 func (s *Screen) setScrollRegion(parts []int) {
120- if s.Frame.Height == 0 {
119+ if s.frame.Height == 0 {
121120 return
122121 }
123- top, bottom := 1, s.Frame.Height
122+ top, bottom := 1, s.frame.Height
124123 if len(parts) > 0 && parts[0] > 0 {
125124 top = parts[0]
126125 }
@@ -130,8 +129,8 @@ func (s *Screen) setScrollRegion(parts []int) {
130129 if top >= bottom {
131130 s.resetScrollRegion()
132131 } else {
133- s.scrollTop = clamp(top-1, 0, s.Frame.Height-1)
134- s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1)
132+ s.scrollTop = clamp(top-1, 0, s.frame.Height-1)
133+ s.scrollBottom = clamp(bottom-1, 0, s.frame.Height-1)
135134 if s.scrollTop >= s.scrollBottom {
136135 s.resetScrollRegion()
137136 }
@@ -141,8 +140,8 @@ func (s *Screen) setScrollRegion(parts []int) {
141140
142141 func (s *Screen) resetScrollRegion() {
143142 s.scrollTop = 0
144- if s.Frame.Height > 0 {
145- s.scrollBottom = s.Frame.Height - 1
143+ if s.frame.Height > 0 {
144+ s.scrollBottom = s.frame.Height - 1
146145 } else {
147146 s.scrollBottom = 0
148147 }
@@ -200,7 +199,6 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
200199 func (s *Screen) enterAlternateScreen() {
201200 if s.alternate == nil {
202201 s.alternate = &screenState{
203- frame: cloneFrame(s.Frame),
204202 buffer: s.buffer.clone(),
205203 row: s.Row,
206204 col: s.Col,
@@ -216,8 +214,8 @@ func (s *Screen) enterAlternateScreen() {
216214 }
217215 s.graphics = nil
218216 s.kittyPendingDisplay = nil
219- s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)
220- s.Frame = s.buffer.frame
217+ s.buffer = s.newBuffer(s.frame.Width, s.frame.Height)
218+ s.frame = s.buffer.frame
221219 s.Row, s.Col = 0, 0
222220 s.Style = renderer.DefaultStyle()
223221 s.savedCursor = cursorState{}
@@ -231,13 +229,9 @@ func (s *Screen) exitAlternateScreen() {
231229 }
232230 state := s.alternate
233231 s.buffer = state.buffer
234- if s.buffer == nil {
235- s.buffer = bufferFromFrame(cloneFrame(state.frame))
236- s.fillMissingRowIDs(s.buffer)
237- }
238- s.Frame = s.buffer.frame
239- s.Row = clamp(state.row, 0, s.Frame.Height-1)
240- s.Col = clamp(state.col, 0, s.Frame.Width-1)
232+ s.frame = s.buffer.frame
233+ s.Row = clamp(state.row, 0, s.frame.Height-1)
234+ s.Col = clamp(state.col, 0, s.frame.Width-1)
241235 s.Style = state.style
242236 s.scrollTop = state.scrollTop
243237 s.scrollBottom = state.scrollBottom
M screen_osc_test.go +3 -3
@@ -13,7 +13,7 @@ func TestOnBellFiresForLoneBEL(t *testing.T) {
1313 s.OnBell = func() { rang++ }
1414 s.Write([]byte("hi\ahi\a"))
1515 require.Equal(t, 2, rang)
16- require.Equal(t, "hihi", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
16+ require.Equal(t, "hihi", strings.TrimRight(rowString(s.frame.Row(0)), " "))
1717 }
1818
1919 func TestOnBellIgnoresOSCTerminator(t *testing.T) {
@@ -27,7 +27,7 @@ func TestOnBellIgnoresOSCTerminator(t *testing.T) {
2727 func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
2828 s := NewScreen(10, 2)
2929 s.Write([]byte("a\ab")) // no OnBell set: must not panic
30- require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
30+ require.Equal(t, "ab", strings.TrimRight(rowString(s.frame.Row(0)), " "))
3131 }
3232
3333 func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
@@ -54,7 +54,7 @@ func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
5454 require.Equal(t, tt.want, s.TerminalTitle())
5555 require.Equal(t, 0, bells)
5656 require.Empty(t, s.Damage())
57- require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0)))
57+ require.Equal(t, strings.Repeat(" ", s.frame.Width), rowString(s.frame.Row(0)))
5858 })
5959 }
6060 }
M screen_resize.go +2 -21
@@ -1,9 +1,7 @@
11 package vt
22
3-import renderer "github.com/bnema/vev-vt/core"
4-
53 func (s *Screen) Resize(width, height int) {
6- if width == s.Frame.Width && height == s.Frame.Height {
4+ if width == s.frame.Width && height == s.frame.Height {
75 s.geometry.Cols, s.geometry.Rows = width, height
86 return
97 }
@@ -15,7 +13,6 @@ func (s *Screen) Resize(width, height int) {
1513 s.Row, s.Col = 0, 0
1614 if state := s.alternate; state != nil {
1715 state.buffer = s.newBuffer(width, height)
18- state.frame = state.buffer.frame
1916 state.row, state.col = 0, 0
2017 state.scrollTop, state.scrollBottom = 0, height-1
2118 }
@@ -49,18 +46,13 @@ func (s *Screen) Resize(width, height int) {
4946 // cursors for each state are mapped by their single buffer layout pass.
5047 s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false)
5148 state := s.alternate
52- if state.buffer == nil {
53- state.buffer = bufferFromFrame(state.frame)
54- s.fillMissingRowIDs(state.buffer)
55- }
5649 state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
57- state.frame = state.buffer.frame
5850 state.scrollTop, state.scrollBottom = 0, height-1
5951 } else {
6052 s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true)
6153 }
6254 }
63- s.Frame = s.buffer.frame
55+ s.frame = s.buffer.frame
6456 s.geometry.Cols, s.geometry.Rows = width, height
6557 // A resize can split an in-flight escape sequence from the terminal state it
6658 // was meant to mutate; keep the durable child state but discard partial bytes.
@@ -72,17 +64,6 @@ func (s *Screen) Resize(width, height int) {
7264 s.fullRedraw()
7365 }
7466
75-// cloneFrame produces an independent copy in canonical layout: it copies the
76-// source's logical rows (via Row) into a fresh frame whose offsets are already
77-// canonical, so a rotated source is normalized in the clone.
78-func cloneFrame(frame renderer.Frame) renderer.Frame {
79- out := renderer.NewFrame(frame.Width, frame.Height)
80- for y := range frame.Height {
81- copy(out.Row(y), frame.Row(y))
82- }
83- return out
84-}
85-
8667 func clamp(v, lo, hi int) int {
8768 if hi < lo {
8869 return lo
M screen_resize_test.go +6 -6
@@ -11,16 +11,16 @@ import (
1111 func TestResize(t *testing.T) {
1212 setRow := func(s *Screen, y int, text string) {
1313 for x, r := range []rune(text) {
14- if x >= s.Frame.Width {
14+ if x >= s.frame.Width {
1515 break
1616 }
17- s.Frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
17+ s.frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
1818 }
1919 }
2020 rowString := func(s *Screen, y int) string {
21- runes := make([]rune, s.Frame.Width)
22- for x := range s.Frame.Width {
23- runes[x] = s.Frame.At(x, y).Rune
21+ runes := make([]rune, s.frame.Width)
22+ for x := range s.frame.Width {
23+ runes[x] = s.frame.At(x, y).Rune
2424 }
2525 return string(runes)
2626 }
@@ -216,7 +216,7 @@ func TestResize(t *testing.T) {
216216 for range 200 {
217217 s := NewScreen(8, 4)
218218 s.Write([]byte("a界b好c語d"))
219- s.Row = rng.Intn(s.Frame.Height)
219+ s.Row = rng.Intn(s.frame.Height)
220220
221221 s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
222222
M screen_scroll.go +17 -17
@@ -16,17 +16,17 @@ import (
1616 // buffer.scrollUp severs that link on the way. Callers that merely advance,
1717 // such as LF and ESC D, ignore the result.
1818 func (s *Screen) index() (movedUp bool) {
19- if s.Frame.Height == 0 {
19+ if s.frame.Height == 0 {
2020 return false
2121 }
2222 if s.Row == s.scrollBottom && s.Row >= s.scrollTop {
2323 return s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)
2424 }
25- if s.Row+1 < s.Frame.Height {
25+ if s.Row+1 < s.frame.Height {
2626 s.Row++
2727 return false
2828 }
29- s.Row = s.Frame.Height - 1
29+ s.Row = s.frame.Height - 1
3030 return false
3131 }
3232
@@ -37,7 +37,7 @@ func (s *Screen) nextLine() {
3737 }
3838
3939 func (s *Screen) reverseIndex() {
40- if s.Frame.Height == 0 {
40+ if s.frame.Height == 0 {
4141 return
4242 }
4343 if s.Row == s.scrollTop && s.Row <= s.scrollBottom {
@@ -71,14 +71,14 @@ func (s *Screen) scrollDownBy(n int) {
7171 // what the scroll observed keeps callers from re-deriving a predicate that this
7272 // function may have declined to act on at all.
7373 func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {
74- if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
74+ if s.frame.Width == 0 || s.frame.Height == 0 || n <= 0 {
7575 return false
7676 }
7777 top, bottom, ok := s.normalizedRegion(top, bottom)
7878 if !ok {
7979 return false
8080 }
81- w := s.Frame.Width
81+ w := s.frame.Width
8282 height := bottom - top + 1
8383 graphicsRows := n
8484 if n > height {
@@ -89,7 +89,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {
8989 // frame's line offsets (recycling and blanking the evicted rows in place)
9090 // instead of copying cells. See renderer.Frame.ScrollUp.
9191 s.emitLineEvicted(top, n)
92- s.Frame.ScrollUp(top, bottom, n)
92+ s.frame.ScrollUp(top, bottom, n)
9393 s.buffer.scrollUp(top, bottom, n)
9494 s.fillMissingRowIDs(s.buffer)
9595 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
@@ -106,17 +106,17 @@ func (s *Screen) emitLineEvicted(top, n int) {
106106 // Read boundaries and IDs before the caller rotates the frame: a soft link
107107 // belongs to the row it follows, and rotation reassigns row indices.
108108 for y := top; y < top+n; y++ {
109- s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y])
109+ s.recordEvicted(s.frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y])
110110 }
111111 }
112112
113113 func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) {
114114 if s.history != nil {
115115 err := s.history.AppendWithID(row, bound, id)
116- if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {
116+ if err != nil && !errors.Is(err, ErrHistoryRowTooLarge) {
117117 panic(err)
118118 }
119- // A nil error records the row; ErrHistoryRowTooWide explicitly leaves it
119+ // A nil error records the row; ErrHistoryRowTooLarge explicitly leaves it
120120 // unrecorded. Both cases continue to the eviction observer below.
121121 }
122122 if s.OnLineEvicted != nil {
@@ -125,14 +125,14 @@ func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) {
125125 }
126126
127127 func (s *Screen) scrollDownRegion(top, bottom, n int) {
128- if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
128+ if s.frame.Width == 0 || s.frame.Height == 0 || n <= 0 {
129129 return
130130 }
131131 top, bottom, ok := s.normalizedRegion(top, bottom)
132132 if !ok {
133133 return
134134 }
135- w := s.Frame.Width
135+ w := s.frame.Width
136136 height := bottom - top + 1
137137 graphicsRows := n
138138 if n > height {
@@ -140,7 +140,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
140140 }
141141 s.scrollGraphicsRegion(top, bottom, graphicsRows)
142142 // Full-width region: rotate line offsets instead of copying cells.
143- s.Frame.ScrollDown(top, bottom, n)
143+ s.frame.ScrollDown(top, bottom, n)
144144 s.buffer.scrollDown(top, bottom, n)
145145 s.fillMissingRowIDs(s.buffer)
146146 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
@@ -155,7 +155,7 @@ func (s *Screen) scrollGraphicsRegion(top, bottom, rows int) {
155155 return
156156 }
157157 delta := int64(rows) * int64(cellHeight)
158- fullScreen := top == 0 && bottom == s.Frame.Height-1
158+ fullScreen := top == 0 && bottom == s.frame.Height-1
159159 snapshot := s.graphics.scene.Snapshot()
160160 placements := snapshot.Placements()
161161 operations := make([]graphics.Operation, 0, len(placements))
@@ -191,10 +191,10 @@ func (s *Screen) scrollGraphicsRegion(top, bottom, rows int) {
191191 }
192192
193193 func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
194- if s.Frame.Height == 0 || top > bottom {
194+ if s.frame.Height == 0 || top > bottom {
195195 return 0, 0, false
196196 }
197- top = clamp(top, 0, s.Frame.Height-1)
198- bottom = clamp(bottom, 0, s.Frame.Height-1)
197+ top = clamp(top, 0, s.frame.Height-1)
198+ bottom = clamp(bottom, 0, s.frame.Height-1)
199199 return top, bottom, top <= bottom
200200 }
M screen_scroll_test.go +3 -3
@@ -27,7 +27,7 @@ func TestWideCharSurvivesRotatedScroll(t *testing.T) {
2727 assertContinuation(t, s, 1, 1)
2828 assertBlank(t, s, 0, 2)
2929 assertBlank(t, s, 1, 2)
30- if err := s.Frame.CheckInvariants(); err != nil {
30+ if err := s.frame.CheckInvariants(); err != nil {
3131 t.Fatalf("invariants: %v", err)
3232 }
3333 },
@@ -43,7 +43,7 @@ func TestWideCharSurvivesRotatedScroll(t *testing.T) {
4343 assertCell(t, s, 0, 1, '世')
4444 assertContinuation(t, s, 1, 1)
4545 assertBlank(t, s, 0, 0)
46- if err := s.Frame.CheckInvariants(); err != nil {
46+ if err := s.frame.CheckInvariants(); err != nil {
4747 t.Fatalf("invariants: %v", err)
4848 }
4949 },
@@ -72,7 +72,7 @@ func TestLineOffsetInvariantAfterVTSequences(t *testing.T) {
7272 for i, seq := range seqs {
7373 s.Write(seq)
7474 s.ClearDamage()
75- if err := s.Frame.CheckInvariants(); err != nil {
75+ if err := s.frame.CheckInvariants(); err != nil {
7676 t.Fatalf("after seq %d: invariants broken: %v", i, err)
7777 }
7878 }
M screen_snapshot.go +7 -8
@@ -24,9 +24,8 @@ type ModeSnapshot struct {
2424 MouseSGR bool
2525 }
2626
27-// ScreenSnapshot is an owned immutable-by-convention capture of the active
28-// terminal viewport. Row returns caller-owned storage; BorrowedRow must not be
29-// mutated and remains valid while the snapshot is retained.
27+// ScreenSnapshot is an owned immutable capture of the active terminal
28+// viewport. It implements core.CellSource, and Row returns caller-owned storage.
3029 type ScreenSnapshot struct {
3130 frame renderer.Frame
3231 bounds []LineBound
@@ -52,7 +51,7 @@ func (s *Screen) Snapshot() ScreenSnapshot {
5251 nextRowID++
5352 }
5453 return ScreenSnapshot{
55- frame: s.Frame.Clone(),
54+ frame: s.frame.Clone(),
5655 bounds: s.LineBounds(),
5756 rowIDs: s.RowIDs(),
5857 nextRowID: nextRowID,
@@ -79,11 +78,11 @@ func (s *Screen) Snapshot() ScreenSnapshot {
7978 func (s ScreenSnapshot) Columns() int { return s.frame.Width }
8079 func (s ScreenSnapshot) Rows() int { return s.frame.Height }
8180
82-func (s ScreenSnapshot) Row(y int) []renderer.Cell {
83- return append([]renderer.Cell(nil), s.BorrowedRow(y)...)
84-}
81+// Cell returns the semantic cell at x, y.
82+func (s ScreenSnapshot) Cell(x, y int) renderer.Cell { return s.frame.Cell(x, y) }
8583
86-func (s ScreenSnapshot) BorrowedRow(y int) []renderer.Cell {
84+// Row returns an owned copy of row y, or nil when y is out of range.
85+func (s ScreenSnapshot) Row(y int) []renderer.Cell {
8786 if y < 0 || y >= s.frame.Height {
8887 return nil
8988 }
M screen_snapshot_test.go +17 -18
@@ -25,7 +25,6 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
2525 wantModes: ModeSnapshot{},
2626 checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
2727 require.Nil(t, snapshot.Row(0))
28- require.Nil(t, snapshot.BorrowedRow(0))
2928 require.Equal(t, LineBound{}, snapshot.Bound(0))
3029 },
3130 },
@@ -48,7 +47,7 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
4847 MouseSGR: true,
4948 },
5049 checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
51- require.Len(t, snapshot.BorrowedRow(0), 6)
50+ require.Len(t, snapshot.Row(0), 6)
5251 require.Equal(t, LineBound{}, snapshot.Bound(0))
5352 },
5453 },
@@ -80,9 +79,8 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
8079 wantTitle: "zero-columns",
8180 wantCursor: CursorSnapshot{Visible: true},
8281 checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
83- require.NotNil(t, snapshot.BorrowedRow(0))
84- require.Empty(t, snapshot.BorrowedRow(0))
85- require.Nil(t, snapshot.Row(0))
82+ require.NotNil(t, snapshot.Row(0))
83+ require.Empty(t, snapshot.Row(0))
8684 require.Empty(t, snapshot.Row(1))
8785 require.Equal(t, LineBound{}, snapshot.Bound(1))
8886 require.Nil(t, snapshot.Row(2))
@@ -100,7 +98,7 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
10098 wantTitle: "zero-rows",
10199 wantCursor: CursorSnapshot{Visible: true},
102100 checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
103- require.Nil(t, snapshot.BorrowedRow(0))
101+ require.Nil(t, snapshot.Row(0))
104102 require.Equal(t, LineBound{}, snapshot.Bound(0))
105103 },
106104 },
@@ -193,7 +191,7 @@ func TestScreenSnapshotFidelity(t *testing.T) {
193191 {Rune: 'x', Style: renderer.DefaultStyle()},
194192 renderer.BlankCell(),
195193 }
196- copy(screen.Frame.Row(0), cells)
194+ screen.frame.WriteRow(0, 0, cells)
197195 screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true}
198196 return screen, cells, []LineBound{{End: 3, Soft: true}, {}}
199197 },
@@ -203,7 +201,7 @@ func TestScreenSnapshotFidelity(t *testing.T) {
203201 make: func() (*Screen, []renderer.Cell, []LineBound) {
204202 screen := NewScreen(3, 1)
205203 screen.Write([]byte("primary\x1b[?1049halt"))
206- cells := append([]renderer.Cell(nil), screen.Frame.Row(0)...)
204+ cells := append([]renderer.Cell(nil), screen.frame.Row(0)...)
207205 return screen, cells, screen.LineBounds()
208206 },
209207 wantModes: ModeSnapshot{AlternateScreen: true},
@@ -214,8 +212,8 @@ func TestScreenSnapshotFidelity(t *testing.T) {
214212 t.Run(test.name, func(t *testing.T) {
215213 screen, wantCells, wantBounds := test.make()
216214 snapshot := screen.Snapshot()
217- require.Equal(t, screen.Frame.Width, snapshot.Columns())
218- require.Equal(t, screen.Frame.Height, snapshot.Rows())
215+ require.Equal(t, screen.frame.Width, snapshot.Columns())
216+ require.Equal(t, screen.frame.Height, snapshot.Rows())
219217 require.Equal(t, wantCells, snapshot.Row(0))
220218 require.Equal(t, test.wantModes, snapshot.Modes())
221219 for row, want := range wantBounds {
@@ -234,7 +232,7 @@ func TestScreenSnapshotPreservesLogicalRowsAfterScroll(t *testing.T) {
234232 snapshot := screen.Snapshot()
235233 for y, want := range []string{"DEF", "G "} {
236234 require.Equal(t, want, rowText(snapshot.Row(y)))
237- require.Equal(t, screen.Frame.Row(y), snapshot.Row(y))
235+ require.Equal(t, screen.frame.Row(y), snapshot.Row(y))
238236 require.Equal(t, screen.LineBounds()[y], snapshot.Bound(y))
239237 }
240238 }
@@ -267,15 +265,15 @@ func TestScreenSnapshotOwnership(t *testing.T) {
267265 require.Equal(t, wantBounds[0], snapshot.Bound(0))
268266 require.Equal(t, wantBounds[1], snapshot.Bound(1))
269267
270- liveBeforeCallerMutation := append([]renderer.Cell(nil), screen.Frame.Row(0)...)
268+ liveBeforeCallerMutation := append([]renderer.Cell(nil), screen.frame.Row(0)...)
271269 secondSnapshot := screen.Snapshot()
272270 secondSnapshotBefore := secondSnapshot.Row(0)
273271
274272 callerCopy := snapshot.Row(0)
275273 callerCopy[0] = renderer.Cell{Rune: 'z'}
276274
277- require.Equal(t, wantRows[0], snapshot.BorrowedRow(0))
278- require.Equal(t, liveBeforeCallerMutation, screen.Frame.Row(0), "mutating Row copy must not change live Screen")
275+ require.Equal(t, wantRows[0], snapshot.Row(0))
276+ require.Equal(t, liveBeforeCallerMutation, screen.frame.Row(0), "mutating Row copy must not change live Screen")
279277 require.Equal(t, secondSnapshotBefore, secondSnapshot.Row(0), "mutating Row copy must not change another snapshot")
280278 })
281279 }
@@ -332,7 +330,7 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
332330 {
333331 name: "screen with sealed chunks and mutable tail",
334332 make: func() *Screen {
335- screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 64, ChunkRows: 2})
333+ screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxBytes: 1 << 20, ChunkRows: 2})
336334 screen.Write([]byte("111122223333444455"))
337335 return screen
338336 },
@@ -346,7 +344,8 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
346344 var beforeHistory HistorySnapshotView
347345 var beforeRows [][]renderer.Cell
348346 var beforeBounds []LineBound
349- beforeCap, beforeCellCap := 0, 0
347+ beforeCap := 0
348+ var beforeByteCap uint64
350349 if screen.History() != nil {
351350 beforeHistory = screen.History().SnapshotView()
352351 beforeView := screen.History().View()
@@ -357,7 +356,7 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
357356 beforeBounds[i] = beforeView.Bound(i)
358357 }
359358 beforeCap = screen.History().Cap()
360- beforeCellCap = screen.History().CellCap()
359+ beforeByteCap = screen.History().ByteCap()
361360 }
362361
363362 _ = screen.Snapshot()
@@ -380,7 +379,7 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
380379 require.Equal(t, beforeRows, afterRows)
381380 require.Equal(t, beforeBounds, afterBounds)
382381 require.Equal(t, beforeCap, screen.History().Cap())
383- require.Equal(t, beforeCellCap, screen.History().CellCap())
382+ require.Equal(t, beforeByteCap, screen.History().ByteCap())
384383 }
385384 })
386385 }
M screen_write.go +38 -45
@@ -14,8 +14,8 @@ func (s *Screen) putRune(r rune) {
1414 case '\r':
1515 s.Col = 0
1616 case '\n', '\v', '\f':
17- if s.Frame.Width > 0 && s.Col >= s.Frame.Width {
18- s.Col = s.Frame.Width - 1
17+ if s.frame.Width > 0 && s.Col >= s.frame.Width {
18+ s.Col = s.frame.Width - 1
1919 }
2020 s.buffer.hard(s.Row)
2121 s.index()
@@ -26,8 +26,8 @@ func (s *Screen) putRune(r rune) {
2626 case '\t':
2727 // HT moves to the next tab stop without modifying the frame. Clamp at
2828 // the right edge rather than triggering deferred wrap or a scroll.
29- if s.Frame.Width > 0 {
30- s.Col = min(s.Col+8-s.Col%8, s.Frame.Width-1)
29+ if s.frame.Width > 0 {
30+ s.Col = min(s.Col+8-s.Col%8, s.frame.Width-1)
3131 }
3232 default:
3333 // Drop C1 control characters (U+0080-U+009F).
@@ -58,23 +58,23 @@ func (s *Screen) putPrintable(r rune) {
5858 if w == 0 {
5959 return
6060 }
61- if s.Frame.Width == 0 || s.Frame.Height == 0 {
61+ if s.frame.Width == 0 || s.frame.Height == 0 {
6262 return
6363 }
6464 // Deferred wrap: cursor sits past the last column.
65- if s.Col >= s.Frame.Width {
65+ if s.Col >= s.frame.Width {
6666 s.buffer.soft(s.Row)
6767 s.Col = 0
6868 s.relinkWrap(s.index())
6969 }
70- if s.Row >= s.Frame.Height {
71- s.Row = s.Frame.Height - 1
70+ if s.Row >= s.frame.Height {
71+ s.Row = s.frame.Height - 1
7272 }
7373
7474 // A wide rune must never straddle the right edge. If it does not fit on the
7575 // current line, clear the abandoned last cell and wrap to the next line.
76- if w == 2 && s.Col+1 >= s.Frame.Width {
77- if s.Frame.Width < 2 {
76+ if w == 2 && s.Col+1 >= s.frame.Width {
77+ if s.frame.Width < 2 {
7878 // The screen is too narrow to ever hold a wide rune; store a narrow
7979 // replacement so the cell's renderer width matches its layout.
8080 r = '\uFFFD'
@@ -83,18 +83,18 @@ func (s *Screen) putPrintable(r rune) {
8383 // The abandoned cell may itself be the continuation of a pair whose
8484 // left half sits one column back; report damage over the full span.
8585 cx := s.Col
86- if s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation {
86+ if s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation {
8787 cx = s.Col - 1
8888 }
8989 s.clearWidePairAt(s.Col, s.Row)
90- s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
90+ s.frame.Set(s.Col, s.Row, renderer.BlankCell())
9191 s.buffer.truncate(s.Row, cx)
9292 s.buffer.continueRow(s.Row)
9393 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
9494 s.Col = 0
9595 s.relinkWrap(s.index())
96- if s.Row >= s.Frame.Height {
97- s.Row = s.Frame.Height - 1
96+ if s.Row >= s.frame.Height {
97+ s.Row = s.frame.Height - 1
9898 }
9999 }
100100 }
@@ -102,38 +102,33 @@ func (s *Screen) putPrintable(r rune) {
102102 insertDamageX := s.Col
103103 insertDamageWidth := 0
104104 if s.insertMode {
105- row := s.Frame.Row(s.Row)
106- leftSplit := s.Col > 0 && row[s.Col].Continuation
107- for x := s.Frame.Width - 1; x >= s.Col+w; x-- {
108- row[x] = row[x-w]
109- }
110- for x := s.Col; x < s.Col+w; x++ {
111- row[x] = renderer.BlankCell()
112- }
105+ leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation
106+ s.frame.CopyRow(s.Row, s.Col+w, s.Col, s.frame.Width-s.Col-w)
107+ s.frame.FillRow(s.Row, s.Col, s.Col+w, renderer.BlankCell())
113108 s.repairRow(s.Row)
114109 s.buffer.insert(s.Row, s.Col, w)
115110 if leftSplit {
116111 insertDamageX = s.Col - 1
117112 }
118- insertDamageWidth = s.Frame.Width - insertDamageX
113+ insertDamageWidth = s.frame.Width - insertDamageX
119114 }
120115
121116 // Determine the range of cells actually modified, extending over any wide
122117 // pair the write lands on so no orphaned half is left behind.
123118 lo, hi := s.Col, s.Col+w-1
124- if s.Frame.At(s.Col, s.Row).Continuation {
119+ if s.frame.At(s.Col, s.Row).Continuation {
125120 lo = s.Col - 1
126121 }
127- if right := s.Col + w; right < s.Frame.Width && s.Frame.At(right, s.Row).Continuation {
122+ if right := s.Col + w; right < s.frame.Width && s.frame.At(right, s.Row).Continuation {
128123 hi = right
129124 }
130125 for x := lo; x <= hi; x++ {
131- s.Frame.Set(x, s.Row, renderer.BlankCell())
126+ s.frame.Set(x, s.Row, renderer.BlankCell())
132127 }
133- s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
128+ s.frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
134129 s.buffer.content(s.Row, s.Col+w)
135130 if w == 2 {
136- s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
131+ s.frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
137132 }
138133 if insertDamageWidth > 0 {
139134 lo = min(lo, insertDamageX)
@@ -149,19 +144,19 @@ func (s *Screen) putPrintable(r rune) {
149144 // left half) holds — which the writer maintains.
150145
151146 func (s *Screen) clearWidePairAt(x, y int) {
152- if x < 0 || x >= s.Frame.Width || y < 0 || y >= s.Frame.Height {
147+ if x < 0 || x >= s.frame.Width || y < 0 || y >= s.frame.Height {
153148 return
154149 }
155- if s.Frame.At(x, y).Continuation {
150+ if s.frame.At(x, y).Continuation {
156151 if x-1 >= 0 {
157- s.Frame.Set(x-1, y, renderer.BlankCell())
152+ s.frame.Set(x-1, y, renderer.BlankCell())
158153 }
159- s.Frame.Set(x, y, renderer.BlankCell())
154+ s.frame.Set(x, y, renderer.BlankCell())
160155 return
161156 }
162- if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation {
163- s.Frame.Set(x, y, renderer.BlankCell())
164- s.Frame.Set(x+1, y, renderer.BlankCell())
157+ if x+1 < s.frame.Width && s.frame.At(x+1, y).Continuation {
158+ s.frame.Set(x, y, renderer.BlankCell())
159+ s.frame.Set(x+1, y, renderer.BlankCell())
165160 }
166161 }
167162
@@ -181,32 +176,30 @@ func (s *Screen) eraseCell() renderer.Cell {
181176 // returns the actual modified span [start, start+width).
182177
183178 func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
184- if y < 0 || y >= s.Frame.Height {
179+ if y < 0 || y >= s.frame.Height {
185180 return x0, 0
186181 }
187182 if x0 < 0 {
188183 x0 = 0
189184 }
190- if x1 > s.Frame.Width {
191- x1 = s.Frame.Width
185+ if x1 > s.frame.Width {
186+ x1 = s.frame.Width
192187 }
193188 if x0 >= x1 {
194189 return x0, 0
195190 }
196191 // Left boundary: a continuation at x0 means its wide left half sits at x0-1.
197- if x0 > 0 && s.Frame.At(x0, y).Continuation {
192+ if x0 > 0 && s.frame.At(x0, y).Continuation {
198193 x0--
199194 }
200195 // Right boundary: a continuation at x1 means its wide left half (at x1-1) is
201196 // inside the range and will be blanked, so swallow the continuation too.
202- if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
197+ if x1 < s.frame.Width && s.frame.At(x1, y).Continuation {
203198 x1++
204199 }
205- fullRow := x0 <= 0 && x1 >= s.Frame.Width
200+ fullRow := x0 <= 0 && x1 >= s.frame.Width
206201 blank := s.eraseCell()
207- for x := x0; x < x1; x++ {
208- s.Frame.Set(x, y, blank)
209- }
202+ s.frame.FillRow(y, x0, x1, blank)
210203 s.buffer.clear(y, x0, x1)
211204 if fullRow {
212205 s.buffer.rowIDs[y] = s.nextRowIDValue()
@@ -220,7 +213,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
220213 // at a boundary or by shifting cells.
221214
222215 func (s *Screen) repairRow(y int) {
223- repairFrameRow(s.Frame, y)
216+ repairFrameRow(s.frame, y)
224217 }
225218
226219 func repairFrameRow(frame renderer.Frame, y int) {
M screen_write_test.go +6 -6
@@ -194,12 +194,12 @@ func TestCursorControlChars(t *testing.T) {
194194 name: "tab moves cursor without changing cells or damage",
195195 run: func(t *testing.T) {
196196 s := NewScreen(10, 2)
197- for y := range s.Frame.Height {
198- for x := range s.Frame.Width {
199- s.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.Frame.Width + x)})
197+ for y := range s.frame.Height {
198+ for x := range s.frame.Width {
199+ s.frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.frame.Width + x)})
200200 }
201201 }
202- before := s.Frame.Clone()
202+ before := s.frame.Clone()
203203 s.Row, s.Col = 1, 6
204204 s.ClearDamage()
205205
@@ -207,7 +207,7 @@ func TestCursorControlChars(t *testing.T) {
207207 if s.Row != 1 || s.Col != 8 {
208208 t.Errorf("cursor after tab = row=%d col=%d, want row=1 col=8", s.Row, s.Col)
209209 }
210- assertFramesEqual(t, s.Frame, before)
210+ assertFramesEqual(t, s.frame, before)
211211 if got := s.Damage(); len(got) != 0 {
212212 t.Errorf("tab damage = %+v, want none", got)
213213 }
@@ -218,7 +218,7 @@ func TestCursorControlChars(t *testing.T) {
218218 if s.Row != 1 || s.Col != 9 {
219219 t.Errorf("cursor after clamped tab = row=%d col=%d, want row=1 col=9", s.Row, s.Col)
220220 }
221- assertFramesEqual(t, s.Frame, before)
221+ assertFramesEqual(t, s.frame, before)
222222 if got := s.Damage(); len(got) != 0 {
223223 t.Errorf("clamped tab damage = %+v, want none", got)
224224 }
M scripts/consumer-smoke.sh +2 -2
@@ -13,7 +13,7 @@ mkdir -p "$tmp/consumer"
1313 cat >"$tmp/consumer/go.mod" <<EOF
1414 module example.com/vev-vt-consumer
1515
16-go 1.26
16+go 1.27
1717
1818 require github.com/bnema/vev-vt v0.0.0
1919
@@ -33,7 +33,7 @@ import (
3333 func main() {
3434 screen := vt.NewScreen(4, 1)
3535 screen.Write([]byte("ok"))
36- output, err := ansi.New(ansi.Capabilities{}).Draw(screen.Frame.Clone(), []vt.Damage{vt.FullRedraw()})
36+ output, err := ansi.New(ansi.Capabilities{}).Draw(screen, []vt.Damage{vt.FullRedraw()})
3737 if err != nil {
3838 fmt.Fprintln(os.Stderr, err)
3939 os.Exit(1)
M scrollback_test.go +3 -3
@@ -92,7 +92,7 @@ func TestOnLineEvictedAltScreenAndRotation(t *testing.T) {
9292 if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) {
9393 t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows)
9494 }
95- if err := s.Frame.CheckInvariants(); err != nil {
95+ if err := s.frame.CheckInvariants(); err != nil {
9696 t.Fatalf("frame invariants after scrollback callback: %v", err)
9797 }
9898 })
@@ -126,7 +126,7 @@ func TestEvictedLinesCarryTheirBound(t *testing.T) {
126126
127127 for _, tc := range tests {
128128 t.Run(tc.name, func(t *testing.T) {
129- s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 1024})
129+ s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxBytes: 1 << 20})
130130 tc.evict(s)
131131 view := s.History().SealAndView()
132132 if view.Len() == 0 {
@@ -140,7 +140,7 @@ func TestEvictedLinesCarryTheirBound(t *testing.T) {
140140 }
141141
142142 func TestReflowEvictedLinesCarryTheirBound(t *testing.T) {
143- s := NewScreenWithHistory(8, 3, HistoryConfig{MaxRows: 16, MaxCells: 1024})
143+ s := NewScreenWithHistory(8, 3, HistoryConfig{MaxRows: 16, MaxBytes: 1 << 20})
144144 // Fill the grid with wrapped content, then shrink so rows are evicted by
145145 // the reflow path in buffer.resize rather than by scrolling.
146146 s.Write([]byte("aaaaaaaabbbbbbbbcccccccc"))
M snapshot.go +63 -20
@@ -40,7 +40,7 @@ func MarshalHistoryChunk(chunk *HistoryChunk) ([]byte, error) {
4040 if chunk == nil {
4141 return nil, fmt.Errorf("marshal history chunk: %w", errInvalidHistory)
4242 }
43- return MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: len(chunk.rows)})
43+ return MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: chunk.len(), cells: chunk.cells()})
4444 }
4545
4646 // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob.
@@ -77,6 +77,9 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
7777 // HistoryFromBlobs restores history directly from sealed, oldest-first blobs
7878 // and the mandatory tail blob. It never feeds decoded rows through Append.
7979 func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {
80+ if err := config.Validate(); err != nil {
81+ return nil, err
82+ }
8083 sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail)
8184 if err != nil {
8285 return nil, err
@@ -89,14 +92,14 @@ func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryVi
8992 sealedViews := make([]HistoryView, len(sealed))
9093 for i, blob := range sealed {
9194 view, err := UnmarshalHistory(blob)
92- if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) {
95+ if err != nil || len(view.chunks) != 1 || view.chunks[0].len() == 0 || !validateRestoredHistoryView(view, seen) {
9396 return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
9497 }
9598 sealedViews[i] = view
9699 }
97100
98101 tailView, err := UnmarshalHistory(tail)
99- if err != nil || len(tailView.chunks) > 1 || !validateRestoredHistoryView(tailView, seen) {
102+ if err != nil || !validateRestoredHistoryView(tailView, seen) {
100103 return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory)
101104 }
102105 for _, view := range extra {
@@ -111,37 +114,73 @@ func restoreHistoryViews(config HistoryConfig, sealed []HistoryView, tail Histor
111114 h := NewHistory(config)
112115 for _, view := range sealed {
113116 h.nextRowID = max(h.nextRowID, view.nextRowID)
114- if h.maxRows == 0 {
117+ chunk := h.boundedRestoredChunk(view.chunks[0])
118+ if chunk == nil {
115119 continue
116120 }
117- chunk := view.chunks[0]
121+ chunkBytes := historyChunkLogicalBytes(chunk)
122+ for h.rows > h.maxRows-chunk.len() || h.logicalBytes > h.maxBytes-chunkBytes {
123+ h.evictOldest()
124+ }
118125 h.chunks = append(h.chunks, chunk)
119- h.rows += len(chunk.rows)
120- h.cells += view.Cells()
121- h.evict()
126+ h.rows += chunk.len()
127+ h.cells += chunk.cells()
128+ h.logicalBytes += chunkBytes
122129 }
123130 h.nextRowID = max(h.nextRowID, tail.nextRowID)
124- if h.maxRows == 0 || len(tail.chunks) == 0 {
131+ if h.maxRows == 0 {
125132 return h
126133 }
127- chunk := tail.chunks[0]
128- h.tail = chunk.rows
129- h.tailBounds = append([]LineBound(nil), chunk.bounds...)
130- h.tailIDs = append([]RowID(nil), chunk.rowIDs...)
131- h.rows += len(h.tail)
132- h.cells += tail.Cells()
133- h.evict()
134- h.normalizeTail()
134+ for _, chunk := range tail.chunks {
135+ for row := range chunk.len() {
136+ if h.prepareChunkRowStyles(chunk, row) > h.maxBytes {
137+ continue
138+ }
139+ for {
140+ delta := h.tailAppendDelta(chunk.width)
141+ if h.rows < h.maxRows && h.logicalBytes <= h.maxBytes-delta {
142+ h.appendTailChunkRow(chunk, row)
143+ h.recordTailRowStyles(chunk.width)
144+ h.tailBytes += delta
145+ h.logicalBytes += delta
146+ h.tailBounds = append(h.tailBounds, chunk.bounds[row])
147+ h.tailIDs = append(h.tailIDs, chunk.rowIDs[row])
148+ h.rows++
149+ h.cells += chunk.width
150+ if len(h.tail) == h.chunkRows {
151+ h.sealTail()
152+ }
153+ break
154+ }
155+ h.evictOldest()
156+ h.prepareChunkRowStyles(chunk, row)
157+ }
158+ }
159+ }
135160 return h
136161 }
137162
163+func (h *History) boundedRestoredChunk(chunk *HistoryChunk) *HistoryChunk {
164+ if h.maxRows == 0 || chunk == nil {
165+ return nil
166+ }
167+ bounded := chunk
168+ for bounded.count > h.maxRows || historyChunkLogicalBytes(bounded) > h.maxBytes {
169+ if bounded.count == 1 {
170+ return nil
171+ }
172+ bounded = bounded.withoutFirstRow()
173+ }
174+ return bounded
175+}
176+
138177 func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool {
139178 if view.nextRowID == 0 || view.nextRowID == ^RowID(0) {
140179 return false
141180 }
142181 var maxID RowID
143182 for _, chunk := range view.chunks {
144- if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) {
183+ if chunk == nil || chunk.len() != len(chunk.rowIDs) || chunk.len() != len(chunk.bounds) {
145184 return false
146185 }
147186 for _, id := range chunk.rowIDs {
@@ -162,6 +201,9 @@ func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool
162201 // history contains the restored bounded history followed by the recovery
163202 // transcript. The transcript is decoded in full before history is restored.
164203 func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, sealed [][]byte, tail, transcript []byte) (*Screen, error) {
204+ if err := config.Validate(); err != nil {
205+ return nil, err
206+ }
165207 transcriptView, err := UnmarshalHistory(transcript)
166208 if err != nil {
167209 return nil, fmt.Errorf("restore recovery transcript: %w", err)
@@ -174,7 +216,8 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
174216 history := restoreHistoryViews(config, sealedViews, tailView)
175217 remainingRows := transcriptView.rows
176218 for _, chunk := range transcriptView.chunks {
177- for i, row := range chunk.rows {
219+ for i := range chunk.len() {
220+ row := chunk.row(i)
178221 remainingRows--
179222 bound := chunk.bounds[i]
180223 if remainingRows == 0 {
@@ -196,6 +239,6 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
196239 for i := range screen.buffer.rowIDs {
197240 screen.buffer.rowIDs[i] = screen.nextRowIDValue()
198241 }
199- screen.Frame = screen.buffer.frame
242+ screen.frame = screen.buffer.frame
200243 return screen, nil
201244 }
M snapshot_test.go +5 -4
@@ -49,7 +49,7 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
4949
5050 func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
5151 fullTail, err := MarshalHistory(HistoryView{
52- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}, rowIDs: []RowID{1, 2}}},
52+ chunks: []*HistoryChunk{testHistoryChunk([][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}, nil, []RowID{1, 2})},
5353 rows: 2,
5454 nextRowID: 3,
5555 })
@@ -64,8 +64,8 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
6464 require.Len(t, restored.tail, 1, "the appended row must be the mutable tail")
6565 }
6666
67-func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) {
68- history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2})
67+func TestHistoryFromBlobsRestoresAccountingAndEvictsToBothBudgets(t *testing.T) {
68+ history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 2})
6969 for _, text := range []string{"a", "b", "cc"} {
7070 requireHistoryAppend(t, history, historyRow(text))
7171 }
@@ -79,7 +79,8 @@ func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing
7979 tail, err := MarshalHistoryTail(view)
8080 require.NoError(t, err)
8181
82- restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, MaxCells: 2, ChunkRows: 2}, sealed, tail)
82+ const budget = 2*renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes + renderer.StyleRecordLogicalBytes
83+ restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, MaxBytes: budget, ChunkRows: 2}, sealed, tail)
8384 require.NoError(t, err)
8485 require.Equal(t, []string{"cc"}, historyViewTexts(restored.View()))
8586 require.Equal(t, 2, restored.Cells())
A storage_benchmark_test.go +192 -0
@@ -0,0 +1,192 @@
1+package vt
2+
3+import (
4+ "runtime"
5+ "testing"
6+
7+ "github.com/bnema/vev-vt/core"
8+)
9+
10+const (
11+ storageBenchmarkRows = 10_000
12+ storageBenchmarkWidth = 120
13+)
14+
15+var benchmarkStorageHistorySink *History
16+
17+type storageBenchmarkScenario struct {
18+ name string
19+ fill func([]core.Cell, int)
20+}
21+
22+func BenchmarkHistoryBuild10Kx120(b *testing.B) {
23+ for _, scenario := range historyStorageScenarios() {
24+ b.Run(scenario.name, func(b *testing.B) {
25+ b.ReportAllocs()
26+ for b.Loop() {
27+ benchmarkStorageHistorySink = buildStorageBenchmarkHistory(b, scenario.fill)
28+ }
29+ b.ReportMetric(storageBenchmarkRows*storageBenchmarkWidth, "cells/op")
30+ })
31+ }
32+}
33+
34+// BenchmarkHistoryRetained10Kx120 isolates each sample with garbage collection
35+// and reports the live heap retained by one history. Use -benchtime=1x when
36+// comparing representations; elapsed time includes the measurement GCs.
37+func BenchmarkHistoryRetained10Kx120(b *testing.B) {
38+ for _, scenario := range historyStorageScenarios() {
39+ b.Run(scenario.name, func(b *testing.B) {
40+ var retained uint64
41+ for b.Loop() {
42+ benchmarkStorageHistorySink = nil
43+ runtime.GC()
44+ var before runtime.MemStats
45+ runtime.ReadMemStats(&before)
46+
47+ benchmarkStorageHistorySink = buildStorageBenchmarkHistory(b, scenario.fill)
48+ runtime.GC()
49+ var after runtime.MemStats
50+ runtime.ReadMemStats(&after)
51+ if after.HeapAlloc >= before.HeapAlloc {
52+ retained += after.HeapAlloc - before.HeapAlloc
53+ }
54+ }
55+ benchmarkStorageHistorySink = nil
56+ if b.N > 0 {
57+ b.ReportMetric(float64(retained)/float64(b.N), "retained-B/op")
58+ }
59+ })
60+ }
61+}
62+
63+// BenchmarkColdHistoryRetained10Kx120 measures the same scenarios after two
64+// bounded idle passes. The newest sealed page and mutable tail remain hot.
65+func BenchmarkColdHistoryRetained10Kx120(b *testing.B) {
66+ for _, scenario := range historyStorageScenarios() {
67+ b.Run(scenario.name, func(b *testing.B) {
68+ var retained uint64
69+ for b.Loop() {
70+ benchmarkStorageHistorySink = nil
71+ runtime.GC()
72+ var before, after runtime.MemStats
73+ runtime.ReadMemStats(&before)
74+ h := buildStorageBenchmarkHistory(b, scenario.fill)
75+ for range 2 {
76+ if _, err := h.CompressIdle(len(h.chunks)); err != nil {
77+ b.Fatal(err)
78+ }
79+ }
80+ benchmarkStorageHistorySink = h
81+ runtime.GC()
82+ runtime.ReadMemStats(&after)
83+ if after.HeapAlloc >= before.HeapAlloc {
84+ retained += after.HeapAlloc - before.HeapAlloc
85+ }
86+ }
87+ stats := benchmarkStorageHistorySink.CompressionStats()
88+ b.ReportMetric(float64(stats.CompressedBytes), "compressed-B")
89+ b.ReportMetric(float64(stats.ColdPages), "cold-pages")
90+ b.ReportMetric(float64(retained)/float64(b.N), "retained-B/op")
91+ benchmarkStorageHistorySink = nil
92+ })
93+ }
94+}
95+
96+func historyStorageScenarios() []storageBenchmarkScenario {
97+ defaultStyle := core.DefaultStyle()
98+ repeatedIndexed := core.Style{
99+ Bold: true,
100+ Foreground: 196,
101+ Background: 17,
102+ UnderlineStyle: core.UnderlineSingle,
103+ }
104+ repeatedRGB := core.Style{
105+ Foreground: -1,
106+ Background: -1,
107+ HasForegroundRGB: true,
108+ ForegroundRGB: core.RGB{R: 224, G: 108, B: 117},
109+ HasBackgroundRGB: true,
110+ BackgroundRGB: core.RGB{R: 40, G: 44, B: 52},
111+ }
112+ return []storageBenchmarkScenario{
113+ {
114+ name: "plain-ascii",
115+ fill: func(row []core.Cell, y int) {
116+ for x := range row {
117+ row[x] = core.Cell{Rune: rune('a' + (x+y)%26), Style: defaultStyle}
118+ }
119+ },
120+ },
121+ {
122+ name: "repeated-indexed-style",
123+ fill: func(row []core.Cell, y int) {
124+ for x := range row {
125+ row[x] = core.Cell{Rune: rune('a' + (x+y)%26), Style: repeatedIndexed}
126+ }
127+ },
128+ },
129+ {
130+ name: "repeated-rgb-style",
131+ fill: func(row []core.Cell, y int) {
132+ for x := range row {
133+ row[x] = core.Cell{Rune: rune('a' + (x+y)%26), Style: repeatedRGB}
134+ }
135+ },
136+ },
137+ {
138+ name: "high-cardinality-rgb",
139+ fill: func(row []core.Cell, y int) {
140+ for x := range row {
141+ value := uint32(y*len(row) + x)
142+ style := core.Style{
143+ Foreground: -1,
144+ Background: -1,
145+ HasForegroundRGB: true,
146+ ForegroundRGB: core.RGB{
147+ R: uint8(value >> 16),
148+ G: uint8(value >> 8),
149+ B: uint8(value),
150+ },
151+ }
152+ row[x] = core.Cell{Rune: 'x', Style: style}
153+ }
154+ },
155+ },
156+ {
157+ name: "wide-unicode",
158+ fill: func(row []core.Cell, _ int) {
159+ for x := 0; x < len(row); x += 2 {
160+ row[x] = core.Cell{Rune: '界', Style: defaultStyle}
161+ row[x+1] = core.Cell{Style: defaultStyle, Continuation: true}
162+ }
163+ },
164+ },
165+ {
166+ name: "styled-blanks",
167+ fill: func(row []core.Cell, y int) {
168+ style := core.Style{Foreground: -1, Background: (y % 16) + 232}
169+ for x := range row {
170+ row[x] = core.Cell{Rune: ' ', Style: style}
171+ }
172+ },
173+ },
174+ }
175+}
176+
177+func buildStorageBenchmarkHistory(b *testing.B, fill func([]core.Cell, int)) *History {
178+ b.Helper()
179+ history := NewHistory(HistoryConfig{
180+ MaxRows: storageBenchmarkRows,
181+ MaxBytes: 1 << 30,
182+ ChunkRows: 256,
183+ })
184+ row := make([]core.Cell, storageBenchmarkWidth)
185+ for y := range storageBenchmarkRows {
186+ fill(row, y)
187+ if err := history.Append(row, LineBound{End: len(row)}); err != nil {
188+ b.Fatal(err)
189+ }
190+ }
191+ return history
192+}
A storage_tuning_test.go +61 -0
@@ -0,0 +1,61 @@
1+package vt
2+
3+import (
4+ "fmt"
5+ "runtime"
6+ "testing"
7+
8+ "github.com/bnema/vev-vt/core"
9+)
10+
11+func TestHistoryTailPreallocationFollowsGeometry(t *testing.T) {
12+ h := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 4})
13+ if err := h.Append(historyRow("abc"), LineBound{End: 3}); err != nil {
14+ t.Fatal(err)
15+ }
16+ if got := cap(h.tailCells); got > 12 {
17+ t.Fatalf("tail preallocation = %d cells, want at most 4x3", got)
18+ }
19+}
20+
21+func BenchmarkHistoryChunkSize(b *testing.B) {
22+ for _, rows := range []int{32, 64, 128, 256} {
23+ b.Run(fmt.Sprintf("rows-%d", rows), func(b *testing.B) {
24+ b.ReportAllocs()
25+ for b.Loop() {
26+ h := NewHistory(HistoryConfig{MaxRows: 10_000, MaxBytes: 1 << 30, ChunkRows: rows})
27+ row := make([]core.Cell, 120)
28+ for x := range row {
29+ row[x] = core.Cell{Rune: rune('a' + x%26), Style: core.DefaultStyle()}
30+ }
31+ for range 10_000 {
32+ if err := h.Append(row, LineBound{End: 120}); err != nil {
33+ b.Fatal(err)
34+ }
35+ }
36+ benchmarkStorageHistorySink = h
37+ }
38+ })
39+ }
40+}
41+
42+func BenchmarkSmallHistoryRetained(b *testing.B) {
43+ var retained uint64
44+ for b.Loop() {
45+ benchmarkStorageHistorySink = nil
46+ runtime.GC()
47+ var before, after runtime.MemStats
48+ runtime.ReadMemStats(&before)
49+ h := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 4})
50+ if err := h.Append(historyRow("abc"), LineBound{End: 3}); err != nil {
51+ b.Fatal(err)
52+ }
53+ benchmarkStorageHistorySink = h
54+ runtime.GC()
55+ runtime.ReadMemStats(&after)
56+ if after.HeapAlloc >= before.HeapAlloc {
57+ retained += after.HeapAlloc - before.HeapAlloc
58+ }
59+ }
60+ b.ReportMetric(float64(retained)/float64(b.N), "retained-B/op")
61+}
D testdata/README.md +0 -7
@@ -1,7 +0,0 @@
1-# Compatibility fixtures
2-
3-`vevs-v4-restore.golden.hex` is an opaque VEVS v4 restore envelope retained for
4-vev consumer compatibility. The standalone module does not decode or own the
5-VEVS outer format; its fixture test verifies the envelope header, length, CRC,
6-and embedded VTH3 payload markers only. The vev repository owns full VEVS restore
7-semantics.
D testdata/vevs-v4-restore.golden.hex +0 -1
@@ -1,1 +0,0 @@
1-5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100
M wrap_scroll_soft_test.go +5 -5
@@ -86,7 +86,7 @@ func TestSoftLinkSurvivesTheScrollItCaused(t *testing.T) {
8686 }
8787 for _, tc := range cases {
8888 t.Run(tc.name, func(t *testing.T) {
89- s := NewScreenWithHistory(width, tc.height, HistoryConfig{MaxRows: 64, MaxCells: 4096})
89+ s := NewScreenWithHistory(width, tc.height, HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20})
9090 s.Write([]byte(tc.input))
9191
9292 bounds := s.LineBounds()
@@ -104,7 +104,7 @@ func TestSoftLinkSurvivesTheScrollItCaused(t *testing.T) {
104104 // wrapped out of the bottom row used to be two unrelated physical rows, and
105105 // widening the pane left it split instead of rejoining it.
106106 func TestResizeRejoinsALineWrappedOutOfTheBottomRow(t *testing.T) {
107- s := NewScreenWithHistory(8, 4, HistoryConfig{MaxRows: 64, MaxCells: 4096})
107+ s := NewScreenWithHistory(8, 4, HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20})
108108 s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij"))
109109
110110 s.Resize(16, 4)
@@ -122,14 +122,14 @@ func TestSoftLinkFromABottomRowWrapReachesHistory(t *testing.T) {
122122 width = 8
123123 height = 4
124124 )
125- s := NewScreenWithHistory(width, height, HistoryConfig{MaxRows: 64, MaxCells: 4096})
125+ s := NewScreenWithHistory(width, height, HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20})
126126 // Wrap out of the bottom row, then push both physical rows into history.
127127 s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij\r\nx\r\ny\r\nz\r\n"))
128128
129129 view := s.History().SealAndView()
130130 require.Equal(t, 7, view.Len(), "history row count")
131- require.Equal(t, "abcdefgh", rowString(view.BorrowedRow(5)))
131+ require.Equal(t, "abcdefgh", rowString(view.Row(5)))
132132 require.Equal(t, LineBound{End: 8, Soft: true}, view.Bound(5), "the wrapped row must reach history soft")
133- require.Equal(t, "ij ", rowString(view.BorrowedRow(6)))
133+ require.Equal(t, "ij ", rowString(view.Row(6)))
134134 require.Equal(t, LineBound{End: 2, Soft: false}, view.Bound(6), "its continuation is a hard line end")
135135 }

Keyboard shortcuts

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

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

Branches 2

Tags 13

↑↓ moveenter openesc close