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 Unverified
B5690EEEBB952194The key is unknown
93 files · +4844 -1538
M .gitignore +3 -0
| @@ -3,6 +3,9 @@ | |||
| 3 | 3 | /coverage.out | |
| 4 | 4 | /testdata/generated/ | |
| 5 | 5 | ||
| 6 | + | # Local agent review notes | |
| 7 | + | /.agent-reviews/ | |
| 8 | + | ||
| 6 | 9 | # Editor and OS files | |
| 7 | 10 | *.swp | |
| 8 | 11 | .DS_Store | |
M README.md +87 -72
| @@ -1,87 +1,102 @@ | |||
| 1 | 1 | # vev-vt | |
| 2 | 2 | ||
| 3 | - | `github.com/bnema/vev-vt` is a frontend-neutral VT terminal engine for Go. | |
| 4 | - | It provides the terminal screen, scrollback history, immutable snapshots, | |
| 5 | - | stable history chunks, VTH3 history bytes, and the reusable cell/frame model. | |
| 6 | - | The `ansi` package turns core frames and damage into transactional ANSI output. | |
| 3 | + | A Go library for reading terminal output and keeping track of what should appear | |
| 4 | + | on screen. It powers [vev](https://github.com/bnema/vev). | |
| 7 | 5 | ||
| 8 | - | ## Packages | |
| 6 | + | Feed it the bytes from a shell or command. It handles text, colors, cursor | |
| 7 | + | movement, scrolling and resizing. You can then read the screen, save its history, | |
| 8 | + | or use the `ansi` package to draw it in a terminal. | |
| 9 | + | ||
| 10 | + | This library does **not** start processes or manage a PTY. Your application does | |
| 11 | + | that and passes the output to vev-vt. | |
| 9 | 12 | ||
| 10 | - | - The module root (`github.com/bnema/vev-vt`) owns VT parsing, screen state, | |
| 11 | - | history, snapshots, callbacks, and the public model aliases (`Cell`, `Style`, | |
| 12 | - | `RGB`, `Frame`, `Damage`, and `RuneWidth`). | |
| 13 | - | - `github.com/bnema/vev-vt/core` owns the frontend-neutral cell, style, frame, | |
| 14 | - | damage, and width implementation. It has no terminal, renderer, transport, | |
| 15 | - | or application dependencies. | |
| 16 | - | - `github.com/bnema/vev-vt/ansi` is the concrete ANSI output package. It consumes | |
| 17 | - | core frames and damage; it does not define a renderer-backend interface. | |
| 18 | - | - `github.com/bnema/vev-vt/graphics` owns bounded renderer-neutral raster assets, | |
| 19 | - | sparse placements, clipping fragments, and immutable scene snapshots. It has no | |
| 20 | - | Kitty, ANSI, VT-policy, or transport dependency. | |
| 21 | - | - `github.com/bnema/vev-vt/protocol/kittygraphics` parses bounded Kitty graphics | |
| 22 | - | APC commands and translates the supported static/direct subset into `graphics`. | |
| 23 | - | ||
| 24 | - | ## Ownership contract | |
| 25 | - | ||
| 26 | - | Stateful values are single-owner and are not internally synchronized. Serialize | |
| 27 | - | screen writes, resizes, snapshots, and history mutations in one owner. Parsing | |
| 28 | - | and callbacks are synchronous: callbacks run before `Write` returns and follow | |
| 29 | - | parser event order. Consume or copy response bytes during the callback. | |
| 30 | - | ||
| 31 | - | `Snapshot`, `HistorySnapshotView`, and `Row` methods provide owned captures or | |
| 32 | - | copies. `BorrowedRow`, `Frame.Row`, and `HistoryView.Range` expose borrowed | |
| 33 | - | storage with the lifetimes documented by their APIs; borrowed storage must not | |
| 34 | - | be mutated or retained beyond its documented lifetime. Sealed `HistoryChunk` | |
| 35 | - | identity is stable for the lifetime of a view. | |
| 36 | - | ||
| 37 | - | ## Compatibility | |
| 38 | - | ||
| 39 | - | VTH3 history bytes are canonical and are decoded strictly, including malformed, | |
| 40 | - | truncated, and trailing input rejection. VEVS is an application-owned outer | |
| 41 | - | snapshot envelope and is intentionally not implemented here. | |
| 42 | - | ||
| 43 | - | The module has no production dependencies. `github.com/stretchr/testify` is | |
| 44 | - | used only by the test suite. Keep the public v0.x API and byte formats immutable | |
| 45 | - | once released; behavior changes require explicit versioning and compatibility | |
| 46 | - | evidence. | |
| 47 | - | ||
| 48 | - | ## Kitty graphics subset | |
| 49 | - | ||
| 50 | - | The VT accepts bounded static direct transmissions used by current | |
| 51 | - | `kitten icat --transfer-mode=stream`: PNG, RGB, and RGBA assets; transmit, | |
| 52 | - | transmit-and-display, place, query, and supported delete operations; chunked | |
| 53 | - | Base64 uploads with optional zlib compression; source rectangles; cell extents; | |
| 54 | - | pixel offsets; z-index; and cursor movement policy. Assets and decoded pixels | |
| 55 | - | are bounded by explicit scene and parser limits. File, temporary-file, | |
| 56 | - | shared-memory, animation, composition, relative placement, and | |
| 57 | - | Unicode-placeholder commands remain unsupported. | |
| 58 | - | ||
| 59 | - | `Screen` allocates graphics state only after a Kitty APC. Graphics snapshots are | |
| 60 | - | separate from cells and history bytes; VTH3 remains text/history-only. Static | |
| 61 | - | placements move with terminal row scrolling and are clipped by the active | |
| 62 | - | viewport; reflow and relative-placement movement remain unsupported. | |
| 63 | - | ||
| 64 | - | ### Headless graphics harness | |
| 65 | - | ||
| 66 | - | `cmd/graphics-harness` feeds a terminal byte capture into a fresh `Screen` and | |
| 67 | - | writes its active graphics snapshot as a PNG using an internal reference | |
| 68 | - | compositor. It is a development inspection tool, not a terminal emulator. | |
| 13 | + | ## Install | |
| 14 | + | ||
| 15 | + | Requires Go 1.27 or newer. | |
| 69 | 16 | ||
| 70 | 17 | ```sh | |
| 71 | - | go run ./cmd/graphics-harness \ | |
| 72 | - | -input internal/graphicsharness/testdata/demo.apc \ | |
| 73 | - | -output /tmp/graphics-harness.png \ | |
| 74 | - | -cols 4 -rows 4 -pixel-width 4 -pixel-height 4 -scale 64 | |
| 18 | + | go get github.com/bnema/vev-vt | |
| 75 | 19 | ``` | |
| 76 | 20 | ||
| 77 | - | The included demo has an opaque blue background and a semi-transparent red | |
| 78 | - | overlay. Its output is suitable for direct image inspection. | |
| 21 | + | **Alpha software:** APIs and saved-history formats can change between versions. | |
| 22 | + | The current history format does not read older VTH3 data. | |
| 23 | + | ||
| 24 | + | ## Read terminal output | |
| 25 | + | ||
| 26 | + | ```go | |
| 27 | + | package main | |
| 28 | + | ||
| 29 | + | import ( | |
| 30 | + | "fmt" | |
| 31 | + | ||
| 32 | + | vt "github.com/bnema/vev-vt" | |
| 33 | + | ) | |
| 79 | 34 | ||
| 80 | - | ## Checks | |
| 35 | + | func main() { | |
| 36 | + | screen := vt.NewScreen(80, 24) | |
| 37 | + | screen.Write([]byte("Hello, \x1b[31mworld\x1b[0m!")) | |
| 38 | + | ||
| 39 | + | // Colors and escape sequences are processed, not included in the text. | |
| 40 | + | for x := 0; x < 13; x++ { | |
| 41 | + | fmt.Printf("%c", screen.Cell(x, 0).Rune) | |
| 42 | + | } | |
| 43 | + | fmt.Println() // Hello, world! | |
| 44 | + | } | |
| 45 | + | ``` | |
| 46 | + | ||
| 47 | + | Use `screen.Resize(columns, rows)` when the terminal size changes. | |
| 48 | + | Use `screen.Snapshot()` when you need a copy that stays unchanged as new output | |
| 49 | + | arrives. | |
| 50 | + | ||
| 51 | + | ## Keep scrollback | |
| 52 | + | ||
| 53 | + | Scrollback is the text that has moved above the visible screen. | |
| 54 | + | ||
| 55 | + | ```go | |
| 56 | + | // Your application chooses these limits; vev-vt has no default budget. | |
| 57 | + | config := vt.HistoryConfig{ | |
| 58 | + | MaxBytes: 20_000_000, // At most 20 MB of history data. | |
| 59 | + | MaxRows: 5_000, // Optional: also keep at most 5,000 lines. | |
| 60 | + | } | |
| 61 | + | screen := vt.NewScreenWithHistory(80, 24, config) | |
| 62 | + | ``` | |
| 63 | + | ||
| 64 | + | vev-vt enforces the limits your application supplies. The PTY transports bytes; | |
| 65 | + | it does not store scrollback. The oldest lines are removed when a supplied limit | |
| 66 | + | is reached. Limits apply to each | |
| 67 | + | screen separately and exclude the visible screen. The byte limit measures | |
| 68 | + | uncompressed history data, **not total process memory**. | |
| 69 | + | ||
| 70 | + | [History guide →](docs/history.md): limit settings, saving/restoring history and | |
| 71 | + | optional compression during idle time. | |
| 72 | + | ||
| 73 | + | ## A few rules | |
| 74 | + | ||
| 75 | + | - Serialize `Write`, `Resize`, `Snapshot`, reads and history mutations in one | |
| 76 | + | goroutine, or protect them with your own lock. | |
| 77 | + | - Rows returned by the API are copies. Changing one does not change the screen. | |
| 78 | + | - Use `vt.DefaultStyle()` for terminal-default colors, not `vt.Style{}`. | |
| 79 | + | - Callbacks run during `screen.Write`; keep them short. | |
| 80 | + | ||
| 81 | + | [API ownership and styles →](docs/ownership.md) | |
| 82 | + | ||
| 83 | + | ## Packages | |
| 84 | + | ||
| 85 | + | | Package | Use it for | | |
| 86 | + | | --- | --- | | |
| 87 | + | | `vev-vt` | Parse terminal output, read the screen and manage history. | | |
| 88 | + | | `vev-vt/core` | Work with cells, styles and writable grids. | | |
| 89 | + | | `vev-vt/ansi` | Render a screen or grid as ANSI terminal output. | | |
| 90 | + | | `vev-vt/graphics` | Read supported terminal images and their positions. | | |
| 91 | + | ||
| 92 | + | [Supported image features →](docs/graphics.md) | |
| 93 | + | ||
| 94 | + | ## Development | |
| 81 | 95 | ||
| 82 | 96 | ```sh | |
| 83 | 97 | go test ./... | |
| 84 | 98 | go test ./... -race | |
| 85 | 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 | 208 | scrolled := NewFrame(120, 40) | |
| 209 | - | copy(scrolled.Cells[0:39*120], frame.Cells[120:40*120]) | |
| 210 | - | for i := 39 * 120; i < 40*120; i++ { | |
| 211 | - | scrolled.Cells[i] = BlankCell() | |
| 209 | + | for y := 0; y < 39; y++ { | |
| 210 | + | scrolled.WriteRow(y, 0, frame.Row(y+1)) | |
| 212 | 211 | } | |
| 213 | 212 | for x, r := range []rune("new output line") { | |
| 214 | 213 | scrolled.Set(x, 39, Cell{Rune: r, Style: DefaultStyle()}) | |
M ansi/consumer_external_test.go +14 -0
| @@ -4,10 +4,24 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | vt "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/bnema/vev-vt/ansi" | |
| 8 | 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 | 25 | func TestExternalANSIConsumesCoreFrameIndependently(t *testing.T) { | |
| 12 | 26 | frame := core.NewFrame(2, 1) | |
| 13 | 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 | 10 | // buildDamagePlan creates the bounded, canonical view of text and clear damage | |
| 11 | 11 | // used for terminal emission. The source damage remains untouched because the | |
| 12 | 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 | 14 | if len(damage) == 1 { | |
| 15 | 15 | d := damage[0] | |
| 16 | 16 | if skip != nil && sameDamage(d, *skip) { | |
| @@ -52,7 +52,7 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool) | |||
| 52 | 52 | 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 | 56 | x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height) | |
| 57 | 57 | if !ok { | |
| 58 | 58 | return nil, false | |
M ansi/delta_plan.go +33 -32
| @@ -30,21 +30,21 @@ type DeltaCandidate struct { | |||
| 30 | 30 | frame Frame | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | - | func newDeltaCandidate(frame Frame, plan DeltaPlan) DeltaCandidate { | |
| 33 | + | func newDeltaCandidate(frame CellSource, plan DeltaPlan) DeltaCandidate { | |
| 34 | 34 | candidate := DeltaCandidate{Plan: plan} | |
| 35 | 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 | 38 | return candidate | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | 41 | // PlanDelta prepares a non-mutating update from committed to frame. | |
| 42 | - | func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) { | |
| 43 | - | if err := frame.Validate(); err != nil { | |
| 42 | + | func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) { | |
| 43 | + | if err := validateCellSource(frame); err != nil { | |
| 44 | 44 | 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 | 48 | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |
| 49 | 49 | } | |
| 50 | 50 | if err := committed.Validate(); err != nil { | |
| @@ -92,7 +92,7 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta | |||
| 92 | 92 | 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 | 96 | spans, full := buildSingleDamageSpans(frame, d) | |
| 97 | 97 | if full { | |
| 98 | 98 | return DeltaPlan{Snapshot: true} | |
| @@ -104,14 +104,12 @@ func planSingleDamage(frame Frame, d Damage) DeltaPlan { | |||
| 104 | 104 | return plan | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | - | func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) { | |
| 107 | + | func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) { | |
| 108 | 108 | var spans []Span | |
| 109 | - | for y := range frame.Height { | |
| 110 | - | frameRow := frame.Row(y) | |
| 111 | - | committedRow := committed.Row(y) | |
| 109 | + | for y := range frame.Rows() { | |
| 112 | 110 | dirty := false | |
| 113 | - | for x := range frame.Width { | |
| 114 | - | if !frameRow[x].Equal(committedRow[x]) { | |
| 111 | + | for x := range frame.Columns() { | |
| 112 | + | if !frame.Cell(x, y).Equal(committed.Cell(x, y)) { | |
| 115 | 113 | dirty = true | |
| 116 | 114 | break | |
| 117 | 115 | } | |
| @@ -120,14 +118,14 @@ func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) { | |||
| 120 | 118 | if len(spans) == maxPlannedDamageSpans { | |
| 121 | 119 | 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 | 124 | return spans, false | |
| 127 | 125 | } | |
| 128 | 126 | ||
| 129 | - | func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool { | |
| 130 | - | if !hasScroll && frame.Height > 1 && len(spans) == 1 { | |
| 127 | + | func deltaCostsSnapshot(frame CellSource, spans []Span, hasScroll bool) bool { | |
| 128 | + | if !hasScroll && frame.Rows() > 1 && len(spans) == 1 { | |
| 131 | 129 | return false | |
| 132 | 130 | } | |
| 133 | 131 | ||
| @@ -139,11 +137,11 @@ func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool { | |||
| 139 | 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 | 141 | if deltaCost < minimumSnapshotCost { | |
| 144 | 142 | 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 | 145 | if deltaCost >= maximumSnapshotCost { | |
| 148 | 146 | return true | |
| 149 | 147 | } | |
| @@ -152,40 +150,43 @@ func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool { | |||
| 152 | 150 | } | |
| 153 | 151 | ||
| 154 | 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 | 155 | var snapshotCost int64 | |
| 158 | - | for y := range frame.Height { | |
| 159 | - | snapshotCost += spanCost(frame, y, 0, frame.Width) | |
| 156 | + | for y := range frame.Rows() { | |
| 157 | + | snapshotCost += spanCost(frame, y, 0, frame.Columns()) | |
| 160 | 158 | } | |
| 161 | 159 | return deltaCost >= snapshotCost | |
| 162 | 160 | } | |
| 163 | 161 | ||
| 164 | - | func fullWidthRows(frame Frame, spans []Span) bool { | |
| 165 | - | if len(spans) != frame.Height { | |
| 162 | + | func fullWidthRows(frame CellSource, spans []Span) bool { | |
| 163 | + | if len(spans) != frame.Rows() { | |
| 166 | 164 | return false | |
| 167 | 165 | } | |
| 168 | 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 | 168 | return false | |
| 171 | 169 | } | |
| 172 | 170 | } | |
| 173 | 171 | return true | |
| 174 | 172 | } | |
| 175 | 173 | ||
| 176 | - | func spanCost(frame Frame, y, x, width int) int64 { | |
| 177 | - | return 8 + int64(width) + 4*int64(styleRunCount(frame.Row(y)[x:x+width])) | |
| 174 | + | func spanCost(frame CellSource, y, x, width int) int64 { | |
| 175 | + | return 8 + int64(width) + 4*int64(styleRunCountIn(frame, y, x, width)) | |
| 178 | 176 | } | |
| 179 | 177 | ||
| 180 | - | func styleRunCount(cells []Cell) int { | |
| 181 | - | if len(cells) == 0 { | |
| 178 | + | func styleRunCountIn(frame CellSource, y, x, width int) int { | |
| 179 | + | if width == 0 { | |
| 182 | 180 | return 0 | |
| 183 | 181 | } | |
| 184 | 182 | runs := 1 | |
| 185 | - | for i := 1; i < len(cells); i++ { | |
| 186 | - | if cells[i-1].Style != cells[i].Style && !cells[i-1].Style.Equal(cells[i].Style) { | |
| 183 | + | previous := frame.Cell(x, y).Style | |
| 184 | + | for column := x + 1; column < x+width; column++ { | |
| 185 | + | style := frame.Cell(column, y).Style | |
| 186 | + | if previous != style && !previous.Equal(style) { | |
| 187 | 187 | runs++ | |
| 188 | 188 | } | |
| 189 | + | previous = style | |
| 189 | 190 | } | |
| 190 | 191 | return runs | |
| 191 | 192 | } | |
| @@ -204,9 +205,9 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||
| 204 | 205 | dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count) | |
| 205 | 206 | } | |
| 206 | 207 | for _, span := range c.Plan.Spans { | |
| 207 | - | dstRow := dst.Row(span.Y) | |
| 208 | - | srcRow := c.frame.Row(span.Y) | |
| 209 | - | copy(dstRow[span.X:span.X+span.Width], srcRow[span.X:span.X+span.Width]) | |
| 208 | + | for x := span.X; x < span.X+span.Width; x++ { | |
| 209 | + | dst.Set(x, span.Y, c.frame.Cell(x, span.Y)) | |
| 210 | + | } | |
| 210 | 211 | } | |
| 211 | 212 | } | |
| 212 | 213 | ||
M ansi/delta_plan_test.go +3 -8
| @@ -169,7 +169,7 @@ func TestNoOpDeltaCandidateDoesNotCloneFrame(t *testing.T) { | |||
| 169 | 169 | if err != nil { | |
| 170 | 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 | 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 | 206 | frame := testFrame("aaaa", "bbbb", "cccc", "dddd") | |
| 207 | 207 | frame.ScrollUp(0, 3, 1) | |
| 208 | 208 | fillFrameRow(frame, 3, "new!") | |
| 209 | 209 | committed := NewFrame(4, 4) | |
| 210 | - | cellsBase := &committed.Cells[0] | |
| 211 | 210 | ||
| 212 | 211 | candidate, err := PlanDelta(frame, nil, committed, true) | |
| 213 | 212 | if err != nil { | |
| @@ -215,9 +214,6 @@ func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) { | |||
| 215 | 214 | } | |
| 216 | 215 | candidate.Commit(&committed) | |
| 217 | 216 | ||
| 218 | - | if &committed.Cells[0] != cellsBase { | |
| 219 | - | t.Fatal("Commit replaced same-sized cell storage") | |
| 220 | - | } | |
| 221 | 217 | for y := range frame.Height { | |
| 222 | 218 | if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) { | |
| 223 | 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 | 227 | func TestPlanDeltaRejectsInvalidFrame(t *testing.T) { | |
| 232 | - | frame := NewFrame(2, 1) | |
| 233 | - | frame.Cells = nil | |
| 228 | + | frame := Frame{Width: 2, Height: 1} | |
| 234 | 229 | if _, err := PlanDelta(frame, nil, Frame{}, false); err == nil { | |
| 235 | 230 | t.Fatal("PlanDelta accepted an invalid frame") | |
| 236 | 231 | } | |
M ansi/doc.go +2 -2
| @@ -2,8 +2,8 @@ | |||
| 2 | 2 | // github.com/bnema/vev-vt/core model. | |
| 3 | 3 | // | |
| 4 | 4 | // The package owns output planning, ANSI encoding, and a transactional renderer | |
| 5 | - | // shadow. It does not own Cell, Style, RGB, Frame, Damage, or RuneWidth; those | |
| 6 | - | // values are aliases of the core package and are owned by core. | |
| 5 | + | // shadow. Renderers consume the read-only CellSource contract. Cell, Style, RGB, | |
| 6 | + | // Frame, CellSource, Damage, and RuneWidth are aliases of the core package. | |
| 7 | 7 | // | |
| 8 | 8 | // [New] preserves the historical truecolor behavior. A renderer created by | |
| 9 | 9 | // [NewWithColorProfile] projects every color-bearing style field to its chosen | |
M ansi/draw_state.go +9 -8
| @@ -165,22 +165,23 @@ const ( | |||
| 165 | 165 | // entirely blank full-width line may instead use a vertical-only move | |
| 166 | 166 | // plus 2K when that encoding is cheaper. Both clears require the default | |
| 167 | 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 | 169 | end := x + width | |
| 170 | + | columns := frame.Columns() | |
| 170 | 171 | // Trailing blank suffix is EL-eligible only when the span reaches the | |
| 171 | 172 | // frame's right edge (EL clears to the end of the line, nothing less). | |
| 172 | 173 | t := end | |
| 173 | - | if end == frame.Width { | |
| 174 | - | for t > x && isBlank(frame.At(t-1, y)) { | |
| 174 | + | if end == columns { | |
| 175 | + | for t > x && isBlank(frame.Cell(t-1, y)) { | |
| 175 | 176 | t-- | |
| 176 | 177 | } | |
| 177 | 178 | } | |
| 178 | - | useEL := end == frame.Width && end-t >= elLen | |
| 179 | + | useEL := end == columns && end-t >= elLen | |
| 179 | 180 | if !useEL { | |
| 180 | 181 | 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 | 185 | // Entire line blank: compare "move to col 0 + EL" against | |
| 185 | 186 | // "vertical-only move + 2K" (2K clears the whole line from any | |
| 186 | 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 | 197 | st.moveTo(out, y, x) | |
| 197 | 198 | for col := x; col < t; col++ { | |
| 198 | - | cell := frame.At(col, y) | |
| 199 | + | cell := frame.Cell(col, y) | |
| 199 | 200 | // Continuation cells are the right half of a wide rune. Emit nothing: | |
| 200 | 201 | // the terminal already advanced two columns for the left cell's rune. | |
| 201 | 202 | if cell.Continuation { | |
| @@ -222,7 +223,7 @@ func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st | |||
| 222 | 223 | out.WriteString("\x1b[K") | |
| 223 | 224 | // EL does not move the cursor: it stays at the start of the blanks. | |
| 224 | 225 | st.setCursor(y, t) | |
| 225 | - | } else if end >= frame.Width { | |
| 226 | + | } else if end >= columns { | |
| 226 | 227 | st.invalidateCursor() | |
| 227 | 228 | } else { | |
| 228 | 229 | st.setCursor(y, end) | |
| @@ -234,7 +235,7 @@ func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st | |||
| 234 | 235 | // malformed inputs the VT layer should never produce, but tracking must | |
| 235 | 236 | // stay certain, so drop it. (t is never a continuation on the EL path — | |
| 236 | 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 | 239 | st.invalidateCursor() | |
| 239 | 240 | } | |
| 240 | 241 | } | |
M ansi/frame.go +48 -1
| @@ -1,9 +1,56 @@ | |||
| 1 | 1 | 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 | 10 | // Frame is an alias for the frontend-neutral terminal grid. ANSI rendering | |
| 6 | 11 | // consumes it but does not own its model or storage policy. | |
| 7 | 12 | 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 | 17 | 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 | 22 | colorProfile ColorProfile | |
| 23 | 23 | width int | |
| 24 | 24 | height int | |
| 25 | - | shadow []Cell | |
| 25 | + | hasCommitted bool | |
| 26 | 26 | committed Frame | |
| 27 | 27 | } | |
| 28 | 28 | ||
| @@ -47,7 +47,7 @@ func NewWithColorProfile(caps Capabilities, profile ColorProfile) *Renderer { | |||
| 47 | 47 | func (r *Renderer) Reset() { | |
| 48 | 48 | r.width = 0 | |
| 49 | 49 | r.height = 0 | |
| 50 | - | r.shadow = nil | |
| 50 | + | r.hasCommitted = false | |
| 51 | 51 | r.committed = Frame{} | |
| 52 | 52 | } | |
| 53 | 53 | ||
| @@ -63,7 +63,13 @@ func (p *PreparedDraw) Commit() { | |||
| 63 | 63 | } | |
| 64 | 64 | p.commitOnce.Do(func() { | |
| 65 | 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 | 73 | p.renderer.setCommittedFrame(committed) | |
| 68 | 74 | }) | |
| 69 | 75 | } | |
| @@ -71,19 +77,21 @@ func (p *PreparedDraw) Commit() { | |||
| 71 | 77 | // Prepare plans and encodes a transactional draw. The renderer advances only | |
| 72 | 78 | // when the returned draw is committed. Keep at most one prepared draw | |
| 73 | 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 | 81 | var candidate DeltaCandidate | |
| 76 | 82 | var err error | |
| 77 | - | if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | |
| 78 | - | if err = frame.Validate(); err == nil { | |
| 79 | - | plan := planSingleDamage(frame, damage[0]) | |
| 80 | - | candidate = DeltaCandidate{Plan: plan} | |
| 81 | - | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | |
| 82 | - | candidate.frame = frame.Clone() | |
| 83 | - | } | |
| 83 | + | if err = validateCellSource(frame); err != nil { | |
| 84 | + | return PreparedDraw{}, err | |
| 85 | + | } | |
| 86 | + | columns, rows := frame.Columns(), frame.Rows() | |
| 87 | + | if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | |
| 88 | + | plan := planSingleDamage(frame, damage[0]) | |
| 89 | + | candidate = DeltaCandidate{Plan: plan} | |
| 90 | + | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | |
| 91 | + | candidate.frame = cloneCellSource(frame) | |
| 84 | 92 | } | |
| 85 | 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 | 96 | if err != nil { | |
| 89 | 97 | return PreparedDraw{}, err | |
| @@ -114,7 +122,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr | |||
| 114 | 122 | } else { | |
| 115 | 123 | if plan.Scroll.Height != 0 { | |
| 116 | 124 | 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 | 127 | for _, span := range plan.Spans { | |
| 120 | 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 | 136 | 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 | 140 | prepared, err := r.Prepare(frame, damage, false) | |
| 133 | 141 | if err != nil { | |
| 134 | 142 | return nil, err | |
| @@ -144,7 +152,7 @@ func (r *Renderer) committedFrame() Frame { | |||
| 144 | 152 | func (r *Renderer) setCommittedFrame(frame Frame) { | |
| 145 | 153 | r.width = frame.Width | |
| 146 | 154 | r.height = frame.Height | |
| 147 | - | r.shadow = frame.Cells | |
| 155 | + | r.hasCommitted = true | |
| 148 | 156 | r.committed = frame | |
| 149 | 157 | } | |
| 150 | 158 | ||
| @@ -185,22 +193,23 @@ func hasScrollDamage(damage []Damage) bool { | |||
| 185 | 193 | return false | |
| 186 | 194 | } | |
| 187 | 195 | ||
| 188 | - | func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) { | |
| 189 | - | for y := range frame.Height { | |
| 190 | - | r.emitSpan(out, frame, y, 0, frame.Width, st) | |
| 196 | + | func (r *Renderer) writeFull(out *bytes.Buffer, frame CellSource, st *drawState) { | |
| 197 | + | columns := frame.Columns() | |
| 198 | + | for y := range frame.Rows() { | |
| 199 | + | r.emitSpan(out, frame, y, 0, columns, st) | |
| 191 | 200 | } | |
| 192 | 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 | 205 | for _, span := range spans { | |
| 197 | 206 | r.emitSpan(out, frame, span.Y, span.X, span.Width, st) | |
| 198 | 207 | } | |
| 199 | 208 | out.WriteString("\x1b[0m") | |
| 200 | 209 | } | |
| 201 | - | func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) { | |
| 202 | - | x, width, okX := clampRange(x, width, frame.Width) | |
| 203 | - | y, height, okY := clampRange(y, height, frame.Height) | |
| 210 | + | func clampRect(frame CellSource, x, y, width, height int) (int, int, int, int, bool) { | |
| 211 | + | x, width, okX := clampRange(x, width, frame.Columns()) | |
| 212 | + | y, height, okY := clampRange(y, height, frame.Rows()) | |
| 204 | 213 | if !okX || !okY { | |
| 205 | 214 | 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 | 146 | if err != nil { | |
| 147 | 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 | 150 | t.Fatal("no-op prepared draw owns frame storage") | |
| 151 | 151 | } | |
| 152 | 152 | prepared.Commit() | |
| @@ -193,10 +193,8 @@ func TestStyleResetDiscipline(t *testing.T) { | |||
| 193 | 193 | ||
| 194 | 194 | // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage. | |
| 195 | 195 | frame2 := NewFrame(4, 3) | |
| 196 | - | copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2 | |
| 197 | - | for i := 8; i < 12; i++ { | |
| 198 | - | frame2.Cells[i] = BlankCell() // bottom row blank | |
| 199 | - | } | |
| 196 | + | frame2.WriteRow(0, 0, frame.Row(1)) | |
| 197 | + | frame2.WriteRow(1, 0, frame.Row(2)) | |
| 200 | 198 | frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row | |
| 201 | 199 | ||
| 202 | 200 | damage := []Damage{ | |
| @@ -353,9 +351,8 @@ func TestScrollFastPath(t *testing.T) { | |||
| 353 | 351 | ||
| 354 | 352 | // Build a new frame that has been scrolled up by 1 (like the VT model does). | |
| 355 | 353 | scrolled := NewFrame(5, 4) | |
| 356 | - | copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3 | |
| 357 | - | for i := 15; i < 20; i++ { | |
| 358 | - | scrolled.Cells[i] = BlankCell() // row 3 blanked | |
| 354 | + | for y := 0; y < 3; y++ { | |
| 355 | + | scrolled.WriteRow(y, 0, frame.Row(y+1)) | |
| 359 | 356 | } | |
| 360 | 357 | scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()}) | |
| 361 | 358 | scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()}) | |
| @@ -567,8 +564,8 @@ func TestReset(t *testing.T) { | |||
| 567 | 564 | ||
| 568 | 565 | r.Reset() | |
| 569 | 566 | ||
| 570 | - | if r.width != 0 || r.height != 0 || r.shadow != nil { | |
| 571 | - | t.Fatal("Reset should clear width/height/shadow") | |
| 567 | + | if r.width != 0 || r.height != 0 || r.hasCommitted { | |
| 568 | + | t.Fatal("Reset should clear committed state") | |
| 572 | 569 | } | |
| 573 | 570 | ||
| 574 | 571 | // After reset, the next draw should be a full draw. | |
| @@ -692,10 +689,8 @@ func TestScrollOnlyDamage(t *testing.T) { | |||
| 692 | 689 | ||
| 693 | 690 | // Scroll up 1 but the VT model would also emit DamageText for the new bottom row. | |
| 694 | 691 | scrolled := NewFrame(4, 3) | |
| 695 | - | copy(scrolled.Cells[0:8], frame.Cells[4:12]) | |
| 696 | - | for i := 8; i < 12; i++ { | |
| 697 | - | scrolled.Cells[i] = BlankCell() | |
| 698 | - | } | |
| 692 | + | scrolled.WriteRow(0, 0, frame.Row(1)) | |
| 693 | + | scrolled.WriteRow(1, 0, frame.Row(2)) | |
| 699 | 694 | scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()}) | |
| 700 | 695 | ||
| 701 | 696 | damage := []Damage{ | |
| @@ -838,7 +833,7 @@ func TestFrameValidate(t *testing.T) { | |||
| 838 | 833 | }{ | |
| 839 | 834 | {name: "valid frame", frame: NewFrame(5, 3), wantErr: false}, | |
| 840 | 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 | 839 | for _, tt := range tests { | |
| @@ -995,9 +990,11 @@ func TestDrawFallbackAndClampingEdgeCases(t *testing.T) { | |||
| 995 | 990 | if rows := strings.Count(string(out), "H"); rows < changed.Height { | |
| 996 | 991 | t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out)) | |
| 997 | 992 | } | |
| 998 | - | for i := range changed.Cells { | |
| 999 | - | if !r.shadow[i].Equal(changed.Cells[i]) { | |
| 1000 | - | t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i]) | |
| 993 | + | for y := range changed.Height { | |
| 994 | + | for x := range changed.Width { | |
| 995 | + | if !r.committed.Cell(x, y).Equal(changed.Cell(x, y)) { | |
| 996 | + | t.Fatalf("committed cell (%d,%d) = %+v, want %+v", x, y, r.committed.Cell(x, y), changed.Cell(x, y)) | |
| 997 | + | } | |
| 1001 | 998 | } | |
| 1002 | 999 | } | |
| 1003 | 1000 | }, | |
| @@ -1400,7 +1397,7 @@ func TestRendererPrepare(t *testing.T) { | |||
| 1400 | 1397 | ||
| 1401 | 1398 | func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) { | |
| 1402 | 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 | 1402 | if _, err := PlanDelta(frame, nil, committed, false); err == nil { | |
| 1406 | 1403 | t.Fatal("PlanDelta accepted an invalid committed frame") | |
M ansi/scroll.go +27 -7
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 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 | 9 | for _, d := range damage { | |
| 10 | 10 | if d.Kind == DamageScrollUp && isSafeScroll(frame, d) { | |
| 11 | 11 | return d, true | |
| @@ -14,8 +14,8 @@ func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) { | |||
| 14 | 14 | return Damage{}, false | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | - | func isSafeScroll(frame Frame, d Damage) bool { | |
| 18 | - | return d.X == 0 && d.Width == frame.Width && d.Y >= 0 && d.Y <= frame.Height && d.Height > 0 && d.Height <= frame.Height-d.Y && d.Count > 0 && d.Count < d.Height | |
| 17 | + | func isSafeScroll(frame CellSource, d Damage) bool { | |
| 18 | + | return d.X == 0 && d.Width == frame.Columns() && d.Y >= 0 && d.Y <= frame.Rows() && d.Height > 0 && d.Height <= frame.Rows()-d.Y && d.Count > 0 && d.Count < d.Height | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | 21 | func emitScrollUp(out *bytes.Buffer, d Damage) { | |
| @@ -42,13 +42,33 @@ func emitScrollUp(out *bytes.Buffer, d Damage) { | |||
| 42 | 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 | 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 | 69 | for x := range scroll.Width { | |
| 50 | 70 | 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 | 72 | if committedCell == frameCell || committedCell.Equal(frameCell) { | |
| 53 | 73 | continue | |
| 54 | 74 | } | |
M benchmark_test.go +2 -2
| @@ -32,8 +32,8 @@ func TestHistoryViewPartialTailAllocationBounded(t *testing.T) { | |||
| 32 | 32 | ||
| 33 | 33 | // Copying a partial tail costs one allocation per row plus a fixed set of | |
| 34 | 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. | |
| 36 | - | const tailHeaders = 4 | |
| 35 | + | // the row slice, bounds, and logical-style metadata parallel to it. | |
| 36 | + | const tailHeaders = 6 | |
| 37 | 37 | ||
| 38 | 38 | sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() }) | |
| 39 | 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 | 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 | 27 | func (b *buffer) clone() *buffer { | |
| 32 | 28 | out := &buffer{ | |
| 33 | - | frame: cloneFrame(b.frame), | |
| 29 | + | frame: b.frame.Clone(), | |
| 34 | 30 | boundaries: append([]LineBound(nil), b.boundaries...), | |
| 35 | 31 | rowIDs: append([]RowID(nil), b.rowIDs...), | |
| 36 | 32 | } | |
| @@ -169,7 +165,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||
| 169 | 165 | evictedBounds := make([]LineBound, 0, shift) | |
| 170 | 166 | evictedIDs := make([]RowID, 0, shift) | |
| 171 | 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 | 169 | // The evicted row keeps its source width, so its extent needs no clamping. | |
| 174 | 170 | evictedBounds = append(evictedBounds, b.boundaries[y]) | |
| 175 | 171 | evictedIDs = append(evictedIDs, b.rowIDs[y]) | |
| @@ -190,7 +186,9 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||
| 190 | 186 | if sy >= b.frame.Height { | |
| 191 | 187 | 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 | 192 | next.boundaries[y] = b.boundaries[sy] | |
| 195 | 193 | next.rowIDs[y] = b.rowIDs[sy] | |
| 196 | 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 | 310 | dst.rowIDs[outputRow-shift] = id | |
| 313 | 311 | } | |
| 314 | 312 | } | |
| 315 | - | blankEvicted := func(outputRow int) []renderer.Cell { | |
| 313 | + | blankOutput := func(outputRow int) { | |
| 316 | 314 | if outputRow < shift { | |
| 317 | 315 | out := evicted[outputRow*width : (outputRow+1)*width] | |
| 318 | 316 | for i := range out { | |
| 319 | 317 | out[i] = renderer.BlankCell() | |
| 320 | 318 | } | |
| 321 | - | return out | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | setOutput := func(outputRow, column int, cell renderer.Cell) { | |
| 322 | + | if outputRow < shift { | |
| 323 | + | evicted[outputRow*width+column] = cell | |
| 324 | + | return | |
| 322 | 325 | } | |
| 323 | 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 | 331 | finishRow := func(soft bool) { | |
| 333 | 332 | // An output row belongs either to history or to the retained viewport, and | |
| 334 | 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 | 343 | row++ | |
| 345 | 344 | col = 0 | |
| 346 | 345 | if dst != nil { | |
| 347 | - | output = blankEvicted(row) | |
| 346 | + | blankOutput(row) | |
| 348 | 347 | } | |
| 349 | 348 | } | |
| 350 | 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 | 381 | truncated := false | |
| 383 | 382 | for y := start; y <= end && !truncated; y++ { | |
| 384 | - | cells := b.frame.Row(y) | |
| 385 | 383 | limit := b.boundaries[y].End | |
| 386 | 384 | for x := 0; x < limit; { | |
| 387 | - | cell := cells[x] | |
| 385 | + | cell := b.frame.At(x, y) | |
| 388 | 386 | if cell.Continuation { // Repair malformed rows by dropping orphaned tails. | |
| 389 | 387 | setPoint(start, offset, row, col) | |
| 390 | 388 | x++ | |
| 391 | 389 | offset++ | |
| 392 | 390 | continue | |
| 393 | 391 | } | |
| 394 | - | wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation | |
| 392 | + | wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && b.frame.At(x+1, y).Continuation | |
| 395 | 393 | w := 1 | |
| 396 | 394 | if wide && width >= 2 { | |
| 397 | 395 | w = 2 | |
| @@ -406,9 +404,8 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||
| 406 | 404 | } | |
| 407 | 405 | setPoint(start, offset, row, col) | |
| 408 | 406 | if wide && width >= 2 { | |
| 409 | - | if output != nil { | |
| 410 | - | output[col], output[col+1] = cell, cells[x+1] | |
| 411 | - | } | |
| 407 | + | setOutput(row, col, cell) | |
| 408 | + | setOutput(row, col+1, b.frame.At(x+1, y)) | |
| 412 | 409 | setPoint(start, offset+1, row, col+1) | |
| 413 | 410 | col += 2 | |
| 414 | 411 | x += 2 | |
| @@ -419,9 +416,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||
| 419 | 416 | cell.Rune = '\uFFFD' | |
| 420 | 417 | cell.Continuation = false | |
| 421 | 418 | } | |
| 422 | - | if output != nil { | |
| 423 | - | output[col] = cell | |
| 424 | - | } | |
| 419 | + | setOutput(row, col, cell) | |
| 425 | 420 | col++ | |
| 426 | 421 | x++ | |
| 427 | 422 | offset++ | |
M core/cell.go +2 -1
| @@ -9,11 +9,12 @@ package core | |||
| 9 | 9 | type Cell struct { | |
| 10 | 10 | Rune rune | |
| 11 | 11 | Style Style | |
| 12 | + | Payload CellPayload | |
| 12 | 13 | Continuation bool | |
| 13 | 14 | } | |
| 14 | 15 | ||
| 15 | 16 | func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} } | |
| 16 | 17 | ||
| 17 | 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 | 1 | // Package vtcore defines the frontend-neutral terminal model shared by the VT | |
| 2 | 2 | // emulator and concrete ANSI renderers. | |
| 3 | 3 | // | |
| 4 | - | // Frame and other mutable values are single-owner. Frame.Row returns a backing | |
| 5 | - | // slice that is valid until the next scroll or resize; Frame.Clone returns | |
| 6 | - | // independent storage. The package has no frontend, transport, or vev policy. | |
| 4 | + | // Frame and other mutable values are single-owner. CellSource provides | |
| 5 | + | // storage-independent semantic reads, Frame.Row returns owned storage, and | |
| 6 | + | // Frame.Clone returns an independent frame. The package has no frontend, | |
| 7 | + | // transport, or vev policy. | |
| 7 | 8 | package core | |
M core/frame.go +323 -104
| @@ -1,169 +1,388 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | - | import "fmt" | |
| 4 | - | ||
| 5 | - | // Frame is a fixed-size grid of cells. Logical rows are decoupled from their | |
| 6 | - | // physical storage through lineOffset: logical row y lives at | |
| 7 | - | // Cells[lineOffset[y] : lineOffset[y]+Width]. This indirection lets full-width | |
| 8 | - | // scrolls rotate offsets instead of copying cell memory (NeoVim's grid | |
| 9 | - | // technique; cf. nvim grid.c line_offset[]). All cell access must go through | |
| 10 | - | // the accessors (At/Set/Row) so callers never assume a physical layout. | |
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "maps" | |
| 6 | + | "math" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // CellSource exposes semantic terminal cells without exposing mutable storage. | |
| 10 | + | // Implementations may use any physical layout; callers must use Cell for reads. | |
| 11 | + | type CellSource interface { | |
| 12 | + | Columns() int | |
| 13 | + | Rows() int | |
| 14 | + | Cell(x, y int) Cell | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | // StoredCellLogicalBytes is the deterministic uncompressed size of one | |
| 19 | + | // compact cell, independent of the Go allocator and host architecture. | |
| 20 | + | StoredCellLogicalBytes uint64 = 16 | |
| 21 | + | // RowDescriptorLogicalBytes is the deterministic size of one compact row | |
| 22 | + | // descriptor. | |
| 23 | + | RowDescriptorLogicalBytes uint64 = 4 | |
| 24 | + | // StyleRecordLogicalBytes is the deterministic size of one canonical | |
| 25 | + | // page-local style record. | |
| 26 | + | StyleRecordLogicalBytes uint64 = 32 | |
| 27 | + | ||
| 28 | + | continuationFlag = uint8(1) | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | type storedCell struct { | |
| 32 | + | rune int32 | |
| 33 | + | styleID uint32 | |
| 34 | + | payloadID uint32 | |
| 35 | + | flags uint8 | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | type styleSlot struct { | |
| 39 | + | style Style | |
| 40 | + | refs uint32 | |
| 41 | + | used bool | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type cellPage struct { | |
| 45 | + | cells []storedCell | |
| 46 | + | rows []uint32 | |
| 47 | + | styles []styleSlot | |
| 48 | + | styleIndex map[Style]uint32 | |
| 49 | + | freeStyles []uint32 | |
| 50 | + | styleCount uint32 | |
| 51 | + | payloads []payloadSlot | |
| 52 | + | payloadIndex map[CellPayload]uint32 | |
| 53 | + | freePayloads []uint32 | |
| 54 | + | payloadBytes uint64 | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Frame is a fixed-size semantic grid backed by one compact page. Cells carry | |
| 58 | + | // page-local style IDs; callers never observe those IDs or the physical row | |
| 59 | + | // layout. Logical rows are mapped through compact row descriptors so full-width | |
| 60 | + | // scrolling rotates descriptors instead of copying cells. | |
| 11 | 61 | type Frame struct { | |
| 12 | 62 | Width int | |
| 13 | 63 | Height int | |
| 14 | - | Cells []Cell | |
| 15 | - | // lineOffset maps a logical row index to its physical base index into | |
| 16 | - | // Cells. It is always a permutation of the canonical offsets y*Width. | |
| 17 | - | lineOffset []int | |
| 64 | + | page *cellPage | |
| 18 | 65 | } | |
| 19 | 66 | ||
| 20 | 67 | func NewFrame(width, height int) Frame { | |
| 21 | - | cells := make([]Cell, width*height) | |
| 22 | - | for i := range cells { | |
| 23 | - | cells[i] = BlankCell() | |
| 68 | + | cellCount, ok := frameCellCount(width, height) | |
| 69 | + | if !ok { | |
| 70 | + | panic("frame cell count exceeds page limit") | |
| 71 | + | } | |
| 72 | + | page := &cellPage{ | |
| 73 | + | cells: make([]storedCell, cellCount), | |
| 74 | + | rows: make([]uint32, height), | |
| 75 | + | styles: []styleSlot{{style: DefaultStyle(), refs: uint32(cellCount), used: true}}, | |
| 76 | + | styleIndex: map[Style]uint32{DefaultStyle(): 0}, | |
| 77 | + | styleCount: 1, | |
| 78 | + | } | |
| 79 | + | for i := range page.cells { | |
| 80 | + | page.cells[i].rune = ' ' | |
| 24 | 81 | } | |
| 25 | - | lineOffset := make([]int, height) | |
| 26 | - | for y := range lineOffset { | |
| 27 | - | lineOffset[y] = y * width | |
| 82 | + | for y := range page.rows { | |
| 83 | + | page.rows[y] = uint32(y * width) | |
| 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 clone | |
| 33 | - | // uses canonical row storage so later scrolls or writes to the source frame do | |
| 34 | - | // not affect it. | |
| 88 | + | // Clone returns an independent compact page preserving physical row rotation | |
| 89 | + | // and page-local style IDs. | |
| 35 | 90 | func (f Frame) Clone() Frame { | |
| 36 | - | clone := NewFrame(f.Width, f.Height) | |
| 37 | - | for y := range f.Height { | |
| 38 | - | copy(clone.Row(y), f.Row(y)) | |
| 91 | + | if f.page == nil { | |
| 92 | + | return Frame{Width: f.Width, Height: f.Height} | |
| 93 | + | } | |
| 94 | + | return Frame{ | |
| 95 | + | Width: f.Width, | |
| 96 | + | Height: f.Height, | |
| 97 | + | page: &cellPage{ | |
| 98 | + | cells: append([]storedCell(nil), f.page.cells...), | |
| 99 | + | rows: append([]uint32(nil), f.page.rows...), | |
| 100 | + | styles: append([]styleSlot(nil), f.page.styles...), | |
| 101 | + | styleIndex: maps.Clone(f.page.styleIndex), | |
| 102 | + | freeStyles: append([]uint32(nil), f.page.freeStyles...), | |
| 103 | + | styleCount: f.page.styleCount, | |
| 104 | + | payloads: append([]payloadSlot(nil), f.page.payloads...), | |
| 105 | + | payloadIndex: maps.Clone(f.page.payloadIndex), | |
| 106 | + | freePayloads: append([]uint32(nil), f.page.freePayloads...), | |
| 107 | + | payloadBytes: f.page.payloadBytes, | |
| 108 | + | }, | |
| 39 | 109 | } | |
| 40 | - | return clone | |
| 41 | 110 | } | |
| 42 | 111 | ||
| 43 | - | // Replace copies src into f while reusing canonical storage when dimensions | |
| 44 | - | // permit. src is read through logical rows so its physical row rotation is | |
| 45 | - | // preserved in the resulting contents without exposing layout internals. | |
| 112 | + | // Replace replaces f with an independent structural copy of src's compact page. | |
| 113 | + | // An invalid or empty source leaves the destination unchanged. | |
| 46 | 114 | func (f *Frame) Replace(src Frame) { | |
| 47 | 115 | if f == nil { | |
| 48 | 116 | return | |
| 49 | 117 | } | |
| 50 | - | if src.Width <= 0 || src.Height <= 0 || len(src.Cells) != src.Width*src.Height { | |
| 51 | - | *f = Frame{} | |
| 118 | + | if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 { | |
| 52 | 119 | return | |
| 53 | 120 | } | |
| 121 | + | *f = src.Clone() | |
| 122 | + | } | |
| 54 | 123 | ||
| 55 | - | sameDimensions := f.Width == src.Width && f.Height == src.Height | |
| 56 | - | cellCount := src.Width * src.Height | |
| 57 | - | if sameDimensions && cap(f.Cells) >= cellCount { | |
| 58 | - | f.Cells = f.Cells[:cellCount] | |
| 59 | - | } else { | |
| 60 | - | f.Cells = make([]Cell, cellCount) | |
| 124 | + | func frameCellCount(width, height int) (int, bool) { | |
| 125 | + | if width < 0 || height < 0 { | |
| 126 | + | return 0, false | |
| 61 | 127 | } | |
| 62 | - | if sameDimensions && cap(f.lineOffset) >= src.Height { | |
| 63 | - | f.lineOffset = f.lineOffset[:src.Height] | |
| 64 | - | } else { | |
| 65 | - | f.lineOffset = make([]int, src.Height) | |
| 128 | + | limit := min(uint64(math.MaxUint32), uint64(math.MaxInt)) | |
| 129 | + | w, h := uint64(width), uint64(height) | |
| 130 | + | if w > limit || h > limit || w != 0 && w*h > limit { | |
| 131 | + | return 0, false | |
| 132 | + | } | |
| 133 | + | return int(w * h), true | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (f Frame) validateStorage() error { | |
| 137 | + | if f.page == nil { | |
| 138 | + | return fmt.Errorf("missing frame page") | |
| 66 | 139 | } | |
| 67 | - | f.Width = src.Width | |
| 68 | - | f.Height = src.Height | |
| 69 | - | for y := range f.lineOffset { | |
| 70 | - | f.lineOffset[y] = y * f.Width | |
| 140 | + | cellCount, ok := frameCellCount(f.Width, f.Height) | |
| 141 | + | if !ok { | |
| 142 | + | return fmt.Errorf("frame cell count exceeds page limit") | |
| 71 | 143 | } | |
| 72 | - | for y := range src.Height { | |
| 73 | - | copy(f.Row(y), src.Row(y)) | |
| 144 | + | if len(f.page.cells) != cellCount { | |
| 145 | + | return fmt.Errorf("invalid cell count: got %d want %d", len(f.page.cells), cellCount) | |
| 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 | 153 | func (f Frame) Validate() error { | |
| 78 | 154 | if f.Width <= 0 || f.Height <= 0 { | |
| 79 | 155 | return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height) | |
| 80 | 156 | } | |
| 81 | - | if len(f.Cells) != f.Width*f.Height { | |
| 82 | - | return fmt.Errorf("invalid cell count: got %d want %d", len(f.Cells), f.Width*f.Height) | |
| 83 | - | } | |
| 84 | - | if len(f.lineOffset) != f.Height { | |
| 85 | - | return fmt.Errorf("invalid lineOffset length: got %d want %d", len(f.lineOffset), f.Height) | |
| 86 | - | } | |
| 87 | - | return nil | |
| 157 | + | return f.validateStorage() | |
| 88 | 158 | } | |
| 89 | 159 | ||
| 90 | - | // CheckInvariants verifies that lineOffset is a permutation of the canonical | |
| 91 | - | // row offsets: every entry is a non-negative multiple of Width within bounds, | |
| 92 | - | // and every logical row maps to a distinct physical row. It is used by tests | |
| 93 | - | // and assertions; the hot path uses the lighter Validate. | |
| 160 | + | // CheckInvariants validates row ownership, style references, dictionary keys, | |
| 161 | + | // and exact reference counts. It is intended for tests and assertions. | |
| 94 | 162 | func (f Frame) CheckInvariants() error { | |
| 95 | - | if f.Width <= 0 { | |
| 96 | - | return fmt.Errorf("invalid width %d", f.Width) | |
| 163 | + | if err := f.validateStorage(); err != nil { | |
| 164 | + | return err | |
| 97 | 165 | } | |
| 98 | - | if len(f.lineOffset) != f.Height { | |
| 99 | - | return fmt.Errorf("lineOffset length %d != height %d", len(f.lineOffset), f.Height) | |
| 166 | + | seenRows := make([]bool, f.Height) | |
| 167 | + | refs := make([]uint32, len(f.page.styles)) | |
| 168 | + | for y, offset := range f.page.rows { | |
| 169 | + | if f.Width == 0 { | |
| 170 | + | if offset != 0 { | |
| 171 | + | return fmt.Errorf("row %d: nonzero offset %d for zero width", y, offset) | |
| 172 | + | } | |
| 173 | + | continue | |
| 174 | + | } | |
| 175 | + | if offset%uint32(f.Width) != 0 { | |
| 176 | + | return fmt.Errorf("row %d: offset %d is not a multiple of width %d", y, offset, f.Width) | |
| 177 | + | } | |
| 178 | + | physical := int(offset) / f.Width | |
| 179 | + | if physical >= f.Height || seenRows[physical] { | |
| 180 | + | return fmt.Errorf("row %d: invalid or duplicate physical row %d", y, physical) | |
| 181 | + | } | |
| 182 | + | seenRows[physical] = true | |
| 183 | + | } | |
| 184 | + | for i, cell := range f.page.cells { | |
| 185 | + | if int(cell.styleID) >= len(f.page.styles) || !f.page.styles[cell.styleID].used { | |
| 186 | + | return fmt.Errorf("cell %d: unresolved style ID %d", i, cell.styleID) | |
| 187 | + | } | |
| 188 | + | refs[cell.styleID]++ | |
| 100 | 189 | } | |
| 101 | - | seen := make([]bool, f.Height) | |
| 102 | - | for y, off := range f.lineOffset { | |
| 103 | - | if off < 0 || off%f.Width != 0 { | |
| 104 | - | return fmt.Errorf("row %d: offset %d is not a non-negative multiple of width %d", y, off, f.Width) | |
| 190 | + | free := make([]bool, len(f.page.styles)) | |
| 191 | + | for position, id := range f.page.freeStyles { | |
| 192 | + | if int(id) >= len(f.page.styles) { | |
| 193 | + | return fmt.Errorf("free style entry %d has out-of-range ID %d", position, id) | |
| 105 | 194 | } | |
| 106 | - | phys := off / f.Width | |
| 107 | - | if phys >= f.Height { | |
| 108 | - | return fmt.Errorf("row %d: offset %d maps to physical row %d out of range [0,%d)", y, off, phys, f.Height) | |
| 195 | + | if f.page.styles[id].used { | |
| 196 | + | return fmt.Errorf("free style entry %d identifies used style %d", position, id) | |
| 109 | 197 | } | |
| 110 | - | if seen[phys] { | |
| 111 | - | return fmt.Errorf("row %d: physical row %d already mapped by another logical row", y, phys) | |
| 198 | + | if free[id] { | |
| 199 | + | return fmt.Errorf("free style ID %d appears more than once", id) | |
| 112 | 200 | } | |
| 113 | - | seen[phys] = true | |
| 201 | + | free[id] = true | |
| 114 | 202 | } | |
| 115 | - | return nil | |
| 203 | + | var used uint32 | |
| 204 | + | for id, slot := range f.page.styles { | |
| 205 | + | if !slot.used { | |
| 206 | + | if slot.refs != 0 { | |
| 207 | + | return fmt.Errorf("free style %d has %d references", id, slot.refs) | |
| 208 | + | } | |
| 209 | + | if !free[id] { | |
| 210 | + | return fmt.Errorf("unused style %d is missing from free list", id) | |
| 211 | + | } | |
| 212 | + | continue | |
| 213 | + | } | |
| 214 | + | used++ | |
| 215 | + | if slot.refs != refs[id] { | |
| 216 | + | return fmt.Errorf("style %d references = %d want %d", id, slot.refs, refs[id]) | |
| 217 | + | } | |
| 218 | + | if slot.style != slot.style.Canonical() { | |
| 219 | + | return fmt.Errorf("style %d is not canonical", id) | |
| 220 | + | } | |
| 221 | + | if indexed, ok := f.page.styleIndex[slot.style]; !ok || indexed != uint32(id) { | |
| 222 | + | return fmt.Errorf("style %d missing from index", id) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | if len(f.page.styles) == 0 || !f.page.styles[0].used || f.page.styles[0].style != DefaultStyle() { | |
| 226 | + | return fmt.Errorf("style ID zero is not the default style") | |
| 227 | + | } | |
| 228 | + | if used != f.page.styleCount || len(f.page.styleIndex) != int(used) { | |
| 229 | + | return fmt.Errorf("style count = %d/%d, index entries %d", used, f.page.styleCount, len(f.page.styleIndex)) | |
| 230 | + | } | |
| 231 | + | return f.checkPayloadInvariants() | |
| 116 | 232 | } | |
| 117 | 233 | ||
| 118 | - | func (f Frame) At(x, y int) Cell { | |
| 119 | - | return f.Cells[f.lineOffset[y]+x] | |
| 234 | + | // LogicalBytes returns deterministic uncompressed page bytes independent of Go | |
| 235 | + | // pointer size, map capacity, and allocator overhead. | |
| 236 | + | func (f Frame) LogicalBytes() uint64 { | |
| 237 | + | if f.page == nil { | |
| 238 | + | return 0 | |
| 239 | + | } | |
| 240 | + | return uint64(len(f.page.cells))*StoredCellLogicalBytes + uint64(len(f.page.rows))*RowDescriptorLogicalBytes + uint64(f.page.styleCount)*StyleRecordLogicalBytes + f.page.payloadBytes | |
| 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 | 259 | func (f Frame) Set(x, y int, cell Cell) { | |
| 123 | - | f.Cells[f.lineOffset[y]+x] = cell | |
| 260 | + | index := f.offset(x, y) | |
| 261 | + | payloadID := f.internPayload(cell.Payload) | |
| 262 | + | f.releasePayload(f.page.cells[index].payloadID) | |
| 263 | + | oldID := f.page.cells[index].styleID | |
| 264 | + | styleID := f.internStyle(cell.Style) | |
| 265 | + | if oldID == styleID { | |
| 266 | + | f.releaseStyle(styleID) // cancel internStyle's temporary reference | |
| 267 | + | } else { | |
| 268 | + | f.releaseStyle(oldID) | |
| 269 | + | } | |
| 270 | + | flags := uint8(0) | |
| 271 | + | if cell.Continuation { | |
| 272 | + | flags = continuationFlag | |
| 273 | + | } | |
| 274 | + | f.page.cells[index] = storedCell{rune: int32(cell.Rune), styleID: styleID, payloadID: payloadID, flags: flags} | |
| 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 | 277 | func (f Frame) Row(y int) []Cell { | |
| 130 | - | base := f.lineOffset[y] | |
| 131 | - | return f.Cells[base : base+f.Width] | |
| 278 | + | if y < 0 || y >= f.Height { | |
| 279 | + | panic("frame row out of range") | |
| 280 | + | } | |
| 281 | + | row := make([]Cell, f.Width) | |
| 282 | + | for x := range row { | |
| 283 | + | row[x] = f.Cell(x, y) | |
| 284 | + | } | |
| 285 | + | return row | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (f Frame) WriteRow(y, x int, cells []Cell) int { | |
| 289 | + | count := min(len(cells), f.Width-x) | |
| 290 | + | for i := range count { | |
| 291 | + | f.Set(x+i, y, cells[i]) | |
| 292 | + | } | |
| 293 | + | return count | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func (f Frame) CopyRow(y, dst, src, count int) { | |
| 297 | + | if count <= 0 || dst == src { | |
| 298 | + | return | |
| 299 | + | } | |
| 300 | + | if dst < src { | |
| 301 | + | for i := range count { | |
| 302 | + | f.Set(dst+i, y, f.Cell(src+i, y)) | |
| 303 | + | } | |
| 304 | + | return | |
| 305 | + | } | |
| 306 | + | for i := count - 1; i >= 0; i-- { | |
| 307 | + | f.Set(dst+i, y, f.Cell(src+i, y)) | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func (f Frame) FillRow(y, start, end int, cell Cell) { | |
| 312 | + | for x := max(0, start); x < min(f.Width, end); x++ { | |
| 313 | + | f.Set(x, y, cell) | |
| 314 | + | } | |
| 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 | 317 | func (f Frame) ScrollUp(top, bottom, n int) { | |
| 141 | 318 | for ; n > 0; n-- { | |
| 142 | - | recycled := f.lineOffset[top] | |
| 143 | - | for y := top; y < bottom; y++ { | |
| 144 | - | f.lineOffset[y] = f.lineOffset[y+1] | |
| 145 | - | } | |
| 146 | - | f.lineOffset[bottom] = recycled | |
| 147 | - | blankRow(f.Cells[recycled : recycled+f.Width]) | |
| 319 | + | recycled := f.page.rows[top] | |
| 320 | + | copy(f.page.rows[top:bottom], f.page.rows[top+1:bottom+1]) | |
| 321 | + | f.page.rows[bottom] = recycled | |
| 322 | + | f.blankPhysicalRow(recycled) | |
| 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 | 326 | func (f Frame) ScrollDown(top, bottom, n int) { | |
| 155 | 327 | for ; n > 0; n-- { | |
| 156 | - | recycled := f.lineOffset[bottom] | |
| 157 | - | for y := bottom; y > top; y-- { | |
| 158 | - | f.lineOffset[y] = f.lineOffset[y-1] | |
| 328 | + | recycled := f.page.rows[bottom] | |
| 329 | + | copy(f.page.rows[top+1:bottom+1], f.page.rows[top:bottom]) | |
| 330 | + | f.page.rows[top] = recycled | |
| 331 | + | f.blankPhysicalRow(recycled) | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func (f Frame) offset(x, y int) int { | |
| 336 | + | if x < 0 || x >= f.Width { | |
| 337 | + | panic("frame column out of range") | |
| 338 | + | } | |
| 339 | + | if y < 0 || y >= f.Height { | |
| 340 | + | panic("frame row out of range") | |
| 341 | + | } | |
| 342 | + | return int(f.page.rows[y]) + x | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func (f Frame) blankPhysicalRow(offset uint32) { | |
| 346 | + | for x := range f.Width { | |
| 347 | + | index := int(offset) + x | |
| 348 | + | f.releasePayload(f.page.cells[index].payloadID) | |
| 349 | + | oldID := f.page.cells[index].styleID | |
| 350 | + | if oldID != 0 { | |
| 351 | + | f.page.styles[0].refs++ | |
| 352 | + | f.releaseStyle(oldID) | |
| 159 | 353 | } | |
| 160 | - | f.lineOffset[top] = recycled | |
| 161 | - | blankRow(f.Cells[recycled : recycled+f.Width]) | |
| 354 | + | f.page.cells[index] = storedCell{rune: ' '} | |
| 162 | 355 | } | |
| 163 | 356 | } | |
| 164 | 357 | ||
| 165 | - | func blankRow(row []Cell) { | |
| 166 | - | for i := range row { | |
| 167 | - | row[i] = BlankCell() | |
| 358 | + | func (f Frame) internStyle(style Style) uint32 { | |
| 359 | + | style = style.Canonical() | |
| 360 | + | if id, ok := f.page.styleIndex[style]; ok { | |
| 361 | + | f.page.styles[id].refs++ | |
| 362 | + | return id | |
| 363 | + | } | |
| 364 | + | var id uint32 | |
| 365 | + | if n := len(f.page.freeStyles); n != 0 { | |
| 366 | + | id = f.page.freeStyles[n-1] | |
| 367 | + | f.page.freeStyles = f.page.freeStyles[:n-1] | |
| 368 | + | f.page.styles[id] = styleSlot{style: style, refs: 1, used: true} | |
| 369 | + | } else { | |
| 370 | + | id = uint32(len(f.page.styles)) | |
| 371 | + | f.page.styles = append(f.page.styles, styleSlot{style: style, refs: 1, used: true}) | |
| 372 | + | } | |
| 373 | + | f.page.styleIndex[style] = id | |
| 374 | + | f.page.styleCount++ | |
| 375 | + | return id | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func (f Frame) releaseStyle(id uint32) { | |
| 379 | + | slot := &f.page.styles[id] | |
| 380 | + | slot.refs-- | |
| 381 | + | if id == 0 || slot.refs != 0 { | |
| 382 | + | return | |
| 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 | 1 | package core | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "reflect" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | func TestFrameCanonicalOffsetsAndInvariants(t *testing.T) { | |
| 6 | 10 | f := NewFrame(4, 3) | |
| 7 | 11 | for y := range f.Height { | |
| 8 | - | if f.lineOffset[y] != y*f.Width { | |
| 9 | - | t.Fatalf("lineOffset[%d] = %d, want %d", y, f.lineOffset[y], y*f.Width) | |
| 12 | + | if f.page.rows[y] != uint32(y*f.Width) { | |
| 13 | + | t.Fatalf("row offset %d = %d, want %d", y, f.page.rows[y], y*f.Width) | |
| 10 | 14 | } | |
| 11 | 15 | } | |
| 12 | 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 | 192 | func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) { | |
| 18 | 193 | f := NewFrame(4, 3) | |
| 19 | - | f.lineOffset[1] = f.lineOffset[0] | |
| 194 | + | f.page.rows[1] = f.page.rows[0] | |
| 20 | 195 | if err := f.CheckInvariants(); err == nil { | |
| 21 | 196 | t.Fatal("expected duplicate physical row to be rejected") | |
| 22 | 197 | } | |
| 23 | 198 | ||
| 24 | 199 | f = NewFrame(4, 3) | |
| 25 | - | f.lineOffset[0] = 1 | |
| 200 | + | f.page.rows[0] = 1 | |
| 26 | 201 | if err := f.CheckInvariants(); err == nil { | |
| 27 | 202 | t.Fatal("expected non-multiple offset to be rejected") | |
| 28 | 203 | } | |
| 29 | 204 | ||
| 30 | 205 | f = NewFrame(4, 3) | |
| 31 | - | f.lineOffset[2] = 99 | |
| 206 | + | f.page.rows[2] = 99 | |
| 32 | 207 | if err := f.CheckInvariants(); err == nil { | |
| 33 | 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 | 237 | func TestReplacePreservesLogicalRows(t *testing.T) { | |
| 38 | 238 | dst := NewFrame(3, 3) | |
| 39 | 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 | 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 | 78 | func (s Style) Equal(other Style) bool { | |
| 52 | 79 | if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse || s.Attrs != other.Attrs || s.UnderlineStyle != other.UnderlineStyle { | |
| 53 | 80 | 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 | 6 | // history, and renderer consumers share one cell/style/frame representation. | |
| 7 | 7 | // The implementation and storage policy remain owned by the core package. | |
| 8 | 8 | type Cell = core.Cell | |
| 9 | + | type CellPayload = core.CellPayload | |
| 9 | 10 | type RGB = core.RGB | |
| 10 | 11 | type StyleAttrs = core.StyleAttrs | |
| 11 | 12 | type UnderlineStyle = core.UnderlineStyle | |
| 12 | 13 | type Style = core.Style | |
| 13 | 14 | type Frame = core.Frame | |
| 15 | + | type CellSource = core.CellSource | |
| 14 | 16 | ||
| 15 | 17 | type DamageKind = core.DamageKind | |
| 16 | 18 | type Damage = core.Damage | |
| @@ -35,10 +37,11 @@ const ( | |||
| 35 | 37 | ) | |
| 36 | 38 | ||
| 37 | 39 | var ( | |
| 38 | - | BlankCell = core.BlankCell | |
| 39 | - | DefaultStyle = core.DefaultStyle | |
| 40 | - | FullRedraw = core.FullRedraw | |
| 41 | - | NewFrame = core.NewFrame | |
| 40 | + | BlankCell = core.BlankCell | |
| 41 | + | DefaultStyle = core.DefaultStyle | |
| 42 | + | FullRedraw = core.FullRedraw | |
| 43 | + | NewFrame = core.NewFrame | |
| 44 | + | NewCellPayload = core.NewCellPayload | |
| 42 | 45 | ) | |
| 43 | 46 | ||
| 44 | 47 | func RuneWidth(r rune) int { return core.RuneWidth(r) } | |
M doc.go +5 -6
| @@ -15,10 +15,9 @@ | |||
| 15 | 15 | // or strings owned by the callback. | |
| 16 | 16 | // | |
| 17 | 17 | // Snapshot and HistorySnapshotView capture owned state without sealing or | |
| 18 | - | // mutating the live owner. Row methods return owned copies. BorrowedRow and | |
| 19 | - | // HistoryView.Range expose immutable backing storage: callers must not mutate | |
| 20 | - | // it, and Range callbacks must not retain their row after returning. A | |
| 21 | - | // HistoryChunk pointer has stable identity for the lifetime of the view, so | |
| 22 | - | // consumers can reuse unchanged sealed chunks. Direct Frame.Row access is a | |
| 23 | - | // mutable borrow valid only until the frame scrolls or resizes. | |
| 18 | + | // mutating the live owner. Screen, ScreenSnapshot, and core.Frame expose | |
| 19 | + | // storage-independent CellSource reads, and Row methods return owned copies. | |
| 20 | + | // HistoryView.Row and HistoryView.Range decode owned semantic rows from compact | |
| 21 | + | // slabs. A HistoryChunk pointer has stable identity for the lifetime of the | |
| 22 | + | // view, so consumers can reuse unchanged sealed chunks. | |
| 24 | 23 | 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 | 12 | screen := vt.NewScreen(6, 2) | |
| 13 | 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 | 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 | 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 | 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 | 33 | if got := vt.RuneWidth('界'); got != 2 { | |
| 25 | 34 | t.Fatalf("RuneWidth(界) = %d, want 2", got) | |
| 26 | 35 | } | |
| @@ -29,7 +38,7 @@ func TestExternalScreenParserPreservesPublicTerminalModel(t *testing.T) { | |||
| 29 | 38 | // provide arbitrary PTY chunk boundaries. | |
| 30 | 39 | screen.Write([]byte("\x1b[2;2H\x1b[3")) | |
| 31 | 40 | screen.Write([]byte("1mX")) | |
| 32 | - | cell := screen.Frame.At(1, 1) | |
| 41 | + | cell := screen.Cell(1, 1) | |
| 33 | 42 | if cell.Rune != 'X' || cell.Style.Foreground != 1 { | |
| 34 | 43 | t.Fatalf("split ANSI write = %#v, want red X", cell) | |
| 35 | 44 | } | |
| @@ -133,9 +142,6 @@ func TestExternalSnapshotsAndHistoryRespectOwnershipContracts(t *testing.T) { | |||
| 133 | 142 | if got := before.Row(0)[0].Rune; got != 'a' { | |
| 134 | 143 | t.Fatalf("owned history row mutation changed view to %q", got) | |
| 135 | 144 | } | |
| 136 | - | if borrowed := before.BorrowedRow(0); &borrowed[0] == ©OfRow[0] { | |
| 137 | - | t.Fatal("Row returned borrowed history storage instead of an owned copy") | |
| 138 | - | } | |
| 139 | 145 | ||
| 140 | 146 | appendHistoryRow(t, history, 'c', 12) | |
| 141 | 147 | after := history.View() | |
M extraction_line_callback_external_test.go +1 -1
| @@ -26,7 +26,7 @@ func TestExternalLineEvictionCallbackReceivesStableCopy(t *testing.T) { | |||
| 26 | 26 | t.Fatalf("evicted rows = %#v, want one stable AAAA row", evicted) | |
| 27 | 27 | } | |
| 28 | 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 | 30 | t.Fatalf("mutating callback row changed live frame to %q", got) | |
| 31 | 31 | } | |
| 32 | 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 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "fmt" | |
| 5 | 6 | "math" | |
| 6 | 7 | "slices" | |
| 7 | 8 | ||
| 8 | 9 | renderer "github.com/bnema/vev-vt/core" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | - | // ErrHistoryRowTooWide is returned when a row cannot fit within the configured | |
| 12 | - | // cell budget. The history is not modified when this error is returned. | |
| 13 | - | var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity") | |
| 12 | + | // ErrHistoryRowTooLarge is returned when one row cannot fit within the | |
| 13 | + | // configured logical-byte budget or compact page bounds. History is unchanged. | |
| 14 | + | var ErrHistoryRowTooLarge = errors.New("history row exceeds logical byte capacity") | |
| 14 | 15 | ||
| 15 | 16 | var errInvalidHistoryRowID = errors.New("invalid history row ID") | |
| 16 | 17 | ||
| 17 | - | // HistoryConfig controls the bounded terminal history retained by a Screen. | |
| 18 | - | type HistoryConfig struct { | |
| 19 | - | MaxRows int | |
| 20 | - | MaxCells int | |
| 21 | - | ChunkRows int | |
| 22 | - | } | |
| 18 | + | const maxTailPreallocCells = 32 * 1024 | |
| 23 | 19 | ||
| 24 | 20 | // History stores terminal rows in immutable chunks. It is intended to be | |
| 25 | 21 | // mutated by the owner of a Screen; views are safe to retain after later | |
| 26 | 22 | // appends. | |
| 27 | 23 | type History struct { | |
| 28 | - | maxRows int | |
| 29 | - | maxCells int | |
| 30 | - | chunkRows int | |
| 31 | - | chunks []*HistoryChunk | |
| 32 | - | tail [][]renderer.Cell | |
| 33 | - | tailBounds []LineBound | |
| 34 | - | tailIDs []RowID | |
| 35 | - | rows int | |
| 36 | - | cells int | |
| 37 | - | nextRowID RowID | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | // HistoryChunk is an immutable group of history rows. Its identity is stable | |
| 41 | - | // and can be used by consumers to avoid copying unchanged sealed chunks. | |
| 42 | - | // bounds is parallel to rows and always has the same length. | |
| 24 | + | maxRows int | |
| 25 | + | rowCeiling int | |
| 26 | + | maxBytes uint64 | |
| 27 | + | byteCeiling uint64 | |
| 28 | + | chunkRows int | |
| 29 | + | chunks []*HistoryChunk | |
| 30 | + | tail [][]renderer.Cell | |
| 31 | + | cachedTail []*HistoryChunk | |
| 32 | + | tailCells []renderer.Cell | |
| 33 | + | tailBounds []LineBound | |
| 34 | + | tailIDs []RowID | |
| 35 | + | tailBytes uint64 | |
| 36 | + | tailPageWidth int | |
| 37 | + | tailPageRows int | |
| 38 | + | tailPageStyles map[renderer.Style]struct{} | |
| 39 | + | styleScratch map[renderer.Style]struct{} | |
| 40 | + | payloadScratch map[renderer.CellPayload]struct{} | |
| 41 | + | tailPagePayloads map[renderer.CellPayload]struct{} | |
| 42 | + | rows int | |
| 43 | + | cells int | |
| 44 | + | logicalBytes uint64 | |
| 45 | + | nextRowID RowID | |
| 46 | + | compressionCursor int | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // HistoryChunk is an immutable compact slab of equal-width history rows. Its | |
| 50 | + | // identity is stable and can be used by consumers to reuse unchanged chunks. | |
| 43 | 51 | type HistoryChunk struct { | |
| 44 | - | rows [][]renderer.Cell | |
| 45 | - | bounds []LineBound | |
| 46 | - | rowIDs []RowID | |
| 52 | + | page *sealedPage | |
| 53 | + | start int | |
| 54 | + | count int | |
| 55 | + | width int | |
| 56 | + | bounds []LineBound | |
| 57 | + | rowIDs []RowID | |
| 58 | + | styleDrops []uint64 | |
| 59 | + | styleCount uint64 | |
| 60 | + | payloadDrops []uint64 | |
| 61 | + | payloadBytes uint64 | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func newHistoryChunks(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID) []*HistoryChunk { | |
| 65 | + | bounds = growBounds(bounds, len(rows)) | |
| 66 | + | rowIDs = growRowIDs(rowIDs, len(rows)) | |
| 67 | + | var chunks []*HistoryChunk | |
| 68 | + | for start := 0; start < len(rows); { | |
| 69 | + | width := len(rows[start]) | |
| 70 | + | limit := start + min(len(rows)-start, maxHistorySlabRows(width)) | |
| 71 | + | end := start + 1 | |
| 72 | + | for end < limit && len(rows[end]) == width { | |
| 73 | + | end++ | |
| 74 | + | } | |
| 75 | + | frame := renderer.NewFrame(width, end-start) | |
| 76 | + | lastStyleRow := make(map[renderer.Style]int) | |
| 77 | + | for i := start; i < end; i++ { | |
| 78 | + | frame.WriteRow(i-start, 0, rows[i]) | |
| 79 | + | for _, cell := range rows[i] { | |
| 80 | + | style := cell.Style.Canonical() | |
| 81 | + | if style != renderer.DefaultStyle() { | |
| 82 | + | lastStyleRow[style] = i - start | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | styleDrops := make([]uint64, end-start) | |
| 87 | + | for _, row := range lastStyleRow { | |
| 88 | + | styleDrops[row]++ | |
| 89 | + | } | |
| 90 | + | chunks = append(chunks, &HistoryChunk{ | |
| 91 | + | page: newSealedPage(frame), | |
| 92 | + | count: end - start, | |
| 93 | + | width: width, | |
| 94 | + | bounds: append([]LineBound(nil), bounds[start:end]...), | |
| 95 | + | rowIDs: append([]RowID(nil), rowIDs[start:end]...), | |
| 96 | + | styleDrops: styleDrops, | |
| 97 | + | styleCount: uint64(len(lastStyleRow)) + 1, | |
| 98 | + | }) | |
| 99 | + | chunks[len(chunks)-1].recordPayloads() | |
| 100 | + | start = end | |
| 101 | + | } | |
| 102 | + | return chunks | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func maxHistorySlabRows(width int) int { | |
| 106 | + | if width <= 0 { | |
| 107 | + | return math.MaxInt | |
| 108 | + | } | |
| 109 | + | rows := min(uint64(math.MaxUint32)/uint64(width), uint64(math.MaxInt)) | |
| 110 | + | return max(int(rows), 1) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (c *HistoryChunk) len() int { | |
| 114 | + | if c == nil { | |
| 115 | + | return 0 | |
| 116 | + | } | |
| 117 | + | return c.count | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (c *HistoryChunk) row(i int) []renderer.Cell { | |
| 121 | + | if c == nil || i < 0 || i >= c.count { | |
| 122 | + | return nil | |
| 123 | + | } | |
| 124 | + | return c.frameView().Row(c.start + i) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (c *HistoryChunk) cell(row, column int) renderer.Cell { | |
| 128 | + | return c.frameView().Cell(column, c.start+row) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (c *HistoryChunk) cells() int { return c.count * c.width } | |
| 132 | + | ||
| 133 | + | // CheckInvariants validates one compact immutable history slab. | |
| 134 | + | func (c *HistoryChunk) CheckInvariants() error { | |
| 135 | + | if c == nil || c.page == nil { | |
| 136 | + | return fmt.Errorf("nil history chunk backing") | |
| 137 | + | } | |
| 138 | + | frame, err := c.page.readFrame(true) | |
| 139 | + | if err != nil { | |
| 140 | + | return err | |
| 141 | + | } | |
| 142 | + | if c.start < 0 || c.count <= 0 || c.width < 0 || c.start > frame.Height-c.count { | |
| 143 | + | return fmt.Errorf("invalid history chunk range start=%d count=%d", c.start, c.count) | |
| 144 | + | } | |
| 145 | + | if frame.Width != c.width || len(c.bounds) != c.count || len(c.rowIDs) != c.count || len(c.styleDrops) != frame.Height { | |
| 146 | + | return fmt.Errorf("history chunk metadata is not parallel") | |
| 147 | + | } | |
| 148 | + | styles := uint64(1) | |
| 149 | + | for _, dropped := range c.styleDrops[c.start:] { | |
| 150 | + | styles += dropped | |
| 151 | + | } | |
| 152 | + | if c.styleCount != styles { | |
| 153 | + | return fmt.Errorf("history chunk style count = %d want %d", c.styleCount, styles) | |
| 154 | + | } | |
| 155 | + | var payloadBytes uint64 | |
| 156 | + | if len(c.payloadDrops) != 0 { | |
| 157 | + | if len(c.payloadDrops) != frame.Height { | |
| 158 | + | return fmt.Errorf("history payload metadata is not parallel") | |
| 159 | + | } | |
| 160 | + | for _, dropped := range c.payloadDrops[c.start:] { | |
| 161 | + | payloadBytes += dropped | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | if payloadBytes != c.payloadBytes { | |
| 165 | + | return fmt.Errorf("history payload accounting mismatch") | |
| 166 | + | } | |
| 167 | + | if err := frame.CheckInvariants(); err != nil { | |
| 168 | + | return fmt.Errorf("history chunk frame: %w", err) | |
| 169 | + | } | |
| 170 | + | for i, id := range c.rowIDs { | |
| 171 | + | if id == 0 || id >= ^RowID(0)-1 { | |
| 172 | + | return fmt.Errorf("history row %d has invalid ID %d", i, id) | |
| 173 | + | } | |
| 174 | + | if !validHistoryBound(c.bounds[i], c.width) { | |
| 175 | + | return fmt.Errorf("history row %d has invalid bound", i) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | return nil | |
| 47 | 179 | } | |
| 48 | 180 | ||
| 49 | 181 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | |
| 50 | 182 | // storage behind a sealed chunk remains owned by VT. | |
| 51 | 183 | type HistoryView struct { | |
| 52 | - | chunks []*HistoryChunk | |
| 53 | - | rows int | |
| 54 | - | cells int | |
| 55 | - | nextRowID RowID | |
| 184 | + | chunks []*HistoryChunk | |
| 185 | + | rows int | |
| 186 | + | cells int | |
| 187 | + | logicalBytes uint64 | |
| 188 | + | nextRowID RowID | |
| 56 | 189 | } | |
| 57 | 190 | ||
| 58 | 191 | // HistorySnapshotView captures sealed history chunks and the mutable tail | |
| 59 | 192 | // independently. Sealed chunks are shared by identity; Tail is owned by the | |
| 60 | 193 | // view and can be serialized without rotating the live tail into a chunk. | |
| 61 | 194 | type HistorySnapshotView struct { | |
| 62 | - | chunks []*HistoryChunk | |
| 63 | - | tail [][]renderer.Cell | |
| 64 | - | tailBounds []LineBound | |
| 65 | - | tailIDs []RowID | |
| 66 | - | rows int | |
| 67 | - | cells int | |
| 68 | - | nextRowID RowID | |
| 195 | + | chunks []*HistoryChunk | |
| 196 | + | tail HistoryView | |
| 197 | + | rows int | |
| 198 | + | cells int | |
| 199 | + | logicalBytes uint64 | |
| 200 | + | nextRowID RowID | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func historyRowBaseLogicalBytes(width int) uint64 { | |
| 204 | + | return uint64(width)*renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes | |
| 69 | 205 | } | |
| 70 | 206 | ||
| 71 | - | func NewHistory(config HistoryConfig) *History { | |
| 72 | - | if config.MaxRows <= 0 { | |
| 73 | - | return &History{} | |
| 207 | + | func addLogicalBytes(total, value uint64) uint64 { | |
| 208 | + | if value > math.MaxUint64-total { | |
| 209 | + | return math.MaxUint64 | |
| 74 | 210 | } | |
| 75 | - | chunkRows := config.ChunkRows | |
| 76 | - | if chunkRows <= 0 { | |
| 77 | - | chunkRows = 256 | |
| 211 | + | return total + value | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func historyChunkLogicalBytes(chunk *HistoryChunk) uint64 { | |
| 215 | + | if chunk == nil { | |
| 216 | + | return 0 | |
| 78 | 217 | } | |
| 79 | - | chunkRows = min(chunkRows, 256) | |
| 80 | - | chunkRows = min(chunkRows, config.MaxRows) | |
| 81 | - | maxCells := config.MaxCells | |
| 82 | - | if maxCells <= 0 { | |
| 83 | - | maxCells = defaultHistoryMaxCells(config.MaxRows) | |
| 218 | + | return uint64(chunk.len())*historyRowBaseLogicalBytes(chunk.width) + chunk.styleCount*renderer.StyleRecordLogicalBytes + chunk.payloadBytes | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func historyChunksLogicalBytes(chunks []*HistoryChunk) uint64 { | |
| 222 | + | var total uint64 | |
| 223 | + | for _, chunk := range chunks { | |
| 224 | + | total = addLogicalBytes(total, historyChunkLogicalBytes(chunk)) | |
| 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 { | |
| 89 | - | if maxRows > math.MaxInt/160 { | |
| 90 | - | return math.MaxInt | |
| 229 | + | func (h *History) prepareRowStyles(row []renderer.Cell) uint64 { | |
| 230 | + | clear(h.payloadScratch) | |
| 231 | + | if h.styleScratch == nil { | |
| 232 | + | h.styleScratch = make(map[renderer.Style]struct{}) | |
| 233 | + | } else { | |
| 234 | + | clear(h.styleScratch) | |
| 235 | + | } | |
| 236 | + | if len(row) > 0 { | |
| 237 | + | firstRaw := row[0].Style | |
| 238 | + | same := true | |
| 239 | + | for _, cell := range row { | |
| 240 | + | h.recordPayloadScratch(cell.Payload) | |
| 241 | + | if cell.Style != firstRaw { | |
| 242 | + | same = false | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | if same { | |
| 246 | + | first := firstRaw.Canonical() | |
| 247 | + | if first != renderer.DefaultStyle() { | |
| 248 | + | h.styleScratch[first] = struct{}{} | |
| 249 | + | } | |
| 250 | + | } else { | |
| 251 | + | for _, cell := range row { | |
| 252 | + | style := cell.Style.Canonical() | |
| 253 | + | if style != renderer.DefaultStyle() { | |
| 254 | + | h.styleScratch[style] = struct{}{} | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | return historyRowBaseLogicalBytes(len(row)) + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (h *History) prepareChunkRowStyles(chunk *HistoryChunk, row int) uint64 { | |
| 263 | + | clear(h.payloadScratch) | |
| 264 | + | if h.styleScratch == nil { | |
| 265 | + | h.styleScratch = make(map[renderer.Style]struct{}) | |
| 266 | + | } else { | |
| 267 | + | clear(h.styleScratch) | |
| 268 | + | } | |
| 269 | + | for column := range chunk.width { | |
| 270 | + | cell := chunk.cell(row, column) | |
| 271 | + | h.recordPayloadScratch(cell.Payload) | |
| 272 | + | style := cell.Style.Canonical() | |
| 273 | + | if style != renderer.DefaultStyle() { | |
| 274 | + | h.styleScratch[style] = struct{}{} | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | return historyRowBaseLogicalBytes(chunk.width) + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func (h *History) tailAppendDelta(width int) uint64 { | |
| 281 | + | newPage := h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width) | |
| 282 | + | delta := historyRowBaseLogicalBytes(width) | |
| 283 | + | if newPage { | |
| 284 | + | return delta + renderer.StyleRecordLogicalBytes + uint64(len(h.styleScratch))*renderer.StyleRecordLogicalBytes + h.rowPayloadBytes() | |
| 285 | + | } | |
| 286 | + | for style := range h.styleScratch { | |
| 287 | + | if _, ok := h.tailPageStyles[style]; !ok { | |
| 288 | + | delta += renderer.StyleRecordLogicalBytes | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | for p := range h.payloadScratch { | |
| 292 | + | if _, ok := h.tailPagePayloads[p]; !ok { | |
| 293 | + | delta += p.LogicalBytes() | |
| 294 | + | } | |
| 91 | 295 | } | |
| 92 | - | return maxRows * 160 | |
| 296 | + | return delta | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func (h *History) recordTailRowStyles(width int) { | |
| 300 | + | if h.tailPageRows == 0 || h.tailPageWidth != width || h.tailPageRows >= maxHistorySlabRows(width) { | |
| 301 | + | h.tailPageWidth = width | |
| 302 | + | h.tailPageRows = 0 | |
| 303 | + | h.tailPageStyles = map[renderer.Style]struct{}{renderer.DefaultStyle(): {}} | |
| 304 | + | h.tailPagePayloads = nil | |
| 305 | + | } | |
| 306 | + | for p := range h.payloadScratch { | |
| 307 | + | if h.tailPagePayloads == nil { | |
| 308 | + | h.tailPagePayloads = make(map[renderer.CellPayload]struct{}) | |
| 309 | + | } | |
| 310 | + | h.tailPagePayloads[p] = struct{}{} | |
| 311 | + | } | |
| 312 | + | for style := range h.styleScratch { | |
| 313 | + | h.tailPageStyles[style] = struct{}{} | |
| 314 | + | } | |
| 315 | + | h.tailPageRows++ | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func (h *History) rebuildTailAccounting() { | |
| 319 | + | old := h.tailBytes | |
| 320 | + | h.tailBytes = 0 | |
| 321 | + | h.tailPageWidth = 0 | |
| 322 | + | h.tailPageRows = 0 | |
| 323 | + | h.tailPageStyles = nil | |
| 324 | + | h.tailPagePayloads = nil | |
| 325 | + | for _, row := range h.tail { | |
| 326 | + | h.prepareRowStyles(row) | |
| 327 | + | delta := h.tailAppendDelta(len(row)) | |
| 328 | + | h.tailBytes += delta | |
| 329 | + | h.recordTailRowStyles(len(row)) | |
| 330 | + | } | |
| 331 | + | h.logicalBytes = h.logicalBytes - old + h.tailBytes | |
| 93 | 332 | } | |
| 94 | 333 | ||
| 95 | 334 | // Append records a copy of row along with its logical extent and an | |
| 96 | 335 | // automatically allocated nonzero ID. Once a chunk is full it is sealed | |
| 97 | - | // forever. Rows wider than the total cell capacity are rejected without | |
| 336 | + | // forever. Rows larger than the logical-byte capacity are rejected without | |
| 98 | 337 | // mutation. | |
| 99 | 338 | func (h *History) Append(row []renderer.Cell, bound LineBound) error { | |
| 100 | 339 | if h == nil || h.maxRows == 0 { | |
| 101 | 340 | return nil | |
| 102 | 341 | } | |
| 103 | - | if len(row) > h.maxCells { | |
| 104 | - | return ErrHistoryRowTooWide | |
| 342 | + | defer func() { clear(h.payloadScratch) }() | |
| 343 | + | if uint64(len(row)) > uint64(math.MaxUint32) || historyRowBaseLogicalBytes(len(row))+renderer.StyleRecordLogicalBytes > h.maxBytes || h.prepareRowStyles(row) > h.maxBytes { | |
| 344 | + | return ErrHistoryRowTooLarge | |
| 105 | 345 | } | |
| 106 | 346 | id, err := h.allocateRowID() | |
| 107 | 347 | if err != nil { | |
| @@ -119,8 +359,9 @@ func (h *History) AppendWithID(row []renderer.Cell, bound LineBound, id RowID) e | |||
| 119 | 359 | if h == nil || h.maxRows == 0 { | |
| 120 | 360 | return nil | |
| 121 | 361 | } | |
| 122 | - | if len(row) > h.maxCells { | |
| 123 | - | return ErrHistoryRowTooWide | |
| 362 | + | defer func() { clear(h.payloadScratch) }() | |
| 363 | + | if uint64(len(row)) > uint64(math.MaxUint32) || historyRowBaseLogicalBytes(len(row))+renderer.StyleRecordLogicalBytes > h.maxBytes || h.prepareRowStyles(row) > h.maxBytes { | |
| 364 | + | return ErrHistoryRowTooLarge | |
| 124 | 365 | } | |
| 125 | 366 | if h.hasRowID(id) { | |
| 126 | 367 | return errInvalidHistoryRowID | |
| @@ -132,19 +373,20 @@ func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) erro | |||
| 132 | 373 | if h.nextRowID == ^RowID(0) { | |
| 133 | 374 | return errInvalidHistoryRowID | |
| 134 | 375 | } | |
| 135 | - | if len(row) > h.maxCells { | |
| 136 | - | return ErrHistoryRowTooWide | |
| 137 | - | } | |
| 138 | 376 | if id >= ^RowID(0)-1 { | |
| 139 | 377 | return errInvalidHistoryRowID | |
| 140 | 378 | } | |
| 141 | 379 | if id >= h.nextRowID { | |
| 142 | 380 | h.nextRowID = id + 1 | |
| 143 | 381 | } | |
| 144 | - | // Make room before adding so Cells cannot overflow even when the default | |
| 145 | - | // capacity is MaxInt. | |
| 146 | - | h.evictFor(len(row)) | |
| 147 | - | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | |
| 382 | + | // Make room before adding so accounting cannot overflow even when the | |
| 383 | + | // configured capacities use their maximum values. | |
| 384 | + | h.evictFor(row) | |
| 385 | + | delta := h.tailAppendDelta(len(row)) | |
| 386 | + | h.appendTailRow(row) | |
| 387 | + | h.recordTailRowStyles(len(row)) | |
| 388 | + | h.tailBytes += delta | |
| 389 | + | h.logicalBytes += delta | |
| 148 | 390 | h.tailBounds = append(h.tailBounds, clampBound(bound, len(row))) | |
| 149 | 391 | h.tailIDs = append(h.tailIDs, id) | |
| 150 | 392 | h.rows++ | |
| @@ -205,90 +447,115 @@ func growRowIDs(ids []RowID, rows int) []RowID { | |||
| 205 | 447 | return out | |
| 206 | 448 | } | |
| 207 | 449 | ||
| 208 | - | func cloneRowIDs(ids []RowID, rows int) []RowID { | |
| 209 | - | out := make([]RowID, rows) | |
| 210 | - | copy(out, ids) | |
| 211 | - | return out | |
| 450 | + | func (h *History) reserveTailRow(width int) (start, end int) { | |
| 451 | + | h.cachedTail = nil | |
| 452 | + | start = len(h.tailCells) | |
| 453 | + | end = start + width | |
| 454 | + | if end > cap(h.tailCells) { | |
| 455 | + | capacity := end | |
| 456 | + | if cap(h.tailCells) <= (math.MaxInt-1)/2 { | |
| 457 | + | capacity = max(capacity, cap(h.tailCells)*2) | |
| 458 | + | } | |
| 459 | + | if len(h.tailCells) == 0 { | |
| 460 | + | prealloc := uint64(maxTailPreallocCells) | |
| 461 | + | prealloc = min(prealloc, h.maxBytes/renderer.StoredCellLogicalBytes, uint64(width)*uint64(h.chunkRows)) | |
| 462 | + | capacity = max(capacity, max(width, int(prealloc))) | |
| 463 | + | } | |
| 464 | + | cells := make([]renderer.Cell, len(h.tailCells), capacity) | |
| 465 | + | copy(cells, h.tailCells) | |
| 466 | + | h.tailCells = cells | |
| 467 | + | offset := 0 | |
| 468 | + | for i := range h.tail { | |
| 469 | + | rowWidth := len(h.tail[i]) | |
| 470 | + | h.tail[i] = h.tailCells[offset : offset+rowWidth : offset+rowWidth] | |
| 471 | + | offset += rowWidth | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | h.tailCells = h.tailCells[:end] | |
| 475 | + | return start, end | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | func (h *History) appendTailRow(row []renderer.Cell) { | |
| 479 | + | start, end := h.reserveTailRow(len(row)) | |
| 480 | + | copy(h.tailCells[start:end], row) | |
| 481 | + | h.tail = append(h.tail, h.tailCells[start:end:end]) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | func (h *History) appendTailChunkRow(chunk *HistoryChunk, row int) { | |
| 485 | + | start, end := h.reserveTailRow(chunk.width) | |
| 486 | + | for x := range chunk.width { | |
| 487 | + | h.tailCells[start+x] = chunk.cell(row, x) | |
| 488 | + | } | |
| 489 | + | h.tail = append(h.tail, h.tailCells[start:end:end]) | |
| 212 | 490 | } | |
| 213 | 491 | ||
| 214 | 492 | func (h *History) sealTail() { | |
| 215 | 493 | if len(h.tail) == 0 { | |
| 216 | 494 | return | |
| 217 | 495 | } | |
| 218 | - | h.chunks = append(h.chunks, &HistoryChunk{ | |
| 219 | - | rows: h.tail, | |
| 220 | - | bounds: growBounds(h.tailBounds, len(h.tail)), | |
| 221 | - | rowIDs: h.tailIDs, | |
| 222 | - | }) | |
| 496 | + | chunks := h.tailViewChunks() | |
| 497 | + | h.chunks = append(h.chunks, chunks...) | |
| 498 | + | h.logicalBytes = h.logicalBytes - h.tailBytes + historyChunksLogicalBytes(chunks) | |
| 223 | 499 | h.tail = nil | |
| 500 | + | h.cachedTail = nil | |
| 501 | + | h.tailCells = nil | |
| 224 | 502 | h.tailBounds = nil | |
| 225 | 503 | h.tailIDs = nil | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | // normalizeTail seals complete chunks so the mutable tail remains shorter than | |
| 229 | - | // chunkRows. Restored snapshots may have been written with a different chunk | |
| 230 | - | // size than the current history configuration. | |
| 231 | - | func (h *History) normalizeTail() { | |
| 232 | - | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)) | |
| 233 | - | for h.chunkRows > 0 && len(h.tail) >= h.chunkRows { | |
| 234 | - | bounds := growBounds(h.tailBounds, len(h.tail)) | |
| 235 | - | h.chunks = append(h.chunks, &HistoryChunk{ | |
| 236 | - | rows: h.tail[:h.chunkRows], | |
| 237 | - | bounds: bounds[:h.chunkRows], | |
| 238 | - | rowIDs: h.tailIDs[:h.chunkRows], | |
| 239 | - | }) | |
| 240 | - | h.tail = h.tail[h.chunkRows:] | |
| 241 | - | h.tailBounds = bounds[h.chunkRows:] | |
| 242 | - | h.tailIDs = h.tailIDs[h.chunkRows:] | |
| 243 | - | } | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | func (h *History) evict() { | |
| 247 | - | h.evictUntil(0, 0) | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | // evictFor discards oldest rows until another row using cellCount cells can fit. | |
| 251 | - | func (h *History) evictFor(cellCount int) { | |
| 252 | - | h.evictUntil(1, cellCount) | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | // evictUntil makes room for rowCount rows and cellCount cells without | |
| 256 | - | // overflowing either accounting total. | |
| 257 | - | func (h *History) evictUntil(rowCount, cellCount int) { | |
| 258 | - | for h.rows > h.maxRows-rowCount || h.cells > h.maxCells-cellCount { | |
| 259 | - | if len(h.chunks) > 0 { | |
| 260 | - | chunk := h.chunks[0] | |
| 261 | - | row := chunk.rows[0] | |
| 262 | - | h.rows-- | |
| 263 | - | h.cells -= len(row) | |
| 264 | - | if len(chunk.rows) == 1 { | |
| 265 | - | copy(h.chunks, h.chunks[1:]) | |
| 266 | - | h.chunks[len(h.chunks)-1] = nil | |
| 267 | - | h.chunks = h.chunks[:len(h.chunks)-1] | |
| 268 | - | } else { | |
| 269 | - | // Preserve cell storage while replacing only the chunk wrapper: a | |
| 270 | - | // retained view may still refer to the original immutable chunk. | |
| 271 | - | bounds := growBounds(chunk.bounds, len(chunk.rows)) | |
| 272 | - | rowIDs := growRowIDs(chunk.rowIDs, len(chunk.rows)) | |
| 273 | - | h.chunks[0] = &HistoryChunk{ | |
| 274 | - | rows: chunk.rows[1:], | |
| 275 | - | bounds: bounds[1:], | |
| 276 | - | rowIDs: rowIDs[1:], | |
| 277 | - | } | |
| 278 | - | } | |
| 279 | - | continue | |
| 280 | - | } | |
| 281 | - | if len(h.tail) == 0 { | |
| 504 | + | h.tailBytes = 0 | |
| 505 | + | h.tailPageWidth = 0 | |
| 506 | + | h.tailPageRows = 0 | |
| 507 | + | h.tailPageStyles = nil | |
| 508 | + | h.tailPagePayloads = nil | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | // evictFor discards oldest rows until row can fit both retention budgets. | |
| 512 | + | func (h *History) evictFor(row []renderer.Cell) { | |
| 513 | + | for { | |
| 514 | + | delta := h.tailAppendDelta(len(row)) | |
| 515 | + | if h.rows <= h.maxRows-1 && h.logicalBytes <= h.maxBytes-delta { | |
| 282 | 516 | 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 | 530 | h.rows-- | |
| 286 | - | h.cells -= len(row) | |
| 287 | - | h.tail[0] = nil | |
| 288 | - | h.tail = h.tail[1:] | |
| 289 | - | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:] | |
| 290 | - | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:] | |
| 531 | + | h.cells -= chunk.width | |
| 532 | + | if chunk.count == 1 { | |
| 533 | + | h.logicalBytes -= oldBytes | |
| 534 | + | copy(h.chunks, h.chunks[1:]) | |
| 535 | + | h.chunks[len(h.chunks)-1] = nil | |
| 536 | + | h.chunks = h.chunks[:len(h.chunks)-1] | |
| 537 | + | } else { | |
| 538 | + | // Preserve cell storage while replacing only the chunk wrapper: a | |
| 539 | + | // retained view may still refer to the original immutable chunk. The | |
| 540 | + | // wrapper tracks only styles still used by its retained suffix. | |
| 541 | + | h.chunks[0] = chunk.withoutFirstRow() | |
| 542 | + | h.logicalBytes -= oldBytes - historyChunkLogicalBytes(h.chunks[0]) | |
| 543 | + | } | |
| 544 | + | return | |
| 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 | 561 | // SealAndView rotates the mutable tail into an immutable chunk, then captures | |
| @@ -300,8 +567,15 @@ func (h *History) SealAndView() HistoryView { | |||
| 300 | 567 | return h.View() | |
| 301 | 568 | } | |
| 302 | 569 | ||
| 303 | - | // View captures the current history. Sealed chunks are shared by identity; a | |
| 304 | - | // partially-filled tail is copied into a new immutable chunk for this view. | |
| 570 | + | func (h *History) tailViewChunks() []*HistoryChunk { | |
| 571 | + | if h.cachedTail == nil && len(h.tail) > 0 { | |
| 572 | + | h.cachedTail = newHistoryChunks(h.tail, h.tailBounds, h.tailIDs) | |
| 573 | + | } | |
| 574 | + | return h.cachedTail | |
| 575 | + | } | |
| 576 | + | ||
| 577 | + | // View captures the current history. Sealed chunks are shared by identity; | |
| 578 | + | // the immutable tail copy is reused until the mutable tail changes. | |
| 305 | 579 | func (h *History) View() HistoryView { | |
| 306 | 580 | if h == nil { | |
| 307 | 581 | return HistoryView{} | |
| @@ -312,13 +586,9 @@ func (h *History) View() HistoryView { | |||
| 312 | 586 | chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1) | |
| 313 | 587 | copy(chunks, h.chunks) | |
| 314 | 588 | if len(h.tail) > 0 { | |
| 315 | - | chunks = append(chunks, &HistoryChunk{ | |
| 316 | - | rows: cloneHistoryRows(h.tail), | |
| 317 | - | bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |
| 318 | - | rowIDs: cloneRowIDs(h.tailIDs, len(h.tail)), | |
| 319 | - | }) | |
| 589 | + | chunks = append(chunks, h.tailViewChunks()...) | |
| 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 | 594 | // SnapshotView captures history for persistence without sealing the mutable | |
| @@ -330,28 +600,23 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||
| 330 | 600 | if h.rows == 0 { | |
| 331 | 601 | return HistorySnapshotView{nextRowID: h.nextRowID} | |
| 332 | 602 | } | |
| 603 | + | tailChunks := newHistoryChunks(h.tail, h.tailBounds, h.tailIDs) | |
| 333 | 604 | return HistorySnapshotView{ | |
| 334 | - | chunks: append([]*HistoryChunk(nil), h.chunks...), | |
| 335 | - | tail: cloneHistoryRows(h.tail), | |
| 336 | - | tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |
| 337 | - | tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)), | |
| 338 | - | rows: h.rows, | |
| 339 | - | cells: h.cells, | |
| 340 | - | nextRowID: h.nextRowID, | |
| 605 | + | chunks: append([]*HistoryChunk(nil), h.chunks...), | |
| 606 | + | tail: HistoryView{ | |
| 607 | + | chunks: tailChunks, | |
| 608 | + | rows: len(h.tail), | |
| 609 | + | cells: historyRowsCells(h.tail), | |
| 610 | + | logicalBytes: historyChunksLogicalBytes(tailChunks), | |
| 611 | + | nextRowID: h.nextRowID, | |
| 612 | + | }, | |
| 613 | + | rows: h.rows, | |
| 614 | + | cells: h.cells, | |
| 615 | + | logicalBytes: h.logicalBytes, | |
| 616 | + | nextRowID: h.nextRowID, | |
| 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 | 620 | // Len returns the currently retained history row count. | |
| 356 | 621 | func (h *History) Len() int { | |
| 357 | 622 | if h == nil { | |
| @@ -368,6 +633,15 @@ func (h *History) Cells() int { | |||
| 368 | 633 | 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 | 645 | // NextRowID returns the next identity allocated by this history. | |
| 372 | 646 | func (h *History) NextRowID() RowID { | |
| 373 | 647 | if h == nil || h.nextRowID == 0 { | |
| @@ -376,24 +650,32 @@ func (h *History) NextRowID() RowID { | |||
| 376 | 650 | 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 | 655 | func (h *History) Cap() int { | |
| 381 | 656 | if h == nil { | |
| 382 | 657 | return 0 | |
| 383 | 658 | } | |
| 384 | - | return h.maxRows | |
| 659 | + | return h.rowCeiling | |
| 385 | 660 | } | |
| 386 | 661 | ||
| 387 | - | // CellCap returns the configured bounded cell capacity. | |
| 388 | - | func (h *History) CellCap() int { | |
| 662 | + | // ByteCap returns the configured uncompressed byte ceiling. Zero means no | |
| 663 | + | // independent byte ceiling, or disabled history when Cap is also zero. | |
| 664 | + | func (h *History) ByteCap() uint64 { | |
| 389 | 665 | if h == nil { | |
| 390 | 666 | return 0 | |
| 391 | 667 | } | |
| 392 | - | return h.maxCells | |
| 668 | + | return h.byteCeiling | |
| 393 | 669 | } | |
| 394 | 670 | ||
| 395 | - | func (v HistoryView) Len() int { return v.rows } | |
| 396 | - | func (v HistoryView) Cells() int { return v.cells } | |
| 671 | + | func (v HistoryView) Len() int { return v.rows } | |
| 672 | + | func (v HistoryView) Cells() int { return v.cells } | |
| 673 | + | func (v HistoryView) LogicalBytes() uint64 { | |
| 674 | + | if v.logicalBytes != 0 || len(v.chunks) == 0 { | |
| 675 | + | return v.logicalBytes | |
| 676 | + | } | |
| 677 | + | return historyChunksLogicalBytes(v.chunks) | |
| 678 | + | } | |
| 397 | 679 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | |
| 398 | 680 | ||
| 399 | 681 | // NextRowID returns the next identity recorded by this view. | |
| @@ -416,37 +698,32 @@ func (v HistoryView) Chunk(i int) *HistoryChunk { | |||
| 416 | 698 | return v.chunks[i] | |
| 417 | 699 | } | |
| 418 | 700 | ||
| 419 | - | // Range calls yield for each row in oldest-first order. The row is borrowed | |
| 420 | - | // immutable storage: yield must not mutate or retain it after returning. | |
| 421 | - | func (v HistoryView) Range(yield func([]renderer.Cell) bool) { | |
| 701 | + | // Range calls yield with a decoded owned row in oldest-first order. Returning | |
| 702 | + | // false stops iteration successfully; a backing restore failure returns an error. | |
| 703 | + | func (v HistoryView) Range(yield func([]renderer.Cell) bool) error { | |
| 422 | 704 | for _, chunk := range v.chunks { | |
| 423 | - | for _, row := range chunk.rows { | |
| 424 | - | if !yield(row) { | |
| 425 | - | return | |
| 705 | + | // Streaming search restores at most one transient page at a time and | |
| 706 | + | // does not populate the random-access cache of every cold page. | |
| 707 | + | frame, err := chunk.page.readFrame(false) | |
| 708 | + | if err != nil { | |
| 709 | + | return err | |
| 710 | + | } | |
| 711 | + | for i := range chunk.len() { | |
| 712 | + | if !yield(frame.Row(chunk.start + i)) { | |
| 713 | + | return nil | |
| 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 | 721 | func (v HistoryView) Row(i int) []renderer.Cell { | |
| 433 | - | return append([]renderer.Cell(nil), v.BorrowedRow(i)...) | |
| 434 | - | } | |
| 435 | - | ||
| 436 | - | // BorrowedRow returns immutable storage for the row at i, or nil when i is out | |
| 437 | - | // of range. The caller must not mutate or retain the result after it no longer | |
| 438 | - | // retains v. Consumers that need ownership should use Row instead. | |
| 439 | - | func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |
| 440 | - | if i < 0 { | |
| 722 | + | chunk, row := v.locateRow(i) | |
| 723 | + | if chunk == nil { | |
| 441 | 724 | return nil | |
| 442 | 725 | } | |
| 443 | - | for _, chunk := range v.chunks { | |
| 444 | - | if i < len(chunk.rows) { | |
| 445 | - | return chunk.rows[i] | |
| 446 | - | } | |
| 447 | - | i -= len(chunk.rows) | |
| 448 | - | } | |
| 449 | - | return nil | |
| 726 | + | return chunk.row(row) | |
| 450 | 727 | } | |
| 451 | 728 | ||
| 452 | 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 | 732 | return 0 | |
| 456 | 733 | } | |
| 457 | 734 | for _, chunk := range v.chunks { | |
| 458 | - | if i < len(chunk.rows) { | |
| 735 | + | if i < chunk.len() { | |
| 459 | 736 | if i < len(chunk.rowIDs) { | |
| 460 | 737 | return chunk.rowIDs[i] | |
| 461 | 738 | } | |
| 462 | 739 | return 0 | |
| 463 | 740 | } | |
| 464 | - | i -= len(chunk.rows) | |
| 741 | + | i -= chunk.len() | |
| 465 | 742 | } | |
| 466 | 743 | return 0 | |
| 467 | 744 | } | |
| @@ -473,7 +750,7 @@ func (v HistoryView) FindRowID(id RowID) int { | |||
| 473 | 750 | } | |
| 474 | 751 | index := 0 | |
| 475 | 752 | for _, chunk := range v.chunks { | |
| 476 | - | for row := range chunk.rows { | |
| 753 | + | for row := range chunk.len() { | |
| 477 | 754 | if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id { | |
| 478 | 755 | return index | |
| 479 | 756 | } | |
| @@ -498,19 +775,25 @@ func (v HistoryView) Bound(i int) LineBound { | |||
| 498 | 775 | return LineBound{} | |
| 499 | 776 | } | |
| 500 | 777 | for _, chunk := range v.chunks { | |
| 501 | - | if i < len(chunk.rows) { | |
| 778 | + | if i < chunk.len() { | |
| 502 | 779 | if i < len(chunk.bounds) { | |
| 503 | 780 | return chunk.bounds[i] | |
| 504 | 781 | } | |
| 505 | 782 | return LineBound{} | |
| 506 | 783 | } | |
| 507 | - | i -= len(chunk.rows) | |
| 784 | + | i -= chunk.len() | |
| 508 | 785 | } | |
| 509 | 786 | return LineBound{} | |
| 510 | 787 | } | |
| 511 | 788 | ||
| 512 | - | func (v HistorySnapshotView) Len() int { return v.rows } | |
| 513 | - | func (v HistorySnapshotView) Cells() int { return v.cells } | |
| 789 | + | func (v HistorySnapshotView) Len() int { return v.rows } | |
| 790 | + | func (v HistorySnapshotView) Cells() int { return v.cells } | |
| 791 | + | func (v HistorySnapshotView) LogicalBytes() uint64 { | |
| 792 | + | if v.logicalBytes != 0 || v.rows == 0 { | |
| 793 | + | return v.logicalBytes | |
| 794 | + | } | |
| 795 | + | return addLogicalBytes(historyChunksLogicalBytes(v.chunks), v.tail.LogicalBytes()) | |
| 796 | + | } | |
| 514 | 797 | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | |
| 515 | 798 | ||
| 516 | 799 | // NextRowID returns the next identity recorded by this snapshot. | |
| @@ -531,15 +814,9 @@ func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | |||
| 531 | 814 | ||
| 532 | 815 | // Tail returns an immutable view of the copied mutable tail. | |
| 533 | 816 | func (v HistorySnapshotView) Tail() HistoryView { | |
| 534 | - | if len(v.tail) == 0 { | |
| 535 | - | return HistoryView{nextRowID: v.nextRowID} | |
| 536 | - | } | |
| 537 | - | return HistoryView{ | |
| 538 | - | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}}, | |
| 539 | - | rows: len(v.tail), | |
| 540 | - | cells: historyRowsCells(v.tail), | |
| 541 | - | nextRowID: v.nextRowID, | |
| 542 | - | } | |
| 817 | + | tail := v.tail | |
| 818 | + | tail.nextRowID = v.nextRowID | |
| 819 | + | return tail | |
| 543 | 820 | } | |
| 544 | 821 | ||
| 545 | 822 | func historyRowsCells(rows [][]renderer.Cell) int { | |
M history_acceptance_test.go +1 -1
| @@ -84,7 +84,7 @@ func TestSnapshotCodecsPreserveEveryTerminalCellAcrossHistoryAndRecoveryTranscri | |||
| 84 | 84 | screen := NewScreen(4, 2) | |
| 85 | 85 | want := [][]renderer.Cell{makeRow('D', rgb), makeRow('E', indexed)} | |
| 86 | 86 | for y, row := range want { | |
| 87 | - | copy(screen.Frame.Row(y), row) | |
| 87 | + | screen.frame.WriteRow(y, 0, row) | |
| 88 | 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 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "math" | |
| 8 | - | "slices" | |
| 9 | - | "unicode/utf8" | |
| 10 | 8 | ||
| 11 | 9 | renderer "github.com/bnema/vev-vt/core" | |
| 12 | 10 | ) | |
| 13 | 11 | ||
| 14 | 12 | const ( | |
| 15 | - | historyMagic = "VTH1" | |
| 16 | - | historyVersion = 3 | |
| 17 | - | historyCellBytes = 41 | |
| 18 | - | historyBoundBytes = 5 | |
| 19 | - | maxHistoryChunkRows = 256 | |
| 20 | - | ||
| 21 | - | // The daemon retains 10,000 history rows. Support a 20% row margin and | |
| 22 | - | // 160-cell rows, which leaves headroom above the representative 10k×120 | |
| 23 | - | // terminal while bounding snapshot allocation. | |
| 24 | - | maxHistoryChunks = 12_000 | |
| 25 | - | maxHistoryRows = 12_000 | |
| 26 | - | maxHistoryRowCells = 160 | |
| 27 | - | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | |
| 28 | - | ||
| 29 | - | maxHistoryDecodeStyles = maxHistoryCells | |
| 30 | - | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |
| 13 | + | historyMagic = "VTC1" | |
| 14 | + | historyHeaderBytes = 16 | |
| 15 | + | historyChunkHeaderBytes = 16 | |
| 16 | + | historyStoredCellBytes = 9 | |
| 17 | + | historyStyleBytes = 37 | |
| 18 | + | historyRowBytes = 13 | |
| 19 | + | historyBoundBytes = 5 | |
| 20 | + | maxHistoryChunkRows = 256 | |
| 21 | + | ||
| 22 | + | // Aggregate resource ceilings allow wide rows without tying persistence to | |
| 23 | + | // a particular terminal geometry. They apply before compact allocation. | |
| 24 | + | maxHistoryChunks = 12_000 | |
| 25 | + | maxHistoryRows = 12_000 | |
| 26 | + | maxHistoryRowCells = 160 | |
| 27 | + | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | |
| 28 | + | maxHistoryStylesPerChunk = maxHistoryCells + 1 | |
| 29 | + | maxHistoryDecodeStyles = maxHistoryCells + maxHistoryChunks | |
| 30 | + | maxHistoryPayloadBytes = 64 << 20 | |
| 31 | + | maxHistoryDecodedBytes = maxHistoryCells*renderer.StoredCellLogicalBytes + | |
| 32 | + | maxHistoryRows*renderer.RowDescriptorLogicalBytes + | |
| 33 | + | maxHistoryDecodeStyles*renderer.StyleRecordLogicalBytes + maxHistoryPayloadBytes | |
| 31 | 34 | ) | |
| 32 | 35 | ||
| 33 | 36 | var errInvalidHistory = errors.New("invalid history payload") | |
| 34 | 37 | ||
| 35 | - | // MarshalHistory encodes a HistoryView in a deterministic, self-contained | |
| 36 | - | // format. It preserves chunk boundaries as well as every Cell and Style field. | |
| 38 | + | type historyDecodeStats struct { | |
| 39 | + | chunks, rows, cells, styles, bytes uint64 | |
| 40 | + | payloadBytes uint64 | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // MarshalHistory serializes semantic cells with canonical chunk-local style | |
| 44 | + | // dictionaries. Internal style IDs and page memory never cross this boundary. | |
| 37 | 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 | 47 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 40 | 48 | } | |
| 41 | - | nextRowID, ok := historyViewNextRowID(view) | |
| 49 | + | nextID, ok := historyViewNextRowID(view) | |
| 42 | 50 | if !ok { | |
| 43 | 51 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 44 | 52 | } | |
| 45 | - | stats := historyDecodeStats{} | |
| 46 | - | seen := make(map[RowID]struct{}, view.rows) | |
| 53 | + | type encodedChunk struct { | |
| 54 | + | chunk *HistoryChunk | |
| 55 | + | styles []renderer.Style | |
| 56 | + | ids map[renderer.Style]uint32 | |
| 57 | + | payloads []renderer.CellPayload | |
| 58 | + | payloadIDs map[renderer.CellPayload]uint32 | |
| 59 | + | } | |
| 60 | + | encoded := make([]encodedChunk, 0, len(view.chunks)) | |
| 61 | + | size := uint64(historyHeaderBytes) | |
| 62 | + | stats := historyDecodeStats{chunks: uint64(len(view.chunks))} | |
| 47 | 63 | for _, chunk := range view.chunks { | |
| 48 | - | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) > maxHistoryChunkRows || | |
| 49 | - | !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) { | |
| 64 | + | if chunk == nil || chunk.page == nil || chunk.len() == 0 || chunk.len() > maxHistoryChunkRows || | |
| 65 | + | chunk.len() != len(chunk.rowIDs) || chunk.len() != len(chunk.bounds) || chunk.width < 0 || | |
| 66 | + | uint64(chunk.width) > math.MaxUint32 { | |
| 50 | 67 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 51 | 68 | } | |
| 52 | - | for i, row := range chunk.rows { | |
| 53 | - | if uint64(len(row)) > math.MaxUint32 { | |
| 54 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 55 | - | } | |
| 56 | - | id := chunk.rowIDs[i] | |
| 57 | - | if id == 0 || id >= ^RowID(0)-1 { | |
| 58 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 59 | - | } | |
| 60 | - | if _, duplicate := seen[id]; duplicate { | |
| 61 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 62 | - | } | |
| 63 | - | seen[id] = struct{}{} | |
| 64 | - | cellCount := uint64(len(row)) | |
| 65 | - | cellBytes, ok := historyCellByteCount(cellCount) | |
| 66 | - | if !ok || | |
| 67 | - | !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) || | |
| 68 | - | !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) || | |
| 69 | - | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | |
| 70 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 71 | - | } | |
| 72 | - | bound := chunkBound(chunk, i) | |
| 73 | - | if !validHistoryBound(bound, len(row)) { | |
| 69 | + | cells := uint64(chunk.width) * uint64(chunk.len()) | |
| 70 | + | if !addHistoryDecodeBudget(&stats.rows, uint64(chunk.len()), maxHistoryRows) || | |
| 71 | + | !addHistoryDecodeBudget(&stats.cells, cells, maxHistoryCells) { | |
| 72 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 73 | + | } | |
| 74 | + | frame, err := chunk.page.readFrame(false) | |
| 75 | + | if err != nil { | |
| 76 | + | return nil, fmt.Errorf("marshal history: %w", err) | |
| 77 | + | } | |
| 78 | + | if frame.Width != chunk.width || chunk.start < 0 || chunk.start > frame.Height-chunk.count { | |
| 79 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 80 | + | } | |
| 81 | + | styles := []renderer.Style{renderer.DefaultStyle()} | |
| 82 | + | ids := map[renderer.Style]uint32{renderer.DefaultStyle(): 0} | |
| 83 | + | var payloads []renderer.CellPayload | |
| 84 | + | var payloadIDs map[renderer.CellPayload]uint32 | |
| 85 | + | var payloadWireBytes uint64 | |
| 86 | + | for row := range chunk.len() { | |
| 87 | + | if !validHistoryBound(chunk.bounds[row], chunk.width) { | |
| 74 | 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 | 92 | if !validHistoryCell(cell) { | |
| 78 | 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) { | |
| 84 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | out := make([]byte, 0, 17) | |
| 124 | + | // Aggregate budgets bound size well below MaxInt even on 32-bit hosts. | |
| 125 | + | out := make([]byte, 0, int(size)) | |
| 88 | 126 | out = append(out, historyMagic...) | |
| 89 | - | out = append(out, historyVersion) | |
| 90 | - | out = appendUint32(out, uint32(len(view.chunks))) | |
| 91 | - | out = binary.BigEndian.AppendUint64(out, uint64(nextRowID)) | |
| 92 | - | for _, chunk := range view.chunks { | |
| 93 | - | out = appendUint32(out, uint32(len(chunk.rows))) | |
| 94 | - | for i, row := range chunk.rows { | |
| 95 | - | out = binary.BigEndian.AppendUint64(out, uint64(chunk.rowIDs[i])) | |
| 96 | - | out = appendUint32(out, uint32(len(row))) | |
| 97 | - | for _, cell := range row { | |
| 98 | - | out = appendHistoryCell(out, cell) | |
| 127 | + | out = binary.BigEndian.AppendUint32(out, uint32(len(encoded))) | |
| 128 | + | out = binary.BigEndian.AppendUint64(out, uint64(nextID)) | |
| 129 | + | for _, entry := range encoded { | |
| 130 | + | chunk, styles, ids := entry.chunk, entry.styles, entry.ids | |
| 131 | + | frame, err := chunk.page.readFrame(false) | |
| 132 | + | if err != nil { | |
| 133 | + | return nil, fmt.Errorf("marshal history: %w", err) | |
| 134 | + | } | |
| 135 | + | out = binary.BigEndian.AppendUint32(out, uint32(chunk.width)) | |
| 136 | + | out = binary.BigEndian.AppendUint32(out, uint32(chunk.len())) | |
| 137 | + | out = binary.BigEndian.AppendUint32(out, uint32(len(styles))) | |
| 138 | + | out = binary.BigEndian.AppendUint32(out, uint32(len(entry.payloads))) | |
| 139 | + | for _, style := range styles[1:] { | |
| 140 | + | out = appendHistoryStyle(out, style) | |
| 141 | + | } | |
| 142 | + | for _, p := range entry.payloads { | |
| 143 | + | out = appendHistoryPayload(out, p) | |
| 144 | + | } | |
| 145 | + | for row, id := range chunk.rowIDs { | |
| 146 | + | out = binary.BigEndian.AppendUint64(out, uint64(id)) | |
| 147 | + | out = appendHistoryBound(out, chunk.bounds[row]) | |
| 148 | + | } | |
| 149 | + | for row := range chunk.len() { | |
| 150 | + | for x := range chunk.width { | |
| 151 | + | cell := frame.Cell(x, chunk.start+row) | |
| 152 | + | out = binary.BigEndian.AppendUint32(out, uint32(cell.Rune)) | |
| 153 | + | out = binary.BigEndian.AppendUint32(out, ids[cell.Style.Canonical()]) | |
| 154 | + | flag := byte(0) | |
| 155 | + | if cell.Continuation { | |
| 156 | + | flag = 1 | |
| 157 | + | } | |
| 158 | + | if !cell.Payload.Empty() { | |
| 159 | + | flag |= 2 | |
| 160 | + | } | |
| 161 | + | out = append(out, flag) | |
| 162 | + | if !cell.Payload.Empty() { | |
| 163 | + | out = binary.BigEndian.AppendUint32(out, entry.payloadIDs[cell.Payload]) | |
| 164 | + | } | |
| 99 | 165 | } | |
| 100 | - | out = appendHistoryBound(out, chunkBound(chunk, i)) | |
| 101 | 166 | } | |
| 102 | 167 | } | |
| 103 | 168 | 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 | 182 | func historyViewNextRowID(view HistoryView) (RowID, bool) { | |
| 107 | 183 | seen := make(map[RowID]struct{}, view.rows) | |
| 184 | + | var maxID RowID | |
| 108 | 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 | 187 | return 0, false | |
| 111 | 188 | } | |
| 112 | 189 | for _, id := range chunk.rowIDs { | |
| @@ -117,110 +194,22 @@ func historyViewNextRowID(view HistoryView) (RowID, bool) { | |||
| 117 | 194 | return 0, false | |
| 118 | 195 | } | |
| 119 | 196 | seen[id] = struct{}{} | |
| 197 | + | maxID = max(maxID, id) | |
| 120 | 198 | } | |
| 121 | 199 | } | |
| 122 | 200 | next := view.nextRowID | |
| 123 | 201 | if next == 0 { | |
| 124 | - | maxID := maxHistoryRowID(seen) | |
| 125 | - | if maxID == ^RowID(0) { | |
| 126 | - | return 0, false | |
| 127 | - | } | |
| 128 | 202 | next = maxID + 1 | |
| 129 | 203 | } | |
| 130 | - | if next == 0 || next == ^RowID(0) || next <= maxHistoryRowID(seen) { | |
| 131 | - | return 0, false | |
| 132 | - | } | |
| 133 | - | return next, true | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | func maxHistoryRowID(ids map[RowID]struct{}) RowID { | |
| 137 | - | var maxID RowID | |
| 138 | - | for id := range ids { | |
| 139 | - | if id > maxID { | |
| 140 | - | maxID = id | |
| 141 | - | } | |
| 142 | - | } | |
| 143 | - | return maxID | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | func historyViewRowCount(view HistoryView) int { | |
| 147 | - | n := 0 | |
| 148 | - | for _, chunk := range view.chunks { | |
| 149 | - | if chunk != nil && len(chunk.rows) <= math.MaxInt-n { | |
| 150 | - | n += len(chunk.rows) | |
| 151 | - | continue | |
| 152 | - | } | |
| 153 | - | return -1 | |
| 154 | - | } | |
| 155 | - | return n | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | func appendUint32(dst []byte, v uint32) []byte { | |
| 159 | - | return binary.BigEndian.AppendUint32(dst, v) | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | func appendHistoryCell(dst []byte, cell renderer.Cell) []byte { | |
| 163 | - | style := cell.Style | |
| 164 | - | dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune)) | |
| 165 | - | flags := byte(0) | |
| 166 | - | if cell.Continuation { | |
| 167 | - | flags |= 1 << 0 | |
| 168 | - | } | |
| 169 | - | if style.Bold { | |
| 170 | - | flags |= 1 << 1 | |
| 171 | - | } | |
| 172 | - | if style.Italic { | |
| 173 | - | flags |= 1 << 2 | |
| 174 | - | } | |
| 175 | - | if style.Inverse { | |
| 176 | - | flags |= 1 << 3 | |
| 177 | - | } | |
| 178 | - | if style.HasForegroundRGB { | |
| 179 | - | flags |= 1 << 4 | |
| 180 | - | } | |
| 181 | - | if style.HasBackgroundRGB { | |
| 182 | - | flags |= 1 << 5 | |
| 183 | - | } | |
| 184 | - | if style.HasUnderlineColor { | |
| 185 | - | flags |= 1 << 6 | |
| 186 | - | } | |
| 187 | - | if style.HasUnderlineColorRGB { | |
| 188 | - | flags |= 1 << 7 | |
| 189 | - | } | |
| 190 | - | dst = append(dst, flags) | |
| 191 | - | dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs)) | |
| 192 | - | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground))) | |
| 193 | - | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background))) | |
| 194 | - | dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B) | |
| 195 | - | dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B) | |
| 196 | - | dst = append(dst, byte(style.UnderlineStyle)) | |
| 197 | - | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor))) | |
| 198 | - | return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B) | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | func chunkBound(chunk *HistoryChunk, i int) LineBound { | |
| 202 | - | if i < 0 || i >= len(chunk.bounds) { | |
| 203 | - | return LineBound{} | |
| 204 | - | } | |
| 205 | - | return chunk.bounds[i] | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | func appendHistoryBound(dst []byte, b LineBound) []byte { | |
| 209 | - | dst = binary.BigEndian.AppendUint32(dst, uint32(b.End)) | |
| 210 | - | if b.Soft { | |
| 211 | - | return append(dst, 1) | |
| 212 | - | } | |
| 213 | - | return append(dst, 0) | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | // validHistoryBound rejects extents that cannot describe their own row. | |
| 217 | - | func validHistoryBound(b LineBound, cells int) bool { | |
| 218 | - | return b.End >= 0 && b.End <= cells | |
| 204 | + | return next, next > maxID && next < ^RowID(0) | |
| 219 | 205 | } | |
| 220 | 206 | ||
| 221 | - | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | |
| 222 | - | // malformed declarations, cells, truncated data, and trailing bytes. | |
| 207 | + | // UnmarshalHistory accepts only VTC1. Validation precedes all decoded frame | |
| 208 | + | // allocations, including validation of later chunks in the same payload. | |
| 223 | 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 | 213 | view, _, ok := parseHistory(data, true) | |
| 225 | 214 | if !ok { | |
| 226 | 215 | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |
| @@ -228,127 +217,139 @@ func UnmarshalHistory(data []byte) (HistoryView, error) { | |||
| 228 | 217 | 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 | 220 | func preflightHistory(data []byte) (historyDecodeStats, bool) { | |
| 243 | 221 | _, stats, ok := parseHistory(data, false) | |
| 244 | 222 | 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 | 225 | func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) { | |
| 250 | - | if len(data) < 17 || string(data[:4]) != historyMagic || data[4] != historyVersion { | |
| 251 | - | return HistoryView{}, historyDecodeStats{}, false | |
| 252 | - | } | |
| 253 | - | ||
| 254 | - | p := historyParser{data: data[5:]} | |
| 255 | - | chunkCount, ok := p.uint32() | |
| 256 | - | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | |
| 257 | - | return HistoryView{}, historyDecodeStats{}, false | |
| 226 | + | invalid := func() (HistoryView, historyDecodeStats, bool) { return HistoryView{}, historyDecodeStats{}, false } | |
| 227 | + | if len(data) < historyHeaderBytes || string(data[:4]) != historyMagic { | |
| 228 | + | return invalid() | |
| 258 | 229 | } | |
| 259 | - | nextRowID, ok := p.uint64() | |
| 260 | - | if !ok || nextRowID == 0 || nextRowID == uint64(^RowID(0)) { | |
| 261 | - | return HistoryView{}, historyDecodeStats{}, false | |
| 230 | + | p := historyParser{data: data[4:]} | |
| 231 | + | chunkCount, _ := p.uint32() | |
| 232 | + | nextID, _ := p.uint64() | |
| 233 | + | if chunkCount > maxHistoryChunks || uint64(chunkCount)*historyChunkHeaderBytes > uint64(len(p.data)) || nextID == 0 || nextID == math.MaxUint64 { | |
| 234 | + | return invalid() | |
| 262 | 235 | } | |
| 263 | 236 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | |
| 264 | - | var seenIDs [maxHistoryRows]RowID | |
| 265 | - | seenCount := 0 | |
| 237 | + | seenIDs := make(map[RowID]struct{}) | |
| 266 | 238 | var maxID RowID | |
| 267 | 239 | var chunks []*HistoryChunk | |
| 268 | 240 | if populate { | |
| 269 | 241 | chunks = make([]*HistoryChunk, 0, chunkCount) | |
| 270 | 242 | } | |
| 271 | 243 | for range chunkCount { | |
| 272 | - | rowCount, ok := p.uint32() | |
| 273 | - | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | |
| 274 | - | uint64(rowCount) > uint64(len(p.data))/(8+4+historyBoundBytes) || | |
| 275 | - | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |
| 276 | - | return HistoryView{}, historyDecodeStats{}, false | |
| 244 | + | width, wok := p.uint32() | |
| 245 | + | rows, rok := p.uint32() | |
| 246 | + | nstyles, sok := p.uint32() | |
| 247 | + | npayloads, pok := p.uint32() | |
| 248 | + | if !wok || !rok || !sok || !pok || rows == 0 || rows > maxHistoryChunkRows || uint64(width) > uint64(math.MaxInt) || nstyles == 0 || nstyles > maxHistoryStylesPerChunk { | |
| 249 | + | return invalid() | |
| 277 | 250 | } | |
| 278 | - | ||
| 279 | - | var rows [][]renderer.Cell | |
| 280 | - | var bounds []LineBound | |
| 281 | - | var rowIDs []RowID | |
| 282 | - | if populate { | |
| 283 | - | rows = make([][]renderer.Cell, 0, rowCount) | |
| 284 | - | bounds = make([]LineBound, 0, rowCount) | |
| 285 | - | rowIDs = make([]RowID, 0, rowCount) | |
| 251 | + | cells := uint64(width) * uint64(rows) | |
| 252 | + | if uint64(nstyles) > cells+1 || uint64(npayloads) > cells || | |
| 253 | + | !addHistoryDecodeBudget(&stats.rows, uint64(rows), maxHistoryRows) || | |
| 254 | + | !addHistoryDecodeBudget(&stats.cells, cells, maxHistoryCells) || | |
| 255 | + | !addHistoryDecodeBudget(&stats.styles, uint64(nstyles), maxHistoryDecodeStyles) || | |
| 256 | + | !addHistoryLogicalBytes(&stats, cells, uint64(rows), uint64(nstyles)) { | |
| 257 | + | return invalid() | |
| 286 | 258 | } | |
| 287 | - | for range rowCount { | |
| 288 | - | id, ok := p.uint64() | |
| 289 | - | if !ok || id == 0 || id >= uint64(^RowID(0)-1) { | |
| 290 | - | return HistoryView{}, historyDecodeStats{}, false | |
| 259 | + | // Counts have now passed aggregate budgets. This sum cannot overflow. | |
| 260 | + | payload := uint64(nstyles-1)*historyStyleBytes + uint64(npayloads)*8 + uint64(rows)*historyRowBytes + cells*historyStoredCellBytes | |
| 261 | + | if payload > uint64(len(p.data)) { | |
| 262 | + | return invalid() | |
| 263 | + | } | |
| 264 | + | styles := make([]renderer.Style, nstyles) | |
| 265 | + | styles[0] = renderer.DefaultStyle() | |
| 266 | + | seenStyles := map[renderer.Style]struct{}{renderer.DefaultStyle(): {}} | |
| 267 | + | for id := uint32(1); id < nstyles; id++ { | |
| 268 | + | style, ok := p.style() | |
| 269 | + | if !ok || style != style.Canonical() || style.UnderlineStyle > renderer.UnderlineDashed { | |
| 270 | + | return invalid() | |
| 291 | 271 | } | |
| 292 | - | rowID := RowID(id) | |
| 293 | - | seenIDs[seenCount] = rowID | |
| 294 | - | seenCount++ | |
| 295 | - | if rowID > maxID { | |
| 296 | - | maxID = rowID | |
| 272 | + | if _, duplicate := seenStyles[style]; duplicate { | |
| 273 | + | return invalid() | |
| 297 | 274 | } | |
| 298 | - | cellCount, ok := p.uint32() | |
| 299 | - | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | |
| 300 | - | if !ok || !validCellBytes || | |
| 301 | - | !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || | |
| 302 | - | !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) || | |
| 303 | - | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) || | |
| 304 | - | uint64(cellCount) > uint64(len(p.data))/historyCellBytes { | |
| 305 | - | return HistoryView{}, historyDecodeStats{}, false | |
| 275 | + | seenStyles[style] = struct{}{} | |
| 276 | + | styles[id] = style | |
| 277 | + | } | |
| 278 | + | payloads, ok := p.payloadDictionary(npayloads, &stats) | |
| 279 | + | if !ok { | |
| 280 | + | return invalid() | |
| 281 | + | } | |
| 282 | + | usedPayloads := make([]bool, npayloads) | |
| 283 | + | rowIDs := make([]RowID, rows) | |
| 284 | + | bounds := make([]LineBound, rows) | |
| 285 | + | for row := range rows { | |
| 286 | + | id, ok := p.uint64() | |
| 287 | + | bound, bok := p.bound() | |
| 288 | + | if !ok || !bok || id == 0 || id >= math.MaxUint64-1 || !validHistoryBound(bound, int(width)) { | |
| 289 | + | return invalid() | |
| 306 | 290 | } | |
| 307 | - | ||
| 308 | - | var row []renderer.Cell | |
| 309 | - | if populate { | |
| 310 | - | row = make([]renderer.Cell, cellCount) | |
| 291 | + | if _, duplicate := seenIDs[RowID(id)]; duplicate { | |
| 292 | + | return invalid() | |
| 311 | 293 | } | |
| 312 | - | for i := range cellCount { | |
| 313 | - | cell, ok := p.cell() | |
| 314 | - | if !ok || !validHistoryCell(cell) { | |
| 315 | - | return HistoryView{}, historyDecodeStats{}, false | |
| 294 | + | seenIDs[RowID(id)] = struct{}{} | |
| 295 | + | maxID = max(maxID, RowID(id)) | |
| 296 | + | rowIDs[row], bounds[row] = RowID(id), bound | |
| 297 | + | } | |
| 298 | + | lastStyleRow := make([]int, nstyles) | |
| 299 | + | for i := range lastStyleRow { | |
| 300 | + | lastStyleRow[i] = -1 | |
| 301 | + | } | |
| 302 | + | var frame renderer.Frame | |
| 303 | + | if populate { | |
| 304 | + | frame = renderer.NewFrame(int(width), int(rows)) | |
| 305 | + | } | |
| 306 | + | for row := range rows { | |
| 307 | + | for x := range width { | |
| 308 | + | r, id, payloadID, continuation, ok := p.storedCell(nstyles, npayloads) | |
| 309 | + | if !ok { | |
| 310 | + | return invalid() | |
| 311 | + | } | |
| 312 | + | lastStyleRow[id] = int(row) | |
| 313 | + | var value renderer.CellPayload | |
| 314 | + | if payloadID != 0 { | |
| 315 | + | usedPayloads[payloadID-1] = true | |
| 316 | + | value = payloads[payloadID-1] | |
| 316 | 317 | } | |
| 317 | 318 | if populate { | |
| 318 | - | row[i] = cell | |
| 319 | + | 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 | - | if !ok || !validHistoryBound(bound, int(cellCount)) { | |
| 323 | - | return HistoryView{}, historyDecodeStats{}, false | |
| 322 | + | } | |
| 323 | + | for id := uint32(1); id < nstyles; id++ { | |
| 324 | + | if lastStyleRow[id] < 0 { | |
| 325 | + | return invalid() | |
| 324 | 326 | } | |
| 325 | - | if populate { | |
| 326 | - | rows = append(rows, row) | |
| 327 | - | bounds = append(bounds, bound) | |
| 328 | - | rowIDs = append(rowIDs, rowID) | |
| 327 | + | } | |
| 328 | + | for _, used := range usedPayloads { | |
| 329 | + | if !used { | |
| 330 | + | return invalid() | |
| 329 | 331 | } | |
| 330 | 332 | } | |
| 331 | 333 | if populate { | |
| 332 | - | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs}) | |
| 333 | - | } | |
| 334 | - | } | |
| 335 | - | slices.Sort(seenIDs[:seenCount]) | |
| 336 | - | for i := 1; i < seenCount; i++ { | |
| 337 | - | if seenIDs[i] == seenIDs[i-1] { | |
| 338 | - | return HistoryView{}, historyDecodeStats{}, false | |
| 334 | + | drops := make([]uint64, rows) | |
| 335 | + | for id := uint32(1); id < nstyles; id++ { | |
| 336 | + | drops[lastStyleRow[id]]++ | |
| 337 | + | } | |
| 338 | + | chunk := &HistoryChunk{page: newSealedPage(frame), count: int(rows), width: int(width), bounds: bounds, rowIDs: rowIDs, styleDrops: drops, styleCount: uint64(nstyles)} | |
| 339 | + | chunk.recordPayloads() | |
| 340 | + | chunks = append(chunks, chunk) | |
| 339 | 341 | } | |
| 340 | 342 | } | |
| 341 | - | if len(p.data) != 0 || RowID(nextRowID) <= maxID { | |
| 342 | - | return HistoryView{}, historyDecodeStats{}, false | |
| 343 | + | if len(p.data) != 0 || RowID(nextID) <= maxID { | |
| 344 | + | return invalid() | |
| 343 | 345 | } | |
| 344 | - | return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells), nextRowID: RowID(nextRowID)}, stats, true | |
| 346 | + | return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells), logicalBytes: stats.bytes, nextRowID: RowID(nextID)}, stats, true | |
| 345 | 347 | } | |
| 346 | 348 | ||
| 347 | - | func historyCellByteCount(cellCount uint64) (uint64, bool) { | |
| 348 | - | if cellCount > math.MaxUint64/historyCellBytes { | |
| 349 | - | return 0, false | |
| 350 | - | } | |
| 351 | - | return cellCount * historyCellBytes, true | |
| 349 | + | func addHistoryLogicalBytes(stats *historyDecodeStats, cells, rows, styles uint64) bool { | |
| 350 | + | // Callers first bound all three counts by the aggregate decode ceilings. | |
| 351 | + | amount := cells*renderer.StoredCellLogicalBytes + rows*renderer.RowDescriptorLogicalBytes + styles*renderer.StyleRecordLogicalBytes | |
| 352 | + | return addHistoryDecodeBudget(&stats.bytes, amount, maxHistoryDecodedBytes) | |
| 352 | 353 | } | |
| 353 | 354 | ||
| 354 | 355 | func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool { | |
| @@ -359,89 +360,6 @@ func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool { | |||
| 359 | 360 | return true | |
| 360 | 361 | } | |
| 361 | 362 | ||
| 362 | - | type historyParser struct{ data []byte } | |
| 363 | - | ||
| 364 | - | func (p *historyParser) uint32() (uint32, bool) { | |
| 365 | - | if len(p.data) < 4 { | |
| 366 | - | return 0, false | |
| 367 | - | } | |
| 368 | - | v := binary.BigEndian.Uint32(p.data) | |
| 369 | - | p.data = p.data[4:] | |
| 370 | - | return v, true | |
| 371 | - | } | |
| 372 | - | ||
| 373 | - | func (p *historyParser) uint64() (uint64, bool) { | |
| 374 | - | if len(p.data) < 8 { | |
| 375 | - | return 0, false | |
| 376 | - | } | |
| 377 | - | v := binary.BigEndian.Uint64(p.data) | |
| 378 | - | p.data = p.data[8:] | |
| 379 | - | return v, true | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | func (p *historyParser) cell() (renderer.Cell, bool) { | |
| 383 | - | if len(p.data) < historyCellBytes { | |
| 384 | - | return renderer.Cell{}, false | |
| 385 | - | } | |
| 386 | - | b := p.data[:historyCellBytes] | |
| 387 | - | p.data = p.data[historyCellBytes:] | |
| 388 | - | foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15])) | |
| 389 | - | if !ok { | |
| 390 | - | return renderer.Cell{}, false | |
| 391 | - | } | |
| 392 | - | background, ok := historyInt(binary.BigEndian.Uint64(b[15:23])) | |
| 393 | - | if !ok { | |
| 394 | - | return renderer.Cell{}, false | |
| 395 | - | } | |
| 396 | - | underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38])) | |
| 397 | - | if !ok { | |
| 398 | - | return renderer.Cell{}, false | |
| 399 | - | } | |
| 400 | - | flags := b[4] | |
| 401 | - | return renderer.Cell{ | |
| 402 | - | Rune: rune(binary.BigEndian.Uint32(b[0:4])), | |
| 403 | - | Continuation: flags&1 != 0, | |
| 404 | - | Style: renderer.Style{ | |
| 405 | - | Bold: flags&(1<<1) != 0, | |
| 406 | - | Italic: flags&(1<<2) != 0, | |
| 407 | - | Inverse: flags&(1<<3) != 0, | |
| 408 | - | Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])), | |
| 409 | - | Foreground: foreground, | |
| 410 | - | Background: background, | |
| 411 | - | HasForegroundRGB: flags&(1<<4) != 0, | |
| 412 | - | ForegroundRGB: renderer.RGB{R: b[23], G: b[24], B: b[25]}, | |
| 413 | - | HasBackgroundRGB: flags&(1<<5) != 0, | |
| 414 | - | BackgroundRGB: renderer.RGB{R: b[26], G: b[27], B: b[28]}, | |
| 415 | - | UnderlineStyle: renderer.UnderlineStyle(b[29]), | |
| 416 | - | HasUnderlineColor: flags&(1<<6) != 0, | |
| 417 | - | UnderlineColor: underlineColor, | |
| 418 | - | HasUnderlineColorRGB: flags&(1<<7) != 0, | |
| 419 | - | UnderlineColorRGB: renderer.RGB{R: b[38], G: b[39], B: b[40]}, | |
| 420 | - | }, | |
| 421 | - | }, true | |
| 422 | - | } | |
| 423 | - | ||
| 424 | - | func (p *historyParser) bound() (LineBound, bool) { | |
| 425 | - | if len(p.data) < historyBoundBytes { | |
| 426 | - | return LineBound{}, false | |
| 427 | - | } | |
| 428 | - | end := binary.BigEndian.Uint32(p.data[:4]) | |
| 429 | - | flag := p.data[4] | |
| 430 | - | p.data = p.data[historyBoundBytes:] | |
| 431 | - | if flag > 1 || uint64(end) > math.MaxInt32 { | |
| 432 | - | return LineBound{}, false | |
| 433 | - | } | |
| 434 | - | return LineBound{End: int(end), Soft: flag == 1}, true | |
| 435 | - | } | |
| 436 | - | ||
| 437 | - | func historyInt(raw uint64) (int, bool) { | |
| 438 | - | v := int64(raw) | |
| 439 | - | if int64(int(v)) != v { | |
| 440 | - | return 0, false | |
| 441 | - | } | |
| 442 | - | return int(v), true | |
| 443 | - | } | |
| 444 | - | ||
| 445 | - | func validHistoryCell(cell renderer.Cell) bool { | |
| 446 | - | return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed | |
| 363 | + | func validHistoryBound(bound LineBound, cells int) bool { | |
| 364 | + | return bound.End >= 0 && bound.End <= cells | |
| 447 | 365 | } | |
M history_codec_bound_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | func TestHistoryCodecRejectsMalformedBounds(t *testing.T) { | |
| 12 | 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 | 14 | rows: 1, | |
| 15 | 15 | cells: 4, | |
| 16 | 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 | 10 | renderer "github.com/bnema/vev-vt/core" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | - | func TestHistoryCodecV3Bytes(t *testing.T) { | |
| 14 | - | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | |
| 15 | - | if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil { | |
| 13 | + | func TestHistoryCodecCompactBytes(t *testing.T) { | |
| 14 | + | h := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: 1 << 20, ChunkRows: 2}) | |
| 15 | + | if err := h.Append([]renderer.Cell{{Rune: 'a', Style: renderer.DefaultStyle()}}, LineBound{End: 1, Soft: true}); err != nil { | |
| 16 | 16 | t.Fatalf("append: %v", err) | |
| 17 | 17 | } | |
| 18 | 18 | got, err := MarshalHistory(h.SealAndView()) | |
| @@ -21,23 +21,17 @@ func TestHistoryCodecV3Bytes(t *testing.T) { | |||
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | 23 | want := []byte{ | |
| 24 | - | 'V', 'T', 'H', '1', 3, // magic, version | |
| 25 | - | 0, 0, 0, 1, // chunk count | |
| 24 | + | 'V', 'T', 'C', '1', | |
| 25 | + | 0, 0, 0, 1, // chunks | |
| 26 | 26 | 0, 0, 0, 0, 0, 0, 0, 2, // next row ID | |
| 27 | - | 0, 0, 0, 1, // row count | |
| 27 | + | 0, 0, 0, 1, // width | |
| 28 | + | 0, 0, 0, 1, // rows | |
| 29 | + | 0, 0, 0, 1, // implicit default style only | |
| 30 | + | 0, 0, 0, 0, // no exceptional payloads | |
| 28 | 31 | 0, 0, 0, 0, 0, 0, 0, 1, // row ID | |
| 29 | - | 0, 0, 0, 1, // cell count | |
| 30 | - | 0, 0, 0, 'a', // Rune | |
| 31 | - | 0, // flags | |
| 32 | - | 0, 0, // Attrs | |
| 33 | - | 0, 0, 0, 0, 0, 0, 0, 0, // Foreground | |
| 34 | - | 0, 0, 0, 0, 0, 0, 0, 0, // Background | |
| 35 | - | 0, 0, 0, // ForegroundRGB | |
| 36 | - | 0, 0, 0, // BackgroundRGB | |
| 37 | - | 0, // UnderlineStyle | |
| 38 | - | 0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor | |
| 39 | - | 0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here | |
| 40 | - | 0, 0, 0, 1, 1, // bound: End=1, Soft=true | |
| 32 | + | 0, 0, 0, 1, 1, // bound | |
| 33 | + | 0, 0, 0, 'a', // rune | |
| 34 | + | 0, 0, 0, 0, 0, // style ID and continuation | |
| 41 | 35 | } | |
| 42 | 36 | if !bytes.Equal(got, want) { | |
| 43 | 37 | t.Fatalf("MarshalHistory() = % x, want % x", got, want) | |
| @@ -45,7 +39,7 @@ func TestHistoryCodecV3Bytes(t *testing.T) { | |||
| 45 | 39 | } | |
| 46 | 40 | ||
| 47 | 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 | 43 | bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}} | |
| 50 | 44 | for _, b := range bounds { | |
| 51 | 45 | if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil { | |
| @@ -81,7 +75,7 @@ func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) { | |||
| 81 | 75 | 0, 0, 0, | |
| 82 | 76 | 0, 0, 0, 1, 1, | |
| 83 | 77 | } | |
| 84 | - | for _, version := range []byte{1, 2} { | |
| 78 | + | for _, version := range []byte{1, 2, 3} { | |
| 85 | 79 | data := append([]byte(nil), legacy...) | |
| 86 | 80 | data[4] = version | |
| 87 | 81 | if _, err := UnmarshalHistory(data); err == nil { | |
| @@ -165,11 +159,11 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | |||
| 165 | 159 | ||
| 166 | 160 | for i := range len(encoded) { | |
| 167 | 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 | 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 | 288 | rowIDs[i] = rowID | |
| 295 | 289 | rowID++ | |
| 296 | 290 | } | |
| 297 | - | chunks = append(chunks, &HistoryChunk{rows: rows, rowIDs: rowIDs}) | |
| 291 | + | chunks = append(chunks, testHistoryChunk(rows, nil, rowIDs)) | |
| 298 | 292 | rowCount -= chunkRows | |
| 299 | 293 | } | |
| 300 | 294 | return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID} | |
| 301 | 295 | } | |
| 302 | 296 | ||
| 303 | 297 | func historyPayloadWithDimensions(rowCount, width int) []byte { | |
| 304 | - | data := make([]byte, 17) | |
| 305 | - | copy(data, historyMagic) | |
| 306 | - | data[4] = historyVersion | |
| 307 | - | chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows | |
| 308 | - | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |
| 309 | - | binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1)) | |
| 310 | - | rowID := uint64(1) | |
| 298 | + | chunks := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows | |
| 299 | + | data := binary.BigEndian.AppendUint32([]byte(historyMagic), uint32(chunks)) | |
| 300 | + | data = binary.BigEndian.AppendUint64(data, uint64(rowCount+1)) | |
| 301 | + | id := uint64(1) | |
| 311 | 302 | for rowCount > 0 { | |
| 312 | - | chunkRows := min(rowCount, maxHistoryChunkRows) | |
| 313 | - | data = binary.BigEndian.AppendUint32(data, uint32(chunkRows)) | |
| 314 | - | for range chunkRows { | |
| 315 | - | data = binary.BigEndian.AppendUint64(data, rowID) | |
| 316 | - | rowID++ | |
| 317 | - | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |
| 318 | - | //nolint:makezero // The header prefix is retained while zeroed cell records are appended. | |
| 319 | - | data = append(data, make([]byte, width*historyCellBytes)...) | |
| 320 | - | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |
| 321 | - | data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended. | |
| 303 | + | rows := min(rowCount, maxHistoryChunkRows) | |
| 304 | + | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |
| 305 | + | data = binary.BigEndian.AppendUint32(data, uint32(rows)) | |
| 306 | + | data = binary.BigEndian.AppendUint32(data, 1) | |
| 307 | + | data = binary.BigEndian.AppendUint32(data, 0) | |
| 308 | + | for range rows { | |
| 309 | + | data = binary.BigEndian.AppendUint64(data, id) | |
| 310 | + | id++ | |
| 311 | + | data = appendHistoryBound(data, LineBound{End: width}) | |
| 322 | 312 | } | |
| 323 | - | rowCount -= chunkRows | |
| 313 | + | data = append(data, make([]byte, rows*width*historyStoredCellBytes)...) | |
| 314 | + | rowCount -= rows | |
| 324 | 315 | } | |
| 325 | 316 | return data | |
| 326 | 317 | } | |
| @@ -329,29 +320,27 @@ func historyPayloadWithDimensions(rowCount, width int) []byte { | |||
| 329 | 320 | // budget, but its cells exceed the aggregate budget. It deliberately omits | |
| 330 | 321 | // cell payload bytes: the aggregate limit must reject it before allocation. | |
| 331 | 322 | func aggregateCellLimitDeclaration() []byte { | |
| 332 | - | data := make([]byte, 17, 29) | |
| 333 | - | copy(data, historyMagic) | |
| 334 | - | data[4] = historyVersion | |
| 335 | - | binary.BigEndian.PutUint32(data[5:], 1) | |
| 336 | - | binary.BigEndian.PutUint64(data[9:], 2) | |
| 323 | + | data := binary.BigEndian.AppendUint32([]byte(historyMagic), 1) | |
| 324 | + | data = binary.BigEndian.AppendUint64(data, 2) | |
| 325 | + | data = binary.BigEndian.AppendUint32(data, maxHistoryCells+1) | |
| 326 | + | data = binary.BigEndian.AppendUint32(data, 1) | |
| 337 | 327 | data = binary.BigEndian.AppendUint32(data, 1) | |
| 338 | - | data = binary.BigEndian.AppendUint64(data, 1) | |
| 339 | - | return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1)) | |
| 328 | + | return binary.BigEndian.AppendUint32(data, 0) | |
| 340 | 329 | } | |
| 341 | 330 | ||
| 342 | 331 | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | |
| 343 | - | data := make([]byte, 17, 17+chunkCount*(4+rowCount*12)) | |
| 344 | - | copy(data, historyMagic) | |
| 345 | - | data[4] = historyVersion | |
| 346 | - | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |
| 347 | - | binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1)) | |
| 332 | + | data := binary.BigEndian.AppendUint32([]byte(historyMagic), uint32(chunkCount)) | |
| 333 | + | data = binary.BigEndian.AppendUint64(data, uint64(chunkCount*rowCount+1)) | |
| 348 | 334 | id := uint64(1) | |
| 349 | 335 | for range chunkCount { | |
| 336 | + | data = binary.BigEndian.AppendUint32(data, 0) | |
| 350 | 337 | data = binary.BigEndian.AppendUint32(data, uint32(rowCount)) | |
| 338 | + | data = binary.BigEndian.AppendUint32(data, 1) | |
| 339 | + | data = binary.BigEndian.AppendUint32(data, 0) | |
| 351 | 340 | for range rowCount { | |
| 352 | 341 | data = binary.BigEndian.AppendUint64(data, id) | |
| 353 | 342 | id++ | |
| 354 | - | data = binary.BigEndian.AppendUint32(data, 0) | |
| 343 | + | data = appendHistoryBound(data, LineBound{}) | |
| 355 | 344 | } | |
| 356 | 345 | } | |
| 357 | 346 | return data | |
| @@ -363,9 +352,9 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||
| 363 | 352 | t.Fatalf("marshal valid history: %v", err) | |
| 364 | 353 | } | |
| 365 | 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 | 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 | 359 | tests := []struct { | |
| 371 | 360 | name string | |
| @@ -379,7 +368,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||
| 379 | 368 | {name: "invalid underline style", data: invalidUnderlineStyle}, | |
| 380 | 369 | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | |
| 381 | 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 | 373 | for _, tt := range tests { | |
| 385 | 374 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -398,7 +387,7 @@ func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) { | |||
| 398 | 387 | f.Fatalf("marshal valid history: %v", err) | |
| 399 | 388 | } | |
| 400 | 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 | 391 | f.Add(valid) | |
| 403 | 392 | f.Add(invalidRune) | |
| 404 | 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 | 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 | 12 | for _, text := range []string{"live-1", "live-2", "live-3"} { | |
| 13 | 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 | 29 | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | |
| 30 | 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 | 32 | require.NoError(t, err) | |
| 33 | 33 | restoredView := restored.View() | |
| 34 | 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 | 183 | for _, tt := range tests { | |
| 184 | 184 | t.Run(tt.name, func(t *testing.T) { | |
| 185 | 185 | s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |
| 186 | - | for y := range s.Frame.Height { | |
| 187 | - | copy(s.Frame.Row(y), historyRow(string([]byte{byte('A' + y), byte('A' + y), byte('A' + y), byte('A' + y)}))) | |
| 186 | + | for y := range s.frame.Height { | |
| 187 | + | s.frame.WriteRow(y, 0, historyRow(string([]byte{byte('A' + y), byte('A' + y), byte('A' + y), byte('A' + y)}))) | |
| 188 | 188 | } | |
| 189 | 189 | events := 0 | |
| 190 | 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) { | |
| 205 | - | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2}) | |
| 204 | + | func TestHistoryBoundsRowsAndBytesWithExactRowEviction(t *testing.T) { | |
| 205 | + | const budget = 3*renderer.StoredCellLogicalBytes + 2*renderer.RowDescriptorLogicalBytes + 2*renderer.StyleRecordLogicalBytes | |
| 206 | + | history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: budget, ChunkRows: 2}) | |
| 206 | 207 | for _, text := range []string{"aa", "bbb", "c", "dd"} { | |
| 207 | 208 | if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil { | |
| 208 | 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 | - | history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2}) | |
| 250 | + | func TestHistoryRejectsRowLargerThanByteBudgetWithoutMutation(t *testing.T) { | |
| 251 | + | const budget = 2*renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes + renderer.StyleRecordLogicalBytes | |
| 252 | + | history := NewHistory(HistoryConfig{MaxRows: 2, MaxBytes: budget, ChunkRows: 2}) | |
| 251 | 253 | kept := historyRow("ok") | |
| 252 | 254 | if err := history.Append(kept, LineBound{End: len(kept)}); err != nil { | |
| 253 | 255 | t.Fatalf("append retained row: %v", err) | |
| @@ -256,8 +258,8 @@ func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) { | |||
| 256 | 258 | ||
| 257 | 259 | wide := historyRow("wide") | |
| 258 | 260 | err := history.Append(wide, LineBound{End: len(wide)}) | |
| 259 | - | if !errors.Is(err, ErrHistoryRowTooWide) { | |
| 260 | - | t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err) | |
| 261 | + | if !errors.Is(err, ErrHistoryRowTooLarge) { | |
| 262 | + | t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooLarge", err) | |
| 261 | 263 | } | |
| 262 | 264 | if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) { | |
| 263 | 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 | 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 | 274 | var events []string | |
| 273 | 275 | screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) } | |
| 274 | - | copy(screen.Frame.Row(0), historyRow("AAAA")) | |
| 275 | - | copy(screen.Frame.Row(1), historyRow("BBBB")) | |
| 276 | + | screen.frame.WriteRow(0, 0, historyRow("AAAA")) | |
| 277 | + | screen.frame.WriteRow(1, 0, historyRow("BBBB")) | |
| 276 | 278 | screen.scrollUpRegion(0, 1, 1) | |
| 277 | 279 | ||
| 278 | 280 | if got := screen.History().Len(); got != 0 { | |
| @@ -314,7 +316,7 @@ func TestHistoryAppendRetainsBounds(t *testing.T) { | |||
| 314 | 316 | ||
| 315 | 317 | for _, tc := range tests { | |
| 316 | 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 | 320 | for i, b := range tc.bounds { | |
| 319 | 321 | if err := h.Append(row("abc"[:b.End]), b); err != nil { | |
| 320 | 322 | t.Fatalf("append %d: %v", i, err) | |
| @@ -331,7 +333,7 @@ func TestHistoryAppendRetainsBounds(t *testing.T) { | |||
| 331 | 333 | } | |
| 332 | 334 | ||
| 333 | 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 | 337 | for i := range 5 { | |
| 336 | 338 | cells := []renderer.Cell{{Rune: rune('a' + i)}} | |
| 337 | 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 | 347 | // Rows 2, 3, 4 survived; their Soft flags were true, false, true. | |
| 346 | 348 | for i, want := range []bool{true, false, true} { | |
| 347 | 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 | 426 | history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1}) | |
| 355 | - | if got, want := history.CellCap(), math.MaxInt; got != want { | |
| 356 | - | t.Fatalf("default cell cap = %d, want %d", got, want) | |
| 427 | + | if got := history.ByteCap(); got != 0 { | |
| 428 | + | t.Fatalf("implicit byte capacity = %d, want no byte ceiling", got) | |
| 429 | + | } | |
| 430 | + | if got := history.Cap(); got != math.MaxInt { | |
| 431 | + | t.Fatalf("row ceiling = %d", got) | |
| 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 | 3 | import renderer "github.com/bnema/vev-vt/core" | |
| 4 | 4 | ||
| 5 | - | // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport | |
| 6 | - | // rows that should be replayed after retained terminal history during recovery. | |
| 5 | + | // RecoveryTranscriptSnapshot owns compact viewport pages to replay after | |
| 6 | + | // retained history. No semantic row slices are retained by the snapshot. | |
| 7 | 7 | type RecoveryTranscriptSnapshot struct { | |
| 8 | 8 | segments []recoveryTranscriptSegment | |
| 9 | 9 | nextRowID RowID | |
| 10 | 10 | } | |
| 11 | 11 | ||
| 12 | 12 | type recoveryTranscriptSegment struct { | |
| 13 | - | rows [][]renderer.Cell | |
| 13 | + | frame renderer.Frame | |
| 14 | 14 | bounds []LineBound | |
| 15 | 15 | rowIDs []RowID | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | - | // RecoveryTranscriptSnapshot captures the active primary viewport, or the | |
| 19 | - | // saved primary viewport followed by the active alternate viewport. | |
| 18 | + | // RecoveryTranscriptSnapshot captures the primary viewport, followed by the | |
| 19 | + | // alternate viewport when active. Untouched trailing rows are omitted and each | |
| 20 | + | // viewport ends with a hard line, independently of physical row rotation. | |
| 20 | 21 | func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot { | |
| 21 | 22 | if s == nil { | |
| 22 | 23 | return RecoveryTranscriptSnapshot{} | |
| 23 | 24 | } | |
| 24 | - | ||
| 25 | 25 | buffers := []*buffer{s.buffer} | |
| 26 | 26 | if s.alternate != nil { | |
| 27 | 27 | buffers = []*buffer{s.alternate.buffer, s.buffer} | |
| 28 | 28 | } | |
| 29 | - | ||
| 30 | - | nextRowID := s.nextRowID | |
| 31 | - | if nextRowID < ^RowID(0) { | |
| 32 | - | nextRowID++ | |
| 29 | + | nextID := s.nextRowID | |
| 30 | + | if nextID < ^RowID(0) { | |
| 31 | + | nextID++ | |
| 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 | 34 | for _, b := range buffers { | |
| 36 | - | segment := captureRecoveryTranscriptSegment(b) | |
| 37 | - | if len(segment.rows) > 0 { | |
| 38 | - | snapshot.segments = append(snapshot.segments, segment) | |
| 35 | + | if b == nil { | |
| 36 | + | continue | |
| 37 | + | } | |
| 38 | + | rows := b.frame.Height | |
| 39 | + | for rows > 0 && recoveryTranscriptFrameRowUntouched(b.frame, rows-1, b.bound(rows-1)) { | |
| 40 | + | rows-- | |
| 41 | + | } | |
| 42 | + | if rows == 0 { | |
| 43 | + | continue | |
| 44 | + | } | |
| 45 | + | frame := renderer.NewFrame(b.frame.Width, rows) | |
| 46 | + | for y := range rows { | |
| 47 | + | for x := range frame.Width { | |
| 48 | + | frame.Set(x, y, b.frame.Cell(x, y)) | |
| 49 | + | } | |
| 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 | 57 | return snapshot | |
| 42 | 58 | } | |
| 43 | 59 | ||
| 44 | - | func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment { | |
| 45 | - | if b == nil { | |
| 46 | - | return recoveryTranscriptSegment{} | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | rowCount := b.frame.Height | |
| 50 | - | for rowCount > 0 && recoveryTranscriptRowUntouched(b.frame.Row(rowCount-1), b.bound(rowCount-1)) { | |
| 51 | - | rowCount-- | |
| 52 | - | } | |
| 53 | - | if rowCount == 0 { | |
| 54 | - | return recoveryTranscriptSegment{} | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | segment := recoveryTranscriptSegment{ | |
| 58 | - | rows: make([][]renderer.Cell, rowCount), | |
| 59 | - | bounds: append([]LineBound(nil), b.boundaries[:rowCount]...), | |
| 60 | - | rowIDs: append([]RowID(nil), b.rowIDs[:rowCount]...), | |
| 61 | - | } | |
| 62 | - | cells := make([]renderer.Cell, rowCount*b.frame.Width) | |
| 63 | - | for y := range rowCount { | |
| 64 | - | start := y * b.frame.Width | |
| 65 | - | end := start + b.frame.Width | |
| 66 | - | segment.rows[y] = cells[start:end:end] | |
| 67 | - | copy(segment.rows[y], b.frame.Row(y)) | |
| 68 | - | } | |
| 69 | - | segment.bounds[rowCount-1].Soft = false | |
| 70 | - | return segment | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | func recoveryTranscriptRowUntouched(row []renderer.Cell, bound LineBound) bool { | |
| 60 | + | func recoveryTranscriptFrameRowUntouched(frame renderer.Frame, y int, bound LineBound) bool { | |
| 74 | 61 | if bound.End != 0 || bound.Soft { | |
| 75 | 62 | return false | |
| 76 | 63 | } | |
| 77 | 64 | blank := renderer.BlankCell() | |
| 78 | - | for _, cell := range row { | |
| 79 | - | if !cell.Equal(blank) { | |
| 65 | + | for x := range frame.Width { | |
| 66 | + | if !frame.Cell(x, y).Equal(blank) { | |
| 80 | 67 | return false | |
| 81 | 68 | } | |
| 82 | 69 | } | |
| 83 | 70 | return true | |
| 84 | 71 | } | |
| 85 | 72 | ||
| 86 | - | // Marshal encodes the captured rows as canonical terminal history without | |
| 87 | - | // consulting or mutating live Screen history. | |
| 73 | + | // Marshal encodes the compact capture without consulting or mutating live state. | |
| 88 | 74 | func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | |
| 89 | - | rows := 0 | |
| 90 | - | cells := 0 | |
| 75 | + | view := HistoryView{nextRowID: snapshot.nextRowID} | |
| 91 | 76 | for _, segment := range snapshot.segments { | |
| 92 | - | rows += len(segment.rows) | |
| 93 | - | for _, row := range segment.rows { | |
| 94 | - | cells += len(row) | |
| 95 | - | } | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | view := HistoryView{rows: rows, cells: cells, nextRowID: snapshot.nextRowID} | |
| 99 | - | for _, segment := range snapshot.segments { | |
| 100 | - | for start := 0; start < len(segment.rows); { | |
| 101 | - | space := maxHistoryChunkRows | |
| 102 | - | if len(view.chunks) > 0 { | |
| 103 | - | last := view.chunks[len(view.chunks)-1] | |
| 104 | - | if len(last.rows) < maxHistoryChunkRows { | |
| 105 | - | space = maxHistoryChunkRows - len(last.rows) | |
| 106 | - | end := min(start+space, len(segment.rows)) | |
| 107 | - | last.rows = append(last.rows, segment.rows[start:end]...) | |
| 108 | - | last.bounds = append(last.bounds, segment.bounds[start:end]...) | |
| 109 | - | last.rowIDs = append(last.rowIDs, segment.rowIDs[start:end]...) | |
| 110 | - | start = end | |
| 111 | - | continue | |
| 77 | + | for start := 0; start < segment.frame.Height; { | |
| 78 | + | count := min(segment.frame.Height-start, maxHistoryChunkRows, maxHistorySlabRows(segment.frame.Width)) | |
| 79 | + | frame := renderer.NewFrame(segment.frame.Width, count) | |
| 80 | + | lastStyleRow := make(map[renderer.Style]int) | |
| 81 | + | for y := range count { | |
| 82 | + | for x := range frame.Width { | |
| 83 | + | cell := segment.frame.Cell(x, start+y) | |
| 84 | + | frame.Set(x, y, cell) | |
| 85 | + | style := cell.Style.Canonical() | |
| 86 | + | if style != renderer.DefaultStyle() { | |
| 87 | + | lastStyleRow[style] = y | |
| 88 | + | } | |
| 112 | 89 | } | |
| 113 | 90 | } | |
| 114 | - | ||
| 115 | - | end := min(start+space, len(segment.rows)) | |
| 116 | - | view.chunks = append(view.chunks, &HistoryChunk{ | |
| 117 | - | rows: append([][]renderer.Cell(nil), segment.rows[start:end]...), | |
| 118 | - | bounds: append([]LineBound(nil), segment.bounds[start:end]...), | |
| 119 | - | rowIDs: append([]RowID(nil), segment.rowIDs[start:end]...), | |
| 120 | - | }) | |
| 121 | - | start = end | |
| 91 | + | drops := make([]uint64, count) | |
| 92 | + | for _, y := range lastStyleRow { | |
| 93 | + | drops[y]++ | |
| 94 | + | } | |
| 95 | + | chunk := &HistoryChunk{ | |
| 96 | + | page: newSealedPage(frame), count: count, width: frame.Width, | |
| 97 | + | bounds: segment.bounds[start : start+count], rowIDs: segment.rowIDs[start : start+count], | |
| 98 | + | styleDrops: drops, styleCount: uint64(len(lastStyleRow)) + 1, | |
| 99 | + | } | |
| 100 | + | chunk.recordPayloads() | |
| 101 | + | view.chunks = append(view.chunks, chunk) | |
| 102 | + | view.rows += count | |
| 103 | + | view.cells += count * frame.Width | |
| 104 | + | view.logicalBytes += historyChunkLogicalBytes(chunk) | |
| 105 | + | start += count | |
| 122 | 106 | } | |
| 123 | 107 | } | |
| 124 | 108 | return MarshalHistory(view) | |
M recovery_transcript_test.go +30 -30
| @@ -17,7 +17,7 @@ func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) { | |||
| 17 | 17 | s.Write([]byte("keep")) | |
| 18 | 18 | ||
| 19 | 19 | snapshot := s.RecoveryTranscriptSnapshot() | |
| 20 | - | s.Frame.Set(0, 0, renderer.BlankCell()) | |
| 20 | + | s.frame.Set(0, 0, renderer.BlankCell()) | |
| 21 | 21 | s.buffer.boundaries[0] = LineBound{} | |
| 22 | 22 | ||
| 23 | 23 | view := decodeRecoveryTranscript(t, snapshot) | |
| @@ -44,7 +44,7 @@ func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *test | |||
| 44 | 44 | newRetainedScreen := func(height int) *Screen { | |
| 45 | 45 | screen := NewScreen(4, height) | |
| 46 | 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 | 48 | screen.buffer.boundaries[y] = LineBound{End: 1} | |
| 49 | 49 | } | |
| 50 | 50 | return screen | |
| @@ -73,7 +73,7 @@ func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSea | |||
| 73 | 73 | s.buffer.boundaries[0].Soft = true | |
| 74 | 74 | ||
| 75 | 75 | snapshot := s.RecoveryTranscriptSnapshot() | |
| 76 | - | s.Frame.Set(0, 0, renderer.BlankCell()) | |
| 76 | + | s.frame.Set(0, 0, renderer.BlankCell()) | |
| 77 | 77 | s.buffer.boundaries[0] = LineBound{} | |
| 78 | 78 | s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell()) | |
| 79 | 79 | s.alternate.buffer.boundaries[0] = LineBound{} | |
| @@ -147,9 +147,9 @@ func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T) | |||
| 147 | 147 | for _, tt := range tests { | |
| 148 | 148 | t.Run(tt.name, func(t *testing.T) { | |
| 149 | 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 | 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 | 153 | s.buffer.boundaries[1] = tt.candidateBound | |
| 154 | 154 | ||
| 155 | 155 | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |
| @@ -165,7 +165,7 @@ func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T) | |||
| 165 | 165 | ||
| 166 | 166 | func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) { | |
| 167 | 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 | 169 | s.buffer.boundaries[2] = LineBound{End: 1} | |
| 170 | 170 | ||
| 171 | 171 | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |
| @@ -200,8 +200,8 @@ func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) { | |||
| 200 | 200 | ||
| 201 | 201 | func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) { | |
| 202 | 202 | s := NewScreen(1, 257) | |
| 203 | - | for y := range s.Frame.Height { | |
| 204 | - | s.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 203 | + | for y := range s.frame.Height { | |
| 204 | + | s.frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 205 | 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 | 210 | require.Equal(t, 257, view.Len()) | |
| 211 | 211 | require.Equal(t, 2, view.ChunkCount()) | |
| 212 | - | require.Len(t, view.Chunk(0).rows, 256) | |
| 213 | - | require.Len(t, view.Chunk(1).rows, 1) | |
| 212 | + | require.Equal(t, 256, view.Chunk(0).len()) | |
| 213 | + | require.Equal(t, 1, view.Chunk(1).len()) | |
| 214 | 214 | require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam") | |
| 215 | 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 | 218 | func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) { | |
| 219 | 219 | s := NewScreen(4, 2) | |
| 220 | 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 | 223 | snapshot := s.RecoveryTranscriptSnapshot() | |
| 224 | - | for x := range s.Frame.Width { | |
| 225 | - | s.Frame.Set(x, 0, renderer.BlankCell()) | |
| 224 | + | for x := range s.frame.Width { | |
| 225 | + | s.frame.Set(x, 0, renderer.BlankCell()) | |
| 226 | 226 | } | |
| 227 | 227 | ||
| 228 | 228 | view := decodeRecoveryTranscript(t, snapshot) | |
| @@ -238,16 +238,16 @@ func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBl | |||
| 238 | 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 | 242 | require.NoError(t, err) | |
| 243 | 243 | ||
| 244 | 244 | view := screen.History().View() | |
| 245 | 245 | require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view)) | |
| 246 | 246 | require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3)) | |
| 247 | 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 { | |
| 249 | - | for x := range screen.Frame.Width { | |
| 250 | - | require.True(t, screen.Frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y) | |
| 248 | + | for y := range screen.frame.Height { | |
| 249 | + | for x := range screen.frame.Width { | |
| 250 | + | require.True(t, screen.frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y) | |
| 251 | 251 | } | |
| 252 | 252 | } | |
| 253 | 253 | require.Equal(t, []LineBound{{}, {}}, screen.LineBounds()) | |
| @@ -263,14 +263,14 @@ func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing. | |||
| 263 | 263 | }{ | |
| 264 | 264 | { | |
| 265 | 265 | name: "row budget", | |
| 266 | - | config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2}, | |
| 266 | + | config: HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 2}, | |
| 267 | 267 | history: []string{"old-1", "old-2", "old-3"}, | |
| 268 | 268 | transcript: []string{"new-1", "new-2"}, | |
| 269 | 269 | want: []string{"old-3", "new-1", "new-2"}, | |
| 270 | 270 | }, | |
| 271 | 271 | { | |
| 272 | - | name: "cell budget", | |
| 273 | - | config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4}, | |
| 272 | + | name: "byte budget", | |
| 273 | + | config: HistoryConfig{MaxRows: 10, MaxBytes: 3*renderer.StoredCellLogicalBytes + 2*renderer.RowDescriptorLogicalBytes + 2*renderer.StyleRecordLogicalBytes, ChunkRows: 4}, | |
| 274 | 274 | history: []string{"aa", "b", "ccc"}, | |
| 275 | 275 | transcript: []string{"d", "ee"}, | |
| 276 | 276 | want: []string{"d", "ee"}, | |
| @@ -293,9 +293,9 @@ func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing | |||
| 293 | 293 | sealed, tail := recoveryHistoryBlobs(t, []string{"ok"}) | |
| 294 | 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 | 299 | require.Nil(t, screen) | |
| 300 | 300 | } | |
| 301 | 301 | ||
| @@ -331,11 +331,11 @@ func BenchmarkRecoveryTranscriptSnapshot(b *testing.B) { | |||
| 331 | 331 | } | |
| 332 | 332 | ||
| 333 | 333 | func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) { | |
| 334 | - | for y := range screen.Frame.Height { | |
| 335 | - | for x := range screen.Frame.Width { | |
| 336 | - | screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()}) | |
| 334 | + | for y := range screen.frame.Height { | |
| 335 | + | for x := range screen.frame.Width { | |
| 336 | + | screen.frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()}) | |
| 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 | 343 | const rows = maxHistoryRows | |
| 344 | 344 | source := NewScreen(1, rows) | |
| 345 | 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 | 347 | source.buffer.boundaries[y] = LineBound{End: 1} | |
| 348 | 348 | } | |
| 349 | 349 | transcript, err := source.RecoveryTranscriptSnapshot().Marshal() | |
| 350 | 350 | require.NoError(b, err) | |
| 351 | 351 | tail, err := MarshalEmptyHistoryTail() | |
| 352 | 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 | 355 | b.ReportAllocs() | |
| 356 | 356 | b.ReportMetric(float64(rows), "rows/restore") | |
| @@ -366,7 +366,7 @@ func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) { | |||
| 366 | 366 | ||
| 367 | 367 | func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) { | |
| 368 | 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 | 370 | for _, text := range texts { | |
| 371 | 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 | 403 | view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))} | |
| 404 | 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 | 407 | blob, err := MarshalHistory(view) | |
| 408 | 408 | require.NoError(t, err) | |
M region_reflow_test.go +3 -3
| @@ -42,9 +42,9 @@ func TestResizeSeversSoftWrapsAtRegionalScrollEdges(t *testing.T) { | |||
| 42 | 42 | ||
| 43 | 43 | s.Resize(3, 6) | |
| 44 | 44 | ||
| 45 | - | got := make([]string, s.Frame.Height) | |
| 46 | - | for y := range s.Frame.Height { | |
| 47 | - | got[y] = rowString(s.Frame.Row(y)) | |
| 45 | + | got := make([]string, s.frame.Height) | |
| 46 | + | for y := range s.frame.Height { | |
| 47 | + | got[y] = rowString(s.frame.Row(y)) | |
| 48 | 48 | } | |
| 49 | 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 | 13 | if len(s.escapeBuf) != 0 { | |
| 14 | 14 | t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf) | |
| 15 | 15 | } | |
| 16 | - | if s.Frame.Width != 0 || s.Frame.Height != 0 { | |
| 17 | - | t.Fatalf("active frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height) | |
| 16 | + | if s.frame.Width != 0 || s.frame.Height != 0 { | |
| 17 | + | t.Fatalf("active frame = %dx%d, want 0x0", s.frame.Width, s.frame.Height) | |
| 18 | 18 | } | |
| 19 | 19 | s.Write([]byte("\x1b[?1049l")) | |
| 20 | - | if s.Frame.Width != 0 || s.Frame.Height != 0 { | |
| 21 | - | t.Fatalf("saved primary frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height) | |
| 20 | + | if s.frame.Width != 0 || s.frame.Height != 0 { | |
| 21 | + | t.Fatalf("saved primary frame = %dx%d, want 0x0", s.frame.Width, s.frame.Height) | |
| 22 | 22 | } | |
| 23 | 23 | } | |
| 24 | 24 | ||
M row_id_persistence_test.go +10 -9
| @@ -15,16 +15,17 @@ func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) { | |||
| 15 | 15 | encoded, err := MarshalHistory(history.SealAndView()) | |
| 16 | 16 | require.NoError(t, err) | |
| 17 | 17 | ||
| 18 | - | firstIDOffset := 17 + 4 | |
| 19 | - | secondIDOffset := firstIDOffset + 8 + 4 + historyCellBytes + historyBoundBytes | |
| 18 | + | styles := int(binary.BigEndian.Uint32(encoded[24:28])) | |
| 19 | + | firstIDOffset := historyHeaderBytes + historyChunkHeaderBytes + (styles-1)*historyStyleBytes | |
| 20 | + | secondIDOffset := firstIDOffset + historyRowBytes | |
| 20 | 21 | for _, test := range []struct { | |
| 21 | 22 | name string | |
| 22 | 23 | mutate func([]byte) | |
| 23 | 24 | }{ | |
| 24 | 25 | {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }}, | |
| 25 | 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: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], ^uint64(0)) }}, | |
| 27 | + | {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[8:16], 2) }}, | |
| 28 | + | {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[8:16], ^uint64(0)) }}, | |
| 28 | 29 | } { | |
| 29 | 30 | t.Run(test.name, func(t *testing.T) { | |
| 30 | 31 | data := append([]byte(nil), encoded...) | |
| @@ -135,7 +136,7 @@ func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) { | |||
| 135 | 136 | allocate() | |
| 136 | 137 | row := historyRow("x") | |
| 137 | 138 | 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 | 140 | screen.buffer.boundaries[0] = LineBound{End: 1} | |
| 140 | 141 | transcript, err := screen.RecoveryTranscriptSnapshot().Marshal() | |
| 141 | 142 | require.NoError(t, err) | |
| @@ -154,7 +155,7 @@ func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) { | |||
| 154 | 155 | ||
| 155 | 156 | func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) { | |
| 156 | 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 | 159 | rows: 1, | |
| 159 | 160 | }) | |
| 160 | 161 | require.Error(t, err) | |
| @@ -166,7 +167,7 @@ func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) { | |||
| 166 | 167 | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | |
| 167 | 168 | require.NoError(t, err) | |
| 168 | 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 | 171 | rows: 1, | |
| 171 | 172 | nextRowID: 51, | |
| 172 | 173 | }) | |
| @@ -186,7 +187,7 @@ func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) { | |||
| 186 | 187 | ||
| 187 | 188 | func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) { | |
| 188 | 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 | 191 | rows: 1, | |
| 191 | 192 | nextRowID: 8, | |
| 192 | 193 | } | |
| @@ -195,7 +196,7 @@ func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *t | |||
| 195 | 196 | tail, err := MarshalEmptyHistoryTail() | |
| 196 | 197 | require.NoError(t, err) | |
| 197 | 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 | 200 | rows: 1, | |
| 200 | 201 | nextRowID: 8, | |
| 201 | 202 | }) | |
M row_id_test.go +3 -3
| @@ -98,9 +98,9 @@ func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) { | |||
| 98 | 98 | ||
| 99 | 99 | func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) { | |
| 100 | 100 | b := newBuffer(8, 3) | |
| 101 | - | copy(b.frame.Row(0), historyRow("abcdefgh")) | |
| 102 | - | copy(b.frame.Row(1), historyRow("ijkl")) | |
| 103 | - | copy(b.frame.Row(2), historyRow("mnop")) | |
| 101 | + | b.frame.WriteRow(0, 0, historyRow("abcdefgh")) | |
| 102 | + | b.frame.WriteRow(1, 0, historyRow("ijkl")) | |
| 103 | + | b.frame.WriteRow(2, 0, historyRow("mnop")) | |
| 104 | 104 | b.boundaries[0] = LineBound{End: 8, Soft: true} | |
| 105 | 105 | b.boundaries[1] = LineBound{End: 4} | |
| 106 | 106 | b.boundaries[2] = LineBound{End: 4} | |
M screen.go +38 -8
| @@ -26,7 +26,7 @@ const ( | |||
| 26 | 26 | ) | |
| 27 | 27 | ||
| 28 | 28 | type Screen struct { | |
| 29 | - | Frame renderer.Frame | |
| 29 | + | frame renderer.Frame | |
| 30 | 30 | Row int | |
| 31 | 31 | Col int | |
| 32 | 32 | Style renderer.Style | |
| @@ -107,7 +107,7 @@ func NewScreen(width, height int) *Screen { | |||
| 107 | 107 | geometry: Geometry{Cols: width, Rows: height}, | |
| 108 | 108 | } | |
| 109 | 109 | s.buffer = s.newBuffer(width, height) | |
| 110 | - | s.Frame = s.buffer.frame | |
| 110 | + | s.frame = s.buffer.frame | |
| 111 | 111 | s.resetScrollRegion() | |
| 112 | 112 | return s | |
| 113 | 113 | } | |
| @@ -124,6 +124,36 @@ func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen { | |||
| 124 | 124 | // configured with NewScreenWithHistory. | |
| 125 | 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 | 157 | // LineBounds returns an owned copy of the live grid's per-row logical extents, | |
| 128 | 158 | // indexed like Frame rows. It returns nil when the screen has no buffer. | |
| 129 | 159 | func (s *Screen) LineBounds() []LineBound { | |
| @@ -440,10 +470,10 @@ func (s *Screen) kittyCursorX() uint64 { | |||
| 440 | 470 | } | |
| 441 | 471 | ||
| 442 | 472 | func (s *Screen) kittyCursorColumn() int { | |
| 443 | - | if s.Frame.Width <= 0 { | |
| 473 | + | if s.frame.Width <= 0 { | |
| 444 | 474 | 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 | 479 | func (s *Screen) kittyCursorY() uint64 { | |
| @@ -467,12 +497,12 @@ func (s *Screen) kittyCellPixels() (width, height int, ok bool) { | |||
| 467 | 497 | } | |
| 468 | 498 | ||
| 469 | 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 | 501 | return | |
| 472 | 502 | } | |
| 473 | 503 | columns, rows := s.kittyPlacementGridSize(c, mutation) | |
| 474 | 504 | targetColumn := s.kittyCursorColumn() + columns | |
| 475 | - | wraps := targetColumn >= s.Frame.Width | |
| 505 | + | wraps := targetColumn >= s.frame.Width | |
| 476 | 506 | rowMoves := max(rows-1, 0) | |
| 477 | 507 | if wraps { | |
| 478 | 508 | rowMoves++ | |
| @@ -486,7 +516,7 @@ func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kit | |||
| 486 | 516 | } | |
| 487 | 517 | ||
| 488 | 518 | func (s *Screen) advanceKittyCursorRows(rows int) { | |
| 489 | - | if rows <= 0 || s.Frame.Height <= 0 { | |
| 519 | + | if rows <= 0 || s.frame.Height <= 0 { | |
| 490 | 520 | return | |
| 491 | 521 | } | |
| 492 | 522 | if s.Row >= s.scrollTop && s.Row <= s.scrollBottom { | |
| @@ -499,7 +529,7 @@ func (s *Screen) advanceKittyCursorRows(rows int) { | |||
| 499 | 529 | s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll) | |
| 500 | 530 | 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 | 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 | 122 | case 'B': | |
| 123 | 123 | s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow()) | |
| 124 | 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 | 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 | 128 | case 'E': | |
| 129 | 129 | s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow()) | |
| 130 | 130 | s.Col = 0 | |
| @@ -133,7 +133,7 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 133 | 133 | s.Col = 0 | |
| 134 | 134 | case 'G': | |
| 135 | 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 | 137 | case 'd': | |
| 138 | 138 | row := firstPositive(parts, 1) | |
| 139 | 139 | s.Row = s.addressedRow(row) | |
| @@ -335,23 +335,18 @@ func sgrUnderlineStyle(param int) renderer.UnderlineStyle { | |||
| 335 | 335 | } | |
| 336 | 336 | ||
| 337 | 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 | 339 | return | |
| 340 | 340 | } | |
| 341 | - | w := s.Frame.Width | |
| 341 | + | w := s.frame.Width | |
| 342 | 342 | if n > w-s.Col { | |
| 343 | 343 | n = w - s.Col | |
| 344 | 344 | } | |
| 345 | - | row := s.Frame.Row(s.Row) | |
| 346 | 345 | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | |
| 347 | 346 | // by the shift; its repair falls outside the default damage rect. | |
| 348 | - | leftSplit := s.Col > 0 && row[s.Col].Continuation | |
| 349 | - | for x := w - 1; x >= s.Col+n; x-- { | |
| 350 | - | row[x] = row[x-n] | |
| 351 | - | } | |
| 352 | - | for x := s.Col; x < s.Col+n; x++ { | |
| 353 | - | row[x] = renderer.BlankCell() | |
| 354 | - | } | |
| 347 | + | leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation | |
| 348 | + | s.frame.CopyRow(s.Row, s.Col+n, s.Col, w-s.Col-n) | |
| 349 | + | s.frame.FillRow(s.Row, s.Col, s.Col+n, renderer.BlankCell()) | |
| 355 | 350 | s.repairRow(s.Row) | |
| 356 | 351 | dmgX := s.Col | |
| 357 | 352 | if leftSplit { | |
| @@ -361,23 +356,18 @@ func (s *Screen) insertChars(n int) { | |||
| 361 | 356 | } | |
| 362 | 357 | ||
| 363 | 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 | 360 | return | |
| 366 | 361 | } | |
| 367 | - | w := s.Frame.Width | |
| 362 | + | w := s.frame.Width | |
| 368 | 363 | if n > w-s.Col { | |
| 369 | 364 | n = w - s.Col | |
| 370 | 365 | } | |
| 371 | - | row := s.Frame.Row(s.Row) | |
| 372 | 366 | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | |
| 373 | 367 | // by the shift; its repair falls outside the default damage rect. | |
| 374 | - | leftSplit := s.Col > 0 && row[s.Col].Continuation | |
| 375 | - | for x := s.Col; x < w-n; x++ { | |
| 376 | - | row[x] = row[x+n] | |
| 377 | - | } | |
| 378 | - | for x := w - n; x < w; x++ { | |
| 379 | - | row[x] = renderer.BlankCell() | |
| 380 | - | } | |
| 368 | + | leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation | |
| 369 | + | s.frame.CopyRow(s.Row, s.Col, s.Col+n, w-s.Col-n) | |
| 370 | + | s.frame.FillRow(s.Row, w-n, w, renderer.BlankCell()) | |
| 381 | 371 | s.repairRow(s.Row) | |
| 382 | 372 | dmgX := s.Col | |
| 383 | 373 | if leftSplit { | |
| @@ -404,34 +394,34 @@ func (s *Screen) clearScreenMode(mode int) { | |||
| 404 | 394 | s.clampCursor() | |
| 405 | 395 | switch mode { | |
| 406 | 396 | case 1: | |
| 407 | - | for y := range min(s.Row+1, s.Frame.Height) { | |
| 408 | - | end := s.Frame.Width | |
| 397 | + | for y := range min(s.Row+1, s.frame.Height) { | |
| 398 | + | end := s.frame.Width | |
| 409 | 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 | 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 | 405 | case 2, 3: | |
| 416 | 406 | blank := s.eraseCell() | |
| 417 | - | for i := range s.Frame.Cells { | |
| 418 | - | s.Frame.Cells[i] = blank | |
| 407 | + | for y := range s.frame.Height { | |
| 408 | + | s.frame.FillRow(y, 0, s.frame.Width, blank) | |
| 419 | 409 | } | |
| 420 | 410 | for y := range s.buffer.boundaries { | |
| 421 | 411 | s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft} | |
| 422 | 412 | s.buffer.rowIDs[y] = s.nextRowIDValue() | |
| 423 | 413 | } | |
| 424 | 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 | 416 | default: | |
| 427 | - | for y := s.Row; y < s.Frame.Height; y++ { | |
| 417 | + | for y := s.Row; y < s.frame.Height; y++ { | |
| 428 | 418 | start := 0 | |
| 429 | 419 | if y == s.Row { | |
| 430 | 420 | 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 | 429 | s.clampCursor() | |
| 440 | 430 | switch mode { | |
| 441 | 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 | 433 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1}) | |
| 444 | 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 | 436 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1}) | |
| 447 | 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 | 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 | 445 | return | |
| 456 | 446 | } | |
| 457 | 447 | s.clampCursor() | |
| 458 | - | if remaining := s.Frame.Width - s.Col; n > remaining { | |
| 448 | + | if remaining := s.frame.Width - s.Col; n > remaining { | |
| 459 | 449 | n = remaining | |
| 460 | 450 | } | |
| 461 | 451 | end := s.Col + n | |
| @@ -466,8 +456,8 @@ func (s *Screen) eraseChars(n int) { | |||
| 466 | 456 | } | |
| 467 | 457 | ||
| 468 | 458 | func (s *Screen) clampCursor() { | |
| 469 | - | s.Row = clamp(s.Row, 0, s.Frame.Height-1) | |
| 470 | - | s.Col = clamp(s.Col, 0, s.Frame.Width-1) | |
| 459 | + | s.Row = clamp(s.Row, 0, s.frame.Height-1) | |
| 460 | + | s.Col = clamp(s.Col, 0, s.frame.Width-1) | |
| 471 | 461 | } | |
| 472 | 462 | ||
| 473 | 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 | 731 | for y := range tt.height { | |
| 732 | 732 | for x := tt.startX; x < tt.width; x++ { | |
| 733 | 733 | cell := cellAt(s, x, y) | |
| 734 | - | if cell.Rune != ' ' || cell.Style != tt.wantStyle { | |
| 734 | + | if cell.Rune != ' ' || !cell.Style.Equal(tt.wantStyle) { | |
| 735 | 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 | 26 | func (s *Screen) cursorMinRow() int { | |
| 27 | 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 | 30 | return 0 | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | func (s *Screen) cursorMaxRow() int { | |
| 34 | 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 | 40 | func (s *Screen) addressedRow(row int) int { | |
| 41 | 41 | if s.originMode { | |
| 42 | 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 | 47 | func (s *Screen) addressCursor(row, col int) { | |
| 48 | 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 | 52 | func (s *Screen) homeCursor() { | |
| @@ -81,8 +81,8 @@ func (s *Screen) restoreCursor() { | |||
| 81 | 81 | if !s.savedCursor.saved { | |
| 82 | 82 | return | |
| 83 | 83 | } | |
| 84 | - | s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1) | |
| 85 | - | s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1) | |
| 84 | + | s.Row = clamp(s.savedCursor.row, 0, s.frame.Height-1) | |
| 85 | + | s.Col = clamp(s.savedCursor.col, 0, s.frame.Width-1) | |
| 86 | 86 | s.Style = s.savedCursor.style | |
| 87 | 87 | s.originMode = s.savedCursor.originMode | |
| 88 | 88 | 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 | 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 | 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 | 79 | func assertNoOrphanWideCells(t *testing.T, s *Screen) { | |
| 72 | 80 | t.Helper() | |
| 73 | - | for y := range s.Frame.Height { | |
| 74 | - | for x := range s.Frame.Width { | |
| 81 | + | for y := range s.frame.Height { | |
| 82 | + | for x := range s.frame.Width { | |
| 75 | 83 | c := cellAt(s, x, y) | |
| 76 | 84 | if c.Continuation { | |
| 77 | 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 | 88 | 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 | 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 | 97 | func lineText(s *Screen, y int) string { | |
| 90 | - | out := make([]rune, s.Frame.Width) | |
| 91 | - | for x := range s.Frame.Width { | |
| 92 | - | out[x] = s.Frame.At(x, y).Rune | |
| 98 | + | out := make([]rune, s.frame.Width) | |
| 99 | + | for x := range s.frame.Width { | |
| 100 | + | out[x] = s.frame.At(x, y).Rune | |
| 93 | 101 | } | |
| 94 | 102 | return string(out) | |
| 95 | 103 | } | |
M screen_integration_test.go +9 -9
| @@ -11,13 +11,13 @@ func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) { | |||
| 11 | 11 | r := renderer.New(renderer.Capabilities{}) | |
| 12 | 12 | ||
| 13 | 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 | 15 | t.Fatal(err) | |
| 16 | 16 | } | |
| 17 | 17 | s.ClearDamage() | |
| 18 | 18 | ||
| 19 | 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 | 21 | t.Fatal(err) | |
| 22 | 22 | } | |
| 23 | 23 | s.ClearDamage() | |
| @@ -25,16 +25,16 @@ func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) { | |||
| 25 | 25 | s.Write([]byte("\x1b[31mh\x1b[39m")) | |
| 26 | 26 | s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m")) | |
| 27 | 27 | ||
| 28 | - | for y := range s.Frame.Height { | |
| 29 | - | for x := range s.Frame.Width { | |
| 30 | - | r := s.Frame.At(x, y).Rune | |
| 28 | + | for y := range s.frame.Height { | |
| 29 | + | for x := range s.frame.Width { | |
| 30 | + | r := s.frame.At(x, y).Rune | |
| 31 | 31 | if r == '\r' || r == '\n' { | |
| 32 | 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 | 38 | if err != nil { | |
| 39 | 39 | t.Fatal(err) | |
| 40 | 40 | } | |
| @@ -56,18 +56,18 @@ func TestIntegrationScenarios(t *testing.T) { | |||
| 56 | 56 | s := NewScreen(8, 2) | |
| 57 | 57 | r := renderer.New(renderer.Capabilities{}) | |
| 58 | 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 | 60 | t.Fatal(err) | |
| 61 | 61 | } | |
| 62 | 62 | s.ClearDamage() | |
| 63 | 63 | ||
| 64 | 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 | 66 | t.Fatal(err) | |
| 67 | 67 | } | |
| 68 | 68 | s.ClearDamage() | |
| 69 | 69 | ||
| 70 | - | out, err := r.Draw(s.Frame, nil) | |
| 70 | + | out, err := r.Draw(s.frame, nil) | |
| 71 | 71 | if err != nil { | |
| 72 | 72 | t.Fatal(err) | |
| 73 | 73 | } | |
M screen_modes.go +13 -19
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | type screenState struct { | |
| 9 | - | frame renderer.Frame | |
| 10 | 9 | buffer *buffer | |
| 11 | 10 | row int | |
| 12 | 11 | col int | |
| @@ -92,8 +91,8 @@ func (s *Screen) colorSchemeReport() []byte { | |||
| 92 | 91 | } | |
| 93 | 92 | ||
| 94 | 93 | func (s *Screen) reset() { | |
| 95 | - | s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height) | |
| 96 | - | s.Frame = s.buffer.frame | |
| 94 | + | s.buffer = s.newBuffer(s.frame.Width, s.frame.Height) | |
| 95 | + | s.frame = s.buffer.frame | |
| 97 | 96 | s.Row, s.Col = 0, 0 | |
| 98 | 97 | s.Style = renderer.DefaultStyle() | |
| 99 | 98 | s.escapeBuf = s.escapeBuf[:0] | |
| @@ -117,10 +116,10 @@ func (s *Screen) reset() { | |||
| 117 | 116 | } | |
| 118 | 117 | ||
| 119 | 118 | func (s *Screen) setScrollRegion(parts []int) { | |
| 120 | - | if s.Frame.Height == 0 { | |
| 119 | + | if s.frame.Height == 0 { | |
| 121 | 120 | return | |
| 122 | 121 | } | |
| 123 | - | top, bottom := 1, s.Frame.Height | |
| 122 | + | top, bottom := 1, s.frame.Height | |
| 124 | 123 | if len(parts) > 0 && parts[0] > 0 { | |
| 125 | 124 | top = parts[0] | |
| 126 | 125 | } | |
| @@ -130,8 +129,8 @@ func (s *Screen) setScrollRegion(parts []int) { | |||
| 130 | 129 | if top >= bottom { | |
| 131 | 130 | s.resetScrollRegion() | |
| 132 | 131 | } else { | |
| 133 | - | s.scrollTop = clamp(top-1, 0, s.Frame.Height-1) | |
| 134 | - | s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1) | |
| 132 | + | s.scrollTop = clamp(top-1, 0, s.frame.Height-1) | |
| 133 | + | s.scrollBottom = clamp(bottom-1, 0, s.frame.Height-1) | |
| 135 | 134 | if s.scrollTop >= s.scrollBottom { | |
| 136 | 135 | s.resetScrollRegion() | |
| 137 | 136 | } | |
| @@ -141,8 +140,8 @@ func (s *Screen) setScrollRegion(parts []int) { | |||
| 141 | 140 | ||
| 142 | 141 | func (s *Screen) resetScrollRegion() { | |
| 143 | 142 | s.scrollTop = 0 | |
| 144 | - | if s.Frame.Height > 0 { | |
| 145 | - | s.scrollBottom = s.Frame.Height - 1 | |
| 143 | + | if s.frame.Height > 0 { | |
| 144 | + | s.scrollBottom = s.frame.Height - 1 | |
| 146 | 145 | } else { | |
| 147 | 146 | s.scrollBottom = 0 | |
| 148 | 147 | } | |
| @@ -200,7 +199,6 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||
| 200 | 199 | func (s *Screen) enterAlternateScreen() { | |
| 201 | 200 | if s.alternate == nil { | |
| 202 | 201 | s.alternate = &screenState{ | |
| 203 | - | frame: cloneFrame(s.Frame), | |
| 204 | 202 | buffer: s.buffer.clone(), | |
| 205 | 203 | row: s.Row, | |
| 206 | 204 | col: s.Col, | |
| @@ -216,8 +214,8 @@ func (s *Screen) enterAlternateScreen() { | |||
| 216 | 214 | } | |
| 217 | 215 | s.graphics = nil | |
| 218 | 216 | s.kittyPendingDisplay = nil | |
| 219 | - | s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height) | |
| 220 | - | s.Frame = s.buffer.frame | |
| 217 | + | s.buffer = s.newBuffer(s.frame.Width, s.frame.Height) | |
| 218 | + | s.frame = s.buffer.frame | |
| 221 | 219 | s.Row, s.Col = 0, 0 | |
| 222 | 220 | s.Style = renderer.DefaultStyle() | |
| 223 | 221 | s.savedCursor = cursorState{} | |
| @@ -231,13 +229,9 @@ func (s *Screen) exitAlternateScreen() { | |||
| 231 | 229 | } | |
| 232 | 230 | state := s.alternate | |
| 233 | 231 | s.buffer = state.buffer | |
| 234 | - | if s.buffer == nil { | |
| 235 | - | s.buffer = bufferFromFrame(cloneFrame(state.frame)) | |
| 236 | - | s.fillMissingRowIDs(s.buffer) | |
| 237 | - | } | |
| 238 | - | s.Frame = s.buffer.frame | |
| 239 | - | s.Row = clamp(state.row, 0, s.Frame.Height-1) | |
| 240 | - | s.Col = clamp(state.col, 0, s.Frame.Width-1) | |
| 232 | + | s.frame = s.buffer.frame | |
| 233 | + | s.Row = clamp(state.row, 0, s.frame.Height-1) | |
| 234 | + | s.Col = clamp(state.col, 0, s.frame.Width-1) | |
| 241 | 235 | s.Style = state.style | |
| 242 | 236 | s.scrollTop = state.scrollTop | |
| 243 | 237 | s.scrollBottom = state.scrollBottom | |
M screen_osc_test.go +3 -3
| @@ -13,7 +13,7 @@ func TestOnBellFiresForLoneBEL(t *testing.T) { | |||
| 13 | 13 | s.OnBell = func() { rang++ } | |
| 14 | 14 | s.Write([]byte("hi\ahi\a")) | |
| 15 | 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 | 19 | func TestOnBellIgnoresOSCTerminator(t *testing.T) { | |
| @@ -27,7 +27,7 @@ func TestOnBellIgnoresOSCTerminator(t *testing.T) { | |||
| 27 | 27 | func TestBELWithoutCallbackIsDiscarded(t *testing.T) { | |
| 28 | 28 | s := NewScreen(10, 2) | |
| 29 | 29 | 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 | 33 | func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) { | |
| @@ -54,7 +54,7 @@ func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) { | |||
| 54 | 54 | require.Equal(t, tt.want, s.TerminalTitle()) | |
| 55 | 55 | require.Equal(t, 0, bells) | |
| 56 | 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 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import renderer "github.com/bnema/vev-vt/core" | |
| 4 | - | ||
| 5 | 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 | 5 | s.geometry.Cols, s.geometry.Rows = width, height | |
| 8 | 6 | return | |
| 9 | 7 | } | |
| @@ -15,7 +13,6 @@ func (s *Screen) Resize(width, height int) { | |||
| 15 | 13 | s.Row, s.Col = 0, 0 | |
| 16 | 14 | if state := s.alternate; state != nil { | |
| 17 | 15 | state.buffer = s.newBuffer(width, height) | |
| 18 | - | state.frame = state.buffer.frame | |
| 19 | 16 | state.row, state.col = 0, 0 | |
| 20 | 17 | state.scrollTop, state.scrollBottom = 0, height-1 | |
| 21 | 18 | } | |
| @@ -49,18 +46,13 @@ func (s *Screen) Resize(width, height int) { | |||
| 49 | 46 | // cursors for each state are mapped by their single buffer layout pass. | |
| 50 | 47 | s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false) | |
| 51 | 48 | state := s.alternate | |
| 52 | - | if state.buffer == nil { | |
| 53 | - | state.buffer = bufferFromFrame(state.frame) | |
| 54 | - | s.fillMissingRowIDs(state.buffer) | |
| 55 | - | } | |
| 56 | 49 | state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true) | |
| 57 | - | state.frame = state.buffer.frame | |
| 58 | 50 | state.scrollTop, state.scrollBottom = 0, height-1 | |
| 59 | 51 | } else { | |
| 60 | 52 | s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true) | |
| 61 | 53 | } | |
| 62 | 54 | } | |
| 63 | - | s.Frame = s.buffer.frame | |
| 55 | + | s.frame = s.buffer.frame | |
| 64 | 56 | s.geometry.Cols, s.geometry.Rows = width, height | |
| 65 | 57 | // A resize can split an in-flight escape sequence from the terminal state it | |
| 66 | 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 | 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 | 67 | func clamp(v, lo, hi int) int { | |
| 87 | 68 | if hi < lo { | |
| 88 | 69 | return lo | |
M screen_resize_test.go +6 -6
| @@ -11,16 +11,16 @@ import ( | |||
| 11 | 11 | func TestResize(t *testing.T) { | |
| 12 | 12 | setRow := func(s *Screen, y int, text string) { | |
| 13 | 13 | for x, r := range []rune(text) { | |
| 14 | - | if x >= s.Frame.Width { | |
| 14 | + | if x >= s.frame.Width { | |
| 15 | 15 | 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 | 20 | rowString := func(s *Screen, y int) string { | |
| 21 | - | runes := make([]rune, s.Frame.Width) | |
| 22 | - | for x := range s.Frame.Width { | |
| 23 | - | runes[x] = s.Frame.At(x, y).Rune | |
| 21 | + | runes := make([]rune, s.frame.Width) | |
| 22 | + | for x := range s.frame.Width { | |
| 23 | + | runes[x] = s.frame.At(x, y).Rune | |
| 24 | 24 | } | |
| 25 | 25 | return string(runes) | |
| 26 | 26 | } | |
| @@ -216,7 +216,7 @@ func TestResize(t *testing.T) { | |||
| 216 | 216 | for range 200 { | |
| 217 | 217 | s := NewScreen(8, 4) | |
| 218 | 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 | 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 | 16 | // buffer.scrollUp severs that link on the way. Callers that merely advance, | |
| 17 | 17 | // such as LF and ESC D, ignore the result. | |
| 18 | 18 | func (s *Screen) index() (movedUp bool) { | |
| 19 | - | if s.Frame.Height == 0 { | |
| 19 | + | if s.frame.Height == 0 { | |
| 20 | 20 | return false | |
| 21 | 21 | } | |
| 22 | 22 | if s.Row == s.scrollBottom && s.Row >= s.scrollTop { | |
| 23 | 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 | 26 | s.Row++ | |
| 27 | 27 | return false | |
| 28 | 28 | } | |
| 29 | - | s.Row = s.Frame.Height - 1 | |
| 29 | + | s.Row = s.frame.Height - 1 | |
| 30 | 30 | return false | |
| 31 | 31 | } | |
| 32 | 32 | ||
| @@ -37,7 +37,7 @@ func (s *Screen) nextLine() { | |||
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | 39 | func (s *Screen) reverseIndex() { | |
| 40 | - | if s.Frame.Height == 0 { | |
| 40 | + | if s.frame.Height == 0 { | |
| 41 | 41 | return | |
| 42 | 42 | } | |
| 43 | 43 | if s.Row == s.scrollTop && s.Row <= s.scrollBottom { | |
| @@ -71,14 +71,14 @@ func (s *Screen) scrollDownBy(n int) { | |||
| 71 | 71 | // what the scroll observed keeps callers from re-deriving a predicate that this | |
| 72 | 72 | // function may have declined to act on at all. | |
| 73 | 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 | 75 | return false | |
| 76 | 76 | } | |
| 77 | 77 | top, bottom, ok := s.normalizedRegion(top, bottom) | |
| 78 | 78 | if !ok { | |
| 79 | 79 | return false | |
| 80 | 80 | } | |
| 81 | - | w := s.Frame.Width | |
| 81 | + | w := s.frame.Width | |
| 82 | 82 | height := bottom - top + 1 | |
| 83 | 83 | graphicsRows := n | |
| 84 | 84 | if n > height { | |
| @@ -89,7 +89,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) { | |||
| 89 | 89 | // frame's line offsets (recycling and blanking the evicted rows in place) | |
| 90 | 90 | // instead of copying cells. See renderer.Frame.ScrollUp. | |
| 91 | 91 | s.emitLineEvicted(top, n) | |
| 92 | - | s.Frame.ScrollUp(top, bottom, n) | |
| 92 | + | s.frame.ScrollUp(top, bottom, n) | |
| 93 | 93 | s.buffer.scrollUp(top, bottom, n) | |
| 94 | 94 | s.fillMissingRowIDs(s.buffer) | |
| 95 | 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 | 106 | // Read boundaries and IDs before the caller rotates the frame: a soft link | |
| 107 | 107 | // belongs to the row it follows, and rotation reassigns row indices. | |
| 108 | 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 | 113 | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) { | |
| 114 | 114 | if s.history != nil { | |
| 115 | 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 | 117 | panic(err) | |
| 118 | 118 | } | |
| 119 | - | // A nil error records the row; ErrHistoryRowTooWide explicitly leaves it | |
| 119 | + | // A nil error records the row; ErrHistoryRowTooLarge explicitly leaves it | |
| 120 | 120 | // unrecorded. Both cases continue to the eviction observer below. | |
| 121 | 121 | } | |
| 122 | 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 | 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 | 129 | return | |
| 130 | 130 | } | |
| 131 | 131 | top, bottom, ok := s.normalizedRegion(top, bottom) | |
| 132 | 132 | if !ok { | |
| 133 | 133 | return | |
| 134 | 134 | } | |
| 135 | - | w := s.Frame.Width | |
| 135 | + | w := s.frame.Width | |
| 136 | 136 | height := bottom - top + 1 | |
| 137 | 137 | graphicsRows := n | |
| 138 | 138 | if n > height { | |
| @@ -140,7 +140,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 140 | 140 | } | |
| 141 | 141 | s.scrollGraphicsRegion(top, bottom, graphicsRows) | |
| 142 | 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 | 144 | s.buffer.scrollDown(top, bottom, n) | |
| 145 | 145 | s.fillMissingRowIDs(s.buffer) | |
| 146 | 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 | 155 | return | |
| 156 | 156 | } | |
| 157 | 157 | delta := int64(rows) * int64(cellHeight) | |
| 158 | - | fullScreen := top == 0 && bottom == s.Frame.Height-1 | |
| 158 | + | fullScreen := top == 0 && bottom == s.frame.Height-1 | |
| 159 | 159 | snapshot := s.graphics.scene.Snapshot() | |
| 160 | 160 | placements := snapshot.Placements() | |
| 161 | 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 | 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 | 195 | return 0, 0, false | |
| 196 | 196 | } | |
| 197 | - | top = clamp(top, 0, s.Frame.Height-1) | |
| 198 | - | bottom = clamp(bottom, 0, s.Frame.Height-1) | |
| 197 | + | top = clamp(top, 0, s.frame.Height-1) | |
| 198 | + | bottom = clamp(bottom, 0, s.frame.Height-1) | |
| 199 | 199 | return top, bottom, top <= bottom | |
| 200 | 200 | } | |
M screen_scroll_test.go +3 -3
| @@ -27,7 +27,7 @@ func TestWideCharSurvivesRotatedScroll(t *testing.T) { | |||
| 27 | 27 | assertContinuation(t, s, 1, 1) | |
| 28 | 28 | assertBlank(t, s, 0, 2) | |
| 29 | 29 | assertBlank(t, s, 1, 2) | |
| 30 | - | if err := s.Frame.CheckInvariants(); err != nil { | |
| 30 | + | if err := s.frame.CheckInvariants(); err != nil { | |
| 31 | 31 | t.Fatalf("invariants: %v", err) | |
| 32 | 32 | } | |
| 33 | 33 | }, | |
| @@ -43,7 +43,7 @@ func TestWideCharSurvivesRotatedScroll(t *testing.T) { | |||
| 43 | 43 | assertCell(t, s, 0, 1, '世') | |
| 44 | 44 | assertContinuation(t, s, 1, 1) | |
| 45 | 45 | assertBlank(t, s, 0, 0) | |
| 46 | - | if err := s.Frame.CheckInvariants(); err != nil { | |
| 46 | + | if err := s.frame.CheckInvariants(); err != nil { | |
| 47 | 47 | t.Fatalf("invariants: %v", err) | |
| 48 | 48 | } | |
| 49 | 49 | }, | |
| @@ -72,7 +72,7 @@ func TestLineOffsetInvariantAfterVTSequences(t *testing.T) { | |||
| 72 | 72 | for i, seq := range seqs { | |
| 73 | 73 | s.Write(seq) | |
| 74 | 74 | s.ClearDamage() | |
| 75 | - | if err := s.Frame.CheckInvariants(); err != nil { | |
| 75 | + | if err := s.frame.CheckInvariants(); err != nil { | |
| 76 | 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 | 24 | MouseSGR bool | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | - | // ScreenSnapshot is an owned immutable-by-convention capture of the active | |
| 28 | - | // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be | |
| 29 | - | // mutated and remains valid while the snapshot is retained. | |
| 27 | + | // ScreenSnapshot is an owned immutable capture of the active terminal | |
| 28 | + | // viewport. It implements core.CellSource, and Row returns caller-owned storage. | |
| 30 | 29 | type ScreenSnapshot struct { | |
| 31 | 30 | frame renderer.Frame | |
| 32 | 31 | bounds []LineBound | |
| @@ -52,7 +51,7 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||
| 52 | 51 | nextRowID++ | |
| 53 | 52 | } | |
| 54 | 53 | return ScreenSnapshot{ | |
| 55 | - | frame: s.Frame.Clone(), | |
| 54 | + | frame: s.frame.Clone(), | |
| 56 | 55 | bounds: s.LineBounds(), | |
| 57 | 56 | rowIDs: s.RowIDs(), | |
| 58 | 57 | nextRowID: nextRowID, | |
| @@ -79,11 +78,11 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||
| 79 | 78 | func (s ScreenSnapshot) Columns() int { return s.frame.Width } | |
| 80 | 79 | func (s ScreenSnapshot) Rows() int { return s.frame.Height } | |
| 81 | 80 | ||
| 82 | - | func (s ScreenSnapshot) Row(y int) []renderer.Cell { | |
| 83 | - | return append([]renderer.Cell(nil), s.BorrowedRow(y)...) | |
| 84 | - | } | |
| 81 | + | // Cell returns the semantic cell at x, y. | |
| 82 | + | func (s ScreenSnapshot) Cell(x, y int) renderer.Cell { return s.frame.Cell(x, y) } | |
| 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 | 86 | if y < 0 || y >= s.frame.Height { | |
| 88 | 87 | return nil | |
| 89 | 88 | } | |
M screen_snapshot_test.go +17 -18
| @@ -25,7 +25,6 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||
| 25 | 25 | wantModes: ModeSnapshot{}, | |
| 26 | 26 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |
| 27 | 27 | require.Nil(t, snapshot.Row(0)) | |
| 28 | - | require.Nil(t, snapshot.BorrowedRow(0)) | |
| 29 | 28 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |
| 30 | 29 | }, | |
| 31 | 30 | }, | |
| @@ -48,7 +47,7 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||
| 48 | 47 | MouseSGR: true, | |
| 49 | 48 | }, | |
| 50 | 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 | 51 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |
| 53 | 52 | }, | |
| 54 | 53 | }, | |
| @@ -80,9 +79,8 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||
| 80 | 79 | wantTitle: "zero-columns", | |
| 81 | 80 | wantCursor: CursorSnapshot{Visible: true}, | |
| 82 | 81 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |
| 83 | - | require.NotNil(t, snapshot.BorrowedRow(0)) | |
| 84 | - | require.Empty(t, snapshot.BorrowedRow(0)) | |
| 85 | - | require.Nil(t, snapshot.Row(0)) | |
| 82 | + | require.NotNil(t, snapshot.Row(0)) | |
| 83 | + | require.Empty(t, snapshot.Row(0)) | |
| 86 | 84 | require.Empty(t, snapshot.Row(1)) | |
| 87 | 85 | require.Equal(t, LineBound{}, snapshot.Bound(1)) | |
| 88 | 86 | require.Nil(t, snapshot.Row(2)) | |
| @@ -100,7 +98,7 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||
| 100 | 98 | wantTitle: "zero-rows", | |
| 101 | 99 | wantCursor: CursorSnapshot{Visible: true}, | |
| 102 | 100 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |
| 103 | - | require.Nil(t, snapshot.BorrowedRow(0)) | |
| 101 | + | require.Nil(t, snapshot.Row(0)) | |
| 104 | 102 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |
| 105 | 103 | }, | |
| 106 | 104 | }, | |
| @@ -193,7 +191,7 @@ func TestScreenSnapshotFidelity(t *testing.T) { | |||
| 193 | 191 | {Rune: 'x', Style: renderer.DefaultStyle()}, | |
| 194 | 192 | renderer.BlankCell(), | |
| 195 | 193 | } | |
| 196 | - | copy(screen.Frame.Row(0), cells) | |
| 194 | + | screen.frame.WriteRow(0, 0, cells) | |
| 197 | 195 | screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true} | |
| 198 | 196 | return screen, cells, []LineBound{{End: 3, Soft: true}, {}} | |
| 199 | 197 | }, | |
| @@ -203,7 +201,7 @@ func TestScreenSnapshotFidelity(t *testing.T) { | |||
| 203 | 201 | make: func() (*Screen, []renderer.Cell, []LineBound) { | |
| 204 | 202 | screen := NewScreen(3, 1) | |
| 205 | 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 | 205 | return screen, cells, screen.LineBounds() | |
| 208 | 206 | }, | |
| 209 | 207 | wantModes: ModeSnapshot{AlternateScreen: true}, | |
| @@ -214,8 +212,8 @@ func TestScreenSnapshotFidelity(t *testing.T) { | |||
| 214 | 212 | t.Run(test.name, func(t *testing.T) { | |
| 215 | 213 | screen, wantCells, wantBounds := test.make() | |
| 216 | 214 | snapshot := screen.Snapshot() | |
| 217 | - | require.Equal(t, screen.Frame.Width, snapshot.Columns()) | |
| 218 | - | require.Equal(t, screen.Frame.Height, snapshot.Rows()) | |
| 215 | + | require.Equal(t, screen.frame.Width, snapshot.Columns()) | |
| 216 | + | require.Equal(t, screen.frame.Height, snapshot.Rows()) | |
| 219 | 217 | require.Equal(t, wantCells, snapshot.Row(0)) | |
| 220 | 218 | require.Equal(t, test.wantModes, snapshot.Modes()) | |
| 221 | 219 | for row, want := range wantBounds { | |
| @@ -234,7 +232,7 @@ func TestScreenSnapshotPreservesLogicalRowsAfterScroll(t *testing.T) { | |||
| 234 | 232 | snapshot := screen.Snapshot() | |
| 235 | 233 | for y, want := range []string{"DEF", "G "} { | |
| 236 | 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 | 236 | require.Equal(t, screen.LineBounds()[y], snapshot.Bound(y)) | |
| 239 | 237 | } | |
| 240 | 238 | } | |
| @@ -267,15 +265,15 @@ func TestScreenSnapshotOwnership(t *testing.T) { | |||
| 267 | 265 | require.Equal(t, wantBounds[0], snapshot.Bound(0)) | |
| 268 | 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 | 269 | secondSnapshot := screen.Snapshot() | |
| 272 | 270 | secondSnapshotBefore := secondSnapshot.Row(0) | |
| 273 | 271 | ||
| 274 | 272 | callerCopy := snapshot.Row(0) | |
| 275 | 273 | callerCopy[0] = renderer.Cell{Rune: 'z'} | |
| 276 | 274 | ||
| 277 | - | require.Equal(t, wantRows[0], snapshot.BorrowedRow(0)) | |
| 278 | - | require.Equal(t, liveBeforeCallerMutation, screen.Frame.Row(0), "mutating Row copy must not change live Screen") | |
| 275 | + | require.Equal(t, wantRows[0], snapshot.Row(0)) | |
| 276 | + | require.Equal(t, liveBeforeCallerMutation, screen.frame.Row(0), "mutating Row copy must not change live Screen") | |
| 279 | 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 | 331 | name: "screen with sealed chunks and mutable tail", | |
| 334 | 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 | 334 | screen.Write([]byte("111122223333444455")) | |
| 337 | 335 | return screen | |
| 338 | 336 | }, | |
| @@ -346,7 +344,8 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) { | |||
| 346 | 344 | var beforeHistory HistorySnapshotView | |
| 347 | 345 | var beforeRows [][]renderer.Cell | |
| 348 | 346 | var beforeBounds []LineBound | |
| 349 | - | beforeCap, beforeCellCap := 0, 0 | |
| 347 | + | beforeCap := 0 | |
| 348 | + | var beforeByteCap uint64 | |
| 350 | 349 | if screen.History() != nil { | |
| 351 | 350 | beforeHistory = screen.History().SnapshotView() | |
| 352 | 351 | beforeView := screen.History().View() | |
| @@ -357,7 +356,7 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) { | |||
| 357 | 356 | beforeBounds[i] = beforeView.Bound(i) | |
| 358 | 357 | } | |
| 359 | 358 | beforeCap = screen.History().Cap() | |
| 360 | - | beforeCellCap = screen.History().CellCap() | |
| 359 | + | beforeByteCap = screen.History().ByteCap() | |
| 361 | 360 | } | |
| 362 | 361 | ||
| 363 | 362 | _ = screen.Snapshot() | |
| @@ -380,7 +379,7 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) { | |||
| 380 | 379 | require.Equal(t, beforeRows, afterRows) | |
| 381 | 380 | require.Equal(t, beforeBounds, afterBounds) | |
| 382 | 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 | 14 | case '\r': | |
| 15 | 15 | s.Col = 0 | |
| 16 | 16 | case '\n', '\v', '\f': | |
| 17 | - | if s.Frame.Width > 0 && s.Col >= s.Frame.Width { | |
| 18 | - | s.Col = s.Frame.Width - 1 | |
| 17 | + | if s.frame.Width > 0 && s.Col >= s.frame.Width { | |
| 18 | + | s.Col = s.frame.Width - 1 | |
| 19 | 19 | } | |
| 20 | 20 | s.buffer.hard(s.Row) | |
| 21 | 21 | s.index() | |
| @@ -26,8 +26,8 @@ func (s *Screen) putRune(r rune) { | |||
| 26 | 26 | case '\t': | |
| 27 | 27 | // HT moves to the next tab stop without modifying the frame. Clamp at | |
| 28 | 28 | // the right edge rather than triggering deferred wrap or a scroll. | |
| 29 | - | if s.Frame.Width > 0 { | |
| 30 | - | s.Col = min(s.Col+8-s.Col%8, s.Frame.Width-1) | |
| 29 | + | if s.frame.Width > 0 { | |
| 30 | + | s.Col = min(s.Col+8-s.Col%8, s.frame.Width-1) | |
| 31 | 31 | } | |
| 32 | 32 | default: | |
| 33 | 33 | // Drop C1 control characters (U+0080-U+009F). | |
| @@ -58,23 +58,23 @@ func (s *Screen) putPrintable(r rune) { | |||
| 58 | 58 | if w == 0 { | |
| 59 | 59 | return | |
| 60 | 60 | } | |
| 61 | - | if s.Frame.Width == 0 || s.Frame.Height == 0 { | |
| 61 | + | if s.frame.Width == 0 || s.frame.Height == 0 { | |
| 62 | 62 | return | |
| 63 | 63 | } | |
| 64 | 64 | // Deferred wrap: cursor sits past the last column. | |
| 65 | - | if s.Col >= s.Frame.Width { | |
| 65 | + | if s.Col >= s.frame.Width { | |
| 66 | 66 | s.buffer.soft(s.Row) | |
| 67 | 67 | s.Col = 0 | |
| 68 | 68 | s.relinkWrap(s.index()) | |
| 69 | 69 | } | |
| 70 | - | if s.Row >= s.Frame.Height { | |
| 71 | - | s.Row = s.Frame.Height - 1 | |
| 70 | + | if s.Row >= s.frame.Height { | |
| 71 | + | s.Row = s.frame.Height - 1 | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | 74 | // A wide rune must never straddle the right edge. If it does not fit on the | |
| 75 | 75 | // current line, clear the abandoned last cell and wrap to the next line. | |
| 76 | - | if w == 2 && s.Col+1 >= s.Frame.Width { | |
| 77 | - | if s.Frame.Width < 2 { | |
| 76 | + | if w == 2 && s.Col+1 >= s.frame.Width { | |
| 77 | + | if s.frame.Width < 2 { | |
| 78 | 78 | // The screen is too narrow to ever hold a wide rune; store a narrow | |
| 79 | 79 | // replacement so the cell's renderer width matches its layout. | |
| 80 | 80 | r = '\uFFFD' | |
| @@ -83,18 +83,18 @@ func (s *Screen) putPrintable(r rune) { | |||
| 83 | 83 | // The abandoned cell may itself be the continuation of a pair whose | |
| 84 | 84 | // left half sits one column back; report damage over the full span. | |
| 85 | 85 | 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 | 87 | cx = s.Col - 1 | |
| 88 | 88 | } | |
| 89 | 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 | 91 | s.buffer.truncate(s.Row, cx) | |
| 92 | 92 | s.buffer.continueRow(s.Row) | |
| 93 | 93 | s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1}) | |
| 94 | 94 | s.Col = 0 | |
| 95 | 95 | s.relinkWrap(s.index()) | |
| 96 | - | if s.Row >= s.Frame.Height { | |
| 97 | - | s.Row = s.Frame.Height - 1 | |
| 96 | + | if s.Row >= s.frame.Height { | |
| 97 | + | s.Row = s.frame.Height - 1 | |
| 98 | 98 | } | |
| 99 | 99 | } | |
| 100 | 100 | } | |
| @@ -102,38 +102,33 @@ func (s *Screen) putPrintable(r rune) { | |||
| 102 | 102 | insertDamageX := s.Col | |
| 103 | 103 | insertDamageWidth := 0 | |
| 104 | 104 | if s.insertMode { | |
| 105 | - | row := s.Frame.Row(s.Row) | |
| 106 | - | leftSplit := s.Col > 0 && row[s.Col].Continuation | |
| 107 | - | for x := s.Frame.Width - 1; x >= s.Col+w; x-- { | |
| 108 | - | row[x] = row[x-w] | |
| 109 | - | } | |
| 110 | - | for x := s.Col; x < s.Col+w; x++ { | |
| 111 | - | row[x] = renderer.BlankCell() | |
| 112 | - | } | |
| 105 | + | leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation | |
| 106 | + | s.frame.CopyRow(s.Row, s.Col+w, s.Col, s.frame.Width-s.Col-w) | |
| 107 | + | s.frame.FillRow(s.Row, s.Col, s.Col+w, renderer.BlankCell()) | |
| 113 | 108 | s.repairRow(s.Row) | |
| 114 | 109 | s.buffer.insert(s.Row, s.Col, w) | |
| 115 | 110 | if leftSplit { | |
| 116 | 111 | insertDamageX = s.Col - 1 | |
| 117 | 112 | } | |
| 118 | - | insertDamageWidth = s.Frame.Width - insertDamageX | |
| 113 | + | insertDamageWidth = s.frame.Width - insertDamageX | |
| 119 | 114 | } | |
| 120 | 115 | ||
| 121 | 116 | // Determine the range of cells actually modified, extending over any wide | |
| 122 | 117 | // pair the write lands on so no orphaned half is left behind. | |
| 123 | 118 | 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 | 120 | 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 | 123 | hi = right | |
| 129 | 124 | } | |
| 130 | 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 | 129 | s.buffer.content(s.Row, s.Col+w) | |
| 135 | 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 | 133 | if insertDamageWidth > 0 { | |
| 139 | 134 | lo = min(lo, insertDamageX) | |
| @@ -149,19 +144,19 @@ func (s *Screen) putPrintable(r rune) { | |||
| 149 | 144 | // left half) holds — which the writer maintains. | |
| 150 | 145 | ||
| 151 | 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 | 148 | return | |
| 154 | 149 | } | |
| 155 | - | if s.Frame.At(x, y).Continuation { | |
| 150 | + | if s.frame.At(x, y).Continuation { | |
| 156 | 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 | 155 | return | |
| 161 | 156 | } | |
| 162 | - | if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation { | |
| 163 | - | s.Frame.Set(x, y, renderer.BlankCell()) | |
| 164 | - | s.Frame.Set(x+1, y, renderer.BlankCell()) | |
| 157 | + | if x+1 < s.frame.Width && s.frame.At(x+1, y).Continuation { | |
| 158 | + | s.frame.Set(x, y, renderer.BlankCell()) | |
| 159 | + | s.frame.Set(x+1, y, renderer.BlankCell()) | |
| 165 | 160 | } | |
| 166 | 161 | } | |
| 167 | 162 | ||
| @@ -181,32 +176,30 @@ func (s *Screen) eraseCell() renderer.Cell { | |||
| 181 | 176 | // returns the actual modified span [start, start+width). | |
| 182 | 177 | ||
| 183 | 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 | 180 | return x0, 0 | |
| 186 | 181 | } | |
| 187 | 182 | if x0 < 0 { | |
| 188 | 183 | x0 = 0 | |
| 189 | 184 | } | |
| 190 | - | if x1 > s.Frame.Width { | |
| 191 | - | x1 = s.Frame.Width | |
| 185 | + | if x1 > s.frame.Width { | |
| 186 | + | x1 = s.frame.Width | |
| 192 | 187 | } | |
| 193 | 188 | if x0 >= x1 { | |
| 194 | 189 | return x0, 0 | |
| 195 | 190 | } | |
| 196 | 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 | 193 | x0-- | |
| 199 | 194 | } | |
| 200 | 195 | // Right boundary: a continuation at x1 means its wide left half (at x1-1) is | |
| 201 | 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 | 198 | x1++ | |
| 204 | 199 | } | |
| 205 | - | fullRow := x0 <= 0 && x1 >= s.Frame.Width | |
| 200 | + | fullRow := x0 <= 0 && x1 >= s.frame.Width | |
| 206 | 201 | blank := s.eraseCell() | |
| 207 | - | for x := x0; x < x1; x++ { | |
| 208 | - | s.Frame.Set(x, y, blank) | |
| 209 | - | } | |
| 202 | + | s.frame.FillRow(y, x0, x1, blank) | |
| 210 | 203 | s.buffer.clear(y, x0, x1) | |
| 211 | 204 | if fullRow { | |
| 212 | 205 | s.buffer.rowIDs[y] = s.nextRowIDValue() | |
| @@ -220,7 +213,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||
| 220 | 213 | // at a boundary or by shifting cells. | |
| 221 | 214 | ||
| 222 | 215 | func (s *Screen) repairRow(y int) { | |
| 223 | - | repairFrameRow(s.Frame, y) | |
| 216 | + | repairFrameRow(s.frame, y) | |
| 224 | 217 | } | |
| 225 | 218 | ||
| 226 | 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 | 194 | name: "tab moves cursor without changing cells or damage", | |
| 195 | 195 | run: func(t *testing.T) { | |
| 196 | 196 | s := NewScreen(10, 2) | |
| 197 | - | for y := range s.Frame.Height { | |
| 198 | - | for x := range s.Frame.Width { | |
| 199 | - | s.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.Frame.Width + x)}) | |
| 197 | + | for y := range s.frame.Height { | |
| 198 | + | for x := range s.frame.Width { | |
| 199 | + | s.frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.frame.Width + x)}) | |
| 200 | 200 | } | |
| 201 | 201 | } | |
| 202 | - | before := s.Frame.Clone() | |
| 202 | + | before := s.frame.Clone() | |
| 203 | 203 | s.Row, s.Col = 1, 6 | |
| 204 | 204 | s.ClearDamage() | |
| 205 | 205 | ||
| @@ -207,7 +207,7 @@ func TestCursorControlChars(t *testing.T) { | |||
| 207 | 207 | if s.Row != 1 || s.Col != 8 { | |
| 208 | 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 | 211 | if got := s.Damage(); len(got) != 0 { | |
| 212 | 212 | t.Errorf("tab damage = %+v, want none", got) | |
| 213 | 213 | } | |
| @@ -218,7 +218,7 @@ func TestCursorControlChars(t *testing.T) { | |||
| 218 | 218 | if s.Row != 1 || s.Col != 9 { | |
| 219 | 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 | 222 | if got := s.Damage(); len(got) != 0 { | |
| 223 | 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 | 13 | cat >"$tmp/consumer/go.mod" <<EOF | |
| 14 | 14 | module example.com/vev-vt-consumer | |
| 15 | 15 | ||
| 16 | - | go 1.26 | |
| 16 | + | go 1.27 | |
| 17 | 17 | ||
| 18 | 18 | require github.com/bnema/vev-vt v0.0.0 | |
| 19 | 19 | ||
| @@ -33,7 +33,7 @@ import ( | |||
| 33 | 33 | func main() { | |
| 34 | 34 | screen := vt.NewScreen(4, 1) | |
| 35 | 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 | 37 | if err != nil { | |
| 38 | 38 | fmt.Fprintln(os.Stderr, err) | |
| 39 | 39 | os.Exit(1) | |
M scrollback_test.go +3 -3
| @@ -92,7 +92,7 @@ func TestOnLineEvictedAltScreenAndRotation(t *testing.T) { | |||
| 92 | 92 | if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) { | |
| 93 | 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 | 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 | 127 | for _, tc := range tests { | |
| 128 | 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 | 130 | tc.evict(s) | |
| 131 | 131 | view := s.History().SealAndView() | |
| 132 | 132 | if view.Len() == 0 { | |
| @@ -140,7 +140,7 @@ func TestEvictedLinesCarryTheirBound(t *testing.T) { | |||
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | 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 | 144 | // Fill the grid with wrapped content, then shrink so rows are evicted by | |
| 145 | 145 | // the reflow path in buffer.resize rather than by scrolling. | |
| 146 | 146 | s.Write([]byte("aaaaaaaabbbbbbbbcccccccc")) | |
M snapshot.go +63 -20
| @@ -40,7 +40,7 @@ func MarshalHistoryChunk(chunk *HistoryChunk) ([]byte, error) { | |||
| 40 | 40 | if chunk == nil { | |
| 41 | 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 | 46 | // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob. | |
| @@ -77,6 +77,9 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |||
| 77 | 77 | // HistoryFromBlobs restores history directly from sealed, oldest-first blobs | |
| 78 | 78 | // and the mandatory tail blob. It never feeds decoded rows through Append. | |
| 79 | 79 | func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) { | |
| 80 | + | if err := config.Validate(); err != nil { | |
| 81 | + | return nil, err | |
| 82 | + | } | |
| 80 | 83 | sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail) | |
| 81 | 84 | if err != nil { | |
| 82 | 85 | return nil, err | |
| @@ -89,14 +92,14 @@ func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryVi | |||
| 89 | 92 | sealedViews := make([]HistoryView, len(sealed)) | |
| 90 | 93 | for i, blob := range sealed { | |
| 91 | 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 | 96 | return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | |
| 94 | 97 | } | |
| 95 | 98 | sealedViews[i] = view | |
| 96 | 99 | } | |
| 97 | 100 | ||
| 98 | 101 | tailView, err := UnmarshalHistory(tail) | |
| 99 | - | if err != nil || len(tailView.chunks) > 1 || !validateRestoredHistoryView(tailView, seen) { | |
| 102 | + | if err != nil || !validateRestoredHistoryView(tailView, seen) { | |
| 100 | 103 | return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory) | |
| 101 | 104 | } | |
| 102 | 105 | for _, view := range extra { | |
| @@ -111,37 +114,73 @@ func restoreHistoryViews(config HistoryConfig, sealed []HistoryView, tail Histor | |||
| 111 | 114 | h := NewHistory(config) | |
| 112 | 115 | for _, view := range sealed { | |
| 113 | 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 | 119 | 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 | 125 | h.chunks = append(h.chunks, chunk) | |
| 119 | - | h.rows += len(chunk.rows) | |
| 120 | - | h.cells += view.Cells() | |
| 121 | - | h.evict() | |
| 126 | + | h.rows += chunk.len() | |
| 127 | + | h.cells += chunk.cells() | |
| 128 | + | h.logicalBytes += chunkBytes | |
| 122 | 129 | } | |
| 123 | 130 | h.nextRowID = max(h.nextRowID, tail.nextRowID) | |
| 124 | - | if h.maxRows == 0 || len(tail.chunks) == 0 { | |
| 131 | + | if h.maxRows == 0 { | |
| 125 | 132 | return h | |
| 126 | 133 | } | |
| 127 | - | chunk := tail.chunks[0] | |
| 128 | - | h.tail = chunk.rows | |
| 129 | - | h.tailBounds = append([]LineBound(nil), chunk.bounds...) | |
| 130 | - | h.tailIDs = append([]RowID(nil), chunk.rowIDs...) | |
| 131 | - | h.rows += len(h.tail) | |
| 132 | - | h.cells += tail.Cells() | |
| 133 | - | h.evict() | |
| 134 | - | h.normalizeTail() | |
| 134 | + | for _, chunk := range tail.chunks { | |
| 135 | + | for row := range chunk.len() { | |
| 136 | + | if h.prepareChunkRowStyles(chunk, row) > h.maxBytes { | |
| 137 | + | continue | |
| 138 | + | } | |
| 139 | + | for { | |
| 140 | + | delta := h.tailAppendDelta(chunk.width) | |
| 141 | + | if h.rows < h.maxRows && h.logicalBytes <= h.maxBytes-delta { | |
| 142 | + | h.appendTailChunkRow(chunk, row) | |
| 143 | + | h.recordTailRowStyles(chunk.width) | |
| 144 | + | h.tailBytes += delta | |
| 145 | + | h.logicalBytes += delta | |
| 146 | + | h.tailBounds = append(h.tailBounds, chunk.bounds[row]) | |
| 147 | + | h.tailIDs = append(h.tailIDs, chunk.rowIDs[row]) | |
| 148 | + | h.rows++ | |
| 149 | + | h.cells += chunk.width | |
| 150 | + | if len(h.tail) == h.chunkRows { | |
| 151 | + | h.sealTail() | |
| 152 | + | } | |
| 153 | + | break | |
| 154 | + | } | |
| 155 | + | h.evictOldest() | |
| 156 | + | h.prepareChunkRowStyles(chunk, row) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | } | |
| 135 | 160 | 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 | 177 | func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool { | |
| 139 | 178 | if view.nextRowID == 0 || view.nextRowID == ^RowID(0) { | |
| 140 | 179 | return false | |
| 141 | 180 | } | |
| 142 | 181 | var maxID RowID | |
| 143 | 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 | 184 | return false | |
| 146 | 185 | } | |
| 147 | 186 | for _, id := range chunk.rowIDs { | |
| @@ -162,6 +201,9 @@ func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool | |||
| 162 | 201 | // history contains the restored bounded history followed by the recovery | |
| 163 | 202 | // transcript. The transcript is decoded in full before history is restored. | |
| 164 | 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 | 207 | transcriptView, err := UnmarshalHistory(transcript) | |
| 166 | 208 | if err != nil { | |
| 167 | 209 | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |
| @@ -174,7 +216,8 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||
| 174 | 216 | history := restoreHistoryViews(config, sealedViews, tailView) | |
| 175 | 217 | remainingRows := transcriptView.rows | |
| 176 | 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 | 221 | remainingRows-- | |
| 179 | 222 | bound := chunk.bounds[i] | |
| 180 | 223 | if remainingRows == 0 { | |
| @@ -196,6 +239,6 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||
| 196 | 239 | for i := range screen.buffer.rowIDs { | |
| 197 | 240 | screen.buffer.rowIDs[i] = screen.nextRowIDValue() | |
| 198 | 241 | } | |
| 199 | - | screen.Frame = screen.buffer.frame | |
| 242 | + | screen.frame = screen.buffer.frame | |
| 200 | 243 | return screen, nil | |
| 201 | 244 | } | |
M snapshot_test.go +5 -4
| @@ -49,7 +49,7 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | |||
| 49 | 49 | ||
| 50 | 50 | func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |
| 51 | 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 | 53 | rows: 2, | |
| 54 | 54 | nextRowID: 3, | |
| 55 | 55 | }) | |
| @@ -64,8 +64,8 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |||
| 64 | 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) { | |
| 68 | - | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2}) | |
| 67 | + | func TestHistoryFromBlobsRestoresAccountingAndEvictsToBothBudgets(t *testing.T) { | |
| 68 | + | history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 2}) | |
| 69 | 69 | for _, text := range []string{"a", "b", "cc"} { | |
| 70 | 70 | requireHistoryAppend(t, history, historyRow(text)) | |
| 71 | 71 | } | |
| @@ -79,7 +79,8 @@ func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing | |||
| 79 | 79 | tail, err := MarshalHistoryTail(view) | |
| 80 | 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 | 84 | require.NoError(t, err) | |
| 84 | 85 | require.Equal(t, []string{"cc"}, historyViewTexts(restored.View())) | |
| 85 | 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 | 87 | for _, tc := range cases { | |
| 88 | 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 | 90 | s.Write([]byte(tc.input)) | |
| 91 | 91 | ||
| 92 | 92 | bounds := s.LineBounds() | |
| @@ -104,7 +104,7 @@ func TestSoftLinkSurvivesTheScrollItCaused(t *testing.T) { | |||
| 104 | 104 | // wrapped out of the bottom row used to be two unrelated physical rows, and | |
| 105 | 105 | // widening the pane left it split instead of rejoining it. | |
| 106 | 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 | 108 | s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij")) | |
| 109 | 109 | ||
| 110 | 110 | s.Resize(16, 4) | |
| @@ -122,14 +122,14 @@ func TestSoftLinkFromABottomRowWrapReachesHistory(t *testing.T) { | |||
| 122 | 122 | width = 8 | |
| 123 | 123 | 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 | 126 | // Wrap out of the bottom row, then push both physical rows into history. | |
| 127 | 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 | 129 | view := s.History().SealAndView() | |
| 130 | 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 | 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 | 134 | require.Equal(t, LineBound{End: 2, Soft: false}, view.Bound(6), "its continuation is a hard line end") | |
| 135 | 135 | } | |