detached 1e4fbe6

refactor(vt): isolate core model and ANSI package

commit
1e4fbe6028372a9c08895aadfa60e5da69660f37
author
brice · · 2026-08-09 10:59 UTC
committer
brice · · 2026-08-10 05:15 UTC
parent
e9e3f59
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
50 files · +911 -110
A docs/vt-extraction-preparation.md +41 -0
@@ -0,0 +1,41 @@
1+# VT extraction preparation checkpoint
2+
3+The extraction boundary is ready for standalone ownership. Compatibility
4+coverage and the first dependency-inversion step are complete; VT behavior,
5+renderer output, wire bytes, transport, ACK/rebase, and cursor-tail policy remain
6+unchanged.
7+
8+## Evidence
9+
10+- The feature worktree was created from a clean checkout.
11+- `make test` passes (`go test ./... -race`).
12+- `make lint` passes (`goimports` has no changes and `go vet ./...` passes).
13+- External-package contract tests pass under `-race` for parsing, callbacks,
14+ borrowing, snapshots, history views, stable chunk identity, and single-owner
15+ usage.
16+- VTH3 golden vectors round-trip byte-for-byte across empty, plain,
17+ continuation, styled/RGB, bounded, and line-bound cases. Truncation,
18+ corruption, invalid fields, and trailing bytes are rejected.
19+- The VEVS v4 restore envelope golden decodes through the existing restore path,
20+ including sealed history, tail history, and recovery transcript blobs.
21+ Truncated, corrupted, trailing, and malformed nested VT payloads are rejected.
22+- Representative VT/history/snapshot benchmarks pass; benchmark output is
23+ retained in the implementation run rather than committed as machine-specific
24+ data.
25+- `git diff --check` passes.
26+- `pkg/vtcore` owns the frontend-neutral Cell, Style, RGB, Frame, Damage, and
27+ RuneWidth model; `pkg/vt` has no production dependency on the ANSI package.
28+- `pkg/ansi` owns ANSI output planning and encoding and consumes `vtcore`; the
29+ old `pkg/renderer` path forwards to it without a second implementation owner.
30+- A core-only frame consumer test builds ANSI output without importing the VT
31+ parser.
32+
33+## Scope guard
34+
35+The production changes in this checkpoint only move model ownership behind an
36+explicit core package and adapt ANSI delta commits to the core Frame API. No VT
37+parser behavior, VTH3/VEVS bytes, or wire/protocol definition changed. The
38+compatibility import path is a temporary cutover bridge; the standalone release
39+must not retain it or a local module replacement. Any unrelated behavior, wire,
40+or dependency change discovered while extracting the standalone module must be
41+split into a separate plan.
@@ -0,0 +1,41 @@
1+# VT extraction preparation checkpoint
2+
3+The extraction boundary is ready for standalone ownership. Compatibility
4+coverage and the first dependency-inversion step are complete; VT behavior,
5+renderer output, wire bytes, transport, ACK/rebase, and cursor-tail policy remain
6+unchanged.
7+
8+## Evidence
9+
10+- The feature worktree was created from a clean checkout.
11+- `make test` passes (`go test ./... -race`).
12+- `make lint` passes (`goimports` has no changes and `go vet ./...` passes).
13+- External-package contract tests pass under `-race` for parsing, callbacks,
14+ borrowing, snapshots, history views, stable chunk identity, and single-owner
15+ usage.
16+- VTH3 golden vectors round-trip byte-for-byte across empty, plain,
17+ continuation, styled/RGB, bounded, and line-bound cases. Truncation,
18+ corruption, invalid fields, and trailing bytes are rejected.
19+- The VEVS v4 restore envelope golden decodes through the existing restore path,
20+ including sealed history, tail history, and recovery transcript blobs.
21+ Truncated, corrupted, trailing, and malformed nested VT payloads are rejected.
22+- Representative VT/history/snapshot benchmarks pass; benchmark output is
23+ retained in the implementation run rather than committed as machine-specific
24+ data.
25+- `git diff --check` passes.
26+- `pkg/vtcore` owns the frontend-neutral Cell, Style, RGB, Frame, Damage, and
27+ RuneWidth model; `pkg/vt` has no production dependency on the ANSI package.
28+- `pkg/ansi` owns ANSI output planning and encoding and consumes `vtcore`; the
29+ old `pkg/renderer` path forwards to it without a second implementation owner.
30+- A core-only frame consumer test builds ANSI output without importing the VT
31+ parser.
32+
33+## Scope guard
34+
35+The production changes in this checkpoint only move model ownership behind an
36+explicit core package and adapt ANSI delta commits to the core Frame API. No VT
37+parser behavior, VTH3/VEVS bytes, or wire/protocol definition changed. The
38+compatibility import path is a temporary cutover bridge; the standalone release
39+must not retain it or a local module replacement. Any unrelated behavior, wire,
40+or dependency change discovered while extracting the standalone module must be
41+split into a separate plan.
A internal/usecase/snapshot/vevs_v4_golden_test.go +109 -0
@@ -0,0 +1,109 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "encoding/hex"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/bnema/vev/pkg/vt"
10+)
11+
12+func TestVEVSV4GoldenRestoreCompatibility(t *testing.T) {
13+ const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100"
14+ data, err := hex.DecodeString(golden)
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+
19+ got, err := Unmarshal(data)
20+ if err != nil {
21+ t.Fatalf("Unmarshal golden VEVS: %v", err)
22+ }
23+ if got.Name != "restore" || got.CreatedAt != 123 || len(got.Tabs) != 1 {
24+ t.Fatalf("session header = %#v", got)
25+ }
26+ tab := got.Tabs[0]
27+ if tab.StableID != "tab" || tab.Cols != 80 || tab.Rows != 24 || tab.NextPaneID != 2 || tab.Focus != layout.PaneID("p") || len(tab.Panes) != 1 {
28+ t.Fatalf("tab header = %#v", tab)
29+ }
30+ pane := tab.Panes[0]
31+ if pane.ID != layout.PaneID("p") || pane.StableID != "stable" || pane.Cwd != "/tmp" || pane.Process != nil || len(pane.SealedChunks) != 1 {
32+ t.Fatalf("pane header = %#v", pane)
33+ }
34+
35+ sealed, err := vt.UnmarshalHistory(pane.SealedChunks[0])
36+ if err != nil {
37+ t.Fatalf("sealed history: %v", err)
38+ }
39+ if sealed.RowID(0) != 41 || sealed.Row(0)[0].Rune != 'A' {
40+ t.Fatalf("sealed history = row %d %#v", sealed.RowID(0), sealed.Row(0))
41+ }
42+ tail, err := vt.UnmarshalHistory(pane.Tail)
43+ if err != nil {
44+ t.Fatalf("history tail: %v", err)
45+ }
46+ if tail.Len() != 0 || tail.NextRowID() != 42 {
47+ t.Fatalf("history tail = len %d next %d", tail.Len(), tail.NextRowID())
48+ }
49+ transcript, err := vt.UnmarshalHistory(pane.Transcript)
50+ if err != nil {
51+ t.Fatalf("recovery transcript: %v", err)
52+ }
53+ if transcript.RowID(0) != 42 || transcript.Row(0)[0].Rune != '界' {
54+ t.Fatalf("recovery transcript = row %d %#v", transcript.RowID(0), transcript.Row(0))
55+ }
56+
57+ screen, err := vt.NewScreenWithRecoveryTranscript(8, 2, vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, pane.SealedChunks, pane.Tail, pane.Transcript)
58+ if err != nil {
59+ t.Fatalf("restore VT history: %v", err)
60+ }
61+ view := screen.History().View()
62+ if view.Len() != 2 || view.RowID(0) != 41 || view.RowID(1) != 42 || view.NextRowID() != 43 {
63+ t.Fatalf("restored history = len %d IDs %d/%d next %d", view.Len(), view.RowID(0), view.RowID(1), view.NextRowID())
64+ }
65+}
66+
67+func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) {
68+ const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100"
69+ valid, err := hex.DecodeString(golden)
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ mutate := func(fn func([]byte)) []byte {
74+ out := append([]byte(nil), valid...)
75+ fn(out)
76+ return out
77+ }
78+ cases := []struct {
79+ name string
80+ data []byte
81+ }{
82+ {name: "truncated", data: valid[:len(valid)-1]},
83+ {name: "trailing bytes", data: append(append([]byte(nil), valid...), 0)},
84+ {name: "bad magic", data: mutate(func(data []byte) { data[0] ^= 0xff })},
85+ {name: "bad version", data: mutate(func(data []byte) { data[4] = 3 })},
86+ {name: "bad flags", data: mutate(func(data []byte) { data[7] = 1 })},
87+ {name: "bad crc", data: mutate(func(data []byte) { data[15] ^= 0xff })},
88+ }
89+
90+ body := append([]byte(nil), valid[16:]...)
91+ if offset := bytes.Index(body, []byte("VTH1")); offset < 0 {
92+ t.Fatal("golden has no VT blob")
93+ } else {
94+ body[offset+4] = 2
95+ cases = append(cases, struct {
96+ name string
97+ data []byte
98+ }{name: "bad nested VT version", data: v4Envelope(4, 0, body)})
99+ }
100+
101+ for _, test := range cases {
102+ t.Run(test.name, func(t *testing.T) {
103+ reject(t, test.data, nil)
104+ })
105+ }
106+ for length := 0; length < len(valid); length++ {
107+ reject(t, valid[:length], nil)
108+ }
109+}
@@ -0,0 +1,109 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "encoding/hex"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/usecase/layout"
9+ "github.com/bnema/vev/pkg/vt"
10+)
11+
12+func TestVEVSV4GoldenRestoreCompatibility(t *testing.T) {
13+ const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100"
14+ data, err := hex.DecodeString(golden)
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+
19+ got, err := Unmarshal(data)
20+ if err != nil {
21+ t.Fatalf("Unmarshal golden VEVS: %v", err)
22+ }
23+ if got.Name != "restore" || got.CreatedAt != 123 || len(got.Tabs) != 1 {
24+ t.Fatalf("session header = %#v", got)
25+ }
26+ tab := got.Tabs[0]
27+ if tab.StableID != "tab" || tab.Cols != 80 || tab.Rows != 24 || tab.NextPaneID != 2 || tab.Focus != layout.PaneID("p") || len(tab.Panes) != 1 {
28+ t.Fatalf("tab header = %#v", tab)
29+ }
30+ pane := tab.Panes[0]
31+ if pane.ID != layout.PaneID("p") || pane.StableID != "stable" || pane.Cwd != "/tmp" || pane.Process != nil || len(pane.SealedChunks) != 1 {
32+ t.Fatalf("pane header = %#v", pane)
33+ }
34+
35+ sealed, err := vt.UnmarshalHistory(pane.SealedChunks[0])
36+ if err != nil {
37+ t.Fatalf("sealed history: %v", err)
38+ }
39+ if sealed.RowID(0) != 41 || sealed.Row(0)[0].Rune != 'A' {
40+ t.Fatalf("sealed history = row %d %#v", sealed.RowID(0), sealed.Row(0))
41+ }
42+ tail, err := vt.UnmarshalHistory(pane.Tail)
43+ if err != nil {
44+ t.Fatalf("history tail: %v", err)
45+ }
46+ if tail.Len() != 0 || tail.NextRowID() != 42 {
47+ t.Fatalf("history tail = len %d next %d", tail.Len(), tail.NextRowID())
48+ }
49+ transcript, err := vt.UnmarshalHistory(pane.Transcript)
50+ if err != nil {
51+ t.Fatalf("recovery transcript: %v", err)
52+ }
53+ if transcript.RowID(0) != 42 || transcript.Row(0)[0].Rune != '界' {
54+ t.Fatalf("recovery transcript = row %d %#v", transcript.RowID(0), transcript.Row(0))
55+ }
56+
57+ screen, err := vt.NewScreenWithRecoveryTranscript(8, 2, vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, pane.SealedChunks, pane.Tail, pane.Transcript)
58+ if err != nil {
59+ t.Fatalf("restore VT history: %v", err)
60+ }
61+ view := screen.History().View()
62+ if view.Len() != 2 || view.RowID(0) != 41 || view.RowID(1) != 42 || view.NextRowID() != 43 {
63+ t.Fatalf("restored history = len %d IDs %d/%d next %d", view.Len(), view.RowID(0), view.RowID(1), view.NextRowID())
64+ }
65+}
66+
67+func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) {
68+ const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100"
69+ valid, err := hex.DecodeString(golden)
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ mutate := func(fn func([]byte)) []byte {
74+ out := append([]byte(nil), valid...)
75+ fn(out)
76+ return out
77+ }
78+ cases := []struct {
79+ name string
80+ data []byte
81+ }{
82+ {name: "truncated", data: valid[:len(valid)-1]},
83+ {name: "trailing bytes", data: append(append([]byte(nil), valid...), 0)},
84+ {name: "bad magic", data: mutate(func(data []byte) { data[0] ^= 0xff })},
85+ {name: "bad version", data: mutate(func(data []byte) { data[4] = 3 })},
86+ {name: "bad flags", data: mutate(func(data []byte) { data[7] = 1 })},
87+ {name: "bad crc", data: mutate(func(data []byte) { data[15] ^= 0xff })},
88+ }
89+
90+ body := append([]byte(nil), valid[16:]...)
91+ if offset := bytes.Index(body, []byte("VTH1")); offset < 0 {
92+ t.Fatal("golden has no VT blob")
93+ } else {
94+ body[offset+4] = 2
95+ cases = append(cases, struct {
96+ name string
97+ data []byte
98+ }{name: "bad nested VT version", data: v4Envelope(4, 0, body)})
99+ }
100+
101+ for _, test := range cases {
102+ t.Run(test.name, func(t *testing.T) {
103+ reject(t, test.data, nil)
104+ })
105+ }
106+ for length := 0; length < len(valid); length++ {
107+ reject(t, valid[:length], nil)
108+ }
109+}
R pkg/renderer/benchmark_test.go → pkg/ansi/benchmark_test.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package ansi
22
33 import "testing"
44
@@ -1,4 +1,4 @@
1-package renderer1+package ansi
2 2
3 import "testing"3 import "testing"
4 4
A pkg/ansi/cell.go +9 -0
@@ -0,0 +1,9 @@
1+package ansi
2+
3+import "github.com/bnema/vev/pkg/vtcore"
4+
5+// Cell is retained as an alias while vev consumers move to vtcore. New
6+// frontend-neutral code should import github.com/bnema/vev/pkg/vtcore.
7+type Cell = vtcore.Cell
8+
9+var BlankCell = vtcore.BlankCell
@@ -0,0 +1,9 @@
1+package ansi
2+
3+import "github.com/bnema/vev/pkg/vtcore"
4+
5+// Cell is retained as an alias while vev consumers move to vtcore. New
6+// frontend-neutral code should import github.com/bnema/vev/pkg/vtcore.
7+type Cell = vtcore.Cell
8+
9+var BlankCell = vtcore.BlankCell
A pkg/ansi/consumer_external_test.go +23 -0
@@ -0,0 +1,23 @@
1+package ansi_test
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/ansi"
8+ "github.com/bnema/vev/pkg/vtcore"
9+)
10+
11+func TestExternalANSIConsumesCoreFrameIndependently(t *testing.T) {
12+ frame := vtcore.NewFrame(2, 1)
13+ frame.Set(0, 0, vtcore.Cell{Rune: 'O', Style: vtcore.DefaultStyle()})
14+ frame.Set(1, 0, vtcore.Cell{Rune: 'K', Style: vtcore.DefaultStyle()})
15+
16+ output, err := ansi.New(ansi.Capabilities{}).Draw(frame, []vtcore.Damage{vtcore.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 core frame content", output)
22+ }
23+}
@@ -0,0 +1,23 @@
1+package ansi_test
2+
3+import (
4+ "bytes"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/ansi"
8+ "github.com/bnema/vev/pkg/vtcore"
9+)
10+
11+func TestExternalANSIConsumesCoreFrameIndependently(t *testing.T) {
12+ frame := vtcore.NewFrame(2, 1)
13+ frame.Set(0, 0, vtcore.Cell{Rune: 'O', Style: vtcore.DefaultStyle()})
14+ frame.Set(1, 0, vtcore.Cell{Rune: 'K', Style: vtcore.DefaultStyle()})
15+
16+ output, err := ansi.New(ansi.Capabilities{}).Draw(frame, []vtcore.Damage{vtcore.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 core frame content", output)
22+ }
23+}
A pkg/ansi/damage.go +16 -0
@@ -0,0 +1,16 @@
1+package ansi
2+
3+import "github.com/bnema/vev/pkg/vtcore"
4+
5+type DamageKind = vtcore.DamageKind
6+
7+type Damage = vtcore.Damage
8+
9+const (
10+ DamageText = vtcore.DamageText
11+ DamageClear = vtcore.DamageClear
12+ DamageScrollUp = vtcore.DamageScrollUp
13+ DamageFullRedraw = vtcore.DamageFullRedraw
14+)
15+
16+var FullRedraw = vtcore.FullRedraw
@@ -0,0 +1,16 @@
1+package ansi
2+
3+import "github.com/bnema/vev/pkg/vtcore"
4+
5+type DamageKind = vtcore.DamageKind
6+
7+type Damage = vtcore.Damage
8+
9+const (
10+ DamageText = vtcore.DamageText
11+ DamageClear = vtcore.DamageClear
12+ DamageScrollUp = vtcore.DamageScrollUp
13+ DamageFullRedraw = vtcore.DamageFullRedraw
14+)
15+
16+var FullRedraw = vtcore.FullRedraw
R pkg/renderer/damage_plan.go → pkg/ansi/damage_plan.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package ansi
22
33 import (
44 "cmp"
@@ -1,4 +1,4 @@
1-package renderer1+package ansi
2 2
3 import (3 import (
4 "cmp"4 "cmp"
R pkg/renderer/damage_plan_test.go → pkg/ansi/damage_plan_test.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package ansi
22
33 import (
44 "math"
@@ -1,4 +1,4 @@
1-package renderer1+package ansi
2 2
3 import (3 import (
4 "math"4 "math"
R pkg/renderer/delta_plan.go → pkg/ansi/delta_plan.go +5 -23 renamed, 87% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package ansi
22
33 // Span identifies a changed horizontal range in a logical frame row.
44 type Span struct {
@@ -204,30 +204,12 @@ func (c DeltaCandidate) Commit(dst *Frame) {
204204 dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)
205205 }
206206 for _, span := range c.Plan.Spans {
207- dstStart := dst.lineOffset[span.Y] + span.X
208- srcStart := c.frame.lineOffset[span.Y] + span.X
209- copy(dst.Cells[dstStart:dstStart+span.Width], c.frame.Cells[srcStart:srcStart+span.Width])
207+ dstRow := dst.Row(span.Y)
208+ srcRow := c.frame.Row(span.Y)
209+ copy(dstRow[span.X:span.X+span.Width], srcRow[span.X:span.X+span.Width])
210210 }
211211 }
212212
213213 func replaceFrame(dst *Frame, src Frame) {
214- sameDimensions := dst.Width == src.Width && dst.Height == src.Height
215- cellCount := src.Width * src.Height
216- if sameDimensions && cap(dst.Cells) >= cellCount {
217- dst.Cells = dst.Cells[:cellCount]
218- } else {
219- dst.Cells = make([]Cell, cellCount)
220- }
221- if sameDimensions && cap(dst.lineOffset) >= src.Height {
222- dst.lineOffset = dst.lineOffset[:src.Height]
223- } else {
224- dst.lineOffset = make([]int, src.Height)
225- }
226- dst.Width = src.Width
227- dst.Height = src.Height
228- for y := range src.Height {
229- start := y * src.Width
230- copy(dst.Cells[start:start+src.Width], src.Row(y))
231- dst.lineOffset[y] = start
232- }
214+ dst.Replace(src)
233215 }
@@ -1,4 +1,4 @@
1-package renderer1+package ansi
2 2
3 // Span identifies a changed horizontal range in a logical frame row.3 // Span identifies a changed horizontal range in a logical frame row.
4 type Span struct {4 type Span struct {
@@ -204,30 +204,12 @@ func (c DeltaCandidate) Commit(dst *Frame) {
204 dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)204 dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)
205 }205 }
206 for _, span := range c.Plan.Spans {206 for _, span := range c.Plan.Spans {
207- dstStart := dst.lineOffset[span.Y] + span.X207+ dstRow := dst.Row(span.Y)
208- srcStart := c.frame.lineOffset[span.Y] + span.X208+ srcRow := c.frame.Row(span.Y)
209- copy(dst.Cells[dstStart:dstStart+span.Width], c.frame.Cells[srcStart:srcStart+span.Width])209+ copy(dstRow[span.X:span.X+span.Width], srcRow[span.X:span.X+span.Width])
210 }210 }
211 }211 }
212 212
213 func replaceFrame(dst *Frame, src Frame) {213 func replaceFrame(dst *Frame, src Frame) {
214- sameDimensions := dst.Width == src.Width && dst.Height == src.Height214+ dst.Replace(src)
215- cellCount := src.Width * src.Height
216- if sameDimensions && cap(dst.Cells) >= cellCount {
217- dst.Cells = dst.Cells[:cellCount]
218- } else {
219- dst.Cells = make([]Cell, cellCount)
220- }
221- if sameDimensions && cap(dst.lineOffset) >= src.Height {
222- dst.lineOffset = dst.lineOffset[:src.Height]
223- } else {
224- dst.lineOffset = make([]int, src.Height)
225- }
226- dst.Width = src.Width
227- dst.Height = src.Height
228- for y := range src.Height {
229- start := y * src.Width
230- copy(dst.Cells[start:start+src.Width], src.Row(y))
231- dst.lineOffset[y] = start
232- }
233 }215 }
R pkg/renderer/delta_plan_test.go → pkg/ansi/delta_plan_test.go +7 -9 renamed, 94% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package ansi
22
33 import (
44 "reflect"
@@ -169,7 +169,7 @@ func TestNoOpDeltaCandidateDoesNotCloneFrame(t *testing.T) {
169169 if err != nil {
170170 t.Fatalf("PlanDelta() error = %v", err)
171171 }
172- if candidate.frame.Cells != nil || candidate.frame.lineOffset != nil {
172+ if candidate.frame.Width != 0 || candidate.frame.Height != 0 || candidate.frame.Cells != nil {
173173 t.Fatal("no-op candidate owns frame storage")
174174 }
175175
@@ -184,8 +184,7 @@ func TestNoOpDeltaCandidateDoesNotCloneFrame(t *testing.T) {
184184 func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) {
185185 committed := testFrame("0000", "1111", "2222", "3333", "4444")
186186 committed.ScrollUp(1, 3, 1)
187- beforeCells := append([]Cell(nil), committed.Cells...)
188- beforeOffsets := append([]int(nil), committed.lineOffset...)
187+ before := committed.Clone()
189188 frame := testFrame("0000", "2222", "3333", "new!", "4444")
190189 damage := []Damage{
191190 {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 3, Count: 1},
@@ -196,11 +195,10 @@ func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) {
196195 t.Fatalf("PlanDelta() error = %v", err)
197196 }
198197
199- if !reflect.DeepEqual(committed.Cells, beforeCells) {
200- t.Fatal("PlanDelta mutated committed cells")
201- }
202- if !reflect.DeepEqual(committed.lineOffset, beforeOffsets) {
203- t.Fatal("PlanDelta mutated committed row offsets")
198+ for y := range committed.Height {
199+ if !reflect.DeepEqual(committed.Row(y), before.Row(y)) {
200+ t.Fatalf("PlanDelta mutated committed row %d", y)
201+ }
204202 }
205203 }
206204
@@ -1,4 +1,4 @@
1-package renderer1+package ansi
2 2
3 import (3 import (
4 "reflect"4 "reflect"
@@ -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.Cells != nil || candidate.frame.lineOffset != nil {172+ if candidate.frame.Width != 0 || candidate.frame.Height != 0 || candidate.frame.Cells != nil {
173 t.Fatal("no-op candidate owns frame storage")173 t.Fatal("no-op candidate owns frame storage")
174 }174 }
175 175
@@ -184,8 +184,7 @@ func TestNoOpDeltaCandidateDoesNotCloneFrame(t *testing.T) {
184 func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) {184 func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) {
185 committed := testFrame("0000", "1111", "2222", "3333", "4444")185 committed := testFrame("0000", "1111", "2222", "3333", "4444")
186 committed.ScrollUp(1, 3, 1)186 committed.ScrollUp(1, 3, 1)
187- beforeCells := append([]Cell(nil), committed.Cells...)187+ before := committed.Clone()
188- beforeOffsets := append([]int(nil), committed.lineOffset...)
189 frame := testFrame("0000", "2222", "3333", "new!", "4444")188 frame := testFrame("0000", "2222", "3333", "new!", "4444")
190 damage := []Damage{189 damage := []Damage{
191 {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 3, Count: 1},190 {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 3, Count: 1},
@@ -196,11 +195,10 @@ func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) {
196 t.Fatalf("PlanDelta() error = %v", err)195 t.Fatalf("PlanDelta() error = %v", err)
197 }196 }
198 197
199- if !reflect.DeepEqual(committed.Cells, beforeCells) {198+ for y := range committed.Height {
200- t.Fatal("PlanDelta mutated committed cells")199+ if !reflect.DeepEqual(committed.Row(y), before.Row(y)) {
201- }200+ t.Fatalf("PlanDelta mutated committed row %d", y)
202- if !reflect.DeepEqual(committed.lineOffset, beforeOffsets) {201+ }
203- t.Fatal("PlanDelta mutated committed row offsets")
204 }202 }
205 }203 }
206 204
A pkg/ansi/doc.go +7 -0
@@ -0,0 +1,7 @@
1+// Package ansi emits ANSI terminal output from the frontend-neutral
2+// github.com/bnema/vev/pkg/vtcore model.
3+//
4+// The package owns output planning, ANSI encoding, and a transactional renderer
5+// shadow. It does not own Cell, Style, RGB, Frame, Damage, or RuneWidth; those
6+// values are aliases during this staged extraction and are owned by vtcore.
7+package ansi
@@ -0,0 +1,7 @@
1+// Package ansi emits ANSI terminal output from the frontend-neutral
2+// github.com/bnema/vev/pkg/vtcore model.
3+//
4+// The package owns output planning, ANSI encoding, and a transactional renderer
5+// shadow. It does not own Cell, Style, RGB, Frame, Damage, or RuneWidth; those
6+// values are aliases during this staged extraction and are owned by vtcore.
7+package ansi
R pkg/renderer/draw_state.go → pkg/ansi/draw_state.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package ansi
22
33 import (
44 "bytes"
@@ -1,4 +1,4 @@
1-package renderer1+package ansi
2 2
3 import (3 import (
4 "bytes"4 "bytes"
A pkg/ansi/frame.go +9 -0
@@ -0,0 +1,9 @@
1+package ansi
2+
3+import "github.com/bnema/vev/pkg/vtcore"
4+
5+// Frame is an alias for the frontend-neutral terminal grid. ANSI rendering
6+// consumes it but does not own its model or storage policy.
7+type Frame = vtcore.Frame
8+
9+var NewFrame = vtcore.NewFrame
@@ -0,0 +1,9 @@
1+package ansi
2+
3+import "github.com/bnema/vev/pkg/vtcore"
4+
5+// Frame is an alias for the frontend-neutral terminal grid. ANSI rendering
6+// consumes it but does not own its model or storage policy.
7+type Frame = vtcore.Frame
8+
9+var NewFrame = vtcore.NewFrame
R pkg/renderer/frame_test.go → pkg/ansi/frame_test.go +13 -25 renamed, 77% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package ansi
22
33 import "testing"
44
@@ -20,11 +20,12 @@ func fillFrame(f Frame, rows []string) {
2020 }
2121 }
2222
23-func TestNewFrameCanonicalOffsets(t *testing.T) {
23+func TestNewFrameHasValidLogicalRows(t *testing.T) {
2424 f := NewFrame(4, 3)
25- for y := range f.Height {
26- if f.lineOffset[y] != y*f.Width {
27- t.Fatalf("lineOffset[%d] = %d, want %d", y, f.lineOffset[y], y*f.Width)
25+ fillFrame(f, []string{"aaaa", "bbbb", "cccc"})
26+ for y, want := range []string{"aaaa", "bbbb", "cccc"} {
27+ if got := rowRunes(f, y); got != want {
28+ t.Fatalf("row %d = %q, want %q", y, got, want)
2829 }
2930 }
3031 if err := f.CheckInvariants(); err != nil {
@@ -32,26 +33,13 @@ func TestNewFrameCanonicalOffsets(t *testing.T) {
3233 }
3334 }
3435
35-// TestCheckInvariantsDetectsBrokenRotation is the RED guard: a rotation that
36-// duplicates a physical offset (the classic off-by-one bug) must be rejected.
37-func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) {
38- f := NewFrame(4, 3)
39- // Corrupt: two logical rows map to the same physical row.
40- f.lineOffset[1] = f.lineOffset[0]
41- if err := f.CheckInvariants(); err == nil {
42- t.Fatal("expected invariant violation for duplicate physical row, got nil")
43- }
44-
45- f = NewFrame(4, 3)
46- f.lineOffset[0] = 1 // not a multiple of width
47- if err := f.CheckInvariants(); err == nil {
48- t.Fatal("expected invariant violation for non-multiple offset, got nil")
49- }
50-
51- f = NewFrame(4, 3)
52- f.lineOffset[2] = 99 // out of range
53- if err := f.CheckInvariants(); err == nil {
54- t.Fatal("expected invariant violation for out-of-range offset, got nil")
36+func TestFrameCloneIsIndependent(t *testing.T) {
37+ f := NewFrame(2, 1)
38+ f.Set(0, 0, Cell{Rune: 'a', Style: DefaultStyle()})
39+ clone := f.Clone()
40+ clone.Set(0, 0, Cell{Rune: 'b', Style: DefaultStyle()})
41+ if got := f.At(0, 0).Rune; got != 'a' {
42+ t.Fatalf("source changed through clone: %q", got)
5543 }
5644 }
5745
@@ -1,4 +1,4 @@
1-package renderer1+package ansi
2 2
3 import "testing"3 import "testing"
4 4
@@ -20,11 +20,12 @@ func fillFrame(f Frame, rows []string) {
20 }20 }
21 }21 }
22 22
23-func TestNewFrameCanonicalOffsets(t *testing.T) {23+func TestNewFrameHasValidLogicalRows(t *testing.T) {
24 f := NewFrame(4, 3)24 f := NewFrame(4, 3)
25- for y := range f.Height {25+ fillFrame(f, []string{"aaaa", "bbbb", "cccc"})
26- if f.lineOffset[y] != y*f.Width {26+ for y, want := range []string{"aaaa", "bbbb", "cccc"} {
27- t.Fatalf("lineOffset[%d] = %d, want %d", y, f.lineOffset[y], y*f.Width)27+ if got := rowRunes(f, y); got != want {
28+ t.Fatalf("row %d = %q, want %q", y, got, want)
28 }29 }
29 }30 }
30 if err := f.CheckInvariants(); err != nil {31 if err := f.CheckInvariants(); err != nil {
@@ -32,26 +33,13 @@ func TestNewFrameCanonicalOffsets(t *testing.T) {
32 }33 }
33 }34 }
34 35
35-// TestCheckInvariantsDetectsBrokenRotation is the RED guard: a rotation that36+func TestFrameCloneIsIndependent(t *testing.T) {
36-// duplicates a physical offset (the classic off-by-one bug) must be rejected.37+ f := NewFrame(2, 1)
37-func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) {38+ f.Set(0, 0, Cell{Rune: 'a', Style: DefaultStyle()})
38- f := NewFrame(4, 3)39+ clone := f.Clone()
39- // Corrupt: two logical rows map to the same physical row.40+ clone.Set(0, 0, Cell{Rune: 'b', Style: DefaultStyle()})
40- f.lineOffset[1] = f.lineOffset[0]41+ if got := f.At(0, 0).Rune; got != 'a' {
41- if err := f.CheckInvariants(); err == nil {42+ t.Fatalf("source changed through clone: %q", got)
42- t.Fatal("expected invariant violation for duplicate physical row, got nil")
43- }
44-
45- f = NewFrame(4, 3)
46- f.lineOffset[0] = 1 // not a multiple of width
47- if err := f.CheckInvariants(); err == nil {
48- t.Fatal("expected invariant violation for non-multiple offset, got nil")
49- }
50-
51- f = NewFrame(4, 3)
52- f.lineOffset[2] = 99 // out of range
53- if err := f.CheckInvariants(); err == nil {
54- t.Fatal("expected invariant violation for out-of-range offset, got nil")
55 }43 }
56 }44 }
57 45
R pkg/renderer/output_min_test.go → pkg/ansi/output_min_test.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package ansi
22
33 import (
44 "bytes"
@@ -1,4 +1,4 @@
1-package renderer1+package ansi
2 2
3 import (3 import (
4 "bytes"4 "bytes"
R pkg/renderer/renderer.go → pkg/ansi/renderer.go +9 -9 renamed, 96% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package ansi
22
33 import (
44 "bytes"
@@ -17,11 +17,11 @@ type Capabilities struct {
1717 }
1818
1919 type Renderer struct {
20- caps Capabilities
21- width int
22- height int
23- shadow []Cell
24- lineOffset []int
20+ caps Capabilities
21+ width int
22+ height int
23+ shadow []Cell
24+ committed Frame
2525 }
2626
2727 // PreparedDraw owns encoded output and its transactional delta until Commit.
@@ -39,7 +39,7 @@ func (r *Renderer) Reset() {
3939 r.width = 0
4040 r.height = 0
4141 r.shadow = nil
42- r.lineOffset = nil
42+ r.committed = Frame{}
4343 }
4444
4545 // Bytes returns the prepared ANSI output. The returned bytes remain valid after
@@ -129,14 +129,14 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
129129 }
130130
131131 func (r *Renderer) committedFrame() Frame {
132- return Frame{Width: r.width, Height: r.height, Cells: r.shadow, lineOffset: r.lineOffset}
132+ return r.committed
133133 }
134134
135135 func (r *Renderer) setCommittedFrame(frame Frame) {
136136 r.width = frame.Width
137137 r.height = frame.Height
138138 r.shadow = frame.Cells
139- r.lineOffset = frame.lineOffset
139+ r.committed = frame
140140 }
141141
142142 // copyBytes copies the buffer contents into a fresh byte slice and is used
@@ -1,4 +1,4 @@
1-package renderer1+package ansi
2 2
3 import (3 import (
4 "bytes"4 "bytes"
@@ -17,11 +17,11 @@ type Capabilities struct {
17 }17 }
18 18
19 type Renderer struct {19 type Renderer struct {
20- caps Capabilities20+ caps Capabilities
21- width int21+ width int
22- height int22+ height int
23- shadow []Cell23+ shadow []Cell
24- lineOffset []int24+ committed Frame
25 }25 }
26 26
27 // PreparedDraw owns encoded output and its transactional delta until Commit.27 // PreparedDraw owns encoded output and its transactional delta until Commit.
@@ -39,7 +39,7 @@ func (r *Renderer) Reset() {
39 r.width = 039 r.width = 0
40 r.height = 040 r.height = 0
41 r.shadow = nil41 r.shadow = nil
42- r.lineOffset = nil42+ r.committed = Frame{}
43 }43 }
44 44
45 // Bytes returns the prepared ANSI output. The returned bytes remain valid after45 // Bytes returns the prepared ANSI output. The returned bytes remain valid after
@@ -129,14 +129,14 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
129 }129 }
130 130
131 func (r *Renderer) committedFrame() Frame {131 func (r *Renderer) committedFrame() Frame {
132- return Frame{Width: r.width, Height: r.height, Cells: r.shadow, lineOffset: r.lineOffset}132+ return r.committed
133 }133 }
134 134
135 func (r *Renderer) setCommittedFrame(frame Frame) {135 func (r *Renderer) setCommittedFrame(frame Frame) {
136 r.width = frame.Width136 r.width = frame.Width
137 r.height = frame.Height137 r.height = frame.Height
138 r.shadow = frame.Cells138 r.shadow = frame.Cells
139- r.lineOffset = frame.lineOffset139+ r.committed = frame
140 }140 }
141 141
142 // copyBytes copies the buffer contents into a fresh byte slice and is used142 // copyBytes copies the buffer contents into a fresh byte slice and is used
R pkg/renderer/renderer_test.go → pkg/ansi/renderer_test.go +3 -4 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package ansi
22
33 import (
44 "bytes"
@@ -146,7 +146,7 @@ func TestPrepareNoOpDoesNotCloneFrame(t *testing.T) {
146146 if err != nil {
147147 t.Fatal(err)
148148 }
149- if prepared.candidate.frame.Cells != nil || prepared.candidate.frame.lineOffset != nil {
149+ if prepared.candidate.frame.Width != 0 || prepared.candidate.frame.Height != 0 || prepared.candidate.frame.Cells != nil {
150150 t.Fatal("no-op prepared draw owns frame storage")
151151 }
152152 prepared.Commit()
@@ -1400,8 +1400,7 @@ func TestRendererPrepare(t *testing.T) {
14001400
14011401 func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {
14021402 frame := NewFrame(2, 1)
1403- committed := NewFrame(2, 1)
1404- committed.lineOffset = nil
1403+ committed := Frame{Width: 2, Height: 1, Cells: make([]Cell, 2)}
14051404
14061405 if _, err := PlanDelta(frame, nil, committed, false); err == nil {
14071406 t.Fatal("PlanDelta accepted an invalid committed frame")
@@ -1,4 +1,4 @@
1-package renderer1+package ansi
2 2
3 import (3 import (
4 "bytes"4 "bytes"
@@ -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.Cells != nil || prepared.candidate.frame.lineOffset != nil {149+ if prepared.candidate.frame.Width != 0 || prepared.candidate.frame.Height != 0 || prepared.candidate.frame.Cells != nil {
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()
@@ -1400,8 +1400,7 @@ func TestRendererPrepare(t *testing.T) {
1400 1400
1401 func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {1401 func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {
1402 frame := NewFrame(2, 1)1402 frame := NewFrame(2, 1)
1403- committed := NewFrame(2, 1)1403+ committed := Frame{Width: 2, Height: 1, Cells: make([]Cell, 2)}
1404- committed.lineOffset = nil
1405 1404
1406 if _, err := PlanDelta(frame, nil, committed, false); err == nil {1405 if _, err := PlanDelta(frame, nil, committed, false); err == nil {
1407 t.Fatal("PlanDelta accepted an invalid committed frame")1406 t.Fatal("PlanDelta accepted an invalid committed frame")
A pkg/ansi/rune_width.go +7 -0
@@ -0,0 +1,7 @@
1+package ansi
2+
3+import "github.com/bnema/vev/pkg/vtcore"
4+
5+// RuneWidth is the terminal cell width function from the frontend-neutral
6+// model package.
7+func RuneWidth(r rune) int { return vtcore.RuneWidth(r) }
@@ -0,0 +1,7 @@
1+package ansi
2+
3+import "github.com/bnema/vev/pkg/vtcore"
4+
5+// RuneWidth is the terminal cell width function from the frontend-neutral
6+// model package.
7+func RuneWidth(r rune) int { return vtcore.RuneWidth(r) }
R pkg/renderer/scroll.go → pkg/ansi/scroll.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package ansi
22
33 import (
44 "bytes"
@@ -1,4 +1,4 @@
1-package renderer1+package ansi
2 2
3 import (3 import (
4 "bytes"4 "bytes"
A pkg/ansi/style.go +24 -0
@@ -0,0 +1,24 @@
1+package ansi
2+
3+import "github.com/bnema/vev/pkg/vtcore"
4+
5+type RGB = vtcore.RGB
6+type StyleAttrs = vtcore.StyleAttrs
7+type UnderlineStyle = vtcore.UnderlineStyle
8+type Style = vtcore.Style
9+
10+const (
11+ AttrDim = vtcore.AttrDim
12+ AttrUnderline = vtcore.AttrUnderline
13+ AttrBlink = vtcore.AttrBlink
14+ AttrStrikethrough = vtcore.AttrStrikethrough
15+
16+ UnderlineNone = vtcore.UnderlineNone
17+ UnderlineSingle = vtcore.UnderlineSingle
18+ UnderlineDouble = vtcore.UnderlineDouble
19+ UnderlineCurly = vtcore.UnderlineCurly
20+ UnderlineDotted = vtcore.UnderlineDotted
21+ UnderlineDashed = vtcore.UnderlineDashed
22+)
23+
24+var DefaultStyle = vtcore.DefaultStyle
@@ -0,0 +1,24 @@
1+package ansi
2+
3+import "github.com/bnema/vev/pkg/vtcore"
4+
5+type RGB = vtcore.RGB
6+type StyleAttrs = vtcore.StyleAttrs
7+type UnderlineStyle = vtcore.UnderlineStyle
8+type Style = vtcore.Style
9+
10+const (
11+ AttrDim = vtcore.AttrDim
12+ AttrUnderline = vtcore.AttrUnderline
13+ AttrBlink = vtcore.AttrBlink
14+ AttrStrikethrough = vtcore.AttrStrikethrough
15+
16+ UnderlineNone = vtcore.UnderlineNone
17+ UnderlineSingle = vtcore.UnderlineSingle
18+ UnderlineDouble = vtcore.UnderlineDouble
19+ UnderlineCurly = vtcore.UnderlineCurly
20+ UnderlineDotted = vtcore.UnderlineDotted
21+ UnderlineDashed = vtcore.UnderlineDashed
22+)
23+
24+var DefaultStyle = vtcore.DefaultStyle
R pkg/renderer/sync_output.go → pkg/ansi/sync_output.go +1 -1 renamed, 95% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package ansi
22
33 const (
44 SyncStartCSI = "\x1b[?2026h"
@@ -1,4 +1,4 @@
1-package renderer1+package ansi
2 2
3 const (3 const (
4 SyncStartCSI = "\x1b[?2026h"4 SyncStartCSI = "\x1b[?2026h"
A pkg/renderer/compat.go +65 -0
@@ -0,0 +1,65 @@
1+package renderer
2+
3+import (
4+ "github.com/bnema/vev/pkg/ansi"
5+ "github.com/bnema/vev/pkg/vtcore"
6+)
7+
8+type Capabilities = ansi.Capabilities
9+type Cell = vtcore.Cell
10+type Damage = vtcore.Damage
11+type DamageKind = vtcore.DamageKind
12+type DeltaCandidate = ansi.DeltaCandidate
13+type DeltaPlan = ansi.DeltaPlan
14+type Frame = vtcore.Frame
15+type PreparedDraw = ansi.PreparedDraw
16+type RGB = vtcore.RGB
17+type Renderer = ansi.Renderer
18+type Scroll = ansi.Scroll
19+type Span = ansi.Span
20+type Style = vtcore.Style
21+type StyleAttrs = vtcore.StyleAttrs
22+type UnderlineStyle = vtcore.UnderlineStyle
23+
24+const (
25+ AttrDim = vtcore.AttrDim
26+ AttrUnderline = vtcore.AttrUnderline
27+ AttrBlink = vtcore.AttrBlink
28+ AttrStrikethrough = vtcore.AttrStrikethrough
29+
30+ DamageText = vtcore.DamageText
31+ DamageClear = vtcore.DamageClear
32+ DamageScrollUp = vtcore.DamageScrollUp
33+ DamageFullRedraw = vtcore.DamageFullRedraw
34+
35+ UnderlineNone = vtcore.UnderlineNone
36+ UnderlineSingle = vtcore.UnderlineSingle
37+ UnderlineDouble = vtcore.UnderlineDouble
38+ UnderlineCurly = vtcore.UnderlineCurly
39+ UnderlineDotted = vtcore.UnderlineDotted
40+ UnderlineDashed = vtcore.UnderlineDashed
41+
42+ SyncStartCSI = ansi.SyncStartCSI
43+ SyncEndCSI = ansi.SyncEndCSI
44+)
45+
46+var (
47+ BlankCell = vtcore.BlankCell
48+ DefaultStyle = vtcore.DefaultStyle
49+ FullRedraw = vtcore.FullRedraw
50+ NewFrame = vtcore.NewFrame
51+)
52+
53+func New(caps Capabilities) *Renderer {
54+ return ansi.New(caps)
55+}
56+
57+func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {
58+ return ansi.PlanDelta(frame, damage, committed, reset)
59+}
60+
61+func RuneWidth(r rune) int { return vtcore.RuneWidth(r) }
62+
63+func WrapSynchronized(content []byte, enabled bool) []byte {
64+ return ansi.WrapSynchronized(content, enabled)
65+}
@@ -0,0 +1,65 @@
1+package renderer
2+
3+import (
4+ "github.com/bnema/vev/pkg/ansi"
5+ "github.com/bnema/vev/pkg/vtcore"
6+)
7+
8+type Capabilities = ansi.Capabilities
9+type Cell = vtcore.Cell
10+type Damage = vtcore.Damage
11+type DamageKind = vtcore.DamageKind
12+type DeltaCandidate = ansi.DeltaCandidate
13+type DeltaPlan = ansi.DeltaPlan
14+type Frame = vtcore.Frame
15+type PreparedDraw = ansi.PreparedDraw
16+type RGB = vtcore.RGB
17+type Renderer = ansi.Renderer
18+type Scroll = ansi.Scroll
19+type Span = ansi.Span
20+type Style = vtcore.Style
21+type StyleAttrs = vtcore.StyleAttrs
22+type UnderlineStyle = vtcore.UnderlineStyle
23+
24+const (
25+ AttrDim = vtcore.AttrDim
26+ AttrUnderline = vtcore.AttrUnderline
27+ AttrBlink = vtcore.AttrBlink
28+ AttrStrikethrough = vtcore.AttrStrikethrough
29+
30+ DamageText = vtcore.DamageText
31+ DamageClear = vtcore.DamageClear
32+ DamageScrollUp = vtcore.DamageScrollUp
33+ DamageFullRedraw = vtcore.DamageFullRedraw
34+
35+ UnderlineNone = vtcore.UnderlineNone
36+ UnderlineSingle = vtcore.UnderlineSingle
37+ UnderlineDouble = vtcore.UnderlineDouble
38+ UnderlineCurly = vtcore.UnderlineCurly
39+ UnderlineDotted = vtcore.UnderlineDotted
40+ UnderlineDashed = vtcore.UnderlineDashed
41+
42+ SyncStartCSI = ansi.SyncStartCSI
43+ SyncEndCSI = ansi.SyncEndCSI
44+)
45+
46+var (
47+ BlankCell = vtcore.BlankCell
48+ DefaultStyle = vtcore.DefaultStyle
49+ FullRedraw = vtcore.FullRedraw
50+ NewFrame = vtcore.NewFrame
51+)
52+
53+func New(caps Capabilities) *Renderer {
54+ return ansi.New(caps)
55+}
56+
57+func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {
58+ return ansi.PlanDelta(frame, damage, committed, reset)
59+}
60+
61+func RuneWidth(r rune) int { return vtcore.RuneWidth(r) }
62+
63+func WrapSynchronized(content []byte, enabled bool) []byte {
64+ return ansi.WrapSynchronized(content, enabled)
65+}
M pkg/renderer/doc.go +6 -1
@@ -1,2 +1,7 @@
1-// Package renderer draws terminal state to the screen.
1+// Package renderer is the pre-extraction import path for the concrete ANSI
2+// renderer. New code should import github.com/bnema/vev/pkg/ansi for output and
3+// github.com/bnema/vev/pkg/vtcore for the terminal model.
4+//
5+// This package contains aliases and forwarding functions only; it is not a
6+// renderer-backend interface or a second implementation owner.
27 package renderer
@@ -1,2 +1,7 @@
1-// Package renderer draws terminal state to the screen.1+// Package renderer is the pre-extraction import path for the concrete ANSI
2+// renderer. New code should import github.com/bnema/vev/pkg/ansi for output and
3+// github.com/bnema/vev/pkg/vtcore for the terminal model.
4+//
5+// This package contains aliases and forwarding functions only; it is not a
6+// renderer-backend interface or a second implementation owner.
2 package renderer7 package renderer
M pkg/vt/buffer.go +1 -1
@@ -1,6 +1,6 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import renderer "github.com/bnema/vev/pkg/vtcore"
44
55 // buffer owns the visible VT grid and the physical-row boundaries needed to
66 // reconstruct logical lines. History deliberately is not part of this type:
@@ -1,6 +1,6 @@
1 package vt1 package vt
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import renderer "github.com/bnema/vev/pkg/vtcore"
4 4
5 // buffer owns the visible VT grid and the physical-row boundaries needed to5 // buffer owns the visible VT grid and the physical-row boundaries needed to
6 // reconstruct logical lines. History deliberately is not part of this type:6 // reconstruct logical lines. History deliberately is not part of this type:
A pkg/vt/core_model.go +36 -0
@@ -0,0 +1,36 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/vtcore"
4+
5+// The public model is re-exported from the frontend-neutral core package while
6+// the extraction is staged. The aliases keep Screen, History, and consumers on
7+// one cell/style/frame representation; vtcore remains the owning package.
8+type Cell = vtcore.Cell
9+type RGB = vtcore.RGB
10+type StyleAttrs = vtcore.StyleAttrs
11+type UnderlineStyle = vtcore.UnderlineStyle
12+type Style = vtcore.Style
13+type Frame = vtcore.Frame
14+
15+const (
16+ AttrDim = vtcore.AttrDim
17+ AttrUnderline = vtcore.AttrUnderline
18+ AttrBlink = vtcore.AttrBlink
19+ AttrStrikethrough = vtcore.AttrStrikethrough
20+
21+ UnderlineNone = vtcore.UnderlineNone
22+ UnderlineSingle = vtcore.UnderlineSingle
23+ UnderlineDouble = vtcore.UnderlineDouble
24+ UnderlineCurly = vtcore.UnderlineCurly
25+ UnderlineDotted = vtcore.UnderlineDotted
26+ UnderlineDashed = vtcore.UnderlineDashed
27+)
28+
29+var (
30+ BlankCell = vtcore.BlankCell
31+ DefaultStyle = vtcore.DefaultStyle
32+ FullRedraw = vtcore.FullRedraw
33+ NewFrame = vtcore.NewFrame
34+)
35+
36+func RuneWidth(r rune) int { return vtcore.RuneWidth(r) }
@@ -0,0 +1,36 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/vtcore"
4+
5+// The public model is re-exported from the frontend-neutral core package while
6+// the extraction is staged. The aliases keep Screen, History, and consumers on
7+// one cell/style/frame representation; vtcore remains the owning package.
8+type Cell = vtcore.Cell
9+type RGB = vtcore.RGB
10+type StyleAttrs = vtcore.StyleAttrs
11+type UnderlineStyle = vtcore.UnderlineStyle
12+type Style = vtcore.Style
13+type Frame = vtcore.Frame
14+
15+const (
16+ AttrDim = vtcore.AttrDim
17+ AttrUnderline = vtcore.AttrUnderline
18+ AttrBlink = vtcore.AttrBlink
19+ AttrStrikethrough = vtcore.AttrStrikethrough
20+
21+ UnderlineNone = vtcore.UnderlineNone
22+ UnderlineSingle = vtcore.UnderlineSingle
23+ UnderlineDouble = vtcore.UnderlineDouble
24+ UnderlineCurly = vtcore.UnderlineCurly
25+ UnderlineDotted = vtcore.UnderlineDotted
26+ UnderlineDashed = vtcore.UnderlineDashed
27+)
28+
29+var (
30+ BlankCell = vtcore.BlankCell
31+ DefaultStyle = vtcore.DefaultStyle
32+ FullRedraw = vtcore.FullRedraw
33+ NewFrame = vtcore.NewFrame
34+)
35+
36+func RuneWidth(r rune) int { return vtcore.RuneWidth(r) }
M pkg/vt/damage.go +8 -6
@@ -1,12 +1,14 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import "github.com/bnema/vev/pkg/vtcore"
44
5-type Damage = renderer.Damage
5+type Damage = vtcore.Damage
6+
7+type DamageKind = vtcore.DamageKind
68
79 const (
8- DamageText = renderer.DamageText
9- DamageClear = renderer.DamageClear
10- DamageScrollUp = renderer.DamageScrollUp
11- DamageFullRedraw = renderer.DamageFullRedraw
10+ DamageText = vtcore.DamageText
11+ DamageClear = vtcore.DamageClear
12+ DamageScrollUp = vtcore.DamageScrollUp
13+ DamageFullRedraw = vtcore.DamageFullRedraw
1214 )
@@ -1,12 +1,14 @@
1 package vt1 package vt
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import "github.com/bnema/vev/pkg/vtcore"
4 4
5-type Damage = renderer.Damage5+type Damage = vtcore.Damage
6+
7+type DamageKind = vtcore.DamageKind
6 8
7 const (9 const (
8- DamageText = renderer.DamageText10+ DamageText = vtcore.DamageText
9- DamageClear = renderer.DamageClear11+ DamageClear = vtcore.DamageClear
10- DamageScrollUp = renderer.DamageScrollUp12+ DamageScrollUp = vtcore.DamageScrollUp
11- DamageFullRedraw = renderer.DamageFullRedraw13+ DamageFullRedraw = vtcore.DamageFullRedraw
12 )14 )
M pkg/vt/doc.go +16 -5
@@ -1,4 +1,4 @@
1-// Package vt implements the server-side terminal emulator core.
1+// Package vt implements the frontend-neutral terminal emulator core.
22 //
33 // It tracks the visible cell grid, damage rectangles, scroll regions,
44 // alternate-screen state, cursor/reporting modes, and common xterm/VT control
@@ -6,8 +6,19 @@
66 // cell, and resize or edit operations repair row-boundary splits instead of
77 // leaving orphaned halves.
88 //
9-// Screen is single-owner. Snapshot returns an owned capture of the active
10-// visible viewport and renderer-relevant metadata; callers must serialize it
11-// with Write, Resize, and host mutations. Terminal history remains available
12-// through HistoryView and HistorySnapshotView rather than ScreenSnapshot.
9+// Stateful values are single-owner and are not internally synchronized. The
10+// owner must serialize Write, Resize, Snapshot, and History mutations. Write
11+// parses synchronously: every callback runs before Write returns and callbacks
12+// are delivered in the order their control sequences are encountered. A line
13+// eviction callback receives a stable row copy; response bytes should be
14+// consumed or copied during the callback. Other callback arguments are values
15+// or strings owned by the callback.
16+//
17+// Snapshot and HistorySnapshotView capture owned state without sealing or
18+// mutating the live owner. Row methods return owned copies. BorrowedRow and
19+// HistoryView.Range expose immutable backing storage: callers must not mutate
20+// it, and Range callbacks must not retain their row after returning. A
21+// HistoryChunk pointer has stable identity for the lifetime of the view, so
22+// consumers can reuse unchanged sealed chunks. Direct Frame.Row access is a
23+// mutable borrow valid only until the frame scrolls or resizes.
1324 package vt
@@ -1,4 +1,4 @@
1-// Package vt implements the server-side terminal emulator core.1+// Package vt implements the frontend-neutral terminal emulator core.
2 //2 //
3 // It tracks the visible cell grid, damage rectangles, scroll regions,3 // It tracks the visible cell grid, damage rectangles, scroll regions,
4 // alternate-screen state, cursor/reporting modes, and common xterm/VT control4 // alternate-screen state, cursor/reporting modes, and common xterm/VT control
@@ -6,8 +6,19 @@
6 // cell, and resize or edit operations repair row-boundary splits instead of6 // cell, and resize or edit operations repair row-boundary splits instead of
7 // leaving orphaned halves.7 // leaving orphaned halves.
8 //8 //
9-// Screen is single-owner. Snapshot returns an owned capture of the active9+// Stateful values are single-owner and are not internally synchronized. The
10-// visible viewport and renderer-relevant metadata; callers must serialize it10+// owner must serialize Write, Resize, Snapshot, and History mutations. Write
11-// with Write, Resize, and host mutations. Terminal history remains available11+// parses synchronously: every callback runs before Write returns and callbacks
12-// through HistoryView and HistorySnapshotView rather than ScreenSnapshot.12+// are delivered in the order their control sequences are encountered. A line
13+// eviction callback receives a stable row copy; response bytes should be
14+// consumed or copied during the callback. Other callback arguments are values
15+// or strings owned by the callback.
16+//
17+// Snapshot and HistorySnapshotView capture owned state without sealing or
18+// mutating the live owner. Row methods return owned copies. BorrowedRow and
19+// HistoryView.Range expose immutable backing storage: callers must not mutate
20+// it, and Range callbacks must not retain their row after returning. A
21+// HistoryChunk pointer has stable identity for the lifetime of the view, so
22+// consumers can reuse unchanged sealed chunks. Direct Frame.Row access is a
23+// mutable borrow valid only until the frame scrolls or resizes.
13 package vt24 package vt
A pkg/vt/extraction_contract_external_test.go +183 -0
@@ -0,0 +1,183 @@
1+package vt_test
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/vt"
8+ "github.com/bnema/vev/pkg/vtcore"
9+)
10+
11+func TestExternalScreenParserPreservesPublicTerminalModel(t *testing.T) {
12+ screen := vt.NewScreen(6, 2)
13+ screen.Write([]byte("A界"))
14+
15+ if got := screen.Frame.At(0, 0); got.Rune != 'A' {
16+ t.Fatalf("ASCII cell = %#v, want A", got)
17+ }
18+ if got := screen.Frame.At(1, 0); got.Rune != '界' || got.Continuation {
19+ t.Fatalf("wide-rune lead cell = %#v, want 界 lead", got)
20+ }
21+ if got := screen.Frame.At(2, 0); !got.Continuation {
22+ t.Fatalf("wide-rune continuation = %#v, want continuation", got)
23+ }
24+ if got := vt.RuneWidth('界'); got != 2 {
25+ t.Fatalf("RuneWidth(界) = %d, want 2", got)
26+ }
27+
28+ // The parser owns incomplete escape sequences across writes, so callers can
29+ // provide arbitrary PTY chunk boundaries.
30+ screen.Write([]byte("\x1b[2;2H\x1b[3"))
31+ screen.Write([]byte("1mX"))
32+ cell := screen.Frame.At(1, 1)
33+ if cell.Rune != 'X' || cell.Style.Foreground != 1 {
34+ t.Fatalf("split ANSI write = %#v, want red X", cell)
35+ }
36+
37+ screen.Write([]byte("\x1b]2;public title\a"))
38+ if got := screen.Snapshot().Title(); got != "public title" {
39+ t.Fatalf("terminal title = %q, want public title", got)
40+ }
41+}
42+
43+func TestExternalCallbacksAreSynchronousAndOrdered(t *testing.T) {
44+ screen := vt.NewScreen(8, 2)
45+ inWrite := false
46+ var events []string
47+ assertInWrite := func(name string) {
48+ if !inWrite {
49+ t.Errorf("%s callback ran after Write returned", name)
50+ }
51+ }
52+
53+ screen.OnBell = func() {
54+ assertInWrite("bell")
55+ events = append(events, "bell")
56+ }
57+ screen.OnResponse = func(response []byte) {
58+ assertInWrite("response")
59+ events = append(events, "response:"+string(response))
60+ }
61+ screen.OnNotify = func(title, body string) {
62+ assertInWrite("notify")
63+ events = append(events, "notify:"+title+":"+body)
64+ }
65+ screen.OnProgress = func(errored bool) {
66+ assertInWrite("progress")
67+ if errored {
68+ events = append(events, "progress:error")
69+ } else {
70+ events = append(events, "progress:clear")
71+ }
72+ }
73+ screen.OnClipboard = func(data string) {
74+ assertInWrite("clipboard")
75+ events = append(events, "clipboard:"+data)
76+ }
77+
78+ inWrite = true
79+ screen.Write([]byte("\a\x1b[5n\x1b]9;body\a\x1b]777;notify;title;body\a" +
80+ "\x1b]9;4;1\a\x1b]9;4;0\a\x1b]9;4;2\a" +
81+ "\x1b]52;c;SGVsbG8=\a"))
82+ inWrite = false
83+
84+ want := []string{
85+ "bell",
86+ "response:\x1b[0n",
87+ "notify::body",
88+ "notify:title:body",
89+ "progress:clear",
90+ "progress:error",
91+ "clipboard:SGVsbG8=",
92+ }
93+ if !reflect.DeepEqual(events, want) {
94+ t.Fatalf("callback events = %#v, want %#v", events, want)
95+ }
96+}
97+
98+func TestExternalSnapshotsAndHistoryRespectOwnershipContracts(t *testing.T) {
99+ screen := vt.NewScreenWithHistory(4, 2, vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})
100+ screen.Write([]byte("A"))
101+ snapshot := screen.Snapshot()
102+
103+ owned := snapshot.Row(0)
104+ owned[0] = vtcore.Cell{Rune: 'X'}
105+ if got := snapshot.Row(0)[0].Rune; got != 'A' {
106+ t.Fatalf("owned snapshot row mutation changed snapshot to %q", got)
107+ }
108+
109+ screen.Write([]byte("\rX"))
110+ if got := snapshot.Row(0)[0].Rune; got != 'A' {
111+ t.Fatalf("snapshot changed after later screen write: %q", got)
112+ }
113+ if got := snapshot.RowIDs(); len(got) != 2 || got[0] == 0 || got[1] == 0 {
114+ t.Fatalf("snapshot row IDs = %#v, want stable nonzero IDs", got)
115+ }
116+ rowIDs := snapshot.RowIDs()
117+ rowIDs[0] = 0
118+ if snapshot.RowID(0) == 0 {
119+ t.Fatal("RowIDs did not return an owned copy")
120+ }
121+
122+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})
123+ appendHistoryRow(t, history, 'a', 10)
124+ appendHistoryRow(t, history, 'b', 11)
125+ before := history.View()
126+ chunk := before.Chunk(0)
127+ if chunk == nil || before.FindRowID(11) != 1 {
128+ t.Fatalf("history view = len %d chunk %#v IDs %d/%d", before.Len(), chunk, before.RowID(0), before.RowID(1))
129+ }
130+
131+ copyOfRow := before.Row(0)
132+ copyOfRow[0] = vtcore.Cell{Rune: 'x'}
133+ if got := before.Row(0)[0].Rune; got != 'a' {
134+ t.Fatalf("owned history row mutation changed view to %q", got)
135+ }
136+ if borrowed := before.BorrowedRow(0); &borrowed[0] == &copyOfRow[0] {
137+ t.Fatal("Row returned borrowed history storage instead of an owned copy")
138+ }
139+
140+ appendHistoryRow(t, history, 'c', 12)
141+ after := history.View()
142+ if after.Chunk(0) != chunk {
143+ t.Fatal("sealed history chunk identity changed after an append")
144+ }
145+ if before.Len() != 2 || before.RowID(0) != 10 || before.RowID(1) != 11 {
146+ t.Fatalf("retained history view changed after append: len=%d IDs=%d/%d", before.Len(), before.RowID(0), before.RowID(1))
147+ }
148+
149+ persistenceView := history.SnapshotView()
150+ appendHistoryRow(t, history, 'd', 13)
151+ if got := persistenceView.Tail().Row(0)[0].Rune; got != 'c' {
152+ t.Fatalf("snapshot tail changed after history append to %q", got)
153+ }
154+}
155+
156+func TestExternalSingleOwnerUsageSerializesMutationAndCapture(t *testing.T) {
157+ screen := vt.NewScreen(4, 1)
158+ operations := make(chan func(), 3)
159+ done := make(chan struct{})
160+ go func() {
161+ defer close(done)
162+ for operation := range operations {
163+ operation()
164+ }
165+ }()
166+
167+ operations <- func() { screen.Write([]byte("A")) }
168+ captured := make(chan vt.ScreenSnapshot, 1)
169+ operations <- func() { captured <- screen.Snapshot() }
170+ close(operations)
171+ <-done
172+
173+ if got := (<-captured).Row(0)[0].Rune; got != 'A' {
174+ t.Fatalf("single-owner capture = %q, want A", got)
175+ }
176+}
177+
178+func appendHistoryRow(t *testing.T, history *vt.History, runeValue rune, id vt.RowID) {
179+ t.Helper()
180+ if err := history.AppendWithID([]vtcore.Cell{{Rune: runeValue}}, vt.LineBound{End: 1}, id); err != nil {
181+ t.Fatalf("AppendWithID(%d): %v", id, err)
182+ }
183+}
@@ -0,0 +1,183 @@
1+package vt_test
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/vt"
8+ "github.com/bnema/vev/pkg/vtcore"
9+)
10+
11+func TestExternalScreenParserPreservesPublicTerminalModel(t *testing.T) {
12+ screen := vt.NewScreen(6, 2)
13+ screen.Write([]byte("A界"))
14+
15+ if got := screen.Frame.At(0, 0); got.Rune != 'A' {
16+ t.Fatalf("ASCII cell = %#v, want A", got)
17+ }
18+ if got := screen.Frame.At(1, 0); got.Rune != '界' || got.Continuation {
19+ t.Fatalf("wide-rune lead cell = %#v, want 界 lead", got)
20+ }
21+ if got := screen.Frame.At(2, 0); !got.Continuation {
22+ t.Fatalf("wide-rune continuation = %#v, want continuation", got)
23+ }
24+ if got := vt.RuneWidth('界'); got != 2 {
25+ t.Fatalf("RuneWidth(界) = %d, want 2", got)
26+ }
27+
28+ // The parser owns incomplete escape sequences across writes, so callers can
29+ // provide arbitrary PTY chunk boundaries.
30+ screen.Write([]byte("\x1b[2;2H\x1b[3"))
31+ screen.Write([]byte("1mX"))
32+ cell := screen.Frame.At(1, 1)
33+ if cell.Rune != 'X' || cell.Style.Foreground != 1 {
34+ t.Fatalf("split ANSI write = %#v, want red X", cell)
35+ }
36+
37+ screen.Write([]byte("\x1b]2;public title\a"))
38+ if got := screen.Snapshot().Title(); got != "public title" {
39+ t.Fatalf("terminal title = %q, want public title", got)
40+ }
41+}
42+
43+func TestExternalCallbacksAreSynchronousAndOrdered(t *testing.T) {
44+ screen := vt.NewScreen(8, 2)
45+ inWrite := false
46+ var events []string
47+ assertInWrite := func(name string) {
48+ if !inWrite {
49+ t.Errorf("%s callback ran after Write returned", name)
50+ }
51+ }
52+
53+ screen.OnBell = func() {
54+ assertInWrite("bell")
55+ events = append(events, "bell")
56+ }
57+ screen.OnResponse = func(response []byte) {
58+ assertInWrite("response")
59+ events = append(events, "response:"+string(response))
60+ }
61+ screen.OnNotify = func(title, body string) {
62+ assertInWrite("notify")
63+ events = append(events, "notify:"+title+":"+body)
64+ }
65+ screen.OnProgress = func(errored bool) {
66+ assertInWrite("progress")
67+ if errored {
68+ events = append(events, "progress:error")
69+ } else {
70+ events = append(events, "progress:clear")
71+ }
72+ }
73+ screen.OnClipboard = func(data string) {
74+ assertInWrite("clipboard")
75+ events = append(events, "clipboard:"+data)
76+ }
77+
78+ inWrite = true
79+ screen.Write([]byte("\a\x1b[5n\x1b]9;body\a\x1b]777;notify;title;body\a" +
80+ "\x1b]9;4;1\a\x1b]9;4;0\a\x1b]9;4;2\a" +
81+ "\x1b]52;c;SGVsbG8=\a"))
82+ inWrite = false
83+
84+ want := []string{
85+ "bell",
86+ "response:\x1b[0n",
87+ "notify::body",
88+ "notify:title:body",
89+ "progress:clear",
90+ "progress:error",
91+ "clipboard:SGVsbG8=",
92+ }
93+ if !reflect.DeepEqual(events, want) {
94+ t.Fatalf("callback events = %#v, want %#v", events, want)
95+ }
96+}
97+
98+func TestExternalSnapshotsAndHistoryRespectOwnershipContracts(t *testing.T) {
99+ screen := vt.NewScreenWithHistory(4, 2, vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})
100+ screen.Write([]byte("A"))
101+ snapshot := screen.Snapshot()
102+
103+ owned := snapshot.Row(0)
104+ owned[0] = vtcore.Cell{Rune: 'X'}
105+ if got := snapshot.Row(0)[0].Rune; got != 'A' {
106+ t.Fatalf("owned snapshot row mutation changed snapshot to %q", got)
107+ }
108+
109+ screen.Write([]byte("\rX"))
110+ if got := snapshot.Row(0)[0].Rune; got != 'A' {
111+ t.Fatalf("snapshot changed after later screen write: %q", got)
112+ }
113+ if got := snapshot.RowIDs(); len(got) != 2 || got[0] == 0 || got[1] == 0 {
114+ t.Fatalf("snapshot row IDs = %#v, want stable nonzero IDs", got)
115+ }
116+ rowIDs := snapshot.RowIDs()
117+ rowIDs[0] = 0
118+ if snapshot.RowID(0) == 0 {
119+ t.Fatal("RowIDs did not return an owned copy")
120+ }
121+
122+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})
123+ appendHistoryRow(t, history, 'a', 10)
124+ appendHistoryRow(t, history, 'b', 11)
125+ before := history.View()
126+ chunk := before.Chunk(0)
127+ if chunk == nil || before.FindRowID(11) != 1 {
128+ t.Fatalf("history view = len %d chunk %#v IDs %d/%d", before.Len(), chunk, before.RowID(0), before.RowID(1))
129+ }
130+
131+ copyOfRow := before.Row(0)
132+ copyOfRow[0] = vtcore.Cell{Rune: 'x'}
133+ if got := before.Row(0)[0].Rune; got != 'a' {
134+ t.Fatalf("owned history row mutation changed view to %q", got)
135+ }
136+ if borrowed := before.BorrowedRow(0); &borrowed[0] == &copyOfRow[0] {
137+ t.Fatal("Row returned borrowed history storage instead of an owned copy")
138+ }
139+
140+ appendHistoryRow(t, history, 'c', 12)
141+ after := history.View()
142+ if after.Chunk(0) != chunk {
143+ t.Fatal("sealed history chunk identity changed after an append")
144+ }
145+ if before.Len() != 2 || before.RowID(0) != 10 || before.RowID(1) != 11 {
146+ t.Fatalf("retained history view changed after append: len=%d IDs=%d/%d", before.Len(), before.RowID(0), before.RowID(1))
147+ }
148+
149+ persistenceView := history.SnapshotView()
150+ appendHistoryRow(t, history, 'd', 13)
151+ if got := persistenceView.Tail().Row(0)[0].Rune; got != 'c' {
152+ t.Fatalf("snapshot tail changed after history append to %q", got)
153+ }
154+}
155+
156+func TestExternalSingleOwnerUsageSerializesMutationAndCapture(t *testing.T) {
157+ screen := vt.NewScreen(4, 1)
158+ operations := make(chan func(), 3)
159+ done := make(chan struct{})
160+ go func() {
161+ defer close(done)
162+ for operation := range operations {
163+ operation()
164+ }
165+ }()
166+
167+ operations <- func() { screen.Write([]byte("A")) }
168+ captured := make(chan vt.ScreenSnapshot, 1)
169+ operations <- func() { captured <- screen.Snapshot() }
170+ close(operations)
171+ <-done
172+
173+ if got := (<-captured).Row(0)[0].Rune; got != 'A' {
174+ t.Fatalf("single-owner capture = %q, want A", got)
175+ }
176+}
177+
178+func appendHistoryRow(t *testing.T, history *vt.History, runeValue rune, id vt.RowID) {
179+ t.Helper()
180+ if err := history.AppendWithID([]vtcore.Cell{{Rune: runeValue}}, vt.LineBound{End: 1}, id); err != nil {
181+ t.Fatalf("AppendWithID(%d): %v", id, err)
182+ }
183+}
A pkg/vt/extraction_line_callback_external_test.go +35 -0
@@ -0,0 +1,35 @@
1+package vt_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/vt"
7+ "github.com/bnema/vev/pkg/vtcore"
8+)
9+
10+func TestExternalLineEvictionCallbackReceivesStableCopy(t *testing.T) {
11+ screen := vt.NewScreen(4, 3)
12+ inWrite := false
13+ var evicted [][]vtcore.Cell
14+ screen.OnLineEvicted = func(row []vtcore.Cell) {
15+ if !inWrite {
16+ t.Error("line eviction callback ran after Write returned")
17+ }
18+ evicted = append(evicted, row)
19+ }
20+
21+ inWrite = true
22+ screen.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\n"))
23+ inWrite = false
24+
25+ if len(evicted) != 1 || len(evicted[0]) != 4 || evicted[0][0].Rune != 'A' {
26+ t.Fatalf("evicted rows = %#v, want one stable AAAA row", evicted)
27+ }
28+ evicted[0][0] = vtcore.Cell{Rune: 'X'}
29+ if got := screen.Frame.At(0, 0).Rune; got != 'B' {
30+ t.Fatalf("mutating callback row changed live frame to %q", got)
31+ }
32+ if got := evicted[0][0].Rune; got != 'X' {
33+ t.Fatalf("callback row was not retained as a stable copy: %q", got)
34+ }
35+}
@@ -0,0 +1,35 @@
1+package vt_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/vt"
7+ "github.com/bnema/vev/pkg/vtcore"
8+)
9+
10+func TestExternalLineEvictionCallbackReceivesStableCopy(t *testing.T) {
11+ screen := vt.NewScreen(4, 3)
12+ inWrite := false
13+ var evicted [][]vtcore.Cell
14+ screen.OnLineEvicted = func(row []vtcore.Cell) {
15+ if !inWrite {
16+ t.Error("line eviction callback ran after Write returned")
17+ }
18+ evicted = append(evicted, row)
19+ }
20+
21+ inWrite = true
22+ screen.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\n"))
23+ inWrite = false
24+
25+ if len(evicted) != 1 || len(evicted[0]) != 4 || evicted[0][0].Rune != 'A' {
26+ t.Fatalf("evicted rows = %#v, want one stable AAAA row", evicted)
27+ }
28+ evicted[0][0] = vtcore.Cell{Rune: 'X'}
29+ if got := screen.Frame.At(0, 0).Rune; got != 'B' {
30+ t.Fatalf("mutating callback row changed live frame to %q", got)
31+ }
32+ if got := evicted[0][0].Rune; got != 'X' {
33+ t.Fatalf("callback row was not retained as a stable copy: %q", got)
34+ }
35+}
M pkg/vt/history.go +1 -1
@@ -5,7 +5,7 @@ import (
55 "math"
66 "slices"
77
8- "github.com/bnema/vev/pkg/renderer"
8+ renderer "github.com/bnema/vev/pkg/vtcore"
99 )
1010
1111 // ErrHistoryRowTooWide is returned when a row cannot fit within the configured
@@ -5,7 +5,7 @@ import (
5 "math"5 "math"
6 "slices"6 "slices"
7 7
8- "github.com/bnema/vev/pkg/renderer"8+ renderer "github.com/bnema/vev/pkg/vtcore"
9 )9 )
10 10
11 // ErrHistoryRowTooWide is returned when a row cannot fit within the configured11 // ErrHistoryRowTooWide is returned when a row cannot fit within the configured
M pkg/vt/history_codec.go +1 -1
@@ -8,7 +8,7 @@ import (
88 "slices"
99 "unicode/utf8"
1010
11- "github.com/bnema/vev/pkg/renderer"
11+ renderer "github.com/bnema/vev/pkg/vtcore"
1212 )
1313
1414 const (
@@ -8,7 +8,7 @@ import (
8 "slices"8 "slices"
9 "unicode/utf8"9 "unicode/utf8"
10 10
11- "github.com/bnema/vev/pkg/renderer"11+ renderer "github.com/bnema/vev/pkg/vtcore"
12 )12 )
13 13
14 const (14 const (
A pkg/vt/history_vth3_golden_external_test.go +150 -0
@@ -0,0 +1,150 @@
1+package vt_test
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "encoding/hex"
7+ "testing"
8+
9+ "github.com/bnema/vev/pkg/vt"
10+ "github.com/bnema/vev/pkg/vtcore"
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 := vtcore.Style{
77+ Bold: true,
78+ Italic: true,
79+ Inverse: true,
80+ Attrs: vtcore.StyleAttrs(0x000f),
81+ Foreground: -7,
82+ Background: 257,
83+ HasForegroundRGB: true,
84+ ForegroundRGB: vtcore.RGB{R: 1, G: 2, B: 3},
85+ HasBackgroundRGB: true,
86+ BackgroundRGB: vtcore.RGB{R: 4, G: 5, B: 6},
87+ UnderlineStyle: vtcore.UnderlineDashed,
88+ UnderlineColor: -9,
89+ HasUnderlineColorRGB: true,
90+ UnderlineColorRGB: vtcore.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(vtcore.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+}
@@ -0,0 +1,150 @@
1+package vt_test
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "encoding/hex"
7+ "testing"
8+
9+ "github.com/bnema/vev/pkg/vt"
10+ "github.com/bnema/vev/pkg/vtcore"
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 := vtcore.Style{
77+ Bold: true,
78+ Italic: true,
79+ Inverse: true,
80+ Attrs: vtcore.StyleAttrs(0x000f),
81+ Foreground: -7,
82+ Background: 257,
83+ HasForegroundRGB: true,
84+ ForegroundRGB: vtcore.RGB{R: 1, G: 2, B: 3},
85+ HasBackgroundRGB: true,
86+ BackgroundRGB: vtcore.RGB{R: 4, G: 5, B: 6},
87+ UnderlineStyle: vtcore.UnderlineDashed,
88+ UnderlineColor: -9,
89+ HasUnderlineColorRGB: true,
90+ UnderlineColorRGB: vtcore.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(vtcore.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 pkg/vt/recovery_transcript.go +1 -1
@@ -1,6 +1,6 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import renderer "github.com/bnema/vev/pkg/vtcore"
44
55 // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport
66 // rows that should be replayed after retained terminal history during recovery.
@@ -1,6 +1,6 @@
1 package vt1 package vt
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import renderer "github.com/bnema/vev/pkg/vtcore"
4 4
5 // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport5 // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport
6 // rows that should be replayed after retained terminal history during recovery.6 // rows that should be replayed after retained terminal history during recovery.
M pkg/vt/screen.go +1 -1
@@ -3,7 +3,7 @@ package vt
33 import (
44 "unicode/utf8"
55
6- "github.com/bnema/vev/pkg/renderer"
6+ renderer "github.com/bnema/vev/pkg/vtcore"
77 )
88
99 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
@@ -3,7 +3,7 @@ package vt
3 import (3 import (
4 "unicode/utf8"4 "unicode/utf8"
5 5
6- "github.com/bnema/vev/pkg/renderer"6+ renderer "github.com/bnema/vev/pkg/vtcore"
7 )7 )
8 8
9 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads9 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
M pkg/vt/screen_csi.go +1 -1
@@ -4,7 +4,7 @@ import (
44 "strconv"
55 "strings"
66
7- "github.com/bnema/vev/pkg/renderer"
7+ renderer "github.com/bnema/vev/pkg/vtcore"
88 )
99
1010 func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) {
@@ -4,7 +4,7 @@ import (
4 "strconv"4 "strconv"
5 "strings"5 "strings"
6 6
7- "github.com/bnema/vev/pkg/renderer"7+ renderer "github.com/bnema/vev/pkg/vtcore"
8 )8 )
9 9
10 func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) {10 func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) {
M pkg/vt/screen_cursor.go +1 -1
@@ -4,7 +4,7 @@ import (
44 "strconv"
55 "strings"
66
7- "github.com/bnema/vev/pkg/renderer"
7+ renderer "github.com/bnema/vev/pkg/vtcore"
88 )
99
1010 type cursorState struct {
@@ -4,7 +4,7 @@ import (
4 "strconv"4 "strconv"
5 "strings"5 "strings"
6 6
7- "github.com/bnema/vev/pkg/renderer"7+ renderer "github.com/bnema/vev/pkg/vtcore"
8 )8 )
9 9
10 type cursorState struct {10 type cursorState struct {
M pkg/vt/screen_damage.go +1 -1
@@ -1,6 +1,6 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import renderer "github.com/bnema/vev/pkg/vtcore"
44
55 // maxPendingDamage bounds metadata retained while no render transaction can
66 // acknowledge a screen. Saturation falls back to one exact full redraw.
@@ -1,6 +1,6 @@
1 package vt1 package vt
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import renderer "github.com/bnema/vev/pkg/vtcore"
4 4
5 // maxPendingDamage bounds metadata retained while no render transaction can5 // maxPendingDamage bounds metadata retained while no render transaction can
6 // acknowledge a screen. Saturation falls back to one exact full redraw.6 // acknowledge a screen. Saturation falls back to one exact full redraw.
M pkg/vt/screen_modes.go +1 -1
@@ -1,6 +1,6 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import renderer "github.com/bnema/vev/pkg/vtcore"
44
55 type screenState struct {
66 frame renderer.Frame
@@ -1,6 +1,6 @@
1 package vt1 package vt
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import renderer "github.com/bnema/vev/pkg/vtcore"
4 4
5 type screenState struct {5 type screenState struct {
6 frame renderer.Frame6 frame renderer.Frame
M pkg/vt/screen_osc.go +1 -1
@@ -4,7 +4,7 @@ import (
44 "strconv"
55 "strings"
66
7- "github.com/bnema/vev/pkg/renderer"
7+ renderer "github.com/bnema/vev/pkg/vtcore"
88 )
99
1010 const (
@@ -4,7 +4,7 @@ import (
4 "strconv"4 "strconv"
5 "strings"5 "strings"
6 6
7- "github.com/bnema/vev/pkg/renderer"7+ renderer "github.com/bnema/vev/pkg/vtcore"
8 )8 )
9 9
10 const (10 const (
M pkg/vt/screen_resize.go +1 -1
@@ -1,6 +1,6 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import renderer "github.com/bnema/vev/pkg/vtcore"
44
55 func (s *Screen) Resize(width, height int) {
66 if width == s.Frame.Width && height == s.Frame.Height {
@@ -1,6 +1,6 @@
1 package vt1 package vt
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import renderer "github.com/bnema/vev/pkg/vtcore"
4 4
5 func (s *Screen) Resize(width, height int) {5 func (s *Screen) Resize(width, height int) {
6 if width == s.Frame.Width && height == s.Frame.Height {6 if width == s.Frame.Width && height == s.Frame.Height {
M pkg/vt/screen_scroll.go +1 -1
@@ -3,7 +3,7 @@ package vt
33 import (
44 "errors"
55
6- "github.com/bnema/vev/pkg/renderer"
6+ renderer "github.com/bnema/vev/pkg/vtcore"
77 )
88
99 // index moves to the next physical row, scrolling the region when the cursor
@@ -3,7 +3,7 @@ package vt
3 import (3 import (
4 "errors"4 "errors"
5 5
6- "github.com/bnema/vev/pkg/renderer"6+ renderer "github.com/bnema/vev/pkg/vtcore"
7 )7 )
8 8
9 // index moves to the next physical row, scrolling the region when the cursor9 // index moves to the next physical row, scrolling the region when the cursor
M pkg/vt/screen_snapshot.go +1 -1
@@ -1,6 +1,6 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import renderer "github.com/bnema/vev/pkg/vtcore"
44
55 // CursorSnapshot is the cursor state captured with a visible screen snapshot.
66 type CursorSnapshot struct {
@@ -1,6 +1,6 @@
1 package vt1 package vt
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import renderer "github.com/bnema/vev/pkg/vtcore"
4 4
5 // CursorSnapshot is the cursor state captured with a visible screen snapshot.5 // CursorSnapshot is the cursor state captured with a visible screen snapshot.
6 type CursorSnapshot struct {6 type CursorSnapshot struct {
M pkg/vt/screen_write.go +1 -1
@@ -1,6 +1,6 @@
11 package vt
22
3-import "github.com/bnema/vev/pkg/renderer"
3+import renderer "github.com/bnema/vev/pkg/vtcore"
44
55 func (s *Screen) putRune(r rune) {
66 switch r {
@@ -1,6 +1,6 @@
1 package vt1 package vt
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import renderer "github.com/bnema/vev/pkg/vtcore"
4 4
5 func (s *Screen) putRune(r rune) {5 func (s *Screen) putRune(r rune) {
6 switch r {6 switch r {
R pkg/renderer/cell.go → pkg/vtcore/cell.go +1 -1 renamed, 97% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package vtcore
22
33 // Cell is a single terminal grid cell.
44 //
@@ -1,4 +1,4 @@
1-package renderer1+package vtcore
2 2
3 // Cell is a single terminal grid cell.3 // Cell is a single terminal grid cell.
4 //4 //
R pkg/renderer/damage.go → pkg/vtcore/damage.go +1 -1 renamed, 94% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package vtcore
22
33 type DamageKind int
44
@@ -1,4 +1,4 @@
1-package renderer1+package vtcore
2 2
3 type DamageKind int3 type DamageKind int
4 4
A pkg/vtcore/doc.go +7 -0
@@ -0,0 +1,7 @@
1+// Package vtcore defines the frontend-neutral terminal model shared by the VT
2+// emulator and concrete ANSI renderers.
3+//
4+// Frame and other mutable values are single-owner. Frame.Row returns a backing
5+// slice that is valid until the next scroll or resize; Frame.Clone returns
6+// independent storage. The package has no frontend, transport, or vev policy.
7+package vtcore
@@ -0,0 +1,7 @@
1+// Package vtcore defines the frontend-neutral terminal model shared by the VT
2+// emulator and concrete ANSI renderers.
3+//
4+// Frame and other mutable values are single-owner. Frame.Row returns a backing
5+// slice that is valid until the next scroll or resize; Frame.Clone returns
6+// independent storage. The package has no frontend, transport, or vev policy.
7+package vtcore
R pkg/renderer/frame.go → pkg/vtcore/frame.go +37 -3 renamed, 80% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package vtcore
22
33 import "fmt"
44
@@ -40,6 +40,40 @@ func (f Frame) Clone() Frame {
4040 return clone
4141 }
4242
43+// Replace copies src into f while reusing canonical storage when dimensions
44+// permit. src is read through logical rows so its physical row rotation is
45+// preserved in the resulting contents without exposing layout internals.
46+func (f *Frame) Replace(src Frame) {
47+ if f == nil {
48+ return
49+ }
50+ if src.Width <= 0 || src.Height <= 0 || len(src.Cells) != src.Width*src.Height {
51+ *f = Frame{}
52+ return
53+ }
54+
55+ sameDimensions := f.Width == src.Width && f.Height == src.Height
56+ cellCount := src.Width * src.Height
57+ if sameDimensions && cap(f.Cells) >= cellCount {
58+ f.Cells = f.Cells[:cellCount]
59+ } else {
60+ f.Cells = make([]Cell, cellCount)
61+ }
62+ if sameDimensions && cap(f.lineOffset) >= src.Height {
63+ f.lineOffset = f.lineOffset[:src.Height]
64+ } else {
65+ f.lineOffset = make([]int, src.Height)
66+ }
67+ f.Width = src.Width
68+ f.Height = src.Height
69+ for y := range f.lineOffset {
70+ f.lineOffset[y] = y * f.Width
71+ }
72+ for y := range src.Height {
73+ copy(f.Row(y), src.Row(y))
74+ }
75+}
76+
4377 func (f Frame) Validate() error {
4478 if f.Width <= 0 || f.Height <= 0 {
4579 return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height)
@@ -115,8 +149,8 @@ func (f Frame) ScrollUp(top, bottom, n int) {
115149 }
116150
117151 // ScrollDown scrolls the logical rows in [top,bottom] down by n lines by
118-// rotating their physical offsets. Rows scrolled off the bottom are recycled to
119-// the top and blanked in place. See ScrollUp for preconditions.
152+// rotating their physical offsets. Rows scrolled off the bottom are recycled
153+// to the top and blanked in place. See ScrollUp for preconditions.
120154 func (f Frame) ScrollDown(top, bottom, n int) {
121155 for ; n > 0; n-- {
122156 recycled := f.lineOffset[bottom]
@@ -1,4 +1,4 @@
1-package renderer1+package vtcore
2 2
3 import "fmt"3 import "fmt"
4 4
@@ -40,6 +40,40 @@ func (f Frame) Clone() Frame {
40 return clone40 return clone
41 }41 }
42 42
43+// Replace copies src into f while reusing canonical storage when dimensions
44+// permit. src is read through logical rows so its physical row rotation is
45+// preserved in the resulting contents without exposing layout internals.
46+func (f *Frame) Replace(src Frame) {
47+ if f == nil {
48+ return
49+ }
50+ if src.Width <= 0 || src.Height <= 0 || len(src.Cells) != src.Width*src.Height {
51+ *f = Frame{}
52+ return
53+ }
54+
55+ sameDimensions := f.Width == src.Width && f.Height == src.Height
56+ cellCount := src.Width * src.Height
57+ if sameDimensions && cap(f.Cells) >= cellCount {
58+ f.Cells = f.Cells[:cellCount]
59+ } else {
60+ f.Cells = make([]Cell, cellCount)
61+ }
62+ if sameDimensions && cap(f.lineOffset) >= src.Height {
63+ f.lineOffset = f.lineOffset[:src.Height]
64+ } else {
65+ f.lineOffset = make([]int, src.Height)
66+ }
67+ f.Width = src.Width
68+ f.Height = src.Height
69+ for y := range f.lineOffset {
70+ f.lineOffset[y] = y * f.Width
71+ }
72+ for y := range src.Height {
73+ copy(f.Row(y), src.Row(y))
74+ }
75+}
76+
43 func (f Frame) Validate() error {77 func (f Frame) Validate() error {
44 if f.Width <= 0 || f.Height <= 0 {78 if f.Width <= 0 || f.Height <= 0 {
45 return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height)79 return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height)
@@ -115,8 +149,8 @@ func (f Frame) ScrollUp(top, bottom, n int) {
115 }149 }
116 150
117 // ScrollDown scrolls the logical rows in [top,bottom] down by n lines by151 // ScrollDown scrolls the logical rows in [top,bottom] down by n lines by
118-// rotating their physical offsets. Rows scrolled off the bottom are recycled to152+// rotating their physical offsets. Rows scrolled off the bottom are recycled
119-// the top and blanked in place. See ScrollUp for preconditions.153+// to the top and blanked in place. See ScrollUp for preconditions.
120 func (f Frame) ScrollDown(top, bottom, n int) {154 func (f Frame) ScrollDown(top, bottom, n int) {
121 for ; n > 0; n-- {155 for ; n > 0; n-- {
122 recycled := f.lineOffset[bottom]156 recycled := f.lineOffset[bottom]
A pkg/vtcore/frame_test.go +61 -0
@@ -0,0 +1,61 @@
1+package vtcore
2+
3+import "testing"
4+
5+func TestFrameCanonicalOffsetsAndInvariants(t *testing.T) {
6+ f := NewFrame(4, 3)
7+ for y := range f.Height {
8+ if f.lineOffset[y] != y*f.Width {
9+ t.Fatalf("lineOffset[%d] = %d, want %d", y, f.lineOffset[y], y*f.Width)
10+ }
11+ }
12+ if err := f.CheckInvariants(); err != nil {
13+ t.Fatalf("fresh frame invariants: %v", err)
14+ }
15+}
16+
17+func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) {
18+ f := NewFrame(4, 3)
19+ f.lineOffset[1] = f.lineOffset[0]
20+ if err := f.CheckInvariants(); err == nil {
21+ t.Fatal("expected duplicate physical row to be rejected")
22+ }
23+
24+ f = NewFrame(4, 3)
25+ f.lineOffset[0] = 1
26+ if err := f.CheckInvariants(); err == nil {
27+ t.Fatal("expected non-multiple offset to be rejected")
28+ }
29+
30+ f = NewFrame(4, 3)
31+ f.lineOffset[2] = 99
32+ if err := f.CheckInvariants(); err == nil {
33+ t.Fatal("expected out-of-range offset to be rejected")
34+ }
35+}
36+
37+func TestReplacePreservesLogicalRows(t *testing.T) {
38+ dst := NewFrame(3, 3)
39+ dst.Set(0, 0, Cell{Rune: 'x'})
40+ dst.ScrollUp(0, 2, 1)
41+
42+ src := NewFrame(3, 3)
43+ for y, row := range []string{"abc", "def", "ghi"} {
44+ for x, r := range row {
45+ src.Set(x, y, Cell{Rune: r})
46+ }
47+ }
48+ src.ScrollUp(0, 2, 1)
49+
50+ dst.Replace(src)
51+ for y, want := range []string{"def", "ghi", " "} {
52+ for x, r := range want {
53+ if got := dst.At(x, y).Rune; got != r {
54+ t.Fatalf("row %d col %d = %q, want %q", y, x, got, r)
55+ }
56+ }
57+ }
58+ if err := dst.CheckInvariants(); err != nil {
59+ t.Fatalf("replaced frame invariants: %v", err)
60+ }
61+}
@@ -0,0 +1,61 @@
1+package vtcore
2+
3+import "testing"
4+
5+func TestFrameCanonicalOffsetsAndInvariants(t *testing.T) {
6+ f := NewFrame(4, 3)
7+ for y := range f.Height {
8+ if f.lineOffset[y] != y*f.Width {
9+ t.Fatalf("lineOffset[%d] = %d, want %d", y, f.lineOffset[y], y*f.Width)
10+ }
11+ }
12+ if err := f.CheckInvariants(); err != nil {
13+ t.Fatalf("fresh frame invariants: %v", err)
14+ }
15+}
16+
17+func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) {
18+ f := NewFrame(4, 3)
19+ f.lineOffset[1] = f.lineOffset[0]
20+ if err := f.CheckInvariants(); err == nil {
21+ t.Fatal("expected duplicate physical row to be rejected")
22+ }
23+
24+ f = NewFrame(4, 3)
25+ f.lineOffset[0] = 1
26+ if err := f.CheckInvariants(); err == nil {
27+ t.Fatal("expected non-multiple offset to be rejected")
28+ }
29+
30+ f = NewFrame(4, 3)
31+ f.lineOffset[2] = 99
32+ if err := f.CheckInvariants(); err == nil {
33+ t.Fatal("expected out-of-range offset to be rejected")
34+ }
35+}
36+
37+func TestReplacePreservesLogicalRows(t *testing.T) {
38+ dst := NewFrame(3, 3)
39+ dst.Set(0, 0, Cell{Rune: 'x'})
40+ dst.ScrollUp(0, 2, 1)
41+
42+ src := NewFrame(3, 3)
43+ for y, row := range []string{"abc", "def", "ghi"} {
44+ for x, r := range row {
45+ src.Set(x, y, Cell{Rune: r})
46+ }
47+ }
48+ src.ScrollUp(0, 2, 1)
49+
50+ dst.Replace(src)
51+ for y, want := range []string{"def", "ghi", " "} {
52+ for x, r := range want {
53+ if got := dst.At(x, y).Rune; got != r {
54+ t.Fatalf("row %d col %d = %q, want %q", y, x, got, r)
55+ }
56+ }
57+ }
58+ if err := dst.CheckInvariants(); err != nil {
59+ t.Fatalf("replaced frame invariants: %v", err)
60+ }
61+}
R pkg/renderer/rune_width.go → pkg/vtcore/rune_width.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package vtcore
22
33 import "unicode"
44
@@ -1,4 +1,4 @@
1-package renderer1+package vtcore
2 2
3 import "unicode"3 import "unicode"
4 4
R pkg/renderer/style.go → pkg/vtcore/style.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package renderer
1+package vtcore
22
33 type RGB struct {
44 R uint8
@@ -1,4 +1,4 @@
1-package renderer1+package vtcore
2 2
3 type RGB struct {3 type RGB struct {
4 R uint84 R uint8

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close