refactor(vt): isolate core model and ANSI package
- commit
- 2981612693b6b884519b516e0a007e7ed27bb798
- author
- brice · · 2026-08-09 10:59 UTC
- committer
- brice · · 2026-08-09 12:00 UTC
- parent
- e4fd2d1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |
| 2 | 2 | ||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "cmp" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | // Span identifies a changed horizontal range in a logical frame row. | |
| 4 | 4 | type Span struct { | |
| @@ -204,30 +204,12 @@ func (c DeltaCandidate) Commit(dst *Frame) { | |||
| 204 | 204 | dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count) | |
| 205 | 205 | } | |
| 206 | 206 | 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]) | |
| 210 | 210 | } | |
| 211 | 211 | } | |
| 212 | 212 | ||
| 213 | 213 | 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) | |
| 233 | 215 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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.X | 207 | + | dstRow := dst.Row(span.Y) |
| 208 | - | srcStart := c.frame.lineOffset[span.Y] + span.X | 208 | + | 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.Height | 214 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "reflect" | |
| @@ -169,7 +169,7 @@ func TestNoOpDeltaCandidateDoesNotCloneFrame(t *testing.T) { | |||
| 169 | 169 | if err != nil { | |
| 170 | 170 | t.Fatalf("PlanDelta() error = %v", err) | |
| 171 | 171 | } | |
| 172 | - | if candidate.frame.Cells != nil || candidate.frame.lineOffset != nil { | |
| 172 | + | if candidate.frame.Width != 0 || candidate.frame.Height != 0 || candidate.frame.Cells != nil { | |
| 173 | 173 | t.Fatal("no-op candidate owns frame storage") | |
| 174 | 174 | } | |
| 175 | 175 | ||
| @@ -184,8 +184,7 @@ func TestNoOpDeltaCandidateDoesNotCloneFrame(t *testing.T) { | |||
| 184 | 184 | func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) { | |
| 185 | 185 | committed := testFrame("0000", "1111", "2222", "3333", "4444") | |
| 186 | 186 | committed.ScrollUp(1, 3, 1) | |
| 187 | - | beforeCells := append([]Cell(nil), committed.Cells...) | |
| 188 | - | beforeOffsets := append([]int(nil), committed.lineOffset...) | |
| 187 | + | before := committed.Clone() | |
| 189 | 188 | frame := testFrame("0000", "2222", "3333", "new!", "4444") | |
| 190 | 189 | damage := []Damage{ | |
| 191 | 190 | {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 3, Count: 1}, | |
| @@ -196,11 +195,10 @@ func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) { | |||
| 196 | 195 | t.Fatalf("PlanDelta() error = %v", err) | |
| 197 | 196 | } | |
| 198 | 197 | ||
| 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 | + | } | |
| 204 | 202 | } | |
| 205 | 203 | } | |
| 206 | 204 | ||
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 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 | 24 | 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) | |
| 28 | 29 | } | |
| 29 | 30 | } | |
| 30 | 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 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) | |
| 55 | 43 | } | |
| 56 | 44 | } | |
| 57 | 45 | ||
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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 that | 36 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| @@ -17,11 +17,11 @@ type Capabilities struct { | |||
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | 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 | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | // PreparedDraw owns encoded output and its transactional delta until Commit. | |
| @@ -39,7 +39,7 @@ func (r *Renderer) Reset() { | |||
| 39 | 39 | r.width = 0 | |
| 40 | 40 | r.height = 0 | |
| 41 | 41 | r.shadow = nil | |
| 42 | - | r.lineOffset = nil | |
| 42 | + | r.committed = Frame{} | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | // 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 | 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 | 135 | func (r *Renderer) setCommittedFrame(frame Frame) { | |
| 136 | 136 | r.width = frame.Width | |
| 137 | 137 | r.height = frame.Height | |
| 138 | 138 | r.shadow = frame.Cells | |
| 139 | - | r.lineOffset = frame.lineOffset | |
| 139 | + | r.committed = frame | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | 142 | // copyBytes copies the buffer contents into a fresh byte slice and is used | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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 Capabilities | 20 | + | caps Capabilities |
| 21 | - | width int | 21 | + | width int |
| 22 | - | height int | 22 | + | height int |
| 23 | - | shadow []Cell | 23 | + | shadow []Cell |
| 24 | - | lineOffset []int | 24 | + | 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 = 0 | 39 | r.width = 0 | ||
| 40 | r.height = 0 | 40 | r.height = 0 | ||
| 41 | r.shadow = nil | 41 | r.shadow = nil | ||
| 42 | - | r.lineOffset = nil | 42 | + | r.committed = Frame{} |
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | // Bytes returns the prepared ANSI output. The returned bytes remain valid after | 45 | // 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.Width | 136 | r.width = frame.Width | ||
| 137 | r.height = frame.Height | 137 | r.height = frame.Height | ||
| 138 | r.shadow = frame.Cells | 138 | r.shadow = frame.Cells | ||
| 139 | - | r.lineOffset = frame.lineOffset | 139 | + | r.committed = frame |
| 140 | } | 140 | } | ||
| 141 | 141 | ||||
| 142 | // copyBytes copies the buffer contents into a fresh byte slice and is used | 142 | // 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| @@ -146,7 +146,7 @@ func TestPrepareNoOpDoesNotCloneFrame(t *testing.T) { | |||
| 146 | 146 | if err != nil { | |
| 147 | 147 | t.Fatal(err) | |
| 148 | 148 | } | |
| 149 | - | if prepared.candidate.frame.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 | 150 | t.Fatal("no-op prepared draw owns frame storage") | |
| 151 | 151 | } | |
| 152 | 152 | prepared.Commit() | |
| @@ -1400,8 +1400,7 @@ func TestRendererPrepare(t *testing.T) { | |||
| 1400 | 1400 | ||
| 1401 | 1401 | func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) { | |
| 1402 | 1402 | frame := NewFrame(2, 1) | |
| 1403 | - | committed := NewFrame(2, 1) | |
| 1404 | - | committed.lineOffset = nil | |
| 1403 | + | committed := Frame{Width: 2, Height: 1, Cells: make([]Cell, 2)} | |
| 1405 | 1404 | ||
| 1406 | 1405 | if _, err := PlanDelta(frame, nil, committed, false); err == nil { | |
| 1407 | 1406 | t.Fatal("PlanDelta accepted an invalid committed frame") | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | const ( | |
| 4 | 4 | SyncStartCSI = "\x1b[?2026h" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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. | |
| 2 | 7 | 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 renderer | 7 | package renderer | ||
M pkg/vt/buffer.go +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import renderer "github.com/bnema/vev/pkg/vtcore" | |
| 4 | 4 | ||
| 5 | 5 | // buffer owns the visible VT grid and the physical-row boundaries needed to | |
| 6 | 6 | // reconstruct logical lines. History deliberately is not part of this type: | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | package vt | 1 | 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 to | 5 | // 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 @@ | |||
| 1 | 1 | 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.Damage | |
| 5 | + | type Damage = vtcore.Damage | |
| 6 | + | ||
| 7 | + | type DamageKind = vtcore.DamageKind | |
| 6 | 8 | ||
| 7 | 9 | 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 | |
| 12 | 14 | ) | |
| @@ -1,12 +1,14 @@ | |||||
| 1 | package vt | 1 | 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.Damage | 5 | + | type Damage = vtcore.Damage |
| 6 | + | ||||
| 7 | + | type DamageKind = vtcore.DamageKind | |||
| 6 | 8 | ||||
| 7 | const ( | 9 | const ( | ||
| 8 | - | DamageText = renderer.DamageText | 10 | + | DamageText = vtcore.DamageText |
| 9 | - | DamageClear = renderer.DamageClear | 11 | + | DamageClear = vtcore.DamageClear |
| 10 | - | DamageScrollUp = renderer.DamageScrollUp | 12 | + | DamageScrollUp = vtcore.DamageScrollUp |
| 11 | - | DamageFullRedraw = renderer.DamageFullRedraw | 13 | + | 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. | |
| 2 | 2 | // | |
| 3 | 3 | // It tracks the visible cell grid, damage rectangles, scroll regions, | |
| 4 | 4 | // alternate-screen state, cursor/reporting modes, and common xterm/VT control | |
| @@ -6,8 +6,19 @@ | |||
| 6 | 6 | // cell, and resize or edit operations repair row-boundary splits instead of | |
| 7 | 7 | // leaving orphaned halves. | |
| 8 | 8 | // | |
| 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. | |
| 13 | 24 | 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 control | 4 | // 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 of | 6 | // 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 active | 9 | + | // Stateful values are single-owner and are not internally synchronized. The |
| 10 | - | // visible viewport and renderer-relevant metadata; callers must serialize it | 10 | + | // owner must serialize Write, Resize, Snapshot, and History mutations. Write |
| 11 | - | // with Write, Resize, and host mutations. Terminal history remains available | 11 | + | // 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 vt | 24 | 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] == ©OfRow[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] == ©OfRow[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 ( | |||
| 5 | 5 | "math" | |
| 6 | 6 | "slices" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | renderer "github.com/bnema/vev/pkg/vtcore" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | // 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 configured | 11 | // ErrHistoryRowTooWide is returned when a row cannot fit within the configured | ||
M pkg/vt/history_codec.go +1 -1
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | "slices" | |
| 9 | 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 | 14 | 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 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import renderer "github.com/bnema/vev/pkg/vtcore" | |
| 4 | 4 | ||
| 5 | 5 | // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport | |
| 6 | 6 | // rows that should be replayed after retained terminal history during recovery. | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | package vt | 1 | 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 viewport | 5 | // 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 | |||
| 3 | 3 | import ( | |
| 4 | 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 | 9 | // 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 payloads | 9 | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | ||
M pkg/vt/screen_csi.go +1 -1
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "strconv" | |
| 5 | 5 | "strings" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | renderer "github.com/bnema/vev/pkg/vtcore" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | 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 ( | |||
| 4 | 4 | "strconv" | |
| 5 | 5 | "strings" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | renderer "github.com/bnema/vev/pkg/vtcore" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | 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 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import renderer "github.com/bnema/vev/pkg/vtcore" | |
| 4 | 4 | ||
| 5 | 5 | // maxPendingDamage bounds metadata retained while no render transaction can | |
| 6 | 6 | // acknowledge a screen. Saturation falls back to one exact full redraw. | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | package vt | 1 | 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 can | 5 | // 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 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import renderer "github.com/bnema/vev/pkg/vtcore" | |
| 4 | 4 | ||
| 5 | 5 | type screenState struct { | |
| 6 | 6 | frame renderer.Frame | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | package vt | 1 | 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.Frame | 6 | frame renderer.Frame | ||
M pkg/vt/screen_osc.go +1 -1
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "strconv" | |
| 5 | 5 | "strings" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | renderer "github.com/bnema/vev/pkg/vtcore" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | 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 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import renderer "github.com/bnema/vev/pkg/vtcore" | |
| 4 | 4 | ||
| 5 | 5 | func (s *Screen) Resize(width, height int) { | |
| 6 | 6 | if width == s.Frame.Width && height == s.Frame.Height { | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | package vt | 1 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | renderer "github.com/bnema/vev/pkg/vtcore" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | // 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 cursor | 9 | // 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 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import renderer "github.com/bnema/vev/pkg/vtcore" | |
| 4 | 4 | ||
| 5 | 5 | // CursorSnapshot is the cursor state captured with a visible screen snapshot. | |
| 6 | 6 | type CursorSnapshot struct { | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | package vt | 1 | 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 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import renderer "github.com/bnema/vev/pkg/vtcore" | |
| 4 | 4 | ||
| 5 | 5 | func (s *Screen) putRune(r rune) { | |
| 6 | 6 | switch r { | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | package vt | 1 | 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 | |
| 2 | 2 | ||
| 3 | 3 | // Cell is a single terminal grid cell. | |
| 4 | 4 | // | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | type DamageKind int | |
| 4 | 4 | ||
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | package vtcore |
| 2 | 2 | ||||
| 3 | type DamageKind int | 3 | 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 | |
| 2 | 2 | ||
| 3 | 3 | import "fmt" | |
| 4 | 4 | ||
| @@ -40,6 +40,40 @@ func (f Frame) Clone() Frame { | |||
| 40 | 40 | 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 | 77 | func (f Frame) Validate() error { | |
| 44 | 78 | if f.Width <= 0 || f.Height <= 0 { | |
| 45 | 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 | 151 | // 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. | |
| 120 | 154 | func (f Frame) ScrollDown(top, bottom, n int) { | |
| 121 | 155 | for ; n > 0; n-- { | |
| 122 | 156 | recycled := f.lineOffset[bottom] | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | package vtcore |
| 2 | 2 | ||||
| 3 | import "fmt" | 3 | import "fmt" | ||
| 4 | 4 | ||||
| @@ -40,6 +40,40 @@ func (f Frame) Clone() Frame { | |||||
| 40 | return clone | 40 | 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 by | 151 | // 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 | 152 | + | // 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 | |
| 2 | 2 | ||
| 3 | 3 | import "unicode" | |
| 4 | 4 | ||
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | type RGB struct { | |
| 4 | 4 | R uint8 | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package renderer | 1 | + | package vtcore |
| 2 | 2 | ||||
| 3 | type RGB struct { | 3 | type RGB struct { | ||
| 4 | R uint8 | 4 | R uint8 | ||