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 | /coverage.out | 3 | /coverage.out | ||
| 4 | /testdata/generated/ | 4 | /testdata/generated/ | ||
| 5 | 5 | ||||
| 6 | + | # Local agent review notes | |||
| 7 | + | /.agent-reviews/ | |||
| 8 | + | ||||
| 6 | # Editor and OS files | 9 | # Editor and OS files | ||
| 7 | *.swp | 10 | *.swp | ||
| 8 | .DS_Store | 11 | .DS_Store | ||
M README.md +87 -72
| @@ -1,87 +1,102 @@ | |||||
| 1 | # vev-vt | 1 | # vev-vt | ||
| 2 | 2 | ||||
| 3 | - | `github.com/bnema/vev-vt` is a frontend-neutral VT terminal engine for Go. | 3 | + | A Go library for reading terminal output and keeping track of what should appear |
| 4 | - | It provides the terminal screen, scrollback history, immutable snapshots, | 4 | + | on screen. It powers [vev](https://github.com/bnema/vev). |
| 5 | - | stable history chunks, VTH3 history bytes, and the reusable cell/frame model. | |||
| 6 | - | The `ansi` package turns core frames and damage into transactional ANSI output. | |||
| 7 | 5 | ||||
| 8 | - | ## 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, | 13 | + | ## Install |
| 11 | - | history, snapshots, callbacks, and the public model aliases (`Cell`, `Style`, | 14 | + | |
| 12 | - | `RGB`, `Frame`, `Damage`, and `RuneWidth`). | 15 | + | Requires Go 1.27 or newer. |
| 13 | - | - `github.com/bnema/vev-vt/core` owns the frontend-neutral cell, style, frame, | |||
| 14 | - | damage, and width implementation. It has no terminal, renderer, transport, | |||
| 15 | - | or application dependencies. | |||
| 16 | - | - `github.com/bnema/vev-vt/ansi` is the concrete ANSI output package. It consumes | |||
| 17 | - | core frames and damage; it does not define a renderer-backend interface. | |||
| 18 | - | - `github.com/bnema/vev-vt/graphics` owns bounded renderer-neutral raster assets, | |||
| 19 | - | sparse placements, clipping fragments, and immutable scene snapshots. It has no | |||
| 20 | - | Kitty, ANSI, VT-policy, or transport dependency. | |||
| 21 | - | - `github.com/bnema/vev-vt/protocol/kittygraphics` parses bounded Kitty graphics | |||
| 22 | - | APC commands and translates the supported static/direct subset into `graphics`. | |||
| 23 | - | ||||
| 24 | - | ## Ownership contract | |||
| 25 | - | ||||
| 26 | - | Stateful values are single-owner and are not internally synchronized. Serialize | |||
| 27 | - | screen writes, resizes, snapshots, and history mutations in one owner. Parsing | |||
| 28 | - | and callbacks are synchronous: callbacks run before `Write` returns and follow | |||
| 29 | - | parser event order. Consume or copy response bytes during the callback. | |||
| 30 | - | ||||
| 31 | - | `Snapshot`, `HistorySnapshotView`, and `Row` methods provide owned captures or | |||
| 32 | - | copies. `BorrowedRow`, `Frame.Row`, and `HistoryView.Range` expose borrowed | |||
| 33 | - | storage with the lifetimes documented by their APIs; borrowed storage must not | |||
| 34 | - | be mutated or retained beyond its documented lifetime. Sealed `HistoryChunk` | |||
| 35 | - | identity is stable for the lifetime of a view. | |||
| 36 | - | ||||
| 37 | - | ## Compatibility | |||
| 38 | - | ||||
| 39 | - | VTH3 history bytes are canonical and are decoded strictly, including malformed, | |||
| 40 | - | truncated, and trailing input rejection. VEVS is an application-owned outer | |||
| 41 | - | snapshot envelope and is intentionally not implemented here. | |||
| 42 | - | ||||
| 43 | - | The module has no production dependencies. `github.com/stretchr/testify` is | |||
| 44 | - | used only by the test suite. Keep the public v0.x API and byte formats immutable | |||
| 45 | - | once released; behavior changes require explicit versioning and compatibility | |||
| 46 | - | evidence. | |||
| 47 | - | ||||
| 48 | - | ## Kitty graphics subset | |||
| 49 | - | ||||
| 50 | - | The VT accepts bounded static direct transmissions used by current | |||
| 51 | - | `kitten icat --transfer-mode=stream`: PNG, RGB, and RGBA assets; transmit, | |||
| 52 | - | transmit-and-display, place, query, and supported delete operations; chunked | |||
| 53 | - | Base64 uploads with optional zlib compression; source rectangles; cell extents; | |||
| 54 | - | pixel offsets; z-index; and cursor movement policy. Assets and decoded pixels | |||
| 55 | - | are bounded by explicit scene and parser limits. File, temporary-file, | |||
| 56 | - | shared-memory, animation, composition, relative placement, and | |||
| 57 | - | Unicode-placeholder commands remain unsupported. | |||
| 58 | - | ||||
| 59 | - | `Screen` allocates graphics state only after a Kitty APC. Graphics snapshots are | |||
| 60 | - | separate from cells and history bytes; VTH3 remains text/history-only. Static | |||
| 61 | - | placements move with terminal row scrolling and are clipped by the active | |||
| 62 | - | viewport; reflow and relative-placement movement remain unsupported. | |||
| 63 | - | ||||
| 64 | - | ### Headless graphics harness | |||
| 65 | - | ||||
| 66 | - | `cmd/graphics-harness` feeds a terminal byte capture into a fresh `Screen` and | |||
| 67 | - | writes its active graphics snapshot as a PNG using an internal reference | |||
| 68 | - | compositor. It is a development inspection tool, not a terminal emulator. | |||
| 69 | 16 | ||||
| 70 | ```sh | 17 | ```sh | ||
| 71 | - | go run ./cmd/graphics-harness \ | 18 | + | go get github.com/bnema/vev-vt |
| 72 | - | -input internal/graphicsharness/testdata/demo.apc \ | |||
| 73 | - | -output /tmp/graphics-harness.png \ | |||
| 74 | - | -cols 4 -rows 4 -pixel-width 4 -pixel-height 4 -scale 64 | |||
| 75 | ``` | 19 | ``` | ||
| 76 | 20 | ||||
| 77 | - | The included demo has an opaque blue background and a semi-transparent red | 21 | + | **Alpha software:** APIs and saved-history formats can change between versions. |
| 78 | - | overlay. Its output is suitable for direct image inspection. | 22 | + | The current history format does not read older VTH3 data. |
| 23 | + | ||||
| 24 | + | ## Read terminal output | |||
| 25 | + | ||||
| 26 | + | ```go | |||
| 27 | + | package main | |||
| 28 | + | ||||
| 29 | + | import ( | |||
| 30 | + | "fmt" | |||
| 31 | + | ||||
| 32 | + | vt "github.com/bnema/vev-vt" | |||
| 33 | + | ) | |||
| 79 | 34 | ||||
| 80 | - | ## 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 | ```sh | 96 | ```sh | ||
| 83 | go test ./... | 97 | go test ./... | ||
| 84 | go test ./... -race | 98 | go test ./... -race | ||
| 85 | go vet ./... | 99 | go vet ./... | ||
| 86 | - | go test ./... -run '^$' -bench='.' -benchmem | |||
| 87 | ``` | 100 | ``` | ||
| 101 | + | ||||
| 102 | + | [Storage benchmarks and design decisions →](docs/storage-optimization.md) | |||
M ansi/benchmark_test.go +2 -3
| @@ -206,9 +206,8 @@ func BenchmarkRendererScrollFastPath(b *testing.B) { | |||||
| 206 | } | 206 | } | ||
| 207 | 207 | ||||
| 208 | scrolled := NewFrame(120, 40) | 208 | scrolled := NewFrame(120, 40) | ||
| 209 | - | copy(scrolled.Cells[0:39*120], frame.Cells[120:40*120]) | 209 | + | for y := 0; y < 39; y++ { |
| 210 | - | for i := 39 * 120; i < 40*120; i++ { | 210 | + | scrolled.WriteRow(y, 0, frame.Row(y+1)) |
| 211 | - | scrolled.Cells[i] = BlankCell() | |||
| 212 | } | 211 | } | ||
| 213 | for x, r := range []rune("new output line") { | 212 | for x, r := range []rune("new output line") { | ||
| 214 | scrolled.Set(x, 39, Cell{Rune: r, Style: DefaultStyle()}) | 213 | scrolled.Set(x, 39, Cell{Rune: r, Style: DefaultStyle()}) | ||
M ansi/consumer_external_test.go +14 -0
| @@ -4,10 +4,24 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | + | vt "github.com/bnema/vev-vt" | |||
| 7 | "github.com/bnema/vev-vt/ansi" | 8 | "github.com/bnema/vev-vt/ansi" | ||
| 8 | "github.com/bnema/vev-vt/core" | 9 | "github.com/bnema/vev-vt/core" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 12 | + | func TestExternalANSIConsumesScreenCellSource(t *testing.T) { | |||
| 13 | + | screen := vt.NewScreen(2, 1) | |||
| 14 | + | screen.Write([]byte("OK")) | |||
| 15 | + | ||||
| 16 | + | output, err := ansi.New(ansi.Capabilities{}).Draw(screen, []core.Damage{core.FullRedraw()}) | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatalf("Draw: %v", err) | |||
| 19 | + | } | |||
| 20 | + | if !bytes.Contains(output, []byte("OK")) { | |||
| 21 | + | t.Fatalf("ANSI output = %q, want visible screen content", output) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 11 | func TestExternalANSIConsumesCoreFrameIndependently(t *testing.T) { | 25 | func TestExternalANSIConsumesCoreFrameIndependently(t *testing.T) { | ||
| 12 | frame := core.NewFrame(2, 1) | 26 | frame := core.NewFrame(2, 1) | ||
| 13 | frame.Set(0, 0, core.Cell{Rune: 'O', Style: core.DefaultStyle()}) | 27 | frame.Set(0, 0, core.Cell{Rune: 'O', Style: core.DefaultStyle()}) | ||
M ansi/damage_plan.go +2 -2
| @@ -10,7 +10,7 @@ const maxPlannedDamageSpans = 4096 | |||||
| 10 | // buildDamagePlan creates the bounded, canonical view of text and clear damage | 10 | // buildDamagePlan creates the bounded, canonical view of text and clear damage | ||
| 11 | // used for terminal emission. The source damage remains untouched because the | 11 | // used for terminal emission. The source damage remains untouched because the | ||
| 12 | // scroll and shadow paths need its original structural information. | 12 | // scroll and shadow paths need its original structural information. | ||
| 13 | - | func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool) { | 13 | + | func buildDamagePlan(frame CellSource, damage []Damage, skip *Damage) ([]Span, bool) { |
| 14 | if len(damage) == 1 { | 14 | if len(damage) == 1 { | ||
| 15 | d := damage[0] | 15 | d := damage[0] | ||
| 16 | if skip != nil && sameDamage(d, *skip) { | 16 | if skip != nil && sameDamage(d, *skip) { | ||
| @@ -52,7 +52,7 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool) | |||||
| 52 | return mergeDamageSpans(spans), false | 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 | x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height) | 56 | x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height) | ||
| 57 | if !ok { | 57 | if !ok { | ||
| 58 | return nil, false | 58 | return nil, false | ||
M ansi/delta_plan.go +33 -32
| @@ -30,21 +30,21 @@ type DeltaCandidate struct { | |||||
| 30 | frame Frame | 30 | frame Frame | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | - | func newDeltaCandidate(frame Frame, plan DeltaPlan) DeltaCandidate { | 33 | + | func newDeltaCandidate(frame CellSource, plan DeltaPlan) DeltaCandidate { |
| 34 | candidate := DeltaCandidate{Plan: plan} | 34 | candidate := DeltaCandidate{Plan: plan} | ||
| 35 | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | 35 | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | ||
| 36 | - | candidate.frame = frame.Clone() | 36 | + | candidate.frame = cloneCellSource(frame) |
| 37 | } | 37 | } | ||
| 38 | return candidate | 38 | return candidate | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | // PlanDelta prepares a non-mutating update from committed to frame. | 41 | // PlanDelta prepares a non-mutating update from committed to frame. | ||
| 42 | - | func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) { | 42 | + | func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) { |
| 43 | - | if err := frame.Validate(); err != nil { | 43 | + | if err := validateCellSource(frame); err != nil { |
| 44 | return DeltaCandidate{}, err | 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 | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | 48 | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | ||
| 49 | } | 49 | } | ||
| 50 | if err := committed.Validate(); err != nil { | 50 | if err := committed.Validate(); err != nil { | ||
| @@ -92,7 +92,7 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta | |||||
| 92 | return newDeltaCandidate(frame, plan), nil | 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 | spans, full := buildSingleDamageSpans(frame, d) | 96 | spans, full := buildSingleDamageSpans(frame, d) | ||
| 97 | if full { | 97 | if full { | ||
| 98 | return DeltaPlan{Snapshot: true} | 98 | return DeltaPlan{Snapshot: true} | ||
| @@ -104,14 +104,12 @@ func planSingleDamage(frame Frame, d Damage) DeltaPlan { | |||||
| 104 | return plan | 104 | return plan | ||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | - | func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) { | 107 | + | func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) { |
| 108 | var spans []Span | 108 | var spans []Span | ||
| 109 | - | for y := range frame.Height { | 109 | + | for y := range frame.Rows() { |
| 110 | - | frameRow := frame.Row(y) | |||
| 111 | - | committedRow := committed.Row(y) | |||
| 112 | dirty := false | 110 | dirty := false | ||
| 113 | - | for x := range frame.Width { | 111 | + | for x := range frame.Columns() { |
| 114 | - | if !frameRow[x].Equal(committedRow[x]) { | 112 | + | if !frame.Cell(x, y).Equal(committed.Cell(x, y)) { |
| 115 | dirty = true | 113 | dirty = true | ||
| 116 | break | 114 | break | ||
| 117 | } | 115 | } | ||
| @@ -120,14 +118,14 @@ func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) { | |||||
| 120 | if len(spans) == maxPlannedDamageSpans { | 118 | if len(spans) == maxPlannedDamageSpans { | ||
| 121 | return nil, true | 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 | return spans, false | 124 | return spans, false | ||
| 127 | } | 125 | } | ||
| 128 | 126 | ||||
| 129 | - | func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool { | 127 | + | func deltaCostsSnapshot(frame CellSource, spans []Span, hasScroll bool) bool { |
| 130 | - | if !hasScroll && frame.Height > 1 && len(spans) == 1 { | 128 | + | if !hasScroll && frame.Rows() > 1 && len(spans) == 1 { |
| 131 | return false | 129 | return false | ||
| 132 | } | 130 | } | ||
| 133 | 131 | ||||
| @@ -139,11 +137,11 @@ func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool { | |||||
| 139 | deltaCost += 12 + int64(span.Width) | 137 | deltaCost += 12 + int64(span.Width) | ||
| 140 | } | 138 | } | ||
| 141 | 139 | ||||
| 142 | - | minimumSnapshotCost := int64(frame.Height) * (12 + int64(frame.Width)) | 140 | + | minimumSnapshotCost := int64(frame.Rows()) * (12 + int64(frame.Columns())) |
| 143 | if deltaCost < minimumSnapshotCost { | 141 | if deltaCost < minimumSnapshotCost { | ||
| 144 | return false | 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 | if deltaCost >= maximumSnapshotCost { | 145 | if deltaCost >= maximumSnapshotCost { | ||
| 148 | return true | 146 | return true | ||
| 149 | } | 147 | } | ||
| @@ -152,40 +150,43 @@ func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool { | |||||
| 152 | } | 150 | } | ||
| 153 | 151 | ||||
| 154 | for _, span := range spans { | 152 | for _, span := range spans { | ||
| 155 | - | deltaCost += 4 * int64(styleRunCount(frame.Row(span.Y)[span.X:span.X+span.Width])-1) | 153 | + | deltaCost += 4 * int64(styleRunCountIn(frame, span.Y, span.X, span.Width)-1) |
| 156 | } | 154 | } | ||
| 157 | var snapshotCost int64 | 155 | var snapshotCost int64 | ||
| 158 | - | for y := range frame.Height { | 156 | + | for y := range frame.Rows() { |
| 159 | - | snapshotCost += spanCost(frame, y, 0, frame.Width) | 157 | + | snapshotCost += spanCost(frame, y, 0, frame.Columns()) |
| 160 | } | 158 | } | ||
| 161 | return deltaCost >= snapshotCost | 159 | return deltaCost >= snapshotCost | ||
| 162 | } | 160 | } | ||
| 163 | 161 | ||||
| 164 | - | func fullWidthRows(frame Frame, spans []Span) bool { | 162 | + | func fullWidthRows(frame CellSource, spans []Span) bool { |
| 165 | - | if len(spans) != frame.Height { | 163 | + | if len(spans) != frame.Rows() { |
| 166 | return false | 164 | return false | ||
| 167 | } | 165 | } | ||
| 168 | for y, span := range spans { | 166 | for y, span := range spans { | ||
| 169 | - | if span.Y != y || span.X != 0 || span.Width != frame.Width { | 167 | + | if span.Y != y || span.X != 0 || span.Width != frame.Columns() { |
| 170 | return false | 168 | return false | ||
| 171 | } | 169 | } | ||
| 172 | } | 170 | } | ||
| 173 | return true | 171 | return true | ||
| 174 | } | 172 | } | ||
| 175 | 173 | ||||
| 176 | - | func spanCost(frame Frame, y, x, width int) int64 { | 174 | + | func spanCost(frame CellSource, y, x, width int) int64 { |
| 177 | - | return 8 + int64(width) + 4*int64(styleRunCount(frame.Row(y)[x:x+width])) | 175 | + | return 8 + int64(width) + 4*int64(styleRunCountIn(frame, y, x, width)) |
| 178 | } | 176 | } | ||
| 179 | 177 | ||||
| 180 | - | func styleRunCount(cells []Cell) int { | 178 | + | func styleRunCountIn(frame CellSource, y, x, width int) int { |
| 181 | - | if len(cells) == 0 { | 179 | + | if width == 0 { |
| 182 | return 0 | 180 | return 0 | ||
| 183 | } | 181 | } | ||
| 184 | runs := 1 | 182 | runs := 1 | ||
| 185 | - | for i := 1; i < len(cells); i++ { | 183 | + | previous := frame.Cell(x, y).Style |
| 186 | - | if cells[i-1].Style != cells[i].Style && !cells[i-1].Style.Equal(cells[i].Style) { | 184 | + | for column := x + 1; column < x+width; column++ { |
| 185 | + | style := frame.Cell(column, y).Style | |||
| 186 | + | if previous != style && !previous.Equal(style) { | |||
| 187 | runs++ | 187 | runs++ | ||
| 188 | } | 188 | } | ||
| 189 | + | previous = style | |||
| 189 | } | 190 | } | ||
| 190 | return runs | 191 | return runs | ||
| 191 | } | 192 | } | ||
| @@ -204,9 +205,9 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||||
| 204 | dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count) | 205 | dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count) | ||
| 205 | } | 206 | } | ||
| 206 | for _, span := range c.Plan.Spans { | 207 | for _, span := range c.Plan.Spans { | ||
| 207 | - | dstRow := dst.Row(span.Y) | 208 | + | for x := span.X; x < span.X+span.Width; x++ { |
| 208 | - | srcRow := c.frame.Row(span.Y) | 209 | + | dst.Set(x, span.Y, c.frame.Cell(x, span.Y)) |
| 209 | - | copy(dstRow[span.X:span.X+span.Width], srcRow[span.X:span.X+span.Width]) | 210 | + | } |
| 210 | } | 211 | } | ||
| 211 | } | 212 | } | ||
| 212 | 213 | ||||
M ansi/delta_plan_test.go +3 -8
| @@ -169,7 +169,7 @@ func TestNoOpDeltaCandidateDoesNotCloneFrame(t *testing.T) { | |||||
| 169 | if err != nil { | 169 | if err != nil { | ||
| 170 | t.Fatalf("PlanDelta() error = %v", err) | 170 | t.Fatalf("PlanDelta() error = %v", err) | ||
| 171 | } | 171 | } | ||
| 172 | - | if candidate.frame.Width != 0 || candidate.frame.Height != 0 || candidate.frame.Cells != nil { | 172 | + | if candidate.frame.Width != 0 || candidate.frame.Height != 0 { |
| 173 | t.Fatal("no-op candidate owns frame storage") | 173 | t.Fatal("no-op candidate owns frame storage") | ||
| 174 | } | 174 | } | ||
| 175 | 175 | ||||
| @@ -202,12 +202,11 @@ func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) { | |||||
| 202 | } | 202 | } | ||
| 203 | } | 203 | } | ||
| 204 | 204 | ||||
| 205 | - | func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) { | 205 | + | func TestDeltaCandidateCommitCopiesLogicalRows(t *testing.T) { |
| 206 | frame := testFrame("aaaa", "bbbb", "cccc", "dddd") | 206 | frame := testFrame("aaaa", "bbbb", "cccc", "dddd") | ||
| 207 | frame.ScrollUp(0, 3, 1) | 207 | frame.ScrollUp(0, 3, 1) | ||
| 208 | fillFrameRow(frame, 3, "new!") | 208 | fillFrameRow(frame, 3, "new!") | ||
| 209 | committed := NewFrame(4, 4) | 209 | committed := NewFrame(4, 4) | ||
| 210 | - | cellsBase := &committed.Cells[0] | |||
| 211 | 210 | ||||
| 212 | candidate, err := PlanDelta(frame, nil, committed, true) | 211 | candidate, err := PlanDelta(frame, nil, committed, true) | ||
| 213 | if err != nil { | 212 | if err != nil { | ||
| @@ -215,9 +214,6 @@ func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) { | |||||
| 215 | } | 214 | } | ||
| 216 | candidate.Commit(&committed) | 215 | candidate.Commit(&committed) | ||
| 217 | 216 | ||||
| 218 | - | if &committed.Cells[0] != cellsBase { | |||
| 219 | - | t.Fatal("Commit replaced same-sized cell storage") | |||
| 220 | - | } | |||
| 221 | for y := range frame.Height { | 217 | for y := range frame.Height { | ||
| 222 | if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) { | 218 | if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) { | ||
| 223 | t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y)) | 219 | t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y)) | ||
| @@ -229,8 +225,7 @@ func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) { | |||||
| 229 | } | 225 | } | ||
| 230 | 226 | ||||
| 231 | func TestPlanDeltaRejectsInvalidFrame(t *testing.T) { | 227 | func TestPlanDeltaRejectsInvalidFrame(t *testing.T) { | ||
| 232 | - | frame := NewFrame(2, 1) | 228 | + | frame := Frame{Width: 2, Height: 1} |
| 233 | - | frame.Cells = nil | |||
| 234 | if _, err := PlanDelta(frame, nil, Frame{}, false); err == nil { | 229 | if _, err := PlanDelta(frame, nil, Frame{}, false); err == nil { | ||
| 235 | t.Fatal("PlanDelta accepted an invalid frame") | 230 | t.Fatal("PlanDelta accepted an invalid frame") | ||
| 236 | } | 231 | } | ||
M ansi/doc.go +2 -2
| @@ -2,8 +2,8 @@ | |||||
| 2 | // github.com/bnema/vev-vt/core model. | 2 | // github.com/bnema/vev-vt/core model. | ||
| 3 | // | 3 | // | ||
| 4 | // The package owns output planning, ANSI encoding, and a transactional renderer | 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 | 5 | + | // shadow. Renderers consume the read-only CellSource contract. Cell, Style, RGB, |
| 6 | - | // values are aliases of the core package and are owned by core. | 6 | + | // Frame, CellSource, Damage, and RuneWidth are aliases of the core package. |
| 7 | // | 7 | // | ||
| 8 | // [New] preserves the historical truecolor behavior. A renderer created by | 8 | // [New] preserves the historical truecolor behavior. A renderer created by | ||
| 9 | // [NewWithColorProfile] projects every color-bearing style field to its chosen | 9 | // [NewWithColorProfile] projects every color-bearing style field to its chosen | ||
M ansi/draw_state.go +9 -8
| @@ -165,22 +165,23 @@ const ( | |||||
| 165 | // entirely blank full-width line may instead use a vertical-only move | 165 | // entirely blank full-width line may instead use a vertical-only move | ||
| 166 | // plus 2K when that encoding is cheaper. Both clears require the default | 166 | // plus 2K when that encoding is cheaper. Both clears require the default | ||
| 167 | // pen (BCE: terminals fill cleared cells with the pen background). | 167 | // pen (BCE: terminals fill cleared cells with the pen background). | ||
| 168 | - | func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st *drawState) { | 168 | + | func (r *Renderer) emitSpan(out *bytes.Buffer, frame CellSource, y, x, width int, st *drawState) { |
| 169 | end := x + width | 169 | end := x + width | ||
| 170 | + | columns := frame.Columns() | |||
| 170 | // Trailing blank suffix is EL-eligible only when the span reaches the | 171 | // Trailing blank suffix is EL-eligible only when the span reaches the | ||
| 171 | // frame's right edge (EL clears to the end of the line, nothing less). | 172 | // frame's right edge (EL clears to the end of the line, nothing less). | ||
| 172 | t := end | 173 | t := end | ||
| 173 | - | if end == frame.Width { | 174 | + | if end == columns { |
| 174 | - | for t > x && isBlank(frame.At(t-1, y)) { | 175 | + | for t > x && isBlank(frame.Cell(t-1, y)) { |
| 175 | t-- | 176 | t-- | ||
| 176 | } | 177 | } | ||
| 177 | } | 178 | } | ||
| 178 | - | useEL := end == frame.Width && end-t >= elLen | 179 | + | useEL := end == columns && end-t >= elLen |
| 179 | if !useEL { | 180 | if !useEL { | ||
| 180 | t = end | 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 | // Entire line blank: compare "move to col 0 + EL" against | 185 | // Entire line blank: compare "move to col 0 + EL" against | ||
| 185 | // "vertical-only move + 2K" (2K clears the whole line from any | 186 | // "vertical-only move + 2K" (2K clears the whole line from any | ||
| 186 | // column, so a cheap same-column CUD/CUU can be reused). | 187 | // column, so a cheap same-column CUD/CUU can be reused). | ||
| @@ -195,7 +196,7 @@ func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st | |||||
| 195 | 196 | ||||
| 196 | st.moveTo(out, y, x) | 197 | st.moveTo(out, y, x) | ||
| 197 | for col := x; col < t; col++ { | 198 | for col := x; col < t; col++ { | ||
| 198 | - | cell := frame.At(col, y) | 199 | + | cell := frame.Cell(col, y) |
| 199 | // Continuation cells are the right half of a wide rune. Emit nothing: | 200 | // Continuation cells are the right half of a wide rune. Emit nothing: | ||
| 200 | // the terminal already advanced two columns for the left cell's rune. | 201 | // the terminal already advanced two columns for the left cell's rune. | ||
| 201 | if cell.Continuation { | 202 | if cell.Continuation { | ||
| @@ -222,7 +223,7 @@ func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st | |||||
| 222 | out.WriteString("\x1b[K") | 223 | out.WriteString("\x1b[K") | ||
| 223 | // EL does not move the cursor: it stays at the start of the blanks. | 224 | // EL does not move the cursor: it stays at the start of the blanks. | ||
| 224 | st.setCursor(y, t) | 225 | st.setCursor(y, t) | ||
| 225 | - | } else if end >= frame.Width { | 226 | + | } else if end >= columns { |
| 226 | st.invalidateCursor() | 227 | st.invalidateCursor() | ||
| 227 | } else { | 228 | } else { | ||
| 228 | st.setCursor(y, end) | 229 | st.setCursor(y, end) | ||
| @@ -234,7 +235,7 @@ func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st | |||||
| 234 | // malformed inputs the VT layer should never produce, but tracking must | 235 | // malformed inputs the VT layer should never produce, but tracking must | ||
| 235 | // stay certain, so drop it. (t is never a continuation on the EL path — | 236 | // stay certain, so drop it. (t is never a continuation on the EL path — | ||
| 236 | // blanks are not continuations — so this check is exact for both paths.) | 237 | // blanks are not continuations — so this check is exact for both paths.) | ||
| 237 | - | if frame.At(x, y).Continuation || (t < frame.Width && frame.At(t, y).Continuation) { | 238 | + | if frame.Cell(x, y).Continuation || (t < columns && frame.Cell(t, y).Continuation) { |
| 238 | st.invalidateCursor() | 239 | st.invalidateCursor() | ||
| 239 | } | 240 | } | ||
| 240 | } | 241 | } | ||
M ansi/frame.go +48 -1
| @@ -1,9 +1,56 @@ | |||||
| 1 | package ansi | 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 | // Frame is an alias for the frontend-neutral terminal grid. ANSI rendering | 10 | // Frame is an alias for the frontend-neutral terminal grid. ANSI rendering | ||
| 6 | // consumes it but does not own its model or storage policy. | 11 | // consumes it but does not own its model or storage policy. | ||
| 7 | type Frame = core.Frame | 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 | var NewFrame = core.NewFrame | 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 | colorProfile ColorProfile | 22 | colorProfile ColorProfile | ||
| 23 | width int | 23 | width int | ||
| 24 | height int | 24 | height int | ||
| 25 | - | shadow []Cell | 25 | + | hasCommitted bool |
| 26 | committed Frame | 26 | committed Frame | ||
| 27 | } | 27 | } | ||
| 28 | 28 | ||||
| @@ -47,7 +47,7 @@ func NewWithColorProfile(caps Capabilities, profile ColorProfile) *Renderer { | |||||
| 47 | func (r *Renderer) Reset() { | 47 | func (r *Renderer) Reset() { | ||
| 48 | r.width = 0 | 48 | r.width = 0 | ||
| 49 | r.height = 0 | 49 | r.height = 0 | ||
| 50 | - | r.shadow = nil | 50 | + | r.hasCommitted = false |
| 51 | r.committed = Frame{} | 51 | r.committed = Frame{} | ||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| @@ -63,7 +63,13 @@ func (p *PreparedDraw) Commit() { | |||||
| 63 | } | 63 | } | ||
| 64 | p.commitOnce.Do(func() { | 64 | p.commitOnce.Do(func() { | ||
| 65 | committed := p.renderer.committedFrame() | 65 | committed := p.renderer.committedFrame() | ||
| 66 | - | p.candidate.Commit(&committed) | 66 | + | if p.candidate.Plan.Snapshot { |
| 67 | + | // PreparedDraw privately owns this snapshot. Its shared Once makes | |||
| 68 | + | // transfer safe even when the caller retains copies of the draw. | |||
| 69 | + | committed = p.candidate.frame | |||
| 70 | + | } else { | |||
| 71 | + | p.candidate.Commit(&committed) | |||
| 72 | + | } | |||
| 67 | p.renderer.setCommittedFrame(committed) | 73 | p.renderer.setCommittedFrame(committed) | ||
| 68 | }) | 74 | }) | ||
| 69 | } | 75 | } | ||
| @@ -71,19 +77,21 @@ func (p *PreparedDraw) Commit() { | |||||
| 71 | // Prepare plans and encodes a transactional draw. The renderer advances only | 77 | // Prepare plans and encodes a transactional draw. The renderer advances only | ||
| 72 | // when the returned draw is committed. Keep at most one prepared draw | 78 | // when the returned draw is committed. Keep at most one prepared draw | ||
| 73 | // outstanding; commit or discard it before calling Prepare again. | 79 | // outstanding; commit or discard it before calling Prepare again. | ||
| 74 | - | func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDraw, error) { | 80 | + | func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) { |
| 75 | var candidate DeltaCandidate | 81 | var candidate DeltaCandidate | ||
| 76 | var err error | 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) { | 83 | + | if err = validateCellSource(frame); err != nil { |
| 78 | - | if err = frame.Validate(); err == nil { | 84 | + | return PreparedDraw{}, err |
| 79 | - | plan := planSingleDamage(frame, damage[0]) | 85 | + | } |
| 80 | - | candidate = DeltaCandidate{Plan: plan} | 86 | + | columns, rows := frame.Columns(), frame.Rows() |
| 81 | - | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | 87 | + | if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { |
| 82 | - | candidate.frame = frame.Clone() | 88 | + | plan := planSingleDamage(frame, damage[0]) |
| 83 | - | } | 89 | + | candidate = DeltaCandidate{Plan: plan} |
| 90 | + | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | |||
| 91 | + | candidate.frame = cloneCellSource(frame) | |||
| 84 | } | 92 | } | ||
| 85 | } else { | 93 | } else { | ||
| 86 | - | candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0) | 94 | + | candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted) |
| 87 | } | 95 | } | ||
| 88 | if err != nil { | 96 | if err != nil { | ||
| 89 | return PreparedDraw{}, err | 97 | return PreparedDraw{}, err | ||
| @@ -114,7 +122,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr | |||||
| 114 | } else { | 122 | } else { | ||
| 115 | if plan.Scroll.Height != 0 { | 123 | if plan.Scroll.Height != 0 { | ||
| 116 | scroll := plan.Scroll | 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 | for _, span := range plan.Spans { | 127 | for _, span := range plan.Spans { | ||
| 120 | r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st) | 128 | r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st) | ||
| @@ -128,7 +136,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr | |||||
| 128 | return prepared, nil | 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 | prepared, err := r.Prepare(frame, damage, false) | 140 | prepared, err := r.Prepare(frame, damage, false) | ||
| 133 | if err != nil { | 141 | if err != nil { | ||
| 134 | return nil, err | 142 | return nil, err | ||
| @@ -144,7 +152,7 @@ func (r *Renderer) committedFrame() Frame { | |||||
| 144 | func (r *Renderer) setCommittedFrame(frame Frame) { | 152 | func (r *Renderer) setCommittedFrame(frame Frame) { | ||
| 145 | r.width = frame.Width | 153 | r.width = frame.Width | ||
| 146 | r.height = frame.Height | 154 | r.height = frame.Height | ||
| 147 | - | r.shadow = frame.Cells | 155 | + | r.hasCommitted = true |
| 148 | r.committed = frame | 156 | r.committed = frame | ||
| 149 | } | 157 | } | ||
| 150 | 158 | ||||
| @@ -185,22 +193,23 @@ func hasScrollDamage(damage []Damage) bool { | |||||
| 185 | return false | 193 | return false | ||
| 186 | } | 194 | } | ||
| 187 | 195 | ||||
| 188 | - | func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) { | 196 | + | func (r *Renderer) writeFull(out *bytes.Buffer, frame CellSource, st *drawState) { |
| 189 | - | for y := range frame.Height { | 197 | + | columns := frame.Columns() |
| 190 | - | r.emitSpan(out, frame, y, 0, frame.Width, st) | 198 | + | for y := range frame.Rows() { |
| 199 | + | r.emitSpan(out, frame, y, 0, columns, st) | |||
| 191 | } | 200 | } | ||
| 192 | out.WriteString("\x1b[0m") | 201 | out.WriteString("\x1b[0m") | ||
| 193 | } | 202 | } | ||
| 194 | 203 | ||||
| 195 | - | func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []Span, st *drawState) { | 204 | + | func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame CellSource, spans []Span, st *drawState) { |
| 196 | for _, span := range spans { | 205 | for _, span := range spans { | ||
| 197 | r.emitSpan(out, frame, span.Y, span.X, span.Width, st) | 206 | r.emitSpan(out, frame, span.Y, span.X, span.Width, st) | ||
| 198 | } | 207 | } | ||
| 199 | out.WriteString("\x1b[0m") | 208 | out.WriteString("\x1b[0m") | ||
| 200 | } | 209 | } | ||
| 201 | - | func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) { | 210 | + | func clampRect(frame CellSource, x, y, width, height int) (int, int, int, int, bool) { |
| 202 | - | x, width, okX := clampRange(x, width, frame.Width) | 211 | + | x, width, okX := clampRange(x, width, frame.Columns()) |
| 203 | - | y, height, okY := clampRange(y, height, frame.Height) | 212 | + | y, height, okY := clampRange(y, height, frame.Rows()) |
| 204 | if !okX || !okY { | 213 | if !okX || !okY { | ||
| 205 | return 0, 0, 0, 0, false | 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 | if err != nil { | 146 | if err != nil { | ||
| 147 | t.Fatal(err) | 147 | t.Fatal(err) | ||
| 148 | } | 148 | } | ||
| 149 | - | if prepared.candidate.frame.Width != 0 || prepared.candidate.frame.Height != 0 || prepared.candidate.frame.Cells != nil { | 149 | + | if prepared.candidate.frame.Width != 0 || prepared.candidate.frame.Height != 0 { |
| 150 | t.Fatal("no-op prepared draw owns frame storage") | 150 | t.Fatal("no-op prepared draw owns frame storage") | ||
| 151 | } | 151 | } | ||
| 152 | prepared.Commit() | 152 | prepared.Commit() | ||
| @@ -193,10 +193,8 @@ func TestStyleResetDiscipline(t *testing.T) { | |||||
| 193 | 193 | ||||
| 194 | // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage. | 194 | // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage. | ||
| 195 | frame2 := NewFrame(4, 3) | 195 | frame2 := NewFrame(4, 3) | ||
| 196 | - | copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2 | 196 | + | frame2.WriteRow(0, 0, frame.Row(1)) |
| 197 | - | for i := 8; i < 12; i++ { | 197 | + | frame2.WriteRow(1, 0, frame.Row(2)) |
| 198 | - | frame2.Cells[i] = BlankCell() // bottom row blank | |||
| 199 | - | } | |||
| 200 | frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row | 198 | frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row | ||
| 201 | 199 | ||||
| 202 | damage := []Damage{ | 200 | damage := []Damage{ | ||
| @@ -353,9 +351,8 @@ func TestScrollFastPath(t *testing.T) { | |||||
| 353 | 351 | ||||
| 354 | // Build a new frame that has been scrolled up by 1 (like the VT model does). | 352 | // Build a new frame that has been scrolled up by 1 (like the VT model does). | ||
| 355 | scrolled := NewFrame(5, 4) | 353 | scrolled := NewFrame(5, 4) | ||
| 356 | - | copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3 | 354 | + | for y := 0; y < 3; y++ { |
| 357 | - | for i := 15; i < 20; i++ { | 355 | + | scrolled.WriteRow(y, 0, frame.Row(y+1)) |
| 358 | - | scrolled.Cells[i] = BlankCell() // row 3 blanked | |||
| 359 | } | 356 | } | ||
| 360 | scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()}) | 357 | scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()}) | ||
| 361 | scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()}) | 358 | scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()}) | ||
| @@ -567,8 +564,8 @@ func TestReset(t *testing.T) { | |||||
| 567 | 564 | ||||
| 568 | r.Reset() | 565 | r.Reset() | ||
| 569 | 566 | ||||
| 570 | - | if r.width != 0 || r.height != 0 || r.shadow != nil { | 567 | + | if r.width != 0 || r.height != 0 || r.hasCommitted { |
| 571 | - | t.Fatal("Reset should clear width/height/shadow") | 568 | + | t.Fatal("Reset should clear committed state") |
| 572 | } | 569 | } | ||
| 573 | 570 | ||||
| 574 | // After reset, the next draw should be a full draw. | 571 | // After reset, the next draw should be a full draw. | ||
| @@ -692,10 +689,8 @@ func TestScrollOnlyDamage(t *testing.T) { | |||||
| 692 | 689 | ||||
| 693 | // Scroll up 1 but the VT model would also emit DamageText for the new bottom row. | 690 | // Scroll up 1 but the VT model would also emit DamageText for the new bottom row. | ||
| 694 | scrolled := NewFrame(4, 3) | 691 | scrolled := NewFrame(4, 3) | ||
| 695 | - | copy(scrolled.Cells[0:8], frame.Cells[4:12]) | 692 | + | scrolled.WriteRow(0, 0, frame.Row(1)) |
| 696 | - | for i := 8; i < 12; i++ { | 693 | + | scrolled.WriteRow(1, 0, frame.Row(2)) |
| 697 | - | scrolled.Cells[i] = BlankCell() | |||
| 698 | - | } | |||
| 699 | scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()}) | 694 | scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()}) | ||
| 700 | 695 | ||||
| 701 | damage := []Damage{ | 696 | damage := []Damage{ | ||
| @@ -838,7 +833,7 @@ func TestFrameValidate(t *testing.T) { | |||||
| 838 | }{ | 833 | }{ | ||
| 839 | {name: "valid frame", frame: NewFrame(5, 3), wantErr: false}, | 834 | {name: "valid frame", frame: NewFrame(5, 3), wantErr: false}, | ||
| 840 | {name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true}, | 835 | {name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true}, | ||
| 841 | - | {name: "wrong cell count", frame: Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}, wantErr: true}, | 836 | + | {name: "missing storage", frame: Frame{Width: 2, Height: 2}, wantErr: true}, |
| 842 | } | 837 | } | ||
| 843 | 838 | ||||
| 844 | for _, tt := range tests { | 839 | for _, tt := range tests { | ||
| @@ -995,9 +990,11 @@ func TestDrawFallbackAndClampingEdgeCases(t *testing.T) { | |||||
| 995 | if rows := strings.Count(string(out), "H"); rows < changed.Height { | 990 | if rows := strings.Count(string(out), "H"); rows < changed.Height { | ||
| 996 | t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out)) | 991 | t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out)) | ||
| 997 | } | 992 | } | ||
| 998 | - | for i := range changed.Cells { | 993 | + | for y := range changed.Height { |
| 999 | - | if !r.shadow[i].Equal(changed.Cells[i]) { | 994 | + | for x := range changed.Width { |
| 1000 | - | t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i]) | 995 | + | if !r.committed.Cell(x, y).Equal(changed.Cell(x, y)) { |
| 996 | + | t.Fatalf("committed cell (%d,%d) = %+v, want %+v", x, y, r.committed.Cell(x, y), changed.Cell(x, y)) | |||
| 997 | + | } | |||
| 1001 | } | 998 | } | ||
| 1002 | } | 999 | } | ||
| 1003 | }, | 1000 | }, | ||
| @@ -1400,7 +1397,7 @@ func TestRendererPrepare(t *testing.T) { | |||||
| 1400 | 1397 | ||||
| 1401 | func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) { | 1398 | func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) { | ||
| 1402 | frame := NewFrame(2, 1) | 1399 | frame := NewFrame(2, 1) | ||
| 1403 | - | committed := Frame{Width: 2, Height: 1, Cells: make([]Cell, 2)} | 1400 | + | committed := Frame{Width: 2, Height: 1} |
| 1404 | 1401 | ||||
| 1405 | if _, err := PlanDelta(frame, nil, committed, false); err == nil { | 1402 | if _, err := PlanDelta(frame, nil, committed, false); err == nil { | ||
| 1406 | t.Fatal("PlanDelta accepted an invalid committed frame") | 1403 | t.Fatal("PlanDelta accepted an invalid committed frame") | ||
M ansi/scroll.go +27 -7
| @@ -5,7 +5,7 @@ import ( | |||||
| 5 | "strconv" | 5 | "strconv" | ||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | - | func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) { | 8 | + | func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) { |
| 9 | for _, d := range damage { | 9 | for _, d := range damage { | ||
| 10 | if d.Kind == DamageScrollUp && isSafeScroll(frame, d) { | 10 | if d.Kind == DamageScrollUp && isSafeScroll(frame, d) { | ||
| 11 | return d, true | 11 | return d, true | ||
| @@ -14,8 +14,8 @@ func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) { | |||||
| 14 | return Damage{}, false | 14 | return Damage{}, false | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | - | func isSafeScroll(frame Frame, d Damage) bool { | 17 | + | func isSafeScroll(frame CellSource, d Damage) bool { |
| 18 | - | return d.X == 0 && d.Width == frame.Width && d.Y >= 0 && d.Y <= frame.Height && d.Height > 0 && d.Height <= frame.Height-d.Y && d.Count > 0 && d.Count < d.Height | 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 | func emitScrollUp(out *bytes.Buffer, d Damage) { | 21 | func emitScrollUp(out *bytes.Buffer, d Damage) { | ||
| @@ -42,13 +42,33 @@ func emitScrollUp(out *bytes.Buffer, d Damage) { | |||||
| 42 | out.WriteString("\x1b[r") | 42 | out.WriteString("\x1b[r") | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | - | func canApplyScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool { | 45 | + | func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, committed Frame) bool { |
| 46 | + | switch frame := frame.(type) { | |||
| 47 | + | case Frame: | |||
| 48 | + | return canApplyDenseScrollAgainst(frame, scroll, damage, committed) | |||
| 49 | + | case *Frame: | |||
| 50 | + | return canApplyDenseScrollAgainst(*frame, scroll, damage, committed) | |||
| 51 | + | } | |||
| 52 | + | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | |||
| 53 | + | for x := range scroll.Width { | |||
| 54 | + | column := scroll.X + x | |||
| 55 | + | committedCell, frameCell := committed.Cell(column, y+scroll.Count), frame.Cell(column, y) | |||
| 56 | + | if committedCell == frameCell || committedCell.Equal(frameCell) { | |||
| 57 | + | continue | |||
| 58 | + | } | |||
| 59 | + | if !damageCoversCell(damage, column, y) { | |||
| 60 | + | return false | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | return true | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool { | |||
| 46 | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | 68 | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | ||
| 47 | - | frameRow := frame.Row(y) | |||
| 48 | - | committedRow := committed.Row(y + scroll.Count) | |||
| 49 | for x := range scroll.Width { | 69 | for x := range scroll.Width { | ||
| 50 | column := scroll.X + x | 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 | if committedCell == frameCell || committedCell.Equal(frameCell) { | 72 | if committedCell == frameCell || committedCell.Equal(frameCell) { | ||
| 53 | continue | 73 | continue | ||
| 54 | } | 74 | } | ||
M benchmark_test.go +2 -2
| @@ -32,8 +32,8 @@ func TestHistoryViewPartialTailAllocationBounded(t *testing.T) { | |||||
| 32 | 32 | ||||
| 33 | // Copying a partial tail costs one allocation per row plus a fixed set of | 33 | // Copying a partial tail costs one allocation per row plus a fixed set of | ||
| 34 | // headers: the chunk slice, its growth for the tail chunk, the chunk itself, | 34 | // headers: the chunk slice, its growth for the tail chunk, the chunk itself, | ||
| 35 | - | // the row slice, and the bounds slice parallel to it. | 35 | + | // the row slice, bounds, and logical-style metadata parallel to it. |
| 36 | - | const tailHeaders = 4 | 36 | + | const tailHeaders = 6 |
| 37 | 37 | ||||
| 38 | sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() }) | 38 | sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() }) | ||
| 39 | partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() }) | 39 | partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() }) | ||
M buffer.go +20 -25
| @@ -24,13 +24,9 @@ func newBuffer(width, height int) *buffer { | |||||
| 24 | return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height), rowIDs: make([]RowID, height)} | 24 | return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height), rowIDs: make([]RowID, height)} | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | - | func bufferFromFrame(frame renderer.Frame) *buffer { | |||
| 28 | - | return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height), rowIDs: make([]RowID, frame.Height)} | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | func (b *buffer) clone() *buffer { | 27 | func (b *buffer) clone() *buffer { | ||
| 32 | out := &buffer{ | 28 | out := &buffer{ | ||
| 33 | - | frame: cloneFrame(b.frame), | 29 | + | frame: b.frame.Clone(), |
| 34 | boundaries: append([]LineBound(nil), b.boundaries...), | 30 | boundaries: append([]LineBound(nil), b.boundaries...), | ||
| 35 | rowIDs: append([]RowID(nil), b.rowIDs...), | 31 | rowIDs: append([]RowID(nil), b.rowIDs...), | ||
| 36 | } | 32 | } | ||
| @@ -169,7 +165,7 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||||
| 169 | evictedBounds := make([]LineBound, 0, shift) | 165 | evictedBounds := make([]LineBound, 0, shift) | ||
| 170 | evictedIDs := make([]RowID, 0, shift) | 166 | evictedIDs := make([]RowID, 0, shift) | ||
| 171 | for y := range shift { | 167 | for y := range shift { | ||
| 172 | - | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | 168 | + | evicted = append(evicted, b.frame.Row(y)) |
| 173 | // The evicted row keeps its source width, so its extent needs no clamping. | 169 | // The evicted row keeps its source width, so its extent needs no clamping. | ||
| 174 | evictedBounds = append(evictedBounds, b.boundaries[y]) | 170 | evictedBounds = append(evictedBounds, b.boundaries[y]) | ||
| 175 | evictedIDs = append(evictedIDs, b.rowIDs[y]) | 171 | evictedIDs = append(evictedIDs, b.rowIDs[y]) | ||
| @@ -190,7 +186,9 @@ func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([] | |||||
| 190 | if sy >= b.frame.Height { | 186 | if sy >= b.frame.Height { | ||
| 191 | break | 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 | next.boundaries[y] = b.boundaries[sy] | 192 | next.boundaries[y] = b.boundaries[sy] | ||
| 195 | next.rowIDs[y] = b.rowIDs[sy] | 193 | next.rowIDs[y] = b.rowIDs[sy] | ||
| 196 | next.boundaries[y].End = min(next.boundaries[y].End, width) | 194 | next.boundaries[y].End = min(next.boundaries[y].End, width) | ||
| @@ -312,23 +310,24 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||||
| 312 | dst.rowIDs[outputRow-shift] = id | 310 | dst.rowIDs[outputRow-shift] = id | ||
| 313 | } | 311 | } | ||
| 314 | } | 312 | } | ||
| 315 | - | blankEvicted := func(outputRow int) []renderer.Cell { | 313 | + | blankOutput := func(outputRow int) { |
| 316 | if outputRow < shift { | 314 | if outputRow < shift { | ||
| 317 | out := evicted[outputRow*width : (outputRow+1)*width] | 315 | out := evicted[outputRow*width : (outputRow+1)*width] | ||
| 318 | for i := range out { | 316 | for i := range out { | ||
| 319 | out[i] = renderer.BlankCell() | 317 | out[i] = renderer.BlankCell() | ||
| 320 | } | 318 | } | ||
| 321 | - | return 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 | if dst != nil && outputRow < shift+dst.frame.Height { | 326 | if dst != nil && outputRow < shift+dst.frame.Height { | ||
| 324 | - | return dst.frame.Row(outputRow - shift) | 327 | + | dst.frame.Set(column, outputRow-shift, cell) |
| 325 | } | 328 | } | ||
| 326 | - | return nil | |||
| 327 | - | } | |||
| 328 | - | var output []renderer.Cell | |||
| 329 | - | if dst != nil && (shift > 0 || dst.frame.Height > 0) { | |||
| 330 | - | output = blankEvicted(0) | |||
| 331 | } | 329 | } | ||
| 330 | + | blankOutput(0) | |||
| 332 | finishRow := func(soft bool) { | 331 | finishRow := func(soft bool) { | ||
| 333 | // An output row belongs either to history or to the retained viewport, and | 332 | // An output row belongs either to history or to the retained viewport, and | ||
| 334 | // only this pass knows where it ended, so both destinations are written here. | 333 | // only this pass knows where it ended, so both destinations are written here. | ||
| @@ -344,7 +343,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||||
| 344 | row++ | 343 | row++ | ||
| 345 | col = 0 | 344 | col = 0 | ||
| 346 | if dst != nil { | 345 | if dst != nil { | ||
| 347 | - | output = blankEvicted(row) | 346 | + | blankOutput(row) |
| 348 | } | 347 | } | ||
| 349 | } | 348 | } | ||
| 350 | setPoint := func(line, offset, pointRow, pointCol int) { | 349 | setPoint := func(line, offset, pointRow, pointCol int) { | ||
| @@ -381,17 +380,16 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||||
| 381 | } | 380 | } | ||
| 382 | truncated := false | 381 | truncated := false | ||
| 383 | for y := start; y <= end && !truncated; y++ { | 382 | for y := start; y <= end && !truncated; y++ { | ||
| 384 | - | cells := b.frame.Row(y) | |||
| 385 | limit := b.boundaries[y].End | 383 | limit := b.boundaries[y].End | ||
| 386 | for x := 0; x < limit; { | 384 | for x := 0; x < limit; { | ||
| 387 | - | cell := cells[x] | 385 | + | cell := b.frame.At(x, y) |
| 388 | if cell.Continuation { // Repair malformed rows by dropping orphaned tails. | 386 | if cell.Continuation { // Repair malformed rows by dropping orphaned tails. | ||
| 389 | setPoint(start, offset, row, col) | 387 | setPoint(start, offset, row, col) | ||
| 390 | x++ | 388 | x++ | ||
| 391 | offset++ | 389 | offset++ | ||
| 392 | continue | 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 | w := 1 | 393 | w := 1 | ||
| 396 | if wide && width >= 2 { | 394 | if wide && width >= 2 { | ||
| 397 | w = 2 | 395 | w = 2 | ||
| @@ -406,9 +404,8 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||||
| 406 | } | 404 | } | ||
| 407 | setPoint(start, offset, row, col) | 405 | setPoint(start, offset, row, col) | ||
| 408 | if wide && width >= 2 { | 406 | if wide && width >= 2 { | ||
| 409 | - | if output != nil { | 407 | + | setOutput(row, col, cell) |
| 410 | - | output[col], output[col+1] = cell, cells[x+1] | 408 | + | setOutput(row, col+1, b.frame.At(x+1, y)) |
| 411 | - | } | |||
| 412 | setPoint(start, offset+1, row, col+1) | 409 | setPoint(start, offset+1, row, col+1) | ||
| 413 | col += 2 | 410 | col += 2 | ||
| 414 | x += 2 | 411 | x += 2 | ||
| @@ -419,9 +416,7 @@ func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, sh | |||||
| 419 | cell.Rune = '\uFFFD' | 416 | cell.Rune = '\uFFFD' | ||
| 420 | cell.Continuation = false | 417 | cell.Continuation = false | ||
| 421 | } | 418 | } | ||
| 422 | - | if output != nil { | 419 | + | setOutput(row, col, cell) |
| 423 | - | output[col] = cell | |||
| 424 | - | } | |||
| 425 | col++ | 420 | col++ | ||
| 426 | x++ | 421 | x++ | ||
| 427 | offset++ | 422 | offset++ | ||
M core/cell.go +2 -1
| @@ -9,11 +9,12 @@ package core | |||||
| 9 | type Cell struct { | 9 | type Cell struct { | ||
| 10 | Rune rune | 10 | Rune rune | ||
| 11 | Style Style | 11 | Style Style | ||
| 12 | + | Payload CellPayload | |||
| 12 | Continuation bool | 13 | Continuation bool | ||
| 13 | } | 14 | } | ||
| 14 | 15 | ||||
| 15 | func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} } | 16 | func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} } | ||
| 16 | 17 | ||||
| 17 | func (c Cell) Equal(other Cell) bool { | 18 | func (c Cell) Equal(other Cell) bool { | ||
| 18 | - | return c.Rune == other.Rune && c.Continuation == other.Continuation && c.Style.Equal(other.Style) | 19 | + | return c.Rune == other.Rune && c.Continuation == other.Continuation && c.Payload == other.Payload && c.Style.Equal(other.Style) |
| 19 | } | 20 | } | ||
A core/cell_test.go +69 -0
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestBlankCellIsSpaceWithDefaultStyle(t *testing.T) { | |||
| 6 | + | want := Cell{Rune: ' ', Style: DefaultStyle()} | |||
| 7 | + | if got := BlankCell(); got != want { | |||
| 8 | + | t.Fatalf("BlankCell() = %+v, want %+v", got, want) | |||
| 9 | + | } | |||
| 10 | + | if BlankCell() == (Cell{}) { | |||
| 11 | + | t.Fatal("blank cell unexpectedly equals the zero Cell") | |||
| 12 | + | } | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func TestCellEqualUsesSemanticStyleEquality(t *testing.T) { | |||
| 16 | + | leftStyle := Style{ | |||
| 17 | + | Foreground: 7, | |||
| 18 | + | HasForegroundRGB: true, | |||
| 19 | + | ForegroundRGB: RGB{R: 1, G: 2, B: 3}, | |||
| 20 | + | Background: -1, | |||
| 21 | + | BackgroundRGB: RGB{R: 4, G: 5, B: 6}, | |||
| 22 | + | UnderlineColor: 9, | |||
| 23 | + | UnderlineColorRGB: RGB{R: 7, G: 8, B: 9}, | |||
| 24 | + | } | |||
| 25 | + | rightStyle := leftStyle | |||
| 26 | + | rightStyle.Foreground = 99 | |||
| 27 | + | rightStyle.BackgroundRGB = RGB{R: 10, G: 11, B: 12} | |||
| 28 | + | rightStyle.UnderlineColor = 42 | |||
| 29 | + | ||||
| 30 | + | left := Cell{Rune: 'x', Style: leftStyle} | |||
| 31 | + | right := Cell{Rune: 'x', Style: rightStyle} | |||
| 32 | + | if !left.Equal(right) { | |||
| 33 | + | t.Fatal("cells with equal active style fields must compare equal") | |||
| 34 | + | } | |||
| 35 | + | if left == right { | |||
| 36 | + | t.Fatal("fixture must differ in inactive raw style fields") | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestCellEqualIncludesPayload(t *testing.T) { | |||
| 41 | + | payload, err := NewCellPayload("e\u0301", "https://example.com") | |||
| 42 | + | if err != nil { | |||
| 43 | + | t.Fatal(err) | |||
| 44 | + | } | |||
| 45 | + | plain := Cell{Rune: 'e', Style: DefaultStyle()} | |||
| 46 | + | rich := plain | |||
| 47 | + | rich.Payload = payload | |||
| 48 | + | if plain.Equal(rich) || rich.Equal(plain) { | |||
| 49 | + | t.Fatal("different payloads must not compare equal") | |||
| 50 | + | } | |||
| 51 | + | if !rich.Equal(rich) { | |||
| 52 | + | t.Fatal("payload cell must equal itself") | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestContinuationCellSemanticsRemainExplicit(t *testing.T) { | |||
| 57 | + | style := DefaultStyle() | |||
| 58 | + | head := Cell{Rune: '界', Style: style} | |||
| 59 | + | tail := Cell{Style: style, Continuation: true} | |||
| 60 | + | if head.Continuation || head.Rune != '界' { | |||
| 61 | + | t.Fatalf("wide head = %+v", head) | |||
| 62 | + | } | |||
| 63 | + | if !tail.Continuation || tail.Rune != 0 { | |||
| 64 | + | t.Fatalf("wide tail = %+v", tail) | |||
| 65 | + | } | |||
| 66 | + | if head.Equal(tail) { | |||
| 67 | + | t.Fatal("wide head and continuation tail unexpectedly compare equal") | |||
| 68 | + | } | |||
| 69 | + | } | |||
M core/doc.go +4 -3
| @@ -1,7 +1,8 @@ | |||||
| 1 | // Package vtcore defines the frontend-neutral terminal model shared by the VT | 1 | // Package vtcore defines the frontend-neutral terminal model shared by the VT | ||
| 2 | // emulator and concrete ANSI renderers. | 2 | // emulator and concrete ANSI renderers. | ||
| 3 | // | 3 | // | ||
| 4 | - | // Frame and other mutable values are single-owner. Frame.Row returns a backing | 4 | + | // Frame and other mutable values are single-owner. CellSource provides |
| 5 | - | // slice that is valid until the next scroll or resize; Frame.Clone returns | 5 | + | // storage-independent semantic reads, Frame.Row returns owned storage, and |
| 6 | - | // independent storage. The package has no frontend, transport, or vev policy. | 6 | + | // Frame.Clone returns an independent frame. The package has no frontend, |
| 7 | + | // transport, or vev policy. | |||
| 7 | package core | 8 | package core | ||
M core/frame.go +323 -104
| @@ -1,169 +1,388 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | - | import "fmt" | 3 | + | import ( |
| 4 | - | 4 | + | "fmt" | |
| 5 | - | // Frame is a fixed-size grid of cells. Logical rows are decoupled from their | 5 | + | "maps" |
| 6 | - | // physical storage through lineOffset: logical row y lives at | 6 | + | "math" |
| 7 | - | // Cells[lineOffset[y] : lineOffset[y]+Width]. This indirection lets full-width | 7 | + | ) |
| 8 | - | // scrolls rotate offsets instead of copying cell memory (NeoVim's grid | 8 | + | |
| 9 | - | // technique; cf. nvim grid.c line_offset[]). All cell access must go through | 9 | + | // CellSource exposes semantic terminal cells without exposing mutable storage. |
| 10 | - | // the accessors (At/Set/Row) so callers never assume a physical layout. | 10 | + | // Implementations may use any physical layout; callers must use Cell for reads. |
| 11 | + | type CellSource interface { | |||
| 12 | + | Columns() int | |||
| 13 | + | Rows() int | |||
| 14 | + | Cell(x, y int) Cell | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | // StoredCellLogicalBytes is the deterministic uncompressed size of one | |||
| 19 | + | // compact cell, independent of the Go allocator and host architecture. | |||
| 20 | + | StoredCellLogicalBytes uint64 = 16 | |||
| 21 | + | // RowDescriptorLogicalBytes is the deterministic size of one compact row | |||
| 22 | + | // descriptor. | |||
| 23 | + | RowDescriptorLogicalBytes uint64 = 4 | |||
| 24 | + | // StyleRecordLogicalBytes is the deterministic size of one canonical | |||
| 25 | + | // page-local style record. | |||
| 26 | + | StyleRecordLogicalBytes uint64 = 32 | |||
| 27 | + | ||||
| 28 | + | continuationFlag = uint8(1) | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | type storedCell struct { | |||
| 32 | + | rune int32 | |||
| 33 | + | styleID uint32 | |||
| 34 | + | payloadID uint32 | |||
| 35 | + | flags uint8 | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | type styleSlot struct { | |||
| 39 | + | style Style | |||
| 40 | + | refs uint32 | |||
| 41 | + | used bool | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | type cellPage struct { | |||
| 45 | + | cells []storedCell | |||
| 46 | + | rows []uint32 | |||
| 47 | + | styles []styleSlot | |||
| 48 | + | styleIndex map[Style]uint32 | |||
| 49 | + | freeStyles []uint32 | |||
| 50 | + | styleCount uint32 | |||
| 51 | + | payloads []payloadSlot | |||
| 52 | + | payloadIndex map[CellPayload]uint32 | |||
| 53 | + | freePayloads []uint32 | |||
| 54 | + | payloadBytes uint64 | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // Frame is a fixed-size semantic grid backed by one compact page. Cells carry | |||
| 58 | + | // page-local style IDs; callers never observe those IDs or the physical row | |||
| 59 | + | // layout. Logical rows are mapped through compact row descriptors so full-width | |||
| 60 | + | // scrolling rotates descriptors instead of copying cells. | |||
| 11 | type Frame struct { | 61 | type Frame struct { | ||
| 12 | Width int | 62 | Width int | ||
| 13 | Height int | 63 | Height int | ||
| 14 | - | Cells []Cell | 64 | + | page *cellPage |
| 15 | - | // lineOffset maps a logical row index to its physical base index into | |||
| 16 | - | // Cells. It is always a permutation of the canonical offsets y*Width. | |||
| 17 | - | lineOffset []int | |||
| 18 | } | 65 | } | ||
| 19 | 66 | ||||
| 20 | func NewFrame(width, height int) Frame { | 67 | func NewFrame(width, height int) Frame { | ||
| 21 | - | cells := make([]Cell, width*height) | 68 | + | cellCount, ok := frameCellCount(width, height) |
| 22 | - | for i := range cells { | 69 | + | if !ok { |
| 23 | - | cells[i] = BlankCell() | 70 | + | panic("frame cell count exceeds page limit") |
| 71 | + | } | |||
| 72 | + | page := &cellPage{ | |||
| 73 | + | cells: make([]storedCell, cellCount), | |||
| 74 | + | rows: make([]uint32, height), | |||
| 75 | + | styles: []styleSlot{{style: DefaultStyle(), refs: uint32(cellCount), used: true}}, | |||
| 76 | + | styleIndex: map[Style]uint32{DefaultStyle(): 0}, | |||
| 77 | + | styleCount: 1, | |||
| 78 | + | } | |||
| 79 | + | for i := range page.cells { | |||
| 80 | + | page.cells[i].rune = ' ' | |||
| 24 | } | 81 | } | ||
| 25 | - | lineOffset := make([]int, height) | 82 | + | for y := range page.rows { |
| 26 | - | for y := range lineOffset { | 83 | + | page.rows[y] = uint32(y * width) |
| 27 | - | lineOffset[y] = y * width | |||
| 28 | } | 84 | } | ||
| 29 | - | return Frame{Width: width, Height: height, Cells: cells, lineOffset: lineOffset} | 85 | + | return Frame{Width: width, Height: height, page: page} |
| 30 | } | 86 | } | ||
| 31 | 87 | ||||
| 32 | - | // Clone returns an independent copy preserving logical row contents. The clone | 88 | + | // Clone returns an independent compact page preserving physical row rotation |
| 33 | - | // uses canonical row storage so later scrolls or writes to the source frame do | 89 | + | // and page-local style IDs. |
| 34 | - | // not affect it. | |||
| 35 | func (f Frame) Clone() Frame { | 90 | func (f Frame) Clone() Frame { | ||
| 36 | - | clone := NewFrame(f.Width, f.Height) | 91 | + | if f.page == nil { |
| 37 | - | for y := range f.Height { | 92 | + | return Frame{Width: f.Width, Height: f.Height} |
| 38 | - | copy(clone.Row(y), f.Row(y)) | 93 | + | } |
| 94 | + | return Frame{ | |||
| 95 | + | Width: f.Width, | |||
| 96 | + | Height: f.Height, | |||
| 97 | + | page: &cellPage{ | |||
| 98 | + | cells: append([]storedCell(nil), f.page.cells...), | |||
| 99 | + | rows: append([]uint32(nil), f.page.rows...), | |||
| 100 | + | styles: append([]styleSlot(nil), f.page.styles...), | |||
| 101 | + | styleIndex: maps.Clone(f.page.styleIndex), | |||
| 102 | + | freeStyles: append([]uint32(nil), f.page.freeStyles...), | |||
| 103 | + | styleCount: f.page.styleCount, | |||
| 104 | + | payloads: append([]payloadSlot(nil), f.page.payloads...), | |||
| 105 | + | payloadIndex: maps.Clone(f.page.payloadIndex), | |||
| 106 | + | freePayloads: append([]uint32(nil), f.page.freePayloads...), | |||
| 107 | + | payloadBytes: f.page.payloadBytes, | |||
| 108 | + | }, | |||
| 39 | } | 109 | } | ||
| 40 | - | return clone | |||
| 41 | } | 110 | } | ||
| 42 | 111 | ||||
| 43 | - | // Replace copies src into f while reusing canonical storage when dimensions | 112 | + | // Replace replaces f with an independent structural copy of src's compact page. |
| 44 | - | // permit. src is read through logical rows so its physical row rotation is | 113 | + | // An invalid or empty source leaves the destination unchanged. |
| 45 | - | // preserved in the resulting contents without exposing layout internals. | |||
| 46 | func (f *Frame) Replace(src Frame) { | 114 | func (f *Frame) Replace(src Frame) { | ||
| 47 | if f == nil { | 115 | if f == nil { | ||
| 48 | return | 116 | return | ||
| 49 | } | 117 | } | ||
| 50 | - | if src.Width <= 0 || src.Height <= 0 || len(src.Cells) != src.Width*src.Height { | 118 | + | if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 { |
| 51 | - | *f = Frame{} | |||
| 52 | return | 119 | return | ||
| 53 | } | 120 | } | ||
| 121 | + | *f = src.Clone() | |||
| 122 | + | } | |||
| 54 | 123 | ||||
| 55 | - | sameDimensions := f.Width == src.Width && f.Height == src.Height | 124 | + | func frameCellCount(width, height int) (int, bool) { |
| 56 | - | cellCount := src.Width * src.Height | 125 | + | if width < 0 || height < 0 { |
| 57 | - | if sameDimensions && cap(f.Cells) >= cellCount { | 126 | + | return 0, false |
| 58 | - | f.Cells = f.Cells[:cellCount] | |||
| 59 | - | } else { | |||
| 60 | - | f.Cells = make([]Cell, cellCount) | |||
| 61 | } | 127 | } | ||
| 62 | - | if sameDimensions && cap(f.lineOffset) >= src.Height { | 128 | + | limit := min(uint64(math.MaxUint32), uint64(math.MaxInt)) |
| 63 | - | f.lineOffset = f.lineOffset[:src.Height] | 129 | + | w, h := uint64(width), uint64(height) |
| 64 | - | } else { | 130 | + | if w > limit || h > limit || w != 0 && w*h > limit { |
| 65 | - | f.lineOffset = make([]int, src.Height) | 131 | + | return 0, false |
| 132 | + | } | |||
| 133 | + | return int(w * h), true | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (f Frame) validateStorage() error { | |||
| 137 | + | if f.page == nil { | |||
| 138 | + | return fmt.Errorf("missing frame page") | |||
| 66 | } | 139 | } | ||
| 67 | - | f.Width = src.Width | 140 | + | cellCount, ok := frameCellCount(f.Width, f.Height) |
| 68 | - | f.Height = src.Height | 141 | + | if !ok { |
| 69 | - | for y := range f.lineOffset { | 142 | + | return fmt.Errorf("frame cell count exceeds page limit") |
| 70 | - | f.lineOffset[y] = y * f.Width | |||
| 71 | } | 143 | } | ||
| 72 | - | for y := range src.Height { | 144 | + | if len(f.page.cells) != cellCount { |
| 73 | - | copy(f.Row(y), src.Row(y)) | 145 | + | return fmt.Errorf("invalid cell count: got %d want %d", len(f.page.cells), cellCount) |
| 74 | } | 146 | } | ||
| 147 | + | if len(f.page.rows) != f.Height { | |||
| 148 | + | return fmt.Errorf("row descriptor count: got %d want %d", len(f.page.rows), f.Height) | |||
| 149 | + | } | |||
| 150 | + | return nil | |||
| 75 | } | 151 | } | ||
| 76 | 152 | ||||
| 77 | func (f Frame) Validate() error { | 153 | func (f Frame) Validate() error { | ||
| 78 | if f.Width <= 0 || f.Height <= 0 { | 154 | if f.Width <= 0 || f.Height <= 0 { | ||
| 79 | return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height) | 155 | return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height) | ||
| 80 | } | 156 | } | ||
| 81 | - | if len(f.Cells) != f.Width*f.Height { | 157 | + | return f.validateStorage() |
| 82 | - | return fmt.Errorf("invalid cell count: got %d want %d", len(f.Cells), f.Width*f.Height) | |||
| 83 | - | } | |||
| 84 | - | if len(f.lineOffset) != f.Height { | |||
| 85 | - | return fmt.Errorf("invalid lineOffset length: got %d want %d", len(f.lineOffset), f.Height) | |||
| 86 | - | } | |||
| 87 | - | return nil | |||
| 88 | } | 158 | } | ||
| 89 | 159 | ||||
| 90 | - | // CheckInvariants verifies that lineOffset is a permutation of the canonical | 160 | + | // CheckInvariants validates row ownership, style references, dictionary keys, |
| 91 | - | // row offsets: every entry is a non-negative multiple of Width within bounds, | 161 | + | // and exact reference counts. It is intended for tests and assertions. |
| 92 | - | // and every logical row maps to a distinct physical row. It is used by tests | |||
| 93 | - | // and assertions; the hot path uses the lighter Validate. | |||
| 94 | func (f Frame) CheckInvariants() error { | 162 | func (f Frame) CheckInvariants() error { | ||
| 95 | - | if f.Width <= 0 { | 163 | + | if err := f.validateStorage(); err != nil { |
| 96 | - | return fmt.Errorf("invalid width %d", f.Width) | 164 | + | return err |
| 97 | } | 165 | } | ||
| 98 | - | if len(f.lineOffset) != f.Height { | 166 | + | seenRows := make([]bool, f.Height) |
| 99 | - | return fmt.Errorf("lineOffset length %d != height %d", len(f.lineOffset), f.Height) | 167 | + | refs := make([]uint32, len(f.page.styles)) |
| 168 | + | for y, offset := range f.page.rows { | |||
| 169 | + | if f.Width == 0 { | |||
| 170 | + | if offset != 0 { | |||
| 171 | + | return fmt.Errorf("row %d: nonzero offset %d for zero width", y, offset) | |||
| 172 | + | } | |||
| 173 | + | continue | |||
| 174 | + | } | |||
| 175 | + | if offset%uint32(f.Width) != 0 { | |||
| 176 | + | return fmt.Errorf("row %d: offset %d is not a multiple of width %d", y, offset, f.Width) | |||
| 177 | + | } | |||
| 178 | + | physical := int(offset) / f.Width | |||
| 179 | + | if physical >= f.Height || seenRows[physical] { | |||
| 180 | + | return fmt.Errorf("row %d: invalid or duplicate physical row %d", y, physical) | |||
| 181 | + | } | |||
| 182 | + | seenRows[physical] = true | |||
| 183 | + | } | |||
| 184 | + | for i, cell := range f.page.cells { | |||
| 185 | + | if int(cell.styleID) >= len(f.page.styles) || !f.page.styles[cell.styleID].used { | |||
| 186 | + | return fmt.Errorf("cell %d: unresolved style ID %d", i, cell.styleID) | |||
| 187 | + | } | |||
| 188 | + | refs[cell.styleID]++ | |||
| 100 | } | 189 | } | ||
| 101 | - | seen := make([]bool, f.Height) | 190 | + | free := make([]bool, len(f.page.styles)) |
| 102 | - | for y, off := range f.lineOffset { | 191 | + | for position, id := range f.page.freeStyles { |
| 103 | - | if off < 0 || off%f.Width != 0 { | 192 | + | if int(id) >= len(f.page.styles) { |
| 104 | - | return fmt.Errorf("row %d: offset %d is not a non-negative multiple of width %d", y, off, f.Width) | 193 | + | return fmt.Errorf("free style entry %d has out-of-range ID %d", position, id) |
| 105 | } | 194 | } | ||
| 106 | - | phys := off / f.Width | 195 | + | if f.page.styles[id].used { |
| 107 | - | if phys >= f.Height { | 196 | + | return fmt.Errorf("free style entry %d identifies used style %d", position, id) |
| 108 | - | return fmt.Errorf("row %d: offset %d maps to physical row %d out of range [0,%d)", y, off, phys, f.Height) | |||
| 109 | } | 197 | } | ||
| 110 | - | if seen[phys] { | 198 | + | if free[id] { |
| 111 | - | return fmt.Errorf("row %d: physical row %d already mapped by another logical row", y, phys) | 199 | + | return fmt.Errorf("free style ID %d appears more than once", id) |
| 112 | } | 200 | } | ||
| 113 | - | seen[phys] = 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 { | 234 | + | // LogicalBytes returns deterministic uncompressed page bytes independent of Go |
| 119 | - | return f.Cells[f.lineOffset[y]+x] | 235 | + | // pointer size, map capacity, and allocator overhead. |
| 236 | + | func (f Frame) LogicalBytes() uint64 { | |||
| 237 | + | if f.page == nil { | |||
| 238 | + | return 0 | |||
| 239 | + | } | |||
| 240 | + | return uint64(len(f.page.cells))*StoredCellLogicalBytes + uint64(len(f.page.rows))*RowDescriptorLogicalBytes + uint64(f.page.styleCount)*StyleRecordLogicalBytes + f.page.payloadBytes | |||
| 120 | } | 241 | } | ||
| 121 | 242 | ||||
| 243 | + | // StyleCount returns the number of live page-local styles, including default ID zero. | |||
| 244 | + | func (f Frame) StyleCount() int { | |||
| 245 | + | if f.page == nil { | |||
| 246 | + | return 0 | |||
| 247 | + | } | |||
| 248 | + | return int(f.page.styleCount) | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func (f Frame) Columns() int { return f.Width } | |||
| 252 | + | func (f Frame) Rows() int { return f.Height } | |||
| 253 | + | func (f Frame) Cell(x, y int) Cell { | |||
| 254 | + | stored := f.page.cells[f.offset(x, y)] | |||
| 255 | + | return Cell{Rune: rune(stored.rune), Style: f.page.styles[stored.styleID].style, Payload: f.payload(stored.payloadID), Continuation: stored.flags&continuationFlag != 0} | |||
| 256 | + | } | |||
| 257 | + | func (f Frame) At(x, y int) Cell { return f.Cell(x, y) } | |||
| 258 | + | ||||
| 122 | func (f Frame) Set(x, y int, cell Cell) { | 259 | func (f Frame) Set(x, y int, cell Cell) { | ||
| 123 | - | f.Cells[f.lineOffset[y]+x] = 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 | func (f Frame) Row(y int) []Cell { | 277 | func (f Frame) Row(y int) []Cell { | ||
| 130 | - | base := f.lineOffset[y] | 278 | + | if y < 0 || y >= f.Height { |
| 131 | - | return f.Cells[base : base+f.Width] | 279 | + | panic("frame row out of range") |
| 280 | + | } | |||
| 281 | + | row := make([]Cell, f.Width) | |||
| 282 | + | for x := range row { | |||
| 283 | + | row[x] = f.Cell(x, y) | |||
| 284 | + | } | |||
| 285 | + | return row | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func (f Frame) WriteRow(y, x int, cells []Cell) int { | |||
| 289 | + | count := min(len(cells), f.Width-x) | |||
| 290 | + | for i := range count { | |||
| 291 | + | f.Set(x+i, y, cells[i]) | |||
| 292 | + | } | |||
| 293 | + | return count | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func (f Frame) CopyRow(y, dst, src, count int) { | |||
| 297 | + | if count <= 0 || dst == src { | |||
| 298 | + | return | |||
| 299 | + | } | |||
| 300 | + | if dst < src { | |||
| 301 | + | for i := range count { | |||
| 302 | + | f.Set(dst+i, y, f.Cell(src+i, y)) | |||
| 303 | + | } | |||
| 304 | + | return | |||
| 305 | + | } | |||
| 306 | + | for i := count - 1; i >= 0; i-- { | |||
| 307 | + | f.Set(dst+i, y, f.Cell(src+i, y)) | |||
| 308 | + | } | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func (f Frame) FillRow(y, start, end int, cell Cell) { | |||
| 312 | + | for x := max(0, start); x < min(f.Width, end); x++ { | |||
| 313 | + | f.Set(x, y, cell) | |||
| 314 | + | } | |||
| 132 | } | 315 | } | ||
| 133 | 316 | ||||
| 134 | - | // ScrollUp scrolls the logical rows in [top,bottom] up by n lines by rotating | |||
| 135 | - | // their physical offsets: no per-cell copying. Rows scrolled off the top are | |||
| 136 | - | // recycled to the bottom and blanked in place. It assumes the full frame width | |||
| 137 | - | // and 0 <= top <= bottom < Height and 0 < n <= bottom-top+1 (the caller | |||
| 138 | - | // clamps). The receiver is a value, but lineOffset and Cells alias the caller's | |||
| 139 | - | // backing arrays, so in-place mutations are visible to the caller. | |||
| 140 | func (f Frame) ScrollUp(top, bottom, n int) { | 317 | func (f Frame) ScrollUp(top, bottom, n int) { | ||
| 141 | for ; n > 0; n-- { | 318 | for ; n > 0; n-- { | ||
| 142 | - | recycled := f.lineOffset[top] | 319 | + | recycled := f.page.rows[top] |
| 143 | - | for y := top; y < bottom; y++ { | 320 | + | copy(f.page.rows[top:bottom], f.page.rows[top+1:bottom+1]) |
| 144 | - | f.lineOffset[y] = f.lineOffset[y+1] | 321 | + | f.page.rows[bottom] = recycled |
| 145 | - | } | 322 | + | f.blankPhysicalRow(recycled) |
| 146 | - | f.lineOffset[bottom] = recycled | |||
| 147 | - | blankRow(f.Cells[recycled : recycled+f.Width]) | |||
| 148 | } | 323 | } | ||
| 149 | } | 324 | } | ||
| 150 | 325 | ||||
| 151 | - | // ScrollDown scrolls the logical rows in [top,bottom] down by n lines by | |||
| 152 | - | // rotating their physical offsets. Rows scrolled off the bottom are recycled | |||
| 153 | - | // to the top and blanked in place. See ScrollUp for preconditions. | |||
| 154 | func (f Frame) ScrollDown(top, bottom, n int) { | 326 | func (f Frame) ScrollDown(top, bottom, n int) { | ||
| 155 | for ; n > 0; n-- { | 327 | for ; n > 0; n-- { | ||
| 156 | - | recycled := f.lineOffset[bottom] | 328 | + | recycled := f.page.rows[bottom] |
| 157 | - | for y := bottom; y > top; y-- { | 329 | + | copy(f.page.rows[top+1:bottom+1], f.page.rows[top:bottom]) |
| 158 | - | f.lineOffset[y] = f.lineOffset[y-1] | 330 | + | f.page.rows[top] = recycled |
| 331 | + | f.blankPhysicalRow(recycled) | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | func (f Frame) offset(x, y int) int { | |||
| 336 | + | if x < 0 || x >= f.Width { | |||
| 337 | + | panic("frame column out of range") | |||
| 338 | + | } | |||
| 339 | + | if y < 0 || y >= f.Height { | |||
| 340 | + | panic("frame row out of range") | |||
| 341 | + | } | |||
| 342 | + | return int(f.page.rows[y]) + x | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func (f Frame) blankPhysicalRow(offset uint32) { | |||
| 346 | + | for x := range f.Width { | |||
| 347 | + | index := int(offset) + x | |||
| 348 | + | f.releasePayload(f.page.cells[index].payloadID) | |||
| 349 | + | oldID := f.page.cells[index].styleID | |||
| 350 | + | if oldID != 0 { | |||
| 351 | + | f.page.styles[0].refs++ | |||
| 352 | + | f.releaseStyle(oldID) | |||
| 159 | } | 353 | } | ||
| 160 | - | f.lineOffset[top] = recycled | 354 | + | f.page.cells[index] = storedCell{rune: ' '} |
| 161 | - | blankRow(f.Cells[recycled : recycled+f.Width]) | |||
| 162 | } | 355 | } | ||
| 163 | } | 356 | } | ||
| 164 | 357 | ||||
| 165 | - | func blankRow(row []Cell) { | 358 | + | func (f Frame) internStyle(style Style) uint32 { |
| 166 | - | for i := range row { | 359 | + | style = style.Canonical() |
| 167 | - | row[i] = BlankCell() | 360 | + | if id, ok := f.page.styleIndex[style]; ok { |
| 361 | + | f.page.styles[id].refs++ | |||
| 362 | + | return id | |||
| 363 | + | } | |||
| 364 | + | var id uint32 | |||
| 365 | + | if n := len(f.page.freeStyles); n != 0 { | |||
| 366 | + | id = f.page.freeStyles[n-1] | |||
| 367 | + | f.page.freeStyles = f.page.freeStyles[:n-1] | |||
| 368 | + | f.page.styles[id] = styleSlot{style: style, refs: 1, used: true} | |||
| 369 | + | } else { | |||
| 370 | + | id = uint32(len(f.page.styles)) | |||
| 371 | + | f.page.styles = append(f.page.styles, styleSlot{style: style, refs: 1, used: true}) | |||
| 372 | + | } | |||
| 373 | + | f.page.styleIndex[style] = id | |||
| 374 | + | f.page.styleCount++ | |||
| 375 | + | return id | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | func (f Frame) releaseStyle(id uint32) { | |||
| 379 | + | slot := &f.page.styles[id] | |||
| 380 | + | slot.refs-- | |||
| 381 | + | if id == 0 || slot.refs != 0 { | |||
| 382 | + | return | |||
| 168 | } | 383 | } | ||
| 384 | + | delete(f.page.styleIndex, slot.style) | |||
| 385 | + | *slot = styleSlot{} | |||
| 386 | + | f.page.freeStyles = append(f.page.freeStyles, id) | |||
| 387 | + | f.page.styleCount-- | |||
| 169 | } | 388 | } | ||
A core/frame_benchmark_test.go +21 -0
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func BenchmarkCompactFrameBuild10Kx120(b *testing.B) { | |||
| 6 | + | const rows, columns = 10_000, 120 | |||
| 7 | + | style := Style{Bold: true, Foreground: 4} | |||
| 8 | + | b.ReportAllocs() | |||
| 9 | + | b.ReportMetric(rows*columns, "cells/op") | |||
| 10 | + | var logicalBytes uint64 | |||
| 11 | + | for b.Loop() { | |||
| 12 | + | frame := NewFrame(columns, rows) | |||
| 13 | + | for y := range rows { | |||
| 14 | + | for x := range columns { | |||
| 15 | + | frame.Set(x, y, Cell{Rune: 'x', Style: style}) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | logicalBytes = frame.LogicalBytes() | |||
| 19 | + | } | |||
| 20 | + | b.ReportMetric(float64(logicalBytes), "logical-B/op") | |||
| 21 | + | } | |||
A core/frame_bounds_test.go +69 -0
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestFrameColumnsCannotAddressAdjacentRows(t *testing.T) { | |||
| 11 | + | for _, x := range []int{-1, 3, 4, math.MaxInt} { | |||
| 12 | + | f := NewFrame(3, 3) | |||
| 13 | + | f.Set(0, 1, Cell{Rune: 'x', Style: DefaultStyle()}) | |||
| 14 | + | f.ScrollUp(0, 2, 1) | |||
| 15 | + | before := f.Clone() | |||
| 16 | + | for y := range f.Height { | |||
| 17 | + | require.Panics(t, func() { f.Cell(x, y) }) | |||
| 18 | + | require.Panics(t, func() { f.Set(x, y, Cell{Rune: 'z'}) }) | |||
| 19 | + | } | |||
| 20 | + | for y := range f.Height { | |||
| 21 | + | require.Equal(t, before.Row(y), f.Row(y)) | |||
| 22 | + | } | |||
| 23 | + | require.NoError(t, f.CheckInvariants()) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestFrameRejectsInvalidRows(t *testing.T) { | |||
| 28 | + | for _, width := range []int{0, 3} { | |||
| 29 | + | f := NewFrame(width, 3) | |||
| 30 | + | require.Len(t, f.Row(1), width) | |||
| 31 | + | for _, y := range []int{-1, 3, math.MaxInt} { | |||
| 32 | + | require.Panics(t, func() { f.Row(y) }) | |||
| 33 | + | require.Panics(t, func() { f.Cell(0, y) }) | |||
| 34 | + | require.Panics(t, func() { f.Set(0, y, BlankCell()) }) | |||
| 35 | + | } | |||
| 36 | + | require.NoError(t, f.CheckInvariants()) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestFillRowClipsColumns(t *testing.T) { | |||
| 41 | + | f := NewFrame(3, 3) | |||
| 42 | + | f.ScrollDown(0, 2, 1) | |||
| 43 | + | f.FillRow(1, math.MinInt, math.MaxInt, Cell{Rune: 'x', Style: DefaultStyle()}) | |||
| 44 | + | for y := range f.Height { | |||
| 45 | + | for x := range f.Width { | |||
| 46 | + | want := rune(' ') | |||
| 47 | + | if y == 1 { | |||
| 48 | + | want = 'x' | |||
| 49 | + | } | |||
| 50 | + | require.Equal(t, want, f.Cell(x, y).Rune) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | require.NoError(t, f.CheckInvariants()) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestReplaceInvalidSourcePreservesDestination(t *testing.T) { | |||
| 57 | + | for _, src := range []Frame{{}, {Width: 2, Height: 2}, NewFrame(0, 2), NewFrame(2, 0)} { | |||
| 58 | + | dst := NewFrame(2, 2) | |||
| 59 | + | dst.Set(1, 1, Cell{Rune: 'x', Style: DefaultStyle()}) | |||
| 60 | + | before := dst.Clone() | |||
| 61 | + | dst.Replace(src) | |||
| 62 | + | require.Equal(t, before.Width, dst.Width) | |||
| 63 | + | require.Equal(t, before.Height, dst.Height) | |||
| 64 | + | for y := range dst.Height { | |||
| 65 | + | require.Equal(t, before.Row(y), dst.Row(y)) | |||
| 66 | + | } | |||
| 67 | + | require.NoError(t, dst.CheckInvariants()) | |||
| 68 | + | } | |||
| 69 | + | } | |||
M core/frame_test.go +206 -6
| @@ -1,12 +1,16 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 3 | + | import ( |
| 4 | + | "math" | |||
| 5 | + | "reflect" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 4 | 8 | ||||
| 5 | func TestFrameCanonicalOffsetsAndInvariants(t *testing.T) { | 9 | func TestFrameCanonicalOffsetsAndInvariants(t *testing.T) { | ||
| 6 | f := NewFrame(4, 3) | 10 | f := NewFrame(4, 3) | ||
| 7 | for y := range f.Height { | 11 | for y := range f.Height { | ||
| 8 | - | if f.lineOffset[y] != y*f.Width { | 12 | + | if f.page.rows[y] != uint32(y*f.Width) { |
| 9 | - | t.Fatalf("lineOffset[%d] = %d, want %d", y, f.lineOffset[y], y*f.Width) | 13 | + | t.Fatalf("row offset %d = %d, want %d", y, f.page.rows[y], y*f.Width) |
| 10 | } | 14 | } | ||
| 11 | } | 15 | } | ||
| 12 | if err := f.CheckInvariants(); err != nil { | 16 | if err := f.CheckInvariants(); err != nil { | ||
| @@ -14,26 +18,222 @@ func TestFrameCanonicalOffsetsAndInvariants(t *testing.T) { | |||||
| 14 | } | 18 | } | ||
| 15 | } | 19 | } | ||
| 16 | 20 | ||||
| 21 | + | func TestFrameRowReturnsOwnedCells(t *testing.T) { | |||
| 22 | + | f := NewFrame(2, 1) | |||
| 23 | + | f.Set(0, 0, Cell{Rune: 'A'}) | |||
| 24 | + | ||||
| 25 | + | row := f.Row(0) | |||
| 26 | + | row[0] = BlankCell() | |||
| 27 | + | ||||
| 28 | + | if got := f.Cell(0, 0).Rune; got != 'A' { | |||
| 29 | + | t.Fatalf("mutating Row result changed frame cell to %q", got) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestFrameRowMutationOperationsUseLogicalRows(t *testing.T) { | |||
| 34 | + | f := NewFrame(5, 2) | |||
| 35 | + | f.WriteRow(0, 0, []Cell{{Rune: 'A'}, {Rune: 'B'}, {Rune: 'C'}, {Rune: 'D'}, {Rune: 'E'}}) | |||
| 36 | + | f.ScrollDown(0, 1, 1) | |||
| 37 | + | ||||
| 38 | + | f.CopyRow(1, 2, 0, 3) | |||
| 39 | + | f.FillRow(1, 0, 2, BlankCell()) | |||
| 40 | + | ||||
| 41 | + | got := f.Row(1) | |||
| 42 | + | want := []rune{' ', ' ', 'A', 'B', 'C'} | |||
| 43 | + | for x, r := range want { | |||
| 44 | + | if got[x].Rune != r { | |||
| 45 | + | t.Fatalf("cell %d = %q, want %q", x, got[x].Rune, r) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestStoredCellFitsCompactTarget(t *testing.T) { | |||
| 51 | + | if got := reflect.TypeOf(storedCell{}).Size(); got < 8 || got > 16 { | |||
| 52 | + | t.Fatalf("storedCell size = %d bytes, want 8..16", got) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestFrameInternsCanonicalStylesLocally(t *testing.T) { | |||
| 57 | + | f := NewFrame(3, 1) | |||
| 58 | + | first := Style{Bold: true, Foreground: 2, ForegroundRGB: RGB{R: 9, G: 8, B: 7}} | |||
| 59 | + | second := first | |||
| 60 | + | second.ForegroundRGB = RGB{R: 1, G: 2, B: 3} | |||
| 61 | + | ||||
| 62 | + | f.Set(0, 0, Cell{Rune: 'a', Style: first}) | |||
| 63 | + | f.Set(1, 0, Cell{Rune: 'b', Style: second}) | |||
| 64 | + | f.Set(2, 0, Cell{Rune: 'c', Style: Style{}}) | |||
| 65 | + | ||||
| 66 | + | if f.page.cells[0].styleID != f.page.cells[1].styleID { | |||
| 67 | + | t.Fatal("semantically equal styles received different page-local IDs") | |||
| 68 | + | } | |||
| 69 | + | if f.page.cells[2].styleID == 0 { | |||
| 70 | + | t.Fatal("zero Style collapsed into default style ID zero") | |||
| 71 | + | } | |||
| 72 | + | if got := f.StyleCount(); got != 3 { | |||
| 73 | + | t.Fatalf("StyleCount() = %d, want default plus two semantic styles", got) | |||
| 74 | + | } | |||
| 75 | + | if err := f.CheckInvariants(); err != nil { | |||
| 76 | + | t.Fatalf("frame invariants: %v", err) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestFrameStyleChurnReusesUnreferencedSlots(t *testing.T) { | |||
| 81 | + | f := NewFrame(1, 1) | |||
| 82 | + | for i := range 1000 { | |||
| 83 | + | f.Set(0, 0, Cell{Rune: 'x', Style: Style{Foreground: i}}) | |||
| 84 | + | } | |||
| 85 | + | if got := f.StyleCount(); got != 2 { | |||
| 86 | + | t.Fatalf("StyleCount() after churn = %d, want default plus live style", got) | |||
| 87 | + | } | |||
| 88 | + | if got := len(f.page.styles); got > 3 { | |||
| 89 | + | t.Fatalf("style slots after churn = %d, want at most 3", got) | |||
| 90 | + | } | |||
| 91 | + | f.Set(0, 0, BlankCell()) | |||
| 92 | + | if got := f.StyleCount(); got != 1 { | |||
| 93 | + | t.Fatalf("StyleCount() after clear = %d, want default only", got) | |||
| 94 | + | } | |||
| 95 | + | if err := f.CheckInvariants(); err != nil { | |||
| 96 | + | t.Fatalf("frame invariants: %v", err) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestFrameCrossPageWriteRemapsStyles(t *testing.T) { | |||
| 101 | + | src := NewFrame(2, 1) | |||
| 102 | + | src.Set(0, 0, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 4}}) | |||
| 103 | + | src.Set(1, 0, Cell{Rune: 'b', Style: Style{Italic: true, Background: 7}}) | |||
| 104 | + | dst := NewFrame(2, 1) | |||
| 105 | + | dst.Set(0, 0, Cell{Rune: 'x', Style: Style{Foreground: 99}}) | |||
| 106 | + | ||||
| 107 | + | dst.WriteRow(0, 0, src.Row(0)) | |||
| 108 | + | ||||
| 109 | + | for x := range 2 { | |||
| 110 | + | if !dst.Cell(x, 0).Equal(src.Cell(x, 0)) { | |||
| 111 | + | t.Fatalf("cell %d changed crossing pages: got %+v want %+v", x, dst.Cell(x, 0), src.Cell(x, 0)) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | if err := dst.CheckInvariants(); err != nil { | |||
| 115 | + | t.Fatalf("destination invariants: %v", err) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestFrameClonePreservesStructureWithoutAliasing(t *testing.T) { | |||
| 120 | + | f := NewFrame(3, 2) | |||
| 121 | + | f.ScrollUp(0, 1, 1) | |||
| 122 | + | f.Set(0, 0, Cell{Rune: 'a', Style: Style{Foreground: 1}}) | |||
| 123 | + | f.Set(1, 0, Cell{Rune: 'b', Style: Style{Foreground: 2}}) | |||
| 124 | + | f.Set(1, 0, BlankCell()) // leave one reusable style slot | |||
| 125 | + | ||||
| 126 | + | clone := f.Clone() | |||
| 127 | + | if clone.page == f.page || &clone.page.cells[0] == &f.page.cells[0] || &clone.page.rows[0] == &f.page.rows[0] { | |||
| 128 | + | t.Fatal("Clone shares page slice storage") | |||
| 129 | + | } | |||
| 130 | + | if clone.page.rows[0] != f.page.rows[0] || clone.page.cells[clone.offset(0, 0)].styleID != f.page.cells[f.offset(0, 0)].styleID { | |||
| 131 | + | t.Fatal("Clone changed row rotation or page-local style IDs") | |||
| 132 | + | } | |||
| 133 | + | clone.Set(0, 0, Cell{Rune: 'x', Style: Style{Bold: true}}) | |||
| 134 | + | if f.Cell(0, 0).Rune != 'a' { | |||
| 135 | + | t.Fatal("mutating clone changed source cells") | |||
| 136 | + | } | |||
| 137 | + | if _, ok := f.page.styleIndex[Style{Bold: true}.Canonical()]; ok { | |||
| 138 | + | t.Fatal("mutating clone changed source style map") | |||
| 139 | + | } | |||
| 140 | + | if err := clone.CheckInvariants(); err != nil { | |||
| 141 | + | t.Fatalf("clone invariants: %v", err) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func TestCheckInvariantsValidatesFreeStyleList(t *testing.T) { | |||
| 146 | + | newFrameWithFreeStyle := func() Frame { | |||
| 147 | + | f := NewFrame(1, 1) | |||
| 148 | + | f.Set(0, 0, Cell{Style: Style{Foreground: 1}}) | |||
| 149 | + | f.Set(0, 0, BlankCell()) | |||
| 150 | + | return f | |||
| 151 | + | } | |||
| 152 | + | tests := map[string]func(*Frame){ | |||
| 153 | + | "duplicate": func(f *Frame) { f.page.freeStyles = append(f.page.freeStyles, f.page.freeStyles[0]) }, | |||
| 154 | + | "missing": func(f *Frame) { f.page.freeStyles = nil }, | |||
| 155 | + | "used": func(f *Frame) { f.page.freeStyles = append(f.page.freeStyles, 0) }, | |||
| 156 | + | "out of range": func(f *Frame) { | |||
| 157 | + | f.page.freeStyles = append(f.page.freeStyles, uint32(len(f.page.styles))) | |||
| 158 | + | }, | |||
| 159 | + | } | |||
| 160 | + | for name, corrupt := range tests { | |||
| 161 | + | t.Run(name, func(t *testing.T) { | |||
| 162 | + | f := newFrameWithFreeStyle() | |||
| 163 | + | corrupt(&f) | |||
| 164 | + | if err := f.CheckInvariants(); err == nil { | |||
| 165 | + | t.Fatal("CheckInvariants accepted corrupt free style list") | |||
| 166 | + | } | |||
| 167 | + | }) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestFrameCellCountRejectsPortableOverflow(t *testing.T) { | |||
| 172 | + | if _, ok := frameCellCount(math.MaxInt, 2); ok { | |||
| 173 | + | t.Fatal("accepted dimensions whose product exceeds int or uint32") | |||
| 174 | + | } | |||
| 175 | + | if _, ok := frameCellCount(65_536, 65_536); ok { | |||
| 176 | + | t.Fatal("accepted dimensions whose product exceeds uint32") | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestFrameLogicalBytesAreDeterministic(t *testing.T) { | |||
| 181 | + | f := NewFrame(3, 2) | |||
| 182 | + | want := uint64(6*StoredCellLogicalBytes + 2*RowDescriptorLogicalBytes + StyleRecordLogicalBytes) | |||
| 183 | + | if got := f.LogicalBytes(); got != want { | |||
| 184 | + | t.Fatalf("LogicalBytes() = %d, want %d", got, want) | |||
| 185 | + | } | |||
| 186 | + | f.Set(0, 0, Cell{Rune: 'x', Style: Style{Bold: true}}) | |||
| 187 | + | if got := f.LogicalBytes(); got != want+StyleRecordLogicalBytes { | |||
| 188 | + | t.Fatalf("LogicalBytes() with one interned style = %d, want %d", got, want+StyleRecordLogicalBytes) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | ||||
| 17 | func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) { | 192 | func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) { | ||
| 18 | f := NewFrame(4, 3) | 193 | f := NewFrame(4, 3) | ||
| 19 | - | f.lineOffset[1] = f.lineOffset[0] | 194 | + | f.page.rows[1] = f.page.rows[0] |
| 20 | if err := f.CheckInvariants(); err == nil { | 195 | if err := f.CheckInvariants(); err == nil { | ||
| 21 | t.Fatal("expected duplicate physical row to be rejected") | 196 | t.Fatal("expected duplicate physical row to be rejected") | ||
| 22 | } | 197 | } | ||
| 23 | 198 | ||||
| 24 | f = NewFrame(4, 3) | 199 | f = NewFrame(4, 3) | ||
| 25 | - | f.lineOffset[0] = 1 | 200 | + | f.page.rows[0] = 1 |
| 26 | if err := f.CheckInvariants(); err == nil { | 201 | if err := f.CheckInvariants(); err == nil { | ||
| 27 | t.Fatal("expected non-multiple offset to be rejected") | 202 | t.Fatal("expected non-multiple offset to be rejected") | ||
| 28 | } | 203 | } | ||
| 29 | 204 | ||||
| 30 | f = NewFrame(4, 3) | 205 | f = NewFrame(4, 3) | ||
| 31 | - | f.lineOffset[2] = 99 | 206 | + | f.page.rows[2] = 99 |
| 32 | if err := f.CheckInvariants(); err == nil { | 207 | if err := f.CheckInvariants(); err == nil { | ||
| 33 | t.Fatal("expected out-of-range offset to be rejected") | 208 | t.Fatal("expected out-of-range offset to be rejected") | ||
| 34 | } | 209 | } | ||
| 35 | } | 210 | } | ||
| 36 | 211 | ||||
| 212 | + | func TestReplacePreservesSelfAliasedScrolledRows(t *testing.T) { | |||
| 213 | + | f := NewFrame(3, 3) | |||
| 214 | + | for y, row := range []string{"abc", "def", "ghi"} { | |||
| 215 | + | for x, r := range row { | |||
| 216 | + | f.Set(x, y, Cell{Rune: r}) | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | f.ScrollUp(0, 2, 1) | |||
| 220 | + | want := [][]Cell{f.Row(0), f.Row(1), f.Row(2)} | |||
| 221 | + | ||||
| 222 | + | f.Replace(f) | |||
| 223 | + | ||||
| 224 | + | for y := range want { | |||
| 225 | + | got := f.Row(y) | |||
| 226 | + | for x := range want[y] { | |||
| 227 | + | if !got[x].Equal(want[y][x]) { | |||
| 228 | + | t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got[x], want[y][x]) | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | if err := f.CheckInvariants(); err != nil { | |||
| 233 | + | t.Fatalf("replaced frame invariants: %v", err) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 37 | func TestReplacePreservesLogicalRows(t *testing.T) { | 237 | func TestReplacePreservesLogicalRows(t *testing.T) { | ||
| 38 | dst := NewFrame(3, 3) | 238 | dst := NewFrame(3, 3) | ||
| 39 | dst.Set(0, 0, Cell{Rune: 'x'}) | 239 | dst.Set(0, 0, Cell{Rune: 'x'}) | ||
A core/payload.go +159 -0
| @@ -0,0 +1,159 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | "unicode/utf8" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const ( | |||
| 11 | + | MaxGraphemeBytes = 128 | |||
| 12 | + | MaxHyperlinkBytes = 4096 | |||
| 13 | + | // PayloadRecordLogicalBytes accounts for two fixed-width string descriptors. | |||
| 14 | + | PayloadRecordLogicalBytes uint64 = 16 | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | var ErrInvalidCellPayload = errors.New("invalid cell payload") | |||
| 18 | + | ||||
| 19 | + | // CellPayload is an immutable semantic exceptional value. Its zero value is | |||
| 20 | + | // empty. Storage support does not imply parsing support for additional terminal | |||
| 21 | + | // protocols; a payload never exposes a page-local handle. | |||
| 22 | + | type CellPayload struct { | |||
| 23 | + | grapheme string | |||
| 24 | + | hyperlink string | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // NewCellPayload validates bounded UTF-8 strings without terminal controls. | |||
| 28 | + | // It copies nonempty strings so a small value cannot retain an oversized input. | |||
| 29 | + | func NewCellPayload(grapheme, hyperlink string) (CellPayload, error) { | |||
| 30 | + | if !validPayloadString(grapheme, MaxGraphemeBytes) || !validPayloadString(hyperlink, MaxHyperlinkBytes) { | |||
| 31 | + | return CellPayload{}, ErrInvalidCellPayload | |||
| 32 | + | } | |||
| 33 | + | return CellPayload{grapheme: strings.Clone(grapheme), hyperlink: strings.Clone(hyperlink)}, nil | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func validPayloadString(value string, limit int) bool { | |||
| 37 | + | if len(value) > limit || !utf8.ValidString(value) { | |||
| 38 | + | return false | |||
| 39 | + | } | |||
| 40 | + | for _, r := range value { | |||
| 41 | + | if r < 0x20 || r >= 0x7f && r <= 0x9f { | |||
| 42 | + | return false | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | return true | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (p CellPayload) Grapheme() string { return p.grapheme } | |||
| 49 | + | func (p CellPayload) Hyperlink() string { return p.hyperlink } | |||
| 50 | + | func (p CellPayload) Empty() bool { return p == CellPayload{} } | |||
| 51 | + | func (p CellPayload) LogicalBytes() uint64 { | |||
| 52 | + | if p.Empty() { | |||
| 53 | + | return 0 | |||
| 54 | + | } | |||
| 55 | + | return PayloadRecordLogicalBytes + uint64(len(p.grapheme)) + uint64(len(p.hyperlink)) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | type payloadSlot struct { | |||
| 59 | + | value CellPayload | |||
| 60 | + | refs uint32 | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // internPayload returns a page-local handle and acquires one reference. Slot | |||
| 64 | + | // zero is the implicit empty payload and requires no dictionary allocation. | |||
| 65 | + | func (f Frame) internPayload(value CellPayload) uint32 { | |||
| 66 | + | if value.Empty() { | |||
| 67 | + | return 0 | |||
| 68 | + | } | |||
| 69 | + | p := f.page | |||
| 70 | + | if id, ok := p.payloadIndex[value]; ok { | |||
| 71 | + | p.payloads[id-1].refs++ | |||
| 72 | + | return id | |||
| 73 | + | } | |||
| 74 | + | if p.payloadIndex == nil { | |||
| 75 | + | p.payloadIndex = make(map[CellPayload]uint32) | |||
| 76 | + | } | |||
| 77 | + | var id uint32 | |||
| 78 | + | if n := len(p.freePayloads); n > 0 { | |||
| 79 | + | id = p.freePayloads[n-1] | |||
| 80 | + | p.freePayloads = p.freePayloads[:n-1] | |||
| 81 | + | p.payloads[id-1] = payloadSlot{value: value, refs: 1} | |||
| 82 | + | } else { | |||
| 83 | + | p.payloads = append(p.payloads, payloadSlot{value: value, refs: 1}) | |||
| 84 | + | id = uint32(len(p.payloads)) | |||
| 85 | + | } | |||
| 86 | + | p.payloadIndex[value] = id | |||
| 87 | + | p.payloadBytes += value.LogicalBytes() | |||
| 88 | + | return id | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (f Frame) releasePayload(id uint32) { | |||
| 92 | + | if id == 0 { | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | p := f.page | |||
| 96 | + | slot := &p.payloads[id-1] | |||
| 97 | + | slot.refs-- | |||
| 98 | + | if slot.refs != 0 { | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | delete(p.payloadIndex, slot.value) | |||
| 102 | + | p.payloadBytes -= slot.value.LogicalBytes() | |||
| 103 | + | *slot = payloadSlot{} | |||
| 104 | + | p.freePayloads = append(p.freePayloads, id) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (f Frame) payload(id uint32) CellPayload { | |||
| 108 | + | if id == 0 { | |||
| 109 | + | return CellPayload{} | |||
| 110 | + | } | |||
| 111 | + | return f.page.payloads[id-1].value | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (f Frame) checkPayloadInvariants() error { | |||
| 115 | + | p := f.page | |||
| 116 | + | refs := make([]uint32, len(p.payloads)) | |||
| 117 | + | for _, cell := range p.cells { | |||
| 118 | + | id := cell.payloadID | |||
| 119 | + | if id == 0 { | |||
| 120 | + | continue | |||
| 121 | + | } | |||
| 122 | + | if uint64(id) > uint64(len(refs)) { | |||
| 123 | + | return fmt.Errorf("unresolved payload ID %d", id) | |||
| 124 | + | } | |||
| 125 | + | refs[id-1]++ | |||
| 126 | + | } | |||
| 127 | + | free := make([]bool, len(p.payloads)) | |||
| 128 | + | for _, id := range p.freePayloads { | |||
| 129 | + | if id == 0 || uint64(id) > uint64(len(free)) || free[id-1] { | |||
| 130 | + | return fmt.Errorf("invalid free payload ID %d", id) | |||
| 131 | + | } | |||
| 132 | + | free[id-1] = true | |||
| 133 | + | } | |||
| 134 | + | var total uint64 | |||
| 135 | + | used := 0 | |||
| 136 | + | for i, slot := range p.payloads { | |||
| 137 | + | if slot.refs != refs[i] { | |||
| 138 | + | return fmt.Errorf("payload %d reference mismatch", i+1) | |||
| 139 | + | } | |||
| 140 | + | if slot.refs == 0 { | |||
| 141 | + | if !free[i] || !slot.value.Empty() { | |||
| 142 | + | return fmt.Errorf("unreclaimed payload %d", i+1) | |||
| 143 | + | } | |||
| 144 | + | continue | |||
| 145 | + | } | |||
| 146 | + | if free[i] || slot.value.Empty() || p.payloadIndex[slot.value] != uint32(i+1) { | |||
| 147 | + | return fmt.Errorf("invalid payload dictionary entry %d", i+1) | |||
| 148 | + | } | |||
| 149 | + | if !validPayloadString(slot.value.grapheme, MaxGraphemeBytes) || !validPayloadString(slot.value.hyperlink, MaxHyperlinkBytes) { | |||
| 150 | + | return ErrInvalidCellPayload | |||
| 151 | + | } | |||
| 152 | + | total += slot.value.LogicalBytes() | |||
| 153 | + | used++ | |||
| 154 | + | } | |||
| 155 | + | if total != p.payloadBytes || used != len(p.payloadIndex) { | |||
| 156 | + | return fmt.Errorf("payload accounting mismatch") | |||
| 157 | + | } | |||
| 158 | + | return nil | |||
| 159 | + | } | |||
A core/payload_test.go +98 -0
| @@ -0,0 +1,98 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestCellPayloadValidation(t *testing.T) { | |||
| 12 | + | for _, tt := range []struct { | |||
| 13 | + | name, grapheme, link string | |||
| 14 | + | valid bool | |||
| 15 | + | }{ | |||
| 16 | + | {"empty", "", "", true}, | |||
| 17 | + | {"grapheme and link", "e\u0301", "https://example.com", true}, | |||
| 18 | + | {"invalid utf8", "\xff", "", false}, | |||
| 19 | + | {"control", "a\n", "", false}, | |||
| 20 | + | {"OSC injection", "", "https://x/\x1b\\", false}, | |||
| 21 | + | {"C1", "", "x\u009c", false}, | |||
| 22 | + | {"grapheme limit", strings.Repeat("x", MaxGraphemeBytes+1), "", false}, | |||
| 23 | + | {"link limit", "", strings.Repeat("x", MaxHyperlinkBytes+1), false}, | |||
| 24 | + | } { | |||
| 25 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 26 | + | p, err := NewCellPayload(tt.grapheme, tt.link) | |||
| 27 | + | if !tt.valid { | |||
| 28 | + | require.ErrorIs(t, err, ErrInvalidCellPayload) | |||
| 29 | + | return | |||
| 30 | + | } | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | require.Equal(t, tt.grapheme, p.Grapheme()) | |||
| 33 | + | require.Equal(t, tt.link, p.Hyperlink()) | |||
| 34 | + | }) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestPayloadOwnershipAndReclamation(t *testing.T) { | |||
| 39 | + | value, err := NewCellPayload("e\u0301", "https://example.com") | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | cell := Cell{Rune: 'e', Style: DefaultStyle(), Payload: value} | |||
| 42 | + | source := NewFrame(3, 2) | |||
| 43 | + | baseline := source.LogicalBytes() | |||
| 44 | + | source.Set(0, 0, cell) | |||
| 45 | + | source.Set(1, 0, cell) | |||
| 46 | + | require.Len(t, source.page.payloadIndex, 1) | |||
| 47 | + | require.Equal(t, baseline+value.LogicalBytes(), source.LogicalBytes()) | |||
| 48 | + | clone := source.Clone() | |||
| 49 | + | dst := NewFrame(3, 2) | |||
| 50 | + | other, err := NewCellPayload("", "https://other.example") | |||
| 51 | + | require.NoError(t, err) | |||
| 52 | + | dst.Set(0, 0, Cell{Rune: 'x', Payload: other}) | |||
| 53 | + | dst.Set(1, 0, source.Cell(0, 0)) | |||
| 54 | + | require.Equal(t, cell, dst.Cell(1, 0)) | |||
| 55 | + | source.FillRow(0, 0, 3, BlankCell()) | |||
| 56 | + | require.Empty(t, source.page.payloadIndex) | |||
| 57 | + | require.Equal(t, baseline, source.LogicalBytes()) | |||
| 58 | + | require.Equal(t, cell, clone.Cell(0, 0)) | |||
| 59 | + | require.Equal(t, cell, dst.Cell(1, 0)) | |||
| 60 | + | for _, f := range []Frame{source, clone, dst} { | |||
| 61 | + | require.NoError(t, f.CheckInvariants()) | |||
| 62 | + | } | |||
| 63 | + | clone.ScrollUp(0, 1, 1) | |||
| 64 | + | require.Empty(t, clone.page.payloadIndex) | |||
| 65 | + | require.NoError(t, clone.CheckInvariants()) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestPayloadChurnIsBounded(t *testing.T) { | |||
| 69 | + | f := NewFrame(1, 1) | |||
| 70 | + | for i := range 1000 { | |||
| 71 | + | p, err := NewCellPayload("", fmt.Sprintf("https://example.com/%d", i)) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | f.Set(0, 0, Cell{Rune: 'x', Payload: p}) | |||
| 74 | + | } | |||
| 75 | + | require.LessOrEqual(t, len(f.page.payloads), 2) | |||
| 76 | + | require.Len(t, f.page.payloadIndex, 1) | |||
| 77 | + | require.NoError(t, f.CheckInvariants()) | |||
| 78 | + | f.page.cells[0].payloadID = 99 | |||
| 79 | + | require.Error(t, f.CheckInvariants()) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func BenchmarkPayloadOverwrite(b *testing.B) { | |||
| 83 | + | values := make([]CellPayload, 128) | |||
| 84 | + | for i := range values { | |||
| 85 | + | var err error | |||
| 86 | + | values[i], err = NewCellPayload("e\u0301", fmt.Sprintf("https://example.com/%d", i)) | |||
| 87 | + | if err != nil { | |||
| 88 | + | b.Fatal(err) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | frame := NewFrame(1, 1) | |||
| 92 | + | i := 0 | |||
| 93 | + | b.ReportAllocs() | |||
| 94 | + | for b.Loop() { | |||
| 95 | + | frame.Set(0, 0, Cell{Rune: 'e', Style: DefaultStyle(), Payload: values[i%len(values)]}) | |||
| 96 | + | i++ | |||
| 97 | + | } | |||
| 98 | + | } | |||
M core/style.go +27 -0
| @@ -48,6 +48,33 @@ type Style struct { | |||||
| 48 | 48 | ||||
| 49 | func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} } | 49 | func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} } | ||
| 50 | 50 | ||||
| 51 | + | // Canonical returns the unique representation of s under Equal. Fields that | |||
| 52 | + | // are inactive for the selected color form are cleared. Canonical does not | |||
| 53 | + | // treat the zero Style as the default style: indexed color zero remains | |||
| 54 | + | // distinct from the unset colors returned by DefaultStyle. | |||
| 55 | + | func (s Style) Canonical() Style { | |||
| 56 | + | if s.HasForegroundRGB { | |||
| 57 | + | s.Foreground = 0 | |||
| 58 | + | } else { | |||
| 59 | + | s.ForegroundRGB = RGB{} | |||
| 60 | + | } | |||
| 61 | + | if s.HasBackgroundRGB { | |||
| 62 | + | s.Background = 0 | |||
| 63 | + | } else { | |||
| 64 | + | s.BackgroundRGB = RGB{} | |||
| 65 | + | } | |||
| 66 | + | if s.HasUnderlineColorRGB { | |||
| 67 | + | s.HasUnderlineColor = false | |||
| 68 | + | s.UnderlineColor = 0 | |||
| 69 | + | } else { | |||
| 70 | + | s.UnderlineColorRGB = RGB{} | |||
| 71 | + | if !s.HasUnderlineColor { | |||
| 72 | + | s.UnderlineColor = 0 | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | return s | |||
| 76 | + | } | |||
| 77 | + | ||||
| 51 | func (s Style) Equal(other Style) bool { | 78 | func (s Style) Equal(other Style) bool { | ||
| 52 | if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse || s.Attrs != other.Attrs || s.UnderlineStyle != other.UnderlineStyle { | 79 | if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse || s.Attrs != other.Attrs || s.UnderlineStyle != other.UnderlineStyle { | ||
| 53 | return false | 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 | // history, and renderer consumers share one cell/style/frame representation. | 6 | // history, and renderer consumers share one cell/style/frame representation. | ||
| 7 | // The implementation and storage policy remain owned by the core package. | 7 | // The implementation and storage policy remain owned by the core package. | ||
| 8 | type Cell = core.Cell | 8 | type Cell = core.Cell | ||
| 9 | + | type CellPayload = core.CellPayload | |||
| 9 | type RGB = core.RGB | 10 | type RGB = core.RGB | ||
| 10 | type StyleAttrs = core.StyleAttrs | 11 | type StyleAttrs = core.StyleAttrs | ||
| 11 | type UnderlineStyle = core.UnderlineStyle | 12 | type UnderlineStyle = core.UnderlineStyle | ||
| 12 | type Style = core.Style | 13 | type Style = core.Style | ||
| 13 | type Frame = core.Frame | 14 | type Frame = core.Frame | ||
| 15 | + | type CellSource = core.CellSource | |||
| 14 | 16 | ||||
| 15 | type DamageKind = core.DamageKind | 17 | type DamageKind = core.DamageKind | ||
| 16 | type Damage = core.Damage | 18 | type Damage = core.Damage | ||
| @@ -35,10 +37,11 @@ const ( | |||||
| 35 | ) | 37 | ) | ||
| 36 | 38 | ||||
| 37 | var ( | 39 | var ( | ||
| 38 | - | BlankCell = core.BlankCell | 40 | + | BlankCell = core.BlankCell |
| 39 | - | DefaultStyle = core.DefaultStyle | 41 | + | DefaultStyle = core.DefaultStyle |
| 40 | - | FullRedraw = core.FullRedraw | 42 | + | FullRedraw = core.FullRedraw |
| 41 | - | NewFrame = core.NewFrame | 43 | + | NewFrame = core.NewFrame |
| 44 | + | NewCellPayload = core.NewCellPayload | |||
| 42 | ) | 45 | ) | ||
| 43 | 46 | ||||
| 44 | func RuneWidth(r rune) int { return core.RuneWidth(r) } | 47 | func RuneWidth(r rune) int { return core.RuneWidth(r) } | ||
M doc.go +5 -6
| @@ -15,10 +15,9 @@ | |||||
| 15 | // or strings owned by the callback. | 15 | // or strings owned by the callback. | ||
| 16 | // | 16 | // | ||
| 17 | // Snapshot and HistorySnapshotView capture owned state without sealing or | 17 | // Snapshot and HistorySnapshotView capture owned state without sealing or | ||
| 18 | - | // mutating the live owner. Row methods return owned copies. BorrowedRow and | 18 | + | // mutating the live owner. Screen, ScreenSnapshot, and core.Frame expose |
| 19 | - | // HistoryView.Range expose immutable backing storage: callers must not mutate | 19 | + | // storage-independent CellSource reads, and Row methods return owned copies. |
| 20 | - | // it, and Range callbacks must not retain their row after returning. A | 20 | + | // HistoryView.Row and HistoryView.Range decode owned semantic rows from compact |
| 21 | - | // HistoryChunk pointer has stable identity for the lifetime of the view, so | 21 | + | // slabs. A HistoryChunk pointer has stable identity for the lifetime of the |
| 22 | - | // consumers can reuse unchanged sealed chunks. Direct Frame.Row access is a | 22 | + | // view, so consumers can reuse unchanged sealed chunks. |
| 23 | - | // mutable borrow valid only until the frame scrolls or resizes. | |||
| 24 | package vt | 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 | screen := vt.NewScreen(6, 2) | 12 | screen := vt.NewScreen(6, 2) | ||
| 13 | screen.Write([]byte("A界")) | 13 | screen.Write([]byte("A界")) | ||
| 14 | 14 | ||||
| 15 | - | if got := screen.Frame.At(0, 0); got.Rune != 'A' { | 15 | + | var source vt.CellSource = screen |
| 16 | + | if source.Columns() != 6 || source.Rows() != 2 { | |||
| 17 | + | t.Fatalf("screen dimensions = %dx%d, want 6x2", source.Columns(), source.Rows()) | |||
| 18 | + | } | |||
| 19 | + | if got := source.Cell(0, 0); got.Rune != 'A' { | |||
| 16 | t.Fatalf("ASCII cell = %#v, want A", got) | 20 | t.Fatalf("ASCII cell = %#v, want A", got) | ||
| 17 | } | 21 | } | ||
| 18 | - | if got := screen.Frame.At(1, 0); got.Rune != '界' || got.Continuation { | 22 | + | if got := source.Cell(1, 0); got.Rune != '界' || got.Continuation { |
| 19 | t.Fatalf("wide-rune lead cell = %#v, want 界 lead", got) | 23 | t.Fatalf("wide-rune lead cell = %#v, want 界 lead", got) | ||
| 20 | } | 24 | } | ||
| 21 | - | if got := screen.Frame.At(2, 0); !got.Continuation { | 25 | + | if got := source.Cell(2, 0); !got.Continuation { |
| 22 | t.Fatalf("wide-rune continuation = %#v, want continuation", got) | 26 | t.Fatalf("wide-rune continuation = %#v, want continuation", got) | ||
| 23 | } | 27 | } | ||
| 28 | + | owned := screen.RowCells(0) | |||
| 29 | + | owned[0] = core.BlankCell() | |||
| 30 | + | if got := source.Cell(0, 0).Rune; got != 'A' { | |||
| 31 | + | t.Fatalf("mutating RowCells result changed live screen cell to %q", got) | |||
| 32 | + | } | |||
| 24 | if got := vt.RuneWidth('界'); got != 2 { | 33 | if got := vt.RuneWidth('界'); got != 2 { | ||
| 25 | t.Fatalf("RuneWidth(界) = %d, want 2", got) | 34 | t.Fatalf("RuneWidth(界) = %d, want 2", got) | ||
| 26 | } | 35 | } | ||
| @@ -29,7 +38,7 @@ func TestExternalScreenParserPreservesPublicTerminalModel(t *testing.T) { | |||||
| 29 | // provide arbitrary PTY chunk boundaries. | 38 | // provide arbitrary PTY chunk boundaries. | ||
| 30 | screen.Write([]byte("\x1b[2;2H\x1b[3")) | 39 | screen.Write([]byte("\x1b[2;2H\x1b[3")) | ||
| 31 | screen.Write([]byte("1mX")) | 40 | screen.Write([]byte("1mX")) | ||
| 32 | - | cell := screen.Frame.At(1, 1) | 41 | + | cell := screen.Cell(1, 1) |
| 33 | if cell.Rune != 'X' || cell.Style.Foreground != 1 { | 42 | if cell.Rune != 'X' || cell.Style.Foreground != 1 { | ||
| 34 | t.Fatalf("split ANSI write = %#v, want red X", cell) | 43 | t.Fatalf("split ANSI write = %#v, want red X", cell) | ||
| 35 | } | 44 | } | ||
| @@ -133,9 +142,6 @@ func TestExternalSnapshotsAndHistoryRespectOwnershipContracts(t *testing.T) { | |||||
| 133 | if got := before.Row(0)[0].Rune; got != 'a' { | 142 | if got := before.Row(0)[0].Rune; got != 'a' { | ||
| 134 | t.Fatalf("owned history row mutation changed view to %q", got) | 143 | t.Fatalf("owned history row mutation changed view to %q", got) | ||
| 135 | } | 144 | } | ||
| 136 | - | if borrowed := before.BorrowedRow(0); &borrowed[0] == ©OfRow[0] { | |||
| 137 | - | t.Fatal("Row returned borrowed history storage instead of an owned copy") | |||
| 138 | - | } | |||
| 139 | 145 | ||||
| 140 | appendHistoryRow(t, history, 'c', 12) | 146 | appendHistoryRow(t, history, 'c', 12) | ||
| 141 | after := history.View() | 147 | after := history.View() | ||
M extraction_line_callback_external_test.go +1 -1
| @@ -26,7 +26,7 @@ func TestExternalLineEvictionCallbackReceivesStableCopy(t *testing.T) { | |||||
| 26 | t.Fatalf("evicted rows = %#v, want one stable AAAA row", evicted) | 26 | t.Fatalf("evicted rows = %#v, want one stable AAAA row", evicted) | ||
| 27 | } | 27 | } | ||
| 28 | evicted[0][0] = core.Cell{Rune: 'X'} | 28 | evicted[0][0] = core.Cell{Rune: 'X'} | ||
| 29 | - | if got := screen.Frame.At(0, 0).Rune; got != 'B' { | 29 | + | if got := screen.Cell(0, 0).Rune; got != 'B' { |
| 30 | t.Fatalf("mutating callback row changed live frame to %q", got) | 30 | t.Fatalf("mutating callback row changed live frame to %q", got) | ||
| 31 | } | 31 | } | ||
| 32 | if got := evicted[0][0].Rune; got != 'X' { | 32 | if got := evicted[0][0].Rune; got != 'X' { | ||
D fixture_compatibility_test.go +0 -47
| @@ -1,47 +0,0 @@ | |||||
| 1 | - | package vt_test | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "embed" | |||
| 6 | - | "encoding/binary" | |||
| 7 | - | "encoding/hex" | |||
| 8 | - | "hash/crc32" | |||
| 9 | - | "strings" | |||
| 10 | - | "testing" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | //go:embed testdata/vevs-v4-restore.golden.hex | |||
| 14 | - | var fixtures embed.FS | |||
| 15 | - | ||||
| 16 | - | func TestVEVSV4FixtureIsDeterministic(t *testing.T) { | |||
| 17 | - | encoded, err := fixtures.ReadFile("testdata/vevs-v4-restore.golden.hex") | |||
| 18 | - | if err != nil { | |||
| 19 | - | t.Fatal(err) | |||
| 20 | - | } | |||
| 21 | - | data, err := hex.DecodeString(strings.TrimSpace(string(encoded))) | |||
| 22 | - | if err != nil { | |||
| 23 | - | t.Fatalf("decode fixture: %v", err) | |||
| 24 | - | } | |||
| 25 | - | if len(data) < 16 || !bytes.Equal(data[:4], []byte("VEVS")) { | |||
| 26 | - | t.Fatalf("fixture header = %x, want VEVS", data[:min(len(data), 4)]) | |||
| 27 | - | } | |||
| 28 | - | if version := binary.BigEndian.Uint16(data[4:6]); version != 4 { | |||
| 29 | - | t.Fatalf("fixture version = %d, want 4", version) | |||
| 30 | - | } | |||
| 31 | - | if bodyLength := binary.BigEndian.Uint32(data[8:12]); int(bodyLength) != len(data)-16 { | |||
| 32 | - | t.Fatalf("fixture body length = %d, want %d", bodyLength, len(data)-16) | |||
| 33 | - | } | |||
| 34 | - | if got := binary.BigEndian.Uint32(data[12:16]); got != crc32.ChecksumIEEE(data[16:]) { | |||
| 35 | - | t.Fatalf("fixture CRC = %#x, want %#x", got, crc32.ChecksumIEEE(data[16:])) | |||
| 36 | - | } | |||
| 37 | - | if got := bytes.Count(data, []byte("VTH1")); got != 3 { | |||
| 38 | - | t.Fatalf("embedded VTH3 marker count = %d, want 3", got) | |||
| 39 | - | } | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | func min(a, b int) int { | |||
| 43 | - | if a < b { | |||
| 44 | - | return a | |||
| 45 | - | } | |||
| 46 | - | return b | |||
| 47 | - | } | |||
M history.go +485 -208
| @@ -2,106 +2,346 @@ package vt | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | + | "fmt" | |||
| 5 | "math" | 6 | "math" | ||
| 6 | "slices" | 7 | "slices" | ||
| 7 | 8 | ||||
| 8 | renderer "github.com/bnema/vev-vt/core" | 9 | renderer "github.com/bnema/vev-vt/core" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | - | // ErrHistoryRowTooWide is returned when a row cannot fit within the configured | 12 | + | // ErrHistoryRowTooLarge is returned when one row cannot fit within the |
| 12 | - | // cell budget. The history is not modified when this error is returned. | 13 | + | // configured logical-byte budget or compact page bounds. History is unchanged. |
| 13 | - | var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity") | 14 | + | var ErrHistoryRowTooLarge = errors.New("history row exceeds logical byte capacity") |
| 14 | 15 | ||||
| 15 | var errInvalidHistoryRowID = errors.New("invalid history row ID") | 16 | var errInvalidHistoryRowID = errors.New("invalid history row ID") | ||
| 16 | 17 | ||||
| 17 | - | // HistoryConfig controls the bounded terminal history retained by a Screen. | 18 | + | const maxTailPreallocCells = 32 * 1024 |
| 18 | - | type HistoryConfig struct { | |||
| 19 | - | MaxRows int | |||
| 20 | - | MaxCells int | |||
| 21 | - | ChunkRows int | |||
| 22 | - | } | |||
| 23 | 19 | ||||
| 24 | // History stores terminal rows in immutable chunks. It is intended to be | 20 | // History stores terminal rows in immutable chunks. It is intended to be | ||
| 25 | // mutated by the owner of a Screen; views are safe to retain after later | 21 | // mutated by the owner of a Screen; views are safe to retain after later | ||
| 26 | // appends. | 22 | // appends. | ||
| 27 | type History struct { | 23 | type History struct { | ||
| 28 | - | maxRows int | 24 | + | maxRows int |
| 29 | - | maxCells int | 25 | + | rowCeiling int |
| 30 | - | chunkRows int | 26 | + | maxBytes uint64 |
| 31 | - | chunks []*HistoryChunk | 27 | + | byteCeiling uint64 |
| 32 | - | tail [][]renderer.Cell | 28 | + | chunkRows int |
| 33 | - | tailBounds []LineBound | 29 | + | chunks []*HistoryChunk |
| 34 | - | tailIDs []RowID | 30 | + | tail [][]renderer.Cell |
| 35 | - | rows int | 31 | + | cachedTail []*HistoryChunk |
| 36 | - | cells int | 32 | + | tailCells []renderer.Cell |
| 37 | - | nextRowID RowID | 33 | + | tailBounds []LineBound |
| 38 | - | } | 34 | + | tailIDs []RowID |
| 39 | - | 35 | + | tailBytes uint64 | |
| 40 | - | // HistoryChunk is an immutable group of history rows. Its identity is stable | 36 | + | tailPageWidth int |
| 41 | - | // and can be used by consumers to avoid copying unchanged sealed chunks. | 37 | + | tailPageRows int |
| 42 | - | // bounds is parallel to rows and always has the same length. | 38 | + | tailPageStyles map[renderer.Style]struct{} |
| 39 | + | styleScratch map[renderer.Style]struct{} | |||
| 40 | + | payloadScratch map[renderer.CellPayload]struct{} | |||
| 41 | + | tailPagePayloads map[renderer.CellPayload]struct{} | |||
| 42 | + | rows int | |||
| 43 | + | cells int | |||
| 44 | + | logicalBytes uint64 | |||
| 45 | + | nextRowID RowID | |||
| 46 | + | compressionCursor int | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // HistoryChunk is an immutable compact slab of equal-width history rows. Its | |||
| 50 | + | // identity is stable and can be used by consumers to reuse unchanged chunks. | |||
| 43 | type HistoryChunk struct { | 51 | type HistoryChunk struct { | ||
| 44 | - | rows [][]renderer.Cell | 52 | + | page *sealedPage |
| 45 | - | bounds []LineBound | 53 | + | start int |
| 46 | - | rowIDs []RowID | 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 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | 181 | // HistoryView is an immutable snapshot of history. Row returns a copy so the | ||
| 50 | // storage behind a sealed chunk remains owned by VT. | 182 | // storage behind a sealed chunk remains owned by VT. | ||
| 51 | type HistoryView struct { | 183 | type HistoryView struct { | ||
| 52 | - | chunks []*HistoryChunk | 184 | + | chunks []*HistoryChunk |
| 53 | - | rows int | 185 | + | rows int |
| 54 | - | cells int | 186 | + | cells int |
| 55 | - | nextRowID RowID | 187 | + | logicalBytes uint64 |
| 188 | + | nextRowID RowID | |||
| 56 | } | 189 | } | ||
| 57 | 190 | ||||
| 58 | // HistorySnapshotView captures sealed history chunks and the mutable tail | 191 | // HistorySnapshotView captures sealed history chunks and the mutable tail | ||
| 59 | // independently. Sealed chunks are shared by identity; Tail is owned by the | 192 | // independently. Sealed chunks are shared by identity; Tail is owned by the | ||
| 60 | // view and can be serialized without rotating the live tail into a chunk. | 193 | // view and can be serialized without rotating the live tail into a chunk. | ||
| 61 | type HistorySnapshotView struct { | 194 | type HistorySnapshotView struct { | ||
| 62 | - | chunks []*HistoryChunk | 195 | + | chunks []*HistoryChunk |
| 63 | - | tail [][]renderer.Cell | 196 | + | tail HistoryView |
| 64 | - | tailBounds []LineBound | 197 | + | rows int |
| 65 | - | tailIDs []RowID | 198 | + | cells int |
| 66 | - | rows int | 199 | + | logicalBytes uint64 |
| 67 | - | cells int | 200 | + | nextRowID RowID |
| 68 | - | 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 { | 207 | + | func addLogicalBytes(total, value uint64) uint64 { |
| 72 | - | if config.MaxRows <= 0 { | 208 | + | if value > math.MaxUint64-total { |
| 73 | - | return &History{} | 209 | + | return math.MaxUint64 |
| 74 | } | 210 | } | ||
| 75 | - | chunkRows := config.ChunkRows | 211 | + | return total + value |
| 76 | - | if chunkRows <= 0 { | 212 | + | } |
| 77 | - | chunkRows = 256 | 213 | + | |
| 214 | + | func historyChunkLogicalBytes(chunk *HistoryChunk) uint64 { | |||
| 215 | + | if chunk == nil { | |||
| 216 | + | return 0 | |||
| 78 | } | 217 | } | ||
| 79 | - | chunkRows = min(chunkRows, 256) | 218 | + | return uint64(chunk.len())*historyRowBaseLogicalBytes(chunk.width) + chunk.styleCount*renderer.StyleRecordLogicalBytes + chunk.payloadBytes |
| 80 | - | chunkRows = min(chunkRows, config.MaxRows) | 219 | + | } |
| 81 | - | maxCells := config.MaxCells | 220 | + | |
| 82 | - | if maxCells <= 0 { | 221 | + | func historyChunksLogicalBytes(chunks []*HistoryChunk) uint64 { |
| 83 | - | maxCells = defaultHistoryMaxCells(config.MaxRows) | 222 | + | var total uint64 |
| 223 | + | for _, chunk := range chunks { | |||
| 224 | + | total = addLogicalBytes(total, historyChunkLogicalBytes(chunk)) | |||
| 84 | } | 225 | } | ||
| 85 | - | return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows, nextRowID: 1} | 226 | + | return total |
| 86 | } | 227 | } | ||
| 87 | 228 | ||||
| 88 | - | func defaultHistoryMaxCells(maxRows int) int { | 229 | + | func (h *History) prepareRowStyles(row []renderer.Cell) uint64 { |
| 89 | - | if maxRows > math.MaxInt/160 { | 230 | + | clear(h.payloadScratch) |
| 90 | - | return math.MaxInt | 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 | // Append records a copy of row along with its logical extent and an | 334 | // Append records a copy of row along with its logical extent and an | ||
| 96 | // automatically allocated nonzero ID. Once a chunk is full it is sealed | 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 | // mutation. | 337 | // mutation. | ||
| 99 | func (h *History) Append(row []renderer.Cell, bound LineBound) error { | 338 | func (h *History) Append(row []renderer.Cell, bound LineBound) error { | ||
| 100 | if h == nil || h.maxRows == 0 { | 339 | if h == nil || h.maxRows == 0 { | ||
| 101 | return nil | 340 | return nil | ||
| 102 | } | 341 | } | ||
| 103 | - | if len(row) > h.maxCells { | 342 | + | defer func() { clear(h.payloadScratch) }() |
| 104 | - | return ErrHistoryRowTooWide | 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 | id, err := h.allocateRowID() | 346 | id, err := h.allocateRowID() | ||
| 107 | if err != nil { | 347 | if err != nil { | ||
| @@ -119,8 +359,9 @@ func (h *History) AppendWithID(row []renderer.Cell, bound LineBound, id RowID) e | |||||
| 119 | if h == nil || h.maxRows == 0 { | 359 | if h == nil || h.maxRows == 0 { | ||
| 120 | return nil | 360 | return nil | ||
| 121 | } | 361 | } | ||
| 122 | - | if len(row) > h.maxCells { | 362 | + | defer func() { clear(h.payloadScratch) }() |
| 123 | - | return ErrHistoryRowTooWide | 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 | if h.hasRowID(id) { | 366 | if h.hasRowID(id) { | ||
| 126 | return errInvalidHistoryRowID | 367 | return errInvalidHistoryRowID | ||
| @@ -132,19 +373,20 @@ func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) erro | |||||
| 132 | if h.nextRowID == ^RowID(0) { | 373 | if h.nextRowID == ^RowID(0) { | ||
| 133 | return errInvalidHistoryRowID | 374 | return errInvalidHistoryRowID | ||
| 134 | } | 375 | } | ||
| 135 | - | if len(row) > h.maxCells { | |||
| 136 | - | return ErrHistoryRowTooWide | |||
| 137 | - | } | |||
| 138 | if id >= ^RowID(0)-1 { | 376 | if id >= ^RowID(0)-1 { | ||
| 139 | return errInvalidHistoryRowID | 377 | return errInvalidHistoryRowID | ||
| 140 | } | 378 | } | ||
| 141 | if id >= h.nextRowID { | 379 | if id >= h.nextRowID { | ||
| 142 | h.nextRowID = id + 1 | 380 | h.nextRowID = id + 1 | ||
| 143 | } | 381 | } | ||
| 144 | - | // Make room before adding so Cells cannot overflow even when the default | 382 | + | // Make room before adding so accounting cannot overflow even when the |
| 145 | - | // capacity is MaxInt. | 383 | + | // configured capacities use their maximum values. |
| 146 | - | h.evictFor(len(row)) | 384 | + | h.evictFor(row) |
| 147 | - | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | 385 | + | delta := h.tailAppendDelta(len(row)) |
| 386 | + | h.appendTailRow(row) | |||
| 387 | + | h.recordTailRowStyles(len(row)) | |||
| 388 | + | h.tailBytes += delta | |||
| 389 | + | h.logicalBytes += delta | |||
| 148 | h.tailBounds = append(h.tailBounds, clampBound(bound, len(row))) | 390 | h.tailBounds = append(h.tailBounds, clampBound(bound, len(row))) | ||
| 149 | h.tailIDs = append(h.tailIDs, id) | 391 | h.tailIDs = append(h.tailIDs, id) | ||
| 150 | h.rows++ | 392 | h.rows++ | ||
| @@ -205,90 +447,115 @@ func growRowIDs(ids []RowID, rows int) []RowID { | |||||
| 205 | return out | 447 | return out | ||
| 206 | } | 448 | } | ||
| 207 | 449 | ||||
| 208 | - | func cloneRowIDs(ids []RowID, rows int) []RowID { | 450 | + | func (h *History) reserveTailRow(width int) (start, end int) { |
| 209 | - | out := make([]RowID, rows) | 451 | + | h.cachedTail = nil |
| 210 | - | copy(out, ids) | 452 | + | start = len(h.tailCells) |
| 211 | - | return out | 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 | func (h *History) sealTail() { | 492 | func (h *History) sealTail() { | ||
| 215 | if len(h.tail) == 0 { | 493 | if len(h.tail) == 0 { | ||
| 216 | return | 494 | return | ||
| 217 | } | 495 | } | ||
| 218 | - | h.chunks = append(h.chunks, &HistoryChunk{ | 496 | + | chunks := h.tailViewChunks() |
| 219 | - | rows: h.tail, | 497 | + | h.chunks = append(h.chunks, chunks...) |
| 220 | - | bounds: growBounds(h.tailBounds, len(h.tail)), | 498 | + | h.logicalBytes = h.logicalBytes - h.tailBytes + historyChunksLogicalBytes(chunks) |
| 221 | - | rowIDs: h.tailIDs, | |||
| 222 | - | }) | |||
| 223 | h.tail = nil | 499 | h.tail = nil | ||
| 500 | + | h.cachedTail = nil | |||
| 501 | + | h.tailCells = nil | |||
| 224 | h.tailBounds = nil | 502 | h.tailBounds = nil | ||
| 225 | h.tailIDs = nil | 503 | h.tailIDs = nil | ||
| 226 | - | } | 504 | + | h.tailBytes = 0 |
| 227 | - | 505 | + | h.tailPageWidth = 0 | |
| 228 | - | // normalizeTail seals complete chunks so the mutable tail remains shorter than | 506 | + | h.tailPageRows = 0 |
| 229 | - | // chunkRows. Restored snapshots may have been written with a different chunk | 507 | + | h.tailPageStyles = nil |
| 230 | - | // size than the current history configuration. | 508 | + | h.tailPagePayloads = nil |
| 231 | - | func (h *History) normalizeTail() { | 509 | + | } |
| 232 | - | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)) | 510 | + | |
| 233 | - | for h.chunkRows > 0 && len(h.tail) >= h.chunkRows { | 511 | + | // evictFor discards oldest rows until row can fit both retention budgets. |
| 234 | - | bounds := growBounds(h.tailBounds, len(h.tail)) | 512 | + | func (h *History) evictFor(row []renderer.Cell) { |
| 235 | - | h.chunks = append(h.chunks, &HistoryChunk{ | 513 | + | for { |
| 236 | - | rows: h.tail[:h.chunkRows], | 514 | + | delta := h.tailAppendDelta(len(row)) |
| 237 | - | bounds: bounds[:h.chunkRows], | 515 | + | if h.rows <= h.maxRows-1 && h.logicalBytes <= h.maxBytes-delta { |
| 238 | - | rowIDs: h.tailIDs[:h.chunkRows], | |||
| 239 | - | }) | |||
| 240 | - | h.tail = h.tail[h.chunkRows:] | |||
| 241 | - | h.tailBounds = bounds[h.chunkRows:] | |||
| 242 | - | h.tailIDs = h.tailIDs[h.chunkRows:] | |||
| 243 | - | } | |||
| 244 | - | } | |||
| 245 | - | ||||
| 246 | - | func (h *History) evict() { | |||
| 247 | - | h.evictUntil(0, 0) | |||
| 248 | - | } | |||
| 249 | - | ||||
| 250 | - | // evictFor discards oldest rows until another row using cellCount cells can fit. | |||
| 251 | - | func (h *History) evictFor(cellCount int) { | |||
| 252 | - | h.evictUntil(1, cellCount) | |||
| 253 | - | } | |||
| 254 | - | ||||
| 255 | - | // evictUntil makes room for rowCount rows and cellCount cells without | |||
| 256 | - | // overflowing either accounting total. | |||
| 257 | - | func (h *History) evictUntil(rowCount, cellCount int) { | |||
| 258 | - | for h.rows > h.maxRows-rowCount || h.cells > h.maxCells-cellCount { | |||
| 259 | - | if len(h.chunks) > 0 { | |||
| 260 | - | chunk := h.chunks[0] | |||
| 261 | - | row := chunk.rows[0] | |||
| 262 | - | h.rows-- | |||
| 263 | - | h.cells -= len(row) | |||
| 264 | - | if len(chunk.rows) == 1 { | |||
| 265 | - | copy(h.chunks, h.chunks[1:]) | |||
| 266 | - | h.chunks[len(h.chunks)-1] = nil | |||
| 267 | - | h.chunks = h.chunks[:len(h.chunks)-1] | |||
| 268 | - | } else { | |||
| 269 | - | // Preserve cell storage while replacing only the chunk wrapper: a | |||
| 270 | - | // retained view may still refer to the original immutable chunk. | |||
| 271 | - | bounds := growBounds(chunk.bounds, len(chunk.rows)) | |||
| 272 | - | rowIDs := growRowIDs(chunk.rowIDs, len(chunk.rows)) | |||
| 273 | - | h.chunks[0] = &HistoryChunk{ | |||
| 274 | - | rows: chunk.rows[1:], | |||
| 275 | - | bounds: bounds[1:], | |||
| 276 | - | rowIDs: rowIDs[1:], | |||
| 277 | - | } | |||
| 278 | - | } | |||
| 279 | - | continue | |||
| 280 | - | } | |||
| 281 | - | if len(h.tail) == 0 { | |||
| 282 | return | 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 | h.rows-- | 530 | h.rows-- | ||
| 286 | - | h.cells -= len(row) | 531 | + | h.cells -= chunk.width |
| 287 | - | h.tail[0] = nil | 532 | + | if chunk.count == 1 { |
| 288 | - | h.tail = h.tail[1:] | 533 | + | h.logicalBytes -= oldBytes |
| 289 | - | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:] | 534 | + | copy(h.chunks, h.chunks[1:]) |
| 290 | - | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:] | 535 | + | h.chunks[len(h.chunks)-1] = nil |
| 536 | + | h.chunks = h.chunks[:len(h.chunks)-1] | |||
| 537 | + | } else { | |||
| 538 | + | // Preserve cell storage while replacing only the chunk wrapper: a | |||
| 539 | + | // retained view may still refer to the original immutable chunk. The | |||
| 540 | + | // wrapper tracks only styles still used by its retained suffix. | |||
| 541 | + | h.chunks[0] = chunk.withoutFirstRow() | |||
| 542 | + | h.logicalBytes -= oldBytes - historyChunkLogicalBytes(h.chunks[0]) | |||
| 543 | + | } | |||
| 544 | + | return | |||
| 291 | } | 545 | } | ||
| 546 | + | if len(h.tail) == 0 { | |||
| 547 | + | return | |||
| 548 | + | } | |||
| 549 | + | h.cachedTail = nil | |||
| 550 | + | row := h.tail[0] | |||
| 551 | + | h.rows-- | |||
| 552 | + | h.cells -= len(row) | |||
| 553 | + | h.tail[0] = nil | |||
| 554 | + | h.tail = h.tail[1:] | |||
| 555 | + | h.tailCells = h.tailCells[len(row):] | |||
| 556 | + | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:] | |||
| 557 | + | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:] | |||
| 558 | + | h.rebuildTailAccounting() | |||
| 292 | } | 559 | } | ||
| 293 | 560 | ||||
| 294 | // SealAndView rotates the mutable tail into an immutable chunk, then captures | 561 | // SealAndView rotates the mutable tail into an immutable chunk, then captures | ||
| @@ -300,8 +567,15 @@ func (h *History) SealAndView() HistoryView { | |||||
| 300 | return h.View() | 567 | return h.View() | ||
| 301 | } | 568 | } | ||
| 302 | 569 | ||||
| 303 | - | // View captures the current history. Sealed chunks are shared by identity; a | 570 | + | func (h *History) tailViewChunks() []*HistoryChunk { |
| 304 | - | // partially-filled tail is copied into a new immutable chunk for this view. | 571 | + | if h.cachedTail == nil && len(h.tail) > 0 { |
| 572 | + | h.cachedTail = newHistoryChunks(h.tail, h.tailBounds, h.tailIDs) | |||
| 573 | + | } | |||
| 574 | + | return h.cachedTail | |||
| 575 | + | } | |||
| 576 | + | ||||
| 577 | + | // View captures the current history. Sealed chunks are shared by identity; | |||
| 578 | + | // the immutable tail copy is reused until the mutable tail changes. | |||
| 305 | func (h *History) View() HistoryView { | 579 | func (h *History) View() HistoryView { | ||
| 306 | if h == nil { | 580 | if h == nil { | ||
| 307 | return HistoryView{} | 581 | return HistoryView{} | ||
| @@ -312,13 +586,9 @@ func (h *History) View() HistoryView { | |||||
| 312 | chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1) | 586 | chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1) | ||
| 313 | copy(chunks, h.chunks) | 587 | copy(chunks, h.chunks) | ||
| 314 | if len(h.tail) > 0 { | 588 | if len(h.tail) > 0 { | ||
| 315 | - | chunks = append(chunks, &HistoryChunk{ | 589 | + | chunks = append(chunks, h.tailViewChunks()...) |
| 316 | - | rows: cloneHistoryRows(h.tail), | |||
| 317 | - | bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |||
| 318 | - | rowIDs: cloneRowIDs(h.tailIDs, len(h.tail)), | |||
| 319 | - | }) | |||
| 320 | } | 590 | } | ||
| 321 | - | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID} | 591 | + | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, logicalBytes: h.logicalBytes, nextRowID: h.nextRowID} |
| 322 | } | 592 | } | ||
| 323 | 593 | ||||
| 324 | // SnapshotView captures history for persistence without sealing the mutable | 594 | // SnapshotView captures history for persistence without sealing the mutable | ||
| @@ -330,28 +600,23 @@ func (h *History) SnapshotView() HistorySnapshotView { | |||||
| 330 | if h.rows == 0 { | 600 | if h.rows == 0 { | ||
| 331 | return HistorySnapshotView{nextRowID: h.nextRowID} | 601 | return HistorySnapshotView{nextRowID: h.nextRowID} | ||
| 332 | } | 602 | } | ||
| 603 | + | tailChunks := newHistoryChunks(h.tail, h.tailBounds, h.tailIDs) | |||
| 333 | return HistorySnapshotView{ | 604 | return HistorySnapshotView{ | ||
| 334 | - | chunks: append([]*HistoryChunk(nil), h.chunks...), | 605 | + | chunks: append([]*HistoryChunk(nil), h.chunks...), |
| 335 | - | tail: cloneHistoryRows(h.tail), | 606 | + | tail: HistoryView{ |
| 336 | - | tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | 607 | + | chunks: tailChunks, |
| 337 | - | tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)), | 608 | + | rows: len(h.tail), |
| 338 | - | rows: h.rows, | 609 | + | cells: historyRowsCells(h.tail), |
| 339 | - | cells: h.cells, | 610 | + | logicalBytes: historyChunksLogicalBytes(tailChunks), |
| 340 | - | nextRowID: h.nextRowID, | 611 | + | nextRowID: h.nextRowID, |
| 612 | + | }, | |||
| 613 | + | rows: h.rows, | |||
| 614 | + | cells: h.cells, | |||
| 615 | + | logicalBytes: h.logicalBytes, | |||
| 616 | + | nextRowID: h.nextRowID, | |||
| 341 | } | 617 | } | ||
| 342 | } | 618 | } | ||
| 343 | 619 | ||||
| 344 | - | func cloneHistoryRows(rows [][]renderer.Cell) [][]renderer.Cell { | |||
| 345 | - | if len(rows) == 0 { | |||
| 346 | - | return nil | |||
| 347 | - | } | |||
| 348 | - | copyRows := make([][]renderer.Cell, len(rows)) | |||
| 349 | - | for i, row := range rows { | |||
| 350 | - | copyRows[i] = append([]renderer.Cell(nil), row...) | |||
| 351 | - | } | |||
| 352 | - | return copyRows | |||
| 353 | - | } | |||
| 354 | - | ||||
| 355 | // Len returns the currently retained history row count. | 620 | // Len returns the currently retained history row count. | ||
| 356 | func (h *History) Len() int { | 621 | func (h *History) Len() int { | ||
| 357 | if h == nil { | 622 | if h == nil { | ||
| @@ -368,6 +633,15 @@ func (h *History) Cells() int { | |||||
| 368 | return h.cells | 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 | // NextRowID returns the next identity allocated by this history. | 645 | // NextRowID returns the next identity allocated by this history. | ||
| 372 | func (h *History) NextRowID() RowID { | 646 | func (h *History) NextRowID() RowID { | ||
| 373 | if h == nil || h.nextRowID == 0 { | 647 | if h == nil || h.nextRowID == 0 { | ||
| @@ -376,24 +650,32 @@ func (h *History) NextRowID() RowID { | |||||
| 376 | return h.nextRowID | 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 | func (h *History) Cap() int { | 655 | func (h *History) Cap() int { | ||
| 381 | if h == nil { | 656 | if h == nil { | ||
| 382 | return 0 | 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. | 662 | + | // ByteCap returns the configured uncompressed byte ceiling. Zero means no |
| 388 | - | func (h *History) CellCap() int { | 663 | + | // independent byte ceiling, or disabled history when Cap is also zero. |
| 664 | + | func (h *History) ByteCap() uint64 { | |||
| 389 | if h == nil { | 665 | if h == nil { | ||
| 390 | return 0 | 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 } | 671 | + | func (v HistoryView) Len() int { return v.rows } |
| 396 | - | func (v HistoryView) Cells() int { return v.cells } | 672 | + | func (v HistoryView) Cells() int { return v.cells } |
| 673 | + | func (v HistoryView) LogicalBytes() uint64 { | |||
| 674 | + | if v.logicalBytes != 0 || len(v.chunks) == 0 { | |||
| 675 | + | return v.logicalBytes | |||
| 676 | + | } | |||
| 677 | + | return historyChunksLogicalBytes(v.chunks) | |||
| 678 | + | } | |||
| 397 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | 679 | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | ||
| 398 | 680 | ||||
| 399 | // NextRowID returns the next identity recorded by this view. | 681 | // NextRowID returns the next identity recorded by this view. | ||
| @@ -416,37 +698,32 @@ func (v HistoryView) Chunk(i int) *HistoryChunk { | |||||
| 416 | return v.chunks[i] | 698 | return v.chunks[i] | ||
| 417 | } | 699 | } | ||
| 418 | 700 | ||||
| 419 | - | // Range calls yield for each row in oldest-first order. The row is borrowed | 701 | + | // Range calls yield with a decoded owned row in oldest-first order. Returning |
| 420 | - | // immutable storage: yield must not mutate or retain it after returning. | 702 | + | // false stops iteration successfully; a backing restore failure returns an error. |
| 421 | - | func (v HistoryView) Range(yield func([]renderer.Cell) bool) { | 703 | + | func (v HistoryView) Range(yield func([]renderer.Cell) bool) error { |
| 422 | for _, chunk := range v.chunks { | 704 | for _, chunk := range v.chunks { | ||
| 423 | - | for _, row := range chunk.rows { | 705 | + | // Streaming search restores at most one transient page at a time and |
| 424 | - | if !yield(row) { | 706 | + | // does not populate the random-access cache of every cold page. |
| 425 | - | return | 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 | func (v HistoryView) Row(i int) []renderer.Cell { | 721 | func (v HistoryView) Row(i int) []renderer.Cell { | ||
| 433 | - | return append([]renderer.Cell(nil), v.BorrowedRow(i)...) | 722 | + | chunk, row := v.locateRow(i) |
| 434 | - | } | 723 | + | if chunk == nil { |
| 435 | - | ||||
| 436 | - | // BorrowedRow returns immutable storage for the row at i, or nil when i is out | |||
| 437 | - | // of range. The caller must not mutate or retain the result after it no longer | |||
| 438 | - | // retains v. Consumers that need ownership should use Row instead. | |||
| 439 | - | func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||
| 440 | - | if i < 0 { | |||
| 441 | return nil | 724 | return nil | ||
| 442 | } | 725 | } | ||
| 443 | - | for _, chunk := range v.chunks { | 726 | + | return chunk.row(row) |
| 444 | - | if i < len(chunk.rows) { | |||
| 445 | - | return chunk.rows[i] | |||
| 446 | - | } | |||
| 447 | - | i -= len(chunk.rows) | |||
| 448 | - | } | |||
| 449 | - | return nil | |||
| 450 | } | 727 | } | ||
| 451 | 728 | ||||
| 452 | // RowID returns the identity of the row at i, or zero when i is out of range. | 729 | // RowID returns the identity of the row at i, or zero when i is out of range. | ||
| @@ -455,13 +732,13 @@ func (v HistoryView) RowID(i int) RowID { | |||||
| 455 | return 0 | 732 | return 0 | ||
| 456 | } | 733 | } | ||
| 457 | for _, chunk := range v.chunks { | 734 | for _, chunk := range v.chunks { | ||
| 458 | - | if i < len(chunk.rows) { | 735 | + | if i < chunk.len() { |
| 459 | if i < len(chunk.rowIDs) { | 736 | if i < len(chunk.rowIDs) { | ||
| 460 | return chunk.rowIDs[i] | 737 | return chunk.rowIDs[i] | ||
| 461 | } | 738 | } | ||
| 462 | return 0 | 739 | return 0 | ||
| 463 | } | 740 | } | ||
| 464 | - | i -= len(chunk.rows) | 741 | + | i -= chunk.len() |
| 465 | } | 742 | } | ||
| 466 | return 0 | 743 | return 0 | ||
| 467 | } | 744 | } | ||
| @@ -473,7 +750,7 @@ func (v HistoryView) FindRowID(id RowID) int { | |||||
| 473 | } | 750 | } | ||
| 474 | index := 0 | 751 | index := 0 | ||
| 475 | for _, chunk := range v.chunks { | 752 | for _, chunk := range v.chunks { | ||
| 476 | - | for row := range chunk.rows { | 753 | + | for row := range chunk.len() { |
| 477 | if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id { | 754 | if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id { | ||
| 478 | return index | 755 | return index | ||
| 479 | } | 756 | } | ||
| @@ -498,19 +775,25 @@ func (v HistoryView) Bound(i int) LineBound { | |||||
| 498 | return LineBound{} | 775 | return LineBound{} | ||
| 499 | } | 776 | } | ||
| 500 | for _, chunk := range v.chunks { | 777 | for _, chunk := range v.chunks { | ||
| 501 | - | if i < len(chunk.rows) { | 778 | + | if i < chunk.len() { |
| 502 | if i < len(chunk.bounds) { | 779 | if i < len(chunk.bounds) { | ||
| 503 | return chunk.bounds[i] | 780 | return chunk.bounds[i] | ||
| 504 | } | 781 | } | ||
| 505 | return LineBound{} | 782 | return LineBound{} | ||
| 506 | } | 783 | } | ||
| 507 | - | i -= len(chunk.rows) | 784 | + | i -= chunk.len() |
| 508 | } | 785 | } | ||
| 509 | return LineBound{} | 786 | return LineBound{} | ||
| 510 | } | 787 | } | ||
| 511 | 788 | ||||
| 512 | - | func (v HistorySnapshotView) Len() int { return v.rows } | 789 | + | func (v HistorySnapshotView) Len() int { return v.rows } |
| 513 | - | func (v HistorySnapshotView) Cells() int { return v.cells } | 790 | + | func (v HistorySnapshotView) Cells() int { return v.cells } |
| 791 | + | func (v HistorySnapshotView) LogicalBytes() uint64 { | |||
| 792 | + | if v.logicalBytes != 0 || v.rows == 0 { | |||
| 793 | + | return v.logicalBytes | |||
| 794 | + | } | |||
| 795 | + | return addLogicalBytes(historyChunksLogicalBytes(v.chunks), v.tail.LogicalBytes()) | |||
| 796 | + | } | |||
| 514 | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | 797 | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | ||
| 515 | 798 | ||||
| 516 | // NextRowID returns the next identity recorded by this snapshot. | 799 | // NextRowID returns the next identity recorded by this snapshot. | ||
| @@ -531,15 +814,9 @@ func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | |||||
| 531 | 814 | ||||
| 532 | // Tail returns an immutable view of the copied mutable tail. | 815 | // Tail returns an immutable view of the copied mutable tail. | ||
| 533 | func (v HistorySnapshotView) Tail() HistoryView { | 816 | func (v HistorySnapshotView) Tail() HistoryView { | ||
| 534 | - | if len(v.tail) == 0 { | 817 | + | tail := v.tail |
| 535 | - | return HistoryView{nextRowID: v.nextRowID} | 818 | + | tail.nextRowID = v.nextRowID |
| 536 | - | } | 819 | + | return tail |
| 537 | - | return HistoryView{ | |||
| 538 | - | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}}, | |||
| 539 | - | rows: len(v.tail), | |||
| 540 | - | cells: historyRowsCells(v.tail), | |||
| 541 | - | nextRowID: v.nextRowID, | |||
| 542 | - | } | |||
| 543 | } | 820 | } | ||
| 544 | 821 | ||||
| 545 | func historyRowsCells(rows [][]renderer.Cell) int { | 822 | func historyRowsCells(rows [][]renderer.Cell) int { | ||
M history_acceptance_test.go +1 -1
| @@ -84,7 +84,7 @@ func TestSnapshotCodecsPreserveEveryTerminalCellAcrossHistoryAndRecoveryTranscri | |||||
| 84 | screen := NewScreen(4, 2) | 84 | screen := NewScreen(4, 2) | ||
| 85 | want := [][]renderer.Cell{makeRow('D', rgb), makeRow('E', indexed)} | 85 | want := [][]renderer.Cell{makeRow('D', rgb), makeRow('E', indexed)} | ||
| 86 | for y, row := range want { | 86 | for y, row := range want { | ||
| 87 | - | copy(screen.Frame.Row(y), row) | 87 | + | screen.frame.WriteRow(y, 0, row) |
| 88 | screen.buffer.boundaries[y] = LineBound{End: len(row)} | 88 | screen.buffer.boundaries[y] = LineBound{End: len(row)} | ||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
A history_bytes_test.go +130 -0
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHistoryBoundsRetainedLogicalBytes(t *testing.T) { | |||
| 11 | + | const budget = 2*(2*renderer.StoredCellLogicalBytes+renderer.RowDescriptorLogicalBytes) + renderer.StyleRecordLogicalBytes | |||
| 12 | + | history := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: budget, ChunkRows: 4}) | |||
| 13 | + | for _, text := range []string{"aa", "bb", "cc"} { | |||
| 14 | + | requireHistoryAppend(t, history, historyRow(text)) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | if got, want := historyViewTexts(history.View()), []string{"bb", "cc"}; !equalStrings(got, want) { | |||
| 18 | + | t.Fatalf("retained rows = %#v, want %#v", got, want) | |||
| 19 | + | } | |||
| 20 | + | if got, want := history.LogicalBytes(), budget; got != want { | |||
| 21 | + | t.Fatalf("logical bytes = %d, want %d", got, want) | |||
| 22 | + | } | |||
| 23 | + | if got, want := history.View().LogicalBytes(), budget; got != want { | |||
| 24 | + | t.Fatalf("view logical bytes = %d, want %d", got, want) | |||
| 25 | + | } | |||
| 26 | + | if got, want := history.ByteCap(), budget; got != want { | |||
| 27 | + | t.Fatalf("byte capacity = %d, want %d", got, want) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestHistoryLogicalBytesDeduplicateCanonicalStylesPerSlab(t *testing.T) { | |||
| 32 | + | const budget = 2*(renderer.StoredCellLogicalBytes+renderer.RowDescriptorLogicalBytes) + 2*renderer.StyleRecordLogicalBytes | |||
| 33 | + | history := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: budget, ChunkRows: 2}) | |||
| 34 | + | style := renderer.Style{Bold: true} | |||
| 35 | + | for _, r := range []rune{'a', 'b'} { | |||
| 36 | + | if err := history.Append([]renderer.Cell{{Rune: r, Style: style}}, LineBound{End: 1}); err != nil { | |||
| 37 | + | t.Fatalf("append %q: %v", r, err) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | if got, want := history.LogicalBytes(), budget; got != want { | |||
| 42 | + | t.Fatalf("logical bytes = %d, want %d", got, want) | |||
| 43 | + | } | |||
| 44 | + | if got := history.SnapshotView().LogicalBytes(); got != history.LogicalBytes() { | |||
| 45 | + | t.Fatalf("snapshot logical bytes = %d, want %d", got, history.LogicalBytes()) | |||
| 46 | + | } | |||
| 47 | + | if got := history.SealAndView().LogicalBytes(); got != history.LogicalBytes() { | |||
| 48 | + | t.Fatalf("sealed view logical bytes = %d, want %d", got, history.LogicalBytes()) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestHistoryLogicalBytesRemainStableAfterStyledRowEvictionAndCodecRoundTrip(t *testing.T) { | |||
| 53 | + | history := NewHistory(HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 3}) | |||
| 54 | + | styles := []renderer.Style{{Bold: true}, renderer.DefaultStyle(), renderer.DefaultStyle(), renderer.DefaultStyle()} | |||
| 55 | + | for i, style := range styles { | |||
| 56 | + | if err := history.Append([]renderer.Cell{{Rune: rune('a' + i), Style: style}}, LineBound{End: 1}); err != nil { | |||
| 57 | + | t.Fatalf("append row %d: %v", i, err) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | before := history.View() | |||
| 62 | + | blob, err := MarshalHistory(before) | |||
| 63 | + | if err != nil { | |||
| 64 | + | t.Fatalf("marshal history: %v", err) | |||
| 65 | + | } | |||
| 66 | + | after, err := UnmarshalHistory(blob) | |||
| 67 | + | if err != nil { | |||
| 68 | + | t.Fatalf("unmarshal history: %v", err) | |||
| 69 | + | } | |||
| 70 | + | if got, want := after.LogicalBytes(), before.LogicalBytes(); got != want { | |||
| 71 | + | t.Fatalf("round-trip logical bytes = %d, want %d", got, want) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestHistoryRejectsSingleRowLargerThanLogicalByteBudget(t *testing.T) { | |||
| 76 | + | history := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: 79, ChunkRows: 4}) | |||
| 77 | + | row := []renderer.Cell{{Rune: 'x', Style: renderer.Style{Bold: true}}} | |||
| 78 | + | ||||
| 79 | + | err := history.Append(row, LineBound{End: 1}) | |||
| 80 | + | if !errors.Is(err, ErrHistoryRowTooLarge) { | |||
| 81 | + | t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooLarge", err) | |||
| 82 | + | } | |||
| 83 | + | if history.Len() != 0 || history.LogicalBytes() != 0 { | |||
| 84 | + | t.Fatalf("rejected append mutated history: rows=%d bytes=%d", history.Len(), history.LogicalBytes()) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func BenchmarkHistoryByteLimitedAppend(b *testing.B) { | |||
| 89 | + | const rows, width = 256, 120 | |||
| 90 | + | maxBytes := uint64(rows)*(uint64(width)*renderer.StoredCellLogicalBytes+renderer.RowDescriptorLogicalBytes) + renderer.StyleRecordLogicalBytes | |||
| 91 | + | history := NewHistory(HistoryConfig{MaxRows: rows * 2, MaxBytes: maxBytes, ChunkRows: rows}) | |||
| 92 | + | row := make([]renderer.Cell, width) | |||
| 93 | + | for range rows { | |||
| 94 | + | requireHistoryAppend(b, history, row) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | b.ReportAllocs() | |||
| 98 | + | b.SetBytes(int64(width) * int64(renderer.StoredCellLogicalBytes)) | |||
| 99 | + | b.ResetTimer() | |||
| 100 | + | for b.Loop() { | |||
| 101 | + | requireHistoryAppend(b, history, row) | |||
| 102 | + | } | |||
| 103 | + | b.StopTimer() | |||
| 104 | + | if history.LogicalBytes() > maxBytes { | |||
| 105 | + | b.Fatalf("logical bytes = %d, exceeds capacity %d", history.LogicalBytes(), maxBytes) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestHistoryRestoreEvictsToLogicalByteBudget(t *testing.T) { | |||
| 110 | + | source := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: 1 << 20, ChunkRows: 4}) | |||
| 111 | + | for _, text := range []string{"aa", "bb", "cc"} { | |||
| 112 | + | requireHistoryAppend(t, source, historyRow(text)) | |||
| 113 | + | } | |||
| 114 | + | sealed, tail, err := MarshalSealedHistory(source.SealAndView()) | |||
| 115 | + | if err != nil { | |||
| 116 | + | t.Fatalf("marshal history: %v", err) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | const budget = 2*(2*renderer.StoredCellLogicalBytes+renderer.RowDescriptorLogicalBytes) + renderer.StyleRecordLogicalBytes | |||
| 120 | + | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 8, MaxBytes: budget, ChunkRows: 4}, sealed, tail) | |||
| 121 | + | if err != nil { | |||
| 122 | + | t.Fatalf("restore history: %v", err) | |||
| 123 | + | } | |||
| 124 | + | if got, want := historyViewTexts(restored.View()), []string{"bb", "cc"}; !equalStrings(got, want) { | |||
| 125 | + | t.Fatalf("restored rows = %#v, want %#v", got, want) | |||
| 126 | + | } | |||
| 127 | + | if got := restored.LogicalBytes(); got > restored.ByteCap() { | |||
| 128 | + | t.Fatalf("restored logical bytes = %d, exceeds capacity %d", got, restored.ByteCap()) | |||
| 129 | + | } | |||
| 130 | + | } | |||
M history_codec.go +251 -333
| @@ -5,108 +5,185 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "math" | 7 | "math" | ||
| 8 | - | "slices" | |||
| 9 | - | "unicode/utf8" | |||
| 10 | 8 | ||||
| 11 | renderer "github.com/bnema/vev-vt/core" | 9 | renderer "github.com/bnema/vev-vt/core" | ||
| 12 | ) | 10 | ) | ||
| 13 | 11 | ||||
| 14 | const ( | 12 | const ( | ||
| 15 | - | historyMagic = "VTH1" | 13 | + | historyMagic = "VTC1" |
| 16 | - | historyVersion = 3 | 14 | + | historyHeaderBytes = 16 |
| 17 | - | historyCellBytes = 41 | 15 | + | historyChunkHeaderBytes = 16 |
| 18 | - | historyBoundBytes = 5 | 16 | + | historyStoredCellBytes = 9 |
| 19 | - | maxHistoryChunkRows = 256 | 17 | + | historyStyleBytes = 37 |
| 20 | - | 18 | + | historyRowBytes = 13 | |
| 21 | - | // The daemon retains 10,000 history rows. Support a 20% row margin and | 19 | + | historyBoundBytes = 5 |
| 22 | - | // 160-cell rows, which leaves headroom above the representative 10k×120 | 20 | + | maxHistoryChunkRows = 256 |
| 23 | - | // terminal while bounding snapshot allocation. | 21 | + | |
| 24 | - | maxHistoryChunks = 12_000 | 22 | + | // Aggregate resource ceilings allow wide rows without tying persistence to |
| 25 | - | maxHistoryRows = 12_000 | 23 | + | // a particular terminal geometry. They apply before compact allocation. |
| 26 | - | maxHistoryRowCells = 160 | 24 | + | maxHistoryChunks = 12_000 |
| 27 | - | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | 25 | + | maxHistoryRows = 12_000 |
| 28 | - | 26 | + | maxHistoryRowCells = 160 | |
| 29 | - | maxHistoryDecodeStyles = maxHistoryCells | 27 | + | maxHistoryCells = maxHistoryRows * maxHistoryRowCells |
| 30 | - | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | 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 | var errInvalidHistory = errors.New("invalid history payload") | 36 | var errInvalidHistory = errors.New("invalid history payload") | ||
| 34 | 37 | ||||
| 35 | - | // MarshalHistory encodes a HistoryView in a deterministic, self-contained | 38 | + | type historyDecodeStats struct { |
| 36 | - | // format. It preserves chunk boundaries as well as every Cell and Style field. | 39 | + | chunks, rows, cells, styles, bytes uint64 |
| 40 | + | payloadBytes uint64 | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // MarshalHistory serializes semantic cells with canonical chunk-local style | |||
| 44 | + | // dictionaries. Internal style IDs and page memory never cross this boundary. | |||
| 37 | func MarshalHistory(view HistoryView) ([]byte, error) { | 45 | func MarshalHistory(view HistoryView) ([]byte, error) { | ||
| 38 | - | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks { | 46 | + | if view.rows < 0 || view.rows > maxHistoryRows || view.rows != historyViewRowCount(view) || len(view.chunks) > maxHistoryChunks { |
| 39 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 47 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 40 | } | 48 | } | ||
| 41 | - | nextRowID, ok := historyViewNextRowID(view) | 49 | + | nextID, ok := historyViewNextRowID(view) |
| 42 | if !ok { | 50 | if !ok { | ||
| 43 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 51 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 44 | } | 52 | } | ||
| 45 | - | stats := historyDecodeStats{} | 53 | + | type encodedChunk struct { |
| 46 | - | seen := make(map[RowID]struct{}, view.rows) | 54 | + | chunk *HistoryChunk |
| 55 | + | styles []renderer.Style | |||
| 56 | + | ids map[renderer.Style]uint32 | |||
| 57 | + | payloads []renderer.CellPayload | |||
| 58 | + | payloadIDs map[renderer.CellPayload]uint32 | |||
| 59 | + | } | |||
| 60 | + | encoded := make([]encodedChunk, 0, len(view.chunks)) | |||
| 61 | + | size := uint64(historyHeaderBytes) | |||
| 62 | + | stats := historyDecodeStats{chunks: uint64(len(view.chunks))} | |||
| 47 | for _, chunk := range view.chunks { | 63 | for _, chunk := range view.chunks { | ||
| 48 | - | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) > maxHistoryChunkRows || | 64 | + | if chunk == nil || chunk.page == nil || chunk.len() == 0 || chunk.len() > maxHistoryChunkRows || |
| 49 | - | !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) { | 65 | + | chunk.len() != len(chunk.rowIDs) || chunk.len() != len(chunk.bounds) || chunk.width < 0 || |
| 66 | + | uint64(chunk.width) > math.MaxUint32 { | |||
| 50 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 67 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 51 | } | 68 | } | ||
| 52 | - | for i, row := range chunk.rows { | 69 | + | cells := uint64(chunk.width) * uint64(chunk.len()) |
| 53 | - | if uint64(len(row)) > math.MaxUint32 { | 70 | + | if !addHistoryDecodeBudget(&stats.rows, uint64(chunk.len()), maxHistoryRows) || |
| 54 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 71 | + | !addHistoryDecodeBudget(&stats.cells, cells, maxHistoryCells) { |
| 55 | - | } | 72 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) |
| 56 | - | id := chunk.rowIDs[i] | 73 | + | } |
| 57 | - | if id == 0 || id >= ^RowID(0)-1 { | 74 | + | frame, err := chunk.page.readFrame(false) |
| 58 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 75 | + | if err != nil { |
| 59 | - | } | 76 | + | return nil, fmt.Errorf("marshal history: %w", err) |
| 60 | - | if _, duplicate := seen[id]; duplicate { | 77 | + | } |
| 61 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 78 | + | if frame.Width != chunk.width || chunk.start < 0 || chunk.start > frame.Height-chunk.count { |
| 62 | - | } | 79 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) |
| 63 | - | seen[id] = struct{}{} | 80 | + | } |
| 64 | - | cellCount := uint64(len(row)) | 81 | + | styles := []renderer.Style{renderer.DefaultStyle()} |
| 65 | - | cellBytes, ok := historyCellByteCount(cellCount) | 82 | + | ids := map[renderer.Style]uint32{renderer.DefaultStyle(): 0} |
| 66 | - | if !ok || | 83 | + | var payloads []renderer.CellPayload |
| 67 | - | !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) || | 84 | + | var payloadIDs map[renderer.CellPayload]uint32 |
| 68 | - | !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) || | 85 | + | var payloadWireBytes uint64 |
| 69 | - | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | 86 | + | for row := range chunk.len() { |
| 70 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 87 | + | if !validHistoryBound(chunk.bounds[row], chunk.width) { |
| 71 | - | } | |||
| 72 | - | bound := chunkBound(chunk, i) | |||
| 73 | - | if !validHistoryBound(bound, len(row)) { | |||
| 74 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 88 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 75 | } | 89 | } | ||
| 76 | - | for _, cell := range row { | 90 | + | for x := range chunk.width { |
| 91 | + | cell := frame.Cell(x, chunk.start+row) | |||
| 77 | if !validHistoryCell(cell) { | 92 | if !validHistoryCell(cell) { | ||
| 78 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 93 | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | ||
| 79 | } | 94 | } | ||
| 95 | + | if !cell.Payload.Empty() { | |||
| 96 | + | payloadWireBytes += 4 // The handle is present only for exceptional cells. | |||
| 97 | + | if payloadIDs == nil { | |||
| 98 | + | payloadIDs = make(map[renderer.CellPayload]uint32) | |||
| 99 | + | } | |||
| 100 | + | if _, exists := payloadIDs[cell.Payload]; !exists { | |||
| 101 | + | n := cell.Payload.LogicalBytes() | |||
| 102 | + | if !addHistoryDecodeBudget(&stats.payloadBytes, n, maxHistoryPayloadBytes) || !addHistoryDecodeBudget(&stats.bytes, n, maxHistoryDecodedBytes) { | |||
| 103 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 104 | + | } | |||
| 105 | + | payloads = append(payloads, cell.Payload) | |||
| 106 | + | payloadIDs[cell.Payload] = uint32(len(payloads)) | |||
| 107 | + | payloadWireBytes += 8 + uint64(len(cell.Payload.Grapheme())+len(cell.Payload.Hyperlink())) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | style := cell.Style.Canonical() | |||
| 111 | + | if _, exists := ids[style]; !exists { | |||
| 112 | + | ids[style] = uint32(len(styles)) | |||
| 113 | + | styles = append(styles, style) | |||
| 114 | + | } | |||
| 80 | } | 115 | } | ||
| 81 | } | 116 | } | ||
| 117 | + | if !addHistoryDecodeBudget(&stats.styles, uint64(len(styles)), maxHistoryDecodeStyles) || | |||
| 118 | + | !addHistoryLogicalBytes(&stats, cells, uint64(chunk.len()), uint64(len(styles))) { | |||
| 119 | + | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 120 | + | } | |||
| 121 | + | size += historyChunkHeaderBytes + uint64(len(styles)-1)*historyStyleBytes + uint64(chunk.len())*historyRowBytes + cells*historyStoredCellBytes + payloadWireBytes | |||
| 122 | + | encoded = append(encoded, encodedChunk{chunk: chunk, styles: styles, ids: ids, payloads: payloads, payloadIDs: payloadIDs}) | |||
| 82 | } | 123 | } | ||
| 83 | - | if nextRowID <= maxHistoryRowID(seen) { | 124 | + | // Aggregate budgets bound size well below MaxInt even on 32-bit hosts. |
| 84 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | 125 | + | out := make([]byte, 0, int(size)) |
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | out := make([]byte, 0, 17) | |||
| 88 | out = append(out, historyMagic...) | 126 | out = append(out, historyMagic...) | ||
| 89 | - | out = append(out, historyVersion) | 127 | + | out = binary.BigEndian.AppendUint32(out, uint32(len(encoded))) |
| 90 | - | out = appendUint32(out, uint32(len(view.chunks))) | 128 | + | out = binary.BigEndian.AppendUint64(out, uint64(nextID)) |
| 91 | - | out = binary.BigEndian.AppendUint64(out, uint64(nextRowID)) | 129 | + | for _, entry := range encoded { |
| 92 | - | for _, chunk := range view.chunks { | 130 | + | chunk, styles, ids := entry.chunk, entry.styles, entry.ids |
| 93 | - | out = appendUint32(out, uint32(len(chunk.rows))) | 131 | + | frame, err := chunk.page.readFrame(false) |
| 94 | - | for i, row := range chunk.rows { | 132 | + | if err != nil { |
| 95 | - | out = binary.BigEndian.AppendUint64(out, uint64(chunk.rowIDs[i])) | 133 | + | return nil, fmt.Errorf("marshal history: %w", err) |
| 96 | - | out = appendUint32(out, uint32(len(row))) | 134 | + | } |
| 97 | - | for _, cell := range row { | 135 | + | out = binary.BigEndian.AppendUint32(out, uint32(chunk.width)) |
| 98 | - | out = appendHistoryCell(out, cell) | 136 | + | out = binary.BigEndian.AppendUint32(out, uint32(chunk.len())) |
| 137 | + | out = binary.BigEndian.AppendUint32(out, uint32(len(styles))) | |||
| 138 | + | out = binary.BigEndian.AppendUint32(out, uint32(len(entry.payloads))) | |||
| 139 | + | for _, style := range styles[1:] { | |||
| 140 | + | out = appendHistoryStyle(out, style) | |||
| 141 | + | } | |||
| 142 | + | for _, p := range entry.payloads { | |||
| 143 | + | out = appendHistoryPayload(out, p) | |||
| 144 | + | } | |||
| 145 | + | for row, id := range chunk.rowIDs { | |||
| 146 | + | out = binary.BigEndian.AppendUint64(out, uint64(id)) | |||
| 147 | + | out = appendHistoryBound(out, chunk.bounds[row]) | |||
| 148 | + | } | |||
| 149 | + | for row := range chunk.len() { | |||
| 150 | + | for x := range chunk.width { | |||
| 151 | + | cell := frame.Cell(x, chunk.start+row) | |||
| 152 | + | out = binary.BigEndian.AppendUint32(out, uint32(cell.Rune)) | |||
| 153 | + | out = binary.BigEndian.AppendUint32(out, ids[cell.Style.Canonical()]) | |||
| 154 | + | flag := byte(0) | |||
| 155 | + | if cell.Continuation { | |||
| 156 | + | flag = 1 | |||
| 157 | + | } | |||
| 158 | + | if !cell.Payload.Empty() { | |||
| 159 | + | flag |= 2 | |||
| 160 | + | } | |||
| 161 | + | out = append(out, flag) | |||
| 162 | + | if !cell.Payload.Empty() { | |||
| 163 | + | out = binary.BigEndian.AppendUint32(out, entry.payloadIDs[cell.Payload]) | |||
| 164 | + | } | |||
| 99 | } | 165 | } | ||
| 100 | - | out = appendHistoryBound(out, chunkBound(chunk, i)) | |||
| 101 | } | 166 | } | ||
| 102 | } | 167 | } | ||
| 103 | return out, nil | 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 | func historyViewNextRowID(view HistoryView) (RowID, bool) { | 182 | func historyViewNextRowID(view HistoryView) (RowID, bool) { | ||
| 107 | seen := make(map[RowID]struct{}, view.rows) | 183 | seen := make(map[RowID]struct{}, view.rows) | ||
| 184 | + | var maxID RowID | |||
| 108 | for _, chunk := range view.chunks { | 185 | for _, chunk := range view.chunks { | ||
| 109 | - | if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) { | 186 | + | if chunk == nil || chunk.len() != len(chunk.rowIDs) { |
| 110 | return 0, false | 187 | return 0, false | ||
| 111 | } | 188 | } | ||
| 112 | for _, id := range chunk.rowIDs { | 189 | for _, id := range chunk.rowIDs { | ||
| @@ -117,110 +194,22 @@ func historyViewNextRowID(view HistoryView) (RowID, bool) { | |||||
| 117 | return 0, false | 194 | return 0, false | ||
| 118 | } | 195 | } | ||
| 119 | seen[id] = struct{}{} | 196 | seen[id] = struct{}{} | ||
| 197 | + | maxID = max(maxID, id) | |||
| 120 | } | 198 | } | ||
| 121 | } | 199 | } | ||
| 122 | next := view.nextRowID | 200 | next := view.nextRowID | ||
| 123 | if next == 0 { | 201 | if next == 0 { | ||
| 124 | - | maxID := maxHistoryRowID(seen) | |||
| 125 | - | if maxID == ^RowID(0) { | |||
| 126 | - | return 0, false | |||
| 127 | - | } | |||
| 128 | next = maxID + 1 | 202 | next = maxID + 1 | ||
| 129 | } | 203 | } | ||
| 130 | - | if next == 0 || next == ^RowID(0) || next <= maxHistoryRowID(seen) { | 204 | + | return next, next > maxID && next < ^RowID(0) |
| 131 | - | return 0, false | |||
| 132 | - | } | |||
| 133 | - | return next, true | |||
| 134 | - | } | |||
| 135 | - | ||||
| 136 | - | func maxHistoryRowID(ids map[RowID]struct{}) RowID { | |||
| 137 | - | var maxID RowID | |||
| 138 | - | for id := range ids { | |||
| 139 | - | if id > maxID { | |||
| 140 | - | maxID = id | |||
| 141 | - | } | |||
| 142 | - | } | |||
| 143 | - | return maxID | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | func historyViewRowCount(view HistoryView) int { | |||
| 147 | - | n := 0 | |||
| 148 | - | for _, chunk := range view.chunks { | |||
| 149 | - | if chunk != nil && len(chunk.rows) <= math.MaxInt-n { | |||
| 150 | - | n += len(chunk.rows) | |||
| 151 | - | continue | |||
| 152 | - | } | |||
| 153 | - | return -1 | |||
| 154 | - | } | |||
| 155 | - | return n | |||
| 156 | - | } | |||
| 157 | - | ||||
| 158 | - | func appendUint32(dst []byte, v uint32) []byte { | |||
| 159 | - | return binary.BigEndian.AppendUint32(dst, v) | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | - | func appendHistoryCell(dst []byte, cell renderer.Cell) []byte { | |||
| 163 | - | style := cell.Style | |||
| 164 | - | dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune)) | |||
| 165 | - | flags := byte(0) | |||
| 166 | - | if cell.Continuation { | |||
| 167 | - | flags |= 1 << 0 | |||
| 168 | - | } | |||
| 169 | - | if style.Bold { | |||
| 170 | - | flags |= 1 << 1 | |||
| 171 | - | } | |||
| 172 | - | if style.Italic { | |||
| 173 | - | flags |= 1 << 2 | |||
| 174 | - | } | |||
| 175 | - | if style.Inverse { | |||
| 176 | - | flags |= 1 << 3 | |||
| 177 | - | } | |||
| 178 | - | if style.HasForegroundRGB { | |||
| 179 | - | flags |= 1 << 4 | |||
| 180 | - | } | |||
| 181 | - | if style.HasBackgroundRGB { | |||
| 182 | - | flags |= 1 << 5 | |||
| 183 | - | } | |||
| 184 | - | if style.HasUnderlineColor { | |||
| 185 | - | flags |= 1 << 6 | |||
| 186 | - | } | |||
| 187 | - | if style.HasUnderlineColorRGB { | |||
| 188 | - | flags |= 1 << 7 | |||
| 189 | - | } | |||
| 190 | - | dst = append(dst, flags) | |||
| 191 | - | dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs)) | |||
| 192 | - | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground))) | |||
| 193 | - | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background))) | |||
| 194 | - | dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B) | |||
| 195 | - | dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B) | |||
| 196 | - | dst = append(dst, byte(style.UnderlineStyle)) | |||
| 197 | - | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor))) | |||
| 198 | - | return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B) | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | func chunkBound(chunk *HistoryChunk, i int) LineBound { | |||
| 202 | - | if i < 0 || i >= len(chunk.bounds) { | |||
| 203 | - | return LineBound{} | |||
| 204 | - | } | |||
| 205 | - | return chunk.bounds[i] | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | func appendHistoryBound(dst []byte, b LineBound) []byte { | |||
| 209 | - | dst = binary.BigEndian.AppendUint32(dst, uint32(b.End)) | |||
| 210 | - | if b.Soft { | |||
| 211 | - | return append(dst, 1) | |||
| 212 | - | } | |||
| 213 | - | return append(dst, 0) | |||
| 214 | - | } | |||
| 215 | - | ||||
| 216 | - | // validHistoryBound rejects extents that cannot describe their own row. | |||
| 217 | - | func validHistoryBound(b LineBound, cells int) bool { | |||
| 218 | - | return b.End >= 0 && b.End <= cells | |||
| 219 | } | 205 | } | ||
| 220 | 206 | ||||
| 221 | - | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | 207 | + | // UnmarshalHistory accepts only VTC1. Validation precedes all decoded frame |
| 222 | - | // malformed declarations, cells, truncated data, and trailing bytes. | 208 | + | // allocations, including validation of later chunks in the same payload. |
| 223 | func UnmarshalHistory(data []byte) (HistoryView, error) { | 209 | func UnmarshalHistory(data []byte) (HistoryView, error) { | ||
| 210 | + | if _, _, ok := parseHistory(data, false); !ok { | |||
| 211 | + | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 212 | + | } | |||
| 224 | view, _, ok := parseHistory(data, true) | 213 | view, _, ok := parseHistory(data, true) | ||
| 225 | if !ok { | 214 | if !ok { | ||
| 226 | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | 215 | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | ||
| @@ -228,127 +217,139 @@ func UnmarshalHistory(data []byte) (HistoryView, error) { | |||||
| 228 | return view, nil | 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 | func preflightHistory(data []byte) (historyDecodeStats, bool) { | 220 | func preflightHistory(data []byte) (historyDecodeStats, bool) { | ||
| 243 | _, stats, ok := parseHistory(data, false) | 221 | _, stats, ok := parseHistory(data, false) | ||
| 244 | return stats, ok | 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 | func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) { | 225 | func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) { | ||
| 250 | - | if len(data) < 17 || string(data[:4]) != historyMagic || data[4] != historyVersion { | 226 | + | invalid := func() (HistoryView, historyDecodeStats, bool) { return HistoryView{}, historyDecodeStats{}, false } |
| 251 | - | return HistoryView{}, historyDecodeStats{}, false | 227 | + | if len(data) < historyHeaderBytes || string(data[:4]) != historyMagic { |
| 252 | - | } | 228 | + | return invalid() |
| 253 | - | ||||
| 254 | - | p := historyParser{data: data[5:]} | |||
| 255 | - | chunkCount, ok := p.uint32() | |||
| 256 | - | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | |||
| 257 | - | return HistoryView{}, historyDecodeStats{}, false | |||
| 258 | } | 229 | } | ||
| 259 | - | nextRowID, ok := p.uint64() | 230 | + | p := historyParser{data: data[4:]} |
| 260 | - | if !ok || nextRowID == 0 || nextRowID == uint64(^RowID(0)) { | 231 | + | chunkCount, _ := p.uint32() |
| 261 | - | return HistoryView{}, historyDecodeStats{}, false | 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 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | 236 | stats := historyDecodeStats{chunks: uint64(chunkCount)} | ||
| 264 | - | var seenIDs [maxHistoryRows]RowID | 237 | + | seenIDs := make(map[RowID]struct{}) |
| 265 | - | seenCount := 0 | |||
| 266 | var maxID RowID | 238 | var maxID RowID | ||
| 267 | var chunks []*HistoryChunk | 239 | var chunks []*HistoryChunk | ||
| 268 | if populate { | 240 | if populate { | ||
| 269 | chunks = make([]*HistoryChunk, 0, chunkCount) | 241 | chunks = make([]*HistoryChunk, 0, chunkCount) | ||
| 270 | } | 242 | } | ||
| 271 | for range chunkCount { | 243 | for range chunkCount { | ||
| 272 | - | rowCount, ok := p.uint32() | 244 | + | width, wok := p.uint32() |
| 273 | - | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | 245 | + | rows, rok := p.uint32() |
| 274 | - | uint64(rowCount) > uint64(len(p.data))/(8+4+historyBoundBytes) || | 246 | + | nstyles, sok := p.uint32() |
| 275 | - | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | 247 | + | npayloads, pok := p.uint32() |
| 276 | - | return HistoryView{}, historyDecodeStats{}, false | 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 | - | 251 | + | cells := uint64(width) * uint64(rows) | |
| 279 | - | var rows [][]renderer.Cell | 252 | + | if uint64(nstyles) > cells+1 || uint64(npayloads) > cells || |
| 280 | - | var bounds []LineBound | 253 | + | !addHistoryDecodeBudget(&stats.rows, uint64(rows), maxHistoryRows) || |
| 281 | - | var rowIDs []RowID | 254 | + | !addHistoryDecodeBudget(&stats.cells, cells, maxHistoryCells) || |
| 282 | - | if populate { | 255 | + | !addHistoryDecodeBudget(&stats.styles, uint64(nstyles), maxHistoryDecodeStyles) || |
| 283 | - | rows = make([][]renderer.Cell, 0, rowCount) | 256 | + | !addHistoryLogicalBytes(&stats, cells, uint64(rows), uint64(nstyles)) { |
| 284 | - | bounds = make([]LineBound, 0, rowCount) | 257 | + | return invalid() |
| 285 | - | rowIDs = make([]RowID, 0, rowCount) | |||
| 286 | } | 258 | } | ||
| 287 | - | for range rowCount { | 259 | + | // Counts have now passed aggregate budgets. This sum cannot overflow. |
| 288 | - | id, ok := p.uint64() | 260 | + | payload := uint64(nstyles-1)*historyStyleBytes + uint64(npayloads)*8 + uint64(rows)*historyRowBytes + cells*historyStoredCellBytes |
| 289 | - | if !ok || id == 0 || id >= uint64(^RowID(0)-1) { | 261 | + | if payload > uint64(len(p.data)) { |
| 290 | - | return HistoryView{}, historyDecodeStats{}, false | 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) | 272 | + | if _, duplicate := seenStyles[style]; duplicate { |
| 293 | - | seenIDs[seenCount] = rowID | 273 | + | return invalid() |
| 294 | - | seenCount++ | |||
| 295 | - | if rowID > maxID { | |||
| 296 | - | maxID = rowID | |||
| 297 | } | 274 | } | ||
| 298 | - | cellCount, ok := p.uint32() | 275 | + | seenStyles[style] = struct{}{} |
| 299 | - | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | 276 | + | styles[id] = style |
| 300 | - | if !ok || !validCellBytes || | 277 | + | } |
| 301 | - | !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || | 278 | + | payloads, ok := p.payloadDictionary(npayloads, &stats) |
| 302 | - | !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) || | 279 | + | if !ok { |
| 303 | - | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) || | 280 | + | return invalid() |
| 304 | - | uint64(cellCount) > uint64(len(p.data))/historyCellBytes { | 281 | + | } |
| 305 | - | return HistoryView{}, historyDecodeStats{}, false | 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 | - | 291 | + | if _, duplicate := seenIDs[RowID(id)]; duplicate { | |
| 308 | - | var row []renderer.Cell | 292 | + | return invalid() |
| 309 | - | if populate { | |||
| 310 | - | row = make([]renderer.Cell, cellCount) | |||
| 311 | } | 293 | } | ||
| 312 | - | for i := range cellCount { | 294 | + | seenIDs[RowID(id)] = struct{}{} |
| 313 | - | cell, ok := p.cell() | 295 | + | maxID = max(maxID, RowID(id)) |
| 314 | - | if !ok || !validHistoryCell(cell) { | 296 | + | rowIDs[row], bounds[row] = RowID(id), bound |
| 315 | - | return HistoryView{}, historyDecodeStats{}, false | 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 | if populate { | 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 | + | } |
| 322 | - | if !ok || !validHistoryBound(bound, int(cellCount)) { | 323 | + | for id := uint32(1); id < nstyles; id++ { |
| 323 | - | return HistoryView{}, historyDecodeStats{}, false | 324 | + | if lastStyleRow[id] < 0 { |
| 325 | + | return invalid() | |||
| 324 | } | 326 | } | ||
| 325 | - | if populate { | 327 | + | } |
| 326 | - | rows = append(rows, row) | 328 | + | for _, used := range usedPayloads { |
| 327 | - | bounds = append(bounds, bound) | 329 | + | if !used { |
| 328 | - | rowIDs = append(rowIDs, rowID) | 330 | + | return invalid() |
| 329 | } | 331 | } | ||
| 330 | } | 332 | } | ||
| 331 | if populate { | 333 | if populate { | ||
| 332 | - | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs}) | 334 | + | drops := make([]uint64, rows) |
| 333 | - | } | 335 | + | for id := uint32(1); id < nstyles; id++ { |
| 334 | - | } | 336 | + | drops[lastStyleRow[id]]++ |
| 335 | - | slices.Sort(seenIDs[:seenCount]) | 337 | + | } |
| 336 | - | for i := 1; i < seenCount; i++ { | 338 | + | chunk := &HistoryChunk{page: newSealedPage(frame), count: int(rows), width: int(width), bounds: bounds, rowIDs: rowIDs, styleDrops: drops, styleCount: uint64(nstyles)} |
| 337 | - | if seenIDs[i] == seenIDs[i-1] { | 339 | + | chunk.recordPayloads() |
| 338 | - | return HistoryView{}, historyDecodeStats{}, false | 340 | + | chunks = append(chunks, chunk) |
| 339 | } | 341 | } | ||
| 340 | } | 342 | } | ||
| 341 | - | if len(p.data) != 0 || RowID(nextRowID) <= maxID { | 343 | + | if len(p.data) != 0 || RowID(nextID) <= maxID { |
| 342 | - | return HistoryView{}, historyDecodeStats{}, false | 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) { | 349 | + | func addHistoryLogicalBytes(stats *historyDecodeStats, cells, rows, styles uint64) bool { |
| 348 | - | if cellCount > math.MaxUint64/historyCellBytes { | 350 | + | // Callers first bound all three counts by the aggregate decode ceilings. |
| 349 | - | return 0, false | 351 | + | amount := cells*renderer.StoredCellLogicalBytes + rows*renderer.RowDescriptorLogicalBytes + styles*renderer.StyleRecordLogicalBytes |
| 350 | - | } | 352 | + | return addHistoryDecodeBudget(&stats.bytes, amount, maxHistoryDecodedBytes) |
| 351 | - | return cellCount * historyCellBytes, true | |||
| 352 | } | 353 | } | ||
| 353 | 354 | ||||
| 354 | func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool { | 355 | func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool { | ||
| @@ -359,89 +360,6 @@ func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool { | |||||
| 359 | return true | 360 | return true | ||
| 360 | } | 361 | } | ||
| 361 | 362 | ||||
| 362 | - | type historyParser struct{ data []byte } | 363 | + | func validHistoryBound(bound LineBound, cells int) bool { |
| 363 | - | 364 | + | return bound.End >= 0 && bound.End <= cells | |
| 364 | - | func (p *historyParser) uint32() (uint32, bool) { | |||
| 365 | - | if len(p.data) < 4 { | |||
| 366 | - | return 0, false | |||
| 367 | - | } | |||
| 368 | - | v := binary.BigEndian.Uint32(p.data) | |||
| 369 | - | p.data = p.data[4:] | |||
| 370 | - | return v, true | |||
| 371 | - | } | |||
| 372 | - | ||||
| 373 | - | func (p *historyParser) uint64() (uint64, bool) { | |||
| 374 | - | if len(p.data) < 8 { | |||
| 375 | - | return 0, false | |||
| 376 | - | } | |||
| 377 | - | v := binary.BigEndian.Uint64(p.data) | |||
| 378 | - | p.data = p.data[8:] | |||
| 379 | - | return v, true | |||
| 380 | - | } | |||
| 381 | - | ||||
| 382 | - | func (p *historyParser) cell() (renderer.Cell, bool) { | |||
| 383 | - | if len(p.data) < historyCellBytes { | |||
| 384 | - | return renderer.Cell{}, false | |||
| 385 | - | } | |||
| 386 | - | b := p.data[:historyCellBytes] | |||
| 387 | - | p.data = p.data[historyCellBytes:] | |||
| 388 | - | foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15])) | |||
| 389 | - | if !ok { | |||
| 390 | - | return renderer.Cell{}, false | |||
| 391 | - | } | |||
| 392 | - | background, ok := historyInt(binary.BigEndian.Uint64(b[15:23])) | |||
| 393 | - | if !ok { | |||
| 394 | - | return renderer.Cell{}, false | |||
| 395 | - | } | |||
| 396 | - | underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38])) | |||
| 397 | - | if !ok { | |||
| 398 | - | return renderer.Cell{}, false | |||
| 399 | - | } | |||
| 400 | - | flags := b[4] | |||
| 401 | - | return renderer.Cell{ | |||
| 402 | - | Rune: rune(binary.BigEndian.Uint32(b[0:4])), | |||
| 403 | - | Continuation: flags&1 != 0, | |||
| 404 | - | Style: renderer.Style{ | |||
| 405 | - | Bold: flags&(1<<1) != 0, | |||
| 406 | - | Italic: flags&(1<<2) != 0, | |||
| 407 | - | Inverse: flags&(1<<3) != 0, | |||
| 408 | - | Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])), | |||
| 409 | - | Foreground: foreground, | |||
| 410 | - | Background: background, | |||
| 411 | - | HasForegroundRGB: flags&(1<<4) != 0, | |||
| 412 | - | ForegroundRGB: renderer.RGB{R: b[23], G: b[24], B: b[25]}, | |||
| 413 | - | HasBackgroundRGB: flags&(1<<5) != 0, | |||
| 414 | - | BackgroundRGB: renderer.RGB{R: b[26], G: b[27], B: b[28]}, | |||
| 415 | - | UnderlineStyle: renderer.UnderlineStyle(b[29]), | |||
| 416 | - | HasUnderlineColor: flags&(1<<6) != 0, | |||
| 417 | - | UnderlineColor: underlineColor, | |||
| 418 | - | HasUnderlineColorRGB: flags&(1<<7) != 0, | |||
| 419 | - | UnderlineColorRGB: renderer.RGB{R: b[38], G: b[39], B: b[40]}, | |||
| 420 | - | }, | |||
| 421 | - | }, true | |||
| 422 | - | } | |||
| 423 | - | ||||
| 424 | - | func (p *historyParser) bound() (LineBound, bool) { | |||
| 425 | - | if len(p.data) < historyBoundBytes { | |||
| 426 | - | return LineBound{}, false | |||
| 427 | - | } | |||
| 428 | - | end := binary.BigEndian.Uint32(p.data[:4]) | |||
| 429 | - | flag := p.data[4] | |||
| 430 | - | p.data = p.data[historyBoundBytes:] | |||
| 431 | - | if flag > 1 || uint64(end) > math.MaxInt32 { | |||
| 432 | - | return LineBound{}, false | |||
| 433 | - | } | |||
| 434 | - | return LineBound{End: int(end), Soft: flag == 1}, true | |||
| 435 | - | } | |||
| 436 | - | ||||
| 437 | - | func historyInt(raw uint64) (int, bool) { | |||
| 438 | - | v := int64(raw) | |||
| 439 | - | if int64(int(v)) != v { | |||
| 440 | - | return 0, false | |||
| 441 | - | } | |||
| 442 | - | return int(v), true | |||
| 443 | - | } | |||
| 444 | - | ||||
| 445 | - | func validHistoryCell(cell renderer.Cell) bool { | |||
| 446 | - | return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed | |||
| 447 | } | 365 | } | ||
M history_codec_bound_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | func TestHistoryCodecRejectsMalformedBounds(t *testing.T) { | 11 | func TestHistoryCodecRejectsMalformedBounds(t *testing.T) { | ||
| 12 | blob, err := MarshalHistory(HistoryView{ | 12 | blob, err := MarshalHistory(HistoryView{ | ||
| 13 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}, rowIDs: []RowID{1}}}, | 13 | + | chunks: []*HistoryChunk{testHistoryChunk([][]renderer.Cell{historyRow("abcd")}, []LineBound{{End: 4, Soft: true}}, []RowID{1})}, |
| 14 | rows: 1, | 14 | rows: 1, | ||
| 15 | cells: 4, | 15 | cells: 4, | ||
| 16 | nextRowID: 2, | 16 | nextRowID: 2, | ||
A history_codec_limits_test.go +25 -0
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev-vt/core" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHistoryRetentionCanExceedSingleBlobLimits(t *testing.T) { | |||
| 11 | + | // Retention policy is application-owned; codec ceilings apply to each blob. | |||
| 12 | + | h := NewHistory(HistoryConfig{MaxRows: maxHistoryRows + 1, ChunkRows: 256}) | |||
| 13 | + | for range maxHistoryRows + 1 { | |||
| 14 | + | require.NoError(t, h.Append([]core.Cell{{Rune: 'x', Style: core.DefaultStyle()}}, LineBound{End: 1})) | |||
| 15 | + | } | |||
| 16 | + | view := h.SealAndView() | |||
| 17 | + | require.Equal(t, maxHistoryRows+1, view.Len()) | |||
| 18 | + | _, err := MarshalHistory(view) | |||
| 19 | + | require.Error(t, err, "one aggregate blob is intentionally bounded") | |||
| 20 | + | chunks, tail, err := MarshalSealedHistory(view) | |||
| 21 | + | require.NoError(t, err, "per-chunk persistence must preserve larger retained history") | |||
| 22 | + | restored, err := HistoryFromBlobs(h.Limits(), chunks, tail) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | require.Equal(t, h.Len(), restored.Len()) | |||
| 25 | + | } | |||
A history_codec_payload.go +49 -0
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | ||||
| 6 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func appendHistoryPayload(dst []byte, value renderer.CellPayload) []byte { | |||
| 10 | + | dst = binary.BigEndian.AppendUint32(dst, uint32(len(value.Grapheme()))) | |||
| 11 | + | dst = binary.BigEndian.AppendUint32(dst, uint32(len(value.Hyperlink()))) | |||
| 12 | + | dst = append(dst, value.Grapheme()...) | |||
| 13 | + | return append(dst, value.Hyperlink()...) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func (p *historyParser) payloadDictionary(count uint32, stats *historyDecodeStats) ([]renderer.CellPayload, bool) { | |||
| 17 | + | if count == 0 { | |||
| 18 | + | return nil, true | |||
| 19 | + | } | |||
| 20 | + | // The record budget bounds the table before allocating any string values. | |||
| 21 | + | if uint64(count)*renderer.PayloadRecordLogicalBytes > maxHistoryPayloadBytes-stats.payloadBytes { | |||
| 22 | + | return nil, false | |||
| 23 | + | } | |||
| 24 | + | values := make([]renderer.CellPayload, count) | |||
| 25 | + | seen := make(map[renderer.CellPayload]struct{}) | |||
| 26 | + | for id := range count { | |||
| 27 | + | grapheme, gok := p.uint32() | |||
| 28 | + | link, lok := p.uint32() | |||
| 29 | + | length := uint64(grapheme) + uint64(link) | |||
| 30 | + | if !gok || !lok || grapheme > renderer.MaxGraphemeBytes || link > renderer.MaxHyperlinkBytes || length == 0 || length > uint64(len(p.data)) { | |||
| 31 | + | return nil, false | |||
| 32 | + | } | |||
| 33 | + | bytes := length + renderer.PayloadRecordLogicalBytes | |||
| 34 | + | if !addHistoryDecodeBudget(&stats.payloadBytes, bytes, maxHistoryPayloadBytes) || !addHistoryDecodeBudget(&stats.bytes, bytes, maxHistoryDecodedBytes) { | |||
| 35 | + | return nil, false | |||
| 36 | + | } | |||
| 37 | + | value, err := renderer.NewCellPayload(string(p.data[:grapheme]), string(p.data[grapheme:length])) | |||
| 38 | + | if err != nil { | |||
| 39 | + | return nil, false | |||
| 40 | + | } | |||
| 41 | + | p.data = p.data[length:] | |||
| 42 | + | if _, duplicate := seen[value]; duplicate { | |||
| 43 | + | return nil, false | |||
| 44 | + | } | |||
| 45 | + | seen[value] = struct{}{} | |||
| 46 | + | values[id] = value | |||
| 47 | + | } | |||
| 48 | + | return values, true | |||
| 49 | + | } | |||
A history_codec_records.go +135 -0
| @@ -0,0 +1,135 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "math" | |||
| 6 | + | "unicode/utf8" | |||
| 7 | + | ||||
| 8 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func appendHistoryStyle(dst []byte, style renderer.Style) []byte { | |||
| 12 | + | style = style.Canonical() | |||
| 13 | + | flags := byte(0) | |||
| 14 | + | for i, set := range [...]bool{style.Bold, style.Italic, style.Inverse, style.HasForegroundRGB, style.HasBackgroundRGB, style.HasUnderlineColor, style.HasUnderlineColorRGB} { | |||
| 15 | + | if set { | |||
| 16 | + | flags |= 1 << i | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | dst = append(dst, flags) | |||
| 20 | + | dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs)) | |||
| 21 | + | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground))) | |||
| 22 | + | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background))) | |||
| 23 | + | dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B) | |||
| 24 | + | dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B) | |||
| 25 | + | dst = append(dst, byte(style.UnderlineStyle)) | |||
| 26 | + | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor))) | |||
| 27 | + | return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func appendHistoryBound(dst []byte, bound LineBound) []byte { | |||
| 31 | + | dst = binary.BigEndian.AppendUint32(dst, uint32(bound.End)) | |||
| 32 | + | if bound.Soft { | |||
| 33 | + | return append(dst, 1) | |||
| 34 | + | } | |||
| 35 | + | return append(dst, 0) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | type historyParser struct{ data []byte } | |||
| 39 | + | ||||
| 40 | + | func (p *historyParser) uint32() (uint32, bool) { | |||
| 41 | + | if len(p.data) < 4 { | |||
| 42 | + | return 0, false | |||
| 43 | + | } | |||
| 44 | + | value := binary.BigEndian.Uint32(p.data) | |||
| 45 | + | p.data = p.data[4:] | |||
| 46 | + | return value, true | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (p *historyParser) uint64() (uint64, bool) { | |||
| 50 | + | if len(p.data) < 8 { | |||
| 51 | + | return 0, false | |||
| 52 | + | } | |||
| 53 | + | value := binary.BigEndian.Uint64(p.data) | |||
| 54 | + | p.data = p.data[8:] | |||
| 55 | + | return value, true | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (p *historyParser) style() (renderer.Style, bool) { | |||
| 59 | + | if len(p.data) < historyStyleBytes { | |||
| 60 | + | return renderer.Style{}, false | |||
| 61 | + | } | |||
| 62 | + | b := p.data[:historyStyleBytes] | |||
| 63 | + | p.data = p.data[historyStyleBytes:] | |||
| 64 | + | flags := b[0] | |||
| 65 | + | if flags&0x80 != 0 { | |||
| 66 | + | return renderer.Style{}, false | |||
| 67 | + | } | |||
| 68 | + | foreground, fok := historyInt(binary.BigEndian.Uint64(b[3:11])) | |||
| 69 | + | background, bok := historyInt(binary.BigEndian.Uint64(b[11:19])) | |||
| 70 | + | underline, uok := historyInt(binary.BigEndian.Uint64(b[26:34])) | |||
| 71 | + | if !fok || !bok || !uok { | |||
| 72 | + | return renderer.Style{}, false | |||
| 73 | + | } | |||
| 74 | + | return renderer.Style{ | |||
| 75 | + | Bold: flags&1 != 0, | |||
| 76 | + | Italic: flags&2 != 0, | |||
| 77 | + | Inverse: flags&4 != 0, | |||
| 78 | + | Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[1:3])), | |||
| 79 | + | Foreground: foreground, | |||
| 80 | + | Background: background, | |||
| 81 | + | HasForegroundRGB: flags&8 != 0, | |||
| 82 | + | ForegroundRGB: renderer.RGB{R: b[19], G: b[20], B: b[21]}, | |||
| 83 | + | HasBackgroundRGB: flags&16 != 0, | |||
| 84 | + | BackgroundRGB: renderer.RGB{R: b[22], G: b[23], B: b[24]}, | |||
| 85 | + | UnderlineStyle: renderer.UnderlineStyle(b[25]), | |||
| 86 | + | HasUnderlineColor: flags&32 != 0, | |||
| 87 | + | UnderlineColor: underline, | |||
| 88 | + | HasUnderlineColorRGB: flags&64 != 0, | |||
| 89 | + | UnderlineColorRGB: renderer.RGB{R: b[34], G: b[35], B: b[36]}, | |||
| 90 | + | }, true | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (p *historyParser) bound() (LineBound, bool) { | |||
| 94 | + | if len(p.data) < historyBoundBytes { | |||
| 95 | + | return LineBound{}, false | |||
| 96 | + | } | |||
| 97 | + | end := binary.BigEndian.Uint32(p.data[:4]) | |||
| 98 | + | flag := p.data[4] | |||
| 99 | + | p.data = p.data[historyBoundBytes:] | |||
| 100 | + | if flag > 1 || uint64(end) > uint64(math.MaxInt) { | |||
| 101 | + | return LineBound{}, false | |||
| 102 | + | } | |||
| 103 | + | return LineBound{End: int(end), Soft: flag == 1}, true | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (p *historyParser) storedCell(styles, payloads uint32) (rune, uint32, uint32, bool, bool) { | |||
| 107 | + | if len(p.data) < historyStoredCellBytes { | |||
| 108 | + | return 0, 0, 0, false, false | |||
| 109 | + | } | |||
| 110 | + | b := p.data[:historyStoredCellBytes] | |||
| 111 | + | p.data = p.data[historyStoredCellBytes:] | |||
| 112 | + | r := rune(binary.BigEndian.Uint32(b[:4])) | |||
| 113 | + | id := binary.BigEndian.Uint32(b[4:8]) | |||
| 114 | + | if !utf8.ValidRune(r) || id >= styles || b[8] > 3 { | |||
| 115 | + | return 0, 0, 0, false, false | |||
| 116 | + | } | |||
| 117 | + | var payloadID uint32 | |||
| 118 | + | if b[8]&2 != 0 { | |||
| 119 | + | var ok bool | |||
| 120 | + | payloadID, ok = p.uint32() | |||
| 121 | + | if !ok || payloadID == 0 || payloadID > payloads { | |||
| 122 | + | return 0, 0, 0, false, false | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | return r, id, payloadID, b[8]&1 != 0, true | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func historyInt(raw uint64) (int, bool) { | |||
| 129 | + | value := int64(raw) | |||
| 130 | + | return int(value), int64(int(value)) == value | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func validHistoryCell(cell renderer.Cell) bool { | |||
| 134 | + | return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed | |||
| 135 | + | } | |||
M history_codec_test.go +47 -58
| @@ -10,9 +10,9 @@ import ( | |||||
| 10 | renderer "github.com/bnema/vev-vt/core" | 10 | renderer "github.com/bnema/vev-vt/core" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | - | func TestHistoryCodecV3Bytes(t *testing.T) { | 13 | + | func TestHistoryCodecCompactBytes(t *testing.T) { |
| 14 | - | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | 14 | + | h := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: 1 << 20, ChunkRows: 2}) |
| 15 | - | if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil { | 15 | + | if err := h.Append([]renderer.Cell{{Rune: 'a', Style: renderer.DefaultStyle()}}, LineBound{End: 1, Soft: true}); err != nil { |
| 16 | t.Fatalf("append: %v", err) | 16 | t.Fatalf("append: %v", err) | ||
| 17 | } | 17 | } | ||
| 18 | got, err := MarshalHistory(h.SealAndView()) | 18 | got, err := MarshalHistory(h.SealAndView()) | ||
| @@ -21,23 +21,17 @@ func TestHistoryCodecV3Bytes(t *testing.T) { | |||||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| 23 | want := []byte{ | 23 | want := []byte{ | ||
| 24 | - | 'V', 'T', 'H', '1', 3, // magic, version | 24 | + | 'V', 'T', 'C', '1', |
| 25 | - | 0, 0, 0, 1, // chunk count | 25 | + | 0, 0, 0, 1, // chunks |
| 26 | 0, 0, 0, 0, 0, 0, 0, 2, // next row ID | 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 | 0, 0, 0, 0, 0, 0, 0, 1, // row ID | 31 | 0, 0, 0, 0, 0, 0, 0, 1, // row ID | ||
| 29 | - | 0, 0, 0, 1, // cell count | 32 | + | 0, 0, 0, 1, 1, // bound |
| 30 | - | 0, 0, 0, 'a', // Rune | 33 | + | 0, 0, 0, 'a', // rune |
| 31 | - | 0, // flags | 34 | + | 0, 0, 0, 0, 0, // style ID and continuation |
| 32 | - | 0, 0, // Attrs | |||
| 33 | - | 0, 0, 0, 0, 0, 0, 0, 0, // Foreground | |||
| 34 | - | 0, 0, 0, 0, 0, 0, 0, 0, // Background | |||
| 35 | - | 0, 0, 0, // ForegroundRGB | |||
| 36 | - | 0, 0, 0, // BackgroundRGB | |||
| 37 | - | 0, // UnderlineStyle | |||
| 38 | - | 0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor | |||
| 39 | - | 0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here | |||
| 40 | - | 0, 0, 0, 1, 1, // bound: End=1, Soft=true | |||
| 41 | } | 35 | } | ||
| 42 | if !bytes.Equal(got, want) { | 36 | if !bytes.Equal(got, want) { | ||
| 43 | t.Fatalf("MarshalHistory() = % x, want % x", got, want) | 37 | t.Fatalf("MarshalHistory() = % x, want % x", got, want) | ||
| @@ -45,7 +39,7 @@ func TestHistoryCodecV3Bytes(t *testing.T) { | |||||
| 45 | } | 39 | } | ||
| 46 | 40 | ||||
| 47 | func TestHistoryCodecRoundTripsBounds(t *testing.T) { | 41 | func TestHistoryCodecRoundTripsBounds(t *testing.T) { | ||
| 48 | - | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | 42 | + | h := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: 1 << 20, ChunkRows: 2}) |
| 49 | bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}} | 43 | bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}} | ||
| 50 | for _, b := range bounds { | 44 | for _, b := range bounds { | ||
| 51 | if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil { | 45 | if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil { | ||
| @@ -81,7 +75,7 @@ func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) { | |||||
| 81 | 0, 0, 0, | 75 | 0, 0, 0, | ||
| 82 | 0, 0, 0, 1, 1, | 76 | 0, 0, 0, 1, 1, | ||
| 83 | } | 77 | } | ||
| 84 | - | for _, version := range []byte{1, 2} { | 78 | + | for _, version := range []byte{1, 2, 3} { |
| 85 | data := append([]byte(nil), legacy...) | 79 | data := append([]byte(nil), legacy...) | ||
| 86 | data[4] = version | 80 | data[4] = version | ||
| 87 | if _, err := UnmarshalHistory(data); err == nil { | 81 | if _, err := UnmarshalHistory(data); err == nil { | ||
| @@ -165,11 +159,11 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | |||||
| 165 | 159 | ||||
| 166 | for i := range len(encoded) { | 160 | for i := range len(encoded) { | ||
| 167 | if _, err := UnmarshalHistory(encoded[:i]); err == nil { | 161 | if _, err := UnmarshalHistory(encoded[:i]); err == nil { | ||
| 168 | - | t.Fatalf("accepted truncated version 3 prefix of length %d", i) | 162 | + | t.Fatalf("accepted truncated compact payload prefix of length %d", i) |
| 169 | } | 163 | } | ||
| 170 | } | 164 | } | ||
| 171 | if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil { | 165 | if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil { | ||
| 172 | - | t.Fatal("accepted trailing garbage after version 3 payload") | 166 | + | t.Fatal("accepted trailing garbage after compact payload") |
| 173 | } | 167 | } | ||
| 174 | } | 168 | } | ||
| 175 | 169 | ||||
| @@ -294,33 +288,30 @@ func historyViewWithDimensions(rowCount, width int) HistoryView { | |||||
| 294 | rowIDs[i] = rowID | 288 | rowIDs[i] = rowID | ||
| 295 | rowID++ | 289 | rowID++ | ||
| 296 | } | 290 | } | ||
| 297 | - | chunks = append(chunks, &HistoryChunk{rows: rows, rowIDs: rowIDs}) | 291 | + | chunks = append(chunks, testHistoryChunk(rows, nil, rowIDs)) |
| 298 | rowCount -= chunkRows | 292 | rowCount -= chunkRows | ||
| 299 | } | 293 | } | ||
| 300 | return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID} | 294 | return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID} | ||
| 301 | } | 295 | } | ||
| 302 | 296 | ||||
| 303 | func historyPayloadWithDimensions(rowCount, width int) []byte { | 297 | func historyPayloadWithDimensions(rowCount, width int) []byte { | ||
| 304 | - | data := make([]byte, 17) | 298 | + | chunks := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows |
| 305 | - | copy(data, historyMagic) | 299 | + | data := binary.BigEndian.AppendUint32([]byte(historyMagic), uint32(chunks)) |
| 306 | - | data[4] = historyVersion | 300 | + | data = binary.BigEndian.AppendUint64(data, uint64(rowCount+1)) |
| 307 | - | chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows | 301 | + | id := uint64(1) |
| 308 | - | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |||
| 309 | - | binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1)) | |||
| 310 | - | rowID := uint64(1) | |||
| 311 | for rowCount > 0 { | 302 | for rowCount > 0 { | ||
| 312 | - | chunkRows := min(rowCount, maxHistoryChunkRows) | 303 | + | rows := min(rowCount, maxHistoryChunkRows) |
| 313 | - | data = binary.BigEndian.AppendUint32(data, uint32(chunkRows)) | 304 | + | data = binary.BigEndian.AppendUint32(data, uint32(width)) |
| 314 | - | for range chunkRows { | 305 | + | data = binary.BigEndian.AppendUint32(data, uint32(rows)) |
| 315 | - | data = binary.BigEndian.AppendUint64(data, rowID) | 306 | + | data = binary.BigEndian.AppendUint32(data, 1) |
| 316 | - | rowID++ | 307 | + | data = binary.BigEndian.AppendUint32(data, 0) |
| 317 | - | data = binary.BigEndian.AppendUint32(data, uint32(width)) | 308 | + | for range rows { |
| 318 | - | //nolint:makezero // The header prefix is retained while zeroed cell records are appended. | 309 | + | data = binary.BigEndian.AppendUint64(data, id) |
| 319 | - | data = append(data, make([]byte, width*historyCellBytes)...) | 310 | + | id++ |
| 320 | - | data = binary.BigEndian.AppendUint32(data, uint32(width)) | 311 | + | data = appendHistoryBound(data, LineBound{End: width}) |
| 321 | - | data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended. | |||
| 322 | } | 312 | } | ||
| 323 | - | rowCount -= chunkRows | 313 | + | data = append(data, make([]byte, rows*width*historyStoredCellBytes)...) |
| 314 | + | rowCount -= rows | |||
| 324 | } | 315 | } | ||
| 325 | return data | 316 | return data | ||
| 326 | } | 317 | } | ||
| @@ -329,29 +320,27 @@ func historyPayloadWithDimensions(rowCount, width int) []byte { | |||||
| 329 | // budget, but its cells exceed the aggregate budget. It deliberately omits | 320 | // budget, but its cells exceed the aggregate budget. It deliberately omits | ||
| 330 | // cell payload bytes: the aggregate limit must reject it before allocation. | 321 | // cell payload bytes: the aggregate limit must reject it before allocation. | ||
| 331 | func aggregateCellLimitDeclaration() []byte { | 322 | func aggregateCellLimitDeclaration() []byte { | ||
| 332 | - | data := make([]byte, 17, 29) | 323 | + | data := binary.BigEndian.AppendUint32([]byte(historyMagic), 1) |
| 333 | - | copy(data, historyMagic) | 324 | + | data = binary.BigEndian.AppendUint64(data, 2) |
| 334 | - | data[4] = historyVersion | 325 | + | data = binary.BigEndian.AppendUint32(data, maxHistoryCells+1) |
| 335 | - | binary.BigEndian.PutUint32(data[5:], 1) | 326 | + | data = binary.BigEndian.AppendUint32(data, 1) |
| 336 | - | binary.BigEndian.PutUint64(data[9:], 2) | |||
| 337 | data = binary.BigEndian.AppendUint32(data, 1) | 327 | data = binary.BigEndian.AppendUint32(data, 1) | ||
| 338 | - | data = binary.BigEndian.AppendUint64(data, 1) | 328 | + | return binary.BigEndian.AppendUint32(data, 0) |
| 339 | - | return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1)) | |||
| 340 | } | 329 | } | ||
| 341 | 330 | ||||
| 342 | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | 331 | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | ||
| 343 | - | data := make([]byte, 17, 17+chunkCount*(4+rowCount*12)) | 332 | + | data := binary.BigEndian.AppendUint32([]byte(historyMagic), uint32(chunkCount)) |
| 344 | - | copy(data, historyMagic) | 333 | + | data = binary.BigEndian.AppendUint64(data, uint64(chunkCount*rowCount+1)) |
| 345 | - | data[4] = historyVersion | |||
| 346 | - | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |||
| 347 | - | binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1)) | |||
| 348 | id := uint64(1) | 334 | id := uint64(1) | ||
| 349 | for range chunkCount { | 335 | for range chunkCount { | ||
| 336 | + | data = binary.BigEndian.AppendUint32(data, 0) | |||
| 350 | data = binary.BigEndian.AppendUint32(data, uint32(rowCount)) | 337 | data = binary.BigEndian.AppendUint32(data, uint32(rowCount)) | ||
| 338 | + | data = binary.BigEndian.AppendUint32(data, 1) | |||
| 339 | + | data = binary.BigEndian.AppendUint32(data, 0) | |||
| 351 | for range rowCount { | 340 | for range rowCount { | ||
| 352 | data = binary.BigEndian.AppendUint64(data, id) | 341 | data = binary.BigEndian.AppendUint64(data, id) | ||
| 353 | id++ | 342 | id++ | ||
| 354 | - | data = binary.BigEndian.AppendUint32(data, 0) | 343 | + | data = appendHistoryBound(data, LineBound{}) |
| 355 | } | 344 | } | ||
| 356 | } | 345 | } | ||
| 357 | return data | 346 | return data | ||
| @@ -363,9 +352,9 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||||
| 363 | t.Fatalf("marshal valid history: %v", err) | 352 | t.Fatalf("marshal valid history: %v", err) | ||
| 364 | } | 353 | } | ||
| 365 | invalidRune := append([]byte(nil), valid...) | 354 | invalidRune := append([]byte(nil), valid...) | ||
| 366 | - | binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0)) | 355 | + | binary.BigEndian.PutUint32(invalidRune[len(valid)-historyStoredCellBytes:], ^uint32(0)) |
| 367 | invalidUnderlineStyle := append([]byte(nil), valid...) | 356 | invalidUnderlineStyle := append([]byte(nil), valid...) | ||
| 368 | - | invalidUnderlineStyle[33+29] = byte(renderer.UnderlineDashed + 1) | 357 | + | invalidUnderlineStyle[historyHeaderBytes+historyChunkHeaderBytes+25] = byte(renderer.UnderlineDashed + 1) |
| 369 | 358 | ||||
| 370 | tests := []struct { | 359 | tests := []struct { | ||
| 371 | name string | 360 | name string | ||
| @@ -379,7 +368,7 @@ func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||||
| 379 | {name: "invalid underline style", data: invalidUnderlineStyle}, | 368 | {name: "invalid underline style", data: invalidUnderlineStyle}, | ||
| 380 | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | 369 | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | ||
| 381 | {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()}, | 370 | {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()}, | ||
| 382 | - | {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0}}, | 371 | + | {name: "zero row count", data: hostileHistoryDeclarations(1, 0)}, |
| 383 | } | 372 | } | ||
| 384 | for _, tt := range tests { | 373 | for _, tt := range tests { | ||
| 385 | t.Run(tt.name, func(t *testing.T) { | 374 | t.Run(tt.name, func(t *testing.T) { | ||
| @@ -398,7 +387,7 @@ func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) { | |||||
| 398 | f.Fatalf("marshal valid history: %v", err) | 387 | f.Fatalf("marshal valid history: %v", err) | ||
| 399 | } | 388 | } | ||
| 400 | invalidRune := append([]byte(nil), valid...) | 389 | invalidRune := append([]byte(nil), valid...) | ||
| 401 | - | binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0)) | 390 | + | binary.BigEndian.PutUint32(invalidRune[len(valid)-historyStoredCellBytes:], ^uint32(0)) |
| 402 | f.Add(valid) | 391 | f.Add(valid) | ||
| 403 | f.Add(invalidRune) | 392 | f.Add(invalidRune) | ||
| 404 | f.Add([]byte("VTH1\x01\x00\x00\x00\x00")) | 393 | f.Add([]byte("VTH1\x01\x00\x00\x00\x00")) | ||
A history_compact_codec_test.go +143 -0
| @@ -0,0 +1,143 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestCompactHistoryDictionaryAndAccounting(t *testing.T) { | |||
| 13 | + | h := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 2}) | |||
| 14 | + | style := renderer.DefaultStyle() | |||
| 15 | + | style.Bold = true | |||
| 16 | + | style.HasForegroundRGB = true | |||
| 17 | + | style.ForegroundRGB = renderer.RGB{R: 1, G: 2, B: 3} | |||
| 18 | + | row := []renderer.Cell{{Rune: 'a', Style: style}, {Rune: 'b', Style: style}} | |||
| 19 | + | for range 2 { | |||
| 20 | + | require.NoError(t, h.Append(row, LineBound{End: 2})) | |||
| 21 | + | } | |||
| 22 | + | view := h.SealAndView() | |||
| 23 | + | blob, err := MarshalHistory(view) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | require.True(t, bytes.HasPrefix(blob, []byte("VTC1"))) | |||
| 26 | + | require.Len(t, blob, historyHeaderBytes+historyChunkHeaderBytes+historyStyleBytes+2*historyRowBytes+4*historyStoredCellBytes) | |||
| 27 | + | stats, err := PreflightHistoryBlob(blob) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | require.Equal(t, uint64(2), stats.Styles) | |||
| 30 | + | require.Equal(t, view.LogicalBytes(), stats.Bytes) | |||
| 31 | + | decoded, err := UnmarshalHistory(blob) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | require.Equal(t, view.LogicalBytes(), decoded.LogicalBytes()) | |||
| 34 | + | require.NoError(t, decoded.Chunk(0).CheckInvariants()) | |||
| 35 | + | again, err := MarshalHistory(decoded) | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | require.Equal(t, blob, again) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestCompactHistoryRejectsMalformedReferences(t *testing.T) { | |||
| 41 | + | plain := historyPayloadWithDimensions(1, 2) | |||
| 42 | + | const rowOffset = historyHeaderBytes + historyChunkHeaderBytes | |||
| 43 | + | const cellOffset = rowOffset + historyRowBytes | |||
| 44 | + | for _, tt := range []struct { | |||
| 45 | + | name string | |||
| 46 | + | mutate func([]byte) | |||
| 47 | + | }{ | |||
| 48 | + | {"style reference", func(b []byte) { binary.BigEndian.PutUint32(b[cellOffset+4:], 1) }}, | |||
| 49 | + | {"continuation flags", func(b []byte) { b[cellOffset+8] = 2 }}, | |||
| 50 | + | {"invalid rune", func(b []byte) { binary.BigEndian.PutUint32(b[cellOffset:], 0xd800) }}, | |||
| 51 | + | {"zero style count", func(b []byte) { clear(b[24:28]) }}, | |||
| 52 | + | {"excessive style count", func(b []byte) { binary.BigEndian.PutUint32(b[24:], ^uint32(0)) }}, | |||
| 53 | + | {"excessive width", func(b []byte) { binary.BigEndian.PutUint32(b[16:], ^uint32(0)) }}, | |||
| 54 | + | {"bound extent", func(b []byte) { binary.BigEndian.PutUint32(b[rowOffset+8:], 3) }}, | |||
| 55 | + | {"bound flag", func(b []byte) { b[rowOffset+12] = 2 }}, | |||
| 56 | + | } { | |||
| 57 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 58 | + | b := bytes.Clone(plain) | |||
| 59 | + | tt.mutate(b) | |||
| 60 | + | _, err := PreflightHistoryBlob(b) | |||
| 61 | + | require.Error(t, err) | |||
| 62 | + | _, err = UnmarshalHistory(b) | |||
| 63 | + | require.Error(t, err) | |||
| 64 | + | }) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestCompactHistoryRejectsMalformedDictionaries(t *testing.T) { | |||
| 69 | + | style := renderer.DefaultStyle() | |||
| 70 | + | style.Bold = true | |||
| 71 | + | other := style | |||
| 72 | + | other.Italic = true | |||
| 73 | + | makeBlob := func(styles []renderer.Style, refs ...uint32) []byte { | |||
| 74 | + | b := binary.BigEndian.AppendUint32([]byte(historyMagic), 1) | |||
| 75 | + | b = binary.BigEndian.AppendUint64(b, 2) | |||
| 76 | + | b = binary.BigEndian.AppendUint32(b, uint32(len(refs))) | |||
| 77 | + | b = binary.BigEndian.AppendUint32(b, 1) | |||
| 78 | + | b = binary.BigEndian.AppendUint32(b, uint32(len(styles)+1)) | |||
| 79 | + | b = binary.BigEndian.AppendUint32(b, 0) | |||
| 80 | + | for _, s := range styles { | |||
| 81 | + | b = appendHistoryStyle(b, s) | |||
| 82 | + | } | |||
| 83 | + | b = binary.BigEndian.AppendUint64(b, 1) | |||
| 84 | + | b = appendHistoryBound(b, LineBound{End: len(refs)}) | |||
| 85 | + | for _, ref := range refs { | |||
| 86 | + | b = binary.BigEndian.AppendUint32(b, 'x') | |||
| 87 | + | b = binary.BigEndian.AppendUint32(b, ref) | |||
| 88 | + | b = append(b, 0) | |||
| 89 | + | } | |||
| 90 | + | return b | |||
| 91 | + | } | |||
| 92 | + | valid := makeBlob([]renderer.Style{style, other}, 1, 2) | |||
| 93 | + | _, err := UnmarshalHistory(valid) | |||
| 94 | + | require.NoError(t, err) | |||
| 95 | + | noncanonical := bytes.Clone(valid) | |||
| 96 | + | // Unused underline color is required to be canonical zero. | |||
| 97 | + | noncanonical[historyHeaderBytes+historyChunkHeaderBytes+33] = 7 | |||
| 98 | + | badFlags := bytes.Clone(valid) | |||
| 99 | + | badFlags[historyHeaderBytes+historyChunkHeaderBytes] |= 0x80 | |||
| 100 | + | for _, tt := range []struct { | |||
| 101 | + | name string | |||
| 102 | + | data []byte | |||
| 103 | + | }{ | |||
| 104 | + | {"duplicate default", makeBlob([]renderer.Style{renderer.DefaultStyle()}, 1)}, | |||
| 105 | + | {"duplicate style", makeBlob([]renderer.Style{style, style}, 1, 2)}, | |||
| 106 | + | {"unused style", makeBlob([]renderer.Style{style, other}, 1, 1)}, | |||
| 107 | + | {"inactive fields", noncanonical}, | |||
| 108 | + | {"reserved flags", badFlags}, | |||
| 109 | + | } { | |||
| 110 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 111 | + | _, err := PreflightHistoryBlob(tt.data) | |||
| 112 | + | require.Error(t, err) | |||
| 113 | + | _, err = UnmarshalHistory(tt.data) | |||
| 114 | + | require.Error(t, err) | |||
| 115 | + | }) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func BenchmarkCompactHistoryCodec(b *testing.B) { | |||
| 120 | + | view := historyViewWithDimensions(10_000, 120) | |||
| 121 | + | blob, err := MarshalHistory(view) | |||
| 122 | + | if err != nil { | |||
| 123 | + | b.Fatal(err) | |||
| 124 | + | } | |||
| 125 | + | b.Run("encode", func(b *testing.B) { | |||
| 126 | + | b.ReportAllocs() | |||
| 127 | + | b.ReportMetric(float64(len(blob)), "encoded-B") | |||
| 128 | + | for b.Loop() { | |||
| 129 | + | if _, err := MarshalHistory(view); err != nil { | |||
| 130 | + | b.Fatal(err) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | }) | |||
| 134 | + | b.Run("decode", func(b *testing.B) { | |||
| 135 | + | b.ReportAllocs() | |||
| 136 | + | b.SetBytes(int64(len(blob))) | |||
| 137 | + | for b.Loop() { | |||
| 138 | + | if _, err := UnmarshalHistory(blob); err != nil { | |||
| 139 | + | b.Fatal(err) | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | }) | |||
| 143 | + | } | |||
A history_config.go +85 -0
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "math" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | var ErrInvalidHistoryConfig = errors.New("invalid history configuration") | |||
| 9 | + | ||||
| 10 | + | // HistoryConfig enforces limits selected by the application. The library | |||
| 11 | + | // supplies no byte or line defaults. Each positive limit is an independent | |||
| 12 | + | // ceiling; zero omits that ceiling. Both zero disable history. Live grids are | |||
| 13 | + | // excluded. ChunkRows is an internal grouping hint in [1,256]; zero selects | |||
| 14 | + | // 256 and does not grant space beyond the application's retention limits. | |||
| 15 | + | type HistoryConfig struct { | |||
| 16 | + | MaxRows int | |||
| 17 | + | MaxBytes uint64 | |||
| 18 | + | ChunkRows int | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func (c HistoryConfig) Validate() error { | |||
| 22 | + | if c.MaxRows < 0 || c.ChunkRows < 0 || c.ChunkRows > maxHistoryChunkRows { | |||
| 23 | + | return ErrInvalidHistoryConfig | |||
| 24 | + | } | |||
| 25 | + | return nil | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // NewHistory constructs owned bounded history. Invalid programmer-supplied | |||
| 29 | + | // configuration panics; user-input boundaries should call Validate first. | |||
| 30 | + | func NewHistory(config HistoryConfig) *History { | |||
| 31 | + | h := &History{nextRowID: 1} | |||
| 32 | + | if err := h.SetLimits(config); err != nil { | |||
| 33 | + | panic(err) | |||
| 34 | + | } | |||
| 35 | + | return h | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // SetLimits validates first, then updates limits and evicts oldest rows before | |||
| 39 | + | // returning. Existing borrowed views remain valid. Disabling history releases | |||
| 40 | + | // its owned backing but preserves the next row identity. The owner must serialize | |||
| 41 | + | // this operation with all other History mutations. | |||
| 42 | + | func (h *History) SetLimits(config HistoryConfig) error { | |||
| 43 | + | if h == nil { | |||
| 44 | + | return ErrInvalidHistoryConfig | |||
| 45 | + | } | |||
| 46 | + | if err := config.Validate(); err != nil { | |||
| 47 | + | return err | |||
| 48 | + | } | |||
| 49 | + | if config.MaxRows == 0 && config.MaxBytes == 0 { | |||
| 50 | + | *h = History{nextRowID: h.NextRowID()} | |||
| 51 | + | return nil | |||
| 52 | + | } | |||
| 53 | + | rows := config.MaxRows | |||
| 54 | + | if rows == 0 { | |||
| 55 | + | rows = math.MaxInt | |||
| 56 | + | } | |||
| 57 | + | chunkRows := config.ChunkRows | |||
| 58 | + | if chunkRows == 0 { | |||
| 59 | + | chunkRows = maxHistoryChunkRows | |||
| 60 | + | } | |||
| 61 | + | chunkRows = min(chunkRows, rows) | |||
| 62 | + | bytes := config.MaxBytes | |||
| 63 | + | if bytes == 0 { | |||
| 64 | + | bytes = math.MaxUint64 | |||
| 65 | + | } | |||
| 66 | + | if len(h.tail) >= chunkRows { | |||
| 67 | + | h.sealTail() | |||
| 68 | + | } | |||
| 69 | + | h.maxRows, h.rowCeiling, h.maxBytes, h.chunkRows = rows, config.MaxRows, bytes, chunkRows | |||
| 70 | + | h.byteCeiling = config.MaxBytes | |||
| 71 | + | for h.rows > rows || h.logicalBytes > bytes { | |||
| 72 | + | h.evictOldest() | |||
| 73 | + | } | |||
| 74 | + | clear(h.payloadScratch) | |||
| 75 | + | return nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // Limits reports the application's policy. A zero limit is omitted; both | |||
| 79 | + | // zero mean history is disabled. ChunkRows reports the resolved grouping size. | |||
| 80 | + | func (h *History) Limits() HistoryConfig { | |||
| 81 | + | if h == nil { | |||
| 82 | + | return HistoryConfig{} | |||
| 83 | + | } | |||
| 84 | + | return HistoryConfig{MaxRows: h.rowCeiling, MaxBytes: h.byteCeiling, ChunkRows: h.chunkRows} | |||
| 85 | + | } | |||
A history_config_test.go +75 -0
| @@ -0,0 +1,75 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHistoryConfigurationUsesOnlyApplicationLimits(t *testing.T) { | |||
| 11 | + | for _, tt := range []struct { | |||
| 12 | + | name string | |||
| 13 | + | config HistoryConfig | |||
| 14 | + | valid bool | |||
| 15 | + | }{ | |||
| 16 | + | {"disabled", HistoryConfig{}, true}, | |||
| 17 | + | {"both explicit", HistoryConfig{MaxRows: 20, MaxBytes: 4096}, true}, | |||
| 18 | + | {"bytes only", HistoryConfig{MaxBytes: 1024}, true}, | |||
| 19 | + | {"rows only", HistoryConfig{MaxRows: 4}, true}, | |||
| 20 | + | {"negative rows", HistoryConfig{MaxRows: -1}, false}, | |||
| 21 | + | {"negative grouping", HistoryConfig{ChunkRows: -1}, false}, | |||
| 22 | + | {"oversized grouping", HistoryConfig{ChunkRows: 257}, false}, | |||
| 23 | + | } { | |||
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 25 | + | if tt.valid { | |||
| 26 | + | require.NoError(t, tt.config.Validate()) | |||
| 27 | + | return | |||
| 28 | + | } | |||
| 29 | + | require.ErrorIs(t, tt.config.Validate(), ErrInvalidHistoryConfig) | |||
| 30 | + | require.Panics(t, func() { NewHistory(tt.config) }) | |||
| 31 | + | _, err := HistoryFromBlobs(tt.config, nil, nil) | |||
| 32 | + | require.ErrorIs(t, err, ErrInvalidHistoryConfig) | |||
| 33 | + | }) | |||
| 34 | + | } | |||
| 35 | + | require.Zero(t, NewHistory(HistoryConfig{MaxRows: 4}).ByteCap(), "library must not supply a byte policy") | |||
| 36 | + | disabled := NewHistory(HistoryConfig{}) | |||
| 37 | + | require.NoError(t, disabled.Append(historyRow("ignored"), LineBound{End: 7})) | |||
| 38 | + | require.Zero(t, disabled.Len()) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestHistoryByteOnlyLimit(t *testing.T) { | |||
| 42 | + | const budget = 2*(renderer.StoredCellLogicalBytes+renderer.RowDescriptorLogicalBytes) + renderer.StyleRecordLogicalBytes | |||
| 43 | + | h := NewHistory(HistoryConfig{MaxBytes: budget}) | |||
| 44 | + | for _, text := range []string{"a", "b", "c"} { | |||
| 45 | + | require.NoError(t, h.Append(historyRow(text), LineBound{End: 1})) | |||
| 46 | + | } | |||
| 47 | + | require.Zero(t, h.Cap(), "no independent line ceiling") | |||
| 48 | + | require.Equal(t, budget, h.ByteCap()) | |||
| 49 | + | require.Equal(t, []string{"b", "c"}, historyViewTexts(h.View())) | |||
| 50 | + | require.Equal(t, budget, h.LogicalBytes()) | |||
| 51 | + | require.Zero(t, h.Limits().MaxRows) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestHistoryLimitsReloadIsValidatedAndPreservesViews(t *testing.T) { | |||
| 55 | + | h := NewHistory(HistoryConfig{MaxRows: 8, MaxBytes: 1 << 20, ChunkRows: 8}) | |||
| 56 | + | for _, text := range []string{"a", "b", "c", "d"} { | |||
| 57 | + | require.NoError(t, h.Append(historyRow(text), LineBound{End: 1})) | |||
| 58 | + | } | |||
| 59 | + | view := h.View() | |||
| 60 | + | before := h.Limits() | |||
| 61 | + | require.ErrorIs(t, h.SetLimits(HistoryConfig{MaxRows: -1}), ErrInvalidHistoryConfig) | |||
| 62 | + | require.Equal(t, before, h.Limits()) | |||
| 63 | + | require.Equal(t, 4, h.Len()) | |||
| 64 | + | require.NoError(t, h.SetLimits(HistoryConfig{MaxRows: 2, MaxBytes: 1 << 20, ChunkRows: 1})) | |||
| 65 | + | require.Equal(t, []string{"c", "d"}, historyViewTexts(h.View())) | |||
| 66 | + | require.Equal(t, []string{"a", "b", "c", "d"}, historyViewTexts(view)) | |||
| 67 | + | next := h.NextRowID() | |||
| 68 | + | require.NoError(t, h.SetLimits(HistoryConfig{})) | |||
| 69 | + | require.Zero(t, h.Len()) | |||
| 70 | + | require.Zero(t, h.LogicalBytes()) | |||
| 71 | + | require.Equal(t, next, h.NextRowID()) | |||
| 72 | + | require.NoError(t, h.SetLimits(HistoryConfig{MaxBytes: 4096})) | |||
| 73 | + | require.NoError(t, h.Append(historyRow("new"), LineBound{End: 3})) | |||
| 74 | + | require.Equal(t, next, h.View().RowID(0)) | |||
| 75 | + | } | |||
M history_final_acceptance_test.go +2 -2
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func TestHistoryAcceptanceKeepsBookmarksAcrossLiveScrollEvictionAndRestore(t *testing.T) { | 10 | func TestHistoryAcceptanceKeepsBookmarksAcrossLiveScrollEvictionAndRestore(t *testing.T) { | ||
| 11 | - | history := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 32, ChunkRows: 2}) | 11 | + | history := NewHistory(HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 2}) |
| 12 | for _, text := range []string{"live-1", "live-2", "live-3"} { | 12 | for _, text := range []string{"live-1", "live-2", "live-3"} { | ||
| 13 | require.NoError(t, history.Append(historyAcceptanceRow(text), LineBound{End: len(text)})) | 13 | require.NoError(t, history.Append(historyAcceptanceRow(text), LineBound{End: len(text)})) | ||
| 14 | } | 14 | } | ||
| @@ -28,7 +28,7 @@ func TestHistoryAcceptanceKeepsBookmarksAcrossLiveScrollEvictionAndRestore(t *te | |||||
| 28 | 28 | ||||
| 29 | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | 29 | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | ||
| 30 | require.NoError(t, err) | 30 | require.NoError(t, err) | ||
| 31 | - | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 3, MaxCells: 32, ChunkRows: 2}, sealed, tail) | 31 | + | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 2}, sealed, tail) |
| 32 | require.NoError(t, err) | 32 | require.NoError(t, err) | ||
| 33 | restoredView := restored.View() | 33 | restoredView := restored.View() | ||
| 34 | require.Equal(t, afterScroll.Len(), restoredView.Len()) | 34 | require.Equal(t, afterScroll.Len(), restoredView.Len()) | ||
A history_measure.go +70 -0
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev-vt/core" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // MeasureHistoryBlob scans resource declarations without allocating. It is a | |||
| 10 | + | // sizing pass, NOT semantic validation: callers must still use | |||
| 11 | + | // PreflightHistoryBlob or UnmarshalHistory after accepting their aggregate | |||
| 12 | + | // budget. In particular, duplicate IDs/dictionary entries are not checked here. | |||
| 13 | + | func MeasureHistoryBlob(data []byte) (DecodeStats, error) { | |||
| 14 | + | invalid := func() (DecodeStats, error) { return DecodeStats{}, errInvalidHistory } | |||
| 15 | + | if len(data) < historyHeaderBytes || string(data[:4]) != historyMagic { | |||
| 16 | + | return invalid() | |||
| 17 | + | } | |||
| 18 | + | p := historyParser{data: data[4:]} | |||
| 19 | + | chunks, _ := p.uint32() | |||
| 20 | + | next, _ := p.uint64() | |||
| 21 | + | if chunks > maxHistoryChunks || uint64(chunks)*historyChunkHeaderBytes > uint64(len(p.data)) || next == 0 || next == math.MaxUint64 { | |||
| 22 | + | return invalid() | |||
| 23 | + | } | |||
| 24 | + | stats := historyDecodeStats{chunks: uint64(chunks)} | |||
| 25 | + | for range chunks { | |||
| 26 | + | width, wok := p.uint32() | |||
| 27 | + | rows, rok := p.uint32() | |||
| 28 | + | styles, sok := p.uint32() | |||
| 29 | + | payloads, pok := p.uint32() | |||
| 30 | + | cells := uint64(width) * uint64(rows) | |||
| 31 | + | if !wok || !rok || !sok || !pok || rows == 0 || rows > maxHistoryChunkRows || uint64(width) > uint64(math.MaxInt) || styles == 0 || styles > maxHistoryStylesPerChunk || uint64(styles) > cells+1 || uint64(payloads) > cells { | |||
| 32 | + | return invalid() | |||
| 33 | + | } | |||
| 34 | + | if !addHistoryDecodeBudget(&stats.rows, uint64(rows), maxHistoryRows) || !addHistoryDecodeBudget(&stats.cells, cells, maxHistoryCells) || !addHistoryDecodeBudget(&stats.styles, uint64(styles), maxHistoryDecodeStyles) || !addHistoryLogicalBytes(&stats, cells, uint64(rows), uint64(styles)) { | |||
| 35 | + | return invalid() | |||
| 36 | + | } | |||
| 37 | + | styleBytes := uint64(styles-1) * historyStyleBytes | |||
| 38 | + | if styleBytes > uint64(len(p.data)) { | |||
| 39 | + | return invalid() | |||
| 40 | + | } | |||
| 41 | + | p.data = p.data[styleBytes:] | |||
| 42 | + | for range payloads { | |||
| 43 | + | grapheme, gok := p.uint32() | |||
| 44 | + | link, lok := p.uint32() | |||
| 45 | + | length := uint64(grapheme) + uint64(link) | |||
| 46 | + | if !gok || !lok || grapheme > core.MaxGraphemeBytes || link > core.MaxHyperlinkBytes || length == 0 || length > uint64(len(p.data)) { | |||
| 47 | + | return invalid() | |||
| 48 | + | } | |||
| 49 | + | bytes := length + core.PayloadRecordLogicalBytes | |||
| 50 | + | if !addHistoryDecodeBudget(&stats.payloadBytes, bytes, maxHistoryPayloadBytes) || !addHistoryDecodeBudget(&stats.bytes, bytes, maxHistoryDecodedBytes) { | |||
| 51 | + | return invalid() | |||
| 52 | + | } | |||
| 53 | + | p.data = p.data[length:] | |||
| 54 | + | } | |||
| 55 | + | rowBytes := uint64(rows) * historyRowBytes | |||
| 56 | + | if rowBytes > uint64(len(p.data)) { | |||
| 57 | + | return invalid() | |||
| 58 | + | } | |||
| 59 | + | p.data = p.data[rowBytes:] | |||
| 60 | + | for range cells { | |||
| 61 | + | if _, _, _, _, ok := p.storedCell(styles, payloads); !ok { | |||
| 62 | + | return invalid() | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | if len(p.data) != 0 { | |||
| 67 | + | return invalid() | |||
| 68 | + | } | |||
| 69 | + | return DecodeStats{Chunks: stats.chunks, Rows: stats.rows, Cells: stats.cells, Styles: stats.styles, Bytes: stats.bytes}, nil | |||
| 70 | + | } | |||
A history_measure_test.go +68 -0
| @@ -0,0 +1,68 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/binary" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev-vt/core" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestMeasureHistoryBlobMatchesValidatedResources(t *testing.T) { | |||
| 12 | + | payload, err := core.NewCellPayload("e\u0301", "https://example.org") | |||
| 13 | + | require.NoError(t, err) | |||
| 14 | + | for _, rows := range []int{0, 1, 8} { | |||
| 15 | + | h := NewHistory(HistoryConfig{MaxRows: 10, ChunkRows: 2}) | |||
| 16 | + | for i := range rows { | |||
| 17 | + | style := core.DefaultStyle() | |||
| 18 | + | style.Foreground = i | |||
| 19 | + | require.NoError(t, h.Append([]core.Cell{{Rune: 'e', Style: style, Payload: payload}}, LineBound{End: 1})) | |||
| 20 | + | } | |||
| 21 | + | data, err := MarshalHistory(h.SealAndView()) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | want, err := PreflightHistoryBlob(data) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | got, err := MeasureHistoryBlob(data) | |||
| 26 | + | require.NoError(t, err) | |||
| 27 | + | require.Equal(t, want, got) | |||
| 28 | + | require.Zero(t, testing.AllocsPerRun(20, func() { | |||
| 29 | + | if _, err := MeasureHistoryBlob(data); err != nil { | |||
| 30 | + | panic(err) | |||
| 31 | + | } | |||
| 32 | + | })) | |||
| 33 | + | for n := range len(data) { | |||
| 34 | + | _, err := MeasureHistoryBlob(data[:n]) | |||
| 35 | + | require.Error(t, err) | |||
| 36 | + | } | |||
| 37 | + | _, err = MeasureHistoryBlob(append(append([]byte(nil), data...), 0)) | |||
| 38 | + | require.Error(t, err) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestMeasureHistoryBlobDoesNotReplaceValidation(t *testing.T) { | |||
| 43 | + | h := NewHistory(HistoryConfig{MaxRows: 1}) | |||
| 44 | + | require.NoError(t, h.Append([]core.Cell{{Rune: 'a', Style: core.DefaultStyle()}}, LineBound{End: 1})) | |||
| 45 | + | data, err := MarshalHistory(h.SealAndView()) | |||
| 46 | + | require.NoError(t, err) | |||
| 47 | + | binary.BigEndian.PutUint64(data[historyHeaderBytes+historyChunkHeaderBytes:], 0) | |||
| 48 | + | _, err = MeasureHistoryBlob(data) | |||
| 49 | + | require.NoError(t, err, "resource sizing deliberately does not validate row identities") | |||
| 50 | + | _, err = PreflightHistoryBlob(data) | |||
| 51 | + | require.Error(t, err, "semantic validation must still reject the invalid identity") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func FuzzMeasureHistoryBlob(f *testing.F) { | |||
| 55 | + | empty, err := MarshalEmptyHistoryTail() | |||
| 56 | + | if err != nil { | |||
| 57 | + | f.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | f.Add(empty) | |||
| 60 | + | f.Fuzz(func(t *testing.T, data []byte) { | |||
| 61 | + | got, measured := MeasureHistoryBlob(data) | |||
| 62 | + | want, validated := PreflightHistoryBlob(data) | |||
| 63 | + | if validated == nil { | |||
| 64 | + | require.NoError(t, measured) | |||
| 65 | + | require.Equal(t, want, got) | |||
| 66 | + | } | |||
| 67 | + | }) | |||
| 68 | + | } | |||
A history_payload.go +62 -0
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import renderer "github.com/bnema/vev-vt/core" | |||
| 4 | + | ||||
| 5 | + | func (c *HistoryChunk) recordPayloads() { | |||
| 6 | + | frame := c.frameView() | |||
| 7 | + | var last map[renderer.CellPayload]int | |||
| 8 | + | for row := range c.count { | |||
| 9 | + | for x := range c.width { | |||
| 10 | + | p := frame.Cell(x, c.start+row).Payload | |||
| 11 | + | if p.Empty() { | |||
| 12 | + | continue | |||
| 13 | + | } | |||
| 14 | + | if last == nil { | |||
| 15 | + | last = make(map[renderer.CellPayload]int) | |||
| 16 | + | } | |||
| 17 | + | last[p] = c.start + row | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | c.payloadBytes = 0 | |||
| 21 | + | c.payloadDrops = nil | |||
| 22 | + | if len(last) == 0 { | |||
| 23 | + | return | |||
| 24 | + | } | |||
| 25 | + | c.payloadDrops = make([]uint64, frame.Height) | |||
| 26 | + | for value, row := range last { | |||
| 27 | + | bytes := value.LogicalBytes() | |||
| 28 | + | c.payloadBytes += bytes | |||
| 29 | + | c.payloadDrops[row] += bytes | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func (c *HistoryChunk) withoutFirstRow() *HistoryChunk { | |||
| 34 | + | payloadBytes := c.payloadBytes | |||
| 35 | + | if len(c.payloadDrops) != 0 { | |||
| 36 | + | payloadBytes -= c.payloadDrops[c.start] | |||
| 37 | + | } | |||
| 38 | + | return &HistoryChunk{ | |||
| 39 | + | page: c.page, start: c.start + 1, count: c.count - 1, width: c.width, | |||
| 40 | + | bounds: c.bounds[1:], rowIDs: c.rowIDs[1:], | |||
| 41 | + | styleDrops: c.styleDrops, styleCount: c.styleCount - c.styleDrops[c.start], | |||
| 42 | + | payloadDrops: c.payloadDrops, payloadBytes: payloadBytes, | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (h *History) recordPayloadScratch(p renderer.CellPayload) { | |||
| 47 | + | if p.Empty() { | |||
| 48 | + | return | |||
| 49 | + | } | |||
| 50 | + | if h.payloadScratch == nil { | |||
| 51 | + | h.payloadScratch = make(map[renderer.CellPayload]struct{}) | |||
| 52 | + | } | |||
| 53 | + | h.payloadScratch[p] = struct{}{} | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (h *History) rowPayloadBytes() uint64 { | |||
| 57 | + | var n uint64 | |||
| 58 | + | for p := range h.payloadScratch { | |||
| 59 | + | n += p.LogicalBytes() | |||
| 60 | + | } | |||
| 61 | + | return n | |||
| 62 | + | } | |||
A history_payload_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestPayloadSurvivesSnapshotResizeAndRecovery(t *testing.T) { | |||
| 13 | + | payload, err := renderer.NewCellPayload("e\u0301", "https://example.com") | |||
| 14 | + | require.NoError(t, err) | |||
| 15 | + | s := NewScreen(4, 2) | |||
| 16 | + | s.Write([]byte("ex")) | |||
| 17 | + | cell := s.Cell(0, 0) | |||
| 18 | + | cell.Payload = payload | |||
| 19 | + | s.frame.Set(0, 0, cell) | |||
| 20 | + | snapshot := s.Snapshot() | |||
| 21 | + | recovery := s.RecoveryTranscriptSnapshot() | |||
| 22 | + | s.Resize(2, 3) | |||
| 23 | + | require.Equal(t, payload, s.Cell(0, 0).Payload) | |||
| 24 | + | s.Write([]byte("\x1b[2J")) | |||
| 25 | + | require.Equal(t, payload, snapshot.Cell(0, 0).Payload) | |||
| 26 | + | blob, err := recovery.Marshal() | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | view, err := UnmarshalHistory(blob) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | require.Equal(t, payload, view.Cell(0, 0).Payload) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestHistoryPayloadRoundTripEvictionAndRestore(t *testing.T) { | |||
| 34 | + | payload, err := renderer.NewCellPayload("e\u0301", "https://example.com") | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | h := NewHistory(HistoryConfig{MaxRows: 2, MaxBytes: 1 << 20, ChunkRows: 2}) | |||
| 37 | + | row := []renderer.Cell{{Rune: 'e', Style: renderer.DefaultStyle(), Payload: payload}} | |||
| 38 | + | require.NoError(t, h.Append(row, LineBound{End: 1})) | |||
| 39 | + | require.NoError(t, h.Append(historyRow("x"), LineBound{End: 1})) | |||
| 40 | + | before := h.View() | |||
| 41 | + | encoded, err := MarshalHistory(before) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | decoded, err := UnmarshalHistory(encoded) | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | require.Equal(t, payload, decoded.Cell(0, 0).Payload) | |||
| 46 | + | require.Equal(t, before.LogicalBytes(), decoded.LogicalBytes()) | |||
| 47 | + | require.NoError(t, decoded.Chunk(0).CheckInvariants()) | |||
| 48 | + | require.NoError(t, h.Append(historyRow("y"), LineBound{End: 1})) | |||
| 49 | + | require.Equal(t, uint64(0), h.View().Chunk(0).payloadBytes) | |||
| 50 | + | require.Equal(t, payload, before.Cell(0, 0).Payload, "eviction must not mutate a sealed borrowed view") | |||
| 51 | + | sealed, tail, err := MarshalSealedHistory(before) | |||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 1, MaxBytes: 1 << 20}, sealed, tail) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | require.Equal(t, 'x', restored.View().Cell(0, 0).Rune) | |||
| 56 | + | require.True(t, restored.View().Cell(0, 0).Payload.Empty()) | |||
| 57 | + | require.NoError(t, restored.View().Chunk(0).CheckInvariants()) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestHistoryPayloadBudgetRejectsBeforeMutation(t *testing.T) { | |||
| 61 | + | payload, err := renderer.NewCellPayload("e\u0301", "https://example.com") | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | rowBytes := renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes + renderer.StyleRecordLogicalBytes | |||
| 64 | + | h := NewHistory(HistoryConfig{MaxRows: 2, MaxBytes: rowBytes + payload.LogicalBytes() - 1}) | |||
| 65 | + | err = h.Append([]renderer.Cell{{Rune: 'e', Style: renderer.DefaultStyle(), Payload: payload}}, LineBound{End: 1}) | |||
| 66 | + | require.ErrorIs(t, err, ErrHistoryRowTooLarge) | |||
| 67 | + | require.Zero(t, h.Len()) | |||
| 68 | + | require.Zero(t, h.LogicalBytes()) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestPayloadCodecRejectsInvalidDictionariesAndReferences(t *testing.T) { | |||
| 72 | + | a, err := renderer.NewCellPayload("", "https://a") | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | b, err := renderer.NewCellPayload("", "https://b") | |||
| 75 | + | require.NoError(t, err) | |||
| 76 | + | h := NewHistory(HistoryConfig{MaxRows: 1, MaxBytes: 1 << 20}) | |||
| 77 | + | require.NoError(t, h.Append([]renderer.Cell{ | |||
| 78 | + | {Rune: 'a', Style: renderer.DefaultStyle(), Payload: a}, | |||
| 79 | + | {Rune: 'b', Style: renderer.DefaultStyle(), Payload: b}, | |||
| 80 | + | }, LineBound{End: 2})) | |||
| 81 | + | valid, err := MarshalHistory(h.View()) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | const dict = historyHeaderBytes + historyChunkHeaderBytes | |||
| 84 | + | firstLen := 8 + len(a.Hyperlink()) | |||
| 85 | + | for _, tt := range []struct { | |||
| 86 | + | name string | |||
| 87 | + | mutate func([]byte) | |||
| 88 | + | }{ | |||
| 89 | + | {"missing payload reference", func(v []byte) { binary.BigEndian.PutUint32(v[len(v)-4:], 3) }}, | |||
| 90 | + | {"zero explicit payload reference", func(v []byte) { clear(v[len(v)-4:]) }}, | |||
| 91 | + | {"unused payload", func(v []byte) { binary.BigEndian.PutUint32(v[len(v)-4:], 1) }}, | |||
| 92 | + | {"duplicate payload", func(v []byte) { copy(v[dict+firstLen:dict+2*firstLen], v[dict:dict+firstLen]) }}, | |||
| 93 | + | {"control injection", func(v []byte) { v[dict+8] = 0x1b }}, | |||
| 94 | + | {"invalid UTF8", func(v []byte) { v[dict+8] = 0xff }}, | |||
| 95 | + | {"oversized string", func(v []byte) { binary.BigEndian.PutUint32(v[dict+4:], renderer.MaxHyperlinkBytes+1) }}, | |||
| 96 | + | {"excessive dictionary", func(v []byte) { binary.BigEndian.PutUint32(v[28:], ^uint32(0)) }}, | |||
| 97 | + | } { | |||
| 98 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 99 | + | v := bytes.Clone(valid) | |||
| 100 | + | tt.mutate(v) | |||
| 101 | + | _, err := PreflightHistoryBlob(v) | |||
| 102 | + | require.Error(t, err) | |||
| 103 | + | _, err = UnmarshalHistory(v) | |||
| 104 | + | require.Error(t, err) | |||
| 105 | + | }) | |||
| 106 | + | } | |||
| 107 | + | for end := range len(valid) { | |||
| 108 | + | _, err := UnmarshalHistory(valid[:end]) | |||
| 109 | + | require.Error(t, err) | |||
| 110 | + | } | |||
| 111 | + | } | |||
A history_range_error_test.go +24 -0
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev-vt/core" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHistoryRangeReportsCorruptionWithoutPanicking(t *testing.T) { | |||
| 11 | + | h := compressionFixture(t) | |||
| 12 | + | compressFixture(t, h) | |||
| 13 | + | view := h.View() | |||
| 14 | + | page := view.Chunk(1).page | |||
| 15 | + | page.compressed[len(page.compressed)-1] ^= 0xff | |||
| 16 | + | calls := 0 | |||
| 17 | + | err := view.Range(func([]core.Cell) bool { calls++; return true }) | |||
| 18 | + | require.ErrorIs(t, err, ErrHistoryCorrupt) | |||
| 19 | + | require.Equal(t, 16, calls, "iteration stops before yielding any row from the corrupt page") | |||
| 20 | + | calls = 0 | |||
| 21 | + | require.NoError(t, view.Range(func([]core.Cell) bool { calls++; return false })) | |||
| 22 | + | require.Equal(t, 1, calls, "early cancellation must not restore later pages") | |||
| 23 | + | require.NoError(t, (HistoryView{}).Range(nil)) | |||
| 24 | + | } | |||
A history_read.go +52 -0
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import renderer "github.com/bnema/vev-vt/core" | |||
| 4 | + | ||||
| 5 | + | // RowWidth returns the retained width of row y, or zero outside the view. | |||
| 6 | + | // Unlike Row, it does not allocate or decode a semantic row. | |||
| 7 | + | func (v HistoryView) RowWidth(y int) int { | |||
| 8 | + | chunk, _ := v.locateRow(y) | |||
| 9 | + | if chunk == nil { | |||
| 10 | + | return 0 | |||
| 11 | + | } | |||
| 12 | + | return chunk.width | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // Cell returns one semantic cell without allocating a row. Coordinates follow | |||
| 16 | + | // Screen.Cell: x is the column and y is the row. Invalid coordinates return the | |||
| 17 | + | // canonical blank cell. Variable-width history does not implement CellSource. | |||
| 18 | + | func (v HistoryView) Cell(x, y int) renderer.Cell { | |||
| 19 | + | chunk, row := v.locateRow(y) | |||
| 20 | + | if chunk == nil || x < 0 || x >= chunk.width { | |||
| 21 | + | return renderer.BlankCell() | |||
| 22 | + | } | |||
| 23 | + | return chunk.cell(row, x) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // CopyRow copies up to len(dst) cells into caller-owned storage and returns the | |||
| 27 | + | // number copied. It neither allocates a row nor exposes mutable page storage. | |||
| 28 | + | func (v HistoryView) CopyRow(y int, dst []renderer.Cell) int { | |||
| 29 | + | chunk, row := v.locateRow(y) | |||
| 30 | + | if chunk == nil { | |||
| 31 | + | return 0 | |||
| 32 | + | } | |||
| 33 | + | n := min(len(dst), chunk.width) | |||
| 34 | + | frame := chunk.frameView() | |||
| 35 | + | for x := range n { | |||
| 36 | + | dst[x] = frame.Cell(x, chunk.start+row) | |||
| 37 | + | } | |||
| 38 | + | return n | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (v HistoryView) locateRow(y int) (*HistoryChunk, int) { | |||
| 42 | + | if y < 0 { | |||
| 43 | + | return nil, 0 | |||
| 44 | + | } | |||
| 45 | + | for _, chunk := range v.chunks { | |||
| 46 | + | if y < chunk.len() { | |||
| 47 | + | return chunk, y | |||
| 48 | + | } | |||
| 49 | + | y -= chunk.len() | |||
| 50 | + | } | |||
| 51 | + | return nil, 0 | |||
| 52 | + | } | |||
A history_read_test.go +58 -0
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHistoryScalarReadsAndCopyDoNotAllocate(t *testing.T) { | |||
| 11 | + | h := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1}) | |||
| 12 | + | require.NoError(t, h.Append(historyRow("abc"), LineBound{End: 3})) | |||
| 13 | + | require.NoError(t, h.Append(historyRow("de"), LineBound{End: 2})) | |||
| 14 | + | view := h.View() | |||
| 15 | + | require.Equal(t, 3, view.RowWidth(0)) | |||
| 16 | + | require.Equal(t, 2, view.RowWidth(1)) | |||
| 17 | + | for _, y := range []int{-1, 2} { | |||
| 18 | + | require.Zero(t, view.RowWidth(y)) | |||
| 19 | + | require.Equal(t, renderer.BlankCell(), view.Cell(0, y)) | |||
| 20 | + | } | |||
| 21 | + | require.Equal(t, renderer.BlankCell(), view.Cell(-1, 0)) | |||
| 22 | + | require.Equal(t, renderer.BlankCell(), view.Cell(3, 0)) | |||
| 23 | + | dst := make([]renderer.Cell, 2) | |||
| 24 | + | require.Equal(t, 2, view.CopyRow(0, dst)) | |||
| 25 | + | require.Equal(t, 'a', dst[0].Rune) | |||
| 26 | + | dst[0].Rune = 'X' | |||
| 27 | + | require.Equal(t, 'a', view.Cell(0, 0).Rune) | |||
| 28 | + | require.Zero(t, testing.AllocsPerRun(100, func() { | |||
| 29 | + | _ = view.RowWidth(1) | |||
| 30 | + | _ = view.Cell(1, 1) | |||
| 31 | + | view.CopyRow(0, dst) | |||
| 32 | + | })) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestSnapshotTailReusesItsCompactCapture(t *testing.T) { | |||
| 36 | + | h := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 4}) | |||
| 37 | + | require.NoError(t, h.Append(historyRow("ab"), LineBound{End: 2})) | |||
| 38 | + | snapshot := h.SnapshotView() | |||
| 39 | + | tail := snapshot.Tail() | |||
| 40 | + | require.Same(t, tail.Chunk(0), snapshot.Tail().Chunk(0)) | |||
| 41 | + | require.Zero(t, testing.AllocsPerRun(100, func() { _ = snapshot.Tail() })) | |||
| 42 | + | require.NoError(t, h.Append(historyRow("cd"), LineBound{End: 2})) | |||
| 43 | + | require.Equal(t, 1, tail.Len()) | |||
| 44 | + | require.Equal(t, 'a', tail.Cell(0, 0).Rune) | |||
| 45 | + | require.NoError(t, tail.Chunk(0).CheckInvariants()) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func BenchmarkRecoveryTranscriptCompactCapture(b *testing.B) { | |||
| 49 | + | s := NewScreen(120, 40) | |||
| 50 | + | for y := range s.Rows() { | |||
| 51 | + | s.frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 52 | + | s.buffer.boundaries[y] = LineBound{End: 1} | |||
| 53 | + | } | |||
| 54 | + | b.ReportAllocs() | |||
| 55 | + | for b.Loop() { | |||
| 56 | + | recoveryTranscriptSnapshotSink = s.RecoveryTranscriptSnapshot() | |||
| 57 | + | } | |||
| 58 | + | } | |||
A history_storage.go +222 -0
| @@ -0,0 +1,222 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "compress/zlib" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "io" | |||
| 9 | + | "sync" | |||
| 10 | + | ||||
| 11 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // ErrHistoryCorrupt reports corruption of an internally compressed sealed page. | |||
| 15 | + | // Public history decoders reject malformed external data before installing it. | |||
| 16 | + | var ErrHistoryCorrupt = errors.New("corrupt compressed history page") | |||
| 17 | + | ||||
| 18 | + | const maxHistoryPageEncodedBytes = 256 << 20 | |||
| 19 | + | ||||
| 20 | + | // sealedPage owns immutable semantic contents and mutable physical backing. | |||
| 21 | + | // Evicted-prefix wrappers and borrowed views share this owner. A returned Frame | |||
| 22 | + | // is an internal read-only borrow: dropping the cached pointer cannot invalidate | |||
| 23 | + | // an in-flight reader because Go keeps that reader's page reference alive. | |||
| 24 | + | type sealedPage struct { | |||
| 25 | + | mu sync.Mutex | |||
| 26 | + | frame renderer.Frame | |||
| 27 | + | compressed []byte | |||
| 28 | + | encodedSize int | |||
| 29 | + | width, height int | |||
| 30 | + | reads, observed uint64 | |||
| 31 | + | observedOnce bool | |||
| 32 | + | incompressible bool | |||
| 33 | + | restores uint64 | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func newSealedPage(frame renderer.Frame) *sealedPage { | |||
| 37 | + | return &sealedPage{frame: frame, width: frame.Width, height: frame.Height} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (p *sealedPage) readFrame(cache bool) (renderer.Frame, error) { | |||
| 41 | + | p.mu.Lock() | |||
| 42 | + | defer p.mu.Unlock() | |||
| 43 | + | p.reads++ | |||
| 44 | + | if p.frame.Height != 0 { | |||
| 45 | + | return p.frame, nil | |||
| 46 | + | } | |||
| 47 | + | frame, err := p.decode() | |||
| 48 | + | if err != nil { | |||
| 49 | + | return renderer.Frame{}, err | |||
| 50 | + | } | |||
| 51 | + | p.restores++ | |||
| 52 | + | if cache { | |||
| 53 | + | p.frame = frame | |||
| 54 | + | } | |||
| 55 | + | return frame, nil | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // decode is called with the page lock held. The exact uncompressed length and | |||
| 59 | + | // zlib checksum are validated before VTC1's full semantic preflight runs. | |||
| 60 | + | func (p *sealedPage) decode() (renderer.Frame, error) { | |||
| 61 | + | invalid := func(err error) (renderer.Frame, error) { | |||
| 62 | + | return renderer.Frame{}, fmt.Errorf("%w: %v", ErrHistoryCorrupt, err) | |||
| 63 | + | } | |||
| 64 | + | if p.encodedSize < historyHeaderBytes || p.encodedSize > maxHistoryPageEncodedBytes || len(p.compressed) == 0 { | |||
| 65 | + | return invalid(errors.New("missing backing")) | |||
| 66 | + | } | |||
| 67 | + | source := bytes.NewReader(p.compressed) | |||
| 68 | + | r, err := zlib.NewReader(source) | |||
| 69 | + | if err != nil { | |||
| 70 | + | return invalid(err) | |||
| 71 | + | } | |||
| 72 | + | data, readErr := io.ReadAll(io.LimitReader(r, int64(p.encodedSize)+1)) | |||
| 73 | + | closeErr := r.Close() | |||
| 74 | + | if readErr != nil { | |||
| 75 | + | return invalid(readErr) | |||
| 76 | + | } | |||
| 77 | + | if closeErr != nil { | |||
| 78 | + | return invalid(closeErr) | |||
| 79 | + | } | |||
| 80 | + | if len(data) != p.encodedSize || source.Len() != 0 { | |||
| 81 | + | return invalid(errors.New("backing length mismatch")) | |||
| 82 | + | } | |||
| 83 | + | view, err := UnmarshalHistory(data) | |||
| 84 | + | if err != nil { | |||
| 85 | + | return invalid(err) | |||
| 86 | + | } | |||
| 87 | + | if len(view.chunks) != 1 { | |||
| 88 | + | return invalid(errors.New("unexpected page count")) | |||
| 89 | + | } | |||
| 90 | + | frame := view.chunks[0].page.frame | |||
| 91 | + | if frame.Width != p.width || frame.Height != p.height { | |||
| 92 | + | return invalid(errors.New("page geometry mismatch")) | |||
| 93 | + | } | |||
| 94 | + | return frame, nil | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (c *HistoryChunk) frameView() renderer.Frame { | |||
| 98 | + | frame, err := c.page.readFrame(true) | |||
| 99 | + | if err != nil { | |||
| 100 | + | panic(err) | |||
| 101 | + | } // Never substitute blank data for corrupted history. | |||
| 102 | + | return frame | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Restore validates and caches the chunk's backing before a caller starts a | |||
| 106 | + | // read transaction. Ordinary reads restore transparently; if private backing is | |||
| 107 | + | // corrupted they panic with ErrHistoryCorrupt rather than silently losing text. | |||
| 108 | + | func (c *HistoryChunk) Restore() error { | |||
| 109 | + | if c == nil { | |||
| 110 | + | return nil | |||
| 111 | + | } | |||
| 112 | + | _, err := c.page.readFrame(true) | |||
| 113 | + | return err | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func (p *sealedPage) compressIfIdle() (bool, error) { | |||
| 117 | + | p.mu.Lock() | |||
| 118 | + | defer p.mu.Unlock() | |||
| 119 | + | if !p.observedOnce || p.observed != p.reads { | |||
| 120 | + | p.observed, p.observedOnce = p.reads, true | |||
| 121 | + | return false, nil | |||
| 122 | + | } | |||
| 123 | + | if p.frame.Height == 0 || p.incompressible { | |||
| 124 | + | return false, nil | |||
| 125 | + | } | |||
| 126 | + | if len(p.compressed) != 0 { | |||
| 127 | + | p.frame = renderer.Frame{} | |||
| 128 | + | return true, nil | |||
| 129 | + | } | |||
| 130 | + | // Encode the full backing, not an evicted-prefix wrapper. Synthetic IDs are | |||
| 131 | + | // local to this private envelope; public stable IDs remain on the wrappers. | |||
| 132 | + | chunk := &HistoryChunk{page: newSealedPage(p.frame), count: p.height, width: p.width, | |||
| 133 | + | bounds: make([]LineBound, p.height), rowIDs: make([]RowID, p.height)} | |||
| 134 | + | for y := range p.height { | |||
| 135 | + | chunk.bounds[y] = LineBound{End: p.width} | |||
| 136 | + | chunk.rowIDs[y] = RowID(y + 1) | |||
| 137 | + | } | |||
| 138 | + | raw, err := MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: p.height, nextRowID: RowID(p.height + 1)}) | |||
| 139 | + | if err != nil { | |||
| 140 | + | return false, err | |||
| 141 | + | } | |||
| 142 | + | if len(raw) > maxHistoryPageEncodedBytes { | |||
| 143 | + | return false, errors.New("history page exceeds encoded backing limit") | |||
| 144 | + | } | |||
| 145 | + | var out bytes.Buffer | |||
| 146 | + | w, err := zlib.NewWriterLevel(&out, zlib.BestSpeed) | |||
| 147 | + | if err != nil { | |||
| 148 | + | return false, err | |||
| 149 | + | } | |||
| 150 | + | if _, err := w.Write(raw); err != nil { | |||
| 151 | + | _ = w.Close() | |||
| 152 | + | return false, err | |||
| 153 | + | } | |||
| 154 | + | if err := w.Close(); err != nil { | |||
| 155 | + | return false, err | |||
| 156 | + | } | |||
| 157 | + | if out.Len() >= len(raw) { | |||
| 158 | + | p.incompressible = true | |||
| 159 | + | return false, nil | |||
| 160 | + | } | |||
| 161 | + | p.compressed = make([]byte, out.Len()) | |||
| 162 | + | copy(p.compressed, out.Bytes()) | |||
| 163 | + | p.encodedSize = len(raw) | |||
| 164 | + | p.frame = renderer.Frame{} | |||
| 165 | + | return true, nil | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // CompressIdle visits at most maxPages sealed pages. Call it from the history | |||
| 169 | + | // owner's idle scheduler, never concurrently with append/eviction. A page must | |||
| 170 | + | // remain unread across two visits; the newest sealed chunk and mutable tail are | |||
| 171 | + | // kept hot. There are no internal goroutines, clocks, pooling or mmap mappings. | |||
| 172 | + | func (h *History) CompressIdle(maxPages int) (int, error) { | |||
| 173 | + | if h == nil || maxPages <= 0 || len(h.chunks) < 2 { | |||
| 174 | + | return 0, nil | |||
| 175 | + | } | |||
| 176 | + | eligible := len(h.chunks) - 1 | |||
| 177 | + | compressed := 0 | |||
| 178 | + | for range min(maxPages, eligible) { | |||
| 179 | + | h.compressionCursor %= eligible | |||
| 180 | + | chunk := h.chunks[h.compressionCursor] | |||
| 181 | + | h.compressionCursor++ | |||
| 182 | + | didCompress, err := chunk.page.compressIfIdle() | |||
| 183 | + | if err != nil { | |||
| 184 | + | return compressed, err | |||
| 185 | + | } | |||
| 186 | + | if didCompress { | |||
| 187 | + | compressed++ | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | return compressed, nil | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // HistoryCompressionStats describes retained physical backing, not process RSS. | |||
| 194 | + | // ResidentLogicalBytes excludes Go allocator/map overhead; CompressedBytes is | |||
| 195 | + | // the encoded backing size. Retention remains governed by LogicalBytes alone. | |||
| 196 | + | type HistoryCompressionStats struct { | |||
| 197 | + | ColdPages int | |||
| 198 | + | CompressedBytes uint64 | |||
| 199 | + | ResidentLogicalBytes uint64 | |||
| 200 | + | Restores uint64 | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func (h *History) CompressionStats() HistoryCompressionStats { | |||
| 204 | + | var stats HistoryCompressionStats | |||
| 205 | + | if h == nil { | |||
| 206 | + | return stats | |||
| 207 | + | } | |||
| 208 | + | stats.ResidentLogicalBytes = h.tailBytes | |||
| 209 | + | for _, chunk := range h.chunks { | |||
| 210 | + | p := chunk.page | |||
| 211 | + | p.mu.Lock() | |||
| 212 | + | if p.frame.Height == 0 { | |||
| 213 | + | stats.ColdPages++ | |||
| 214 | + | } else { | |||
| 215 | + | stats.ResidentLogicalBytes += p.frame.LogicalBytes() | |||
| 216 | + | } | |||
| 217 | + | stats.CompressedBytes += uint64(len(p.compressed)) | |||
| 218 | + | stats.Restores += p.restores | |||
| 219 | + | p.mu.Unlock() | |||
| 220 | + | } | |||
| 221 | + | return stats | |||
| 222 | + | } | |||
A history_storage_test.go +174 -0
| @@ -0,0 +1,174 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | renderer "github.com/bnema/vev-vt/core" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func compressionFixture(t testing.TB) *History { | |||
| 14 | + | t.Helper() | |||
| 15 | + | h := NewHistory(HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20, ChunkRows: 16}) | |||
| 16 | + | row := historyRow(string(bytes.Repeat([]byte("a"), 80))) | |||
| 17 | + | payload, err := renderer.NewCellPayload("e\u0301", "https://example.com") | |||
| 18 | + | require.NoError(t, err) | |||
| 19 | + | row[0].Payload = payload | |||
| 20 | + | for range 48 { | |||
| 21 | + | require.NoError(t, h.Append(row, LineBound{End: 80, Soft: true})) | |||
| 22 | + | } | |||
| 23 | + | return h | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func compressFixture(t testing.TB, h *History) { | |||
| 27 | + | t.Helper() | |||
| 28 | + | n, err := h.CompressIdle(100) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | require.Zero(t, n, "first observation must not compress a recently read page") | |||
| 31 | + | n, err = h.CompressIdle(100) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | require.Equal(t, 2, n) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestColdHistoryPreservesIdentityAccountingAndContents(t *testing.T) { | |||
| 37 | + | h := compressionFixture(t) | |||
| 38 | + | view := h.View() | |||
| 39 | + | chunk := view.Chunk(0) | |||
| 40 | + | logical := h.LogicalBytes() | |||
| 41 | + | encoded, err := MarshalHistory(view) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | compressFixture(t, h) | |||
| 44 | + | for _, sealed := range h.chunks { | |||
| 45 | + | require.Equal(t, len(sealed.page.compressed), cap(sealed.page.compressed), "compressed backing must not retain buffer slack") | |||
| 46 | + | } | |||
| 47 | + | stats := h.CompressionStats() | |||
| 48 | + | require.Equal(t, 2, stats.ColdPages) | |||
| 49 | + | require.Positive(t, stats.CompressedBytes) | |||
| 50 | + | require.Less(t, stats.CompressedBytes+stats.ResidentLogicalBytes, logical) | |||
| 51 | + | require.Equal(t, logical, h.LogicalBytes()) | |||
| 52 | + | require.Same(t, chunk, h.View().Chunk(0)) | |||
| 53 | + | // Persistence and streaming search do not fill random-access caches. | |||
| 54 | + | again, err := MarshalHistory(view) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | require.Equal(t, encoded, again) | |||
| 57 | + | rows := 0 | |||
| 58 | + | require.NoError(t, view.Range(func(row []renderer.Cell) bool { | |||
| 59 | + | require.Equal(t, 'a', row[0].Rune) | |||
| 60 | + | require.Equal(t, "e\u0301", row[0].Payload.Grapheme()) | |||
| 61 | + | rows++ | |||
| 62 | + | return true | |||
| 63 | + | })) | |||
| 64 | + | require.Equal(t, 48, rows) | |||
| 65 | + | require.Equal(t, 2, h.CompressionStats().ColdPages) | |||
| 66 | + | require.Equal(t, 'a', view.Cell(0, 0).Rune) | |||
| 67 | + | require.Equal(t, 1, h.CompressionStats().ColdPages) | |||
| 68 | + | require.Equal(t, RowID(1), view.RowID(0)) | |||
| 69 | + | require.Equal(t, LineBound{End: 80, Soft: true}, view.Bound(0)) | |||
| 70 | + | require.NoError(t, chunk.CheckInvariants()) | |||
| 71 | + | _, err = h.CompressIdle(100) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | n, err := h.CompressIdle(100) | |||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | require.Equal(t, 1, n, "an unread restored cache is released without recompression") | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestColdHistoryIncrementalBudgetAndEviction(t *testing.T) { | |||
| 79 | + | h := compressionFixture(t) | |||
| 80 | + | before := h.View() | |||
| 81 | + | for range 4 { | |||
| 82 | + | n, err := h.CompressIdle(1) | |||
| 83 | + | require.NoError(t, err) | |||
| 84 | + | require.LessOrEqual(t, n, 1) | |||
| 85 | + | } | |||
| 86 | + | require.Equal(t, 2, h.CompressionStats().ColdPages) | |||
| 87 | + | for range 20 { | |||
| 88 | + | require.NoError(t, h.Append(historyRow("new"), LineBound{End: 3})) | |||
| 89 | + | } | |||
| 90 | + | require.Equal(t, 64, h.Len()) | |||
| 91 | + | require.Equal(t, RowID(5), h.View().RowID(0)) | |||
| 92 | + | require.Equal(t, RowID(1), before.RowID(0)) | |||
| 93 | + | require.Equal(t, 'a', before.Cell(0, 0).Rune) | |||
| 94 | + | require.NoError(t, h.View().Chunk(0).CheckInvariants()) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestColdHistoryRejectsCorruptedBacking(t *testing.T) { | |||
| 98 | + | h := compressionFixture(t) | |||
| 99 | + | compressFixture(t, h) | |||
| 100 | + | chunk := h.View().Chunk(0) | |||
| 101 | + | chunk.page.compressed[len(chunk.page.compressed)-1] ^= 0xff | |||
| 102 | + | require.ErrorIs(t, chunk.Restore(), ErrHistoryCorrupt) | |||
| 103 | + | _, err := MarshalHistory(h.View()) | |||
| 104 | + | require.ErrorIs(t, err, ErrHistoryCorrupt) | |||
| 105 | + | func() { | |||
| 106 | + | defer func() { | |||
| 107 | + | value := recover() | |||
| 108 | + | err, ok := value.(error) | |||
| 109 | + | require.True(t, ok) | |||
| 110 | + | require.True(t, errors.Is(err, ErrHistoryCorrupt)) | |||
| 111 | + | }() | |||
| 112 | + | _ = h.View().Cell(0, 0) | |||
| 113 | + | t.Fatal("corrupt read silently succeeded") | |||
| 114 | + | }() | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func TestColdHistoryRejectsBackingLengthCorruption(t *testing.T) { | |||
| 118 | + | for _, tt := range []struct { | |||
| 119 | + | name string | |||
| 120 | + | mutate func(*sealedPage) | |||
| 121 | + | }{ | |||
| 122 | + | {"trailing data", func(p *sealedPage) { p.compressed = append(p.compressed, 0) }}, | |||
| 123 | + | {"truncated data", func(p *sealedPage) { p.compressed = p.compressed[:len(p.compressed)-1] }}, | |||
| 124 | + | {"short decoded length", func(p *sealedPage) { p.encodedSize-- }}, | |||
| 125 | + | {"oversized decoded length", func(p *sealedPage) { p.encodedSize = 257 << 20 }}, | |||
| 126 | + | } { | |||
| 127 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 128 | + | h := compressionFixture(t) | |||
| 129 | + | compressFixture(t, h) | |||
| 130 | + | chunk := h.View().Chunk(0) | |||
| 131 | + | tt.mutate(chunk.page) | |||
| 132 | + | require.ErrorIs(t, chunk.Restore(), ErrHistoryCorrupt) | |||
| 133 | + | }) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestColdHistoryBorrowedReadConcurrentWithMaintenance(t *testing.T) { | |||
| 138 | + | h := compressionFixture(t) | |||
| 139 | + | view := h.View() | |||
| 140 | + | var wg sync.WaitGroup | |||
| 141 | + | wg.Add(1) | |||
| 142 | + | go func() { | |||
| 143 | + | defer wg.Done() | |||
| 144 | + | for range 100 { | |||
| 145 | + | if view.Cell(0, 0).Rune != 'a' { | |||
| 146 | + | t.Error("borrowed contents changed") | |||
| 147 | + | return | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | }() | |||
| 151 | + | for range 100 { | |||
| 152 | + | _, err := h.CompressIdle(2) | |||
| 153 | + | require.NoError(t, err) | |||
| 154 | + | } | |||
| 155 | + | wg.Wait() | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func BenchmarkColdHistoryRestore(b *testing.B) { | |||
| 159 | + | h := compressionFixture(b) | |||
| 160 | + | compressFixture(b, h) | |||
| 161 | + | chunk := h.View().Chunk(0) | |||
| 162 | + | b.ReportAllocs() | |||
| 163 | + | for b.Loop() { | |||
| 164 | + | if err := chunk.Restore(); err != nil { | |||
| 165 | + | b.Fatal(err) | |||
| 166 | + | } | |||
| 167 | + | if _, err := h.CompressIdle(100); err != nil { | |||
| 168 | + | b.Fatal(err) | |||
| 169 | + | } | |||
| 170 | + | if _, err := h.CompressIdle(100); err != nil { | |||
| 171 | + | b.Fatal(err) | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | } | |||
M history_test.go +92 -17
| @@ -183,8 +183,8 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) { | |||||
| 183 | for _, tt := range tests { | 183 | for _, tt := range tests { | ||
| 184 | t.Run(tt.name, func(t *testing.T) { | 184 | t.Run(tt.name, func(t *testing.T) { | ||
| 185 | s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8, ChunkRows: 2}) | 185 | s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8, ChunkRows: 2}) | ||
| 186 | - | for y := range s.Frame.Height { | 186 | + | for y := range s.frame.Height { |
| 187 | - | copy(s.Frame.Row(y), historyRow(string([]byte{byte('A' + y), byte('A' + y), byte('A' + y), byte('A' + y)}))) | 187 | + | s.frame.WriteRow(y, 0, historyRow(string([]byte{byte('A' + y), byte('A' + y), byte('A' + y), byte('A' + y)}))) |
| 188 | } | 188 | } | ||
| 189 | events := 0 | 189 | events := 0 | ||
| 190 | s.OnLineEvicted = func([]renderer.Cell) { events++ } | 190 | s.OnLineEvicted = func([]renderer.Cell) { events++ } | ||
| @@ -201,8 +201,9 @@ func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) { | |||||
| 201 | } | 201 | } | ||
| 202 | } | 202 | } | ||
| 203 | 203 | ||||
| 204 | - | func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) { | 204 | + | func TestHistoryBoundsRowsAndBytesWithExactRowEviction(t *testing.T) { |
| 205 | - | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2}) | 205 | + | const budget = 3*renderer.StoredCellLogicalBytes + 2*renderer.RowDescriptorLogicalBytes + 2*renderer.StyleRecordLogicalBytes |
| 206 | + | history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: budget, ChunkRows: 2}) | |||
| 206 | for _, text := range []string{"aa", "bbb", "c", "dd"} { | 207 | for _, text := range []string{"aa", "bbb", "c", "dd"} { | ||
| 207 | if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil { | 208 | if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil { | ||
| 208 | t.Fatalf("append %q: %v", text, err) | 209 | t.Fatalf("append %q: %v", text, err) | ||
| @@ -246,8 +247,9 @@ func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) { | |||||
| 246 | } | 247 | } | ||
| 247 | } | 248 | } | ||
| 248 | 249 | ||||
| 249 | - | func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) { | 250 | + | func TestHistoryRejectsRowLargerThanByteBudgetWithoutMutation(t *testing.T) { |
| 250 | - | history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2}) | 251 | + | const budget = 2*renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes + renderer.StyleRecordLogicalBytes |
| 252 | + | history := NewHistory(HistoryConfig{MaxRows: 2, MaxBytes: budget, ChunkRows: 2}) | |||
| 251 | kept := historyRow("ok") | 253 | kept := historyRow("ok") | ||
| 252 | if err := history.Append(kept, LineBound{End: len(kept)}); err != nil { | 254 | if err := history.Append(kept, LineBound{End: len(kept)}); err != nil { | ||
| 253 | t.Fatalf("append retained row: %v", err) | 255 | t.Fatalf("append retained row: %v", err) | ||
| @@ -256,8 +258,8 @@ func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) { | |||||
| 256 | 258 | ||||
| 257 | wide := historyRow("wide") | 259 | wide := historyRow("wide") | ||
| 258 | err := history.Append(wide, LineBound{End: len(wide)}) | 260 | err := history.Append(wide, LineBound{End: len(wide)}) | ||
| 259 | - | if !errors.Is(err, ErrHistoryRowTooWide) { | 261 | + | if !errors.Is(err, ErrHistoryRowTooLarge) { |
| 260 | - | t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err) | 262 | + | t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooLarge", err) |
| 261 | } | 263 | } | ||
| 262 | if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) { | 264 | if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) { | ||
| 263 | t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want) | 265 | t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want) | ||
| @@ -268,11 +270,11 @@ func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) { | |||||
| 268 | } | 270 | } | ||
| 269 | 271 | ||||
| 270 | func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) { | 272 | func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) { | ||
| 271 | - | screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxCells: 3}) | 273 | + | screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxBytes: 83}) |
| 272 | var events []string | 274 | var events []string | ||
| 273 | screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) } | 275 | screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) } | ||
| 274 | - | copy(screen.Frame.Row(0), historyRow("AAAA")) | 276 | + | screen.frame.WriteRow(0, 0, historyRow("AAAA")) |
| 275 | - | copy(screen.Frame.Row(1), historyRow("BBBB")) | 277 | + | screen.frame.WriteRow(1, 0, historyRow("BBBB")) |
| 276 | screen.scrollUpRegion(0, 1, 1) | 278 | screen.scrollUpRegion(0, 1, 1) | ||
| 277 | 279 | ||||
| 278 | if got := screen.History().Len(); got != 0 { | 280 | if got := screen.History().Len(); got != 0 { | ||
| @@ -314,7 +316,7 @@ func TestHistoryAppendRetainsBounds(t *testing.T) { | |||||
| 314 | 316 | ||||
| 315 | for _, tc := range tests { | 317 | for _, tc := range tests { | ||
| 316 | t.Run(tc.name, func(t *testing.T) { | 318 | t.Run(tc.name, func(t *testing.T) { | ||
| 317 | - | h := NewHistory(HistoryConfig{MaxRows: 16, MaxCells: 1024, ChunkRows: tc.chunkRows}) | 319 | + | h := NewHistory(HistoryConfig{MaxRows: 16, MaxBytes: 1 << 20, ChunkRows: tc.chunkRows}) |
| 318 | for i, b := range tc.bounds { | 320 | for i, b := range tc.bounds { | ||
| 319 | if err := h.Append(row("abc"[:b.End]), b); err != nil { | 321 | if err := h.Append(row("abc"[:b.End]), b); err != nil { | ||
| 320 | t.Fatalf("append %d: %v", i, err) | 322 | t.Fatalf("append %d: %v", i, err) | ||
| @@ -331,7 +333,7 @@ func TestHistoryAppendRetainsBounds(t *testing.T) { | |||||
| 331 | } | 333 | } | ||
| 332 | 334 | ||||
| 333 | func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) { | 335 | func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) { | ||
| 334 | - | h := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 1024, ChunkRows: 2}) | 336 | + | h := NewHistory(HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 2}) |
| 335 | for i := range 5 { | 337 | for i := range 5 { | ||
| 336 | cells := []renderer.Cell{{Rune: rune('a' + i)}} | 338 | cells := []renderer.Cell{{Rune: rune('a' + i)}} | ||
| 337 | if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil { | 339 | if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil { | ||
| @@ -345,15 +347,88 @@ func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) { | |||||
| 345 | // Rows 2, 3, 4 survived; their Soft flags were true, false, true. | 347 | // Rows 2, 3, 4 survived; their Soft flags were true, false, true. | ||
| 346 | for i, want := range []bool{true, false, true} { | 348 | for i, want := range []bool{true, false, true} { | ||
| 347 | if got := view.Bound(i).Soft; got != want { | 349 | if got := view.Bound(i).Soft; got != want { | ||
| 348 | - | t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.BorrowedRow(i)[0].Rune) | 350 | + | t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.Row(i)[0].Rune) |
| 349 | } | 351 | } | ||
| 350 | } | 352 | } | ||
| 351 | } | 353 | } | ||
| 352 | 354 | ||||
| 353 | - | func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) { | 355 | + | func TestHistorySlabAndTailAllocationBounds(t *testing.T) { |
| 356 | + | if uint64(math.MaxInt) >= uint64(math.MaxUint32) { | |||
| 357 | + | maxWidth := uint64(math.MaxUint32) | |||
| 358 | + | width := int(maxWidth) | |||
| 359 | + | if got := maxHistorySlabRows(width); got != 1 { | |||
| 360 | + | t.Fatalf("maxHistorySlabRows(MaxUint32) = %d, want 1", got) | |||
| 361 | + | } | |||
| 362 | + | if got := maxHistorySlabRows(width / 2); got != 2 { | |||
| 363 | + | t.Fatalf("maxHistorySlabRows(MaxUint32/2) = %d, want 2", got) | |||
| 364 | + | } | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | history := NewHistory(HistoryConfig{MaxRows: 256, MaxBytes: 1 << 30, ChunkRows: 256}) | |||
| 368 | + | row := make([]renderer.Cell, 10_000) | |||
| 369 | + | if err := history.Append(row, LineBound{}); err != nil { | |||
| 370 | + | t.Fatal(err) | |||
| 371 | + | } | |||
| 372 | + | if got, max := cap(history.tailCells), max(len(row), maxTailPreallocCells); got > max { | |||
| 373 | + | t.Fatalf("wide-row tail capacity = %d, want at most %d", got, max) | |||
| 374 | + | } | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | func TestHistorySealsCompactContiguousSlabs(t *testing.T) { | |||
| 378 | + | const rows, columns = 256, 120 | |||
| 379 | + | history := NewHistory(HistoryConfig{MaxRows: rows, MaxBytes: 1 << 30, ChunkRows: rows}) | |||
| 380 | + | row := make([]renderer.Cell, columns) | |||
| 381 | + | for x := range row { | |||
| 382 | + | row[x] = renderer.Cell{Rune: 'x', Style: renderer.Style{Bold: true, Foreground: 4}} | |||
| 383 | + | } | |||
| 384 | + | for range rows { | |||
| 385 | + | if err := history.Append(row, LineBound{End: columns}); err != nil { | |||
| 386 | + | t.Fatal(err) | |||
| 387 | + | } | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | view := history.View() | |||
| 391 | + | chunk := view.Chunk(0) | |||
| 392 | + | if view.ChunkCount() != 1 || chunk == nil || chunk.count != rows || chunk.width != columns { | |||
| 393 | + | t.Fatalf("compact chunk = %#v, chunks %d", chunk, view.ChunkCount()) | |||
| 394 | + | } | |||
| 395 | + | // The original inline Cell occupied 72 bytes; retain the 4x reduction gate. | |||
| 396 | + | if got, max := chunk.frameView().LogicalBytes(), uint64(rows*columns*72/4); got >= max { | |||
| 397 | + | t.Fatalf("compact slab logical bytes = %d, want below %d", got, max) | |||
| 398 | + | } | |||
| 399 | + | if got := chunk.frameView().StyleCount(); got != 2 { | |||
| 400 | + | t.Fatalf("page-local style count = %d, want default plus repeated style", got) | |||
| 401 | + | } | |||
| 402 | + | if err := chunk.CheckInvariants(); err != nil { | |||
| 403 | + | t.Fatalf("compact chunk invariants: %v", err) | |||
| 404 | + | } | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | func TestHistorySplitsCompactSlabsWhenRowWidthChanges(t *testing.T) { | |||
| 408 | + | history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 4}) | |||
| 409 | + | for _, text := range []string{"aa", "bb", "ccc", "ddd"} { | |||
| 410 | + | if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil { | |||
| 411 | + | t.Fatal(err) | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | view := history.View() | |||
| 415 | + | if got := view.ChunkCount(); got != 2 { | |||
| 416 | + | t.Fatalf("ChunkCount() = %d, want one slab per consecutive width", got) | |||
| 417 | + | } | |||
| 418 | + | for i, want := range []string{"aa", "bb", "ccc", "ddd"} { | |||
| 419 | + | if got := rowText(view.Row(i)); got != want { | |||
| 420 | + | t.Fatalf("Row(%d) = %q, want %q", i, got, want) | |||
| 421 | + | } | |||
| 422 | + | } | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | func TestHistoryLargeRowLimitDoesNotInventByteBudget(t *testing.T) { | |||
| 354 | history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1}) | 426 | history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1}) | ||
| 355 | - | if got, want := history.CellCap(), math.MaxInt; got != want { | 427 | + | if got := history.ByteCap(); got != 0 { |
| 356 | - | t.Fatalf("default cell cap = %d, want %d", got, want) | 428 | + | t.Fatalf("implicit byte capacity = %d, want no byte ceiling", got) |
| 429 | + | } | |||
| 430 | + | if got := history.Cap(); got != math.MaxInt { | |||
| 431 | + | t.Fatalf("row ceiling = %d", got) | |||
| 357 | } | 432 | } | ||
| 358 | } | 433 | } | ||
| 359 | 434 | ||||
A history_view_cache_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev-vt/core" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHistoryViewReusesUnchangedTail(t *testing.T) { | |||
| 11 | + | h := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 4}) | |||
| 12 | + | require.NoError(t, h.Append([]core.Cell{{Rune: 'a', Style: core.DefaultStyle()}}, LineBound{End: 1})) | |||
| 13 | + | first := h.View() | |||
| 14 | + | require.Same(t, first.chunks[0], h.View().chunks[0]) | |||
| 15 | + | require.LessOrEqual(t, testing.AllocsPerRun(20, func() { _ = h.View() }), float64(1)) | |||
| 16 | + | require.Same(t, first.chunks[0], h.SealAndView().chunks[0]) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestHistoryTailCacheInvalidationPreservesRetainedViews(t *testing.T) { | |||
| 20 | + | for _, tt := range []struct { | |||
| 21 | + | name string | |||
| 22 | + | mutate func(*History) error | |||
| 23 | + | want int | |||
| 24 | + | }{ | |||
| 25 | + | {"append", func(h *History) error { | |||
| 26 | + | return h.Append([]core.Cell{{Rune: 'c', Style: core.DefaultStyle()}}, LineBound{End: 1}) | |||
| 27 | + | }, 3}, | |||
| 28 | + | {"byte eviction", func(h *History) error { | |||
| 29 | + | return h.SetLimits(HistoryConfig{MaxBytes: core.StoredCellLogicalBytes + core.RowDescriptorLogicalBytes + core.StyleRecordLogicalBytes, ChunkRows: 4}) | |||
| 30 | + | }, 1}, | |||
| 31 | + | {"disable", func(h *History) error { return h.SetLimits(HistoryConfig{}) }, 0}, | |||
| 32 | + | {"seal and row eviction", func(h *History) error { return h.SetLimits(HistoryConfig{MaxRows: 1, ChunkRows: 1}) }, 1}, | |||
| 33 | + | } { | |||
| 34 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 35 | + | h := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 4}) | |||
| 36 | + | for _, r := range "ab" { | |||
| 37 | + | require.NoError(t, h.Append([]core.Cell{{Rune: r, Style: core.DefaultStyle()}}, LineBound{End: 1})) | |||
| 38 | + | } | |||
| 39 | + | before := h.View() | |||
| 40 | + | snapshot := h.SnapshotView() | |||
| 41 | + | require.NoError(t, tt.mutate(h)) | |||
| 42 | + | require.Equal(t, tt.want, h.View().Len()) | |||
| 43 | + | require.Equal(t, 2, before.Len()) | |||
| 44 | + | require.Equal(t, 'a', before.Cell(0, 0).Rune) | |||
| 45 | + | require.Equal(t, 'b', before.Cell(0, 1).Rune) | |||
| 46 | + | require.Equal(t, 'a', snapshot.Tail().Cell(0, 0).Rune) | |||
| 47 | + | require.Equal(t, 'b', snapshot.Tail().Cell(0, 1).Rune) | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 50 | + | } | |||
D history_vth3_golden_external_test.go +0 -150
| @@ -1,150 +0,0 @@ | |||||
| 1 | - | package vt_test | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "encoding/binary" | |||
| 6 | - | "encoding/hex" | |||
| 7 | - | "testing" | |||
| 8 | - | ||||
| 9 | - | vt "github.com/bnema/vev-vt" | |||
| 10 | - | "github.com/bnema/vev-vt/core" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | func TestExternalVTH3GoldenVectors(t *testing.T) { | |||
| 14 | - | vectors := []struct { | |||
| 15 | - | name string | |||
| 16 | - | hex string | |||
| 17 | - | stats vt.DecodeStats | |||
| 18 | - | }{ | |||
| 19 | - | { | |||
| 20 | - | name: "empty", | |||
| 21 | - | hex: "565448310300000000000000000000002a", | |||
| 22 | - | stats: vt.DecodeStats{}, | |||
| 23 | - | }, | |||
| 24 | - | { | |||
| 25 | - | name: "plain and continuation cells", | |||
| 26 | - | hex: "565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff0000000000000200", | |||
| 27 | - | stats: vt.DecodeStats{Chunks: 1, Rows: 1, Cells: 2, Styles: 2, Bytes: 82}, | |||
| 28 | - | }, | |||
| 29 | - | { | |||
| 30 | - | name: "all style fields", | |||
| 31 | - | hex: "565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff70708090000000101", | |||
| 32 | - | stats: vt.DecodeStats{Chunks: 1, Rows: 1, Cells: 1, Styles: 1, Bytes: 41}, | |||
| 33 | - | }, | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | for _, vector := range vectors { | |||
| 37 | - | t.Run(vector.name, func(t *testing.T) { | |||
| 38 | - | data, err := hex.DecodeString(vector.hex) | |||
| 39 | - | if err != nil { | |||
| 40 | - | t.Fatal(err) | |||
| 41 | - | } | |||
| 42 | - | stats, err := vt.PreflightHistoryBlob(data) | |||
| 43 | - | if err != nil { | |||
| 44 | - | t.Fatalf("PreflightHistoryBlob: %v", err) | |||
| 45 | - | } | |||
| 46 | - | if stats != vector.stats { | |||
| 47 | - | t.Fatalf("decode stats = %#v, want %#v", stats, vector.stats) | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | view, err := vt.UnmarshalHistory(data) | |||
| 51 | - | if err != nil { | |||
| 52 | - | t.Fatalf("UnmarshalHistory: %v", err) | |||
| 53 | - | } | |||
| 54 | - | reencoded, err := vt.MarshalHistory(view) | |||
| 55 | - | if err != nil { | |||
| 56 | - | t.Fatalf("MarshalHistory: %v", err) | |||
| 57 | - | } | |||
| 58 | - | if !bytes.Equal(reencoded, data) { | |||
| 59 | - | t.Fatalf("canonical bytes changed:\n got %x\nwant %x", reencoded, data) | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | switch vector.name { | |||
| 63 | - | case "empty": | |||
| 64 | - | if view.Len() != 0 || view.ChunkCount() != 0 || view.NextRowID() != 42 { | |||
| 65 | - | t.Fatalf("empty view = len %d chunks %d next %d", view.Len(), view.ChunkCount(), view.NextRowID()) | |||
| 66 | - | } | |||
| 67 | - | case "plain and continuation cells": | |||
| 68 | - | if view.Len() != 1 || view.RowID(0) != 41 || view.Bound(0) != (vt.LineBound{End: 2}) || view.NextRowID() != 42 { | |||
| 69 | - | t.Fatalf("plain view = len %d row %d bound %#v next %d", view.Len(), view.RowID(0), view.Bound(0), view.NextRowID()) | |||
| 70 | - | } | |||
| 71 | - | row := view.Row(0) | |||
| 72 | - | if len(row) != 2 || row[0].Rune != 'A' || row[1].Rune != 0 || !row[1].Continuation { | |||
| 73 | - | t.Fatalf("plain row = %#v", row) | |||
| 74 | - | } | |||
| 75 | - | case "all style fields": | |||
| 76 | - | wantStyle := core.Style{ | |||
| 77 | - | Bold: true, | |||
| 78 | - | Italic: true, | |||
| 79 | - | Inverse: true, | |||
| 80 | - | Attrs: core.StyleAttrs(0x000f), | |||
| 81 | - | Foreground: -7, | |||
| 82 | - | Background: 257, | |||
| 83 | - | HasForegroundRGB: true, | |||
| 84 | - | ForegroundRGB: core.RGB{R: 1, G: 2, B: 3}, | |||
| 85 | - | HasBackgroundRGB: true, | |||
| 86 | - | BackgroundRGB: core.RGB{R: 4, G: 5, B: 6}, | |||
| 87 | - | UnderlineStyle: core.UnderlineDashed, | |||
| 88 | - | UnderlineColor: -9, | |||
| 89 | - | HasUnderlineColorRGB: true, | |||
| 90 | - | UnderlineColorRGB: core.RGB{R: 7, G: 8, B: 9}, | |||
| 91 | - | } | |||
| 92 | - | if view.Len() != 1 || view.RowID(0) != 42 || view.Bound(0) != (vt.LineBound{End: 1, Soft: true}) || view.NextRowID() != 43 { | |||
| 93 | - | t.Fatalf("styled view = len %d row %d bound %#v next %d", view.Len(), view.RowID(0), view.Bound(0), view.NextRowID()) | |||
| 94 | - | } | |||
| 95 | - | row := view.Row(0) | |||
| 96 | - | if len(row) != 1 || row[0].Rune != '界' || !row[0].Style.Equal(wantStyle) { | |||
| 97 | - | t.Fatalf("styled row = %#v, want %#v", row, wantStyle) | |||
| 98 | - | } | |||
| 99 | - | } | |||
| 100 | - | }) | |||
| 101 | - | } | |||
| 102 | - | } | |||
| 103 | - | ||||
| 104 | - | func TestExternalVTH3RejectsMalformedVectors(t *testing.T) { | |||
| 105 | - | valid, err := hex.DecodeString("565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff0000000000000200") | |||
| 106 | - | if err != nil { | |||
| 107 | - | t.Fatal(err) | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | mutate := func(fn func([]byte)) []byte { | |||
| 111 | - | out := append([]byte(nil), valid...) | |||
| 112 | - | fn(out) | |||
| 113 | - | return out | |||
| 114 | - | } | |||
| 115 | - | const ( | |||
| 116 | - | rowIDOffset = 21 | |||
| 117 | - | cellOffset = 33 | |||
| 118 | - | boundOffset = 115 | |||
| 119 | - | ) | |||
| 120 | - | cases := []struct { | |||
| 121 | - | name string | |||
| 122 | - | data []byte | |||
| 123 | - | }{ | |||
| 124 | - | {name: "truncated", data: valid[:len(valid)-1]}, | |||
| 125 | - | {name: "trailing bytes", data: append(append([]byte(nil), valid...), 0)}, | |||
| 126 | - | {name: "bad magic", data: mutate(func(data []byte) { data[0] ^= 0xff })}, | |||
| 127 | - | {name: "bad version", data: mutate(func(data []byte) { data[4] = 2 })}, | |||
| 128 | - | {name: "zero next row ID", data: mutate(func(data []byte) { clear(data[9:17]) })}, | |||
| 129 | - | {name: "zero row ID", data: mutate(func(data []byte) { clear(data[rowIDOffset : rowIDOffset+8]) })}, | |||
| 130 | - | {name: "invalid rune", data: mutate(func(data []byte) { binary.BigEndian.PutUint32(data[cellOffset:], ^uint32(0)) })}, | |||
| 131 | - | {name: "invalid underline style", data: mutate(func(data []byte) { data[cellOffset+29] = byte(core.UnderlineDashed + 1) })}, | |||
| 132 | - | {name: "bound exceeds row", data: mutate(func(data []byte) { binary.BigEndian.PutUint32(data[boundOffset:], 3) })}, | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | for _, test := range cases { | |||
| 136 | - | t.Run(test.name, func(t *testing.T) { | |||
| 137 | - | if _, err := vt.PreflightHistoryBlob(test.data); err == nil { | |||
| 138 | - | t.Fatal("PreflightHistoryBlob accepted malformed data") | |||
| 139 | - | } | |||
| 140 | - | if _, err := vt.UnmarshalHistory(test.data); err == nil { | |||
| 141 | - | t.Fatal("UnmarshalHistory accepted malformed data") | |||
| 142 | - | } | |||
| 143 | - | }) | |||
| 144 | - | } | |||
| 145 | - | for length := 0; length < len(valid); length++ { | |||
| 146 | - | if _, err := vt.UnmarshalHistory(valid[:length]); err == nil { | |||
| 147 | - | t.Fatalf("accepted truncated prefix of length %d", length) | |||
| 148 | - | } | |||
| 149 | - | } | |||
| 150 | - | } | |||
M recovery_transcript.go +62 -78
| @@ -2,123 +2,107 @@ package vt | |||||
| 2 | 2 | ||||
| 3 | import renderer "github.com/bnema/vev-vt/core" | 3 | import renderer "github.com/bnema/vev-vt/core" | ||
| 4 | 4 | ||||
| 5 | - | // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport | 5 | + | // RecoveryTranscriptSnapshot owns compact viewport pages to replay after |
| 6 | - | // rows that should be replayed after retained terminal history during recovery. | 6 | + | // retained history. No semantic row slices are retained by the snapshot. |
| 7 | type RecoveryTranscriptSnapshot struct { | 7 | type RecoveryTranscriptSnapshot struct { | ||
| 8 | segments []recoveryTranscriptSegment | 8 | segments []recoveryTranscriptSegment | ||
| 9 | nextRowID RowID | 9 | nextRowID RowID | ||
| 10 | } | 10 | } | ||
| 11 | 11 | ||||
| 12 | type recoveryTranscriptSegment struct { | 12 | type recoveryTranscriptSegment struct { | ||
| 13 | - | rows [][]renderer.Cell | 13 | + | frame renderer.Frame |
| 14 | bounds []LineBound | 14 | bounds []LineBound | ||
| 15 | rowIDs []RowID | 15 | rowIDs []RowID | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | - | // RecoveryTranscriptSnapshot captures the active primary viewport, or the | 18 | + | // RecoveryTranscriptSnapshot captures the primary viewport, followed by the |
| 19 | - | // saved primary viewport followed by the active alternate viewport. | 19 | + | // alternate viewport when active. Untouched trailing rows are omitted and each |
| 20 | + | // viewport ends with a hard line, independently of physical row rotation. | |||
| 20 | func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot { | 21 | func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot { | ||
| 21 | if s == nil { | 22 | if s == nil { | ||
| 22 | return RecoveryTranscriptSnapshot{} | 23 | return RecoveryTranscriptSnapshot{} | ||
| 23 | } | 24 | } | ||
| 24 | - | ||||
| 25 | buffers := []*buffer{s.buffer} | 25 | buffers := []*buffer{s.buffer} | ||
| 26 | if s.alternate != nil { | 26 | if s.alternate != nil { | ||
| 27 | buffers = []*buffer{s.alternate.buffer, s.buffer} | 27 | buffers = []*buffer{s.alternate.buffer, s.buffer} | ||
| 28 | } | 28 | } | ||
| 29 | - | 29 | + | nextID := s.nextRowID | |
| 30 | - | nextRowID := s.nextRowID | 30 | + | if nextID < ^RowID(0) { |
| 31 | - | if nextRowID < ^RowID(0) { | 31 | + | nextID++ |
| 32 | - | nextRowID++ | |||
| 33 | } | 32 | } | ||
| 34 | - | snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID} | 33 | + | snapshot := RecoveryTranscriptSnapshot{nextRowID: nextID, segments: make([]recoveryTranscriptSegment, 0, len(buffers))} |
| 35 | for _, b := range buffers { | 34 | for _, b := range buffers { | ||
| 36 | - | segment := captureRecoveryTranscriptSegment(b) | 35 | + | if b == nil { |
| 37 | - | if len(segment.rows) > 0 { | 36 | + | continue |
| 38 | - | snapshot.segments = append(snapshot.segments, segment) | 37 | + | } |
| 38 | + | rows := b.frame.Height | |||
| 39 | + | for rows > 0 && recoveryTranscriptFrameRowUntouched(b.frame, rows-1, b.bound(rows-1)) { | |||
| 40 | + | rows-- | |||
| 41 | + | } | |||
| 42 | + | if rows == 0 { | |||
| 43 | + | continue | |||
| 44 | + | } | |||
| 45 | + | frame := renderer.NewFrame(b.frame.Width, rows) | |||
| 46 | + | for y := range rows { | |||
| 47 | + | for x := range frame.Width { | |||
| 48 | + | frame.Set(x, y, b.frame.Cell(x, y)) | |||
| 49 | + | } | |||
| 39 | } | 50 | } | ||
| 51 | + | bounds := append([]LineBound(nil), b.boundaries[:rows]...) | |||
| 52 | + | bounds[rows-1].Soft = false | |||
| 53 | + | snapshot.segments = append(snapshot.segments, recoveryTranscriptSegment{ | |||
| 54 | + | frame: frame, bounds: bounds, rowIDs: append([]RowID(nil), b.rowIDs[:rows]...), | |||
| 55 | + | }) | |||
| 40 | } | 56 | } | ||
| 41 | return snapshot | 57 | return snapshot | ||
| 42 | } | 58 | } | ||
| 43 | 59 | ||||
| 44 | - | func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment { | 60 | + | func recoveryTranscriptFrameRowUntouched(frame renderer.Frame, y int, bound LineBound) bool { |
| 45 | - | if b == nil { | |||
| 46 | - | return recoveryTranscriptSegment{} | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | rowCount := b.frame.Height | |||
| 50 | - | for rowCount > 0 && recoveryTranscriptRowUntouched(b.frame.Row(rowCount-1), b.bound(rowCount-1)) { | |||
| 51 | - | rowCount-- | |||
| 52 | - | } | |||
| 53 | - | if rowCount == 0 { | |||
| 54 | - | return recoveryTranscriptSegment{} | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | segment := recoveryTranscriptSegment{ | |||
| 58 | - | rows: make([][]renderer.Cell, rowCount), | |||
| 59 | - | bounds: append([]LineBound(nil), b.boundaries[:rowCount]...), | |||
| 60 | - | rowIDs: append([]RowID(nil), b.rowIDs[:rowCount]...), | |||
| 61 | - | } | |||
| 62 | - | cells := make([]renderer.Cell, rowCount*b.frame.Width) | |||
| 63 | - | for y := range rowCount { | |||
| 64 | - | start := y * b.frame.Width | |||
| 65 | - | end := start + b.frame.Width | |||
| 66 | - | segment.rows[y] = cells[start:end:end] | |||
| 67 | - | copy(segment.rows[y], b.frame.Row(y)) | |||
| 68 | - | } | |||
| 69 | - | segment.bounds[rowCount-1].Soft = false | |||
| 70 | - | return segment | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | func recoveryTranscriptRowUntouched(row []renderer.Cell, bound LineBound) bool { | |||
| 74 | if bound.End != 0 || bound.Soft { | 61 | if bound.End != 0 || bound.Soft { | ||
| 75 | return false | 62 | return false | ||
| 76 | } | 63 | } | ||
| 77 | blank := renderer.BlankCell() | 64 | blank := renderer.BlankCell() | ||
| 78 | - | for _, cell := range row { | 65 | + | for x := range frame.Width { |
| 79 | - | if !cell.Equal(blank) { | 66 | + | if !frame.Cell(x, y).Equal(blank) { |
| 80 | return false | 67 | return false | ||
| 81 | } | 68 | } | ||
| 82 | } | 69 | } | ||
| 83 | return true | 70 | return true | ||
| 84 | } | 71 | } | ||
| 85 | 72 | ||||
| 86 | - | // Marshal encodes the captured rows as canonical terminal history without | 73 | + | // Marshal encodes the compact capture without consulting or mutating live state. |
| 87 | - | // consulting or mutating live Screen history. | |||
| 88 | func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | 74 | func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | ||
| 89 | - | rows := 0 | 75 | + | view := HistoryView{nextRowID: snapshot.nextRowID} |
| 90 | - | cells := 0 | |||
| 91 | for _, segment := range snapshot.segments { | 76 | for _, segment := range snapshot.segments { | ||
| 92 | - | rows += len(segment.rows) | 77 | + | for start := 0; start < segment.frame.Height; { |
| 93 | - | for _, row := range segment.rows { | 78 | + | count := min(segment.frame.Height-start, maxHistoryChunkRows, maxHistorySlabRows(segment.frame.Width)) |
| 94 | - | cells += len(row) | 79 | + | frame := renderer.NewFrame(segment.frame.Width, count) |
| 95 | - | } | 80 | + | lastStyleRow := make(map[renderer.Style]int) |
| 96 | - | } | 81 | + | for y := range count { |
| 97 | - | 82 | + | for x := range frame.Width { | |
| 98 | - | view := HistoryView{rows: rows, cells: cells, nextRowID: snapshot.nextRowID} | 83 | + | cell := segment.frame.Cell(x, start+y) |
| 99 | - | for _, segment := range snapshot.segments { | 84 | + | frame.Set(x, y, cell) |
| 100 | - | for start := 0; start < len(segment.rows); { | 85 | + | style := cell.Style.Canonical() |
| 101 | - | space := maxHistoryChunkRows | 86 | + | if style != renderer.DefaultStyle() { |
| 102 | - | if len(view.chunks) > 0 { | 87 | + | lastStyleRow[style] = y |
| 103 | - | last := view.chunks[len(view.chunks)-1] | 88 | + | } |
| 104 | - | if len(last.rows) < maxHistoryChunkRows { | |||
| 105 | - | space = maxHistoryChunkRows - len(last.rows) | |||
| 106 | - | end := min(start+space, len(segment.rows)) | |||
| 107 | - | last.rows = append(last.rows, segment.rows[start:end]...) | |||
| 108 | - | last.bounds = append(last.bounds, segment.bounds[start:end]...) | |||
| 109 | - | last.rowIDs = append(last.rowIDs, segment.rowIDs[start:end]...) | |||
| 110 | - | start = end | |||
| 111 | - | continue | |||
| 112 | } | 89 | } | ||
| 113 | } | 90 | } | ||
| 114 | - | 91 | + | drops := make([]uint64, count) | |
| 115 | - | end := min(start+space, len(segment.rows)) | 92 | + | for _, y := range lastStyleRow { |
| 116 | - | view.chunks = append(view.chunks, &HistoryChunk{ | 93 | + | drops[y]++ |
| 117 | - | rows: append([][]renderer.Cell(nil), segment.rows[start:end]...), | 94 | + | } |
| 118 | - | bounds: append([]LineBound(nil), segment.bounds[start:end]...), | 95 | + | chunk := &HistoryChunk{ |
| 119 | - | rowIDs: append([]RowID(nil), segment.rowIDs[start:end]...), | 96 | + | page: newSealedPage(frame), count: count, width: frame.Width, |
| 120 | - | }) | 97 | + | bounds: segment.bounds[start : start+count], rowIDs: segment.rowIDs[start : start+count], |
| 121 | - | start = end | 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 | return MarshalHistory(view) | 108 | return MarshalHistory(view) | ||
M recovery_transcript_test.go +30 -30
| @@ -17,7 +17,7 @@ func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) { | |||||
| 17 | s.Write([]byte("keep")) | 17 | s.Write([]byte("keep")) | ||
| 18 | 18 | ||||
| 19 | snapshot := s.RecoveryTranscriptSnapshot() | 19 | snapshot := s.RecoveryTranscriptSnapshot() | ||
| 20 | - | s.Frame.Set(0, 0, renderer.BlankCell()) | 20 | + | s.frame.Set(0, 0, renderer.BlankCell()) |
| 21 | s.buffer.boundaries[0] = LineBound{} | 21 | s.buffer.boundaries[0] = LineBound{} | ||
| 22 | 22 | ||||
| 23 | view := decodeRecoveryTranscript(t, snapshot) | 23 | view := decodeRecoveryTranscript(t, snapshot) | ||
| @@ -44,7 +44,7 @@ func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *test | |||||
| 44 | newRetainedScreen := func(height int) *Screen { | 44 | newRetainedScreen := func(height int) *Screen { | ||
| 45 | screen := NewScreen(4, height) | 45 | screen := NewScreen(4, height) | ||
| 46 | for y := range height { | 46 | for y := range height { | ||
| 47 | - | screen.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | 47 | + | screen.frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) |
| 48 | screen.buffer.boundaries[y] = LineBound{End: 1} | 48 | screen.buffer.boundaries[y] = LineBound{End: 1} | ||
| 49 | } | 49 | } | ||
| 50 | return screen | 50 | return screen | ||
| @@ -73,7 +73,7 @@ func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSea | |||||
| 73 | s.buffer.boundaries[0].Soft = true | 73 | s.buffer.boundaries[0].Soft = true | ||
| 74 | 74 | ||||
| 75 | snapshot := s.RecoveryTranscriptSnapshot() | 75 | snapshot := s.RecoveryTranscriptSnapshot() | ||
| 76 | - | s.Frame.Set(0, 0, renderer.BlankCell()) | 76 | + | s.frame.Set(0, 0, renderer.BlankCell()) |
| 77 | s.buffer.boundaries[0] = LineBound{} | 77 | s.buffer.boundaries[0] = LineBound{} | ||
| 78 | s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell()) | 78 | s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell()) | ||
| 79 | s.alternate.buffer.boundaries[0] = LineBound{} | 79 | s.alternate.buffer.boundaries[0] = LineBound{} | ||
| @@ -147,9 +147,9 @@ func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T) | |||||
| 147 | for _, tt := range tests { | 147 | for _, tt := range tests { | ||
| 148 | t.Run(tt.name, func(t *testing.T) { | 148 | t.Run(tt.name, func(t *testing.T) { | ||
| 149 | s := NewScreen(3, 3) | 149 | s := NewScreen(3, 3) | ||
| 150 | - | s.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | 150 | + | s.frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) |
| 151 | s.buffer.boundaries[0] = LineBound{End: 1} | 151 | s.buffer.boundaries[0] = LineBound{End: 1} | ||
| 152 | - | s.Frame.Set(0, 1, tt.candidateCell) | 152 | + | s.frame.Set(0, 1, tt.candidateCell) |
| 153 | s.buffer.boundaries[1] = tt.candidateBound | 153 | s.buffer.boundaries[1] = tt.candidateBound | ||
| 154 | 154 | ||||
| 155 | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | 155 | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | ||
| @@ -165,7 +165,7 @@ func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T) | |||||
| 165 | 165 | ||||
| 166 | func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) { | 166 | func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) { | ||
| 167 | s := NewScreen(3, 4) | 167 | s := NewScreen(3, 4) | ||
| 168 | - | s.Frame.Set(0, 2, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | 168 | + | s.frame.Set(0, 2, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) |
| 169 | s.buffer.boundaries[2] = LineBound{End: 1} | 169 | s.buffer.boundaries[2] = LineBound{End: 1} | ||
| 170 | 170 | ||||
| 171 | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | 171 | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | ||
| @@ -200,8 +200,8 @@ func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) { | |||||
| 200 | 200 | ||||
| 201 | func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) { | 201 | func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) { | ||
| 202 | s := NewScreen(1, 257) | 202 | s := NewScreen(1, 257) | ||
| 203 | - | for y := range s.Frame.Height { | 203 | + | for y := range s.frame.Height { |
| 204 | - | s.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | 204 | + | s.frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) |
| 205 | s.buffer.boundaries[y] = LineBound{End: 1, Soft: true} | 205 | s.buffer.boundaries[y] = LineBound{End: 1, Soft: true} | ||
| 206 | } | 206 | } | ||
| 207 | 207 | ||||
| @@ -209,8 +209,8 @@ func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T | |||||
| 209 | 209 | ||||
| 210 | require.Equal(t, 257, view.Len()) | 210 | require.Equal(t, 257, view.Len()) | ||
| 211 | require.Equal(t, 2, view.ChunkCount()) | 211 | require.Equal(t, 2, view.ChunkCount()) | ||
| 212 | - | require.Len(t, view.Chunk(0).rows, 256) | 212 | + | require.Equal(t, 256, view.Chunk(0).len()) |
| 213 | - | require.Len(t, view.Chunk(1).rows, 1) | 213 | + | require.Equal(t, 1, view.Chunk(1).len()) |
| 214 | require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam") | 214 | require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam") | ||
| 215 | require.False(t, view.Bound(256).Soft, "the transcript's final seam must be hard") | 215 | require.False(t, view.Bound(256).Soft, "the transcript's final seam must be hard") | ||
| 216 | } | 216 | } | ||
| @@ -218,11 +218,11 @@ func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T | |||||
| 218 | func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) { | 218 | func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) { | ||
| 219 | s := NewScreen(4, 2) | 219 | s := NewScreen(4, 2) | ||
| 220 | s.Write([]byte("界")) | 220 | s.Write([]byte("界")) | ||
| 221 | - | want := append([]renderer.Cell(nil), s.Frame.Row(0)...) | 221 | + | want := append([]renderer.Cell(nil), s.frame.Row(0)...) |
| 222 | 222 | ||||
| 223 | snapshot := s.RecoveryTranscriptSnapshot() | 223 | snapshot := s.RecoveryTranscriptSnapshot() | ||
| 224 | - | for x := range s.Frame.Width { | 224 | + | for x := range s.frame.Width { |
| 225 | - | s.Frame.Set(x, 0, renderer.BlankCell()) | 225 | + | s.frame.Set(x, 0, renderer.BlankCell()) |
| 226 | } | 226 | } | ||
| 227 | 227 | ||||
| 228 | view := decodeRecoveryTranscript(t, snapshot) | 228 | view := decodeRecoveryTranscript(t, snapshot) | ||
| @@ -238,16 +238,16 @@ func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBl | |||||
| 238 | []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}}, | 238 | []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}}, | ||
| 239 | ) | 239 | ) | ||
| 240 | 240 | ||||
| 241 | - | screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxCells: 128, ChunkRows: 2}, sealed, tail, transcript) | 241 | + | screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxBytes: 1 << 20, ChunkRows: 2}, sealed, tail, transcript) |
| 242 | require.NoError(t, err) | 242 | require.NoError(t, err) | ||
| 243 | 243 | ||||
| 244 | view := screen.History().View() | 244 | view := screen.History().View() | ||
| 245 | require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view)) | 245 | require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view)) | ||
| 246 | require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3)) | 246 | require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3)) | ||
| 247 | require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam") | 247 | require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam") | ||
| 248 | - | for y := range screen.Frame.Height { | 248 | + | for y := range screen.frame.Height { |
| 249 | - | for x := range screen.Frame.Width { | 249 | + | for x := range screen.frame.Width { |
| 250 | - | require.True(t, screen.Frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y) | 250 | + | require.True(t, screen.frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y) |
| 251 | } | 251 | } | ||
| 252 | } | 252 | } | ||
| 253 | require.Equal(t, []LineBound{{}, {}}, screen.LineBounds()) | 253 | require.Equal(t, []LineBound{{}, {}}, screen.LineBounds()) | ||
| @@ -263,14 +263,14 @@ func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing. | |||||
| 263 | }{ | 263 | }{ | ||
| 264 | { | 264 | { | ||
| 265 | name: "row budget", | 265 | name: "row budget", | ||
| 266 | - | config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2}, | 266 | + | config: HistoryConfig{MaxRows: 3, MaxBytes: 1 << 20, ChunkRows: 2}, |
| 267 | history: []string{"old-1", "old-2", "old-3"}, | 267 | history: []string{"old-1", "old-2", "old-3"}, | ||
| 268 | transcript: []string{"new-1", "new-2"}, | 268 | transcript: []string{"new-1", "new-2"}, | ||
| 269 | want: []string{"old-3", "new-1", "new-2"}, | 269 | want: []string{"old-3", "new-1", "new-2"}, | ||
| 270 | }, | 270 | }, | ||
| 271 | { | 271 | { | ||
| 272 | - | name: "cell budget", | 272 | + | name: "byte budget", |
| 273 | - | config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4}, | 273 | + | config: HistoryConfig{MaxRows: 10, MaxBytes: 3*renderer.StoredCellLogicalBytes + 2*renderer.RowDescriptorLogicalBytes + 2*renderer.StyleRecordLogicalBytes, ChunkRows: 4}, |
| 274 | history: []string{"aa", "b", "ccc"}, | 274 | history: []string{"aa", "b", "ccc"}, | ||
| 275 | transcript: []string{"d", "ee"}, | 275 | transcript: []string{"d", "ee"}, | ||
| 276 | want: []string{"d", "ee"}, | 276 | want: []string{"d", "ee"}, | ||
| @@ -293,9 +293,9 @@ func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing | |||||
| 293 | sealed, tail := recoveryHistoryBlobs(t, []string{"ok"}) | 293 | sealed, tail := recoveryHistoryBlobs(t, []string{"ok"}) | ||
| 294 | transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil) | 294 | transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil) | ||
| 295 | 295 | ||||
| 296 | - | screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxCells: 3}, sealed, tail, transcript) | 296 | + | screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxBytes: 83}, sealed, tail, transcript) |
| 297 | 297 | ||||
| 298 | - | require.ErrorIs(t, err, ErrHistoryRowTooWide) | 298 | + | require.ErrorIs(t, err, ErrHistoryRowTooLarge) |
| 299 | require.Nil(t, screen) | 299 | require.Nil(t, screen) | ||
| 300 | } | 300 | } | ||
| 301 | 301 | ||||
| @@ -331,11 +331,11 @@ func BenchmarkRecoveryTranscriptSnapshot(b *testing.B) { | |||||
| 331 | } | 331 | } | ||
| 332 | 332 | ||||
| 333 | func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) { | 333 | func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) { | ||
| 334 | - | for y := range screen.Frame.Height { | 334 | + | for y := range screen.frame.Height { |
| 335 | - | for x := range screen.Frame.Width { | 335 | + | for x := range screen.frame.Width { |
| 336 | - | screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()}) | 336 | + | screen.frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()}) |
| 337 | } | 337 | } | ||
| 338 | - | screen.buffer.boundaries[y] = LineBound{End: screen.Frame.Width} | 338 | + | screen.buffer.boundaries[y] = LineBound{End: screen.frame.Width} |
| 339 | } | 339 | } | ||
| 340 | } | 340 | } | ||
| 341 | 341 | ||||
| @@ -343,14 +343,14 @@ func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) { | |||||
| 343 | const rows = maxHistoryRows | 343 | const rows = maxHistoryRows | ||
| 344 | source := NewScreen(1, rows) | 344 | source := NewScreen(1, rows) | ||
| 345 | for y := range rows { | 345 | for y := range rows { | ||
| 346 | - | source.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | 346 | + | source.frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) |
| 347 | source.buffer.boundaries[y] = LineBound{End: 1} | 347 | source.buffer.boundaries[y] = LineBound{End: 1} | ||
| 348 | } | 348 | } | ||
| 349 | transcript, err := source.RecoveryTranscriptSnapshot().Marshal() | 349 | transcript, err := source.RecoveryTranscriptSnapshot().Marshal() | ||
| 350 | require.NoError(b, err) | 350 | require.NoError(b, err) | ||
| 351 | tail, err := MarshalEmptyHistoryTail() | 351 | tail, err := MarshalEmptyHistoryTail() | ||
| 352 | require.NoError(b, err) | 352 | require.NoError(b, err) | ||
| 353 | - | config := HistoryConfig{MaxRows: rows, MaxCells: rows, ChunkRows: maxHistoryChunkRows} | 353 | + | config := HistoryConfig{MaxRows: rows, MaxBytes: 1 << 30, ChunkRows: maxHistoryChunkRows} |
| 354 | 354 | ||||
| 355 | b.ReportAllocs() | 355 | b.ReportAllocs() | ||
| 356 | b.ReportMetric(float64(rows), "rows/restore") | 356 | b.ReportMetric(float64(rows), "rows/restore") | ||
| @@ -366,7 +366,7 @@ func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) { | |||||
| 366 | 366 | ||||
| 367 | func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) { | 367 | func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) { | ||
| 368 | t.Helper() | 368 | t.Helper() | ||
| 369 | - | history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2}) | 369 | + | history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxBytes: 1 << 20, ChunkRows: 2}) |
| 370 | for _, text := range texts { | 370 | for _, text := range texts { | ||
| 371 | require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)})) | 371 | require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)})) | ||
| 372 | } | 372 | } | ||
| @@ -402,7 +402,7 @@ func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) [] | |||||
| 402 | } | 402 | } | ||
| 403 | view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))} | 403 | view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))} | ||
| 404 | if len(rows) > 0 { | 404 | if len(rows) > 0 { | ||
| 405 | - | view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds, rowIDs: rowIDs}} | 405 | + | view.chunks = newHistoryChunks(rows, bounds, rowIDs) |
| 406 | } | 406 | } | ||
| 407 | blob, err := MarshalHistory(view) | 407 | blob, err := MarshalHistory(view) | ||
| 408 | require.NoError(t, err) | 408 | require.NoError(t, err) | ||
M region_reflow_test.go +3 -3
| @@ -42,9 +42,9 @@ func TestResizeSeversSoftWrapsAtRegionalScrollEdges(t *testing.T) { | |||||
| 42 | 42 | ||||
| 43 | s.Resize(3, 6) | 43 | s.Resize(3, 6) | ||
| 44 | 44 | ||||
| 45 | - | got := make([]string, s.Frame.Height) | 45 | + | got := make([]string, s.frame.Height) |
| 46 | - | for y := range s.Frame.Height { | 46 | + | for y := range s.frame.Height { |
| 47 | - | got[y] = rowString(s.Frame.Row(y)) | 47 | + | got[y] = rowString(s.frame.Row(y)) |
| 48 | } | 48 | } | ||
| 49 | require.Equal(t, tt.want, got) | 49 | require.Equal(t, tt.want, got) | ||
| 50 | }) | 50 | }) | ||
M resize_reflow_regression_test.go +4 -4
| @@ -13,12 +13,12 @@ func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) { | |||||
| 13 | if len(s.escapeBuf) != 0 { | 13 | if len(s.escapeBuf) != 0 { | ||
| 14 | t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf) | 14 | t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf) | ||
| 15 | } | 15 | } | ||
| 16 | - | if s.Frame.Width != 0 || s.Frame.Height != 0 { | 16 | + | if s.frame.Width != 0 || s.frame.Height != 0 { |
| 17 | - | t.Fatalf("active frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height) | 17 | + | t.Fatalf("active frame = %dx%d, want 0x0", s.frame.Width, s.frame.Height) |
| 18 | } | 18 | } | ||
| 19 | s.Write([]byte("\x1b[?1049l")) | 19 | s.Write([]byte("\x1b[?1049l")) | ||
| 20 | - | if s.Frame.Width != 0 || s.Frame.Height != 0 { | 20 | + | if s.frame.Width != 0 || s.frame.Height != 0 { |
| 21 | - | t.Fatalf("saved primary frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height) | 21 | + | t.Fatalf("saved primary frame = %dx%d, want 0x0", s.frame.Width, s.frame.Height) |
| 22 | } | 22 | } | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
M row_id_persistence_test.go +10 -9
| @@ -15,16 +15,17 @@ func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) { | |||||
| 15 | encoded, err := MarshalHistory(history.SealAndView()) | 15 | encoded, err := MarshalHistory(history.SealAndView()) | ||
| 16 | require.NoError(t, err) | 16 | require.NoError(t, err) | ||
| 17 | 17 | ||||
| 18 | - | firstIDOffset := 17 + 4 | 18 | + | styles := int(binary.BigEndian.Uint32(encoded[24:28])) |
| 19 | - | secondIDOffset := firstIDOffset + 8 + 4 + historyCellBytes + historyBoundBytes | 19 | + | firstIDOffset := historyHeaderBytes + historyChunkHeaderBytes + (styles-1)*historyStyleBytes |
| 20 | + | secondIDOffset := firstIDOffset + historyRowBytes | |||
| 20 | for _, test := range []struct { | 21 | for _, test := range []struct { | ||
| 21 | name string | 22 | name string | ||
| 22 | mutate func([]byte) | 23 | mutate func([]byte) | ||
| 23 | }{ | 24 | }{ | ||
| 24 | {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }}, | 25 | {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }}, | ||
| 25 | {name: "duplicate row ID", mutate: func(data []byte) { copy(data[secondIDOffset:secondIDOffset+8], data[firstIDOffset:firstIDOffset+8]) }}, | 26 | {name: "duplicate row ID", mutate: func(data []byte) { copy(data[secondIDOffset:secondIDOffset+8], data[firstIDOffset:firstIDOffset+8]) }}, | ||
| 26 | - | {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], 2) }}, | 27 | + | {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[8:16], 2) }}, |
| 27 | - | {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], ^uint64(0)) }}, | 28 | + | {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[8:16], ^uint64(0)) }}, |
| 28 | } { | 29 | } { | ||
| 29 | t.Run(test.name, func(t *testing.T) { | 30 | t.Run(test.name, func(t *testing.T) { | ||
| 30 | data := append([]byte(nil), encoded...) | 31 | data := append([]byte(nil), encoded...) | ||
| @@ -135,7 +136,7 @@ func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) { | |||||
| 135 | allocate() | 136 | allocate() | ||
| 136 | row := historyRow("x") | 137 | row := historyRow("x") | ||
| 137 | screen.buffer.rowIDs[0] = test.want | 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 | screen.buffer.boundaries[0] = LineBound{End: 1} | 140 | screen.buffer.boundaries[0] = LineBound{End: 1} | ||
| 140 | transcript, err := screen.RecoveryTranscriptSnapshot().Marshal() | 141 | transcript, err := screen.RecoveryTranscriptSnapshot().Marshal() | ||
| 141 | require.NoError(t, err) | 142 | require.NoError(t, err) | ||
| @@ -154,7 +155,7 @@ func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) { | |||||
| 154 | 155 | ||||
| 155 | func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) { | 156 | func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) { | ||
| 156 | _, err := MarshalHistory(HistoryView{ | 157 | _, err := MarshalHistory(HistoryView{ | ||
| 157 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}}, | 158 | + | chunks: []*HistoryChunk{testHistoryChunk([][]renderer.Cell{historyRow("row")}, []LineBound{{End: 3}}, nil)}, |
| 158 | rows: 1, | 159 | rows: 1, | ||
| 159 | }) | 160 | }) | ||
| 160 | require.Error(t, err) | 161 | require.Error(t, err) | ||
| @@ -166,7 +167,7 @@ func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) { | |||||
| 166 | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | 167 | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | ||
| 167 | require.NoError(t, err) | 168 | require.NoError(t, err) | ||
| 168 | transcript, err := MarshalHistory(HistoryView{ | 169 | transcript, err := MarshalHistory(HistoryView{ | ||
| 169 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{50}}}, | 170 | + | chunks: []*HistoryChunk{testHistoryChunk([][]renderer.Cell{historyRow("live")}, []LineBound{{End: 4}}, []RowID{50})}, |
| 170 | rows: 1, | 171 | rows: 1, | ||
| 171 | nextRowID: 51, | 172 | nextRowID: 51, | ||
| 172 | }) | 173 | }) | ||
| @@ -186,7 +187,7 @@ func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) { | |||||
| 186 | 187 | ||||
| 187 | func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) { | 188 | func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) { | ||
| 188 | sealedView := HistoryView{ | 189 | sealedView := HistoryView{ | ||
| 189 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("old")}, bounds: []LineBound{{End: 3}}, rowIDs: []RowID{7}}}, | 190 | + | chunks: []*HistoryChunk{testHistoryChunk([][]renderer.Cell{historyRow("old")}, []LineBound{{End: 3}}, []RowID{7})}, |
| 190 | rows: 1, | 191 | rows: 1, | ||
| 191 | nextRowID: 8, | 192 | nextRowID: 8, | ||
| 192 | } | 193 | } | ||
| @@ -195,7 +196,7 @@ func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *t | |||||
| 195 | tail, err := MarshalEmptyHistoryTail() | 196 | tail, err := MarshalEmptyHistoryTail() | ||
| 196 | require.NoError(t, err) | 197 | require.NoError(t, err) | ||
| 197 | transcript, err := MarshalHistory(HistoryView{ | 198 | transcript, err := MarshalHistory(HistoryView{ | ||
| 198 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{7}}}, | 199 | + | chunks: []*HistoryChunk{testHistoryChunk([][]renderer.Cell{historyRow("live")}, []LineBound{{End: 4}}, []RowID{7})}, |
| 199 | rows: 1, | 200 | rows: 1, | ||
| 200 | nextRowID: 8, | 201 | nextRowID: 8, | ||
| 201 | }) | 202 | }) | ||
M row_id_test.go +3 -3
| @@ -98,9 +98,9 @@ func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) { | |||||
| 98 | 98 | ||||
| 99 | func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) { | 99 | func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) { | ||
| 100 | b := newBuffer(8, 3) | 100 | b := newBuffer(8, 3) | ||
| 101 | - | copy(b.frame.Row(0), historyRow("abcdefgh")) | 101 | + | b.frame.WriteRow(0, 0, historyRow("abcdefgh")) |
| 102 | - | copy(b.frame.Row(1), historyRow("ijkl")) | 102 | + | b.frame.WriteRow(1, 0, historyRow("ijkl")) |
| 103 | - | copy(b.frame.Row(2), historyRow("mnop")) | 103 | + | b.frame.WriteRow(2, 0, historyRow("mnop")) |
| 104 | b.boundaries[0] = LineBound{End: 8, Soft: true} | 104 | b.boundaries[0] = LineBound{End: 8, Soft: true} | ||
| 105 | b.boundaries[1] = LineBound{End: 4} | 105 | b.boundaries[1] = LineBound{End: 4} | ||
| 106 | b.boundaries[2] = LineBound{End: 4} | 106 | b.boundaries[2] = LineBound{End: 4} | ||
M screen.go +38 -8
| @@ -26,7 +26,7 @@ const ( | |||||
| 26 | ) | 26 | ) | ||
| 27 | 27 | ||||
| 28 | type Screen struct { | 28 | type Screen struct { | ||
| 29 | - | Frame renderer.Frame | 29 | + | frame renderer.Frame |
| 30 | Row int | 30 | Row int | ||
| 31 | Col int | 31 | Col int | ||
| 32 | Style renderer.Style | 32 | Style renderer.Style | ||
| @@ -107,7 +107,7 @@ func NewScreen(width, height int) *Screen { | |||||
| 107 | geometry: Geometry{Cols: width, Rows: height}, | 107 | geometry: Geometry{Cols: width, Rows: height}, | ||
| 108 | } | 108 | } | ||
| 109 | s.buffer = s.newBuffer(width, height) | 109 | s.buffer = s.newBuffer(width, height) | ||
| 110 | - | s.Frame = s.buffer.frame | 110 | + | s.frame = s.buffer.frame |
| 111 | s.resetScrollRegion() | 111 | s.resetScrollRegion() | ||
| 112 | return s | 112 | return s | ||
| 113 | } | 113 | } | ||
| @@ -124,6 +124,36 @@ func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen { | |||||
| 124 | // configured with NewScreenWithHistory. | 124 | // configured with NewScreenWithHistory. | ||
| 125 | func (s *Screen) History() *History { return s.history } | 125 | func (s *Screen) History() *History { return s.history } | ||
| 126 | 126 | ||||
| 127 | + | // Columns returns the visible grid width. | |||
| 128 | + | func (s *Screen) Columns() int { | |||
| 129 | + | if s == nil || s.buffer == nil { | |||
| 130 | + | return 0 | |||
| 131 | + | } | |||
| 132 | + | return s.buffer.frame.Width | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // Rows returns the visible grid height. | |||
| 136 | + | func (s *Screen) Rows() int { | |||
| 137 | + | if s == nil || s.buffer == nil { | |||
| 138 | + | return 0 | |||
| 139 | + | } | |||
| 140 | + | return s.buffer.frame.Height | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // Cell returns the semantic cell at x, y. Coordinates must be inside the | |||
| 144 | + | // visible grid. | |||
| 145 | + | func (s *Screen) Cell(x, y int) renderer.Cell { | |||
| 146 | + | return s.buffer.frame.At(x, y) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // RowCells returns an owned copy of visible row y, or nil when y is out of range. | |||
| 150 | + | func (s *Screen) RowCells(y int) []renderer.Cell { | |||
| 151 | + | if s == nil || s.buffer == nil || y < 0 || y >= s.buffer.frame.Height { | |||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | return s.buffer.frame.Row(y) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 127 | // LineBounds returns an owned copy of the live grid's per-row logical extents, | 157 | // LineBounds returns an owned copy of the live grid's per-row logical extents, | ||
| 128 | // indexed like Frame rows. It returns nil when the screen has no buffer. | 158 | // indexed like Frame rows. It returns nil when the screen has no buffer. | ||
| 129 | func (s *Screen) LineBounds() []LineBound { | 159 | func (s *Screen) LineBounds() []LineBound { | ||
| @@ -440,10 +470,10 @@ func (s *Screen) kittyCursorX() uint64 { | |||||
| 440 | } | 470 | } | ||
| 441 | 471 | ||||
| 442 | func (s *Screen) kittyCursorColumn() int { | 472 | func (s *Screen) kittyCursorColumn() int { | ||
| 443 | - | if s.Frame.Width <= 0 { | 473 | + | if s.frame.Width <= 0 { |
| 444 | return 0 | 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 | func (s *Screen) kittyCursorY() uint64 { | 479 | func (s *Screen) kittyCursorY() uint64 { | ||
| @@ -467,12 +497,12 @@ func (s *Screen) kittyCellPixels() (width, height int, ok bool) { | |||||
| 467 | } | 497 | } | ||
| 468 | 498 | ||||
| 469 | func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) { | 499 | func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kittygraphics.Mutation) { | ||
| 470 | - | if c.HasCursor && c.Cursor == 1 || s.Frame.Width <= 0 || s.Frame.Height <= 0 { | 500 | + | if c.HasCursor && c.Cursor == 1 || s.frame.Width <= 0 || s.frame.Height <= 0 { |
| 471 | return | 501 | return | ||
| 472 | } | 502 | } | ||
| 473 | columns, rows := s.kittyPlacementGridSize(c, mutation) | 503 | columns, rows := s.kittyPlacementGridSize(c, mutation) | ||
| 474 | targetColumn := s.kittyCursorColumn() + columns | 504 | targetColumn := s.kittyCursorColumn() + columns | ||
| 475 | - | wraps := targetColumn >= s.Frame.Width | 505 | + | wraps := targetColumn >= s.frame.Width |
| 476 | rowMoves := max(rows-1, 0) | 506 | rowMoves := max(rows-1, 0) | ||
| 477 | if wraps { | 507 | if wraps { | ||
| 478 | rowMoves++ | 508 | rowMoves++ | ||
| @@ -486,7 +516,7 @@ func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls, mutation kit | |||||
| 486 | } | 516 | } | ||
| 487 | 517 | ||||
| 488 | func (s *Screen) advanceKittyCursorRows(rows int) { | 518 | func (s *Screen) advanceKittyCursorRows(rows int) { | ||
| 489 | - | if rows <= 0 || s.Frame.Height <= 0 { | 519 | + | if rows <= 0 || s.frame.Height <= 0 { |
| 490 | return | 520 | return | ||
| 491 | } | 521 | } | ||
| 492 | if s.Row >= s.scrollTop && s.Row <= s.scrollBottom { | 522 | if s.Row >= s.scrollTop && s.Row <= s.scrollBottom { | ||
| @@ -499,7 +529,7 @@ func (s *Screen) advanceKittyCursorRows(rows int) { | |||||
| 499 | s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll) | 529 | s.scrollUpRegion(s.scrollTop, s.scrollBottom, rows-beforeScroll) | ||
| 500 | return | 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 | func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) { | 535 | func (s *Screen) kittyPlacementGridSize(c kittygraphics.Controls, mutation kittygraphics.Mutation) (columns, rows int) { | ||
M screen_csi.go +29 -39
| @@ -122,9 +122,9 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||||
| 122 | case 'B': | 122 | case 'B': | ||
| 123 | s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow()) | 123 | s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow()) | ||
| 124 | case 'C': | 124 | case 'C': | ||
| 125 | - | s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1) | 125 | + | s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.frame.Width-1) |
| 126 | case 'D': | 126 | case 'D': | ||
| 127 | - | s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1) | 127 | + | s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.frame.Width-1) |
| 128 | case 'E': | 128 | case 'E': | ||
| 129 | s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow()) | 129 | s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow()) | ||
| 130 | s.Col = 0 | 130 | s.Col = 0 | ||
| @@ -133,7 +133,7 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||||
| 133 | s.Col = 0 | 133 | s.Col = 0 | ||
| 134 | case 'G': | 134 | case 'G': | ||
| 135 | col := firstPositive(parts, 1) | 135 | col := firstPositive(parts, 1) | ||
| 136 | - | s.Col = clamp(col-1, 0, s.Frame.Width-1) | 136 | + | s.Col = clamp(col-1, 0, s.frame.Width-1) |
| 137 | case 'd': | 137 | case 'd': | ||
| 138 | row := firstPositive(parts, 1) | 138 | row := firstPositive(parts, 1) | ||
| 139 | s.Row = s.addressedRow(row) | 139 | s.Row = s.addressedRow(row) | ||
| @@ -335,23 +335,18 @@ func sgrUnderlineStyle(param int) renderer.UnderlineStyle { | |||||
| 335 | } | 335 | } | ||
| 336 | 336 | ||||
| 337 | func (s *Screen) insertChars(n int) { | 337 | func (s *Screen) insertChars(n int) { | ||
| 338 | - | if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 { | 338 | + | if s.Row < 0 || s.Row >= s.frame.Height || s.Col >= s.frame.Width || n <= 0 { |
| 339 | return | 339 | return | ||
| 340 | } | 340 | } | ||
| 341 | - | w := s.Frame.Width | 341 | + | w := s.frame.Width |
| 342 | if n > w-s.Col { | 342 | if n > w-s.Col { | ||
| 343 | n = w - s.Col | 343 | n = w - s.Col | ||
| 344 | } | 344 | } | ||
| 345 | - | row := s.Frame.Row(s.Row) | |||
| 346 | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | 345 | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | ||
| 347 | // by the shift; its repair falls outside the default damage rect. | 346 | // by the shift; its repair falls outside the default damage rect. | ||
| 348 | - | leftSplit := s.Col > 0 && row[s.Col].Continuation | 347 | + | leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation |
| 349 | - | for x := w - 1; x >= s.Col+n; x-- { | 348 | + | s.frame.CopyRow(s.Row, s.Col+n, s.Col, w-s.Col-n) |
| 350 | - | row[x] = row[x-n] | 349 | + | s.frame.FillRow(s.Row, s.Col, s.Col+n, renderer.BlankCell()) |
| 351 | - | } | |||
| 352 | - | for x := s.Col; x < s.Col+n; x++ { | |||
| 353 | - | row[x] = renderer.BlankCell() | |||
| 354 | - | } | |||
| 355 | s.repairRow(s.Row) | 350 | s.repairRow(s.Row) | ||
| 356 | dmgX := s.Col | 351 | dmgX := s.Col | ||
| 357 | if leftSplit { | 352 | if leftSplit { | ||
| @@ -361,23 +356,18 @@ func (s *Screen) insertChars(n int) { | |||||
| 361 | } | 356 | } | ||
| 362 | 357 | ||||
| 363 | func (s *Screen) deleteChars(n int) { | 358 | func (s *Screen) deleteChars(n int) { | ||
| 364 | - | if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 { | 359 | + | if s.Row < 0 || s.Row >= s.frame.Height || s.Col >= s.frame.Width || n <= 0 { |
| 365 | return | 360 | return | ||
| 366 | } | 361 | } | ||
| 367 | - | w := s.Frame.Width | 362 | + | w := s.frame.Width |
| 368 | if n > w-s.Col { | 363 | if n > w-s.Col { | ||
| 369 | n = w - s.Col | 364 | n = w - s.Col | ||
| 370 | } | 365 | } | ||
| 371 | - | row := s.Frame.Row(s.Row) | |||
| 372 | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | 366 | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | ||
| 373 | // by the shift; its repair falls outside the default damage rect. | 367 | // by the shift; its repair falls outside the default damage rect. | ||
| 374 | - | leftSplit := s.Col > 0 && row[s.Col].Continuation | 368 | + | leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation |
| 375 | - | for x := s.Col; x < w-n; x++ { | 369 | + | s.frame.CopyRow(s.Row, s.Col, s.Col+n, w-s.Col-n) |
| 376 | - | row[x] = row[x+n] | 370 | + | s.frame.FillRow(s.Row, w-n, w, renderer.BlankCell()) |
| 377 | - | } | |||
| 378 | - | for x := w - n; x < w; x++ { | |||
| 379 | - | row[x] = renderer.BlankCell() | |||
| 380 | - | } | |||
| 381 | s.repairRow(s.Row) | 371 | s.repairRow(s.Row) | ||
| 382 | dmgX := s.Col | 372 | dmgX := s.Col | ||
| 383 | if leftSplit { | 373 | if leftSplit { | ||
| @@ -404,34 +394,34 @@ func (s *Screen) clearScreenMode(mode int) { | |||||
| 404 | s.clampCursor() | 394 | s.clampCursor() | ||
| 405 | switch mode { | 395 | switch mode { | ||
| 406 | case 1: | 396 | case 1: | ||
| 407 | - | for y := range min(s.Row+1, s.Frame.Height) { | 397 | + | for y := range min(s.Row+1, s.frame.Height) { |
| 408 | - | end := s.Frame.Width | 398 | + | end := s.frame.Width |
| 409 | if y == s.Row { | 399 | if y == s.Row { | ||
| 410 | - | end = min(s.Col+1, s.Frame.Width) | 400 | + | end = min(s.Col+1, s.frame.Width) |
| 411 | } | 401 | } | ||
| 412 | s.clearRow(y, 0, end) | 402 | s.clearRow(y, 0, end) | ||
| 413 | } | 403 | } | ||
| 414 | - | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: min(s.Row+1, s.Frame.Height), Count: 1}) | 404 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.frame.Width, Height: min(s.Row+1, s.frame.Height), Count: 1}) |
| 415 | case 2, 3: | 405 | case 2, 3: | ||
| 416 | blank := s.eraseCell() | 406 | blank := s.eraseCell() | ||
| 417 | - | for i := range s.Frame.Cells { | 407 | + | for y := range s.frame.Height { |
| 418 | - | s.Frame.Cells[i] = blank | 408 | + | s.frame.FillRow(y, 0, s.frame.Width, blank) |
| 419 | } | 409 | } | ||
| 420 | for y := range s.buffer.boundaries { | 410 | for y := range s.buffer.boundaries { | ||
| 421 | s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft} | 411 | s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft} | ||
| 422 | s.buffer.rowIDs[y] = s.nextRowIDValue() | 412 | s.buffer.rowIDs[y] = s.nextRowIDValue() | ||
| 423 | } | 413 | } | ||
| 424 | s.graphicsClearPlacements() | 414 | s.graphicsClearPlacements() | ||
| 425 | - | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1}) | 415 | + | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.frame.Width, Height: s.frame.Height, Count: 1}) |
| 426 | default: | 416 | default: | ||
| 427 | - | for y := s.Row; y < s.Frame.Height; y++ { | 417 | + | for y := s.Row; y < s.frame.Height; y++ { |
| 428 | start := 0 | 418 | start := 0 | ||
| 429 | if y == s.Row { | 419 | if y == s.Row { | ||
| 430 | start = s.Col | 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 | s.clampCursor() | 429 | s.clampCursor() | ||
| 440 | switch mode { | 430 | switch mode { | ||
| 441 | case 1: | 431 | case 1: | ||
| 442 | - | start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.Frame.Width)) | 432 | + | start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.frame.Width)) |
| 443 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1}) | 433 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1}) | ||
| 444 | case 2: | 434 | case 2: | ||
| 445 | - | start, width := s.clearRow(s.Row, 0, s.Frame.Width) | 435 | + | start, width := s.clearRow(s.Row, 0, s.frame.Width) |
| 446 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1}) | 436 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1}) | ||
| 447 | default: | 437 | default: | ||
| 448 | - | start, width := s.clearRow(s.Row, s.Col, s.Frame.Width) | 438 | + | start, width := s.clearRow(s.Row, s.Col, s.frame.Width) |
| 449 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1}) | 439 | s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1}) | ||
| 450 | } | 440 | } | ||
| 451 | } | 441 | } | ||
| @@ -455,7 +445,7 @@ func (s *Screen) eraseChars(n int) { | |||||
| 455 | return | 445 | return | ||
| 456 | } | 446 | } | ||
| 457 | s.clampCursor() | 447 | s.clampCursor() | ||
| 458 | - | if remaining := s.Frame.Width - s.Col; n > remaining { | 448 | + | if remaining := s.frame.Width - s.Col; n > remaining { |
| 459 | n = remaining | 449 | n = remaining | ||
| 460 | } | 450 | } | ||
| 461 | end := s.Col + n | 451 | end := s.Col + n | ||
| @@ -466,8 +456,8 @@ func (s *Screen) eraseChars(n int) { | |||||
| 466 | } | 456 | } | ||
| 467 | 457 | ||||
| 468 | func (s *Screen) clampCursor() { | 458 | func (s *Screen) clampCursor() { | ||
| 469 | - | s.Row = clamp(s.Row, 0, s.Frame.Height-1) | 459 | + | s.Row = clamp(s.Row, 0, s.frame.Height-1) |
| 470 | - | s.Col = clamp(s.Col, 0, s.Frame.Width-1) | 460 | + | s.Col = clamp(s.Col, 0, s.frame.Width-1) |
| 471 | } | 461 | } | ||
| 472 | 462 | ||||
| 473 | func (s *Screen) parseCSIInts(params string) []int { | 463 | func (s *Screen) parseCSIInts(params string) []int { | ||
M screen_csi_test.go +1 -1
| @@ -731,7 +731,7 @@ func TestBackgroundColorErase(t *testing.T) { | |||||
| 731 | for y := range tt.height { | 731 | for y := range tt.height { | ||
| 732 | for x := tt.startX; x < tt.width; x++ { | 732 | for x := tt.startX; x < tt.width; x++ { | ||
| 733 | cell := cellAt(s, x, y) | 733 | cell := cellAt(s, x, y) | ||
| 734 | - | if cell.Rune != ' ' || cell.Style != tt.wantStyle { | 734 | + | if cell.Rune != ' ' || !cell.Style.Equal(tt.wantStyle) { |
| 735 | t.Errorf("cell(%d,%d) = %+v, want space with style %+v", x, y, cell, tt.wantStyle) | 735 | t.Errorf("cell(%d,%d) = %+v, want space with style %+v", x, y, cell, tt.wantStyle) | ||
| 736 | } | 736 | } | ||
| 737 | } | 737 | } | ||
M screen_cursor.go +7 -7
| @@ -25,28 +25,28 @@ func (s *Screen) cursorReportRow() int { | |||||
| 25 | 25 | ||||
| 26 | func (s *Screen) cursorMinRow() int { | 26 | func (s *Screen) cursorMinRow() int { | ||
| 27 | if s.originMode { | 27 | if s.originMode { | ||
| 28 | - | return clamp(s.scrollTop, 0, s.Frame.Height-1) | 28 | + | return clamp(s.scrollTop, 0, s.frame.Height-1) |
| 29 | } | 29 | } | ||
| 30 | return 0 | 30 | return 0 | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | func (s *Screen) cursorMaxRow() int { | 33 | func (s *Screen) cursorMaxRow() int { | ||
| 34 | if s.originMode { | 34 | if s.originMode { | ||
| 35 | - | return clamp(s.scrollBottom, 0, s.Frame.Height-1) | 35 | + | return clamp(s.scrollBottom, 0, s.frame.Height-1) |
| 36 | } | 36 | } | ||
| 37 | - | return max(s.Frame.Height-1, 0) | 37 | + | return max(s.frame.Height-1, 0) |
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | func (s *Screen) addressedRow(row int) int { | 40 | func (s *Screen) addressedRow(row int) int { | ||
| 41 | if s.originMode { | 41 | if s.originMode { | ||
| 42 | return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow()) | 42 | return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow()) | ||
| 43 | } | 43 | } | ||
| 44 | - | return clamp(row-1, 0, s.Frame.Height-1) | 44 | + | return clamp(row-1, 0, s.frame.Height-1) |
| 45 | } | 45 | } | ||
| 46 | 46 | ||||
| 47 | func (s *Screen) addressCursor(row, col int) { | 47 | func (s *Screen) addressCursor(row, col int) { | ||
| 48 | s.Row = s.addressedRow(row) | 48 | s.Row = s.addressedRow(row) | ||
| 49 | - | s.Col = clamp(col-1, 0, s.Frame.Width-1) | 49 | + | s.Col = clamp(col-1, 0, s.frame.Width-1) |
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | func (s *Screen) homeCursor() { | 52 | func (s *Screen) homeCursor() { | ||
| @@ -81,8 +81,8 @@ func (s *Screen) restoreCursor() { | |||||
| 81 | if !s.savedCursor.saved { | 81 | if !s.savedCursor.saved { | ||
| 82 | return | 82 | return | ||
| 83 | } | 83 | } | ||
| 84 | - | s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1) | 84 | + | s.Row = clamp(s.savedCursor.row, 0, s.frame.Height-1) |
| 85 | - | s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1) | 85 | + | s.Col = clamp(s.savedCursor.col, 0, s.frame.Width-1) |
| 86 | s.Style = s.savedCursor.style | 86 | s.Style = s.savedCursor.style | ||
| 87 | s.originMode = s.savedCursor.originMode | 87 | s.originMode = s.savedCursor.originMode | ||
| 88 | s.insertMode = s.savedCursor.insertMode | 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 | func cellAt(s *Screen, x, y int) renderer.Cell { | 27 | func cellAt(s *Screen, x, y int) renderer.Cell { | ||
| 28 | - | return s.Frame.At(x, y) | 28 | + | return s.frame.At(x, y) |
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func testHistoryChunk(rows [][]renderer.Cell, bounds []LineBound, rowIDs []RowID) *HistoryChunk { | |||
| 32 | + | chunks := newHistoryChunks(rows, bounds, rowIDs) | |||
| 33 | + | if len(chunks) != 1 { | |||
| 34 | + | panic("test history chunk rows must have one width") | |||
| 35 | + | } | |||
| 36 | + | return chunks[0] | |||
| 29 | } | 37 | } | ||
| 30 | 38 | ||||
| 31 | func assertFramesEqual(t *testing.T, got, want renderer.Frame) { | 39 | func assertFramesEqual(t *testing.T, got, want renderer.Frame) { | ||
| @@ -70,8 +78,8 @@ func assertBlank(t *testing.T, s *Screen, x, y int) { | |||||
| 70 | 78 | ||||
| 71 | func assertNoOrphanWideCells(t *testing.T, s *Screen) { | 79 | func assertNoOrphanWideCells(t *testing.T, s *Screen) { | ||
| 72 | t.Helper() | 80 | t.Helper() | ||
| 73 | - | for y := range s.Frame.Height { | 81 | + | for y := range s.frame.Height { |
| 74 | - | for x := range s.Frame.Width { | 82 | + | for x := range s.frame.Width { |
| 75 | c := cellAt(s, x, y) | 83 | c := cellAt(s, x, y) | ||
| 76 | if c.Continuation { | 84 | if c.Continuation { | ||
| 77 | if x == 0 || cellAt(s, x-1, y).Continuation || renderer.RuneWidth(cellAt(s, x-1, y).Rune) != 2 { | 85 | if x == 0 || cellAt(s, x-1, y).Continuation || renderer.RuneWidth(cellAt(s, x-1, y).Rune) != 2 { | ||
| @@ -79,7 +87,7 @@ func assertNoOrphanWideCells(t *testing.T, s *Screen) { | |||||
| 79 | } | 87 | } | ||
| 80 | continue | 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 | t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune) | 91 | t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune) | ||
| 84 | } | 92 | } | ||
| 85 | } | 93 | } | ||
| @@ -87,9 +95,9 @@ func assertNoOrphanWideCells(t *testing.T, s *Screen) { | |||||
| 87 | } | 95 | } | ||
| 88 | 96 | ||||
| 89 | func lineText(s *Screen, y int) string { | 97 | func lineText(s *Screen, y int) string { | ||
| 90 | - | out := make([]rune, s.Frame.Width) | 98 | + | out := make([]rune, s.frame.Width) |
| 91 | - | for x := range s.Frame.Width { | 99 | + | for x := range s.frame.Width { |
| 92 | - | out[x] = s.Frame.At(x, y).Rune | 100 | + | out[x] = s.frame.At(x, y).Rune |
| 93 | } | 101 | } | ||
| 94 | return string(out) | 102 | return string(out) | ||
| 95 | } | 103 | } | ||
M screen_integration_test.go +9 -9
| @@ -11,13 +11,13 @@ func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) { | |||||
| 11 | r := renderer.New(renderer.Capabilities{}) | 11 | r := renderer.New(renderer.Capabilities{}) | ||
| 12 | 12 | ||||
| 13 | s.Write([]byte("❯ ")) | 13 | s.Write([]byte("❯ ")) | ||
| 14 | - | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | 14 | + | if _, err := r.Draw(s.frame, s.Damage()); err != nil { |
| 15 | t.Fatal(err) | 15 | t.Fatal(err) | ||
| 16 | } | 16 | } | ||
| 17 | s.ClearDamage() | 17 | s.ClearDamage() | ||
| 18 | 18 | ||||
| 19 | s.Write([]byte("h")) | 19 | s.Write([]byte("h")) | ||
| 20 | - | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | 20 | + | if _, err := r.Draw(s.frame, s.Damage()); err != nil { |
| 21 | t.Fatal(err) | 21 | t.Fatal(err) | ||
| 22 | } | 22 | } | ||
| 23 | s.ClearDamage() | 23 | s.ClearDamage() | ||
| @@ -25,16 +25,16 @@ func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) { | |||||
| 25 | s.Write([]byte("\x1b[31mh\x1b[39m")) | 25 | s.Write([]byte("\x1b[31mh\x1b[39m")) | ||
| 26 | s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m")) | 26 | s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m")) | ||
| 27 | 27 | ||||
| 28 | - | for y := range s.Frame.Height { | 28 | + | for y := range s.frame.Height { |
| 29 | - | for x := range s.Frame.Width { | 29 | + | for x := range s.frame.Width { |
| 30 | - | r := s.Frame.At(x, y).Rune | 30 | + | r := s.frame.At(x, y).Rune |
| 31 | if r == '\r' || r == '\n' { | 31 | if r == '\r' || r == '\n' { | ||
| 32 | t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r) | 32 | t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r) | ||
| 33 | } | 33 | } | ||
| 34 | } | 34 | } | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | - | out, err := r.Draw(s.Frame, s.Damage()) | 37 | + | out, err := r.Draw(s.frame, s.Damage()) |
| 38 | if err != nil { | 38 | if err != nil { | ||
| 39 | t.Fatal(err) | 39 | t.Fatal(err) | ||
| 40 | } | 40 | } | ||
| @@ -56,18 +56,18 @@ func TestIntegrationScenarios(t *testing.T) { | |||||
| 56 | s := NewScreen(8, 2) | 56 | s := NewScreen(8, 2) | ||
| 57 | r := renderer.New(renderer.Capabilities{}) | 57 | r := renderer.New(renderer.Capabilities{}) | ||
| 58 | s.Write([]byte("abcdef")) | 58 | s.Write([]byte("abcdef")) | ||
| 59 | - | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | 59 | + | if _, err := r.Draw(s.frame, s.Damage()); err != nil { |
| 60 | t.Fatal(err) | 60 | t.Fatal(err) | ||
| 61 | } | 61 | } | ||
| 62 | s.ClearDamage() | 62 | s.ClearDamage() | ||
| 63 | 63 | ||||
| 64 | s.Write([]byte("\x1b[1;3H\x1b[2@XY")) | 64 | s.Write([]byte("\x1b[1;3H\x1b[2@XY")) | ||
| 65 | - | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | 65 | + | if _, err := r.Draw(s.frame, s.Damage()); err != nil { |
| 66 | t.Fatal(err) | 66 | t.Fatal(err) | ||
| 67 | } | 67 | } | ||
| 68 | s.ClearDamage() | 68 | s.ClearDamage() | ||
| 69 | 69 | ||||
| 70 | - | out, err := r.Draw(s.Frame, nil) | 70 | + | out, err := r.Draw(s.frame, nil) |
| 71 | if err != nil { | 71 | if err != nil { | ||
| 72 | t.Fatal(err) | 72 | t.Fatal(err) | ||
| 73 | } | 73 | } | ||
M screen_modes.go +13 -19
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | type screenState struct { | 8 | type screenState struct { | ||
| 9 | - | frame renderer.Frame | |||
| 10 | buffer *buffer | 9 | buffer *buffer | ||
| 11 | row int | 10 | row int | ||
| 12 | col int | 11 | col int | ||
| @@ -92,8 +91,8 @@ func (s *Screen) colorSchemeReport() []byte { | |||||
| 92 | } | 91 | } | ||
| 93 | 92 | ||||
| 94 | func (s *Screen) reset() { | 93 | func (s *Screen) reset() { | ||
| 95 | - | s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height) | 94 | + | s.buffer = s.newBuffer(s.frame.Width, s.frame.Height) |
| 96 | - | s.Frame = s.buffer.frame | 95 | + | s.frame = s.buffer.frame |
| 97 | s.Row, s.Col = 0, 0 | 96 | s.Row, s.Col = 0, 0 | ||
| 98 | s.Style = renderer.DefaultStyle() | 97 | s.Style = renderer.DefaultStyle() | ||
| 99 | s.escapeBuf = s.escapeBuf[:0] | 98 | s.escapeBuf = s.escapeBuf[:0] | ||
| @@ -117,10 +116,10 @@ func (s *Screen) reset() { | |||||
| 117 | } | 116 | } | ||
| 118 | 117 | ||||
| 119 | func (s *Screen) setScrollRegion(parts []int) { | 118 | func (s *Screen) setScrollRegion(parts []int) { | ||
| 120 | - | if s.Frame.Height == 0 { | 119 | + | if s.frame.Height == 0 { |
| 121 | return | 120 | return | ||
| 122 | } | 121 | } | ||
| 123 | - | top, bottom := 1, s.Frame.Height | 122 | + | top, bottom := 1, s.frame.Height |
| 124 | if len(parts) > 0 && parts[0] > 0 { | 123 | if len(parts) > 0 && parts[0] > 0 { | ||
| 125 | top = parts[0] | 124 | top = parts[0] | ||
| 126 | } | 125 | } | ||
| @@ -130,8 +129,8 @@ func (s *Screen) setScrollRegion(parts []int) { | |||||
| 130 | if top >= bottom { | 129 | if top >= bottom { | ||
| 131 | s.resetScrollRegion() | 130 | s.resetScrollRegion() | ||
| 132 | } else { | 131 | } else { | ||
| 133 | - | s.scrollTop = clamp(top-1, 0, s.Frame.Height-1) | 132 | + | s.scrollTop = clamp(top-1, 0, s.frame.Height-1) |
| 134 | - | s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1) | 133 | + | s.scrollBottom = clamp(bottom-1, 0, s.frame.Height-1) |
| 135 | if s.scrollTop >= s.scrollBottom { | 134 | if s.scrollTop >= s.scrollBottom { | ||
| 136 | s.resetScrollRegion() | 135 | s.resetScrollRegion() | ||
| 137 | } | 136 | } | ||
| @@ -141,8 +140,8 @@ func (s *Screen) setScrollRegion(parts []int) { | |||||
| 141 | 140 | ||||
| 142 | func (s *Screen) resetScrollRegion() { | 141 | func (s *Screen) resetScrollRegion() { | ||
| 143 | s.scrollTop = 0 | 142 | s.scrollTop = 0 | ||
| 144 | - | if s.Frame.Height > 0 { | 143 | + | if s.frame.Height > 0 { |
| 145 | - | s.scrollBottom = s.Frame.Height - 1 | 144 | + | s.scrollBottom = s.frame.Height - 1 |
| 146 | } else { | 145 | } else { | ||
| 147 | s.scrollBottom = 0 | 146 | s.scrollBottom = 0 | ||
| 148 | } | 147 | } | ||
| @@ -200,7 +199,6 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||||
| 200 | func (s *Screen) enterAlternateScreen() { | 199 | func (s *Screen) enterAlternateScreen() { | ||
| 201 | if s.alternate == nil { | 200 | if s.alternate == nil { | ||
| 202 | s.alternate = &screenState{ | 201 | s.alternate = &screenState{ | ||
| 203 | - | frame: cloneFrame(s.Frame), | |||
| 204 | buffer: s.buffer.clone(), | 202 | buffer: s.buffer.clone(), | ||
| 205 | row: s.Row, | 203 | row: s.Row, | ||
| 206 | col: s.Col, | 204 | col: s.Col, | ||
| @@ -216,8 +214,8 @@ func (s *Screen) enterAlternateScreen() { | |||||
| 216 | } | 214 | } | ||
| 217 | s.graphics = nil | 215 | s.graphics = nil | ||
| 218 | s.kittyPendingDisplay = nil | 216 | s.kittyPendingDisplay = nil | ||
| 219 | - | s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height) | 217 | + | s.buffer = s.newBuffer(s.frame.Width, s.frame.Height) |
| 220 | - | s.Frame = s.buffer.frame | 218 | + | s.frame = s.buffer.frame |
| 221 | s.Row, s.Col = 0, 0 | 219 | s.Row, s.Col = 0, 0 | ||
| 222 | s.Style = renderer.DefaultStyle() | 220 | s.Style = renderer.DefaultStyle() | ||
| 223 | s.savedCursor = cursorState{} | 221 | s.savedCursor = cursorState{} | ||
| @@ -231,13 +229,9 @@ func (s *Screen) exitAlternateScreen() { | |||||
| 231 | } | 229 | } | ||
| 232 | state := s.alternate | 230 | state := s.alternate | ||
| 233 | s.buffer = state.buffer | 231 | s.buffer = state.buffer | ||
| 234 | - | if s.buffer == nil { | 232 | + | s.frame = s.buffer.frame |
| 235 | - | s.buffer = bufferFromFrame(cloneFrame(state.frame)) | 233 | + | s.Row = clamp(state.row, 0, s.frame.Height-1) |
| 236 | - | s.fillMissingRowIDs(s.buffer) | 234 | + | s.Col = clamp(state.col, 0, s.frame.Width-1) |
| 237 | - | } | |||
| 238 | - | s.Frame = s.buffer.frame | |||
| 239 | - | s.Row = clamp(state.row, 0, s.Frame.Height-1) | |||
| 240 | - | s.Col = clamp(state.col, 0, s.Frame.Width-1) | |||
| 241 | s.Style = state.style | 235 | s.Style = state.style | ||
| 242 | s.scrollTop = state.scrollTop | 236 | s.scrollTop = state.scrollTop | ||
| 243 | s.scrollBottom = state.scrollBottom | 237 | s.scrollBottom = state.scrollBottom | ||
M screen_osc_test.go +3 -3
| @@ -13,7 +13,7 @@ func TestOnBellFiresForLoneBEL(t *testing.T) { | |||||
| 13 | s.OnBell = func() { rang++ } | 13 | s.OnBell = func() { rang++ } | ||
| 14 | s.Write([]byte("hi\ahi\a")) | 14 | s.Write([]byte("hi\ahi\a")) | ||
| 15 | require.Equal(t, 2, rang) | 15 | require.Equal(t, 2, rang) | ||
| 16 | - | require.Equal(t, "hihi", strings.TrimRight(rowString(s.Frame.Row(0)), " ")) | 16 | + | require.Equal(t, "hihi", strings.TrimRight(rowString(s.frame.Row(0)), " ")) |
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | func TestOnBellIgnoresOSCTerminator(t *testing.T) { | 19 | func TestOnBellIgnoresOSCTerminator(t *testing.T) { | ||
| @@ -27,7 +27,7 @@ func TestOnBellIgnoresOSCTerminator(t *testing.T) { | |||||
| 27 | func TestBELWithoutCallbackIsDiscarded(t *testing.T) { | 27 | func TestBELWithoutCallbackIsDiscarded(t *testing.T) { | ||
| 28 | s := NewScreen(10, 2) | 28 | s := NewScreen(10, 2) | ||
| 29 | s.Write([]byte("a\ab")) // no OnBell set: must not panic | 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 | func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) { | 33 | func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) { | ||
| @@ -54,7 +54,7 @@ func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) { | |||||
| 54 | require.Equal(t, tt.want, s.TerminalTitle()) | 54 | require.Equal(t, tt.want, s.TerminalTitle()) | ||
| 55 | require.Equal(t, 0, bells) | 55 | require.Equal(t, 0, bells) | ||
| 56 | require.Empty(t, s.Damage()) | 56 | require.Empty(t, s.Damage()) | ||
| 57 | - | require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0))) | 57 | + | require.Equal(t, strings.Repeat(" ", s.frame.Width), rowString(s.frame.Row(0))) |
| 58 | }) | 58 | }) | ||
| 59 | } | 59 | } | ||
| 60 | } | 60 | } | ||
M screen_resize.go +2 -21
| @@ -1,9 +1,7 @@ | |||||
| 1 | package vt | 1 | package vt | ||
| 2 | 2 | ||||
| 3 | - | import renderer "github.com/bnema/vev-vt/core" | |||
| 4 | - | ||||
| 5 | func (s *Screen) Resize(width, height int) { | 3 | func (s *Screen) Resize(width, height int) { | ||
| 6 | - | if width == s.Frame.Width && height == s.Frame.Height { | 4 | + | if width == s.frame.Width && height == s.frame.Height { |
| 7 | s.geometry.Cols, s.geometry.Rows = width, height | 5 | s.geometry.Cols, s.geometry.Rows = width, height | ||
| 8 | return | 6 | return | ||
| 9 | } | 7 | } | ||
| @@ -15,7 +13,6 @@ func (s *Screen) Resize(width, height int) { | |||||
| 15 | s.Row, s.Col = 0, 0 | 13 | s.Row, s.Col = 0, 0 | ||
| 16 | if state := s.alternate; state != nil { | 14 | if state := s.alternate; state != nil { | ||
| 17 | state.buffer = s.newBuffer(width, height) | 15 | state.buffer = s.newBuffer(width, height) | ||
| 18 | - | state.frame = state.buffer.frame | |||
| 19 | state.row, state.col = 0, 0 | 16 | state.row, state.col = 0, 0 | ||
| 20 | state.scrollTop, state.scrollBottom = 0, height-1 | 17 | state.scrollTop, state.scrollBottom = 0, height-1 | ||
| 21 | } | 18 | } | ||
| @@ -49,18 +46,13 @@ func (s *Screen) Resize(width, height int) { | |||||
| 49 | // cursors for each state are mapped by their single buffer layout pass. | 46 | // cursors for each state are mapped by their single buffer layout pass. | ||
| 50 | s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false) | 47 | s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false) | ||
| 51 | state := s.alternate | 48 | state := s.alternate | ||
| 52 | - | if state.buffer == nil { | |||
| 53 | - | state.buffer = bufferFromFrame(state.frame) | |||
| 54 | - | s.fillMissingRowIDs(state.buffer) | |||
| 55 | - | } | |||
| 56 | state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true) | 49 | state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true) | ||
| 57 | - | state.frame = state.buffer.frame | |||
| 58 | state.scrollTop, state.scrollBottom = 0, height-1 | 50 | state.scrollTop, state.scrollBottom = 0, height-1 | ||
| 59 | } else { | 51 | } else { | ||
| 60 | s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true) | 52 | s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true) | ||
| 61 | } | 53 | } | ||
| 62 | } | 54 | } | ||
| 63 | - | s.Frame = s.buffer.frame | 55 | + | s.frame = s.buffer.frame |
| 64 | s.geometry.Cols, s.geometry.Rows = width, height | 56 | s.geometry.Cols, s.geometry.Rows = width, height | ||
| 65 | // A resize can split an in-flight escape sequence from the terminal state it | 57 | // A resize can split an in-flight escape sequence from the terminal state it | ||
| 66 | // was meant to mutate; keep the durable child state but discard partial bytes. | 58 | // was meant to mutate; keep the durable child state but discard partial bytes. | ||
| @@ -72,17 +64,6 @@ func (s *Screen) Resize(width, height int) { | |||||
| 72 | s.fullRedraw() | 64 | s.fullRedraw() | ||
| 73 | } | 65 | } | ||
| 74 | 66 | ||||
| 75 | - | // cloneFrame produces an independent copy in canonical layout: it copies the | |||
| 76 | - | // source's logical rows (via Row) into a fresh frame whose offsets are already | |||
| 77 | - | // canonical, so a rotated source is normalized in the clone. | |||
| 78 | - | func cloneFrame(frame renderer.Frame) renderer.Frame { | |||
| 79 | - | out := renderer.NewFrame(frame.Width, frame.Height) | |||
| 80 | - | for y := range frame.Height { | |||
| 81 | - | copy(out.Row(y), frame.Row(y)) | |||
| 82 | - | } | |||
| 83 | - | return out | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | func clamp(v, lo, hi int) int { | 67 | func clamp(v, lo, hi int) int { | ||
| 87 | if hi < lo { | 68 | if hi < lo { | ||
| 88 | return lo | 69 | return lo | ||
M screen_resize_test.go +6 -6
| @@ -11,16 +11,16 @@ import ( | |||||
| 11 | func TestResize(t *testing.T) { | 11 | func TestResize(t *testing.T) { | ||
| 12 | setRow := func(s *Screen, y int, text string) { | 12 | setRow := func(s *Screen, y int, text string) { | ||
| 13 | for x, r := range []rune(text) { | 13 | for x, r := range []rune(text) { | ||
| 14 | - | if x >= s.Frame.Width { | 14 | + | if x >= s.frame.Width { |
| 15 | break | 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 | rowString := func(s *Screen, y int) string { | 20 | rowString := func(s *Screen, y int) string { | ||
| 21 | - | runes := make([]rune, s.Frame.Width) | 21 | + | runes := make([]rune, s.frame.Width) |
| 22 | - | for x := range s.Frame.Width { | 22 | + | for x := range s.frame.Width { |
| 23 | - | runes[x] = s.Frame.At(x, y).Rune | 23 | + | runes[x] = s.frame.At(x, y).Rune |
| 24 | } | 24 | } | ||
| 25 | return string(runes) | 25 | return string(runes) | ||
| 26 | } | 26 | } | ||
| @@ -216,7 +216,7 @@ func TestResize(t *testing.T) { | |||||
| 216 | for range 200 { | 216 | for range 200 { | ||
| 217 | s := NewScreen(8, 4) | 217 | s := NewScreen(8, 4) | ||
| 218 | s.Write([]byte("a界b好c語d")) | 218 | s.Write([]byte("a界b好c語d")) | ||
| 219 | - | s.Row = rng.Intn(s.Frame.Height) | 219 | + | s.Row = rng.Intn(s.frame.Height) |
| 220 | 220 | ||||
| 221 | s.Resize(rng.Intn(8)+1, rng.Intn(4)+1) | 221 | s.Resize(rng.Intn(8)+1, rng.Intn(4)+1) | ||
| 222 | 222 | ||||
M screen_scroll.go +17 -17
| @@ -16,17 +16,17 @@ import ( | |||||
| 16 | // buffer.scrollUp severs that link on the way. Callers that merely advance, | 16 | // buffer.scrollUp severs that link on the way. Callers that merely advance, | ||
| 17 | // such as LF and ESC D, ignore the result. | 17 | // such as LF and ESC D, ignore the result. | ||
| 18 | func (s *Screen) index() (movedUp bool) { | 18 | func (s *Screen) index() (movedUp bool) { | ||
| 19 | - | if s.Frame.Height == 0 { | 19 | + | if s.frame.Height == 0 { |
| 20 | return false | 20 | return false | ||
| 21 | } | 21 | } | ||
| 22 | if s.Row == s.scrollBottom && s.Row >= s.scrollTop { | 22 | if s.Row == s.scrollBottom && s.Row >= s.scrollTop { | ||
| 23 | return s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1) | 23 | return s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1) | ||
| 24 | } | 24 | } | ||
| 25 | - | if s.Row+1 < s.Frame.Height { | 25 | + | if s.Row+1 < s.frame.Height { |
| 26 | s.Row++ | 26 | s.Row++ | ||
| 27 | return false | 27 | return false | ||
| 28 | } | 28 | } | ||
| 29 | - | s.Row = s.Frame.Height - 1 | 29 | + | s.Row = s.frame.Height - 1 |
| 30 | return false | 30 | return false | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| @@ -37,7 +37,7 @@ func (s *Screen) nextLine() { | |||||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | func (s *Screen) reverseIndex() { | 39 | func (s *Screen) reverseIndex() { | ||
| 40 | - | if s.Frame.Height == 0 { | 40 | + | if s.frame.Height == 0 { |
| 41 | return | 41 | return | ||
| 42 | } | 42 | } | ||
| 43 | if s.Row == s.scrollTop && s.Row <= s.scrollBottom { | 43 | if s.Row == s.scrollTop && s.Row <= s.scrollBottom { | ||
| @@ -71,14 +71,14 @@ func (s *Screen) scrollDownBy(n int) { | |||||
| 71 | // what the scroll observed keeps callers from re-deriving a predicate that this | 71 | // what the scroll observed keeps callers from re-deriving a predicate that this | ||
| 72 | // function may have declined to act on at all. | 72 | // function may have declined to act on at all. | ||
| 73 | func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) { | 73 | func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) { | ||
| 74 | - | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | 74 | + | if s.frame.Width == 0 || s.frame.Height == 0 || n <= 0 { |
| 75 | return false | 75 | return false | ||
| 76 | } | 76 | } | ||
| 77 | top, bottom, ok := s.normalizedRegion(top, bottom) | 77 | top, bottom, ok := s.normalizedRegion(top, bottom) | ||
| 78 | if !ok { | 78 | if !ok { | ||
| 79 | return false | 79 | return false | ||
| 80 | } | 80 | } | ||
| 81 | - | w := s.Frame.Width | 81 | + | w := s.frame.Width |
| 82 | height := bottom - top + 1 | 82 | height := bottom - top + 1 | ||
| 83 | graphicsRows := n | 83 | graphicsRows := n | ||
| 84 | if n > height { | 84 | if n > height { | ||
| @@ -89,7 +89,7 @@ func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) { | |||||
| 89 | // frame's line offsets (recycling and blanking the evicted rows in place) | 89 | // frame's line offsets (recycling and blanking the evicted rows in place) | ||
| 90 | // instead of copying cells. See renderer.Frame.ScrollUp. | 90 | // instead of copying cells. See renderer.Frame.ScrollUp. | ||
| 91 | s.emitLineEvicted(top, n) | 91 | s.emitLineEvicted(top, n) | ||
| 92 | - | s.Frame.ScrollUp(top, bottom, n) | 92 | + | s.frame.ScrollUp(top, bottom, n) |
| 93 | s.buffer.scrollUp(top, bottom, n) | 93 | s.buffer.scrollUp(top, bottom, n) | ||
| 94 | s.fillMissingRowIDs(s.buffer) | 94 | s.fillMissingRowIDs(s.buffer) | ||
| 95 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | 95 | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | ||
| @@ -106,17 +106,17 @@ func (s *Screen) emitLineEvicted(top, n int) { | |||||
| 106 | // Read boundaries and IDs before the caller rotates the frame: a soft link | 106 | // Read boundaries and IDs before the caller rotates the frame: a soft link | ||
| 107 | // belongs to the row it follows, and rotation reassigns row indices. | 107 | // belongs to the row it follows, and rotation reassigns row indices. | ||
| 108 | for y := top; y < top+n; y++ { | 108 | for y := top; y < top+n; y++ { | ||
| 109 | - | s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y]) | 109 | + | s.recordEvicted(s.frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y]) |
| 110 | } | 110 | } | ||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) { | 113 | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) { | ||
| 114 | if s.history != nil { | 114 | if s.history != nil { | ||
| 115 | err := s.history.AppendWithID(row, bound, id) | 115 | err := s.history.AppendWithID(row, bound, id) | ||
| 116 | - | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | 116 | + | if err != nil && !errors.Is(err, ErrHistoryRowTooLarge) { |
| 117 | panic(err) | 117 | panic(err) | ||
| 118 | } | 118 | } | ||
| 119 | - | // A nil error records the row; ErrHistoryRowTooWide explicitly leaves it | 119 | + | // A nil error records the row; ErrHistoryRowTooLarge explicitly leaves it |
| 120 | // unrecorded. Both cases continue to the eviction observer below. | 120 | // unrecorded. Both cases continue to the eviction observer below. | ||
| 121 | } | 121 | } | ||
| 122 | if s.OnLineEvicted != nil { | 122 | if s.OnLineEvicted != nil { | ||
| @@ -125,14 +125,14 @@ func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) { | |||||
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | func (s *Screen) scrollDownRegion(top, bottom, n int) { | 127 | func (s *Screen) scrollDownRegion(top, bottom, n int) { | ||
| 128 | - | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | 128 | + | if s.frame.Width == 0 || s.frame.Height == 0 || n <= 0 { |
| 129 | return | 129 | return | ||
| 130 | } | 130 | } | ||
| 131 | top, bottom, ok := s.normalizedRegion(top, bottom) | 131 | top, bottom, ok := s.normalizedRegion(top, bottom) | ||
| 132 | if !ok { | 132 | if !ok { | ||
| 133 | return | 133 | return | ||
| 134 | } | 134 | } | ||
| 135 | - | w := s.Frame.Width | 135 | + | w := s.frame.Width |
| 136 | height := bottom - top + 1 | 136 | height := bottom - top + 1 | ||
| 137 | graphicsRows := n | 137 | graphicsRows := n | ||
| 138 | if n > height { | 138 | if n > height { | ||
| @@ -140,7 +140,7 @@ func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||||
| 140 | } | 140 | } | ||
| 141 | s.scrollGraphicsRegion(top, bottom, graphicsRows) | 141 | s.scrollGraphicsRegion(top, bottom, graphicsRows) | ||
| 142 | // Full-width region: rotate line offsets instead of copying cells. | 142 | // Full-width region: rotate line offsets instead of copying cells. | ||
| 143 | - | s.Frame.ScrollDown(top, bottom, n) | 143 | + | s.frame.ScrollDown(top, bottom, n) |
| 144 | s.buffer.scrollDown(top, bottom, n) | 144 | s.buffer.scrollDown(top, bottom, n) | ||
| 145 | s.fillMissingRowIDs(s.buffer) | 145 | s.fillMissingRowIDs(s.buffer) | ||
| 146 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | 146 | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | ||
| @@ -155,7 +155,7 @@ func (s *Screen) scrollGraphicsRegion(top, bottom, rows int) { | |||||
| 155 | return | 155 | return | ||
| 156 | } | 156 | } | ||
| 157 | delta := int64(rows) * int64(cellHeight) | 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 | snapshot := s.graphics.scene.Snapshot() | 159 | snapshot := s.graphics.scene.Snapshot() | ||
| 160 | placements := snapshot.Placements() | 160 | placements := snapshot.Placements() | ||
| 161 | operations := make([]graphics.Operation, 0, len(placements)) | 161 | operations := make([]graphics.Operation, 0, len(placements)) | ||
| @@ -191,10 +191,10 @@ func (s *Screen) scrollGraphicsRegion(top, bottom, rows int) { | |||||
| 191 | } | 191 | } | ||
| 192 | 192 | ||||
| 193 | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | 193 | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | ||
| 194 | - | if s.Frame.Height == 0 || top > bottom { | 194 | + | if s.frame.Height == 0 || top > bottom { |
| 195 | return 0, 0, false | 195 | return 0, 0, false | ||
| 196 | } | 196 | } | ||
| 197 | - | top = clamp(top, 0, s.Frame.Height-1) | 197 | + | top = clamp(top, 0, s.frame.Height-1) |
| 198 | - | bottom = clamp(bottom, 0, s.Frame.Height-1) | 198 | + | bottom = clamp(bottom, 0, s.frame.Height-1) |
| 199 | return top, bottom, top <= bottom | 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 | assertContinuation(t, s, 1, 1) | 27 | assertContinuation(t, s, 1, 1) | ||
| 28 | assertBlank(t, s, 0, 2) | 28 | assertBlank(t, s, 0, 2) | ||
| 29 | assertBlank(t, s, 1, 2) | 29 | assertBlank(t, s, 1, 2) | ||
| 30 | - | if err := s.Frame.CheckInvariants(); err != nil { | 30 | + | if err := s.frame.CheckInvariants(); err != nil { |
| 31 | t.Fatalf("invariants: %v", err) | 31 | t.Fatalf("invariants: %v", err) | ||
| 32 | } | 32 | } | ||
| 33 | }, | 33 | }, | ||
| @@ -43,7 +43,7 @@ func TestWideCharSurvivesRotatedScroll(t *testing.T) { | |||||
| 43 | assertCell(t, s, 0, 1, '世') | 43 | assertCell(t, s, 0, 1, '世') | ||
| 44 | assertContinuation(t, s, 1, 1) | 44 | assertContinuation(t, s, 1, 1) | ||
| 45 | assertBlank(t, s, 0, 0) | 45 | assertBlank(t, s, 0, 0) | ||
| 46 | - | if err := s.Frame.CheckInvariants(); err != nil { | 46 | + | if err := s.frame.CheckInvariants(); err != nil { |
| 47 | t.Fatalf("invariants: %v", err) | 47 | t.Fatalf("invariants: %v", err) | ||
| 48 | } | 48 | } | ||
| 49 | }, | 49 | }, | ||
| @@ -72,7 +72,7 @@ func TestLineOffsetInvariantAfterVTSequences(t *testing.T) { | |||||
| 72 | for i, seq := range seqs { | 72 | for i, seq := range seqs { | ||
| 73 | s.Write(seq) | 73 | s.Write(seq) | ||
| 74 | s.ClearDamage() | 74 | s.ClearDamage() | ||
| 75 | - | if err := s.Frame.CheckInvariants(); err != nil { | 75 | + | if err := s.frame.CheckInvariants(); err != nil { |
| 76 | t.Fatalf("after seq %d: invariants broken: %v", i, err) | 76 | t.Fatalf("after seq %d: invariants broken: %v", i, err) | ||
| 77 | } | 77 | } | ||
| 78 | } | 78 | } | ||
M screen_snapshot.go +7 -8
| @@ -24,9 +24,8 @@ type ModeSnapshot struct { | |||||
| 24 | MouseSGR bool | 24 | MouseSGR bool | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | - | // ScreenSnapshot is an owned immutable-by-convention capture of the active | 27 | + | // ScreenSnapshot is an owned immutable capture of the active terminal |
| 28 | - | // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be | 28 | + | // viewport. It implements core.CellSource, and Row returns caller-owned storage. |
| 29 | - | // mutated and remains valid while the snapshot is retained. | |||
| 30 | type ScreenSnapshot struct { | 29 | type ScreenSnapshot struct { | ||
| 31 | frame renderer.Frame | 30 | frame renderer.Frame | ||
| 32 | bounds []LineBound | 31 | bounds []LineBound | ||
| @@ -52,7 +51,7 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||||
| 52 | nextRowID++ | 51 | nextRowID++ | ||
| 53 | } | 52 | } | ||
| 54 | return ScreenSnapshot{ | 53 | return ScreenSnapshot{ | ||
| 55 | - | frame: s.Frame.Clone(), | 54 | + | frame: s.frame.Clone(), |
| 56 | bounds: s.LineBounds(), | 55 | bounds: s.LineBounds(), | ||
| 57 | rowIDs: s.RowIDs(), | 56 | rowIDs: s.RowIDs(), | ||
| 58 | nextRowID: nextRowID, | 57 | nextRowID: nextRowID, | ||
| @@ -79,11 +78,11 @@ func (s *Screen) Snapshot() ScreenSnapshot { | |||||
| 79 | func (s ScreenSnapshot) Columns() int { return s.frame.Width } | 78 | func (s ScreenSnapshot) Columns() int { return s.frame.Width } | ||
| 80 | func (s ScreenSnapshot) Rows() int { return s.frame.Height } | 79 | func (s ScreenSnapshot) Rows() int { return s.frame.Height } | ||
| 81 | 80 | ||||
| 82 | - | func (s ScreenSnapshot) Row(y int) []renderer.Cell { | 81 | + | // Cell returns the semantic cell at x, y. |
| 83 | - | return append([]renderer.Cell(nil), s.BorrowedRow(y)...) | 82 | + | func (s ScreenSnapshot) Cell(x, y int) renderer.Cell { return s.frame.Cell(x, y) } |
| 84 | - | } | |||
| 85 | 83 | ||||
| 86 | - | func (s ScreenSnapshot) BorrowedRow(y int) []renderer.Cell { | 84 | + | // Row returns an owned copy of row y, or nil when y is out of range. |
| 85 | + | func (s ScreenSnapshot) Row(y int) []renderer.Cell { | |||
| 87 | if y < 0 || y >= s.frame.Height { | 86 | if y < 0 || y >= s.frame.Height { | ||
| 88 | return nil | 87 | return nil | ||
| 89 | } | 88 | } | ||
M screen_snapshot_test.go +17 -18
| @@ -25,7 +25,6 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||||
| 25 | wantModes: ModeSnapshot{}, | 25 | wantModes: ModeSnapshot{}, | ||
| 26 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | 26 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | ||
| 27 | require.Nil(t, snapshot.Row(0)) | 27 | require.Nil(t, snapshot.Row(0)) | ||
| 28 | - | require.Nil(t, snapshot.BorrowedRow(0)) | |||
| 29 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | 28 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | ||
| 30 | }, | 29 | }, | ||
| 31 | }, | 30 | }, | ||
| @@ -48,7 +47,7 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||||
| 48 | MouseSGR: true, | 47 | MouseSGR: true, | ||
| 49 | }, | 48 | }, | ||
| 50 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | 49 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | ||
| 51 | - | require.Len(t, snapshot.BorrowedRow(0), 6) | 50 | + | require.Len(t, snapshot.Row(0), 6) |
| 52 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | 51 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | ||
| 53 | }, | 52 | }, | ||
| 54 | }, | 53 | }, | ||
| @@ -80,9 +79,8 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||||
| 80 | wantTitle: "zero-columns", | 79 | wantTitle: "zero-columns", | ||
| 81 | wantCursor: CursorSnapshot{Visible: true}, | 80 | wantCursor: CursorSnapshot{Visible: true}, | ||
| 82 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | 81 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | ||
| 83 | - | require.NotNil(t, snapshot.BorrowedRow(0)) | 82 | + | require.NotNil(t, snapshot.Row(0)) |
| 84 | - | require.Empty(t, snapshot.BorrowedRow(0)) | 83 | + | require.Empty(t, snapshot.Row(0)) |
| 85 | - | require.Nil(t, snapshot.Row(0)) | |||
| 86 | require.Empty(t, snapshot.Row(1)) | 84 | require.Empty(t, snapshot.Row(1)) | ||
| 87 | require.Equal(t, LineBound{}, snapshot.Bound(1)) | 85 | require.Equal(t, LineBound{}, snapshot.Bound(1)) | ||
| 88 | require.Nil(t, snapshot.Row(2)) | 86 | require.Nil(t, snapshot.Row(2)) | ||
| @@ -100,7 +98,7 @@ func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||||
| 100 | wantTitle: "zero-rows", | 98 | wantTitle: "zero-rows", | ||
| 101 | wantCursor: CursorSnapshot{Visible: true}, | 99 | wantCursor: CursorSnapshot{Visible: true}, | ||
| 102 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | 100 | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | ||
| 103 | - | require.Nil(t, snapshot.BorrowedRow(0)) | 101 | + | require.Nil(t, snapshot.Row(0)) |
| 104 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | 102 | require.Equal(t, LineBound{}, snapshot.Bound(0)) | ||
| 105 | }, | 103 | }, | ||
| 106 | }, | 104 | }, | ||
| @@ -193,7 +191,7 @@ func TestScreenSnapshotFidelity(t *testing.T) { | |||||
| 193 | {Rune: 'x', Style: renderer.DefaultStyle()}, | 191 | {Rune: 'x', Style: renderer.DefaultStyle()}, | ||
| 194 | renderer.BlankCell(), | 192 | renderer.BlankCell(), | ||
| 195 | } | 193 | } | ||
| 196 | - | copy(screen.Frame.Row(0), cells) | 194 | + | screen.frame.WriteRow(0, 0, cells) |
| 197 | screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true} | 195 | screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true} | ||
| 198 | return screen, cells, []LineBound{{End: 3, Soft: true}, {}} | 196 | return screen, cells, []LineBound{{End: 3, Soft: true}, {}} | ||
| 199 | }, | 197 | }, | ||
| @@ -203,7 +201,7 @@ func TestScreenSnapshotFidelity(t *testing.T) { | |||||
| 203 | make: func() (*Screen, []renderer.Cell, []LineBound) { | 201 | make: func() (*Screen, []renderer.Cell, []LineBound) { | ||
| 204 | screen := NewScreen(3, 1) | 202 | screen := NewScreen(3, 1) | ||
| 205 | screen.Write([]byte("primary\x1b[?1049halt")) | 203 | screen.Write([]byte("primary\x1b[?1049halt")) | ||
| 206 | - | cells := append([]renderer.Cell(nil), screen.Frame.Row(0)...) | 204 | + | cells := append([]renderer.Cell(nil), screen.frame.Row(0)...) |
| 207 | return screen, cells, screen.LineBounds() | 205 | return screen, cells, screen.LineBounds() | ||
| 208 | }, | 206 | }, | ||
| 209 | wantModes: ModeSnapshot{AlternateScreen: true}, | 207 | wantModes: ModeSnapshot{AlternateScreen: true}, | ||
| @@ -214,8 +212,8 @@ func TestScreenSnapshotFidelity(t *testing.T) { | |||||
| 214 | t.Run(test.name, func(t *testing.T) { | 212 | t.Run(test.name, func(t *testing.T) { | ||
| 215 | screen, wantCells, wantBounds := test.make() | 213 | screen, wantCells, wantBounds := test.make() | ||
| 216 | snapshot := screen.Snapshot() | 214 | snapshot := screen.Snapshot() | ||
| 217 | - | require.Equal(t, screen.Frame.Width, snapshot.Columns()) | 215 | + | require.Equal(t, screen.frame.Width, snapshot.Columns()) |
| 218 | - | require.Equal(t, screen.Frame.Height, snapshot.Rows()) | 216 | + | require.Equal(t, screen.frame.Height, snapshot.Rows()) |
| 219 | require.Equal(t, wantCells, snapshot.Row(0)) | 217 | require.Equal(t, wantCells, snapshot.Row(0)) | ||
| 220 | require.Equal(t, test.wantModes, snapshot.Modes()) | 218 | require.Equal(t, test.wantModes, snapshot.Modes()) | ||
| 221 | for row, want := range wantBounds { | 219 | for row, want := range wantBounds { | ||
| @@ -234,7 +232,7 @@ func TestScreenSnapshotPreservesLogicalRowsAfterScroll(t *testing.T) { | |||||
| 234 | snapshot := screen.Snapshot() | 232 | snapshot := screen.Snapshot() | ||
| 235 | for y, want := range []string{"DEF", "G "} { | 233 | for y, want := range []string{"DEF", "G "} { | ||
| 236 | require.Equal(t, want, rowText(snapshot.Row(y))) | 234 | require.Equal(t, want, rowText(snapshot.Row(y))) | ||
| 237 | - | require.Equal(t, screen.Frame.Row(y), snapshot.Row(y)) | 235 | + | require.Equal(t, screen.frame.Row(y), snapshot.Row(y)) |
| 238 | require.Equal(t, screen.LineBounds()[y], snapshot.Bound(y)) | 236 | require.Equal(t, screen.LineBounds()[y], snapshot.Bound(y)) | ||
| 239 | } | 237 | } | ||
| 240 | } | 238 | } | ||
| @@ -267,15 +265,15 @@ func TestScreenSnapshotOwnership(t *testing.T) { | |||||
| 267 | require.Equal(t, wantBounds[0], snapshot.Bound(0)) | 265 | require.Equal(t, wantBounds[0], snapshot.Bound(0)) | ||
| 268 | require.Equal(t, wantBounds[1], snapshot.Bound(1)) | 266 | require.Equal(t, wantBounds[1], snapshot.Bound(1)) | ||
| 269 | 267 | ||||
| 270 | - | liveBeforeCallerMutation := append([]renderer.Cell(nil), screen.Frame.Row(0)...) | 268 | + | liveBeforeCallerMutation := append([]renderer.Cell(nil), screen.frame.Row(0)...) |
| 271 | secondSnapshot := screen.Snapshot() | 269 | secondSnapshot := screen.Snapshot() | ||
| 272 | secondSnapshotBefore := secondSnapshot.Row(0) | 270 | secondSnapshotBefore := secondSnapshot.Row(0) | ||
| 273 | 271 | ||||
| 274 | callerCopy := snapshot.Row(0) | 272 | callerCopy := snapshot.Row(0) | ||
| 275 | callerCopy[0] = renderer.Cell{Rune: 'z'} | 273 | callerCopy[0] = renderer.Cell{Rune: 'z'} | ||
| 276 | 274 | ||||
| 277 | - | require.Equal(t, wantRows[0], snapshot.BorrowedRow(0)) | 275 | + | require.Equal(t, wantRows[0], snapshot.Row(0)) |
| 278 | - | require.Equal(t, liveBeforeCallerMutation, screen.Frame.Row(0), "mutating Row copy must not change live Screen") | 276 | + | require.Equal(t, liveBeforeCallerMutation, screen.frame.Row(0), "mutating Row copy must not change live Screen") |
| 279 | require.Equal(t, secondSnapshotBefore, secondSnapshot.Row(0), "mutating Row copy must not change another snapshot") | 277 | require.Equal(t, secondSnapshotBefore, secondSnapshot.Row(0), "mutating Row copy must not change another snapshot") | ||
| 280 | }) | 278 | }) | ||
| 281 | } | 279 | } | ||
| @@ -332,7 +330,7 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) { | |||||
| 332 | { | 330 | { | ||
| 333 | name: "screen with sealed chunks and mutable tail", | 331 | name: "screen with sealed chunks and mutable tail", | ||
| 334 | make: func() *Screen { | 332 | make: func() *Screen { | ||
| 335 | - | screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 64, ChunkRows: 2}) | 333 | + | screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxBytes: 1 << 20, ChunkRows: 2}) |
| 336 | screen.Write([]byte("111122223333444455")) | 334 | screen.Write([]byte("111122223333444455")) | ||
| 337 | return screen | 335 | return screen | ||
| 338 | }, | 336 | }, | ||
| @@ -346,7 +344,8 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) { | |||||
| 346 | var beforeHistory HistorySnapshotView | 344 | var beforeHistory HistorySnapshotView | ||
| 347 | var beforeRows [][]renderer.Cell | 345 | var beforeRows [][]renderer.Cell | ||
| 348 | var beforeBounds []LineBound | 346 | var beforeBounds []LineBound | ||
| 349 | - | beforeCap, beforeCellCap := 0, 0 | 347 | + | beforeCap := 0 |
| 348 | + | var beforeByteCap uint64 | |||
| 350 | if screen.History() != nil { | 349 | if screen.History() != nil { | ||
| 351 | beforeHistory = screen.History().SnapshotView() | 350 | beforeHistory = screen.History().SnapshotView() | ||
| 352 | beforeView := screen.History().View() | 351 | beforeView := screen.History().View() | ||
| @@ -357,7 +356,7 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) { | |||||
| 357 | beforeBounds[i] = beforeView.Bound(i) | 356 | beforeBounds[i] = beforeView.Bound(i) | ||
| 358 | } | 357 | } | ||
| 359 | beforeCap = screen.History().Cap() | 358 | beforeCap = screen.History().Cap() | ||
| 360 | - | beforeCellCap = screen.History().CellCap() | 359 | + | beforeByteCap = screen.History().ByteCap() |
| 361 | } | 360 | } | ||
| 362 | 361 | ||||
| 363 | _ = screen.Snapshot() | 362 | _ = screen.Snapshot() | ||
| @@ -380,7 +379,7 @@ func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) { | |||||
| 380 | require.Equal(t, beforeRows, afterRows) | 379 | require.Equal(t, beforeRows, afterRows) | ||
| 381 | require.Equal(t, beforeBounds, afterBounds) | 380 | require.Equal(t, beforeBounds, afterBounds) | ||
| 382 | require.Equal(t, beforeCap, screen.History().Cap()) | 381 | require.Equal(t, beforeCap, screen.History().Cap()) | ||
| 383 | - | require.Equal(t, beforeCellCap, screen.History().CellCap()) | 382 | + | require.Equal(t, beforeByteCap, screen.History().ByteCap()) |
| 384 | } | 383 | } | ||
| 385 | }) | 384 | }) | ||
| 386 | } | 385 | } | ||
M screen_write.go +38 -45
| @@ -14,8 +14,8 @@ func (s *Screen) putRune(r rune) { | |||||
| 14 | case '\r': | 14 | case '\r': | ||
| 15 | s.Col = 0 | 15 | s.Col = 0 | ||
| 16 | case '\n', '\v', '\f': | 16 | case '\n', '\v', '\f': | ||
| 17 | - | if s.Frame.Width > 0 && s.Col >= s.Frame.Width { | 17 | + | if s.frame.Width > 0 && s.Col >= s.frame.Width { |
| 18 | - | s.Col = s.Frame.Width - 1 | 18 | + | s.Col = s.frame.Width - 1 |
| 19 | } | 19 | } | ||
| 20 | s.buffer.hard(s.Row) | 20 | s.buffer.hard(s.Row) | ||
| 21 | s.index() | 21 | s.index() | ||
| @@ -26,8 +26,8 @@ func (s *Screen) putRune(r rune) { | |||||
| 26 | case '\t': | 26 | case '\t': | ||
| 27 | // HT moves to the next tab stop without modifying the frame. Clamp at | 27 | // HT moves to the next tab stop without modifying the frame. Clamp at | ||
| 28 | // the right edge rather than triggering deferred wrap or a scroll. | 28 | // the right edge rather than triggering deferred wrap or a scroll. | ||
| 29 | - | if s.Frame.Width > 0 { | 29 | + | if s.frame.Width > 0 { |
| 30 | - | s.Col = min(s.Col+8-s.Col%8, s.Frame.Width-1) | 30 | + | s.Col = min(s.Col+8-s.Col%8, s.frame.Width-1) |
| 31 | } | 31 | } | ||
| 32 | default: | 32 | default: | ||
| 33 | // Drop C1 control characters (U+0080-U+009F). | 33 | // Drop C1 control characters (U+0080-U+009F). | ||
| @@ -58,23 +58,23 @@ func (s *Screen) putPrintable(r rune) { | |||||
| 58 | if w == 0 { | 58 | if w == 0 { | ||
| 59 | return | 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 | return | 62 | return | ||
| 63 | } | 63 | } | ||
| 64 | // Deferred wrap: cursor sits past the last column. | 64 | // Deferred wrap: cursor sits past the last column. | ||
| 65 | - | if s.Col >= s.Frame.Width { | 65 | + | if s.Col >= s.frame.Width { |
| 66 | s.buffer.soft(s.Row) | 66 | s.buffer.soft(s.Row) | ||
| 67 | s.Col = 0 | 67 | s.Col = 0 | ||
| 68 | s.relinkWrap(s.index()) | 68 | s.relinkWrap(s.index()) | ||
| 69 | } | 69 | } | ||
| 70 | - | if s.Row >= s.Frame.Height { | 70 | + | if s.Row >= s.frame.Height { |
| 71 | - | s.Row = s.Frame.Height - 1 | 71 | + | s.Row = s.frame.Height - 1 |
| 72 | } | 72 | } | ||
| 73 | 73 | ||||
| 74 | // A wide rune must never straddle the right edge. If it does not fit on the | 74 | // A wide rune must never straddle the right edge. If it does not fit on the | ||
| 75 | // current line, clear the abandoned last cell and wrap to the next line. | 75 | // current line, clear the abandoned last cell and wrap to the next line. | ||
| 76 | - | if w == 2 && s.Col+1 >= s.Frame.Width { | 76 | + | if w == 2 && s.Col+1 >= s.frame.Width { |
| 77 | - | if s.Frame.Width < 2 { | 77 | + | if s.frame.Width < 2 { |
| 78 | // The screen is too narrow to ever hold a wide rune; store a narrow | 78 | // The screen is too narrow to ever hold a wide rune; store a narrow | ||
| 79 | // replacement so the cell's renderer width matches its layout. | 79 | // replacement so the cell's renderer width matches its layout. | ||
| 80 | r = '\uFFFD' | 80 | r = '\uFFFD' | ||
| @@ -83,18 +83,18 @@ func (s *Screen) putPrintable(r rune) { | |||||
| 83 | // The abandoned cell may itself be the continuation of a pair whose | 83 | // The abandoned cell may itself be the continuation of a pair whose | ||
| 84 | // left half sits one column back; report damage over the full span. | 84 | // left half sits one column back; report damage over the full span. | ||
| 85 | cx := s.Col | 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 | cx = s.Col - 1 | 87 | cx = s.Col - 1 | ||
| 88 | } | 88 | } | ||
| 89 | s.clearWidePairAt(s.Col, s.Row) | 89 | s.clearWidePairAt(s.Col, s.Row) | ||
| 90 | - | s.Frame.Set(s.Col, s.Row, renderer.BlankCell()) | 90 | + | s.frame.Set(s.Col, s.Row, renderer.BlankCell()) |
| 91 | s.buffer.truncate(s.Row, cx) | 91 | s.buffer.truncate(s.Row, cx) | ||
| 92 | s.buffer.continueRow(s.Row) | 92 | s.buffer.continueRow(s.Row) | ||
| 93 | s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1}) | 93 | s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1}) | ||
| 94 | s.Col = 0 | 94 | s.Col = 0 | ||
| 95 | s.relinkWrap(s.index()) | 95 | s.relinkWrap(s.index()) | ||
| 96 | - | if s.Row >= s.Frame.Height { | 96 | + | if s.Row >= s.frame.Height { |
| 97 | - | s.Row = s.Frame.Height - 1 | 97 | + | s.Row = s.frame.Height - 1 |
| 98 | } | 98 | } | ||
| 99 | } | 99 | } | ||
| 100 | } | 100 | } | ||
| @@ -102,38 +102,33 @@ func (s *Screen) putPrintable(r rune) { | |||||
| 102 | insertDamageX := s.Col | 102 | insertDamageX := s.Col | ||
| 103 | insertDamageWidth := 0 | 103 | insertDamageWidth := 0 | ||
| 104 | if s.insertMode { | 104 | if s.insertMode { | ||
| 105 | - | row := s.Frame.Row(s.Row) | 105 | + | leftSplit := s.Col > 0 && s.frame.At(s.Col, s.Row).Continuation |
| 106 | - | leftSplit := s.Col > 0 && row[s.Col].Continuation | 106 | + | s.frame.CopyRow(s.Row, s.Col+w, s.Col, s.frame.Width-s.Col-w) |
| 107 | - | for x := s.Frame.Width - 1; x >= s.Col+w; x-- { | 107 | + | s.frame.FillRow(s.Row, s.Col, s.Col+w, renderer.BlankCell()) |
| 108 | - | row[x] = row[x-w] | |||
| 109 | - | } | |||
| 110 | - | for x := s.Col; x < s.Col+w; x++ { | |||
| 111 | - | row[x] = renderer.BlankCell() | |||
| 112 | - | } | |||
| 113 | s.repairRow(s.Row) | 108 | s.repairRow(s.Row) | ||
| 114 | s.buffer.insert(s.Row, s.Col, w) | 109 | s.buffer.insert(s.Row, s.Col, w) | ||
| 115 | if leftSplit { | 110 | if leftSplit { | ||
| 116 | insertDamageX = s.Col - 1 | 111 | insertDamageX = s.Col - 1 | ||
| 117 | } | 112 | } | ||
| 118 | - | insertDamageWidth = s.Frame.Width - insertDamageX | 113 | + | insertDamageWidth = s.frame.Width - insertDamageX |
| 119 | } | 114 | } | ||
| 120 | 115 | ||||
| 121 | // Determine the range of cells actually modified, extending over any wide | 116 | // Determine the range of cells actually modified, extending over any wide | ||
| 122 | // pair the write lands on so no orphaned half is left behind. | 117 | // pair the write lands on so no orphaned half is left behind. | ||
| 123 | lo, hi := s.Col, s.Col+w-1 | 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 | lo = s.Col - 1 | 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 | hi = right | 123 | hi = right | ||
| 129 | } | 124 | } | ||
| 130 | for x := lo; x <= hi; x++ { | 125 | for x := lo; x <= hi; x++ { | ||
| 131 | - | s.Frame.Set(x, s.Row, renderer.BlankCell()) | 126 | + | s.frame.Set(x, s.Row, renderer.BlankCell()) |
| 132 | } | 127 | } | ||
| 133 | - | s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style}) | 128 | + | s.frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style}) |
| 134 | s.buffer.content(s.Row, s.Col+w) | 129 | s.buffer.content(s.Row, s.Col+w) | ||
| 135 | if w == 2 { | 130 | if w == 2 { | ||
| 136 | - | s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style}) | 131 | + | s.frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style}) |
| 137 | } | 132 | } | ||
| 138 | if insertDamageWidth > 0 { | 133 | if insertDamageWidth > 0 { | ||
| 139 | lo = min(lo, insertDamageX) | 134 | lo = min(lo, insertDamageX) | ||
| @@ -149,19 +144,19 @@ func (s *Screen) putPrintable(r rune) { | |||||
| 149 | // left half) holds — which the writer maintains. | 144 | // left half) holds — which the writer maintains. | ||
| 150 | 145 | ||||
| 151 | func (s *Screen) clearWidePairAt(x, y int) { | 146 | func (s *Screen) clearWidePairAt(x, y int) { | ||
| 152 | - | if x < 0 || x >= s.Frame.Width || y < 0 || y >= s.Frame.Height { | 147 | + | if x < 0 || x >= s.frame.Width || y < 0 || y >= s.frame.Height { |
| 153 | return | 148 | return | ||
| 154 | } | 149 | } | ||
| 155 | - | if s.Frame.At(x, y).Continuation { | 150 | + | if s.frame.At(x, y).Continuation { |
| 156 | if x-1 >= 0 { | 151 | if x-1 >= 0 { | ||
| 157 | - | s.Frame.Set(x-1, y, renderer.BlankCell()) | 152 | + | s.frame.Set(x-1, y, renderer.BlankCell()) |
| 158 | } | 153 | } | ||
| 159 | - | s.Frame.Set(x, y, renderer.BlankCell()) | 154 | + | s.frame.Set(x, y, renderer.BlankCell()) |
| 160 | return | 155 | return | ||
| 161 | } | 156 | } | ||
| 162 | - | if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation { | 157 | + | if x+1 < s.frame.Width && s.frame.At(x+1, y).Continuation { |
| 163 | - | s.Frame.Set(x, y, renderer.BlankCell()) | 158 | + | s.frame.Set(x, y, renderer.BlankCell()) |
| 164 | - | s.Frame.Set(x+1, y, renderer.BlankCell()) | 159 | + | s.frame.Set(x+1, y, renderer.BlankCell()) |
| 165 | } | 160 | } | ||
| 166 | } | 161 | } | ||
| 167 | 162 | ||||
| @@ -181,32 +176,30 @@ func (s *Screen) eraseCell() renderer.Cell { | |||||
| 181 | // returns the actual modified span [start, start+width). | 176 | // returns the actual modified span [start, start+width). | ||
| 182 | 177 | ||||
| 183 | func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | 178 | func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | ||
| 184 | - | if y < 0 || y >= s.Frame.Height { | 179 | + | if y < 0 || y >= s.frame.Height { |
| 185 | return x0, 0 | 180 | return x0, 0 | ||
| 186 | } | 181 | } | ||
| 187 | if x0 < 0 { | 182 | if x0 < 0 { | ||
| 188 | x0 = 0 | 183 | x0 = 0 | ||
| 189 | } | 184 | } | ||
| 190 | - | if x1 > s.Frame.Width { | 185 | + | if x1 > s.frame.Width { |
| 191 | - | x1 = s.Frame.Width | 186 | + | x1 = s.frame.Width |
| 192 | } | 187 | } | ||
| 193 | if x0 >= x1 { | 188 | if x0 >= x1 { | ||
| 194 | return x0, 0 | 189 | return x0, 0 | ||
| 195 | } | 190 | } | ||
| 196 | // Left boundary: a continuation at x0 means its wide left half sits at x0-1. | 191 | // Left boundary: a continuation at x0 means its wide left half sits at x0-1. | ||
| 197 | - | if x0 > 0 && s.Frame.At(x0, y).Continuation { | 192 | + | if x0 > 0 && s.frame.At(x0, y).Continuation { |
| 198 | x0-- | 193 | x0-- | ||
| 199 | } | 194 | } | ||
| 200 | // Right boundary: a continuation at x1 means its wide left half (at x1-1) is | 195 | // Right boundary: a continuation at x1 means its wide left half (at x1-1) is | ||
| 201 | // inside the range and will be blanked, so swallow the continuation too. | 196 | // inside the range and will be blanked, so swallow the continuation too. | ||
| 202 | - | if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation { | 197 | + | if x1 < s.frame.Width && s.frame.At(x1, y).Continuation { |
| 203 | x1++ | 198 | x1++ | ||
| 204 | } | 199 | } | ||
| 205 | - | fullRow := x0 <= 0 && x1 >= s.Frame.Width | 200 | + | fullRow := x0 <= 0 && x1 >= s.frame.Width |
| 206 | blank := s.eraseCell() | 201 | blank := s.eraseCell() | ||
| 207 | - | for x := x0; x < x1; x++ { | 202 | + | s.frame.FillRow(y, x0, x1, blank) |
| 208 | - | s.Frame.Set(x, y, blank) | |||
| 209 | - | } | |||
| 210 | s.buffer.clear(y, x0, x1) | 203 | s.buffer.clear(y, x0, x1) | ||
| 211 | if fullRow { | 204 | if fullRow { | ||
| 212 | s.buffer.rowIDs[y] = s.nextRowIDValue() | 205 | s.buffer.rowIDs[y] = s.nextRowIDValue() | ||
| @@ -220,7 +213,7 @@ func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||||
| 220 | // at a boundary or by shifting cells. | 213 | // at a boundary or by shifting cells. | ||
| 221 | 214 | ||||
| 222 | func (s *Screen) repairRow(y int) { | 215 | func (s *Screen) repairRow(y int) { | ||
| 223 | - | repairFrameRow(s.Frame, y) | 216 | + | repairFrameRow(s.frame, y) |
| 224 | } | 217 | } | ||
| 225 | 218 | ||||
| 226 | func repairFrameRow(frame renderer.Frame, y int) { | 219 | func repairFrameRow(frame renderer.Frame, y int) { | ||
M screen_write_test.go +6 -6
| @@ -194,12 +194,12 @@ func TestCursorControlChars(t *testing.T) { | |||||
| 194 | name: "tab moves cursor without changing cells or damage", | 194 | name: "tab moves cursor without changing cells or damage", | ||
| 195 | run: func(t *testing.T) { | 195 | run: func(t *testing.T) { | ||
| 196 | s := NewScreen(10, 2) | 196 | s := NewScreen(10, 2) | ||
| 197 | - | for y := range s.Frame.Height { | 197 | + | for y := range s.frame.Height { |
| 198 | - | for x := range s.Frame.Width { | 198 | + | for x := range s.frame.Width { |
| 199 | - | s.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.Frame.Width + x)}) | 199 | + | s.frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.frame.Width + x)}) |
| 200 | } | 200 | } | ||
| 201 | } | 201 | } | ||
| 202 | - | before := s.Frame.Clone() | 202 | + | before := s.frame.Clone() |
| 203 | s.Row, s.Col = 1, 6 | 203 | s.Row, s.Col = 1, 6 | ||
| 204 | s.ClearDamage() | 204 | s.ClearDamage() | ||
| 205 | 205 | ||||
| @@ -207,7 +207,7 @@ func TestCursorControlChars(t *testing.T) { | |||||
| 207 | if s.Row != 1 || s.Col != 8 { | 207 | if s.Row != 1 || s.Col != 8 { | ||
| 208 | t.Errorf("cursor after tab = row=%d col=%d, want row=1 col=8", s.Row, s.Col) | 208 | t.Errorf("cursor after tab = row=%d col=%d, want row=1 col=8", s.Row, s.Col) | ||
| 209 | } | 209 | } | ||
| 210 | - | assertFramesEqual(t, s.Frame, before) | 210 | + | assertFramesEqual(t, s.frame, before) |
| 211 | if got := s.Damage(); len(got) != 0 { | 211 | if got := s.Damage(); len(got) != 0 { | ||
| 212 | t.Errorf("tab damage = %+v, want none", got) | 212 | t.Errorf("tab damage = %+v, want none", got) | ||
| 213 | } | 213 | } | ||
| @@ -218,7 +218,7 @@ func TestCursorControlChars(t *testing.T) { | |||||
| 218 | if s.Row != 1 || s.Col != 9 { | 218 | if s.Row != 1 || s.Col != 9 { | ||
| 219 | t.Errorf("cursor after clamped tab = row=%d col=%d, want row=1 col=9", s.Row, s.Col) | 219 | t.Errorf("cursor after clamped tab = row=%d col=%d, want row=1 col=9", s.Row, s.Col) | ||
| 220 | } | 220 | } | ||
| 221 | - | assertFramesEqual(t, s.Frame, before) | 221 | + | assertFramesEqual(t, s.frame, before) |
| 222 | if got := s.Damage(); len(got) != 0 { | 222 | if got := s.Damage(); len(got) != 0 { | ||
| 223 | t.Errorf("clamped tab damage = %+v, want none", got) | 223 | t.Errorf("clamped tab damage = %+v, want none", got) | ||
| 224 | } | 224 | } | ||
M scripts/consumer-smoke.sh +2 -2
| @@ -13,7 +13,7 @@ mkdir -p "$tmp/consumer" | |||||
| 13 | cat >"$tmp/consumer/go.mod" <<EOF | 13 | cat >"$tmp/consumer/go.mod" <<EOF | ||
| 14 | module example.com/vev-vt-consumer | 14 | module example.com/vev-vt-consumer | ||
| 15 | 15 | ||||
| 16 | - | go 1.26 | 16 | + | go 1.27 |
| 17 | 17 | ||||
| 18 | require github.com/bnema/vev-vt v0.0.0 | 18 | require github.com/bnema/vev-vt v0.0.0 | ||
| 19 | 19 | ||||
| @@ -33,7 +33,7 @@ import ( | |||||
| 33 | func main() { | 33 | func main() { | ||
| 34 | screen := vt.NewScreen(4, 1) | 34 | screen := vt.NewScreen(4, 1) | ||
| 35 | screen.Write([]byte("ok")) | 35 | screen.Write([]byte("ok")) | ||
| 36 | - | output, err := ansi.New(ansi.Capabilities{}).Draw(screen.Frame.Clone(), []vt.Damage{vt.FullRedraw()}) | 36 | + | output, err := ansi.New(ansi.Capabilities{}).Draw(screen, []vt.Damage{vt.FullRedraw()}) |
| 37 | if err != nil { | 37 | if err != nil { | ||
| 38 | fmt.Fprintln(os.Stderr, err) | 38 | fmt.Fprintln(os.Stderr, err) | ||
| 39 | os.Exit(1) | 39 | os.Exit(1) | ||
M scrollback_test.go +3 -3
| @@ -92,7 +92,7 @@ func TestOnLineEvictedAltScreenAndRotation(t *testing.T) { | |||||
| 92 | if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) { | 92 | if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) { | ||
| 93 | t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows) | 93 | t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows) | ||
| 94 | } | 94 | } | ||
| 95 | - | if err := s.Frame.CheckInvariants(); err != nil { | 95 | + | if err := s.frame.CheckInvariants(); err != nil { |
| 96 | t.Fatalf("frame invariants after scrollback callback: %v", err) | 96 | t.Fatalf("frame invariants after scrollback callback: %v", err) | ||
| 97 | } | 97 | } | ||
| 98 | }) | 98 | }) | ||
| @@ -126,7 +126,7 @@ func TestEvictedLinesCarryTheirBound(t *testing.T) { | |||||
| 126 | 126 | ||||
| 127 | for _, tc := range tests { | 127 | for _, tc := range tests { | ||
| 128 | t.Run(tc.name, func(t *testing.T) { | 128 | t.Run(tc.name, func(t *testing.T) { | ||
| 129 | - | s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 1024}) | 129 | + | s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxBytes: 1 << 20}) |
| 130 | tc.evict(s) | 130 | tc.evict(s) | ||
| 131 | view := s.History().SealAndView() | 131 | view := s.History().SealAndView() | ||
| 132 | if view.Len() == 0 { | 132 | if view.Len() == 0 { | ||
| @@ -140,7 +140,7 @@ func TestEvictedLinesCarryTheirBound(t *testing.T) { | |||||
| 140 | } | 140 | } | ||
| 141 | 141 | ||||
| 142 | func TestReflowEvictedLinesCarryTheirBound(t *testing.T) { | 142 | func TestReflowEvictedLinesCarryTheirBound(t *testing.T) { | ||
| 143 | - | s := NewScreenWithHistory(8, 3, HistoryConfig{MaxRows: 16, MaxCells: 1024}) | 143 | + | s := NewScreenWithHistory(8, 3, HistoryConfig{MaxRows: 16, MaxBytes: 1 << 20}) |
| 144 | // Fill the grid with wrapped content, then shrink so rows are evicted by | 144 | // Fill the grid with wrapped content, then shrink so rows are evicted by | ||
| 145 | // the reflow path in buffer.resize rather than by scrolling. | 145 | // the reflow path in buffer.resize rather than by scrolling. | ||
| 146 | s.Write([]byte("aaaaaaaabbbbbbbbcccccccc")) | 146 | s.Write([]byte("aaaaaaaabbbbbbbbcccccccc")) | ||
M snapshot.go +63 -20
| @@ -40,7 +40,7 @@ func MarshalHistoryChunk(chunk *HistoryChunk) ([]byte, error) { | |||||
| 40 | if chunk == nil { | 40 | if chunk == nil { | ||
| 41 | return nil, fmt.Errorf("marshal history chunk: %w", errInvalidHistory) | 41 | return nil, fmt.Errorf("marshal history chunk: %w", errInvalidHistory) | ||
| 42 | } | 42 | } | ||
| 43 | - | return MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: len(chunk.rows)}) | 43 | + | return MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: chunk.len(), cells: chunk.cells()}) |
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob. | 46 | // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob. | ||
| @@ -77,6 +77,9 @@ func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |||||
| 77 | // HistoryFromBlobs restores history directly from sealed, oldest-first blobs | 77 | // HistoryFromBlobs restores history directly from sealed, oldest-first blobs | ||
| 78 | // and the mandatory tail blob. It never feeds decoded rows through Append. | 78 | // and the mandatory tail blob. It never feeds decoded rows through Append. | ||
| 79 | func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) { | 79 | func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) { | ||
| 80 | + | if err := config.Validate(); err != nil { | |||
| 81 | + | return nil, err | |||
| 82 | + | } | |||
| 80 | sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail) | 83 | sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail) | ||
| 81 | if err != nil { | 84 | if err != nil { | ||
| 82 | return nil, err | 85 | return nil, err | ||
| @@ -89,14 +92,14 @@ func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryVi | |||||
| 89 | sealedViews := make([]HistoryView, len(sealed)) | 92 | sealedViews := make([]HistoryView, len(sealed)) | ||
| 90 | for i, blob := range sealed { | 93 | for i, blob := range sealed { | ||
| 91 | view, err := UnmarshalHistory(blob) | 94 | view, err := UnmarshalHistory(blob) | ||
| 92 | - | if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) { | 95 | + | if err != nil || len(view.chunks) != 1 || view.chunks[0].len() == 0 || !validateRestoredHistoryView(view, seen) { |
| 93 | return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | 96 | return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | ||
| 94 | } | 97 | } | ||
| 95 | sealedViews[i] = view | 98 | sealedViews[i] = view | ||
| 96 | } | 99 | } | ||
| 97 | 100 | ||||
| 98 | tailView, err := UnmarshalHistory(tail) | 101 | tailView, err := UnmarshalHistory(tail) | ||
| 99 | - | if err != nil || len(tailView.chunks) > 1 || !validateRestoredHistoryView(tailView, seen) { | 102 | + | if err != nil || !validateRestoredHistoryView(tailView, seen) { |
| 100 | return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory) | 103 | return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory) | ||
| 101 | } | 104 | } | ||
| 102 | for _, view := range extra { | 105 | for _, view := range extra { | ||
| @@ -111,37 +114,73 @@ func restoreHistoryViews(config HistoryConfig, sealed []HistoryView, tail Histor | |||||
| 111 | h := NewHistory(config) | 114 | h := NewHistory(config) | ||
| 112 | for _, view := range sealed { | 115 | for _, view := range sealed { | ||
| 113 | h.nextRowID = max(h.nextRowID, view.nextRowID) | 116 | h.nextRowID = max(h.nextRowID, view.nextRowID) | ||
| 114 | - | if h.maxRows == 0 { | 117 | + | chunk := h.boundedRestoredChunk(view.chunks[0]) |
| 118 | + | if chunk == nil { | |||
| 115 | continue | 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 | h.chunks = append(h.chunks, chunk) | 125 | h.chunks = append(h.chunks, chunk) | ||
| 119 | - | h.rows += len(chunk.rows) | 126 | + | h.rows += chunk.len() |
| 120 | - | h.cells += view.Cells() | 127 | + | h.cells += chunk.cells() |
| 121 | - | h.evict() | 128 | + | h.logicalBytes += chunkBytes |
| 122 | } | 129 | } | ||
| 123 | h.nextRowID = max(h.nextRowID, tail.nextRowID) | 130 | h.nextRowID = max(h.nextRowID, tail.nextRowID) | ||
| 124 | - | if h.maxRows == 0 || len(tail.chunks) == 0 { | 131 | + | if h.maxRows == 0 { |
| 125 | return h | 132 | return h | ||
| 126 | } | 133 | } | ||
| 127 | - | chunk := tail.chunks[0] | 134 | + | for _, chunk := range tail.chunks { |
| 128 | - | h.tail = chunk.rows | 135 | + | for row := range chunk.len() { |
| 129 | - | h.tailBounds = append([]LineBound(nil), chunk.bounds...) | 136 | + | if h.prepareChunkRowStyles(chunk, row) > h.maxBytes { |
| 130 | - | h.tailIDs = append([]RowID(nil), chunk.rowIDs...) | 137 | + | continue |
| 131 | - | h.rows += len(h.tail) | 138 | + | } |
| 132 | - | h.cells += tail.Cells() | 139 | + | for { |
| 133 | - | h.evict() | 140 | + | delta := h.tailAppendDelta(chunk.width) |
| 134 | - | h.normalizeTail() | 141 | + | if h.rows < h.maxRows && h.logicalBytes <= h.maxBytes-delta { |
| 142 | + | h.appendTailChunkRow(chunk, row) | |||
| 143 | + | h.recordTailRowStyles(chunk.width) | |||
| 144 | + | h.tailBytes += delta | |||
| 145 | + | h.logicalBytes += delta | |||
| 146 | + | h.tailBounds = append(h.tailBounds, chunk.bounds[row]) | |||
| 147 | + | h.tailIDs = append(h.tailIDs, chunk.rowIDs[row]) | |||
| 148 | + | h.rows++ | |||
| 149 | + | h.cells += chunk.width | |||
| 150 | + | if len(h.tail) == h.chunkRows { | |||
| 151 | + | h.sealTail() | |||
| 152 | + | } | |||
| 153 | + | break | |||
| 154 | + | } | |||
| 155 | + | h.evictOldest() | |||
| 156 | + | h.prepareChunkRowStyles(chunk, row) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 135 | return h | 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 | func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool { | 177 | func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool { | ||
| 139 | if view.nextRowID == 0 || view.nextRowID == ^RowID(0) { | 178 | if view.nextRowID == 0 || view.nextRowID == ^RowID(0) { | ||
| 140 | return false | 179 | return false | ||
| 141 | } | 180 | } | ||
| 142 | var maxID RowID | 181 | var maxID RowID | ||
| 143 | for _, chunk := range view.chunks { | 182 | for _, chunk := range view.chunks { | ||
| 144 | - | if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) { | 183 | + | if chunk == nil || chunk.len() != len(chunk.rowIDs) || chunk.len() != len(chunk.bounds) { |
| 145 | return false | 184 | return false | ||
| 146 | } | 185 | } | ||
| 147 | for _, id := range chunk.rowIDs { | 186 | for _, id := range chunk.rowIDs { | ||
| @@ -162,6 +201,9 @@ func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool | |||||
| 162 | // history contains the restored bounded history followed by the recovery | 201 | // history contains the restored bounded history followed by the recovery | ||
| 163 | // transcript. The transcript is decoded in full before history is restored. | 202 | // transcript. The transcript is decoded in full before history is restored. | ||
| 164 | func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, sealed [][]byte, tail, transcript []byte) (*Screen, error) { | 203 | func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, sealed [][]byte, tail, transcript []byte) (*Screen, error) { | ||
| 204 | + | if err := config.Validate(); err != nil { | |||
| 205 | + | return nil, err | |||
| 206 | + | } | |||
| 165 | transcriptView, err := UnmarshalHistory(transcript) | 207 | transcriptView, err := UnmarshalHistory(transcript) | ||
| 166 | if err != nil { | 208 | if err != nil { | ||
| 167 | return nil, fmt.Errorf("restore recovery transcript: %w", err) | 209 | return nil, fmt.Errorf("restore recovery transcript: %w", err) | ||
| @@ -174,7 +216,8 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||||
| 174 | history := restoreHistoryViews(config, sealedViews, tailView) | 216 | history := restoreHistoryViews(config, sealedViews, tailView) | ||
| 175 | remainingRows := transcriptView.rows | 217 | remainingRows := transcriptView.rows | ||
| 176 | for _, chunk := range transcriptView.chunks { | 218 | for _, chunk := range transcriptView.chunks { | ||
| 177 | - | for i, row := range chunk.rows { | 219 | + | for i := range chunk.len() { |
| 220 | + | row := chunk.row(i) | |||
| 178 | remainingRows-- | 221 | remainingRows-- | ||
| 179 | bound := chunk.bounds[i] | 222 | bound := chunk.bounds[i] | ||
| 180 | if remainingRows == 0 { | 223 | if remainingRows == 0 { | ||
| @@ -196,6 +239,6 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se | |||||
| 196 | for i := range screen.buffer.rowIDs { | 239 | for i := range screen.buffer.rowIDs { | ||
| 197 | screen.buffer.rowIDs[i] = screen.nextRowIDValue() | 240 | screen.buffer.rowIDs[i] = screen.nextRowIDValue() | ||
| 198 | } | 241 | } | ||
| 199 | - | screen.Frame = screen.buffer.frame | 242 | + | screen.frame = screen.buffer.frame |
| 200 | return screen, nil | 243 | return screen, nil | ||
| 201 | } | 244 | } | ||
M snapshot_test.go +5 -4
| @@ -49,7 +49,7 @@ func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | |||||
| 49 | 49 | ||||
| 50 | func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | 50 | func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | ||
| 51 | fullTail, err := MarshalHistory(HistoryView{ | 51 | fullTail, err := MarshalHistory(HistoryView{ | ||
| 52 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}, rowIDs: []RowID{1, 2}}}, | 52 | + | chunks: []*HistoryChunk{testHistoryChunk([][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}, nil, []RowID{1, 2})}, |
| 53 | rows: 2, | 53 | rows: 2, | ||
| 54 | nextRowID: 3, | 54 | nextRowID: 3, | ||
| 55 | }) | 55 | }) | ||
| @@ -64,8 +64,8 @@ func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |||||
| 64 | require.Len(t, restored.tail, 1, "the appended row must be the mutable tail") | 64 | require.Len(t, restored.tail, 1, "the appended row must be the mutable tail") | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | - | func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) { | 67 | + | func TestHistoryFromBlobsRestoresAccountingAndEvictsToBothBudgets(t *testing.T) { |
| 68 | - | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2}) | 68 | + | history := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 2}) |
| 69 | for _, text := range []string{"a", "b", "cc"} { | 69 | for _, text := range []string{"a", "b", "cc"} { | ||
| 70 | requireHistoryAppend(t, history, historyRow(text)) | 70 | requireHistoryAppend(t, history, historyRow(text)) | ||
| 71 | } | 71 | } | ||
| @@ -79,7 +79,8 @@ func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing | |||||
| 79 | tail, err := MarshalHistoryTail(view) | 79 | tail, err := MarshalHistoryTail(view) | ||
| 80 | require.NoError(t, err) | 80 | require.NoError(t, err) | ||
| 81 | 81 | ||||
| 82 | - | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, MaxCells: 2, ChunkRows: 2}, sealed, tail) | 82 | + | const budget = 2*renderer.StoredCellLogicalBytes + renderer.RowDescriptorLogicalBytes + renderer.StyleRecordLogicalBytes |
| 83 | + | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, MaxBytes: budget, ChunkRows: 2}, sealed, tail) | |||
| 83 | require.NoError(t, err) | 84 | require.NoError(t, err) | ||
| 84 | require.Equal(t, []string{"cc"}, historyViewTexts(restored.View())) | 85 | require.Equal(t, []string{"cc"}, historyViewTexts(restored.View())) | ||
| 85 | require.Equal(t, 2, restored.Cells()) | 86 | require.Equal(t, 2, restored.Cells()) | ||
A storage_benchmark_test.go +192 -0
| @@ -0,0 +1,192 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "runtime" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev-vt/core" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const ( | |||
| 11 | + | storageBenchmarkRows = 10_000 | |||
| 12 | + | storageBenchmarkWidth = 120 | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | var benchmarkStorageHistorySink *History | |||
| 16 | + | ||||
| 17 | + | type storageBenchmarkScenario struct { | |||
| 18 | + | name string | |||
| 19 | + | fill func([]core.Cell, int) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func BenchmarkHistoryBuild10Kx120(b *testing.B) { | |||
| 23 | + | for _, scenario := range historyStorageScenarios() { | |||
| 24 | + | b.Run(scenario.name, func(b *testing.B) { | |||
| 25 | + | b.ReportAllocs() | |||
| 26 | + | for b.Loop() { | |||
| 27 | + | benchmarkStorageHistorySink = buildStorageBenchmarkHistory(b, scenario.fill) | |||
| 28 | + | } | |||
| 29 | + | b.ReportMetric(storageBenchmarkRows*storageBenchmarkWidth, "cells/op") | |||
| 30 | + | }) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // BenchmarkHistoryRetained10Kx120 isolates each sample with garbage collection | |||
| 35 | + | // and reports the live heap retained by one history. Use -benchtime=1x when | |||
| 36 | + | // comparing representations; elapsed time includes the measurement GCs. | |||
| 37 | + | func BenchmarkHistoryRetained10Kx120(b *testing.B) { | |||
| 38 | + | for _, scenario := range historyStorageScenarios() { | |||
| 39 | + | b.Run(scenario.name, func(b *testing.B) { | |||
| 40 | + | var retained uint64 | |||
| 41 | + | for b.Loop() { | |||
| 42 | + | benchmarkStorageHistorySink = nil | |||
| 43 | + | runtime.GC() | |||
| 44 | + | var before runtime.MemStats | |||
| 45 | + | runtime.ReadMemStats(&before) | |||
| 46 | + | ||||
| 47 | + | benchmarkStorageHistorySink = buildStorageBenchmarkHistory(b, scenario.fill) | |||
| 48 | + | runtime.GC() | |||
| 49 | + | var after runtime.MemStats | |||
| 50 | + | runtime.ReadMemStats(&after) | |||
| 51 | + | if after.HeapAlloc >= before.HeapAlloc { | |||
| 52 | + | retained += after.HeapAlloc - before.HeapAlloc | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | benchmarkStorageHistorySink = nil | |||
| 56 | + | if b.N > 0 { | |||
| 57 | + | b.ReportMetric(float64(retained)/float64(b.N), "retained-B/op") | |||
| 58 | + | } | |||
| 59 | + | }) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // BenchmarkColdHistoryRetained10Kx120 measures the same scenarios after two | |||
| 64 | + | // bounded idle passes. The newest sealed page and mutable tail remain hot. | |||
| 65 | + | func BenchmarkColdHistoryRetained10Kx120(b *testing.B) { | |||
| 66 | + | for _, scenario := range historyStorageScenarios() { | |||
| 67 | + | b.Run(scenario.name, func(b *testing.B) { | |||
| 68 | + | var retained uint64 | |||
| 69 | + | for b.Loop() { | |||
| 70 | + | benchmarkStorageHistorySink = nil | |||
| 71 | + | runtime.GC() | |||
| 72 | + | var before, after runtime.MemStats | |||
| 73 | + | runtime.ReadMemStats(&before) | |||
| 74 | + | h := buildStorageBenchmarkHistory(b, scenario.fill) | |||
| 75 | + | for range 2 { | |||
| 76 | + | if _, err := h.CompressIdle(len(h.chunks)); err != nil { | |||
| 77 | + | b.Fatal(err) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | benchmarkStorageHistorySink = h | |||
| 81 | + | runtime.GC() | |||
| 82 | + | runtime.ReadMemStats(&after) | |||
| 83 | + | if after.HeapAlloc >= before.HeapAlloc { | |||
| 84 | + | retained += after.HeapAlloc - before.HeapAlloc | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | stats := benchmarkStorageHistorySink.CompressionStats() | |||
| 88 | + | b.ReportMetric(float64(stats.CompressedBytes), "compressed-B") | |||
| 89 | + | b.ReportMetric(float64(stats.ColdPages), "cold-pages") | |||
| 90 | + | b.ReportMetric(float64(retained)/float64(b.N), "retained-B/op") | |||
| 91 | + | benchmarkStorageHistorySink = nil | |||
| 92 | + | }) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func historyStorageScenarios() []storageBenchmarkScenario { | |||
| 97 | + | defaultStyle := core.DefaultStyle() | |||
| 98 | + | repeatedIndexed := core.Style{ | |||
| 99 | + | Bold: true, | |||
| 100 | + | Foreground: 196, | |||
| 101 | + | Background: 17, | |||
| 102 | + | UnderlineStyle: core.UnderlineSingle, | |||
| 103 | + | } | |||
| 104 | + | repeatedRGB := core.Style{ | |||
| 105 | + | Foreground: -1, | |||
| 106 | + | Background: -1, | |||
| 107 | + | HasForegroundRGB: true, | |||
| 108 | + | ForegroundRGB: core.RGB{R: 224, G: 108, B: 117}, | |||
| 109 | + | HasBackgroundRGB: true, | |||
| 110 | + | BackgroundRGB: core.RGB{R: 40, G: 44, B: 52}, | |||
| 111 | + | } | |||
| 112 | + | return []storageBenchmarkScenario{ | |||
| 113 | + | { | |||
| 114 | + | name: "plain-ascii", | |||
| 115 | + | fill: func(row []core.Cell, y int) { | |||
| 116 | + | for x := range row { | |||
| 117 | + | row[x] = core.Cell{Rune: rune('a' + (x+y)%26), Style: defaultStyle} | |||
| 118 | + | } | |||
| 119 | + | }, | |||
| 120 | + | }, | |||
| 121 | + | { | |||
| 122 | + | name: "repeated-indexed-style", | |||
| 123 | + | fill: func(row []core.Cell, y int) { | |||
| 124 | + | for x := range row { | |||
| 125 | + | row[x] = core.Cell{Rune: rune('a' + (x+y)%26), Style: repeatedIndexed} | |||
| 126 | + | } | |||
| 127 | + | }, | |||
| 128 | + | }, | |||
| 129 | + | { | |||
| 130 | + | name: "repeated-rgb-style", | |||
| 131 | + | fill: func(row []core.Cell, y int) { | |||
| 132 | + | for x := range row { | |||
| 133 | + | row[x] = core.Cell{Rune: rune('a' + (x+y)%26), Style: repeatedRGB} | |||
| 134 | + | } | |||
| 135 | + | }, | |||
| 136 | + | }, | |||
| 137 | + | { | |||
| 138 | + | name: "high-cardinality-rgb", | |||
| 139 | + | fill: func(row []core.Cell, y int) { | |||
| 140 | + | for x := range row { | |||
| 141 | + | value := uint32(y*len(row) + x) | |||
| 142 | + | style := core.Style{ | |||
| 143 | + | Foreground: -1, | |||
| 144 | + | Background: -1, | |||
| 145 | + | HasForegroundRGB: true, | |||
| 146 | + | ForegroundRGB: core.RGB{ | |||
| 147 | + | R: uint8(value >> 16), | |||
| 148 | + | G: uint8(value >> 8), | |||
| 149 | + | B: uint8(value), | |||
| 150 | + | }, | |||
| 151 | + | } | |||
| 152 | + | row[x] = core.Cell{Rune: 'x', Style: style} | |||
| 153 | + | } | |||
| 154 | + | }, | |||
| 155 | + | }, | |||
| 156 | + | { | |||
| 157 | + | name: "wide-unicode", | |||
| 158 | + | fill: func(row []core.Cell, _ int) { | |||
| 159 | + | for x := 0; x < len(row); x += 2 { | |||
| 160 | + | row[x] = core.Cell{Rune: '界', Style: defaultStyle} | |||
| 161 | + | row[x+1] = core.Cell{Style: defaultStyle, Continuation: true} | |||
| 162 | + | } | |||
| 163 | + | }, | |||
| 164 | + | }, | |||
| 165 | + | { | |||
| 166 | + | name: "styled-blanks", | |||
| 167 | + | fill: func(row []core.Cell, y int) { | |||
| 168 | + | style := core.Style{Foreground: -1, Background: (y % 16) + 232} | |||
| 169 | + | for x := range row { | |||
| 170 | + | row[x] = core.Cell{Rune: ' ', Style: style} | |||
| 171 | + | } | |||
| 172 | + | }, | |||
| 173 | + | }, | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func buildStorageBenchmarkHistory(b *testing.B, fill func([]core.Cell, int)) *History { | |||
| 178 | + | b.Helper() | |||
| 179 | + | history := NewHistory(HistoryConfig{ | |||
| 180 | + | MaxRows: storageBenchmarkRows, | |||
| 181 | + | MaxBytes: 1 << 30, | |||
| 182 | + | ChunkRows: 256, | |||
| 183 | + | }) | |||
| 184 | + | row := make([]core.Cell, storageBenchmarkWidth) | |||
| 185 | + | for y := range storageBenchmarkRows { | |||
| 186 | + | fill(row, y) | |||
| 187 | + | if err := history.Append(row, LineBound{End: len(row)}); err != nil { | |||
| 188 | + | b.Fatal(err) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | return history | |||
| 192 | + | } | |||
A storage_tuning_test.go +61 -0
| @@ -0,0 +1,61 @@ | |||||
| 1 | + | package vt | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "runtime" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev-vt/core" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestHistoryTailPreallocationFollowsGeometry(t *testing.T) { | |||
| 12 | + | h := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 4}) | |||
| 13 | + | if err := h.Append(historyRow("abc"), LineBound{End: 3}); err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | if got := cap(h.tailCells); got > 12 { | |||
| 17 | + | t.Fatalf("tail preallocation = %d cells, want at most 4x3", got) | |||
| 18 | + | } | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func BenchmarkHistoryChunkSize(b *testing.B) { | |||
| 22 | + | for _, rows := range []int{32, 64, 128, 256} { | |||
| 23 | + | b.Run(fmt.Sprintf("rows-%d", rows), func(b *testing.B) { | |||
| 24 | + | b.ReportAllocs() | |||
| 25 | + | for b.Loop() { | |||
| 26 | + | h := NewHistory(HistoryConfig{MaxRows: 10_000, MaxBytes: 1 << 30, ChunkRows: rows}) | |||
| 27 | + | row := make([]core.Cell, 120) | |||
| 28 | + | for x := range row { | |||
| 29 | + | row[x] = core.Cell{Rune: rune('a' + x%26), Style: core.DefaultStyle()} | |||
| 30 | + | } | |||
| 31 | + | for range 10_000 { | |||
| 32 | + | if err := h.Append(row, LineBound{End: 120}); err != nil { | |||
| 33 | + | b.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | benchmarkStorageHistorySink = h | |||
| 37 | + | } | |||
| 38 | + | }) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func BenchmarkSmallHistoryRetained(b *testing.B) { | |||
| 43 | + | var retained uint64 | |||
| 44 | + | for b.Loop() { | |||
| 45 | + | benchmarkStorageHistorySink = nil | |||
| 46 | + | runtime.GC() | |||
| 47 | + | var before, after runtime.MemStats | |||
| 48 | + | runtime.ReadMemStats(&before) | |||
| 49 | + | h := NewHistory(HistoryConfig{MaxRows: 4, MaxBytes: 1 << 20, ChunkRows: 4}) | |||
| 50 | + | if err := h.Append(historyRow("abc"), LineBound{End: 3}); err != nil { | |||
| 51 | + | b.Fatal(err) | |||
| 52 | + | } | |||
| 53 | + | benchmarkStorageHistorySink = h | |||
| 54 | + | runtime.GC() | |||
| 55 | + | runtime.ReadMemStats(&after) | |||
| 56 | + | if after.HeapAlloc >= before.HeapAlloc { | |||
| 57 | + | retained += after.HeapAlloc - before.HeapAlloc | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | b.ReportMetric(float64(retained)/float64(b.N), "retained-B/op") | |||
| 61 | + | } | |||
D testdata/README.md +0 -7
| @@ -1,7 +0,0 @@ | |||||
| 1 | - | # Compatibility fixtures | |||
| 2 | - | ||||
| 3 | - | `vevs-v4-restore.golden.hex` is an opaque VEVS v4 restore envelope retained for | |||
| 4 | - | vev consumer compatibility. The standalone module does not decode or own the | |||
| 5 | - | VEVS outer format; its fixture test verifies the envelope header, length, CRC, | |||
| 6 | - | and embedded VTH3 payload markers only. The vev repository owns full VEVS restore | |||
| 7 | - | semantics. | |||
D testdata/vevs-v4-restore.golden.hex +0 -1
| @@ -1,1 +0,0 @@ | |||||
| 1 | - | 5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100 | |||
M wrap_scroll_soft_test.go +5 -5
| @@ -86,7 +86,7 @@ func TestSoftLinkSurvivesTheScrollItCaused(t *testing.T) { | |||||
| 86 | } | 86 | } | ||
| 87 | for _, tc := range cases { | 87 | for _, tc := range cases { | ||
| 88 | t.Run(tc.name, func(t *testing.T) { | 88 | t.Run(tc.name, func(t *testing.T) { | ||
| 89 | - | s := NewScreenWithHistory(width, tc.height, HistoryConfig{MaxRows: 64, MaxCells: 4096}) | 89 | + | s := NewScreenWithHistory(width, tc.height, HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20}) |
| 90 | s.Write([]byte(tc.input)) | 90 | s.Write([]byte(tc.input)) | ||
| 91 | 91 | ||||
| 92 | bounds := s.LineBounds() | 92 | bounds := s.LineBounds() | ||
| @@ -104,7 +104,7 @@ func TestSoftLinkSurvivesTheScrollItCaused(t *testing.T) { | |||||
| 104 | // wrapped out of the bottom row used to be two unrelated physical rows, and | 104 | // wrapped out of the bottom row used to be two unrelated physical rows, and | ||
| 105 | // widening the pane left it split instead of rejoining it. | 105 | // widening the pane left it split instead of rejoining it. | ||
| 106 | func TestResizeRejoinsALineWrappedOutOfTheBottomRow(t *testing.T) { | 106 | func TestResizeRejoinsALineWrappedOutOfTheBottomRow(t *testing.T) { | ||
| 107 | - | s := NewScreenWithHistory(8, 4, HistoryConfig{MaxRows: 64, MaxCells: 4096}) | 107 | + | s := NewScreenWithHistory(8, 4, HistoryConfig{MaxRows: 64, MaxBytes: 1 << 20}) |
| 108 | s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij")) | 108 | s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij")) | ||
| 109 | 109 | ||||
| 110 | s.Resize(16, 4) | 110 | s.Resize(16, 4) | ||
| @@ -122,14 +122,14 @@ func TestSoftLinkFromABottomRowWrapReachesHistory(t *testing.T) { | |||||
| 122 | width = 8 | 122 | width = 8 | ||
| 123 | height = 4 | 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 | // Wrap out of the bottom row, then push both physical rows into history. | 126 | // Wrap out of the bottom row, then push both physical rows into history. | ||
| 127 | s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij\r\nx\r\ny\r\nz\r\n")) | 127 | s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij\r\nx\r\ny\r\nz\r\n")) | ||
| 128 | 128 | ||||
| 129 | view := s.History().SealAndView() | 129 | view := s.History().SealAndView() | ||
| 130 | require.Equal(t, 7, view.Len(), "history row count") | 130 | require.Equal(t, 7, view.Len(), "history row count") | ||
| 131 | - | require.Equal(t, "abcdefgh", rowString(view.BorrowedRow(5))) | 131 | + | require.Equal(t, "abcdefgh", rowString(view.Row(5))) |
| 132 | require.Equal(t, LineBound{End: 8, Soft: true}, view.Bound(5), "the wrapped row must reach history soft") | 132 | require.Equal(t, LineBound{End: 8, Soft: true}, view.Bound(5), "the wrapped row must reach history soft") | ||
| 133 | - | require.Equal(t, "ij ", rowString(view.BorrowedRow(6))) | 133 | + | require.Equal(t, "ij ", rowString(view.Row(6))) |
| 134 | require.Equal(t, LineBound{End: 2, Soft: false}, view.Bound(6), "its continuation is a hard line end") | 134 | require.Equal(t, LineBound{End: 2, Soft: false}, view.Bound(6), "its continuation is a hard line end") | ||
| 135 | } | 135 | } | ||