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 @@
3 /coverage.out3 /coverage.out
4 /testdata/generated/4 /testdata/generated/
5 5
6+# Local agent review notes
7+/.agent-reviews/
8+
6 # Editor and OS files9 # Editor and OS files
7 *.swp10 *.swp
8 .DS_Store11 .DS_Store
M README.md +87 -72
@@ -1,87 +1,102 @@
1 # vev-vt1 # vev-vt
2 2
3-`github.com/bnema/vev-vt` is a frontend-neutral VT terminal engine for Go.3+A Go library for reading terminal output and keeping track of what should appear
4-It provides the terminal screen, scrollback history, immutable snapshots,4+on screen. It powers [vev](https://github.com/bnema/vev).
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.
7 5
8-## Packages6+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.
9 12
10-- The module root (`github.com/bnema/vev-vt`) owns VT parsing, screen state,13+## Install
11- history, snapshots, callbacks, and the public model aliases (`Cell`, `Style`,14+
12- `RGB`, `Frame`, `Damage`, and `RuneWidth`).15+Requires Go 1.27 or newer.
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.
69 16
70 ```sh17 ```sh
71-go run ./cmd/graphics-harness \18+go get github.com/bnema/vev-vt
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
75 ```19 ```
76 20
77-The included demo has an opaque blue background and a semi-transparent red21+**Alpha software:** APIs and saved-history formats can change between versions.
78-overlay. Its output is suitable for direct image inspection.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+)
79 34
80-## Checks35+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
81 95
82 ```sh96 ```sh
83 go test ./...97 go test ./...
84 go test ./... -race98 go test ./... -race
85 go vet ./...99 go vet ./...
86-go test ./... -run '^$' -bench='.' -benchmem
87 ```100 ```
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) {
206 }206 }
207 207
208 scrolled := NewFrame(120, 40)208 scrolled := NewFrame(120, 40)
209- copy(scrolled.Cells[0:39*120], frame.Cells[120:40*120])209+ for y := 0; y < 39; y++ {
210- for i := 39 * 120; i < 40*120; i++ {210+ scrolled.WriteRow(y, 0, frame.Row(y+1))
211- scrolled.Cells[i] = BlankCell()
212 }211 }
213 for x, r := range []rune("new output line") {212 for x, r := range []rune("new output line") {
214 scrolled.Set(x, 39, Cell{Rune: r, Style: DefaultStyle()})213 scrolled.Set(x, 39, Cell{Rune: r, Style: DefaultStyle()})
M ansi/consumer_external_test.go +14 -0
@@ -4,10 +4,24 @@ import (
4 "bytes"4 "bytes"
5 "testing"5 "testing"
6 6
7+ vt "github.com/bnema/vev-vt"
7 "github.com/bnema/vev-vt/ansi"8 "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev-vt/core"9 "github.com/bnema/vev-vt/core"
9 )10 )
10 11
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+
11 func TestExternalANSIConsumesCoreFrameIndependently(t *testing.T) {25 func TestExternalANSIConsumesCoreFrameIndependently(t *testing.T) {
12 frame := core.NewFrame(2, 1)26 frame := core.NewFrame(2, 1)
13 frame.Set(0, 0, core.Cell{Rune: 'O', Style: core.DefaultStyle()})27 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
10 // buildDamagePlan creates the bounded, canonical view of text and clear damage10 // buildDamagePlan creates the bounded, canonical view of text and clear damage
11 // used for terminal emission. The source damage remains untouched because the11 // used for terminal emission. The source damage remains untouched because the
12 // scroll and shadow paths need its original structural information.12 // 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) {
14 if len(damage) == 1 {14 if len(damage) == 1 {
15 d := damage[0]15 d := damage[0]
16 if skip != nil && sameDamage(d, *skip) {16 if skip != nil && sameDamage(d, *skip) {
@@ -52,7 +52,7 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool)
52 return mergeDamageSpans(spans), false52 return mergeDamageSpans(spans), false
53 }53 }
54 54
55-func buildSingleDamageSpans(frame Frame, d Damage) ([]Span, bool) {55+func buildSingleDamageSpans(frame CellSource, d Damage) ([]Span, bool) {
56 x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)56 x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
57 if !ok {57 if !ok {
58 return nil, false58 return nil, false
M ansi/delta_plan.go +33 -32
@@ -30,21 +30,21 @@ type DeltaCandidate struct {
30 frame Frame30 frame Frame
31 }31 }
32 32
33-func newDeltaCandidate(frame Frame, plan DeltaPlan) DeltaCandidate {33+func newDeltaCandidate(frame CellSource, plan DeltaPlan) DeltaCandidate {
34 candidate := DeltaCandidate{Plan: plan}34 candidate := DeltaCandidate{Plan: plan}
35 if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {35 if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
36- candidate.frame = frame.Clone()36+ candidate.frame = cloneCellSource(frame)
37 }37 }
38 return candidate38 return candidate
39 }39 }
40 40
41 // PlanDelta prepares a non-mutating update from committed to frame.41 // PlanDelta prepares a non-mutating update from committed to frame.
42-func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {42+func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {
43- if err := frame.Validate(); err != nil {43+ if err := validateCellSource(frame); err != nil {
44 return DeltaCandidate{}, err44 return DeltaCandidate{}, err
45 }45 }
46 46
47- if reset || frame.Width != committed.Width || frame.Height != committed.Height {47+ if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() {
48 return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil48 return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil
49 }49 }
50 if err := committed.Validate(); err != nil {50 if err := committed.Validate(); err != nil {
@@ -92,7 +92,7 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta
92 return newDeltaCandidate(frame, plan), nil92 return newDeltaCandidate(frame, plan), nil
93 }93 }
94 94
95-func planSingleDamage(frame Frame, d Damage) DeltaPlan {95+func planSingleDamage(frame CellSource, d Damage) DeltaPlan {
96 spans, full := buildSingleDamageSpans(frame, d)96 spans, full := buildSingleDamageSpans(frame, d)
97 if full {97 if full {
98 return DeltaPlan{Snapshot: true}98 return DeltaPlan{Snapshot: true}
@@ -104,14 +104,12 @@ func planSingleDamage(frame Frame, d Damage) DeltaPlan {
104 return plan104 return plan
105 }105 }
106 106
107-func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) {107+func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) {
108 var spans []Span108 var spans []Span
109- for y := range frame.Height {109+ for y := range frame.Rows() {
110- frameRow := frame.Row(y)
111- committedRow := committed.Row(y)
112 dirty := false110 dirty := false
113- for x := range frame.Width {111+ for x := range frame.Columns() {
114- if !frameRow[x].Equal(committedRow[x]) {112+ if !frame.Cell(x, y).Equal(committed.Cell(x, y)) {
115 dirty = true113 dirty = true
116 break114 break
117 }115 }
@@ -120,14 +118,14 @@ func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) {
120 if len(spans) == maxPlannedDamageSpans {118 if len(spans) == maxPlannedDamageSpans {
121 return nil, true119 return nil, true
122 }120 }
123- spans = append(spans, Span{Y: y, Width: frame.Width})121+ spans = append(spans, Span{Y: y, Width: frame.Columns()})
124 }122 }
125 }123 }
126 return spans, false124 return spans, false
127 }125 }
128 126
129-func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool {127+func deltaCostsSnapshot(frame CellSource, spans []Span, hasScroll bool) bool {
130- if !hasScroll && frame.Height > 1 && len(spans) == 1 {128+ if !hasScroll && frame.Rows() > 1 && len(spans) == 1 {
131 return false129 return false
132 }130 }
133 131
@@ -139,11 +137,11 @@ func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool {
139 deltaCost += 12 + int64(span.Width)137 deltaCost += 12 + int64(span.Width)
140 }138 }
141 139
142- minimumSnapshotCost := int64(frame.Height) * (12 + int64(frame.Width))140+ minimumSnapshotCost := int64(frame.Rows()) * (12 + int64(frame.Columns()))
143 if deltaCost < minimumSnapshotCost {141 if deltaCost < minimumSnapshotCost {
144 return false142 return false
145 }143 }
146- maximumSnapshotCost := int64(frame.Height) * (8 + 5*int64(frame.Width))144+ maximumSnapshotCost := int64(frame.Rows()) * (8 + 5*int64(frame.Columns()))
147 if deltaCost >= maximumSnapshotCost {145 if deltaCost >= maximumSnapshotCost {
148 return true146 return true
149 }147 }
@@ -152,40 +150,43 @@ func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool {
152 }150 }
153 151
154 for _, span := range spans {152 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)
156 }154 }
157 var snapshotCost int64155 var snapshotCost int64
158- for y := range frame.Height {156+ for y := range frame.Rows() {
159- snapshotCost += spanCost(frame, y, 0, frame.Width)157+ snapshotCost += spanCost(frame, y, 0, frame.Columns())
160 }158 }
161 return deltaCost >= snapshotCost159 return deltaCost >= snapshotCost
162 }160 }
163 161
164-func fullWidthRows(frame Frame, spans []Span) bool {162+func fullWidthRows(frame CellSource, spans []Span) bool {
165- if len(spans) != frame.Height {163+ if len(spans) != frame.Rows() {
166 return false164 return false
167 }165 }
168 for y, span := range spans {166 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() {
170 return false168 return false
171 }169 }
172 }170 }
173 return true171 return true
174 }172 }
175 173
176-func spanCost(frame Frame, y, x, width int) int64 {174+func spanCost(frame CellSource, y, x, width int) int64 {
177- return 8 + int64(width) + 4*int64(styleRunCount(frame.Row(y)[x:x+width]))175+ return 8 + int64(width) + 4*int64(styleRunCountIn(frame, y, x, width))
178 }176 }
179 177
180-func styleRunCount(cells []Cell) int {178+func styleRunCountIn(frame CellSource, y, x, width int) int {
181- if len(cells) == 0 {179+ if width == 0 {
182 return 0180 return 0
183 }181 }
184 runs := 1182 runs := 1
185- for i := 1; i < len(cells); i++ {183+ previous := frame.Cell(x, y).Style
186- if cells[i-1].Style != cells[i].Style && !cells[i-1].Style.Equal(cells[i].Style) {184+ for column := x + 1; column < x+width; column++ {
185+ style := frame.Cell(column, y).Style
186+ if previous != style && !previous.Equal(style) {
187 runs++187 runs++
188 }188 }
189+ previous = style
189 }190 }
190 return runs191 return runs
191 }192 }
@@ -204,9 +205,9 @@ func (c DeltaCandidate) Commit(dst *Frame) {
204 dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)205 dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)
205 }206 }
206 for _, span := range c.Plan.Spans {207 for _, span := range c.Plan.Spans {
207- dstRow := dst.Row(span.Y)208+ for x := span.X; x < span.X+span.Width; x++ {
208- srcRow := c.frame.Row(span.Y)209+ dst.Set(x, span.Y, c.frame.Cell(x, span.Y))
209- copy(dstRow[span.X:span.X+span.Width], srcRow[span.X:span.X+span.Width])210+ }
210 }211 }
211 }212 }
212 213
M ansi/delta_plan_test.go +3 -8
@@ -169,7 +169,7 @@ func TestNoOpDeltaCandidateDoesNotCloneFrame(t *testing.T) {
169 if err != nil {169 if err != nil {
170 t.Fatalf("PlanDelta() error = %v", err)170 t.Fatalf("PlanDelta() error = %v", err)
171 }171 }
172- if candidate.frame.Width != 0 || candidate.frame.Height != 0 || candidate.frame.Cells != nil {172+ if candidate.frame.Width != 0 || candidate.frame.Height != 0 {
173 t.Fatal("no-op candidate owns frame storage")173 t.Fatal("no-op candidate owns frame storage")
174 }174 }
175 175
@@ -202,12 +202,11 @@ func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) {
202 }202 }
203 }203 }
204 204
205-func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) {205+func TestDeltaCandidateCommitCopiesLogicalRows(t *testing.T) {
206 frame := testFrame("aaaa", "bbbb", "cccc", "dddd")206 frame := testFrame("aaaa", "bbbb", "cccc", "dddd")
207 frame.ScrollUp(0, 3, 1)207 frame.ScrollUp(0, 3, 1)
208 fillFrameRow(frame, 3, "new!")208 fillFrameRow(frame, 3, "new!")
209 committed := NewFrame(4, 4)209 committed := NewFrame(4, 4)
210- cellsBase := &committed.Cells[0]
211 210
212 candidate, err := PlanDelta(frame, nil, committed, true)211 candidate, err := PlanDelta(frame, nil, committed, true)
213 if err != nil {212 if err != nil {
@@ -215,9 +214,6 @@ func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) {
215 }214 }
216 candidate.Commit(&committed)215 candidate.Commit(&committed)
217 216
218- if &committed.Cells[0] != cellsBase {
219- t.Fatal("Commit replaced same-sized cell storage")
220- }
221 for y := range frame.Height {217 for y := range frame.Height {
222 if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) {218 if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) {
223 t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y))219 t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y))
@@ -229,8 +225,7 @@ func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) {
229 }225 }
230 226
231 func TestPlanDeltaRejectsInvalidFrame(t *testing.T) {227 func TestPlanDeltaRejectsInvalidFrame(t *testing.T) {
232- frame := NewFrame(2, 1)228+ frame := Frame{Width: 2, Height: 1}
233- frame.Cells = nil
234 if _, err := PlanDelta(frame, nil, Frame{}, false); err == nil {229 if _, err := PlanDelta(frame, nil, Frame{}, false); err == nil {
235 t.Fatal("PlanDelta accepted an invalid frame")230 t.Fatal("PlanDelta accepted an invalid frame")
236 }231 }
M ansi/doc.go +2 -2
@@ -2,8 +2,8 @@
2 // github.com/bnema/vev-vt/core model.2 // github.com/bnema/vev-vt/core model.
3 //3 //
4 // The package owns output planning, ANSI encoding, and a transactional renderer4 // The package owns output planning, ANSI encoding, and a transactional renderer
5-// shadow. It does not own Cell, Style, RGB, Frame, Damage, or RuneWidth; those5+// shadow. Renderers consume the read-only CellSource contract. Cell, Style, RGB,
6-// values are aliases of the core package and are owned by core.6+// Frame, CellSource, Damage, and RuneWidth are aliases of the core package.
7 //7 //
8 // [New] preserves the historical truecolor behavior. A renderer created by8 // [New] preserves the historical truecolor behavior. A renderer created by
9 // [NewWithColorProfile] projects every color-bearing style field to its chosen9 // [NewWithColorProfile] projects every color-bearing style field to its chosen
M ansi/draw_state.go +9 -8
@@ -165,22 +165,23 @@ const (
165 // entirely blank full-width line may instead use a vertical-only move165 // entirely blank full-width line may instead use a vertical-only move
166 // plus 2K when that encoding is cheaper. Both clears require the default166 // plus 2K when that encoding is cheaper. Both clears require the default
167 // pen (BCE: terminals fill cleared cells with the pen background).167 // 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) {
169 end := x + width169 end := x + width
170+ columns := frame.Columns()
170 // Trailing blank suffix is EL-eligible only when the span reaches the171 // Trailing blank suffix is EL-eligible only when the span reaches the
171 // frame's right edge (EL clears to the end of the line, nothing less).172 // frame's right edge (EL clears to the end of the line, nothing less).
172 t := end173 t := end
173- if end == frame.Width {174+ if end == columns {
174- for t > x && isBlank(frame.At(t-1, y)) {175+ for t > x && isBlank(frame.Cell(t-1, y)) {
175 t--176 t--
176 }177 }
177 }178 }
178- useEL := end == frame.Width && end-t >= elLen179+ useEL := end == columns && end-t >= elLen
179 if !useEL {180 if !useEL {
180 t = end181 t = end
181 }182 }
182 183
183- if useEL && t == x && x == 0 && width == frame.Width {184+ if useEL && t == x && x == 0 && width == columns {
184 // Entire line blank: compare "move to col 0 + EL" against185 // Entire line blank: compare "move to col 0 + EL" against
185 // "vertical-only move + 2K" (2K clears the whole line from any186 // "vertical-only move + 2K" (2K clears the whole line from any
186 // column, so a cheap same-column CUD/CUU can be reused).187 // 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
195 196
196 st.moveTo(out, y, x)197 st.moveTo(out, y, x)
197 for col := x; col < t; col++ {198 for col := x; col < t; col++ {
198- cell := frame.At(col, y)199+ cell := frame.Cell(col, y)
199 // Continuation cells are the right half of a wide rune. Emit nothing:200 // Continuation cells are the right half of a wide rune. Emit nothing:
200 // the terminal already advanced two columns for the left cell's rune.201 // the terminal already advanced two columns for the left cell's rune.
201 if cell.Continuation {202 if cell.Continuation {
@@ -222,7 +223,7 @@ func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st
222 out.WriteString("\x1b[K")223 out.WriteString("\x1b[K")
223 // EL does not move the cursor: it stays at the start of the blanks.224 // EL does not move the cursor: it stays at the start of the blanks.
224 st.setCursor(y, t)225 st.setCursor(y, t)
225- } else if end >= frame.Width {226+ } else if end >= columns {
226 st.invalidateCursor()227 st.invalidateCursor()
227 } else {228 } else {
228 st.setCursor(y, end)229 st.setCursor(y, end)
@@ -234,7 +235,7 @@ func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st
234 // malformed inputs the VT layer should never produce, but tracking must235 // malformed inputs the VT layer should never produce, but tracking must
235 // stay certain, so drop it. (t is never a continuation on the EL path —236 // stay certain, so drop it. (t is never a continuation on the EL path —
236 // blanks are not continuations — so this check is exact for both paths.)237 // 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) {
238 st.invalidateCursor()239 st.invalidateCursor()
239 }240 }
240 }241 }
M ansi/frame.go +48 -1
@@ -1,9 +1,56 @@
1 package ansi1 package ansi
2 2
3-import "github.com/bnema/vev-vt/core"3+import (
4+ "fmt"
5+ "reflect"
6+
7+ "github.com/bnema/vev-vt/core"
8+)
4 9
5 // Frame is an alias for the frontend-neutral terminal grid. ANSI rendering10 // Frame is an alias for the frontend-neutral terminal grid. ANSI rendering
6 // consumes it but does not own its model or storage policy.11 // consumes it but does not own its model or storage policy.
7 type Frame = core.Frame12 type Frame = core.Frame
8 13
14+// CellSource is the read-only semantic grid consumed by ANSI rendering.
15+type CellSource = core.CellSource
16+
9 var NewFrame = core.NewFrame17 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 {
22 colorProfile ColorProfile22 colorProfile ColorProfile
23 width int23 width int
24 height int24 height int
25- shadow []Cell25+ hasCommitted bool
26 committed Frame26 committed Frame
27 }27 }
28 28
@@ -47,7 +47,7 @@ func NewWithColorProfile(caps Capabilities, profile ColorProfile) *Renderer {
47 func (r *Renderer) Reset() {47 func (r *Renderer) Reset() {
48 r.width = 048 r.width = 0
49 r.height = 049 r.height = 0
50- r.shadow = nil50+ r.hasCommitted = false
51 r.committed = Frame{}51 r.committed = Frame{}
52 }52 }
53 53
@@ -63,7 +63,13 @@ func (p *PreparedDraw) Commit() {
63 }63 }
64 p.commitOnce.Do(func() {64 p.commitOnce.Do(func() {
65 committed := p.renderer.committedFrame()65 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+ }
67 p.renderer.setCommittedFrame(committed)73 p.renderer.setCommittedFrame(committed)
68 })74 })
69 }75 }
@@ -71,19 +77,21 @@ func (p *PreparedDraw) Commit() {
71 // Prepare plans and encodes a transactional draw. The renderer advances only77 // Prepare plans and encodes a transactional draw. The renderer advances only
72 // when the returned draw is committed. Keep at most one prepared draw78 // when the returned draw is committed. Keep at most one prepared draw
73 // outstanding; commit or discard it before calling Prepare again.79 // 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) {
75 var candidate DeltaCandidate81 var candidate DeltaCandidate
76 var err error82 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) {83+ if err = validateCellSource(frame); err != nil {
78- if err = frame.Validate(); err == nil {84+ return PreparedDraw{}, err
79- plan := planSingleDamage(frame, damage[0])85+ }
80- candidate = DeltaCandidate{Plan: plan}86+ columns, rows := frame.Columns(), frame.Rows()
81- if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {87+ if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
82- candidate.frame = frame.Clone()88+ plan := planSingleDamage(frame, damage[0])
83- }89+ candidate = DeltaCandidate{Plan: plan}
90+ if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
91+ candidate.frame = cloneCellSource(frame)
84 }92 }
85 } else {93 } 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)
87 }95 }
88 if err != nil {96 if err != nil {
89 return PreparedDraw{}, err97 return PreparedDraw{}, err
@@ -114,7 +122,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr
114 } else {122 } else {
115 if plan.Scroll.Height != 0 {123 if plan.Scroll.Height != 0 {
116 scroll := plan.Scroll124 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})
118 }126 }
119 for _, span := range plan.Spans {127 for _, span := range plan.Spans {
120 r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st)128 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
128 return prepared, nil136 return prepared, nil
129 }137 }
130 138
131-func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {139+func (r *Renderer) Draw(frame CellSource, damage []Damage) ([]byte, error) {
132 prepared, err := r.Prepare(frame, damage, false)140 prepared, err := r.Prepare(frame, damage, false)
133 if err != nil {141 if err != nil {
134 return nil, err142 return nil, err
@@ -144,7 +152,7 @@ func (r *Renderer) committedFrame() Frame {
144 func (r *Renderer) setCommittedFrame(frame Frame) {152 func (r *Renderer) setCommittedFrame(frame Frame) {
145 r.width = frame.Width153 r.width = frame.Width
146 r.height = frame.Height154 r.height = frame.Height
147- r.shadow = frame.Cells155+ r.hasCommitted = true
148 r.committed = frame156 r.committed = frame
149 }157 }
150 158
@@ -185,22 +193,23 @@ func hasScrollDamage(damage []Damage) bool {
185 return false193 return false
186 }194 }
187 195
188-func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {196+func (r *Renderer) writeFull(out *bytes.Buffer, frame CellSource, st *drawState) {
189- for y := range frame.Height {197+ columns := frame.Columns()
190- r.emitSpan(out, frame, y, 0, frame.Width, st)198+ for y := range frame.Rows() {
199+ r.emitSpan(out, frame, y, 0, columns, st)
191 }200 }
192 out.WriteString("\x1b[0m")201 out.WriteString("\x1b[0m")
193 }202 }
194 203
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) {
196 for _, span := range spans {205 for _, span := range spans {
197 r.emitSpan(out, frame, span.Y, span.X, span.Width, st)206 r.emitSpan(out, frame, span.Y, span.X, span.Width, st)
198 }207 }
199 out.WriteString("\x1b[0m")208 out.WriteString("\x1b[0m")
200 }209 }
201-func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {210+func clampRect(frame CellSource, x, y, width, height int) (int, int, int, int, bool) {
202- x, width, okX := clampRange(x, width, frame.Width)211+ x, width, okX := clampRange(x, width, frame.Columns())
203- y, height, okY := clampRange(y, height, frame.Height)212+ y, height, okY := clampRange(y, height, frame.Rows())
204 if !okX || !okY {213 if !okX || !okY {
205 return 0, 0, 0, 0, false214 return 0, 0, 0, 0, false
206 }215 }
M ansi/renderer_test.go +16 -19
@@ -146,7 +146,7 @@ func TestPrepareNoOpDoesNotCloneFrame(t *testing.T) {
146 if err != nil {146 if err != nil {
147 t.Fatal(err)147 t.Fatal(err)
148 }148 }
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 {
150 t.Fatal("no-op prepared draw owns frame storage")150 t.Fatal("no-op prepared draw owns frame storage")
151 }151 }
152 prepared.Commit()152 prepared.Commit()
@@ -193,10 +193,8 @@ func TestStyleResetDiscipline(t *testing.T) {
193 193
194 // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage.194 // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage.
195 frame2 := NewFrame(4, 3)195 frame2 := NewFrame(4, 3)
196- copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2196+ frame2.WriteRow(0, 0, frame.Row(1))
197- for i := 8; i < 12; i++ {197+ frame2.WriteRow(1, 0, frame.Row(2))
198- frame2.Cells[i] = BlankCell() // bottom row blank
199- }
200 frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row198 frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row
201 199
202 damage := []Damage{200 damage := []Damage{
@@ -353,9 +351,8 @@ func TestScrollFastPath(t *testing.T) {
353 351
354 // Build a new frame that has been scrolled up by 1 (like the VT model does).352 // Build a new frame that has been scrolled up by 1 (like the VT model does).
355 scrolled := NewFrame(5, 4)353 scrolled := NewFrame(5, 4)
356- copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3354+ for y := 0; y < 3; y++ {
357- for i := 15; i < 20; i++ {355+ scrolled.WriteRow(y, 0, frame.Row(y+1))
358- scrolled.Cells[i] = BlankCell() // row 3 blanked
359 }356 }
360 scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()})357 scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()})
361 scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()})358 scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()})
@@ -567,8 +564,8 @@ func TestReset(t *testing.T) {
567 564
568 r.Reset()565 r.Reset()
569 566
570- if r.width != 0 || r.height != 0 || r.shadow != nil {567+ if r.width != 0 || r.height != 0 || r.hasCommitted {
571- t.Fatal("Reset should clear width/height/shadow")568+ t.Fatal("Reset should clear committed state")
572 }569 }
573 570
574 // After reset, the next draw should be a full draw.571 // After reset, the next draw should be a full draw.
@@ -692,10 +689,8 @@ func TestScrollOnlyDamage(t *testing.T) {
692 689
693 // Scroll up 1 but the VT model would also emit DamageText for the new bottom row.690 // Scroll up 1 but the VT model would also emit DamageText for the new bottom row.
694 scrolled := NewFrame(4, 3)691 scrolled := NewFrame(4, 3)
695- copy(scrolled.Cells[0:8], frame.Cells[4:12])692+ scrolled.WriteRow(0, 0, frame.Row(1))
696- for i := 8; i < 12; i++ {693+ scrolled.WriteRow(1, 0, frame.Row(2))
697- scrolled.Cells[i] = BlankCell()
698- }
699 scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()})694 scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()})
700 695
701 damage := []Damage{696 damage := []Damage{
@@ -838,7 +833,7 @@ func TestFrameValidate(t *testing.T) {
838 }{833 }{
839 {name: "valid frame", frame: NewFrame(5, 3), wantErr: false},834 {name: "valid frame", frame: NewFrame(5, 3), wantErr: false},
840 {name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true},835 {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},
842 }837 }
843 838
844 for _, tt := range tests {839 for _, tt := range tests {
@@ -995,9 +990,11 @@ func TestDrawFallbackAndClampingEdgeCases(t *testing.T) {
995 if rows := strings.Count(string(out), "H"); rows < changed.Height {990 if rows := strings.Count(string(out), "H"); rows < changed.Height {
996 t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out))991 t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out))
997 }992 }
998- for i := range changed.Cells {993+ for y := range changed.Height {
999- if !r.shadow[i].Equal(changed.Cells[i]) {994+ for x := range changed.Width {
1000- t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i])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+ }
1001 }998 }
1002 }999 }
1003 },1000 },
@@ -1400,7 +1397,7 @@ func TestRendererPrepare(t *testing.T) {
1400 1397
1401 func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {1398 func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {
1402 frame := NewFrame(2, 1)1399 frame := NewFrame(2, 1)
1403- committed := Frame{Width: 2, Height: 1, Cells: make([]Cell, 2)}1400+ committed := Frame{Width: 2, Height: 1}
1404 1401
1405 if _, err := PlanDelta(frame, nil, committed, false); err == nil {1402 if _, err := PlanDelta(frame, nil, committed, false); err == nil {
1406 t.Fatal("PlanDelta accepted an invalid committed frame")1403 t.Fatal("PlanDelta accepted an invalid committed frame")
M ansi/scroll.go +27 -7
@@ -5,7 +5,7 @@ import (
5 "strconv"5 "strconv"
6 )6 )
7 7
8-func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) {8+func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) {
9 for _, d := range damage {9 for _, d := range damage {
10 if d.Kind == DamageScrollUp && isSafeScroll(frame, d) {10 if d.Kind == DamageScrollUp && isSafeScroll(frame, d) {
11 return d, true11 return d, true
@@ -14,8 +14,8 @@ func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) {
14 return Damage{}, false14 return Damage{}, false
15 }15 }
16 16
17-func isSafeScroll(frame Frame, d Damage) bool {17+func isSafeScroll(frame CellSource, 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.Height18+ 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
19 }19 }
20 20
21 func emitScrollUp(out *bytes.Buffer, d Damage) {21 func emitScrollUp(out *bytes.Buffer, d Damage) {
@@ -42,13 +42,33 @@ func emitScrollUp(out *bytes.Buffer, d Damage) {
42 out.WriteString("\x1b[r")42 out.WriteString("\x1b[r")
43 }43 }
44 44
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 {
46 for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {68 for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {
47- frameRow := frame.Row(y)
48- committedRow := committed.Row(y + scroll.Count)
49 for x := range scroll.Width {69 for x := range scroll.Width {
50 column := scroll.X + x70 column := scroll.X + x
51- committedCell, frameCell := committedRow[column], frameRow[column]71+ committedCell, frameCell := committed.At(column, y+scroll.Count), frame.At(column, y)
52 if committedCell == frameCell || committedCell.Equal(frameCell) {72 if committedCell == frameCell || committedCell.Equal(frameCell) {
53 continue73 continue
54 }74 }
M benchmark_test.go +2 -2
@@ -32,8 +32,8 @@ func TestHistoryViewPartialTailAllocationBounded(t *testing.T) {
32 32
33 // Copying a partial tail costs one allocation per row plus a fixed set of33 // Copying a partial tail costs one allocation per row plus a fixed set of
34 // headers: the chunk slice, its growth for the tail chunk, the chunk itself,34 // headers: the chunk slice, its growth for the tail chunk, the chunk itself,
35- // the row slice, and the bounds slice parallel to it.35+ // the row slice, bounds, and logical-style metadata parallel to it.
36- const tailHeaders = 436+ const tailHeaders = 6
37 37
38 sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() })38 sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() })
39 partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() })39 partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() })
M buffer.go +20 -25
@@ -24,13 +24,9 @@ func newBuffer(width, height int) *buffer {
24 return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height), rowIDs: make([]RowID, height)}24 return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height), rowIDs: make([]RowID, height)}
25 }25 }
26 26
27-func bufferFromFrame(frame renderer.Frame) *buffer {
28- return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height), rowIDs: make([]RowID, frame.Height)}
29-}
30-
31 func (b *buffer) clone() *buffer {27 func (b *buffer) clone() *buffer {
32 out := &buffer{28 out := &buffer{
33- frame: cloneFrame(b.frame),29+ frame: b.frame.Clone(),
34 boundaries: append([]LineBound(nil), b.boundaries...),30 boundaries: append([]LineBound(nil), b.boundaries...),
35 rowIDs: append([]RowID(nil), b.rowIDs...),31 rowIDs: append([]RowID(nil), b.rowIDs...),
36 }32 }
@@ -169,7 +165,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([]
169 evictedBounds := make([]LineBound, 0, shift)165 evictedBounds := make([]LineBound, 0, shift)
170 evictedIDs := make([]RowID, 0, shift)166 evictedIDs := make([]RowID, 0, shift)
171 for y := range shift {167 for y := range shift {
172- evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))168+ evicted = append(evicted, b.frame.Row(y))
173 // The evicted row keeps its source width, so its extent needs no clamping.169 // The evicted row keeps its source width, so its extent needs no clamping.
174 evictedBounds = append(evictedBounds, b.boundaries[y])170 evictedBounds = append(evictedBounds, b.boundaries[y])
175 evictedIDs = append(evictedIDs, b.rowIDs[y])171 evictedIDs = append(evictedIDs, b.rowIDs[y])
@@ -190,7 +186,9 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([]
190 if sy >= b.frame.Height {186 if sy >= b.frame.Height {
191 break187 break
192 }188 }
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+ }
194 next.boundaries[y] = b.boundaries[sy]192 next.boundaries[y] = b.boundaries[sy]
195 next.rowIDs[y] = b.rowIDs[sy]193 next.rowIDs[y] = b.rowIDs[sy]
196 next.boundaries[y].End = min(next.boundaries[y].End, width)194 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
312 dst.rowIDs[outputRow-shift] = id310 dst.rowIDs[outputRow-shift] = id
313 }311 }
314 }312 }
315- blankEvicted := func(outputRow int) []renderer.Cell {313+ blankOutput := func(outputRow int) {
316 if outputRow < shift {314 if outputRow < shift {
317 out := evicted[outputRow*width : (outputRow+1)*width]315 out := evicted[outputRow*width : (outputRow+1)*width]
318 for i := range out {316 for i := range out {
319 out[i] = renderer.BlankCell()317 out[i] = renderer.BlankCell()
320 }318 }
321- return out319+ }
320+ }
321+ setOutput := func(outputRow, column int, cell renderer.Cell) {
322+ if outputRow < shift {
323+ evicted[outputRow*width+column] = cell
324+ return
322 }325 }
323 if dst != nil && outputRow < shift+dst.frame.Height {326 if dst != nil && outputRow < shift+dst.frame.Height {
324- return dst.frame.Row(outputRow - shift)327+ dst.frame.Set(column, outputRow-shift, cell)
325 }328 }
326- return nil
327- }
328- var output []renderer.Cell
329- if dst != nil && (shift > 0 || dst.frame.Height > 0) {
330- output = blankEvicted(0)
331 }329 }
330+ blankOutput(0)
332 finishRow := func(soft bool) {331 finishRow := func(soft bool) {
333 // An output row belongs either to history or to the retained viewport, and332 // An output row belongs either to history or to the retained viewport, and
334 // only this pass knows where it ended, so both destinations are written here.333 // 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
344 row++343 row++
345 col = 0344 col = 0
346 if dst != nil {345 if dst != nil {
347- output = blankEvicted(row)346+ blankOutput(row)
348 }347 }
349 }348 }
350 setPoint := func(line, offset, pointRow, pointCol int) {349 setPoint := func(line, offset, pointRow, pointCol int) {
@@ -381,17 +380,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
381 }380 }
382 truncated := false381 truncated := false
383 for y := start; y <= end && !truncated; y++ {382 for y := start; y <= end && !truncated; y++ {
384- cells := b.frame.Row(y)
385 limit := b.boundaries[y].End383 limit := b.boundaries[y].End
386 for x := 0; x < limit; {384 for x := 0; x < limit; {
387- cell := cells[x]385+ cell := b.frame.At(x, y)
388 if cell.Continuation { // Repair malformed rows by dropping orphaned tails.386 if cell.Continuation { // Repair malformed rows by dropping orphaned tails.
389 setPoint(start, offset, row, col)387 setPoint(start, offset, row, col)
390 x++388 x++
391 offset++389 offset++
392 continue390 continue
393 }391 }
394- wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation392+ wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && b.frame.At(x+1, y).Continuation
395 w := 1393 w := 1
396 if wide && width >= 2 {394 if wide && width >= 2 {
397 w = 2395 w = 2
@@ -406,9 +404,8 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
406 }404 }
407 setPoint(start, offset, row, col)405 setPoint(start, offset, row, col)
408 if wide && width >= 2 {406 if wide && width >= 2 {
409- if output != nil {407+ setOutput(row, col, cell)
410- output[col], output[col+1] = cell, cells[x+1]408+ setOutput(row, col+1, b.frame.At(x+1, y))
411- }
412 setPoint(start, offset+1, row, col+1)409 setPoint(start, offset+1, row, col+1)
413 col += 2410 col += 2
414 x += 2411 x += 2
@@ -419,9 +416,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh
419 cell.Rune = '\uFFFD'416 cell.Rune = '\uFFFD'
420 cell.Continuation = false417 cell.Continuation = false
421 }418 }
422- if output != nil {419+ setOutput(row, col, cell)
423- output[col] = cell
424- }
425 col++420 col++
426 x++421 x++
427 offset++422 offset++
M core/cell.go +2 -1
@@ -9,11 +9,12 @@ package core
9 type Cell struct {9 type Cell struct {
10 Rune rune10 Rune rune
11 Style Style11 Style Style
12+ Payload CellPayload
12 Continuation bool13 Continuation bool
13 }14 }
14 15
15 func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} }16 func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} }
16 17
17 func (c Cell) Equal(other Cell) bool {18 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)
19 }20 }
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 @@
1 // Package vtcore defines the frontend-neutral terminal model shared by the VT1 // Package vtcore defines the frontend-neutral terminal model shared by the VT
2 // emulator and concrete ANSI renderers.2 // emulator and concrete ANSI renderers.
3 //3 //
4-// Frame and other mutable values are single-owner. Frame.Row returns a backing4+// Frame and other mutable values are single-owner. CellSource provides
5-// slice that is valid until the next scroll or resize; Frame.Clone returns5+// storage-independent semantic reads, Frame.Row returns owned storage, and
6-// independent storage. The package has no frontend, transport, or vev policy.6+// Frame.Clone returns an independent frame. The package has no frontend,
7+// transport, or vev policy.
7 package core8 package core
M core/frame.go +323 -104
@@ -1,169 +1,388 @@
1 package core1 package core
2 2
3-import "fmt"3+import (
4-4+ "fmt"
5-// Frame is a fixed-size grid of cells. Logical rows are decoupled from their5+ "maps"
6-// physical storage through lineOffset: logical row y lives at6+ "math"
7-// Cells[lineOffset[y] : lineOffset[y]+Width]. This indirection lets full-width7+)
8-// scrolls rotate offsets instead of copying cell memory (NeoVim's grid8+
9-// technique; cf. nvim grid.c line_offset[]). All cell access must go through9+// CellSource exposes semantic terminal cells without exposing mutable storage.
10-// the accessors (At/Set/Row) so callers never assume a physical layout.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.
11 type Frame struct {61 type Frame struct {
12 Width int62 Width int
13 Height int63 Height int
14- Cells []Cell64+ page *cellPage
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
18 }65 }
19 66
20 func NewFrame(width, height int) Frame {67 func NewFrame(width, height int) Frame {
21- cells := make([]Cell, width*height)68+ cellCount, ok := frameCellCount(width, height)
22- for i := range cells {69+ if !ok {
23- cells[i] = BlankCell()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 = ' '
24 }81 }
25- lineOffset := make([]int, height)82+ for y := range page.rows {
26- for y := range lineOffset {83+ page.rows[y] = uint32(y * width)
27- lineOffset[y] = y * width
28 }84 }
29- return Frame{Width: width, Height: height, Cells: cells, lineOffset: lineOffset}85+ return Frame{Width: width, Height: height, page: page}
30 }86 }
31 87
32-// Clone returns an independent copy preserving logical row contents. The clone88+// Clone returns an independent compact page preserving physical row rotation
33-// uses canonical row storage so later scrolls or writes to the source frame do89+// and page-local style IDs.
34-// not affect it.
35 func (f Frame) Clone() Frame {90 func (f Frame) Clone() Frame {
36- clone := NewFrame(f.Width, f.Height)91+ if f.page == nil {
37- for y := range f.Height {92+ return Frame{Width: f.Width, Height: f.Height}
38- copy(clone.Row(y), f.Row(y))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+ },
39 }109 }
40- return clone
41 }110 }
42 111
43-// Replace copies src into f while reusing canonical storage when dimensions112+// Replace replaces f with an independent structural copy of src's compact page.
44-// permit. src is read through logical rows so its physical row rotation is113+// An invalid or empty source leaves the destination unchanged.
45-// preserved in the resulting contents without exposing layout internals.
46 func (f *Frame) Replace(src Frame) {114 func (f *Frame) Replace(src Frame) {
47 if f == nil {115 if f == nil {
48 return116 return
49 }117 }
50- if src.Width <= 0 || src.Height <= 0 || len(src.Cells) != src.Width*src.Height {118+ if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 {
51- *f = Frame{}
52 return119 return
53 }120 }
121+ *f = src.Clone()
122+}
54 123
55- sameDimensions := f.Width == src.Width && f.Height == src.Height124+func frameCellCount(width, height int) (int, bool) {
56- cellCount := src.Width * src.Height125+ if width < 0 || height < 0 {
57- if sameDimensions && cap(f.Cells) >= cellCount {126+ return 0, false
58- f.Cells = f.Cells[:cellCount]
59- } else {
60- f.Cells = make([]Cell, cellCount)
61 }127 }
62- if sameDimensions && cap(f.lineOffset) >= src.Height {128+ limit := min(uint64(math.MaxUint32), uint64(math.MaxInt))
63- f.lineOffset = f.lineOffset[:src.Height]129+ w, h := uint64(width), uint64(height)
64- } else {130+ if w > limit || h > limit || w != 0 && w*h > limit {
65- f.lineOffset = make([]int, src.Height)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")
66 }139 }
67- f.Width = src.Width140+ cellCount, ok := frameCellCount(f.Width, f.Height)
68- f.Height = src.Height141+ if !ok {
69- for y := range f.lineOffset {142+ return fmt.Errorf("frame cell count exceeds page limit")
70- f.lineOffset[y] = y * f.Width
71 }143 }
72- for y := range src.Height {144+ if len(f.page.cells) != cellCount {
73- copy(f.Row(y), src.Row(y))145+ return fmt.Errorf("invalid cell count: got %d want %d", len(f.page.cells), cellCount)
74 }146 }
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
75 }151 }
76 152
77 func (f Frame) Validate() error {153 func (f Frame) Validate() error {
78 if f.Width <= 0 || f.Height <= 0 {154 if f.Width <= 0 || f.Height <= 0 {
79 return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height)155 return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height)
80 }156 }
81- if len(f.Cells) != f.Width*f.Height {157+ return f.validateStorage()
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
88 }158 }
89 159
90-// CheckInvariants verifies that lineOffset is a permutation of the canonical160+// CheckInvariants validates row ownership, style references, dictionary keys,
91-// row offsets: every entry is a non-negative multiple of Width within bounds,161+// and exact reference counts. It is intended for tests and assertions.
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.
94 func (f Frame) CheckInvariants() error {162 func (f Frame) CheckInvariants() error {
95- if f.Width <= 0 {163+ if err := f.validateStorage(); err != nil {
96- return fmt.Errorf("invalid width %d", f.Width)164+ return err
97 }165 }
98- if len(f.lineOffset) != f.Height {166+ seenRows := make([]bool, f.Height)
99- return fmt.Errorf("lineOffset length %d != height %d", len(f.lineOffset), 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]++
100 }189 }
101- seen := make([]bool, f.Height)190+ free := make([]bool, len(f.page.styles))
102- for y, off := range f.lineOffset {191+ for position, id := range f.page.freeStyles {
103- if off < 0 || off%f.Width != 0 {192+ if int(id) >= len(f.page.styles) {
104- return fmt.Errorf("row %d: offset %d is not a non-negative multiple of width %d", y, off, f.Width)193+ return fmt.Errorf("free style entry %d has out-of-range ID %d", position, id)
105 }194 }
106- phys := off / f.Width195+ if f.page.styles[id].used {
107- if phys >= f.Height {196+ return fmt.Errorf("free style entry %d identifies used style %d", position, id)
108- return fmt.Errorf("row %d: offset %d maps to physical row %d out of range [0,%d)", y, off, phys, f.Height)
109 }197 }
110- if seen[phys] {198+ if free[id] {
111- return fmt.Errorf("row %d: physical row %d already mapped by another logical row", y, phys)199+ return fmt.Errorf("free style ID %d appears more than once", id)
112 }200 }
113- seen[phys] = true201+ free[id] = true
114 }202 }
115- return nil203+ 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()
116 }232 }
117 233
118-func (f Frame) At(x, y int) Cell {234+// LogicalBytes returns deterministic uncompressed page bytes independent of Go
119- return f.Cells[f.lineOffset[y]+x]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
120 }241 }
121 242
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+
122 func (f Frame) Set(x, y int, cell Cell) {259 func (f Frame) Set(x, y int, cell Cell) {
123- f.Cells[f.lineOffset[y]+x] = cell260+ 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}
124 }275 }
125 276
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.
129 func (f Frame) Row(y int) []Cell {277 func (f Frame) Row(y int) []Cell {
130- base := f.lineOffset[y]278+ if y < 0 || y >= f.Height {
131- return f.Cells[base : base+f.Width]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+ }
132 }315 }
133 316
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.
140 func (f Frame) ScrollUp(top, bottom, n int) {317 func (f Frame) ScrollUp(top, bottom, n int) {
141 for ; n > 0; n-- {318 for ; n > 0; n-- {
142- recycled := f.lineOffset[top]319+ recycled := f.page.rows[top]
143- for y := top; y < bottom; y++ {320+ copy(f.page.rows[top:bottom], f.page.rows[top+1:bottom+1])
144- f.lineOffset[y] = f.lineOffset[y+1]321+ f.page.rows[bottom] = recycled
145- }322+ f.blankPhysicalRow(recycled)
146- f.lineOffset[bottom] = recycled
147- blankRow(f.Cells[recycled : recycled+f.Width])
148 }323 }
149 }324 }
150 325
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.
154 func (f Frame) ScrollDown(top, bottom, n int) {326 func (f Frame) ScrollDown(top, bottom, n int) {
155 for ; n > 0; n-- {327 for ; n > 0; n-- {
156- recycled := f.lineOffset[bottom]328+ recycled := f.page.rows[bottom]
157- for y := bottom; y > top; y-- {329+ copy(f.page.rows[top+1:bottom+1], f.page.rows[top:bottom])
158- f.lineOffset[y] = f.lineOffset[y-1]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)
159 }353 }
160- f.lineOffset[top] = recycled354+ f.page.cells[index] = storedCell{rune: ' '}
161- blankRow(f.Cells[recycled : recycled+f.Width])
162 }355 }
163 }356 }
164 357
165-func blankRow(row []Cell) {358+func (f Frame) internStyle(style Style) uint32 {
166- for i := range row {359+ style = style.Canonical()
167- row[i] = BlankCell()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
168 }383 }
384+ delete(f.page.styleIndex, slot.style)
385+ *slot = styleSlot{}
386+ f.page.freeStyles = append(f.page.freeStyles, id)
387+ f.page.styleCount--
169 }388 }
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 @@
1 package core1 package core
2 2
3-import "testing"3+import (
4+ "math"
5+ "reflect"
6+ "testing"
7+)
4 8
5 func TestFrameCanonicalOffsetsAndInvariants(t *testing.T) {9 func TestFrameCanonicalOffsetsAndInvariants(t *testing.T) {
6 f := NewFrame(4, 3)10 f := NewFrame(4, 3)
7 for y := range f.Height {11 for y := range f.Height {
8- if f.lineOffset[y] != y*f.Width {12+ if f.page.rows[y] != uint32(y*f.Width) {
9- t.Fatalf("lineOffset[%d] = %d, want %d", y, f.lineOffset[y], y*f.Width)13+ t.Fatalf("row offset %d = %d, want %d", y, f.page.rows[y], y*f.Width)
10 }14 }
11 }15 }
12 if err := f.CheckInvariants(); err != nil {16 if err := f.CheckInvariants(); err != nil {
@@ -14,26 +18,222 @@ func TestFrameCanonicalOffsetsAndInvariants(t *testing.T) {
14 }18 }
15 }19 }
16 20
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+
17 func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) {192 func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) {
18 f := NewFrame(4, 3)193 f := NewFrame(4, 3)
19- f.lineOffset[1] = f.lineOffset[0]194+ f.page.rows[1] = f.page.rows[0]
20 if err := f.CheckInvariants(); err == nil {195 if err := f.CheckInvariants(); err == nil {
21 t.Fatal("expected duplicate physical row to be rejected")196 t.Fatal("expected duplicate physical row to be rejected")
22 }197 }
23 198
24 f = NewFrame(4, 3)199 f = NewFrame(4, 3)
25- f.lineOffset[0] = 1200+ f.page.rows[0] = 1
26 if err := f.CheckInvariants(); err == nil {201 if err := f.CheckInvariants(); err == nil {
27 t.Fatal("expected non-multiple offset to be rejected")202 t.Fatal("expected non-multiple offset to be rejected")
28 }203 }
29 204
30 f = NewFrame(4, 3)205 f = NewFrame(4, 3)
31- f.lineOffset[2] = 99206+ f.page.rows[2] = 99
32 if err := f.CheckInvariants(); err == nil {207 if err := f.CheckInvariants(); err == nil {
33 t.Fatal("expected out-of-range offset to be rejected")208 t.Fatal("expected out-of-range offset to be rejected")
34 }209 }
35 }210 }
36 211
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+
37 func TestReplacePreservesLogicalRows(t *testing.T) {237 func TestReplacePreservesLogicalRows(t *testing.T) {
38 dst := NewFrame(3, 3)238 dst := NewFrame(3, 3)
39 dst.Set(0, 0, Cell{Rune: 'x'})239 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 {
48 48
49 func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} }49 func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} }
50 50
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+
51 func (s Style) Equal(other Style) bool {78 func (s Style) Equal(other Style) bool {
52 if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse || s.Attrs != other.Attrs || s.UnderlineStyle != other.UnderlineStyle {79 if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse || s.Attrs != other.Attrs || s.UnderlineStyle != other.UnderlineStyle {
53 return false80 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"
6 // history, and renderer consumers share one cell/style/frame representation.6 // history, and renderer consumers share one cell/style/frame representation.
7 // The implementation and storage policy remain owned by the core package.7 // The implementation and storage policy remain owned by the core package.
8 type Cell = core.Cell8 type Cell = core.Cell
9+type CellPayload = core.CellPayload
9 type RGB = core.RGB10 type RGB = core.RGB
10 type StyleAttrs = core.StyleAttrs11 type StyleAttrs = core.StyleAttrs
11 type UnderlineStyle = core.UnderlineStyle12 type UnderlineStyle = core.UnderlineStyle
12 type Style = core.Style13 type Style = core.Style
13 type Frame = core.Frame14 type Frame = core.Frame
15+type CellSource = core.CellSource
14 16
15 type DamageKind = core.DamageKind17 type DamageKind = core.DamageKind
16 type Damage = core.Damage18 type Damage = core.Damage
@@ -35,10 +37,11 @@ const (
35 )37 )
36 38
37 var (39 var (
38- BlankCell = core.BlankCell40+ BlankCell = core.BlankCell
39- DefaultStyle = core.DefaultStyle41+ DefaultStyle = core.DefaultStyle
40- FullRedraw = core.FullRedraw42+ FullRedraw = core.FullRedraw
41- NewFrame = core.NewFrame43+ NewFrame = core.NewFrame
44+ NewCellPayload = core.NewCellPayload
42 )45 )
43 46
44 func RuneWidth(r rune) int { return core.RuneWidth(r) }47 func RuneWidth(r rune) int { return core.RuneWidth(r) }
M doc.go +5 -6
@@ -15,10 +15,9 @@
15 // or strings owned by the callback.15 // or strings owned by the callback.
16 //16 //
17 // Snapshot and HistorySnapshotView capture owned state without sealing or17 // Snapshot and HistorySnapshotView capture owned state without sealing or
18-// mutating the live owner. Row methods return owned copies. BorrowedRow and18+// mutating the live owner. Screen, ScreenSnapshot, and core.Frame expose
19-// HistoryView.Range expose immutable backing storage: callers must not mutate19+// storage-independent CellSource reads, and Row methods return owned copies.
20-// it, and Range callbacks must not retain their row after returning. A20+// HistoryView.Row and HistoryView.Range decode owned semantic rows from compact
21-// HistoryChunk pointer has stable identity for the lifetime of the view, so21+// slabs. A HistoryChunk pointer has stable identity for the lifetime of the
22-// consumers can reuse unchanged sealed chunks. Direct Frame.Row access is a22+// view, so consumers can reuse unchanged sealed chunks.
23-// mutable borrow valid only until the frame scrolls or resizes.
24 package vt23 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) {
12 screen := vt.NewScreen(6, 2)12 screen := vt.NewScreen(6, 2)
13 screen.Write([]byte("A界"))13 screen.Write([]byte("A界"))
14 14
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' {
16 t.Fatalf("ASCII cell = %#v, want A", got)20 t.Fatalf("ASCII cell = %#v, want A", got)
17 }21 }
18- if got := screen.Frame.At(1, 0); got.Rune != '界' || got.Continuation {22+ if got := source.Cell(1, 0); got.Rune != '界' || got.Continuation {
19 t.Fatalf("wide-rune lead cell = %#v, want 界 lead", got)23 t.Fatalf("wide-rune lead cell = %#v, want 界 lead", got)
20 }24 }
21- if got := screen.Frame.At(2, 0); !got.Continuation {25+ if got := source.Cell(2, 0); !got.Continuation {
22 t.Fatalf("wide-rune continuation = %#v, want continuation", got)26 t.Fatalf("wide-rune continuation = %#v, want continuation", got)
23 }27 }
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+ }
24 if got := vt.RuneWidth('界'); got != 2 {33 if got := vt.RuneWidth('界'); got != 2 {
25 t.Fatalf("RuneWidth(界) = %d, want 2", got)34 t.Fatalf("RuneWidth(界) = %d, want 2", got)
26 }35 }
@@ -29,7 +38,7 @@ func TestExternalScreenParserPreservesPublicTerminalModel(t *testing.T) {
29 // provide arbitrary PTY chunk boundaries.38 // provide arbitrary PTY chunk boundaries.
30 screen.Write([]byte("\x1b[2;2H\x1b[3"))39 screen.Write([]byte("\x1b[2;2H\x1b[3"))
31 screen.Write([]byte("1mX"))40 screen.Write([]byte("1mX"))
32- cell := screen.Frame.At(1, 1)41+ cell := screen.Cell(1, 1)
33 if cell.Rune != 'X' || cell.Style.Foreground != 1 {42 if cell.Rune != 'X' || cell.Style.Foreground != 1 {
34 t.Fatalf("split ANSI write = %#v, want red X", cell)43 t.Fatalf("split ANSI write = %#v, want red X", cell)
35 }44 }
@@ -133,9 +142,6 @@ func TestExternalSnapshotsAndHistoryRespectOwnershipContracts(t *testing.T) {
133 if got := before.Row(0)[0].Rune; got != 'a' {142 if got := before.Row(0)[0].Rune; got != 'a' {
134 t.Fatalf("owned history row mutation changed view to %q", got)143 t.Fatalf("owned history row mutation changed view to %q", got)
135 }144 }
136- if borrowed := before.BorrowedRow(0); &borrowed[0] == &copyOfRow[0] {
137- t.Fatal("Row returned borrowed history storage instead of an owned copy")
138- }
139 145
140 appendHistoryRow(t, history, 'c', 12)146 appendHistoryRow(t, history, 'c', 12)
141 after := history.View()147 after := history.View()
M extraction_line_callback_external_test.go +1 -1
@@ -26,7 +26,7 @@ func TestExternalLineEvictionCallbackReceivesStableCopy(t *testing.T) {
26 t.Fatalf("evicted rows = %#v, want one stable AAAA row", evicted)26 t.Fatalf("evicted rows = %#v, want one stable AAAA row", evicted)
27 }27 }
28 evicted[0][0] = core.Cell{Rune: 'X'}28 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' {
30 t.Fatalf("mutating callback row changed live frame to %q", got)30 t.Fatalf("mutating callback row changed live frame to %q", got)
31 }31 }
32 if got := evicted[0][0].Rune; got != 'X' {32 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
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "fmt"
5 "math"6 "math"
6 "slices"7 "slices"
7 8
8 renderer "github.com/bnema/vev-vt/core"9 renderer "github.com/bnema/vev-vt/core"
9 )10 )
10 11
11-// ErrHistoryRowTooWide is returned when a row cannot fit within the configured12+// ErrHistoryRowTooLarge is returned when one row cannot fit within the
12-// cell budget. The history is not modified when this error is returned.13+// configured logical-byte budget or compact page bounds. History is unchanged.
13-var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity")14+var ErrHistoryRowTooLarge = errors.New("history row exceeds logical byte capacity")
14 15
15 var errInvalidHistoryRowID = errors.New("invalid history row ID")16 var errInvalidHistoryRowID = errors.New("invalid history row ID")
16 17
17-// HistoryConfig controls the bounded terminal history retained by a Screen.18+const maxTailPreallocCells = 32 * 1024
18-type HistoryConfig struct {
19- MaxRows int
20- MaxCells int
21- ChunkRows int
22-}
23 19
24 // History stores terminal rows in immutable chunks. It is intended to be20 // History stores terminal rows in immutable chunks. It is intended to be
25 // mutated by the owner of a Screen; views are safe to retain after later21 // mutated by the owner of a Screen; views are safe to retain after later
26 // appends.22 // appends.
27 type History struct {23 type History struct {
28- maxRows int24+ maxRows int
29- maxCells int25+ rowCeiling int
30- chunkRows int26+ maxBytes uint64
31- chunks []*HistoryChunk27+ byteCeiling uint64
32- tail [][]renderer.Cell28+ chunkRows int
33- tailBounds []LineBound29+ chunks []*HistoryChunk
34- tailIDs []RowID30+ tail [][]renderer.Cell
35- rows int31+ cachedTail []*HistoryChunk
36- cells int32+ tailCells []renderer.Cell
37- nextRowID RowID33+ tailBounds []LineBound
38-}34+ tailIDs []RowID
39-35+ tailBytes uint64
40-// HistoryChunk is an immutable group of history rows. Its identity is stable36+ tailPageWidth int
41-// and can be used by consumers to avoid copying unchanged sealed chunks.37+ tailPageRows int
42-// bounds is parallel to rows and always has the same length.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.
43 type HistoryChunk struct {51 type HistoryChunk struct {
44- rows [][]renderer.Cell52+ page *sealedPage
45- bounds []LineBound53+ start int
46- rowIDs []RowID54+ 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
47 }179 }
48 180
49 // HistoryView is an immutable snapshot of history. Row returns a copy so the181 // HistoryView is an immutable snapshot of history. Row returns a copy so the
50 // storage behind a sealed chunk remains owned by VT.182 // storage behind a sealed chunk remains owned by VT.
51 type HistoryView struct {183 type HistoryView struct {
52- chunks []*HistoryChunk184+ chunks []*HistoryChunk
53- rows int185+ rows int
54- cells int186+ cells int
55- nextRowID RowID187+ logicalBytes uint64
188+ nextRowID RowID
56 }189 }
57 190
58 // HistorySnapshotView captures sealed history chunks and the mutable tail191 // HistorySnapshotView captures sealed history chunks and the mutable tail
59 // independently. Sealed chunks are shared by identity; Tail is owned by the192 // independently. Sealed chunks are shared by identity; Tail is owned by the
60 // view and can be serialized without rotating the live tail into a chunk.193 // view and can be serialized without rotating the live tail into a chunk.
61 type HistorySnapshotView struct {194 type HistorySnapshotView struct {
62- chunks []*HistoryChunk195+ chunks []*HistoryChunk
63- tail [][]renderer.Cell196+ tail HistoryView
64- tailBounds []LineBound197+ rows int
65- tailIDs []RowID198+ cells int
66- rows int199+ logicalBytes uint64
67- cells int200+ nextRowID RowID
68- nextRowID RowID201+}
202+
203+func historyRowBaseLogicalBytes(width int) uint64 {
204+ return uint64(width)*renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes
69 }205 }
70 206
71-func NewHistory(config HistoryConfig) *History {207+func addLogicalBytes(total, value uint64) uint64 {
72- if config.MaxRows <= 0 {208+ if value > math.MaxUint64-total {
73- return &History{}209+ return math.MaxUint64
74 }210 }
75- chunkRows := config.ChunkRows211+ return total + value
76- if chunkRows <= 0 {212+}
77- chunkRows = 256213+
214+func historyChunkLogicalBytes(chunk *HistoryChunk) uint64 {
215+ if chunk == nil {
216+ return 0
78 }217 }
79- chunkRows = min(chunkRows, 256)218+ return uint64(chunk.len())*historyRowBaseLogicalBytes(chunk.width) + chunk.styleCount*renderer.StyleRecordLogicalBytes + chunk.payloadBytes
80- chunkRows = min(chunkRows, config.MaxRows)219+}
81- maxCells := config.MaxCells220+
82- if maxCells <= 0 {221+func historyChunksLogicalBytes(chunks []*HistoryChunk) uint64 {
83- maxCells = defaultHistoryMaxCells(config.MaxRows)222+ var total uint64
223+ for _, chunk := range chunks {
224+ total = addLogicalBytes(total, historyChunkLogicalBytes(chunk))
84 }225 }
85- return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows, nextRowID: 1}226+ return total
86 }227 }
87 228
88-func defaultHistoryMaxCells(maxRows int) int {229+func (h *History) prepareRowStyles(row []renderer.Cell) uint64 {
89- if maxRows > math.MaxInt/160 {230+ clear(h.payloadScratch)
90- return math.MaxInt231+ 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+ }
91 }295 }
92- return maxRows * 160296+ 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
93 }332 }
94 333
95 // Append records a copy of row along with its logical extent and an334 // Append records a copy of row along with its logical extent and an
96 // automatically allocated nonzero ID. Once a chunk is full it is sealed335 // automatically allocated nonzero ID. Once a chunk is full it is sealed
97-// forever. Rows wider than the total cell capacity are rejected without336+// forever. Rows larger than the logical-byte capacity are rejected without
98 // mutation.337 // mutation.
99 func (h *History) Append(row []renderer.Cell, bound LineBound) error {338 func (h *History) Append(row []renderer.Cell, bound LineBound) error {
100 if h == nil || h.maxRows == 0 {339 if h == nil || h.maxRows == 0 {
101 return nil340 return nil
102 }341 }
103- if len(row) > h.maxCells {342+ defer func() { clear(h.payloadScratch) }()
104- return ErrHistoryRowTooWide343+ if uint64(len(row)) > uint64(math.MaxUint32) || historyRowBaseLogicalBytes(len(row))+renderer.StyleRecordLogicalBytes > h.maxBytes || h.prepareRowStyles(row) > h.maxBytes {
344+ return ErrHistoryRowTooLarge
105 }345 }
106 id, err := h.allocateRowID()346 id, err := h.allocateRowID()
107 if err != nil {347 if err != nil {
@@ -119,8 +359,9 @@ func (h *History) AppendWithID(row []renderer.Cell, bound LineBound, id RowID) e
119 if h == nil || h.maxRows == 0 {359 if h == nil || h.maxRows == 0 {
120 return nil360 return nil
121 }361 }
122- if len(row) > h.maxCells {362+ defer func() { clear(h.payloadScratch) }()
123- return ErrHistoryRowTooWide363+ if uint64(len(row)) > uint64(math.MaxUint32) || historyRowBaseLogicalBytes(len(row))+renderer.StyleRecordLogicalBytes > h.maxBytes || h.prepareRowStyles(row) > h.maxBytes {
364+ return ErrHistoryRowTooLarge
124 }365 }
125 if h.hasRowID(id) {366 if h.hasRowID(id) {
126 return errInvalidHistoryRowID367 return errInvalidHistoryRowID
@@ -132,19 +373,20 @@ func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) erro
132 if h.nextRowID == ^RowID(0) {373 if h.nextRowID == ^RowID(0) {
133 return errInvalidHistoryRowID374 return errInvalidHistoryRowID
134 }375 }
135- if len(row) > h.maxCells {
136- return ErrHistoryRowTooWide
137- }
138 if id >= ^RowID(0)-1 {376 if id >= ^RowID(0)-1 {
139 return errInvalidHistoryRowID377 return errInvalidHistoryRowID
140 }378 }
141 if id >= h.nextRowID {379 if id >= h.nextRowID {
142 h.nextRowID = id + 1380 h.nextRowID = id + 1
143 }381 }
144- // Make room before adding so Cells cannot overflow even when the default382+ // Make room before adding so accounting cannot overflow even when the
145- // capacity is MaxInt.383+ // configured capacities use their maximum values.
146- h.evictFor(len(row))384+ h.evictFor(row)
147- h.tail = append(h.tail, append([]renderer.Cell(nil), row...))385+ delta := h.tailAppendDelta(len(row))
386+ h.appendTailRow(row)
387+ h.recordTailRowStyles(len(row))
388+ h.tailBytes += delta
389+ h.logicalBytes += delta
148 h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))390 h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))
149 h.tailIDs = append(h.tailIDs, id)391 h.tailIDs = append(h.tailIDs, id)
150 h.rows++392 h.rows++
@@ -205,90 +447,115 @@ func growRowIDs(ids []RowID, rows int) []RowID {
205 return out447 return out
206 }448 }
207 449
208-func cloneRowIDs(ids []RowID, rows int) []RowID {450+func (h *History) reserveTailRow(width int) (start, end int) {
209- out := make([]RowID, rows)451+ h.cachedTail = nil
210- copy(out, ids)452+ start = len(h.tailCells)
211- return out453+ 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])
212 }490 }
213 491
214 func (h *History) sealTail() {492 func (h *History) sealTail() {
215 if len(h.tail) == 0 {493 if len(h.tail) == 0 {
216 return494 return
217 }495 }
218- h.chunks = append(h.chunks, &HistoryChunk{496+ chunks := h.tailViewChunks()
219- rows: h.tail,497+ h.chunks = append(h.chunks, chunks...)
220- bounds: growBounds(h.tailBounds, len(h.tail)),498+ h.logicalBytes = h.logicalBytes - h.tailBytes + historyChunksLogicalBytes(chunks)
221- rowIDs: h.tailIDs,
222- })
223 h.tail = nil499 h.tail = nil
500+ h.cachedTail = nil
501+ h.tailCells = nil
224 h.tailBounds = nil502 h.tailBounds = nil
225 h.tailIDs = nil503 h.tailIDs = nil
226-}504+ h.tailBytes = 0
227-505+ h.tailPageWidth = 0
228-// normalizeTail seals complete chunks so the mutable tail remains shorter than506+ h.tailPageRows = 0
229-// chunkRows. Restored snapshots may have been written with a different chunk507+ h.tailPageStyles = nil
230-// size than the current history configuration.508+ h.tailPagePayloads = nil
231-func (h *History) normalizeTail() {509+}
232- h.tailIDs = growRowIDs(h.tailIDs, len(h.tail))510+
233- for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {511+// evictFor discards oldest rows until row can fit both retention budgets.
234- bounds := growBounds(h.tailBounds, len(h.tail))512+func (h *History) evictFor(row []renderer.Cell) {
235- h.chunks = append(h.chunks, &HistoryChunk{513+ for {
236- rows: h.tail[:h.chunkRows],514+ delta := h.tailAppendDelta(len(row))
237- bounds: bounds[:h.chunkRows],515+ if h.rows <= h.maxRows-1 && h.logicalBytes <= h.maxBytes-delta {
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 {
282 return516 return
283 }517 }
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)
285 h.rows--530 h.rows--
286- h.cells -= len(row)531+ h.cells -= chunk.width
287- h.tail[0] = nil532+ if chunk.count == 1 {
288- h.tail = h.tail[1:]533+ h.logicalBytes -= oldBytes
289- h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]534+ copy(h.chunks, h.chunks[1:])
290- h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[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
291 }545 }
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()
292 }559 }
293 560
294 // SealAndView rotates the mutable tail into an immutable chunk, then captures561 // SealAndView rotates the mutable tail into an immutable chunk, then captures
@@ -300,8 +567,15 @@ func (h *History) SealAndView() HistoryView {
300 return h.View()567 return h.View()
301 }568 }
302 569
303-// View captures the current history. Sealed chunks are shared by identity; a570+func (h *History) tailViewChunks() []*HistoryChunk {
304-// partially-filled tail is copied into a new immutable chunk for this view.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.
305 func (h *History) View() HistoryView {579 func (h *History) View() HistoryView {
306 if h == nil {580 if h == nil {
307 return HistoryView{}581 return HistoryView{}
@@ -312,13 +586,9 @@ func (h *History) View() HistoryView {
312 chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1)586 chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1)
313 copy(chunks, h.chunks)587 copy(chunks, h.chunks)
314 if len(h.tail) > 0 {588 if len(h.tail) > 0 {
315- chunks = append(chunks, &HistoryChunk{589+ chunks = append(chunks, h.tailViewChunks()...)
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- })
320 }590 }
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}
322 }592 }
323 593
324 // SnapshotView captures history for persistence without sealing the mutable594 // SnapshotView captures history for persistence without sealing the mutable
@@ -330,28 +600,23 @@ func (h *History) SnapshotView() HistorySnapshotView {
330 if h.rows == 0 {600 if h.rows == 0 {
331 return HistorySnapshotView{nextRowID: h.nextRowID}601 return HistorySnapshotView{nextRowID: h.nextRowID}
332 }602 }
603+ tailChunks := newHistoryChunks(h.tail, h.tailBounds, h.tailIDs)
333 return HistorySnapshotView{604 return HistorySnapshotView{
334- chunks: append([]*HistoryChunk(nil), h.chunks...),605+ chunks: append([]*HistoryChunk(nil), h.chunks...),
335- tail: cloneHistoryRows(h.tail),606+ tail: HistoryView{
336- tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),607+ chunks: tailChunks,
337- tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)),608+ rows: len(h.tail),
338- rows: h.rows,609+ cells: historyRowsCells(h.tail),
339- cells: h.cells,610+ logicalBytes: historyChunksLogicalBytes(tailChunks),
340- nextRowID: h.nextRowID,611+ nextRowID: h.nextRowID,
612+ },
613+ rows: h.rows,
614+ cells: h.cells,
615+ logicalBytes: h.logicalBytes,
616+ nextRowID: h.nextRowID,
341 }617 }
342 }618 }
343 619
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-
355 // Len returns the currently retained history row count.620 // Len returns the currently retained history row count.
356 func (h *History) Len() int {621 func (h *History) Len() int {
357 if h == nil {622 if h == nil {
@@ -368,6 +633,15 @@ func (h *History) Cells() int {
368 return h.cells633 return h.cells
369 }634 }
370 635
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+
371 // NextRowID returns the next identity allocated by this history.645 // NextRowID returns the next identity allocated by this history.
372 func (h *History) NextRowID() RowID {646 func (h *History) NextRowID() RowID {
373 if h == nil || h.nextRowID == 0 {647 if h == nil || h.nextRowID == 0 {
@@ -376,24 +650,32 @@ func (h *History) NextRowID() RowID {
376 return h.nextRowID650 return h.nextRowID
377 }651 }
378 652
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.
380 func (h *History) Cap() int {655 func (h *History) Cap() int {
381 if h == nil {656 if h == nil {
382 return 0657 return 0
383 }658 }
384- return h.maxRows659+ return h.rowCeiling
385 }660 }
386 661
387-// CellCap returns the configured bounded cell capacity.662+// ByteCap returns the configured uncompressed byte ceiling. Zero means no
388-func (h *History) CellCap() int {663+// independent byte ceiling, or disabled history when Cap is also zero.
664+func (h *History) ByteCap() uint64 {
389 if h == nil {665 if h == nil {
390 return 0666 return 0
391 }667 }
392- return h.maxCells668+ return h.byteCeiling
393 }669 }
394 670
395-func (v HistoryView) Len() int { return v.rows }671+func (v HistoryView) Len() int { return v.rows }
396-func (v HistoryView) Cells() int { return v.cells }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+}
397 func (v HistoryView) ChunkCount() int { return len(v.chunks) }679 func (v HistoryView) ChunkCount() int { return len(v.chunks) }
398 680
399 // NextRowID returns the next identity recorded by this view.681 // NextRowID returns the next identity recorded by this view.
@@ -416,37 +698,32 @@ func (v HistoryView) Chunk(i int) *HistoryChunk {
416 return v.chunks[i]698 return v.chunks[i]
417 }699 }
418 700
419-// Range calls yield for each row in oldest-first order. The row is borrowed701+// Range calls yield with a decoded owned row in oldest-first order. Returning
420-// immutable storage: yield must not mutate or retain it after returning.702+// false stops iteration successfully; a backing restore failure returns an error.
421-func (v HistoryView) Range(yield func([]renderer.Cell) bool) {703+func (v HistoryView) Range(yield func([]renderer.Cell) bool) error {
422 for _, chunk := range v.chunks {704 for _, chunk := range v.chunks {
423- for _, row := range chunk.rows {705+ // Streaming search restores at most one transient page at a time and
424- if !yield(row) {706+ // does not populate the random-access cache of every cold page.
425- return707+ 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
426 }714 }
427 }715 }
428 }716 }
717+ return nil
429 }718 }
430 719
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.
432 func (v HistoryView) Row(i int) []renderer.Cell {721 func (v HistoryView) Row(i int) []renderer.Cell {
433- return append([]renderer.Cell(nil), v.BorrowedRow(i)...)722+ chunk, row := v.locateRow(i)
434-}723+ if chunk == nil {
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 {
441 return nil724 return nil
442 }725 }
443- for _, chunk := range v.chunks {726+ return chunk.row(row)
444- if i < len(chunk.rows) {
445- return chunk.rows[i]
446- }
447- i -= len(chunk.rows)
448- }
449- return nil
450 }727 }
451 728
452 // RowID returns the identity of the row at i, or zero when i is out of range.729 // 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 {
455 return 0732 return 0
456 }733 }
457 for _, chunk := range v.chunks {734 for _, chunk := range v.chunks {
458- if i < len(chunk.rows) {735+ if i < chunk.len() {
459 if i < len(chunk.rowIDs) {736 if i < len(chunk.rowIDs) {
460 return chunk.rowIDs[i]737 return chunk.rowIDs[i]
461 }738 }
462 return 0739 return 0
463 }740 }
464- i -= len(chunk.rows)741+ i -= chunk.len()
465 }742 }
466 return 0743 return 0
467 }744 }
@@ -473,7 +750,7 @@ func (v HistoryView) FindRowID(id RowID) int {
473 }750 }
474 index := 0751 index := 0
475 for _, chunk := range v.chunks {752 for _, chunk := range v.chunks {
476- for row := range chunk.rows {753+ for row := range chunk.len() {
477 if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id {754 if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id {
478 return index755 return index
479 }756 }
@@ -498,19 +775,25 @@ func (v HistoryView) Bound(i int) LineBound {
498 return LineBound{}775 return LineBound{}
499 }776 }
500 for _, chunk := range v.chunks {777 for _, chunk := range v.chunks {
501- if i < len(chunk.rows) {778+ if i < chunk.len() {
502 if i < len(chunk.bounds) {779 if i < len(chunk.bounds) {
503 return chunk.bounds[i]780 return chunk.bounds[i]
504 }781 }
505 return LineBound{}782 return LineBound{}
506 }783 }
507- i -= len(chunk.rows)784+ i -= chunk.len()
508 }785 }
509 return LineBound{}786 return LineBound{}
510 }787 }
511 788
512-func (v HistorySnapshotView) Len() int { return v.rows }789+func (v HistorySnapshotView) Len() int { return v.rows }
513-func (v HistorySnapshotView) Cells() int { return v.cells }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+}
514 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }797 func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
515 798
516 // NextRowID returns the next identity recorded by this snapshot.799 // NextRowID returns the next identity recorded by this snapshot.
@@ -531,15 +814,9 @@ func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {
531 814
532 // Tail returns an immutable view of the copied mutable tail.815 // Tail returns an immutable view of the copied mutable tail.
533 func (v HistorySnapshotView) Tail() HistoryView {816 func (v HistorySnapshotView) Tail() HistoryView {
534- if len(v.tail) == 0 {817+ tail := v.tail
535- return HistoryView{nextRowID: v.nextRowID}818+ tail.nextRowID = v.nextRowID
536- }819+ return tail
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- }
543 }820 }
544 821
545 func historyRowsCells(rows [][]renderer.Cell) int {822 func historyRowsCells(rows [][]renderer.Cell) int {
M history_acceptance_test.go +1 -1
@@ -84,7 +84,7 @@ func TestSnapshotCodecsPreserveEveryTerminalCellAcrossHistoryAndRecoveryTranscri
84 screen := NewScreen(4, 2)84 screen := NewScreen(4, 2)
85 want := [][]renderer.Cell{makeRow('D', rgb), makeRow('E', indexed)}85 want := [][]renderer.Cell{makeRow('D', rgb), makeRow('E', indexed)}
86 for y, row := range want {86 for y, row := range want {
87- copy(screen.Frame.Row(y), row)87+ screen.frame.WriteRow(y, 0, row)
88 screen.buffer.boundaries[y] = LineBound{End: len(row)}88 screen.buffer.boundaries[y] = LineBound{End: len(row)}
89 }89 }
90 90
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 (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "math"7 "math"
8- "slices"
9- "unicode/utf8"
10 8
11 renderer "github.com/bnema/vev-vt/core"9 renderer "github.com/bnema/vev-vt/core"
12 )10 )
13 11
14 const (12 const (
15- historyMagic = "VTH1"13+ historyMagic = "VTC1"
16- historyVersion = 314+ historyHeaderBytes = 16
17- historyCellBytes = 4115+ historyChunkHeaderBytes = 16
18- historyBoundBytes = 516+ historyStoredCellBytes = 9
19- maxHistoryChunkRows = 25617+ historyStyleBytes = 37
20-18+ historyRowBytes = 13
21- // The daemon retains 10,000 history rows. Support a 20% row margin and19+ historyBoundBytes = 5
22- // 160-cell rows, which leaves headroom above the representative 10k×12020+ maxHistoryChunkRows = 256
23- // terminal while bounding snapshot allocation.21+
24- maxHistoryChunks = 12_00022+ // Aggregate resource ceilings allow wide rows without tying persistence to
25- maxHistoryRows = 12_00023+ // a particular terminal geometry. They apply before compact allocation.
26- maxHistoryRowCells = 16024+ maxHistoryChunks = 12_000
27- maxHistoryCells = maxHistoryRows * maxHistoryRowCells25+ maxHistoryRows = 12_000
28-26+ maxHistoryRowCells = 160
29- maxHistoryDecodeStyles = maxHistoryCells27+ maxHistoryCells = maxHistoryRows * maxHistoryRowCells
30- maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes28+ 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
31 )34 )
32 35
33 var errInvalidHistory = errors.New("invalid history payload")36 var errInvalidHistory = errors.New("invalid history payload")
34 37
35-// MarshalHistory encodes a HistoryView in a deterministic, self-contained38+type historyDecodeStats struct {
36-// format. It preserves chunk boundaries as well as every Cell and Style field.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.
37 func MarshalHistory(view HistoryView) ([]byte, error) {45 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 {
39 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)47 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
40 }48 }
41- nextRowID, ok := historyViewNextRowID(view)49+ nextID, ok := historyViewNextRowID(view)
42 if !ok {50 if !ok {
43 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)51 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
44 }52 }
45- stats := historyDecodeStats{}53+ type encodedChunk struct {
46- seen := make(map[RowID]struct{}, view.rows)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))}
47 for _, chunk := range view.chunks {63 for _, chunk := range view.chunks {
48- if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) > maxHistoryChunkRows ||64+ if chunk == nil || chunk.page == nil || chunk.len() == 0 || chunk.len() > maxHistoryChunkRows ||
49- !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) {65+ chunk.len() != len(chunk.rowIDs) || chunk.len() != len(chunk.bounds) || chunk.width < 0 ||
66+ uint64(chunk.width) > math.MaxUint32 {
50 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)67 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
51 }68 }
52- for i, row := range chunk.rows {69+ cells := uint64(chunk.width) * uint64(chunk.len())
53- if uint64(len(row)) > math.MaxUint32 {70+ if !addHistoryDecodeBudget(&stats.rows, uint64(chunk.len()), maxHistoryRows) ||
54- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)71+ !addHistoryDecodeBudget(&stats.cells, cells, maxHistoryCells) {
55- }72+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
56- id := chunk.rowIDs[i]73+ }
57- if id == 0 || id >= ^RowID(0)-1 {74+ frame, err := chunk.page.readFrame(false)
58- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)75+ if err != nil {
59- }76+ return nil, fmt.Errorf("marshal history: %w", err)
60- if _, duplicate := seen[id]; duplicate {77+ }
61- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)78+ if frame.Width != chunk.width || chunk.start < 0 || chunk.start > frame.Height-chunk.count {
62- }79+ return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
63- seen[id] = struct{}{}80+ }
64- cellCount := uint64(len(row))81+ styles := []renderer.Style{renderer.DefaultStyle()}
65- cellBytes, ok := historyCellByteCount(cellCount)82+ ids := map[renderer.Style]uint32{renderer.DefaultStyle(): 0}
66- if !ok ||83+ var payloads []renderer.CellPayload
67- !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) ||84+ var payloadIDs map[renderer.CellPayload]uint32
68- !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) ||85+ var payloadWireBytes uint64
69- !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {86+ for row := range chunk.len() {
70- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)87+ if !validHistoryBound(chunk.bounds[row], chunk.width) {
71- }
72- bound := chunkBound(chunk, i)
73- if !validHistoryBound(bound, len(row)) {
74 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)88 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
75 }89 }
76- for _, cell := range row {90+ for x := range chunk.width {
91+ cell := frame.Cell(x, chunk.start+row)
77 if !validHistoryCell(cell) {92 if !validHistoryCell(cell) {
78 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)93 return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
79 }94 }
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+ }
80 }115 }
81 }116 }
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})
82 }123 }
83- if nextRowID <= maxHistoryRowID(seen) {124+ // Aggregate budgets bound size well below MaxInt even on 32-bit hosts.
84- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)125+ out := make([]byte, 0, int(size))
85- }
86-
87- out := make([]byte, 0, 17)
88 out = append(out, historyMagic...)126 out = append(out, historyMagic...)
89- out = append(out, historyVersion)127+ out = binary.BigEndian.AppendUint32(out, uint32(len(encoded)))
90- out = appendUint32(out, uint32(len(view.chunks)))128+ out = binary.BigEndian.AppendUint64(out, uint64(nextID))
91- out = binary.BigEndian.AppendUint64(out, uint64(nextRowID))129+ for _, entry := range encoded {
92- for _, chunk := range view.chunks {130+ chunk, styles, ids := entry.chunk, entry.styles, entry.ids
93- out = appendUint32(out, uint32(len(chunk.rows)))131+ frame, err := chunk.page.readFrame(false)
94- for i, row := range chunk.rows {132+ if err != nil {
95- out = binary.BigEndian.AppendUint64(out, uint64(chunk.rowIDs[i]))133+ return nil, fmt.Errorf("marshal history: %w", err)
96- out = appendUint32(out, uint32(len(row)))134+ }
97- for _, cell := range row {135+ out = binary.BigEndian.AppendUint32(out, uint32(chunk.width))
98- out = appendHistoryCell(out, cell)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+ }
99 }165 }
100- out = appendHistoryBound(out, chunkBound(chunk, i))
101 }166 }
102 }167 }
103 return out, nil168 return out, nil
104 }169 }
105 170
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+
106 func historyViewNextRowID(view HistoryView) (RowID, bool) {182 func historyViewNextRowID(view HistoryView) (RowID, bool) {
107 seen := make(map[RowID]struct{}, view.rows)183 seen := make(map[RowID]struct{}, view.rows)
184+ var maxID RowID
108 for _, chunk := range view.chunks {185 for _, chunk := range view.chunks {
109- if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) {186+ if chunk == nil || chunk.len() != len(chunk.rowIDs) {
110 return 0, false187 return 0, false
111 }188 }
112 for _, id := range chunk.rowIDs {189 for _, id := range chunk.rowIDs {
@@ -117,110 +194,22 @@ func historyViewNextRowID(view HistoryView) (RowID, bool) {
117 return 0, false194 return 0, false
118 }195 }
119 seen[id] = struct{}{}196 seen[id] = struct{}{}
197+ maxID = max(maxID, id)
120 }198 }
121 }199 }
122 next := view.nextRowID200 next := view.nextRowID
123 if next == 0 {201 if next == 0 {
124- maxID := maxHistoryRowID(seen)
125- if maxID == ^RowID(0) {
126- return 0, false
127- }
128 next = maxID + 1202 next = maxID + 1
129 }203 }
130- if next == 0 || next == ^RowID(0) || next <= maxHistoryRowID(seen) {204+ return next, next > maxID && next < ^RowID(0)
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
219 }205 }
220 206
221-// UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects207+// UnmarshalHistory accepts only VTC1. Validation precedes all decoded frame
222-// malformed declarations, cells, truncated data, and trailing bytes.208+// allocations, including validation of later chunks in the same payload.
223 func UnmarshalHistory(data []byte) (HistoryView, error) {209 func UnmarshalHistory(data []byte) (HistoryView, error) {
210+ if _, _, ok := parseHistory(data, false); !ok {
211+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
212+ }
224 view, _, ok := parseHistory(data, true)213 view, _, ok := parseHistory(data, true)
225 if !ok {214 if !ok {
226 return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)215 return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
@@ -228,127 +217,139 @@ func UnmarshalHistory(data []byte) (HistoryView, error) {
228 return view, nil217 return view, nil
229 }218 }
230 219
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.
242 func preflightHistory(data []byte) (historyDecodeStats, bool) {220 func preflightHistory(data []byte) (historyDecodeStats, bool) {
243 _, stats, ok := parseHistory(data, false)221 _, stats, ok := parseHistory(data, false)
244 return stats, ok222 return stats, ok
245 }223 }
246 224
247-// parseHistory is the authoritative history payload validation path. populate
248-// controls whether validated cells are retained in a HistoryView.
249 func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) {225 func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) {
250- if len(data) < 17 || string(data[:4]) != historyMagic || data[4] != historyVersion {226+ invalid := func() (HistoryView, historyDecodeStats, bool) { return HistoryView{}, historyDecodeStats{}, false }
251- return HistoryView{}, historyDecodeStats{}, false227+ if len(data) < historyHeaderBytes || string(data[:4]) != historyMagic {
252- }228+ return invalid()
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
258 }229 }
259- nextRowID, ok := p.uint64()230+ p := historyParser{data: data[4:]}
260- if !ok || nextRowID == 0 || nextRowID == uint64(^RowID(0)) {231+ chunkCount, _ := p.uint32()
261- return HistoryView{}, historyDecodeStats{}, false232+ nextID, _ := p.uint64()
233+ if chunkCount > maxHistoryChunks || uint64(chunkCount)*historyChunkHeaderBytes > uint64(len(p.data)) || nextID == 0 || nextID == math.MaxUint64 {
234+ return invalid()
262 }235 }
263 stats := historyDecodeStats{chunks: uint64(chunkCount)}236 stats := historyDecodeStats{chunks: uint64(chunkCount)}
264- var seenIDs [maxHistoryRows]RowID237+ seenIDs := make(map[RowID]struct{})
265- seenCount := 0
266 var maxID RowID238 var maxID RowID
267 var chunks []*HistoryChunk239 var chunks []*HistoryChunk
268 if populate {240 if populate {
269 chunks = make([]*HistoryChunk, 0, chunkCount)241 chunks = make([]*HistoryChunk, 0, chunkCount)
270 }242 }
271 for range chunkCount {243 for range chunkCount {
272- rowCount, ok := p.uint32()244+ width, wok := p.uint32()
273- if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||245+ rows, rok := p.uint32()
274- uint64(rowCount) > uint64(len(p.data))/(8+4+historyBoundBytes) ||246+ nstyles, sok := p.uint32()
275- !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {247+ npayloads, pok := p.uint32()
276- return HistoryView{}, historyDecodeStats{}, false248+ if !wok || !rok || !sok || !pok || rows == 0 || rows > maxHistoryChunkRows || uint64(width) > uint64(math.MaxInt) || nstyles == 0 || nstyles > maxHistoryStylesPerChunk {
249+ return invalid()
277 }250 }
278-251+ cells := uint64(width) * uint64(rows)
279- var rows [][]renderer.Cell252+ if uint64(nstyles) > cells+1 || uint64(npayloads) > cells ||
280- var bounds []LineBound253+ !addHistoryDecodeBudget(&stats.rows, uint64(rows), maxHistoryRows) ||
281- var rowIDs []RowID254+ !addHistoryDecodeBudget(&stats.cells, cells, maxHistoryCells) ||
282- if populate {255+ !addHistoryDecodeBudget(&stats.styles, uint64(nstyles), maxHistoryDecodeStyles) ||
283- rows = make([][]renderer.Cell, 0, rowCount)256+ !addHistoryLogicalBytes(&stats, cells, uint64(rows), uint64(nstyles)) {
284- bounds = make([]LineBound, 0, rowCount)257+ return invalid()
285- rowIDs = make([]RowID, 0, rowCount)
286 }258 }
287- for range rowCount {259+ // Counts have now passed aggregate budgets. This sum cannot overflow.
288- id, ok := p.uint64()260+ payload := uint64(nstyles-1)*historyStyleBytes + uint64(npayloads)*8 + uint64(rows)*historyRowBytes + cells*historyStoredCellBytes
289- if !ok || id == 0 || id >= uint64(^RowID(0)-1) {261+ if payload > uint64(len(p.data)) {
290- return HistoryView{}, historyDecodeStats{}, false262+ 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()
291 }271 }
292- rowID := RowID(id)272+ if _, duplicate := seenStyles[style]; duplicate {
293- seenIDs[seenCount] = rowID273+ return invalid()
294- seenCount++
295- if rowID > maxID {
296- maxID = rowID
297 }274 }
298- cellCount, ok := p.uint32()275+ seenStyles[style] = struct{}{}
299- cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))276+ styles[id] = style
300- if !ok || !validCellBytes ||277+ }
301- !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) ||278+ payloads, ok := p.payloadDictionary(npayloads, &stats)
302- !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||279+ if !ok {
303- !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) ||280+ return invalid()
304- uint64(cellCount) > uint64(len(p.data))/historyCellBytes {281+ }
305- return HistoryView{}, historyDecodeStats{}, false282+ 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()
306 }290 }
307-291+ if _, duplicate := seenIDs[RowID(id)]; duplicate {
308- var row []renderer.Cell292+ return invalid()
309- if populate {
310- row = make([]renderer.Cell, cellCount)
311 }293 }
312- for i := range cellCount {294+ seenIDs[RowID(id)] = struct{}{}
313- cell, ok := p.cell()295+ maxID = max(maxID, RowID(id))
314- if !ok || !validHistoryCell(cell) {296+ rowIDs[row], bounds[row] = RowID(id), bound
315- return HistoryView{}, historyDecodeStats{}, false297+ }
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]
316 }317 }
317 if populate {318 if populate {
318- row[i] = cell319+ frame.Set(int(x), int(row), renderer.Cell{Rune: r, Style: styles[id], Payload: value, Continuation: continuation})
319 }320 }
320 }321 }
321- bound, ok := p.bound()322+ }
322- if !ok || !validHistoryBound(bound, int(cellCount)) {323+ for id := uint32(1); id < nstyles; id++ {
323- return HistoryView{}, historyDecodeStats{}, false324+ if lastStyleRow[id] < 0 {
325+ return invalid()
324 }326 }
325- if populate {327+ }
326- rows = append(rows, row)328+ for _, used := range usedPayloads {
327- bounds = append(bounds, bound)329+ if !used {
328- rowIDs = append(rowIDs, rowID)330+ return invalid()
329 }331 }
330 }332 }
331 if populate {333 if populate {
332- chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs})334+ drops := make([]uint64, rows)
333- }335+ for id := uint32(1); id < nstyles; id++ {
334- }336+ drops[lastStyleRow[id]]++
335- slices.Sort(seenIDs[:seenCount])337+ }
336- for i := 1; i < seenCount; i++ {338+ chunk := &HistoryChunk{page: newSealedPage(frame), count: int(rows), width: int(width), bounds: bounds, rowIDs: rowIDs, styleDrops: drops, styleCount: uint64(nstyles)}
337- if seenIDs[i] == seenIDs[i-1] {339+ chunk.recordPayloads()
338- return HistoryView{}, historyDecodeStats{}, false340+ chunks = append(chunks, chunk)
339 }341 }
340 }342 }
341- if len(p.data) != 0 || RowID(nextRowID) <= maxID {343+ if len(p.data) != 0 || RowID(nextID) <= maxID {
342- return HistoryView{}, historyDecodeStats{}, false344+ return invalid()
343 }345 }
344- return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells), nextRowID: RowID(nextRowID)}, stats, true346+ return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells), logicalBytes: stats.bytes, nextRowID: RowID(nextID)}, stats, true
345 }347 }
346 348
347-func historyCellByteCount(cellCount uint64) (uint64, bool) {349+func addHistoryLogicalBytes(stats *historyDecodeStats, cells, rows, styles uint64) bool {
348- if cellCount > math.MaxUint64/historyCellBytes {350+ // Callers first bound all three counts by the aggregate decode ceilings.
349- return 0, false351+ amount := cells*renderer.StoredCellLogicalBytes + rows*renderer.RowDescriptorLogicalBytes + styles*renderer.StyleRecordLogicalBytes
350- }352+ return addHistoryDecodeBudget(&stats.bytes, amount, maxHistoryDecodedBytes)
351- return cellCount * historyCellBytes, true
352 }353 }
353 354
354 func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool {355 func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool {
@@ -359,89 +360,6 @@ func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool {
359 return true360 return true
360 }361 }
361 362
362-type historyParser struct{ data []byte }363+func validHistoryBound(bound LineBound, cells int) bool {
363-364+ return bound.End >= 0 && bound.End <= cells
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
447 }365 }
M history_codec_bound_test.go +1 -1
@@ -10,7 +10,7 @@ import (
10 10
11 func TestHistoryCodecRejectsMalformedBounds(t *testing.T) {11 func TestHistoryCodecRejectsMalformedBounds(t *testing.T) {
12 blob, err := MarshalHistory(HistoryView{12 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})},
14 rows: 1,14 rows: 1,
15 cells: 4,15 cells: 4,
16 nextRowID: 2,16 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 (
10 renderer "github.com/bnema/vev-vt/core"10 renderer "github.com/bnema/vev-vt/core"
11 )11 )
12 12
13-func TestHistoryCodecV3Bytes(t *testing.T) {13+func TestHistoryCodecCompactBytes(t *testing.T) {
14- h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})14+ h := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: 1 << 20, ChunkRows: 2})
15- if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil {15+ if err := h.Append([]renderer.Cell{{Rune: 'a', Style: renderer.DefaultStyle()}}, LineBound{End: 1, Soft: true}); err != nil {
16 t.Fatalf("append: %v", err)16 t.Fatalf("append: %v", err)
17 }17 }
18 got, err := MarshalHistory(h.SealAndView())18 got, err := MarshalHistory(h.SealAndView())
@@ -21,23 +21,17 @@ func TestHistoryCodecV3Bytes(t *testing.T) {
21 }21 }
22 22
23 want := []byte{23 want := []byte{
24- 'V', 'T', 'H', '1', 3, // magic, version24+ 'V', 'T', 'C', '1',
25- 0, 0, 0, 1, // chunk count25+ 0, 0, 0, 1, // chunks
26 0, 0, 0, 0, 0, 0, 0, 2, // next row ID26 0, 0, 0, 0, 0, 0, 0, 2, // next row ID
27- 0, 0, 0, 1, // row count27+ 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
28 0, 0, 0, 0, 0, 0, 0, 1, // row ID31 0, 0, 0, 0, 0, 0, 0, 1, // row ID
29- 0, 0, 0, 1, // cell count32+ 0, 0, 0, 1, 1, // bound
30- 0, 0, 0, 'a', // Rune33+ 0, 0, 0, 'a', // rune
31- 0, // flags34+ 0, 0, 0, 0, 0, // style ID and continuation
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
41 }35 }
42 if !bytes.Equal(got, want) {36 if !bytes.Equal(got, want) {
43 t.Fatalf("MarshalHistory() = % x, want % x", got, want)37 t.Fatalf("MarshalHistory() = % x, want % x", got, want)
@@ -45,7 +39,7 @@ func TestHistoryCodecV3Bytes(t *testing.T) {
45 }39 }
46 40
47 func TestHistoryCodecRoundTripsBounds(t *testing.T) {41 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})
49 bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}}43 bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}}
50 for _, b := range bounds {44 for _, b := range bounds {
51 if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil {45 if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil {
@@ -81,7 +75,7 @@ func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) {
81 0, 0, 0,75 0, 0, 0,
82 0, 0, 0, 1, 1,76 0, 0, 0, 1, 1,
83 }77 }
84- for _, version := range []byte{1, 2} {78+ for _, version := range []byte{1, 2, 3} {
85 data := append([]byte(nil), legacy...)79 data := append([]byte(nil), legacy...)
86 data[4] = version80 data[4] = version
87 if _, err := UnmarshalHistory(data); err == nil {81 if _, err := UnmarshalHistory(data); err == nil {
@@ -165,11 +159,11 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
165 159
166 for i := range len(encoded) {160 for i := range len(encoded) {
167 if _, err := UnmarshalHistory(encoded[:i]); err == nil {161 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)
169 }163 }
170 }164 }
171 if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil {165 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")
173 }167 }
174 }168 }
175 169
@@ -294,33 +288,30 @@ func historyViewWithDimensions(rowCount, width int) HistoryView {
294 rowIDs[i] = rowID288 rowIDs[i] = rowID
295 rowID++289 rowID++
296 }290 }
297- chunks = append(chunks, &HistoryChunk{rows: rows, rowIDs: rowIDs})291+ chunks = append(chunks, testHistoryChunk(rows, nil, rowIDs))
298 rowCount -= chunkRows292 rowCount -= chunkRows
299 }293 }
300 return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID}294 return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID}
301 }295 }
302 296
303 func historyPayloadWithDimensions(rowCount, width int) []byte {297 func historyPayloadWithDimensions(rowCount, width int) []byte {
304- data := make([]byte, 17)298+ chunks := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows
305- copy(data, historyMagic)299+ data := binary.BigEndian.AppendUint32([]byte(historyMagic), uint32(chunks))
306- data[4] = historyVersion300+ data = binary.BigEndian.AppendUint64(data, uint64(rowCount+1))
307- chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows301+ id := uint64(1)
308- binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
309- binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1))
310- rowID := uint64(1)
311 for rowCount > 0 {302 for rowCount > 0 {
312- chunkRows := min(rowCount, maxHistoryChunkRows)303+ rows := min(rowCount, maxHistoryChunkRows)
313- data = binary.BigEndian.AppendUint32(data, uint32(chunkRows))304+ data = binary.BigEndian.AppendUint32(data, uint32(width))
314- for range chunkRows {305+ data = binary.BigEndian.AppendUint32(data, uint32(rows))
315- data = binary.BigEndian.AppendUint64(data, rowID)306+ data = binary.BigEndian.AppendUint32(data, 1)
316- rowID++307+ data = binary.BigEndian.AppendUint32(data, 0)
317- data = binary.BigEndian.AppendUint32(data, uint32(width))308+ for range rows {
318- //nolint:makezero // The header prefix is retained while zeroed cell records are appended.309+ data = binary.BigEndian.AppendUint64(data, id)
319- data = append(data, make([]byte, width*historyCellBytes)...)310+ id++
320- data = binary.BigEndian.AppendUint32(data, uint32(width))311+ data = appendHistoryBound(data, LineBound{End: width})
321- data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended.
322 }312 }
323- rowCount -= chunkRows313+ data = append(data, make([]byte, rows*width*historyStoredCellBytes)...)
314+ rowCount -= rows
324 }315 }
325 return data316 return data
326 }317 }
@@ -329,29 +320,27 @@ func historyPayloadWithDimensions(rowCount, width int) []byte {
329 // budget, but its cells exceed the aggregate budget. It deliberately omits320 // budget, but its cells exceed the aggregate budget. It deliberately omits
330 // cell payload bytes: the aggregate limit must reject it before allocation.321 // cell payload bytes: the aggregate limit must reject it before allocation.
331 func aggregateCellLimitDeclaration() []byte {322 func aggregateCellLimitDeclaration() []byte {
332- data := make([]byte, 17, 29)323+ data := binary.BigEndian.AppendUint32([]byte(historyMagic), 1)
333- copy(data, historyMagic)324+ data = binary.BigEndian.AppendUint64(data, 2)
334- data[4] = historyVersion325+ data = binary.BigEndian.AppendUint32(data, maxHistoryCells+1)
335- binary.BigEndian.PutUint32(data[5:], 1)326+ data = binary.BigEndian.AppendUint32(data, 1)
336- binary.BigEndian.PutUint64(data[9:], 2)
337 data = binary.BigEndian.AppendUint32(data, 1)327 data = binary.BigEndian.AppendUint32(data, 1)
338- data = binary.BigEndian.AppendUint64(data, 1)328+ return binary.BigEndian.AppendUint32(data, 0)
339- return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1))
340 }329 }
341 330
342 func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {331 func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {
343- data := make([]byte, 17, 17+chunkCount*(4+rowCount*12))332+ data := binary.BigEndian.AppendUint32([]byte(historyMagic), uint32(chunkCount))
344- copy(data, historyMagic)333+ data = binary.BigEndian.AppendUint64(data, uint64(chunkCount*rowCount+1))
345- data[4] = historyVersion
346- binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
347- binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1))
348 id := uint64(1)334 id := uint64(1)
349 for range chunkCount {335 for range chunkCount {
336+ data = binary.BigEndian.AppendUint32(data, 0)
350 data = binary.BigEndian.AppendUint32(data, uint32(rowCount))337 data = binary.BigEndian.AppendUint32(data, uint32(rowCount))
338+ data = binary.BigEndian.AppendUint32(data, 1)
339+ data = binary.BigEndian.AppendUint32(data, 0)
351 for range rowCount {340 for range rowCount {
352 data = binary.BigEndian.AppendUint64(data, id)341 data = binary.BigEndian.AppendUint64(data, id)
353 id++342 id++
354- data = binary.BigEndian.AppendUint32(data, 0)343+ data = appendHistoryBound(data, LineBound{})
355 }344 }
356 }345 }
357 return data346 return data
@@ -363,9 +352,9 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
363 t.Fatalf("marshal valid history: %v", err)352 t.Fatalf("marshal valid history: %v", err)
364 }353 }
365 invalidRune := append([]byte(nil), valid...)354 invalidRune := append([]byte(nil), valid...)
366- binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0))355+ binary.BigEndian.PutUint32(invalidRune[len(valid)-historyStoredCellBytes:], ^uint32(0))
367 invalidUnderlineStyle := append([]byte(nil), valid...)356 invalidUnderlineStyle := append([]byte(nil), valid...)
368- invalidUnderlineStyle[33+29] = byte(renderer.UnderlineDashed + 1)357+ invalidUnderlineStyle[historyHeaderBytes+historyChunkHeaderBytes+25] = byte(renderer.UnderlineDashed + 1)
369 358
370 tests := []struct {359 tests := []struct {
371 name string360 name string
@@ -379,7 +368,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
379 {name: "invalid underline style", data: invalidUnderlineStyle},368 {name: "invalid underline style", data: invalidUnderlineStyle},
380 {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)},369 {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)},
381 {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()},370 {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)},
383 }372 }
384 for _, tt := range tests {373 for _, tt := range tests {
385 t.Run(tt.name, func(t *testing.T) {374 t.Run(tt.name, func(t *testing.T) {
@@ -398,7 +387,7 @@ func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) {
398 f.Fatalf("marshal valid history: %v", err)387 f.Fatalf("marshal valid history: %v", err)
399 }388 }
400 invalidRune := append([]byte(nil), valid...)389 invalidRune := append([]byte(nil), valid...)
401- binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0))390+ binary.BigEndian.PutUint32(invalidRune[len(valid)-historyStoredCellBytes:], ^uint32(0))
402 f.Add(valid)391 f.Add(valid)
403 f.Add(invalidRune)392 f.Add(invalidRune)
404 f.Add([]byte("VTH1\x01\x00\x00\x00\x00"))393 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 (
8 )8 )
9 9
10 func TestHistoryAcceptanceKeepsBookmarksAcrossLiveScrollEvictionAndRestore(t *testing.T) {10 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})
12 for _, text := range []string{"live-1", "live-2", "live-3"} {12 for _, text := range []string{"live-1", "live-2", "live-3"} {
13 require.NoError(t, history.Append(historyAcceptanceRow(text), LineBound{End: len(text)}))13 require.NoError(t, history.Append(historyAcceptanceRow(text), LineBound{End: len(text)}))
14 }14 }
@@ -28,7 +28,7 @@ func TestHistoryAcceptanceKeepsBookmarksAcrossLiveScrollEvictionAndRestore(t *te
28 28
29 sealed, tail, err := MarshalSealedHistory(history.SealAndView())29 sealed, tail, err := MarshalSealedHistory(history.SealAndView())
30 require.NoError(t, err)30 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)
32 require.NoError(t, err)32 require.NoError(t, err)
33 restoredView := restored.View()33 restoredView := restored.View()
34 require.Equal(t, afterScroll.Len(), restoredView.Len())34 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) {
183 for _, tt := range tests {183 for _, tt := range tests {
184 t.Run(tt.name, func(t *testing.T) {184 t.Run(tt.name, func(t *testing.T) {
185 s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8, ChunkRows: 2})185 s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8, ChunkRows: 2})
186- for y := range s.Frame.Height {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)})))187+ s.frame.WriteRow(y, 0, historyRow(string([]byte{byte('A' + y), byte('A' + y), byte('A' + y), byte('A' + y)})))
188 }188 }
189 events := 0189 events := 0
190 s.OnLineEvicted = func([]renderer.Cell) { events++ }190 s.OnLineEvicted = func([]renderer.Cell) { events++ }
@@ -201,8 +201,9 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
201 }201 }
202 }202 }
203 203
204-func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {204+func TestHistoryBoundsRowsAndBytesWithExactRowEviction(t *testing.T) {
205- history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})205+ const budget = 3*renderer.StoredCellLogicalBytes + 2*renderer.RowDescriptorLogicalBytes + 2*renderer.StyleRecordLogicalBytes
206+ history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: budget, ChunkRows: 2})
206 for _, text := range []string{"aa", "bbb", "c", "dd"} {207 for _, text := range []string{"aa", "bbb", "c", "dd"} {
207 if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil {208 if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil {
208 t.Fatalf("append %q: %v", text, err)209 t.Fatalf("append %q: %v", text, err)
@@ -246,8 +247,9 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
246 }247 }
247 }248 }
248 249
249-func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {250+func TestHistoryRejectsRowLargerThanByteBudgetWithoutMutation(t *testing.T) {
250- history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})251+ const budget = 2*renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes + renderer.StyleRecordLogicalBytes
252+ history := NewHistory(HistoryConfig{MaxRows: 2, MaxBytes: budget, ChunkRows: 2})
251 kept := historyRow("ok")253 kept := historyRow("ok")
252 if err := history.Append(kept, LineBound{End: len(kept)}); err != nil {254 if err := history.Append(kept, LineBound{End: len(kept)}); err != nil {
253 t.Fatalf("append retained row: %v", err)255 t.Fatalf("append retained row: %v", err)
@@ -256,8 +258,8 @@ func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
256 258
257 wide := historyRow("wide")259 wide := historyRow("wide")
258 err := history.Append(wide, LineBound{End: len(wide)})260 err := history.Append(wide, LineBound{End: len(wide)})
259- if !errors.Is(err, ErrHistoryRowTooWide) {261+ if !errors.Is(err, ErrHistoryRowTooLarge) {
260- t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)262+ t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooLarge", err)
261 }263 }
262 if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) {264 if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) {
263 t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want)265 t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want)
@@ -268,11 +270,11 @@ func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
268 }270 }
269 271
270 func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) {272 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})
272 var events []string274 var events []string
273 screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) }275 screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) }
274- copy(screen.Frame.Row(0), historyRow("AAAA"))276+ screen.frame.WriteRow(0, 0, historyRow("AAAA"))
275- copy(screen.Frame.Row(1), historyRow("BBBB"))277+ screen.frame.WriteRow(1, 0, historyRow("BBBB"))
276 screen.scrollUpRegion(0, 1, 1)278 screen.scrollUpRegion(0, 1, 1)
277 279
278 if got := screen.History().Len(); got != 0 {280 if got := screen.History().Len(); got != 0 {
@@ -314,7 +316,7 @@ func TestHistoryAppendRetainsBounds(t *testing.T) {
314 316
315 for _, tc := range tests {317 for _, tc := range tests {
316 t.Run(tc.name, func(t *testing.T) {318 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})
318 for i, b := range tc.bounds {320 for i, b := range tc.bounds {
319 if err := h.Append(row("abc"[:b.End]), b); err != nil {321 if err := h.Append(row("abc"[:b.End]), b); err != nil {
320 t.Fatalf("append %d: %v", i, err)322 t.Fatalf("append %d: %v", i, err)
@@ -331,7 +333,7 @@ func TestHistoryAppendRetainsBounds(t *testing.T) {
331 }333 }
332 334
333 func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) {335 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})
335 for i := range 5 {337 for i := range 5 {
336 cells := []renderer.Cell{{Rune: rune('a' + i)}}338 cells := []renderer.Cell{{Rune: rune('a' + i)}}
337 if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil {339 if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil {
@@ -345,15 +347,88 @@ func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) {
345 // Rows 2, 3, 4 survived; their Soft flags were true, false, true.347 // Rows 2, 3, 4 survived; their Soft flags were true, false, true.
346 for i, want := range []bool{true, false, true} {348 for i, want := range []bool{true, false, true} {
347 if got := view.Bound(i).Soft; got != want {349 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)
349 }351 }
350 }352 }
351 }353 }
352 354
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) {
354 history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})426 history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})
355- if got, want := history.CellCap(), math.MaxInt; got != want {427+ if got := history.ByteCap(); got != 0 {
356- t.Fatalf("default cell cap = %d, want %d", got, want)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)
357 }432 }
358 }433 }
359 434
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
2 2
3 import renderer "github.com/bnema/vev-vt/core"3 import renderer "github.com/bnema/vev-vt/core"
4 4
5-// RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport5+// RecoveryTranscriptSnapshot owns compact viewport pages to replay after
6-// rows that should be replayed after retained terminal history during recovery.6+// retained history. No semantic row slices are retained by the snapshot.
7 type RecoveryTranscriptSnapshot struct {7 type RecoveryTranscriptSnapshot struct {
8 segments []recoveryTranscriptSegment8 segments []recoveryTranscriptSegment
9 nextRowID RowID9 nextRowID RowID
10 }10 }
11 11
12 type recoveryTranscriptSegment struct {12 type recoveryTranscriptSegment struct {
13- rows [][]renderer.Cell13+ frame renderer.Frame
14 bounds []LineBound14 bounds []LineBound
15 rowIDs []RowID15 rowIDs []RowID
16 }16 }
17 17
18-// RecoveryTranscriptSnapshot captures the active primary viewport, or the18+// RecoveryTranscriptSnapshot captures the primary viewport, followed by the
19-// saved primary viewport followed by the active alternate viewport.19+// alternate viewport when active. Untouched trailing rows are omitted and each
20+// viewport ends with a hard line, independently of physical row rotation.
20 func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot {21 func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot {
21 if s == nil {22 if s == nil {
22 return RecoveryTranscriptSnapshot{}23 return RecoveryTranscriptSnapshot{}
23 }24 }
24-
25 buffers := []*buffer{s.buffer}25 buffers := []*buffer{s.buffer}
26 if s.alternate != nil {26 if s.alternate != nil {
27 buffers = []*buffer{s.alternate.buffer, s.buffer}27 buffers = []*buffer{s.alternate.buffer, s.buffer}
28 }28 }
29-29+ nextID := s.nextRowID
30- nextRowID := s.nextRowID30+ if nextID < ^RowID(0) {
31- if nextRowID < ^RowID(0) {31+ nextID++
32- nextRowID++
33 }32 }
34- snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID}33+ snapshot := RecoveryTranscriptSnapshot{nextRowID: nextID, segments: make([]recoveryTranscriptSegment, 0, len(buffers))}
35 for _, b := range buffers {34 for _, b := range buffers {
36- segment := captureRecoveryTranscriptSegment(b)35+ if b == nil {
37- if len(segment.rows) > 0 {36+ continue
38- snapshot.segments = append(snapshot.segments, segment)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+ }
39 }50 }
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+ })
40 }56 }
41 return snapshot57 return snapshot
42 }58 }
43 59
44-func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment {60+func recoveryTranscriptFrameRowUntouched(frame renderer.Frame, y int, bound LineBound) bool {
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 {
74 if bound.End != 0 || bound.Soft {61 if bound.End != 0 || bound.Soft {
75 return false62 return false
76 }63 }
77 blank := renderer.BlankCell()64 blank := renderer.BlankCell()
78- for _, cell := range row {65+ for x := range frame.Width {
79- if !cell.Equal(blank) {66+ if !frame.Cell(x, y).Equal(blank) {
80 return false67 return false
81 }68 }
82 }69 }
83 return true70 return true
84 }71 }
85 72
86-// Marshal encodes the captured rows as canonical terminal history without73+// Marshal encodes the compact capture without consulting or mutating live state.
87-// consulting or mutating live Screen history.
88 func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {74 func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
89- rows := 075+ view := HistoryView{nextRowID: snapshot.nextRowID}
90- cells := 0
91 for _, segment := range snapshot.segments {76 for _, segment := range snapshot.segments {
92- rows += len(segment.rows)77+ for start := 0; start < segment.frame.Height; {
93- for _, row := range segment.rows {78+ count := min(segment.frame.Height-start, maxHistoryChunkRows, maxHistorySlabRows(segment.frame.Width))
94- cells += len(row)79+ frame := renderer.NewFrame(segment.frame.Width, count)
95- }80+ lastStyleRow := make(map[renderer.Style]int)
96- }81+ for y := range count {
97-82+ for x := range frame.Width {
98- view := HistoryView{rows: rows, cells: cells, nextRowID: snapshot.nextRowID}83+ cell := segment.frame.Cell(x, start+y)
99- for _, segment := range snapshot.segments {84+ frame.Set(x, y, cell)
100- for start := 0; start < len(segment.rows); {85+ style := cell.Style.Canonical()
101- space := maxHistoryChunkRows86+ if style != renderer.DefaultStyle() {
102- if len(view.chunks) > 0 {87+ lastStyleRow[style] = y
103- last := view.chunks[len(view.chunks)-1]88+ }
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
112 }89 }
113 }90 }
114-91+ drops := make([]uint64, count)
115- end := min(start+space, len(segment.rows))92+ for _, y := range lastStyleRow {
116- view.chunks = append(view.chunks, &HistoryChunk{93+ drops[y]++
117- rows: append([][]renderer.Cell(nil), segment.rows[start:end]...),94+ }
118- bounds: append([]LineBound(nil), segment.bounds[start:end]...),95+ chunk := &HistoryChunk{
119- rowIDs: append([]RowID(nil), segment.rowIDs[start:end]...),96+ page: newSealedPage(frame), count: count, width: frame.Width,
120- })97+ bounds: segment.bounds[start : start+count], rowIDs: segment.rowIDs[start : start+count],
121- start = end98+ 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
122 }106 }
123 }107 }
124 return MarshalHistory(view)108 return MarshalHistory(view)
M recovery_transcript_test.go +30 -30
@@ -17,7 +17,7 @@ func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) {
17 s.Write([]byte("keep"))17 s.Write([]byte("keep"))
18 18
19 snapshot := s.RecoveryTranscriptSnapshot()19 snapshot := s.RecoveryTranscriptSnapshot()
20- s.Frame.Set(0, 0, renderer.BlankCell())20+ s.frame.Set(0, 0, renderer.BlankCell())
21 s.buffer.boundaries[0] = LineBound{}21 s.buffer.boundaries[0] = LineBound{}
22 22
23 view := decodeRecoveryTranscript(t, snapshot)23 view := decodeRecoveryTranscript(t, snapshot)
@@ -44,7 +44,7 @@ func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *test
44 newRetainedScreen := func(height int) *Screen {44 newRetainedScreen := func(height int) *Screen {
45 screen := NewScreen(4, height)45 screen := NewScreen(4, height)
46 for y := range height {46 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()})
48 screen.buffer.boundaries[y] = LineBound{End: 1}48 screen.buffer.boundaries[y] = LineBound{End: 1}
49 }49 }
50 return screen50 return screen
@@ -73,7 +73,7 @@ func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSea
73 s.buffer.boundaries[0].Soft = true73 s.buffer.boundaries[0].Soft = true
74 74
75 snapshot := s.RecoveryTranscriptSnapshot()75 snapshot := s.RecoveryTranscriptSnapshot()
76- s.Frame.Set(0, 0, renderer.BlankCell())76+ s.frame.Set(0, 0, renderer.BlankCell())
77 s.buffer.boundaries[0] = LineBound{}77 s.buffer.boundaries[0] = LineBound{}
78 s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell())78 s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell())
79 s.alternate.buffer.boundaries[0] = LineBound{}79 s.alternate.buffer.boundaries[0] = LineBound{}
@@ -147,9 +147,9 @@ func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T)
147 for _, tt := range tests {147 for _, tt := range tests {
148 t.Run(tt.name, func(t *testing.T) {148 t.Run(tt.name, func(t *testing.T) {
149 s := NewScreen(3, 3)149 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()})
151 s.buffer.boundaries[0] = LineBound{End: 1}151 s.buffer.boundaries[0] = LineBound{End: 1}
152- s.Frame.Set(0, 1, tt.candidateCell)152+ s.frame.Set(0, 1, tt.candidateCell)
153 s.buffer.boundaries[1] = tt.candidateBound153 s.buffer.boundaries[1] = tt.candidateBound
154 154
155 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())155 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
@@ -165,7 +165,7 @@ func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T)
165 165
166 func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) {166 func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) {
167 s := NewScreen(3, 4)167 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()})
169 s.buffer.boundaries[2] = LineBound{End: 1}169 s.buffer.boundaries[2] = LineBound{End: 1}
170 170
171 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())171 view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
@@ -200,8 +200,8 @@ func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) {
200 200
201 func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) {201 func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) {
202 s := NewScreen(1, 257)202 s := NewScreen(1, 257)
203- for y := range s.Frame.Height {203+ for y := range s.frame.Height {
204- s.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})204+ s.frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
205 s.buffer.boundaries[y] = LineBound{End: 1, Soft: true}205 s.buffer.boundaries[y] = LineBound{End: 1, Soft: true}
206 }206 }
207 207
@@ -209,8 +209,8 @@ func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T
209 209
210 require.Equal(t, 257, view.Len())210 require.Equal(t, 257, view.Len())
211 require.Equal(t, 2, view.ChunkCount())211 require.Equal(t, 2, view.ChunkCount())
212- require.Len(t, view.Chunk(0).rows, 256)212+ require.Equal(t, 256, view.Chunk(0).len())
213- require.Len(t, view.Chunk(1).rows, 1)213+ require.Equal(t, 1, view.Chunk(1).len())
214 require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam")214 require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam")
215 require.False(t, view.Bound(256).Soft, "the transcript's final seam must be hard")215 require.False(t, view.Bound(256).Soft, "the transcript's final seam must be hard")
216 }216 }
@@ -218,11 +218,11 @@ func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T
218 func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) {218 func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) {
219 s := NewScreen(4, 2)219 s := NewScreen(4, 2)
220 s.Write([]byte("界"))220 s.Write([]byte("界"))
221- want := append([]renderer.Cell(nil), s.Frame.Row(0)...)221+ want := append([]renderer.Cell(nil), s.frame.Row(0)...)
222 222
223 snapshot := s.RecoveryTranscriptSnapshot()223 snapshot := s.RecoveryTranscriptSnapshot()
224- for x := range s.Frame.Width {224+ for x := range s.frame.Width {
225- s.Frame.Set(x, 0, renderer.BlankCell())225+ s.frame.Set(x, 0, renderer.BlankCell())
226 }226 }
227 227
228 view := decodeRecoveryTranscript(t, snapshot)228 view := decodeRecoveryTranscript(t, snapshot)
@@ -238,16 +238,16 @@ func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBl
238 []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}},238 []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}},
239 )239 )
240 240
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)
242 require.NoError(t, err)242 require.NoError(t, err)
243 243
244 view := screen.History().View()244 view := screen.History().View()
245 require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view))245 require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view))
246 require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3))246 require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3))
247 require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam")247 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 {248+ for y := range screen.frame.Height {
249- for x := range screen.Frame.Width {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)250+ require.True(t, screen.frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y)
251 }251 }
252 }252 }
253 require.Equal(t, []LineBound{{}, {}}, screen.LineBounds())253 require.Equal(t, []LineBound{{}, {}}, screen.LineBounds())
@@ -263,14 +263,14 @@ func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing.
263 }{263 }{
264 {264 {
265 name: "row budget",265 name: "row budget",
266- config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2},266+ config: HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 2},
267 history: []string{"old-1", "old-2", "old-3"},267 history: []string{"old-1", "old-2", "old-3"},
268 transcript: []string{"new-1", "new-2"},268 transcript: []string{"new-1", "new-2"},
269 want: []string{"old-3", "new-1", "new-2"},269 want: []string{"old-3", "new-1", "new-2"},
270 },270 },
271 {271 {
272- name: "cell budget",272+ name: "byte budget",
273- config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4},273+ config: HistoryConfig{MaxRows: 10, MaxBytes: 3*renderer.StoredCellLogicalBytes + 2*renderer.RowDescriptorLogicalBytes + 2*renderer.StyleRecordLogicalBytes, ChunkRows: 4},
274 history: []string{"aa", "b", "ccc"},274 history: []string{"aa", "b", "ccc"},
275 transcript: []string{"d", "ee"},275 transcript: []string{"d", "ee"},
276 want: []string{"d", "ee"},276 want: []string{"d", "ee"},
@@ -293,9 +293,9 @@ func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing
293 sealed, tail := recoveryHistoryBlobs(t, []string{"ok"})293 sealed, tail := recoveryHistoryBlobs(t, []string{"ok"})
294 transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil)294 transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil)
295 295
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)
297 297
298- require.ErrorIs(t, err, ErrHistoryRowTooWide)298+ require.ErrorIs(t, err, ErrHistoryRowTooLarge)
299 require.Nil(t, screen)299 require.Nil(t, screen)
300 }300 }
301 301
@@ -331,11 +331,11 @@ func BenchmarkRecoveryTranscriptSnapshot(b *testing.B) {
331 }331 }
332 332
333 func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) {333 func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) {
334- for y := range screen.Frame.Height {334+ for y := range screen.frame.Height {
335- for x := range screen.Frame.Width {335+ for x := range screen.frame.Width {
336- screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()})336+ screen.frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()})
337 }337 }
338- screen.buffer.boundaries[y] = LineBound{End: screen.Frame.Width}338+ screen.buffer.boundaries[y] = LineBound{End: screen.frame.Width}
339 }339 }
340 }340 }
341 341
@@ -343,14 +343,14 @@ func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) {
343 const rows = maxHistoryRows343 const rows = maxHistoryRows
344 source := NewScreen(1, rows)344 source := NewScreen(1, rows)
345 for y := range rows {345 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()})
347 source.buffer.boundaries[y] = LineBound{End: 1}347 source.buffer.boundaries[y] = LineBound{End: 1}
348 }348 }
349 transcript, err := source.RecoveryTranscriptSnapshot().Marshal()349 transcript, err := source.RecoveryTranscriptSnapshot().Marshal()
350 require.NoError(b, err)350 require.NoError(b, err)
351 tail, err := MarshalEmptyHistoryTail()351 tail, err := MarshalEmptyHistoryTail()
352 require.NoError(b, err)352 require.NoError(b, err)
353- config := HistoryConfig{MaxRows: rows, MaxCells: rows, ChunkRows: maxHistoryChunkRows}353+ config := HistoryConfig{MaxRows: rows, MaxBytes: 1 << 30, ChunkRows: maxHistoryChunkRows}
354 354
355 b.ReportAllocs()355 b.ReportAllocs()
356 b.ReportMetric(float64(rows), "rows/restore")356 b.ReportMetric(float64(rows), "rows/restore")
@@ -366,7 +366,7 @@ func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) {
366 366
367 func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) {367 func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) {
368 t.Helper()368 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})
370 for _, text := range texts {370 for _, text := range texts {
371 require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)}))371 require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)}))
372 }372 }
@@ -402,7 +402,7 @@ func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []
402 }402 }
403 view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))}403 view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))}
404 if len(rows) > 0 {404 if len(rows) > 0 {
405- view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds, rowIDs: rowIDs}}405+ view.chunks = newHistoryChunks(rows, bounds, rowIDs)
406 }406 }
407 blob, err := MarshalHistory(view)407 blob, err := MarshalHistory(view)
408 require.NoError(t, err)408 require.NoError(t, err)
M region_reflow_test.go +3 -3
@@ -42,9 +42,9 @@ func TestResizeSeversSoftWrapsAtRegionalScrollEdges(t *testing.T) {
42 42
43 s.Resize(3, 6)43 s.Resize(3, 6)
44 44
45- got := make([]string, s.Frame.Height)45+ got := make([]string, s.frame.Height)
46- for y := range s.Frame.Height {46+ for y := range s.frame.Height {
47- got[y] = rowString(s.Frame.Row(y))47+ got[y] = rowString(s.frame.Row(y))
48 }48 }
49 require.Equal(t, tt.want, got)49 require.Equal(t, tt.want, got)
50 })50 })
M resize_reflow_regression_test.go +4 -4
@@ -13,12 +13,12 @@ func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) {
13 if len(s.escapeBuf) != 0 {13 if len(s.escapeBuf) != 0 {
14 t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf)14 t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf)
15 }15 }
16- if s.Frame.Width != 0 || s.Frame.Height != 0 {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)17+ t.Fatalf("active frame = %dx%d, want 0x0", s.frame.Width, s.frame.Height)
18 }18 }
19 s.Write([]byte("\x1b[?1049l"))19 s.Write([]byte("\x1b[?1049l"))
20- if s.Frame.Width != 0 || s.Frame.Height != 0 {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)21+ t.Fatalf("saved primary frame = %dx%d, want 0x0", s.frame.Width, s.frame.Height)
22 }22 }
23 }23 }
24 24
M row_id_persistence_test.go +10 -9
@@ -15,16 +15,17 @@ func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) {
15 encoded, err := MarshalHistory(history.SealAndView())15 encoded, err := MarshalHistory(history.SealAndView())
16 require.NoError(t, err)16 require.NoError(t, err)
17 17
18- firstIDOffset := 17 + 418+ styles := int(binary.BigEndian.Uint32(encoded[24:28]))
19- secondIDOffset := firstIDOffset + 8 + 4 + historyCellBytes + historyBoundBytes19+ firstIDOffset := historyHeaderBytes + historyChunkHeaderBytes + (styles-1)*historyStyleBytes
20+ secondIDOffset := firstIDOffset + historyRowBytes
20 for _, test := range []struct {21 for _, test := range []struct {
21 name string22 name string
22 mutate func([]byte)23 mutate func([]byte)
23 }{24 }{
24 {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }},25 {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }},
25 {name: "duplicate row ID", mutate: func(data []byte) { copy(data[secondIDOffset:secondIDOffset+8], data[firstIDOffset:firstIDOffset+8]) }},26 {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: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[8:16], 2) }},
27- {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], ^uint64(0)) }},28+ {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[8:16], ^uint64(0)) }},
28 } {29 } {
29 t.Run(test.name, func(t *testing.T) {30 t.Run(test.name, func(t *testing.T) {
30 data := append([]byte(nil), encoded...)31 data := append([]byte(nil), encoded...)
@@ -135,7 +136,7 @@ func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) {
135 allocate()136 allocate()
136 row := historyRow("x")137 row := historyRow("x")
137 screen.buffer.rowIDs[0] = test.want138 screen.buffer.rowIDs[0] = test.want
138- screen.buffer.frame.Row(0)[0] = row[0]139+ screen.buffer.frame.Set(0, 0, row[0])
139 screen.buffer.boundaries[0] = LineBound{End: 1}140 screen.buffer.boundaries[0] = LineBound{End: 1}
140 transcript, err := screen.RecoveryTranscriptSnapshot().Marshal()141 transcript, err := screen.RecoveryTranscriptSnapshot().Marshal()
141 require.NoError(t, err)142 require.NoError(t, err)
@@ -154,7 +155,7 @@ func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) {
154 155
155 func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) {156 func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) {
156 _, err := MarshalHistory(HistoryView{157 _, 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)},
158 rows: 1,159 rows: 1,
159 })160 })
160 require.Error(t, err)161 require.Error(t, err)
@@ -166,7 +167,7 @@ func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) {
166 sealed, tail, err := MarshalSealedHistory(history.SealAndView())167 sealed, tail, err := MarshalSealedHistory(history.SealAndView())
167 require.NoError(t, err)168 require.NoError(t, err)
168 transcript, err := MarshalHistory(HistoryView{169 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})},
170 rows: 1,171 rows: 1,
171 nextRowID: 51,172 nextRowID: 51,
172 })173 })
@@ -186,7 +187,7 @@ func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) {
186 187
187 func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) {188 func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) {
188 sealedView := HistoryView{189 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})},
190 rows: 1,191 rows: 1,
191 nextRowID: 8,192 nextRowID: 8,
192 }193 }
@@ -195,7 +196,7 @@ func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *t
195 tail, err := MarshalEmptyHistoryTail()196 tail, err := MarshalEmptyHistoryTail()
196 require.NoError(t, err)197 require.NoError(t, err)
197 transcript, err := MarshalHistory(HistoryView{198 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})},
199 rows: 1,200 rows: 1,
200 nextRowID: 8,201 nextRowID: 8,
201 })202 })
M row_id_test.go +3 -3
@@ -98,9 +98,9 @@ func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) {
98 98
99 func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) {99 func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) {
100 b := newBuffer(8, 3)100 b := newBuffer(8, 3)
101- copy(b.frame.Row(0), historyRow("abcdefgh"))101+ b.frame.WriteRow(0, 0, historyRow("abcdefgh"))
102- copy(b.frame.Row(1), historyRow("ijkl"))102+ b.frame.WriteRow(1, 0, historyRow("ijkl"))
103- copy(b.frame.Row(2), historyRow("mnop"))103+ b.frame.WriteRow(2, 0, historyRow("mnop"))
104 b.boundaries[0] = LineBound{End: 8, Soft: true}104 b.boundaries[0] = LineBound{End: 8, Soft: true}
105 b.boundaries[1] = LineBound{End: 4}105 b.boundaries[1] = LineBound{End: 4}
106 b.boundaries[2] = LineBound{End: 4}106 b.boundaries[2] = LineBound{End: 4}
M screen.go +38 -8
@@ -26,7 +26,7 @@ const (
26 )26 )
27 27
28 type Screen struct {28 type Screen struct {
29- Frame renderer.Frame29+ frame renderer.Frame
30 Row int30 Row int
31 Col int31 Col int
32 Style renderer.Style32 Style renderer.Style
@@ -107,7 +107,7 @@ func NewScreen(width, height int) *Screen {
107 geometry: Geometry{Cols: width, Rows: height},107 geometry: Geometry{Cols: width, Rows: height},
108 }108 }
109 s.buffer = s.newBuffer(width, height)109 s.buffer = s.newBuffer(width, height)
110- s.Frame = s.buffer.frame110+ s.frame = s.buffer.frame
111 s.resetScrollRegion()111 s.resetScrollRegion()
112 return s112 return s
113 }113 }
@@ -124,6 +124,36 @@ func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
124 // configured with NewScreenWithHistory.124 // configured with NewScreenWithHistory.
125 func (s *Screen) History() *History { return s.history }125 func (s *Screen) History() *History { return s.history }
126 126
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+
127 // LineBounds returns an owned copy of the live grid's per-row logical extents,157 // LineBounds returns an owned copy of the live grid's per-row logical extents,
128 // indexed like Frame rows. It returns nil when the screen has no buffer.158 // indexed like Frame rows. It returns nil when the screen has no buffer.
129 func (s *Screen) LineBounds() []LineBound {159 func (s *Screen) LineBounds() []LineBound {
@@ -440,10 +470,10 @@ func (s *Screen) kittyCursorX() uint64 {
440 }470 }
441 471
442 func (s *Screen) kittyCursorColumn() int {472 func (s *Screen) kittyCursorColumn() int {
443- if s.Frame.Width <= 0 {473+ if s.frame.Width <= 0 {
444 return 0474 return 0
445 }475 }
446- return clamp(s.Col, 0, s.Frame.Width-1)476+ return clamp(s.Col, 0, s.frame.Width-1)
447 }477 }
448 478
449 func (s *Screen) kittyCursorY() uint64 {479 func (s *Screen) kittyCursorY() uint64 {
@@ -467,12 +497,12 @@ func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
467 }497 }
468 498
469 func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) {499 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 {
471 return501 return
472 }502 }
473 columns, rows := s.kittyPlacementGridSize(c, mutation)503 columns, rows := s.kittyPlacementGridSize(c, mutation)
474 targetColumn := s.kittyCursorColumn() + columns504 targetColumn := s.kittyCursorColumn() + columns
475- wraps := targetColumn >= s.Frame.Width505+ wraps := targetColumn >= s.frame.Width
476 rowMoves := max(rows-1, 0)506 rowMoves := max(rows-1, 0)
477 if wraps {507 if wraps {
478 rowMoves++508 rowMoves++
@@ -486,7 +516,7 @@ func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kit
486 }516 }
487 517
488 func (s *Screen) advanceKittyCursorRows(rows int) {518 func (s *Screen) advanceKittyCursorRows(rows int) {
489- if rows <= 0 || s.Frame.Height <= 0 {519+ if rows <= 0 || s.frame.Height <= 0 {
490 return520 return
491 }521 }
492 if s.Row >= s.scrollTop && s.Row <= s.scrollBottom {522 if s.Row >= s.scrollTop && s.Row <= s.scrollBottom {
@@ -499,7 +529,7 @@ func (s *Screen) advanceKittyCursorRows(rows int) {
499 s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll)529 s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll)
500 return530 return
501 }531 }
502- s.Row = min(s.Row+rows, s.Frame.Height-1)532+ s.Row = min(s.Row+rows, s.frame.Height-1)
503 }533 }
504 534
505 func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) {535 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) {
122 case 'B':122 case 'B':
123 s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())123 s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
124 case 'C':124 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)
126 case 'D':126 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)
128 case 'E':128 case 'E':
129 s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())129 s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
130 s.Col = 0130 s.Col = 0
@@ -133,7 +133,7 @@ func (s *Screen) applyCSI(params string, cmd byte) {
133 s.Col = 0133 s.Col = 0
134 case 'G':134 case 'G':
135 col := firstPositive(parts, 1)135 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)
137 case 'd':137 case 'd':
138 row := firstPositive(parts, 1)138 row := firstPositive(parts, 1)
139 s.Row = s.addressedRow(row)139 s.Row = s.addressedRow(row)
@@ -335,23 +335,18 @@ func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
335 }335 }
336 336
337 func (s *Screen) insertChars(n int) {337 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 {
339 return339 return
340 }340 }
341- w := s.Frame.Width341+ w := s.frame.Width
342 if n > w-s.Col {342 if n > w-s.Col {
343 n = w - s.Col343 n = w - s.Col
344 }344 }
345- row := s.Frame.Row(s.Row)
346 // A wide left half at Col-1 whose continuation sits at Col will be orphaned345 // A wide left half at Col-1 whose continuation sits at Col will be orphaned
347 // by the shift; its repair falls outside the default damage rect.346 // by the shift; its repair falls outside the default damage rect.
348- leftSplit := s.Col > 0 && row[s.Col].Continuation347+ leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation
349- for x := w - 1; x >= s.Col+n; x-- {348+ s.frame.CopyRow(s.Row, s.Col+n, s.Col, w-s.Col-n)
350- row[x] = row[x-n]349+ s.frame.FillRow(s.Row, s.Col, s.Col+n, renderer.BlankCell())
351- }
352- for x := s.Col; x < s.Col+n; x++ {
353- row[x] = renderer.BlankCell()
354- }
355 s.repairRow(s.Row)350 s.repairRow(s.Row)
356 dmgX := s.Col351 dmgX := s.Col
357 if leftSplit {352 if leftSplit {
@@ -361,23 +356,18 @@ func (s *Screen) insertChars(n int) {
361 }356 }
362 357
363 func (s *Screen) deleteChars(n int) {358 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 {
365 return360 return
366 }361 }
367- w := s.Frame.Width362+ w := s.frame.Width
368 if n > w-s.Col {363 if n > w-s.Col {
369 n = w - s.Col364 n = w - s.Col
370 }365 }
371- row := s.Frame.Row(s.Row)
372 // A wide left half at Col-1 whose continuation sits at Col will be orphaned366 // A wide left half at Col-1 whose continuation sits at Col will be orphaned
373 // by the shift; its repair falls outside the default damage rect.367 // by the shift; its repair falls outside the default damage rect.
374- leftSplit := s.Col > 0 && row[s.Col].Continuation368+ leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation
375- for x := s.Col; x < w-n; x++ {369+ s.frame.CopyRow(s.Row, s.Col, s.Col+n, w-s.Col-n)
376- row[x] = row[x+n]370+ s.frame.FillRow(s.Row, w-n, w, renderer.BlankCell())
377- }
378- for x := w - n; x < w; x++ {
379- row[x] = renderer.BlankCell()
380- }
381 s.repairRow(s.Row)371 s.repairRow(s.Row)
382 dmgX := s.Col372 dmgX := s.Col
383 if leftSplit {373 if leftSplit {
@@ -404,34 +394,34 @@ func (s *Screen) clearScreenMode(mode int) {
404 s.clampCursor()394 s.clampCursor()
405 switch mode {395 switch mode {
406 case 1:396 case 1:
407- for y := range min(s.Row+1, s.Frame.Height) {397+ for y := range min(s.Row+1, s.frame.Height) {
408- end := s.Frame.Width398+ end := s.frame.Width
409 if y == s.Row {399 if y == s.Row {
410- end = min(s.Col+1, s.Frame.Width)400+ end = min(s.Col+1, s.frame.Width)
411 }401 }
412 s.clearRow(y, 0, end)402 s.clearRow(y, 0, end)
413 }403 }
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})
415 case 2, 3:405 case 2, 3:
416 blank := s.eraseCell()406 blank := s.eraseCell()
417- for i := range s.Frame.Cells {407+ for y := range s.frame.Height {
418- s.Frame.Cells[i] = blank408+ s.frame.FillRow(y, 0, s.frame.Width, blank)
419 }409 }
420 for y := range s.buffer.boundaries {410 for y := range s.buffer.boundaries {
421 s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}411 s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}
422 s.buffer.rowIDs[y] = s.nextRowIDValue()412 s.buffer.rowIDs[y] = s.nextRowIDValue()
423 }413 }
424 s.graphicsClearPlacements()414 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})
426 default:416 default:
427- for y := s.Row; y < s.Frame.Height; y++ {417+ for y := s.Row; y < s.frame.Height; y++ {
428 start := 0418 start := 0
429 if y == s.Row {419 if y == s.Row {
430 start = s.Col420 start = s.Col
431 }421 }
432- s.clearRow(y, start, s.Frame.Width)422+ s.clearRow(y, start, s.frame.Width)
433 }423 }
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})
435 }425 }
436 }426 }
437 427
@@ -439,13 +429,13 @@ func (s *Screen) clearLineMode(mode int) {
439 s.clampCursor()429 s.clampCursor()
440 switch mode {430 switch mode {
441 case 1:431 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))
443 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})433 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
444 case 2:434 case 2:
445- start, width := s.clearRow(s.Row, 0, s.Frame.Width)435+ start, width := s.clearRow(s.Row, 0, s.frame.Width)
446 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})436 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
447 default:437 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)
449 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})439 s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
450 }440 }
451 }441 }
@@ -455,7 +445,7 @@ func (s *Screen) eraseChars(n int) {
455 return445 return
456 }446 }
457 s.clampCursor()447 s.clampCursor()
458- if remaining := s.Frame.Width - s.Col; n > remaining {448+ if remaining := s.frame.Width - s.Col; n > remaining {
459 n = remaining449 n = remaining
460 }450 }
461 end := s.Col + n451 end := s.Col + n
@@ -466,8 +456,8 @@ func (s *Screen) eraseChars(n int) {
466 }456 }
467 457
468 func (s *Screen) clampCursor() {458 func (s *Screen) clampCursor() {
469- s.Row = clamp(s.Row, 0, s.Frame.Height-1)459+ s.Row = clamp(s.Row, 0, s.frame.Height-1)
470- s.Col = clamp(s.Col, 0, s.Frame.Width-1)460+ s.Col = clamp(s.Col, 0, s.frame.Width-1)
471 }461 }
472 462
473 func (s *Screen) parseCSIInts(params string) []int {463 func (s *Screen) parseCSIInts(params string) []int {
M screen_csi_test.go +1 -1
@@ -731,7 +731,7 @@ func TestBackgroundColorErase(t *testing.T) {
731 for y := range tt.height {731 for y := range tt.height {
732 for x := tt.startX; x < tt.width; x++ {732 for x := tt.startX; x < tt.width; x++ {
733 cell := cellAt(s, x, y)733 cell := cellAt(s, x, y)
734- if cell.Rune != ' ' || cell.Style != tt.wantStyle {734+ if cell.Rune != ' ' || !cell.Style.Equal(tt.wantStyle) {
735 t.Errorf("cell(%d,%d) = %+v, want space with style %+v", x, y, cell, tt.wantStyle)735 t.Errorf("cell(%d,%d) = %+v, want space with style %+v", x, y, cell, tt.wantStyle)
736 }736 }
737 }737 }
M screen_cursor.go +7 -7
@@ -25,28 +25,28 @@ func (s *Screen) cursorReportRow() int {
25 25
26 func (s *Screen) cursorMinRow() int {26 func (s *Screen) cursorMinRow() int {
27 if s.originMode {27 if s.originMode {
28- return clamp(s.scrollTop, 0, s.Frame.Height-1)28+ return clamp(s.scrollTop, 0, s.frame.Height-1)
29 }29 }
30 return 030 return 0
31 }31 }
32 32
33 func (s *Screen) cursorMaxRow() int {33 func (s *Screen) cursorMaxRow() int {
34 if s.originMode {34 if s.originMode {
35- return clamp(s.scrollBottom, 0, s.Frame.Height-1)35+ return clamp(s.scrollBottom, 0, s.frame.Height-1)
36 }36 }
37- return max(s.Frame.Height-1, 0)37+ return max(s.frame.Height-1, 0)
38 }38 }
39 39
40 func (s *Screen) addressedRow(row int) int {40 func (s *Screen) addressedRow(row int) int {
41 if s.originMode {41 if s.originMode {
42 return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())42 return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
43 }43 }
44- return clamp(row-1, 0, s.Frame.Height-1)44+ return clamp(row-1, 0, s.frame.Height-1)
45 }45 }
46 46
47 func (s *Screen) addressCursor(row, col int) {47 func (s *Screen) addressCursor(row, col int) {
48 s.Row = s.addressedRow(row)48 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)
50 }50 }
51 51
52 func (s *Screen) homeCursor() {52 func (s *Screen) homeCursor() {
@@ -81,8 +81,8 @@ func (s *Screen) restoreCursor() {
81 if !s.savedCursor.saved {81 if !s.savedCursor.saved {
82 return82 return
83 }83 }
84- s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-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)85+ s.Col = clamp(s.savedCursor.col, 0, s.frame.Width-1)
86 s.Style = s.savedCursor.style86 s.Style = s.savedCursor.style
87 s.originMode = s.savedCursor.originMode87 s.originMode = s.savedCursor.originMode
88 s.insertMode = s.savedCursor.insertMode88 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 {
25 }25 }
26 26
27 func cellAt(s *Screen, x, y int) renderer.Cell {27 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]
29 }37 }
30 38
31 func assertFramesEqual(t *testing.T, got, want renderer.Frame) {39 func assertFramesEqual(t *testing.T, got, want renderer.Frame) {
@@ -70,8 +78,8 @@ func assertBlank(t *testing.T, s *Screen, x, y int) {
70 78
71 func assertNoOrphanWideCells(t *testing.T, s *Screen) {79 func assertNoOrphanWideCells(t *testing.T, s *Screen) {
72 t.Helper()80 t.Helper()
73- for y := range s.Frame.Height {81+ for y := range s.frame.Height {
74- for x := range s.Frame.Width {82+ for x := range s.frame.Width {
75 c := cellAt(s, x, y)83 c := cellAt(s, x, y)
76 if c.Continuation {84 if c.Continuation {
77 if x == 0 || cellAt(s, x-1, y).Continuation || renderer.RuneWidth(cellAt(s, x-1, y).Rune) != 2 {85 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) {
79 }87 }
80 continue88 continue
81 }89 }
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) {
83 t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune)91 t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune)
84 }92 }
85 }93 }
@@ -87,9 +95,9 @@ func assertNoOrphanWideCells(t *testing.T, s *Screen) {
87 }95 }
88 96
89 func lineText(s *Screen, y int) string {97 func lineText(s *Screen, y int) string {
90- out := make([]rune, s.Frame.Width)98+ out := make([]rune, s.frame.Width)
91- for x := range s.Frame.Width {99+ for x := range s.frame.Width {
92- out[x] = s.Frame.At(x, y).Rune100+ out[x] = s.frame.At(x, y).Rune
93 }101 }
94 return string(out)102 return string(out)
95 }103 }
M screen_integration_test.go +9 -9
@@ -11,13 +11,13 @@ func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) {
11 r := renderer.New(renderer.Capabilities{})11 r := renderer.New(renderer.Capabilities{})
12 12
13 s.Write([]byte("❯ "))13 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 {
15 t.Fatal(err)15 t.Fatal(err)
16 }16 }
17 s.ClearDamage()17 s.ClearDamage()
18 18
19 s.Write([]byte("h"))19 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 {
21 t.Fatal(err)21 t.Fatal(err)
22 }22 }
23 s.ClearDamage()23 s.ClearDamage()
@@ -25,16 +25,16 @@ func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) {
25 s.Write([]byte("\x1b[31mh\x1b[39m"))25 s.Write([]byte("\x1b[31mh\x1b[39m"))
26 s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m"))26 s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m"))
27 27
28- for y := range s.Frame.Height {28+ for y := range s.frame.Height {
29- for x := range s.Frame.Width {29+ for x := range s.frame.Width {
30- r := s.Frame.At(x, y).Rune30+ r := s.frame.At(x, y).Rune
31 if r == '\r' || r == '\n' {31 if r == '\r' || r == '\n' {
32 t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r)32 t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r)
33 }33 }
34 }34 }
35 }35 }
36 36
37- out, err := r.Draw(s.Frame, s.Damage())37+ out, err := r.Draw(s.frame, s.Damage())
38 if err != nil {38 if err != nil {
39 t.Fatal(err)39 t.Fatal(err)
40 }40 }
@@ -56,18 +56,18 @@ func TestIntegrationScenarios(t *testing.T) {
56 s := NewScreen(8, 2)56 s := NewScreen(8, 2)
57 r := renderer.New(renderer.Capabilities{})57 r := renderer.New(renderer.Capabilities{})
58 s.Write([]byte("abcdef"))58 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 {
60 t.Fatal(err)60 t.Fatal(err)
61 }61 }
62 s.ClearDamage()62 s.ClearDamage()
63 63
64 s.Write([]byte("\x1b[1;3H\x1b[2@XY"))64 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 {
66 t.Fatal(err)66 t.Fatal(err)
67 }67 }
68 s.ClearDamage()68 s.ClearDamage()
69 69
70- out, err := r.Draw(s.Frame, nil)70+ out, err := r.Draw(s.frame, nil)
71 if err != nil {71 if err != nil {
72 t.Fatal(err)72 t.Fatal(err)
73 }73 }
M screen_modes.go +13 -19
@@ -6,7 +6,6 @@ import (
6 )6 )
7 7
8 type screenState struct {8 type screenState struct {
9- frame renderer.Frame
10 buffer *buffer9 buffer *buffer
11 row int10 row int
12 col int11 col int
@@ -92,8 +91,8 @@ func (s *Screen) colorSchemeReport() []byte {
92 }91 }
93 92
94 func (s *Screen) reset() {93 func (s *Screen) reset() {
95- s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)94+ s.buffer = s.newBuffer(s.frame.Width, s.frame.Height)
96- s.Frame = s.buffer.frame95+ s.frame = s.buffer.frame
97 s.Row, s.Col = 0, 096 s.Row, s.Col = 0, 0
98 s.Style = renderer.DefaultStyle()97 s.Style = renderer.DefaultStyle()
99 s.escapeBuf = s.escapeBuf[:0]98 s.escapeBuf = s.escapeBuf[:0]
@@ -117,10 +116,10 @@ func (s *Screen) reset() {
117 }116 }
118 117
119 func (s *Screen) setScrollRegion(parts []int) {118 func (s *Screen) setScrollRegion(parts []int) {
120- if s.Frame.Height == 0 {119+ if s.frame.Height == 0 {
121 return120 return
122 }121 }
123- top, bottom := 1, s.Frame.Height122+ top, bottom := 1, s.frame.Height
124 if len(parts) > 0 && parts[0] > 0 {123 if len(parts) > 0 && parts[0] > 0 {
125 top = parts[0]124 top = parts[0]
126 }125 }
@@ -130,8 +129,8 @@ func (s *Screen) setScrollRegion(parts []int) {
130 if top >= bottom {129 if top >= bottom {
131 s.resetScrollRegion()130 s.resetScrollRegion()
132 } else {131 } else {
133- s.scrollTop = clamp(top-1, 0, s.Frame.Height-1)132+ s.scrollTop = clamp(top-1, 0, s.frame.Height-1)
134- s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1)133+ s.scrollBottom = clamp(bottom-1, 0, s.frame.Height-1)
135 if s.scrollTop >= s.scrollBottom {134 if s.scrollTop >= s.scrollBottom {
136 s.resetScrollRegion()135 s.resetScrollRegion()
137 }136 }
@@ -141,8 +140,8 @@ func (s *Screen) setScrollRegion(parts []int) {
141 140
142 func (s *Screen) resetScrollRegion() {141 func (s *Screen) resetScrollRegion() {
143 s.scrollTop = 0142 s.scrollTop = 0
144- if s.Frame.Height > 0 {143+ if s.frame.Height > 0 {
145- s.scrollBottom = s.Frame.Height - 1144+ s.scrollBottom = s.frame.Height - 1
146 } else {145 } else {
147 s.scrollBottom = 0146 s.scrollBottom = 0
148 }147 }
@@ -200,7 +199,6 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
200 func (s *Screen) enterAlternateScreen() {199 func (s *Screen) enterAlternateScreen() {
201 if s.alternate == nil {200 if s.alternate == nil {
202 s.alternate = &screenState{201 s.alternate = &screenState{
203- frame: cloneFrame(s.Frame),
204 buffer: s.buffer.clone(),202 buffer: s.buffer.clone(),
205 row: s.Row,203 row: s.Row,
206 col: s.Col,204 col: s.Col,
@@ -216,8 +214,8 @@ func (s *Screen) enterAlternateScreen() {
216 }214 }
217 s.graphics = nil215 s.graphics = nil
218 s.kittyPendingDisplay = nil216 s.kittyPendingDisplay = nil
219- s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)217+ s.buffer = s.newBuffer(s.frame.Width, s.frame.Height)
220- s.Frame = s.buffer.frame218+ s.frame = s.buffer.frame
221 s.Row, s.Col = 0, 0219 s.Row, s.Col = 0, 0
222 s.Style = renderer.DefaultStyle()220 s.Style = renderer.DefaultStyle()
223 s.savedCursor = cursorState{}221 s.savedCursor = cursorState{}
@@ -231,13 +229,9 @@ func (s *Screen) exitAlternateScreen() {
231 }229 }
232 state := s.alternate230 state := s.alternate
233 s.buffer = state.buffer231 s.buffer = state.buffer
234- if s.buffer == nil {232+ s.frame = s.buffer.frame
235- s.buffer = bufferFromFrame(cloneFrame(state.frame))233+ s.Row = clamp(state.row, 0, s.frame.Height-1)
236- s.fillMissingRowIDs(s.buffer)234+ s.Col = clamp(state.col, 0, s.frame.Width-1)
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)
241 s.Style = state.style235 s.Style = state.style
242 s.scrollTop = state.scrollTop236 s.scrollTop = state.scrollTop
243 s.scrollBottom = state.scrollBottom237 s.scrollBottom = state.scrollBottom
M screen_osc_test.go +3 -3
@@ -13,7 +13,7 @@ func TestOnBellFiresForLoneBEL(t *testing.T) {
13 s.OnBell = func() { rang++ }13 s.OnBell = func() { rang++ }
14 s.Write([]byte("hi\ahi\a"))14 s.Write([]byte("hi\ahi\a"))
15 require.Equal(t, 2, rang)15 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)), " "))
17 }17 }
18 18
19 func TestOnBellIgnoresOSCTerminator(t *testing.T) {19 func TestOnBellIgnoresOSCTerminator(t *testing.T) {
@@ -27,7 +27,7 @@ func TestOnBellIgnoresOSCTerminator(t *testing.T) {
27 func TestBELWithoutCallbackIsDiscarded(t *testing.T) {27 func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
28 s := NewScreen(10, 2)28 s := NewScreen(10, 2)
29 s.Write([]byte("a\ab")) // no OnBell set: must not panic29 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)), " "))
31 }31 }
32 32
33 func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {33 func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
@@ -54,7 +54,7 @@ func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
54 require.Equal(t, tt.want, s.TerminalTitle())54 require.Equal(t, tt.want, s.TerminalTitle())
55 require.Equal(t, 0, bells)55 require.Equal(t, 0, bells)
56 require.Empty(t, s.Damage())56 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)))
58 })58 })
59 }59 }
60 }60 }
M screen_resize.go +2 -21
@@ -1,9 +1,7 @@
1 package vt1 package vt
2 2
3-import renderer "github.com/bnema/vev-vt/core"
4-
5 func (s *Screen) Resize(width, height int) {3 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 {
7 s.geometry.Cols, s.geometry.Rows = width, height5 s.geometry.Cols, s.geometry.Rows = width, height
8 return6 return
9 }7 }
@@ -15,7 +13,6 @@ func (s *Screen) Resize(width, height int) {
15 s.Row, s.Col = 0, 013 s.Row, s.Col = 0, 0
16 if state := s.alternate; state != nil {14 if state := s.alternate; state != nil {
17 state.buffer = s.newBuffer(width, height)15 state.buffer = s.newBuffer(width, height)
18- state.frame = state.buffer.frame
19 state.row, state.col = 0, 016 state.row, state.col = 0, 0
20 state.scrollTop, state.scrollBottom = 0, height-117 state.scrollTop, state.scrollBottom = 0, height-1
21 }18 }
@@ -49,18 +46,13 @@ func (s *Screen) Resize(width, height int) {
49 // cursors for each state are mapped by their single buffer layout pass.46 // cursors for each state are mapped by their single buffer layout pass.
50 s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false)47 s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false)
51 state := s.alternate48 state := s.alternate
52- if state.buffer == nil {
53- state.buffer = bufferFromFrame(state.frame)
54- s.fillMissingRowIDs(state.buffer)
55- }
56 state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)49 state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
57- state.frame = state.buffer.frame
58 state.scrollTop, state.scrollBottom = 0, height-150 state.scrollTop, state.scrollBottom = 0, height-1
59 } else {51 } else {
60 s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true)52 s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true)
61 }53 }
62 }54 }
63- s.Frame = s.buffer.frame55+ s.frame = s.buffer.frame
64 s.geometry.Cols, s.geometry.Rows = width, height56 s.geometry.Cols, s.geometry.Rows = width, height
65 // A resize can split an in-flight escape sequence from the terminal state it57 // A resize can split an in-flight escape sequence from the terminal state it
66 // was meant to mutate; keep the durable child state but discard partial bytes.58 // was meant to mutate; keep the durable child state but discard partial bytes.
@@ -72,17 +64,6 @@ func (s *Screen) Resize(width, height int) {
72 s.fullRedraw()64 s.fullRedraw()
73 }65 }
74 66
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-
86 func clamp(v, lo, hi int) int {67 func clamp(v, lo, hi int) int {
87 if hi < lo {68 if hi < lo {
88 return lo69 return lo
M screen_resize_test.go +6 -6
@@ -11,16 +11,16 @@ import (
11 func TestResize(t *testing.T) {11 func TestResize(t *testing.T) {
12 setRow := func(s *Screen, y int, text string) {12 setRow := func(s *Screen, y int, text string) {
13 for x, r := range []rune(text) {13 for x, r := range []rune(text) {
14- if x >= s.Frame.Width {14+ if x >= s.frame.Width {
15 break15 break
16 }16 }
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()})
18 }18 }
19 }19 }
20 rowString := func(s *Screen, y int) string {20 rowString := func(s *Screen, y int) string {
21- runes := make([]rune, s.Frame.Width)21+ runes := make([]rune, s.frame.Width)
22- for x := range s.Frame.Width {22+ for x := range s.frame.Width {
23- runes[x] = s.Frame.At(x, y).Rune23+ runes[x] = s.frame.At(x, y).Rune
24 }24 }
25 return string(runes)25 return string(runes)
26 }26 }
@@ -216,7 +216,7 @@ func TestResize(t *testing.T) {
216 for range 200 {216 for range 200 {
217 s := NewScreen(8, 4)217 s := NewScreen(8, 4)
218 s.Write([]byte("a界b好c語d"))218 s.Write([]byte("a界b好c語d"))
219- s.Row = rng.Intn(s.Frame.Height)219+ s.Row = rng.Intn(s.frame.Height)
220 220
221 s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)221 s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
222 222
M screen_scroll.go +17 -17
@@ -16,17 +16,17 @@ import (
16 // buffer.scrollUp severs that link on the way. Callers that merely advance,16 // buffer.scrollUp severs that link on the way. Callers that merely advance,
17 // such as LF and ESC D, ignore the result.17 // such as LF and ESC D, ignore the result.
18 func (s *Screen) index() (movedUp bool) {18 func (s *Screen) index() (movedUp bool) {
19- if s.Frame.Height == 0 {19+ if s.frame.Height == 0 {
20 return false20 return false
21 }21 }
22 if s.Row == s.scrollBottom && s.Row >= s.scrollTop {22 if s.Row == s.scrollBottom && s.Row >= s.scrollTop {
23 return s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)23 return s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)
24 }24 }
25- if s.Row+1 < s.Frame.Height {25+ if s.Row+1 < s.frame.Height {
26 s.Row++26 s.Row++
27 return false27 return false
28 }28 }
29- s.Row = s.Frame.Height - 129+ s.Row = s.frame.Height - 1
30 return false30 return false
31 }31 }
32 32
@@ -37,7 +37,7 @@ func (s *Screen) nextLine() {
37 }37 }
38 38
39 func (s *Screen) reverseIndex() {39 func (s *Screen) reverseIndex() {
40- if s.Frame.Height == 0 {40+ if s.frame.Height == 0 {
41 return41 return
42 }42 }
43 if s.Row == s.scrollTop && s.Row <= s.scrollBottom {43 if s.Row == s.scrollTop && s.Row <= s.scrollBottom {
@@ -71,14 +71,14 @@ func (s *Screen) scrollDownBy(n int) {
71 // what the scroll observed keeps callers from re-deriving a predicate that this71 // what the scroll observed keeps callers from re-deriving a predicate that this
72 // function may have declined to act on at all.72 // function may have declined to act on at all.
73 func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {73 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 {
75 return false75 return false
76 }76 }
77 top, bottom, ok := s.normalizedRegion(top, bottom)77 top, bottom, ok := s.normalizedRegion(top, bottom)
78 if !ok {78 if !ok {
79 return false79 return false
80 }80 }
81- w := s.Frame.Width81+ w := s.frame.Width
82 height := bottom - top + 182 height := bottom - top + 1
83 graphicsRows := n83 graphicsRows := n
84 if n > height {84 if n > height {
@@ -89,7 +89,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {
89 // frame's line offsets (recycling and blanking the evicted rows in place)89 // frame's line offsets (recycling and blanking the evicted rows in place)
90 // instead of copying cells. See renderer.Frame.ScrollUp.90 // instead of copying cells. See renderer.Frame.ScrollUp.
91 s.emitLineEvicted(top, n)91 s.emitLineEvicted(top, n)
92- s.Frame.ScrollUp(top, bottom, n)92+ s.frame.ScrollUp(top, bottom, n)
93 s.buffer.scrollUp(top, bottom, n)93 s.buffer.scrollUp(top, bottom, n)
94 s.fillMissingRowIDs(s.buffer)94 s.fillMissingRowIDs(s.buffer)
95 s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})95 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) {
106 // Read boundaries and IDs before the caller rotates the frame: a soft link106 // Read boundaries and IDs before the caller rotates the frame: a soft link
107 // belongs to the row it follows, and rotation reassigns row indices.107 // belongs to the row it follows, and rotation reassigns row indices.
108 for y := top; y < top+n; y++ {108 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])
110 }110 }
111 }111 }
112 112
113 func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) {113 func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) {
114 if s.history != nil {114 if s.history != nil {
115 err := s.history.AppendWithID(row, bound, id)115 err := s.history.AppendWithID(row, bound, id)
116- if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {116+ if err != nil && !errors.Is(err, ErrHistoryRowTooLarge) {
117 panic(err)117 panic(err)
118 }118 }
119- // A nil error records the row; ErrHistoryRowTooWide explicitly leaves it119+ // A nil error records the row; ErrHistoryRowTooLarge explicitly leaves it
120 // unrecorded. Both cases continue to the eviction observer below.120 // unrecorded. Both cases continue to the eviction observer below.
121 }121 }
122 if s.OnLineEvicted != nil {122 if s.OnLineEvicted != nil {
@@ -125,14 +125,14 @@ func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) {
125 }125 }
126 126
127 func (s *Screen) scrollDownRegion(top, bottom, n int) {127 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 {
129 return129 return
130 }130 }
131 top, bottom, ok := s.normalizedRegion(top, bottom)131 top, bottom, ok := s.normalizedRegion(top, bottom)
132 if !ok {132 if !ok {
133 return133 return
134 }134 }
135- w := s.Frame.Width135+ w := s.frame.Width
136 height := bottom - top + 1136 height := bottom - top + 1
137 graphicsRows := n137 graphicsRows := n
138 if n > height {138 if n > height {
@@ -140,7 +140,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) {
140 }140 }
141 s.scrollGraphicsRegion(top, bottom, graphicsRows)141 s.scrollGraphicsRegion(top, bottom, graphicsRows)
142 // Full-width region: rotate line offsets instead of copying cells.142 // Full-width region: rotate line offsets instead of copying cells.
143- s.Frame.ScrollDown(top, bottom, n)143+ s.frame.ScrollDown(top, bottom, n)
144 s.buffer.scrollDown(top, bottom, n)144 s.buffer.scrollDown(top, bottom, n)
145 s.fillMissingRowIDs(s.buffer)145 s.fillMissingRowIDs(s.buffer)
146 s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})146 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) {
155 return155 return
156 }156 }
157 delta := int64(rows) * int64(cellHeight)157 delta := int64(rows) * int64(cellHeight)
158- fullScreen := top == 0 && bottom == s.Frame.Height-1158+ fullScreen := top == 0 && bottom == s.frame.Height-1
159 snapshot := s.graphics.scene.Snapshot()159 snapshot := s.graphics.scene.Snapshot()
160 placements := snapshot.Placements()160 placements := snapshot.Placements()
161 operations := make([]graphics.Operation, 0, len(placements))161 operations := make([]graphics.Operation, 0, len(placements))
@@ -191,10 +191,10 @@ func (s *Screen) scrollGraphicsRegion(top, bottom, rows int) {
191 }191 }
192 192
193 func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {193 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 {
195 return 0, 0, false195 return 0, 0, false
196 }196 }
197- top = clamp(top, 0, s.Frame.Height-1)197+ top = clamp(top, 0, s.frame.Height-1)
198- bottom = clamp(bottom, 0, s.Frame.Height-1)198+ bottom = clamp(bottom, 0, s.frame.Height-1)
199 return top, bottom, top <= bottom199 return top, bottom, top <= bottom
200 }200 }
M screen_scroll_test.go +3 -3
@@ -27,7 +27,7 @@ func TestWideCharSurvivesRotatedScroll(t *testing.T) {
27 assertContinuation(t, s, 1, 1)27 assertContinuation(t, s, 1, 1)
28 assertBlank(t, s, 0, 2)28 assertBlank(t, s, 0, 2)
29 assertBlank(t, s, 1, 2)29 assertBlank(t, s, 1, 2)
30- if err := s.Frame.CheckInvariants(); err != nil {30+ if err := s.frame.CheckInvariants(); err != nil {
31 t.Fatalf("invariants: %v", err)31 t.Fatalf("invariants: %v", err)
32 }32 }
33 },33 },
@@ -43,7 +43,7 @@ func TestWideCharSurvivesRotatedScroll(t *testing.T) {
43 assertCell(t, s, 0, 1, '世')43 assertCell(t, s, 0, 1, '世')
44 assertContinuation(t, s, 1, 1)44 assertContinuation(t, s, 1, 1)
45 assertBlank(t, s, 0, 0)45 assertBlank(t, s, 0, 0)
46- if err := s.Frame.CheckInvariants(); err != nil {46+ if err := s.frame.CheckInvariants(); err != nil {
47 t.Fatalf("invariants: %v", err)47 t.Fatalf("invariants: %v", err)
48 }48 }
49 },49 },
@@ -72,7 +72,7 @@ func TestLineOffsetInvariantAfterVTSequences(t *testing.T) {
72 for i, seq := range seqs {72 for i, seq := range seqs {
73 s.Write(seq)73 s.Write(seq)
74 s.ClearDamage()74 s.ClearDamage()
75- if err := s.Frame.CheckInvariants(); err != nil {75+ if err := s.frame.CheckInvariants(); err != nil {
76 t.Fatalf("after seq %d: invariants broken: %v", i, err)76 t.Fatalf("after seq %d: invariants broken: %v", i, err)
77 }77 }
78 }78 }
M screen_snapshot.go +7 -8
@@ -24,9 +24,8 @@ type ModeSnapshot struct {
24 MouseSGR bool24 MouseSGR bool
25 }25 }
26 26
27-// ScreenSnapshot is an owned immutable-by-convention capture of the active27+// ScreenSnapshot is an owned immutable capture of the active terminal
28-// terminal viewport. Row returns caller-owned storage; BorrowedRow must not be28+// viewport. It implements core.CellSource, and Row returns caller-owned storage.
29-// mutated and remains valid while the snapshot is retained.
30 type ScreenSnapshot struct {29 type ScreenSnapshot struct {
31 frame renderer.Frame30 frame renderer.Frame
32 bounds []LineBound31 bounds []LineBound
@@ -52,7 +51,7 @@ func (s *Screen) Snapshot() ScreenSnapshot {
52 nextRowID++51 nextRowID++
53 }52 }
54 return ScreenSnapshot{53 return ScreenSnapshot{
55- frame: s.Frame.Clone(),54+ frame: s.frame.Clone(),
56 bounds: s.LineBounds(),55 bounds: s.LineBounds(),
57 rowIDs: s.RowIDs(),56 rowIDs: s.RowIDs(),
58 nextRowID: nextRowID,57 nextRowID: nextRowID,
@@ -79,11 +78,11 @@ func (s *Screen) Snapshot() ScreenSnapshot {
79 func (s ScreenSnapshot) Columns() int { return s.frame.Width }78 func (s ScreenSnapshot) Columns() int { return s.frame.Width }
80 func (s ScreenSnapshot) Rows() int { return s.frame.Height }79 func (s ScreenSnapshot) Rows() int { return s.frame.Height }
81 80
82-func (s ScreenSnapshot) Row(y int) []renderer.Cell {81+// Cell returns the semantic cell at x, y.
83- return append([]renderer.Cell(nil), s.BorrowedRow(y)...)82+func (s ScreenSnapshot) Cell(x, y int) renderer.Cell { return s.frame.Cell(x, y) }
84-}
85 83
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 {
87 if y < 0 || y >= s.frame.Height {86 if y < 0 || y >= s.frame.Height {
88 return nil87 return nil
89 }88 }
M screen_snapshot_test.go +17 -18
@@ -25,7 +25,6 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
25 wantModes: ModeSnapshot{},25 wantModes: ModeSnapshot{},
26 checkRows: func(t *testing.T, snapshot ScreenSnapshot) {26 checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
27 require.Nil(t, snapshot.Row(0))27 require.Nil(t, snapshot.Row(0))
28- require.Nil(t, snapshot.BorrowedRow(0))
29 require.Equal(t, LineBound{}, snapshot.Bound(0))28 require.Equal(t, LineBound{}, snapshot.Bound(0))
30 },29 },
31 },30 },
@@ -48,7 +47,7 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
48 MouseSGR: true,47 MouseSGR: true,
49 },48 },
50 checkRows: func(t *testing.T, snapshot ScreenSnapshot) {49 checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
51- require.Len(t, snapshot.BorrowedRow(0), 6)50+ require.Len(t, snapshot.Row(0), 6)
52 require.Equal(t, LineBound{}, snapshot.Bound(0))51 require.Equal(t, LineBound{}, snapshot.Bound(0))
53 },52 },
54 },53 },
@@ -80,9 +79,8 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
80 wantTitle: "zero-columns",79 wantTitle: "zero-columns",
81 wantCursor: CursorSnapshot{Visible: true},80 wantCursor: CursorSnapshot{Visible: true},
82 checkRows: func(t *testing.T, snapshot ScreenSnapshot) {81 checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
83- require.NotNil(t, snapshot.BorrowedRow(0))82+ require.NotNil(t, snapshot.Row(0))
84- require.Empty(t, snapshot.BorrowedRow(0))83+ require.Empty(t, snapshot.Row(0))
85- require.Nil(t, snapshot.Row(0))
86 require.Empty(t, snapshot.Row(1))84 require.Empty(t, snapshot.Row(1))
87 require.Equal(t, LineBound{}, snapshot.Bound(1))85 require.Equal(t, LineBound{}, snapshot.Bound(1))
88 require.Nil(t, snapshot.Row(2))86 require.Nil(t, snapshot.Row(2))
@@ -100,7 +98,7 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
100 wantTitle: "zero-rows",98 wantTitle: "zero-rows",
101 wantCursor: CursorSnapshot{Visible: true},99 wantCursor: CursorSnapshot{Visible: true},
102 checkRows: func(t *testing.T, snapshot ScreenSnapshot) {100 checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
103- require.Nil(t, snapshot.BorrowedRow(0))101+ require.Nil(t, snapshot.Row(0))
104 require.Equal(t, LineBound{}, snapshot.Bound(0))102 require.Equal(t, LineBound{}, snapshot.Bound(0))
105 },103 },
106 },104 },
@@ -193,7 +191,7 @@ func TestScreenSnapshotFidelity(t *testing.T) {
193 {Rune: 'x', Style: renderer.DefaultStyle()},191 {Rune: 'x', Style: renderer.DefaultStyle()},
194 renderer.BlankCell(),192 renderer.BlankCell(),
195 }193 }
196- copy(screen.Frame.Row(0), cells)194+ screen.frame.WriteRow(0, 0, cells)
197 screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true}195 screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true}
198 return screen, cells, []LineBound{{End: 3, Soft: true}, {}}196 return screen, cells, []LineBound{{End: 3, Soft: true}, {}}
199 },197 },
@@ -203,7 +201,7 @@ func TestScreenSnapshotFidelity(t *testing.T) {
203 make: func() (*Screen, []renderer.Cell, []LineBound) {201 make: func() (*Screen, []renderer.Cell, []LineBound) {
204 screen := NewScreen(3, 1)202 screen := NewScreen(3, 1)
205 screen.Write([]byte("primary\x1b[?1049halt"))203 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)...)
207 return screen, cells, screen.LineBounds()205 return screen, cells, screen.LineBounds()
208 },206 },
209 wantModes: ModeSnapshot{AlternateScreen: true},207 wantModes: ModeSnapshot{AlternateScreen: true},
@@ -214,8 +212,8 @@ func TestScreenSnapshotFidelity(t *testing.T) {
214 t.Run(test.name, func(t *testing.T) {212 t.Run(test.name, func(t *testing.T) {
215 screen, wantCells, wantBounds := test.make()213 screen, wantCells, wantBounds := test.make()
216 snapshot := screen.Snapshot()214 snapshot := screen.Snapshot()
217- require.Equal(t, screen.Frame.Width, snapshot.Columns())215+ require.Equal(t, screen.frame.Width, snapshot.Columns())
218- require.Equal(t, screen.Frame.Height, snapshot.Rows())216+ require.Equal(t, screen.frame.Height, snapshot.Rows())
219 require.Equal(t, wantCells, snapshot.Row(0))217 require.Equal(t, wantCells, snapshot.Row(0))
220 require.Equal(t, test.wantModes, snapshot.Modes())218 require.Equal(t, test.wantModes, snapshot.Modes())
221 for row, want := range wantBounds {219 for row, want := range wantBounds {
@@ -234,7 +232,7 @@ func TestScreenSnapshotPreservesLogicalRowsAfterScroll(t *testing.T) {
234 snapshot := screen.Snapshot()232 snapshot := screen.Snapshot()
235 for y, want := range []string{"DEF", "G "} {233 for y, want := range []string{"DEF", "G "} {
236 require.Equal(t, want, rowText(snapshot.Row(y)))234 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))
238 require.Equal(t, screen.LineBounds()[y], snapshot.Bound(y))236 require.Equal(t, screen.LineBounds()[y], snapshot.Bound(y))
239 }237 }
240 }238 }
@@ -267,15 +265,15 @@ func TestScreenSnapshotOwnership(t *testing.T) {
267 require.Equal(t, wantBounds[0], snapshot.Bound(0))265 require.Equal(t, wantBounds[0], snapshot.Bound(0))
268 require.Equal(t, wantBounds[1], snapshot.Bound(1))266 require.Equal(t, wantBounds[1], snapshot.Bound(1))
269 267
270- liveBeforeCallerMutation := append([]renderer.Cell(nil), screen.Frame.Row(0)...)268+ liveBeforeCallerMutation := append([]renderer.Cell(nil), screen.frame.Row(0)...)
271 secondSnapshot := screen.Snapshot()269 secondSnapshot := screen.Snapshot()
272 secondSnapshotBefore := secondSnapshot.Row(0)270 secondSnapshotBefore := secondSnapshot.Row(0)
273 271
274 callerCopy := snapshot.Row(0)272 callerCopy := snapshot.Row(0)
275 callerCopy[0] = renderer.Cell{Rune: 'z'}273 callerCopy[0] = renderer.Cell{Rune: 'z'}
276 274
277- require.Equal(t, wantRows[0], snapshot.BorrowedRow(0))275+ require.Equal(t, wantRows[0], snapshot.Row(0))
278- require.Equal(t, liveBeforeCallerMutation, screen.Frame.Row(0), "mutating Row copy must not change live Screen")276+ require.Equal(t, liveBeforeCallerMutation, screen.frame.Row(0), "mutating Row copy must not change live Screen")
279 require.Equal(t, secondSnapshotBefore, secondSnapshot.Row(0), "mutating Row copy must not change another snapshot")277 require.Equal(t, secondSnapshotBefore, secondSnapshot.Row(0), "mutating Row copy must not change another snapshot")
280 })278 })
281 }279 }
@@ -332,7 +330,7 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
332 {330 {
333 name: "screen with sealed chunks and mutable tail",331 name: "screen with sealed chunks and mutable tail",
334 make: func() *Screen {332 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})
336 screen.Write([]byte("111122223333444455"))334 screen.Write([]byte("111122223333444455"))
337 return screen335 return screen
338 },336 },
@@ -346,7 +344,8 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
346 var beforeHistory HistorySnapshotView344 var beforeHistory HistorySnapshotView
347 var beforeRows [][]renderer.Cell345 var beforeRows [][]renderer.Cell
348 var beforeBounds []LineBound346 var beforeBounds []LineBound
349- beforeCap, beforeCellCap := 0, 0347+ beforeCap := 0
348+ var beforeByteCap uint64
350 if screen.History() != nil {349 if screen.History() != nil {
351 beforeHistory = screen.History().SnapshotView()350 beforeHistory = screen.History().SnapshotView()
352 beforeView := screen.History().View()351 beforeView := screen.History().View()
@@ -357,7 +356,7 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
357 beforeBounds[i] = beforeView.Bound(i)356 beforeBounds[i] = beforeView.Bound(i)
358 }357 }
359 beforeCap = screen.History().Cap()358 beforeCap = screen.History().Cap()
360- beforeCellCap = screen.History().CellCap()359+ beforeByteCap = screen.History().ByteCap()
361 }360 }
362 361
363 _ = screen.Snapshot()362 _ = screen.Snapshot()
@@ -380,7 +379,7 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
380 require.Equal(t, beforeRows, afterRows)379 require.Equal(t, beforeRows, afterRows)
381 require.Equal(t, beforeBounds, afterBounds)380 require.Equal(t, beforeBounds, afterBounds)
382 require.Equal(t, beforeCap, screen.History().Cap())381 require.Equal(t, beforeCap, screen.History().Cap())
383- require.Equal(t, beforeCellCap, screen.History().CellCap())382+ require.Equal(t, beforeByteCap, screen.History().ByteCap())
384 }383 }
385 })384 })
386 }385 }
M screen_write.go +38 -45
@@ -14,8 +14,8 @@ func (s *Screen) putRune(r rune) {
14 case '\r':14 case '\r':
15 s.Col = 015 s.Col = 0
16 case '\n', '\v', '\f':16 case '\n', '\v', '\f':
17- if s.Frame.Width > 0 && s.Col >= s.Frame.Width {17+ if s.frame.Width > 0 && s.Col >= s.frame.Width {
18- s.Col = s.Frame.Width - 118+ s.Col = s.frame.Width - 1
19 }19 }
20 s.buffer.hard(s.Row)20 s.buffer.hard(s.Row)
21 s.index()21 s.index()
@@ -26,8 +26,8 @@ func (s *Screen) putRune(r rune) {
26 case '\t':26 case '\t':
27 // HT moves to the next tab stop without modifying the frame. Clamp at27 // HT moves to the next tab stop without modifying the frame. Clamp at
28 // the right edge rather than triggering deferred wrap or a scroll.28 // the right edge rather than triggering deferred wrap or a scroll.
29- if s.Frame.Width > 0 {29+ if s.frame.Width > 0 {
30- s.Col = min(s.Col+8-s.Col%8, s.Frame.Width-1)30+ s.Col = min(s.Col+8-s.Col%8, s.frame.Width-1)
31 }31 }
32 default:32 default:
33 // Drop C1 control characters (U+0080-U+009F).33 // Drop C1 control characters (U+0080-U+009F).
@@ -58,23 +58,23 @@ func (s *Screen) putPrintable(r rune) {
58 if w == 0 {58 if w == 0 {
59 return59 return
60 }60 }
61- if s.Frame.Width == 0 || s.Frame.Height == 0 {61+ if s.frame.Width == 0 || s.frame.Height == 0 {
62 return62 return
63 }63 }
64 // Deferred wrap: cursor sits past the last column.64 // Deferred wrap: cursor sits past the last column.
65- if s.Col >= s.Frame.Width {65+ if s.Col >= s.frame.Width {
66 s.buffer.soft(s.Row)66 s.buffer.soft(s.Row)
67 s.Col = 067 s.Col = 0
68 s.relinkWrap(s.index())68 s.relinkWrap(s.index())
69 }69 }
70- if s.Row >= s.Frame.Height {70+ if s.Row >= s.frame.Height {
71- s.Row = s.Frame.Height - 171+ s.Row = s.frame.Height - 1
72 }72 }
73 73
74 // A wide rune must never straddle the right edge. If it does not fit on the74 // A wide rune must never straddle the right edge. If it does not fit on the
75 // current line, clear the abandoned last cell and wrap to the next line.75 // current line, clear the abandoned last cell and wrap to the next line.
76- if w == 2 && s.Col+1 >= s.Frame.Width {76+ if w == 2 && s.Col+1 >= s.frame.Width {
77- if s.Frame.Width < 2 {77+ if s.frame.Width < 2 {
78 // The screen is too narrow to ever hold a wide rune; store a narrow78 // The screen is too narrow to ever hold a wide rune; store a narrow
79 // replacement so the cell's renderer width matches its layout.79 // replacement so the cell's renderer width matches its layout.
80 r = '\uFFFD'80 r = '\uFFFD'
@@ -83,18 +83,18 @@ func (s *Screen) putPrintable(r rune) {
83 // The abandoned cell may itself be the continuation of a pair whose83 // The abandoned cell may itself be the continuation of a pair whose
84 // left half sits one column back; report damage over the full span.84 // left half sits one column back; report damage over the full span.
85 cx := s.Col85 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 {
87 cx = s.Col - 187 cx = s.Col - 1
88 }88 }
89 s.clearWidePairAt(s.Col, s.Row)89 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())
91 s.buffer.truncate(s.Row, cx)91 s.buffer.truncate(s.Row, cx)
92 s.buffer.continueRow(s.Row)92 s.buffer.continueRow(s.Row)
93 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})93 s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
94 s.Col = 094 s.Col = 0
95 s.relinkWrap(s.index())95 s.relinkWrap(s.index())
96- if s.Row >= s.Frame.Height {96+ if s.Row >= s.frame.Height {
97- s.Row = s.Frame.Height - 197+ s.Row = s.frame.Height - 1
98 }98 }
99 }99 }
100 }100 }
@@ -102,38 +102,33 @@ func (s *Screen) putPrintable(r rune) {
102 insertDamageX := s.Col102 insertDamageX := s.Col
103 insertDamageWidth := 0103 insertDamageWidth := 0
104 if s.insertMode {104 if s.insertMode {
105- row := s.Frame.Row(s.Row)105+ leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation
106- leftSplit := s.Col > 0 && row[s.Col].Continuation106+ s.frame.CopyRow(s.Row, s.Col+w, s.Col, s.frame.Width-s.Col-w)
107- for x := s.Frame.Width - 1; x >= s.Col+w; x-- {107+ s.frame.FillRow(s.Row, s.Col, s.Col+w, renderer.BlankCell())
108- row[x] = row[x-w]
109- }
110- for x := s.Col; x < s.Col+w; x++ {
111- row[x] = renderer.BlankCell()
112- }
113 s.repairRow(s.Row)108 s.repairRow(s.Row)
114 s.buffer.insert(s.Row, s.Col, w)109 s.buffer.insert(s.Row, s.Col, w)
115 if leftSplit {110 if leftSplit {
116 insertDamageX = s.Col - 1111 insertDamageX = s.Col - 1
117 }112 }
118- insertDamageWidth = s.Frame.Width - insertDamageX113+ insertDamageWidth = s.frame.Width - insertDamageX
119 }114 }
120 115
121 // Determine the range of cells actually modified, extending over any wide116 // Determine the range of cells actually modified, extending over any wide
122 // pair the write lands on so no orphaned half is left behind.117 // pair the write lands on so no orphaned half is left behind.
123 lo, hi := s.Col, s.Col+w-1118 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 {
125 lo = s.Col - 1120 lo = s.Col - 1
126 }121 }
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 {
128 hi = right123 hi = right
129 }124 }
130 for x := lo; x <= hi; x++ {125 for x := lo; x <= hi; x++ {
131- s.Frame.Set(x, s.Row, renderer.BlankCell())126+ s.frame.Set(x, s.Row, renderer.BlankCell())
132 }127 }
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})
134 s.buffer.content(s.Row, s.Col+w)129 s.buffer.content(s.Row, s.Col+w)
135 if w == 2 {130 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})
137 }132 }
138 if insertDamageWidth > 0 {133 if insertDamageWidth > 0 {
139 lo = min(lo, insertDamageX)134 lo = min(lo, insertDamageX)
@@ -149,19 +144,19 @@ func (s *Screen) putPrintable(r rune) {
149 // left half) holds — which the writer maintains.144 // left half) holds — which the writer maintains.
150 145
151 func (s *Screen) clearWidePairAt(x, y int) {146 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 {
153 return148 return
154 }149 }
155- if s.Frame.At(x, y).Continuation {150+ if s.frame.At(x, y).Continuation {
156 if x-1 >= 0 {151 if x-1 >= 0 {
157- s.Frame.Set(x-1, y, renderer.BlankCell())152+ s.frame.Set(x-1, y, renderer.BlankCell())
158 }153 }
159- s.Frame.Set(x, y, renderer.BlankCell())154+ s.frame.Set(x, y, renderer.BlankCell())
160 return155 return
161 }156 }
162- if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation {157+ if x+1 < s.frame.Width && s.frame.At(x+1, y).Continuation {
163- s.Frame.Set(x, y, renderer.BlankCell())158+ s.frame.Set(x, y, renderer.BlankCell())
164- s.Frame.Set(x+1, y, renderer.BlankCell())159+ s.frame.Set(x+1, y, renderer.BlankCell())
165 }160 }
166 }161 }
167 162
@@ -181,32 +176,30 @@ func (s *Screen) eraseCell() renderer.Cell {
181 // returns the actual modified span [start, start+width).176 // returns the actual modified span [start, start+width).
182 177
183 func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {178 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 {
185 return x0, 0180 return x0, 0
186 }181 }
187 if x0 < 0 {182 if x0 < 0 {
188 x0 = 0183 x0 = 0
189 }184 }
190- if x1 > s.Frame.Width {185+ if x1 > s.frame.Width {
191- x1 = s.Frame.Width186+ x1 = s.frame.Width
192 }187 }
193 if x0 >= x1 {188 if x0 >= x1 {
194 return x0, 0189 return x0, 0
195 }190 }
196 // Left boundary: a continuation at x0 means its wide left half sits at x0-1.191 // 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 {
198 x0--193 x0--
199 }194 }
200 // Right boundary: a continuation at x1 means its wide left half (at x1-1) is195 // Right boundary: a continuation at x1 means its wide left half (at x1-1) is
201 // inside the range and will be blanked, so swallow the continuation too.196 // 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 {
203 x1++198 x1++
204 }199 }
205- fullRow := x0 <= 0 && x1 >= s.Frame.Width200+ fullRow := x0 <= 0 && x1 >= s.frame.Width
206 blank := s.eraseCell()201 blank := s.eraseCell()
207- for x := x0; x < x1; x++ {202+ s.frame.FillRow(y, x0, x1, blank)
208- s.Frame.Set(x, y, blank)
209- }
210 s.buffer.clear(y, x0, x1)203 s.buffer.clear(y, x0, x1)
211 if fullRow {204 if fullRow {
212 s.buffer.rowIDs[y] = s.nextRowIDValue()205 s.buffer.rowIDs[y] = s.nextRowIDValue()
@@ -220,7 +213,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
220 // at a boundary or by shifting cells.213 // at a boundary or by shifting cells.
221 214
222 func (s *Screen) repairRow(y int) {215 func (s *Screen) repairRow(y int) {
223- repairFrameRow(s.Frame, y)216+ repairFrameRow(s.frame, y)
224 }217 }
225 218
226 func repairFrameRow(frame renderer.Frame, y int) {219 func repairFrameRow(frame renderer.Frame, y int) {
M screen_write_test.go +6 -6
@@ -194,12 +194,12 @@ func TestCursorControlChars(t *testing.T) {
194 name: "tab moves cursor without changing cells or damage",194 name: "tab moves cursor without changing cells or damage",
195 run: func(t *testing.T) {195 run: func(t *testing.T) {
196 s := NewScreen(10, 2)196 s := NewScreen(10, 2)
197- for y := range s.Frame.Height {197+ for y := range s.frame.Height {
198- for x := range s.Frame.Width {198+ for x := range s.frame.Width {
199- s.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.Frame.Width + x)})199+ s.frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.frame.Width + x)})
200 }200 }
201 }201 }
202- before := s.Frame.Clone()202+ before := s.frame.Clone()
203 s.Row, s.Col = 1, 6203 s.Row, s.Col = 1, 6
204 s.ClearDamage()204 s.ClearDamage()
205 205
@@ -207,7 +207,7 @@ func TestCursorControlChars(t *testing.T) {
207 if s.Row != 1 || s.Col != 8 {207 if s.Row != 1 || s.Col != 8 {
208 t.Errorf("cursor after tab = row=%d col=%d, want row=1 col=8", s.Row, s.Col)208 t.Errorf("cursor after tab = row=%d col=%d, want row=1 col=8", s.Row, s.Col)
209 }209 }
210- assertFramesEqual(t, s.Frame, before)210+ assertFramesEqual(t, s.frame, before)
211 if got := s.Damage(); len(got) != 0 {211 if got := s.Damage(); len(got) != 0 {
212 t.Errorf("tab damage = %+v, want none", got)212 t.Errorf("tab damage = %+v, want none", got)
213 }213 }
@@ -218,7 +218,7 @@ func TestCursorControlChars(t *testing.T) {
218 if s.Row != 1 || s.Col != 9 {218 if s.Row != 1 || s.Col != 9 {
219 t.Errorf("cursor after clamped tab = row=%d col=%d, want row=1 col=9", s.Row, s.Col)219 t.Errorf("cursor after clamped tab = row=%d col=%d, want row=1 col=9", s.Row, s.Col)
220 }220 }
221- assertFramesEqual(t, s.Frame, before)221+ assertFramesEqual(t, s.frame, before)
222 if got := s.Damage(); len(got) != 0 {222 if got := s.Damage(); len(got) != 0 {
223 t.Errorf("clamped tab damage = %+v, want none", got)223 t.Errorf("clamped tab damage = %+v, want none", got)
224 }224 }
M scripts/consumer-smoke.sh +2 -2
@@ -13,7 +13,7 @@ mkdir -p "$tmp/consumer"
13 cat >"$tmp/consumer/go.mod" <<EOF13 cat >"$tmp/consumer/go.mod" <<EOF
14 module example.com/vev-vt-consumer14 module example.com/vev-vt-consumer
15 15
16-go 1.2616+go 1.27
17 17
18 require github.com/bnema/vev-vt v0.0.018 require github.com/bnema/vev-vt v0.0.0
19 19
@@ -33,7 +33,7 @@ import (
33 func main() {33 func main() {
34 screen := vt.NewScreen(4, 1)34 screen := vt.NewScreen(4, 1)
35 screen.Write([]byte("ok"))35 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()})
37 if err != nil {37 if err != nil {
38 fmt.Fprintln(os.Stderr, err)38 fmt.Fprintln(os.Stderr, err)
39 os.Exit(1)39 os.Exit(1)
M scrollback_test.go +3 -3
@@ -92,7 +92,7 @@ func TestOnLineEvictedAltScreenAndRotation(t *testing.T) {
92 if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) {92 if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) {
93 t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows)93 t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows)
94 }94 }
95- if err := s.Frame.CheckInvariants(); err != nil {95+ if err := s.frame.CheckInvariants(); err != nil {
96 t.Fatalf("frame invariants after scrollback callback: %v", err)96 t.Fatalf("frame invariants after scrollback callback: %v", err)
97 }97 }
98 })98 })
@@ -126,7 +126,7 @@ func TestEvictedLinesCarryTheirBound(t *testing.T) {
126 126
127 for _, tc := range tests {127 for _, tc := range tests {
128 t.Run(tc.name, func(t *testing.T) {128 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})
130 tc.evict(s)130 tc.evict(s)
131 view := s.History().SealAndView()131 view := s.History().SealAndView()
132 if view.Len() == 0 {132 if view.Len() == 0 {
@@ -140,7 +140,7 @@ func TestEvictedLinesCarryTheirBound(t *testing.T) {
140 }140 }
141 141
142 func TestReflowEvictedLinesCarryTheirBound(t *testing.T) {142 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})
144 // Fill the grid with wrapped content, then shrink so rows are evicted by144 // Fill the grid with wrapped content, then shrink so rows are evicted by
145 // the reflow path in buffer.resize rather than by scrolling.145 // the reflow path in buffer.resize rather than by scrolling.
146 s.Write([]byte("aaaaaaaabbbbbbbbcccccccc"))146 s.Write([]byte("aaaaaaaabbbbbbbbcccccccc"))
M snapshot.go +63 -20
@@ -40,7 +40,7 @@ func MarshalHistoryChunk(chunk *HistoryChunk) ([]byte, error) {
40 if chunk == nil {40 if chunk == nil {
41 return nil, fmt.Errorf("marshal history chunk: %w", errInvalidHistory)41 return nil, fmt.Errorf("marshal history chunk: %w", errInvalidHistory)
42 }42 }
43- return MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: len(chunk.rows)})43+ return MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: chunk.len(), cells: chunk.cells()})
44 }44 }
45 45
46 // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob.46 // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob.
@@ -77,6 +77,9 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
77 // HistoryFromBlobs restores history directly from sealed, oldest-first blobs77 // HistoryFromBlobs restores history directly from sealed, oldest-first blobs
78 // and the mandatory tail blob. It never feeds decoded rows through Append.78 // and the mandatory tail blob. It never feeds decoded rows through Append.
79 func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {79 func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {
80+ if err := config.Validate(); err != nil {
81+ return nil, err
82+ }
80 sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail)83 sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail)
81 if err != nil {84 if err != nil {
82 return nil, err85 return nil, err
@@ -89,14 +92,14 @@ func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryVi
89 sealedViews := make([]HistoryView, len(sealed))92 sealedViews := make([]HistoryView, len(sealed))
90 for i, blob := range sealed {93 for i, blob := range sealed {
91 view, err := UnmarshalHistory(blob)94 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) {
93 return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory)96 return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
94 }97 }
95 sealedViews[i] = view98 sealedViews[i] = view
96 }99 }
97 100
98 tailView, err := UnmarshalHistory(tail)101 tailView, err := UnmarshalHistory(tail)
99- if err != nil || len(tailView.chunks) > 1 || !validateRestoredHistoryView(tailView, seen) {102+ if err != nil || !validateRestoredHistoryView(tailView, seen) {
100 return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory)103 return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory)
101 }104 }
102 for _, view := range extra {105 for _, view := range extra {
@@ -111,37 +114,73 @@ func restoreHistoryViews(config HistoryConfig, sealed []HistoryView, tail Histor
111 h := NewHistory(config)114 h := NewHistory(config)
112 for _, view := range sealed {115 for _, view := range sealed {
113 h.nextRowID = max(h.nextRowID, view.nextRowID)116 h.nextRowID = max(h.nextRowID, view.nextRowID)
114- if h.maxRows == 0 {117+ chunk := h.boundedRestoredChunk(view.chunks[0])
118+ if chunk == nil {
115 continue119 continue
116 }120 }
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+ }
118 h.chunks = append(h.chunks, chunk)125 h.chunks = append(h.chunks, chunk)
119- h.rows += len(chunk.rows)126+ h.rows += chunk.len()
120- h.cells += view.Cells()127+ h.cells += chunk.cells()
121- h.evict()128+ h.logicalBytes += chunkBytes
122 }129 }
123 h.nextRowID = max(h.nextRowID, tail.nextRowID)130 h.nextRowID = max(h.nextRowID, tail.nextRowID)
124- if h.maxRows == 0 || len(tail.chunks) == 0 {131+ if h.maxRows == 0 {
125 return h132 return h
126 }133 }
127- chunk := tail.chunks[0]134+ for _, chunk := range tail.chunks {
128- h.tail = chunk.rows135+ for row := range chunk.len() {
129- h.tailBounds = append([]LineBound(nil), chunk.bounds...)136+ if h.prepareChunkRowStyles(chunk, row) > h.maxBytes {
130- h.tailIDs = append([]RowID(nil), chunk.rowIDs...)137+ continue
131- h.rows += len(h.tail)138+ }
132- h.cells += tail.Cells()139+ for {
133- h.evict()140+ delta := h.tailAppendDelta(chunk.width)
134- h.normalizeTail()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+ }
135 return h160 return h
136 }161 }
137 162
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+
138 func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool {177 func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool {
139 if view.nextRowID == 0 || view.nextRowID == ^RowID(0) {178 if view.nextRowID == 0 || view.nextRowID == ^RowID(0) {
140 return false179 return false
141 }180 }
142 var maxID RowID181 var maxID RowID
143 for _, chunk := range view.chunks {182 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) {
145 return false184 return false
146 }185 }
147 for _, id := range chunk.rowIDs {186 for _, id := range chunk.rowIDs {
@@ -162,6 +201,9 @@ func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool
162 // history contains the restored bounded history followed by the recovery201 // history contains the restored bounded history followed by the recovery
163 // transcript. The transcript is decoded in full before history is restored.202 // transcript. The transcript is decoded in full before history is restored.
164 func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, sealed [][]byte, tail, transcript []byte) (*Screen, error) {203 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+ }
165 transcriptView, err := UnmarshalHistory(transcript)207 transcriptView, err := UnmarshalHistory(transcript)
166 if err != nil {208 if err != nil {
167 return nil, fmt.Errorf("restore recovery transcript: %w", err)209 return nil, fmt.Errorf("restore recovery transcript: %w", err)
@@ -174,7 +216,8 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
174 history := restoreHistoryViews(config, sealedViews, tailView)216 history := restoreHistoryViews(config, sealedViews, tailView)
175 remainingRows := transcriptView.rows217 remainingRows := transcriptView.rows
176 for _, chunk := range transcriptView.chunks {218 for _, chunk := range transcriptView.chunks {
177- for i, row := range chunk.rows {219+ for i := range chunk.len() {
220+ row := chunk.row(i)
178 remainingRows--221 remainingRows--
179 bound := chunk.bounds[i]222 bound := chunk.bounds[i]
180 if remainingRows == 0 {223 if remainingRows == 0 {
@@ -196,6 +239,6 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
196 for i := range screen.buffer.rowIDs {239 for i := range screen.buffer.rowIDs {
197 screen.buffer.rowIDs[i] = screen.nextRowIDValue()240 screen.buffer.rowIDs[i] = screen.nextRowIDValue()
198 }241 }
199- screen.Frame = screen.buffer.frame242+ screen.frame = screen.buffer.frame
200 return screen, nil243 return screen, nil
201 }244 }
M snapshot_test.go +5 -4
@@ -49,7 +49,7 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
49 49
50 func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {50 func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
51 fullTail, err := MarshalHistory(HistoryView{51 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})},
53 rows: 2,53 rows: 2,
54 nextRowID: 3,54 nextRowID: 3,
55 })55 })
@@ -64,8 +64,8 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
64 require.Len(t, restored.tail, 1, "the appended row must be the mutable tail")64 require.Len(t, restored.tail, 1, "the appended row must be the mutable tail")
65 }65 }
66 66
67-func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) {67+func TestHistoryFromBlobsRestoresAccountingAndEvictsToBothBudgets(t *testing.T) {
68- history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2})68+ history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 2})
69 for _, text := range []string{"a", "b", "cc"} {69 for _, text := range []string{"a", "b", "cc"} {
70 requireHistoryAppend(t, history, historyRow(text))70 requireHistoryAppend(t, history, historyRow(text))
71 }71 }
@@ -79,7 +79,8 @@ func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing
79 tail, err := MarshalHistoryTail(view)79 tail, err := MarshalHistoryTail(view)
80 require.NoError(t, err)80 require.NoError(t, err)
81 81
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)
83 require.NoError(t, err)84 require.NoError(t, err)
84 require.Equal(t, []string{"cc"}, historyViewTexts(restored.View()))85 require.Equal(t, []string{"cc"}, historyViewTexts(restored.View()))
85 require.Equal(t, 2, restored.Cells())86 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) {
86 }86 }
87 for _, tc := range cases {87 for _, tc := range cases {
88 t.Run(tc.name, func(t *testing.T) {88 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})
90 s.Write([]byte(tc.input))90 s.Write([]byte(tc.input))
91 91
92 bounds := s.LineBounds()92 bounds := s.LineBounds()
@@ -104,7 +104,7 @@ func TestSoftLinkSurvivesTheScrollItCaused(t *testing.T) {
104 // wrapped out of the bottom row used to be two unrelated physical rows, and104 // wrapped out of the bottom row used to be two unrelated physical rows, and
105 // widening the pane left it split instead of rejoining it.105 // widening the pane left it split instead of rejoining it.
106 func TestResizeRejoinsALineWrappedOutOfTheBottomRow(t *testing.T) {106 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})
108 s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij"))108 s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij"))
109 109
110 s.Resize(16, 4)110 s.Resize(16, 4)
@@ -122,14 +122,14 @@ func TestSoftLinkFromABottomRowWrapReachesHistory(t *testing.T) {
122 width = 8122 width = 8
123 height = 4123 height = 4
124 )124 )
125- s := NewScreenWithHistory(width, height, HistoryConfig{MaxRows: 64, MaxCells: 4096})125+ s := NewScreenWithHistory(width, height, HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20})
126 // Wrap out of the bottom row, then push both physical rows into history.126 // Wrap out of the bottom row, then push both physical rows into history.
127 s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij\r\nx\r\ny\r\nz\r\n"))127 s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij\r\nx\r\ny\r\nz\r\n"))
128 128
129 view := s.History().SealAndView()129 view := s.History().SealAndView()
130 require.Equal(t, 7, view.Len(), "history row count")130 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)))
132 require.Equal(t, LineBound{End: 8, Soft: true}, view.Bound(5), "the wrapped row must reach history soft")132 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)))
134 require.Equal(t, LineBound{End: 2, Soft: false}, view.Bound(6), "its continuation is a hard line end")134 require.Equal(t, LineBound{End: 2, Soft: false}, view.Bound(6), "its continuation is a hard line end")
135 }135 }

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