Merge pull request #189 from bnema/refactor/standalone-vev-vt-extraction
refactor(vt): extract VT engine into standalone module
190 files · +358 -14851
A docs/vt-extraction-preparation.md +39 -0
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | # VT extraction preparation checkpoint | |||
| 2 | + | ||||
| 3 | + | The standalone module owns the frontend-neutral VT engine and ANSI renderer; | |||
| 4 | + | vev consumes the released `github.com/bnema/vev-vt v0.2.1` module. 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 | + | - The standalone module owns the frontend-neutral Cell, Style, RGB, Frame, | |||
| 27 | + | Damage, RuneWidth, VT parser, history, snapshots, and ANSI output packages. | |||
| 28 | + | - The cutover has no local VT, core, ANSI, or renderer implementation owner; | |||
| 29 | + | vev resolves the released module through normal module download semantics. | |||
| 30 | + | - A standalone core-only frame consumer test builds ANSI output without | |||
| 31 | + | importing the VT parser. | |||
| 32 | + | ||||
| 33 | + | ## Scope guard | |||
| 34 | + | ||||
| 35 | + | The vev cutover changes only module ownership and import paths; no VT parser | |||
| 36 | + | behavior, VTH3/VEVS bytes, or wire/protocol definition changed. There is no | |||
| 37 | + | committed `replace` directive or compatibility owner in vev. Any unrelated | |||
| 38 | + | behavior, wire, or dependency change discovered while maintaining the standalone | |||
| 39 | + | module must be split into a separate plan. | |||
A docs/vt-extraction-verification.md +44 -0
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | # VT extraction verification evidence | |||
| 2 | + | ||||
| 3 | + | ## Release | |||
| 4 | + | ||||
| 5 | + | - Module: `github.com/bnema/vev-vt v0.2.1` | |||
| 6 | + | - Module checksum: `h1:sb5XWVVIgiYd82KSYb3gHvkHVt9r5oRoEK0hqO1B+Aw=` | |||
| 7 | + | - Go module checksum: `h1:QdV0QI7vUnlAF+7+CiPhThHDpDSkxMEZmCYl/Eo+xCA=` | |||
| 8 | + | - Standalone release commit: `7bd1a3c4` | |||
| 9 | + | - vev cutover commit: `3fd4a02d` | |||
| 10 | + | ||||
| 11 | + | ## Acceptance evidence | |||
| 12 | + | ||||
| 13 | + | - vev: `make test`, `make lint`, `go mod verify`, and clean-checkout module | |||
| 14 | + | resolution pass without a `replace` directive. | |||
| 15 | + | - Standalone module: tests, race tests, `go vet`, `golangci-lint`, external | |||
| 16 | + | consumer smoke, import-boundary checks, and signed release tag pass. | |||
| 17 | + | - Fuzz smoke passes for VT input, ANSI rendering, VTH3 decoding, and VEVS v4 | |||
| 18 | + | restore decoding. Seeds include escape sequences, Unicode, clipboard input, | |||
| 19 | + | malformed history, and the VEVS restore golden. | |||
| 20 | + | - VTH3 and VEVS fixtures remain byte-compatible. VEVS framing and session policy | |||
| 21 | + | remain vev-owned. | |||
| 22 | + | - History/codec benchmark allocations and bytes are unchanged in the selected | |||
| 23 | + | pre/post comparison. Representative daemon frame benchmarks remained within | |||
| 24 | + | normal run variance; no output-byte regression was observed. | |||
| 25 | + | ||||
| 26 | + | ## Diff classification | |||
| 27 | + | ||||
| 28 | + | - **Extraction:** standalone module release, vev module dependency cutover, and | |||
| 29 | + | removal of the in-repository VT/core/ANSI owners. | |||
| 30 | + | - **Tests and fixtures:** external ownership contracts, VTH3 golden vectors, | |||
| 31 | + | VEVS restore fixtures, and fuzz smoke coverage. | |||
| 32 | + | - **Documentation:** ownership, callback, borrowing, release, and verification | |||
| 33 | + | records. | |||
| 34 | + | - **CI:** standalone formatting, lint, race, fuzz, benchmark, and consumer jobs. | |||
| 35 | + | ||||
| 36 | + | ## Risks and follow-up | |||
| 37 | + | ||||
| 38 | + | - A broad baseline run including `BenchmarkScreenResizeReflowViewport` still | |||
| 39 | + | exposes the pre-existing `invalid history row ID` benchmark panic. The | |||
| 40 | + | selected extraction comparison excludes that benchmark rather than masking it. | |||
| 41 | + | - There is no dedicated standalone `RuneWidth` benchmark; width behavior is | |||
| 42 | + | covered by public contract tests and the screen/parser benchmark suite. | |||
| 43 | + | - The outer VEVS codec intentionally remains in vev and must not move into the | |||
| 44 | + | standalone module. | |||
M go.mod +4 -1
| @@ -2,7 +2,10 @@ module github.com/bnema/vev | |||||
| 2 | 2 | ||||
| 3 | go 1.26 | 3 | go 1.26 | ||
| 4 | 4 | ||||
| 5 | - | require github.com/stretchr/testify v1.11.1 | 5 | + | require ( |
| 6 | + | github.com/bnema/vev-vt v0.2.1 | |||
| 7 | + | github.com/stretchr/testify v1.11.1 | |||
| 8 | + | ) | |||
| 6 | 9 | ||||
| 7 | require ( | 10 | require ( | ||
| 8 | github.com/davecgh/go-spew v1.1.1 // indirect | 11 | github.com/davecgh/go-spew v1.1.1 // indirect | ||
M go.sum +2 -0 generated file: collapsed
M internal/adapters/remote/preview_test.go +1 -1
| @@ -12,10 +12,10 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | 14 | ||||
| 15 | + | renderer "github.com/bnema/vev-vt" | |||
| 15 | "github.com/bnema/vev/internal/adapters/sshstdio" | 16 | "github.com/bnema/vev/internal/adapters/sshstdio" | ||
| 16 | "github.com/bnema/vev/internal/domain" | 17 | "github.com/bnema/vev/internal/domain" | ||
| 17 | "github.com/bnema/vev/internal/ports" | 18 | "github.com/bnema/vev/internal/ports" | ||
| 18 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | func previewClientTargetForTest() domain.RemoteSessionTarget { | 21 | func previewClientTargetForTest() domain.RemoteSessionTarget { | ||
M internal/adapters/snapshot/test_helpers_test.go +2 -2
| @@ -4,8 +4,8 @@ import ( | |||||
| 4 | "path/filepath" | 4 | "path/filepath" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | 7 | + | vt "github.com/bnema/vev-vt" |
| 8 | - | "github.com/bnema/vev/pkg/vt" | 8 | + | renderer "github.com/bnema/vev-vt/ansi" |
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | func privateDir(t *testing.T) string { | 11 | func privateDir(t *testing.T) string { | ||
M internal/app/daemon_integration_test.go +1 -1
| @@ -19,6 +19,7 @@ import ( | |||||
| 19 | 19 | ||||
| 20 | "github.com/stretchr/testify/require" | 20 | "github.com/stretchr/testify/require" | ||
| 21 | 21 | ||||
| 22 | + | vt "github.com/bnema/vev-vt" | |||
| 22 | "github.com/bnema/vev/internal/adapters/clock" | 23 | "github.com/bnema/vev/internal/adapters/clock" | ||
| 23 | "github.com/bnema/vev/internal/adapters/ipc" | 24 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 24 | "github.com/bnema/vev/internal/adapters/lifecycle" | 25 | "github.com/bnema/vev/internal/adapters/lifecycle" | ||
| @@ -29,7 +30,6 @@ import ( | |||||
| 29 | "github.com/bnema/vev/internal/ports" | 30 | "github.com/bnema/vev/internal/ports" | ||
| 30 | "github.com/bnema/vev/internal/usecase/daemon" | 31 | "github.com/bnema/vev/internal/usecase/daemon" | ||
| 31 | "github.com/bnema/vev/internal/usecase/recovery" | 32 | "github.com/bnema/vev/internal/usecase/recovery" | ||
| 32 | - | "github.com/bnema/vev/pkg/vt" | |||
| 33 | ) | 33 | ) | ||
| 34 | 34 | ||||
| 35 | // These integration tests drive the real daemon over a real unix socket with a | 35 | // These integration tests drive the real daemon over a real unix socket with a | ||
M internal/ports/frame.go +1 -1
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "time" | 4 | "time" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | // HandshakeTimeout bounds every transport handshake from connect through the | 10 | // HandshakeTimeout bounds every transport handshake from connect through the | ||
M internal/ports/remote_preview.go +1 -1
| @@ -6,8 +6,8 @@ import ( | |||||
| 6 | "unicode" | 6 | "unicode" | ||
| 7 | "unicode/utf8" | 7 | "unicode/utf8" | ||
| 8 | 8 | ||||
| 9 | + | renderer "github.com/bnema/vev-vt" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | const ( | 13 | const ( | ||
M internal/ports/remote_preview_hardening_test.go +1 -1
| @@ -6,8 +6,8 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | + | renderer "github.com/bnema/vev-vt" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | const ( | 13 | const ( | ||
M internal/ports/remote_preview_test.go +1 -1
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func previewTargetForTest() domain.RemoteSessionTarget { | 10 | func previewTargetForTest() domain.RemoteSessionTarget { | ||
M internal/ports/wire.go +1 -1
| @@ -12,8 +12,8 @@ import ( | |||||
| 12 | "fmt" | 12 | "fmt" | ||
| 13 | "math" | 13 | "math" | ||
| 14 | 14 | ||||
| 15 | + | renderer "github.com/bnema/vev-vt" | |||
| 15 | "github.com/bnema/vev/internal/domain" | 16 | "github.com/bnema/vev/internal/domain" | ||
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | // errShortPayload is returned when a payload ends before a required field | 19 | // errShortPayload is returned when a payload ends before a required field | ||
M internal/ports/wire_test.go +1 -1
| @@ -7,8 +7,8 @@ import ( | |||||
| 7 | "reflect" | 7 | "reflect" | ||
| 8 | "testing" | 8 | "testing" | ||
| 9 | 9 | ||||
| 10 | + | renderer "github.com/bnema/vev-vt" | |||
| 10 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
M internal/usecase/client/client.go +1 -1
| @@ -19,10 +19,10 @@ import ( | |||||
| 19 | "sync/atomic" | 19 | "sync/atomic" | ||
| 20 | "time" | 20 | "time" | ||
| 21 | 21 | ||||
| 22 | + | renderer "github.com/bnema/vev-vt" | |||
| 22 | "github.com/bnema/vev/internal/domain" | 23 | "github.com/bnema/vev/internal/domain" | ||
| 23 | "github.com/bnema/vev/internal/ports" | 24 | "github.com/bnema/vev/internal/ports" | ||
| 24 | "github.com/bnema/vev/internal/usecase/theme" | 25 | "github.com/bnema/vev/internal/usecase/theme" | ||
| 25 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 26 | ) | 26 | ) | ||
| 27 | 27 | ||||
| 28 | // ProtocolError is a session- or protocol-level failure reported by the | 28 | // ProtocolError is a session- or protocol-level failure reported by the | ||
M internal/usecase/client/palette_attach_generation_test.go +1 -1
| @@ -13,9 +13,9 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 15 | 15 | ||||
| 16 | + | renderer "github.com/bnema/vev-vt" | |||
| 16 | "github.com/bnema/vev/internal/domain" | 17 | "github.com/bnema/vev/internal/domain" | ||
| 17 | "github.com/bnema/vev/internal/ports" | 18 | "github.com/bnema/vev/internal/ports" | ||
| 18 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | // attachPaletteClock deliberately exposes each independently-created timer. | 21 | // attachPaletteClock deliberately exposes each independently-created timer. | ||
M internal/usecase/client/palette_attach_test.go +1 -1
| @@ -11,9 +11,9 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | 13 | ||||
| 14 | + | renderer "github.com/bnema/vev-vt" | |||
| 14 | "github.com/bnema/vev/internal/domain" | 15 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | type paletteAttachClock struct{} | 19 | type paletteAttachClock struct{} | ||
M internal/usecase/client/palette_generation.go +1 -1
| @@ -3,8 +3,8 @@ package client | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "time" | 4 | "time" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/ports" | 7 | "github.com/bnema/vev/internal/ports" | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | // Palette queries always have a DECRQM boundary. The first marker drains | 10 | // Palette queries always have a DECRQM boundary. The first marker drains | ||
M internal/usecase/client/palette_generation_test.go +1 -1
| @@ -6,8 +6,8 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | 8 | ||||
| 9 | + | renderer "github.com/bnema/vev-vt" | |||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | // manualPaletteClock applies only declarative coordinator timer actions. Its | 13 | // manualPaletteClock applies only declarative coordinator timer actions. Its | ||
M internal/usecase/client/reconnect_toast.go +1 -1
| @@ -5,9 +5,9 @@ import ( | |||||
| 5 | "io" | 5 | "io" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/usecase/ui" | 10 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | const reconnectToastMessage = "offline; retrying…" | 13 | const reconnectToastMessage = "offline; retrying…" | ||
M internal/usecase/client/reconnect_toast_test.go +1 -1
| @@ -16,10 +16,10 @@ import ( | |||||
| 16 | "github.com/stretchr/testify/mock" | 16 | "github.com/stretchr/testify/mock" | ||
| 17 | "github.com/stretchr/testify/require" | 17 | "github.com/stretchr/testify/require" | ||
| 18 | 18 | ||||
| 19 | + | renderer "github.com/bnema/vev-vt" | |||
| 19 | "github.com/bnema/vev/internal/domain" | 20 | "github.com/bnema/vev/internal/domain" | ||
| 20 | "github.com/bnema/vev/internal/ports" | 21 | "github.com/bnema/vev/internal/ports" | ||
| 21 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 22 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 22 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 23 | ) | 23 | ) | ||
| 24 | 24 | ||||
| 25 | type reconnectToastTerminalHarness struct { | 25 | type reconnectToastTerminalHarness struct { | ||
M internal/usecase/copy/document.go +2 -2
| @@ -4,8 +4,8 @@ import ( | |||||
| 4 | "strings" | 4 | "strings" | ||
| 5 | "unicode" | 5 | "unicode" | ||
| 6 | 6 | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | 7 | + | vt "github.com/bnema/vev-vt" |
| 8 | - | "github.com/bnema/vev/pkg/vt" | 8 | + | renderer "github.com/bnema/vev-vt/ansi" |
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | // Pos identifies a terminal display cell in a document. Valid positions are | 11 | // Pos identifies a terminal display cell in a document. Valid positions are | ||
M internal/usecase/copy/document_test.go +2 -2
| @@ -3,8 +3,8 @@ package copy | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | 6 | + | vt "github.com/bnema/vev-vt" |
| 7 | - | "github.com/bnema/vev/pkg/vt" | 7 | + | renderer "github.com/bnema/vev-vt/ansi" |
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
M internal/usecase/copy/mode.go +2 -2
| @@ -7,8 +7,8 @@ import ( | |||||
| 7 | "strings" | 7 | "strings" | ||
| 8 | "unicode" | 8 | "unicode" | ||
| 9 | 9 | ||||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | 10 | + | vt "github.com/bnema/vev-vt" |
| 11 | - | "github.com/bnema/vev/pkg/vt" | 11 | + | renderer "github.com/bnema/vev-vt/ansi" |
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | const OSC52MaxPayloadBytes = 75_000 | 14 | const OSC52MaxPayloadBytes = 75_000 | ||
M internal/usecase/copy/mode_test.go +2 -2
| @@ -6,9 +6,9 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | + | vt "github.com/bnema/vev-vt" | |||
| 10 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | - | "github.com/bnema/vev/pkg/vt" | |||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
M internal/usecase/copy/navigator_test.go +1 -1
| @@ -3,7 +3,7 @@ package copy | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | 6 | + | renderer "github.com/bnema/vev-vt" |
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
M internal/usecase/copy/selection.go +1 -1
| @@ -1,6 +1,6 @@ | |||||
| 1 | package copy | 1 | package copy | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | 3 | + | import renderer "github.com/bnema/vev-vt" |
| 4 | 4 | ||||
| 5 | // Granularity controls how a selection's endpoints become display-cell ranges. | 5 | // Granularity controls how a selection's endpoints become display-cell ranges. | ||
| 6 | type Granularity uint8 | 6 | type Granularity uint8 | ||
M internal/usecase/copy/selection_test.go +1 -1
| @@ -3,7 +3,7 @@ package copy | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | 6 | + | renderer "github.com/bnema/vev-vt" |
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
M internal/usecase/daemon/attachment_effect_gate_test.go +1 -1
| @@ -9,10 +9,10 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | 11 | ||||
| 12 | + | renderer "github.com/bnema/vev-vt" | |||
| 12 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 14 | "github.com/bnema/vev/internal/usecase/keys" | 15 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 15 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | func releaseTestGate(t *testing.T, ch chan struct{}) func() { | 18 | func releaseTestGate(t *testing.T, ch chan struct{}) func() { | ||
M internal/usecase/daemon/attachment_registry.go +1 -1
| @@ -5,8 +5,8 @@ import ( | |||||
| 5 | "cmp" | 5 | "cmp" | ||
| 6 | "slices" | 6 | "slices" | ||
| 7 | 7 | ||||
| 8 | + | vt "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | - | "github.com/bnema/vev/pkg/vt" | |||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | // attachmentView is the mutable viewport owned by one attachment. Tab and | 12 | // attachmentView is the mutable viewport owned by one attachment. Tab and | ||
M internal/usecase/daemon/attention_test.go +1 -1
| @@ -10,13 +10,13 @@ import ( | |||||
| 10 | "github.com/stretchr/testify/mock" | 10 | "github.com/stretchr/testify/mock" | ||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | 12 | ||||
| 13 | + | renderer "github.com/bnema/vev-vt" | |||
| 13 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 14 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 16 | "github.com/bnema/vev/internal/usecase/keys" | 17 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 17 | "github.com/bnema/vev/internal/usecase/layout" | 18 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 18 | "github.com/bnema/vev/internal/usecase/ui" | 19 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 19 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 20 | ) | 20 | ) | ||
| 21 | 21 | ||||
| 22 | func TestNoteAttentionFlagsBackgroundTab(t *testing.T) { | 22 | func TestNoteAttentionFlagsBackgroundTab(t *testing.T) { | ||
M internal/usecase/daemon/config_test.go +1 -1
| @@ -6,11 +6,11 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | 8 | ||||
| 9 | + | renderer "github.com/bnema/vev-vt" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 11 | "github.com/bnema/vev/internal/usecase/keys" | 12 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | 13 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | type captureKeyHandler struct { | 16 | type captureKeyHandler struct { | ||
M internal/usecase/daemon/copy_mouse_map_test.go +1 -1
| @@ -5,10 +5,10 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 10 | "github.com/bnema/vev/internal/usecase/mouse" | 11 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | func TestCopyMouseMapTopBarAndContent(t *testing.T) { | 14 | func TestCopyMouseMapTopBarAndContent(t *testing.T) { | ||
M internal/usecase/daemon/copymode.go +1 -1
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "strconv" | 5 | "strconv" | ||
| 6 | "time" | 6 | "time" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | 11 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| @@ -13,7 +14,6 @@ import ( | |||||
| 13 | themeui "github.com/bnema/vev/internal/usecase/theme" | 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 14 | "github.com/bnema/vev/internal/usecase/ui" | 15 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 15 | "github.com/bnema/vev/internal/usecase/visualsearch" | 16 | "github.com/bnema/vev/internal/usecase/visualsearch" | ||
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} | 19 | var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}} | ||
M internal/usecase/daemon/copymode_mouse_test.go +1 -1
| @@ -7,10 +7,10 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | 9 | ||||
| 10 | + | renderer "github.com/bnema/vev-vt" | |||
| 10 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| 11 | "github.com/bnema/vev/internal/ports" | 12 | "github.com/bnema/vev/internal/ports" | ||
| 12 | scopy "github.com/bnema/vev/internal/usecase/copy" | 13 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | type manualMouseClock struct{ now time.Time } | 16 | type manualMouseClock struct{ now time.Time } | ||
M internal/usecase/daemon/copymode_test.go +2 -2
| @@ -13,14 +13,14 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/mock" | 13 | "github.com/stretchr/testify/mock" | ||
| 14 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 15 | 15 | ||||
| 16 | + | vt "github.com/bnema/vev-vt" | |||
| 17 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 16 | "github.com/bnema/vev/internal/domain" | 18 | "github.com/bnema/vev/internal/domain" | ||
| 17 | "github.com/bnema/vev/internal/ports" | 19 | "github.com/bnema/vev/internal/ports" | ||
| 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 20 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 19 | scopy "github.com/bnema/vev/internal/usecase/copy" | 21 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 20 | "github.com/bnema/vev/internal/usecase/layout" | 22 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 21 | "github.com/bnema/vev/internal/usecase/ui" | 23 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 22 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 23 | - | "github.com/bnema/vev/pkg/vt" | |||
| 24 | ) | 24 | ) | ||
| 25 | 25 | ||||
| 26 | // --- test doubles ----------------------------------------------------------- | 26 | // --- test doubles ----------------------------------------------------------- | ||
M internal/usecase/daemon/daemon_test.go +2 -2
| @@ -11,12 +11,12 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/mock" | 11 | "github.com/stretchr/testify/mock" | ||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | 13 | ||||
| 14 | + | vt "github.com/bnema/vev-vt" | |||
| 15 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 14 | "github.com/bnema/vev/internal/domain" | 16 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 17 | "github.com/bnema/vev/internal/usecase/keys" | 19 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 18 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 19 | - | "github.com/bnema/vev/pkg/vt" | |||
| 20 | ) | 20 | ) | ||
| 21 | 21 | ||||
| 22 | // --- test doubles ----------------------------------------------------------- | 22 | // --- test doubles ----------------------------------------------------------- | ||
M internal/usecase/daemon/final_acceptance_test.go +1 -1
| @@ -3,10 +3,10 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | vt "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 8 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 9 | - | "github.com/bnema/vev/pkg/vt" | |||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
M internal/usecase/daemon/floating_render.go +1 -1
| @@ -1,10 +1,10 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 4 | "github.com/bnema/vev/internal/domain" | 5 | "github.com/bnema/vev/internal/domain" | ||
| 5 | themeui "github.com/bnema/vev/internal/usecase/theme" | 6 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 6 | "github.com/bnema/vev/internal/usecase/ui" | 7 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | // floatingGeometry describes a popup's outer frame and terminal content area. | 10 | // floatingGeometry describes a popup's outer frame and terminal content area. | ||
M internal/usecase/daemon/floating_render_test.go +1 -1
| @@ -9,11 +9,11 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | 11 | ||||
| 12 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 12 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/usecase/layout" | 14 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | 15 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 15 | "github.com/bnema/vev/internal/usecase/ui" | 16 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | // applyFloatingResizePlanForTest exercises the transactional apply primitive. | 19 | // applyFloatingResizePlanForTest exercises the transactional apply primitive. | ||
M internal/usecase/daemon/history_test_helper_test.go +1 -1
| @@ -1,6 +1,6 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/pkg/vt" | 3 | + | import vt "github.com/bnema/vev-vt" |
| 4 | 4 | ||||
| 5 | func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) } | 5 | func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) } | ||
| 6 | 6 | ||||
M internal/usecase/daemon/input_test.go +1 -1
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/mock" | 13 | "github.com/stretchr/testify/mock" | ||
| 14 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 15 | 15 | ||||
| 16 | + | vt "github.com/bnema/vev-vt" | |||
| 16 | "github.com/bnema/vev/internal/domain" | 17 | "github.com/bnema/vev/internal/domain" | ||
| 17 | "github.com/bnema/vev/internal/ports" | 18 | "github.com/bnema/vev/internal/ports" | ||
| 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 19 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| @@ -22,7 +23,6 @@ import ( | |||||
| 22 | "github.com/bnema/vev/internal/usecase/mouse" | 23 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 23 | "github.com/bnema/vev/internal/usecase/picker" | 24 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 24 | "github.com/bnema/vev/internal/usecase/ui" | 25 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 25 | - | "github.com/bnema/vev/pkg/vt" | |||
| 26 | ) | 26 | ) | ||
| 27 | 27 | ||||
| 28 | // --- test doubles ----------------------------------------------------------- | 28 | // --- test doubles ----------------------------------------------------------- | ||
M internal/usecase/daemon/modal_compose.go +1 -1
| @@ -1,10 +1,10 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 4 | "github.com/bnema/vev/internal/domain" | 5 | "github.com/bnema/vev/internal/domain" | ||
| 5 | themeui "github.com/bnema/vev/internal/usecase/theme" | 6 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 6 | "github.com/bnema/vev/internal/usecase/ui" | 7 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) { | 10 | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) { | ||
M internal/usecase/daemon/move_tab_test.go +1 -1
| @@ -9,9 +9,9 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | 11 | ||||
| 12 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 12 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 13 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | 14 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | ||
| 14 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | 17 | func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | ||
M internal/usecase/daemon/notice_render_test.go +1 -1
| @@ -7,9 +7,9 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | 9 | ||||
| 10 | + | vt "github.com/bnema/vev-vt" | |||
| 10 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| 11 | "github.com/bnema/vev/internal/ports" | 12 | "github.com/bnema/vev/internal/ports" | ||
| 12 | - | "github.com/bnema/vev/pkg/vt" | |||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | // replayOutputFrames decodes and writes each MsgOutput frame's terminal bytes | 15 | // replayOutputFrames decodes and writes each MsgOutput frame's terminal bytes | ||
M internal/usecase/daemon/notices_overlay_test.go +1 -1
| @@ -9,11 +9,11 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | 11 | ||||
| 12 | + | renderer "github.com/bnema/vev-vt" | |||
| 12 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 14 | "github.com/bnema/vev/internal/usecase/notices" | 15 | "github.com/bnema/vev/internal/usecase/notices" | ||
| 15 | themeui "github.com/bnema/vev/internal/usecase/theme" | 16 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | func TestCaptureNoticesOverlayUsesTerminalBackgroundOutsideSelection(t *testing.T) { | 19 | func TestCaptureNoticesOverlayUsesTerminalBackgroundOutsideSelection(t *testing.T) { | ||
M internal/usecase/daemon/notify_test.go +1 -1
| @@ -12,10 +12,10 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | 14 | ||||
| 15 | + | vt "github.com/bnema/vev-vt" | |||
| 15 | "github.com/bnema/vev/internal/domain" | 16 | "github.com/bnema/vev/internal/domain" | ||
| 16 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 18 | - | "github.com/bnema/vev/pkg/vt" | |||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | func TestReportAttachmentErrorPreservesLocalOrNilScope(t *testing.T) { | 21 | func TestReportAttachmentErrorPreservesLocalOrNilScope(t *testing.T) { | ||
M internal/usecase/daemon/output_state_stream.go +1 -1
| @@ -5,9 +5,9 @@ import ( | |||||
| 5 | "sync" | 5 | "sync" | ||
| 6 | "sync/atomic" | 6 | "sync/atomic" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | // outputStateStream owns the terminal-output dependency chain for one | 13 | // outputStateStream owns the terminal-output dependency chain for one | ||
M internal/usecase/daemon/output_state_stream_test.go +2 -2
| @@ -5,10 +5,10 @@ import ( | |||||
| 5 | "runtime" | 5 | "runtime" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | + | vt "github.com/bnema/vev-vt" | |||
| 9 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | - | "github.com/bnema/vev/pkg/vt" | |||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
M internal/usecase/daemon/output_state_sync_test.go +2 -2
| @@ -8,10 +8,10 @@ import ( | |||||
| 8 | "testing" | 8 | "testing" | ||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | + | vt "github.com/bnema/vev-vt" | |||
| 12 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 11 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 12 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 14 | - | "github.com/bnema/vev/pkg/vt" | |||
| 15 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
M internal/usecase/daemon/overlay_backdrop.go +1 -1
| @@ -1,9 +1,9 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 4 | "github.com/bnema/vev/internal/domain" | 5 | "github.com/bnema/vev/internal/domain" | ||
| 5 | themeui "github.com/bnema/vev/internal/usecase/theme" | 6 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | // applyOverlayBackdrop dims every cell already composed into the complete | 9 | // applyOverlayBackdrop dims every cell already composed into the complete | ||
M internal/usecase/daemon/overlay_backdrop_test.go +1 -1
| @@ -5,9 +5,9 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | themeui "github.com/bnema/vev/internal/usecase/theme" | 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | func backdropTheme() themeui.Theme { | 13 | func backdropTheme() themeui.Theme { | ||
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
| @@ -5,11 +5,11 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 10 | promptui "github.com/bnema/vev/internal/usecase/prompt" | 11 | promptui "github.com/bnema/vev/internal/usecase/prompt" | ||
| 11 | "github.com/bnema/vev/internal/usecase/visualsearch" | 12 | "github.com/bnema/vev/internal/usecase/visualsearch" | ||
| 12 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | func TestOverlayRuntimeActive(t *testing.T) { | 15 | func TestOverlayRuntimeActive(t *testing.T) { | ||
M internal/usecase/daemon/palette.go +1 -1
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "sort" | 5 | "sort" | ||
| 6 | "time" | 6 | "time" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | "github.com/bnema/vev/internal/usecase/command" | 11 | "github.com/bnema/vev/internal/usecase/command" | ||
| @@ -13,7 +14,6 @@ import ( | |||||
| 13 | "github.com/bnema/vev/internal/usecase/picker" | 14 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | 15 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 15 | "github.com/bnema/vev/internal/usecase/ui" | 16 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | const ( | 19 | const ( | ||
M internal/usecase/daemon/palette_test.go +1 -1
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "github.com/stretchr/testify/mock" | 8 | "github.com/stretchr/testify/mock" | ||
| 9 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 10 | 10 | ||||
| 11 | + | renderer "github.com/bnema/vev-vt" | |||
| 11 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 12 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| @@ -15,7 +16,6 @@ import ( | |||||
| 15 | "github.com/bnema/vev/internal/usecase/palette" | 16 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 16 | themeui "github.com/bnema/vev/internal/usecase/theme" | 17 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 17 | "github.com/bnema/vev/internal/usecase/ui" | 18 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 18 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | func testRecentRouteSnapshot() ports.RecentRouteSnapshot { | 21 | func testRecentRouteSnapshot() ports.RecentRouteSnapshot { | ||
M internal/usecase/daemon/pane.go +1 -1
| @@ -5,10 +5,10 @@ import ( | |||||
| 5 | "sync" | 5 | "sync" | ||
| 6 | "sync/atomic" | 6 | "sync/atomic" | ||
| 7 | 7 | ||||
| 8 | + | vt "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | "github.com/bnema/vev/internal/usecase/layout" | 11 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 11 | - | "github.com/bnema/vev/pkg/vt" | |||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | // paneProcessLifetime owns the context passed to PTYFactory.Open. Its daemon | 14 | // paneProcessLifetime owns the context passed to PTYFactory.Open. Its daemon | ||
M internal/usecase/daemon/pane_actions_test.go +1 -1
| @@ -12,12 +12,12 @@ import ( | |||||
| 12 | "github.com/stretchr/testify/mock" | 12 | "github.com/stretchr/testify/mock" | ||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | 14 | ||||
| 15 | + | renderer "github.com/bnema/vev-vt" | |||
| 15 | "github.com/bnema/vev/internal/domain" | 16 | "github.com/bnema/vev/internal/domain" | ||
| 16 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 18 | scopy "github.com/bnema/vev/internal/usecase/copy" | 19 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 19 | "github.com/bnema/vev/internal/usecase/layout" | 20 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 20 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 21 | ) | 21 | ) | ||
| 22 | 22 | ||||
| 23 | func TestLayoutGenerationMutationBookkeeping(t *testing.T) { | 23 | func TestLayoutGenerationMutationBookkeeping(t *testing.T) { | ||
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
| @@ -12,11 +12,11 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | 14 | ||||
| 15 | + | renderer "github.com/bnema/vev-vt" | |||
| 15 | "github.com/bnema/vev/internal/domain" | 16 | "github.com/bnema/vev/internal/domain" | ||
| 16 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 17 | "github.com/bnema/vev/internal/usecase/layout" | 18 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 18 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 19 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 20 | ) | 20 | ) | ||
| 21 | 21 | ||||
| 22 | func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) { | 22 | func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) { | ||
M internal/usecase/daemon/picker.go +1 -1
| @@ -7,12 +7,12 @@ import ( | |||||
| 7 | "sort" | 7 | "sort" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| 10 | + | renderer "github.com/bnema/vev-vt" | |||
| 10 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| 11 | "github.com/bnema/vev/internal/ports" | 12 | "github.com/bnema/vev/internal/ports" | ||
| 12 | "github.com/bnema/vev/internal/usecase/layout" | 13 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 13 | "github.com/bnema/vev/internal/usecase/picker" | 14 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 14 | "github.com/bnema/vev/internal/usecase/ui" | 15 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 15 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | 18 | var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}} | ||
M internal/usecase/daemon/picker_test.go +1 -1
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/mock" | 11 | "github.com/stretchr/testify/mock" | ||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | 13 | ||||
| 14 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 14 | "github.com/bnema/vev/internal/domain" | 15 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/persist" | 16 | "github.com/bnema/vev/internal/persist" | ||
| 16 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| @@ -19,7 +20,6 @@ import ( | |||||
| 19 | "github.com/bnema/vev/internal/usecase/picker" | 20 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 20 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | 21 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | ||
| 21 | themeui "github.com/bnema/vev/internal/usecase/theme" | 22 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 22 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 23 | ) | 23 | ) | ||
| 24 | 24 | ||||
| 25 | var ( | 25 | var ( | ||
M internal/usecase/daemon/remote_preview.go +1 -1
| @@ -3,9 +3,9 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "errors" | 4 | "errors" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | func (d *Daemon) handleRemotePreview(tr ports.Transport, frame ports.Frame) error { | 11 | func (d *Daemon) handleRemotePreview(tr ports.Transport, frame ports.Frame) error { | ||
M internal/usecase/daemon/remote_preview_cache_test.go +1 -1
| @@ -9,10 +9,10 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | 11 | ||||
| 12 | + | renderer "github.com/bnema/vev-vt" | |||
| 12 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 14 | "github.com/bnema/vev/internal/usecase/picker" | 15 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 15 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | type remotePreviewTestClock struct { | 18 | type remotePreviewTestClock struct { | ||
M internal/usecase/daemon/remote_preview_capture_test.go +2 -2
| @@ -5,10 +5,10 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | 7 | ||||
| 8 | + | vt "github.com/bnema/vev-vt" | |||
| 9 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | - | "github.com/bnema/vev/pkg/vt" | |||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | func TestCaptureRemotePreviewUsesBottomRowsForShorterPreview(t *testing.T) { | 14 | func TestCaptureRemotePreviewUsesBottomRowsForShorterPreview(t *testing.T) { | ||
M internal/usecase/daemon/render.go +1 -1
| @@ -4,11 +4,11 @@ package daemon | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | 6 | ||||
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 8 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 9 | "github.com/bnema/vev/internal/usecase/layout" | 10 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | 11 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one | 14 | // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one | ||
M internal/usecase/daemon/render_capture.go +1 -1
| @@ -1,13 +1,13 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 4 | "github.com/bnema/vev/internal/domain" | 5 | "github.com/bnema/vev/internal/domain" | ||
| 5 | scopy "github.com/bnema/vev/internal/usecase/copy" | 6 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 6 | "github.com/bnema/vev/internal/usecase/layout" | 7 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 7 | "github.com/bnema/vev/internal/usecase/picker" | 8 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 8 | themeui "github.com/bnema/vev/internal/usecase/theme" | 9 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 9 | "github.com/bnema/vev/internal/usecase/ui" | 10 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | // capturedRenderState is the immutable hand-off from authoritative daemon | 13 | // capturedRenderState is the immutable hand-off from authoritative daemon | ||
M internal/usecase/daemon/render_damage_receipt_test.go +1 -1
| @@ -3,11 +3,11 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 8 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 9 | "github.com/bnema/vev/internal/usecase/picker" | 10 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
M internal/usecase/daemon/render_divider_geometry_test.go +1 -1
| @@ -3,9 +3,9 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/usecase/layout" | 8 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
M internal/usecase/daemon/render_pipeline.go +1 -1
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "strconv" | 5 | "strconv" | ||
| 6 | 6 | ||||
| 7 | + | renderer "github.com/bnema/vev-vt" | |||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 8 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 9 | "github.com/bnema/vev/internal/usecase/layout" | 10 | "github.com/bnema/vev/internal/usecase/layout" | ||
| @@ -14,7 +15,6 @@ import ( | |||||
| 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | 15 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 15 | "github.com/bnema/vev/internal/usecase/ui" | 16 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 16 | "github.com/bnema/vev/internal/usecase/visualsearch" | 17 | "github.com/bnema/vev/internal/usecase/visualsearch" | ||
| 17 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 18 | ) | 18 | ) | ||
| 19 | 19 | ||||
| 20 | // composeCacheInput is an attachment-local value snapshot. composeFrame never | 20 | // composeCacheInput is an attachment-local value snapshot. composeFrame never | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -1
| @@ -6,12 +6,12 @@ import ( | |||||
| 6 | "maps" | 6 | "maps" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | + | renderer "github.com/bnema/vev-vt" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 11 | "github.com/bnema/vev/internal/usecase/layout" | 12 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | 13 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 13 | "github.com/bnema/vev/internal/usecase/ui" | 14 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 14 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 15 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
M internal/usecase/daemon/render_pipeline_test.go +2 -2
| @@ -11,6 +11,8 @@ import ( | |||||
| 11 | "testing" | 11 | "testing" | ||
| 12 | "time" | 12 | "time" | ||
| 13 | 13 | ||||
| 14 | + | vt "github.com/bnema/vev-vt" | |||
| 15 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 14 | "github.com/bnema/vev/internal/domain" | 16 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 16 | "github.com/bnema/vev/internal/testutil/replaytest" | 18 | "github.com/bnema/vev/internal/testutil/replaytest" | ||
| @@ -23,8 +25,6 @@ import ( | |||||
| 23 | themeui "github.com/bnema/vev/internal/usecase/theme" | 25 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 24 | "github.com/bnema/vev/internal/usecase/ui" | 26 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 25 | "github.com/bnema/vev/internal/usecase/visualsearch" | 27 | "github.com/bnema/vev/internal/usecase/visualsearch" | ||
| 26 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 27 | - | "github.com/bnema/vev/pkg/vt" | |||
| 28 | "github.com/stretchr/testify/require" | 28 | "github.com/stretchr/testify/require" | ||
| 29 | ) | 29 | ) | ||
| 30 | 30 | ||||
M internal/usecase/daemon/render_test.go +2 -2
| @@ -16,14 +16,14 @@ import ( | |||||
| 16 | "github.com/stretchr/testify/mock" | 16 | "github.com/stretchr/testify/mock" | ||
| 17 | "github.com/stretchr/testify/require" | 17 | "github.com/stretchr/testify/require" | ||
| 18 | 18 | ||||
| 19 | + | vt "github.com/bnema/vev-vt" | |||
| 20 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 19 | "github.com/bnema/vev/internal/domain" | 21 | "github.com/bnema/vev/internal/domain" | ||
| 20 | "github.com/bnema/vev/internal/ports" | 22 | "github.com/bnema/vev/internal/ports" | ||
| 21 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 23 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 22 | scopy "github.com/bnema/vev/internal/usecase/copy" | 24 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 23 | "github.com/bnema/vev/internal/usecase/layout" | 25 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 24 | themeui "github.com/bnema/vev/internal/usecase/theme" | 26 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 25 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 26 | - | "github.com/bnema/vev/pkg/vt" | |||
| 27 | ) | 27 | ) | ||
| 28 | 28 | ||||
| 29 | func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | 29 | func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | ||
M internal/usecase/daemon/resume_test.go +1 -1
| @@ -11,11 +11,11 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/mock" | 11 | "github.com/stretchr/testify/mock" | ||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | 13 | ||||
| 14 | + | vt "github.com/bnema/vev-vt" | |||
| 14 | "github.com/bnema/vev/internal/domain" | 15 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 17 | "github.com/bnema/vev/internal/usecase/layout" | 18 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 18 | - | "github.com/bnema/vev/pkg/vt" | |||
| 19 | ) | 19 | ) | ||
| 20 | 20 | ||||
| 21 | type closeTrackingTransport struct { | 21 | type closeTrackingTransport struct { | ||
M internal/usecase/daemon/snapshot.go +1 -1
| @@ -7,10 +7,10 @@ import ( | |||||
| 7 | "sync" | 7 | "sync" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| 10 | + | vt "github.com/bnema/vev-vt" | |||
| 10 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| 11 | "github.com/bnema/vev/internal/usecase/layout" | 12 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 12 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 13 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 13 | - | "github.com/bnema/vev/pkg/vt" | |||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // snapshotQueueCapacity bounds retained immutable captures. A full queue never | 16 | // snapshotQueueCapacity bounds retained immutable captures. A full queue never | ||
M internal/usecase/daemon/snapshot_acceptance_test.go +2 -2
| @@ -6,8 +6,8 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | 8 | ||||
| 9 | - | "github.com/bnema/vev/pkg/renderer" | 9 | + | vt "github.com/bnema/vev-vt" |
| 10 | - | "github.com/bnema/vev/pkg/vt" | 10 | + | renderer "github.com/bnema/vev-vt/ansi" |
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | func TestSnapshotChunkCacheIsScopedToNamedSession(t *testing.T) { | 13 | func TestSnapshotChunkCacheIsScopedToNamedSession(t *testing.T) { | ||
M internal/usecase/daemon/snapshot_incremental.go +1 -1
| @@ -3,10 +3,10 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | 5 | ||||
| 6 | + | vt "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 8 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 9 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 9 | - | "github.com/bnema/vev/pkg/vt" | |||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | // snapshotChunkCacheLimit is deliberately small: history chunks remain owned | 12 | // snapshotChunkCacheLimit is deliberately small: history chunks remain owned | ||
M internal/usecase/daemon/snapshot_incremental_test.go +2 -2
| @@ -7,10 +7,10 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | 9 | ||||
| 10 | + | vt "github.com/bnema/vev-vt" | |||
| 11 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 10 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 11 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 13 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 12 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 13 | - | "github.com/bnema/vev/pkg/vt" | |||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) { | 16 | func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) { | ||
M internal/usecase/daemon/snapshot_restore.go +1 -1
| @@ -7,11 +7,11 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "math" | 8 | "math" | ||
| 9 | 9 | ||||
| 10 | + | vt "github.com/bnema/vev-vt" | |||
| 10 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| 11 | "github.com/bnema/vev/internal/ports" | 12 | "github.com/bnema/vev/internal/ports" | ||
| 12 | "github.com/bnema/vev/internal/usecase/layout" | 13 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 13 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 14 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 14 | - | "github.com/bnema/vev/pkg/vt" | |||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | func (d *Daemon) closeRestoreDone() { | 17 | func (d *Daemon) closeRestoreDone() { | ||
M internal/usecase/daemon/snapshot_restore_test.go +2 -2
| @@ -11,14 +11,14 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/mock" | 11 | "github.com/stretchr/testify/mock" | ||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | 13 | ||||
| 14 | + | vt "github.com/bnema/vev-vt" | |||
| 15 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 14 | "github.com/bnema/vev/internal/domain" | 16 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 17 | "github.com/bnema/vev/internal/usecase/layout" | 19 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 18 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | 20 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | ||
| 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | 21 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | ||
| 20 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 21 | - | "github.com/bnema/vev/pkg/vt" | |||
| 22 | ) | 22 | ) | ||
| 23 | 23 | ||||
| 24 | type snapshotAcceptanceRepository struct { | 24 | type snapshotAcceptanceRepository struct { | ||
M internal/usecase/daemon/snapshot_test.go +1 -1
| @@ -9,11 +9,11 @@ import ( | |||||
| 9 | "github.com/stretchr/testify/mock" | 9 | "github.com/stretchr/testify/mock" | ||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | 11 | ||||
| 12 | + | renderer "github.com/bnema/vev-vt" | |||
| 12 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 15 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | 16 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | ||
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | // TestWithSnapshotRepositoryRejectsTypedNil verifies typed nil repositories are disabled. | 19 | // TestWithSnapshotRepositoryRejectsTypedNil verifies typed nil repositories are disabled. | ||
M internal/usecase/daemon/status.go +1 -1
| @@ -6,13 +6,13 @@ import ( | |||||
| 6 | "strconv" | 6 | "strconv" | ||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | + | renderer "github.com/bnema/vev-vt" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 11 | "github.com/bnema/vev/internal/usecase/command" | 12 | "github.com/bnema/vev/internal/usecase/command" | ||
| 12 | "github.com/bnema/vev/internal/usecase/palette" | 13 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 13 | themeui "github.com/bnema/vev/internal/usecase/theme" | 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 14 | "github.com/bnema/vev/internal/usecase/ui" | 15 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 15 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | const ( | 18 | const ( | ||
M internal/usecase/daemon/status_test.go +1 -1
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | 13 | ||||
| 14 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 14 | "github.com/bnema/vev/internal/domain" | 15 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 16 | "github.com/bnema/vev/internal/usecase/command" | 17 | "github.com/bnema/vev/internal/usecase/command" | ||
| @@ -19,7 +20,6 @@ import ( | |||||
| 19 | "github.com/bnema/vev/internal/usecase/picker" | 20 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 20 | themeui "github.com/bnema/vev/internal/usecase/theme" | 21 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 21 | "github.com/bnema/vev/internal/usecase/ui" | 22 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 22 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 23 | ) | 23 | ) | ||
| 24 | 24 | ||||
| 25 | // stubClock returns timers whose channel never fires, so a scheduler under it | 25 | // stubClock returns timers whose channel never fires, so a scheduler under it | ||
M internal/usecase/daemon/theme_cache_test.go +1 -1
| @@ -5,11 +5,11 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | "github.com/bnema/vev/internal/usecase/layout" | 11 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 11 | themeui "github.com/bnema/vev/internal/usecase/theme" | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 12 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | func cacheTheme() themeui.Theme { | 15 | func cacheTheme() themeui.Theme { | ||
M internal/usecase/daemon/theme_integration_test.go +1 -1
| @@ -5,10 +5,10 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | 11 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) ports.Theme { | 14 | func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) ports.Theme { | ||
M internal/usecase/daemon/toast_damage_test.go +2 -2
| @@ -3,10 +3,10 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | vt "github.com/bnema/vev-vt" | |||
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/usecase/layout" | 9 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | - | "github.com/bnema/vev/pkg/vt" | |||
| 10 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
M internal/usecase/notices/model.go +1 -1
| @@ -6,9 +6,9 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | + | renderer "github.com/bnema/vev-vt" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | "github.com/bnema/vev/internal/usecase/ui" | 11 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | // RenderStyles are the styles Render uses to draw list rows. The zero value | 14 | // RenderStyles are the styles Render uses to draw list rows. The zero value | ||
M internal/usecase/notices/model_test.go +1 -1
| @@ -6,8 +6,8 @@ import ( | |||||
| 6 | "testing" | 6 | "testing" | ||
| 7 | "time" | 7 | "time" | ||
| 8 | 8 | ||||
| 9 | + | renderer "github.com/bnema/vev-vt" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | func testStyles() RenderStyles { | 13 | func testStyles() RenderStyles { | ||
M internal/usecase/palette/model.go +1 -1
| @@ -4,10 +4,10 @@ import ( | |||||
| 4 | "unicode" | 4 | "unicode" | ||
| 5 | "unicode/utf8" | 5 | "unicode/utf8" | ||
| 6 | 6 | ||||
| 7 | + | renderer "github.com/bnema/vev-vt" | |||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 8 | "github.com/bnema/vev/internal/usecase/command" | 9 | "github.com/bnema/vev/internal/usecase/command" | ||
| 9 | "github.com/bnema/vev/internal/usecase/ui" | 10 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | type RenderStyles struct { | 13 | type RenderStyles struct { | ||
M internal/usecase/palette/model_test.go +1 -1
| @@ -5,10 +5,10 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | "time" | 6 | "time" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/usecase/command" | 10 | "github.com/bnema/vev/internal/usecase/command" | ||
| 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | 11 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
M internal/usecase/picker/model.go +1 -1
| @@ -1,9 +1,9 @@ | |||||
| 1 | package picker | 1 | package picker | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 4 | "github.com/bnema/vev/internal/domain" | 5 | "github.com/bnema/vev/internal/domain" | ||
| 5 | "github.com/bnema/vev/internal/usecase/ui" | 6 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | const ( | 9 | const ( | ||
M internal/usecase/picker/model_test.go +1 -1
| @@ -5,10 +5,10 @@ import ( | |||||
| 5 | "strings" | 5 | "strings" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | themeui "github.com/bnema/vev/internal/usecase/theme" | 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 10 | "github.com/bnema/vev/internal/usecase/ui" | 11 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
M internal/usecase/picker/remote_parity_test.go +1 -1
| @@ -5,8 +5,8 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | func remoteParityLifecycle(seed byte) domain.SessionLifecycleID { | 12 | func remoteParityLifecycle(seed byte) domain.SessionLifecycleID { | ||
M internal/usecase/prompt/model.go +1 -1
| @@ -1,9 +1,9 @@ | |||||
| 1 | package prompt | 1 | package prompt | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 4 | "github.com/bnema/vev/internal/domain" | 5 | "github.com/bnema/vev/internal/domain" | ||
| 5 | "github.com/bnema/vev/internal/usecase/ui" | 6 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | // RenderStyles contains daemon-supplied semantic chrome roles. The zero value | 9 | // RenderStyles contains daemon-supplied semantic chrome roles. The zero value | ||
M internal/usecase/prompt/model_test.go +1 -1
| @@ -4,8 +4,8 @@ import ( | |||||
| 4 | "strings" | 4 | "strings" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | + | renderer "github.com/bnema/vev-vt" | |||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
A internal/usecase/snapshot/codec_fuzz_test.go +20 -0
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/hex" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func FuzzVEVSRestoreDoesNotPanic(f *testing.F) { | |||
| 9 | + | golden, err := hex.DecodeString("5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100") | |||
| 10 | + | if err != nil { | |||
| 11 | + | f.Fatalf("decode VEVS seed: %v", err) | |||
| 12 | + | } | |||
| 13 | + | f.Add(golden) | |||
| 14 | + | f.Add([]byte("VEVS\x00\x04")) | |||
| 15 | + | f.Add([]byte("not a snapshot")) | |||
| 16 | + | ||||
| 17 | + | f.Fuzz(func(t *testing.T, data []byte) { | |||
| 18 | + | _, _ = Unmarshal(data) | |||
| 19 | + | }) | |||
| 20 | + | } | |||
M internal/usecase/snapshot/codec_test.go +2 -2
| @@ -9,9 +9,9 @@ import ( | |||||
| 9 | "strconv" | 9 | "strconv" | ||
| 10 | "testing" | 10 | "testing" | ||
| 11 | 11 | ||||
| 12 | + | vt "github.com/bnema/vev-vt" | |||
| 13 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 12 | "github.com/bnema/vev/internal/usecase/layout" | 14 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 14 | - | "github.com/bnema/vev/pkg/vt" | |||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | func TestPayloadReaderGetBytes(t *testing.T) { | 17 | func TestPayloadReaderGetBytes(t *testing.T) { | ||
M internal/usecase/snapshot/preflight.go +1 -1
| @@ -4,8 +4,8 @@ import ( | |||||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "math" | 5 | "math" | ||
| 6 | 6 | ||||
| 7 | + | vt "github.com/bnema/vev-vt" | |||
| 7 | "github.com/bnema/vev/internal/usecase/layout" | 8 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 8 | - | "github.com/bnema/vev/pkg/vt" | |||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | func preflightSession(body []byte) error { | 11 | func preflightSession(body []byte) error { | ||
M internal/usecase/snapshot/preflight_acceptance_test.go +1 -1
| @@ -4,8 +4,8 @@ import ( | |||||
| 4 | "errors" | 4 | "errors" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | + | renderer "github.com/bnema/vev-vt" | |||
| 7 | "github.com/bnema/vev/internal/usecase/layout" | 8 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) { | 11 | func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) { | ||
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 | + | vt "github.com/bnema/vev-vt" | |||
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 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 | + | } | |||
M internal/usecase/theme/accent_resolve.go +1 -1
| @@ -1,8 +1,8 @@ | |||||
| 1 | package theme | 1 | package theme | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 4 | "github.com/bnema/vev/internal/domain" | 5 | "github.com/bnema/vev/internal/domain" | ||
| 5 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | const ( | 8 | const ( | ||
M internal/usecase/theme/accent_resolve_test.go +1 -1
| @@ -4,8 +4,8 @@ import ( | |||||
| 4 | "math/rand" | 4 | "math/rand" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | + | renderer "github.com/bnema/vev-vt" | |||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
M internal/usecase/theme/benchmark_test.go +1 -1
| @@ -3,8 +3,8 @@ package theme | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | var ( | 10 | var ( | ||
M internal/usecase/theme/color_oklab.go +1 -1
| @@ -3,7 +3,7 @@ package theme | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "math" | 4 | "math" | ||
| 5 | 5 | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | 6 | + | renderer "github.com/bnema/vev-vt" |
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | // oklab is the perceptually uniform OKLab representation of an sRGB color. | 9 | // oklab is the perceptually uniform OKLab representation of an sRGB color. | ||
M internal/usecase/theme/color_oklab_test.go +1 -1
| @@ -4,7 +4,7 @@ import ( | |||||
| 4 | "math" | 4 | "math" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | 7 | + | renderer "github.com/bnema/vev-vt" |
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func TestRGBToOKLabKnownValues(t *testing.T) { | 10 | func TestRGBToOKLabKnownValues(t *testing.T) { | ||
M internal/usecase/theme/palette_scan_test.go +1 -1
| @@ -6,7 +6,7 @@ import ( | |||||
| 6 | "strconv" | 6 | "strconv" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | - | "github.com/bnema/vev/pkg/renderer" | 9 | + | renderer "github.com/bnema/vev-vt" |
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | type paletteEvent struct { | 12 | type paletteEvent struct { | ||
M internal/usecase/theme/phase3_review_test.go +1 -1
| @@ -3,8 +3,8 @@ package theme | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
M internal/usecase/theme/ramp.go +1 -1
| @@ -1,6 +1,6 @@ | |||||
| 1 | package theme | 1 | package theme | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | 3 | + | import renderer "github.com/bnema/vev-vt" |
| 4 | 4 | ||||
| 5 | const ( | 5 | const ( | ||
| 6 | normalTextContrast = 4.5 | 6 | normalTextContrast = 4.5 | ||
M internal/usecase/theme/ramp_test.go +1 -1
| @@ -4,8 +4,8 @@ import ( | |||||
| 4 | "math" | 4 | "math" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | + | renderer "github.com/bnema/vev-vt" | |||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
M internal/usecase/theme/resolved_styles.go +1 -1
| @@ -1,8 +1,8 @@ | |||||
| 1 | package theme | 1 | package theme | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 4 | "github.com/bnema/vev/internal/domain" | 5 | "github.com/bnema/vev/internal/domain" | ||
| 5 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | const neutralAccentBlend = 0.25 | 8 | const neutralAccentBlend = 0.25 | ||
M internal/usecase/theme/styles.go +1 -1
| @@ -3,8 +3,8 @@ package theme | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "math" | 4 | "math" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | const accentContrastMin = 3.0 | 10 | const accentContrastMin = 3.0 | ||
M internal/usecase/theme/styles_test.go +1 -1
| @@ -3,7 +3,7 @@ package theme | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | 6 | + | renderer "github.com/bnema/vev-vt" |
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
M internal/usecase/theme/theme.go +2 -2
| @@ -5,8 +5,8 @@ import ( | |||||
| 5 | "strconv" | 5 | "strconv" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | 7 | ||||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | 8 | + | vt "github.com/bnema/vev-vt" |
| 9 | - | "github.com/bnema/vev/pkg/vt" | 9 | + | renderer "github.com/bnema/vev-vt/ansi" |
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | const maxPending = 64 | 12 | const maxPending = 64 | ||
M internal/usecase/theme/theme_test.go +1 -1
| @@ -5,7 +5,7 @@ import ( | |||||
| 5 | "reflect" | 5 | "reflect" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | 8 | + | renderer "github.com/bnema/vev-vt" |
| 9 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
M internal/usecase/ui/frame.go +1 -1
| @@ -1,8 +1,8 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 4 | "github.com/bnema/vev/internal/domain" | 5 | "github.com/bnema/vev/internal/domain" | ||
| 5 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // VerticalAnchor controls which end of a source is retained when its height | 8 | // VerticalAnchor controls which end of a source is retained when its height | ||
M internal/usecase/ui/frame_test.go +1 -1
| @@ -3,8 +3,8 @@ package ui | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func cell(r rune, style renderer.Style, continuation bool) renderer.Cell { | 10 | func cell(r rune, style renderer.Style, continuation bool) renderer.Cell { | ||
M internal/usecase/ui/modal.go +1 -1
| @@ -1,8 +1,8 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 4 | "github.com/bnema/vev/internal/domain" | 5 | "github.com/bnema/vev/internal/domain" | ||
| 5 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // Modal describes a rectangular overlay. | 8 | // Modal describes a rectangular overlay. | ||
M internal/usecase/ui/modal_test.go +1 -1
| @@ -3,8 +3,8 @@ package ui | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
M internal/usecase/ui/notice.go +1 -1
| @@ -4,8 +4,8 @@ import ( | |||||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | 6 | ||||
| 7 | + | renderer "github.com/bnema/vev-vt" | |||
| 7 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | const ( | 11 | const ( | ||
M internal/usecase/ui/notice_test.go +1 -1
| @@ -5,8 +5,8 @@ import ( | |||||
| 5 | "strings" | 5 | "strings" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | // frameText joins every row of frame into one newline-separated string so | 12 | // frameText joins every row of frame into one newline-separated string so | ||
M internal/usecase/ui/separator.go +1 -1
| @@ -1,8 +1,8 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 4 | "github.com/bnema/vev/internal/domain" | 5 | "github.com/bnema/vev/internal/domain" | ||
| 5 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | ) | 6 | ) | ||
| 7 | 7 | ||||
| 8 | // SeparatorOrientation selects the axis of a separator slice. | 8 | // SeparatorOrientation selects the axis of a separator slice. | ||
M internal/usecase/ui/separator_test.go +1 -1
| @@ -3,8 +3,8 @@ package ui | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func TestDrawSeparator(t *testing.T) { | 10 | func TestDrawSeparator(t *testing.T) { | ||
M internal/usecase/ui/textinput.go +1 -1
| @@ -1,6 +1,6 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | 3 | + | import renderer "github.com/bnema/vev-vt" |
| 4 | 4 | ||||
| 5 | // TextInput stores editable text as runes for terminal overlay input models. | 5 | // TextInput stores editable text as runes for terminal overlay input models. | ||
| 6 | type TextInput struct { | 6 | type TextInput struct { | ||
M internal/usecase/ui/textinput_test.go +1 -1
| @@ -3,7 +3,7 @@ package ui | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | 6 | + | renderer "github.com/bnema/vev-vt" |
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
M internal/usecase/ui/toast.go +1 -1
| @@ -3,8 +3,8 @@ package ui | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "time" | 4 | "time" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | const ( | 10 | const ( | ||
M internal/usecase/ui/toast_test.go +1 -1
| @@ -5,8 +5,8 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | "time" | 6 | "time" | ||
| 7 | 7 | ||||
| 8 | + | renderer "github.com/bnema/vev-vt" | |||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | func TestToastManagerDurationDismissAndClear(t *testing.T) { | 12 | func TestToastManagerDurationDismissAndClear(t *testing.T) { | ||
M internal/usecase/visualsearch/model.go +1 -1
| @@ -3,10 +3,10 @@ package visualsearch | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "strconv" | 4 | "strconv" | ||
| 5 | 5 | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 6 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 7 | scopy "github.com/bnema/vev/internal/usecase/copy" | 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 8 | "github.com/bnema/vev/internal/usecase/ui" | 9 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | type Model struct { | 12 | type Model struct { | ||
M internal/usecase/visualsearch/model_test.go +1 -1
| @@ -6,9 +6,9 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/stretchr/testify/require" | 7 | "github.com/stretchr/testify/require" | ||
| 8 | 8 | ||||
| 9 | + | renderer "github.com/bnema/vev-vt" | |||
| 9 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | 11 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | var benchmarkModelSink *Model | 14 | var benchmarkModelSink *Model | ||
D pkg/renderer/benchmark_test.go +0 -235
| @@ -1,235 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import "testing" | |||
| 4 | - | ||||
| 5 | - | func markBenchmarkFrame(f *Frame) { | |||
| 6 | - | for y := range f.Height { | |||
| 7 | - | for x := range f.Width { | |||
| 8 | - | f.Set(x, y, Cell{Rune: rune('A' + (y*f.Width+x)%26), Style: DefaultStyle()}) | |||
| 9 | - | } | |||
| 10 | - | } | |||
| 11 | - | } | |||
| 12 | - | ||||
| 13 | - | func setBenchmarkShadow(r *Renderer, frame Frame) { | |||
| 14 | - | committed := r.committedFrame() | |||
| 15 | - | replaceFrame(&committed, frame) | |||
| 16 | - | r.setCommittedFrame(committed) | |||
| 17 | - | } | |||
| 18 | - | ||||
| 19 | - | func BenchmarkRendererFullFrameDraw(b *testing.B) { | |||
| 20 | - | frame := NewFrame(120, 40) | |||
| 21 | - | markBenchmarkFrame(&frame) | |||
| 22 | - | r := New(Capabilities{}) | |||
| 23 | - | damage := []Damage{FullRedraw()} | |||
| 24 | - | b.ReportAllocs() | |||
| 25 | - | var outBytes int64 | |||
| 26 | - | for b.Loop() { | |||
| 27 | - | r.Reset() | |||
| 28 | - | out, err := r.Draw(frame, damage) | |||
| 29 | - | if err != nil { | |||
| 30 | - | b.Fatal(err) | |||
| 31 | - | } | |||
| 32 | - | if len(out) == 0 { | |||
| 33 | - | b.Fatal("expected renderer output") | |||
| 34 | - | } | |||
| 35 | - | outBytes += int64(len(out)) | |||
| 36 | - | } | |||
| 37 | - | b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op") | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | func BenchmarkRendererFragmentedDamage(b *testing.B) { | |||
| 41 | - | const ( | |||
| 42 | - | width = 120 | |||
| 43 | - | height = 40 | |||
| 44 | - | ) | |||
| 45 | - | ||||
| 46 | - | baseline := NewFrame(width, height) | |||
| 47 | - | markBenchmarkFrame(&baseline) | |||
| 48 | - | frame := NewFrame(width, height) | |||
| 49 | - | markBenchmarkFrame(&frame) | |||
| 50 | - | ||||
| 51 | - | // Create 256 overlapping or vertically adjacent text rectangles in reverse | |||
| 52 | - | // row/column order. Canonical damage planning must sort and merge these into | |||
| 53 | - | // stable per-row spans before emission. | |||
| 54 | - | damage := make([]Damage, 0, 256) | |||
| 55 | - | for row := 15; row >= 0; row-- { | |||
| 56 | - | for col := 15; col >= 0; col-- { | |||
| 57 | - | x, y := col*7, row*2 | |||
| 58 | - | damage = append(damage, Damage{Kind: DamageText, X: x, Y: y, Width: 8, Height: 2}) | |||
| 59 | - | for dy := range 2 { | |||
| 60 | - | for dx := range 8 { | |||
| 61 | - | frame.Set(x+dx, y+dy, Cell{Rune: 'z', Style: DefaultStyle()}) | |||
| 62 | - | } | |||
| 63 | - | } | |||
| 64 | - | } | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | r := New(Capabilities{}) | |||
| 68 | - | b.ReportAllocs() | |||
| 69 | - | var outBytes int64 | |||
| 70 | - | for b.Loop() { | |||
| 71 | - | // Each iteration starts with the identical pre-update terminal shadow. | |||
| 72 | - | setBenchmarkShadow(r, baseline) | |||
| 73 | - | out, err := r.Draw(frame, damage) | |||
| 74 | - | if err != nil { | |||
| 75 | - | b.Fatal(err) | |||
| 76 | - | } | |||
| 77 | - | if len(out) == 0 { | |||
| 78 | - | b.Fatal("expected renderer output") | |||
| 79 | - | } | |||
| 80 | - | outBytes += int64(len(out)) | |||
| 81 | - | } | |||
| 82 | - | b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op") | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | func BenchmarkRendererIncrementalOneCell(b *testing.B) { | |||
| 86 | - | for _, tt := range []struct { | |||
| 87 | - | name string | |||
| 88 | - | prepareCommit bool | |||
| 89 | - | }{ | |||
| 90 | - | {name: "PrepareCommit", prepareCommit: true}, | |||
| 91 | - | {name: "Draw"}, | |||
| 92 | - | } { | |||
| 93 | - | b.Run(tt.name, func(b *testing.B) { | |||
| 94 | - | frame := NewFrame(120, 40) | |||
| 95 | - | markBenchmarkFrame(&frame) | |||
| 96 | - | r := New(Capabilities{}) | |||
| 97 | - | if tt.prepareCommit { | |||
| 98 | - | initial, err := r.Prepare(frame, []Damage{FullRedraw()}, false) | |||
| 99 | - | if err != nil { | |||
| 100 | - | b.Fatal(err) | |||
| 101 | - | } | |||
| 102 | - | initial.Commit() | |||
| 103 | - | } else if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |||
| 104 | - | b.Fatal(err) | |||
| 105 | - | } | |||
| 106 | - | damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}} | |||
| 107 | - | ||||
| 108 | - | b.ReportAllocs() | |||
| 109 | - | var outBytes int64 | |||
| 110 | - | changed := false | |||
| 111 | - | for b.Loop() { | |||
| 112 | - | changed = !changed | |||
| 113 | - | cell := Cell{Rune: 'X', Style: DefaultStyle()} | |||
| 114 | - | if changed { | |||
| 115 | - | cell.Rune = 'Y' | |||
| 116 | - | } | |||
| 117 | - | frame.Set(60, 20, cell) | |||
| 118 | - | if tt.prepareCommit { | |||
| 119 | - | prepared, err := r.Prepare(frame, damage, false) | |||
| 120 | - | if err != nil { | |||
| 121 | - | b.Fatal(err) | |||
| 122 | - | } | |||
| 123 | - | if len(prepared.Bytes()) == 0 { | |||
| 124 | - | b.Fatal("expected renderer output") | |||
| 125 | - | } | |||
| 126 | - | outBytes += int64(len(prepared.Bytes())) | |||
| 127 | - | prepared.Commit() | |||
| 128 | - | continue | |||
| 129 | - | } | |||
| 130 | - | out, err := r.Draw(frame, damage) | |||
| 131 | - | if err != nil { | |||
| 132 | - | b.Fatal(err) | |||
| 133 | - | } | |||
| 134 | - | if len(out) == 0 { | |||
| 135 | - | b.Fatal("expected renderer output") | |||
| 136 | - | } | |||
| 137 | - | outBytes += int64(len(out)) | |||
| 138 | - | } | |||
| 139 | - | b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op") | |||
| 140 | - | }) | |||
| 141 | - | } | |||
| 142 | - | } | |||
| 143 | - | ||||
| 144 | - | func BenchmarkRendererIncrementalNoBytePrepareCommit(b *testing.B) { | |||
| 145 | - | frame := NewFrame(120, 40) | |||
| 146 | - | markBenchmarkFrame(&frame) | |||
| 147 | - | r := New(Capabilities{}) | |||
| 148 | - | initial, err := r.Prepare(frame, []Damage{FullRedraw()}, false) | |||
| 149 | - | if err != nil { | |||
| 150 | - | b.Fatal(err) | |||
| 151 | - | } | |||
| 152 | - | initial.Commit() | |||
| 153 | - | ||||
| 154 | - | b.ReportAllocs() | |||
| 155 | - | for b.Loop() { | |||
| 156 | - | prepared, err := r.Prepare(frame, nil, false) | |||
| 157 | - | if err != nil { | |||
| 158 | - | b.Fatal(err) | |||
| 159 | - | } | |||
| 160 | - | if len(prepared.Bytes()) != 0 { | |||
| 161 | - | b.Fatal("expected no output") | |||
| 162 | - | } | |||
| 163 | - | prepared.Commit() | |||
| 164 | - | } | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | func BenchmarkRendererBroadRegularDamage(b *testing.B) { | |||
| 168 | - | const ( | |||
| 169 | - | width = 120 | |||
| 170 | - | height = 40 | |||
| 171 | - | ) | |||
| 172 | - | baseline := NewFrame(width, height) | |||
| 173 | - | markBenchmarkFrame(&baseline) | |||
| 174 | - | frame := baseline.Clone() | |||
| 175 | - | for y := range height { | |||
| 176 | - | frame.Set(0, y, Cell{Rune: 'z', Style: DefaultStyle()}) | |||
| 177 | - | } | |||
| 178 | - | damage := make([]Damage, height) | |||
| 179 | - | for y := range height { | |||
| 180 | - | damage[y] = Damage{Kind: DamageText, X: 0, Y: y, Width: width, Height: 1} | |||
| 181 | - | } | |||
| 182 | - | ||||
| 183 | - | r := New(Capabilities{}) | |||
| 184 | - | b.ReportAllocs() | |||
| 185 | - | var outBytes int64 | |||
| 186 | - | for b.Loop() { | |||
| 187 | - | setBenchmarkShadow(r, baseline) | |||
| 188 | - | out, err := r.Draw(frame, damage) | |||
| 189 | - | if err != nil { | |||
| 190 | - | b.Fatal(err) | |||
| 191 | - | } | |||
| 192 | - | if len(out) == 0 { | |||
| 193 | - | b.Fatal("expected renderer output") | |||
| 194 | - | } | |||
| 195 | - | outBytes += int64(len(out)) | |||
| 196 | - | } | |||
| 197 | - | b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op") | |||
| 198 | - | } | |||
| 199 | - | ||||
| 200 | - | func BenchmarkRendererScrollFastPath(b *testing.B) { | |||
| 201 | - | frame := NewFrame(120, 40) | |||
| 202 | - | markBenchmarkFrame(&frame) | |||
| 203 | - | r := New(Capabilities{}) | |||
| 204 | - | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |||
| 205 | - | b.Fatal(err) | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | scrolled := NewFrame(120, 40) | |||
| 209 | - | copy(scrolled.Cells[0:39*120], frame.Cells[120:40*120]) | |||
| 210 | - | for i := 39 * 120; i < 40*120; i++ { | |||
| 211 | - | scrolled.Cells[i] = BlankCell() | |||
| 212 | - | } | |||
| 213 | - | for x, r := range []rune("new output line") { | |||
| 214 | - | scrolled.Set(x, 39, Cell{Rune: r, Style: DefaultStyle()}) | |||
| 215 | - | } | |||
| 216 | - | damage := []Damage{ | |||
| 217 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 120, Height: 40, Count: 1}, | |||
| 218 | - | {Kind: DamageText, X: 0, Y: 39, Width: 120, Height: 1, Count: 1}, | |||
| 219 | - | } | |||
| 220 | - | ||||
| 221 | - | b.ReportAllocs() | |||
| 222 | - | var outBytes int64 | |||
| 223 | - | for b.Loop() { | |||
| 224 | - | setBenchmarkShadow(r, frame) | |||
| 225 | - | out, err := r.Draw(scrolled, damage) | |||
| 226 | - | if err != nil { | |||
| 227 | - | b.Fatal(err) | |||
| 228 | - | } | |||
| 229 | - | if len(out) == 0 { | |||
| 230 | - | b.Fatal("expected renderer output") | |||
| 231 | - | } | |||
| 232 | - | outBytes += int64(len(out)) | |||
| 233 | - | } | |||
| 234 | - | b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op") | |||
| 235 | - | } | |||
D pkg/renderer/cell.go +0 -19
| @@ -1,19 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | // Cell is a single terminal grid cell. | |||
| 4 | - | // | |||
| 5 | - | // A double-width rune (CJK, emoji) occupies TWO adjacent cells: the left cell | |||
| 6 | - | // holds the rune ({Rune: r, Style: s}) and the right cell is a continuation | |||
| 7 | - | // marker ({Continuation: true, Rune: 0}). Continuation cells are never emitted | |||
| 8 | - | // by the renderer — the terminal advances two columns for the wide rune itself. | |||
| 9 | - | type Cell struct { | |||
| 10 | - | Rune rune | |||
| 11 | - | Style Style | |||
| 12 | - | Continuation bool | |||
| 13 | - | } | |||
| 14 | - | ||||
| 15 | - | func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} } | |||
| 16 | - | ||||
| 17 | - | func (c Cell) Equal(other Cell) bool { | |||
| 18 | - | return c.Rune == other.Rune && c.Continuation == other.Continuation && c.Style.Equal(other.Style) | |||
| 19 | - | } | |||
D pkg/renderer/damage.go +0 -21
| @@ -1,21 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | type DamageKind int | |||
| 4 | - | ||||
| 5 | - | const ( | |||
| 6 | - | DamageText DamageKind = iota | |||
| 7 | - | DamageClear | |||
| 8 | - | DamageScrollUp | |||
| 9 | - | DamageFullRedraw | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | type Damage struct { | |||
| 13 | - | Kind DamageKind | |||
| 14 | - | X int | |||
| 15 | - | Y int | |||
| 16 | - | Width int | |||
| 17 | - | Height int | |||
| 18 | - | Count int | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | - | func FullRedraw() Damage { return Damage{Kind: DamageFullRedraw, Count: 1} } | |||
D pkg/renderer/damage_plan.go +0 -95
| @@ -1,95 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "cmp" | |||
| 5 | - | "slices" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | const maxPlannedDamageSpans = 4096 | |||
| 9 | - | ||||
| 10 | - | // buildDamagePlan creates the bounded, canonical view of text and clear damage | |||
| 11 | - | // used for terminal emission. The source damage remains untouched because the | |||
| 12 | - | // scroll and shadow paths need its original structural information. | |||
| 13 | - | func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool) { | |||
| 14 | - | if len(damage) == 1 { | |||
| 15 | - | d := damage[0] | |||
| 16 | - | if skip != nil && sameDamage(d, *skip) { | |||
| 17 | - | return nil, false | |||
| 18 | - | } | |||
| 19 | - | if d.Kind != DamageText && d.Kind != DamageClear { | |||
| 20 | - | return nil, false | |||
| 21 | - | } | |||
| 22 | - | return buildSingleDamageSpans(frame, d) | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | spans := make([]Span, 0) | |||
| 26 | - | for _, d := range damage { | |||
| 27 | - | if skip != nil && sameDamage(d, *skip) { | |||
| 28 | - | continue | |||
| 29 | - | } | |||
| 30 | - | if d.Kind != DamageText && d.Kind != DamageClear { | |||
| 31 | - | continue | |||
| 32 | - | } | |||
| 33 | - | x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height) | |||
| 34 | - | if !ok { | |||
| 35 | - | continue | |||
| 36 | - | } | |||
| 37 | - | for row := y; row < y+height; row++ { | |||
| 38 | - | if len(spans) == maxPlannedDamageSpans { | |||
| 39 | - | return nil, true | |||
| 40 | - | } | |||
| 41 | - | spans = append(spans, Span{Y: row, X: x, Width: width}) | |||
| 42 | - | } | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | slices.SortFunc(spans, func(a, b Span) int { | |||
| 46 | - | if c := cmp.Compare(a.Y, b.Y); c != 0 { | |||
| 47 | - | return c | |||
| 48 | - | } | |||
| 49 | - | return cmp.Compare(a.X, b.X) | |||
| 50 | - | }) | |||
| 51 | - | ||||
| 52 | - | return mergeDamageSpans(spans), false | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | func buildSingleDamageSpans(frame Frame, d Damage) ([]Span, bool) { | |||
| 56 | - | x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height) | |||
| 57 | - | if !ok { | |||
| 58 | - | return nil, false | |||
| 59 | - | } | |||
| 60 | - | if height > maxPlannedDamageSpans { | |||
| 61 | - | return nil, true | |||
| 62 | - | } | |||
| 63 | - | spans := make([]Span, height) | |||
| 64 | - | for row := range height { | |||
| 65 | - | spans[row] = Span{Y: y + row, X: x, Width: width} | |||
| 66 | - | } | |||
| 67 | - | return spans, false | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | func mergeDamageSpans(spans []Span) []Span { | |||
| 71 | - | if len(spans) < 2 { | |||
| 72 | - | return spans | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | out := spans[:0] | |||
| 76 | - | for _, span := range spans { | |||
| 77 | - | if len(out) == 0 { | |||
| 78 | - | out = append(out, span) | |||
| 79 | - | continue | |||
| 80 | - | } | |||
| 81 | - | last := &out[len(out)-1] | |||
| 82 | - | // Both endpoints are bounded by frame.Width after clampRect, so these | |||
| 83 | - | // additions cannot overflow. The comparison includes adjacency. | |||
| 84 | - | lastEnd := last.X + last.Width | |||
| 85 | - | spanEnd := span.X + span.Width | |||
| 86 | - | if span.Y != last.Y || span.X > lastEnd { | |||
| 87 | - | out = append(out, span) | |||
| 88 | - | continue | |||
| 89 | - | } | |||
| 90 | - | if spanEnd > lastEnd { | |||
| 91 | - | last.Width = spanEnd - last.X | |||
| 92 | - | } | |||
| 93 | - | } | |||
| 94 | - | return out | |||
| 95 | - | } | |||
D pkg/renderer/damage_plan_test.go +0 -99
| @@ -1,99 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "math" | |||
| 5 | - | "reflect" | |||
| 6 | - | "testing" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func TestBuildDamagePlan(t *testing.T) { | |||
| 10 | - | tests := []struct { | |||
| 11 | - | name string | |||
| 12 | - | frame Frame | |||
| 13 | - | damage []Damage | |||
| 14 | - | want []Span | |||
| 15 | - | full bool | |||
| 16 | - | }{ | |||
| 17 | - | { | |||
| 18 | - | name: "merges reverse overlaps", | |||
| 19 | - | frame: NewFrame(10, 2), | |||
| 20 | - | damage: []Damage{ | |||
| 21 | - | {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1}, | |||
| 22 | - | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1}, | |||
| 23 | - | }, | |||
| 24 | - | want: []Span{{Y: 0, X: 0, Width: 9}}, | |||
| 25 | - | }, | |||
| 26 | - | { | |||
| 27 | - | name: "merges text and clear", | |||
| 28 | - | frame: NewFrame(10, 1), | |||
| 29 | - | damage: []Damage{ | |||
| 30 | - | {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1}, | |||
| 31 | - | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1}, | |||
| 32 | - | }, | |||
| 33 | - | want: []Span{{Y: 0, X: 0, Width: 9}}, | |||
| 34 | - | }, | |||
| 35 | - | { | |||
| 36 | - | name: "orders multi-row spans", | |||
| 37 | - | frame: NewFrame(10, 3), | |||
| 38 | - | damage: []Damage{ | |||
| 39 | - | {Kind: DamageText, X: 5, Y: 2, Width: 2, Height: 1}, | |||
| 40 | - | {Kind: DamageText, X: 4, Y: 0, Width: 2, Height: 2}, | |||
| 41 | - | {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2}, | |||
| 42 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1}, | |||
| 43 | - | }, | |||
| 44 | - | want: []Span{ | |||
| 45 | - | {Y: 0, X: 1, Width: 2}, {Y: 0, X: 4, Width: 2}, | |||
| 46 | - | {Y: 1, X: 1, Width: 2}, {Y: 1, X: 4, Width: 2}, | |||
| 47 | - | {Y: 2, X: 5, Width: 2}, | |||
| 48 | - | }, | |||
| 49 | - | }, | |||
| 50 | - | { | |||
| 51 | - | name: "clamps rectangles", | |||
| 52 | - | frame: NewFrame(10, 3), | |||
| 53 | - | damage: []Damage{ | |||
| 54 | - | {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3}, | |||
| 55 | - | {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3}, | |||
| 56 | - | }, | |||
| 57 | - | want: []Span{{Y: 0, X: 0, Width: 3}, {Y: 1, X: 0, Width: 3}, {Y: 1, X: 8, Width: 2}, {Y: 2, X: 8, Width: 2}}, | |||
| 58 | - | }, | |||
| 59 | - | { | |||
| 60 | - | name: "does not mutate input", | |||
| 61 | - | frame: NewFrame(10, 2), | |||
| 62 | - | damage: []Damage{ | |||
| 63 | - | {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1}, | |||
| 64 | - | {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1}, | |||
| 65 | - | }, | |||
| 66 | - | want: []Span{{Y: 0, X: 1, Width: 2}, {Y: 1, X: 5, Width: 2}}, | |||
| 67 | - | }, | |||
| 68 | - | { | |||
| 69 | - | name: "exceeding budget requests full redraw", | |||
| 70 | - | frame: NewFrame(1, maxPlannedDamageSpans+1), | |||
| 71 | - | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}}, | |||
| 72 | - | want: nil, | |||
| 73 | - | full: true, | |||
| 74 | - | }, | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | for _, tt := range tests { | |||
| 78 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 79 | - | original := append([]Damage(nil), tt.damage...) | |||
| 80 | - | got, full := buildDamagePlan(tt.frame, tt.damage, nil) | |||
| 81 | - | if full != tt.full || !reflect.DeepEqual(got, tt.want) { | |||
| 82 | - | t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full) | |||
| 83 | - | } | |||
| 84 | - | if !reflect.DeepEqual(tt.damage, original) { | |||
| 85 | - | t.Fatalf("buildDamagePlan mutated damage: got %#v, want %#v", tt.damage, original) | |||
| 86 | - | } | |||
| 87 | - | }) | |||
| 88 | - | } | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | func TestClampRectRejectsOverflowingBounds(t *testing.T) { | |||
| 92 | - | frame := NewFrame(10, 10) | |||
| 93 | - | if _, _, _, _, ok := clampRect(frame, math.MaxInt-1, 0, 4, 1); ok { | |||
| 94 | - | t.Fatal("clampRect accepted overflowing x bound") | |||
| 95 | - | } | |||
| 96 | - | if _, _, _, _, ok := clampRect(frame, 0, math.MaxInt-1, 1, 4); ok { | |||
| 97 | - | t.Fatal("clampRect accepted overflowing y bound") | |||
| 98 | - | } | |||
| 99 | - | } | |||
D pkg/renderer/delta_plan.go +0 -233
| @@ -1,233 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | // Span identifies a changed horizontal range in a logical frame row. | |||
| 4 | - | type Span struct { | |||
| 5 | - | Y int | |||
| 6 | - | X int | |||
| 7 | - | Width int | |||
| 8 | - | } | |||
| 9 | - | ||||
| 10 | - | // Scroll identifies a safe full-width upward scroll. | |||
| 11 | - | type Scroll struct { | |||
| 12 | - | Y int | |||
| 13 | - | Height int | |||
| 14 | - | Count int | |||
| 15 | - | } | |||
| 16 | - | ||||
| 17 | - | // DeltaPlan describes how to advance a committed frame to the candidate frame. | |||
| 18 | - | // Snapshot requests replacement of the committed frame; Spans, when present, | |||
| 19 | - | // are the canonical ranges to encode before that replacement. | |||
| 20 | - | type DeltaPlan struct { | |||
| 21 | - | Snapshot bool | |||
| 22 | - | Scroll Scroll | |||
| 23 | - | Spans []Span | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | // DeltaCandidate holds a plan and owns a source frame snapshot when the plan | |||
| 27 | - | // has work to commit. Empty candidates are true no-ops. | |||
| 28 | - | type DeltaCandidate struct { | |||
| 29 | - | Plan DeltaPlan | |||
| 30 | - | frame Frame | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | func newDeltaCandidate(frame Frame, plan DeltaPlan) DeltaCandidate { | |||
| 34 | - | candidate := DeltaCandidate{Plan: plan} | |||
| 35 | - | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | |||
| 36 | - | candidate.frame = frame.Clone() | |||
| 37 | - | } | |||
| 38 | - | return candidate | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | // PlanDelta prepares a non-mutating update from committed to frame. | |||
| 42 | - | func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) { | |||
| 43 | - | if err := frame.Validate(); err != nil { | |||
| 44 | - | return DeltaCandidate{}, err | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | if reset || frame.Width != committed.Width || frame.Height != committed.Height { | |||
| 48 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |||
| 49 | - | } | |||
| 50 | - | if err := committed.Validate(); err != nil { | |||
| 51 | - | return DeltaCandidate{}, err | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | |||
| 55 | - | return newDeltaCandidate(frame, planSingleDamage(frame, damage[0])), nil | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | if needsFull(damage) { | |||
| 59 | - | dirty, full := buildDirtyLinePlan(frame, committed) | |||
| 60 | - | if len(dirty) > 0 || full { | |||
| 61 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}), nil | |||
| 62 | - | } | |||
| 63 | - | return DeltaCandidate{}, nil | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | var plan DeltaPlan | |||
| 67 | - | var skip *Damage | |||
| 68 | - | if scroll, ok := findSafeScroll(frame, damage); ok && canApplyScrollAgainst(frame, scroll, damage, committed) { | |||
| 69 | - | plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count} | |||
| 70 | - | skip = &scroll | |||
| 71 | - | } else if hasScrollDamage(damage) { | |||
| 72 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | var spans []Span | |||
| 76 | - | var full bool | |||
| 77 | - | if len(damage) == 0 { | |||
| 78 | - | spans, full = buildDirtyLinePlan(frame, committed) | |||
| 79 | - | } else { | |||
| 80 | - | spans, full = buildDamagePlan(frame, damage, skip) | |||
| 81 | - | } | |||
| 82 | - | if full { | |||
| 83 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |||
| 84 | - | } | |||
| 85 | - | if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) { | |||
| 86 | - | if plan.Scroll.Height != 0 { | |||
| 87 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil | |||
| 88 | - | } | |||
| 89 | - | return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}), nil | |||
| 90 | - | } | |||
| 91 | - | plan.Spans = spans | |||
| 92 | - | return newDeltaCandidate(frame, plan), nil | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | func planSingleDamage(frame Frame, d Damage) DeltaPlan { | |||
| 96 | - | spans, full := buildSingleDamageSpans(frame, d) | |||
| 97 | - | if full { | |||
| 98 | - | return DeltaPlan{Snapshot: true} | |||
| 99 | - | } | |||
| 100 | - | plan := DeltaPlan{Spans: spans} | |||
| 101 | - | if deltaCostsSnapshot(frame, spans, false) { | |||
| 102 | - | plan.Snapshot = true | |||
| 103 | - | } | |||
| 104 | - | return plan | |||
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) { | |||
| 108 | - | var spans []Span | |||
| 109 | - | for y := range frame.Height { | |||
| 110 | - | frameRow := frame.Row(y) | |||
| 111 | - | committedRow := committed.Row(y) | |||
| 112 | - | dirty := false | |||
| 113 | - | for x := range frame.Width { | |||
| 114 | - | if !frameRow[x].Equal(committedRow[x]) { | |||
| 115 | - | dirty = true | |||
| 116 | - | break | |||
| 117 | - | } | |||
| 118 | - | } | |||
| 119 | - | if dirty { | |||
| 120 | - | if len(spans) == maxPlannedDamageSpans { | |||
| 121 | - | return nil, true | |||
| 122 | - | } | |||
| 123 | - | spans = append(spans, Span{Y: y, Width: frame.Width}) | |||
| 124 | - | } | |||
| 125 | - | } | |||
| 126 | - | return spans, false | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool { | |||
| 130 | - | if !hasScroll && frame.Height > 1 && len(spans) == 1 { | |||
| 131 | - | return false | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | var deltaCost int64 | |||
| 135 | - | if hasScroll { | |||
| 136 | - | deltaCost = 6 | |||
| 137 | - | } | |||
| 138 | - | for _, span := range spans { | |||
| 139 | - | deltaCost += 12 + int64(span.Width) | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | minimumSnapshotCost := int64(frame.Height) * (12 + int64(frame.Width)) | |||
| 143 | - | if deltaCost < minimumSnapshotCost { | |||
| 144 | - | return false | |||
| 145 | - | } | |||
| 146 | - | maximumSnapshotCost := int64(frame.Height) * (8 + 5*int64(frame.Width)) | |||
| 147 | - | if deltaCost >= maximumSnapshotCost { | |||
| 148 | - | return true | |||
| 149 | - | } | |||
| 150 | - | if !hasScroll && fullWidthRows(frame, spans) { | |||
| 151 | - | return true | |||
| 152 | - | } | |||
| 153 | - | ||||
| 154 | - | for _, span := range spans { | |||
| 155 | - | deltaCost += 4 * int64(styleRunCount(frame.Row(span.Y)[span.X:span.X+span.Width])-1) | |||
| 156 | - | } | |||
| 157 | - | var snapshotCost int64 | |||
| 158 | - | for y := range frame.Height { | |||
| 159 | - | snapshotCost += spanCost(frame, y, 0, frame.Width) | |||
| 160 | - | } | |||
| 161 | - | return deltaCost >= snapshotCost | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | func fullWidthRows(frame Frame, spans []Span) bool { | |||
| 165 | - | if len(spans) != frame.Height { | |||
| 166 | - | return false | |||
| 167 | - | } | |||
| 168 | - | for y, span := range spans { | |||
| 169 | - | if span.Y != y || span.X != 0 || span.Width != frame.Width { | |||
| 170 | - | return false | |||
| 171 | - | } | |||
| 172 | - | } | |||
| 173 | - | return true | |||
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | func spanCost(frame Frame, y, x, width int) int64 { | |||
| 177 | - | return 8 + int64(width) + 4*int64(styleRunCount(frame.Row(y)[x:x+width])) | |||
| 178 | - | } | |||
| 179 | - | ||||
| 180 | - | func styleRunCount(cells []Cell) int { | |||
| 181 | - | if len(cells) == 0 { | |||
| 182 | - | return 0 | |||
| 183 | - | } | |||
| 184 | - | runs := 1 | |||
| 185 | - | for i := 1; i < len(cells); i++ { | |||
| 186 | - | if cells[i-1].Style != cells[i].Style && !cells[i-1].Style.Equal(cells[i].Style) { | |||
| 187 | - | runs++ | |||
| 188 | - | } | |||
| 189 | - | } | |||
| 190 | - | return runs | |||
| 191 | - | } | |||
| 192 | - | ||||
| 193 | - | // Commit advances dst to the frame snapshot owned by the candidate. | |||
| 194 | - | func (c DeltaCandidate) Commit(dst *Frame) { | |||
| 195 | - | if !c.Plan.Snapshot && c.Plan.Scroll.Height == 0 && len(c.Plan.Spans) == 0 { | |||
| 196 | - | return | |||
| 197 | - | } | |||
| 198 | - | if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height { | |||
| 199 | - | replaceFrame(dst, c.frame) | |||
| 200 | - | return | |||
| 201 | - | } | |||
| 202 | - | if c.Plan.Scroll.Height != 0 { | |||
| 203 | - | scroll := c.Plan.Scroll | |||
| 204 | - | dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count) | |||
| 205 | - | } | |||
| 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]) | |||
| 210 | - | } | |||
| 211 | - | } | |||
| 212 | - | ||||
| 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 | - | } | |||
| 233 | - | } | |||
D pkg/renderer/delta_plan_test.go +0 -271
| @@ -1,271 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "reflect" | |||
| 5 | - | "testing" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | func testFrame(rows ...string) Frame { | |||
| 9 | - | frame := NewFrame(len([]rune(rows[0])), len(rows)) | |||
| 10 | - | fillFrame(frame, rows) | |||
| 11 | - | return frame | |||
| 12 | - | } | |||
| 13 | - | ||||
| 14 | - | func TestPlanDelta(t *testing.T) { | |||
| 15 | - | scrollCommitted := testFrame("0000", "1111", "2222", "3333", "4444", "5555", "6666", "7777") | |||
| 16 | - | scrolled := testFrame("0000", "2222", "3333", "4444", "5555", "6666", "new!", "7777") | |||
| 17 | - | largeFrame := NewFrame(1, maxPlannedDamageSpans+1) | |||
| 18 | - | styledCommitted := NewFrame(10, 1) | |||
| 19 | - | styledCommitted.Set(3, 0, Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}}) | |||
| 20 | - | styledFrame := styledCommitted.Clone() | |||
| 21 | - | styledFrame.Set(0, 0, Cell{Rune: 'a', Style: DefaultStyle()}) | |||
| 22 | - | styledFrame.Set(9, 0, Cell{Rune: 'z', Style: DefaultStyle()}) | |||
| 23 | - | ||||
| 24 | - | tests := []struct { | |||
| 25 | - | name string | |||
| 26 | - | frame Frame | |||
| 27 | - | damage []Damage | |||
| 28 | - | committed Frame | |||
| 29 | - | reset bool | |||
| 30 | - | want DeltaPlan | |||
| 31 | - | }{ | |||
| 32 | - | { | |||
| 33 | - | name: "reset snapshots", | |||
| 34 | - | frame: NewFrame(4, 2), | |||
| 35 | - | committed: NewFrame(4, 2), | |||
| 36 | - | reset: true, | |||
| 37 | - | want: DeltaPlan{Snapshot: true}, | |||
| 38 | - | }, | |||
| 39 | - | { | |||
| 40 | - | name: "dimension mismatch snapshots", | |||
| 41 | - | frame: NewFrame(4, 2), | |||
| 42 | - | committed: NewFrame(3, 2), | |||
| 43 | - | want: DeltaPlan{Snapshot: true}, | |||
| 44 | - | }, | |||
| 45 | - | { | |||
| 46 | - | name: "unchanged same size has no spans", | |||
| 47 | - | frame: NewFrame(4, 2), | |||
| 48 | - | committed: NewFrame(4, 2), | |||
| 49 | - | want: DeltaPlan{}, | |||
| 50 | - | }, | |||
| 51 | - | { | |||
| 52 | - | name: "unchanged full redraw has no spans", | |||
| 53 | - | frame: NewFrame(4, 2), | |||
| 54 | - | damage: []Damage{FullRedraw()}, | |||
| 55 | - | committed: NewFrame(4, 2), | |||
| 56 | - | want: DeltaPlan{}, | |||
| 57 | - | }, | |||
| 58 | - | { | |||
| 59 | - | name: "changed row without damage becomes a full width span", | |||
| 60 | - | frame: testFrame(" ", "edit", " "), | |||
| 61 | - | committed: NewFrame(4, 3), | |||
| 62 | - | want: DeltaPlan{Spans: []Span{{Y: 1, X: 0, Width: 4}}}, | |||
| 63 | - | }, | |||
| 64 | - | { | |||
| 65 | - | name: "adjacent and overlapping damage is ordered and merged", | |||
| 66 | - | frame: testFrame(" ", "aaaaaaaaa ", " "), | |||
| 67 | - | damage: []Damage{ | |||
| 68 | - | {Kind: DamageText, X: 4, Y: 1, Width: 5, Height: 1}, | |||
| 69 | - | {Kind: DamageClear, X: 0, Y: 1, Width: 5, Height: 1}, | |||
| 70 | - | {Kind: DamageText, X: 2, Y: 1, Width: 2, Height: 1}, | |||
| 71 | - | }, | |||
| 72 | - | committed: NewFrame(10, 3), | |||
| 73 | - | want: DeltaPlan{Spans: []Span{{Y: 1, X: 0, Width: 9}}}, | |||
| 74 | - | }, | |||
| 75 | - | { | |||
| 76 | - | name: "safe full width scroll retains exposed span", | |||
| 77 | - | frame: scrolled, | |||
| 78 | - | damage: []Damage{ | |||
| 79 | - | {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 6, Count: 1}, | |||
| 80 | - | {Kind: DamageText, X: 0, Y: 6, Width: 4, Height: 1}, | |||
| 81 | - | }, | |||
| 82 | - | committed: scrollCommitted, | |||
| 83 | - | want: DeltaPlan{ | |||
| 84 | - | Scroll: Scroll{Y: 1, Height: 6, Count: 1}, | |||
| 85 | - | Spans: []Span{{Y: 6, X: 0, Width: 4}}, | |||
| 86 | - | }, | |||
| 87 | - | }, | |||
| 88 | - | { | |||
| 89 | - | name: "safe full width scroll with expensive delta snapshots", | |||
| 90 | - | frame: testFrame("1111", "new!"), | |||
| 91 | - | damage: []Damage{ | |||
| 92 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 2, Count: 1}, | |||
| 93 | - | {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}, | |||
| 94 | - | }, | |||
| 95 | - | committed: testFrame("0000", "1111"), | |||
| 96 | - | want: DeltaPlan{Snapshot: true}, | |||
| 97 | - | }, | |||
| 98 | - | { | |||
| 99 | - | name: "rectangular scroll snapshots", | |||
| 100 | - | frame: scrolled, | |||
| 101 | - | damage: []Damage{ | |||
| 102 | - | {Kind: DamageScrollUp, X: 1, Y: 1, Width: 3, Height: 6, Count: 1}, | |||
| 103 | - | }, | |||
| 104 | - | committed: scrollCommitted, | |||
| 105 | - | want: DeltaPlan{Snapshot: true}, | |||
| 106 | - | }, | |||
| 107 | - | { | |||
| 108 | - | name: "unsafe scroll content snapshots", | |||
| 109 | - | frame: testFrame("0000", "2222", "xxxx", "4444", "5555", "6666", "new!", "7777"), | |||
| 110 | - | damage: []Damage{ | |||
| 111 | - | {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 6, Count: 1}, | |||
| 112 | - | {Kind: DamageText, X: 0, Y: 6, Width: 4, Height: 1}, | |||
| 113 | - | }, | |||
| 114 | - | committed: scrollCommitted, | |||
| 115 | - | want: DeltaPlan{Snapshot: true}, | |||
| 116 | - | }, | |||
| 117 | - | { | |||
| 118 | - | name: "more than 4096 canonical spans snapshots", | |||
| 119 | - | frame: largeFrame, | |||
| 120 | - | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}}, | |||
| 121 | - | committed: NewFrame(1, maxPlannedDamageSpans+1), | |||
| 122 | - | want: DeltaPlan{Snapshot: true}, | |||
| 123 | - | }, | |||
| 124 | - | { | |||
| 125 | - | name: "delta cost equal to snapshot cost snapshots", | |||
| 126 | - | frame: testFrame("changed!"), | |||
| 127 | - | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 8, Height: 1}}, | |||
| 128 | - | committed: NewFrame(8, 1), | |||
| 129 | - | want: DeltaPlan{Snapshot: true, Spans: []Span{{Y: 0, X: 0, Width: 8}}}, | |||
| 130 | - | }, | |||
| 131 | - | { | |||
| 132 | - | name: "single broad damage retains spans for snapshot encoding", | |||
| 133 | - | frame: testFrame("aaaa", "bbbb"), | |||
| 134 | - | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}, | |||
| 135 | - | committed: NewFrame(4, 2), | |||
| 136 | - | want: DeltaPlan{ | |||
| 137 | - | Snapshot: true, | |||
| 138 | - | Spans: []Span{{Y: 0, X: 0, Width: 4}, {Y: 1, X: 0, Width: 4}}, | |||
| 139 | - | }, | |||
| 140 | - | }, | |||
| 141 | - | { | |||
| 142 | - | name: "snapshot cost includes style runs", | |||
| 143 | - | frame: styledFrame, | |||
| 144 | - | damage: []Damage{ | |||
| 145 | - | {Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}, | |||
| 146 | - | {Kind: DamageText, X: 9, Y: 0, Width: 1, Height: 1}, | |||
| 147 | - | }, | |||
| 148 | - | committed: styledCommitted, | |||
| 149 | - | want: DeltaPlan{Spans: []Span{{Y: 0, X: 0, Width: 1}, {Y: 0, X: 9, Width: 1}}}, | |||
| 150 | - | }, | |||
| 151 | - | } | |||
| 152 | - | ||||
| 153 | - | for _, tt := range tests { | |||
| 154 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 155 | - | candidate, err := PlanDelta(tt.frame, tt.damage, tt.committed, tt.reset) | |||
| 156 | - | if err != nil { | |||
| 157 | - | t.Fatalf("PlanDelta() error = %v", err) | |||
| 158 | - | } | |||
| 159 | - | if !reflect.DeepEqual(candidate.Plan, tt.want) { | |||
| 160 | - | t.Fatalf("PlanDelta() = %#v, want %#v", candidate.Plan, tt.want) | |||
| 161 | - | } | |||
| 162 | - | }) | |||
| 163 | - | } | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | func TestNoOpDeltaCandidateDoesNotCloneFrame(t *testing.T) { | |||
| 167 | - | frame := testFrame("abcd", "efgh") | |||
| 168 | - | candidate, err := PlanDelta(frame, nil, frame, false) | |||
| 169 | - | if err != nil { | |||
| 170 | - | t.Fatalf("PlanDelta() error = %v", err) | |||
| 171 | - | } | |||
| 172 | - | if candidate.frame.Cells != nil || candidate.frame.lineOffset != nil { | |||
| 173 | - | t.Fatal("no-op candidate owns frame storage") | |||
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | committed := frame.Clone() | |||
| 177 | - | before := committed.Clone() | |||
| 178 | - | candidate.Commit(&committed) | |||
| 179 | - | if !reflect.DeepEqual(committed, before) { | |||
| 180 | - | t.Fatal("no-op candidate changed committed frame") | |||
| 181 | - | } | |||
| 182 | - | } | |||
| 183 | - | ||||
| 184 | - | func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) { | |||
| 185 | - | committed := testFrame("0000", "1111", "2222", "3333", "4444") | |||
| 186 | - | committed.ScrollUp(1, 3, 1) | |||
| 187 | - | beforeCells := append([]Cell(nil), committed.Cells...) | |||
| 188 | - | beforeOffsets := append([]int(nil), committed.lineOffset...) | |||
| 189 | - | frame := testFrame("0000", "2222", "3333", "new!", "4444") | |||
| 190 | - | damage := []Damage{ | |||
| 191 | - | {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 3, Count: 1}, | |||
| 192 | - | {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1}, | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | if _, err := PlanDelta(frame, damage, committed, false); err != nil { | |||
| 196 | - | t.Fatalf("PlanDelta() error = %v", err) | |||
| 197 | - | } | |||
| 198 | - | ||||
| 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") | |||
| 204 | - | } | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | - | func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) { | |||
| 208 | - | frame := testFrame("aaaa", "bbbb", "cccc", "dddd") | |||
| 209 | - | frame.ScrollUp(0, 3, 1) | |||
| 210 | - | fillFrameRow(frame, 3, "new!") | |||
| 211 | - | committed := NewFrame(4, 4) | |||
| 212 | - | cellsBase := &committed.Cells[0] | |||
| 213 | - | ||||
| 214 | - | candidate, err := PlanDelta(frame, nil, committed, true) | |||
| 215 | - | if err != nil { | |||
| 216 | - | t.Fatalf("PlanDelta() error = %v", err) | |||
| 217 | - | } | |||
| 218 | - | candidate.Commit(&committed) | |||
| 219 | - | ||||
| 220 | - | if &committed.Cells[0] != cellsBase { | |||
| 221 | - | t.Fatal("Commit replaced same-sized cell storage") | |||
| 222 | - | } | |||
| 223 | - | for y := range frame.Height { | |||
| 224 | - | if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) { | |||
| 225 | - | t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y)) | |||
| 226 | - | } | |||
| 227 | - | } | |||
| 228 | - | if err := committed.CheckInvariants(); err != nil { | |||
| 229 | - | t.Fatalf("committed frame invariants: %v", err) | |||
| 230 | - | } | |||
| 231 | - | } | |||
| 232 | - | ||||
| 233 | - | func TestPlanDeltaRejectsInvalidFrame(t *testing.T) { | |||
| 234 | - | frame := NewFrame(2, 1) | |||
| 235 | - | frame.Cells = nil | |||
| 236 | - | if _, err := PlanDelta(frame, nil, Frame{}, false); err == nil { | |||
| 237 | - | t.Fatal("PlanDelta accepted an invalid frame") | |||
| 238 | - | } | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | - | func TestDeltaCandidateCommitAppliesScrollToLogicalRows(t *testing.T) { | |||
| 242 | - | committed := testFrame("0000", "1111", "2222", "3333", "4444", "5555", "6666", "7777") | |||
| 243 | - | frame := committed.Clone() | |||
| 244 | - | frame.ScrollUp(1, 6, 1) | |||
| 245 | - | fillFrameRow(frame, 6, "new!") | |||
| 246 | - | damage := []Damage{ | |||
| 247 | - | {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 6, Count: 1}, | |||
| 248 | - | {Kind: DamageText, X: 0, Y: 6, Width: 4, Height: 1}, | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | candidate, err := PlanDelta(frame, damage, committed, false) | |||
| 252 | - | if err != nil { | |||
| 253 | - | t.Fatalf("PlanDelta() error = %v", err) | |||
| 254 | - | } | |||
| 255 | - | if candidate.Plan.Scroll.Height == 0 { | |||
| 256 | - | t.Fatal("PlanDelta dropped safe scroll") | |||
| 257 | - | } | |||
| 258 | - | candidate.Commit(&committed) | |||
| 259 | - | ||||
| 260 | - | for y := range frame.Height { | |||
| 261 | - | if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) { | |||
| 262 | - | t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y)) | |||
| 263 | - | } | |||
| 264 | - | } | |||
| 265 | - | } | |||
| 266 | - | ||||
| 267 | - | func fillFrameRow(frame Frame, y int, row string) { | |||
| 268 | - | for x, r := range row { | |||
| 269 | - | frame.Set(x, y, Cell{Rune: r, Style: DefaultStyle()}) | |||
| 270 | - | } | |||
| 271 | - | } | |||
D pkg/renderer/doc.go +0 -2
| @@ -1,2 +0,0 @@ | |||||
| 1 | - | // Package renderer draws terminal state to the screen. | |||
| 2 | - | package renderer | |||
D pkg/renderer/draw_state.go +0 -250
| @@ -1,250 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "strconv" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | // drawState carries the per-Draw terminal state model: the cursor position we | |||
| 9 | - | // last put the terminal at (when it is known with certainty) and the SGR pen | |||
| 10 | - | // currently active. It lives for exactly one Draw call: between Draws the | |||
| 11 | - | // terminal may be moved by other writers, so cursor tracking never persists | |||
| 12 | - | // (this also trivially satisfies "invalidate on Reset"). The pen starts as | |||
| 13 | - | // the default style because every Draw that emits output ends with \x1b[0m, | |||
| 14 | - | // which is this package's output contract. | |||
| 15 | - | type drawState struct { | |||
| 16 | - | curKnown bool | |||
| 17 | - | curRow int | |||
| 18 | - | curCol int | |||
| 19 | - | pen Style | |||
| 20 | - | } | |||
| 21 | - | ||||
| 22 | - | func newDrawState() drawState { | |||
| 23 | - | return drawState{pen: DefaultStyle()} | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | func (st *drawState) setCursor(row, col int) { | |||
| 27 | - | st.curKnown = true | |||
| 28 | - | st.curRow = row | |||
| 29 | - | st.curCol = col | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | // invalidateCursor marks the tracked position as uncertain. It is called | |||
| 33 | - | // whenever an emission leaves the cursor in a terminal-dependent state, most | |||
| 34 | - | // importantly after writing into the last column: terminals differ on | |||
| 35 | - | // wrap-pending behavior, so we never model it and fall back to absolute CUP. | |||
| 36 | - | func (st *drawState) invalidateCursor() { | |||
| 37 | - | st.curKnown = false | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | // Cursor-motion candidates considered by planMove. Absolute CUP is always | |||
| 41 | - | // correct; the others are only valid relative to a certain tracked position. | |||
| 42 | - | const ( | |||
| 43 | - | moveCUP = 'H' // \x1b[<r>;<c>H | |||
| 44 | - | moveCR = '\r' // carriage return: same row, column 0 | |||
| 45 | - | moveCUU = 'A' // \x1b[<n>A: up, same column | |||
| 46 | - | moveCUD = 'B' // \x1b[<n>B: down, same column | |||
| 47 | - | moveCUF = 'C' // \x1b[<n>C: forward, same row | |||
| 48 | - | moveCUB = 'D' // \x1b[<n>D: backward, same row | |||
| 49 | - | ) | |||
| 50 | - | ||||
| 51 | - | // planMove picks the cheapest correct sequence to move from the tracked | |||
| 52 | - | // position to (row, col). "Cheapest" is decided by comparing the actual | |||
| 53 | - | // encoded byte lengths of every applicable candidate (no fixed thresholds): | |||
| 54 | - | // CUP costs 4 + digits(row+1) + digits(col+1); CR costs 1; CUU/CUD/CUF/CUB | |||
| 55 | - | // cost 3 for a delta of 1 (count omitted) and 3 + digits(n) otherwise. | |||
| 56 | - | // Relative candidates are considered only when the tracked position is | |||
| 57 | - | // certain; ties go to absolute CUP, the safest form. | |||
| 58 | - | func (st *drawState) planMove(row, col int) (op byte, n, cost int) { | |||
| 59 | - | op, n, cost = moveCUP, 0, 4+decimalLen(row+1)+decimalLen(col+1) | |||
| 60 | - | if !st.curKnown { | |||
| 61 | - | return op, n, cost | |||
| 62 | - | } | |||
| 63 | - | consider := func(candOp byte, candN, candCost int) { | |||
| 64 | - | if candCost < cost { | |||
| 65 | - | op, n, cost = candOp, candN, candCost | |||
| 66 | - | } | |||
| 67 | - | } | |||
| 68 | - | if row == st.curRow { | |||
| 69 | - | if col == 0 { | |||
| 70 | - | consider(moveCR, 0, 1) | |||
| 71 | - | } | |||
| 72 | - | if dc := col - st.curCol; dc > 0 { | |||
| 73 | - | consider(moveCUF, dc, csiRelLen(dc)) | |||
| 74 | - | } else if dc < 0 { | |||
| 75 | - | consider(moveCUB, -dc, csiRelLen(-dc)) | |||
| 76 | - | } | |||
| 77 | - | } else if col == st.curCol { | |||
| 78 | - | if dr := row - st.curRow; dr > 0 { | |||
| 79 | - | consider(moveCUD, dr, csiRelLen(dr)) | |||
| 80 | - | } else { | |||
| 81 | - | consider(moveCUU, -dr, csiRelLen(-dr)) | |||
| 82 | - | } | |||
| 83 | - | } | |||
| 84 | - | return op, n, cost | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | // moveTo emits the cheapest correct cursor-positioning sequence for (row, col) | |||
| 88 | - | // and updates tracking. Moving to the already-tracked position emits nothing. | |||
| 89 | - | func (st *drawState) moveTo(out *bytes.Buffer, row, col int) { | |||
| 90 | - | if st.curKnown && st.curRow == row && st.curCol == col { | |||
| 91 | - | return | |||
| 92 | - | } | |||
| 93 | - | op, n, _ := st.planMove(row, col) | |||
| 94 | - | switch op { | |||
| 95 | - | case moveCUP: | |||
| 96 | - | writeCursor(out, row, col) | |||
| 97 | - | case moveCR: | |||
| 98 | - | out.WriteByte('\r') | |||
| 99 | - | default: | |||
| 100 | - | writeCSIRel(out, n, op) | |||
| 101 | - | } | |||
| 102 | - | st.setCursor(row, col) | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | // moveCost returns the encoded length moveTo would emit for (row, col). | |||
| 106 | - | func (st *drawState) moveCost(row, col int) int { | |||
| 107 | - | if st.curKnown && st.curRow == row && st.curCol == col { | |||
| 108 | - | return 0 | |||
| 109 | - | } | |||
| 110 | - | _, _, cost := st.planMove(row, col) | |||
| 111 | - | return cost | |||
| 112 | - | } | |||
| 113 | - | ||||
| 114 | - | // setPen emits an SGR sequence if style differs from the current pen. | |||
| 115 | - | func (st *drawState) setPen(out *bytes.Buffer, style Style) { | |||
| 116 | - | if style.Equal(st.pen) { | |||
| 117 | - | return | |||
| 118 | - | } | |||
| 119 | - | writeStyle(out, style) | |||
| 120 | - | st.pen = style | |||
| 121 | - | } | |||
| 122 | - | ||||
| 123 | - | // isBlank reports whether a cell renders as a default-style space. This is | |||
| 124 | - | // the differ's notion of blank: rune ' ' or 0, default style, and not the | |||
| 125 | - | // continuation half of a wide pair. | |||
| 126 | - | func isBlank(c Cell) bool { | |||
| 127 | - | return !c.Continuation && (c.Rune == ' ' || c.Rune == 0) && c.Style.Equal(DefaultStyle()) | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | // Native-clear encoded lengths: EL "\x1b[K" and EL2 "\x1b[2K". | |||
| 131 | - | const ( | |||
| 132 | - | elLen = 3 | |||
| 133 | - | el2Len = 4 | |||
| 134 | - | ) | |||
| 135 | - | ||||
| 136 | - | // emitSpan positions the cursor and emits the cells of logical row y in | |||
| 137 | - | // [x, x+width), already clamped by the caller. It applies the three output | |||
| 138 | - | // minimizations: | |||
| 139 | - | // | |||
| 140 | - | // - cursor motion: moveTo picks the cheapest sequence, and after emission | |||
| 141 | - | // the cursor is tracked at x+width (continuation cells count as columns: | |||
| 142 | - | // the terminal advances two columns per wide rune). A span that touches | |||
| 143 | - | // the last column invalidates tracking instead — wrap-pending state is | |||
| 144 | - | // terminal-dependent and never modeled. | |||
| 145 | - | // - SGR: the pen persists across spans (st is Draw-scoped); a style change | |||
| 146 | - | // is emitted only when a cell's style differs from the pen. | |||
| 147 | - | // - native clears: a trailing run of blank cells that reaches the frame's | |||
| 148 | - | // right edge is replaced by EL when EL is not longer than the spaces it | |||
| 149 | - | // replaces (ties prefer EL: it keeps the cursor position certain). An | |||
| 150 | - | // entirely blank full-width line may instead use a vertical-only move | |||
| 151 | - | // plus 2K when that encoding is cheaper. Both clears require the default | |||
| 152 | - | // pen (BCE: terminals fill cleared cells with the pen background). | |||
| 153 | - | func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st *drawState) { | |||
| 154 | - | end := x + width | |||
| 155 | - | // Trailing blank suffix is EL-eligible only when the span reaches the | |||
| 156 | - | // frame's right edge (EL clears to the end of the line, nothing less). | |||
| 157 | - | t := end | |||
| 158 | - | if end == frame.Width { | |||
| 159 | - | for t > x && isBlank(frame.At(t-1, y)) { | |||
| 160 | - | t-- | |||
| 161 | - | } | |||
| 162 | - | } | |||
| 163 | - | useEL := end == frame.Width && end-t >= elLen | |||
| 164 | - | if !useEL { | |||
| 165 | - | t = end | |||
| 166 | - | } | |||
| 167 | - | ||||
| 168 | - | if useEL && t == x && x == 0 && width == frame.Width { | |||
| 169 | - | // Entire line blank: compare "move to col 0 + EL" against | |||
| 170 | - | // "vertical-only move + 2K" (2K clears the whole line from any | |||
| 171 | - | // column, so a cheap same-column CUD/CUU can be reused). | |||
| 172 | - | elCost := st.moveCost(y, 0) + elLen | |||
| 173 | - | if st.curKnown && st.moveCost(y, st.curCol)+el2Len < elCost { | |||
| 174 | - | st.setPen(out, DefaultStyle()) | |||
| 175 | - | st.moveTo(out, y, st.curCol) | |||
| 176 | - | out.WriteString("\x1b[2K") | |||
| 177 | - | return | |||
| 178 | - | } | |||
| 179 | - | } | |||
| 180 | - | ||||
| 181 | - | st.moveTo(out, y, x) | |||
| 182 | - | for col := x; col < t; col++ { | |||
| 183 | - | cell := frame.At(col, y) | |||
| 184 | - | // Continuation cells are the right half of a wide rune. Emit nothing: | |||
| 185 | - | // the terminal already advanced two columns for the left cell's rune. | |||
| 186 | - | if cell.Continuation { | |||
| 187 | - | continue | |||
| 188 | - | } | |||
| 189 | - | // Pen check kept inline (not via setPen): this is the hottest loop in | |||
| 190 | - | // the renderer and the method call costs measurably on full redraws. | |||
| 191 | - | if !cell.Style.Equal(st.pen) { | |||
| 192 | - | writeStyle(out, cell.Style) | |||
| 193 | - | st.pen = cell.Style | |||
| 194 | - | } | |||
| 195 | - | if cell.Rune == 0 { | |||
| 196 | - | out.WriteByte(' ') | |||
| 197 | - | } else { | |||
| 198 | - | out.WriteRune(cell.Rune) | |||
| 199 | - | } | |||
| 200 | - | } | |||
| 201 | - | if useEL { | |||
| 202 | - | st.setPen(out, DefaultStyle()) | |||
| 203 | - | out.WriteString("\x1b[K") | |||
| 204 | - | // EL does not move the cursor: it stays at the start of the blanks. | |||
| 205 | - | st.setCursor(y, t) | |||
| 206 | - | } else if end >= frame.Width { | |||
| 207 | - | st.invalidateCursor() | |||
| 208 | - | } else { | |||
| 209 | - | st.setCursor(y, end) | |||
| 210 | - | } | |||
| 211 | - | // A span that splits a wide pair breaks the "terminal advance == span | |||
| 212 | - | // width" assumption tracking relies on: a leading orphan continuation | |||
| 213 | - | // (head outside the span) advances one column less, and a span ending on | |||
| 214 | - | // a wide head whose continuation lies outside advances one more. Both are | |||
| 215 | - | // malformed inputs the VT layer should never produce, but tracking must | |||
| 216 | - | // stay certain, so drop it. (t is never a continuation on the EL path — | |||
| 217 | - | // blanks are not continuations — so this check is exact for both paths.) | |||
| 218 | - | if frame.At(x, y).Continuation || (t < frame.Width && frame.At(t, y).Continuation) { | |||
| 219 | - | st.invalidateCursor() | |||
| 220 | - | } | |||
| 221 | - | } | |||
| 222 | - | ||||
| 223 | - | // writeCSIRel emits a relative cursor move "\x1b[<n><letter>", omitting the | |||
| 224 | - | // count when n == 1 as the CSI default allows. | |||
| 225 | - | func writeCSIRel(out *bytes.Buffer, n int, letter byte) { | |||
| 226 | - | out.WriteString("\x1b[") | |||
| 227 | - | if n != 1 { | |||
| 228 | - | var b [16]byte | |||
| 229 | - | out.Write(strconv.AppendInt(b[:0], int64(n), 10)) | |||
| 230 | - | } | |||
| 231 | - | out.WriteByte(letter) | |||
| 232 | - | } | |||
| 233 | - | ||||
| 234 | - | // csiRelLen is the encoded length of writeCSIRel's output. | |||
| 235 | - | func csiRelLen(n int) int { | |||
| 236 | - | if n == 1 { | |||
| 237 | - | return 3 | |||
| 238 | - | } | |||
| 239 | - | return 3 + decimalLen(n) | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | // decimalLen is the number of decimal digits of a non-negative int. | |||
| 243 | - | func decimalLen(n int) int { | |||
| 244 | - | l := 1 | |||
| 245 | - | for n >= 10 { | |||
| 246 | - | n /= 10 | |||
| 247 | - | l++ | |||
| 248 | - | } | |||
| 249 | - | return l | |||
| 250 | - | } | |||
D pkg/renderer/frame.go +0 -135
| @@ -1,135 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import "fmt" | |||
| 4 | - | ||||
| 5 | - | // Frame is a fixed-size grid of cells. Logical rows are decoupled from their | |||
| 6 | - | // physical storage through lineOffset: logical row y lives at | |||
| 7 | - | // Cells[lineOffset[y] : lineOffset[y]+Width]. This indirection lets full-width | |||
| 8 | - | // scrolls rotate offsets instead of copying cell memory (NeoVim's grid | |||
| 9 | - | // technique; cf. nvim grid.c line_offset[]). All cell access must go through | |||
| 10 | - | // the accessors (At/Set/Row) so callers never assume a physical layout. | |||
| 11 | - | type Frame struct { | |||
| 12 | - | Width int | |||
| 13 | - | Height int | |||
| 14 | - | Cells []Cell | |||
| 15 | - | // lineOffset maps a logical row index to its physical base index into | |||
| 16 | - | // Cells. It is always a permutation of the canonical offsets y*Width. | |||
| 17 | - | lineOffset []int | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | func NewFrame(width, height int) Frame { | |||
| 21 | - | cells := make([]Cell, width*height) | |||
| 22 | - | for i := range cells { | |||
| 23 | - | cells[i] = BlankCell() | |||
| 24 | - | } | |||
| 25 | - | lineOffset := make([]int, height) | |||
| 26 | - | for y := range lineOffset { | |||
| 27 | - | lineOffset[y] = y * width | |||
| 28 | - | } | |||
| 29 | - | return Frame{Width: width, Height: height, Cells: cells, lineOffset: lineOffset} | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | // Clone returns an independent copy preserving logical row contents. The clone | |||
| 33 | - | // uses canonical row storage so later scrolls or writes to the source frame do | |||
| 34 | - | // not affect it. | |||
| 35 | - | func (f Frame) Clone() Frame { | |||
| 36 | - | clone := NewFrame(f.Width, f.Height) | |||
| 37 | - | for y := range f.Height { | |||
| 38 | - | copy(clone.Row(y), f.Row(y)) | |||
| 39 | - | } | |||
| 40 | - | return clone | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | func (f Frame) Validate() error { | |||
| 44 | - | if f.Width <= 0 || f.Height <= 0 { | |||
| 45 | - | return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height) | |||
| 46 | - | } | |||
| 47 | - | if len(f.Cells) != f.Width*f.Height { | |||
| 48 | - | return fmt.Errorf("invalid cell count: got %d want %d", len(f.Cells), f.Width*f.Height) | |||
| 49 | - | } | |||
| 50 | - | if len(f.lineOffset) != f.Height { | |||
| 51 | - | return fmt.Errorf("invalid lineOffset length: got %d want %d", len(f.lineOffset), f.Height) | |||
| 52 | - | } | |||
| 53 | - | return nil | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | // CheckInvariants verifies that lineOffset is a permutation of the canonical | |||
| 57 | - | // row offsets: every entry is a non-negative multiple of Width within bounds, | |||
| 58 | - | // and every logical row maps to a distinct physical row. It is used by tests | |||
| 59 | - | // and assertions; the hot path uses the lighter Validate. | |||
| 60 | - | func (f Frame) CheckInvariants() error { | |||
| 61 | - | if f.Width <= 0 { | |||
| 62 | - | return fmt.Errorf("invalid width %d", f.Width) | |||
| 63 | - | } | |||
| 64 | - | if len(f.lineOffset) != f.Height { | |||
| 65 | - | return fmt.Errorf("lineOffset length %d != height %d", len(f.lineOffset), f.Height) | |||
| 66 | - | } | |||
| 67 | - | seen := make([]bool, f.Height) | |||
| 68 | - | for y, off := range f.lineOffset { | |||
| 69 | - | if off < 0 || off%f.Width != 0 { | |||
| 70 | - | return fmt.Errorf("row %d: offset %d is not a non-negative multiple of width %d", y, off, f.Width) | |||
| 71 | - | } | |||
| 72 | - | phys := off / f.Width | |||
| 73 | - | if phys >= f.Height { | |||
| 74 | - | return fmt.Errorf("row %d: offset %d maps to physical row %d out of range [0,%d)", y, off, phys, f.Height) | |||
| 75 | - | } | |||
| 76 | - | if seen[phys] { | |||
| 77 | - | return fmt.Errorf("row %d: physical row %d already mapped by another logical row", y, phys) | |||
| 78 | - | } | |||
| 79 | - | seen[phys] = true | |||
| 80 | - | } | |||
| 81 | - | return nil | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | func (f Frame) At(x, y int) Cell { | |||
| 85 | - | return f.Cells[f.lineOffset[y]+x] | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | func (f Frame) Set(x, y int, cell Cell) { | |||
| 89 | - | f.Cells[f.lineOffset[y]+x] = cell | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | // Row returns the backing slice for logical row y. Mutating the returned slice | |||
| 93 | - | // mutates the frame in place. The slice is valid until the next scroll or | |||
| 94 | - | // resize that reassigns offsets. | |||
| 95 | - | func (f Frame) Row(y int) []Cell { | |||
| 96 | - | base := f.lineOffset[y] | |||
| 97 | - | return f.Cells[base : base+f.Width] | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | // ScrollUp scrolls the logical rows in [top,bottom] up by n lines by rotating | |||
| 101 | - | // their physical offsets: no per-cell copying. Rows scrolled off the top are | |||
| 102 | - | // recycled to the bottom and blanked in place. It assumes the full frame width | |||
| 103 | - | // and 0 <= top <= bottom < Height and 0 < n <= bottom-top+1 (the caller | |||
| 104 | - | // clamps). The receiver is a value, but lineOffset and Cells alias the caller's | |||
| 105 | - | // backing arrays, so in-place mutations are visible to the caller. | |||
| 106 | - | func (f Frame) ScrollUp(top, bottom, n int) { | |||
| 107 | - | for ; n > 0; n-- { | |||
| 108 | - | recycled := f.lineOffset[top] | |||
| 109 | - | for y := top; y < bottom; y++ { | |||
| 110 | - | f.lineOffset[y] = f.lineOffset[y+1] | |||
| 111 | - | } | |||
| 112 | - | f.lineOffset[bottom] = recycled | |||
| 113 | - | blankRow(f.Cells[recycled : recycled+f.Width]) | |||
| 114 | - | } | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | // 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. | |||
| 120 | - | func (f Frame) ScrollDown(top, bottom, n int) { | |||
| 121 | - | for ; n > 0; n-- { | |||
| 122 | - | recycled := f.lineOffset[bottom] | |||
| 123 | - | for y := bottom; y > top; y-- { | |||
| 124 | - | f.lineOffset[y] = f.lineOffset[y-1] | |||
| 125 | - | } | |||
| 126 | - | f.lineOffset[top] = recycled | |||
| 127 | - | blankRow(f.Cells[recycled : recycled+f.Width]) | |||
| 128 | - | } | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | func blankRow(row []Cell) { | |||
| 132 | - | for i := range row { | |||
| 133 | - | row[i] = BlankCell() | |||
| 134 | - | } | |||
| 135 | - | } | |||
D pkg/renderer/frame_test.go +0 -165
| @@ -1,165 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import "testing" | |||
| 4 | - | ||||
| 5 | - | // rowRunes renders a logical row to a string for readable assertions. | |||
| 6 | - | func rowRunes(f Frame, y int) string { | |||
| 7 | - | out := make([]rune, f.Width) | |||
| 8 | - | for x := range f.Width { | |||
| 9 | - | out[x] = f.At(x, y).Rune | |||
| 10 | - | } | |||
| 11 | - | return string(out) | |||
| 12 | - | } | |||
| 13 | - | ||||
| 14 | - | // fillFrame writes one distinct rune per logical row so rotation is observable. | |||
| 15 | - | func fillFrame(f Frame, rows []string) { | |||
| 16 | - | for y, s := range rows { | |||
| 17 | - | for x, r := range s { | |||
| 18 | - | f.Set(x, y, Cell{Rune: r, Style: DefaultStyle()}) | |||
| 19 | - | } | |||
| 20 | - | } | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | func TestNewFrameCanonicalOffsets(t *testing.T) { | |||
| 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) | |||
| 28 | - | } | |||
| 29 | - | } | |||
| 30 | - | if err := f.CheckInvariants(); err != nil { | |||
| 31 | - | t.Fatalf("fresh frame invariants: %v", err) | |||
| 32 | - | } | |||
| 33 | - | } | |||
| 34 | - | ||||
| 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") | |||
| 55 | - | } | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | func TestScrollUpEquivalence(t *testing.T) { | |||
| 59 | - | tests := []struct { | |||
| 60 | - | name string | |||
| 61 | - | width, height int | |||
| 62 | - | rows []string | |||
| 63 | - | top, bottom int | |||
| 64 | - | n int | |||
| 65 | - | want []string | |||
| 66 | - | }{ | |||
| 67 | - | { | |||
| 68 | - | name: "scroll up 1 full screen", width: 3, height: 4, | |||
| 69 | - | rows: []string{"aaa", "bbb", "ccc", "ddd"}, | |||
| 70 | - | top: 0, bottom: 3, n: 1, | |||
| 71 | - | want: []string{"bbb", "ccc", "ddd", " "}, | |||
| 72 | - | }, | |||
| 73 | - | { | |||
| 74 | - | name: "scroll up 2 full screen", width: 3, height: 4, | |||
| 75 | - | rows: []string{"aaa", "bbb", "ccc", "ddd"}, | |||
| 76 | - | top: 0, bottom: 3, n: 2, | |||
| 77 | - | want: []string{"ccc", "ddd", " ", " "}, | |||
| 78 | - | }, | |||
| 79 | - | { | |||
| 80 | - | name: "scroll up region subset", width: 3, height: 5, | |||
| 81 | - | rows: []string{"aaa", "bbb", "ccc", "ddd", "eee"}, | |||
| 82 | - | top: 1, bottom: 3, n: 1, | |||
| 83 | - | want: []string{"aaa", "ccc", "ddd", " ", "eee"}, | |||
| 84 | - | }, | |||
| 85 | - | } | |||
| 86 | - | for _, tt := range tests { | |||
| 87 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 88 | - | f := NewFrame(tt.width, tt.height) | |||
| 89 | - | fillFrame(f, tt.rows) | |||
| 90 | - | f.ScrollUp(tt.top, tt.bottom, tt.n) | |||
| 91 | - | for y := range f.Height { | |||
| 92 | - | if got := rowRunes(f, y); got != tt.want[y] { | |||
| 93 | - | t.Errorf("row %d = %q, want %q", y, got, tt.want[y]) | |||
| 94 | - | } | |||
| 95 | - | } | |||
| 96 | - | if err := f.CheckInvariants(); err != nil { | |||
| 97 | - | t.Errorf("invariants after scroll up: %v", err) | |||
| 98 | - | } | |||
| 99 | - | }) | |||
| 100 | - | } | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | func TestScrollDownEquivalence(t *testing.T) { | |||
| 104 | - | tests := []struct { | |||
| 105 | - | name string | |||
| 106 | - | width, height int | |||
| 107 | - | rows []string | |||
| 108 | - | top, bottom int | |||
| 109 | - | n int | |||
| 110 | - | want []string | |||
| 111 | - | }{ | |||
| 112 | - | { | |||
| 113 | - | name: "scroll down 1 full screen", width: 3, height: 4, | |||
| 114 | - | rows: []string{"aaa", "bbb", "ccc", "ddd"}, | |||
| 115 | - | top: 0, bottom: 3, n: 1, | |||
| 116 | - | want: []string{" ", "aaa", "bbb", "ccc"}, | |||
| 117 | - | }, | |||
| 118 | - | { | |||
| 119 | - | name: "scroll down 2 region subset", width: 3, height: 5, | |||
| 120 | - | rows: []string{"aaa", "bbb", "ccc", "ddd", "eee"}, | |||
| 121 | - | top: 1, bottom: 4, n: 2, | |||
| 122 | - | want: []string{"aaa", " ", " ", "bbb", "ccc"}, | |||
| 123 | - | }, | |||
| 124 | - | } | |||
| 125 | - | for _, tt := range tests { | |||
| 126 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 127 | - | f := NewFrame(tt.width, tt.height) | |||
| 128 | - | fillFrame(f, tt.rows) | |||
| 129 | - | f.ScrollDown(tt.top, tt.bottom, tt.n) | |||
| 130 | - | for y := range f.Height { | |||
| 131 | - | if got := rowRunes(f, y); got != tt.want[y] { | |||
| 132 | - | t.Errorf("row %d = %q, want %q", y, got, tt.want[y]) | |||
| 133 | - | } | |||
| 134 | - | } | |||
| 135 | - | if err := f.CheckInvariants(); err != nil { | |||
| 136 | - | t.Errorf("invariants after scroll down: %v", err) | |||
| 137 | - | } | |||
| 138 | - | }) | |||
| 139 | - | } | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | // TestRotationIsPermutationUnderRandomOps drives an arbitrary sequence of | |||
| 143 | - | // scrolls and asserts lineOffset stays a valid permutation throughout. | |||
| 144 | - | func TestRotationIsPermutationUnderRandomOps(t *testing.T) { | |||
| 145 | - | f := NewFrame(5, 6) | |||
| 146 | - | fillFrame(f, []string{"aaaaa", "bbbbb", "ccccc", "ddddd", "eeeee", "fffff"}) | |||
| 147 | - | ops := []struct { | |||
| 148 | - | up bool | |||
| 149 | - | top, bottom int | |||
| 150 | - | n int | |||
| 151 | - | }{ | |||
| 152 | - | {true, 0, 5, 1}, {false, 1, 4, 2}, {true, 2, 5, 3}, | |||
| 153 | - | {false, 0, 5, 1}, {true, 0, 2, 2}, {false, 3, 5, 1}, | |||
| 154 | - | } | |||
| 155 | - | for i, op := range ops { | |||
| 156 | - | if op.up { | |||
| 157 | - | f.ScrollUp(op.top, op.bottom, op.n) | |||
| 158 | - | } else { | |||
| 159 | - | f.ScrollDown(op.top, op.bottom, op.n) | |||
| 160 | - | } | |||
| 161 | - | if err := f.CheckInvariants(); err != nil { | |||
| 162 | - | t.Fatalf("op %d (%+v): invariants broken: %v", i, op, err) | |||
| 163 | - | } | |||
| 164 | - | } | |||
| 165 | - | } | |||
D pkg/renderer/output_min_test.go +0 -487
| @@ -1,487 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "strconv" | |||
| 6 | - | "strings" | |||
| 7 | - | "testing" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | // --------------------------------------------------------------------------- | |||
| 11 | - | // Cursor-motion minimization (drawState.moveTo) | |||
| 12 | - | // --------------------------------------------------------------------------- | |||
| 13 | - | ||||
| 14 | - | func TestMoveToChoosesCheapestSequence(t *testing.T) { | |||
| 15 | - | tests := []struct { | |||
| 16 | - | name string | |||
| 17 | - | known bool | |||
| 18 | - | fromRow int | |||
| 19 | - | fromCol int | |||
| 20 | - | toRow int | |||
| 21 | - | toCol int | |||
| 22 | - | want string | |||
| 23 | - | }{ | |||
| 24 | - | {name: "unknown position uses absolute CUP", known: false, toRow: 2, toCol: 3, want: "\x1b[3;4H"}, | |||
| 25 | - | {name: "same position emits nothing", known: true, fromRow: 2, fromCol: 3, toRow: 2, toCol: 3, want: ""}, | |||
| 26 | - | {name: "same row col0 uses CR", known: true, fromRow: 2, fromCol: 5, toRow: 2, toCol: 0, want: "\r"}, | |||
| 27 | - | {name: "same row forward uses CUF", known: true, fromRow: 2, fromCol: 2, toRow: 2, toCol: 7, want: "\x1b[5C"}, | |||
| 28 | - | {name: "same row backward uses CUB", known: true, fromRow: 2, fromCol: 7, toRow: 2, toCol: 2, want: "\x1b[5D"}, | |||
| 29 | - | {name: "same row forward by one omits count", known: true, fromRow: 2, fromCol: 3, toRow: 2, toCol: 4, want: "\x1b[C"}, | |||
| 30 | - | {name: "same col up uses CUU", known: true, fromRow: 5, fromCol: 3, toRow: 2, toCol: 3, want: "\x1b[3A"}, | |||
| 31 | - | {name: "same col down uses CUD", known: true, fromRow: 2, fromCol: 3, toRow: 5, toCol: 3, want: "\x1b[3B"}, | |||
| 32 | - | {name: "same col up by one omits count", known: true, fromRow: 5, fromCol: 3, toRow: 4, toCol: 3, want: "\x1b[A"}, | |||
| 33 | - | {name: "diagonal move uses absolute CUP", known: true, fromRow: 2, fromCol: 2, toRow: 5, toCol: 7, want: "\x1b[6;8H"}, | |||
| 34 | - | {name: "short forward beats absolute", known: true, fromRow: 2, fromCol: 0, toRow: 2, toCol: 1, want: "\x1b[C"}, | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | for _, tt := range tests { | |||
| 38 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 39 | - | st := newDrawState() | |||
| 40 | - | if tt.known { | |||
| 41 | - | st.setCursor(tt.fromRow, tt.fromCol) | |||
| 42 | - | } | |||
| 43 | - | var buf bytes.Buffer | |||
| 44 | - | st.moveTo(&buf, tt.toRow, tt.toCol) | |||
| 45 | - | if buf.String() != tt.want { | |||
| 46 | - | t.Errorf("moveTo() = %q, want %q", buf.String(), tt.want) | |||
| 47 | - | } | |||
| 48 | - | if !st.curKnown || st.curRow != tt.toRow || st.curCol != tt.toCol { | |||
| 49 | - | t.Errorf("cursor not tracked to (%d,%d): got known=%v (%d,%d)", tt.toRow, tt.toCol, st.curKnown, st.curRow, st.curCol) | |||
| 50 | - | } | |||
| 51 | - | }) | |||
| 52 | - | } | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | // --------------------------------------------------------------------------- | |||
| 56 | - | // End-to-end golden byte output (exact bytes emitted by Draw) | |||
| 57 | - | // --------------------------------------------------------------------------- | |||
| 58 | - | ||||
| 59 | - | func drawGolden(t *testing.T, r *Renderer, frame Frame, damage []Damage) string { | |||
| 60 | - | t.Helper() | |||
| 61 | - | out, err := r.Draw(frame, damage) | |||
| 62 | - | if err != nil { | |||
| 63 | - | t.Fatal(err) | |||
| 64 | - | } | |||
| 65 | - | return string(out) | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | func setRow(f Frame, y int, s string, st Style) { | |||
| 69 | - | col := 0 | |||
| 70 | - | for _, ru := range s { | |||
| 71 | - | f.Set(col, y, Cell{Rune: ru, Style: st}) | |||
| 72 | - | col++ | |||
| 73 | - | } | |||
| 74 | - | } | |||
| 75 | - | ||||
| 76 | - | func hasCUB(out string) bool { | |||
| 77 | - | for i := 0; i+2 < len(out); i++ { | |||
| 78 | - | if out[i] != '\x1b' || out[i+1] != '[' { | |||
| 79 | - | continue | |||
| 80 | - | } | |||
| 81 | - | j := i + 2 | |||
| 82 | - | for j < len(out) && out[j] >= '0' && out[j] <= '9' { | |||
| 83 | - | j++ | |||
| 84 | - | } | |||
| 85 | - | if j < len(out) && out[j] == 'D' { | |||
| 86 | - | return true | |||
| 87 | - | } | |||
| 88 | - | } | |||
| 89 | - | return false | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | func TestBackwardOverlappingDamageRendersFinalSpanOnce(t *testing.T) { | |||
| 93 | - | r := New(Capabilities{}) | |||
| 94 | - | base := NewFrame(10, 1) | |||
| 95 | - | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |||
| 96 | - | t.Fatal(err) | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | frame := NewFrame(10, 1) | |||
| 100 | - | setRow(frame, 0, "abcdefghi", DefaultStyle()) | |||
| 101 | - | got := drawGolden(t, r, frame, []Damage{ | |||
| 102 | - | {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1}, | |||
| 103 | - | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1}, | |||
| 104 | - | }) | |||
| 105 | - | want := "\x1b[1;1Habcdefghi\x1b[0m" | |||
| 106 | - | if got != want { | |||
| 107 | - | t.Fatalf("output = %q, want %q", got, want) | |||
| 108 | - | } | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | func TestDamageOrderPermutationProducesCanonicalOutput(t *testing.T) { | |||
| 112 | - | tests := []struct { | |||
| 113 | - | name string | |||
| 114 | - | spans []Damage | |||
| 115 | - | }{ | |||
| 116 | - | { | |||
| 117 | - | name: "overlapping spans", | |||
| 118 | - | spans: []Damage{ | |||
| 119 | - | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1}, | |||
| 120 | - | {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1}, | |||
| 121 | - | }, | |||
| 122 | - | }, | |||
| 123 | - | { | |||
| 124 | - | name: "adjacent spans", | |||
| 125 | - | spans: []Damage{ | |||
| 126 | - | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1}, | |||
| 127 | - | {Kind: DamageText, X: 5, Y: 0, Width: 4, Height: 1, Count: 1}, | |||
| 128 | - | }, | |||
| 129 | - | }, | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | for _, tt := range tests { | |||
| 133 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 134 | - | render := func(spans []Damage) string { | |||
| 135 | - | t.Helper() | |||
| 136 | - | r := New(Capabilities{}) | |||
| 137 | - | if _, err := r.Draw(NewFrame(10, 1), []Damage{FullRedraw()}); err != nil { | |||
| 138 | - | t.Fatal(err) | |||
| 139 | - | } | |||
| 140 | - | frame := NewFrame(10, 1) | |||
| 141 | - | setRow(frame, 0, "abcdefghi", DefaultStyle()) | |||
| 142 | - | return drawGolden(t, r, frame, spans) | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | forward := render(tt.spans) | |||
| 146 | - | reverse := render([]Damage{tt.spans[1], tt.spans[0]}) | |||
| 147 | - | if forward != reverse { | |||
| 148 | - | t.Fatalf("forward output = %q, reverse output = %q", forward, reverse) | |||
| 149 | - | } | |||
| 150 | - | if hasCUB(forward) { | |||
| 151 | - | t.Fatalf("canonical output contains CUB: %q", forward) | |||
| 152 | - | } | |||
| 153 | - | want := "\x1b[1;1Habcdefghi\x1b[0m" | |||
| 154 | - | if forward != want { | |||
| 155 | - | t.Fatalf("output = %q, want canonical output %q", forward, want) | |||
| 156 | - | } | |||
| 157 | - | }) | |||
| 158 | - | } | |||
| 159 | - | } | |||
| 160 | - | ||||
| 161 | - | func TestNativeClearGolden(t *testing.T) { | |||
| 162 | - | tests := []struct { | |||
| 163 | - | name string | |||
| 164 | - | build func() Frame | |||
| 165 | - | want string | |||
| 166 | - | }{ | |||
| 167 | - | { | |||
| 168 | - | name: "trailing blank span becomes EL", | |||
| 169 | - | build: func() Frame { | |||
| 170 | - | f := NewFrame(10, 1) | |||
| 171 | - | setRow(f, 0, "Hi", DefaultStyle()) | |||
| 172 | - | return f | |||
| 173 | - | }, | |||
| 174 | - | // content, then EL to clear the 8 trailing blanks, then reset | |||
| 175 | - | want: "\x1b[1;1HHi\x1b[K\x1b[0m", | |||
| 176 | - | }, | |||
| 177 | - | { | |||
| 178 | - | name: "whole blank line cleared natively", | |||
| 179 | - | build: func() Frame { | |||
| 180 | - | f := NewFrame(10, 2) | |||
| 181 | - | setRow(f, 0, "AB", DefaultStyle()) | |||
| 182 | - | // row 1 stays entirely blank | |||
| 183 | - | return f | |||
| 184 | - | }, | |||
| 185 | - | // Row 0 leaves the cursor tracked at (0,2); the cheapest whole-line | |||
| 186 | - | // clear for row 1 is a one-row CUD in the same column plus 2K | |||
| 187 | - | // (3+4 bytes) rather than CUP to col 0 plus EL (6+3 bytes). | |||
| 188 | - | want: "\x1b[1;1HAB\x1b[K\x1b[B\x1b[2K\x1b[0m", | |||
| 189 | - | }, | |||
| 190 | - | { | |||
| 191 | - | name: "short trailing blank span kept as spaces", | |||
| 192 | - | build: func() Frame { | |||
| 193 | - | f := NewFrame(5, 1) | |||
| 194 | - | setRow(f, 0, "ABC", DefaultStyle()) | |||
| 195 | - | // two trailing blanks (< 3) → cheaper as spaces | |||
| 196 | - | return f | |||
| 197 | - | }, | |||
| 198 | - | want: "\x1b[1;1HABC \x1b[0m", | |||
| 199 | - | }, | |||
| 200 | - | } | |||
| 201 | - | ||||
| 202 | - | for _, tt := range tests { | |||
| 203 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 204 | - | r := New(Capabilities{}) | |||
| 205 | - | frame := tt.build() | |||
| 206 | - | got := drawGolden(t, r, frame, []Damage{FullRedraw()}) | |||
| 207 | - | if got != tt.want { | |||
| 208 | - | t.Fatalf("output = %q, want %q", got, tt.want) | |||
| 209 | - | } | |||
| 210 | - | // Shadow consistency: an identical redraw must be a no-op. | |||
| 211 | - | out2, err := r.Draw(frame, nil) | |||
| 212 | - | if err != nil { | |||
| 213 | - | t.Fatal(err) | |||
| 214 | - | } | |||
| 215 | - | if len(out2) != 0 { | |||
| 216 | - | t.Fatalf("second draw not a no-op: %q", string(out2)) | |||
| 217 | - | } | |||
| 218 | - | }) | |||
| 219 | - | } | |||
| 220 | - | } | |||
| 221 | - | ||||
| 222 | - | func TestSGRPersistsAcrossDamageRects(t *testing.T) { | |||
| 223 | - | // Two same-styled rects on different rows: the SGR sequence must be emitted | |||
| 224 | - | // exactly once (the pen persists across rects for the whole Draw). | |||
| 225 | - | r := New(Capabilities{}) | |||
| 226 | - | base := NewFrame(4, 3) | |||
| 227 | - | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |||
| 228 | - | t.Fatal(err) | |||
| 229 | - | } | |||
| 230 | - | ||||
| 231 | - | styled := Style{Bold: true, Foreground: 1, Background: -1} | |||
| 232 | - | frame := NewFrame(4, 3) | |||
| 233 | - | setRow(frame, 0, "XXXX", styled) | |||
| 234 | - | setRow(frame, 2, "YYYY", styled) | |||
| 235 | - | damage := []Damage{ | |||
| 236 | - | {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 1, Count: 1}, | |||
| 237 | - | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1, Count: 1}, | |||
| 238 | - | } | |||
| 239 | - | got := drawGolden(t, r, frame, damage) | |||
| 240 | - | // Row0 reaches the right edge (no trailing blanks) so tracking invalidates; | |||
| 241 | - | // row2 is therefore positioned with an absolute CUP. The SGR appears once. | |||
| 242 | - | want := "\x1b[1;1H\x1b[0;1;38;5;1mXXXX\x1b[3;1HYYYY\x1b[0m" | |||
| 243 | - | if got != want { | |||
| 244 | - | t.Fatalf("output = %q, want %q", got, want) | |||
| 245 | - | } | |||
| 246 | - | } | |||
| 247 | - | ||||
| 248 | - | func TestRightEdgeRunInvalidatesTracking(t *testing.T) { | |||
| 249 | - | // Adversarial: a run printed up to the terminal's right edge leaves the | |||
| 250 | - | // cursor in a terminal-dependent "wrap pending" state. The renderer must | |||
| 251 | - | // NOT assume the cursor wrapped to the next row; the following row must be | |||
| 252 | - | // positioned with an absolute CUP, never an implicit newline/relative move. | |||
| 253 | - | r := New(Capabilities{}) | |||
| 254 | - | frame := NewFrame(5, 2) | |||
| 255 | - | setRow(frame, 0, "ABCDE", DefaultStyle()) | |||
| 256 | - | setRow(frame, 1, "FGHIJ", DefaultStyle()) | |||
| 257 | - | got := drawGolden(t, r, frame, []Damage{FullRedraw()}) | |||
| 258 | - | want := "\x1b[1;1HABCDE\x1b[2;1HFGHIJ\x1b[0m" | |||
| 259 | - | if got != want { | |||
| 260 | - | t.Fatalf("output = %q, want %q", got, want) | |||
| 261 | - | } | |||
| 262 | - | } | |||
| 263 | - | ||||
| 264 | - | func TestWideRunCursorTrackingAndEL(t *testing.T) { | |||
| 265 | - | t.Run("cursor advances two columns per wide rune", func(t *testing.T) { | |||
| 266 | - | // Rect A ends with a wide pair (not at the edge); rect B starts one | |||
| 267 | - | // column later. The gap between them must be crossed with the cursor | |||
| 268 | - | // position that reflects the terminal advancing two columns for the | |||
| 269 | - | // wide rune — otherwise the relative move would be wrong. | |||
| 270 | - | r := New(Capabilities{}) | |||
| 271 | - | base := NewFrame(8, 1) | |||
| 272 | - | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |||
| 273 | - | t.Fatal(err) | |||
| 274 | - | } | |||
| 275 | - | frame := NewFrame(8, 1) | |||
| 276 | - | frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()}) | |||
| 277 | - | frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()}) | |||
| 278 | - | frame.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()}) | |||
| 279 | - | frame.Set(3, 0, Cell{Rune: 'B', Style: DefaultStyle()}) | |||
| 280 | - | // column 4 stays blank (undamaged gap) | |||
| 281 | - | frame.Set(5, 0, Cell{Rune: 'C', Style: DefaultStyle()}) | |||
| 282 | - | frame.Set(6, 0, Cell{Rune: 'D', Style: DefaultStyle()}) | |||
| 283 | - | frame.Set(7, 0, Cell{Rune: 'E', Style: DefaultStyle()}) | |||
| 284 | - | damage := []Damage{ | |||
| 285 | - | {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 1, Count: 1}, | |||
| 286 | - | {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1}, | |||
| 287 | - | } | |||
| 288 | - | got := drawGolden(t, r, frame, damage) | |||
| 289 | - | // After 你AB the cursor is at col 4; moving to col 5 is a one-column CUF. | |||
| 290 | - | want := "\x1b[1;1H你AB\x1b[CCDE\x1b[0m" | |||
| 291 | - | if got != want { | |||
| 292 | - | t.Fatalf("output = %q, want %q", got, want) | |||
| 293 | - | } | |||
| 294 | - | }) | |||
| 295 | - | ||||
| 296 | - | t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) { | |||
| 297 | - | // A damage rect that ends on a wide head whose continuation lies | |||
| 298 | - | // outside the rect makes the terminal advance one column further than | |||
| 299 | - | // the rect width accounts for. Tracking must be dropped: the next | |||
| 300 | - | // rect must be positioned with absolute CUP, not a relative move. | |||
| 301 | - | r := New(Capabilities{}) | |||
| 302 | - | base := NewFrame(8, 1) | |||
| 303 | - | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |||
| 304 | - | t.Fatal(err) | |||
| 305 | - | } | |||
| 306 | - | frame := NewFrame(8, 1) | |||
| 307 | - | frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()}) | |||
| 308 | - | frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()}) | |||
| 309 | - | frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()}) | |||
| 310 | - | frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()}) | |||
| 311 | - | damage := []Damage{ | |||
| 312 | - | // Ends on the wide head at col 1; its continuation (col 2) is outside. | |||
| 313 | - | {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1}, | |||
| 314 | - | {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1}, | |||
| 315 | - | } | |||
| 316 | - | got := drawGolden(t, r, frame, damage) | |||
| 317 | - | // After A你 the real cursor is at col 3, not col 2: a relative CUF | |||
| 318 | - | // would be off by one, so the second rect must use absolute CUP. | |||
| 319 | - | want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m" | |||
| 320 | - | if got != want { | |||
| 321 | - | t.Fatalf("output = %q, want %q", got, want) | |||
| 322 | - | } | |||
| 323 | - | }) | |||
| 324 | - | ||||
| 325 | - | t.Run("EL after a wide pair", func(t *testing.T) { | |||
| 326 | - | r := New(Capabilities{}) | |||
| 327 | - | frame := NewFrame(7, 1) | |||
| 328 | - | frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()}) | |||
| 329 | - | frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()}) | |||
| 330 | - | frame.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()}) | |||
| 331 | - | frame.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()}) | |||
| 332 | - | // columns 4,5,6 blank → EL | |||
| 333 | - | got := drawGolden(t, r, frame, []Damage{FullRedraw()}) | |||
| 334 | - | want := "\x1b[1;1H你好\x1b[K\x1b[0m" | |||
| 335 | - | if got != want { | |||
| 336 | - | t.Fatalf("output = %q, want %q", got, want) | |||
| 337 | - | } | |||
| 338 | - | out2, err := r.Draw(frame, nil) | |||
| 339 | - | if err != nil { | |||
| 340 | - | t.Fatal(err) | |||
| 341 | - | } | |||
| 342 | - | if len(out2) != 0 { | |||
| 343 | - | t.Fatalf("second draw not a no-op: %q", string(out2)) | |||
| 344 | - | } | |||
| 345 | - | }) | |||
| 346 | - | } | |||
| 347 | - | ||||
| 348 | - | func TestScrollFastPathEmitsCanonicalTextDamage(t *testing.T) { | |||
| 349 | - | render := func(damage []Damage) string { | |||
| 350 | - | t.Helper() | |||
| 351 | - | r := New(Capabilities{}) | |||
| 352 | - | base := NewFrame(10, 3) | |||
| 353 | - | setRow(base, 0, "aaaaaaaaaa", DefaultStyle()) | |||
| 354 | - | setRow(base, 1, "bbbbbbbbbb", DefaultStyle()) | |||
| 355 | - | setRow(base, 2, "cccccccccc", DefaultStyle()) | |||
| 356 | - | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |||
| 357 | - | t.Fatal(err) | |||
| 358 | - | } | |||
| 359 | - | ||||
| 360 | - | frame := NewFrame(10, 3) | |||
| 361 | - | setRow(frame, 0, "bbbbbbbbbb", DefaultStyle()) | |||
| 362 | - | setRow(frame, 1, "cccccccccc", DefaultStyle()) | |||
| 363 | - | setRow(frame, 2, "abcdefghij", DefaultStyle()) | |||
| 364 | - | out, err := r.Draw(frame, damage) | |||
| 365 | - | if err != nil { | |||
| 366 | - | t.Fatal(err) | |||
| 367 | - | } | |||
| 368 | - | return string(out) | |||
| 369 | - | } | |||
| 370 | - | ||||
| 371 | - | scroll := Damage{Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1} | |||
| 372 | - | forward := render([]Damage{ | |||
| 373 | - | scroll, | |||
| 374 | - | {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1}, | |||
| 375 | - | {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1}, | |||
| 376 | - | }) | |||
| 377 | - | reverse := render([]Damage{ | |||
| 378 | - | {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1}, | |||
| 379 | - | scroll, | |||
| 380 | - | {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1}, | |||
| 381 | - | }) | |||
| 382 | - | if forward != reverse { | |||
| 383 | - | t.Fatalf("scroll output depends on text damage order: forward %q, reverse %q", forward, reverse) | |||
| 384 | - | } | |||
| 385 | - | if strings.Contains(forward, "\r") || !strings.Contains(forward, "abcdefghij") { | |||
| 386 | - | t.Fatalf("scroll output did not emit one forward canonical span: %q", forward) | |||
| 387 | - | } | |||
| 388 | - | } | |||
| 389 | - | ||||
| 390 | - | func TestCanonicalDamageMergesClearAndText(t *testing.T) { | |||
| 391 | - | r := New(Capabilities{}) | |||
| 392 | - | base := NewFrame(10, 1) | |||
| 393 | - | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |||
| 394 | - | t.Fatal(err) | |||
| 395 | - | } | |||
| 396 | - | frame := NewFrame(10, 1) | |||
| 397 | - | setRow(frame, 0, "abcdefghi", DefaultStyle()) | |||
| 398 | - | out, err := r.Draw(frame, []Damage{ | |||
| 399 | - | {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1}, | |||
| 400 | - | {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1}, | |||
| 401 | - | }) | |||
| 402 | - | if err != nil { | |||
| 403 | - | t.Fatal(err) | |||
| 404 | - | } | |||
| 405 | - | if got, want := string(out), "\x1b[1;1Habcdefghi\x1b[0m"; got != want { | |||
| 406 | - | t.Fatalf("output = %q, want %q", got, want) | |||
| 407 | - | } | |||
| 408 | - | } | |||
| 409 | - | ||||
| 410 | - | func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) { | |||
| 411 | - | r := New(Capabilities{}) | |||
| 412 | - | base := NewFrame(8, 1) | |||
| 413 | - | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |||
| 414 | - | t.Fatal(err) | |||
| 415 | - | } | |||
| 416 | - | frame := NewFrame(8, 1) | |||
| 417 | - | frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()}) | |||
| 418 | - | frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()}) | |||
| 419 | - | frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()}) | |||
| 420 | - | frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()}) | |||
| 421 | - | out, err := r.Draw(frame, []Damage{ | |||
| 422 | - | {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1}, | |||
| 423 | - | {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1}, | |||
| 424 | - | }) | |||
| 425 | - | if err != nil { | |||
| 426 | - | t.Fatal(err) | |||
| 427 | - | } | |||
| 428 | - | if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want { | |||
| 429 | - | t.Fatalf("output = %q, want %q", got, want) | |||
| 430 | - | } | |||
| 431 | - | } | |||
| 432 | - | ||||
| 433 | - | func TestScrollPlannerFallbackRedrawsWithoutScrollEscape(t *testing.T) { | |||
| 434 | - | r := New(Capabilities{}) | |||
| 435 | - | base := NewFrame(1, maxPlannedDamageSpans+1) | |||
| 436 | - | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |||
| 437 | - | t.Fatal(err) | |||
| 438 | - | } | |||
| 439 | - | ||||
| 440 | - | frame := base.Clone() | |||
| 441 | - | frame.ScrollUp(0, frame.Height-1, 1) | |||
| 442 | - | frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()}) | |||
| 443 | - | damage := []Damage{ | |||
| 444 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: frame.Width, Height: frame.Height, Count: 1}, | |||
| 445 | - | {Kind: DamageText, X: 0, Y: 0, Width: frame.Width, Height: frame.Height}, | |||
| 446 | - | } | |||
| 447 | - | out, err := r.Draw(frame, damage) | |||
| 448 | - | if err != nil { | |||
| 449 | - | t.Fatal(err) | |||
| 450 | - | } | |||
| 451 | - | if scrollEscape := "\x1b[1;" + strconv.Itoa(frame.Height) + "r"; strings.Contains(string(out), scrollEscape) { | |||
| 452 | - | t.Fatalf("planner fallback emitted scroll escape %q before full redraw: %q", scrollEscape, string(out)) | |||
| 453 | - | } | |||
| 454 | - | if !strings.Contains(string(out), "X") { | |||
| 455 | - | t.Fatalf("planner fallback did not redraw the frame: %q", string(out)) | |||
| 456 | - | } | |||
| 457 | - | out, err = r.Draw(frame, nil) | |||
| 458 | - | if err != nil { | |||
| 459 | - | t.Fatal(err) | |||
| 460 | - | } | |||
| 461 | - | if len(out) != 0 { | |||
| 462 | - | t.Fatalf("planner fallback did not synchronize shadow; follow-up output = %q", string(out)) | |||
| 463 | - | } | |||
| 464 | - | } | |||
| 465 | - | ||||
| 466 | - | func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) { | |||
| 467 | - | r := New(Capabilities{}) | |||
| 468 | - | frame := NewFrame(1, maxPlannedDamageSpans+1) | |||
| 469 | - | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |||
| 470 | - | t.Fatal(err) | |||
| 471 | - | } | |||
| 472 | - | frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()}) | |||
| 473 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}}) | |||
| 474 | - | if err != nil { | |||
| 475 | - | t.Fatal(err) | |||
| 476 | - | } | |||
| 477 | - | if !strings.Contains(string(out), "X") { | |||
| 478 | - | t.Fatalf("fallback output did not redraw the frame: %q", string(out)) | |||
| 479 | - | } | |||
| 480 | - | out, err = r.Draw(frame, nil) | |||
| 481 | - | if err != nil { | |||
| 482 | - | t.Fatal(err) | |||
| 483 | - | } | |||
| 484 | - | if len(out) != 0 { | |||
| 485 | - | t.Fatalf("fallback did not synchronize shadow; follow-up output = %q", string(out)) | |||
| 486 | - | } | |||
| 487 | - | } | |||
D pkg/renderer/renderer.go +0 -313
| @@ -1,313 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "strconv" | |||
| 6 | - | "sync" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | const maxPooledBufferCap = 1 << 20 | |||
| 10 | - | ||||
| 11 | - | var bufferPool = sync.Pool{ | |||
| 12 | - | New: func() any { return new(bytes.Buffer) }, | |||
| 13 | - | } | |||
| 14 | - | ||||
| 15 | - | type Capabilities struct { | |||
| 16 | - | SynchronizedOutput bool | |||
| 17 | - | } | |||
| 18 | - | ||||
| 19 | - | type Renderer struct { | |||
| 20 | - | caps Capabilities | |||
| 21 | - | width int | |||
| 22 | - | height int | |||
| 23 | - | shadow []Cell | |||
| 24 | - | lineOffset []int | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | // PreparedDraw owns encoded output and its transactional delta until Commit. | |||
| 28 | - | // It is returned by value so ordinary prepared draws do not allocate. | |||
| 29 | - | type PreparedDraw struct { | |||
| 30 | - | renderer *Renderer | |||
| 31 | - | candidate DeltaCandidate | |||
| 32 | - | data []byte | |||
| 33 | - | commitOnce *sync.Once | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} } | |||
| 37 | - | ||||
| 38 | - | func (r *Renderer) Reset() { | |||
| 39 | - | r.width = 0 | |||
| 40 | - | r.height = 0 | |||
| 41 | - | r.shadow = nil | |||
| 42 | - | r.lineOffset = nil | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | // Bytes returns the prepared ANSI output. The returned bytes remain valid after | |||
| 46 | - | // another draw. | |||
| 47 | - | func (p PreparedDraw) Bytes() []byte { return p.data } | |||
| 48 | - | ||||
| 49 | - | // Commit applies the prepared delta exactly once. Discarding it leaves the | |||
| 50 | - | // renderer's committed state unchanged. | |||
| 51 | - | func (p *PreparedDraw) Commit() { | |||
| 52 | - | if p == nil || p.renderer == nil || p.commitOnce == nil { | |||
| 53 | - | return | |||
| 54 | - | } | |||
| 55 | - | p.commitOnce.Do(func() { | |||
| 56 | - | committed := p.renderer.committedFrame() | |||
| 57 | - | p.candidate.Commit(&committed) | |||
| 58 | - | p.renderer.setCommittedFrame(committed) | |||
| 59 | - | }) | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | // Prepare plans and encodes a transactional draw. The renderer advances only | |||
| 63 | - | // when the returned draw is committed. Keep at most one prepared draw | |||
| 64 | - | // outstanding; commit or discard it before calling Prepare again. | |||
| 65 | - | func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDraw, error) { | |||
| 66 | - | var candidate DeltaCandidate | |||
| 67 | - | var err error | |||
| 68 | - | if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | |||
| 69 | - | if err = frame.Validate(); err == nil { | |||
| 70 | - | plan := planSingleDamage(frame, damage[0]) | |||
| 71 | - | candidate = DeltaCandidate{Plan: plan} | |||
| 72 | - | if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 { | |||
| 73 | - | candidate.frame = frame.Clone() | |||
| 74 | - | } | |||
| 75 | - | } | |||
| 76 | - | } else { | |||
| 77 | - | candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0) | |||
| 78 | - | } | |||
| 79 | - | if err != nil { | |||
| 80 | - | return PreparedDraw{}, err | |||
| 81 | - | } | |||
| 82 | - | prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)} | |||
| 83 | - | plan := candidate.Plan | |||
| 84 | - | if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 { | |||
| 85 | - | return prepared, nil | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | buf, ok := bufferPool.Get().(*bytes.Buffer) | |||
| 89 | - | if !ok { | |||
| 90 | - | buf = new(bytes.Buffer) | |||
| 91 | - | } | |||
| 92 | - | buf.Reset() | |||
| 93 | - | defer putBuffer(buf) | |||
| 94 | - | if r.caps.SynchronizedOutput { | |||
| 95 | - | buf.WriteString(SyncStartCSI) | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | st := newDrawState() | |||
| 99 | - | if plan.Snapshot { | |||
| 100 | - | if len(plan.Spans) > 0 { | |||
| 101 | - | r.emitDamageSpans(buf, frame, plan.Spans, &st) | |||
| 102 | - | } else { | |||
| 103 | - | r.writeFull(buf, frame, &st) | |||
| 104 | - | } | |||
| 105 | - | } else { | |||
| 106 | - | if plan.Scroll.Height != 0 { | |||
| 107 | - | scroll := plan.Scroll | |||
| 108 | - | emitScrollUp(buf, Damage{Kind: DamageScrollUp, X: 0, Y: scroll.Y, Width: frame.Width, Height: scroll.Height, Count: scroll.Count}) | |||
| 109 | - | } | |||
| 110 | - | for _, span := range plan.Spans { | |||
| 111 | - | r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st) | |||
| 112 | - | } | |||
| 113 | - | buf.WriteString("\x1b[0m") | |||
| 114 | - | } | |||
| 115 | - | if r.caps.SynchronizedOutput { | |||
| 116 | - | buf.WriteString(SyncEndCSI) | |||
| 117 | - | } | |||
| 118 | - | prepared.data = copyBytes(buf) | |||
| 119 | - | return prepared, nil | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||
| 123 | - | prepared, err := r.Prepare(frame, damage, false) | |||
| 124 | - | if err != nil { | |||
| 125 | - | return nil, err | |||
| 126 | - | } | |||
| 127 | - | prepared.Commit() | |||
| 128 | - | return prepared.Bytes(), nil | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | func (r *Renderer) committedFrame() Frame { | |||
| 132 | - | return Frame{Width: r.width, Height: r.height, Cells: r.shadow, lineOffset: r.lineOffset} | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | func (r *Renderer) setCommittedFrame(frame Frame) { | |||
| 136 | - | r.width = frame.Width | |||
| 137 | - | r.height = frame.Height | |||
| 138 | - | r.shadow = frame.Cells | |||
| 139 | - | r.lineOffset = frame.lineOffset | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | // copyBytes copies the buffer contents into a fresh byte slice and is used | |||
| 143 | - | // to return output that is independent of the pooled scratch buffer. | |||
| 144 | - | func copyBytes(buf *bytes.Buffer) []byte { | |||
| 145 | - | b := buf.Bytes() | |||
| 146 | - | out := make([]byte, len(b)) | |||
| 147 | - | copy(out, b) | |||
| 148 | - | return out | |||
| 149 | - | } | |||
| 150 | - | ||||
| 151 | - | func putBuffer(buf *bytes.Buffer) { | |||
| 152 | - | if buf.Cap() > maxPooledBufferCap { | |||
| 153 | - | return | |||
| 154 | - | } | |||
| 155 | - | bufferPool.Put(buf) | |||
| 156 | - | } | |||
| 157 | - | ||||
| 158 | - | func needsFull(damage []Damage) bool { | |||
| 159 | - | if len(damage) == 0 { | |||
| 160 | - | return false | |||
| 161 | - | } | |||
| 162 | - | for _, d := range damage { | |||
| 163 | - | if d.Kind == DamageFullRedraw { | |||
| 164 | - | return true | |||
| 165 | - | } | |||
| 166 | - | } | |||
| 167 | - | return false | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | func hasScrollDamage(damage []Damage) bool { | |||
| 171 | - | for _, d := range damage { | |||
| 172 | - | if d.Kind == DamageScrollUp { | |||
| 173 | - | return true | |||
| 174 | - | } | |||
| 175 | - | } | |||
| 176 | - | return false | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) { | |||
| 180 | - | for y := range frame.Height { | |||
| 181 | - | r.emitSpan(out, frame, y, 0, frame.Width, st) | |||
| 182 | - | } | |||
| 183 | - | out.WriteString("\x1b[0m") | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []Span, st *drawState) { | |||
| 187 | - | for _, span := range spans { | |||
| 188 | - | r.emitSpan(out, frame, span.Y, span.X, span.Width, st) | |||
| 189 | - | } | |||
| 190 | - | out.WriteString("\x1b[0m") | |||
| 191 | - | } | |||
| 192 | - | func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) { | |||
| 193 | - | x, width, okX := clampRange(x, width, frame.Width) | |||
| 194 | - | y, height, okY := clampRange(y, height, frame.Height) | |||
| 195 | - | if !okX || !okY { | |||
| 196 | - | return 0, 0, 0, 0, false | |||
| 197 | - | } | |||
| 198 | - | return x, y, width, height, true | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | // clampRange intersects [pos, pos+size) with [0, limit) without evaluating an | |||
| 202 | - | // overflowing endpoint from untrusted damage coordinates. | |||
| 203 | - | func clampRange(pos, size, limit int) (int, int, bool) { | |||
| 204 | - | if size <= 0 || limit <= 0 || pos >= limit { | |||
| 205 | - | return 0, 0, false | |||
| 206 | - | } | |||
| 207 | - | if pos < 0 { | |||
| 208 | - | // -size is safe because size is positive. If pos is at or before that | |||
| 209 | - | // point, the rectangle ends at or before zero. | |||
| 210 | - | if pos <= -size { | |||
| 211 | - | return 0, 0, false | |||
| 212 | - | } | |||
| 213 | - | end := pos + size // pos > -size proves this addition cannot overflow. | |||
| 214 | - | if end > limit { | |||
| 215 | - | end = limit | |||
| 216 | - | } | |||
| 217 | - | return 0, end, true | |||
| 218 | - | } | |||
| 219 | - | ||||
| 220 | - | available := limit - pos | |||
| 221 | - | return pos, min(size, available), true | |||
| 222 | - | } | |||
| 223 | - | ||||
| 224 | - | // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf | |||
| 225 | - | // allocations. It uses a stack-allocated buffer for integer formatting. | |||
| 226 | - | func writeCursor(out *bytes.Buffer, y, x int) { | |||
| 227 | - | out.WriteString("\x1b[") | |||
| 228 | - | var b [16]byte | |||
| 229 | - | n := strconv.AppendInt(b[:0], int64(y+1), 10) | |||
| 230 | - | out.Write(n) | |||
| 231 | - | out.WriteByte(';') | |||
| 232 | - | n = strconv.AppendInt(b[:0], int64(x+1), 10) | |||
| 233 | - | out.Write(n) | |||
| 234 | - | out.WriteByte('H') | |||
| 235 | - | } | |||
| 236 | - | ||||
| 237 | - | // writeStyle emits SGR style parameters without fmt.Fprintf allocations. | |||
| 238 | - | func writeStyle(out *bytes.Buffer, style Style) { | |||
| 239 | - | out.WriteString("\x1b[0") | |||
| 240 | - | if style.Bold { | |||
| 241 | - | out.WriteString(";1") | |||
| 242 | - | } | |||
| 243 | - | if style.Attrs&AttrDim != 0 { | |||
| 244 | - | out.WriteString(";2") | |||
| 245 | - | } | |||
| 246 | - | if style.Italic { | |||
| 247 | - | out.WriteString(";3") | |||
| 248 | - | } | |||
| 249 | - | if style.Attrs&AttrUnderline != 0 { | |||
| 250 | - | switch style.UnderlineStyle { | |||
| 251 | - | case UnderlineDouble: | |||
| 252 | - | out.WriteString(";21") | |||
| 253 | - | case UnderlineCurly: | |||
| 254 | - | out.WriteString(";4:3") | |||
| 255 | - | case UnderlineDotted: | |||
| 256 | - | out.WriteString(";4:4") | |||
| 257 | - | case UnderlineDashed: | |||
| 258 | - | out.WriteString(";4:5") | |||
| 259 | - | default: | |||
| 260 | - | out.WriteString(";4") | |||
| 261 | - | } | |||
| 262 | - | } | |||
| 263 | - | if style.Attrs&AttrBlink != 0 { | |||
| 264 | - | out.WriteString(";5") | |||
| 265 | - | } | |||
| 266 | - | if style.Inverse { | |||
| 267 | - | out.WriteString(";7") | |||
| 268 | - | } | |||
| 269 | - | if style.Attrs&AttrStrikethrough != 0 { | |||
| 270 | - | out.WriteString(";9") | |||
| 271 | - | } | |||
| 272 | - | var b [16]byte | |||
| 273 | - | if style.HasForegroundRGB { | |||
| 274 | - | out.WriteString(";38;2;") | |||
| 275 | - | writeRGB(out, &b, style.ForegroundRGB) | |||
| 276 | - | } else if style.Foreground >= 0 { | |||
| 277 | - | out.WriteString(";38;5;") | |||
| 278 | - | n := strconv.AppendInt(b[:0], int64(style.Foreground), 10) | |||
| 279 | - | out.Write(n) | |||
| 280 | - | } | |||
| 281 | - | if style.HasBackgroundRGB { | |||
| 282 | - | out.WriteString(";48;2;") | |||
| 283 | - | writeRGB(out, &b, style.BackgroundRGB) | |||
| 284 | - | } else if style.Background >= 0 { | |||
| 285 | - | out.WriteString(";48;5;") | |||
| 286 | - | n := strconv.AppendInt(b[:0], int64(style.Background), 10) | |||
| 287 | - | out.Write(n) | |||
| 288 | - | } | |||
| 289 | - | if style.HasUnderlineColorRGB { | |||
| 290 | - | out.WriteString(";58;2;") | |||
| 291 | - | writeRGB(out, &b, style.UnderlineColorRGB) | |||
| 292 | - | } else if style.HasUnderlineColor { | |||
| 293 | - | out.WriteString(";58;5;") | |||
| 294 | - | n := strconv.AppendInt(b[:0], int64(style.UnderlineColor), 10) | |||
| 295 | - | out.Write(n) | |||
| 296 | - | } | |||
| 297 | - | out.WriteByte('m') | |||
| 298 | - | } | |||
| 299 | - | ||||
| 300 | - | func writeRGB(out *bytes.Buffer, b *[16]byte, rgb RGB) { | |||
| 301 | - | n := strconv.AppendInt(b[:0], int64(rgb.R), 10) | |||
| 302 | - | out.Write(n) | |||
| 303 | - | out.WriteByte(';') | |||
| 304 | - | n = strconv.AppendInt(b[:0], int64(rgb.G), 10) | |||
| 305 | - | out.Write(n) | |||
| 306 | - | out.WriteByte(';') | |||
| 307 | - | n = strconv.AppendInt(b[:0], int64(rgb.B), 10) | |||
| 308 | - | out.Write(n) | |||
| 309 | - | } | |||
| 310 | - | ||||
| 311 | - | func sameDamage(a, b Damage) bool { | |||
| 312 | - | return a.Kind == b.Kind && a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height && a.Count == b.Count | |||
| 313 | - | } | |||
D pkg/renderer/renderer_test.go +0 -1436
| @@ -1,1436 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "strings" | |||
| 6 | - | "testing" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | // markFrame writes uniform printable content so each row is distinguishable. | |||
| 10 | - | func markFrame(f *Frame) { | |||
| 11 | - | for y := range f.Height { | |||
| 12 | - | for x := range f.Width { | |||
| 13 | - | f.Set(x, y, Cell{Rune: rune('A' + (y*f.Width+x)%26), Style: DefaultStyle()}) | |||
| 14 | - | } | |||
| 15 | - | } | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | // outputContains checks that the output contains all the given substrings. | |||
| 19 | - | func outputContains(t *testing.T, data []byte, subs ...string) { | |||
| 20 | - | t.Helper() | |||
| 21 | - | s := string(data) | |||
| 22 | - | for _, sub := range subs { | |||
| 23 | - | if !strings.Contains(s, sub) { | |||
| 24 | - | t.Errorf("output should contain %q", sub) | |||
| 25 | - | } | |||
| 26 | - | } | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func outputEndsWith(t *testing.T, data []byte, suffix string) { | |||
| 30 | - | t.Helper() | |||
| 31 | - | s := string(data) | |||
| 32 | - | if !strings.HasSuffix(s, suffix) { | |||
| 33 | - | t.Errorf("output should end with %q, got %q", suffix, s) | |||
| 34 | - | } | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | // --------------------------------------------------------------------------- | |||
| 38 | - | // First draw | |||
| 39 | - | // --------------------------------------------------------------------------- | |||
| 40 | - | ||||
| 41 | - | func TestFirstDraw(t *testing.T) { | |||
| 42 | - | tests := []struct { | |||
| 43 | - | name string | |||
| 44 | - | damage []Damage | |||
| 45 | - | minCSICount int | |||
| 46 | - | }{ | |||
| 47 | - | { | |||
| 48 | - | name: "explicit text damage", | |||
| 49 | - | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}, | |||
| 50 | - | minCSICount: 3, | |||
| 51 | - | }, | |||
| 52 | - | { | |||
| 53 | - | name: "full redraw damage", | |||
| 54 | - | damage: []Damage{FullRedraw()}, | |||
| 55 | - | }, | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | for _, tt := range tests { | |||
| 59 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 60 | - | r := New(Capabilities{}) | |||
| 61 | - | frame := NewFrame(5, 3) | |||
| 62 | - | markFrame(&frame) | |||
| 63 | - | ||||
| 64 | - | out, err := r.Draw(frame, tt.damage) | |||
| 65 | - | if err != nil { | |||
| 66 | - | t.Fatal(err) | |||
| 67 | - | } | |||
| 68 | - | if len(out) == 0 { | |||
| 69 | - | t.Fatal("expected non-empty output on first draw") | |||
| 70 | - | } | |||
| 71 | - | outputContains(t, out, "\x1b[0m") | |||
| 72 | - | if tt.minCSICount > 0 { | |||
| 73 | - | // Should have cursor-position sequences for all three rows. | |||
| 74 | - | if c := strings.Count(string(out), "\x1b["); c < tt.minCSICount { | |||
| 75 | - | t.Fatalf("expected at least %d CSI sequences, got %d", tt.minCSICount, c) | |||
| 76 | - | } | |||
| 77 | - | } | |||
| 78 | - | }) | |||
| 79 | - | } | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | // --------------------------------------------------------------------------- | |||
| 83 | - | // No-op | |||
| 84 | - | // --------------------------------------------------------------------------- | |||
| 85 | - | ||||
| 86 | - | func TestNoOp(t *testing.T) { | |||
| 87 | - | tests := []struct { | |||
| 88 | - | name string | |||
| 89 | - | width int | |||
| 90 | - | height int | |||
| 91 | - | firstDamage []Damage | |||
| 92 | - | secondDamage []Damage | |||
| 93 | - | }{ | |||
| 94 | - | { | |||
| 95 | - | name: "nil damage on unchanged frame", | |||
| 96 | - | width: 5, | |||
| 97 | - | height: 3, | |||
| 98 | - | firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}}, | |||
| 99 | - | secondDamage: nil, | |||
| 100 | - | }, | |||
| 101 | - | { | |||
| 102 | - | name: "empty damage slice on unchanged frame", | |||
| 103 | - | width: 3, | |||
| 104 | - | height: 2, | |||
| 105 | - | firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}, | |||
| 106 | - | secondDamage: []Damage{}, | |||
| 107 | - | }, | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | for _, tt := range tests { | |||
| 111 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 112 | - | r := New(Capabilities{}) | |||
| 113 | - | frame := NewFrame(tt.width, tt.height) | |||
| 114 | - | markFrame(&frame) | |||
| 115 | - | ||||
| 116 | - | // First draw – populate shadow. | |||
| 117 | - | out1, err := r.Draw(frame, tt.firstDamage) | |||
| 118 | - | if err != nil { | |||
| 119 | - | t.Fatal(err) | |||
| 120 | - | } | |||
| 121 | - | if len(out1) == 0 { | |||
| 122 | - | t.Fatal("first draw must produce output") | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | // Second draw with no changes. | |||
| 126 | - | out2, err := r.Draw(frame, tt.secondDamage) | |||
| 127 | - | if err != nil { | |||
| 128 | - | t.Fatal(err) | |||
| 129 | - | } | |||
| 130 | - | if len(out2) != 0 { | |||
| 131 | - | t.Fatalf("expected no-op (empty output), got %q", string(out2)) | |||
| 132 | - | } | |||
| 133 | - | }) | |||
| 134 | - | } | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | func TestPrepareNoOpDoesNotCloneFrame(t *testing.T) { | |||
| 138 | - | r := New(Capabilities{}) | |||
| 139 | - | frame := NewFrame(3, 2) | |||
| 140 | - | markFrame(&frame) | |||
| 141 | - | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}); err != nil { | |||
| 142 | - | t.Fatal(err) | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | prepared, err := r.Prepare(frame, []Damage{{Kind: DamageText, X: frame.Width, Y: 0, Width: 1, Height: 1}}, false) | |||
| 146 | - | if err != nil { | |||
| 147 | - | t.Fatal(err) | |||
| 148 | - | } | |||
| 149 | - | if prepared.candidate.frame.Cells != nil || prepared.candidate.frame.lineOffset != nil { | |||
| 150 | - | t.Fatal("no-op prepared draw owns frame storage") | |||
| 151 | - | } | |||
| 152 | - | prepared.Commit() | |||
| 153 | - | } | |||
| 154 | - | ||||
| 155 | - | // --------------------------------------------------------------------------- | |||
| 156 | - | // Style reset discipline | |||
| 157 | - | // --------------------------------------------------------------------------- | |||
| 158 | - | ||||
| 159 | - | func TestStyleResetDiscipline(t *testing.T) { | |||
| 160 | - | tests := []struct { | |||
| 161 | - | name string | |||
| 162 | - | run func(t *testing.T) | |||
| 163 | - | }{ | |||
| 164 | - | { | |||
| 165 | - | name: "single styled cell among default cells", | |||
| 166 | - | run: func(t *testing.T) { | |||
| 167 | - | r := New(Capabilities{}) | |||
| 168 | - | frame := NewFrame(4, 2) | |||
| 169 | - | ||||
| 170 | - | // One styled cell, rest default. | |||
| 171 | - | frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}}) | |||
| 172 | - | ||||
| 173 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |||
| 174 | - | if err != nil { | |||
| 175 | - | t.Fatal(err) | |||
| 176 | - | } | |||
| 177 | - | // Must end with style reset. | |||
| 178 | - | outputEndsWith(t, out, "\x1b[0m") | |||
| 179 | - | }, | |||
| 180 | - | }, | |||
| 181 | - | { | |||
| 182 | - | name: "after scroll damage", | |||
| 183 | - | run: func(t *testing.T) { | |||
| 184 | - | r := New(Capabilities{}) | |||
| 185 | - | frame := NewFrame(4, 3) | |||
| 186 | - | markFrame(&frame) | |||
| 187 | - | ||||
| 188 | - | // Populate shadow. | |||
| 189 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | |||
| 190 | - | if err != nil { | |||
| 191 | - | t.Fatal(err) | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | - | // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage. | |||
| 195 | - | frame2 := NewFrame(4, 3) | |||
| 196 | - | copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2 | |||
| 197 | - | for i := 8; i < 12; i++ { | |||
| 198 | - | frame2.Cells[i] = BlankCell() // bottom row blank | |||
| 199 | - | } | |||
| 200 | - | frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row | |||
| 201 | - | ||||
| 202 | - | damage := []Damage{ | |||
| 203 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |||
| 204 | - | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |||
| 205 | - | } | |||
| 206 | - | ||||
| 207 | - | out, err := r.Draw(frame2, damage) | |||
| 208 | - | if err != nil { | |||
| 209 | - | t.Fatal(err) | |||
| 210 | - | } | |||
| 211 | - | // The output must end with a style reset. | |||
| 212 | - | outputEndsWith(t, out, "\x1b[0m") | |||
| 213 | - | }, | |||
| 214 | - | }, | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | for _, tt := range tests { | |||
| 218 | - | t.Run(tt.name, tt.run) | |||
| 219 | - | } | |||
| 220 | - | } | |||
| 221 | - | ||||
| 222 | - | func TestStyleEqualUsesActiveColorMode(t *testing.T) { | |||
| 223 | - | tests := []struct { | |||
| 224 | - | name string | |||
| 225 | - | left Style | |||
| 226 | - | right Style | |||
| 227 | - | wantEqual bool | |||
| 228 | - | }{ | |||
| 229 | - | { | |||
| 230 | - | name: "ignores inactive indexed foreground", | |||
| 231 | - | left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}, | |||
| 232 | - | right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}, | |||
| 233 | - | wantEqual: true, | |||
| 234 | - | }, | |||
| 235 | - | { | |||
| 236 | - | name: "compares active RGB values", | |||
| 237 | - | left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}, | |||
| 238 | - | right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 13, G: 34, B: 56}, Background: -1}, | |||
| 239 | - | wantEqual: false, | |||
| 240 | - | }, | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | for _, tt := range tests { | |||
| 244 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 245 | - | if got := tt.left.Equal(tt.right); got != tt.wantEqual { | |||
| 246 | - | t.Fatalf("Equal() = %v, want %v", got, tt.wantEqual) | |||
| 247 | - | } | |||
| 248 | - | }) | |||
| 249 | - | } | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | func TestRendererEmitsSGR(t *testing.T) { | |||
| 253 | - | tests := []struct { | |||
| 254 | - | name string | |||
| 255 | - | width int | |||
| 256 | - | height int | |||
| 257 | - | setup func(frame Frame) | |||
| 258 | - | damage []Damage | |||
| 259 | - | wantAll []string | |||
| 260 | - | }{ | |||
| 261 | - | { | |||
| 262 | - | name: "truecolor foreground and background", | |||
| 263 | - | width: 1, | |||
| 264 | - | height: 1, | |||
| 265 | - | setup: func(frame Frame) { | |||
| 266 | - | frame.Set(0, 0, Cell{ | |||
| 267 | - | Rune: 'X', | |||
| 268 | - | Style: Style{ | |||
| 269 | - | Foreground: -1, | |||
| 270 | - | Background: -1, | |||
| 271 | - | HasForegroundRGB: true, | |||
| 272 | - | ForegroundRGB: RGB{R: 12, G: 34, B: 56}, | |||
| 273 | - | HasBackgroundRGB: true, | |||
| 274 | - | BackgroundRGB: RGB{R: 200, G: 100, B: 50}, | |||
| 275 | - | }, | |||
| 276 | - | }) | |||
| 277 | - | }, | |||
| 278 | - | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, | |||
| 279 | - | wantAll: []string{"\x1b[0;38;2;12;34;56;48;2;200;100;50m", "X"}, | |||
| 280 | - | }, | |||
| 281 | - | { | |||
| 282 | - | name: "style changes across adjacent color modes", | |||
| 283 | - | width: 3, | |||
| 284 | - | height: 1, | |||
| 285 | - | setup: func(frame Frame) { | |||
| 286 | - | frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}}) | |||
| 287 | - | frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}}) | |||
| 288 | - | frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()}) | |||
| 289 | - | }, | |||
| 290 | - | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}}, | |||
| 291 | - | wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"}, | |||
| 292 | - | }, | |||
| 293 | - | { | |||
| 294 | - | name: "bold italic inverse", | |||
| 295 | - | width: 1, | |||
| 296 | - | height: 1, | |||
| 297 | - | setup: func(frame Frame) { | |||
| 298 | - | frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Italic: true, Inverse: true, Foreground: -1, Background: -1}}) | |||
| 299 | - | }, | |||
| 300 | - | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, | |||
| 301 | - | wantAll: []string{"\x1b[0;1;3;7m", "X"}, | |||
| 302 | - | }, | |||
| 303 | - | { | |||
| 304 | - | name: "extended SGR attributes and underline color", | |||
| 305 | - | width: 1, | |||
| 306 | - | height: 1, | |||
| 307 | - | setup: func(frame Frame) { | |||
| 308 | - | frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Foreground: -1, Background: -1, Attrs: AttrDim | AttrUnderline | AttrBlink | AttrStrikethrough, UnderlineStyle: UnderlineCurly, HasUnderlineColor: true, UnderlineColor: 9}}) | |||
| 309 | - | }, | |||
| 310 | - | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, | |||
| 311 | - | wantAll: []string{"\x1b[0;2;4:3;5;9;58;5;9m", "X"}, | |||
| 312 | - | }, | |||
| 313 | - | } | |||
| 314 | - | ||||
| 315 | - | for _, tt := range tests { | |||
| 316 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 317 | - | r := New(Capabilities{}) | |||
| 318 | - | frame := NewFrame(tt.width, tt.height) | |||
| 319 | - | tt.setup(frame) | |||
| 320 | - | ||||
| 321 | - | out, err := r.Draw(frame, tt.damage) | |||
| 322 | - | if err != nil { | |||
| 323 | - | t.Fatal(err) | |||
| 324 | - | } | |||
| 325 | - | ||||
| 326 | - | outputContains(t, out, tt.wantAll...) | |||
| 327 | - | }) | |||
| 328 | - | } | |||
| 329 | - | } | |||
| 330 | - | ||||
| 331 | - | // --------------------------------------------------------------------------- | |||
| 332 | - | // Scroll fast path | |||
| 333 | - | // --------------------------------------------------------------------------- | |||
| 334 | - | ||||
| 335 | - | func TestScrollFastPath(t *testing.T) { | |||
| 336 | - | tests := []struct { | |||
| 337 | - | name string | |||
| 338 | - | }{ | |||
| 339 | - | {name: "scroll up by one row exposes new bottom row via scroll region"}, | |||
| 340 | - | } | |||
| 341 | - | ||||
| 342 | - | for _, tt := range tests { | |||
| 343 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 344 | - | r := New(Capabilities{}) | |||
| 345 | - | frame := NewFrame(5, 4) | |||
| 346 | - | markFrame(&frame) | |||
| 347 | - | ||||
| 348 | - | // Populate shadow. | |||
| 349 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}}) | |||
| 350 | - | if err != nil { | |||
| 351 | - | t.Fatal(err) | |||
| 352 | - | } | |||
| 353 | - | ||||
| 354 | - | // Build a new frame that has been scrolled up by 1 (like the VT model does). | |||
| 355 | - | scrolled := NewFrame(5, 4) | |||
| 356 | - | copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3 | |||
| 357 | - | for i := 15; i < 20; i++ { | |||
| 358 | - | scrolled.Cells[i] = BlankCell() // row 3 blanked | |||
| 359 | - | } | |||
| 360 | - | scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()}) | |||
| 361 | - | scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()}) | |||
| 362 | - | scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()}) | |||
| 363 | - | ||||
| 364 | - | damage := []Damage{ | |||
| 365 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1}, | |||
| 366 | - | {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1}, | |||
| 367 | - | } | |||
| 368 | - | ||||
| 369 | - | out, err := r.Draw(scrolled, damage) | |||
| 370 | - | if err != nil { | |||
| 371 | - | t.Fatal(err) | |||
| 372 | - | } | |||
| 373 | - | if len(out) == 0 { | |||
| 374 | - | t.Fatal("expected non-empty output for scroll") | |||
| 375 | - | } | |||
| 376 | - | // Should contain scroll-region sequences. | |||
| 377 | - | outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4 | |||
| 378 | - | outputContains(t, out, "\x1b[r") // restore scroll region | |||
| 379 | - | // Should contain the new text on the exposed row. | |||
| 380 | - | outputContains(t, out, "N") | |||
| 381 | - | outputContains(t, out, "e") | |||
| 382 | - | outputContains(t, out, "w") | |||
| 383 | - | }) | |||
| 384 | - | } | |||
| 385 | - | } | |||
| 386 | - | ||||
| 387 | - | // --------------------------------------------------------------------------- | |||
| 388 | - | // Synchronized output wrapper | |||
| 389 | - | // --------------------------------------------------------------------------- | |||
| 390 | - | ||||
| 391 | - | func TestSynchronizedOutput(t *testing.T) { | |||
| 392 | - | tests := []struct { | |||
| 393 | - | name string | |||
| 394 | - | run func(t *testing.T) | |||
| 395 | - | }{ | |||
| 396 | - | { | |||
| 397 | - | name: "wraps output in sync CSI", | |||
| 398 | - | run: func(t *testing.T) { | |||
| 399 | - | r := New(Capabilities{SynchronizedOutput: true}) | |||
| 400 | - | frame := NewFrame(3, 2) | |||
| 401 | - | markFrame(&frame) | |||
| 402 | - | ||||
| 403 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |||
| 404 | - | if err != nil { | |||
| 405 | - | t.Fatal(err) | |||
| 406 | - | } | |||
| 407 | - | if !strings.HasPrefix(string(out), SyncStartCSI) { | |||
| 408 | - | t.Errorf("expected output to start with sync start CSI") | |||
| 409 | - | } | |||
| 410 | - | if !strings.HasSuffix(string(out), SyncEndCSI) { | |||
| 411 | - | t.Errorf("expected output to end with sync end CSI, got %q", string(out)) | |||
| 412 | - | } | |||
| 413 | - | }, | |||
| 414 | - | }, | |||
| 415 | - | { | |||
| 416 | - | name: "no-op returns empty output, not wrapped empty output", | |||
| 417 | - | run: func(t *testing.T) { | |||
| 418 | - | r := New(Capabilities{SynchronizedOutput: true}) | |||
| 419 | - | frame := NewFrame(3, 2) | |||
| 420 | - | markFrame(&frame) | |||
| 421 | - | ||||
| 422 | - | // First draw. | |||
| 423 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |||
| 424 | - | if err != nil { | |||
| 425 | - | t.Fatal(err) | |||
| 426 | - | } | |||
| 427 | - | ||||
| 428 | - | // No-op – must return nil, not wrapped empty output. | |||
| 429 | - | out, err := r.Draw(frame, nil) | |||
| 430 | - | if err != nil { | |||
| 431 | - | t.Fatal(err) | |||
| 432 | - | } | |||
| 433 | - | if len(out) != 0 { | |||
| 434 | - | t.Fatalf("expected empty output for no-op, got %q", string(out)) | |||
| 435 | - | } | |||
| 436 | - | }, | |||
| 437 | - | }, | |||
| 438 | - | } | |||
| 439 | - | ||||
| 440 | - | for _, tt := range tests { | |||
| 441 | - | t.Run(tt.name, tt.run) | |||
| 442 | - | } | |||
| 443 | - | } | |||
| 444 | - | ||||
| 445 | - | // --------------------------------------------------------------------------- | |||
| 446 | - | // WrapSynchronized standalone function | |||
| 447 | - | // --------------------------------------------------------------------------- | |||
| 448 | - | ||||
| 449 | - | func TestWrapSynchronized(t *testing.T) { | |||
| 450 | - | content := []byte("\x1b[0mhello") | |||
| 451 | - | ||||
| 452 | - | tests := []struct { | |||
| 453 | - | name string | |||
| 454 | - | content []byte | |||
| 455 | - | enabled bool | |||
| 456 | - | check func(t *testing.T, got []byte) | |||
| 457 | - | }{ | |||
| 458 | - | { | |||
| 459 | - | name: "enabled wraps with sync start/end and preserves content", | |||
| 460 | - | content: content, | |||
| 461 | - | enabled: true, | |||
| 462 | - | check: func(t *testing.T, got []byte) { | |||
| 463 | - | if !strings.HasPrefix(string(got), SyncStartCSI) { | |||
| 464 | - | t.Errorf("missing sync start") | |||
| 465 | - | } | |||
| 466 | - | if !strings.HasSuffix(string(got), SyncEndCSI) { | |||
| 467 | - | t.Errorf("missing sync end") | |||
| 468 | - | } | |||
| 469 | - | if !strings.Contains(string(got), "hello") { | |||
| 470 | - | t.Errorf("missing original content") | |||
| 471 | - | } | |||
| 472 | - | }, | |||
| 473 | - | }, | |||
| 474 | - | { | |||
| 475 | - | name: "disabled returns content unchanged", | |||
| 476 | - | content: content, | |||
| 477 | - | enabled: false, | |||
| 478 | - | check: func(t *testing.T, got []byte) { | |||
| 479 | - | if string(got) != string(content) { | |||
| 480 | - | t.Errorf("disabled wrapping should return content unchanged") | |||
| 481 | - | } | |||
| 482 | - | }, | |||
| 483 | - | }, | |||
| 484 | - | { | |||
| 485 | - | name: "empty input returns empty", | |||
| 486 | - | content: nil, | |||
| 487 | - | enabled: true, | |||
| 488 | - | check: func(t *testing.T, got []byte) { | |||
| 489 | - | if len(got) != 0 { | |||
| 490 | - | t.Errorf("empty input should return empty") | |||
| 491 | - | } | |||
| 492 | - | }, | |||
| 493 | - | }, | |||
| 494 | - | } | |||
| 495 | - | ||||
| 496 | - | for _, tt := range tests { | |||
| 497 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 498 | - | got := WrapSynchronized(tt.content, tt.enabled) | |||
| 499 | - | tt.check(t, got) | |||
| 500 | - | }) | |||
| 501 | - | } | |||
| 502 | - | } | |||
| 503 | - | ||||
| 504 | - | // --------------------------------------------------------------------------- | |||
| 505 | - | // Partial damage draw | |||
| 506 | - | // --------------------------------------------------------------------------- | |||
| 507 | - | ||||
| 508 | - | func TestPartialDamage(t *testing.T) { | |||
| 509 | - | tests := []struct { | |||
| 510 | - | name string | |||
| 511 | - | }{ | |||
| 512 | - | {name: "single changed cell repositions cursor and resets style"}, | |||
| 513 | - | } | |||
| 514 | - | ||||
| 515 | - | for _, tt := range tests { | |||
| 516 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 517 | - | r := New(Capabilities{}) | |||
| 518 | - | frame := NewFrame(4, 2) | |||
| 519 | - | markFrame(&frame) | |||
| 520 | - | ||||
| 521 | - | // Populate shadow. | |||
| 522 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}) | |||
| 523 | - | if err != nil { | |||
| 524 | - | t.Fatal(err) | |||
| 525 | - | } | |||
| 526 | - | ||||
| 527 | - | // Change one cell. | |||
| 528 | - | frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()}) | |||
| 529 | - | damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}} | |||
| 530 | - | ||||
| 531 | - | out, err := r.Draw(frame, damage) | |||
| 532 | - | if err != nil { | |||
| 533 | - | t.Fatal(err) | |||
| 534 | - | } | |||
| 535 | - | if len(out) == 0 { | |||
| 536 | - | t.Fatal("expected non-empty output for partial damage") | |||
| 537 | - | } | |||
| 538 | - | // Should position cursor at (2,1) — 1-indexed (3;2). | |||
| 539 | - | outputContains(t, out, "\x1b[2;3H") | |||
| 540 | - | outputContains(t, out, "Z") | |||
| 541 | - | outputEndsWith(t, out, "\x1b[0m") | |||
| 542 | - | }) | |||
| 543 | - | } | |||
| 544 | - | } | |||
| 545 | - | ||||
| 546 | - | // --------------------------------------------------------------------------- | |||
| 547 | - | // Reset | |||
| 548 | - | // --------------------------------------------------------------------------- | |||
| 549 | - | ||||
| 550 | - | func TestReset(t *testing.T) { | |||
| 551 | - | tests := []struct { | |||
| 552 | - | name string | |||
| 553 | - | }{ | |||
| 554 | - | {name: "clears shadow state and forces a full draw next time"}, | |||
| 555 | - | } | |||
| 556 | - | ||||
| 557 | - | for _, tt := range tests { | |||
| 558 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 559 | - | r := New(Capabilities{}) | |||
| 560 | - | frame := NewFrame(3, 2) | |||
| 561 | - | markFrame(&frame) | |||
| 562 | - | ||||
| 563 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}) | |||
| 564 | - | if err != nil { | |||
| 565 | - | t.Fatal(err) | |||
| 566 | - | } | |||
| 567 | - | ||||
| 568 | - | r.Reset() | |||
| 569 | - | ||||
| 570 | - | if r.width != 0 || r.height != 0 || r.shadow != nil { | |||
| 571 | - | t.Fatal("Reset should clear width/height/shadow") | |||
| 572 | - | } | |||
| 573 | - | ||||
| 574 | - | // After reset, the next draw should be a full draw. | |||
| 575 | - | out, err := r.Draw(frame, nil) | |||
| 576 | - | if err != nil { | |||
| 577 | - | t.Fatal(err) | |||
| 578 | - | } | |||
| 579 | - | if len(out) == 0 { | |||
| 580 | - | t.Fatal("expected non-empty output after reset") | |||
| 581 | - | } | |||
| 582 | - | }) | |||
| 583 | - | } | |||
| 584 | - | } | |||
| 585 | - | ||||
| 586 | - | // --------------------------------------------------------------------------- | |||
| 587 | - | // Damage helper | |||
| 588 | - | // --------------------------------------------------------------------------- | |||
| 589 | - | ||||
| 590 | - | func TestFullRedraw(t *testing.T) { | |||
| 591 | - | tests := []struct { | |||
| 592 | - | name string | |||
| 593 | - | run func(t *testing.T) | |||
| 594 | - | }{ | |||
| 595 | - | { | |||
| 596 | - | name: "known same-size full redraw diffs against shadow", | |||
| 597 | - | run: func(t *testing.T) { | |||
| 598 | - | r := New(Capabilities{}) | |||
| 599 | - | frame := NewFrame(3, 2) | |||
| 600 | - | markFrame(&frame) | |||
| 601 | - | ||||
| 602 | - | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |||
| 603 | - | t.Fatal(err) | |||
| 604 | - | } | |||
| 605 | - | ||||
| 606 | - | changed := frame | |||
| 607 | - | changed.Set(0, 1, Cell{Rune: 'Z', Style: DefaultStyle()}) | |||
| 608 | - | out, err := r.Draw(changed, []Damage{FullRedraw()}) | |||
| 609 | - | if err != nil { | |||
| 610 | - | t.Fatal(err) | |||
| 611 | - | } | |||
| 612 | - | got := string(out) | |||
| 613 | - | if strings.Contains(got, "ABC") { | |||
| 614 | - | t.Fatalf("unchanged row was re-emitted on dimension-stable FullRedraw: %q", got) | |||
| 615 | - | } | |||
| 616 | - | if !strings.Contains(got, "ZEF") { | |||
| 617 | - | t.Fatalf("changed row was not emitted on dimension-stable FullRedraw: %q", got) | |||
| 618 | - | } | |||
| 619 | - | outputEndsWith(t, out, "\x1b[0m") | |||
| 620 | - | }, | |||
| 621 | - | }, | |||
| 622 | - | { | |||
| 623 | - | name: "unchanged known same-size full redraw is a no-op", | |||
| 624 | - | run: func(t *testing.T) { | |||
| 625 | - | r := New(Capabilities{}) | |||
| 626 | - | frame := NewFrame(3, 2) | |||
| 627 | - | markFrame(&frame) | |||
| 628 | - | ||||
| 629 | - | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |||
| 630 | - | t.Fatal(err) | |||
| 631 | - | } | |||
| 632 | - | out, err := r.Draw(frame, []Damage{FullRedraw()}) | |||
| 633 | - | if err != nil { | |||
| 634 | - | t.Fatal(err) | |||
| 635 | - | } | |||
| 636 | - | if len(out) != 0 { | |||
| 637 | - | t.Fatalf("unchanged dimension-stable FullRedraw emitted output: %q", string(out)) | |||
| 638 | - | } | |||
| 639 | - | }, | |||
| 640 | - | }, | |||
| 641 | - | { | |||
| 642 | - | name: "reset full redraw emits complete frame", | |||
| 643 | - | run: func(t *testing.T) { | |||
| 644 | - | r := New(Capabilities{}) | |||
| 645 | - | frame := NewFrame(3, 2) | |||
| 646 | - | markFrame(&frame) | |||
| 647 | - | ||||
| 648 | - | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |||
| 649 | - | t.Fatal(err) | |||
| 650 | - | } | |||
| 651 | - | r.Reset() | |||
| 652 | - | ||||
| 653 | - | out, err := r.Draw(frame, []Damage{FullRedraw()}) | |||
| 654 | - | if err != nil { | |||
| 655 | - | t.Fatal(err) | |||
| 656 | - | } | |||
| 657 | - | got := string(out) | |||
| 658 | - | if !strings.Contains(got, "ABC") || !strings.Contains(got, "DEF") { | |||
| 659 | - | t.Fatalf("reset FullRedraw did not emit complete frame: %q", got) | |||
| 660 | - | } | |||
| 661 | - | outputEndsWith(t, out, "\x1b[0m") | |||
| 662 | - | }, | |||
| 663 | - | }, | |||
| 664 | - | } | |||
| 665 | - | ||||
| 666 | - | for _, tt := range tests { | |||
| 667 | - | t.Run(tt.name, tt.run) | |||
| 668 | - | } | |||
| 669 | - | } | |||
| 670 | - | ||||
| 671 | - | // --------------------------------------------------------------------------- | |||
| 672 | - | // Scroll with no remaining damage | |||
| 673 | - | // --------------------------------------------------------------------------- | |||
| 674 | - | ||||
| 675 | - | func TestScrollOnlyDamage(t *testing.T) { | |||
| 676 | - | tests := []struct { | |||
| 677 | - | name string | |||
| 678 | - | }{ | |||
| 679 | - | {name: "scroll damage plus new-row text damage from the VT model"}, | |||
| 680 | - | } | |||
| 681 | - | ||||
| 682 | - | for _, tt := range tests { | |||
| 683 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 684 | - | r := New(Capabilities{}) | |||
| 685 | - | frame := NewFrame(4, 3) | |||
| 686 | - | markFrame(&frame) | |||
| 687 | - | ||||
| 688 | - | _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}) | |||
| 689 | - | if err != nil { | |||
| 690 | - | t.Fatal(err) | |||
| 691 | - | } | |||
| 692 | - | ||||
| 693 | - | // Scroll up 1 but the VT model would also emit DamageText for the new bottom row. | |||
| 694 | - | scrolled := NewFrame(4, 3) | |||
| 695 | - | copy(scrolled.Cells[0:8], frame.Cells[4:12]) | |||
| 696 | - | for i := 8; i < 12; i++ { | |||
| 697 | - | scrolled.Cells[i] = BlankCell() | |||
| 698 | - | } | |||
| 699 | - | scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()}) | |||
| 700 | - | ||||
| 701 | - | damage := []Damage{ | |||
| 702 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |||
| 703 | - | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |||
| 704 | - | } | |||
| 705 | - | ||||
| 706 | - | out, err := r.Draw(scrolled, damage) | |||
| 707 | - | if err != nil { | |||
| 708 | - | t.Fatal(err) | |||
| 709 | - | } | |||
| 710 | - | if len(out) == 0 { | |||
| 711 | - | t.Fatal("expected output for scroll") | |||
| 712 | - | } | |||
| 713 | - | // Should include scroll region | |||
| 714 | - | outputContains(t, out, "\x1b[1;3r") | |||
| 715 | - | outputContains(t, out, "\x1b[r") | |||
| 716 | - | outputEndsWith(t, out, "\x1b[0m") | |||
| 717 | - | }) | |||
| 718 | - | } | |||
| 719 | - | } | |||
| 720 | - | ||||
| 721 | - | // --------------------------------------------------------------------------- | |||
| 722 | - | // writeCursor output format | |||
| 723 | - | // --------------------------------------------------------------------------- | |||
| 724 | - | ||||
| 725 | - | func TestWriteCursor(t *testing.T) { | |||
| 726 | - | tests := []struct { | |||
| 727 | - | name string | |||
| 728 | - | y, x int | |||
| 729 | - | want string | |||
| 730 | - | }{ | |||
| 731 | - | {name: "origin", y: 0, x: 0, want: "\x1b[1;1H"}, | |||
| 732 | - | {name: "offset", y: 2, x: 5, want: "\x1b[3;6H"}, | |||
| 733 | - | } | |||
| 734 | - | ||||
| 735 | - | for _, tt := range tests { | |||
| 736 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 737 | - | var buf bytes.Buffer | |||
| 738 | - | writeCursor(&buf, tt.y, tt.x) | |||
| 739 | - | if buf.String() != tt.want { | |||
| 740 | - | t.Errorf("unexpected cursor positioning: %q", buf.String()) | |||
| 741 | - | } | |||
| 742 | - | }) | |||
| 743 | - | } | |||
| 744 | - | } | |||
| 745 | - | ||||
| 746 | - | // --------------------------------------------------------------------------- | |||
| 747 | - | // isSafeScroll edge cases | |||
| 748 | - | // --------------------------------------------------------------------------- | |||
| 749 | - | ||||
| 750 | - | func TestIsSafeScroll(t *testing.T) { | |||
| 751 | - | frame := NewFrame(10, 8) | |||
| 752 | - | ||||
| 753 | - | tests := []struct { | |||
| 754 | - | name string | |||
| 755 | - | d Damage | |||
| 756 | - | want bool | |||
| 757 | - | }{ | |||
| 758 | - | {"full width, partial height", Damage{X: 0, Y: 1, Width: 10, Height: 5, Count: 2}, true}, | |||
| 759 | - | {"not full width", Damage{X: 1, Y: 1, Width: 8, Height: 5, Count: 2}, false}, | |||
| 760 | - | {"count zero", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 0}, false}, | |||
| 761 | - | {"count equals height", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 8}, false}, | |||
| 762 | - | {"extends past frame", Damage{X: 0, Y: 6, Width: 10, Height: 6, Count: 2}, false}, | |||
| 763 | - | {"negative Y", Damage{X: 0, Y: -1, Width: 10, Height: 5, Count: 1}, false}, | |||
| 764 | - | {"height zero", Damage{X: 0, Y: 0, Width: 10, Height: 0, Count: 1}, false}, | |||
| 765 | - | } | |||
| 766 | - | ||||
| 767 | - | for _, tt := range tests { | |||
| 768 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 769 | - | got := isSafeScroll(frame, tt.d) | |||
| 770 | - | if got != tt.want { | |||
| 771 | - | t.Errorf("isSafeScroll(%+v) = %v, want %v", tt.d, got, tt.want) | |||
| 772 | - | } | |||
| 773 | - | }) | |||
| 774 | - | } | |||
| 775 | - | } | |||
| 776 | - | ||||
| 777 | - | // --------------------------------------------------------------------------- | |||
| 778 | - | // BlankCell / Cell.Equal | |||
| 779 | - | // --------------------------------------------------------------------------- | |||
| 780 | - | ||||
| 781 | - | func TestCellEqual(t *testing.T) { | |||
| 782 | - | tests := []struct { | |||
| 783 | - | name string | |||
| 784 | - | a Cell | |||
| 785 | - | b Cell | |||
| 786 | - | want bool | |||
| 787 | - | }{ | |||
| 788 | - | { | |||
| 789 | - | name: "blank cells should be equal", | |||
| 790 | - | a: BlankCell(), | |||
| 791 | - | b: BlankCell(), | |||
| 792 | - | want: true, | |||
| 793 | - | }, | |||
| 794 | - | { | |||
| 795 | - | name: "different runes should not be equal", | |||
| 796 | - | a: Cell{Rune: 'A', Style: DefaultStyle()}, | |||
| 797 | - | b: BlankCell(), | |||
| 798 | - | want: false, | |||
| 799 | - | }, | |||
| 800 | - | { | |||
| 801 | - | name: "different styles should not be equal", | |||
| 802 | - | a: Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}}, | |||
| 803 | - | b: BlankCell(), | |||
| 804 | - | want: false, | |||
| 805 | - | }, | |||
| 806 | - | { | |||
| 807 | - | name: "continuation flag participates in equality", | |||
| 808 | - | a: Cell{Continuation: true}, | |||
| 809 | - | b: Cell{}, | |||
| 810 | - | want: false, | |||
| 811 | - | }, | |||
| 812 | - | { | |||
| 813 | - | name: "two continuation cells are equal", | |||
| 814 | - | a: Cell{Continuation: true}, | |||
| 815 | - | b: Cell{Continuation: true}, | |||
| 816 | - | want: true, | |||
| 817 | - | }, | |||
| 818 | - | } | |||
| 819 | - | ||||
| 820 | - | for _, tt := range tests { | |||
| 821 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 822 | - | if got := tt.a.Equal(tt.b); got != tt.want { | |||
| 823 | - | t.Errorf("Equal() = %v, want %v", got, tt.want) | |||
| 824 | - | } | |||
| 825 | - | }) | |||
| 826 | - | } | |||
| 827 | - | } | |||
| 828 | - | ||||
| 829 | - | // --------------------------------------------------------------------------- | |||
| 830 | - | // Frame helpers | |||
| 831 | - | // --------------------------------------------------------------------------- | |||
| 832 | - | ||||
| 833 | - | func TestFrameValidate(t *testing.T) { | |||
| 834 | - | tests := []struct { | |||
| 835 | - | name string | |||
| 836 | - | frame Frame | |||
| 837 | - | wantErr bool | |||
| 838 | - | }{ | |||
| 839 | - | {name: "valid frame", frame: NewFrame(5, 3), wantErr: false}, | |||
| 840 | - | {name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true}, | |||
| 841 | - | {name: "wrong cell count", frame: Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}, wantErr: true}, | |||
| 842 | - | } | |||
| 843 | - | ||||
| 844 | - | for _, tt := range tests { | |||
| 845 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 846 | - | err := tt.frame.Validate() | |||
| 847 | - | if tt.wantErr && err == nil { | |||
| 848 | - | t.Fatal("expected error, got nil") | |||
| 849 | - | } | |||
| 850 | - | if !tt.wantErr && err != nil { | |||
| 851 | - | t.Fatalf("unexpected error: %v", err) | |||
| 852 | - | } | |||
| 853 | - | }) | |||
| 854 | - | } | |||
| 855 | - | } | |||
| 856 | - | ||||
| 857 | - | func TestFrameAtSet(t *testing.T) { | |||
| 858 | - | tests := []struct { | |||
| 859 | - | name string | |||
| 860 | - | }{ | |||
| 861 | - | {name: "At/Set round-trip and unaffected cells stay blank"}, | |||
| 862 | - | } | |||
| 863 | - | ||||
| 864 | - | for _, tt := range tests { | |||
| 865 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 866 | - | f := NewFrame(3, 2) | |||
| 867 | - | cell := Cell{Rune: 'X', Style: Style{Bold: true}} | |||
| 868 | - | f.Set(1, 1, cell) | |||
| 869 | - | ||||
| 870 | - | got := f.At(1, 1) | |||
| 871 | - | if got.Rune != 'X' || !got.Style.Bold { | |||
| 872 | - | t.Errorf("At/Set round-trip failed: got %+v", got) | |||
| 873 | - | } | |||
| 874 | - | ||||
| 875 | - | // Unaffected cells are blank. | |||
| 876 | - | got = f.At(0, 0) | |||
| 877 | - | if got != BlankCell() { | |||
| 878 | - | t.Errorf("unexpected cell at (0,0): %+v", got) | |||
| 879 | - | } | |||
| 880 | - | }) | |||
| 881 | - | } | |||
| 882 | - | } | |||
| 883 | - | ||||
| 884 | - | // --------------------------------------------------------------------------- | |||
| 885 | - | // Damage helpers | |||
| 886 | - | // --------------------------------------------------------------------------- | |||
| 887 | - | ||||
| 888 | - | func TestSameDamage(t *testing.T) { | |||
| 889 | - | tests := []struct { | |||
| 890 | - | name string | |||
| 891 | - | a Damage | |||
| 892 | - | b Damage | |||
| 893 | - | want bool | |||
| 894 | - | }{ | |||
| 895 | - | { | |||
| 896 | - | name: "identical damages should be equal", | |||
| 897 | - | a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}, | |||
| 898 | - | b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}, | |||
| 899 | - | want: true, | |||
| 900 | - | }, | |||
| 901 | - | { | |||
| 902 | - | name: "different counts should not be equal", | |||
| 903 | - | a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}, | |||
| 904 | - | b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0}, | |||
| 905 | - | want: false, | |||
| 906 | - | }, | |||
| 907 | - | } | |||
| 908 | - | ||||
| 909 | - | for _, tt := range tests { | |||
| 910 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 911 | - | if got := sameDamage(tt.a, tt.b); got != tt.want { | |||
| 912 | - | t.Errorf("sameDamage() = %v, want %v", got, tt.want) | |||
| 913 | - | } | |||
| 914 | - | }) | |||
| 915 | - | } | |||
| 916 | - | } | |||
| 917 | - | ||||
| 918 | - | func TestDamageCoversCell(t *testing.T) { | |||
| 919 | - | damage := []Damage{ | |||
| 920 | - | {Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2}, | |||
| 921 | - | } | |||
| 922 | - | ||||
| 923 | - | tests := []struct { | |||
| 924 | - | name string | |||
| 925 | - | x, y int | |||
| 926 | - | want bool | |||
| 927 | - | }{ | |||
| 928 | - | {name: "should cover (3,3)", x: 3, y: 3, want: true}, | |||
| 929 | - | {name: "should cover (6,4)", x: 6, y: 4, want: true}, | |||
| 930 | - | {name: "should not cover (1,3)", x: 1, y: 3, want: false}, | |||
| 931 | - | {name: "should not cover (3,5)", x: 3, y: 5, want: false}, | |||
| 932 | - | {name: "should not cover (7,3)", x: 7, y: 3, want: false}, | |||
| 933 | - | } | |||
| 934 | - | ||||
| 935 | - | for _, tt := range tests { | |||
| 936 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 937 | - | if got := damageCoversCell(damage, tt.x, tt.y); got != tt.want { | |||
| 938 | - | t.Errorf("damageCoversCell(%d,%d) = %v, want %v", tt.x, tt.y, got, tt.want) | |||
| 939 | - | } | |||
| 940 | - | }) | |||
| 941 | - | } | |||
| 942 | - | } | |||
| 943 | - | ||||
| 944 | - | // --------------------------------------------------------------------------- | |||
| 945 | - | // needsFull | |||
| 946 | - | // --------------------------------------------------------------------------- | |||
| 947 | - | ||||
| 948 | - | func TestNeedsFull(t *testing.T) { | |||
| 949 | - | tests := []struct { | |||
| 950 | - | name string | |||
| 951 | - | damage []Damage | |||
| 952 | - | want bool | |||
| 953 | - | }{ | |||
| 954 | - | {name: "nil damage should not need full", damage: nil, want: false}, | |||
| 955 | - | {name: "empty damage should not need full", damage: []Damage{}, want: false}, | |||
| 956 | - | {name: "text damage should not need full", damage: []Damage{{Kind: DamageText}}, want: false}, | |||
| 957 | - | {name: "FullRedraw should need full", damage: []Damage{FullRedraw()}, want: true}, | |||
| 958 | - | } | |||
| 959 | - | ||||
| 960 | - | for _, tt := range tests { | |||
| 961 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 962 | - | if got := needsFull(tt.damage); got != tt.want { | |||
| 963 | - | t.Errorf("needsFull() = %v, want %v", got, tt.want) | |||
| 964 | - | } | |||
| 965 | - | }) | |||
| 966 | - | } | |||
| 967 | - | } | |||
| 968 | - | ||||
| 969 | - | func TestDrawFallbackAndClampingEdgeCases(t *testing.T) { | |||
| 970 | - | tests := []struct { | |||
| 971 | - | name string | |||
| 972 | - | run func(t *testing.T) | |||
| 973 | - | }{ | |||
| 974 | - | { | |||
| 975 | - | name: "falls back to full redraw when scroll fast path is unsafe", | |||
| 976 | - | run: func(t *testing.T) { | |||
| 977 | - | r := New(Capabilities{}) | |||
| 978 | - | frame := NewFrame(4, 3) | |||
| 979 | - | markFrame(&frame) | |||
| 980 | - | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil { | |||
| 981 | - | t.Fatal(err) | |||
| 982 | - | } | |||
| 983 | - | ||||
| 984 | - | changed := NewFrame(4, 3) | |||
| 985 | - | markFrame(&changed) | |||
| 986 | - | changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship | |||
| 987 | - | damage := []Damage{ | |||
| 988 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |||
| 989 | - | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |||
| 990 | - | } | |||
| 991 | - | out, err := r.Draw(changed, damage) | |||
| 992 | - | if err != nil { | |||
| 993 | - | t.Fatal(err) | |||
| 994 | - | } | |||
| 995 | - | if rows := strings.Count(string(out), "H"); rows < changed.Height { | |||
| 996 | - | t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out)) | |||
| 997 | - | } | |||
| 998 | - | for i := range changed.Cells { | |||
| 999 | - | if !r.shadow[i].Equal(changed.Cells[i]) { | |||
| 1000 | - | t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i]) | |||
| 1001 | - | } | |||
| 1002 | - | } | |||
| 1003 | - | }, | |||
| 1004 | - | }, | |||
| 1005 | - | { | |||
| 1006 | - | name: "out-of-bounds damage rectangles are clamped without panicking", | |||
| 1007 | - | run: func(t *testing.T) { | |||
| 1008 | - | r := New(Capabilities{}) | |||
| 1009 | - | frame := NewFrame(3, 2) | |||
| 1010 | - | markFrame(&frame) | |||
| 1011 | - | if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil { | |||
| 1012 | - | t.Fatal(err) | |||
| 1013 | - | } | |||
| 1014 | - | frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()}) | |||
| 1015 | - | defer func() { | |||
| 1016 | - | if r := recover(); r != nil { | |||
| 1017 | - | t.Fatalf("Draw panicked for out-of-bounds damage: %v", r) | |||
| 1018 | - | } | |||
| 1019 | - | }() | |||
| 1020 | - | if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil { | |||
| 1021 | - | t.Fatal(err) | |||
| 1022 | - | } | |||
| 1023 | - | }, | |||
| 1024 | - | }, | |||
| 1025 | - | } | |||
| 1026 | - | ||||
| 1027 | - | for _, tt := range tests { | |||
| 1028 | - | t.Run(tt.name, tt.run) | |||
| 1029 | - | } | |||
| 1030 | - | } | |||
| 1031 | - | ||||
| 1032 | - | // --------------------------------------------------------------------------- | |||
| 1033 | - | // Wide (double-width) character rendering | |||
| 1034 | - | // --------------------------------------------------------------------------- | |||
| 1035 | - | ||||
| 1036 | - | func TestWideCharRendering(t *testing.T) { | |||
| 1037 | - | // A wide left cell {Rune: r} followed by a continuation cell | |||
| 1038 | - | // {Continuation: true}. The renderer must emit the rune once and emit | |||
| 1039 | - | // nothing at all for the continuation cell (the terminal advances two | |||
| 1040 | - | // columns by itself). It must never emit a space for a continuation. | |||
| 1041 | - | tests := []struct { | |||
| 1042 | - | name string | |||
| 1043 | - | build func() Frame | |||
| 1044 | - | want string | |||
| 1045 | - | }{ | |||
| 1046 | - | { | |||
| 1047 | - | name: "single wide char followed by ascii", | |||
| 1048 | - | build: func() Frame { | |||
| 1049 | - | f := NewFrame(4, 1) | |||
| 1050 | - | f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()}) | |||
| 1051 | - | f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()}) | |||
| 1052 | - | f.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()}) | |||
| 1053 | - | return f | |||
| 1054 | - | }, | |||
| 1055 | - | // row 0: cursor home, 你, (skip continuation), A, trailing space, reset | |||
| 1056 | - | want: "\x1b[1;1H你A \x1b[0m", | |||
| 1057 | - | }, | |||
| 1058 | - | { | |||
| 1059 | - | name: "two adjacent wide chars then ascii", | |||
| 1060 | - | build: func() Frame { | |||
| 1061 | - | f := NewFrame(6, 1) | |||
| 1062 | - | f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()}) | |||
| 1063 | - | f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()}) | |||
| 1064 | - | f.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()}) | |||
| 1065 | - | f.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()}) | |||
| 1066 | - | f.Set(4, 0, Cell{Rune: 'X', Style: DefaultStyle()}) | |||
| 1067 | - | return f | |||
| 1068 | - | }, | |||
| 1069 | - | want: "\x1b[1;1H你好X \x1b[0m", | |||
| 1070 | - | }, | |||
| 1071 | - | } | |||
| 1072 | - | ||||
| 1073 | - | for _, tt := range tests { | |||
| 1074 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 1075 | - | r := New(Capabilities{}) | |||
| 1076 | - | frame := tt.build() | |||
| 1077 | - | out, err := r.Draw(frame, []Damage{FullRedraw()}) | |||
| 1078 | - | if err != nil { | |||
| 1079 | - | t.Fatal(err) | |||
| 1080 | - | } | |||
| 1081 | - | if string(out) != tt.want { | |||
| 1082 | - | t.Errorf("output = %q, want %q", string(out), tt.want) | |||
| 1083 | - | } | |||
| 1084 | - | // No continuation must ever be rendered as a space: the visible | |||
| 1085 | - | // glyph count is exactly the number of non-continuation cells. | |||
| 1086 | - | // A second draw with no damage must be a no-op, proving the shadow | |||
| 1087 | - | // matches the frame (continuation cells included). | |||
| 1088 | - | out2, err := r.Draw(frame, nil) | |||
| 1089 | - | if err != nil { | |||
| 1090 | - | t.Fatal(err) | |||
| 1091 | - | } | |||
| 1092 | - | if len(out2) != 0 { | |||
| 1093 | - | t.Errorf("second draw not a no-op: %q (shadow inconsistent)", string(out2)) | |||
| 1094 | - | } | |||
| 1095 | - | }) | |||
| 1096 | - | } | |||
| 1097 | - | } | |||
| 1098 | - | ||||
| 1099 | - | func TestRendererPrepare(t *testing.T) { | |||
| 1100 | - | fullFrame := testFrame("ABC", "DEF") | |||
| 1101 | - | fullDamage := []Damage{FullRedraw()} | |||
| 1102 | - | ||||
| 1103 | - | tests := []struct { | |||
| 1104 | - | name string | |||
| 1105 | - | run func(*testing.T) | |||
| 1106 | - | }{ | |||
| 1107 | - | {name: "zero commit is safe", run: func(t *testing.T) { | |||
| 1108 | - | var prepared PreparedDraw | |||
| 1109 | - | prepared.Commit() | |||
| 1110 | - | }}, | |||
| 1111 | - | ||||
| 1112 | - | {name: "discard and reprepare returns identical owned bytes", run: func(t *testing.T) { | |||
| 1113 | - | r := New(Capabilities{}) | |||
| 1114 | - | first, err := r.Prepare(fullFrame, fullDamage, false) | |||
| 1115 | - | if err != nil { | |||
| 1116 | - | t.Fatal(err) | |||
| 1117 | - | } | |||
| 1118 | - | want := append([]byte(nil), first.Bytes()...) | |||
| 1119 | - | ||||
| 1120 | - | second, err := r.Prepare(fullFrame, fullDamage, false) | |||
| 1121 | - | if err != nil { | |||
| 1122 | - | t.Fatal(err) | |||
| 1123 | - | } | |||
| 1124 | - | if !bytes.Equal(second.Bytes(), want) { | |||
| 1125 | - | t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want) | |||
| 1126 | - | } | |||
| 1127 | - | ||||
| 1128 | - | other := testFrame("12345678", "abcdefgh", "ABCDEFGH") | |||
| 1129 | - | if _, err := r.Prepare(other, fullDamage, false); err != nil { | |||
| 1130 | - | t.Fatal(err) | |||
| 1131 | - | } | |||
| 1132 | - | if !bytes.Equal(first.Bytes(), want) { | |||
| 1133 | - | t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want) | |||
| 1134 | - | } | |||
| 1135 | - | }}, | |||
| 1136 | - | ||||
| 1137 | - | {name: "commit then no-op", run: func(t *testing.T) { | |||
| 1138 | - | r := New(Capabilities{}) | |||
| 1139 | - | prepared, err := r.Prepare(fullFrame, fullDamage, false) | |||
| 1140 | - | if err != nil { | |||
| 1141 | - | t.Fatal(err) | |||
| 1142 | - | } | |||
| 1143 | - | prepared.Commit() | |||
| 1144 | - | ||||
| 1145 | - | next, err := r.Prepare(fullFrame, nil, false) | |||
| 1146 | - | if err != nil { | |||
| 1147 | - | t.Fatal(err) | |||
| 1148 | - | } | |||
| 1149 | - | if len(next.Bytes()) != 0 { | |||
| 1150 | - | t.Fatalf("unchanged frame emitted %q", next.Bytes()) | |||
| 1151 | - | } | |||
| 1152 | - | }}, | |||
| 1153 | - | ||||
| 1154 | - | {name: "reset preparation is transactional", run: func(t *testing.T) { | |||
| 1155 | - | r := New(Capabilities{}) | |||
| 1156 | - | initial, err := r.Prepare(fullFrame, fullDamage, false) | |||
| 1157 | - | if err != nil { | |||
| 1158 | - | t.Fatal(err) | |||
| 1159 | - | } | |||
| 1160 | - | initial.Commit() | |||
| 1161 | - | ||||
| 1162 | - | resetFrame := testFrame("ABC", "XYZ") | |||
| 1163 | - | resetPrepared, err := r.Prepare(resetFrame, nil, true) | |||
| 1164 | - | if err != nil { | |||
| 1165 | - | t.Fatal(err) | |||
| 1166 | - | } | |||
| 1167 | - | want, err := New(Capabilities{}).Draw(resetFrame, nil) | |||
| 1168 | - | if err != nil { | |||
| 1169 | - | t.Fatal(err) | |||
| 1170 | - | } | |||
| 1171 | - | if !bytes.Equal(resetPrepared.Bytes(), want) { | |||
| 1172 | - | t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want) | |||
| 1173 | - | } | |||
| 1174 | - | ||||
| 1175 | - | unchanged, err := r.Prepare(fullFrame, nil, false) | |||
| 1176 | - | if err != nil { | |||
| 1177 | - | t.Fatal(err) | |||
| 1178 | - | } | |||
| 1179 | - | if len(unchanged.Bytes()) != 0 { | |||
| 1180 | - | t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes()) | |||
| 1181 | - | } | |||
| 1182 | - | ||||
| 1183 | - | resetPrepared, err = r.Prepare(resetFrame, nil, true) | |||
| 1184 | - | if err != nil { | |||
| 1185 | - | t.Fatal(err) | |||
| 1186 | - | } | |||
| 1187 | - | resetPrepared.Commit() | |||
| 1188 | - | afterCommit, err := r.Prepare(resetFrame, nil, false) | |||
| 1189 | - | if err != nil { | |||
| 1190 | - | t.Fatal(err) | |||
| 1191 | - | } | |||
| 1192 | - | if len(afterCommit.Bytes()) != 0 { | |||
| 1193 | - | t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes()) | |||
| 1194 | - | } | |||
| 1195 | - | }}, | |||
| 1196 | - | ||||
| 1197 | - | {name: "successful no-byte commit", run: func(t *testing.T) { | |||
| 1198 | - | r := New(Capabilities{}) | |||
| 1199 | - | initial, err := r.Prepare(fullFrame, fullDamage, false) | |||
| 1200 | - | if err != nil { | |||
| 1201 | - | t.Fatal(err) | |||
| 1202 | - | } | |||
| 1203 | - | initial.Commit() | |||
| 1204 | - | ||||
| 1205 | - | noOp, err := r.Prepare(fullFrame, nil, false) | |||
| 1206 | - | if err != nil { | |||
| 1207 | - | t.Fatal(err) | |||
| 1208 | - | } | |||
| 1209 | - | if len(noOp.Bytes()) != 0 { | |||
| 1210 | - | t.Fatalf("no-op emitted %q", noOp.Bytes()) | |||
| 1211 | - | } | |||
| 1212 | - | noOp.Commit() | |||
| 1213 | - | ||||
| 1214 | - | again, err := r.Prepare(fullFrame, nil, false) | |||
| 1215 | - | if err != nil { | |||
| 1216 | - | t.Fatal(err) | |||
| 1217 | - | } | |||
| 1218 | - | if len(again.Bytes()) != 0 { | |||
| 1219 | - | t.Fatalf("committed no-op changed renderer state: %q", again.Bytes()) | |||
| 1220 | - | } | |||
| 1221 | - | }}, | |||
| 1222 | - | ||||
| 1223 | - | {name: "double commit is idempotent", run: func(t *testing.T) { | |||
| 1224 | - | base := testFrame("0000", "1111", "2222", "3333") | |||
| 1225 | - | scrolled := testFrame("1111", "2222", "3333", "new!") | |||
| 1226 | - | damage := []Damage{ | |||
| 1227 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1}, | |||
| 1228 | - | {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1}, | |||
| 1229 | - | } | |||
| 1230 | - | r := New(Capabilities{}) | |||
| 1231 | - | if _, err := r.Draw(base, fullDamage); err != nil { | |||
| 1232 | - | t.Fatal(err) | |||
| 1233 | - | } | |||
| 1234 | - | ||||
| 1235 | - | prepared, err := r.Prepare(scrolled, damage, false) | |||
| 1236 | - | if err != nil { | |||
| 1237 | - | t.Fatal(err) | |||
| 1238 | - | } | |||
| 1239 | - | prepared.Commit() | |||
| 1240 | - | prepared.Commit() | |||
| 1241 | - | ||||
| 1242 | - | next, err := r.Prepare(scrolled, nil, false) | |||
| 1243 | - | if err != nil { | |||
| 1244 | - | t.Fatal(err) | |||
| 1245 | - | } | |||
| 1246 | - | if len(next.Bytes()) != 0 { | |||
| 1247 | - | t.Fatalf("double commit advanced state twice: %q", next.Bytes()) | |||
| 1248 | - | } | |||
| 1249 | - | }}, | |||
| 1250 | - | ||||
| 1251 | - | {name: "copied prepared draw commits scroll once", run: func(t *testing.T) { | |||
| 1252 | - | base := testFrame("0000", "1111", "2222", "3333") | |||
| 1253 | - | scrolled := testFrame("1111", "2222", "3333", "new!") | |||
| 1254 | - | damage := []Damage{ | |||
| 1255 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1}, | |||
| 1256 | - | {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1}, | |||
| 1257 | - | } | |||
| 1258 | - | r := New(Capabilities{}) | |||
| 1259 | - | if _, err := r.Draw(base, fullDamage); err != nil { | |||
| 1260 | - | t.Fatal(err) | |||
| 1261 | - | } | |||
| 1262 | - | ||||
| 1263 | - | prepared, err := r.Prepare(scrolled, damage, false) | |||
| 1264 | - | if err != nil { | |||
| 1265 | - | t.Fatal(err) | |||
| 1266 | - | } | |||
| 1267 | - | copied := prepared | |||
| 1268 | - | prepared.Commit() | |||
| 1269 | - | copied.Commit() | |||
| 1270 | - | ||||
| 1271 | - | next, err := r.Prepare(scrolled, nil, false) | |||
| 1272 | - | if err != nil { | |||
| 1273 | - | t.Fatal(err) | |||
| 1274 | - | } | |||
| 1275 | - | if len(next.Bytes()) != 0 { | |||
| 1276 | - | t.Fatalf("copied commit advanced state twice: %q", next.Bytes()) | |||
| 1277 | - | } | |||
| 1278 | - | }}, | |||
| 1279 | - | ||||
| 1280 | - | {name: "commit uses immutable frame snapshot", run: func(t *testing.T) { | |||
| 1281 | - | r := New(Capabilities{}) | |||
| 1282 | - | base := testFrame("ABC", "DEF") | |||
| 1283 | - | if _, err := r.Draw(base, fullDamage); err != nil { | |||
| 1284 | - | t.Fatal(err) | |||
| 1285 | - | } | |||
| 1286 | - | ||||
| 1287 | - | changed := base.Clone() | |||
| 1288 | - | changed.Set(1, 1, Cell{Rune: 'X', Style: DefaultStyle()}) | |||
| 1289 | - | want := changed.Clone() | |||
| 1290 | - | prepared, err := r.Prepare(changed, []Damage{{Kind: DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false) | |||
| 1291 | - | if err != nil { | |||
| 1292 | - | t.Fatal(err) | |||
| 1293 | - | } | |||
| 1294 | - | if !strings.Contains(string(prepared.Bytes()), "X") { | |||
| 1295 | - | t.Fatalf("prepared output = %q, want changed cell", prepared.Bytes()) | |||
| 1296 | - | } | |||
| 1297 | - | ||||
| 1298 | - | changed.Set(1, 1, Cell{Rune: 'Y', Style: DefaultStyle()}) | |||
| 1299 | - | prepared.Commit() | |||
| 1300 | - | ||||
| 1301 | - | next, err := r.Prepare(want, nil, false) | |||
| 1302 | - | if err != nil { | |||
| 1303 | - | t.Fatal(err) | |||
| 1304 | - | } | |||
| 1305 | - | if len(next.Bytes()) != 0 { | |||
| 1306 | - | t.Fatalf("snapshot did not preserve prepared frame: %q", next.Bytes()) | |||
| 1307 | - | } | |||
| 1308 | - | }}, | |||
| 1309 | - | ||||
| 1310 | - | {name: "matches concrete ANSI fixtures", run: func(t *testing.T) { | |||
| 1311 | - | tests := []struct { | |||
| 1312 | - | name string | |||
| 1313 | - | prepare func(t *testing.T) PreparedDraw | |||
| 1314 | - | want string | |||
| 1315 | - | }{ | |||
| 1316 | - | { | |||
| 1317 | - | name: "full draw", | |||
| 1318 | - | prepare: func(t *testing.T) PreparedDraw { | |||
| 1319 | - | prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false) | |||
| 1320 | - | if err != nil { | |||
| 1321 | - | t.Fatal(err) | |||
| 1322 | - | } | |||
| 1323 | - | return prepared | |||
| 1324 | - | }, | |||
| 1325 | - | want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m", | |||
| 1326 | - | }, | |||
| 1327 | - | { | |||
| 1328 | - | name: "synchronized full draw", | |||
| 1329 | - | prepare: func(t *testing.T) PreparedDraw { | |||
| 1330 | - | prepared, err := New(Capabilities{SynchronizedOutput: true}).Prepare(fullFrame, fullDamage, false) | |||
| 1331 | - | if err != nil { | |||
| 1332 | - | t.Fatal(err) | |||
| 1333 | - | } | |||
| 1334 | - | return prepared | |||
| 1335 | - | }, | |||
| 1336 | - | want: SyncStartCSI + "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m" + SyncEndCSI, | |||
| 1337 | - | }, | |||
| 1338 | - | { | |||
| 1339 | - | name: "fragmented damage", | |||
| 1340 | - | prepare: func(t *testing.T) PreparedDraw { | |||
| 1341 | - | base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx") | |||
| 1342 | - | frame := base.Clone() | |||
| 1343 | - | frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()}) | |||
| 1344 | - | frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()}) | |||
| 1345 | - | frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()}) | |||
| 1346 | - | damage := []Damage{ | |||
| 1347 | - | {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1}, | |||
| 1348 | - | {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1}, | |||
| 1349 | - | {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1}, | |||
| 1350 | - | } | |||
| 1351 | - | r := New(Capabilities{}) | |||
| 1352 | - | if _, err := r.Draw(base, fullDamage); err != nil { | |||
| 1353 | - | t.Fatal(err) | |||
| 1354 | - | } | |||
| 1355 | - | prepared, err := r.Prepare(frame, damage, false) | |||
| 1356 | - | if err != nil { | |||
| 1357 | - | t.Fatal(err) | |||
| 1358 | - | } | |||
| 1359 | - | return prepared | |||
| 1360 | - | }, | |||
| 1361 | - | want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m", | |||
| 1362 | - | }, | |||
| 1363 | - | { | |||
| 1364 | - | name: "scroll", | |||
| 1365 | - | prepare: func(t *testing.T) PreparedDraw { | |||
| 1366 | - | base := testFrame("00000", "11111", "22222", "33333") | |||
| 1367 | - | frame := testFrame("11111", "22222", "33333", "new!!") | |||
| 1368 | - | damage := []Damage{ | |||
| 1369 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1}, | |||
| 1370 | - | {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1}, | |||
| 1371 | - | } | |||
| 1372 | - | r := New(Capabilities{}) | |||
| 1373 | - | if _, err := r.Draw(base, fullDamage); err != nil { | |||
| 1374 | - | t.Fatal(err) | |||
| 1375 | - | } | |||
| 1376 | - | prepared, err := r.Prepare(frame, damage, false) | |||
| 1377 | - | if err != nil { | |||
| 1378 | - | t.Fatal(err) | |||
| 1379 | - | } | |||
| 1380 | - | return prepared | |||
| 1381 | - | }, | |||
| 1382 | - | want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m", | |||
| 1383 | - | }, | |||
| 1384 | - | } | |||
| 1385 | - | ||||
| 1386 | - | for _, tt := range tests { | |||
| 1387 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 1388 | - | if got := string(tt.prepare(t).Bytes()); got != tt.want { | |||
| 1389 | - | t.Fatalf("prepared ANSI = %q, want %q", got, tt.want) | |||
| 1390 | - | } | |||
| 1391 | - | }) | |||
| 1392 | - | } | |||
| 1393 | - | }}, | |||
| 1394 | - | } | |||
| 1395 | - | ||||
| 1396 | - | for _, tt := range tests { | |||
| 1397 | - | t.Run(tt.name, tt.run) | |||
| 1398 | - | } | |||
| 1399 | - | } | |||
| 1400 | - | ||||
| 1401 | - | func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) { | |||
| 1402 | - | frame := NewFrame(2, 1) | |||
| 1403 | - | committed := NewFrame(2, 1) | |||
| 1404 | - | committed.lineOffset = nil | |||
| 1405 | - | ||||
| 1406 | - | if _, err := PlanDelta(frame, nil, committed, false); err == nil { | |||
| 1407 | - | t.Fatal("PlanDelta accepted an invalid committed frame") | |||
| 1408 | - | } | |||
| 1409 | - | } | |||
| 1410 | - | ||||
| 1411 | - | func TestWideCharDamageEmission(t *testing.T) { | |||
| 1412 | - | // Populate the shadow with a blank frame, then place a wide char and draw | |||
| 1413 | - | // with a text-damage rect covering the pair. The continuation cell must | |||
| 1414 | - | // not produce a space. | |||
| 1415 | - | r := New(Capabilities{}) | |||
| 1416 | - | blank := NewFrame(4, 1) | |||
| 1417 | - | if _, err := r.Draw(blank, []Damage{FullRedraw()}); err != nil { | |||
| 1418 | - | t.Fatal(err) | |||
| 1419 | - | } | |||
| 1420 | - | ||||
| 1421 | - | frame := NewFrame(4, 1) | |||
| 1422 | - | frame.Set(0, 0, Cell{Rune: '好', Style: DefaultStyle()}) | |||
| 1423 | - | frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()}) | |||
| 1424 | - | ||||
| 1425 | - | out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1}}) | |||
| 1426 | - | if err != nil { | |||
| 1427 | - | t.Fatal(err) | |||
| 1428 | - | } | |||
| 1429 | - | got := string(out) | |||
| 1430 | - | if !strings.Contains(got, "好") { | |||
| 1431 | - | t.Errorf("output %q missing wide rune", got) | |||
| 1432 | - | } | |||
| 1433 | - | if strings.Contains(got, "好 ") { | |||
| 1434 | - | t.Errorf("output %q emitted a space for the continuation cell", got) | |||
| 1435 | - | } | |||
| 1436 | - | } | |||
D pkg/renderer/rune_width.go +0 -120
| @@ -1,120 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import "unicode" | |||
| 4 | - | ||||
| 5 | - | // RuneWidth returns the terminal cell width of a rune. | |||
| 6 | - | // | |||
| 7 | - | // Returns 0 for combining marks, zero-width characters, and control codes. | |||
| 8 | - | // Returns 2 for CJK, fullwidth, and wide emoji characters. | |||
| 9 | - | // Returns 1 for all other printable characters. | |||
| 10 | - | // | |||
| 11 | - | // Wide runes (width 2) occupy two grid cells: a left cell holding the rune and | |||
| 12 | - | // a right continuation cell. Combining marks (Mn/Me) and zero-width joiner | |||
| 13 | - | // sequences are out of scope: combining marks report width 0 (current | |||
| 14 | - | // behavior), ZWJ sequences are not coalesced (each rune is measured on its own). | |||
| 15 | - | func RuneWidth(r rune) int { | |||
| 16 | - | switch { | |||
| 17 | - | case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F): | |||
| 18 | - | return 0 | |||
| 19 | - | // Soft hyphen, combining grapheme joiner, Arabic sign, etc. | |||
| 20 | - | case r == 0x00AD || r == 0x034F || r == 0x061C: | |||
| 21 | - | return 0 | |||
| 22 | - | // Hangul Jungseong O/E, Hangul Jungseong Yu | |||
| 23 | - | case r == 0x115F || r == 0x1160: | |||
| 24 | - | return 0 | |||
| 25 | - | // Balinese musical symbols | |||
| 26 | - | case r == 0x17B4 || r == 0x17B5: | |||
| 27 | - | return 0 | |||
| 28 | - | // Mongolian variation selectors, free variation selector | |||
| 29 | - | case r == 0x180B || r == 0x180E || r == 0x200B || r == 0x200C || r == 0x200D || r == 0x200E || r == 0x200F: | |||
| 30 | - | return 0 | |||
| 31 | - | // Line/paragraph separator, bidirectional overrides | |||
| 32 | - | case r >= 0x2028 && r <= 0x202E: | |||
| 33 | - | return 0 | |||
| 34 | - | // Word joiner, invisible operators, Arabic/Syriac number marks, etc. | |||
| 35 | - | case r >= 0x2060 && r <= 0x206F: | |||
| 36 | - | return 0 | |||
| 37 | - | case r == 0xFEFF || r == 0xFFF9 || r == 0xFFFA || r == 0xFFFB: | |||
| 38 | - | return 0 | |||
| 39 | - | // Tags | |||
| 40 | - | case r >= 0xE0001 && r <= 0xE007F: | |||
| 41 | - | return 0 | |||
| 42 | - | // Combining diacritical marks (Unicode categories Mn, Me) | |||
| 43 | - | case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r): | |||
| 44 | - | return 0 | |||
| 45 | - | // --- Wide characters (width 2) --- | |||
| 46 | - | // Hangul Jamo | |||
| 47 | - | case r >= 0x1100 && r <= 0x115F: | |||
| 48 | - | return 2 | |||
| 49 | - | // Left/right-pointing angle bracket (for compatibility) | |||
| 50 | - | case r == 0x2329 || r == 0x232A: | |||
| 51 | - | return 2 | |||
| 52 | - | // CJK Radicals Supplement, Kangxi Radicals, Ideographic Description | |||
| 53 | - | // CJK Symbols, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo | |||
| 54 | - | // Kanbun, CJK Strokes, Enclosed CJK Letters | |||
| 55 | - | case r >= 0x2E80 && r <= 0x303E: | |||
| 56 | - | return 2 | |||
| 57 | - | // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, Kanbun | |||
| 58 | - | case r >= 0x3040 && r <= 0x33FF: | |||
| 59 | - | return 2 | |||
| 60 | - | // CJK Unified Ideographs Extension A | |||
| 61 | - | case r >= 0x3400 && r <= 0x4DBF: | |||
| 62 | - | return 2 | |||
| 63 | - | // CJK Unified Ideographs, Yi Syllables, Yi Radicals | |||
| 64 | - | case r >= 0x4E00 && r <= 0xA4CF: | |||
| 65 | - | return 2 | |||
| 66 | - | // Hangul Jamo Extended-A | |||
| 67 | - | case r >= 0xA960 && r <= 0xA97C: | |||
| 68 | - | return 2 | |||
| 69 | - | // Hangul Syllables | |||
| 70 | - | case r >= 0xAC00 && r <= 0xD7AF: | |||
| 71 | - | return 2 | |||
| 72 | - | // Hangul Jamo Extended-B | |||
| 73 | - | case r >= 0xD7B0 && r <= 0xD7FF: | |||
| 74 | - | return 2 | |||
| 75 | - | // CJK Compatibility Ideographs | |||
| 76 | - | case r >= 0xF900 && r <= 0xFAFF: | |||
| 77 | - | return 2 | |||
| 78 | - | // Vertical Forms | |||
| 79 | - | case r >= 0xFE10 && r <= 0xFE19: | |||
| 80 | - | return 2 | |||
| 81 | - | // CJK Compatibility Forms | |||
| 82 | - | case r >= 0xFE30 && r <= 0xFE6F: | |||
| 83 | - | return 2 | |||
| 84 | - | // Fullwidth Forms (variation selectors, fullwidth punctuation, etc.) | |||
| 85 | - | case r >= 0xFF01 && r <= 0xFF60: | |||
| 86 | - | return 2 | |||
| 87 | - | // Fullwidth Signs (cent, pound, yen, won, etc.) | |||
| 88 | - | case r >= 0xFFE0 && r <= 0xFFE6: | |||
| 89 | - | return 2 | |||
| 90 | - | // Kana Supplement | |||
| 91 | - | case r >= 0x1B000 && r <= 0x1B0FF: | |||
| 92 | - | return 2 | |||
| 93 | - | // Kana Extended-A | |||
| 94 | - | case r >= 0x1B100 && r <= 0x1B12F: | |||
| 95 | - | return 2 | |||
| 96 | - | // Mahjong Tiles, Domino Tiles, Playing Cards | |||
| 97 | - | case r >= 0x1F000 && r <= 0x1F09F: | |||
| 98 | - | return 2 | |||
| 99 | - | // Miscellaneous Symbols and Pictographs, Emoticons, Ornamental Dingbats | |||
| 100 | - | case r >= 0x1F0A0 && r <= 0x1F64F: | |||
| 101 | - | return 2 | |||
| 102 | - | // Transport and Map Symbols | |||
| 103 | - | case r >= 0x1F680 && r <= 0x1F6FF: | |||
| 104 | - | return 2 | |||
| 105 | - | // Supplemental Symbols and Pictographs | |||
| 106 | - | case r >= 0x1F900 && r <= 0x1F9FF: | |||
| 107 | - | return 2 | |||
| 108 | - | // Symbols and Pictographs Extended-A (emoji added in recent Unicode) | |||
| 109 | - | case r >= 0x1FA70 && r <= 0x1FAFF: | |||
| 110 | - | return 2 | |||
| 111 | - | // CJK Unified Ideographs Extension B and beyond | |||
| 112 | - | case r >= 0x20000 && r <= 0x2FFFF: | |||
| 113 | - | return 2 | |||
| 114 | - | // CJK Unified Ideographs Extension G and beyond | |||
| 115 | - | case r >= 0x30000 && r <= 0x3FFFF: | |||
| 116 | - | return 2 | |||
| 117 | - | default: | |||
| 118 | - | return 1 | |||
| 119 | - | } | |||
| 120 | - | } | |||
D pkg/renderer/scroll.go +0 -70
| @@ -1,70 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "strconv" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) { | |||
| 9 | - | for _, d := range damage { | |||
| 10 | - | if d.Kind == DamageScrollUp && isSafeScroll(frame, d) { | |||
| 11 | - | return d, true | |||
| 12 | - | } | |||
| 13 | - | } | |||
| 14 | - | return Damage{}, false | |||
| 15 | - | } | |||
| 16 | - | ||||
| 17 | - | func isSafeScroll(frame Frame, d Damage) bool { | |||
| 18 | - | return d.X == 0 && d.Width == frame.Width && d.Y >= 0 && d.Y <= frame.Height && d.Height > 0 && d.Height <= frame.Height-d.Y && d.Count > 0 && d.Count < d.Height | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | - | func emitScrollUp(out *bytes.Buffer, d Damage) { | |||
| 22 | - | out.WriteString("\x1b[0m") | |||
| 23 | - | // \x1b[%d;%dr — set scroll region | |||
| 24 | - | out.WriteString("\x1b[") | |||
| 25 | - | var b [16]byte | |||
| 26 | - | n := strconv.AppendInt(b[:0], int64(d.Y+1), 10) | |||
| 27 | - | out.Write(n) | |||
| 28 | - | out.WriteByte(';') | |||
| 29 | - | n = strconv.AppendInt(b[:0], int64(d.Y+d.Height), 10) | |||
| 30 | - | out.Write(n) | |||
| 31 | - | out.WriteByte('r') | |||
| 32 | - | // Position cursor at bottom of scroll region. | |||
| 33 | - | writeCursor(out, d.Y+d.Height-1, 0) | |||
| 34 | - | if d.Count == 1 { | |||
| 35 | - | out.WriteByte('\n') | |||
| 36 | - | } else { | |||
| 37 | - | out.WriteString("\x1b[") | |||
| 38 | - | n = strconv.AppendInt(b[:0], int64(d.Count), 10) | |||
| 39 | - | out.Write(n) | |||
| 40 | - | out.WriteByte('S') | |||
| 41 | - | } | |||
| 42 | - | out.WriteString("\x1b[r") | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | func canApplyScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool { | |||
| 46 | - | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | |||
| 47 | - | frameRow := frame.Row(y) | |||
| 48 | - | committedRow := committed.Row(y + scroll.Count) | |||
| 49 | - | for x := range scroll.Width { | |||
| 50 | - | column := scroll.X + x | |||
| 51 | - | committedCell, frameCell := committedRow[column], frameRow[column] | |||
| 52 | - | if committedCell == frameCell || committedCell.Equal(frameCell) { | |||
| 53 | - | continue | |||
| 54 | - | } | |||
| 55 | - | if !damageCoversCell(damage, column, y) { | |||
| 56 | - | return false | |||
| 57 | - | } | |||
| 58 | - | } | |||
| 59 | - | } | |||
| 60 | - | return true | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | func damageCoversCell(damage []Damage, x, y int) bool { | |||
| 64 | - | for _, d := range damage { | |||
| 65 | - | if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height { | |||
| 66 | - | return true | |||
| 67 | - | } | |||
| 68 | - | } | |||
| 69 | - | return false | |||
| 70 | - | } | |||
D pkg/renderer/style.go +0 -82
| @@ -1,82 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | type RGB struct { | |||
| 4 | - | R uint8 | |||
| 5 | - | G uint8 | |||
| 6 | - | B uint8 | |||
| 7 | - | } | |||
| 8 | - | ||||
| 9 | - | type StyleAttrs uint16 | |||
| 10 | - | ||||
| 11 | - | const ( | |||
| 12 | - | AttrDim StyleAttrs = 1 << iota | |||
| 13 | - | AttrUnderline | |||
| 14 | - | AttrBlink | |||
| 15 | - | AttrStrikethrough | |||
| 16 | - | ) | |||
| 17 | - | ||||
| 18 | - | type UnderlineStyle uint8 | |||
| 19 | - | ||||
| 20 | - | const ( | |||
| 21 | - | UnderlineNone UnderlineStyle = iota | |||
| 22 | - | UnderlineSingle | |||
| 23 | - | UnderlineDouble | |||
| 24 | - | UnderlineCurly | |||
| 25 | - | UnderlineDotted | |||
| 26 | - | UnderlineDashed | |||
| 27 | - | ) | |||
| 28 | - | ||||
| 29 | - | type Style struct { | |||
| 30 | - | Bold bool | |||
| 31 | - | Italic bool | |||
| 32 | - | Inverse bool | |||
| 33 | - | Attrs StyleAttrs | |||
| 34 | - | Foreground int // -1 means unset; ignored when HasForegroundRGB is true | |||
| 35 | - | Background int // -1 means unset; ignored when HasBackgroundRGB is true | |||
| 36 | - | ||||
| 37 | - | HasForegroundRGB bool | |||
| 38 | - | ForegroundRGB RGB | |||
| 39 | - | HasBackgroundRGB bool | |||
| 40 | - | BackgroundRGB RGB | |||
| 41 | - | ||||
| 42 | - | UnderlineStyle UnderlineStyle | |||
| 43 | - | HasUnderlineColor bool | |||
| 44 | - | UnderlineColor int // ignored when HasUnderlineColorRGB is true | |||
| 45 | - | HasUnderlineColorRGB bool | |||
| 46 | - | UnderlineColorRGB RGB | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} } | |||
| 50 | - | ||||
| 51 | - | func (s Style) Equal(other Style) bool { | |||
| 52 | - | if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse || s.Attrs != other.Attrs || s.UnderlineStyle != other.UnderlineStyle { | |||
| 53 | - | return false | |||
| 54 | - | } | |||
| 55 | - | if s.HasForegroundRGB != other.HasForegroundRGB || s.HasBackgroundRGB != other.HasBackgroundRGB || s.HasUnderlineColorRGB != other.HasUnderlineColorRGB { | |||
| 56 | - | return false | |||
| 57 | - | } | |||
| 58 | - | if s.HasForegroundRGB { | |||
| 59 | - | if s.ForegroundRGB != other.ForegroundRGB { | |||
| 60 | - | return false | |||
| 61 | - | } | |||
| 62 | - | } else if s.Foreground != other.Foreground { | |||
| 63 | - | return false | |||
| 64 | - | } | |||
| 65 | - | if s.HasBackgroundRGB { | |||
| 66 | - | if s.BackgroundRGB != other.BackgroundRGB { | |||
| 67 | - | return false | |||
| 68 | - | } | |||
| 69 | - | } else if s.Background != other.Background { | |||
| 70 | - | return false | |||
| 71 | - | } | |||
| 72 | - | if s.HasUnderlineColorRGB { | |||
| 73 | - | return s.UnderlineColorRGB == other.UnderlineColorRGB | |||
| 74 | - | } | |||
| 75 | - | if s.HasUnderlineColor != other.HasUnderlineColor { | |||
| 76 | - | return false | |||
| 77 | - | } | |||
| 78 | - | if s.HasUnderlineColor { | |||
| 79 | - | return s.UnderlineColor == other.UnderlineColor | |||
| 80 | - | } | |||
| 81 | - | return true | |||
| 82 | - | } | |||
D pkg/renderer/sync_output.go +0 -17
| @@ -1,17 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | const ( | |||
| 4 | - | SyncStartCSI = "\x1b[?2026h" | |||
| 5 | - | SyncEndCSI = "\x1b[?2026l" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | func WrapSynchronized(content []byte, enabled bool) []byte { | |||
| 9 | - | if !enabled || len(content) == 0 { | |||
| 10 | - | return content | |||
| 11 | - | } | |||
| 12 | - | out := make([]byte, 0, len(SyncStartCSI)+len(content)+len(SyncEndCSI)) | |||
| 13 | - | out = append(out, SyncStartCSI...) | |||
| 14 | - | out = append(out, content...) | |||
| 15 | - | out = append(out, SyncEndCSI...) | |||
| 16 | - | return out | |||
| 17 | - | } | |||
D pkg/vt/benchmark_test.go +0 -242
| @@ -1,242 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | var ( | |||
| 11 | - | benchmarkHistoryViewSink HistoryView | |||
| 12 | - | benchmarkHistorySnapshotSink HistorySnapshotView | |||
| 13 | - | benchmarkHistoryBlobSink []byte | |||
| 14 | - | benchmarkHistoryBlobsSink [][]byte | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | // TestHistoryViewPartialTailAllocationBounded guards the capture property used | |||
| 18 | - | // by persistence: retained sealed chunks are shared, while only the small | |||
| 19 | - | // mutable tail is copied into a view. | |||
| 20 | - | func TestHistoryViewPartialTailAllocationBounded(t *testing.T) { | |||
| 21 | - | const ( | |||
| 22 | - | width = 120 | |||
| 23 | - | chunkRows = 64 | |||
| 24 | - | tailRows = 7 | |||
| 25 | - | ) | |||
| 26 | - | sealed := benchmarkHistory(t, chunkRows*4, width, chunkRows) | |||
| 27 | - | sealed.SealAndView() | |||
| 28 | - | partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows) | |||
| 29 | - | partialBefore := partial.View() | |||
| 30 | - | ||||
| 31 | - | // Copying a partial tail costs one allocation per row plus a fixed set of | |||
| 32 | - | // headers: the chunk slice, its growth for the tail chunk, the chunk itself, | |||
| 33 | - | // the row slice, and the bounds slice parallel to it. | |||
| 34 | - | const tailHeaders = 4 | |||
| 35 | - | ||||
| 36 | - | sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() }) | |||
| 37 | - | partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() }) | |||
| 38 | - | if partialAllocs > sealedAllocs+tailRows+tailHeaders { | |||
| 39 | - | t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows) | |||
| 40 | - | } | |||
| 41 | - | if partial.View().Chunk(0) != partialBefore.Chunk(0) { | |||
| 42 | - | t.Fatal("unchanged sealed chunks must retain identity across partial-tail views") | |||
| 43 | - | } | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | func BenchmarkHistoryView10KRowsSealedReuse(b *testing.B) { | |||
| 47 | - | history := benchmarkHistory(b, 10_000, 120, 256) | |||
| 48 | - | sealed := history.SealAndView() | |||
| 49 | - | if sealed.Len() != 10_000 || sealed.ChunkCount() != 40 || sealed.Chunk(0) == nil { | |||
| 50 | - | b.Fatalf("invalid sealed history fixture: rows=%d chunks=%d", sealed.Len(), sealed.ChunkCount()) | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | b.ReportAllocs() | |||
| 54 | - | b.ResetTimer() | |||
| 55 | - | for b.Loop() { | |||
| 56 | - | benchmarkHistoryViewSink = history.View() | |||
| 57 | - | } | |||
| 58 | - | b.StopTimer() | |||
| 59 | - | if got := benchmarkHistoryViewSink; got.Len() != sealed.Len() || got.Chunk(0) != sealed.Chunk(0) { | |||
| 60 | - | b.Fatal("unchanged sealed history chunks were not reused") | |||
| 61 | - | } | |||
| 62 | - | b.ReportMetric(float64(sealed.ChunkCount()), "sealedchunks/view") | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | func BenchmarkHistoryView10KRowsPartialTail(b *testing.B) { | |||
| 66 | - | history := benchmarkHistory(b, 10_000, 120, 256) | |||
| 67 | - | before := history.View() | |||
| 68 | - | if before.Len() != 10_000 || before.ChunkCount() != 40 || before.Chunk(0) == nil { | |||
| 69 | - | b.Fatalf("invalid partial-tail history fixture: rows=%d chunks=%d", before.Len(), before.ChunkCount()) | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | b.ReportAllocs() | |||
| 73 | - | b.ResetTimer() | |||
| 74 | - | for b.Loop() { | |||
| 75 | - | benchmarkHistoryViewSink = history.View() | |||
| 76 | - | } | |||
| 77 | - | b.StopTimer() | |||
| 78 | - | if got := benchmarkHistoryViewSink; got.Len() != before.Len() || got.Chunk(0) != before.Chunk(0) { | |||
| 79 | - | b.Fatal("partial-tail capture did not reuse its sealed chunks") | |||
| 80 | - | } | |||
| 81 | - | b.ReportMetric(float64(10_000%256), "tailrows/view") | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | func BenchmarkHistorySnapshotView10KRowsPartialTail(b *testing.B) { | |||
| 85 | - | history := benchmarkHistory(b, 10_000, 120, 256) | |||
| 86 | - | if got, want := len(history.tail), 16; got != want { | |||
| 87 | - | b.Fatalf("partial tail rows = %d, want %d", got, want) | |||
| 88 | - | } | |||
| 89 | - | sealed := history.View().Chunk(0) | |||
| 90 | - | ||||
| 91 | - | b.ReportAllocs() | |||
| 92 | - | b.ResetTimer() | |||
| 93 | - | for b.Loop() { | |||
| 94 | - | benchmarkHistorySnapshotSink = history.SnapshotView() | |||
| 95 | - | } | |||
| 96 | - | b.StopTimer() | |||
| 97 | - | if got := benchmarkHistorySnapshotSink; got.Chunk(0) != sealed || got.Tail().Len() != 16 { | |||
| 98 | - | b.Fatal("snapshot did not preserve chunk identity and tail rows") | |||
| 99 | - | } | |||
| 100 | - | b.ReportMetric(float64(benchmarkHistorySnapshotSink.ChunkCount()), "sealedchunks/view") | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | func BenchmarkMarshalHistoryChunk256x120(b *testing.B) { | |||
| 104 | - | history := benchmarkHistory(b, 256, 120, 256) | |||
| 105 | - | chunk := history.SealAndView().Chunk(0) | |||
| 106 | - | if chunk == nil { | |||
| 107 | - | b.Fatal("invalid history chunk fixture") | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | b.ReportAllocs() | |||
| 111 | - | b.ResetTimer() | |||
| 112 | - | for b.Loop() { | |||
| 113 | - | var err error | |||
| 114 | - | benchmarkHistoryBlobSink, err = MarshalHistoryChunk(chunk) | |||
| 115 | - | if err != nil { | |||
| 116 | - | b.Fatal(err) | |||
| 117 | - | } | |||
| 118 | - | } | |||
| 119 | - | b.StopTimer() | |||
| 120 | - | if _, err := PreflightHistoryBlob(benchmarkHistoryBlobSink); err != nil { | |||
| 121 | - | b.Fatalf("encoded history chunk is invalid: %v", err) | |||
| 122 | - | } | |||
| 123 | - | b.SetBytes(int64(len(benchmarkHistoryBlobSink))) | |||
| 124 | - | } | |||
| 125 | - | ||||
| 126 | - | func BenchmarkUnmarshalHistoryChunk256x120(b *testing.B) { | |||
| 127 | - | history := benchmarkHistory(b, 256, 120, 256) | |||
| 128 | - | blob, err := MarshalHistoryChunk(history.SealAndView().Chunk(0)) | |||
| 129 | - | if err != nil { | |||
| 130 | - | b.Fatal(err) | |||
| 131 | - | } | |||
| 132 | - | if stats, err := PreflightHistoryBlob(blob); err != nil || stats.Rows != 256 || stats.Cells != 256*120 { | |||
| 133 | - | b.Fatalf("invalid encoded history chunk fixture: stats=%+v err=%v", stats, err) | |||
| 134 | - | } | |||
| 135 | - | ||||
| 136 | - | b.ReportAllocs() | |||
| 137 | - | b.SetBytes(int64(len(blob))) | |||
| 138 | - | b.ResetTimer() | |||
| 139 | - | for b.Loop() { | |||
| 140 | - | var err error | |||
| 141 | - | benchmarkHistoryViewSink, err = UnmarshalHistory(blob) | |||
| 142 | - | if err != nil { | |||
| 143 | - | b.Fatal(err) | |||
| 144 | - | } | |||
| 145 | - | } | |||
| 146 | - | b.StopTimer() | |||
| 147 | - | if benchmarkHistoryViewSink.Len() != 256 || benchmarkHistoryViewSink.ChunkCount() != 1 { | |||
| 148 | - | b.Fatal("decoded history chunk lost rows") | |||
| 149 | - | } | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | func BenchmarkMarshalSealedHistory10KRows(b *testing.B) { | |||
| 153 | - | history := benchmarkHistory(b, 10_000, 120, 256) | |||
| 154 | - | view := history.SealAndView() | |||
| 155 | - | if view.Len() != 10_000 || view.ChunkCount() != 40 { | |||
| 156 | - | b.Fatalf("invalid sealed history fixture: rows=%d chunks=%d", view.Len(), view.ChunkCount()) | |||
| 157 | - | } | |||
| 158 | - | ||||
| 159 | - | b.ReportAllocs() | |||
| 160 | - | b.ResetTimer() | |||
| 161 | - | for b.Loop() { | |||
| 162 | - | var err error | |||
| 163 | - | benchmarkHistoryBlobsSink, benchmarkHistoryBlobSink, err = MarshalSealedHistory(view) | |||
| 164 | - | if err != nil { | |||
| 165 | - | b.Fatal(err) | |||
| 166 | - | } | |||
| 167 | - | } | |||
| 168 | - | b.StopTimer() | |||
| 169 | - | if len(benchmarkHistoryBlobsSink) != view.ChunkCount() || len(benchmarkHistoryBlobSink) == 0 { | |||
| 170 | - | b.Fatal("sealed-history encoder produced an incomplete capture") | |||
| 171 | - | } | |||
| 172 | - | b.ReportMetric(float64(len(benchmarkHistoryBlobsSink)), "sealedchunks/op") | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | - | func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History { | |||
| 176 | - | t.Helper() | |||
| 177 | - | history := NewHistory(HistoryConfig{MaxRows: rows, ChunkRows: chunkRows}) | |||
| 178 | - | for row := range rows { | |||
| 179 | - | cells := make([]renderer.Cell, width) | |||
| 180 | - | for col := range cells { | |||
| 181 | - | cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)} | |||
| 182 | - | } | |||
| 183 | - | requireHistoryAppend(t, history, cells) | |||
| 184 | - | } | |||
| 185 | - | return history | |||
| 186 | - | } | |||
| 187 | - | ||||
| 188 | - | // BenchmarkScreenResizeReflowViewport verifies resize only visits the live | |||
| 189 | - | // viewport. The 10K-history variant must track the control rather than history | |||
| 190 | - | // length; history is deliberately populated without changing the viewport. | |||
| 191 | - | func BenchmarkScreenResizeReflowViewport(b *testing.B) { | |||
| 192 | - | b.Run("control", func(b *testing.B) { benchmarkScreenResizeReflow(b, 0) }) | |||
| 193 | - | b.Run("10k-history", func(b *testing.B) { benchmarkScreenResizeReflow(b, 10_000) }) | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | func benchmarkScreenResizeReflow(b *testing.B, historyRows int) { | |||
| 197 | - | b.Helper() | |||
| 198 | - | s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500}) | |||
| 199 | - | if historyRows > 0 { | |||
| 200 | - | for range historyRows { | |||
| 201 | - | requireHistoryAppend(b, s.history, make([]renderer.Cell, 120)) | |||
| 202 | - | } | |||
| 203 | - | } | |||
| 204 | - | s.Write(bytes.Repeat([]byte("x"), 120*39)) | |||
| 205 | - | s.ClearDamage() | |||
| 206 | - | sizes := [2][2]int{{80, 40}, {120, 40}} | |||
| 207 | - | ||||
| 208 | - | b.ReportAllocs() | |||
| 209 | - | b.ResetTimer() | |||
| 210 | - | for i := 0; b.Loop(); i++ { | |||
| 211 | - | s.Resize(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1]) | |||
| 212 | - | s.ClearDamage() | |||
| 213 | - | } | |||
| 214 | - | b.StopTimer() | |||
| 215 | - | if got := s.History(); historyRows > 0 && got.Len() < historyRows { | |||
| 216 | - | b.Fatalf("resize discarded retained history rows: got %d want at least %d", got.Len(), historyRows) | |||
| 217 | - | } | |||
| 218 | - | } | |||
| 219 | - | ||||
| 220 | - | func BenchmarkScreenShellRedrawBurst(b *testing.B) { | |||
| 221 | - | chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C") | |||
| 222 | - | b.ReportAllocs() | |||
| 223 | - | for b.Loop() { | |||
| 224 | - | s := NewScreen(120, 40) | |||
| 225 | - | for range 200 { | |||
| 226 | - | s.Write(chunk) | |||
| 227 | - | s.ClearDamage() | |||
| 228 | - | } | |||
| 229 | - | } | |||
| 230 | - | } | |||
| 231 | - | ||||
| 232 | - | func BenchmarkScreenFullscreenScrollRegion(b *testing.B) { | |||
| 233 | - | chunk := []byte("\x1b[2;39r\x1b[39;1Hline\n") | |||
| 234 | - | b.ReportAllocs() | |||
| 235 | - | for b.Loop() { | |||
| 236 | - | s := NewScreen(120, 40) | |||
| 237 | - | for range 500 { | |||
| 238 | - | s.Write(chunk) | |||
| 239 | - | s.ClearDamage() | |||
| 240 | - | } | |||
| 241 | - | } | |||
| 242 | - | } | |||
D pkg/vt/buffer.go +0 -435
| @@ -1,435 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | - | ||||
| 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: | |||
| 7 | - | // reflow is bounded by the live grid. | |||
| 8 | - | type buffer struct { | |||
| 9 | - | frame renderer.Frame | |||
| 10 | - | boundaries []LineBound | |||
| 11 | - | rowIDs []RowID | |||
| 12 | - | } | |||
| 13 | - | ||||
| 14 | - | // LineBound describes a physical row's logical extent. End is exclusive: it is | |||
| 15 | - | // the count of meaningful cells, so the last significant column is End-1. It | |||
| 16 | - | // excludes padding introduced when a wide rune was moved off the right edge. | |||
| 17 | - | // Soft reports that the row continues into the following physical row. | |||
| 18 | - | type LineBound struct { | |||
| 19 | - | End int | |||
| 20 | - | Soft bool | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | func newBuffer(width, height int) *buffer { | |||
| 24 | - | return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height), rowIDs: make([]RowID, height)} | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | func bufferFromFrame(frame renderer.Frame) *buffer { | |||
| 28 | - | return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height), rowIDs: make([]RowID, frame.Height)} | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | func (b *buffer) clone() *buffer { | |||
| 32 | - | out := &buffer{ | |||
| 33 | - | frame: cloneFrame(b.frame), | |||
| 34 | - | boundaries: append([]LineBound(nil), b.boundaries...), | |||
| 35 | - | rowIDs: append([]RowID(nil), b.rowIDs...), | |||
| 36 | - | } | |||
| 37 | - | return out | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | func (b *buffer) content(y, end int) { | |||
| 41 | - | if y < 0 || y >= len(b.boundaries) { | |||
| 42 | - | return | |||
| 43 | - | } | |||
| 44 | - | b.boundaries[y].End = max(b.boundaries[y].End, clamp(end, 0, b.frame.Width)) | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | // bound reports the logical extent of row y. Callers on eviction paths may run | |||
| 48 | - | // against a nil or shorter buffer than the frame they are walking, so an | |||
| 49 | - | // unknown row reads as a hard row of no extent rather than panicking. | |||
| 50 | - | func (b *buffer) bound(y int) LineBound { | |||
| 51 | - | if b == nil || y < 0 || y >= len(b.boundaries) { | |||
| 52 | - | return LineBound{} | |||
| 53 | - | } | |||
| 54 | - | return b.boundaries[y] | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | func (b *buffer) truncate(y, end int) { | |||
| 58 | - | if y < 0 || y >= len(b.boundaries) { | |||
| 59 | - | return | |||
| 60 | - | } | |||
| 61 | - | b.boundaries[y].End = min(b.boundaries[y].End, clamp(end, 0, b.frame.Width)) | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | // insert retains the meaningful shifted tail when insertion happens within it. | |||
| 65 | - | func (b *buffer) insert(y, at, width int) { | |||
| 66 | - | if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].End { | |||
| 67 | - | return | |||
| 68 | - | } | |||
| 69 | - | b.boundaries[y].End = min(b.boundaries[y].End+width, b.frame.Width) | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | func (b *buffer) hard(y int) { | |||
| 73 | - | if y >= 0 && y < len(b.boundaries) { | |||
| 74 | - | b.boundaries[y].Soft = false | |||
| 75 | - | } | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | func (b *buffer) soft(y int) { | |||
| 79 | - | if y >= 0 && y < len(b.boundaries) { | |||
| 80 | - | b.boundaries[y].End = b.frame.Width | |||
| 81 | - | b.boundaries[y].Soft = true | |||
| 82 | - | } | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | func (b *buffer) continueRow(y int) { | |||
| 86 | - | if y >= 0 && y < len(b.boundaries) { | |||
| 87 | - | b.boundaries[y].Soft = true | |||
| 88 | - | } | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | func (b *buffer) clear(y, x0, x1 int) { | |||
| 92 | - | if y < 0 || y >= len(b.boundaries) { | |||
| 93 | - | return | |||
| 94 | - | } | |||
| 95 | - | b.content(y, x1) | |||
| 96 | - | if x1 >= b.frame.Width { | |||
| 97 | - | // Erasing through the right edge leaves nothing flowing onto the next | |||
| 98 | - | // row: the logical line ends here. Keeping a stale soft link would let | |||
| 99 | - | // reflow merge a repainted row with the unrelated row below it. | |||
| 100 | - | b.boundaries[y].Soft = false | |||
| 101 | - | } | |||
| 102 | - | if x0 == 0 && x1 >= b.frame.Width { | |||
| 103 | - | b.boundaries[y] = LineBound{} | |||
| 104 | - | } | |||
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | func (b *buffer) scrollUp(top, bottom, n int) { | |||
| 108 | - | // A region operation changes which rows meet at both region edges. A soft | |||
| 109 | - | // boundary belongs to the row it follows, not the cells moved into its | |||
| 110 | - | // neighbor, so sever links crossing either edge before reflow can observe | |||
| 111 | - | // them. | |||
| 112 | - | b.hard(top - 1) | |||
| 113 | - | copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1]) | |||
| 114 | - | copy(b.rowIDs[top:bottom-n+1], b.rowIDs[top+n:bottom+1]) | |||
| 115 | - | for y := bottom - n + 1; y <= bottom; y++ { | |||
| 116 | - | b.boundaries[y] = LineBound{} | |||
| 117 | - | b.rowIDs[y] = 0 | |||
| 118 | - | } | |||
| 119 | - | b.hard(bottom - n) | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | func (b *buffer) scrollDown(top, bottom, n int) { | |||
| 123 | - | // See scrollUp: the old top no longer follows the row above the region, and | |||
| 124 | - | // the last moved row no longer precedes the row below it. | |||
| 125 | - | b.hard(top - 1) | |||
| 126 | - | copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1]) | |||
| 127 | - | copy(b.rowIDs[top+n:bottom+1], b.rowIDs[top:bottom-n+1]) | |||
| 128 | - | for y := top; y < top+n; y++ { | |||
| 129 | - | b.boundaries[y] = LineBound{} | |||
| 130 | - | b.rowIDs[y] = 0 | |||
| 131 | - | } | |||
| 132 | - | b.hard(bottom) | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | func (b *buffer) hydrate() { | |||
| 136 | - | for y := range b.boundaries { | |||
| 137 | - | if b.boundaries[y].End != 0 { | |||
| 138 | - | continue | |||
| 139 | - | } | |||
| 140 | - | for x := b.frame.Width - 1; x >= 0; x-- { | |||
| 141 | - | if !b.frame.At(x, y).Equal(renderer.BlankCell()) { | |||
| 142 | - | b.boundaries[y].End = x + 1 | |||
| 143 | - | break | |||
| 144 | - | } | |||
| 145 | - | } | |||
| 146 | - | } | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | - | type bufferCursor struct{ row, col int } | |||
| 150 | - | ||||
| 151 | - | func (b *buffer) hasSoft() bool { | |||
| 152 | - | for _, boundary := range b.boundaries { | |||
| 153 | - | if boundary.Soft { | |||
| 154 | - | return true | |||
| 155 | - | } | |||
| 156 | - | } | |||
| 157 | - | return false | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | // resizeFixed keeps hard physical lines independent. It is the common shell | |||
| 161 | - | // path and avoids constructing logical-line scratch state when nothing wraps. | |||
| 162 | - | func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound, []RowID) { | |||
| 163 | - | anchor := 0 | |||
| 164 | - | if active != nil { | |||
| 165 | - | anchor = active.row | |||
| 166 | - | } | |||
| 167 | - | shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0)) | |||
| 168 | - | evicted := make([][]renderer.Cell, 0, shift) | |||
| 169 | - | evictedBounds := make([]LineBound, 0, shift) | |||
| 170 | - | evictedIDs := make([]RowID, 0, shift) | |||
| 171 | - | for y := range shift { | |||
| 172 | - | evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...)) | |||
| 173 | - | // The evicted row keeps its source width, so its extent needs no clamping. | |||
| 174 | - | evictedBounds = append(evictedBounds, b.boundaries[y]) | |||
| 175 | - | evictedIDs = append(evictedIDs, b.rowIDs[y]) | |||
| 176 | - | } | |||
| 177 | - | // Fixed-line resizes can retain the boundary backing store. In particular, | |||
| 178 | - | // keep its capacity through a short viewport so the common shrink/grow | |||
| 179 | - | // sequence does not add metadata allocation to every resize epoch. | |||
| 180 | - | boundaries := b.boundaries | |||
| 181 | - | if cap(boundaries) < height { | |||
| 182 | - | boundaries = make([]LineBound, height) | |||
| 183 | - | } else { | |||
| 184 | - | boundaries = boundaries[:height] | |||
| 185 | - | } | |||
| 186 | - | next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries, rowIDs: make([]RowID, height)} | |||
| 187 | - | copied := 0 | |||
| 188 | - | for y := range height { | |||
| 189 | - | sy := y + shift | |||
| 190 | - | if sy >= b.frame.Height { | |||
| 191 | - | break | |||
| 192 | - | } | |||
| 193 | - | copy(next.frame.Row(y), b.frame.Row(sy)) | |||
| 194 | - | next.boundaries[y] = b.boundaries[sy] | |||
| 195 | - | next.rowIDs[y] = b.rowIDs[sy] | |||
| 196 | - | next.boundaries[y].End = min(next.boundaries[y].End, width) | |||
| 197 | - | repairFrameRow(next.frame, y) | |||
| 198 | - | copied++ | |||
| 199 | - | } | |||
| 200 | - | clear(next.boundaries[copied:]) | |||
| 201 | - | for _, cur := range [2]*bufferCursor{active, saved} { | |||
| 202 | - | if cur != nil { | |||
| 203 | - | cur.row = clamp(cur.row-shift, 0, height-1) | |||
| 204 | - | cur.col = clamp(cur.col, 0, width) | |||
| 205 | - | } | |||
| 206 | - | } | |||
| 207 | - | *b = next | |||
| 208 | - | return evicted, evictedBounds, evictedIDs | |||
| 209 | - | } | |||
| 210 | - | ||||
| 211 | - | type reflowPoint struct { | |||
| 212 | - | line, offset int | |||
| 213 | - | row, col int | |||
| 214 | - | } | |||
| 215 | - | ||||
| 216 | - | // cursorReflowPoints maps source cursor positions to offsets in their logical | |||
| 217 | - | // lines. There are exactly two callers (active and DECSC), so this stays flat | |||
| 218 | - | // and stack allocated rather than building a row/column lookup map. | |||
| 219 | - | func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint { | |||
| 220 | - | points := [2]reflowPoint{{line: -1}, {line: -1}} | |||
| 221 | - | cursors := [2]*bufferCursor{active, saved} | |||
| 222 | - | for start := 0; start < b.frame.Height; { | |||
| 223 | - | end := start | |||
| 224 | - | for b.boundaries[end].Soft && end+1 < b.frame.Height { | |||
| 225 | - | end++ | |||
| 226 | - | } | |||
| 227 | - | for i, cur := range cursors { | |||
| 228 | - | if cur == nil || cur.row < start || cur.row > end { | |||
| 229 | - | continue | |||
| 230 | - | } | |||
| 231 | - | offset := 0 | |||
| 232 | - | for y := start; y < cur.row; y++ { | |||
| 233 | - | offset += b.boundaries[y].End | |||
| 234 | - | } | |||
| 235 | - | points[i] = reflowPoint{ | |||
| 236 | - | line: start, | |||
| 237 | - | offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].End), | |||
| 238 | - | } | |||
| 239 | - | } | |||
| 240 | - | start = end + 1 | |||
| 241 | - | } | |||
| 242 | - | return points | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | - | // resize lays out only the current grid. It does two direct, bounded passes: | |||
| 246 | - | // the first maps both cursors and counts rows; the second writes only the | |||
| 247 | - | // retained viewport (and the rows genuinely evicted to history). It never | |||
| 248 | - | // materializes logical lines, per-cell position maps, or temporary output rows. | |||
| 249 | - | func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound, []RowID) { | |||
| 250 | - | if !b.hasSoft() { | |||
| 251 | - | return b.resizeFixed(width, height, active, saved) | |||
| 252 | - | } | |||
| 253 | - | // Only reflow needs meaningful extents; hard physical rows are copied as | |||
| 254 | - | // cells, so scanning blank rows on the shell fast path is wasted work. | |||
| 255 | - | b.hydrate() | |||
| 256 | - | for _, cur := range [2]*bufferCursor{active, saved} { | |||
| 257 | - | if cur != nil { | |||
| 258 | - | b.content(cur.row, cur.col) | |||
| 259 | - | } | |||
| 260 | - | } | |||
| 261 | - | ||||
| 262 | - | points := b.cursorReflowPoints(active, saved) | |||
| 263 | - | rows := b.layoutReflow(width, &points, nil, 0, nil, nil, nil, nil) | |||
| 264 | - | anchor := 0 | |||
| 265 | - | if active != nil { | |||
| 266 | - | anchor = points[0].row | |||
| 267 | - | } | |||
| 268 | - | shift := clamp(anchor-(height-1), 0, max(rows-height, 0)) | |||
| 269 | - | ||||
| 270 | - | next := newBuffer(width, height) | |||
| 271 | - | var evicted [][]renderer.Cell | |||
| 272 | - | var evictedCells []renderer.Cell | |||
| 273 | - | var evictedBounds []LineBound | |||
| 274 | - | var evictedIDs []RowID | |||
| 275 | - | if shift > 0 { | |||
| 276 | - | // History needs owned rows. One flat backing store replaces a temporary | |||
| 277 | - | // allocation per evicted output row. | |||
| 278 | - | evicted = make([][]renderer.Cell, shift) | |||
| 279 | - | evictedCells = make([]renderer.Cell, shift*width) | |||
| 280 | - | evictedBounds = make([]LineBound, shift) | |||
| 281 | - | evictedIDs = make([]RowID, shift) | |||
| 282 | - | for y := range evicted { | |||
| 283 | - | evicted[y] = evictedCells[y*width : (y+1)*width] | |||
| 284 | - | } | |||
| 285 | - | } | |||
| 286 | - | b.layoutReflow(width, &points, next, shift, b.rowIDs, evictedCells, evictedBounds, evictedIDs) | |||
| 287 | - | for i, cur := range [2]*bufferCursor{active, saved} { | |||
| 288 | - | if cur != nil { | |||
| 289 | - | cur.row = clamp(points[i].row-shift, 0, height-1) | |||
| 290 | - | cur.col = clamp(points[i].col, 0, width) | |||
| 291 | - | } | |||
| 292 | - | } | |||
| 293 | - | *b = *next | |||
| 294 | - | return evicted, evictedBounds, evictedIDs | |||
| 295 | - | } | |||
| 296 | - | ||||
| 297 | - | // layoutReflow maps source offsets and, when dst is non-nil, emits only rows in | |||
| 298 | - | // [shift, shift+dst.height). Rows before shift are written straight into the | |||
| 299 | - | // contiguous eviction backing store. The return is the logical output height. | |||
| 300 | - | func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, sourceIDs []RowID, evicted []renderer.Cell, evictedBounds []LineBound, evictedIDs []RowID) int { | |||
| 301 | - | row, col := 0, 0 | |||
| 302 | - | setRowID := func(outputRow int, id RowID) { | |||
| 303 | - | if id == 0 { | |||
| 304 | - | return | |||
| 305 | - | } | |||
| 306 | - | switch { | |||
| 307 | - | case outputRow < shift: | |||
| 308 | - | if outputRow < len(evictedIDs) { | |||
| 309 | - | evictedIDs[outputRow] = id | |||
| 310 | - | } | |||
| 311 | - | case dst != nil && outputRow < shift+dst.frame.Height: | |||
| 312 | - | dst.rowIDs[outputRow-shift] = id | |||
| 313 | - | } | |||
| 314 | - | } | |||
| 315 | - | blankEvicted := func(outputRow int) []renderer.Cell { | |||
| 316 | - | if outputRow < shift { | |||
| 317 | - | out := evicted[outputRow*width : (outputRow+1)*width] | |||
| 318 | - | for i := range out { | |||
| 319 | - | out[i] = renderer.BlankCell() | |||
| 320 | - | } | |||
| 321 | - | return out | |||
| 322 | - | } | |||
| 323 | - | if dst != nil && outputRow < shift+dst.frame.Height { | |||
| 324 | - | return dst.frame.Row(outputRow - shift) | |||
| 325 | - | } | |||
| 326 | - | return nil | |||
| 327 | - | } | |||
| 328 | - | var output []renderer.Cell | |||
| 329 | - | if dst != nil && (shift > 0 || dst.frame.Height > 0) { | |||
| 330 | - | output = blankEvicted(0) | |||
| 331 | - | } | |||
| 332 | - | finishRow := func(soft bool) { | |||
| 333 | - | // An output row belongs either to history or to the retained viewport, and | |||
| 334 | - | // only this pass knows where it ended, so both destinations are written here. | |||
| 335 | - | bound := LineBound{End: col, Soft: soft} | |||
| 336 | - | switch { | |||
| 337 | - | case row < shift: | |||
| 338 | - | if evictedBounds != nil { | |||
| 339 | - | evictedBounds[row] = bound | |||
| 340 | - | } | |||
| 341 | - | case dst != nil && row < shift+dst.frame.Height: | |||
| 342 | - | dst.boundaries[row-shift] = bound | |||
| 343 | - | } | |||
| 344 | - | row++ | |||
| 345 | - | col = 0 | |||
| 346 | - | if dst != nil { | |||
| 347 | - | output = blankEvicted(row) | |||
| 348 | - | } | |||
| 349 | - | } | |||
| 350 | - | setPoint := func(line, offset, pointRow, pointCol int) { | |||
| 351 | - | for i := range points { | |||
| 352 | - | if points[i].line == line && points[i].offset == offset { | |||
| 353 | - | points[i].row, points[i].col = pointRow, pointCol | |||
| 354 | - | } | |||
| 355 | - | } | |||
| 356 | - | } | |||
| 357 | - | setRemainingPoints := func(line, offset, pointRow, pointCol int) { | |||
| 358 | - | for i := range points { | |||
| 359 | - | if points[i].line == line && points[i].offset >= offset { | |||
| 360 | - | points[i].row, points[i].col = pointRow, pointCol | |||
| 361 | - | } | |||
| 362 | - | } | |||
| 363 | - | } | |||
| 364 | - | ||||
| 365 | - | for start := 0; start < b.frame.Height; { | |||
| 366 | - | end := start | |||
| 367 | - | for b.boundaries[end].Soft && end+1 < b.frame.Height { | |||
| 368 | - | end++ | |||
| 369 | - | } | |||
| 370 | - | reflow := end > start | |||
| 371 | - | offset := 0 | |||
| 372 | - | sourceID := RowID(0) | |||
| 373 | - | if start < len(sourceIDs) { | |||
| 374 | - | sourceID = sourceIDs[start] | |||
| 375 | - | } | |||
| 376 | - | // A source row that starts in a partially filled output row has already | |||
| 377 | - | // inherited that row's identity. Continuation rows created by wrapping | |||
| 378 | - | // receive fresh IDs from the owning Screen after this pass. | |||
| 379 | - | if col == 0 { | |||
| 380 | - | setRowID(row, sourceID) | |||
| 381 | - | } | |||
| 382 | - | truncated := false | |||
| 383 | - | for y := start; y <= end && !truncated; y++ { | |||
| 384 | - | cells := b.frame.Row(y) | |||
| 385 | - | limit := b.boundaries[y].End | |||
| 386 | - | for x := 0; x < limit; { | |||
| 387 | - | cell := cells[x] | |||
| 388 | - | if cell.Continuation { // Repair malformed rows by dropping orphaned tails. | |||
| 389 | - | setPoint(start, offset, row, col) | |||
| 390 | - | x++ | |||
| 391 | - | offset++ | |||
| 392 | - | continue | |||
| 393 | - | } | |||
| 394 | - | wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation | |||
| 395 | - | w := 1 | |||
| 396 | - | if wide && width >= 2 { | |||
| 397 | - | w = 2 | |||
| 398 | - | } | |||
| 399 | - | if col+w > width && col > 0 { | |||
| 400 | - | if !reflow { | |||
| 401 | - | setRemainingPoints(start, offset, row, col) | |||
| 402 | - | truncated = true | |||
| 403 | - | break | |||
| 404 | - | } | |||
| 405 | - | finishRow(true) | |||
| 406 | - | } | |||
| 407 | - | setPoint(start, offset, row, col) | |||
| 408 | - | if wide && width >= 2 { | |||
| 409 | - | if output != nil { | |||
| 410 | - | output[col], output[col+1] = cell, cells[x+1] | |||
| 411 | - | } | |||
| 412 | - | setPoint(start, offset+1, row, col+1) | |||
| 413 | - | col += 2 | |||
| 414 | - | x += 2 | |||
| 415 | - | offset += 2 | |||
| 416 | - | continue | |||
| 417 | - | } | |||
| 418 | - | if wide { | |||
| 419 | - | cell.Rune = '\uFFFD' | |||
| 420 | - | cell.Continuation = false | |||
| 421 | - | } | |||
| 422 | - | if output != nil { | |||
| 423 | - | output[col] = cell | |||
| 424 | - | } | |||
| 425 | - | col++ | |||
| 426 | - | x++ | |||
| 427 | - | offset++ | |||
| 428 | - | } | |||
| 429 | - | } | |||
| 430 | - | setPoint(start, offset, row, col) | |||
| 431 | - | finishRow(false) | |||
| 432 | - | start = end + 1 | |||
| 433 | - | } | |||
| 434 | - | return row | |||
| 435 | - | } | |||
D pkg/vt/buffer_cursor_reflow_test.go +0 -21
| @@ -1,21 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import "testing" | |||
| 4 | - | ||||
| 5 | - | func TestResizeReflowMapsActiveAndSavedCursorAtNewRowBoundary(t *testing.T) { | |||
| 6 | - | s := NewScreen(5, 3) | |||
| 7 | - | s.Write([]byte("abcdefgh")) | |||
| 8 | - | s.Row, s.Col = 0, 3 | |||
| 9 | - | s.saveCursor() | |||
| 10 | - | ||||
| 11 | - | s.Resize(3, 3) | |||
| 12 | - | ||||
| 13 | - | if s.Row != 1 || s.Col != 0 { | |||
| 14 | - | t.Fatalf("active cursor = (%d,%d), want (1,0)", s.Row, s.Col) | |||
| 15 | - | } | |||
| 16 | - | s.Row, s.Col = 0, 0 | |||
| 17 | - | s.restoreCursor() | |||
| 18 | - | if s.Row != 1 || s.Col != 0 { | |||
| 19 | - | t.Fatalf("saved cursor = (%d,%d), want (1,0)", s.Row, s.Col) | |||
| 20 | - | } | |||
| 21 | - | } | |||
D pkg/vt/damage.go +0 -12
| @@ -1,12 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | - | ||||
| 5 | - | type Damage = renderer.Damage | |||
| 6 | - | ||||
| 7 | - | const ( | |||
| 8 | - | DamageText = renderer.DamageText | |||
| 9 | - | DamageClear = renderer.DamageClear | |||
| 10 | - | DamageScrollUp = renderer.DamageScrollUp | |||
| 11 | - | DamageFullRedraw = renderer.DamageFullRedraw | |||
| 12 | - | ) | |||
D pkg/vt/doc.go +0 -13
| @@ -1,13 +0,0 @@ | |||||
| 1 | - | // Package vt implements the server-side terminal emulator core. | |||
| 2 | - | // | |||
| 3 | - | // It tracks the visible cell grid, damage rectangles, scroll regions, | |||
| 4 | - | // alternate-screen state, cursor/reporting modes, and common xterm/VT control | |||
| 5 | - | // sequences. Wide runes that fit are stored as a head cell plus a continuation | |||
| 6 | - | // cell, and resize or edit operations repair row-boundary splits instead of | |||
| 7 | - | // leaving orphaned halves. | |||
| 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. | |||
| 13 | - | package vt | |||
D pkg/vt/history.go +0 -551
| @@ -1,551 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | "math" | |||
| 6 | - | "slices" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | // ErrHistoryRowTooWide is returned when a row cannot fit within the configured | |||
| 12 | - | // cell budget. The history is not modified when this error is returned. | |||
| 13 | - | var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity") | |||
| 14 | - | ||||
| 15 | - | var errInvalidHistoryRowID = errors.New("invalid history row ID") | |||
| 16 | - | ||||
| 17 | - | // HistoryConfig controls the bounded terminal history retained by a Screen. | |||
| 18 | - | type HistoryConfig struct { | |||
| 19 | - | MaxRows int | |||
| 20 | - | MaxCells int | |||
| 21 | - | ChunkRows int | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | // History stores terminal rows in immutable chunks. It is intended to be | |||
| 25 | - | // mutated by the owner of a Screen; views are safe to retain after later | |||
| 26 | - | // appends. | |||
| 27 | - | type History struct { | |||
| 28 | - | maxRows int | |||
| 29 | - | maxCells int | |||
| 30 | - | chunkRows int | |||
| 31 | - | chunks []*HistoryChunk | |||
| 32 | - | tail [][]renderer.Cell | |||
| 33 | - | tailBounds []LineBound | |||
| 34 | - | tailIDs []RowID | |||
| 35 | - | rows int | |||
| 36 | - | cells int | |||
| 37 | - | nextRowID RowID | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | // HistoryChunk is an immutable group of history rows. Its identity is stable | |||
| 41 | - | // and can be used by consumers to avoid copying unchanged sealed chunks. | |||
| 42 | - | // bounds is parallel to rows and always has the same length. | |||
| 43 | - | type HistoryChunk struct { | |||
| 44 | - | rows [][]renderer.Cell | |||
| 45 | - | bounds []LineBound | |||
| 46 | - | rowIDs []RowID | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | // HistoryView is an immutable snapshot of history. Row returns a copy so the | |||
| 50 | - | // storage behind a sealed chunk remains owned by VT. | |||
| 51 | - | type HistoryView struct { | |||
| 52 | - | chunks []*HistoryChunk | |||
| 53 | - | rows int | |||
| 54 | - | cells int | |||
| 55 | - | nextRowID RowID | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | // HistorySnapshotView captures sealed history chunks and the mutable tail | |||
| 59 | - | // independently. Sealed chunks are shared by identity; Tail is owned by the | |||
| 60 | - | // view and can be serialized without rotating the live tail into a chunk. | |||
| 61 | - | type HistorySnapshotView struct { | |||
| 62 | - | chunks []*HistoryChunk | |||
| 63 | - | tail [][]renderer.Cell | |||
| 64 | - | tailBounds []LineBound | |||
| 65 | - | tailIDs []RowID | |||
| 66 | - | rows int | |||
| 67 | - | cells int | |||
| 68 | - | nextRowID RowID | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | func NewHistory(config HistoryConfig) *History { | |||
| 72 | - | if config.MaxRows <= 0 { | |||
| 73 | - | return &History{} | |||
| 74 | - | } | |||
| 75 | - | chunkRows := config.ChunkRows | |||
| 76 | - | if chunkRows <= 0 { | |||
| 77 | - | chunkRows = 256 | |||
| 78 | - | } | |||
| 79 | - | chunkRows = min(chunkRows, 256) | |||
| 80 | - | chunkRows = min(chunkRows, config.MaxRows) | |||
| 81 | - | maxCells := config.MaxCells | |||
| 82 | - | if maxCells <= 0 { | |||
| 83 | - | maxCells = defaultHistoryMaxCells(config.MaxRows) | |||
| 84 | - | } | |||
| 85 | - | return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows, nextRowID: 1} | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | func defaultHistoryMaxCells(maxRows int) int { | |||
| 89 | - | if maxRows > math.MaxInt/160 { | |||
| 90 | - | return math.MaxInt | |||
| 91 | - | } | |||
| 92 | - | return maxRows * 160 | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | // Append records a copy of row along with its logical extent and an | |||
| 96 | - | // automatically allocated nonzero ID. Once a chunk is full it is sealed | |||
| 97 | - | // forever. Rows wider than the total cell capacity are rejected without | |||
| 98 | - | // mutation. | |||
| 99 | - | func (h *History) Append(row []renderer.Cell, bound LineBound) error { | |||
| 100 | - | if h == nil || h.maxRows == 0 { | |||
| 101 | - | return nil | |||
| 102 | - | } | |||
| 103 | - | if len(row) > h.maxCells { | |||
| 104 | - | return ErrHistoryRowTooWide | |||
| 105 | - | } | |||
| 106 | - | id, err := h.allocateRowID() | |||
| 107 | - | if err != nil { | |||
| 108 | - | return err | |||
| 109 | - | } | |||
| 110 | - | return h.appendRow(row, bound, id) | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | // AppendWithID records a copy of row with an explicit persisted identity. | |||
| 114 | - | // Unlike Append, malformed identities are rejected rather than synthesized. | |||
| 115 | - | func (h *History) AppendWithID(row []renderer.Cell, bound LineBound, id RowID) error { | |||
| 116 | - | if id == 0 || id >= ^RowID(0)-1 { | |||
| 117 | - | return errInvalidHistoryRowID | |||
| 118 | - | } | |||
| 119 | - | if h == nil || h.maxRows == 0 { | |||
| 120 | - | return nil | |||
| 121 | - | } | |||
| 122 | - | if len(row) > h.maxCells { | |||
| 123 | - | return ErrHistoryRowTooWide | |||
| 124 | - | } | |||
| 125 | - | if h.hasRowID(id) { | |||
| 126 | - | return errInvalidHistoryRowID | |||
| 127 | - | } | |||
| 128 | - | return h.appendRow(row, bound, id) | |||
| 129 | - | } | |||
| 130 | - | ||||
| 131 | - | func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) error { | |||
| 132 | - | if h.nextRowID == ^RowID(0) { | |||
| 133 | - | return errInvalidHistoryRowID | |||
| 134 | - | } | |||
| 135 | - | if len(row) > h.maxCells { | |||
| 136 | - | return ErrHistoryRowTooWide | |||
| 137 | - | } | |||
| 138 | - | if id >= ^RowID(0)-1 { | |||
| 139 | - | return errInvalidHistoryRowID | |||
| 140 | - | } | |||
| 141 | - | if id >= h.nextRowID { | |||
| 142 | - | h.nextRowID = id + 1 | |||
| 143 | - | } | |||
| 144 | - | // Make room before adding so Cells cannot overflow even when the default | |||
| 145 | - | // capacity is MaxInt. | |||
| 146 | - | h.evictFor(len(row)) | |||
| 147 | - | h.tail = append(h.tail, append([]renderer.Cell(nil), row...)) | |||
| 148 | - | h.tailBounds = append(h.tailBounds, clampBound(bound, len(row))) | |||
| 149 | - | h.tailIDs = append(h.tailIDs, id) | |||
| 150 | - | h.rows++ | |||
| 151 | - | h.cells += len(row) | |||
| 152 | - | if len(h.tail) == h.chunkRows { | |||
| 153 | - | h.sealTail() | |||
| 154 | - | } | |||
| 155 | - | return nil | |||
| 156 | - | } | |||
| 157 | - | ||||
| 158 | - | // allocateRowID relies on appendRow advancing nextRowID above every accepted | |||
| 159 | - | // explicit ID; automatic IDs therefore never need to scan retained rows. | |||
| 160 | - | func (h *History) allocateRowID() (RowID, error) { | |||
| 161 | - | if h.nextRowID == 0 { | |||
| 162 | - | h.nextRowID = 1 | |||
| 163 | - | } | |||
| 164 | - | if h.nextRowID >= ^RowID(0)-1 { | |||
| 165 | - | return 0, errInvalidHistoryRowID | |||
| 166 | - | } | |||
| 167 | - | id := h.nextRowID | |||
| 168 | - | h.nextRowID++ | |||
| 169 | - | return id, nil | |||
| 170 | - | } | |||
| 171 | - | ||||
| 172 | - | func (h *History) hasRowID(id RowID) bool { | |||
| 173 | - | for _, chunk := range h.chunks { | |||
| 174 | - | if slices.Contains(chunk.rowIDs, id) { | |||
| 175 | - | return true | |||
| 176 | - | } | |||
| 177 | - | } | |||
| 178 | - | return slices.Contains(h.tailIDs, id) | |||
| 179 | - | } | |||
| 180 | - | ||||
| 181 | - | // clampBound keeps End inside the row it describes so a persisted bound can be | |||
| 182 | - | // validated without consulting its row again. | |||
| 183 | - | func clampBound(b LineBound, cells int) LineBound { | |||
| 184 | - | b.End = min(max(b.End, 0), cells) | |||
| 185 | - | return b | |||
| 186 | - | } | |||
| 187 | - | ||||
| 188 | - | // growBounds returns bounds sized to rows, padding with zero values. It keeps | |||
| 189 | - | // the parallel-slice invariant when a chunk arrives with fewer bounds than rows. | |||
| 190 | - | func growBounds(bounds []LineBound, rows int) []LineBound { | |||
| 191 | - | if len(bounds) == rows { | |||
| 192 | - | return bounds | |||
| 193 | - | } | |||
| 194 | - | out := make([]LineBound, rows) | |||
| 195 | - | copy(out, bounds) | |||
| 196 | - | return out | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | - | func growRowIDs(ids []RowID, rows int) []RowID { | |||
| 200 | - | if len(ids) == rows { | |||
| 201 | - | return ids | |||
| 202 | - | } | |||
| 203 | - | out := make([]RowID, rows) | |||
| 204 | - | copy(out, ids) | |||
| 205 | - | return out | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | func cloneRowIDs(ids []RowID, rows int) []RowID { | |||
| 209 | - | out := make([]RowID, rows) | |||
| 210 | - | copy(out, ids) | |||
| 211 | - | return out | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | func (h *History) sealTail() { | |||
| 215 | - | if len(h.tail) == 0 { | |||
| 216 | - | return | |||
| 217 | - | } | |||
| 218 | - | h.chunks = append(h.chunks, &HistoryChunk{ | |||
| 219 | - | rows: h.tail, | |||
| 220 | - | bounds: growBounds(h.tailBounds, len(h.tail)), | |||
| 221 | - | rowIDs: h.tailIDs, | |||
| 222 | - | }) | |||
| 223 | - | h.tail = nil | |||
| 224 | - | h.tailBounds = nil | |||
| 225 | - | h.tailIDs = nil | |||
| 226 | - | } | |||
| 227 | - | ||||
| 228 | - | // normalizeTail seals complete chunks so the mutable tail remains shorter than | |||
| 229 | - | // chunkRows. Restored snapshots may have been written with a different chunk | |||
| 230 | - | // size than the current history configuration. | |||
| 231 | - | func (h *History) normalizeTail() { | |||
| 232 | - | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)) | |||
| 233 | - | for h.chunkRows > 0 && len(h.tail) >= h.chunkRows { | |||
| 234 | - | bounds := growBounds(h.tailBounds, len(h.tail)) | |||
| 235 | - | h.chunks = append(h.chunks, &HistoryChunk{ | |||
| 236 | - | rows: h.tail[:h.chunkRows], | |||
| 237 | - | bounds: bounds[:h.chunkRows], | |||
| 238 | - | rowIDs: h.tailIDs[:h.chunkRows], | |||
| 239 | - | }) | |||
| 240 | - | h.tail = h.tail[h.chunkRows:] | |||
| 241 | - | h.tailBounds = bounds[h.chunkRows:] | |||
| 242 | - | h.tailIDs = h.tailIDs[h.chunkRows:] | |||
| 243 | - | } | |||
| 244 | - | } | |||
| 245 | - | ||||
| 246 | - | func (h *History) evict() { | |||
| 247 | - | h.evictUntil(0, 0) | |||
| 248 | - | } | |||
| 249 | - | ||||
| 250 | - | // evictFor discards oldest rows until another row using cellCount cells can fit. | |||
| 251 | - | func (h *History) evictFor(cellCount int) { | |||
| 252 | - | h.evictUntil(1, cellCount) | |||
| 253 | - | } | |||
| 254 | - | ||||
| 255 | - | // evictUntil makes room for rowCount rows and cellCount cells without | |||
| 256 | - | // overflowing either accounting total. | |||
| 257 | - | func (h *History) evictUntil(rowCount, cellCount int) { | |||
| 258 | - | for h.rows > h.maxRows-rowCount || h.cells > h.maxCells-cellCount { | |||
| 259 | - | if len(h.chunks) > 0 { | |||
| 260 | - | chunk := h.chunks[0] | |||
| 261 | - | row := chunk.rows[0] | |||
| 262 | - | h.rows-- | |||
| 263 | - | h.cells -= len(row) | |||
| 264 | - | if len(chunk.rows) == 1 { | |||
| 265 | - | copy(h.chunks, h.chunks[1:]) | |||
| 266 | - | h.chunks[len(h.chunks)-1] = nil | |||
| 267 | - | h.chunks = h.chunks[:len(h.chunks)-1] | |||
| 268 | - | } else { | |||
| 269 | - | // Preserve cell storage while replacing only the chunk wrapper: a | |||
| 270 | - | // retained view may still refer to the original immutable chunk. | |||
| 271 | - | bounds := growBounds(chunk.bounds, len(chunk.rows)) | |||
| 272 | - | rowIDs := growRowIDs(chunk.rowIDs, len(chunk.rows)) | |||
| 273 | - | h.chunks[0] = &HistoryChunk{ | |||
| 274 | - | rows: chunk.rows[1:], | |||
| 275 | - | bounds: bounds[1:], | |||
| 276 | - | rowIDs: rowIDs[1:], | |||
| 277 | - | } | |||
| 278 | - | } | |||
| 279 | - | continue | |||
| 280 | - | } | |||
| 281 | - | if len(h.tail) == 0 { | |||
| 282 | - | return | |||
| 283 | - | } | |||
| 284 | - | row := h.tail[0] | |||
| 285 | - | h.rows-- | |||
| 286 | - | h.cells -= len(row) | |||
| 287 | - | h.tail[0] = nil | |||
| 288 | - | h.tail = h.tail[1:] | |||
| 289 | - | h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:] | |||
| 290 | - | h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:] | |||
| 291 | - | } | |||
| 292 | - | } | |||
| 293 | - | ||||
| 294 | - | // SealAndView rotates the mutable tail into an immutable chunk, then captures | |||
| 295 | - | // the sealed chunks by identity. Callers must synchronize access to History. | |||
| 296 | - | func (h *History) SealAndView() HistoryView { | |||
| 297 | - | if h != nil { | |||
| 298 | - | h.sealTail() | |||
| 299 | - | } | |||
| 300 | - | return h.View() | |||
| 301 | - | } | |||
| 302 | - | ||||
| 303 | - | // View captures the current history. Sealed chunks are shared by identity; a | |||
| 304 | - | // partially-filled tail is copied into a new immutable chunk for this view. | |||
| 305 | - | func (h *History) View() HistoryView { | |||
| 306 | - | if h == nil { | |||
| 307 | - | return HistoryView{} | |||
| 308 | - | } | |||
| 309 | - | if h.rows == 0 { | |||
| 310 | - | return HistoryView{nextRowID: h.nextRowID} | |||
| 311 | - | } | |||
| 312 | - | chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1) | |||
| 313 | - | copy(chunks, h.chunks) | |||
| 314 | - | if len(h.tail) > 0 { | |||
| 315 | - | chunks = append(chunks, &HistoryChunk{ | |||
| 316 | - | rows: cloneHistoryRows(h.tail), | |||
| 317 | - | bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |||
| 318 | - | rowIDs: cloneRowIDs(h.tailIDs, len(h.tail)), | |||
| 319 | - | }) | |||
| 320 | - | } | |||
| 321 | - | return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID} | |||
| 322 | - | } | |||
| 323 | - | ||||
| 324 | - | // SnapshotView captures history for persistence without sealing the mutable | |||
| 325 | - | // tail. Sealed chunks are shared by identity and the tail is deeply copied. | |||
| 326 | - | func (h *History) SnapshotView() HistorySnapshotView { | |||
| 327 | - | if h == nil { | |||
| 328 | - | return HistorySnapshotView{} | |||
| 329 | - | } | |||
| 330 | - | if h.rows == 0 { | |||
| 331 | - | return HistorySnapshotView{nextRowID: h.nextRowID} | |||
| 332 | - | } | |||
| 333 | - | return HistorySnapshotView{ | |||
| 334 | - | chunks: append([]*HistoryChunk(nil), h.chunks...), | |||
| 335 | - | tail: cloneHistoryRows(h.tail), | |||
| 336 | - | tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...), | |||
| 337 | - | tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)), | |||
| 338 | - | rows: h.rows, | |||
| 339 | - | cells: h.cells, | |||
| 340 | - | nextRowID: h.nextRowID, | |||
| 341 | - | } | |||
| 342 | - | } | |||
| 343 | - | ||||
| 344 | - | func cloneHistoryRows(rows [][]renderer.Cell) [][]renderer.Cell { | |||
| 345 | - | if len(rows) == 0 { | |||
| 346 | - | return nil | |||
| 347 | - | } | |||
| 348 | - | copyRows := make([][]renderer.Cell, len(rows)) | |||
| 349 | - | for i, row := range rows { | |||
| 350 | - | copyRows[i] = append([]renderer.Cell(nil), row...) | |||
| 351 | - | } | |||
| 352 | - | return copyRows | |||
| 353 | - | } | |||
| 354 | - | ||||
| 355 | - | // Len returns the currently retained history row count. | |||
| 356 | - | func (h *History) Len() int { | |||
| 357 | - | if h == nil { | |||
| 358 | - | return 0 | |||
| 359 | - | } | |||
| 360 | - | return h.rows | |||
| 361 | - | } | |||
| 362 | - | ||||
| 363 | - | // Cells returns the currently retained history cell count. | |||
| 364 | - | func (h *History) Cells() int { | |||
| 365 | - | if h == nil { | |||
| 366 | - | return 0 | |||
| 367 | - | } | |||
| 368 | - | return h.cells | |||
| 369 | - | } | |||
| 370 | - | ||||
| 371 | - | // NextRowID returns the next identity allocated by this history. | |||
| 372 | - | func (h *History) NextRowID() RowID { | |||
| 373 | - | if h == nil || h.nextRowID == 0 { | |||
| 374 | - | return 1 | |||
| 375 | - | } | |||
| 376 | - | return h.nextRowID | |||
| 377 | - | } | |||
| 378 | - | ||||
| 379 | - | // Cap returns the configured bounded row capacity. | |||
| 380 | - | func (h *History) Cap() int { | |||
| 381 | - | if h == nil { | |||
| 382 | - | return 0 | |||
| 383 | - | } | |||
| 384 | - | return h.maxRows | |||
| 385 | - | } | |||
| 386 | - | ||||
| 387 | - | // CellCap returns the configured bounded cell capacity. | |||
| 388 | - | func (h *History) CellCap() int { | |||
| 389 | - | if h == nil { | |||
| 390 | - | return 0 | |||
| 391 | - | } | |||
| 392 | - | return h.maxCells | |||
| 393 | - | } | |||
| 394 | - | ||||
| 395 | - | func (v HistoryView) Len() int { return v.rows } | |||
| 396 | - | func (v HistoryView) Cells() int { return v.cells } | |||
| 397 | - | func (v HistoryView) ChunkCount() int { return len(v.chunks) } | |||
| 398 | - | ||||
| 399 | - | // NextRowID returns the next identity recorded by this view. | |||
| 400 | - | func (v HistoryView) NextRowID() RowID { | |||
| 401 | - | if v.nextRowID == 0 { | |||
| 402 | - | next, ok := historyViewNextRowID(v) | |||
| 403 | - | if !ok { | |||
| 404 | - | return 1 | |||
| 405 | - | } | |||
| 406 | - | return next | |||
| 407 | - | } | |||
| 408 | - | return v.nextRowID | |||
| 409 | - | } | |||
| 410 | - | ||||
| 411 | - | // Chunk returns the immutable chunk at i, or nil when i is out of range. | |||
| 412 | - | func (v HistoryView) Chunk(i int) *HistoryChunk { | |||
| 413 | - | if i < 0 || i >= len(v.chunks) { | |||
| 414 | - | return nil | |||
| 415 | - | } | |||
| 416 | - | return v.chunks[i] | |||
| 417 | - | } | |||
| 418 | - | ||||
| 419 | - | // Range calls yield for each row in oldest-first order. The row is borrowed | |||
| 420 | - | // immutable storage: yield must not mutate or retain it after returning. | |||
| 421 | - | func (v HistoryView) Range(yield func([]renderer.Cell) bool) { | |||
| 422 | - | for _, chunk := range v.chunks { | |||
| 423 | - | for _, row := range chunk.rows { | |||
| 424 | - | if !yield(row) { | |||
| 425 | - | return | |||
| 426 | - | } | |||
| 427 | - | } | |||
| 428 | - | } | |||
| 429 | - | } | |||
| 430 | - | ||||
| 431 | - | // Row returns a copy of the row at i, or nil when i is out of range. | |||
| 432 | - | func (v HistoryView) Row(i int) []renderer.Cell { | |||
| 433 | - | return append([]renderer.Cell(nil), v.BorrowedRow(i)...) | |||
| 434 | - | } | |||
| 435 | - | ||||
| 436 | - | // BorrowedRow returns immutable storage for the row at i, or nil when i is out | |||
| 437 | - | // of range. The caller must not mutate or retain the result after it no longer | |||
| 438 | - | // retains v. Consumers that need ownership should use Row instead. | |||
| 439 | - | func (v HistoryView) BorrowedRow(i int) []renderer.Cell { | |||
| 440 | - | if i < 0 { | |||
| 441 | - | return nil | |||
| 442 | - | } | |||
| 443 | - | for _, chunk := range v.chunks { | |||
| 444 | - | if i < len(chunk.rows) { | |||
| 445 | - | return chunk.rows[i] | |||
| 446 | - | } | |||
| 447 | - | i -= len(chunk.rows) | |||
| 448 | - | } | |||
| 449 | - | return nil | |||
| 450 | - | } | |||
| 451 | - | ||||
| 452 | - | // RowID returns the identity of the row at i, or zero when i is out of range. | |||
| 453 | - | func (v HistoryView) RowID(i int) RowID { | |||
| 454 | - | if i < 0 { | |||
| 455 | - | return 0 | |||
| 456 | - | } | |||
| 457 | - | for _, chunk := range v.chunks { | |||
| 458 | - | if i < len(chunk.rows) { | |||
| 459 | - | if i < len(chunk.rowIDs) { | |||
| 460 | - | return chunk.rowIDs[i] | |||
| 461 | - | } | |||
| 462 | - | return 0 | |||
| 463 | - | } | |||
| 464 | - | i -= len(chunk.rows) | |||
| 465 | - | } | |||
| 466 | - | return 0 | |||
| 467 | - | } | |||
| 468 | - | ||||
| 469 | - | // FindRowID returns the oldest-first index of id, or -1 when id is absent. | |||
| 470 | - | func (v HistoryView) FindRowID(id RowID) int { | |||
| 471 | - | if id == 0 { | |||
| 472 | - | return -1 | |||
| 473 | - | } | |||
| 474 | - | index := 0 | |||
| 475 | - | for _, chunk := range v.chunks { | |||
| 476 | - | for row := range chunk.rows { | |||
| 477 | - | if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id { | |||
| 478 | - | return index | |||
| 479 | - | } | |||
| 480 | - | index++ | |||
| 481 | - | } | |||
| 482 | - | } | |||
| 483 | - | return -1 | |||
| 484 | - | } | |||
| 485 | - | ||||
| 486 | - | // RowID returns the identity of the row within an immutable chunk. | |||
| 487 | - | func (c *HistoryChunk) RowID(i int) RowID { | |||
| 488 | - | if c == nil || i < 0 || i >= len(c.rowIDs) { | |||
| 489 | - | return 0 | |||
| 490 | - | } | |||
| 491 | - | return c.rowIDs[i] | |||
| 492 | - | } | |||
| 493 | - | ||||
| 494 | - | // Bound returns the logical extent of the row at i, or the zero value when i is | |||
| 495 | - | // out of range. A zero value describes a hard row whose End is not meaningful. | |||
| 496 | - | func (v HistoryView) Bound(i int) LineBound { | |||
| 497 | - | if i < 0 { | |||
| 498 | - | return LineBound{} | |||
| 499 | - | } | |||
| 500 | - | for _, chunk := range v.chunks { | |||
| 501 | - | if i < len(chunk.rows) { | |||
| 502 | - | if i < len(chunk.bounds) { | |||
| 503 | - | return chunk.bounds[i] | |||
| 504 | - | } | |||
| 505 | - | return LineBound{} | |||
| 506 | - | } | |||
| 507 | - | i -= len(chunk.rows) | |||
| 508 | - | } | |||
| 509 | - | return LineBound{} | |||
| 510 | - | } | |||
| 511 | - | ||||
| 512 | - | func (v HistorySnapshotView) Len() int { return v.rows } | |||
| 513 | - | func (v HistorySnapshotView) Cells() int { return v.cells } | |||
| 514 | - | func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) } | |||
| 515 | - | ||||
| 516 | - | // NextRowID returns the next identity recorded by this snapshot. | |||
| 517 | - | func (v HistorySnapshotView) NextRowID() RowID { | |||
| 518 | - | if v.nextRowID == 0 { | |||
| 519 | - | return v.Tail().NextRowID() | |||
| 520 | - | } | |||
| 521 | - | return v.nextRowID | |||
| 522 | - | } | |||
| 523 | - | ||||
| 524 | - | // Chunk returns a sealed immutable chunk at i, or nil when i is out of range. | |||
| 525 | - | func (v HistorySnapshotView) Chunk(i int) *HistoryChunk { | |||
| 526 | - | if i < 0 || i >= len(v.chunks) { | |||
| 527 | - | return nil | |||
| 528 | - | } | |||
| 529 | - | return v.chunks[i] | |||
| 530 | - | } | |||
| 531 | - | ||||
| 532 | - | // Tail returns an immutable view of the copied mutable tail. | |||
| 533 | - | func (v HistorySnapshotView) Tail() HistoryView { | |||
| 534 | - | if len(v.tail) == 0 { | |||
| 535 | - | return HistoryView{nextRowID: v.nextRowID} | |||
| 536 | - | } | |||
| 537 | - | return HistoryView{ | |||
| 538 | - | chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}}, | |||
| 539 | - | rows: len(v.tail), | |||
| 540 | - | cells: historyRowsCells(v.tail), | |||
| 541 | - | nextRowID: v.nextRowID, | |||
| 542 | - | } | |||
| 543 | - | } | |||
| 544 | - | ||||
| 545 | - | func historyRowsCells(rows [][]renderer.Cell) int { | |||
| 546 | - | cells := 0 | |||
| 547 | - | for _, row := range rows { | |||
| 548 | - | cells += len(row) | |||
| 549 | - | } | |||
| 550 | - | return cells | |||
| 551 | - | } | |||
D pkg/vt/history_acceptance_test.go +0 -106
| @@ -1,106 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func TestSnapshotCodecsPreserveEveryTerminalCellAcrossHistoryAndRecoveryTranscript(t *testing.T) { | |||
| 10 | - | indexed := renderer.Style{ | |||
| 11 | - | Bold: true, | |||
| 12 | - | Italic: true, | |||
| 13 | - | Inverse: true, | |||
| 14 | - | Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough, | |||
| 15 | - | Foreground: 196, | |||
| 16 | - | Background: 17, | |||
| 17 | - | HasUnderlineColor: true, | |||
| 18 | - | UnderlineColor: 203, | |||
| 19 | - | } | |||
| 20 | - | rgb := renderer.Style{ | |||
| 21 | - | Bold: true, | |||
| 22 | - | Italic: true, | |||
| 23 | - | Inverse: true, | |||
| 24 | - | Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough, | |||
| 25 | - | HasForegroundRGB: true, | |||
| 26 | - | ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 27 | - | HasBackgroundRGB: true, | |||
| 28 | - | BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 29 | - | HasUnderlineColorRGB: true, | |||
| 30 | - | UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9}, | |||
| 31 | - | } | |||
| 32 | - | underlines := []renderer.UnderlineStyle{ | |||
| 33 | - | renderer.UnderlineNone, | |||
| 34 | - | renderer.UnderlineSingle, | |||
| 35 | - | renderer.UnderlineDouble, | |||
| 36 | - | renderer.UnderlineCurly, | |||
| 37 | - | renderer.UnderlineDotted, | |||
| 38 | - | renderer.UnderlineDashed, | |||
| 39 | - | } | |||
| 40 | - | makeRow := func(r rune, style renderer.Style) []renderer.Cell { | |||
| 41 | - | style.UnderlineStyle = underlines[int(r)%len(underlines)] | |||
| 42 | - | return []renderer.Cell{ | |||
| 43 | - | {Rune: r, Style: style}, | |||
| 44 | - | {Rune: ' ', Style: renderer.DefaultStyle()}, | |||
| 45 | - | {Rune: '界', Style: style}, | |||
| 46 | - | {Continuation: true, Style: style}, | |||
| 47 | - | } | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | tests := []struct { | |||
| 51 | - | name string | |||
| 52 | - | run func(*testing.T) | |||
| 53 | - | }{ | |||
| 54 | - | { | |||
| 55 | - | name: "history view retains sealed chunks and mutable tail", | |||
| 56 | - | run: func(t *testing.T) { | |||
| 57 | - | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 58 | - | // A through F exercise every underline style; G remains in the | |||
| 59 | - | // mutable tail so both history ownership paths are encoded. | |||
| 60 | - | want := [][]renderer.Cell{ | |||
| 61 | - | makeRow('A', indexed), makeRow('B', rgb), makeRow('C', indexed), | |||
| 62 | - | makeRow('D', rgb), makeRow('E', indexed), makeRow('F', rgb), makeRow('G', indexed), | |||
| 63 | - | } | |||
| 64 | - | for _, row := range want { | |||
| 65 | - | requireHistoryAppend(t, history, row) | |||
| 66 | - | } | |||
| 67 | - | if got, wantChunks := history.View().ChunkCount(), 4; got != wantChunks { | |||
| 68 | - | t.Fatalf("chunk count = %d, want three sealed chunks plus copied tail %d", got, wantChunks) | |||
| 69 | - | } | |||
| 70 | - | encoded, err := MarshalHistory(history.View()) | |||
| 71 | - | if err != nil { | |||
| 72 | - | t.Fatalf("MarshalHistory: %v", err) | |||
| 73 | - | } | |||
| 74 | - | got, err := UnmarshalHistory(encoded) | |||
| 75 | - | if err != nil { | |||
| 76 | - | t.Fatalf("UnmarshalHistory: %v", err) | |||
| 77 | - | } | |||
| 78 | - | assertHistoryRowsEqual(t, got, want) | |||
| 79 | - | }, | |||
| 80 | - | }, | |||
| 81 | - | { | |||
| 82 | - | name: "recovery transcript retains blanks wide heads continuations and styles", | |||
| 83 | - | run: func(t *testing.T) { | |||
| 84 | - | screen := NewScreen(4, 2) | |||
| 85 | - | want := [][]renderer.Cell{makeRow('D', rgb), makeRow('E', indexed)} | |||
| 86 | - | for y, row := range want { | |||
| 87 | - | copy(screen.Frame.Row(y), row) | |||
| 88 | - | screen.buffer.boundaries[y] = LineBound{End: len(row)} | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | encoded, err := screen.RecoveryTranscriptSnapshot().Marshal() | |||
| 92 | - | if err != nil { | |||
| 93 | - | t.Fatalf("marshal recovery transcript: %v", err) | |||
| 94 | - | } | |||
| 95 | - | got, err := UnmarshalHistory(encoded) | |||
| 96 | - | if err != nil { | |||
| 97 | - | t.Fatalf("unmarshal recovery transcript: %v", err) | |||
| 98 | - | } | |||
| 99 | - | assertHistoryRowsEqual(t, got, want) | |||
| 100 | - | }, | |||
| 101 | - | }, | |||
| 102 | - | } | |||
| 103 | - | for _, tt := range tests { | |||
| 104 | - | t.Run(tt.name, tt.run) | |||
| 105 | - | } | |||
| 106 | - | } | |||
D pkg/vt/history_codec.go +0 -447
| @@ -1,447 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "encoding/binary" | |||
| 5 | - | "errors" | |||
| 6 | - | "fmt" | |||
| 7 | - | "math" | |||
| 8 | - | "slices" | |||
| 9 | - | "unicode/utf8" | |||
| 10 | - | ||||
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | const ( | |||
| 15 | - | historyMagic = "VTH1" | |||
| 16 | - | historyVersion = 3 | |||
| 17 | - | historyCellBytes = 41 | |||
| 18 | - | historyBoundBytes = 5 | |||
| 19 | - | maxHistoryChunkRows = 256 | |||
| 20 | - | ||||
| 21 | - | // The daemon retains 10,000 history rows. Support a 20% row margin and | |||
| 22 | - | // 160-cell rows, which leaves headroom above the representative 10k×120 | |||
| 23 | - | // terminal while bounding snapshot allocation. | |||
| 24 | - | maxHistoryChunks = 12_000 | |||
| 25 | - | maxHistoryRows = 12_000 | |||
| 26 | - | maxHistoryRowCells = 160 | |||
| 27 | - | maxHistoryCells = maxHistoryRows * maxHistoryRowCells | |||
| 28 | - | ||||
| 29 | - | maxHistoryDecodeStyles = maxHistoryCells | |||
| 30 | - | maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes | |||
| 31 | - | ) | |||
| 32 | - | ||||
| 33 | - | var errInvalidHistory = errors.New("invalid history payload") | |||
| 34 | - | ||||
| 35 | - | // MarshalHistory encodes a HistoryView in a deterministic, self-contained | |||
| 36 | - | // format. It preserves chunk boundaries as well as every Cell and Style field. | |||
| 37 | - | func MarshalHistory(view HistoryView) ([]byte, error) { | |||
| 38 | - | if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks { | |||
| 39 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 40 | - | } | |||
| 41 | - | nextRowID, ok := historyViewNextRowID(view) | |||
| 42 | - | if !ok { | |||
| 43 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 44 | - | } | |||
| 45 | - | stats := historyDecodeStats{} | |||
| 46 | - | seen := make(map[RowID]struct{}, view.rows) | |||
| 47 | - | for _, chunk := range view.chunks { | |||
| 48 | - | if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) > maxHistoryChunkRows || | |||
| 49 | - | !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) { | |||
| 50 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 51 | - | } | |||
| 52 | - | for i, row := range chunk.rows { | |||
| 53 | - | if uint64(len(row)) > math.MaxUint32 { | |||
| 54 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 55 | - | } | |||
| 56 | - | id := chunk.rowIDs[i] | |||
| 57 | - | if id == 0 || id >= ^RowID(0)-1 { | |||
| 58 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 59 | - | } | |||
| 60 | - | if _, duplicate := seen[id]; duplicate { | |||
| 61 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 62 | - | } | |||
| 63 | - | seen[id] = struct{}{} | |||
| 64 | - | cellCount := uint64(len(row)) | |||
| 65 | - | cellBytes, ok := historyCellByteCount(cellCount) | |||
| 66 | - | if !ok || | |||
| 67 | - | !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) || | |||
| 68 | - | !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) || | |||
| 69 | - | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) { | |||
| 70 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 71 | - | } | |||
| 72 | - | bound := chunkBound(chunk, i) | |||
| 73 | - | if !validHistoryBound(bound, len(row)) { | |||
| 74 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 75 | - | } | |||
| 76 | - | for _, cell := range row { | |||
| 77 | - | if !validHistoryCell(cell) { | |||
| 78 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 79 | - | } | |||
| 80 | - | } | |||
| 81 | - | } | |||
| 82 | - | } | |||
| 83 | - | if nextRowID <= maxHistoryRowID(seen) { | |||
| 84 | - | return nil, fmt.Errorf("marshal history: %w", errInvalidHistory) | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | out := make([]byte, 0, 17) | |||
| 88 | - | out = append(out, historyMagic...) | |||
| 89 | - | out = append(out, historyVersion) | |||
| 90 | - | out = appendUint32(out, uint32(len(view.chunks))) | |||
| 91 | - | out = binary.BigEndian.AppendUint64(out, uint64(nextRowID)) | |||
| 92 | - | for _, chunk := range view.chunks { | |||
| 93 | - | out = appendUint32(out, uint32(len(chunk.rows))) | |||
| 94 | - | for i, row := range chunk.rows { | |||
| 95 | - | out = binary.BigEndian.AppendUint64(out, uint64(chunk.rowIDs[i])) | |||
| 96 | - | out = appendUint32(out, uint32(len(row))) | |||
| 97 | - | for _, cell := range row { | |||
| 98 | - | out = appendHistoryCell(out, cell) | |||
| 99 | - | } | |||
| 100 | - | out = appendHistoryBound(out, chunkBound(chunk, i)) | |||
| 101 | - | } | |||
| 102 | - | } | |||
| 103 | - | return out, nil | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | func historyViewNextRowID(view HistoryView) (RowID, bool) { | |||
| 107 | - | seen := make(map[RowID]struct{}, view.rows) | |||
| 108 | - | for _, chunk := range view.chunks { | |||
| 109 | - | if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) { | |||
| 110 | - | return 0, false | |||
| 111 | - | } | |||
| 112 | - | for _, id := range chunk.rowIDs { | |||
| 113 | - | if id == 0 || id >= ^RowID(0)-1 { | |||
| 114 | - | return 0, false | |||
| 115 | - | } | |||
| 116 | - | if _, duplicate := seen[id]; duplicate { | |||
| 117 | - | return 0, false | |||
| 118 | - | } | |||
| 119 | - | seen[id] = struct{}{} | |||
| 120 | - | } | |||
| 121 | - | } | |||
| 122 | - | next := view.nextRowID | |||
| 123 | - | if next == 0 { | |||
| 124 | - | maxID := maxHistoryRowID(seen) | |||
| 125 | - | if maxID == ^RowID(0) { | |||
| 126 | - | return 0, false | |||
| 127 | - | } | |||
| 128 | - | next = maxID + 1 | |||
| 129 | - | } | |||
| 130 | - | if next == 0 || next == ^RowID(0) || next <= maxHistoryRowID(seen) { | |||
| 131 | - | return 0, false | |||
| 132 | - | } | |||
| 133 | - | return next, true | |||
| 134 | - | } | |||
| 135 | - | ||||
| 136 | - | func maxHistoryRowID(ids map[RowID]struct{}) RowID { | |||
| 137 | - | var maxID RowID | |||
| 138 | - | for id := range ids { | |||
| 139 | - | if id > maxID { | |||
| 140 | - | maxID = id | |||
| 141 | - | } | |||
| 142 | - | } | |||
| 143 | - | return maxID | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | func historyViewRowCount(view HistoryView) int { | |||
| 147 | - | n := 0 | |||
| 148 | - | for _, chunk := range view.chunks { | |||
| 149 | - | if chunk != nil && len(chunk.rows) <= math.MaxInt-n { | |||
| 150 | - | n += len(chunk.rows) | |||
| 151 | - | continue | |||
| 152 | - | } | |||
| 153 | - | return -1 | |||
| 154 | - | } | |||
| 155 | - | return n | |||
| 156 | - | } | |||
| 157 | - | ||||
| 158 | - | func appendUint32(dst []byte, v uint32) []byte { | |||
| 159 | - | return binary.BigEndian.AppendUint32(dst, v) | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | - | func appendHistoryCell(dst []byte, cell renderer.Cell) []byte { | |||
| 163 | - | style := cell.Style | |||
| 164 | - | dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune)) | |||
| 165 | - | flags := byte(0) | |||
| 166 | - | if cell.Continuation { | |||
| 167 | - | flags |= 1 << 0 | |||
| 168 | - | } | |||
| 169 | - | if style.Bold { | |||
| 170 | - | flags |= 1 << 1 | |||
| 171 | - | } | |||
| 172 | - | if style.Italic { | |||
| 173 | - | flags |= 1 << 2 | |||
| 174 | - | } | |||
| 175 | - | if style.Inverse { | |||
| 176 | - | flags |= 1 << 3 | |||
| 177 | - | } | |||
| 178 | - | if style.HasForegroundRGB { | |||
| 179 | - | flags |= 1 << 4 | |||
| 180 | - | } | |||
| 181 | - | if style.HasBackgroundRGB { | |||
| 182 | - | flags |= 1 << 5 | |||
| 183 | - | } | |||
| 184 | - | if style.HasUnderlineColor { | |||
| 185 | - | flags |= 1 << 6 | |||
| 186 | - | } | |||
| 187 | - | if style.HasUnderlineColorRGB { | |||
| 188 | - | flags |= 1 << 7 | |||
| 189 | - | } | |||
| 190 | - | dst = append(dst, flags) | |||
| 191 | - | dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs)) | |||
| 192 | - | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground))) | |||
| 193 | - | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background))) | |||
| 194 | - | dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B) | |||
| 195 | - | dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B) | |||
| 196 | - | dst = append(dst, byte(style.UnderlineStyle)) | |||
| 197 | - | dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor))) | |||
| 198 | - | return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B) | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | func chunkBound(chunk *HistoryChunk, i int) LineBound { | |||
| 202 | - | if i < 0 || i >= len(chunk.bounds) { | |||
| 203 | - | return LineBound{} | |||
| 204 | - | } | |||
| 205 | - | return chunk.bounds[i] | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | func appendHistoryBound(dst []byte, b LineBound) []byte { | |||
| 209 | - | dst = binary.BigEndian.AppendUint32(dst, uint32(b.End)) | |||
| 210 | - | if b.Soft { | |||
| 211 | - | return append(dst, 1) | |||
| 212 | - | } | |||
| 213 | - | return append(dst, 0) | |||
| 214 | - | } | |||
| 215 | - | ||||
| 216 | - | // validHistoryBound rejects extents that cannot describe their own row. | |||
| 217 | - | func validHistoryBound(b LineBound, cells int) bool { | |||
| 218 | - | return b.End >= 0 && b.End <= cells | |||
| 219 | - | } | |||
| 220 | - | ||||
| 221 | - | // UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects | |||
| 222 | - | // malformed declarations, cells, truncated data, and trailing bytes. | |||
| 223 | - | func UnmarshalHistory(data []byte) (HistoryView, error) { | |||
| 224 | - | view, _, ok := parseHistory(data, true) | |||
| 225 | - | if !ok { | |||
| 226 | - | return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory) | |||
| 227 | - | } | |||
| 228 | - | return view, nil | |||
| 229 | - | } | |||
| 230 | - | ||||
| 231 | - | type historyDecodeStats struct { | |||
| 232 | - | chunks uint64 | |||
| 233 | - | rows uint64 | |||
| 234 | - | cells uint64 | |||
| 235 | - | styles uint64 | |||
| 236 | - | bytes uint64 | |||
| 237 | - | } | |||
| 238 | - | ||||
| 239 | - | // preflightHistory validates a history blob without allocating decoded rows. | |||
| 240 | - | // It shares parseHistory with UnmarshalHistory so both paths enforce the same | |||
| 241 | - | // structural, cell, and aggregate-budget constraints. | |||
| 242 | - | func preflightHistory(data []byte) (historyDecodeStats, bool) { | |||
| 243 | - | _, stats, ok := parseHistory(data, false) | |||
| 244 | - | return stats, ok | |||
| 245 | - | } | |||
| 246 | - | ||||
| 247 | - | // parseHistory is the authoritative history payload validation path. populate | |||
| 248 | - | // controls whether validated cells are retained in a HistoryView. | |||
| 249 | - | func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) { | |||
| 250 | - | if len(data) < 17 || string(data[:4]) != historyMagic || data[4] != historyVersion { | |||
| 251 | - | return HistoryView{}, historyDecodeStats{}, false | |||
| 252 | - | } | |||
| 253 | - | ||||
| 254 | - | p := historyParser{data: data[5:]} | |||
| 255 | - | chunkCount, ok := p.uint32() | |||
| 256 | - | if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 { | |||
| 257 | - | return HistoryView{}, historyDecodeStats{}, false | |||
| 258 | - | } | |||
| 259 | - | nextRowID, ok := p.uint64() | |||
| 260 | - | if !ok || nextRowID == 0 || nextRowID == uint64(^RowID(0)) { | |||
| 261 | - | return HistoryView{}, historyDecodeStats{}, false | |||
| 262 | - | } | |||
| 263 | - | stats := historyDecodeStats{chunks: uint64(chunkCount)} | |||
| 264 | - | var seenIDs [maxHistoryRows]RowID | |||
| 265 | - | seenCount := 0 | |||
| 266 | - | var maxID RowID | |||
| 267 | - | var chunks []*HistoryChunk | |||
| 268 | - | if populate { | |||
| 269 | - | chunks = make([]*HistoryChunk, 0, chunkCount) | |||
| 270 | - | } | |||
| 271 | - | for range chunkCount { | |||
| 272 | - | rowCount, ok := p.uint32() | |||
| 273 | - | if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || | |||
| 274 | - | uint64(rowCount) > uint64(len(p.data))/(8+4+historyBoundBytes) || | |||
| 275 | - | !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) { | |||
| 276 | - | return HistoryView{}, historyDecodeStats{}, false | |||
| 277 | - | } | |||
| 278 | - | ||||
| 279 | - | var rows [][]renderer.Cell | |||
| 280 | - | var bounds []LineBound | |||
| 281 | - | var rowIDs []RowID | |||
| 282 | - | if populate { | |||
| 283 | - | rows = make([][]renderer.Cell, 0, rowCount) | |||
| 284 | - | bounds = make([]LineBound, 0, rowCount) | |||
| 285 | - | rowIDs = make([]RowID, 0, rowCount) | |||
| 286 | - | } | |||
| 287 | - | for range rowCount { | |||
| 288 | - | id, ok := p.uint64() | |||
| 289 | - | if !ok || id == 0 || id >= uint64(^RowID(0)-1) { | |||
| 290 | - | return HistoryView{}, historyDecodeStats{}, false | |||
| 291 | - | } | |||
| 292 | - | rowID := RowID(id) | |||
| 293 | - | seenIDs[seenCount] = rowID | |||
| 294 | - | seenCount++ | |||
| 295 | - | if rowID > maxID { | |||
| 296 | - | maxID = rowID | |||
| 297 | - | } | |||
| 298 | - | cellCount, ok := p.uint32() | |||
| 299 | - | cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount)) | |||
| 300 | - | if !ok || !validCellBytes || | |||
| 301 | - | !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) || | |||
| 302 | - | !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) || | |||
| 303 | - | !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) || | |||
| 304 | - | uint64(cellCount) > uint64(len(p.data))/historyCellBytes { | |||
| 305 | - | return HistoryView{}, historyDecodeStats{}, false | |||
| 306 | - | } | |||
| 307 | - | ||||
| 308 | - | var row []renderer.Cell | |||
| 309 | - | if populate { | |||
| 310 | - | row = make([]renderer.Cell, cellCount) | |||
| 311 | - | } | |||
| 312 | - | for i := range cellCount { | |||
| 313 | - | cell, ok := p.cell() | |||
| 314 | - | if !ok || !validHistoryCell(cell) { | |||
| 315 | - | return HistoryView{}, historyDecodeStats{}, false | |||
| 316 | - | } | |||
| 317 | - | if populate { | |||
| 318 | - | row[i] = cell | |||
| 319 | - | } | |||
| 320 | - | } | |||
| 321 | - | bound, ok := p.bound() | |||
| 322 | - | if !ok || !validHistoryBound(bound, int(cellCount)) { | |||
| 323 | - | return HistoryView{}, historyDecodeStats{}, false | |||
| 324 | - | } | |||
| 325 | - | if populate { | |||
| 326 | - | rows = append(rows, row) | |||
| 327 | - | bounds = append(bounds, bound) | |||
| 328 | - | rowIDs = append(rowIDs, rowID) | |||
| 329 | - | } | |||
| 330 | - | } | |||
| 331 | - | if populate { | |||
| 332 | - | chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs}) | |||
| 333 | - | } | |||
| 334 | - | } | |||
| 335 | - | slices.Sort(seenIDs[:seenCount]) | |||
| 336 | - | for i := 1; i < seenCount; i++ { | |||
| 337 | - | if seenIDs[i] == seenIDs[i-1] { | |||
| 338 | - | return HistoryView{}, historyDecodeStats{}, false | |||
| 339 | - | } | |||
| 340 | - | } | |||
| 341 | - | if len(p.data) != 0 || RowID(nextRowID) <= maxID { | |||
| 342 | - | return HistoryView{}, historyDecodeStats{}, false | |||
| 343 | - | } | |||
| 344 | - | return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells), nextRowID: RowID(nextRowID)}, stats, true | |||
| 345 | - | } | |||
| 346 | - | ||||
| 347 | - | func historyCellByteCount(cellCount uint64) (uint64, bool) { | |||
| 348 | - | if cellCount > math.MaxUint64/historyCellBytes { | |||
| 349 | - | return 0, false | |||
| 350 | - | } | |||
| 351 | - | return cellCount * historyCellBytes, true | |||
| 352 | - | } | |||
| 353 | - | ||||
| 354 | - | func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool { | |||
| 355 | - | if *total > limit || amount > limit-*total { | |||
| 356 | - | return false | |||
| 357 | - | } | |||
| 358 | - | *total += amount | |||
| 359 | - | return true | |||
| 360 | - | } | |||
| 361 | - | ||||
| 362 | - | type historyParser struct{ data []byte } | |||
| 363 | - | ||||
| 364 | - | func (p *historyParser) uint32() (uint32, bool) { | |||
| 365 | - | if len(p.data) < 4 { | |||
| 366 | - | return 0, false | |||
| 367 | - | } | |||
| 368 | - | v := binary.BigEndian.Uint32(p.data) | |||
| 369 | - | p.data = p.data[4:] | |||
| 370 | - | return v, true | |||
| 371 | - | } | |||
| 372 | - | ||||
| 373 | - | func (p *historyParser) uint64() (uint64, bool) { | |||
| 374 | - | if len(p.data) < 8 { | |||
| 375 | - | return 0, false | |||
| 376 | - | } | |||
| 377 | - | v := binary.BigEndian.Uint64(p.data) | |||
| 378 | - | p.data = p.data[8:] | |||
| 379 | - | return v, true | |||
| 380 | - | } | |||
| 381 | - | ||||
| 382 | - | func (p *historyParser) cell() (renderer.Cell, bool) { | |||
| 383 | - | if len(p.data) < historyCellBytes { | |||
| 384 | - | return renderer.Cell{}, false | |||
| 385 | - | } | |||
| 386 | - | b := p.data[:historyCellBytes] | |||
| 387 | - | p.data = p.data[historyCellBytes:] | |||
| 388 | - | foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15])) | |||
| 389 | - | if !ok { | |||
| 390 | - | return renderer.Cell{}, false | |||
| 391 | - | } | |||
| 392 | - | background, ok := historyInt(binary.BigEndian.Uint64(b[15:23])) | |||
| 393 | - | if !ok { | |||
| 394 | - | return renderer.Cell{}, false | |||
| 395 | - | } | |||
| 396 | - | underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38])) | |||
| 397 | - | if !ok { | |||
| 398 | - | return renderer.Cell{}, false | |||
| 399 | - | } | |||
| 400 | - | flags := b[4] | |||
| 401 | - | return renderer.Cell{ | |||
| 402 | - | Rune: rune(binary.BigEndian.Uint32(b[0:4])), | |||
| 403 | - | Continuation: flags&1 != 0, | |||
| 404 | - | Style: renderer.Style{ | |||
| 405 | - | Bold: flags&(1<<1) != 0, | |||
| 406 | - | Italic: flags&(1<<2) != 0, | |||
| 407 | - | Inverse: flags&(1<<3) != 0, | |||
| 408 | - | Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])), | |||
| 409 | - | Foreground: foreground, | |||
| 410 | - | Background: background, | |||
| 411 | - | HasForegroundRGB: flags&(1<<4) != 0, | |||
| 412 | - | ForegroundRGB: renderer.RGB{R: b[23], G: b[24], B: b[25]}, | |||
| 413 | - | HasBackgroundRGB: flags&(1<<5) != 0, | |||
| 414 | - | BackgroundRGB: renderer.RGB{R: b[26], G: b[27], B: b[28]}, | |||
| 415 | - | UnderlineStyle: renderer.UnderlineStyle(b[29]), | |||
| 416 | - | HasUnderlineColor: flags&(1<<6) != 0, | |||
| 417 | - | UnderlineColor: underlineColor, | |||
| 418 | - | HasUnderlineColorRGB: flags&(1<<7) != 0, | |||
| 419 | - | UnderlineColorRGB: renderer.RGB{R: b[38], G: b[39], B: b[40]}, | |||
| 420 | - | }, | |||
| 421 | - | }, true | |||
| 422 | - | } | |||
| 423 | - | ||||
| 424 | - | func (p *historyParser) bound() (LineBound, bool) { | |||
| 425 | - | if len(p.data) < historyBoundBytes { | |||
| 426 | - | return LineBound{}, false | |||
| 427 | - | } | |||
| 428 | - | end := binary.BigEndian.Uint32(p.data[:4]) | |||
| 429 | - | flag := p.data[4] | |||
| 430 | - | p.data = p.data[historyBoundBytes:] | |||
| 431 | - | if flag > 1 || uint64(end) > math.MaxInt32 { | |||
| 432 | - | return LineBound{}, false | |||
| 433 | - | } | |||
| 434 | - | return LineBound{End: int(end), Soft: flag == 1}, true | |||
| 435 | - | } | |||
| 436 | - | ||||
| 437 | - | func historyInt(raw uint64) (int, bool) { | |||
| 438 | - | v := int64(raw) | |||
| 439 | - | if int64(int(v)) != v { | |||
| 440 | - | return 0, false | |||
| 441 | - | } | |||
| 442 | - | return int(v), true | |||
| 443 | - | } | |||
| 444 | - | ||||
| 445 | - | func validHistoryCell(cell renderer.Cell) bool { | |||
| 446 | - | return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed | |||
| 447 | - | } | |||
D pkg/vt/history_codec_bound_test.go +0 -50
| @@ -1,50 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "encoding/binary" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | - | "github.com/stretchr/testify/require" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | func TestHistoryCodecRejectsMalformedBounds(t *testing.T) { | |||
| 12 | - | blob, err := MarshalHistory(HistoryView{ | |||
| 13 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}, rowIDs: []RowID{1}}}, | |||
| 14 | - | rows: 1, | |||
| 15 | - | cells: 4, | |||
| 16 | - | nextRowID: 2, | |||
| 17 | - | }) | |||
| 18 | - | require.NoError(t, err) | |||
| 19 | - | boundOffset := len(blob) - historyBoundBytes | |||
| 20 | - | ||||
| 21 | - | tests := []struct { | |||
| 22 | - | name string | |||
| 23 | - | mutate func([]byte) | |||
| 24 | - | }{ | |||
| 25 | - | { | |||
| 26 | - | name: "end exceeds row width", | |||
| 27 | - | mutate: func(data []byte) { | |||
| 28 | - | binary.BigEndian.PutUint32(data[boundOffset:boundOffset+4], 5) | |||
| 29 | - | }, | |||
| 30 | - | }, | |||
| 31 | - | { | |||
| 32 | - | name: "soft flag is not canonical", | |||
| 33 | - | mutate: func(data []byte) { | |||
| 34 | - | data[boundOffset+4] = 2 | |||
| 35 | - | }, | |||
| 36 | - | }, | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | for _, tt := range tests { | |||
| 40 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 41 | - | data := append([]byte(nil), blob...) | |||
| 42 | - | tt.mutate(data) | |||
| 43 | - | ||||
| 44 | - | _, preflightErr := PreflightHistoryBlob(data) | |||
| 45 | - | require.Error(t, preflightErr) | |||
| 46 | - | _, unmarshalErr := UnmarshalHistory(data) | |||
| 47 | - | require.Error(t, unmarshalErr) | |||
| 48 | - | }) | |||
| 49 | - | } | |||
| 50 | - | } | |||
D pkg/vt/history_codec_test.go +0 -432
| @@ -1,432 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "encoding/binary" | |||
| 6 | - | "fmt" | |||
| 7 | - | "math" | |||
| 8 | - | "testing" | |||
| 9 | - | ||||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | func TestHistoryCodecV3Bytes(t *testing.T) { | |||
| 14 | - | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | |||
| 15 | - | if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil { | |||
| 16 | - | t.Fatalf("append: %v", err) | |||
| 17 | - | } | |||
| 18 | - | got, err := MarshalHistory(h.SealAndView()) | |||
| 19 | - | if err != nil { | |||
| 20 | - | t.Fatalf("marshal: %v", err) | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | want := []byte{ | |||
| 24 | - | 'V', 'T', 'H', '1', 3, // magic, version | |||
| 25 | - | 0, 0, 0, 1, // chunk count | |||
| 26 | - | 0, 0, 0, 0, 0, 0, 0, 2, // next row ID | |||
| 27 | - | 0, 0, 0, 1, // row count | |||
| 28 | - | 0, 0, 0, 0, 0, 0, 0, 1, // row ID | |||
| 29 | - | 0, 0, 0, 1, // cell count | |||
| 30 | - | 0, 0, 0, 'a', // Rune | |||
| 31 | - | 0, // flags | |||
| 32 | - | 0, 0, // Attrs | |||
| 33 | - | 0, 0, 0, 0, 0, 0, 0, 0, // Foreground | |||
| 34 | - | 0, 0, 0, 0, 0, 0, 0, 0, // Background | |||
| 35 | - | 0, 0, 0, // ForegroundRGB | |||
| 36 | - | 0, 0, 0, // BackgroundRGB | |||
| 37 | - | 0, // UnderlineStyle | |||
| 38 | - | 0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor | |||
| 39 | - | 0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here | |||
| 40 | - | 0, 0, 0, 1, 1, // bound: End=1, Soft=true | |||
| 41 | - | } | |||
| 42 | - | if !bytes.Equal(got, want) { | |||
| 43 | - | t.Fatalf("MarshalHistory() = % x, want % x", got, want) | |||
| 44 | - | } | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | func TestHistoryCodecRoundTripsBounds(t *testing.T) { | |||
| 48 | - | h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2}) | |||
| 49 | - | bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}} | |||
| 50 | - | for _, b := range bounds { | |||
| 51 | - | if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil { | |||
| 52 | - | t.Fatalf("append: %v", err) | |||
| 53 | - | } | |||
| 54 | - | } | |||
| 55 | - | blob, err := MarshalHistory(h.SealAndView()) | |||
| 56 | - | if err != nil { | |||
| 57 | - | t.Fatalf("marshal: %v", err) | |||
| 58 | - | } | |||
| 59 | - | view, err := UnmarshalHistory(blob) | |||
| 60 | - | if err != nil { | |||
| 61 | - | t.Fatalf("unmarshal: %v", err) | |||
| 62 | - | } | |||
| 63 | - | for i, want := range bounds { | |||
| 64 | - | if got := view.Bound(i); got != want { | |||
| 65 | - | t.Errorf("Bound(%d) = %+v, want %+v", i, got, want) | |||
| 66 | - | } | |||
| 67 | - | } | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) { | |||
| 71 | - | // The previous layouts are deliberately incompatible with stable IDs. | |||
| 72 | - | legacy := []byte{ | |||
| 73 | - | 'V', 'T', 'H', '1', 2, | |||
| 74 | - | 0, 0, 0, 1, 0, 0, 0, 1, | |||
| 75 | - | 0, 0, 0, 1, 0, 0, 0, 'a', | |||
| 76 | - | 0, 0, 0, | |||
| 77 | - | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, | |||
| 78 | - | 0, 0, 0, 0, 0, 0, | |||
| 79 | - | 0, | |||
| 80 | - | 0, 0, 0, 0, 0, 0, 0, 0, | |||
| 81 | - | 0, 0, 0, | |||
| 82 | - | 0, 0, 0, 1, 1, | |||
| 83 | - | } | |||
| 84 | - | for _, version := range []byte{1, 2} { | |||
| 85 | - | data := append([]byte(nil), legacy...) | |||
| 86 | - | data[4] = version | |||
| 87 | - | if _, err := UnmarshalHistory(data); err == nil { | |||
| 88 | - | t.Fatalf("accepted legacy history version %d", version) | |||
| 89 | - | } | |||
| 90 | - | } | |||
| 91 | - | } | |||
| 92 | - | ||||
| 93 | - | func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) { | |||
| 94 | - | indexed := renderer.Style{ | |||
| 95 | - | Bold: true, | |||
| 96 | - | Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrStrikethrough, | |||
| 97 | - | Foreground: 123, | |||
| 98 | - | Background: 45, | |||
| 99 | - | UnderlineStyle: renderer.UnderlineDashed, | |||
| 100 | - | HasUnderlineColor: true, | |||
| 101 | - | UnderlineColor: 201, | |||
| 102 | - | } | |||
| 103 | - | rgb := renderer.Style{ | |||
| 104 | - | Italic: true, | |||
| 105 | - | Inverse: true, | |||
| 106 | - | Attrs: renderer.AttrBlink, | |||
| 107 | - | HasForegroundRGB: true, | |||
| 108 | - | ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 109 | - | HasBackgroundRGB: true, | |||
| 110 | - | BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 111 | - | UnderlineStyle: renderer.UnderlineCurly, | |||
| 112 | - | HasUnderlineColorRGB: true, | |||
| 113 | - | UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9}, | |||
| 114 | - | } | |||
| 115 | - | tests := []struct { | |||
| 116 | - | name string | |||
| 117 | - | rows [][]renderer.Cell | |||
| 118 | - | }{ | |||
| 119 | - | { | |||
| 120 | - | name: "indexed RGB blank and wide continuation cells are exact", | |||
| 121 | - | rows: [][]renderer.Cell{ | |||
| 122 | - | { | |||
| 123 | - | {Rune: 'I', Style: indexed}, | |||
| 124 | - | {Rune: 'R', Style: rgb}, | |||
| 125 | - | {Rune: ' ', Style: renderer.DefaultStyle()}, | |||
| 126 | - | {Rune: '界', Style: rgb}, | |||
| 127 | - | {Continuation: true, Style: rgb}, | |||
| 128 | - | }, | |||
| 129 | - | { | |||
| 130 | - | {Rune: ' ', Style: renderer.DefaultStyle()}, | |||
| 131 | - | {Rune: 'X', Style: indexed}, | |||
| 132 | - | }, | |||
| 133 | - | }, | |||
| 134 | - | }, | |||
| 135 | - | } | |||
| 136 | - | ||||
| 137 | - | for _, tt := range tests { | |||
| 138 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 139 | - | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 140 | - | for _, row := range tt.rows { | |||
| 141 | - | requireHistoryAppend(t, history, row) | |||
| 142 | - | } | |||
| 143 | - | ||||
| 144 | - | encoded, err := MarshalHistory(history.View()) | |||
| 145 | - | if err != nil { | |||
| 146 | - | t.Fatalf("marshal history: %v", err) | |||
| 147 | - | } | |||
| 148 | - | decoded, err := UnmarshalHistory(encoded) | |||
| 149 | - | if err != nil { | |||
| 150 | - | t.Fatalf("unmarshal history: %v", err) | |||
| 151 | - | } | |||
| 152 | - | assertHistoryRowsEqual(t, decoded, tt.rows) | |||
| 153 | - | }) | |||
| 154 | - | } | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) { | |||
| 158 | - | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2}) | |||
| 159 | - | requireHistoryAppend(t, history, historyRow("AAAA")) | |||
| 160 | - | requireHistoryAppend(t, history, historyRow("BBBB")) | |||
| 161 | - | encoded, err := MarshalHistory(history.View()) | |||
| 162 | - | if err != nil { | |||
| 163 | - | t.Fatalf("marshal history: %v", err) | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | for i := range len(encoded) { | |||
| 167 | - | if _, err := UnmarshalHistory(encoded[:i]); err == nil { | |||
| 168 | - | t.Fatalf("accepted truncated version 3 prefix of length %d", i) | |||
| 169 | - | } | |||
| 170 | - | } | |||
| 171 | - | if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil { | |||
| 172 | - | t.Fatal("accepted trailing garbage after version 3 payload") | |||
| 173 | - | } | |||
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) { | |||
| 177 | - | const ( | |||
| 178 | - | rows = 10_000 | |||
| 179 | - | width = 120 | |||
| 180 | - | ) | |||
| 181 | - | ||||
| 182 | - | encoded, err := MarshalHistory(historyViewWithDimensions(rows, width)) | |||
| 183 | - | if err != nil { | |||
| 184 | - | t.Fatalf("marshal representative history: %v", err) | |||
| 185 | - | } | |||
| 186 | - | decoded, err := UnmarshalHistory(encoded) | |||
| 187 | - | if err != nil { | |||
| 188 | - | t.Fatalf("unmarshal representative history: %v", err) | |||
| 189 | - | } | |||
| 190 | - | if got := decoded.Len(); got != rows { | |||
| 191 | - | t.Fatalf("decoded rows = %d, want %d", got, rows) | |||
| 192 | - | } | |||
| 193 | - | for _, rowIndex := range []int{0, rows - 1} { | |||
| 194 | - | if got := len(decoded.Row(rowIndex)); got != width { | |||
| 195 | - | t.Fatalf("decoded row %d width = %d, want %d", rowIndex, got, width) | |||
| 196 | - | } | |||
| 197 | - | } | |||
| 198 | - | } | |||
| 199 | - | ||||
| 200 | - | func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) { | |||
| 201 | - | const supportedRows = 12_000 | |||
| 202 | - | tests := []struct { | |||
| 203 | - | name string | |||
| 204 | - | view HistoryView | |||
| 205 | - | data []byte | |||
| 206 | - | }{ | |||
| 207 | - | { | |||
| 208 | - | name: "too many rows", | |||
| 209 | - | view: historyViewWithDimensions(supportedRows+1, 0), | |||
| 210 | - | data: historyPayloadWithDimensions(supportedRows+1, 0), | |||
| 211 | - | }, | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | for _, tt := range tests { | |||
| 215 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 216 | - | if _, err := MarshalHistory(tt.view); err == nil { | |||
| 217 | - | t.Fatal("marshal accepted dimensions beyond the supported limits") | |||
| 218 | - | } | |||
| 219 | - | if _, err := UnmarshalHistory(tt.data); err == nil { | |||
| 220 | - | t.Fatal("unmarshal accepted dimensions beyond the supported limits") | |||
| 221 | - | } | |||
| 222 | - | }) | |||
| 223 | - | } | |||
| 224 | - | } | |||
| 225 | - | ||||
| 226 | - | func TestChunkCodecRejectsAggregateCellDeclarationBeforePayloadAllocation(t *testing.T) { | |||
| 227 | - | data := aggregateCellLimitDeclaration() | |||
| 228 | - | if len(data) >= 1024 { | |||
| 229 | - | t.Fatalf("aggregate declaration length = %d, want compact fixture", len(data)) | |||
| 230 | - | } | |||
| 231 | - | if _, err := PreflightHistoryBlob(data); err == nil { | |||
| 232 | - | t.Fatal("preflight accepted aggregate cell declaration") | |||
| 233 | - | } | |||
| 234 | - | if _, err := UnmarshalHistory(data); err == nil { | |||
| 235 | - | t.Fatal("unmarshal accepted aggregate cell declaration") | |||
| 236 | - | } | |||
| 237 | - | } | |||
| 238 | - | ||||
| 239 | - | func TestChunkCodecAcceptsWideRowsWithinAggregateBudget(t *testing.T) { | |||
| 240 | - | for _, width := range []int{161, 293, math.MaxUint16} { | |||
| 241 | - | t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) { | |||
| 242 | - | view := historyViewWithDimensions(1, width) | |||
| 243 | - | encoded, err := MarshalHistory(view) | |||
| 244 | - | if err != nil { | |||
| 245 | - | t.Fatalf("marshal width %d: %v", width, err) | |||
| 246 | - | } | |||
| 247 | - | decoded, err := UnmarshalHistory(encoded) | |||
| 248 | - | if err != nil { | |||
| 249 | - | t.Fatalf("unmarshal width %d: %v", width, err) | |||
| 250 | - | } | |||
| 251 | - | if got := len(decoded.Row(0)); got != width { | |||
| 252 | - | t.Fatalf("decoded width = %d, want %d", got, width) | |||
| 253 | - | } | |||
| 254 | - | if stats, err := PreflightHistoryBlob(encoded); err != nil || stats.Cells != uint64(width) { | |||
| 255 | - | t.Fatalf("preflight stats = %+v, err = %v", stats, err) | |||
| 256 | - | } | |||
| 257 | - | }) | |||
| 258 | - | } | |||
| 259 | - | } | |||
| 260 | - | ||||
| 261 | - | func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) { | |||
| 262 | - | tests := []struct { | |||
| 263 | - | name string | |||
| 264 | - | chunkCount int | |||
| 265 | - | rowCount int | |||
| 266 | - | }{ | |||
| 267 | - | {name: "more than twelve thousand chunks", chunkCount: 12_001, rowCount: 1}, | |||
| 268 | - | {name: "more than twelve thousand rows", chunkCount: 47, rowCount: maxHistoryChunkRows}, | |||
| 269 | - | } | |||
| 270 | - | ||||
| 271 | - | for _, tt := range tests { | |||
| 272 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 273 | - | if _, err := UnmarshalHistory(hostileHistoryDeclarations(tt.chunkCount, tt.rowCount)); err == nil { | |||
| 274 | - | t.Fatal("unmarshal accepted resource-exhausting history declarations") | |||
| 275 | - | } | |||
| 276 | - | }) | |||
| 277 | - | } | |||
| 278 | - | } | |||
| 279 | - | ||||
| 280 | - | func historyViewWithDimensions(rowCount, width int) HistoryView { | |||
| 281 | - | totalRows := rowCount | |||
| 282 | - | row := make([]renderer.Cell, width) | |||
| 283 | - | for i := range row { | |||
| 284 | - | row[i].Rune = 'x' | |||
| 285 | - | } | |||
| 286 | - | chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows) | |||
| 287 | - | rowID := RowID(1) | |||
| 288 | - | for rowCount > 0 { | |||
| 289 | - | chunkRows := min(rowCount, maxHistoryChunkRows) | |||
| 290 | - | rows := make([][]renderer.Cell, chunkRows) | |||
| 291 | - | rowIDs := make([]RowID, chunkRows) | |||
| 292 | - | for i := range rows { | |||
| 293 | - | rows[i] = row | |||
| 294 | - | rowIDs[i] = rowID | |||
| 295 | - | rowID++ | |||
| 296 | - | } | |||
| 297 | - | chunks = append(chunks, &HistoryChunk{rows: rows, rowIDs: rowIDs}) | |||
| 298 | - | rowCount -= chunkRows | |||
| 299 | - | } | |||
| 300 | - | return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID} | |||
| 301 | - | } | |||
| 302 | - | ||||
| 303 | - | func historyPayloadWithDimensions(rowCount, width int) []byte { | |||
| 304 | - | data := make([]byte, 17) | |||
| 305 | - | copy(data, historyMagic) | |||
| 306 | - | data[4] = historyVersion | |||
| 307 | - | chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows | |||
| 308 | - | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |||
| 309 | - | binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1)) | |||
| 310 | - | rowID := uint64(1) | |||
| 311 | - | for rowCount > 0 { | |||
| 312 | - | chunkRows := min(rowCount, maxHistoryChunkRows) | |||
| 313 | - | data = binary.BigEndian.AppendUint32(data, uint32(chunkRows)) | |||
| 314 | - | for range chunkRows { | |||
| 315 | - | data = binary.BigEndian.AppendUint64(data, rowID) | |||
| 316 | - | rowID++ | |||
| 317 | - | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |||
| 318 | - | //nolint:makezero // The header prefix is retained while zeroed cell records are appended. | |||
| 319 | - | data = append(data, make([]byte, width*historyCellBytes)...) | |||
| 320 | - | data = binary.BigEndian.AppendUint32(data, uint32(width)) | |||
| 321 | - | data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended. | |||
| 322 | - | } | |||
| 323 | - | rowCount -= chunkRows | |||
| 324 | - | } | |||
| 325 | - | return data | |||
| 326 | - | } | |||
| 327 | - | ||||
| 328 | - | // aggregateCellLimitDeclaration declares one row, which is within the row | |||
| 329 | - | // budget, but its cells exceed the aggregate budget. It deliberately omits | |||
| 330 | - | // cell payload bytes: the aggregate limit must reject it before allocation. | |||
| 331 | - | func aggregateCellLimitDeclaration() []byte { | |||
| 332 | - | data := make([]byte, 17, 29) | |||
| 333 | - | copy(data, historyMagic) | |||
| 334 | - | data[4] = historyVersion | |||
| 335 | - | binary.BigEndian.PutUint32(data[5:], 1) | |||
| 336 | - | binary.BigEndian.PutUint64(data[9:], 2) | |||
| 337 | - | data = binary.BigEndian.AppendUint32(data, 1) | |||
| 338 | - | data = binary.BigEndian.AppendUint64(data, 1) | |||
| 339 | - | return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1)) | |||
| 340 | - | } | |||
| 341 | - | ||||
| 342 | - | func hostileHistoryDeclarations(chunkCount, rowCount int) []byte { | |||
| 343 | - | data := make([]byte, 17, 17+chunkCount*(4+rowCount*12)) | |||
| 344 | - | copy(data, historyMagic) | |||
| 345 | - | data[4] = historyVersion | |||
| 346 | - | binary.BigEndian.PutUint32(data[5:], uint32(chunkCount)) | |||
| 347 | - | binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1)) | |||
| 348 | - | id := uint64(1) | |||
| 349 | - | for range chunkCount { | |||
| 350 | - | data = binary.BigEndian.AppendUint32(data, uint32(rowCount)) | |||
| 351 | - | for range rowCount { | |||
| 352 | - | data = binary.BigEndian.AppendUint64(data, id) | |||
| 353 | - | id++ | |||
| 354 | - | data = binary.BigEndian.AppendUint32(data, 0) | |||
| 355 | - | } | |||
| 356 | - | } | |||
| 357 | - | return data | |||
| 358 | - | } | |||
| 359 | - | ||||
| 360 | - | func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) { | |||
| 361 | - | valid, err := MarshalHistory(historyViewWithDimensions(1, 1)) | |||
| 362 | - | if err != nil { | |||
| 363 | - | t.Fatalf("marshal valid history: %v", err) | |||
| 364 | - | } | |||
| 365 | - | invalidRune := append([]byte(nil), valid...) | |||
| 366 | - | binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0)) | |||
| 367 | - | invalidUnderlineStyle := append([]byte(nil), valid...) | |||
| 368 | - | invalidUnderlineStyle[33+29] = byte(renderer.UnderlineDashed + 1) | |||
| 369 | - | ||||
| 370 | - | tests := []struct { | |||
| 371 | - | name string | |||
| 372 | - | data []byte | |||
| 373 | - | valid bool | |||
| 374 | - | }{ | |||
| 375 | - | {name: "valid", data: valid, valid: true}, | |||
| 376 | - | {name: "truncated cell", data: valid[:len(valid)-1]}, | |||
| 377 | - | {name: "trailing byte", data: append(append([]byte(nil), valid...), 0)}, | |||
| 378 | - | {name: "invalid rune", data: invalidRune}, | |||
| 379 | - | {name: "invalid underline style", data: invalidUnderlineStyle}, | |||
| 380 | - | {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)}, | |||
| 381 | - | {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()}, | |||
| 382 | - | {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0}}, | |||
| 383 | - | } | |||
| 384 | - | for _, tt := range tests { | |||
| 385 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 386 | - | _, preflightErr := PreflightHistoryBlob(tt.data) | |||
| 387 | - | _, unmarshalErr := UnmarshalHistory(tt.data) | |||
| 388 | - | if (preflightErr == nil) != tt.valid || (unmarshalErr == nil) != tt.valid { | |||
| 389 | - | t.Fatalf("preflight error = %v, unmarshal error = %v, want valid = %t", preflightErr, unmarshalErr, tt.valid) | |||
| 390 | - | } | |||
| 391 | - | }) | |||
| 392 | - | } | |||
| 393 | - | } | |||
| 394 | - | ||||
| 395 | - | func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) { | |||
| 396 | - | valid, err := MarshalHistory(historyViewWithDimensions(1, 1)) | |||
| 397 | - | if err != nil { | |||
| 398 | - | f.Fatalf("marshal valid history: %v", err) | |||
| 399 | - | } | |||
| 400 | - | invalidRune := append([]byte(nil), valid...) | |||
| 401 | - | binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0)) | |||
| 402 | - | f.Add(valid) | |||
| 403 | - | f.Add(invalidRune) | |||
| 404 | - | f.Add([]byte("VTH1\x01\x00\x00\x00\x00")) | |||
| 405 | - | f.Add([]byte("not a history payload")) | |||
| 406 | - | ||||
| 407 | - | f.Fuzz(func(t *testing.T, data []byte) { | |||
| 408 | - | _, preflightErr := PreflightHistoryBlob(data) | |||
| 409 | - | _, unmarshalErr := UnmarshalHistory(data) | |||
| 410 | - | if (preflightErr == nil) != (unmarshalErr == nil) { | |||
| 411 | - | t.Fatalf("preflight error = %v, unmarshal error = %v", preflightErr, unmarshalErr) | |||
| 412 | - | } | |||
| 413 | - | }) | |||
| 414 | - | } | |||
| 415 | - | ||||
| 416 | - | func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) { | |||
| 417 | - | t.Helper() | |||
| 418 | - | if got := view.Len(); got != len(want) { | |||
| 419 | - | t.Fatalf("row count = %d, want %d", got, len(want)) | |||
| 420 | - | } | |||
| 421 | - | for y, wantRow := range want { | |||
| 422 | - | gotRow := view.Row(y) | |||
| 423 | - | if len(gotRow) != len(wantRow) { | |||
| 424 | - | t.Fatalf("row %d width = %d, want %d", y, len(gotRow), len(wantRow)) | |||
| 425 | - | } | |||
| 426 | - | for x, wantCell := range wantRow { | |||
| 427 | - | if !gotRow[x].Equal(wantCell) { | |||
| 428 | - | t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, gotRow[x], wantCell) | |||
| 429 | - | } | |||
| 430 | - | } | |||
| 431 | - | } | |||
| 432 | - | } | |||
D pkg/vt/history_final_acceptance_test.go +0 -57
| @@ -1,57 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | "github.com/stretchr/testify/require" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func TestHistoryAcceptanceKeepsBookmarksAcrossLiveScrollEvictionAndRestore(t *testing.T) { | |||
| 11 | - | history := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 32, ChunkRows: 2}) | |||
| 12 | - | for _, text := range []string{"live-1", "live-2", "live-3"} { | |||
| 13 | - | require.NoError(t, history.Append(historyAcceptanceRow(text), LineBound{End: len(text)})) | |||
| 14 | - | } | |||
| 15 | - | ||||
| 16 | - | beforeScroll := history.View() | |||
| 17 | - | bookmark := beforeScroll.RowID(1) | |||
| 18 | - | immutable := beforeScroll.Row(1) | |||
| 19 | - | immutable[0].Rune = 'X' | |||
| 20 | - | require.Equal(t, 'l', beforeScroll.Row(1)[0].Rune, "immutable history view exposed mutable cells") | |||
| 21 | - | ||||
| 22 | - | require.NoError(t, history.Append(historyAcceptanceRow("live-4"), LineBound{End: 6})) | |||
| 23 | - | afterScroll := history.View() | |||
| 24 | - | require.Equal(t, 3, afterScroll.Len()) | |||
| 25 | - | require.Equal(t, 0, afterScroll.FindRowID(bookmark), "bookmark row was not retained after one live scroll") | |||
| 26 | - | require.Equal(t, "live-2", historyAcceptanceText(afterScroll.Row(0))) | |||
| 27 | - | require.Equal(t, "live-1", historyAcceptanceText(beforeScroll.Row(0)), "retained view changed after eviction") | |||
| 28 | - | ||||
| 29 | - | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | |||
| 30 | - | require.NoError(t, err) | |||
| 31 | - | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 3, MaxCells: 32, ChunkRows: 2}, sealed, tail) | |||
| 32 | - | require.NoError(t, err) | |||
| 33 | - | restoredView := restored.View() | |||
| 34 | - | require.Equal(t, afterScroll.Len(), restoredView.Len()) | |||
| 35 | - | require.Equal(t, bookmark, restoredView.RowID(0), "bookmark row identity changed during restore") | |||
| 36 | - | require.Equal(t, "live-2", historyAcceptanceText(restoredView.Row(0))) | |||
| 37 | - | ||||
| 38 | - | next := restoredView.NextRowID() | |||
| 39 | - | require.NoError(t, restored.Append(historyAcceptanceRow("live-5"), LineBound{End: 6})) | |||
| 40 | - | require.Greater(t, restored.View().NextRowID(), next) | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | func historyAcceptanceRow(text string) []renderer.Cell { | |||
| 44 | - | row := make([]renderer.Cell, len(text)) | |||
| 45 | - | for i, r := range text { | |||
| 46 | - | row[i] = renderer.Cell{Rune: r} | |||
| 47 | - | } | |||
| 48 | - | return row | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | func historyAcceptanceText(row []renderer.Cell) string { | |||
| 52 | - | text := make([]rune, len(row)) | |||
| 53 | - | for i, cell := range row { | |||
| 54 | - | text[i] = cell.Rune | |||
| 55 | - | } | |||
| 56 | - | return string(text) | |||
| 57 | - | } | |||
D pkg/vt/history_test.go +0 -383
| @@ -1,383 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | "math" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) { | |||
| 12 | - | t.Helper() | |||
| 13 | - | if err := history.Append(row, LineBound{End: len(row)}); err != nil { | |||
| 14 | - | t.Fatalf("append history row: %v", err) | |||
| 15 | - | } | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) { | |||
| 19 | - | tests := []struct { | |||
| 20 | - | name string | |||
| 21 | - | maxRows int | |||
| 22 | - | chunkRows int | |||
| 23 | - | rows []string | |||
| 24 | - | want []string | |||
| 25 | - | }{ | |||
| 26 | - | { | |||
| 27 | - | name: "oldest chunks are evicted before newer chunks", | |||
| 28 | - | maxRows: 4, | |||
| 29 | - | chunkRows: 2, | |||
| 30 | - | rows: []string{"aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"}, | |||
| 31 | - | want: []string{"cccc", "dddd", "eeee", "ffff"}, | |||
| 32 | - | }, | |||
| 33 | - | { | |||
| 34 | - | name: "nonmultiple capacity evicts exactly the oldest row", | |||
| 35 | - | maxRows: 5, | |||
| 36 | - | chunkRows: 2, | |||
| 37 | - | rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"}, | |||
| 38 | - | want: []string{"1111", "2222", "3333", "4444", "5555"}, | |||
| 39 | - | }, | |||
| 40 | - | { | |||
| 41 | - | name: "rows preserve append order across chunk boundaries", | |||
| 42 | - | maxRows: 4, | |||
| 43 | - | chunkRows: 2, | |||
| 44 | - | rows: []string{"0000", "1111", "2222", "3333"}, | |||
| 45 | - | want: []string{"0000", "1111", "2222", "3333"}, | |||
| 46 | - | }, | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | for _, tt := range tests { | |||
| 50 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 51 | - | history := NewHistory(HistoryConfig{MaxRows: tt.maxRows, ChunkRows: tt.chunkRows}) | |||
| 52 | - | for _, text := range tt.rows { | |||
| 53 | - | requireHistoryAppend(t, history, historyRow(text)) | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | view := history.View() | |||
| 57 | - | if got := view.Len(); got > tt.maxRows { | |||
| 58 | - | t.Fatalf("retained row count = %d, exceeds capacity %d", got, tt.maxRows) | |||
| 59 | - | } | |||
| 60 | - | if got := historyViewTexts(view); !equalStrings(got, tt.want) { | |||
| 61 | - | t.Fatalf("view rows = %#v, want %#v", got, tt.want) | |||
| 62 | - | } | |||
| 63 | - | if got, want := view.ChunkCount(), (len(tt.want)+1)/2; got != want { | |||
| 64 | - | t.Fatalf("chunk count = %d, want %d", got, want) | |||
| 65 | - | } | |||
| 66 | - | }) | |||
| 67 | - | } | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | func TestHistoryTailRotationAndStableViews(t *testing.T) { | |||
| 71 | - | tests := []struct { | |||
| 72 | - | name string | |||
| 73 | - | }{ | |||
| 74 | - | {name: "a full tail seals and a later append cannot change an existing view"}, | |||
| 75 | - | {name: "rows passed to append are copied before the screen recycles them"}, | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | for _, tt := range tests { | |||
| 79 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 80 | - | history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2}) | |||
| 81 | - | first := historyRow("AAAA") | |||
| 82 | - | requireHistoryAppend(t, history, first) | |||
| 83 | - | requireHistoryAppend(t, history, historyRow("BBBB")) | |||
| 84 | - | view := history.View() | |||
| 85 | - | if got, want := view.ChunkCount(), 1; got != want { | |||
| 86 | - | t.Fatalf("sealed chunk count = %d, want %d", got, want) | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | first[0].Rune = 'X' | |||
| 90 | - | requireHistoryAppend(t, history, historyRow("CCCC")) | |||
| 91 | - | ||||
| 92 | - | if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) { | |||
| 93 | - | t.Fatalf("stable view rows = %#v, want %#v", got, want) | |||
| 94 | - | } | |||
| 95 | - | if got, want := historyViewTexts(history.View()), []string{"AAAA", "BBBB", "CCCC"}; !equalStrings(got, want) { | |||
| 96 | - | t.Fatalf("current view rows = %#v, want %#v", got, want) | |||
| 97 | - | } | |||
| 98 | - | }) | |||
| 99 | - | } | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) { | |||
| 103 | - | tests := []struct { | |||
| 104 | - | name string | |||
| 105 | - | }{ | |||
| 106 | - | {name: "unchanged sealed chunks are shared by successive views"}, | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | for _, tt := range tests { | |||
| 110 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 111 | - | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 112 | - | requireHistoryAppend(t, history, historyRow("AAAA")) | |||
| 113 | - | requireHistoryAppend(t, history, historyRow("BBBB")) | |||
| 114 | - | before := history.View() | |||
| 115 | - | ||||
| 116 | - | requireHistoryAppend(t, history, historyRow("CCCC")) | |||
| 117 | - | after := history.View() | |||
| 118 | - | ||||
| 119 | - | if before.ChunkCount() == 0 || after.ChunkCount() == 0 { | |||
| 120 | - | t.Fatal("sealed chunk missing from view") | |||
| 121 | - | } | |||
| 122 | - | if before.Chunk(0) != after.Chunk(0) { | |||
| 123 | - | t.Fatal("unchanged sealed chunk was copied instead of reused") | |||
| 124 | - | } | |||
| 125 | - | }) | |||
| 126 | - | } | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | func TestHistoryOwnedByScreenIgnoresAlternateScreenEvictions(t *testing.T) { | |||
| 130 | - | tests := []struct { | |||
| 131 | - | name string | |||
| 132 | - | write []byte | |||
| 133 | - | want []string | |||
| 134 | - | }{ | |||
| 135 | - | { | |||
| 136 | - | name: "primary screen evictions enter terminal history", | |||
| 137 | - | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | |||
| 138 | - | want: []string{"AAAA"}, | |||
| 139 | - | }, | |||
| 140 | - | { | |||
| 141 | - | name: "alternate screen scrolling never enters terminal history", | |||
| 142 | - | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"), | |||
| 143 | - | want: []string{"AAAA"}, | |||
| 144 | - | }, | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | for _, tt := range tests { | |||
| 148 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 149 | - | screen := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 150 | - | screen.Write(tt.write) | |||
| 151 | - | ||||
| 152 | - | if got := historyViewTexts(screen.History().View()); !equalStrings(got, tt.want) { | |||
| 153 | - | t.Fatalf("terminal history rows = %#v, want %#v", got, tt.want) | |||
| 154 | - | } | |||
| 155 | - | }) | |||
| 156 | - | } | |||
| 157 | - | } | |||
| 158 | - | ||||
| 159 | - | func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) { | |||
| 160 | - | tests := []struct { | |||
| 161 | - | name string | |||
| 162 | - | top int | |||
| 163 | - | bottom int | |||
| 164 | - | wantRows int | |||
| 165 | - | wantEvents int | |||
| 166 | - | }{ | |||
| 167 | - | { | |||
| 168 | - | name: "interior scroll region does not enter global history", | |||
| 169 | - | top: 1, | |||
| 170 | - | bottom: 3, | |||
| 171 | - | wantRows: 0, | |||
| 172 | - | wantEvents: 0, | |||
| 173 | - | }, | |||
| 174 | - | { | |||
| 175 | - | name: "top-edge scroll enters global history", | |||
| 176 | - | top: 0, | |||
| 177 | - | bottom: 3, | |||
| 178 | - | wantRows: 1, | |||
| 179 | - | wantEvents: 1, | |||
| 180 | - | }, | |||
| 181 | - | } | |||
| 182 | - | ||||
| 183 | - | for _, tt := range tests { | |||
| 184 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 185 | - | s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 186 | - | for y := range s.Frame.Height { | |||
| 187 | - | copy(s.Frame.Row(y), historyRow(string([]byte{byte('A' + y), byte('A' + y), byte('A' + y), byte('A' + y)}))) | |||
| 188 | - | } | |||
| 189 | - | events := 0 | |||
| 190 | - | s.OnLineEvicted = func([]renderer.Cell) { events++ } | |||
| 191 | - | ||||
| 192 | - | s.scrollUpRegion(tt.top, tt.bottom, 1) | |||
| 193 | - | ||||
| 194 | - | if got := s.History().View().Len(); got != tt.wantRows { | |||
| 195 | - | t.Errorf("history rows = %d, want %d", got, tt.wantRows) | |||
| 196 | - | } | |||
| 197 | - | if events != tt.wantEvents { | |||
| 198 | - | t.Errorf("eviction events = %d, want %d", events, tt.wantEvents) | |||
| 199 | - | } | |||
| 200 | - | }) | |||
| 201 | - | } | |||
| 202 | - | } | |||
| 203 | - | ||||
| 204 | - | func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) { | |||
| 205 | - | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2}) | |||
| 206 | - | for _, text := range []string{"aa", "bbb", "c", "dd"} { | |||
| 207 | - | if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil { | |||
| 208 | - | t.Fatalf("append %q: %v", text, err) | |||
| 209 | - | } | |||
| 210 | - | } | |||
| 211 | - | ||||
| 212 | - | view := history.View() | |||
| 213 | - | if got, want := historyViewTexts(view), []string{"c", "dd"}; !equalStrings(got, want) { | |||
| 214 | - | t.Fatalf("retained rows = %#v, want %#v", got, want) | |||
| 215 | - | } | |||
| 216 | - | if got, want := history.Len(), 2; got != want { | |||
| 217 | - | t.Fatalf("retained rows = %d, want %d", got, want) | |||
| 218 | - | } | |||
| 219 | - | if got, want := history.Cells(), 3; got != want { | |||
| 220 | - | t.Fatalf("retained cells = %d, want %d", got, want) | |||
| 221 | - | } | |||
| 222 | - | if got, want := view.Cells(), 3; got != want { | |||
| 223 | - | t.Fatalf("view cells = %d, want %d", got, want) | |||
| 224 | - | } | |||
| 225 | - | } | |||
| 226 | - | ||||
| 227 | - | func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) { | |||
| 228 | - | tests := []struct { | |||
| 229 | - | name string | |||
| 230 | - | history *History | |||
| 231 | - | }{ | |||
| 232 | - | {name: "nil history"}, | |||
| 233 | - | {name: "zero-value history", history: &History{}}, | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | for _, tt := range tests { | |||
| 237 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 238 | - | row := historyRow("row") | |||
| 239 | - | if err := tt.history.Append(row, LineBound{End: len(row)}); err != nil { | |||
| 240 | - | t.Fatalf("append error = %v, want nil", err) | |||
| 241 | - | } | |||
| 242 | - | if got := tt.history.Len(); got != 0 { | |||
| 243 | - | t.Fatalf("retained rows = %d, want 0", got) | |||
| 244 | - | } | |||
| 245 | - | }) | |||
| 246 | - | } | |||
| 247 | - | } | |||
| 248 | - | ||||
| 249 | - | func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) { | |||
| 250 | - | history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2}) | |||
| 251 | - | kept := historyRow("ok") | |||
| 252 | - | if err := history.Append(kept, LineBound{End: len(kept)}); err != nil { | |||
| 253 | - | t.Fatalf("append retained row: %v", err) | |||
| 254 | - | } | |||
| 255 | - | before := history.View() | |||
| 256 | - | ||||
| 257 | - | wide := historyRow("wide") | |||
| 258 | - | err := history.Append(wide, LineBound{End: len(wide)}) | |||
| 259 | - | if !errors.Is(err, ErrHistoryRowTooWide) { | |||
| 260 | - | t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err) | |||
| 261 | - | } | |||
| 262 | - | if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) { | |||
| 263 | - | t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want) | |||
| 264 | - | } | |||
| 265 | - | if got, want := history.Cells(), 2; got != want { | |||
| 266 | - | t.Fatalf("retained cells after rejected append = %d, want %d", got, want) | |||
| 267 | - | } | |||
| 268 | - | } | |||
| 269 | - | ||||
| 270 | - | func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) { | |||
| 271 | - | screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxCells: 3}) | |||
| 272 | - | var events []string | |||
| 273 | - | screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) } | |||
| 274 | - | copy(screen.Frame.Row(0), historyRow("AAAA")) | |||
| 275 | - | copy(screen.Frame.Row(1), historyRow("BBBB")) | |||
| 276 | - | screen.scrollUpRegion(0, 1, 1) | |||
| 277 | - | ||||
| 278 | - | if got := screen.History().Len(); got != 0 { | |||
| 279 | - | t.Fatalf("oversized scrollback rows = %d, want 0", got) | |||
| 280 | - | } | |||
| 281 | - | if got, want := events, []string{"AAAA"}; !equalStrings(got, want) { | |||
| 282 | - | t.Fatalf("scroll callbacks = %#v, want %#v", got, want) | |||
| 283 | - | } | |||
| 284 | - | if got := lineText(screen, 0); got != "BBBB" { | |||
| 285 | - | t.Fatalf("screen did not scroll after history drop: row 0 = %q", got) | |||
| 286 | - | } | |||
| 287 | - | } | |||
| 288 | - | ||||
| 289 | - | func TestHistoryAppendRetainsBounds(t *testing.T) { | |||
| 290 | - | row := func(s string) []renderer.Cell { | |||
| 291 | - | cells := make([]renderer.Cell, 0, len(s)) | |||
| 292 | - | for _, r := range s { | |||
| 293 | - | cells = append(cells, renderer.Cell{Rune: r}) | |||
| 294 | - | } | |||
| 295 | - | return cells | |||
| 296 | - | } | |||
| 297 | - | ||||
| 298 | - | tests := []struct { | |||
| 299 | - | name string | |||
| 300 | - | chunkRows int | |||
| 301 | - | bounds []LineBound | |||
| 302 | - | }{ | |||
| 303 | - | { | |||
| 304 | - | name: "within the mutable tail", | |||
| 305 | - | chunkRows: 8, | |||
| 306 | - | bounds: []LineBound{{End: 3, Soft: true}, {End: 2}}, | |||
| 307 | - | }, | |||
| 308 | - | { | |||
| 309 | - | name: "across a sealed chunk boundary", | |||
| 310 | - | chunkRows: 2, | |||
| 311 | - | bounds: []LineBound{{End: 3, Soft: true}, {End: 3, Soft: true}, {End: 2}}, | |||
| 312 | - | }, | |||
| 313 | - | } | |||
| 314 | - | ||||
| 315 | - | for _, tc := range tests { | |||
| 316 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 317 | - | h := NewHistory(HistoryConfig{MaxRows: 16, MaxCells: 1024, ChunkRows: tc.chunkRows}) | |||
| 318 | - | for i, b := range tc.bounds { | |||
| 319 | - | if err := h.Append(row("abc"[:b.End]), b); err != nil { | |||
| 320 | - | t.Fatalf("append %d: %v", i, err) | |||
| 321 | - | } | |||
| 322 | - | } | |||
| 323 | - | view := h.SealAndView() | |||
| 324 | - | for i, want := range tc.bounds { | |||
| 325 | - | if got := view.Bound(i); got != want { | |||
| 326 | - | t.Errorf("Bound(%d) = %+v, want %+v", i, got, want) | |||
| 327 | - | } | |||
| 328 | - | } | |||
| 329 | - | }) | |||
| 330 | - | } | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | - | func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) { | |||
| 334 | - | h := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 1024, ChunkRows: 2}) | |||
| 335 | - | for i := range 5 { | |||
| 336 | - | cells := []renderer.Cell{{Rune: rune('a' + i)}} | |||
| 337 | - | if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil { | |||
| 338 | - | t.Fatalf("append %d: %v", i, err) | |||
| 339 | - | } | |||
| 340 | - | } | |||
| 341 | - | view := h.SealAndView() | |||
| 342 | - | if view.Len() != 3 { | |||
| 343 | - | t.Fatalf("Len() = %d, want 3", view.Len()) | |||
| 344 | - | } | |||
| 345 | - | // Rows 2, 3, 4 survived; their Soft flags were true, false, true. | |||
| 346 | - | for i, want := range []bool{true, false, true} { | |||
| 347 | - | if got := view.Bound(i).Soft; got != want { | |||
| 348 | - | t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.BorrowedRow(i)[0].Rune) | |||
| 349 | - | } | |||
| 350 | - | } | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | - | func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) { | |||
| 354 | - | history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1}) | |||
| 355 | - | if got, want := history.CellCap(), math.MaxInt; got != want { | |||
| 356 | - | t.Fatalf("default cell cap = %d, want %d", got, want) | |||
| 357 | - | } | |||
| 358 | - | } | |||
| 359 | - | ||||
| 360 | - | func historyRow(text string) []renderer.Cell { | |||
| 361 | - | row := make([]renderer.Cell, len(text)) | |||
| 362 | - | for i, r := range text { | |||
| 363 | - | row[i] = renderer.BlankCell() | |||
| 364 | - | row[i].Rune = r | |||
| 365 | - | } | |||
| 366 | - | return row | |||
| 367 | - | } | |||
| 368 | - | ||||
| 369 | - | func historyViewTexts(view HistoryView) []string { | |||
| 370 | - | rows := make([]string, view.Len()) | |||
| 371 | - | for i := range rows { | |||
| 372 | - | rows[i] = rowText(view.Row(i)) | |||
| 373 | - | } | |||
| 374 | - | return rows | |||
| 375 | - | } | |||
| 376 | - | ||||
| 377 | - | func rowText(row []renderer.Cell) string { | |||
| 378 | - | runes := make([]rune, len(row)) | |||
| 379 | - | for i, cell := range row { | |||
| 380 | - | runes[i] = cell.Rune | |||
| 381 | - | } | |||
| 382 | - | return string(runes) | |||
| 383 | - | } | |||
D pkg/vt/query_test.go +0 -207
| @@ -1,207 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func TestScreenQueryResponses(t *testing.T) { | |||
| 11 | - | cases := []struct { | |||
| 12 | - | name string | |||
| 13 | - | input string | |||
| 14 | - | want string | |||
| 15 | - | }{ | |||
| 16 | - | {name: "primary DA bare", input: "\x1b[c", want: "\x1b[?6c"}, | |||
| 17 | - | {name: "primary DA with 0", input: "\x1b[0c", want: "\x1b[?6c"}, | |||
| 18 | - | {name: "secondary DA", input: "\x1b[>c", want: "\x1b[>0;0;0c"}, | |||
| 19 | - | {name: "secondary DA with 0", input: "\x1b[>0c", want: "\x1b[>0;0;0c"}, | |||
| 20 | - | {name: "DSR status report", input: "\x1b[5n", want: "\x1b[0n"}, | |||
| 21 | - | {name: "CPR at home", input: "\x1b[6n", want: "\x1b[1;1R"}, | |||
| 22 | - | {name: "CPR after CUP", input: "\x1b[3;7H\x1b[6n", want: "\x1b[3;7R"}, | |||
| 23 | - | {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"}, | |||
| 24 | - | {name: "CPR in DECOM reports origin-relative row", input: "\x1b[2;4r\x1b[?6h\x1b[6n", want: "\x1b[1;1R"}, | |||
| 25 | - | {name: "DECRQM DECOM reset", input: "\x1b[?6$p", want: "\x1b[?6;2$y"}, | |||
| 26 | - | {name: "DECRQM DECOM set", input: "\x1b[?6h\x1b[?6$p", want: "\x1b[?6;1$y"}, | |||
| 27 | - | {name: "DECRQM IRM reset", input: "\x1b[4$p", want: "\x1b[4;2$y"}, | |||
| 28 | - | {name: "DECRQM IRM set", input: "\x1b[4h\x1b[4$p", want: "\x1b[4;1$y"}, | |||
| 29 | - | {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"}, | |||
| 30 | - | {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"}, | |||
| 31 | - | {name: "DECRQM 2031 reset", input: "\x1b[?2031$p", want: "\x1b[?2031;2$y"}, | |||
| 32 | - | {name: "DECRQM 2031 set", input: "\x1b[?2031h\x1b[?2031$p", want: "\x1b[?2031;1$y"}, | |||
| 33 | - | {name: "color scheme DSR dark", input: "\x1b[?996n", want: ColorSchemeReportDark}, | |||
| 34 | - | {name: "color scheme DSR light", input: "\x1b[?996n", want: ColorSchemeReportLight}, | |||
| 35 | - | {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"}, | |||
| 36 | - | {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""}, | |||
| 37 | - | {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""}, | |||
| 38 | - | {name: "DA split across writes", input: "", want: "\x1b[?6c"}, | |||
| 39 | - | } | |||
| 40 | - | for _, tc := range cases { | |||
| 41 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 42 | - | s := NewScreen(80, 24) | |||
| 43 | - | var got bytes.Buffer | |||
| 44 | - | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 45 | - | switch tc.name { | |||
| 46 | - | case "DA split across writes": | |||
| 47 | - | s.Write([]byte("\x1b[")) | |||
| 48 | - | s.Write([]byte("0c")) | |||
| 49 | - | case "color scheme DSR light": | |||
| 50 | - | s.SetColorScheme(true) | |||
| 51 | - | s.Write([]byte(tc.input)) | |||
| 52 | - | case "color scheme DSR dark": | |||
| 53 | - | s.SetColorScheme(false) | |||
| 54 | - | s.Write([]byte(tc.input)) | |||
| 55 | - | default: | |||
| 56 | - | s.Write([]byte(tc.input)) | |||
| 57 | - | } | |||
| 58 | - | if got.String() != tc.want { | |||
| 59 | - | t.Fatalf("response = %q, want %q", got.String(), tc.want) | |||
| 60 | - | } | |||
| 61 | - | }) | |||
| 62 | - | } | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | func TestScreenOSCDefaultColorQueries(t *testing.T) { | |||
| 66 | - | cases := []struct { | |||
| 67 | - | name string | |||
| 68 | - | input string | |||
| 69 | - | want string | |||
| 70 | - | }{ | |||
| 71 | - | {name: "foreground BEL", input: "\x1b]10;?\a", want: "\x1b]10;rgb:1212/3434/5656\a"}, | |||
| 72 | - | {name: "background BEL", input: "\x1b]11;?\a", want: "\x1b]11;rgb:7878/9a9a/bcbc\a"}, | |||
| 73 | - | {name: "foreground ST", input: "\x1b]10;?\x1b\\", want: "\x1b]10;rgb:1212/3434/5656\x1b\\"}, | |||
| 74 | - | {name: "background ST", input: "\x1b]11;?\x1b\\", want: "\x1b]11;rgb:7878/9a9a/bcbc\x1b\\"}, | |||
| 75 | - | } | |||
| 76 | - | for _, tc := range cases { | |||
| 77 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 78 | - | s := NewScreen(80, 24) | |||
| 79 | - | s.SetDefaultColors( | |||
| 80 | - | renderer.RGB{R: 0x12, G: 0x34, B: 0x56}, | |||
| 81 | - | renderer.RGB{R: 0x78, G: 0x9a, B: 0xbc}, | |||
| 82 | - | true, | |||
| 83 | - | ) | |||
| 84 | - | var got bytes.Buffer | |||
| 85 | - | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 86 | - | s.Write([]byte(tc.input)) | |||
| 87 | - | if got.String() != tc.want { | |||
| 88 | - | t.Fatalf("response = %q, want %q", got.String(), tc.want) | |||
| 89 | - | } | |||
| 90 | - | }) | |||
| 91 | - | } | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | func TestScreenOSCDefaultColorQueriesUnknownAreSilent(t *testing.T) { | |||
| 95 | - | s := NewScreen(80, 24) | |||
| 96 | - | s.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, false) | |||
| 97 | - | var got bytes.Buffer | |||
| 98 | - | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 99 | - | s.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |||
| 100 | - | if got.Len() != 0 { | |||
| 101 | - | t.Fatalf("response = %q, want silence", got.String()) | |||
| 102 | - | } | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | func TestScreenOSCDefaultColorQuerySplitAcrossWrites(t *testing.T) { | |||
| 106 | - | s := NewScreen(80, 24) | |||
| 107 | - | s.SetDefaultColors(renderer.RGB{R: 0x01, G: 0x02, B: 0x03}, renderer.RGB{R: 0x04, G: 0x05, B: 0x06}, true) | |||
| 108 | - | var got bytes.Buffer | |||
| 109 | - | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 110 | - | s.Write([]byte("\x1b]10")) | |||
| 111 | - | s.Write([]byte(";?\a")) | |||
| 112 | - | if got.String() != "\x1b]10;rgb:0101/0202/0303\a" { | |||
| 113 | - | t.Fatalf("response = %q", got.String()) | |||
| 114 | - | } | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | func TestScreenQueriesWithNilResponderDoNotPanic(t *testing.T) { | |||
| 118 | - | s := NewScreen(80, 24) | |||
| 119 | - | s.Write([]byte("\x1b[c\x1b[6n\x1b[?2026$p")) | |||
| 120 | - | s.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true) | |||
| 121 | - | s.Write([]byte("\x1b]10;?\a\x1b]11;?\x1b\\")) | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | func TestCSIuDispatch(t *testing.T) { | |||
| 125 | - | cases := []struct { | |||
| 126 | - | name string | |||
| 127 | - | input string | |||
| 128 | - | wantRow int | |||
| 129 | - | wantCol int | |||
| 130 | - | }{ | |||
| 131 | - | {name: "bare u restores cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[u", wantRow: 4, wantCol: 9}, | |||
| 132 | - | {name: "kitty query leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[?u", wantRow: 1, wantCol: 1}, | |||
| 133 | - | {name: "kitty push leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[>1u", wantRow: 1, wantCol: 1}, | |||
| 134 | - | {name: "kitty pop leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[<u", wantRow: 1, wantCol: 1}, | |||
| 135 | - | } | |||
| 136 | - | for _, tc := range cases { | |||
| 137 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 138 | - | s := NewScreen(80, 24) | |||
| 139 | - | s.Write([]byte(tc.input)) | |||
| 140 | - | if s.Row != tc.wantRow || s.Col != tc.wantCol { | |||
| 141 | - | t.Fatalf("cursor = %d;%d, want %d;%d", s.Row, s.Col, tc.wantRow, tc.wantCol) | |||
| 142 | - | } | |||
| 143 | - | }) | |||
| 144 | - | } | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | func TestScreenColorSchemeQueriesUnknownAreSilent(t *testing.T) { | |||
| 148 | - | s := NewScreen(80, 24) | |||
| 149 | - | s.SetColorScheme(true) | |||
| 150 | - | s.ClearColorScheme() | |||
| 151 | - | var got bytes.Buffer | |||
| 152 | - | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 153 | - | s.Write([]byte("\x1b[?996n")) | |||
| 154 | - | if got.Len() != 0 { | |||
| 155 | - | t.Fatalf("response = %q, want silence", got.String()) | |||
| 156 | - | } | |||
| 157 | - | } | |||
| 158 | - | ||||
| 159 | - | func TestScreenResetClearsColorSchemeModeSubscription(t *testing.T) { | |||
| 160 | - | s := NewScreen(80, 24) | |||
| 161 | - | var got bytes.Buffer | |||
| 162 | - | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 163 | - | ||||
| 164 | - | s.Write([]byte("\x1b[?2031h")) | |||
| 165 | - | s.Write([]byte("\x1bc")) | |||
| 166 | - | ||||
| 167 | - | s.SetColorScheme(true) | |||
| 168 | - | if got.Len() != 0 { | |||
| 169 | - | t.Fatalf("unsolicited response after reset = %q, want silence", got.String()) | |||
| 170 | - | } | |||
| 171 | - | ||||
| 172 | - | got.Reset() | |||
| 173 | - | s.Write([]byte("\x1b[?2031$p")) | |||
| 174 | - | if got.String() != "\x1b[?2031;2$y" { | |||
| 175 | - | t.Fatalf("DECRQM 2031 after reset = %q, want reset state", got.String()) | |||
| 176 | - | } | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) { | |||
| 180 | - | s := NewScreen(80, 24) | |||
| 181 | - | var got bytes.Buffer | |||
| 182 | - | s.OnResponse = func(b []byte) { got.Write(b) } | |||
| 183 | - | ||||
| 184 | - | s.SetColorScheme(true) | |||
| 185 | - | if got.Len() != 0 { | |||
| 186 | - | t.Fatalf("unsubscribed response = %q, want silence", got.String()) | |||
| 187 | - | } | |||
| 188 | - | ||||
| 189 | - | s.Write([]byte("\x1b[?2031h")) | |||
| 190 | - | if !s.ColorSchemeMode() { | |||
| 191 | - | t.Fatalf("ColorSchemeMode() = false, want true") | |||
| 192 | - | } | |||
| 193 | - | s.SetColorScheme(true) | |||
| 194 | - | if got.Len() != 0 { | |||
| 195 | - | t.Fatalf("unchanged response = %q, want silence", got.String()) | |||
| 196 | - | } | |||
| 197 | - | s.SetColorScheme(false) | |||
| 198 | - | if got.String() != ColorSchemeReportDark { | |||
| 199 | - | t.Fatalf("changed response = %q, want dark notification", got.String()) | |||
| 200 | - | } | |||
| 201 | - | got.Reset() | |||
| 202 | - | s.Write([]byte("\x1b[?2031l")) | |||
| 203 | - | s.SetColorScheme(true) | |||
| 204 | - | if got.Len() != 0 { | |||
| 205 | - | t.Fatalf("disabled response = %q, want silence", got.String()) | |||
| 206 | - | } | |||
| 207 | - | } | |||
D pkg/vt/recovery_transcript.go +0 -125
| @@ -1,125 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | - | ||||
| 5 | - | // RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport | |||
| 6 | - | // rows that should be replayed after retained terminal history during recovery. | |||
| 7 | - | type RecoveryTranscriptSnapshot struct { | |||
| 8 | - | segments []recoveryTranscriptSegment | |||
| 9 | - | nextRowID RowID | |||
| 10 | - | } | |||
| 11 | - | ||||
| 12 | - | type recoveryTranscriptSegment struct { | |||
| 13 | - | rows [][]renderer.Cell | |||
| 14 | - | bounds []LineBound | |||
| 15 | - | rowIDs []RowID | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | // RecoveryTranscriptSnapshot captures the active primary viewport, or the | |||
| 19 | - | // saved primary viewport followed by the active alternate viewport. | |||
| 20 | - | func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot { | |||
| 21 | - | if s == nil { | |||
| 22 | - | return RecoveryTranscriptSnapshot{} | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | buffers := []*buffer{s.buffer} | |||
| 26 | - | if s.alternate != nil { | |||
| 27 | - | buffers = []*buffer{s.alternate.buffer, s.buffer} | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | nextRowID := s.nextRowID | |||
| 31 | - | if nextRowID < ^RowID(0) { | |||
| 32 | - | nextRowID++ | |||
| 33 | - | } | |||
| 34 | - | snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID} | |||
| 35 | - | for _, b := range buffers { | |||
| 36 | - | segment := captureRecoveryTranscriptSegment(b) | |||
| 37 | - | if len(segment.rows) > 0 { | |||
| 38 | - | snapshot.segments = append(snapshot.segments, segment) | |||
| 39 | - | } | |||
| 40 | - | } | |||
| 41 | - | return snapshot | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment { | |||
| 45 | - | if b == nil { | |||
| 46 | - | return recoveryTranscriptSegment{} | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | rowCount := b.frame.Height | |||
| 50 | - | for rowCount > 0 && recoveryTranscriptRowUntouched(b.frame.Row(rowCount-1), b.bound(rowCount-1)) { | |||
| 51 | - | rowCount-- | |||
| 52 | - | } | |||
| 53 | - | if rowCount == 0 { | |||
| 54 | - | return recoveryTranscriptSegment{} | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | segment := recoveryTranscriptSegment{ | |||
| 58 | - | rows: make([][]renderer.Cell, rowCount), | |||
| 59 | - | bounds: append([]LineBound(nil), b.boundaries[:rowCount]...), | |||
| 60 | - | rowIDs: append([]RowID(nil), b.rowIDs[:rowCount]...), | |||
| 61 | - | } | |||
| 62 | - | cells := make([]renderer.Cell, rowCount*b.frame.Width) | |||
| 63 | - | for y := range rowCount { | |||
| 64 | - | start := y * b.frame.Width | |||
| 65 | - | end := start + b.frame.Width | |||
| 66 | - | segment.rows[y] = cells[start:end:end] | |||
| 67 | - | copy(segment.rows[y], b.frame.Row(y)) | |||
| 68 | - | } | |||
| 69 | - | segment.bounds[rowCount-1].Soft = false | |||
| 70 | - | return segment | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | func recoveryTranscriptRowUntouched(row []renderer.Cell, bound LineBound) bool { | |||
| 74 | - | if bound.End != 0 || bound.Soft { | |||
| 75 | - | return false | |||
| 76 | - | } | |||
| 77 | - | blank := renderer.BlankCell() | |||
| 78 | - | for _, cell := range row { | |||
| 79 | - | if !cell.Equal(blank) { | |||
| 80 | - | return false | |||
| 81 | - | } | |||
| 82 | - | } | |||
| 83 | - | return true | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | // Marshal encodes the captured rows as canonical terminal history without | |||
| 87 | - | // consulting or mutating live Screen history. | |||
| 88 | - | func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) { | |||
| 89 | - | rows := 0 | |||
| 90 | - | cells := 0 | |||
| 91 | - | for _, segment := range snapshot.segments { | |||
| 92 | - | rows += len(segment.rows) | |||
| 93 | - | for _, row := range segment.rows { | |||
| 94 | - | cells += len(row) | |||
| 95 | - | } | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | view := HistoryView{rows: rows, cells: cells, nextRowID: snapshot.nextRowID} | |||
| 99 | - | for _, segment := range snapshot.segments { | |||
| 100 | - | for start := 0; start < len(segment.rows); { | |||
| 101 | - | space := maxHistoryChunkRows | |||
| 102 | - | if len(view.chunks) > 0 { | |||
| 103 | - | last := view.chunks[len(view.chunks)-1] | |||
| 104 | - | if len(last.rows) < maxHistoryChunkRows { | |||
| 105 | - | space = maxHistoryChunkRows - len(last.rows) | |||
| 106 | - | end := min(start+space, len(segment.rows)) | |||
| 107 | - | last.rows = append(last.rows, segment.rows[start:end]...) | |||
| 108 | - | last.bounds = append(last.bounds, segment.bounds[start:end]...) | |||
| 109 | - | last.rowIDs = append(last.rowIDs, segment.rowIDs[start:end]...) | |||
| 110 | - | start = end | |||
| 111 | - | continue | |||
| 112 | - | } | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | end := min(start+space, len(segment.rows)) | |||
| 116 | - | view.chunks = append(view.chunks, &HistoryChunk{ | |||
| 117 | - | rows: append([][]renderer.Cell(nil), segment.rows[start:end]...), | |||
| 118 | - | bounds: append([]LineBound(nil), segment.bounds[start:end]...), | |||
| 119 | - | rowIDs: append([]RowID(nil), segment.rowIDs[start:end]...), | |||
| 120 | - | }) | |||
| 121 | - | start = end | |||
| 122 | - | } | |||
| 123 | - | } | |||
| 124 | - | return MarshalHistory(view) | |||
| 125 | - | } | |||
D pkg/vt/recovery_transcript_ownership_test.go +0 -29
| @@ -1,29 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/stretchr/testify/require" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func TestRecoveryTranscriptSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) { | |||
| 10 | - | s := NewScreen(8, 2) | |||
| 11 | - | s.Write([]byte("primary")) | |||
| 12 | - | s.Write([]byte("\x1b[?1049h")) | |||
| 13 | - | ||||
| 14 | - | captured := s.RecoveryTranscriptSnapshot() | |||
| 15 | - | mutated := make(chan struct{}) | |||
| 16 | - | go func() { | |||
| 17 | - | s.Write([]byte("alternate")) | |||
| 18 | - | s.Resize(3, 3) | |||
| 19 | - | s.Write([]byte("\x1b[?1049lmutated")) | |||
| 20 | - | close(mutated) | |||
| 21 | - | }() | |||
| 22 | - | ||||
| 23 | - | blob, err := captured.Marshal() | |||
| 24 | - | <-mutated | |||
| 25 | - | require.NoError(t, err) | |||
| 26 | - | view, err := UnmarshalHistory(blob) | |||
| 27 | - | require.NoError(t, err) | |||
| 28 | - | require.Equal(t, []string{"primary "}, historyViewTexts(view)) | |||
| 29 | - | } | |||
D pkg/vt/recovery_transcript_test.go +0 -419
| @@ -1,419 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | "github.com/stretchr/testify/require" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | var ( | |||
| 11 | - | recoveryTranscriptSnapshotSink RecoveryTranscriptSnapshot | |||
| 12 | - | recoveryTranscriptScreenSink *Screen | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) { | |||
| 16 | - | s := NewScreen(4, 2) | |||
| 17 | - | s.Write([]byte("keep")) | |||
| 18 | - | ||||
| 19 | - | snapshot := s.RecoveryTranscriptSnapshot() | |||
| 20 | - | s.Frame.Set(0, 0, renderer.BlankCell()) | |||
| 21 | - | s.buffer.boundaries[0] = LineBound{} | |||
| 22 | - | ||||
| 23 | - | view := decodeRecoveryTranscript(t, snapshot) | |||
| 24 | - | require.Equal(t, []string{"keep"}, historyViewTexts(view)) | |||
| 25 | - | require.Equal(t, LineBound{End: 4}, view.Bound(0)) | |||
| 26 | - | } | |||
| 27 | - | ||||
| 28 | - | func TestRecoveryTranscriptSnapshotRetainsReflowBounds(t *testing.T) { | |||
| 29 | - | s := NewScreen(5, 3) | |||
| 30 | - | s.Write([]byte("abcdefgh")) | |||
| 31 | - | ||||
| 32 | - | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 33 | - | ||||
| 34 | - | require.Equal(t, []string{"abcde", "fgh "}, historyViewTexts(view)) | |||
| 35 | - | require.Equal(t, LineBound{End: 5, Soft: true}, view.Bound(0)) | |||
| 36 | - | require.Equal(t, LineBound{End: 3}, view.Bound(1)) | |||
| 37 | - | } | |||
| 38 | - | ||||
| 39 | - | func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *testing.T) { | |||
| 40 | - | const ( | |||
| 41 | - | shortHeight = 8 | |||
| 42 | - | tallHeight = 512 | |||
| 43 | - | ) | |||
| 44 | - | newRetainedScreen := func(height int) *Screen { | |||
| 45 | - | screen := NewScreen(4, height) | |||
| 46 | - | for y := range height { | |||
| 47 | - | screen.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 48 | - | screen.buffer.boundaries[y] = LineBound{End: 1} | |||
| 49 | - | } | |||
| 50 | - | return screen | |||
| 51 | - | } | |||
| 52 | - | short, tall := newRetainedScreen(shortHeight), newRetainedScreen(tallHeight) | |||
| 53 | - | ||||
| 54 | - | shortAllocs := testing.AllocsPerRun(20, func() { | |||
| 55 | - | recoveryTranscriptSnapshotSink = short.RecoveryTranscriptSnapshot() | |||
| 56 | - | }) | |||
| 57 | - | tallAllocs := testing.AllocsPerRun(20, func() { | |||
| 58 | - | recoveryTranscriptSnapshotSink = tall.RecoveryTranscriptSnapshot() | |||
| 59 | - | }) | |||
| 60 | - | ||||
| 61 | - | // Allow a small fixed variance while rejecting the former allocation per row. | |||
| 62 | - | if tallAllocs > shortAllocs+4 { | |||
| 63 | - | t.Fatalf("snapshot allocations scale with retained rows: height %d = %.0f, height %d = %.0f", shortHeight, shortAllocs, tallHeight, tallAllocs) | |||
| 64 | - | } | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSeams(t *testing.T) { | |||
| 68 | - | s := NewScreen(4, 3) | |||
| 69 | - | s.Write([]byte("prim")) | |||
| 70 | - | s.buffer.boundaries[0].Soft = true | |||
| 71 | - | s.Write([]byte("\x1b[?1049h")) | |||
| 72 | - | s.Write([]byte("alt")) | |||
| 73 | - | s.buffer.boundaries[0].Soft = true | |||
| 74 | - | ||||
| 75 | - | snapshot := s.RecoveryTranscriptSnapshot() | |||
| 76 | - | s.Frame.Set(0, 0, renderer.BlankCell()) | |||
| 77 | - | s.buffer.boundaries[0] = LineBound{} | |||
| 78 | - | s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell()) | |||
| 79 | - | s.alternate.buffer.boundaries[0] = LineBound{} | |||
| 80 | - | ||||
| 81 | - | view := decodeRecoveryTranscript(t, snapshot) | |||
| 82 | - | ||||
| 83 | - | require.Equal(t, []string{"prim", "alt "}, historyViewTexts(view)) | |||
| 84 | - | require.Equal(t, LineBound{End: 4}, view.Bound(0), "saved-primary seam must be hard") | |||
| 85 | - | require.Equal(t, LineBound{End: 3}, view.Bound(1), "final alternate seam must be hard") | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | func TestRecoveryTranscriptSnapshotTrimsEachViewportIndependently(t *testing.T) { | |||
| 89 | - | s := NewScreen(4, 3) | |||
| 90 | - | s.Write([]byte("main")) | |||
| 91 | - | s.Write([]byte("\x1b[?1049h")) | |||
| 92 | - | s.Write([]byte("alt")) | |||
| 93 | - | ||||
| 94 | - | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 95 | - | ||||
| 96 | - | require.Equal(t, []string{"main", "alt "}, historyViewTexts(view)) | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | func TestRecoveryTranscriptSnapshotExcludesExistingBoundedHistory(t *testing.T) { | |||
| 100 | - | s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2}) | |||
| 101 | - | require.NoError(t, s.History().Append(historyRow("old"), LineBound{End: 3})) | |||
| 102 | - | s.Write([]byte("live")) | |||
| 103 | - | ||||
| 104 | - | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 105 | - | ||||
| 106 | - | require.Equal(t, []string{"live"}, historyViewTexts(view)) | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T) { | |||
| 110 | - | styledBlank := renderer.BlankCell() | |||
| 111 | - | styledBlank.Style.Bold = true | |||
| 112 | - | ||||
| 113 | - | tests := []struct { | |||
| 114 | - | name string | |||
| 115 | - | candidateCell renderer.Cell | |||
| 116 | - | candidateBound LineBound | |||
| 117 | - | wantRows int | |||
| 118 | - | wantCell renderer.Cell | |||
| 119 | - | }{ | |||
| 120 | - | { | |||
| 121 | - | name: "untouched default blank with zero hard bound", | |||
| 122 | - | candidateCell: renderer.BlankCell(), | |||
| 123 | - | wantRows: 1, | |||
| 124 | - | }, | |||
| 125 | - | { | |||
| 126 | - | name: "written default space", | |||
| 127 | - | candidateCell: renderer.BlankCell(), | |||
| 128 | - | candidateBound: LineBound{End: 1}, | |||
| 129 | - | wantRows: 2, | |||
| 130 | - | wantCell: renderer.BlankCell(), | |||
| 131 | - | }, | |||
| 132 | - | { | |||
| 133 | - | name: "styled blank", | |||
| 134 | - | candidateCell: styledBlank, | |||
| 135 | - | wantRows: 2, | |||
| 136 | - | wantCell: styledBlank, | |||
| 137 | - | }, | |||
| 138 | - | { | |||
| 139 | - | name: "soft blank", | |||
| 140 | - | candidateCell: renderer.BlankCell(), | |||
| 141 | - | candidateBound: LineBound{Soft: true}, | |||
| 142 | - | wantRows: 2, | |||
| 143 | - | wantCell: renderer.BlankCell(), | |||
| 144 | - | }, | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | for _, tt := range tests { | |||
| 148 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 149 | - | s := NewScreen(3, 3) | |||
| 150 | - | s.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 151 | - | s.buffer.boundaries[0] = LineBound{End: 1} | |||
| 152 | - | s.Frame.Set(0, 1, tt.candidateCell) | |||
| 153 | - | s.buffer.boundaries[1] = tt.candidateBound | |||
| 154 | - | ||||
| 155 | - | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 156 | - | ||||
| 157 | - | require.Equal(t, tt.wantRows, view.Len()) | |||
| 158 | - | if tt.wantRows == 2 { | |||
| 159 | - | require.True(t, view.Row(1)[0].Equal(tt.wantCell)) | |||
| 160 | - | require.False(t, view.Bound(1).Soft, "the retained segment's final row must be hardened") | |||
| 161 | - | } | |||
| 162 | - | }) | |||
| 163 | - | } | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) { | |||
| 167 | - | s := NewScreen(3, 4) | |||
| 168 | - | s.Frame.Set(0, 2, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 169 | - | s.buffer.boundaries[2] = LineBound{End: 1} | |||
| 170 | - | ||||
| 171 | - | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 172 | - | ||||
| 173 | - | require.Equal(t, []string{" ", " ", "x "}, historyViewTexts(view)) | |||
| 174 | - | require.Equal(t, []LineBound{{}, {}, {End: 1}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)}) | |||
| 175 | - | } | |||
| 176 | - | ||||
| 177 | - | func TestRecoveryTranscriptSnapshotPreservesBoundsAndHardensOnlyTheFinalRow(t *testing.T) { | |||
| 178 | - | s := NewScreen(4, 3) | |||
| 179 | - | s.buffer.boundaries = []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3, Soft: true}} | |||
| 180 | - | ||||
| 181 | - | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 182 | - | ||||
| 183 | - | require.Equal(t, []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)}) | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) { | |||
| 187 | - | s := NewScreen(4, 2) | |||
| 188 | - | ||||
| 189 | - | got, err := s.RecoveryTranscriptSnapshot().Marshal() | |||
| 190 | - | require.NoError(t, err) | |||
| 191 | - | want, err := MarshalHistory(HistoryView{nextRowID: 3}) | |||
| 192 | - | require.NoError(t, err) | |||
| 193 | - | ||||
| 194 | - | require.Equal(t, want, got) | |||
| 195 | - | view, err := UnmarshalHistory(got) | |||
| 196 | - | require.NoError(t, err) | |||
| 197 | - | require.Zero(t, view.Len()) | |||
| 198 | - | require.Zero(t, view.ChunkCount()) | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) { | |||
| 202 | - | s := NewScreen(1, 257) | |||
| 203 | - | for y := range s.Frame.Height { | |||
| 204 | - | s.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 205 | - | s.buffer.boundaries[y] = LineBound{End: 1, Soft: true} | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot()) | |||
| 209 | - | ||||
| 210 | - | require.Equal(t, 257, view.Len()) | |||
| 211 | - | require.Equal(t, 2, view.ChunkCount()) | |||
| 212 | - | require.Len(t, view.Chunk(0).rows, 256) | |||
| 213 | - | require.Len(t, view.Chunk(1).rows, 1) | |||
| 214 | - | require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam") | |||
| 215 | - | require.False(t, view.Bound(256).Soft, "the transcript's final seam must be hard") | |||
| 216 | - | } | |||
| 217 | - | ||||
| 218 | - | func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) { | |||
| 219 | - | s := NewScreen(4, 2) | |||
| 220 | - | s.Write([]byte("界")) | |||
| 221 | - | want := append([]renderer.Cell(nil), s.Frame.Row(0)...) | |||
| 222 | - | ||||
| 223 | - | snapshot := s.RecoveryTranscriptSnapshot() | |||
| 224 | - | for x := range s.Frame.Width { | |||
| 225 | - | s.Frame.Set(x, 0, renderer.BlankCell()) | |||
| 226 | - | } | |||
| 227 | - | ||||
| 228 | - | view := decodeRecoveryTranscript(t, snapshot) | |||
| 229 | - | require.Equal(t, want, view.Row(0)) | |||
| 230 | - | require.Equal(t, LineBound{End: 2}, view.Bound(0)) | |||
| 231 | - | require.True(t, view.Row(0)[1].Continuation) | |||
| 232 | - | } | |||
| 233 | - | ||||
| 234 | - | func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBlank(t *testing.T) { | |||
| 235 | - | sealed, tail := recoveryHistoryBlobs(t, []string{"sealed-a", "sealed-b", "tail"}) | |||
| 236 | - | transcript := recoveryTranscriptBlob(t, | |||
| 237 | - | []string{"primary", "alternate"}, | |||
| 238 | - | []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}}, | |||
| 239 | - | ) | |||
| 240 | - | ||||
| 241 | - | screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxCells: 128, ChunkRows: 2}, sealed, tail, transcript) | |||
| 242 | - | require.NoError(t, err) | |||
| 243 | - | ||||
| 244 | - | view := screen.History().View() | |||
| 245 | - | require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view)) | |||
| 246 | - | require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3)) | |||
| 247 | - | require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam") | |||
| 248 | - | for y := range screen.Frame.Height { | |||
| 249 | - | for x := range screen.Frame.Width { | |||
| 250 | - | require.True(t, screen.Frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y) | |||
| 251 | - | } | |||
| 252 | - | } | |||
| 253 | - | require.Equal(t, []LineBound{{}, {}}, screen.LineBounds()) | |||
| 254 | - | } | |||
| 255 | - | ||||
| 256 | - | func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing.T) { | |||
| 257 | - | tests := []struct { | |||
| 258 | - | name string | |||
| 259 | - | config HistoryConfig | |||
| 260 | - | history []string | |||
| 261 | - | transcript []string | |||
| 262 | - | want []string | |||
| 263 | - | }{ | |||
| 264 | - | { | |||
| 265 | - | name: "row budget", | |||
| 266 | - | config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2}, | |||
| 267 | - | history: []string{"old-1", "old-2", "old-3"}, | |||
| 268 | - | transcript: []string{"new-1", "new-2"}, | |||
| 269 | - | want: []string{"old-3", "new-1", "new-2"}, | |||
| 270 | - | }, | |||
| 271 | - | { | |||
| 272 | - | name: "cell budget", | |||
| 273 | - | config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4}, | |||
| 274 | - | history: []string{"aa", "b", "ccc"}, | |||
| 275 | - | transcript: []string{"d", "ee"}, | |||
| 276 | - | want: []string{"d", "ee"}, | |||
| 277 | - | }, | |||
| 278 | - | } | |||
| 279 | - | ||||
| 280 | - | for _, tt := range tests { | |||
| 281 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 282 | - | sealed, tail := recoveryHistoryBlobs(t, tt.history) | |||
| 283 | - | transcript := recoveryTranscriptBlob(t, tt.transcript, nil) | |||
| 284 | - | ||||
| 285 | - | screen, err := NewScreenWithRecoveryTranscript(4, 2, tt.config, sealed, tail, transcript) | |||
| 286 | - | require.NoError(t, err) | |||
| 287 | - | require.Equal(t, tt.want, historyViewTexts(screen.History().View())) | |||
| 288 | - | }) | |||
| 289 | - | } | |||
| 290 | - | } | |||
| 291 | - | ||||
| 292 | - | func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing.T) { | |||
| 293 | - | sealed, tail := recoveryHistoryBlobs(t, []string{"ok"}) | |||
| 294 | - | transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil) | |||
| 295 | - | ||||
| 296 | - | screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxCells: 3}, sealed, tail, transcript) | |||
| 297 | - | ||||
| 298 | - | require.ErrorIs(t, err, ErrHistoryRowTooWide) | |||
| 299 | - | require.Nil(t, screen) | |||
| 300 | - | } | |||
| 301 | - | ||||
| 302 | - | func BenchmarkRecoveryTranscriptSnapshot(b *testing.B) { | |||
| 303 | - | for _, tt := range []struct { | |||
| 304 | - | name string | |||
| 305 | - | alternate bool | |||
| 306 | - | wantRows int | |||
| 307 | - | }{ | |||
| 308 | - | {name: "primary", wantRows: 40}, | |||
| 309 | - | {name: "primary-and-active-alternate", alternate: true, wantRows: 80}, | |||
| 310 | - | } { | |||
| 311 | - | b.Run(tt.name, func(b *testing.B) { | |||
| 312 | - | screen := NewScreen(120, 40) | |||
| 313 | - | fillRecoveryTranscriptBenchmarkFrame(screen) | |||
| 314 | - | if tt.alternate { | |||
| 315 | - | screen.Write([]byte("\x1b[?1049h")) | |||
| 316 | - | fillRecoveryTranscriptBenchmarkFrame(screen) | |||
| 317 | - | } | |||
| 318 | - | ||||
| 319 | - | b.ReportAllocs() | |||
| 320 | - | b.ReportMetric(float64(tt.wantRows), "rows/snapshot") | |||
| 321 | - | b.ResetTimer() | |||
| 322 | - | for b.Loop() { | |||
| 323 | - | recoveryTranscriptSnapshotSink = screen.RecoveryTranscriptSnapshot() | |||
| 324 | - | } | |||
| 325 | - | b.StopTimer() | |||
| 326 | - | if got := decodeRecoveryTranscript(b, recoveryTranscriptSnapshotSink).Len(); got != tt.wantRows { | |||
| 327 | - | b.Fatalf("captured rows = %d, want %d", got, tt.wantRows) | |||
| 328 | - | } | |||
| 329 | - | }) | |||
| 330 | - | } | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | - | func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) { | |||
| 334 | - | for y := range screen.Frame.Height { | |||
| 335 | - | for x := range screen.Frame.Width { | |||
| 336 | - | screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()}) | |||
| 337 | - | } | |||
| 338 | - | screen.buffer.boundaries[y] = LineBound{End: screen.Frame.Width} | |||
| 339 | - | } | |||
| 340 | - | } | |||
| 341 | - | ||||
| 342 | - | func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) { | |||
| 343 | - | const rows = maxHistoryRows | |||
| 344 | - | source := NewScreen(1, rows) | |||
| 345 | - | for y := range rows { | |||
| 346 | - | source.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 347 | - | source.buffer.boundaries[y] = LineBound{End: 1} | |||
| 348 | - | } | |||
| 349 | - | transcript, err := source.RecoveryTranscriptSnapshot().Marshal() | |||
| 350 | - | require.NoError(b, err) | |||
| 351 | - | tail, err := MarshalEmptyHistoryTail() | |||
| 352 | - | require.NoError(b, err) | |||
| 353 | - | config := HistoryConfig{MaxRows: rows, MaxCells: rows, ChunkRows: maxHistoryChunkRows} | |||
| 354 | - | ||||
| 355 | - | b.ReportAllocs() | |||
| 356 | - | b.ReportMetric(float64(rows), "rows/restore") | |||
| 357 | - | b.ReportMetric(float64((rows+maxHistoryChunkRows-1)/maxHistoryChunkRows), "chunks/restore") | |||
| 358 | - | b.ResetTimer() | |||
| 359 | - | for b.Loop() { | |||
| 360 | - | recoveryTranscriptScreenSink, err = NewScreenWithRecoveryTranscript(1, 1, config, nil, tail, transcript) | |||
| 361 | - | if err != nil { | |||
| 362 | - | b.Fatal(err) | |||
| 363 | - | } | |||
| 364 | - | } | |||
| 365 | - | } | |||
| 366 | - | ||||
| 367 | - | func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) { | |||
| 368 | - | t.Helper() | |||
| 369 | - | history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2}) | |||
| 370 | - | for _, text := range texts { | |||
| 371 | - | require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)})) | |||
| 372 | - | } | |||
| 373 | - | view := history.SnapshotView() | |||
| 374 | - | sealed := make([][]byte, view.ChunkCount()) | |||
| 375 | - | for i := range sealed { | |||
| 376 | - | var err error | |||
| 377 | - | sealed[i], err = MarshalHistoryChunk(view.Chunk(i)) | |||
| 378 | - | require.NoError(t, err) | |||
| 379 | - | } | |||
| 380 | - | tail, err := MarshalHistoryTail(view) | |||
| 381 | - | require.NoError(t, err) | |||
| 382 | - | return sealed, tail | |||
| 383 | - | } | |||
| 384 | - | ||||
| 385 | - | func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []byte { | |||
| 386 | - | t.Helper() | |||
| 387 | - | rows := make([][]renderer.Cell, len(texts)) | |||
| 388 | - | cells := 0 | |||
| 389 | - | for i, text := range texts { | |||
| 390 | - | rows[i] = historyRow(text) | |||
| 391 | - | cells += len(rows[i]) | |||
| 392 | - | } | |||
| 393 | - | if bounds == nil { | |||
| 394 | - | bounds = make([]LineBound, len(rows)) | |||
| 395 | - | for i, row := range rows { | |||
| 396 | - | bounds[i].End = len(row) | |||
| 397 | - | } | |||
| 398 | - | } | |||
| 399 | - | rowIDs := make([]RowID, len(rows)) | |||
| 400 | - | for i := range rowIDs { | |||
| 401 | - | rowIDs[i] = RowID(100 + i) | |||
| 402 | - | } | |||
| 403 | - | view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))} | |||
| 404 | - | if len(rows) > 0 { | |||
| 405 | - | view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds, rowIDs: rowIDs}} | |||
| 406 | - | } | |||
| 407 | - | blob, err := MarshalHistory(view) | |||
| 408 | - | require.NoError(t, err) | |||
| 409 | - | return blob | |||
| 410 | - | } | |||
| 411 | - | ||||
| 412 | - | func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView { | |||
| 413 | - | t.Helper() | |||
| 414 | - | blob, err := snapshot.Marshal() | |||
| 415 | - | require.NoError(t, err) | |||
| 416 | - | view, err := UnmarshalHistory(blob) | |||
| 417 | - | require.NoError(t, err) | |||
| 418 | - | return view | |||
| 419 | - | } | |||
D pkg/vt/region_reflow_test.go +0 -52
| @@ -1,52 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/stretchr/testify/require" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func TestResizeSeversSoftWrapsAtRegionalScrollEdges(t *testing.T) { | |||
| 10 | - | tests := []struct { | |||
| 11 | - | name string | |||
| 12 | - | seq string | |||
| 13 | - | want []string | |||
| 14 | - | }{ | |||
| 15 | - | { | |||
| 16 | - | name: "CSI S", | |||
| 17 | - | seq: "\x1b[2;4r\x1b[S", | |||
| 18 | - | want: []string{"ABC", "IJK", "LMN", "OP ", " ", "QRS"}, | |||
| 19 | - | }, | |||
| 20 | - | { | |||
| 21 | - | name: "CSI T", | |||
| 22 | - | seq: "\x1b[2;4r\x1b[T", | |||
| 23 | - | want: []string{"ABC", " ", "EFG", "HIJ", "KL ", "QRS"}, | |||
| 24 | - | }, | |||
| 25 | - | { | |||
| 26 | - | name: "insert lines", | |||
| 27 | - | seq: "\x1b[2;4r\x1b[2;1H\x1b[L", | |||
| 28 | - | want: []string{"ABC", " ", "EFG", "HIJ", "KL ", "QRS"}, | |||
| 29 | - | }, | |||
| 30 | - | { | |||
| 31 | - | name: "delete lines", | |||
| 32 | - | seq: "\x1b[2;4r\x1b[2;1H\x1b[M", | |||
| 33 | - | want: []string{"ABC", "IJK", "LMN", "OP ", " ", "QRS"}, | |||
| 34 | - | }, | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | for _, tt := range tests { | |||
| 38 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 39 | - | s := NewScreen(4, 5) | |||
| 40 | - | s.Write([]byte("ABCDEFGHIJKLMNOPQRST")) | |||
| 41 | - | s.Write([]byte(tt.seq)) | |||
| 42 | - | ||||
| 43 | - | s.Resize(3, 6) | |||
| 44 | - | ||||
| 45 | - | got := make([]string, s.Frame.Height) | |||
| 46 | - | for y := range s.Frame.Height { | |||
| 47 | - | got[y] = rowString(s.Frame.Row(y)) | |||
| 48 | - | } | |||
| 49 | - | require.Equal(t, tt.want, got) | |||
| 50 | - | }) | |||
| 51 | - | } | |||
| 52 | - | } | |||
D pkg/vt/resize_reflow_regression_test.go +0 -54
| @@ -1,54 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import "testing" | |||
| 4 | - | ||||
| 5 | - | func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) { | |||
| 6 | - | s := NewScreen(5, 3) | |||
| 7 | - | s.Write([]byte("shell")) | |||
| 8 | - | s.Write([]byte("\x1b[?1049h")) | |||
| 9 | - | s.Write([]byte("\x1b[")) | |||
| 10 | - | ||||
| 11 | - | s.Resize(0, 0) | |||
| 12 | - | ||||
| 13 | - | if len(s.escapeBuf) != 0 { | |||
| 14 | - | t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf) | |||
| 15 | - | } | |||
| 16 | - | if s.Frame.Width != 0 || s.Frame.Height != 0 { | |||
| 17 | - | t.Fatalf("active frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height) | |||
| 18 | - | } | |||
| 19 | - | s.Write([]byte("\x1b[?1049l")) | |||
| 20 | - | if s.Frame.Width != 0 || s.Frame.Height != 0 { | |||
| 21 | - | t.Fatalf("saved primary frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height) | |||
| 22 | - | } | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | func TestResizeReflowDropsAbandonedWideEdgePadding(t *testing.T) { | |||
| 26 | - | s := NewScreen(4, 3) | |||
| 27 | - | s.Write([]byte("ab界")) | |||
| 28 | - | s.Write([]byte("\x1b[1;4H界x")) | |||
| 29 | - | ||||
| 30 | - | s.Resize(6, 3) | |||
| 31 | - | ||||
| 32 | - | assertCell(t, s, 0, 0, 'a') | |||
| 33 | - | assertCell(t, s, 1, 0, 'b') | |||
| 34 | - | assertCell(t, s, 2, 0, '界') | |||
| 35 | - | if !cellAt(s, 3, 0).Continuation { | |||
| 36 | - | t.Fatal("reflowed wide rune lost continuation") | |||
| 37 | - | } | |||
| 38 | - | assertCell(t, s, 4, 0, 'x') | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | func TestInsertModeRetainsShiftedTailInReflowExtent(t *testing.T) { | |||
| 42 | - | s := NewScreen(6, 2) | |||
| 43 | - | s.Write([]byte("abcd")) | |||
| 44 | - | s.Write([]byte("\x1b[1;2H\x1b[4hX")) | |||
| 45 | - | // Simulate the subsequent logical continuation that causes the row to be | |||
| 46 | - | // reflowed. Its meaningful extent must include the shifted tail. | |||
| 47 | - | s.buffer.continueRow(0) | |||
| 48 | - | ||||
| 49 | - | s.Resize(5, 2) | |||
| 50 | - | ||||
| 51 | - | for x, r := range []rune("aXbcd") { | |||
| 52 | - | assertCell(t, s, x, 0, r) | |||
| 53 | - | } | |||
| 54 | - | } | |||
D pkg/vt/row_id.go +0 -49
| @@ -1,49 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | // RowID identifies one physical terminal row for the lifetime of a Screen. | |||
| 4 | - | // Row IDs are never zero and are never reused by their owning Screen. | |||
| 5 | - | type RowID uint64 | |||
| 6 | - | ||||
| 7 | - | func (s *Screen) nextRowIDValue() RowID { | |||
| 8 | - | if s.nextRowID >= ^RowID(0)-2 { | |||
| 9 | - | panic("vt: row ID space exhausted") | |||
| 10 | - | } | |||
| 11 | - | s.nextRowID++ | |||
| 12 | - | return s.nextRowID | |||
| 13 | - | } | |||
| 14 | - | ||||
| 15 | - | func (s *Screen) newBuffer(width, height int) *buffer { | |||
| 16 | - | b := newBuffer(width, height) | |||
| 17 | - | for i := range b.rowIDs { | |||
| 18 | - | b.rowIDs[i] = s.nextRowIDValue() | |||
| 19 | - | } | |||
| 20 | - | return b | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | func (s *Screen) fillMissingRowIDs(b *buffer) { | |||
| 24 | - | if b == nil { | |||
| 25 | - | return | |||
| 26 | - | } | |||
| 27 | - | for i := range b.rowIDs { | |||
| 28 | - | if b.rowIDs[i] == 0 { | |||
| 29 | - | b.rowIDs[i] = s.nextRowIDValue() | |||
| 30 | - | } | |||
| 31 | - | } | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | - | // RowIDs returns an owned copy of the active live grid's row identities. | |||
| 35 | - | func (s *Screen) RowIDs() []RowID { | |||
| 36 | - | if s == nil || s.buffer == nil { | |||
| 37 | - | return nil | |||
| 38 | - | } | |||
| 39 | - | return append([]RowID(nil), s.buffer.rowIDs...) | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | // RowID returns the identity of active live row y, or zero when y is out of | |||
| 43 | - | // range. | |||
| 44 | - | func (s *Screen) RowID(y int) RowID { | |||
| 45 | - | if s == nil || s.buffer == nil || y < 0 || y >= len(s.buffer.rowIDs) { | |||
| 46 | - | return 0 | |||
| 47 | - | } | |||
| 48 | - | return s.buffer.rowIDs[y] | |||
| 49 | - | } | |||
D pkg/vt/row_id_persistence_test.go +0 -222
| @@ -1,222 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "encoding/binary" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | - | "github.com/stretchr/testify/require" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) { | |||
| 12 | - | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 4}) | |||
| 13 | - | require.NoError(t, history.Append(historyRow("a"), LineBound{End: 1})) | |||
| 14 | - | require.NoError(t, history.Append(historyRow("b"), LineBound{End: 1})) | |||
| 15 | - | encoded, err := MarshalHistory(history.SealAndView()) | |||
| 16 | - | require.NoError(t, err) | |||
| 17 | - | ||||
| 18 | - | firstIDOffset := 17 + 4 | |||
| 19 | - | secondIDOffset := firstIDOffset + 8 + 4 + historyCellBytes + historyBoundBytes | |||
| 20 | - | for _, test := range []struct { | |||
| 21 | - | name string | |||
| 22 | - | mutate func([]byte) | |||
| 23 | - | }{ | |||
| 24 | - | {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }}, | |||
| 25 | - | {name: "duplicate row ID", mutate: func(data []byte) { copy(data[secondIDOffset:secondIDOffset+8], data[firstIDOffset:firstIDOffset+8]) }}, | |||
| 26 | - | {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], 2) }}, | |||
| 27 | - | {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], ^uint64(0)) }}, | |||
| 28 | - | } { | |||
| 29 | - | t.Run(test.name, func(t *testing.T) { | |||
| 30 | - | data := append([]byte(nil), encoded...) | |||
| 31 | - | test.mutate(data) | |||
| 32 | - | _, unmarshalErr := UnmarshalHistory(data) | |||
| 33 | - | require.Error(t, unmarshalErr) | |||
| 34 | - | _, preflightErr := PreflightHistoryBlob(data) | |||
| 35 | - | require.Error(t, preflightErr) | |||
| 36 | - | }) | |||
| 37 | - | } | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | func TestHistoryAppendRejectsExhaustedRowIDCounter(t *testing.T) { | |||
| 41 | - | history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1}) | |||
| 42 | - | history.nextRowID = ^RowID(0) | |||
| 43 | - | require.Error(t, history.Append(historyRow("row"), LineBound{End: 3})) | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | func TestHistoryAppendWithIDRejectsMalformedIDs(t *testing.T) { | |||
| 47 | - | const maxPersistedRowID = ^RowID(0) - 2 | |||
| 48 | - | for _, test := range []struct { | |||
| 49 | - | name string | |||
| 50 | - | id RowID | |||
| 51 | - | existing bool | |||
| 52 | - | }{ | |||
| 53 | - | {name: "zero", id: 0}, | |||
| 54 | - | {name: "duplicate", id: 7, existing: true}, | |||
| 55 | - | {name: "first unpersistable", id: maxPersistedRowID + 1}, | |||
| 56 | - | {name: "maximum", id: ^RowID(0)}, | |||
| 57 | - | } { | |||
| 58 | - | t.Run(test.name, func(t *testing.T) { | |||
| 59 | - | history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 2}) | |||
| 60 | - | if test.existing { | |||
| 61 | - | require.NoError(t, history.AppendWithID(historyRow("existing"), LineBound{End: 8}, test.id)) | |||
| 62 | - | } | |||
| 63 | - | before := history.View() | |||
| 64 | - | require.Error(t, history.AppendWithID(historyRow("row"), LineBound{End: 3}, test.id)) | |||
| 65 | - | require.Equal(t, before.Len(), history.Len()) | |||
| 66 | - | require.Equal(t, before.NextRowID(), history.NextRowID()) | |||
| 67 | - | }) | |||
| 68 | - | } | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | func TestHistoryAppendWithIDValidatesOutOfOrderDuplicates(t *testing.T) { | |||
| 72 | - | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1}) | |||
| 73 | - | require.NoError(t, history.AppendWithID(historyRow("high"), LineBound{End: 4}, RowID(100))) | |||
| 74 | - | require.NoError(t, history.AppendWithID(historyRow("low"), LineBound{End: 3}, RowID(7))) | |||
| 75 | - | require.Equal(t, RowID(101), history.NextRowID()) | |||
| 76 | - | ||||
| 77 | - | before := history.View() | |||
| 78 | - | require.Error(t, history.AppendWithID(historyRow("duplicate"), LineBound{End: 9}, RowID(7))) | |||
| 79 | - | require.Equal(t, before.Len(), history.Len()) | |||
| 80 | - | require.Equal(t, before.NextRowID(), history.NextRowID()) | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | func TestHistoryAutomaticAllocationStaysAboveAcceptedIDsAfterEviction(t *testing.T) { | |||
| 84 | - | history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |||
| 85 | - | require.NoError(t, history.AppendWithID(historyRow("high"), LineBound{End: 4}, RowID(100))) | |||
| 86 | - | require.NoError(t, history.AppendWithID(historyRow("low"), LineBound{End: 3}, RowID(7))) | |||
| 87 | - | require.NoError(t, history.AppendWithID(historyRow("next-low"), LineBound{End: 8}, RowID(8))) | |||
| 88 | - | require.NoError(t, history.AppendWithID(historyRow("evict"), LineBound{End: 5}, RowID(9))) | |||
| 89 | - | require.Equal(t, RowID(101), history.NextRowID()) | |||
| 90 | - | ||||
| 91 | - | require.NoError(t, history.Append(historyRow("automatic"), LineBound{End: 9})) | |||
| 92 | - | view := history.View() | |||
| 93 | - | require.Equal(t, RowID(101), view.RowID(view.Len()-1)) | |||
| 94 | - | require.Equal(t, RowID(102), history.NextRowID()) | |||
| 95 | - | for i := range view.Len() { | |||
| 96 | - | require.Less(t, view.RowID(i), history.NextRowID()) | |||
| 97 | - | } | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | func TestHistoryAutomaticAllocationBoundary(t *testing.T) { | |||
| 101 | - | lastPersistableID := ^RowID(0) - 2 | |||
| 102 | - | history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |||
| 103 | - | history.nextRowID = lastPersistableID | |||
| 104 | - | ||||
| 105 | - | require.NoError(t, history.Append(historyRow("last"), LineBound{End: 4})) | |||
| 106 | - | require.Equal(t, lastPersistableID+1, history.NextRowID()) | |||
| 107 | - | before := history.View() | |||
| 108 | - | require.Error(t, history.Append(historyRow("exhausted"), LineBound{End: 9})) | |||
| 109 | - | require.Equal(t, before.Len(), history.Len()) | |||
| 110 | - | require.Equal(t, before.NextRowID(), history.NextRowID()) | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) { | |||
| 114 | - | const maxPersistedRowID = ^RowID(0) - 2 | |||
| 115 | - | for _, test := range []struct { | |||
| 116 | - | name string | |||
| 117 | - | current RowID | |||
| 118 | - | want RowID | |||
| 119 | - | persist bool | |||
| 120 | - | }{ | |||
| 121 | - | {name: "last persistable ID", current: maxPersistedRowID - 1, want: maxPersistedRowID, persist: true}, | |||
| 122 | - | {name: "first non-persistable ID", current: maxPersistedRowID, want: maxPersistedRowID + 1}, | |||
| 123 | - | } { | |||
| 124 | - | t.Run(test.name, func(t *testing.T) { | |||
| 125 | - | screen := NewScreen(1, 1) | |||
| 126 | - | screen.nextRowID = test.current | |||
| 127 | - | allocate := func() { | |||
| 128 | - | require.Equal(t, test.want, screen.nextRowIDValue()) | |||
| 129 | - | } | |||
| 130 | - | if !test.persist { | |||
| 131 | - | require.Panics(t, allocate) | |||
| 132 | - | return | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | allocate() | |||
| 136 | - | row := historyRow("x") | |||
| 137 | - | screen.buffer.rowIDs[0] = test.want | |||
| 138 | - | screen.buffer.frame.Row(0)[0] = row[0] | |||
| 139 | - | screen.buffer.boundaries[0] = LineBound{End: 1} | |||
| 140 | - | transcript, err := screen.RecoveryTranscriptSnapshot().Marshal() | |||
| 141 | - | require.NoError(t, err) | |||
| 142 | - | view, err := UnmarshalHistory(transcript) | |||
| 143 | - | require.NoError(t, err) | |||
| 144 | - | require.Equal(t, test.want, view.RowID(0)) | |||
| 145 | - | require.Equal(t, test.want+1, view.NextRowID()) | |||
| 146 | - | ||||
| 147 | - | history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1}) | |||
| 148 | - | require.NoError(t, history.AppendWithID(row, LineBound{End: 1}, screen.RowID(0))) | |||
| 149 | - | _, err = MarshalHistory(history.SealAndView()) | |||
| 150 | - | require.NoError(t, err) | |||
| 151 | - | }) | |||
| 152 | - | } | |||
| 153 | - | } | |||
| 154 | - | ||||
| 155 | - | func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) { | |||
| 156 | - | _, err := MarshalHistory(HistoryView{ | |||
| 157 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}}, | |||
| 158 | - | rows: 1, | |||
| 159 | - | }) | |||
| 160 | - | require.Error(t, err) | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) { | |||
| 164 | - | history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1}) | |||
| 165 | - | require.NoError(t, history.AppendWithID(historyRow("old"), LineBound{End: 3}, RowID(40))) | |||
| 166 | - | sealed, tail, err := MarshalSealedHistory(history.SealAndView()) | |||
| 167 | - | require.NoError(t, err) | |||
| 168 | - | transcript, err := MarshalHistory(HistoryView{ | |||
| 169 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{50}}}, | |||
| 170 | - | rows: 1, | |||
| 171 | - | nextRowID: 51, | |||
| 172 | - | }) | |||
| 173 | - | require.NoError(t, err) | |||
| 174 | - | ||||
| 175 | - | screen, err := NewScreenWithRecoveryTranscript(3, 1, HistoryConfig{MaxRows: 8, ChunkRows: 2}, sealed, tail, transcript) | |||
| 176 | - | require.NoError(t, err) | |||
| 177 | - | require.Equal(t, RowID(40), screen.History().View().RowID(0)) | |||
| 178 | - | require.Equal(t, RowID(50), screen.History().View().RowID(1)) | |||
| 179 | - | require.Equal(t, RowID(51), screen.History().NextRowID()) | |||
| 180 | - | require.Greater(t, screen.RowID(0), RowID(50)) | |||
| 181 | - | ||||
| 182 | - | before := screen.RowID(0) | |||
| 183 | - | screen.Write([]byte("\x1b[2J")) | |||
| 184 | - | require.Greater(t, screen.RowID(0), before) | |||
| 185 | - | } | |||
| 186 | - | ||||
| 187 | - | func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) { | |||
| 188 | - | sealedView := HistoryView{ | |||
| 189 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("old")}, bounds: []LineBound{{End: 3}}, rowIDs: []RowID{7}}}, | |||
| 190 | - | rows: 1, | |||
| 191 | - | nextRowID: 8, | |||
| 192 | - | } | |||
| 193 | - | sealed, err := MarshalHistoryChunk(sealedView.Chunk(0)) | |||
| 194 | - | require.NoError(t, err) | |||
| 195 | - | tail, err := MarshalEmptyHistoryTail() | |||
| 196 | - | require.NoError(t, err) | |||
| 197 | - | transcript, err := MarshalHistory(HistoryView{ | |||
| 198 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{7}}}, | |||
| 199 | - | rows: 1, | |||
| 200 | - | nextRowID: 8, | |||
| 201 | - | }) | |||
| 202 | - | require.NoError(t, err) | |||
| 203 | - | ||||
| 204 | - | _, err = NewScreenWithRecoveryTranscript(4, 1, HistoryConfig{MaxRows: 1, ChunkRows: 1}, [][]byte{sealed}, tail, transcript) | |||
| 205 | - | require.Error(t, err) | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) { | |||
| 209 | - | first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |||
| 210 | - | second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1}) | |||
| 211 | - | require.NoError(t, first.AppendWithID(historyRow("one"), LineBound{End: 3}, RowID(7))) | |||
| 212 | - | require.NoError(t, second.AppendWithID(historyRow("two"), LineBound{End: 3}, RowID(7))) | |||
| 213 | - | firstBlob, err := MarshalHistoryChunk(first.SealAndView().Chunk(0)) | |||
| 214 | - | require.NoError(t, err) | |||
| 215 | - | secondBlob, err := MarshalHistoryChunk(second.SealAndView().Chunk(0)) | |||
| 216 | - | require.NoError(t, err) | |||
| 217 | - | tail, err := MarshalEmptyHistoryTail() | |||
| 218 | - | require.NoError(t, err) | |||
| 219 | - | ||||
| 220 | - | _, err = HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 1}, [][]byte{firstBlob, secondBlob}, tail) | |||
| 221 | - | require.Error(t, err) | |||
| 222 | - | } | |||
D pkg/vt/row_id_test.go +0 -180
| @@ -1,180 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import "testing" | |||
| 4 | - | ||||
| 5 | - | func TestScreenRowIDsRotateWithRowsAndHistory(t *testing.T) { | |||
| 6 | - | s := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 7 | - | initial := s.RowIDs() | |||
| 8 | - | assertUniqueNonzeroRowIDs(t, initial) | |||
| 9 | - | ||||
| 10 | - | s.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\n")) | |||
| 11 | - | ||||
| 12 | - | live := s.RowIDs() | |||
| 13 | - | if got, want := live, []RowID{initial[1], initial[2], live[2]}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { | |||
| 14 | - | t.Fatalf("live row IDs = %v, want rows moved from %v with a fresh blank row", got, initial) | |||
| 15 | - | } | |||
| 16 | - | if live[2] == initial[0] || live[2] == initial[1] || live[2] == initial[2] { | |||
| 17 | - | t.Fatalf("new blank row reused a row ID: live=%v initial=%v", live, initial) | |||
| 18 | - | } | |||
| 19 | - | assertUniqueNonzeroRowIDs(t, live) | |||
| 20 | - | ||||
| 21 | - | view := s.History().SealAndView() | |||
| 22 | - | if got, want := view.RowID(0), initial[0]; got != want { | |||
| 23 | - | t.Fatalf("evicted row ID = %d, want %d", got, want) | |||
| 24 | - | } | |||
| 25 | - | if got := view.FindRowID(initial[0]); got != 0 { | |||
| 26 | - | t.Fatalf("FindRowID(%d) = %d, want 0", initial[0], got) | |||
| 27 | - | } | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | func TestScreenRowIDsDoNotAppendInteriorOrAlternateScrollHistory(t *testing.T) { | |||
| 31 | - | s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8}) | |||
| 32 | - | s.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\nDDDD")) | |||
| 33 | - | s.Write([]byte("\x1b[2;4r\x1b[4;1H\n")) | |||
| 34 | - | if got := s.History().Len(); got != 0 { | |||
| 35 | - | t.Fatalf("interior scroll history rows = %d, want 0", got) | |||
| 36 | - | } | |||
| 37 | - | assertUniqueNonzeroRowIDs(t, s.RowIDs()) | |||
| 38 | - | primary := s.RowIDs() | |||
| 39 | - | ||||
| 40 | - | s.Write([]byte("\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\r\n")) | |||
| 41 | - | if got := s.History().Len(); got != 0 { | |||
| 42 | - | t.Fatalf("alternate scroll history rows = %d, want 0", got) | |||
| 43 | - | } | |||
| 44 | - | s.Write([]byte("\x1b[?1049l")) | |||
| 45 | - | for i, id := range primary { | |||
| 46 | - | if got := s.RowID(i); got != id { | |||
| 47 | - | t.Fatalf("restored primary row %d ID = %d, want %d", i, got, id) | |||
| 48 | - | } | |||
| 49 | - | } | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | - | func TestScreenRowIDRefreshesOnSnapExpandedFullClear(t *testing.T) { | |||
| 53 | - | tests := []struct { | |||
| 54 | - | name string | |||
| 55 | - | x0, x1 int | |||
| 56 | - | wantRefresh bool | |||
| 57 | - | }{ | |||
| 58 | - | {name: "snap-expanded full clear", x0: 1, x1: 4, wantRefresh: true}, | |||
| 59 | - | {name: "partial clear", x0: 0, x1: 1, wantRefresh: false}, | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | for _, tt := range tests { | |||
| 63 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 64 | - | s := NewScreen(4, 1) | |||
| 65 | - | s.Write([]byte("界")) | |||
| 66 | - | before := s.RowID(0) | |||
| 67 | - | ||||
| 68 | - | s.clearRow(0, tt.x0, tt.x1) | |||
| 69 | - | ||||
| 70 | - | if got := s.RowID(0) != before; got != tt.wantRefresh { | |||
| 71 | - | t.Fatalf("row ID refreshed = %t, want %t", got, tt.wantRefresh) | |||
| 72 | - | } | |||
| 73 | - | }) | |||
| 74 | - | } | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) { | |||
| 78 | - | s := NewScreen(4, 3) | |||
| 79 | - | before := s.RowIDs() | |||
| 80 | - | s.Write([]byte("AAAA\x1b[2J")) | |||
| 81 | - | cleared := s.RowIDs() | |||
| 82 | - | assertFreshRowIDs(t, before, cleared) | |||
| 83 | - | ||||
| 84 | - | s.Write([]byte("\x1bc")) | |||
| 85 | - | reset := s.RowIDs() | |||
| 86 | - | assertFreshRowIDs(t, cleared, reset) | |||
| 87 | - | ||||
| 88 | - | s.Write([]byte("BBBB\x1b[?1049h")) | |||
| 89 | - | alternate := s.RowIDs() | |||
| 90 | - | assertFreshRowIDs(t, reset, alternate) | |||
| 91 | - | s.Write([]byte("CCCC\x1b[?1049l")) | |||
| 92 | - | for i, id := range reset { | |||
| 93 | - | if got := s.RowID(i); got != id { | |||
| 94 | - | t.Fatalf("cloned primary row %d ID = %d, want %d", i, got, id) | |||
| 95 | - | } | |||
| 96 | - | } | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) { | |||
| 100 | - | b := newBuffer(8, 3) | |||
| 101 | - | copy(b.frame.Row(0), historyRow("abcdefgh")) | |||
| 102 | - | copy(b.frame.Row(1), historyRow("ijkl")) | |||
| 103 | - | copy(b.frame.Row(2), historyRow("mnop")) | |||
| 104 | - | b.boundaries[0] = LineBound{End: 8, Soft: true} | |||
| 105 | - | b.boundaries[1] = LineBound{End: 4} | |||
| 106 | - | b.boundaries[2] = LineBound{End: 4} | |||
| 107 | - | b.rowIDs = []RowID{11, 12, 13} | |||
| 108 | - | ||||
| 109 | - | active := &bufferCursor{row: 2, col: 4} | |||
| 110 | - | evicted, bounds, ids := b.resize(4, 2, active, nil) | |||
| 111 | - | ||||
| 112 | - | if len(evicted) != 2 || bounds[0].End != 4 || ids[0] != 11 || ids[1] != 0 { | |||
| 113 | - | t.Fatalf("reflow eviction = rows=%d bound=%v IDs=%v, want two rows with source ID 11 then a generated ID", len(evicted), bounds, ids) | |||
| 114 | - | } | |||
| 115 | - | if got, want := b.rowIDs, []RowID{0, 13}; got[1] != want[1] || len(got) != len(want) { | |||
| 116 | - | t.Fatalf("reflow viewport IDs = %v, want generated continuation then source ID %d", got, want[1]) | |||
| 117 | - | } | |||
| 118 | - | } | |||
| 119 | - | ||||
| 120 | - | func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) { | |||
| 121 | - | h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2}) | |||
| 122 | - | for i, text := range []string{"one", "two"} { | |||
| 123 | - | if err := h.AppendWithID(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil { | |||
| 124 | - | t.Fatal(err) | |||
| 125 | - | } | |||
| 126 | - | } | |||
| 127 | - | before := h.SealAndView() | |||
| 128 | - | if got, want := before.RowID(0), RowID(1); got != want { | |||
| 129 | - | t.Fatalf("sealed row ID = %d, want %d", got, want) | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | if err := h.AppendWithID(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil { | |||
| 133 | - | t.Fatal(err) | |||
| 134 | - | } | |||
| 135 | - | if got := before.FindRowID(2); got != 1 { | |||
| 136 | - | t.Fatalf("retained view FindRowID(2) = %d, want 1", got) | |||
| 137 | - | } | |||
| 138 | - | after := h.SealAndView() | |||
| 139 | - | if got := after.FindRowID(3); got != 2 { | |||
| 140 | - | t.Fatalf("new view FindRowID(3) = %d, want 2", got) | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | if err := h.AppendWithID(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil { | |||
| 144 | - | t.Fatal(err) | |||
| 145 | - | } | |||
| 146 | - | if got := before.FindRowID(1); got != 0 { | |||
| 147 | - | t.Fatalf("eviction mutated retained view FindRowID(1) = %d, want 0", got) | |||
| 148 | - | } | |||
| 149 | - | if got := h.View().FindRowID(1); got != -1 { | |||
| 150 | - | t.Fatalf("evicted current FindRowID(1) = %d, want -1", got) | |||
| 151 | - | } | |||
| 152 | - | } | |||
| 153 | - | ||||
| 154 | - | func assertUniqueNonzeroRowIDs(t *testing.T, ids []RowID) { | |||
| 155 | - | t.Helper() | |||
| 156 | - | seen := make(map[RowID]struct{}, len(ids)) | |||
| 157 | - | for _, id := range ids { | |||
| 158 | - | if id == 0 { | |||
| 159 | - | t.Fatal("row ID is zero") | |||
| 160 | - | } | |||
| 161 | - | if _, ok := seen[id]; ok { | |||
| 162 | - | t.Fatalf("duplicate row ID %d in %v", id, ids) | |||
| 163 | - | } | |||
| 164 | - | seen[id] = struct{}{} | |||
| 165 | - | } | |||
| 166 | - | } | |||
| 167 | - | ||||
| 168 | - | func assertFreshRowIDs(t *testing.T, before, after []RowID) { | |||
| 169 | - | t.Helper() | |||
| 170 | - | assertUniqueNonzeroRowIDs(t, after) | |||
| 171 | - | seen := make(map[RowID]struct{}, len(before)) | |||
| 172 | - | for _, id := range before { | |||
| 173 | - | seen[id] = struct{}{} | |||
| 174 | - | } | |||
| 175 | - | for _, id := range after { | |||
| 176 | - | if _, ok := seen[id]; ok { | |||
| 177 | - | t.Fatalf("row ID %d was reused: before=%v after=%v", id, before, after) | |||
| 178 | - | } | |||
| 179 | - | } | |||
| 180 | - | } | |||
D pkg/vt/screen.go +0 -215
| @@ -1,215 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "unicode/utf8" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | |||
| 10 | - | // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64 | |||
| 11 | - | // expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy, | |||
| 12 | - | // so keep this value in sync if that payload cap changes. | |||
| 13 | - | const maxEscapeBufferLen = 128 * 1024 | |||
| 14 | - | ||||
| 15 | - | const ( | |||
| 16 | - | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | |||
| 17 | - | ColorSchemeReportDark = "\x1b[?997;1n" | |||
| 18 | - | // ColorSchemeReportLight is the DEC 2031 light-scheme report. | |||
| 19 | - | ColorSchemeReportLight = "\x1b[?997;2n" | |||
| 20 | - | ) | |||
| 21 | - | ||||
| 22 | - | type Screen struct { | |||
| 23 | - | Frame renderer.Frame | |||
| 24 | - | Row int | |||
| 25 | - | Col int | |||
| 26 | - | Style renderer.Style | |||
| 27 | - | // OnLineEvicted is called just before a full-width upward scroll recycles | |||
| 28 | - | // and blanks rows. The callback receives a stable copy of each evicted row. | |||
| 29 | - | OnLineEvicted func([]renderer.Cell) | |||
| 30 | - | // OnResponse is called synchronously from Write with reply bytes that the | |||
| 31 | - | // emulator must send back to the child process (DA, DSR, and ANSI/DEC mode | |||
| 32 | - | // query reports). The host wires it to the PTY input. Nil disables responses. | |||
| 33 | - | OnResponse func([]byte) | |||
| 34 | - | // OnBell is called synchronously from Write for each lone BEL (0x07) | |||
| 35 | - | // outside escape sequences. BELs that terminate an OSC never fire it. | |||
| 36 | - | // Nil disables bell reporting. | |||
| 37 | - | OnBell func() | |||
| 38 | - | // OnNotify is called synchronously from Write for explicit terminal | |||
| 39 | - | // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body). | |||
| 40 | - | // Other non-clipboard OSC payloads remain discarded. Nil disables it. | |||
| 41 | - | OnNotify func(title, body string) | |||
| 42 | - | // OnProgress is called synchronously from Write for OSC 9;4 progress | |||
| 43 | - | // transitions that request attention: active progress cleared or first entry | |||
| 44 | - | // into error state. Nil disables progress reporting. | |||
| 45 | - | OnProgress func(errored bool) | |||
| 46 | - | // OnClipboard is called synchronously from Write for a complete OSC 52 | |||
| 47 | - | // clipboard set request from the child. The OSC 52 selection field is | |||
| 48 | - | // accepted but ignored; the callback receives only the raw base64 payload. | |||
| 49 | - | // Clipboard queries (data == "?") and malformed payloads are ignored and | |||
| 50 | - | // never invoke it. Nil disables it. | |||
| 51 | - | OnClipboard func(b64 string) | |||
| 52 | - | ||||
| 53 | - | history *History | |||
| 54 | - | buffer *buffer | |||
| 55 | - | ||||
| 56 | - | nextRowID RowID | |||
| 57 | - | ||||
| 58 | - | defaultFG renderer.RGB | |||
| 59 | - | defaultBG renderer.RGB | |||
| 60 | - | defaultColorsKnown bool | |||
| 61 | - | terminalTitle string | |||
| 62 | - | ||||
| 63 | - | damage []renderer.Damage | |||
| 64 | - | damageGeneration uint64 | |||
| 65 | - | damageSaturated bool | |||
| 66 | - | damageFullRedrawSticky bool | |||
| 67 | - | escapeBuf []byte | |||
| 68 | - | csiScratch []int | |||
| 69 | - | sgrScratch []int | |||
| 70 | - | ||||
| 71 | - | scrollTop int | |||
| 72 | - | scrollBottom int | |||
| 73 | - | savedCursor cursorState | |||
| 74 | - | alternate *screenState | |||
| 75 | - | syncUpdateActive bool | |||
| 76 | - | progressState int | |||
| 77 | - | cursorVisible bool | |||
| 78 | - | cursorStyle int | |||
| 79 | - | cursorStyleSet bool | |||
| 80 | - | mouseMode int | |||
| 81 | - | mouseSGR bool | |||
| 82 | - | bracketedPaste bool | |||
| 83 | - | originMode bool | |||
| 84 | - | insertMode bool | |||
| 85 | - | colorSchemeMode bool | |||
| 86 | - | colorSchemeLight bool | |||
| 87 | - | colorSchemeSet bool | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | func NewScreen(width, height int) *Screen { | |||
| 91 | - | s := &Screen{ | |||
| 92 | - | Style: renderer.DefaultStyle(), | |||
| 93 | - | damage: []renderer.Damage{renderer.FullRedraw()}, | |||
| 94 | - | damageGeneration: 1, | |||
| 95 | - | cursorVisible: true, | |||
| 96 | - | } | |||
| 97 | - | s.buffer = s.newBuffer(width, height) | |||
| 98 | - | s.Frame = s.buffer.frame | |||
| 99 | - | s.resetScrollRegion() | |||
| 100 | - | return s | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | // NewScreenWithHistory creates a screen that records rows evicted from its | |||
| 104 | - | // primary screen into bounded immutable terminal history. | |||
| 105 | - | func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen { | |||
| 106 | - | s := NewScreen(width, height) | |||
| 107 | - | s.history = NewHistory(config) | |||
| 108 | - | return s | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // History returns this screen's terminal history, or nil when history was not | |||
| 112 | - | // configured with NewScreenWithHistory. | |||
| 113 | - | func (s *Screen) History() *History { return s.history } | |||
| 114 | - | ||||
| 115 | - | // LineBounds returns an owned copy of the live grid's per-row logical extents, | |||
| 116 | - | // indexed like Frame rows. It returns nil when the screen has no buffer. | |||
| 117 | - | func (s *Screen) LineBounds() []LineBound { | |||
| 118 | - | if s == nil || s.buffer == nil { | |||
| 119 | - | return nil | |||
| 120 | - | } | |||
| 121 | - | return append([]LineBound(nil), s.buffer.boundaries...) | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | func (s *Screen) Write(data []byte) { | |||
| 125 | - | if len(s.escapeBuf) > 0 { | |||
| 126 | - | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | |||
| 127 | - | combined = append(combined, s.escapeBuf...) | |||
| 128 | - | combined = append(combined, data...) | |||
| 129 | - | data = combined | |||
| 130 | - | s.escapeBuf = s.escapeBuf[:0] | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | - | for len(data) > 0 { | |||
| 134 | - | if data[0] == 0x1b { | |||
| 135 | - | consumed, partial := s.consumeEscape(data) | |||
| 136 | - | if consumed > 0 { | |||
| 137 | - | data = data[consumed:] | |||
| 138 | - | continue | |||
| 139 | - | } | |||
| 140 | - | if partial { | |||
| 141 | - | if len(data) <= maxEscapeBufferLen { | |||
| 142 | - | s.escapeBuf = append(s.escapeBuf[:0], data...) | |||
| 143 | - | } | |||
| 144 | - | return | |||
| 145 | - | } | |||
| 146 | - | } | |||
| 147 | - | r, size := utf8.DecodeRune(data) | |||
| 148 | - | if r == utf8.RuneError && size == 1 { | |||
| 149 | - | data = data[1:] | |||
| 150 | - | continue | |||
| 151 | - | } | |||
| 152 | - | s.putRune(r) | |||
| 153 | - | data = data[size:] | |||
| 154 | - | } | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |||
| 158 | - | if len(data) < 2 { | |||
| 159 | - | return 0, true | |||
| 160 | - | } | |||
| 161 | - | switch data[1] { | |||
| 162 | - | case ']': | |||
| 163 | - | return s.consumeOSC(data) | |||
| 164 | - | case 'P': | |||
| 165 | - | return consumeSTString(data) | |||
| 166 | - | case '_', '^', 'X': | |||
| 167 | - | return consumeSTString(data) | |||
| 168 | - | case '[': | |||
| 169 | - | return s.consumeCSI(data) | |||
| 170 | - | case '=': | |||
| 171 | - | return 2, false | |||
| 172 | - | case '>': | |||
| 173 | - | return 2, false | |||
| 174 | - | case '7': | |||
| 175 | - | s.saveCursor() | |||
| 176 | - | return 2, false | |||
| 177 | - | case '8': | |||
| 178 | - | s.restoreCursor() | |||
| 179 | - | return 2, false | |||
| 180 | - | case 'D': | |||
| 181 | - | s.buffer.hard(s.Row) | |||
| 182 | - | s.index() | |||
| 183 | - | return 2, false | |||
| 184 | - | case 'E': | |||
| 185 | - | s.nextLine() | |||
| 186 | - | return 2, false | |||
| 187 | - | case 'M': | |||
| 188 | - | s.reverseIndex() | |||
| 189 | - | return 2, false | |||
| 190 | - | case 'c': | |||
| 191 | - | s.reset() | |||
| 192 | - | return 2, false | |||
| 193 | - | case 'H': | |||
| 194 | - | return 2, false | |||
| 195 | - | case '(', ')', '*', '+', '-', '.', '/': | |||
| 196 | - | if len(data) < 3 { | |||
| 197 | - | return 0, true | |||
| 198 | - | } | |||
| 199 | - | return 3, false | |||
| 200 | - | default: | |||
| 201 | - | if data[1] >= 0x30 && data[1] <= 0x7e { | |||
| 202 | - | return 2, false | |||
| 203 | - | } | |||
| 204 | - | return 0, false | |||
| 205 | - | } | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | func consumeSTString(data []byte) (consumed int, partial bool) { | |||
| 209 | - | for i := 2; i < len(data); i++ { | |||
| 210 | - | if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' { | |||
| 211 | - | return i + 2, false | |||
| 212 | - | } | |||
| 213 | - | } | |||
| 214 | - | return 0, true | |||
| 215 | - | } | |||
D pkg/vt/screen_csi.go +0 -598
| @@ -1,598 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "strconv" | |||
| 5 | - | "strings" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) { | |||
| 11 | - | end := -1 | |||
| 12 | - | for i := 2; i < len(data); i++ { | |||
| 13 | - | if data[i] >= '@' && data[i] <= '~' { | |||
| 14 | - | end = i | |||
| 15 | - | break | |||
| 16 | - | } | |||
| 17 | - | } | |||
| 18 | - | if end == -1 { | |||
| 19 | - | return 0, true | |||
| 20 | - | } | |||
| 21 | - | params := string(data[2:end]) | |||
| 22 | - | cmd := data[end] | |||
| 23 | - | s.applyCSI(params, cmd) | |||
| 24 | - | return end + 1, false | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 28 | - | private := strings.HasPrefix(params, "?") | |||
| 29 | - | parts := s.parseCSIInts(params) | |||
| 30 | - | switch cmd { | |||
| 31 | - | case 'c': | |||
| 32 | - | switch params { | |||
| 33 | - | case "", "0": | |||
| 34 | - | s.respond([]byte("\x1b[?6c")) | |||
| 35 | - | case ">", ">0": | |||
| 36 | - | s.respond([]byte("\x1b[>0;0;0c")) | |||
| 37 | - | } | |||
| 38 | - | case 'n': | |||
| 39 | - | switch firstPositive(parts, 0) { | |||
| 40 | - | case 5: | |||
| 41 | - | if !private { | |||
| 42 | - | s.respond([]byte("\x1b[0n")) | |||
| 43 | - | } | |||
| 44 | - | case 996: | |||
| 45 | - | if private { | |||
| 46 | - | if report := s.colorSchemeReport(); report != nil { | |||
| 47 | - | s.respond(report) | |||
| 48 | - | } | |||
| 49 | - | } | |||
| 50 | - | case 6: | |||
| 51 | - | resp := make([]byte, 0, 16) | |||
| 52 | - | resp = append(resp, "\x1b["...) | |||
| 53 | - | if private { | |||
| 54 | - | resp = append(resp, '?') | |||
| 55 | - | } | |||
| 56 | - | resp = strconv.AppendInt(resp, int64(s.cursorReportRow()), 10) | |||
| 57 | - | resp = append(resp, ';') | |||
| 58 | - | resp = strconv.AppendInt(resp, int64(s.Col+1), 10) | |||
| 59 | - | resp = append(resp, 'R') | |||
| 60 | - | s.respond(resp) | |||
| 61 | - | } | |||
| 62 | - | case 'p': | |||
| 63 | - | if modeText, ok := strings.CutSuffix(params, "$"); ok { | |||
| 64 | - | if private { | |||
| 65 | - | modeText = strings.TrimPrefix(modeText, "?") | |||
| 66 | - | } | |||
| 67 | - | mode, err := strconv.Atoi(modeText) | |||
| 68 | - | if err != nil { | |||
| 69 | - | return | |||
| 70 | - | } | |||
| 71 | - | state := s.modeReportState(private, mode) | |||
| 72 | - | resp := make([]byte, 0, 16) | |||
| 73 | - | resp = append(resp, "\x1b["...) | |||
| 74 | - | if private { | |||
| 75 | - | resp = append(resp, '?') | |||
| 76 | - | } | |||
| 77 | - | resp = strconv.AppendInt(resp, int64(mode), 10) | |||
| 78 | - | resp = append(resp, ';') | |||
| 79 | - | resp = strconv.AppendInt(resp, int64(state), 10) | |||
| 80 | - | resp = append(resp, "$y"...) | |||
| 81 | - | s.respond(resp) | |||
| 82 | - | } | |||
| 83 | - | case 'm': | |||
| 84 | - | if strings.HasPrefix(params, ">") { | |||
| 85 | - | return | |||
| 86 | - | } | |||
| 87 | - | s.applySGR(params) | |||
| 88 | - | case 'q': | |||
| 89 | - | s.applyCursorStyle(params) | |||
| 90 | - | case 'J': | |||
| 91 | - | mode := 0 | |||
| 92 | - | if len(parts) > 0 { | |||
| 93 | - | mode = parts[0] | |||
| 94 | - | } | |||
| 95 | - | s.clearScreenMode(mode) | |||
| 96 | - | case 'K': | |||
| 97 | - | mode := 0 | |||
| 98 | - | if len(parts) > 0 { | |||
| 99 | - | mode = parts[0] | |||
| 100 | - | } | |||
| 101 | - | s.clearLineMode(mode) | |||
| 102 | - | case 'X': | |||
| 103 | - | s.eraseChars(firstPositive(parts, 1)) | |||
| 104 | - | case 'S': | |||
| 105 | - | s.scrollUpBy(firstPositive(parts, 1)) | |||
| 106 | - | case 'T': | |||
| 107 | - | s.scrollDownBy(firstPositive(parts, 1)) | |||
| 108 | - | case 'H', 'f': | |||
| 109 | - | row, col := 1, 1 | |||
| 110 | - | if len(parts) > 0 && parts[0] > 0 { | |||
| 111 | - | row = parts[0] | |||
| 112 | - | } | |||
| 113 | - | if len(parts) > 1 && parts[1] > 0 { | |||
| 114 | - | col = parts[1] | |||
| 115 | - | } | |||
| 116 | - | s.addressCursor(row, col) | |||
| 117 | - | case 'A': | |||
| 118 | - | s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow()) | |||
| 119 | - | case 'B': | |||
| 120 | - | s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow()) | |||
| 121 | - | case 'C': | |||
| 122 | - | s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1) | |||
| 123 | - | case 'D': | |||
| 124 | - | s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1) | |||
| 125 | - | case 'E': | |||
| 126 | - | s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow()) | |||
| 127 | - | s.Col = 0 | |||
| 128 | - | case 'F': | |||
| 129 | - | s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow()) | |||
| 130 | - | s.Col = 0 | |||
| 131 | - | case 'G': | |||
| 132 | - | col := firstPositive(parts, 1) | |||
| 133 | - | s.Col = clamp(col-1, 0, s.Frame.Width-1) | |||
| 134 | - | case 'd': | |||
| 135 | - | row := firstPositive(parts, 1) | |||
| 136 | - | s.Row = s.addressedRow(row) | |||
| 137 | - | case '@': | |||
| 138 | - | s.insertChars(firstPositive(parts, 1)) | |||
| 139 | - | case 'P': | |||
| 140 | - | s.deleteChars(firstPositive(parts, 1)) | |||
| 141 | - | case 'L': | |||
| 142 | - | s.insertLines(firstPositive(parts, 1)) | |||
| 143 | - | case 'M': | |||
| 144 | - | s.deleteLines(firstPositive(parts, 1)) | |||
| 145 | - | case 'r': | |||
| 146 | - | s.setScrollRegion(parts) | |||
| 147 | - | case 's': | |||
| 148 | - | s.saveCursor() | |||
| 149 | - | case 'u': | |||
| 150 | - | if params == "" { | |||
| 151 | - | s.restoreCursor() | |||
| 152 | - | } | |||
| 153 | - | case 'h': | |||
| 154 | - | s.setMode(private, parts, true) | |||
| 155 | - | case 'l': | |||
| 156 | - | s.setMode(private, parts, false) | |||
| 157 | - | } | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | func firstPositive(parts []int, fallback int) int { | |||
| 161 | - | if len(parts) == 0 || parts[0] <= 0 { | |||
| 162 | - | return fallback | |||
| 163 | - | } | |||
| 164 | - | return parts[0] | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | func (s *Screen) modeReportState(private bool, mode int) int { | |||
| 168 | - | if private { | |||
| 169 | - | switch mode { | |||
| 170 | - | case 6: | |||
| 171 | - | return boolModeReportState(s.originMode) | |||
| 172 | - | case 2026: | |||
| 173 | - | return boolModeReportState(s.syncUpdateActive) | |||
| 174 | - | case 2031: | |||
| 175 | - | return boolModeReportState(s.colorSchemeMode) | |||
| 176 | - | } | |||
| 177 | - | return 0 | |||
| 178 | - | } | |||
| 179 | - | if mode == 4 { | |||
| 180 | - | return boolModeReportState(s.insertMode) | |||
| 181 | - | } | |||
| 182 | - | return 0 | |||
| 183 | - | } | |||
| 184 | - | ||||
| 185 | - | func boolModeReportState(enabled bool) int { | |||
| 186 | - | if enabled { | |||
| 187 | - | return 1 | |||
| 188 | - | } | |||
| 189 | - | return 2 | |||
| 190 | - | } | |||
| 191 | - | ||||
| 192 | - | const sgrUnderlineStyleMarker = -1000 | |||
| 193 | - | ||||
| 194 | - | func (s *Screen) applySGR(params string) { | |||
| 195 | - | if params == "" { | |||
| 196 | - | s.Style = renderer.DefaultStyle() | |||
| 197 | - | return | |||
| 198 | - | } | |||
| 199 | - | parts := s.parseSGRInts(params) | |||
| 200 | - | for i := 0; i < len(parts); i++ { | |||
| 201 | - | switch parts[i] { | |||
| 202 | - | case 0: | |||
| 203 | - | s.Style = renderer.DefaultStyle() | |||
| 204 | - | case 1: | |||
| 205 | - | s.Style.Bold = true | |||
| 206 | - | case 2: | |||
| 207 | - | s.Style.Attrs |= renderer.AttrDim | |||
| 208 | - | case 3: | |||
| 209 | - | s.Style.Italic = true | |||
| 210 | - | case 4: | |||
| 211 | - | s.Style.Attrs |= renderer.AttrUnderline | |||
| 212 | - | s.Style.UnderlineStyle = renderer.UnderlineSingle | |||
| 213 | - | if i+2 < len(parts) && parts[i+1] == sgrUnderlineStyleMarker { | |||
| 214 | - | underlineStyle := sgrUnderlineStyle(parts[i+2]) | |||
| 215 | - | if underlineStyle == renderer.UnderlineNone { | |||
| 216 | - | s.Style.Attrs &^= renderer.AttrUnderline | |||
| 217 | - | } else { | |||
| 218 | - | s.Style.Attrs |= renderer.AttrUnderline | |||
| 219 | - | } | |||
| 220 | - | s.Style.UnderlineStyle = underlineStyle | |||
| 221 | - | i += 2 | |||
| 222 | - | } | |||
| 223 | - | case 5, 6: | |||
| 224 | - | s.Style.Attrs |= renderer.AttrBlink | |||
| 225 | - | case 7: | |||
| 226 | - | s.Style.Inverse = true | |||
| 227 | - | case 9: | |||
| 228 | - | s.Style.Attrs |= renderer.AttrStrikethrough | |||
| 229 | - | case 21: | |||
| 230 | - | s.Style.Attrs |= renderer.AttrUnderline | |||
| 231 | - | s.Style.UnderlineStyle = renderer.UnderlineDouble | |||
| 232 | - | case 22: | |||
| 233 | - | s.Style.Bold = false | |||
| 234 | - | s.Style.Attrs &^= renderer.AttrDim | |||
| 235 | - | case 23: | |||
| 236 | - | s.Style.Italic = false | |||
| 237 | - | case 24: | |||
| 238 | - | s.Style.Attrs &^= renderer.AttrUnderline | |||
| 239 | - | s.Style.UnderlineStyle = renderer.UnderlineNone | |||
| 240 | - | case 25: | |||
| 241 | - | s.Style.Attrs &^= renderer.AttrBlink | |||
| 242 | - | case 27: | |||
| 243 | - | s.Style.Inverse = false | |||
| 244 | - | case 29: | |||
| 245 | - | s.Style.Attrs &^= renderer.AttrStrikethrough | |||
| 246 | - | case 30, 31, 32, 33, 34, 35, 36, 37: | |||
| 247 | - | s.Style.Foreground = parts[i] - 30 | |||
| 248 | - | s.Style.HasForegroundRGB = false | |||
| 249 | - | case 39: | |||
| 250 | - | s.Style.Foreground = -1 | |||
| 251 | - | s.Style.HasForegroundRGB = false | |||
| 252 | - | case 40, 41, 42, 43, 44, 45, 46, 47: | |||
| 253 | - | s.Style.Background = parts[i] - 40 | |||
| 254 | - | s.Style.HasBackgroundRGB = false | |||
| 255 | - | case 49: | |||
| 256 | - | s.Style.Background = -1 | |||
| 257 | - | s.Style.HasBackgroundRGB = false | |||
| 258 | - | case 90, 91, 92, 93, 94, 95, 96, 97: | |||
| 259 | - | s.Style.Foreground = parts[i] - 90 + 8 | |||
| 260 | - | s.Style.HasForegroundRGB = false | |||
| 261 | - | case 100, 101, 102, 103, 104, 105, 106, 107: | |||
| 262 | - | s.Style.Background = parts[i] - 100 + 8 | |||
| 263 | - | s.Style.HasBackgroundRGB = false | |||
| 264 | - | case 38: | |||
| 265 | - | if i+2 < len(parts) && parts[i+1] == 5 { | |||
| 266 | - | s.Style.Foreground = parts[i+2] | |||
| 267 | - | s.Style.HasForegroundRGB = false | |||
| 268 | - | i += 2 | |||
| 269 | - | } else if i+4 < len(parts) && parts[i+1] == 2 { | |||
| 270 | - | s.Style.Foreground = -1 | |||
| 271 | - | s.Style.HasForegroundRGB = true | |||
| 272 | - | s.Style.ForegroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4]) | |||
| 273 | - | i += 4 | |||
| 274 | - | } | |||
| 275 | - | case 48: | |||
| 276 | - | if i+2 < len(parts) && parts[i+1] == 5 { | |||
| 277 | - | s.Style.Background = parts[i+2] | |||
| 278 | - | s.Style.HasBackgroundRGB = false | |||
| 279 | - | i += 2 | |||
| 280 | - | } else if i+4 < len(parts) && parts[i+1] == 2 { | |||
| 281 | - | s.Style.Background = -1 | |||
| 282 | - | s.Style.HasBackgroundRGB = true | |||
| 283 | - | s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4]) | |||
| 284 | - | i += 4 | |||
| 285 | - | } | |||
| 286 | - | case 58: | |||
| 287 | - | if i+2 < len(parts) && parts[i+1] == 5 { | |||
| 288 | - | s.Style.UnderlineColor = parts[i+2] | |||
| 289 | - | s.Style.HasUnderlineColor = true | |||
| 290 | - | s.Style.HasUnderlineColorRGB = false | |||
| 291 | - | i += 2 | |||
| 292 | - | } else if i+4 < len(parts) && parts[i+1] == 2 { | |||
| 293 | - | s.Style.HasUnderlineColor = false | |||
| 294 | - | s.Style.HasUnderlineColorRGB = true | |||
| 295 | - | s.Style.UnderlineColorRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4]) | |||
| 296 | - | i += 4 | |||
| 297 | - | } | |||
| 298 | - | case 59: | |||
| 299 | - | s.Style.HasUnderlineColor = false | |||
| 300 | - | s.Style.HasUnderlineColorRGB = false | |||
| 301 | - | } | |||
| 302 | - | } | |||
| 303 | - | } | |||
| 304 | - | ||||
| 305 | - | func sgrRGB(r, g, b int) renderer.RGB { | |||
| 306 | - | return renderer.RGB{ | |||
| 307 | - | R: uint8(clamp(r, 0, 255)), | |||
| 308 | - | G: uint8(clamp(g, 0, 255)), | |||
| 309 | - | B: uint8(clamp(b, 0, 255)), | |||
| 310 | - | } | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | func sgrUnderlineStyle(param int) renderer.UnderlineStyle { | |||
| 314 | - | switch param { | |||
| 315 | - | case 0: | |||
| 316 | - | return renderer.UnderlineNone | |||
| 317 | - | case 2: | |||
| 318 | - | return renderer.UnderlineDouble | |||
| 319 | - | case 3: | |||
| 320 | - | return renderer.UnderlineCurly | |||
| 321 | - | case 4: | |||
| 322 | - | return renderer.UnderlineDotted | |||
| 323 | - | case 5: | |||
| 324 | - | return renderer.UnderlineDashed | |||
| 325 | - | default: | |||
| 326 | - | return renderer.UnderlineSingle | |||
| 327 | - | } | |||
| 328 | - | } | |||
| 329 | - | ||||
| 330 | - | func (s *Screen) insertChars(n int) { | |||
| 331 | - | if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 { | |||
| 332 | - | return | |||
| 333 | - | } | |||
| 334 | - | w := s.Frame.Width | |||
| 335 | - | if n > w-s.Col { | |||
| 336 | - | n = w - s.Col | |||
| 337 | - | } | |||
| 338 | - | row := s.Frame.Row(s.Row) | |||
| 339 | - | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | |||
| 340 | - | // by the shift; its repair falls outside the default damage rect. | |||
| 341 | - | leftSplit := s.Col > 0 && row[s.Col].Continuation | |||
| 342 | - | for x := w - 1; x >= s.Col+n; x-- { | |||
| 343 | - | row[x] = row[x-n] | |||
| 344 | - | } | |||
| 345 | - | for x := s.Col; x < s.Col+n; x++ { | |||
| 346 | - | row[x] = renderer.BlankCell() | |||
| 347 | - | } | |||
| 348 | - | s.repairRow(s.Row) | |||
| 349 | - | dmgX := s.Col | |||
| 350 | - | if leftSplit { | |||
| 351 | - | dmgX = s.Col - 1 | |||
| 352 | - | } | |||
| 353 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1}) | |||
| 354 | - | } | |||
| 355 | - | ||||
| 356 | - | func (s *Screen) deleteChars(n int) { | |||
| 357 | - | if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 { | |||
| 358 | - | return | |||
| 359 | - | } | |||
| 360 | - | w := s.Frame.Width | |||
| 361 | - | if n > w-s.Col { | |||
| 362 | - | n = w - s.Col | |||
| 363 | - | } | |||
| 364 | - | row := s.Frame.Row(s.Row) | |||
| 365 | - | // A wide left half at Col-1 whose continuation sits at Col will be orphaned | |||
| 366 | - | // by the shift; its repair falls outside the default damage rect. | |||
| 367 | - | leftSplit := s.Col > 0 && row[s.Col].Continuation | |||
| 368 | - | for x := s.Col; x < w-n; x++ { | |||
| 369 | - | row[x] = row[x+n] | |||
| 370 | - | } | |||
| 371 | - | for x := w - n; x < w; x++ { | |||
| 372 | - | row[x] = renderer.BlankCell() | |||
| 373 | - | } | |||
| 374 | - | s.repairRow(s.Row) | |||
| 375 | - | dmgX := s.Col | |||
| 376 | - | if leftSplit { | |||
| 377 | - | dmgX = s.Col - 1 | |||
| 378 | - | } | |||
| 379 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1}) | |||
| 380 | - | } | |||
| 381 | - | ||||
| 382 | - | func (s *Screen) insertLines(n int) { | |||
| 383 | - | if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 { | |||
| 384 | - | return | |||
| 385 | - | } | |||
| 386 | - | s.scrollDownRegion(s.Row, s.scrollBottom, n) | |||
| 387 | - | } | |||
| 388 | - | ||||
| 389 | - | func (s *Screen) deleteLines(n int) { | |||
| 390 | - | if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 { | |||
| 391 | - | return | |||
| 392 | - | } | |||
| 393 | - | s.scrollUpRegion(s.Row, s.scrollBottom, n) | |||
| 394 | - | } | |||
| 395 | - | ||||
| 396 | - | func (s *Screen) clearScreenMode(mode int) { | |||
| 397 | - | s.clampCursor() | |||
| 398 | - | switch mode { | |||
| 399 | - | case 1: | |||
| 400 | - | for y := range min(s.Row+1, s.Frame.Height) { | |||
| 401 | - | end := s.Frame.Width | |||
| 402 | - | if y == s.Row { | |||
| 403 | - | end = min(s.Col+1, s.Frame.Width) | |||
| 404 | - | } | |||
| 405 | - | s.clearRow(y, 0, end) | |||
| 406 | - | } | |||
| 407 | - | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: min(s.Row+1, s.Frame.Height), Count: 1}) | |||
| 408 | - | case 2, 3: | |||
| 409 | - | blank := s.eraseCell() | |||
| 410 | - | for i := range s.Frame.Cells { | |||
| 411 | - | s.Frame.Cells[i] = blank | |||
| 412 | - | } | |||
| 413 | - | for y := range s.buffer.boundaries { | |||
| 414 | - | s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft} | |||
| 415 | - | s.buffer.rowIDs[y] = s.nextRowIDValue() | |||
| 416 | - | } | |||
| 417 | - | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1}) | |||
| 418 | - | default: | |||
| 419 | - | for y := s.Row; y < s.Frame.Height; y++ { | |||
| 420 | - | start := 0 | |||
| 421 | - | if y == s.Row { | |||
| 422 | - | start = s.Col | |||
| 423 | - | } | |||
| 424 | - | s.clearRow(y, start, s.Frame.Width) | |||
| 425 | - | } | |||
| 426 | - | s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1}) | |||
| 427 | - | } | |||
| 428 | - | } | |||
| 429 | - | ||||
| 430 | - | func (s *Screen) clearLineMode(mode int) { | |||
| 431 | - | s.clampCursor() | |||
| 432 | - | switch mode { | |||
| 433 | - | case 1: | |||
| 434 | - | start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.Frame.Width)) | |||
| 435 | - | s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1}) | |||
| 436 | - | case 2: | |||
| 437 | - | start, width := s.clearRow(s.Row, 0, s.Frame.Width) | |||
| 438 | - | s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1}) | |||
| 439 | - | default: | |||
| 440 | - | start, width := s.clearRow(s.Row, s.Col, s.Frame.Width) | |||
| 441 | - | s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1}) | |||
| 442 | - | } | |||
| 443 | - | } | |||
| 444 | - | ||||
| 445 | - | func (s *Screen) eraseChars(n int) { | |||
| 446 | - | if n <= 0 { | |||
| 447 | - | return | |||
| 448 | - | } | |||
| 449 | - | s.clampCursor() | |||
| 450 | - | if remaining := s.Frame.Width - s.Col; n > remaining { | |||
| 451 | - | n = remaining | |||
| 452 | - | } | |||
| 453 | - | end := s.Col + n | |||
| 454 | - | start, width := s.clearRow(s.Row, s.Col, end) | |||
| 455 | - | if width > 0 { | |||
| 456 | - | s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1}) | |||
| 457 | - | } | |||
| 458 | - | } | |||
| 459 | - | ||||
| 460 | - | func (s *Screen) clampCursor() { | |||
| 461 | - | s.Row = clamp(s.Row, 0, s.Frame.Height-1) | |||
| 462 | - | s.Col = clamp(s.Col, 0, s.Frame.Width-1) | |||
| 463 | - | } | |||
| 464 | - | ||||
| 465 | - | func (s *Screen) parseCSIInts(params string) []int { | |||
| 466 | - | s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params) | |||
| 467 | - | return s.csiScratch | |||
| 468 | - | } | |||
| 469 | - | ||||
| 470 | - | func parseCSIInts(params string) []int { | |||
| 471 | - | return parseCSIIntsInto(nil, params) | |||
| 472 | - | } | |||
| 473 | - | ||||
| 474 | - | func parseCSIIntsInto(out []int, params string) []int { | |||
| 475 | - | if params == "" { | |||
| 476 | - | return out | |||
| 477 | - | } | |||
| 478 | - | params = strings.TrimPrefix(params, "?") | |||
| 479 | - | params = strings.TrimPrefix(params, ">") | |||
| 480 | - | start := 0 | |||
| 481 | - | for i := 0; i <= len(params); i++ { | |||
| 482 | - | if i == len(params) || params[i] == ';' { | |||
| 483 | - | out = append(out, parseCSIInt(params[start:i])) | |||
| 484 | - | start = i + 1 | |||
| 485 | - | } | |||
| 486 | - | } | |||
| 487 | - | return out | |||
| 488 | - | } | |||
| 489 | - | ||||
| 490 | - | func (s *Screen) parseSGRInts(params string) []int { | |||
| 491 | - | s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params) | |||
| 492 | - | return s.sgrScratch | |||
| 493 | - | } | |||
| 494 | - | ||||
| 495 | - | func parseSGRInts(params string) []int { | |||
| 496 | - | return parseSGRIntsInto(nil, params) | |||
| 497 | - | } | |||
| 498 | - | ||||
| 499 | - | func parseSGRIntsInto(out []int, params string) []int { | |||
| 500 | - | if params == "" { | |||
| 501 | - | return out | |||
| 502 | - | } | |||
| 503 | - | start := 0 | |||
| 504 | - | for i := 0; i <= len(params); i++ { | |||
| 505 | - | if i == len(params) || params[i] == ';' { | |||
| 506 | - | out = appendSGRGroup(out, params[start:i]) | |||
| 507 | - | start = i + 1 | |||
| 508 | - | } | |||
| 509 | - | } | |||
| 510 | - | return out | |||
| 511 | - | } | |||
| 512 | - | ||||
| 513 | - | func appendSGRGroup(out []int, group string) []int { | |||
| 514 | - | colon := false | |||
| 515 | - | for i := 0; i < len(group); i++ { | |||
| 516 | - | if group[i] == ':' { | |||
| 517 | - | colon = true | |||
| 518 | - | break | |||
| 519 | - | } | |||
| 520 | - | } | |||
| 521 | - | if !colon { | |||
| 522 | - | return append(out, parseCSIInt(group)) | |||
| 523 | - | } | |||
| 524 | - | ||||
| 525 | - | parts := countSGRColonFields(group) | |||
| 526 | - | code := parseCSIInt(sgrColonField(group, 0)) | |||
| 527 | - | if code == 4 { | |||
| 528 | - | if parts < 2 { | |||
| 529 | - | return append(out, code) | |||
| 530 | - | } | |||
| 531 | - | return append(out, code, sgrUnderlineStyleMarker, parseCSIInt(sgrColonField(group, 1))) | |||
| 532 | - | } | |||
| 533 | - | if code != 38 && code != 48 && code != 58 { | |||
| 534 | - | return append(out, code) | |||
| 535 | - | } | |||
| 536 | - | mode := 0 | |||
| 537 | - | if parts > 1 { | |||
| 538 | - | mode = parseCSIInt(sgrColonField(group, 1)) | |||
| 539 | - | } | |||
| 540 | - | switch mode { | |||
| 541 | - | case 5: | |||
| 542 | - | if parts < 3 { | |||
| 543 | - | return out | |||
| 544 | - | } | |||
| 545 | - | out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2))) | |||
| 546 | - | case 2: | |||
| 547 | - | // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after | |||
| 548 | - | // the colorspace slot; code:mode:R:G:B omits that slot. | |||
| 549 | - | start := 2 | |||
| 550 | - | if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) { | |||
| 551 | - | start = 3 | |||
| 552 | - | } | |||
| 553 | - | if parts < start+3 { | |||
| 554 | - | return out | |||
| 555 | - | } | |||
| 556 | - | out = append(out, code, mode) | |||
| 557 | - | for i := range 3 { | |||
| 558 | - | out = append(out, parseCSIInt(sgrColonField(group, start+i))) | |||
| 559 | - | } | |||
| 560 | - | } | |||
| 561 | - | return out | |||
| 562 | - | } | |||
| 563 | - | ||||
| 564 | - | func countSGRColonFields(group string) int { | |||
| 565 | - | count := 1 | |||
| 566 | - | for i := 0; i < len(group); i++ { | |||
| 567 | - | if group[i] == ':' { | |||
| 568 | - | count++ | |||
| 569 | - | } | |||
| 570 | - | } | |||
| 571 | - | return count | |||
| 572 | - | } | |||
| 573 | - | ||||
| 574 | - | func sgrColonField(group string, index int) string { | |||
| 575 | - | start := 0 | |||
| 576 | - | field := 0 | |||
| 577 | - | for i := 0; i <= len(group); i++ { | |||
| 578 | - | if i == len(group) || group[i] == ':' { | |||
| 579 | - | if field == index { | |||
| 580 | - | return group[start:i] | |||
| 581 | - | } | |||
| 582 | - | field++ | |||
| 583 | - | start = i + 1 | |||
| 584 | - | } | |||
| 585 | - | } | |||
| 586 | - | return "" | |||
| 587 | - | } | |||
| 588 | - | ||||
| 589 | - | func parseCSIInt(param string) int { | |||
| 590 | - | if param == "" { | |||
| 591 | - | return 0 | |||
| 592 | - | } | |||
| 593 | - | v, err := strconv.Atoi(param) | |||
| 594 | - | if err != nil { | |||
| 595 | - | return 0 | |||
| 596 | - | } | |||
| 597 | - | return v | |||
| 598 | - | } | |||
D pkg/vt/screen_csi_test.go +0 -1066
| @@ -1,1066 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "strconv" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | - | "github.com/stretchr/testify/require" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | func TestParseCSIInts(t *testing.T) { | |||
| 12 | - | t.Parallel() | |||
| 13 | - | ||||
| 14 | - | tests := []struct { | |||
| 15 | - | name string | |||
| 16 | - | params string | |||
| 17 | - | want []int | |||
| 18 | - | }{ | |||
| 19 | - | {name: "empty", params: "", want: nil}, | |||
| 20 | - | {name: "single", params: "5", want: []int{5}}, | |||
| 21 | - | {name: "multiple", params: "1;2;3", want: []int{1, 2, 3}}, | |||
| 22 | - | {name: "leading empty token", params: ";5", want: []int{0, 5}}, | |||
| 23 | - | {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}}, | |||
| 24 | - | {name: "trailing empty token", params: "5;", want: []int{5, 0}}, | |||
| 25 | - | {name: "question prefix", params: "?25", want: []int{25}}, | |||
| 26 | - | {name: "greater prefix", params: ">0;1", want: []int{0, 1}}, | |||
| 27 | - | {name: "prefix order", params: "?>7", want: []int{7}}, | |||
| 28 | - | {name: "junk token", params: "12x;4", want: []int{0, 4}}, | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | for _, tt := range tests { | |||
| 32 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 33 | - | require.Equal(t, tt.want, parseCSIInts(tt.params)) | |||
| 34 | - | }) | |||
| 35 | - | } | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | func TestParseSGRInts(t *testing.T) { | |||
| 39 | - | t.Parallel() | |||
| 40 | - | ||||
| 41 | - | tests := []struct { | |||
| 42 | - | name string | |||
| 43 | - | params string | |||
| 44 | - | want []int | |||
| 45 | - | }{ | |||
| 46 | - | {name: "empty", params: "", want: nil}, | |||
| 47 | - | {name: "single", params: "1", want: []int{1}}, | |||
| 48 | - | {name: "multiple", params: "1;31", want: []int{1, 31}}, | |||
| 49 | - | {name: "leading empty token", params: ";5", want: []int{0, 5}}, | |||
| 50 | - | {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}}, | |||
| 51 | - | {name: "trailing empty token", params: "5;", want: []int{5, 0}}, | |||
| 52 | - | {name: "indexed foreground colon", params: "38:5:196", want: []int{38, 5, 196}}, | |||
| 53 | - | {name: "rgb foreground colon", params: "38:2:1:2:3", want: []int{38, 2, 1, 2, 3}}, | |||
| 54 | - | {name: "indexed background colon", params: "48:5:10", want: []int{48, 5, 10}}, | |||
| 55 | - | {name: "rgb foreground empty colorspace", params: "38:2::1:2:3", want: []int{38, 2, 1, 2, 3}}, | |||
| 56 | - | {name: "rgb foreground explicit colorspace", params: "38:2:9:1:2:3", want: []int{38, 2, 1, 2, 3}}, | |||
| 57 | - | {name: "non color colon group", params: "1:2:3", want: []int{1}}, | |||
| 58 | - | {name: "junk token", params: "abc;31", want: []int{0, 31}}, | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | - | for _, tt := range tests { | |||
| 62 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 63 | - | require.Equal(t, tt.want, parseSGRInts(tt.params)) | |||
| 64 | - | }) | |||
| 65 | - | } | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | func TestSGR(t *testing.T) { | |||
| 69 | - | tests := []struct { | |||
| 70 | - | name string | |||
| 71 | - | seq string | |||
| 72 | - | check func(t *testing.T, s *Screen) | |||
| 73 | - | }{ | |||
| 74 | - | { | |||
| 75 | - | name: "SGR 0 resets to default style", | |||
| 76 | - | seq: "\x1b[0m", | |||
| 77 | - | check: func(t *testing.T, s *Screen) { | |||
| 78 | - | if s.Style != (renderer.DefaultStyle()) { | |||
| 79 | - | t.Errorf("style after SGR 0 = %+v, want default", s.Style) | |||
| 80 | - | } | |||
| 81 | - | }, | |||
| 82 | - | }, | |||
| 83 | - | { | |||
| 84 | - | name: "SGR 1 sets bold", | |||
| 85 | - | seq: "\x1b[1m", | |||
| 86 | - | check: func(t *testing.T, s *Screen) { | |||
| 87 | - | if !s.Style.Bold { | |||
| 88 | - | t.Error("expected bold") | |||
| 89 | - | } | |||
| 90 | - | }, | |||
| 91 | - | }, | |||
| 92 | - | { | |||
| 93 | - | name: "SGR 7 sets inverse", | |||
| 94 | - | seq: "\x1b[7m", | |||
| 95 | - | check: func(t *testing.T, s *Screen) { | |||
| 96 | - | if !s.Style.Inverse { | |||
| 97 | - | t.Error("expected inverse") | |||
| 98 | - | } | |||
| 99 | - | }, | |||
| 100 | - | }, | |||
| 101 | - | { | |||
| 102 | - | name: "SGR 31 sets red foreground", | |||
| 103 | - | seq: "\x1b[31m", | |||
| 104 | - | check: func(t *testing.T, s *Screen) { | |||
| 105 | - | if s.Style.Foreground != 1 { | |||
| 106 | - | t.Errorf("foreground = %d, want 1 (red)", s.Style.Foreground) | |||
| 107 | - | } | |||
| 108 | - | }, | |||
| 109 | - | }, | |||
| 110 | - | { | |||
| 111 | - | name: "SGR 44 sets blue background", | |||
| 112 | - | seq: "\x1b[44m", | |||
| 113 | - | check: func(t *testing.T, s *Screen) { | |||
| 114 | - | if s.Style.Background != 4 { | |||
| 115 | - | t.Errorf("background = %d, want 4 (blue)", s.Style.Background) | |||
| 116 | - | } | |||
| 117 | - | }, | |||
| 118 | - | }, | |||
| 119 | - | { | |||
| 120 | - | name: "SGR 38;5 sets 256-color foreground", | |||
| 121 | - | seq: "\x1b[38;5;82m", | |||
| 122 | - | check: func(t *testing.T, s *Screen) { | |||
| 123 | - | if s.Style.Foreground != 82 { | |||
| 124 | - | t.Errorf("foreground = %d, want 82", s.Style.Foreground) | |||
| 125 | - | } | |||
| 126 | - | }, | |||
| 127 | - | }, | |||
| 128 | - | { | |||
| 129 | - | name: "SGR 48;5 sets 256-color background", | |||
| 130 | - | seq: "\x1b[48;5;200m", | |||
| 131 | - | check: func(t *testing.T, s *Screen) { | |||
| 132 | - | if s.Style.Background != 200 { | |||
| 133 | - | t.Errorf("background = %d, want 200", s.Style.Background) | |||
| 134 | - | } | |||
| 135 | - | }, | |||
| 136 | - | }, | |||
| 137 | - | { | |||
| 138 | - | name: "SGR 91 sets bright red foreground", | |||
| 139 | - | seq: "\x1b[91m", | |||
| 140 | - | check: func(t *testing.T, s *Screen) { | |||
| 141 | - | if s.Style.Foreground != 9 { | |||
| 142 | - | t.Errorf("foreground = %d, want 9 (bright red)", s.Style.Foreground) | |||
| 143 | - | } | |||
| 144 | - | }, | |||
| 145 | - | }, | |||
| 146 | - | { | |||
| 147 | - | name: "SGR 107 sets bright white background", | |||
| 148 | - | seq: "\x1b[107m", | |||
| 149 | - | check: func(t *testing.T, s *Screen) { | |||
| 150 | - | if s.Style.Background != 15 { | |||
| 151 | - | t.Errorf("background = %d, want 15 (bright white)", s.Style.Background) | |||
| 152 | - | } | |||
| 153 | - | }, | |||
| 154 | - | }, | |||
| 155 | - | { | |||
| 156 | - | name: "SGR 22 resets bold", | |||
| 157 | - | seq: "\x1b[1mHello\x1b[22mWorld", | |||
| 158 | - | check: func(t *testing.T, s *Screen) { | |||
| 159 | - | if s.Style.Bold { | |||
| 160 | - | t.Error("bold should be reset after 22") | |||
| 161 | - | } | |||
| 162 | - | }, | |||
| 163 | - | }, | |||
| 164 | - | { | |||
| 165 | - | name: "SGR 27 resets inverse", | |||
| 166 | - | seq: "\x1b[7m\x1b[27m", | |||
| 167 | - | check: func(t *testing.T, s *Screen) { | |||
| 168 | - | if s.Style.Inverse { | |||
| 169 | - | t.Error("inverse should be reset after 27") | |||
| 170 | - | } | |||
| 171 | - | }, | |||
| 172 | - | }, | |||
| 173 | - | { | |||
| 174 | - | name: "SGR 39 resets foreground to default", | |||
| 175 | - | seq: "\x1b[31m\x1b[39m", | |||
| 176 | - | check: func(t *testing.T, s *Screen) { | |||
| 177 | - | if s.Style.Foreground != -1 { | |||
| 178 | - | t.Errorf("foreground should be default after 39, got %d", s.Style.Foreground) | |||
| 179 | - | } | |||
| 180 | - | }, | |||
| 181 | - | }, | |||
| 182 | - | { | |||
| 183 | - | name: "SGR 49 resets background to default", | |||
| 184 | - | seq: "\x1b[44m\x1b[49m", | |||
| 185 | - | check: func(t *testing.T, s *Screen) { | |||
| 186 | - | if s.Style.Background != -1 { | |||
| 187 | - | t.Errorf("background should be default after 49, got %d", s.Style.Background) | |||
| 188 | - | } | |||
| 189 | - | }, | |||
| 190 | - | }, | |||
| 191 | - | { | |||
| 192 | - | name: "SGR style is applied to written cells", | |||
| 193 | - | seq: "\x1b[1;31mX", | |||
| 194 | - | check: func(t *testing.T, s *Screen) { | |||
| 195 | - | c := cellAt(s, 0, 0) | |||
| 196 | - | if !c.Style.Bold { | |||
| 197 | - | t.Error("cell should be bold") | |||
| 198 | - | } | |||
| 199 | - | if c.Style.Foreground != 1 { | |||
| 200 | - | t.Errorf("cell foreground = %d, want 1", c.Style.Foreground) | |||
| 201 | - | } | |||
| 202 | - | }, | |||
| 203 | - | }, | |||
| 204 | - | { | |||
| 205 | - | name: "multiple sequential SGR sequences accumulate", | |||
| 206 | - | seq: "\x1b[31m\x1b[1m\x1b[44mX", | |||
| 207 | - | check: func(t *testing.T, s *Screen) { | |||
| 208 | - | c := cellAt(s, 0, 0) | |||
| 209 | - | if c.Style.Foreground != 1 || !c.Style.Bold || c.Style.Background != 4 { | |||
| 210 | - | t.Errorf("cell style = %+v, want fg=1 bold bg=4", c.Style) | |||
| 211 | - | } | |||
| 212 | - | }, | |||
| 213 | - | }, | |||
| 214 | - | { | |||
| 215 | - | name: "SGR 3 sets italic on style and cell, SGR 23 clears it", | |||
| 216 | - | seq: "\x1b[3mX\x1b[23mY", | |||
| 217 | - | check: func(t *testing.T, s *Screen) { | |||
| 218 | - | if !cellAt(s, 0, 0).Style.Italic { | |||
| 219 | - | t.Errorf("first cell italic = false, want true") | |||
| 220 | - | } | |||
| 221 | - | if cellAt(s, 1, 0).Style.Italic { | |||
| 222 | - | t.Errorf("second cell italic = true, want false after SGR 23") | |||
| 223 | - | } | |||
| 224 | - | if s.Style.Italic { | |||
| 225 | - | t.Errorf("current style italic = true, want false after SGR 23") | |||
| 226 | - | } | |||
| 227 | - | }, | |||
| 228 | - | }, | |||
| 229 | - | { | |||
| 230 | - | name: "SGR preserves dim underline blink and strikethrough with resets", | |||
| 231 | - | seq: "\x1b[2;4;5;9mA\x1b[22;24;25;29mB", | |||
| 232 | - | check: func(t *testing.T, s *Screen) { | |||
| 233 | - | first := cellAt(s, 0, 0).Style | |||
| 234 | - | if first.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough { | |||
| 235 | - | t.Errorf("first cell attrs = %b, want dim underline blink strikethrough", first.Attrs) | |||
| 236 | - | } | |||
| 237 | - | second := cellAt(s, 1, 0).Style | |||
| 238 | - | if second.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 { | |||
| 239 | - | t.Errorf("second cell attrs = %b, want resets cleared", second.Attrs) | |||
| 240 | - | } | |||
| 241 | - | }, | |||
| 242 | - | }, | |||
| 243 | - | { | |||
| 244 | - | name: "SGR 22 resets both bold and dim", | |||
| 245 | - | seq: "\x1b[1;2m\x1b[22m", | |||
| 246 | - | check: func(t *testing.T, s *Screen) { | |||
| 247 | - | if s.Style.Bold || s.Style.Attrs&renderer.AttrDim != 0 { | |||
| 248 | - | t.Errorf("style after SGR 22 = bold:%v attrs:%b, want neither bold nor dim", s.Style.Bold, s.Style.Attrs) | |||
| 249 | - | } | |||
| 250 | - | }, | |||
| 251 | - | }, | |||
| 252 | - | { | |||
| 253 | - | name: "SGR underline style and color are preserved and reset", | |||
| 254 | - | seq: "\x1b[4:3;58:2::255:0:0mA\x1b[59mB", | |||
| 255 | - | check: func(t *testing.T, s *Screen) { | |||
| 256 | - | first := cellAt(s, 0, 0).Style | |||
| 257 | - | if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly { | |||
| 258 | - | t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle) | |||
| 259 | - | } | |||
| 260 | - | want := renderer.RGB{R: 255, G: 0, B: 0} | |||
| 261 | - | if !first.HasUnderlineColorRGB || first.UnderlineColorRGB != want { | |||
| 262 | - | t.Errorf("first underline color = rgb:%v %+v, want %+v", first.HasUnderlineColorRGB, first.UnderlineColorRGB, want) | |||
| 263 | - | } | |||
| 264 | - | second := cellAt(s, 1, 0).Style | |||
| 265 | - | if second.HasUnderlineColorRGB || second.HasUnderlineColor { | |||
| 266 | - | t.Errorf("second underline color flags = indexed:%v rgb:%v, want reset", second.HasUnderlineColor, second.HasUnderlineColorRGB) | |||
| 267 | - | } | |||
| 268 | - | }, | |||
| 269 | - | }, | |||
| 270 | - | { | |||
| 271 | - | name: "SGR 4 colon zero clears underline", | |||
| 272 | - | seq: "\x1b[4:3mA\x1b[4:0mB", | |||
| 273 | - | check: func(t *testing.T, s *Screen) { | |||
| 274 | - | first := cellAt(s, 0, 0).Style | |||
| 275 | - | if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly { | |||
| 276 | - | t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle) | |||
| 277 | - | } | |||
| 278 | - | second := cellAt(s, 1, 0).Style | |||
| 279 | - | if second.Attrs&renderer.AttrUnderline != 0 || second.UnderlineStyle != renderer.UnderlineNone { | |||
| 280 | - | t.Errorf("second underline = attrs:%b style:%d, want cleared", second.Attrs, second.UnderlineStyle) | |||
| 281 | - | } | |||
| 282 | - | }, | |||
| 283 | - | }, | |||
| 284 | - | { | |||
| 285 | - | name: "empty SGR params reset style", | |||
| 286 | - | seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset | |||
| 287 | - | check: func(t *testing.T, s *Screen) { | |||
| 288 | - | if s.Style != (renderer.DefaultStyle()) { | |||
| 289 | - | t.Errorf("style after empty SGR = %+v, want default", s.Style) | |||
| 290 | - | } | |||
| 291 | - | }, | |||
| 292 | - | }, | |||
| 293 | - | } | |||
| 294 | - | ||||
| 295 | - | for _, tt := range tests { | |||
| 296 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 297 | - | s := NewScreen(10, 3) | |||
| 298 | - | s.Write([]byte(tt.seq)) | |||
| 299 | - | tt.check(t, s) | |||
| 300 | - | }) | |||
| 301 | - | } | |||
| 302 | - | } | |||
| 303 | - | ||||
| 304 | - | func TestSGRTruecolor(t *testing.T) { | |||
| 305 | - | tests := []struct { | |||
| 306 | - | name string | |||
| 307 | - | seq string | |||
| 308 | - | check func(t *testing.T, s *Screen) | |||
| 309 | - | }{ | |||
| 310 | - | { | |||
| 311 | - | name: "SGR 38;2 sets truecolor foreground on style and cell", | |||
| 312 | - | seq: "\x1b[38;2;12;34;56mX", | |||
| 313 | - | check: func(t *testing.T, s *Screen) { | |||
| 314 | - | want := renderer.RGB{R: 12, G: 34, B: 56} | |||
| 315 | - | if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want { | |||
| 316 | - | t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want) | |||
| 317 | - | } | |||
| 318 | - | if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want { | |||
| 319 | - | t.Errorf("cell foreground RGB = %+v, want %+v", got, want) | |||
| 320 | - | } | |||
| 321 | - | }, | |||
| 322 | - | }, | |||
| 323 | - | { | |||
| 324 | - | name: "SGR 38:2 sets truecolor foreground", | |||
| 325 | - | seq: "\x1b[38:2:12:34:56mX", | |||
| 326 | - | check: func(t *testing.T, s *Screen) { | |||
| 327 | - | want := renderer.RGB{R: 12, G: 34, B: 56} | |||
| 328 | - | if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want { | |||
| 329 | - | t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want) | |||
| 330 | - | } | |||
| 331 | - | if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want { | |||
| 332 | - | t.Errorf("cell foreground RGB = %+v, want %+v", got, want) | |||
| 333 | - | } | |||
| 334 | - | }, | |||
| 335 | - | }, | |||
| 336 | - | { | |||
| 337 | - | name: "SGR 38:2 empty color space sets truecolor foreground", | |||
| 338 | - | seq: "\x1b[38:2::12:34:56mX", | |||
| 339 | - | check: func(t *testing.T, s *Screen) { | |||
| 340 | - | want := renderer.RGB{R: 12, G: 34, B: 56} | |||
| 341 | - | if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want { | |||
| 342 | - | t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want) | |||
| 343 | - | } | |||
| 344 | - | if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want { | |||
| 345 | - | t.Errorf("cell foreground RGB = %+v, want %+v", got, want) | |||
| 346 | - | } | |||
| 347 | - | }, | |||
| 348 | - | }, | |||
| 349 | - | { | |||
| 350 | - | name: "SGR 38:2 color space id sets truecolor foreground", | |||
| 351 | - | seq: "\x1b[38:2:1:12:34:56mX", | |||
| 352 | - | check: func(t *testing.T, s *Screen) { | |||
| 353 | - | want := renderer.RGB{R: 12, G: 34, B: 56} | |||
| 354 | - | if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want { | |||
| 355 | - | t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want) | |||
| 356 | - | } | |||
| 357 | - | if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want { | |||
| 358 | - | t.Errorf("cell foreground RGB = %+v, want %+v", got, want) | |||
| 359 | - | } | |||
| 360 | - | }, | |||
| 361 | - | }, | |||
| 362 | - | { | |||
| 363 | - | name: "mixed semicolon and colon SGR groups", | |||
| 364 | - | seq: "\x1b[1;38:2:10:20:30;7mX", | |||
| 365 | - | check: func(t *testing.T, s *Screen) { | |||
| 366 | - | want := renderer.RGB{R: 10, G: 20, B: 30} | |||
| 367 | - | if !s.Style.Bold || !s.Style.Inverse { | |||
| 368 | - | t.Errorf("bold/inverse = %v/%v, want true/true", s.Style.Bold, s.Style.Inverse) | |||
| 369 | - | } | |||
| 370 | - | if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want { | |||
| 371 | - | t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want) | |||
| 372 | - | } | |||
| 373 | - | }, | |||
| 374 | - | }, | |||
| 375 | - | { | |||
| 376 | - | name: "truncated colon truecolor group does not consume next parameter", | |||
| 377 | - | seq: "\x1b[38:2:1:2;31m", | |||
| 378 | - | check: func(t *testing.T, s *Screen) { | |||
| 379 | - | if s.Style.Foreground != 1 || s.Style.HasForegroundRGB { | |||
| 380 | - | t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB) | |||
| 381 | - | } | |||
| 382 | - | }, | |||
| 383 | - | }, | |||
| 384 | - | { | |||
| 385 | - | name: "SGR 48;2 sets truecolor background", | |||
| 386 | - | seq: "\x1b[48;2;200;100;50m", | |||
| 387 | - | check: func(t *testing.T, s *Screen) { | |||
| 388 | - | want := renderer.RGB{R: 200, G: 100, B: 50} | |||
| 389 | - | if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want { | |||
| 390 | - | t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want) | |||
| 391 | - | } | |||
| 392 | - | }, | |||
| 393 | - | }, | |||
| 394 | - | { | |||
| 395 | - | name: "SGR 48:2 sets truecolor background", | |||
| 396 | - | seq: "\x1b[48:2:200:100:50m", | |||
| 397 | - | check: func(t *testing.T, s *Screen) { | |||
| 398 | - | want := renderer.RGB{R: 200, G: 100, B: 50} | |||
| 399 | - | if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want { | |||
| 400 | - | t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want) | |||
| 401 | - | } | |||
| 402 | - | }, | |||
| 403 | - | }, | |||
| 404 | - | { | |||
| 405 | - | name: "SGR 38:5 sets 256-color foreground", | |||
| 406 | - | seq: "\x1b[38:5:82m", | |||
| 407 | - | check: func(t *testing.T, s *Screen) { | |||
| 408 | - | if s.Style.Foreground != 82 || s.Style.HasForegroundRGB { | |||
| 409 | - | t.Errorf("foreground = %d rgb:%v, want 82 false", s.Style.Foreground, s.Style.HasForegroundRGB) | |||
| 410 | - | } | |||
| 411 | - | }, | |||
| 412 | - | }, | |||
| 413 | - | { | |||
| 414 | - | name: "SGR 48:5 sets 256-color background", | |||
| 415 | - | seq: "\x1b[48:5:200m", | |||
| 416 | - | check: func(t *testing.T, s *Screen) { | |||
| 417 | - | if s.Style.Background != 200 || s.Style.HasBackgroundRGB { | |||
| 418 | - | t.Errorf("background = %d rgb:%v, want 200 false", s.Style.Background, s.Style.HasBackgroundRGB) | |||
| 419 | - | } | |||
| 420 | - | }, | |||
| 421 | - | }, | |||
| 422 | - | { | |||
| 423 | - | name: "truncated colon foreground index group does not consume next parameter", | |||
| 424 | - | seq: "\x1b[38:5;31m", | |||
| 425 | - | check: func(t *testing.T, s *Screen) { | |||
| 426 | - | if s.Style.Foreground != 1 || s.Style.HasForegroundRGB { | |||
| 427 | - | t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB) | |||
| 428 | - | } | |||
| 429 | - | }, | |||
| 430 | - | }, | |||
| 431 | - | { | |||
| 432 | - | name: "truncated colon background index group does not consume next parameter", | |||
| 433 | - | seq: "\x1b[48:5;44m", | |||
| 434 | - | check: func(t *testing.T, s *Screen) { | |||
| 435 | - | if s.Style.Background != 4 || s.Style.HasBackgroundRGB { | |||
| 436 | - | t.Errorf("background = %d rgb:%v, want 4 false", s.Style.Background, s.Style.HasBackgroundRGB) | |||
| 437 | - | } | |||
| 438 | - | }, | |||
| 439 | - | }, | |||
| 440 | - | { | |||
| 441 | - | name: "SGR 39 after truecolor clears truecolor foreground", | |||
| 442 | - | seq: "\x1b[38;2;12;34;56m\x1b[39m", | |||
| 443 | - | check: func(t *testing.T, s *Screen) { | |||
| 444 | - | if s.Style.HasForegroundRGB || s.Style.Foreground != -1 { | |||
| 445 | - | t.Errorf("foreground after reset = rgb:%v index:%d, want default", s.Style.HasForegroundRGB, s.Style.Foreground) | |||
| 446 | - | } | |||
| 447 | - | }, | |||
| 448 | - | }, | |||
| 449 | - | { | |||
| 450 | - | name: "truecolor components are clamped to 0-255", | |||
| 451 | - | seq: "\x1b[38;2;-1;300;42m", | |||
| 452 | - | check: func(t *testing.T, s *Screen) { | |||
| 453 | - | want := renderer.RGB{R: 0, G: 255, B: 42} | |||
| 454 | - | if s.Style.ForegroundRGB != want { | |||
| 455 | - | t.Errorf("foreground RGB = %+v, want %+v", s.Style.ForegroundRGB, want) | |||
| 456 | - | } | |||
| 457 | - | }, | |||
| 458 | - | }, | |||
| 459 | - | } | |||
| 460 | - | ||||
| 461 | - | for _, tt := range tests { | |||
| 462 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 463 | - | s := NewScreen(10, 3) | |||
| 464 | - | s.Write([]byte(tt.seq)) | |||
| 465 | - | tt.check(t, s) | |||
| 466 | - | }) | |||
| 467 | - | } | |||
| 468 | - | } | |||
| 469 | - | ||||
| 470 | - | func TestSGRColorModeTransitionsClearInactiveState(t *testing.T) { | |||
| 471 | - | tests := []struct { | |||
| 472 | - | name string | |||
| 473 | - | seq string | |||
| 474 | - | foreground int | |||
| 475 | - | background int | |||
| 476 | - | hasForeground bool | |||
| 477 | - | hasBackground bool | |||
| 478 | - | }{ | |||
| 479 | - | {name: "foreground rgb to bright", seq: "\x1b[38;2;1;2;3m\x1b[91m", foreground: 9, background: -1}, | |||
| 480 | - | {name: "foreground rgb to indexed", seq: "\x1b[38;2;1;2;3m\x1b[38;5;82m", foreground: 82, background: -1}, | |||
| 481 | - | {name: "foreground indexed to rgb", seq: "\x1b[38;5;82m\x1b[38;2;4;5;6m", foreground: -1, background: -1, hasForeground: true}, | |||
| 482 | - | {name: "background rgb to bright", seq: "\x1b[48;2;1;2;3m\x1b[107m", foreground: -1, background: 15}, | |||
| 483 | - | {name: "background rgb to indexed", seq: "\x1b[48;2;1;2;3m\x1b[48;5;200m", foreground: -1, background: 200}, | |||
| 484 | - | {name: "background rgb to default", seq: "\x1b[48;2;1;2;3m\x1b[49m", foreground: -1, background: -1}, | |||
| 485 | - | {name: "background indexed to rgb", seq: "\x1b[48;5;200m\x1b[48;2;4;5;6m", foreground: -1, background: -1, hasBackground: true}, | |||
| 486 | - | } | |||
| 487 | - | ||||
| 488 | - | for _, tt := range tests { | |||
| 489 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 490 | - | s := NewScreen(10, 3) | |||
| 491 | - | s.Write([]byte(tt.seq)) | |||
| 492 | - | ||||
| 493 | - | if s.Style.Foreground != tt.foreground || s.Style.HasForegroundRGB != tt.hasForeground { | |||
| 494 | - | t.Errorf("foreground = %d rgb:%v, want %d rgb:%v", s.Style.Foreground, s.Style.HasForegroundRGB, tt.foreground, tt.hasForeground) | |||
| 495 | - | } | |||
| 496 | - | if s.Style.Background != tt.background || s.Style.HasBackgroundRGB != tt.hasBackground { | |||
| 497 | - | t.Errorf("background = %d rgb:%v, want %d rgb:%v", s.Style.Background, s.Style.HasBackgroundRGB, tt.background, tt.hasBackground) | |||
| 498 | - | } | |||
| 499 | - | }) | |||
| 500 | - | } | |||
| 501 | - | } | |||
| 502 | - | ||||
| 503 | - | func TestClearAndErase(t *testing.T) { | |||
| 504 | - | tests := []struct { | |||
| 505 | - | name string | |||
| 506 | - | run func(t *testing.T) | |||
| 507 | - | }{ | |||
| 508 | - | { | |||
| 509 | - | name: "CSI 2J clears the whole screen", | |||
| 510 | - | run: func(t *testing.T) { | |||
| 511 | - | s := NewScreen(5, 3) | |||
| 512 | - | s.Write([]byte("HelloWorldABCDE")) | |||
| 513 | - | // Clear damage from writing. | |||
| 514 | - | s.ClearDamage() | |||
| 515 | - | ||||
| 516 | - | s.Write([]byte("\x1b[2J")) | |||
| 517 | - | ||||
| 518 | - | // All cells should be blank. | |||
| 519 | - | for y := range 3 { | |||
| 520 | - | for x := range 5 { | |||
| 521 | - | if c := cellAt(s, x, y); c.Rune != ' ' { | |||
| 522 | - | t.Errorf("cell(%d,%d) = %q, want space after clear", x, y, c.Rune) | |||
| 523 | - | } | |||
| 524 | - | } | |||
| 525 | - | } | |||
| 526 | - | ||||
| 527 | - | d := s.Damage() | |||
| 528 | - | if !hasDamageKind(d, renderer.DamageClear) { | |||
| 529 | - | t.Error("expected DamageClear after CSI 2 J") | |||
| 530 | - | } | |||
| 531 | - | }, | |||
| 532 | - | }, | |||
| 533 | - | { | |||
| 534 | - | name: "CSI K clears from cursor to end of line", | |||
| 535 | - | run: func(t *testing.T) { | |||
| 536 | - | s := NewScreen(10, 3) | |||
| 537 | - | s.Write([]byte("HelloWorld")) | |||
| 538 | - | s.ClearDamage() | |||
| 539 | - | ||||
| 540 | - | // Position cursor at row 1 col 6 (0-indexed: 0, 5) then clear to end. | |||
| 541 | - | s.Write([]byte("\x1b[1;6H")) | |||
| 542 | - | s.Write([]byte("\x1b[K")) | |||
| 543 | - | ||||
| 544 | - | for x := 5; x < 10; x++ { | |||
| 545 | - | if c := cellAt(s, x, 0); c.Rune != ' ' { | |||
| 546 | - | t.Errorf("cell(%d,0) = %q, want space after clear line", x, c.Rune) | |||
| 547 | - | } | |||
| 548 | - | } | |||
| 549 | - | // First 5 chars should remain. | |||
| 550 | - | assertCell(t, s, 0, 0, 'H') | |||
| 551 | - | ||||
| 552 | - | d := s.Damage() | |||
| 553 | - | if !hasDamageKind(d, renderer.DamageClear) { | |||
| 554 | - | t.Error("expected DamageClear after CSI K") | |||
| 555 | - | } | |||
| 556 | - | }, | |||
| 557 | - | }, | |||
| 558 | - | { | |||
| 559 | - | name: "erase display modes 0/1/2 (implicit, 1, 2/3)", | |||
| 560 | - | run: func(t *testing.T) { | |||
| 561 | - | s := NewScreen(5, 3) | |||
| 562 | - | s.Write([]byte("abcdefghijklmno")) | |||
| 563 | - | s.Write([]byte("\x1b[2;3H")) | |||
| 564 | - | s.ClearDamage() | |||
| 565 | - | s.Write([]byte("\x1b[J")) | |||
| 566 | - | assertCell(t, s, 0, 0, 'a') | |||
| 567 | - | assertCell(t, s, 1, 1, 'g') | |||
| 568 | - | if c := cellAt(s, 2, 1); c.Rune != ' ' { | |||
| 569 | - | t.Fatalf("cursor-to-end clear left %q at cursor", c.Rune) | |||
| 570 | - | } | |||
| 571 | - | ||||
| 572 | - | s = NewScreen(5, 3) | |||
| 573 | - | s.Write([]byte("abcdefghijklmno")) | |||
| 574 | - | s.Write([]byte("\x1b[2;3H")) | |||
| 575 | - | s.Write([]byte("\x1b[1J")) | |||
| 576 | - | if c := cellAt(s, 0, 0); c.Rune != ' ' { | |||
| 577 | - | t.Fatalf("start-to-cursor clear left %q at start", c.Rune) | |||
| 578 | - | } | |||
| 579 | - | assertCell(t, s, 3, 1, 'i') | |||
| 580 | - | }, | |||
| 581 | - | }, | |||
| 582 | - | { | |||
| 583 | - | name: "erase line modes 0/1/2", | |||
| 584 | - | run: func(t *testing.T) { | |||
| 585 | - | s := NewScreen(5, 2) | |||
| 586 | - | s.Write([]byte("abcde")) | |||
| 587 | - | s.Write([]byte("\x1b[1;3H")) | |||
| 588 | - | s.Write([]byte("\x1b[1K")) | |||
| 589 | - | if c := cellAt(s, 0, 0); c.Rune != ' ' { | |||
| 590 | - | t.Fatalf("line start clear left %q", c.Rune) | |||
| 591 | - | } | |||
| 592 | - | assertCell(t, s, 3, 0, 'd') | |||
| 593 | - | ||||
| 594 | - | s = NewScreen(5, 2) | |||
| 595 | - | s.Write([]byte("abcde")) | |||
| 596 | - | s.Write([]byte("\x1b[1;3H")) | |||
| 597 | - | s.Write([]byte("\x1b[2K")) | |||
| 598 | - | for x := range 5 { | |||
| 599 | - | if c := cellAt(s, x, 0); c.Rune != ' ' { | |||
| 600 | - | t.Fatalf("line clear left %q at %d", c.Rune, x) | |||
| 601 | - | } | |||
| 602 | - | } | |||
| 603 | - | }, | |||
| 604 | - | }, | |||
| 605 | - | } | |||
| 606 | - | ||||
| 607 | - | for _, tt := range tests { | |||
| 608 | - | t.Run(tt.name, tt.run) | |||
| 609 | - | } | |||
| 610 | - | } | |||
| 611 | - | ||||
| 612 | - | func TestEraseCharacters(t *testing.T) { | |||
| 613 | - | maxInt := strconv.Itoa(int(^uint(0) >> 1)) | |||
| 614 | - | tests := []struct { | |||
| 615 | - | name string | |||
| 616 | - | width int | |||
| 617 | - | row int | |||
| 618 | - | initial string | |||
| 619 | - | seq string | |||
| 620 | - | wantRow string | |||
| 621 | - | wantCol int | |||
| 622 | - | wantDamage []renderer.Damage | |||
| 623 | - | }{ | |||
| 624 | - | { | |||
| 625 | - | name: "explicit count", | |||
| 626 | - | width: 50, | |||
| 627 | - | row: 1, | |||
| 628 | - | initial: ` // scroll-method "on-button-down"`, | |||
| 629 | - | seq: "\x1b[19Gbutton 273\x1b[13X", | |||
| 630 | - | wantRow: " // scroll-button 273", | |||
| 631 | - | wantCol: 28, | |||
| 632 | - | wantDamage: []renderer.Damage{ | |||
| 633 | - | {Kind: renderer.DamageText, X: 18, Y: 1, Width: 10, Height: 1, Count: 1}, | |||
| 634 | - | {Kind: renderer.DamageClear, X: 28, Y: 1, Width: 13, Height: 1, Count: 1}, | |||
| 635 | - | }, | |||
| 636 | - | }, | |||
| 637 | - | { | |||
| 638 | - | name: "omitted count erases exactly one cell", | |||
| 639 | - | width: 6, | |||
| 640 | - | row: 1, | |||
| 641 | - | initial: "abcdef", | |||
| 642 | - | seq: "\x1b[3G\x1b[X", | |||
| 643 | - | wantRow: "ab def", | |||
| 644 | - | wantCol: 2, | |||
| 645 | - | wantDamage: []renderer.Damage{{Kind: renderer.DamageClear, X: 2, Y: 1, Width: 1, Height: 1, Count: 1}}, | |||
| 646 | - | }, | |||
| 647 | - | { | |||
| 648 | - | name: "maximum count clips to screen edge", | |||
| 649 | - | width: 6, | |||
| 650 | - | row: 1, | |||
| 651 | - | initial: "abcdef", | |||
| 652 | - | seq: "\x1b[4G\x1b[" + maxInt + "X", | |||
| 653 | - | wantRow: "abc", | |||
| 654 | - | wantCol: 3, | |||
| 655 | - | wantDamage: []renderer.Damage{{Kind: renderer.DamageClear, X: 3, Y: 1, Width: 3, Height: 1, Count: 1}}, | |||
| 656 | - | }, | |||
| 657 | - | } | |||
| 658 | - | ||||
| 659 | - | for _, tt := range tests { | |||
| 660 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 661 | - | s := NewScreen(tt.width, 2) | |||
| 662 | - | s.Write([]byte("\x1b[2;1H")) | |||
| 663 | - | s.Write([]byte(tt.initial)) | |||
| 664 | - | s.ClearDamage() | |||
| 665 | - | s.Write([]byte(tt.seq)) | |||
| 666 | - | ||||
| 667 | - | for x := range tt.width { | |||
| 668 | - | want := ' ' | |||
| 669 | - | if x < len(tt.wantRow) { | |||
| 670 | - | want = rune(tt.wantRow[x]) | |||
| 671 | - | } | |||
| 672 | - | require.Equal(t, want, cellAt(s, x, tt.row).Rune, "cell(%d,%d)", x, tt.row) | |||
| 673 | - | } | |||
| 674 | - | require.Equal(t, tt.row, s.Row) | |||
| 675 | - | require.Equal(t, tt.wantCol, s.Col) | |||
| 676 | - | require.Equal(t, tt.wantDamage, s.Damage()) | |||
| 677 | - | }) | |||
| 678 | - | } | |||
| 679 | - | } | |||
| 680 | - | ||||
| 681 | - | func TestBackgroundColorErase(t *testing.T) { | |||
| 682 | - | tests := []struct { | |||
| 683 | - | name string | |||
| 684 | - | width int | |||
| 685 | - | height int | |||
| 686 | - | seq string | |||
| 687 | - | startX int | |||
| 688 | - | wantStyle renderer.Style | |||
| 689 | - | }{ | |||
| 690 | - | { | |||
| 691 | - | name: "indexed background for EL", | |||
| 692 | - | width: 8, | |||
| 693 | - | height: 1, | |||
| 694 | - | seq: "\x1b[1;31;48;5;236mX\x1b[K", | |||
| 695 | - | startX: 1, | |||
| 696 | - | wantStyle: renderer.Style{Foreground: -1, Background: 236}, | |||
| 697 | - | }, | |||
| 698 | - | { | |||
| 699 | - | name: "truecolor background for full ED", | |||
| 700 | - | width: 3, | |||
| 701 | - | height: 2, | |||
| 702 | - | seq: "abcdef\x1b[1;31;48;2;12;34;56m\x1b[2J", | |||
| 703 | - | wantStyle: renderer.Style{ | |||
| 704 | - | Foreground: -1, | |||
| 705 | - | Background: -1, | |||
| 706 | - | HasBackgroundRGB: true, | |||
| 707 | - | BackgroundRGB: renderer.RGB{R: 12, G: 34, B: 56}, | |||
| 708 | - | }, | |||
| 709 | - | }, | |||
| 710 | - | { | |||
| 711 | - | name: "default background after SGR 49", | |||
| 712 | - | width: 2, | |||
| 713 | - | height: 1, | |||
| 714 | - | seq: "\x1b[48;2;12;34;56m\x1b[49m\x1b[K", | |||
| 715 | - | wantStyle: renderer.DefaultStyle(), | |||
| 716 | - | }, | |||
| 717 | - | { | |||
| 718 | - | name: "default background after SGR 0", | |||
| 719 | - | width: 2, | |||
| 720 | - | height: 1, | |||
| 721 | - | seq: "\x1b[48;2;12;34;56m\x1b[0m\x1b[K", | |||
| 722 | - | wantStyle: renderer.DefaultStyle(), | |||
| 723 | - | }, | |||
| 724 | - | } | |||
| 725 | - | ||||
| 726 | - | for _, tt := range tests { | |||
| 727 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 728 | - | s := NewScreen(tt.width, tt.height) | |||
| 729 | - | s.Write([]byte(tt.seq)) | |||
| 730 | - | ||||
| 731 | - | for y := range tt.height { | |||
| 732 | - | for x := tt.startX; x < tt.width; x++ { | |||
| 733 | - | cell := cellAt(s, x, y) | |||
| 734 | - | if cell.Rune != ' ' || cell.Style != tt.wantStyle { | |||
| 735 | - | t.Errorf("cell(%d,%d) = %+v, want space with style %+v", x, y, cell, tt.wantStyle) | |||
| 736 | - | } | |||
| 737 | - | } | |||
| 738 | - | } | |||
| 739 | - | }) | |||
| 740 | - | } | |||
| 741 | - | } | |||
| 742 | - | ||||
| 743 | - | func TestCursorMove(t *testing.T) { | |||
| 744 | - | tests := []struct { | |||
| 745 | - | name string | |||
| 746 | - | seq string | |||
| 747 | - | initRow int | |||
| 748 | - | initCol int | |||
| 749 | - | wantRow int | |||
| 750 | - | wantCol int | |||
| 751 | - | }{ | |||
| 752 | - | {name: "CSI H moves to explicit row/col", seq: "\x1b[3;4H", wantRow: 2, wantCol: 3}, | |||
| 753 | - | {name: "CSI H clamps to bottom-right when out of bounds", seq: "\x1b[100;200H", wantRow: 4, wantCol: 9}, | |||
| 754 | - | {name: "CSI f moves to explicit row/col", seq: "\x1b[2;8f", wantRow: 1, wantCol: 7}, | |||
| 755 | - | {name: "CSI C moves forward after CR", seq: "\r\x1b[3C", wantRow: 0, wantCol: 3}, | |||
| 756 | - | {name: "CSI H with no params moves to (1,1)", seq: "\x1b[H", wantRow: 0, wantCol: 0}, | |||
| 757 | - | {name: "CSI ;5H uses default row with explicit column", seq: "\x1b[;5H", initRow: 3, initCol: 4, wantRow: 0, wantCol: 4}, | |||
| 758 | - | } | |||
| 759 | - | ||||
| 760 | - | for _, tt := range tests { | |||
| 761 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 762 | - | s := NewScreen(10, 5) | |||
| 763 | - | s.Row, s.Col = tt.initRow, tt.initCol | |||
| 764 | - | s.Write([]byte(tt.seq)) | |||
| 765 | - | ||||
| 766 | - | if s.Row != tt.wantRow || s.Col != tt.wantCol { | |||
| 767 | - | t.Errorf("cursor at row=%d col=%d, want row=%d col=%d", s.Row, s.Col, tt.wantRow, tt.wantCol) | |||
| 768 | - | } | |||
| 769 | - | }) | |||
| 770 | - | } | |||
| 771 | - | } | |||
| 772 | - | ||||
| 773 | - | func TestCSIEditingSequences(t *testing.T) { | |||
| 774 | - | tests := []struct { | |||
| 775 | - | name string | |||
| 776 | - | run func(t *testing.T) | |||
| 777 | - | }{ | |||
| 778 | - | { | |||
| 779 | - | name: "directional cursor moves (A/B/C/D)", | |||
| 780 | - | run: func(t *testing.T) { | |||
| 781 | - | s := NewScreen(10, 4) | |||
| 782 | - | s.Write([]byte("\x1b[3;5HX\x1b[2D<\x1b[1A^\x1b[1Bv\x1b[3C>")) | |||
| 783 | - | ||||
| 784 | - | assertCell(t, s, 4, 2, 'X') | |||
| 785 | - | assertCell(t, s, 3, 2, '<') | |||
| 786 | - | assertCell(t, s, 4, 1, '^') | |||
| 787 | - | assertCell(t, s, 5, 2, 'v') | |||
| 788 | - | assertCell(t, s, 9, 2, '>') | |||
| 789 | - | }, | |||
| 790 | - | }, | |||
| 791 | - | { | |||
| 792 | - | name: "insert and delete characters (@ and P)", | |||
| 793 | - | run: func(t *testing.T) { | |||
| 794 | - | s := NewScreen(8, 2) | |||
| 795 | - | s.Write([]byte("abcdef")) | |||
| 796 | - | s.Write([]byte("\x1b[1;3H\x1b[2@XY")) | |||
| 797 | - | if got := lineText(s, 0); got != "abXYcdef" { | |||
| 798 | - | t.Fatalf("line after ICH = %q, want %q", got, "abXYcdef") | |||
| 799 | - | } | |||
| 800 | - | ||||
| 801 | - | s.Write([]byte("\x1b[1;4H\x1b[3P")) | |||
| 802 | - | if got := lineText(s, 0); got != "abXef " { | |||
| 803 | - | t.Fatalf("line after DCH = %q, want %q", got, "abXef ") | |||
| 804 | - | } | |||
| 805 | - | }, | |||
| 806 | - | }, | |||
| 807 | - | { | |||
| 808 | - | name: "insert and delete lines (L and M)", | |||
| 809 | - | run: func(t *testing.T) { | |||
| 810 | - | s := NewScreen(5, 4) | |||
| 811 | - | s.Write([]byte("11111222223333344444")) | |||
| 812 | - | s.Write([]byte("\x1b[2;1H\x1b[L")) | |||
| 813 | - | if got := lineText(s, 1); got != " " { | |||
| 814 | - | t.Fatalf("inserted line = %q, want blank", got) | |||
| 815 | - | } | |||
| 816 | - | if got := lineText(s, 2); got != "22222" { | |||
| 817 | - | t.Fatalf("shifted line = %q, want 22222", got) | |||
| 818 | - | } | |||
| 819 | - | ||||
| 820 | - | s.Write([]byte("\x1b[2;1H\x1b[M")) | |||
| 821 | - | if got := lineText(s, 1); got != "22222" { | |||
| 822 | - | t.Fatalf("line after delete = %q, want 22222", got) | |||
| 823 | - | } | |||
| 824 | - | }, | |||
| 825 | - | }, | |||
| 826 | - | { | |||
| 827 | - | name: "scroll region (CSI r) confines LF scrolling", | |||
| 828 | - | run: func(t *testing.T) { | |||
| 829 | - | s := NewScreen(5, 4) | |||
| 830 | - | s.Write([]byte("11111222223333344444")) | |||
| 831 | - | s.Write([]byte("\x1b[2;3r\x1b[3;1H\n")) | |||
| 832 | - | ||||
| 833 | - | if got := lineText(s, 0); got != "11111" { | |||
| 834 | - | t.Fatalf("row outside region changed: %q", got) | |||
| 835 | - | } | |||
| 836 | - | if got := lineText(s, 1); got != "33333" { | |||
| 837 | - | t.Fatalf("region did not scroll up, row 1 = %q", got) | |||
| 838 | - | } | |||
| 839 | - | if got := lineText(s, 2); got != " " { | |||
| 840 | - | t.Fatalf("region bottom not blanked, row 2 = %q", got) | |||
| 841 | - | } | |||
| 842 | - | if got := lineText(s, 3); got != "44444" { | |||
| 843 | - | t.Fatalf("row below region changed: %q", got) | |||
| 844 | - | } | |||
| 845 | - | }, | |||
| 846 | - | }, | |||
| 847 | - | { | |||
| 848 | - | name: "DECOM homes and addresses relative to scroll region", | |||
| 849 | - | run: func(t *testing.T) { | |||
| 850 | - | s := NewScreen(6, 5) | |||
| 851 | - | s.Write([]byte("\x1b[2;4r\x1b[?6h")) | |||
| 852 | - | if s.Row != 1 || s.Col != 0 { | |||
| 853 | - | t.Fatalf("DECOM set cursor = (%d,%d), want (1,0)", s.Row, s.Col) | |||
| 854 | - | } | |||
| 855 | - | ||||
| 856 | - | s.Write([]byte("\x1b[2;3H")) | |||
| 857 | - | if s.Row != 2 || s.Col != 2 { | |||
| 858 | - | t.Fatalf("DECOM addressed cursor = (%d,%d), want (2,2)", s.Row, s.Col) | |||
| 859 | - | } | |||
| 860 | - | s.Write([]byte("\x1b[99;1H")) | |||
| 861 | - | if s.Row != 3 || s.Col != 0 { | |||
| 862 | - | t.Fatalf("DECOM clamped cursor = (%d,%d), want (3,0)", s.Row, s.Col) | |||
| 863 | - | } | |||
| 864 | - | }, | |||
| 865 | - | }, | |||
| 866 | - | { | |||
| 867 | - | name: "DECOM already set homes DECSTBM to scroll region", | |||
| 868 | - | run: func(t *testing.T) { | |||
| 869 | - | s := NewScreen(6, 5) | |||
| 870 | - | s.Write([]byte("\x1b[?6h\x1b[2;4r")) | |||
| 871 | - | if s.Row != 1 || s.Col != 0 { | |||
| 872 | - | t.Fatalf("DECSTBM cursor with DECOM set = (%d,%d), want (1,0)", s.Row, s.Col) | |||
| 873 | - | } | |||
| 874 | - | ||||
| 875 | - | s.Write([]byte("\x1b[2;3H")) | |||
| 876 | - | if s.Row != 2 || s.Col != 2 { | |||
| 877 | - | t.Fatalf("DECOM addressed cursor after DECSTBM = (%d,%d), want (2,2)", s.Row, s.Col) | |||
| 878 | - | } | |||
| 879 | - | s.Write([]byte("\x1b[99;1H")) | |||
| 880 | - | if s.Row != 3 || s.Col != 0 { | |||
| 881 | - | t.Fatalf("DECOM clamped cursor after DECSTBM = (%d,%d), want (3,0)", s.Row, s.Col) | |||
| 882 | - | } | |||
| 883 | - | }, | |||
| 884 | - | }, | |||
| 885 | - | { | |||
| 886 | - | name: "DECOM reset homes and restores full-frame addressing", | |||
| 887 | - | run: func(t *testing.T) { | |||
| 888 | - | s := NewScreen(6, 5) | |||
| 889 | - | s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[?6l")) | |||
| 890 | - | if s.Row != 0 || s.Col != 0 { | |||
| 891 | - | t.Fatalf("DECOM reset cursor = (%d,%d), want (0,0)", s.Row, s.Col) | |||
| 892 | - | } | |||
| 893 | - | ||||
| 894 | - | s.Write([]byte("\x1b[5;6H")) | |||
| 895 | - | if s.Row != 4 || s.Col != 5 { | |||
| 896 | - | t.Fatalf("full-frame cursor = (%d,%d), want (4,5)", s.Row, s.Col) | |||
| 897 | - | } | |||
| 898 | - | }, | |||
| 899 | - | }, | |||
| 900 | - | { | |||
| 901 | - | name: "private alternate screen mode restores normal screen on exit", | |||
| 902 | - | run: func(t *testing.T) { | |||
| 903 | - | s := NewScreen(20, 5) | |||
| 904 | - | s.Write([]byte("normal-line")) | |||
| 905 | - | s.Write([]byte("\x1b[?1049h\x1b[2J\x1b[HLOCKED\x1b[3;4HAPP")) | |||
| 906 | - | s.Write([]byte("\x1b[?1049lafter")) | |||
| 907 | - | ||||
| 908 | - | if got := lineText(s, 0); got != "normal-lineafter " { | |||
| 909 | - | t.Fatalf("row 0 = %q, want restored normal screen with post-exit text", got) | |||
| 910 | - | } | |||
| 911 | - | if got := lineText(s, 2); got != " " { | |||
| 912 | - | t.Fatalf("alternate content leaked into row 2: %q", got) | |||
| 913 | - | } | |||
| 914 | - | }, | |||
| 915 | - | }, | |||
| 916 | - | } | |||
| 917 | - | ||||
| 918 | - | for _, tt := range tests { | |||
| 919 | - | t.Run(tt.name, tt.run) | |||
| 920 | - | } | |||
| 921 | - | } | |||
| 922 | - | ||||
| 923 | - | func TestCSIScrollUp(t *testing.T) { | |||
| 924 | - | tests := []struct { | |||
| 925 | - | name string | |||
| 926 | - | run func(t *testing.T) | |||
| 927 | - | }{ | |||
| 928 | - | { | |||
| 929 | - | name: "CSI S with no param scrolls up by 1 and preserves cursor", | |||
| 930 | - | run: func(t *testing.T) { | |||
| 931 | - | s := NewScreen(5, 4) | |||
| 932 | - | s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows | |||
| 933 | - | s.ClearDamage() | |||
| 934 | - | ||||
| 935 | - | // CSI S with no param → scroll up by 1. | |||
| 936 | - | s.Write([]byte("\x1b[S")) | |||
| 937 | - | ||||
| 938 | - | // Content should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = DDDDD, row 3 = blank. | |||
| 939 | - | assertCell(t, s, 0, 0, 'B') | |||
| 940 | - | assertCell(t, s, 0, 1, 'C') | |||
| 941 | - | assertCell(t, s, 0, 2, 'D') | |||
| 942 | - | assertCell(t, s, 0, 3, ' ') | |||
| 943 | - | ||||
| 944 | - | // Cursor should NOT have moved. | |||
| 945 | - | if s.Col != 5 || s.Row != 3 { | |||
| 946 | - | t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row) | |||
| 947 | - | } | |||
| 948 | - | ||||
| 949 | - | d := s.Damage() | |||
| 950 | - | if !hasDamageKind(d, renderer.DamageScrollUp) { | |||
| 951 | - | t.Errorf("expected DamageScrollUp, got %v", damageKinds(d)) | |||
| 952 | - | } | |||
| 953 | - | }, | |||
| 954 | - | }, | |||
| 955 | - | { | |||
| 956 | - | name: "CSI 2S scrolls up by an explicit count", | |||
| 957 | - | run: func(t *testing.T) { | |||
| 958 | - | s := NewScreen(5, 4) | |||
| 959 | - | s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows | |||
| 960 | - | s.ClearDamage() | |||
| 961 | - | ||||
| 962 | - | // CSI 2 S → scroll up by 2. | |||
| 963 | - | s.Write([]byte("\x1b[2S")) | |||
| 964 | - | ||||
| 965 | - | // Content: row 0 = CCCCC, row 1 = DDDDD, row 2 = blank, row 3 = blank. | |||
| 966 | - | assertCell(t, s, 0, 0, 'C') | |||
| 967 | - | assertCell(t, s, 0, 1, 'D') | |||
| 968 | - | assertCell(t, s, 0, 2, ' ') | |||
| 969 | - | assertCell(t, s, 0, 3, ' ') | |||
| 970 | - | ||||
| 971 | - | // Cursor preserved. | |||
| 972 | - | if s.Col != 5 || s.Row != 3 { | |||
| 973 | - | t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row) | |||
| 974 | - | } | |||
| 975 | - | }, | |||
| 976 | - | }, | |||
| 977 | - | { | |||
| 978 | - | name: "CSI 100S on a 3-row screen clamps to the full screen", | |||
| 979 | - | run: func(t *testing.T) { | |||
| 980 | - | s := NewScreen(5, 3) | |||
| 981 | - | s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows | |||
| 982 | - | s.ClearDamage() | |||
| 983 | - | ||||
| 984 | - | // CSI 100 S on a 3-row screen → clamp to 3 (entire screen blank). | |||
| 985 | - | s.Write([]byte("\x1b[100S")) | |||
| 986 | - | ||||
| 987 | - | // All rows should be blank. | |||
| 988 | - | for y := range 3 { | |||
| 989 | - | for x := range 5 { | |||
| 990 | - | assertCell(t, s, x, y, ' ') | |||
| 991 | - | } | |||
| 992 | - | } | |||
| 993 | - | }, | |||
| 994 | - | }, | |||
| 995 | - | { | |||
| 996 | - | name: "CSI 0S defaults to a scroll count of 1", | |||
| 997 | - | run: func(t *testing.T) { | |||
| 998 | - | s := NewScreen(5, 3) | |||
| 999 | - | s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows | |||
| 1000 | - | s.ClearDamage() | |||
| 1001 | - | ||||
| 1002 | - | // CSI 0 S → default to 1 (parameter 0 means default in VT spec). | |||
| 1003 | - | s.Write([]byte("\x1b[0S")) | |||
| 1004 | - | ||||
| 1005 | - | // Row 0 should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = blank. | |||
| 1006 | - | assertCell(t, s, 0, 0, 'B') | |||
| 1007 | - | assertCell(t, s, 0, 1, 'C') | |||
| 1008 | - | assertCell(t, s, 0, 2, ' ') | |||
| 1009 | - | }, | |||
| 1010 | - | }, | |||
| 1011 | - | { | |||
| 1012 | - | name: "CSI 3S reports scroll count 3 and blanked-row text damage", | |||
| 1013 | - | run: func(t *testing.T) { | |||
| 1014 | - | s := NewScreen(5, 4) | |||
| 1015 | - | s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows | |||
| 1016 | - | s.ClearDamage() | |||
| 1017 | - | ||||
| 1018 | - | s.Write([]byte("\x1b[3S")) // scroll up by 3 | |||
| 1019 | - | ||||
| 1020 | - | d := s.Damage() | |||
| 1021 | - | var scrollDamage *renderer.Damage | |||
| 1022 | - | for i, dd := range d { | |||
| 1023 | - | if dd.Kind == renderer.DamageScrollUp { | |||
| 1024 | - | scrollDamage = &d[i] | |||
| 1025 | - | break | |||
| 1026 | - | } | |||
| 1027 | - | } | |||
| 1028 | - | if scrollDamage == nil { | |||
| 1029 | - | t.Fatal("expected DamageScrollUp") | |||
| 1030 | - | } | |||
| 1031 | - | if scrollDamage.Count != 3 { | |||
| 1032 | - | t.Errorf("scroll count = %d, want 3", scrollDamage.Count) | |||
| 1033 | - | } | |||
| 1034 | - | if scrollDamage.Width != 5 || scrollDamage.Height != 4 { | |||
| 1035 | - | t.Errorf("scroll size = %dx%d, want 5x4", scrollDamage.Width, scrollDamage.Height) | |||
| 1036 | - | } | |||
| 1037 | - | ||||
| 1038 | - | // Should also have text damage for the blanked rows (bottom 3 rows). | |||
| 1039 | - | if !hasDamageKind(d, renderer.DamageText) { | |||
| 1040 | - | t.Error("expected DamageText for blanked rows") | |||
| 1041 | - | } | |||
| 1042 | - | }, | |||
| 1043 | - | }, | |||
| 1044 | - | { | |||
| 1045 | - | name: "cursor position is preserved across a scroll-up", | |||
| 1046 | - | run: func(t *testing.T) { | |||
| 1047 | - | s := NewScreen(10, 5) | |||
| 1048 | - | s.Write([]byte("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE")) // fill all 5 rows | |||
| 1049 | - | s.ClearDamage() | |||
| 1050 | - | ||||
| 1051 | - | // Move cursor to middle of row 2. | |||
| 1052 | - | s.Write([]byte("\x1b[3;3H")) // row=3, col=3 | |||
| 1053 | - | s.Write([]byte("\x1b[2S")) // scroll up by 2 | |||
| 1054 | - | ||||
| 1055 | - | // Cursor should still be at 1-indexed (3,3) = 0-indexed (2,2). | |||
| 1056 | - | if s.Row != 2 || s.Col != 2 { | |||
| 1057 | - | t.Errorf("cursor at row=%d col=%d, want row=2 col=2", s.Row, s.Col) | |||
| 1058 | - | } | |||
| 1059 | - | }, | |||
| 1060 | - | }, | |||
| 1061 | - | } | |||
| 1062 | - | ||||
| 1063 | - | for _, tt := range tests { | |||
| 1064 | - | t.Run(tt.name, tt.run) | |||
| 1065 | - | } | |||
| 1066 | - | } | |||
D pkg/vt/screen_cursor.go +0 -89
| @@ -1,89 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "strconv" | |||
| 5 | - | "strings" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | type cursorState struct { | |||
| 11 | - | row int | |||
| 12 | - | col int | |||
| 13 | - | style renderer.Style | |||
| 14 | - | originMode bool | |||
| 15 | - | insertMode bool | |||
| 16 | - | saved bool | |||
| 17 | - | } | |||
| 18 | - | ||||
| 19 | - | func (s *Screen) cursorReportRow() int { | |||
| 20 | - | if s.originMode { | |||
| 21 | - | return clamp(s.Row, s.cursorMinRow(), s.cursorMaxRow()) - s.cursorMinRow() + 1 | |||
| 22 | - | } | |||
| 23 | - | return s.Row + 1 | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | func (s *Screen) cursorMinRow() int { | |||
| 27 | - | if s.originMode { | |||
| 28 | - | return clamp(s.scrollTop, 0, s.Frame.Height-1) | |||
| 29 | - | } | |||
| 30 | - | return 0 | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | func (s *Screen) cursorMaxRow() int { | |||
| 34 | - | if s.originMode { | |||
| 35 | - | return clamp(s.scrollBottom, 0, s.Frame.Height-1) | |||
| 36 | - | } | |||
| 37 | - | return max(s.Frame.Height-1, 0) | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | func (s *Screen) addressedRow(row int) int { | |||
| 41 | - | if s.originMode { | |||
| 42 | - | return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow()) | |||
| 43 | - | } | |||
| 44 | - | return clamp(row-1, 0, s.Frame.Height-1) | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | func (s *Screen) addressCursor(row, col int) { | |||
| 48 | - | s.Row = s.addressedRow(row) | |||
| 49 | - | s.Col = clamp(col-1, 0, s.Frame.Width-1) | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | - | func (s *Screen) homeCursor() { | |||
| 53 | - | s.addressCursor(1, 1) | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | func (s *Screen) applyCursorStyle(params string) { | |||
| 57 | - | if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") { | |||
| 58 | - | return | |||
| 59 | - | } | |||
| 60 | - | styleParam := strings.TrimSuffix(params, " ") | |||
| 61 | - | style := 0 | |||
| 62 | - | if styleParam != "" { | |||
| 63 | - | v, err := strconv.Atoi(styleParam) | |||
| 64 | - | if err != nil { | |||
| 65 | - | return | |||
| 66 | - | } | |||
| 67 | - | style = v | |||
| 68 | - | } | |||
| 69 | - | if style < 0 || style > 6 { | |||
| 70 | - | return | |||
| 71 | - | } | |||
| 72 | - | s.cursorStyle = style | |||
| 73 | - | s.cursorStyleSet = true | |||
| 74 | - | } | |||
| 75 | - | ||||
| 76 | - | func (s *Screen) saveCursor() { | |||
| 77 | - | s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true} | |||
| 78 | - | } | |||
| 79 | - | ||||
| 80 | - | func (s *Screen) restoreCursor() { | |||
| 81 | - | if !s.savedCursor.saved { | |||
| 82 | - | return | |||
| 83 | - | } | |||
| 84 | - | s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1) | |||
| 85 | - | s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1) | |||
| 86 | - | s.Style = s.savedCursor.style | |||
| 87 | - | s.originMode = s.savedCursor.originMode | |||
| 88 | - | s.insertMode = s.savedCursor.insertMode | |||
| 89 | - | } | |||
D pkg/vt/screen_damage.go +0 -74
| @@ -1,74 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | - | ||||
| 5 | - | // maxPendingDamage bounds metadata retained while no render transaction can | |||
| 6 | - | // acknowledge a screen. Saturation falls back to one exact full redraw. | |||
| 7 | - | const maxPendingDamage = 1024 | |||
| 8 | - | ||||
| 9 | - | // DamageCapture is an immutable copy of pending damage at one screen generation. | |||
| 10 | - | type DamageCapture struct { | |||
| 11 | - | Damage []renderer.Damage | |||
| 12 | - | Generation uint64 | |||
| 13 | - | } | |||
| 14 | - | ||||
| 15 | - | // Damage returns the current damage list. The caller must not modify the | |||
| 16 | - | // returned slice; ClearDamage must be called after the damage is consumed. | |||
| 17 | - | func (s *Screen) Damage() []renderer.Damage { return s.damage } | |||
| 18 | - | func (s *Screen) ClearDamage() { | |||
| 19 | - | s.damage = s.damage[:0] | |||
| 20 | - | s.damageSaturated = false | |||
| 21 | - | s.damageFullRedrawSticky = false | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | // CaptureDamage snapshots pending damage without consuming it. | |||
| 25 | - | func (s *Screen) CaptureDamage() DamageCapture { | |||
| 26 | - | return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration} | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | // AcknowledgeDamage consumes a capture only if no screen mutation occurred | |||
| 30 | - | // since it was taken. A stale acknowledgement conservatively requests a full | |||
| 31 | - | // redraw, ensuring intervening writes remain visible to the next capture. | |||
| 32 | - | func (s *Screen) AcknowledgeDamage(generation uint64) bool { | |||
| 33 | - | if generation != s.damageGeneration { | |||
| 34 | - | s.fullRedraw() | |||
| 35 | - | return false | |||
| 36 | - | } | |||
| 37 | - | s.ClearDamage() | |||
| 38 | - | return true | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | func (s *Screen) record(d renderer.Damage) { | |||
| 42 | - | s.damageGeneration++ | |||
| 43 | - | if s.damageSaturated || s.damageFullRedrawSticky { | |||
| 44 | - | return | |||
| 45 | - | } | |||
| 46 | - | if len(s.damage) >= maxPendingDamage { | |||
| 47 | - | s.damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 48 | - | s.damageSaturated = true | |||
| 49 | - | return | |||
| 50 | - | } | |||
| 51 | - | // Replace FullRedraw with the first concrete damage item. | |||
| 52 | - | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | |||
| 53 | - | s.damage[0] = d | |||
| 54 | - | return | |||
| 55 | - | } | |||
| 56 | - | // Coalesce adjacent single-cell text damage on the same line. | |||
| 57 | - | if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 { | |||
| 58 | - | last := &s.damage[len(s.damage)-1] | |||
| 59 | - | if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 { | |||
| 60 | - | last.Width++ | |||
| 61 | - | return | |||
| 62 | - | } | |||
| 63 | - | } | |||
| 64 | - | s.damage = append(s.damage, d) | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | func (s *Screen) fullRedraw() { | |||
| 68 | - | s.damageGeneration++ | |||
| 69 | - | s.damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 70 | - | // A structural frame replacement (such as DEC 1049 exit) cannot be | |||
| 71 | - | // represented by the following text damage alone. Retain the redraw until | |||
| 72 | - | // the render owner acknowledges this screen generation. | |||
| 73 | - | s.damageFullRedrawSticky = true | |||
| 74 | - | } | |||
D pkg/vt/screen_damage_test.go +0 -240
| @@ -1,240 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | "github.com/stretchr/testify/require" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func TestScrollDamage(t *testing.T) { | |||
| 11 | - | tests := []struct { | |||
| 12 | - | name string | |||
| 13 | - | run func(t *testing.T) | |||
| 14 | - | }{ | |||
| 15 | - | { | |||
| 16 | - | name: "scroll produces both DamageScrollUp and DamageText", | |||
| 17 | - | run: func(t *testing.T) { | |||
| 18 | - | s := NewScreen(5, 2) | |||
| 19 | - | // Fill screen then cause a scroll. | |||
| 20 | - | s.Write([]byte("AAAAABBBBB")) // both rows filled | |||
| 21 | - | s.ClearDamage() | |||
| 22 | - | ||||
| 23 | - | s.Write([]byte("CCCCC")) // forces a scroll up | |||
| 24 | - | ||||
| 25 | - | d := s.Damage() | |||
| 26 | - | if len(d) == 0 { | |||
| 27 | - | t.Fatal("expected damage after scroll") | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | if !hasDamageKind(d, renderer.DamageScrollUp) { | |||
| 31 | - | t.Errorf("expected DamageScrollUp in %v", damageKinds(d)) | |||
| 32 | - | } | |||
| 33 | - | if !hasDamageKind(d, renderer.DamageText) { | |||
| 34 | - | t.Errorf("expected DamageText in %v", damageKinds(d)) | |||
| 35 | - | } | |||
| 36 | - | }, | |||
| 37 | - | }, | |||
| 38 | - | { | |||
| 39 | - | name: "scroll damage coordinates match the full-width scrolled region", | |||
| 40 | - | run: func(t *testing.T) { | |||
| 41 | - | s := NewScreen(5, 3) | |||
| 42 | - | s.Write([]byte("AAAAABBBBBCCCCC")) // fill all 3 rows | |||
| 43 | - | s.ClearDamage() | |||
| 44 | - | ||||
| 45 | - | // Write another char to force scroll. | |||
| 46 | - | s.Write([]byte("D")) | |||
| 47 | - | ||||
| 48 | - | d := s.Damage() | |||
| 49 | - | var scrollDamage *renderer.Damage | |||
| 50 | - | for i, dd := range d { | |||
| 51 | - | if dd.Kind == renderer.DamageScrollUp { | |||
| 52 | - | scrollDamage = &d[i] | |||
| 53 | - | break | |||
| 54 | - | } | |||
| 55 | - | } | |||
| 56 | - | if scrollDamage == nil { | |||
| 57 | - | t.Fatal("expected scroll damage") | |||
| 58 | - | } | |||
| 59 | - | if scrollDamage.X != 0 || scrollDamage.Y != 0 { | |||
| 60 | - | t.Errorf("scroll position: (%d,%d), want (0,0)", scrollDamage.X, scrollDamage.Y) | |||
| 61 | - | } | |||
| 62 | - | if scrollDamage.Width != 5 || scrollDamage.Height != 3 { | |||
| 63 | - | t.Errorf("scroll size: %dx%d, want 5x3", scrollDamage.Width, scrollDamage.Height) | |||
| 64 | - | } | |||
| 65 | - | if scrollDamage.Count != 1 { | |||
| 66 | - | t.Errorf("scroll count = %d, want 1", scrollDamage.Count) | |||
| 67 | - | } | |||
| 68 | - | }, | |||
| 69 | - | }, | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | for _, tt := range tests { | |||
| 73 | - | t.Run(tt.name, tt.run) | |||
| 74 | - | } | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | func TestDamageCoalescingBehavior(t *testing.T) { | |||
| 78 | - | tests := []struct { | |||
| 79 | - | name string | |||
| 80 | - | run func(t *testing.T) | |||
| 81 | - | }{ | |||
| 82 | - | { | |||
| 83 | - | name: "adjacent single-cell writes coalesce into one damage entry", | |||
| 84 | - | run: func(t *testing.T) { | |||
| 85 | - | s := NewScreen(20, 3) | |||
| 86 | - | s.Write([]byte("ABC")) // Three adjacent chars on one line | |||
| 87 | - | ||||
| 88 | - | d := s.Damage() | |||
| 89 | - | // Should be coalesced into a single DamageText. | |||
| 90 | - | if len(d) != 1 { | |||
| 91 | - | t.Fatalf("expected 1 coalesced damage, got %d: %+v", len(d), d) | |||
| 92 | - | } | |||
| 93 | - | if d[0].Kind != renderer.DamageText || d[0].X != 0 || d[0].Y != 0 || d[0].Width != 3 || d[0].Height != 1 { | |||
| 94 | - | t.Errorf("unexpected coalesced damage: %+v", d[0]) | |||
| 95 | - | } | |||
| 96 | - | }, | |||
| 97 | - | }, | |||
| 98 | - | { | |||
| 99 | - | name: "newline breaks coalescing across lines", | |||
| 100 | - | run: func(t *testing.T) { | |||
| 101 | - | s := NewScreen(10, 3) | |||
| 102 | - | s.Write([]byte("AB\nCD")) | |||
| 103 | - | ||||
| 104 | - | d := s.Damage() | |||
| 105 | - | // "AB" on line 0, "CD" on line 1 → two separate DamageText items. | |||
| 106 | - | textCount := 0 | |||
| 107 | - | for _, dd := range d { | |||
| 108 | - | if dd.Kind == renderer.DamageText { | |||
| 109 | - | textCount++ | |||
| 110 | - | } | |||
| 111 | - | } | |||
| 112 | - | if textCount < 2 { | |||
| 113 | - | t.Errorf("expected at least 2 DamageText items (one per line), got %d", textCount) | |||
| 114 | - | } | |||
| 115 | - | }, | |||
| 116 | - | }, | |||
| 117 | - | { | |||
| 118 | - | name: "writing replaces the initial FullRedraw with text damage", | |||
| 119 | - | run: func(t *testing.T) { | |||
| 120 | - | s := NewScreen(5, 3) | |||
| 121 | - | // NewScreen already has FullRedraw in damage. | |||
| 122 | - | d := s.Damage() | |||
| 123 | - | if len(d) != 1 || d[0].Kind != renderer.DamageFullRedraw { | |||
| 124 | - | t.Fatalf("expected single FullRedraw, got %+v", d) | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | // Writing should replace it with text damage. | |||
| 128 | - | s.Write([]byte("X")) | |||
| 129 | - | d = s.Damage() | |||
| 130 | - | if len(d) == 0 { | |||
| 131 | - | t.Fatal("expected damage after write") | |||
| 132 | - | } | |||
| 133 | - | if d[0].Kind == renderer.DamageFullRedraw { | |||
| 134 | - | t.Error("FullRedraw should have been replaced by text damage") | |||
| 135 | - | } | |||
| 136 | - | }, | |||
| 137 | - | }, | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | for _, tt := range tests { | |||
| 141 | - | t.Run(tt.name, tt.run) | |||
| 142 | - | } | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | func TestDamageSaturationBoundsPendingRecords(t *testing.T) { | |||
| 146 | - | s := NewScreen(2, 2) | |||
| 147 | - | s.ClearDamage() | |||
| 148 | - | ||||
| 149 | - | for i := range 1_100 { | |||
| 150 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: i % 2, Width: 1, Height: 1, Count: 1}) | |||
| 151 | - | } | |||
| 152 | - | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage()) | |||
| 153 | - | ||||
| 154 | - | // Saturation is sticky until the render owner acknowledges or clears it. | |||
| 155 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}) | |||
| 156 | - | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage()) | |||
| 157 | - | ||||
| 158 | - | s.ClearDamage() | |||
| 159 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}) | |||
| 160 | - | require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, s.Damage()) | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | func TestClearDamage(t *testing.T) { | |||
| 164 | - | tests := []struct { | |||
| 165 | - | name string | |||
| 166 | - | }{ | |||
| 167 | - | {name: "ClearDamage empties the damage list"}, | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | for _, tt := range tests { | |||
| 171 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 172 | - | s := NewScreen(5, 3) | |||
| 173 | - | s.Write([]byte("Hello")) | |||
| 174 | - | s.ClearDamage() | |||
| 175 | - | d := s.Damage() | |||
| 176 | - | if len(d) != 0 { | |||
| 177 | - | t.Fatalf("expected empty damage after ClearDamage, got %+v", d) | |||
| 178 | - | } | |||
| 179 | - | }) | |||
| 180 | - | } | |||
| 181 | - | } | |||
| 182 | - | ||||
| 183 | - | func TestDamageReturnsReference(t *testing.T) { | |||
| 184 | - | tests := []struct { | |||
| 185 | - | name string | |||
| 186 | - | }{ | |||
| 187 | - | {name: "Damage() returns a reference to the internal slice"}, | |||
| 188 | - | } | |||
| 189 | - | ||||
| 190 | - | for _, tt := range tests { | |||
| 191 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 192 | - | // Damage() should return a reference to internal slice (no copy). | |||
| 193 | - | s := NewScreen(10, 3) | |||
| 194 | - | s.Write([]byte("X")) | |||
| 195 | - | d1 := s.Damage() | |||
| 196 | - | // Internal slice should be the same (or at least reference the same backing). | |||
| 197 | - | if len(d1) == 0 { | |||
| 198 | - | t.Fatal("expected damage") | |||
| 199 | - | } | |||
| 200 | - | // Write more to trigger record. | |||
| 201 | - | s.Write([]byte("Y")) | |||
| 202 | - | d2 := s.Damage() | |||
| 203 | - | _ = d2 | |||
| 204 | - | // Both d1 and d2 reference the internal slice; we just don't crash. | |||
| 205 | - | }) | |||
| 206 | - | } | |||
| 207 | - | } | |||
| 208 | - | ||||
| 209 | - | func TestDamageCaptureAcknowledgementPreservesConcurrentWrite(t *testing.T) { | |||
| 210 | - | s := NewScreen(8, 2) | |||
| 211 | - | s.ClearDamage() | |||
| 212 | - | s.Write([]byte("a")) | |||
| 213 | - | ||||
| 214 | - | captured := s.CaptureDamage() | |||
| 215 | - | require.NotEmpty(t, captured.Damage) | |||
| 216 | - | s.Write([]byte("b")) | |||
| 217 | - | ||||
| 218 | - | require.False(t, s.AcknowledgeDamage(captured.Generation)) | |||
| 219 | - | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage(), "stale acknowledgement must retain later writes conservatively") | |||
| 220 | - | } | |||
| 221 | - | ||||
| 222 | - | func TestDamageCaptureAcknowledgementClearsMatchingGeneration(t *testing.T) { | |||
| 223 | - | s := NewScreen(8, 2) | |||
| 224 | - | s.ClearDamage() | |||
| 225 | - | s.Write([]byte("a")) | |||
| 226 | - | ||||
| 227 | - | captured := s.CaptureDamage() | |||
| 228 | - | require.True(t, s.AcknowledgeDamage(captured.Generation)) | |||
| 229 | - | require.Empty(t, s.Damage()) | |||
| 230 | - | } | |||
| 231 | - | ||||
| 232 | - | func TestDamageCaptureOwnsDamageSlice(t *testing.T) { | |||
| 233 | - | s := NewScreen(8, 2) | |||
| 234 | - | s.ClearDamage() | |||
| 235 | - | s.Write([]byte("a")) | |||
| 236 | - | ||||
| 237 | - | captured := s.CaptureDamage() | |||
| 238 | - | s.ClearDamage() | |||
| 239 | - | require.NotEmpty(t, captured.Damage, "captured damage must not alias mutable screen storage") | |||
| 240 | - | } | |||
D pkg/vt/screen_helpers_test.go +0 -103
| @@ -1,103 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "reflect" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func damageKinds(d []renderer.Damage) []renderer.DamageKind { | |||
| 11 | - | ks := make([]renderer.DamageKind, len(d)) | |||
| 12 | - | for i, dd := range d { | |||
| 13 | - | ks[i] = dd.Kind | |||
| 14 | - | } | |||
| 15 | - | return ks | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | func hasDamageKind(d []renderer.Damage, kind renderer.DamageKind) bool { | |||
| 19 | - | for _, dd := range d { | |||
| 20 | - | if dd.Kind == kind { | |||
| 21 | - | return true | |||
| 22 | - | } | |||
| 23 | - | } | |||
| 24 | - | return false | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | func cellAt(s *Screen, x, y int) renderer.Cell { | |||
| 28 | - | return s.Frame.At(x, y) | |||
| 29 | - | } | |||
| 30 | - | ||||
| 31 | - | func assertFramesEqual(t *testing.T, got, want renderer.Frame) { | |||
| 32 | - | t.Helper() | |||
| 33 | - | if !reflect.DeepEqual(got, want) { | |||
| 34 | - | t.Fatal("frame changed unexpectedly") | |||
| 35 | - | } | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | func assertCell(t *testing.T, s *Screen, x, y int, expected rune) { | |||
| 39 | - | t.Helper() | |||
| 40 | - | c := cellAt(s, x, y) | |||
| 41 | - | if c.Rune != expected { | |||
| 42 | - | t.Errorf("cell(%d,%d) rune = %q, want %q", x, y, c.Rune, expected) | |||
| 43 | - | } | |||
| 44 | - | if c.Continuation { | |||
| 45 | - | t.Errorf("cell(%d,%d) unexpectedly marked as continuation", x, y) | |||
| 46 | - | } | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | // assertContinuation asserts the cell at (x,y) is the right half of a | |||
| 50 | - | // wide-character pair (Continuation set, Rune 0). | |||
| 51 | - | func assertContinuation(t *testing.T, s *Screen, x, y int) { | |||
| 52 | - | t.Helper() | |||
| 53 | - | c := cellAt(s, x, y) | |||
| 54 | - | if !c.Continuation { | |||
| 55 | - | t.Errorf("cell(%d,%d) expected continuation, got rune=%q continuation=%v", x, y, c.Rune, c.Continuation) | |||
| 56 | - | } | |||
| 57 | - | if c.Rune != 0 { | |||
| 58 | - | t.Errorf("cell(%d,%d) continuation rune = %q, want 0", x, y, c.Rune) | |||
| 59 | - | } | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | // assertBlank asserts the cell at (x,y) is a blank default cell. | |||
| 63 | - | func assertBlank(t *testing.T, s *Screen, x, y int) { | |||
| 64 | - | t.Helper() | |||
| 65 | - | c := cellAt(s, x, y) | |||
| 66 | - | if c.Rune != ' ' || c.Continuation { | |||
| 67 | - | t.Errorf("cell(%d,%d) = {rune:%q cont:%v}, want blank space", x, y, c.Rune, c.Continuation) | |||
| 68 | - | } | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | func assertNoOrphanWideCells(t *testing.T, s *Screen) { | |||
| 72 | - | t.Helper() | |||
| 73 | - | for y := range s.Frame.Height { | |||
| 74 | - | for x := range s.Frame.Width { | |||
| 75 | - | c := cellAt(s, x, y) | |||
| 76 | - | if c.Continuation { | |||
| 77 | - | if x == 0 || cellAt(s, x-1, y).Continuation || renderer.RuneWidth(cellAt(s, x-1, y).Rune) != 2 { | |||
| 78 | - | t.Fatalf("orphan continuation at (%d,%d)", x, y) | |||
| 79 | - | } | |||
| 80 | - | continue | |||
| 81 | - | } | |||
| 82 | - | if renderer.RuneWidth(c.Rune) == 2 && (x+1 >= s.Frame.Width || !cellAt(s, x+1, y).Continuation) { | |||
| 83 | - | t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune) | |||
| 84 | - | } | |||
| 85 | - | } | |||
| 86 | - | } | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | func lineText(s *Screen, y int) string { | |||
| 90 | - | out := make([]rune, s.Frame.Width) | |||
| 91 | - | for x := range s.Frame.Width { | |||
| 92 | - | out[x] = s.Frame.At(x, y).Rune | |||
| 93 | - | } | |||
| 94 | - | return string(out) | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | func rowString(row []renderer.Cell) string { | |||
| 98 | - | out := make([]rune, len(row)) | |||
| 99 | - | for i, cell := range row { | |||
| 100 | - | out[i] = cell.Rune | |||
| 101 | - | } | |||
| 102 | - | return string(out) | |||
| 103 | - | } | |||
D pkg/vt/screen_integration_test.go +0 -118
| @@ -1,118 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) { | |||
| 10 | - | s := NewScreen(20, 2) | |||
| 11 | - | r := renderer.New(renderer.Capabilities{}) | |||
| 12 | - | ||||
| 13 | - | s.Write([]byte("❯ ")) | |||
| 14 | - | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | |||
| 15 | - | t.Fatal(err) | |||
| 16 | - | } | |||
| 17 | - | s.ClearDamage() | |||
| 18 | - | ||||
| 19 | - | s.Write([]byte("h")) | |||
| 20 | - | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | |||
| 21 | - | t.Fatal(err) | |||
| 22 | - | } | |||
| 23 | - | s.ClearDamage() | |||
| 24 | - | ||||
| 25 | - | s.Write([]byte("\x1b[31mh\x1b[39m")) | |||
| 26 | - | s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m")) | |||
| 27 | - | ||||
| 28 | - | for y := range s.Frame.Height { | |||
| 29 | - | for x := range s.Frame.Width { | |||
| 30 | - | r := s.Frame.At(x, y).Rune | |||
| 31 | - | if r == '\r' || r == '\n' { | |||
| 32 | - | t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r) | |||
| 33 | - | } | |||
| 34 | - | } | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | out, err := r.Draw(s.Frame, s.Damage()) | |||
| 38 | - | if err != nil { | |||
| 39 | - | t.Fatal(err) | |||
| 40 | - | } | |||
| 41 | - | for _, b := range out { | |||
| 42 | - | if b == '\r' || b == '\n' { | |||
| 43 | - | t.Fatalf("incremental output contains raw control byte %q: %q", b, string(out)) | |||
| 44 | - | } | |||
| 45 | - | } | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | func TestIntegrationScenarios(t *testing.T) { | |||
| 49 | - | tests := []struct { | |||
| 50 | - | name string | |||
| 51 | - | run func(t *testing.T) | |||
| 52 | - | }{ | |||
| 53 | - | { | |||
| 54 | - | name: "renderer stays in sync after VT edit damage", | |||
| 55 | - | run: func(t *testing.T) { | |||
| 56 | - | s := NewScreen(8, 2) | |||
| 57 | - | r := renderer.New(renderer.Capabilities{}) | |||
| 58 | - | s.Write([]byte("abcdef")) | |||
| 59 | - | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | |||
| 60 | - | t.Fatal(err) | |||
| 61 | - | } | |||
| 62 | - | s.ClearDamage() | |||
| 63 | - | ||||
| 64 | - | s.Write([]byte("\x1b[1;3H\x1b[2@XY")) | |||
| 65 | - | if _, err := r.Draw(s.Frame, s.Damage()); err != nil { | |||
| 66 | - | t.Fatal(err) | |||
| 67 | - | } | |||
| 68 | - | s.ClearDamage() | |||
| 69 | - | ||||
| 70 | - | out, err := r.Draw(s.Frame, nil) | |||
| 71 | - | if err != nil { | |||
| 72 | - | t.Fatal(err) | |||
| 73 | - | } | |||
| 74 | - | if len(out) != 0 { | |||
| 75 | - | t.Fatalf("renderer emitted stale follow-up output after VT edit damage: %q", string(out)) | |||
| 76 | - | } | |||
| 77 | - | }, | |||
| 78 | - | }, | |||
| 79 | - | { | |||
| 80 | - | name: "DCS sequence terminated by ST is ignored", | |||
| 81 | - | run: func(t *testing.T) { | |||
| 82 | - | s := NewScreen(20, 2) | |||
| 83 | - | s.Write([]byte("\x1bP+q696e646e\x1b\\prompt")) | |||
| 84 | - | ||||
| 85 | - | assertCell(t, s, 0, 0, 'p') | |||
| 86 | - | assertCell(t, s, 1, 0, 'r') | |||
| 87 | - | assertCell(t, s, 2, 0, 'o') | |||
| 88 | - | assertCell(t, s, 3, 0, 'm') | |||
| 89 | - | assertCell(t, s, 4, 0, 'p') | |||
| 90 | - | assertCell(t, s, 5, 0, 't') | |||
| 91 | - | if s.Col != 6 || s.Row != 0 { | |||
| 92 | - | t.Errorf("cursor at row=%d col=%d, want row=0 col=6", s.Row, s.Col) | |||
| 93 | - | } | |||
| 94 | - | }, | |||
| 95 | - | }, | |||
| 96 | - | { | |||
| 97 | - | name: "fish-like prompt redraw preserves typed characters", | |||
| 98 | - | run: func(t *testing.T) { | |||
| 99 | - | s := NewScreen(10, 2) | |||
| 100 | - | s.Write([]byte("> ")) | |||
| 101 | - | s.Write([]byte("a\r\x1b[3C")) | |||
| 102 | - | s.Write([]byte("\x1b[91mb\x1b[39m\x1b[K\r\x1b[4C")) | |||
| 103 | - | ||||
| 104 | - | assertCell(t, s, 0, 0, '>') | |||
| 105 | - | assertCell(t, s, 1, 0, ' ') | |||
| 106 | - | assertCell(t, s, 2, 0, 'a') | |||
| 107 | - | assertCell(t, s, 3, 0, 'b') | |||
| 108 | - | if s.Row != 0 || s.Col != 4 { | |||
| 109 | - | t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col) | |||
| 110 | - | } | |||
| 111 | - | }, | |||
| 112 | - | }, | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | for _, tt := range tests { | |||
| 116 | - | t.Run(tt.name, tt.run) | |||
| 117 | - | } | |||
| 118 | - | } | |||
D pkg/vt/screen_modes.go +0 -236
| @@ -1,236 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | - | ||||
| 5 | - | type screenState struct { | |||
| 6 | - | frame renderer.Frame | |||
| 7 | - | buffer *buffer | |||
| 8 | - | row int | |||
| 9 | - | col int | |||
| 10 | - | style renderer.Style | |||
| 11 | - | scrollTop int | |||
| 12 | - | scrollBottom int | |||
| 13 | - | savedCursor cursorState | |||
| 14 | - | originMode bool | |||
| 15 | - | insertMode bool | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | |||
| 19 | - | // is currently enabled by the child process. | |||
| 20 | - | func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |||
| 21 | - | ||||
| 22 | - | // BracketedPasteMode reports whether DEC private mode 2004 is currently enabled | |||
| 23 | - | // by the child process. | |||
| 24 | - | func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste } | |||
| 25 | - | ||||
| 26 | - | // ColorSchemeMode reports whether DEC private mode 2031 is currently enabled. | |||
| 27 | - | func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode } | |||
| 28 | - | ||||
| 29 | - | // SetColorScheme updates the host color scheme and notifies subscribed child apps. | |||
| 30 | - | func (s *Screen) SetColorScheme(light bool) { | |||
| 31 | - | if s.colorSchemeSet && s.colorSchemeLight == light { | |||
| 32 | - | return | |||
| 33 | - | } | |||
| 34 | - | s.colorSchemeSet = true | |||
| 35 | - | s.colorSchemeLight = light | |||
| 36 | - | if s.colorSchemeMode { | |||
| 37 | - | s.respond(s.colorSchemeReport()) | |||
| 38 | - | } | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | // ClearColorScheme marks the host color scheme as unknown. Future child color | |||
| 42 | - | // scheme queries are silent until SetColorScheme supplies a known value again. | |||
| 43 | - | func (s *Screen) ClearColorScheme() { | |||
| 44 | - | s.colorSchemeSet = false | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update). | |||
| 48 | - | // Hosts use this as a safety valve if a child enters synchronized update mode | |||
| 49 | - | // and never sends the matching end sequence. | |||
| 50 | - | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | |||
| 51 | - | ||||
| 52 | - | // SetDefaultColors sets the terminal default foreground/background colors used | |||
| 53 | - | // to answer child OSC 10/11 color queries. Passing ok=false makes color queries | |||
| 54 | - | // silent until known colors are supplied again. | |||
| 55 | - | func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) { | |||
| 56 | - | s.defaultFG = fg | |||
| 57 | - | s.defaultBG = bg | |||
| 58 | - | s.defaultColorsKnown = ok | |||
| 59 | - | } | |||
| 60 | - | ||||
| 61 | - | func (s *Screen) CursorRow() int { return s.Row } | |||
| 62 | - | func (s *Screen) CursorCol() int { return s.Col } | |||
| 63 | - | func (s *Screen) CursorVisible() bool { | |||
| 64 | - | return s.cursorVisible | |||
| 65 | - | } | |||
| 66 | - | func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet } | |||
| 67 | - | func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR } | |||
| 68 | - | func (s *Screen) AltScreenActive() bool { return s.alternate != nil } | |||
| 69 | - | ||||
| 70 | - | // TerminalTitle returns the latest title set by OSC 0 or OSC 2. | |||
| 71 | - | func (s *Screen) TerminalTitle() string { return s.terminalTitle } | |||
| 72 | - | ||||
| 73 | - | func (s *Screen) respond(b []byte) { | |||
| 74 | - | if s.OnResponse != nil { | |||
| 75 | - | s.OnResponse(b) | |||
| 76 | - | } | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | func (s *Screen) colorSchemeReport() []byte { | |||
| 80 | - | if !s.colorSchemeSet { | |||
| 81 | - | return nil | |||
| 82 | - | } | |||
| 83 | - | if s.colorSchemeLight { | |||
| 84 | - | return []byte(ColorSchemeReportLight) | |||
| 85 | - | } | |||
| 86 | - | return []byte(ColorSchemeReportDark) | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | func (s *Screen) reset() { | |||
| 90 | - | s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height) | |||
| 91 | - | s.Frame = s.buffer.frame | |||
| 92 | - | s.Row, s.Col = 0, 0 | |||
| 93 | - | s.Style = renderer.DefaultStyle() | |||
| 94 | - | s.escapeBuf = s.escapeBuf[:0] | |||
| 95 | - | s.savedCursor = cursorState{} | |||
| 96 | - | s.alternate = nil | |||
| 97 | - | s.originMode = false | |||
| 98 | - | s.insertMode = false | |||
| 99 | - | s.cursorVisible = true | |||
| 100 | - | s.cursorStyle = 0 | |||
| 101 | - | s.cursorStyleSet = false | |||
| 102 | - | s.mouseMode = 0 | |||
| 103 | - | s.mouseSGR = false | |||
| 104 | - | s.bracketedPaste = false | |||
| 105 | - | s.colorSchemeMode = false | |||
| 106 | - | s.resetScrollRegion() | |||
| 107 | - | s.fullRedraw() | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | func (s *Screen) setScrollRegion(parts []int) { | |||
| 111 | - | if s.Frame.Height == 0 { | |||
| 112 | - | return | |||
| 113 | - | } | |||
| 114 | - | top, bottom := 1, s.Frame.Height | |||
| 115 | - | if len(parts) > 0 && parts[0] > 0 { | |||
| 116 | - | top = parts[0] | |||
| 117 | - | } | |||
| 118 | - | if len(parts) > 1 && parts[1] > 0 { | |||
| 119 | - | bottom = parts[1] | |||
| 120 | - | } | |||
| 121 | - | if top >= bottom { | |||
| 122 | - | s.resetScrollRegion() | |||
| 123 | - | } else { | |||
| 124 | - | s.scrollTop = clamp(top-1, 0, s.Frame.Height-1) | |||
| 125 | - | s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1) | |||
| 126 | - | if s.scrollTop >= s.scrollBottom { | |||
| 127 | - | s.resetScrollRegion() | |||
| 128 | - | } | |||
| 129 | - | } | |||
| 130 | - | s.homeCursor() | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | - | func (s *Screen) resetScrollRegion() { | |||
| 134 | - | s.scrollTop = 0 | |||
| 135 | - | if s.Frame.Height > 0 { | |||
| 136 | - | s.scrollBottom = s.Frame.Height - 1 | |||
| 137 | - | } else { | |||
| 138 | - | s.scrollBottom = 0 | |||
| 139 | - | } | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||
| 143 | - | if !private { | |||
| 144 | - | for _, mode := range parts { | |||
| 145 | - | switch mode { | |||
| 146 | - | case 4: | |||
| 147 | - | s.insertMode = enabled | |||
| 148 | - | default: | |||
| 149 | - | // Other ANSI modes (for example LNM) are intentionally ignored | |||
| 150 | - | // until the screen model implements their observable behavior. | |||
| 151 | - | continue | |||
| 152 | - | } | |||
| 153 | - | } | |||
| 154 | - | return | |||
| 155 | - | } | |||
| 156 | - | for _, mode := range parts { | |||
| 157 | - | switch mode { | |||
| 158 | - | case 6: | |||
| 159 | - | s.originMode = enabled | |||
| 160 | - | s.homeCursor() | |||
| 161 | - | case 47, 1047, 1049: | |||
| 162 | - | if enabled { | |||
| 163 | - | s.enterAlternateScreen() | |||
| 164 | - | } else { | |||
| 165 | - | s.exitAlternateScreen() | |||
| 166 | - | } | |||
| 167 | - | case 2026: | |||
| 168 | - | s.syncUpdateActive = enabled | |||
| 169 | - | case 2031: | |||
| 170 | - | s.colorSchemeMode = enabled | |||
| 171 | - | case 25: | |||
| 172 | - | s.cursorVisible = enabled | |||
| 173 | - | case 1000, 1002, 1003: | |||
| 174 | - | if enabled { | |||
| 175 | - | s.mouseMode = mode | |||
| 176 | - | } else if s.mouseMode == mode { | |||
| 177 | - | s.mouseMode = 0 | |||
| 178 | - | } | |||
| 179 | - | case 1006: | |||
| 180 | - | s.mouseSGR = enabled | |||
| 181 | - | case 2004: | |||
| 182 | - | s.bracketedPaste = enabled | |||
| 183 | - | case 1, 1004, 1005: | |||
| 184 | - | // Trackable terminal modes that do not directly affect the current | |||
| 185 | - | // cell model yet. Consuming them prevents mode bytes from leaking. | |||
| 186 | - | continue | |||
| 187 | - | } | |||
| 188 | - | } | |||
| 189 | - | } | |||
| 190 | - | ||||
| 191 | - | func (s *Screen) enterAlternateScreen() { | |||
| 192 | - | if s.alternate == nil { | |||
| 193 | - | s.alternate = &screenState{ | |||
| 194 | - | frame: cloneFrame(s.Frame), | |||
| 195 | - | buffer: s.buffer.clone(), | |||
| 196 | - | row: s.Row, | |||
| 197 | - | col: s.Col, | |||
| 198 | - | style: s.Style, | |||
| 199 | - | scrollTop: s.scrollTop, | |||
| 200 | - | scrollBottom: s.scrollBottom, | |||
| 201 | - | savedCursor: s.savedCursor, | |||
| 202 | - | originMode: s.originMode, | |||
| 203 | - | insertMode: s.insertMode, | |||
| 204 | - | } | |||
| 205 | - | } | |||
| 206 | - | s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height) | |||
| 207 | - | s.Frame = s.buffer.frame | |||
| 208 | - | s.Row, s.Col = 0, 0 | |||
| 209 | - | s.Style = renderer.DefaultStyle() | |||
| 210 | - | s.savedCursor = cursorState{} | |||
| 211 | - | s.resetScrollRegion() | |||
| 212 | - | s.fullRedraw() | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | func (s *Screen) exitAlternateScreen() { | |||
| 216 | - | if s.alternate == nil { | |||
| 217 | - | return | |||
| 218 | - | } | |||
| 219 | - | state := s.alternate | |||
| 220 | - | s.buffer = state.buffer | |||
| 221 | - | if s.buffer == nil { | |||
| 222 | - | s.buffer = bufferFromFrame(cloneFrame(state.frame)) | |||
| 223 | - | s.fillMissingRowIDs(s.buffer) | |||
| 224 | - | } | |||
| 225 | - | s.Frame = s.buffer.frame | |||
| 226 | - | s.Row = clamp(state.row, 0, s.Frame.Height-1) | |||
| 227 | - | s.Col = clamp(state.col, 0, s.Frame.Width-1) | |||
| 228 | - | s.Style = state.style | |||
| 229 | - | s.scrollTop = state.scrollTop | |||
| 230 | - | s.scrollBottom = state.scrollBottom | |||
| 231 | - | s.savedCursor = state.savedCursor | |||
| 232 | - | s.originMode = state.originMode | |||
| 233 | - | s.insertMode = state.insertMode | |||
| 234 | - | s.alternate = nil | |||
| 235 | - | s.fullRedraw() | |||
| 236 | - | } | |||
D pkg/vt/screen_modes_test.go +0 -228
| @@ -1,228 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ) | |||
| 6 | - | ||||
| 7 | - | func TestESCSequences(t *testing.T) { | |||
| 8 | - | tests := []struct { | |||
| 9 | - | name string | |||
| 10 | - | run func(t *testing.T) | |||
| 11 | - | }{ | |||
| 12 | - | { | |||
| 13 | - | name: "ESC = (DECKPAM) is ignored, following text is printable", | |||
| 14 | - | run: func(t *testing.T) { | |||
| 15 | - | s := NewScreen(10, 2) | |||
| 16 | - | s.Write([]byte("\x1b=abc")) | |||
| 17 | - | ||||
| 18 | - | assertCell(t, s, 0, 0, 'a') | |||
| 19 | - | assertCell(t, s, 1, 0, 'b') | |||
| 20 | - | assertCell(t, s, 2, 0, 'c') | |||
| 21 | - | if s.Col != 3 || s.Row != 0 { | |||
| 22 | - | t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col) | |||
| 23 | - | } | |||
| 24 | - | }, | |||
| 25 | - | }, | |||
| 26 | - | { | |||
| 27 | - | name: "ESC 7 / ESC 8 save and restore the cursor", | |||
| 28 | - | run: func(t *testing.T) { | |||
| 29 | - | s := NewScreen(10, 3) | |||
| 30 | - | s.Write([]byte("abc\x1b7\x1b[2;5HZZ\x1b8X")) | |||
| 31 | - | ||||
| 32 | - | assertCell(t, s, 0, 0, 'a') | |||
| 33 | - | assertCell(t, s, 1, 0, 'b') | |||
| 34 | - | assertCell(t, s, 2, 0, 'c') | |||
| 35 | - | assertCell(t, s, 3, 0, 'X') | |||
| 36 | - | assertCell(t, s, 4, 1, 'Z') | |||
| 37 | - | assertCell(t, s, 5, 1, 'Z') | |||
| 38 | - | if s.Row != 0 || s.Col != 4 { | |||
| 39 | - | t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col) | |||
| 40 | - | } | |||
| 41 | - | }, | |||
| 42 | - | }, | |||
| 43 | - | { | |||
| 44 | - | name: "ESC 7 / ESC 8 restore origin and insert modes", | |||
| 45 | - | run: func(t *testing.T) { | |||
| 46 | - | s := NewScreen(6, 5) | |||
| 47 | - | s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[4h\x1b7")) | |||
| 48 | - | s.Write([]byte("\x1b[?6l\x1b[4l\x1b8")) | |||
| 49 | - | ||||
| 50 | - | if !s.originMode { | |||
| 51 | - | t.Fatal("origin mode was not restored") | |||
| 52 | - | } | |||
| 53 | - | if !s.insertMode { | |||
| 54 | - | t.Fatal("insert mode was not restored") | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | s.Write([]byte("\x1b[1;1Habcd\x1b[1;3HXY")) | |||
| 58 | - | if s.Row != 1 || s.Col != 4 { | |||
| 59 | - | t.Fatalf("cursor after restored origin and insert modes = (%d,%d), want (1,4)", s.Row, s.Col) | |||
| 60 | - | } | |||
| 61 | - | if got := lineText(s, 1); got != "abXYcd" { | |||
| 62 | - | t.Fatalf("line after restored insert mode = %q, want %q", got, "abXYcd") | |||
| 63 | - | } | |||
| 64 | - | }, | |||
| 65 | - | }, | |||
| 66 | - | { | |||
| 67 | - | name: "ESC D (index), ESC E (next line), ESC M (reverse index)", | |||
| 68 | - | run: func(t *testing.T) { | |||
| 69 | - | s := NewScreen(5, 3) | |||
| 70 | - | s.Write([]byte("A\x1bDB\x1bEC")) | |||
| 71 | - | ||||
| 72 | - | assertCell(t, s, 0, 0, 'A') | |||
| 73 | - | assertCell(t, s, 1, 1, 'B') | |||
| 74 | - | assertCell(t, s, 0, 2, 'C') | |||
| 75 | - | ||||
| 76 | - | s = NewScreen(5, 3) | |||
| 77 | - | s.Write([]byte("\x1b[2;1Hmid\r\x1bMtop")) | |||
| 78 | - | assertCell(t, s, 0, 0, 't') | |||
| 79 | - | assertCell(t, s, 0, 1, 'm') | |||
| 80 | - | }, | |||
| 81 | - | }, | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | for _, tt := range tests { | |||
| 85 | - | t.Run(tt.name, tt.run) | |||
| 86 | - | } | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | func TestScreenReportsSynchronizedUpdateMode(t *testing.T) { | |||
| 90 | - | s := NewScreen(10, 2) | |||
| 91 | - | s.Write([]byte("\x1b[?2026h")) | |||
| 92 | - | if !s.SyncUpdateActive() { | |||
| 93 | - | t.Fatal("sync update mode should be active") | |||
| 94 | - | } | |||
| 95 | - | s.Write([]byte("\x1b[?2026l")) | |||
| 96 | - | if s.SyncUpdateActive() { | |||
| 97 | - | t.Fatal("sync update mode should be inactive") | |||
| 98 | - | } | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | func TestForceSyncEnd(t *testing.T) { | |||
| 102 | - | s := NewScreen(10, 2) | |||
| 103 | - | s.Write([]byte("\x1b[?2026h")) | |||
| 104 | - | if !s.SyncUpdateActive() { | |||
| 105 | - | t.Fatal("sync update mode should be active before forcing end") | |||
| 106 | - | } | |||
| 107 | - | s.ForceSyncEnd() | |||
| 108 | - | if s.SyncUpdateActive() { | |||
| 109 | - | t.Fatal("sync update mode should be inactive after forcing end") | |||
| 110 | - | } | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | func TestScreenCursorAndMouseStateAccessors(t *testing.T) { | |||
| 114 | - | tests := []struct { | |||
| 115 | - | name string | |||
| 116 | - | seq string | |||
| 117 | - | check func(t *testing.T, s *Screen) | |||
| 118 | - | }{ | |||
| 119 | - | { | |||
| 120 | - | name: "cursor position and visibility are exposed", | |||
| 121 | - | seq: "\x1b[2;3H\x1b[?25l", | |||
| 122 | - | check: func(t *testing.T, s *Screen) { | |||
| 123 | - | if s.CursorRow() != 1 || s.CursorCol() != 2 { | |||
| 124 | - | t.Fatalf("cursor = row %d col %d, want row 1 col 2", s.CursorRow(), s.CursorCol()) | |||
| 125 | - | } | |||
| 126 | - | if s.CursorVisible() { | |||
| 127 | - | t.Fatal("cursor should be hidden") | |||
| 128 | - | } | |||
| 129 | - | s.Write([]byte("\x1b[?25h")) | |||
| 130 | - | if !s.CursorVisible() { | |||
| 131 | - | t.Fatal("cursor should be visible") | |||
| 132 | - | } | |||
| 133 | - | }, | |||
| 134 | - | }, | |||
| 135 | - | { | |||
| 136 | - | name: "mouse mode and SGR are exposed", | |||
| 137 | - | seq: "\x1b[?1002h\x1b[?1006h", | |||
| 138 | - | check: func(t *testing.T, s *Screen) { | |||
| 139 | - | mode, sgr := s.MouseMode() | |||
| 140 | - | if mode != 1002 || !sgr { | |||
| 141 | - | t.Fatalf("MouseMode() = (%d, %v), want (1002, true)", mode, sgr) | |||
| 142 | - | } | |||
| 143 | - | }, | |||
| 144 | - | }, | |||
| 145 | - | { | |||
| 146 | - | name: "reset restores cursor and mouse defaults", | |||
| 147 | - | seq: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1bc", | |||
| 148 | - | check: func(t *testing.T, s *Screen) { | |||
| 149 | - | mode, sgr := s.MouseMode() | |||
| 150 | - | if !s.CursorVisible() || mode != 0 || sgr { | |||
| 151 | - | t.Fatalf("after reset CursorVisible=%v MouseMode=(%d,%v), want true,(0,false)", s.CursorVisible(), mode, sgr) | |||
| 152 | - | } | |||
| 153 | - | }, | |||
| 154 | - | }, | |||
| 155 | - | } | |||
| 156 | - | for _, tt := range tests { | |||
| 157 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 158 | - | s := NewScreen(10, 4) | |||
| 159 | - | s.Write([]byte(tt.seq)) | |||
| 160 | - | tt.check(t, s) | |||
| 161 | - | }) | |||
| 162 | - | } | |||
| 163 | - | } | |||
| 164 | - | ||||
| 165 | - | func TestScreenMouseModeDisableInactiveIsNoOp(t *testing.T) { | |||
| 166 | - | s := NewScreen(10, 2) | |||
| 167 | - | s.Write([]byte("\x1b[?1002h")) | |||
| 168 | - | s.Write([]byte("\x1b[?1000l")) | |||
| 169 | - | mode, _ := s.MouseMode() | |||
| 170 | - | if mode != 1002 { | |||
| 171 | - | t.Fatalf("mouse mode after disabling inactive 1000 = %d, want 1002", mode) | |||
| 172 | - | } | |||
| 173 | - | s.Write([]byte("\x1b[?1002l")) | |||
| 174 | - | mode, _ = s.MouseMode() | |||
| 175 | - | if mode != 0 { | |||
| 176 | - | t.Fatalf("mouse mode after disabling active 1002 = %d, want 0", mode) | |||
| 177 | - | } | |||
| 178 | - | } | |||
| 179 | - | ||||
| 180 | - | func TestScreenCursorStyleDECSCUSR(t *testing.T) { | |||
| 181 | - | tests := []struct { | |||
| 182 | - | name string | |||
| 183 | - | seq string | |||
| 184 | - | wantStyle int | |||
| 185 | - | wantSet bool | |||
| 186 | - | }{ | |||
| 187 | - | {name: "explicit style", seq: "\x1b[5 q", wantStyle: 5, wantSet: true}, | |||
| 188 | - | {name: "blank style parameter", seq: "\x1b[ q", wantStyle: 0, wantSet: true}, | |||
| 189 | - | {name: "invalid low style is ignored", seq: "\x1b[-1 q", wantStyle: 0, wantSet: false}, | |||
| 190 | - | {name: "invalid high style is ignored", seq: "\x1b[7 q", wantStyle: 0, wantSet: false}, | |||
| 191 | - | {name: "XTVERSION is ignored", seq: "\x1b[>0q", wantStyle: 0, wantSet: false}, | |||
| 192 | - | } | |||
| 193 | - | for _, tt := range tests { | |||
| 194 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 195 | - | s := NewScreen(10, 2) | |||
| 196 | - | s.Write([]byte(tt.seq)) | |||
| 197 | - | style, set := s.CursorStyle() | |||
| 198 | - | if style != tt.wantStyle || set != tt.wantSet { | |||
| 199 | - | t.Fatalf("CursorStyle() = (%d, %v), want (%d, %v)", style, set, tt.wantStyle, tt.wantSet) | |||
| 200 | - | } | |||
| 201 | - | }) | |||
| 202 | - | } | |||
| 203 | - | } | |||
| 204 | - | ||||
| 205 | - | func TestScreenInvalidCursorStyleDoesNotOverwriteCurrentStyle(t *testing.T) { | |||
| 206 | - | s := NewScreen(10, 2) | |||
| 207 | - | s.Write([]byte("\x1b[5 q")) | |||
| 208 | - | s.Write([]byte("\x1b[99 q")) | |||
| 209 | - | style, set := s.CursorStyle() | |||
| 210 | - | if style != 5 || !set { | |||
| 211 | - | t.Fatalf("CursorStyle() = (%d, %v), want (5, true)", style, set) | |||
| 212 | - | } | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | func TestScreenAltScreenActiveAccessor(t *testing.T) { | |||
| 216 | - | s := NewScreen(10, 2) | |||
| 217 | - | if s.AltScreenActive() { | |||
| 218 | - | t.Fatal("alt screen should start inactive") | |||
| 219 | - | } | |||
| 220 | - | s.Write([]byte("\x1b[?1049h")) | |||
| 221 | - | if !s.AltScreenActive() { | |||
| 222 | - | t.Fatal("alt screen should be active") | |||
| 223 | - | } | |||
| 224 | - | s.Write([]byte("\x1b[?1049l")) | |||
| 225 | - | if s.AltScreenActive() { | |||
| 226 | - | t.Fatal("alt screen should be inactive after exit") | |||
| 227 | - | } | |||
| 228 | - | } | |||
D pkg/vt/screen_osc.go +0 -159
| @@ -1,159 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "strconv" | |||
| 5 | - | "strings" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | const ( | |||
| 11 | - | progressStateClear = iota | |||
| 12 | - | progressStateNormal | |||
| 13 | - | progressStateError | |||
| 14 | - | progressStateIndeterminate | |||
| 15 | - | progressStatePaused | |||
| 16 | - | ) | |||
| 17 | - | ||||
| 18 | - | func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) { | |||
| 19 | - | for i := 2; i < len(data); i++ { | |||
| 20 | - | switch data[i] { | |||
| 21 | - | case 0x07: | |||
| 22 | - | s.applyOSC(data[2:i], []byte{0x07}) | |||
| 23 | - | s.handleOSC(data[2:i]) | |||
| 24 | - | return i + 1, false | |||
| 25 | - | case 0x1b: | |||
| 26 | - | if i+1 < len(data) && data[i+1] == '\\' { | |||
| 27 | - | s.applyOSC(data[2:i], []byte{0x1b, '\\'}) | |||
| 28 | - | s.handleOSC(data[2:i]) | |||
| 29 | - | return i + 2, false | |||
| 30 | - | } | |||
| 31 | - | } | |||
| 32 | - | } | |||
| 33 | - | return 0, true | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | func (s *Screen) applyOSC(payload, terminator []byte) { | |||
| 37 | - | if !s.defaultColorsKnown { | |||
| 38 | - | return | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | var color renderer.RGB | |||
| 42 | - | switch string(payload) { | |||
| 43 | - | case "10;?": | |||
| 44 | - | color = s.defaultFG | |||
| 45 | - | case "11;?": | |||
| 46 | - | color = s.defaultBG | |||
| 47 | - | default: | |||
| 48 | - | return | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | resp := make([]byte, 0, len(payload)+len("\x1b];rgb:0000/0000/0000")+len(terminator)) | |||
| 52 | - | resp = append(resp, "\x1b]"...) | |||
| 53 | - | resp = append(resp, payload[:2]...) | |||
| 54 | - | resp = append(resp, ";rgb:"...) | |||
| 55 | - | resp = appendOSCColorComponent(resp, color.R) | |||
| 56 | - | resp = append(resp, '/') | |||
| 57 | - | resp = appendOSCColorComponent(resp, color.G) | |||
| 58 | - | resp = append(resp, '/') | |||
| 59 | - | resp = appendOSCColorComponent(resp, color.B) | |||
| 60 | - | resp = append(resp, terminator...) | |||
| 61 | - | s.respond(resp) | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | func appendOSCColorComponent(dst []byte, c uint8) []byte { | |||
| 65 | - | const hex = "0123456789abcdef" | |||
| 66 | - | v := uint16(c)<<8 | uint16(c) | |||
| 67 | - | return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf]) | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | // handleOSC inspects a complete OSC payload (between "ESC ]" and its | |||
| 71 | - | // terminator). Terminal titles, notification sequences (OSC 9, OSC 777 | |||
| 72 | - | // "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC | |||
| 73 | - | // is discarded. | |||
| 74 | - | ||||
| 75 | - | func (s *Screen) handleOSC(payload []byte) { | |||
| 76 | - | if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' { | |||
| 77 | - | s.handleOSC52(string(payload[len("52;"):])) | |||
| 78 | - | return | |||
| 79 | - | } | |||
| 80 | - | p := string(payload) | |||
| 81 | - | if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") { | |||
| 82 | - | s.terminalTitle = title | |||
| 83 | - | return | |||
| 84 | - | } | |||
| 85 | - | if p == "9;4" || strings.HasPrefix(p, "9;4;") { | |||
| 86 | - | s.handleProgress(p[len("9;4"):]) | |||
| 87 | - | return | |||
| 88 | - | } | |||
| 89 | - | if s.OnNotify == nil { | |||
| 90 | - | return | |||
| 91 | - | } | |||
| 92 | - | switch { | |||
| 93 | - | case strings.HasPrefix(p, "9;"): | |||
| 94 | - | s.OnNotify("", p[len("9;"):]) | |||
| 95 | - | case strings.HasPrefix(p, "777;"): | |||
| 96 | - | parts := strings.SplitN(p[len("777;"):], ";", 3) | |||
| 97 | - | if parts[0] != "notify" { | |||
| 98 | - | return | |||
| 99 | - | } | |||
| 100 | - | var title, body string | |||
| 101 | - | if len(parts) > 1 { | |||
| 102 | - | title = parts[1] | |||
| 103 | - | } | |||
| 104 | - | if len(parts) > 2 { | |||
| 105 | - | body = parts[2] | |||
| 106 | - | } | |||
| 107 | - | s.OnNotify(title, body) | |||
| 108 | - | } | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | func (s *Screen) handleProgress(rest string) { | |||
| 112 | - | rest = strings.TrimPrefix(rest, ";") | |||
| 113 | - | if rest == "" { | |||
| 114 | - | return | |||
| 115 | - | } | |||
| 116 | - | token, _, _ := strings.Cut(rest, ";") | |||
| 117 | - | state, err := strconv.Atoi(token) | |||
| 118 | - | if err != nil { | |||
| 119 | - | return | |||
| 120 | - | } | |||
| 121 | - | switch state { | |||
| 122 | - | case progressStateNormal, progressStateIndeterminate, progressStatePaused: | |||
| 123 | - | s.progressState = state | |||
| 124 | - | case progressStateClear: | |||
| 125 | - | previous := s.progressState | |||
| 126 | - | s.progressState = state | |||
| 127 | - | if previous == progressStateNormal || previous == progressStateIndeterminate || previous == progressStatePaused { | |||
| 128 | - | if s.OnProgress != nil { | |||
| 129 | - | s.OnProgress(false) | |||
| 130 | - | } | |||
| 131 | - | } | |||
| 132 | - | case progressStateError: | |||
| 133 | - | previous := s.progressState | |||
| 134 | - | s.progressState = state | |||
| 135 | - | if previous != progressStateError { | |||
| 136 | - | if s.OnProgress != nil { | |||
| 137 | - | s.OnProgress(true) | |||
| 138 | - | } | |||
| 139 | - | } | |||
| 140 | - | } | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | // handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52 | |||
| 144 | - | // clipboard payload (selection may be empty; split on the first ";"). A | |||
| 145 | - | // clipboard query (data == "?") is always ignored — vev never answers | |||
| 146 | - | // clipboard queries. A payload with no second ";" is malformed and ignored. | |||
| 147 | - | ||||
| 148 | - | func (s *Screen) handleOSC52(rest string) { | |||
| 149 | - | _, data, ok := strings.Cut(rest, ";") | |||
| 150 | - | if !ok { | |||
| 151 | - | return | |||
| 152 | - | } | |||
| 153 | - | if data == "?" { | |||
| 154 | - | return | |||
| 155 | - | } | |||
| 156 | - | if s.OnClipboard != nil { | |||
| 157 | - | s.OnClipboard(data) | |||
| 158 | - | } | |||
| 159 | - | } | |||
D pkg/vt/screen_osc_test.go +0 -429
| @@ -1,429 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "strings" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/stretchr/testify/require" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func TestOnBellFiresForLoneBEL(t *testing.T) { | |||
| 11 | - | s := NewScreen(10, 2) | |||
| 12 | - | rang := 0 | |||
| 13 | - | s.OnBell = func() { rang++ } | |||
| 14 | - | s.Write([]byte("hi\ahi\a")) | |||
| 15 | - | require.Equal(t, 2, rang) | |||
| 16 | - | require.Equal(t, "hihi", strings.TrimRight(rowString(s.Frame.Row(0)), " ")) | |||
| 17 | - | } | |||
| 18 | - | ||||
| 19 | - | func TestOnBellIgnoresOSCTerminator(t *testing.T) { | |||
| 20 | - | s := NewScreen(10, 2) | |||
| 21 | - | rang := 0 | |||
| 22 | - | s.OnBell = func() { rang++ } | |||
| 23 | - | s.Write([]byte("\x1b]0;title\a")) // BEL terminates the OSC, it is not a bell | |||
| 24 | - | require.Equal(t, 0, rang) | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | func TestBELWithoutCallbackIsDiscarded(t *testing.T) { | |||
| 28 | - | s := NewScreen(10, 2) | |||
| 29 | - | s.Write([]byte("a\ab")) // no OnBell set: must not panic | |||
| 30 | - | require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " ")) | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) { | |||
| 34 | - | tests := []struct { | |||
| 35 | - | name string | |||
| 36 | - | seq string | |||
| 37 | - | want string | |||
| 38 | - | }{ | |||
| 39 | - | {name: "OSC 0 BEL terminated", seq: "\x1b]0;zero title\x07", want: "zero title"}, | |||
| 40 | - | {name: "OSC 0 ST terminated", seq: "\x1b]0;zero title\x1b\\", want: "zero title"}, | |||
| 41 | - | {name: "OSC 2 BEL terminated", seq: "\x1b]2;two title\x07", want: "two title"}, | |||
| 42 | - | {name: "OSC 2 ST terminated", seq: "\x1b]2;two title\x1b\\", want: "two title"}, | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | for _, tt := range tests { | |||
| 46 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 47 | - | s := NewScreen(10, 2) | |||
| 48 | - | bells := 0 | |||
| 49 | - | s.OnBell = func() { bells++ } | |||
| 50 | - | s.ClearDamage() | |||
| 51 | - | ||||
| 52 | - | s.Write([]byte(tt.seq)) | |||
| 53 | - | ||||
| 54 | - | require.Equal(t, tt.want, s.TerminalTitle()) | |||
| 55 | - | require.Equal(t, 0, bells) | |||
| 56 | - | require.Empty(t, s.Damage()) | |||
| 57 | - | require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0))) | |||
| 58 | - | }) | |||
| 59 | - | } | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | func TestTerminalTitleHandlesSplitWritesReplacementAndClear(t *testing.T) { | |||
| 63 | - | s := NewScreen(10, 2) | |||
| 64 | - | ||||
| 65 | - | s.Write([]byte("\x1b]0;split")) | |||
| 66 | - | s.Write([]byte(" title\x1b")) | |||
| 67 | - | require.Empty(t, s.TerminalTitle()) | |||
| 68 | - | s.Write([]byte("\\")) | |||
| 69 | - | require.Equal(t, "split title", s.TerminalTitle()) | |||
| 70 | - | ||||
| 71 | - | s.Write([]byte("\x1b]2;replacement\x07")) | |||
| 72 | - | require.Equal(t, "replacement", s.TerminalTitle()) | |||
| 73 | - | ||||
| 74 | - | s.Write([]byte("\x1b]0;\x07")) | |||
| 75 | - | require.Empty(t, s.TerminalTitle()) | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | func TestTerminalTitleIgnoresUnrelatedAndOversizedOSC(t *testing.T) { | |||
| 79 | - | s := NewScreen(10, 2) | |||
| 80 | - | s.Write([]byte("\x1b]0;retained\x07")) | |||
| 81 | - | ||||
| 82 | - | for _, seq := range []string{ | |||
| 83 | - | "\x1b]1;icon title\x07", | |||
| 84 | - | "\x1b]20;not selector two\x1b\\", | |||
| 85 | - | "\x1b]777;other;x;y\x07", | |||
| 86 | - | } { | |||
| 87 | - | s.Write([]byte(seq)) | |||
| 88 | - | require.Equal(t, "retained", s.TerminalTitle()) | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | payload := strings.Repeat("x", maxEscapeBufferLen+1) | |||
| 92 | - | s.Write([]byte("\x1b]2;" + payload)) | |||
| 93 | - | require.Equal(t, "retained", s.TerminalTitle()) | |||
| 94 | - | require.Empty(t, s.escapeBuf) | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | func TestOnNotifyOSC9(t *testing.T) { | |||
| 98 | - | s := NewScreen(10, 2) | |||
| 99 | - | var gotTitle, gotBody string | |||
| 100 | - | calls := 0 | |||
| 101 | - | s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ } | |||
| 102 | - | s.Write([]byte("\x1b]9;agent done\x07")) | |||
| 103 | - | require.Equal(t, 1, calls) | |||
| 104 | - | require.Equal(t, "", gotTitle) | |||
| 105 | - | require.Equal(t, "agent done", gotBody) | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | func TestOSC9ProgressTransitions(t *testing.T) { | |||
| 109 | - | tests := []struct { | |||
| 110 | - | name string | |||
| 111 | - | seqs []string | |||
| 112 | - | want []bool | |||
| 113 | - | }{ | |||
| 114 | - | { | |||
| 115 | - | name: "finish fires once", | |||
| 116 | - | seqs: []string{"9;4;1;50", "9;4;0;100", "9;4;0;100"}, | |||
| 117 | - | want: []bool{false}, | |||
| 118 | - | }, | |||
| 119 | - | { | |||
| 120 | - | name: "error fires once repeat no fire", | |||
| 121 | - | seqs: []string{"9;4;2;0", "9;4;2;50"}, | |||
| 122 | - | want: []bool{true}, | |||
| 123 | - | }, | |||
| 124 | - | { | |||
| 125 | - | name: "indeterminate clear fires", | |||
| 126 | - | seqs: []string{"9;4;3", "9;4;0"}, | |||
| 127 | - | want: []bool{false}, | |||
| 128 | - | }, | |||
| 129 | - | { | |||
| 130 | - | name: "paused clear fires", | |||
| 131 | - | seqs: []string{"9;4;4;80", "9;4;0;80"}, | |||
| 132 | - | want: []bool{false}, | |||
| 133 | - | }, | |||
| 134 | - | { | |||
| 135 | - | name: "ongoing updates silent", | |||
| 136 | - | seqs: []string{"9;4;1;10", "9;4;1;50", "9;4;3;90", "9;4;4;90"}, | |||
| 137 | - | }, | |||
| 138 | - | { | |||
| 139 | - | name: "bare clear silent", | |||
| 140 | - | seqs: []string{"9;4;0"}, | |||
| 141 | - | }, | |||
| 142 | - | { | |||
| 143 | - | name: "bare progress ignored", | |||
| 144 | - | seqs: []string{"9;4"}, | |||
| 145 | - | }, | |||
| 146 | - | { | |||
| 147 | - | name: "unparseable ignored", | |||
| 148 | - | seqs: []string{"9;4;bogus", "9;4;99", "9;4;1", "9;4;0"}, | |||
| 149 | - | want: []bool{false}, | |||
| 150 | - | }, | |||
| 151 | - | { | |||
| 152 | - | name: "error active clear fires error then finish", | |||
| 153 | - | seqs: []string{"9;4;2;0", "9;4;1;50", "9;4;0;100"}, | |||
| 154 | - | want: []bool{true, false}, | |||
| 155 | - | }, | |||
| 156 | - | { | |||
| 157 | - | name: "error clear only fires error", | |||
| 158 | - | seqs: []string{"9;4;2;0", "9;4;0;100"}, | |||
| 159 | - | want: []bool{true}, | |||
| 160 | - | }, | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | for _, tt := range tests { | |||
| 164 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 165 | - | s := NewScreen(10, 2) | |||
| 166 | - | var got []bool | |||
| 167 | - | s.OnProgress = func(errored bool) { got = append(got, errored) } | |||
| 168 | - | for _, seq := range tt.seqs { | |||
| 169 | - | s.Write([]byte("\x1b]" + seq + "\x07")) | |||
| 170 | - | } | |||
| 171 | - | require.Equal(t, tt.want, got) | |||
| 172 | - | }) | |||
| 173 | - | } | |||
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | func TestOSC9ProgressSplitAcrossWritesFiresOnce(t *testing.T) { | |||
| 177 | - | s := NewScreen(10, 2) | |||
| 178 | - | calls := 0 | |||
| 179 | - | s.OnProgress = func(bool) { calls++ } | |||
| 180 | - | s.Write([]byte("\x1b]9;4;1")) | |||
| 181 | - | s.Write([]byte(";50\x07\x1b]9;4;0;100\x07")) | |||
| 182 | - | require.Equal(t, 1, calls) | |||
| 183 | - | } | |||
| 184 | - | ||||
| 185 | - | func TestOSC9ProgressNilCallbackDoesNotPanic(t *testing.T) { | |||
| 186 | - | s := NewScreen(10, 2) | |||
| 187 | - | require.NotPanics(t, func() { | |||
| 188 | - | s.Write([]byte("\x1b]9;4;1;50\x07")) | |||
| 189 | - | s.Write([]byte("\x1b]9;4;0;100\x07")) | |||
| 190 | - | s.Write([]byte("\x1b]9;4;2;0\x07")) | |||
| 191 | - | }) | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | - | func TestOSC9ProgressSTTerminated(t *testing.T) { | |||
| 195 | - | s := NewScreen(10, 2) | |||
| 196 | - | var got []bool | |||
| 197 | - | s.OnProgress = func(errored bool) { got = append(got, errored) } | |||
| 198 | - | s.Write([]byte("\x1b]9;4;1;50\x1b\\\x1b]9;4;0;100\x1b\\")) | |||
| 199 | - | require.Equal(t, []bool{false}, got) | |||
| 200 | - | } | |||
| 201 | - | ||||
| 202 | - | func TestOSC9ProgressStateTrackedBeforeCallbackSet(t *testing.T) { | |||
| 203 | - | s := NewScreen(10, 2) | |||
| 204 | - | s.Write([]byte("\x1b]9;4;1;50\x07")) | |||
| 205 | - | var got []bool | |||
| 206 | - | s.OnProgress = func(errored bool) { got = append(got, errored) } | |||
| 207 | - | s.Write([]byte("\x1b]9;4;0;100\x07")) | |||
| 208 | - | require.Equal(t, []bool{false}, got) | |||
| 209 | - | } | |||
| 210 | - | ||||
| 211 | - | func TestOnNotifyIgnoresOSC9Progress(t *testing.T) { | |||
| 212 | - | s := NewScreen(10, 2) | |||
| 213 | - | calls := 0 | |||
| 214 | - | s.OnNotify = func(string, string) { calls++ } | |||
| 215 | - | s.Write([]byte("\x1b]9;4;1;50\x07")) | |||
| 216 | - | s.Write([]byte("\x1b]9;4;0;100\x07")) | |||
| 217 | - | s.Write([]byte("\x1b]9;4;2;0\x07")) | |||
| 218 | - | require.Equal(t, 0, calls) | |||
| 219 | - | } | |||
| 220 | - | ||||
| 221 | - | func TestOnNotifyOSC940RemainsGeneric(t *testing.T) { | |||
| 222 | - | s := NewScreen(10, 2) | |||
| 223 | - | var gotTitle, gotBody string | |||
| 224 | - | calls := 0 | |||
| 225 | - | s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ } | |||
| 226 | - | s.Write([]byte("\x1b]9;40;not progress\x07")) | |||
| 227 | - | require.Equal(t, 1, calls) | |||
| 228 | - | require.Equal(t, "", gotTitle) | |||
| 229 | - | require.Equal(t, "40;not progress", gotBody) | |||
| 230 | - | } | |||
| 231 | - | ||||
| 232 | - | func TestOnNotifyOSC777STTerminated(t *testing.T) { | |||
| 233 | - | s := NewScreen(10, 2) | |||
| 234 | - | var gotTitle, gotBody string | |||
| 235 | - | calls := 0 | |||
| 236 | - | s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ } | |||
| 237 | - | s.Write([]byte("\x1b]777;notify;Claude;needs input\x1b\\")) | |||
| 238 | - | require.Equal(t, 1, calls) | |||
| 239 | - | require.Equal(t, "Claude", gotTitle) | |||
| 240 | - | require.Equal(t, "needs input", gotBody) | |||
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | func TestOnNotifyIgnoresOtherOSC(t *testing.T) { | |||
| 244 | - | s := NewScreen(10, 2) | |||
| 245 | - | calls := 0 | |||
| 246 | - | s.OnNotify = func(string, string) { calls++ } | |||
| 247 | - | s.Write([]byte("\x1b]0;window title\x07")) // title: discarded | |||
| 248 | - | s.Write([]byte("\x1b]777;other;x;y\x1b\\")) // not "notify": discarded | |||
| 249 | - | require.Equal(t, 0, calls) | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | func TestOnNotifySplitAcrossWrites(t *testing.T) { | |||
| 253 | - | s := NewScreen(10, 2) | |||
| 254 | - | calls := 0 | |||
| 255 | - | s.OnNotify = func(string, string) { calls++ } | |||
| 256 | - | s.Write([]byte("\x1b]9;par")) | |||
| 257 | - | s.Write([]byte("tial\x07")) | |||
| 258 | - | require.Equal(t, 1, calls) | |||
| 259 | - | } | |||
| 260 | - | ||||
| 261 | - | func TestOnClipboardBELTerminated(t *testing.T) { | |||
| 262 | - | s := NewScreen(10, 2) | |||
| 263 | - | var got string | |||
| 264 | - | calls := 0 | |||
| 265 | - | s.OnClipboard = func(b64 string) { got = b64; calls++ } | |||
| 266 | - | s.Write([]byte("\x1b]52;c;aGVsbG8=\x07")) | |||
| 267 | - | require.Equal(t, 1, calls) | |||
| 268 | - | require.Equal(t, "aGVsbG8=", got) | |||
| 269 | - | } | |||
| 270 | - | ||||
| 271 | - | func TestOnClipboardSTTerminated(t *testing.T) { | |||
| 272 | - | s := NewScreen(10, 2) | |||
| 273 | - | var got string | |||
| 274 | - | calls := 0 | |||
| 275 | - | s.OnClipboard = func(b64 string) { got = b64; calls++ } | |||
| 276 | - | s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\")) | |||
| 277 | - | require.Equal(t, 1, calls) | |||
| 278 | - | require.Equal(t, "aGVsbG8=", got) | |||
| 279 | - | } | |||
| 280 | - | ||||
| 281 | - | func TestOnClipboardQueryIgnored(t *testing.T) { | |||
| 282 | - | s := NewScreen(10, 2) | |||
| 283 | - | calls := 0 | |||
| 284 | - | s.OnClipboard = func(string) { calls++ } | |||
| 285 | - | s.Write([]byte("\x1b]52;c;?\x07")) | |||
| 286 | - | require.Equal(t, 0, calls) | |||
| 287 | - | } | |||
| 288 | - | ||||
| 289 | - | func TestOnClipboardEmptySelection(t *testing.T) { | |||
| 290 | - | s := NewScreen(10, 2) | |||
| 291 | - | var got string | |||
| 292 | - | calls := 0 | |||
| 293 | - | s.OnClipboard = func(b64 string) { got = b64; calls++ } | |||
| 294 | - | s.Write([]byte("\x1b]52;;aGVsbG8=\x07")) | |||
| 295 | - | require.Equal(t, 1, calls) | |||
| 296 | - | require.Equal(t, "aGVsbG8=", got) | |||
| 297 | - | } | |||
| 298 | - | ||||
| 299 | - | func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) { | |||
| 300 | - | s := NewScreen(10, 2) | |||
| 301 | - | require.NotPanics(t, func() { | |||
| 302 | - | s.Write([]byte("\x1b]52;c;aGVsbG8=\x07")) | |||
| 303 | - | }) | |||
| 304 | - | } | |||
| 305 | - | ||||
| 306 | - | func TestOnClipboardSplitAcrossWrites(t *testing.T) { | |||
| 307 | - | s := NewScreen(10, 2) | |||
| 308 | - | var got string | |||
| 309 | - | calls := 0 | |||
| 310 | - | s.OnClipboard = func(b64 string) { got = b64; calls++ } | |||
| 311 | - | s.Write([]byte("\x1b]52;c;aGVs")) | |||
| 312 | - | s.Write([]byte("bG8=\x07")) | |||
| 313 | - | require.Equal(t, 1, calls) | |||
| 314 | - | require.Equal(t, "aGVsbG8=", got) | |||
| 315 | - | } | |||
| 316 | - | ||||
| 317 | - | func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) { | |||
| 318 | - | s := NewScreen(10, 2) | |||
| 319 | - | calls := 0 | |||
| 320 | - | s.OnClipboard = func(string) { calls++ } | |||
| 321 | - | s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field | |||
| 322 | - | require.Equal(t, 0, calls) | |||
| 323 | - | } | |||
| 324 | - | ||||
| 325 | - | func TestOSCSequences(t *testing.T) { | |||
| 326 | - | tests := []struct { | |||
| 327 | - | name string | |||
| 328 | - | run func(t *testing.T) | |||
| 329 | - | }{ | |||
| 330 | - | { | |||
| 331 | - | name: "BEL-terminated OSC is ignored", | |||
| 332 | - | run: func(t *testing.T) { | |||
| 333 | - | s := NewScreen(20, 2) | |||
| 334 | - | s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ ")) | |||
| 335 | - | ||||
| 336 | - | assertCell(t, s, 0, 0, 'f') | |||
| 337 | - | assertCell(t, s, 1, 0, 'i') | |||
| 338 | - | assertCell(t, s, 2, 0, 's') | |||
| 339 | - | assertCell(t, s, 3, 0, 'h') | |||
| 340 | - | assertCell(t, s, 4, 0, '$') | |||
| 341 | - | assertCell(t, s, 5, 0, ' ') | |||
| 342 | - | if s.Col != 6 || s.Row != 0 { | |||
| 343 | - | t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row) | |||
| 344 | - | } | |||
| 345 | - | }, | |||
| 346 | - | }, | |||
| 347 | - | { | |||
| 348 | - | name: "ST-terminated OSC is ignored", | |||
| 349 | - | run: func(t *testing.T) { | |||
| 350 | - | s := NewScreen(20, 2) | |||
| 351 | - | s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> ")) | |||
| 352 | - | ||||
| 353 | - | assertCell(t, s, 0, 0, '>') | |||
| 354 | - | assertCell(t, s, 1, 0, ' ') | |||
| 355 | - | if s.Col != 2 || s.Row != 0 { | |||
| 356 | - | t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row) | |||
| 357 | - | } | |||
| 358 | - | }, | |||
| 359 | - | }, | |||
| 360 | - | { | |||
| 361 | - | name: "multiple OSC sequences in a row are all ignored", | |||
| 362 | - | run: func(t *testing.T) { | |||
| 363 | - | s := NewScreen(20, 2) | |||
| 364 | - | s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt")) | |||
| 365 | - | ||||
| 366 | - | assertCell(t, s, 0, 0, 'p') | |||
| 367 | - | assertCell(t, s, 1, 0, 'r') | |||
| 368 | - | assertCell(t, s, 2, 0, 'o') | |||
| 369 | - | assertCell(t, s, 3, 0, 'm') | |||
| 370 | - | assertCell(t, s, 4, 0, 'p') | |||
| 371 | - | assertCell(t, s, 5, 0, 't') | |||
| 372 | - | if s.Col != 6 || s.Row != 0 { | |||
| 373 | - | t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row) | |||
| 374 | - | } | |||
| 375 | - | }, | |||
| 376 | - | }, | |||
| 377 | - | { | |||
| 378 | - | name: "BEL-terminated OSC split across writes is ignored", | |||
| 379 | - | run: func(t *testing.T) { | |||
| 380 | - | s := NewScreen(20, 2) | |||
| 381 | - | s.Write([]byte("\x1b]0;~/Projects/ymux")) | |||
| 382 | - | s.Write([]byte(" - fish\x07fish$ ")) | |||
| 383 | - | ||||
| 384 | - | assertCell(t, s, 0, 0, 'f') | |||
| 385 | - | assertCell(t, s, 1, 0, 'i') | |||
| 386 | - | assertCell(t, s, 2, 0, 's') | |||
| 387 | - | assertCell(t, s, 3, 0, 'h') | |||
| 388 | - | assertCell(t, s, 4, 0, '$') | |||
| 389 | - | assertCell(t, s, 5, 0, ' ') | |||
| 390 | - | if s.Col != 6 || s.Row != 0 { | |||
| 391 | - | t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row) | |||
| 392 | - | } | |||
| 393 | - | }, | |||
| 394 | - | }, | |||
| 395 | - | { | |||
| 396 | - | name: "ST-terminated OSC split across writes is ignored", | |||
| 397 | - | run: func(t *testing.T) { | |||
| 398 | - | s := NewScreen(20, 2) | |||
| 399 | - | s.Write([]byte("\x1b]133;A;click_events=1\x1b")) | |||
| 400 | - | s.Write([]byte("\\> ")) | |||
| 401 | - | ||||
| 402 | - | assertCell(t, s, 0, 0, '>') | |||
| 403 | - | assertCell(t, s, 1, 0, ' ') | |||
| 404 | - | if s.Col != 2 || s.Row != 0 { | |||
| 405 | - | t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row) | |||
| 406 | - | } | |||
| 407 | - | }, | |||
| 408 | - | }, | |||
| 409 | - | { | |||
| 410 | - | name: "unterminated OSC sequence is bounded and dropped", | |||
| 411 | - | run: func(t *testing.T) { | |||
| 412 | - | s := NewScreen(20, 2) | |||
| 413 | - | payload := strings.Repeat("x", maxEscapeBufferLen+1) | |||
| 414 | - | s.Write([]byte("\x1b]0;" + payload)) | |||
| 415 | - | s.Write([]byte("OK")) | |||
| 416 | - | ||||
| 417 | - | assertCell(t, s, 0, 0, 'O') | |||
| 418 | - | assertCell(t, s, 1, 0, 'K') | |||
| 419 | - | if len(s.escapeBuf) != 0 { | |||
| 420 | - | t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC", len(s.escapeBuf)) | |||
| 421 | - | } | |||
| 422 | - | }, | |||
| 423 | - | }, | |||
| 424 | - | } | |||
| 425 | - | ||||
| 426 | - | for _, tt := range tests { | |||
| 427 | - | t.Run(tt.name, tt.run) | |||
| 428 | - | } | |||
| 429 | - | } | |||
D pkg/vt/screen_resize.go +0 -92
| @@ -1,92 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | - | ||||
| 5 | - | func (s *Screen) Resize(width, height int) { | |||
| 6 | - | if width == s.Frame.Width && height == s.Frame.Height { | |||
| 7 | - | return | |||
| 8 | - | } | |||
| 9 | - | if width <= 0 || height <= 0 { | |||
| 10 | - | // renderer.Frame permits a collapsed geometry. There is no layout to | |||
| 11 | - | // preserve until a usable viewport is supplied again. | |||
| 12 | - | width, height = max(width, 0), max(height, 0) | |||
| 13 | - | s.buffer = s.newBuffer(width, height) | |||
| 14 | - | s.Row, s.Col = 0, 0 | |||
| 15 | - | if state := s.alternate; state != nil { | |||
| 16 | - | state.buffer = s.newBuffer(width, height) | |||
| 17 | - | state.frame = state.buffer.frame | |||
| 18 | - | state.row, state.col = 0, 0 | |||
| 19 | - | state.scrollTop, state.scrollBottom = 0, height-1 | |||
| 20 | - | } | |||
| 21 | - | } else { | |||
| 22 | - | resize := func(b *buffer, row, col int, saved *cursorState, evict bool) (int, int) { | |||
| 23 | - | active := &bufferCursor{row: row, col: col} | |||
| 24 | - | var savedPoint *bufferCursor | |||
| 25 | - | if saved != nil && saved.saved { | |||
| 26 | - | savedPoint = &bufferCursor{row: saved.row, col: saved.col} | |||
| 27 | - | } | |||
| 28 | - | evicted, evictedBounds, evictedIDs := b.resize(width, height, active, savedPoint) | |||
| 29 | - | for i := range evictedIDs { | |||
| 30 | - | if evictedIDs[i] == 0 { | |||
| 31 | - | evictedIDs[i] = s.nextRowIDValue() | |||
| 32 | - | } | |||
| 33 | - | } | |||
| 34 | - | s.fillMissingRowIDs(b) | |||
| 35 | - | if evict { | |||
| 36 | - | for i, line := range evicted { | |||
| 37 | - | s.recordEvicted(line, evictedBounds[i], evictedIDs[i]) | |||
| 38 | - | } | |||
| 39 | - | } | |||
| 40 | - | if savedPoint != nil { | |||
| 41 | - | saved.row, saved.col = savedPoint.row, clamp(savedPoint.col, 0, width-1) | |||
| 42 | - | } | |||
| 43 | - | return active.row, clamp(active.col, 0, width-1) | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | if s.alternate != nil { | |||
| 47 | - | // Both screen states are reflowed independently, but active and saved | |||
| 48 | - | // cursors for each state are mapped by their single buffer layout pass. | |||
| 49 | - | s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false) | |||
| 50 | - | state := s.alternate | |||
| 51 | - | if state.buffer == nil { | |||
| 52 | - | state.buffer = bufferFromFrame(state.frame) | |||
| 53 | - | s.fillMissingRowIDs(state.buffer) | |||
| 54 | - | } | |||
| 55 | - | state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true) | |||
| 56 | - | state.frame = state.buffer.frame | |||
| 57 | - | state.scrollTop, state.scrollBottom = 0, height-1 | |||
| 58 | - | } else { | |||
| 59 | - | s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true) | |||
| 60 | - | } | |||
| 61 | - | } | |||
| 62 | - | s.Frame = s.buffer.frame | |||
| 63 | - | // A resize can split an in-flight escape sequence from the terminal state it | |||
| 64 | - | // was meant to mutate; keep the durable child state but discard partial bytes. | |||
| 65 | - | s.escapeBuf = s.escapeBuf[:0] | |||
| 66 | - | s.resetScrollRegion() | |||
| 67 | - | s.fullRedraw() | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | // cloneFrame produces an independent copy in canonical layout: it copies the | |||
| 71 | - | // source's logical rows (via Row) into a fresh frame whose offsets are already | |||
| 72 | - | // canonical, so a rotated source is normalized in the clone. | |||
| 73 | - | func cloneFrame(frame renderer.Frame) renderer.Frame { | |||
| 74 | - | out := renderer.NewFrame(frame.Width, frame.Height) | |||
| 75 | - | for y := range frame.Height { | |||
| 76 | - | copy(out.Row(y), frame.Row(y)) | |||
| 77 | - | } | |||
| 78 | - | return out | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | func clamp(v, lo, hi int) int { | |||
| 82 | - | if hi < lo { | |||
| 83 | - | return lo | |||
| 84 | - | } | |||
| 85 | - | if v < lo { | |||
| 86 | - | return lo | |||
| 87 | - | } | |||
| 88 | - | if v > hi { | |||
| 89 | - | return hi | |||
| 90 | - | } | |||
| 91 | - | return v | |||
| 92 | - | } | |||
D pkg/vt/screen_resize_test.go +0 -335
| @@ -1,335 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "math/rand" | |||
| 5 | - | "strings" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | func TestResize(t *testing.T) { | |||
| 12 | - | setRow := func(s *Screen, y int, text string) { | |||
| 13 | - | for x, r := range []rune(text) { | |||
| 14 | - | if x >= s.Frame.Width { | |||
| 15 | - | break | |||
| 16 | - | } | |||
| 17 | - | s.Frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 18 | - | } | |||
| 19 | - | } | |||
| 20 | - | rowString := func(s *Screen, y int) string { | |||
| 21 | - | runes := make([]rune, s.Frame.Width) | |||
| 22 | - | for x := range s.Frame.Width { | |||
| 23 | - | runes[x] = s.Frame.At(x, y).Rune | |||
| 24 | - | } | |||
| 25 | - | return string(runes) | |||
| 26 | - | } | |||
| 27 | - | ||||
| 28 | - | tests := []struct { | |||
| 29 | - | name string | |||
| 30 | - | run func(t *testing.T) | |||
| 31 | - | }{ | |||
| 32 | - | { | |||
| 33 | - | name: "same size preserves content and skips FullRedraw", | |||
| 34 | - | run: func(t *testing.T) { | |||
| 35 | - | s := NewScreen(5, 3) | |||
| 36 | - | s.ClearDamage() | |||
| 37 | - | setRow(s, 0, "Hello") | |||
| 38 | - | ||||
| 39 | - | s.Resize(5, 3) | |||
| 40 | - | ||||
| 41 | - | if got := rowString(s, 0); got != "Hello" { | |||
| 42 | - | t.Fatalf("row 0 = %q, want Hello", got) | |||
| 43 | - | } | |||
| 44 | - | if d := s.Damage(); len(d) != 0 { | |||
| 45 | - | t.Fatalf("same-size resize damage = %v, want none", damageKinds(d)) | |||
| 46 | - | } | |||
| 47 | - | }, | |||
| 48 | - | }, | |||
| 49 | - | { | |||
| 50 | - | name: "grow preserves visible content and cursor", | |||
| 51 | - | run: func(t *testing.T) { | |||
| 52 | - | s := NewScreen(5, 2) | |||
| 53 | - | setRow(s, 0, "Hello") | |||
| 54 | - | setRow(s, 1, "World") | |||
| 55 | - | s.Row, s.Col = 1, 4 | |||
| 56 | - | ||||
| 57 | - | s.Resize(8, 4) | |||
| 58 | - | ||||
| 59 | - | if got := rowString(s, 0); got != "Hello " { | |||
| 60 | - | t.Fatalf("row 0 = %q", got) | |||
| 61 | - | } | |||
| 62 | - | if got := rowString(s, 1); got != "World " { | |||
| 63 | - | t.Fatalf("row 1 = %q", got) | |||
| 64 | - | } | |||
| 65 | - | if s.Row != 1 || s.Col != 4 { | |||
| 66 | - | t.Fatalf("cursor = (%d,%d), want (1,4)", s.Row, s.Col) | |||
| 67 | - | } | |||
| 68 | - | if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) { | |||
| 69 | - | t.Fatalf("resize damage = %v, want FullRedraw", damageKinds(s.Damage())) | |||
| 70 | - | } | |||
| 71 | - | }, | |||
| 72 | - | }, | |||
| 73 | - | { | |||
| 74 | - | name: "height shrink evicts top lines and follows cursor", | |||
| 75 | - | run: func(t *testing.T) { | |||
| 76 | - | s := NewScreen(4, 5) | |||
| 77 | - | for y, text := range []string{"0000", "1111", "2222", "3333", "4444"} { | |||
| 78 | - | setRow(s, y, text) | |||
| 79 | - | } | |||
| 80 | - | s.Row, s.Col = 4, 2 | |||
| 81 | - | var evicted []string | |||
| 82 | - | s.OnLineEvicted = func(row []renderer.Cell) { | |||
| 83 | - | runes := make([]rune, len(row)) | |||
| 84 | - | for i, c := range row { | |||
| 85 | - | runes[i] = c.Rune | |||
| 86 | - | } | |||
| 87 | - | evicted = append(evicted, string(runes)) | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | s.Resize(4, 3) | |||
| 91 | - | ||||
| 92 | - | if got, want := evicted, []string{"0000", "1111"}; strings.Join(got, ",") != strings.Join(want, ",") { | |||
| 93 | - | t.Fatalf("evicted = %v, want %v", got, want) | |||
| 94 | - | } | |||
| 95 | - | if got := rowString(s, 0); got != "2222" { | |||
| 96 | - | t.Fatalf("row 0 = %q", got) | |||
| 97 | - | } | |||
| 98 | - | if s.Row != 2 || s.Col != 2 { | |||
| 99 | - | t.Fatalf("cursor = (%d,%d), want (2,2)", s.Row, s.Col) | |||
| 100 | - | } | |||
| 101 | - | if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) { | |||
| 102 | - | t.Fatalf("shrink damage = %v, want FullRedraw", damageKinds(s.Damage())) | |||
| 103 | - | } | |||
| 104 | - | }, | |||
| 105 | - | }, | |||
| 106 | - | { | |||
| 107 | - | name: "soft wrapped lines reflow on shrink and growth", | |||
| 108 | - | run: func(t *testing.T) { | |||
| 109 | - | s := NewScreen(5, 3) | |||
| 110 | - | s.Write([]byte("abcdefgh")) | |||
| 111 | - | ||||
| 112 | - | s.Resize(3, 3) | |||
| 113 | - | if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.Join(got, ",") != "abc,def,gh " { | |||
| 114 | - | t.Fatalf("shrink rows = %q", got) | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | s.Resize(5, 3) | |||
| 118 | - | if got := []string{rowString(s, 0), rowString(s, 1)}; strings.Join(got, ",") != "abcde,fgh " { | |||
| 119 | - | t.Fatalf("growth rows = %q", got) | |||
| 120 | - | } | |||
| 121 | - | }, | |||
| 122 | - | }, | |||
| 123 | - | { | |||
| 124 | - | name: "erase severs a soft wrap boundary", | |||
| 125 | - | run: func(t *testing.T) { | |||
| 126 | - | s := NewScreen(4, 2) | |||
| 127 | - | s.Write([]byte("abcdef")) | |||
| 128 | - | s.Write([]byte("\x1b[1;1H\x1b[2K")) | |||
| 129 | - | ||||
| 130 | - | // Erasing through the right edge ends the logical line: the | |||
| 131 | - | // blank head must not pull "ef" up a row on reflow. | |||
| 132 | - | s.Resize(3, 2) | |||
| 133 | - | if got := []string{rowString(s, 0), rowString(s, 1)}; strings.Join(got, ",") != " ,ef " { | |||
| 134 | - | t.Fatalf("erased soft line reflow = %q", got) | |||
| 135 | - | } | |||
| 136 | - | }, | |||
| 137 | - | }, | |||
| 138 | - | { | |||
| 139 | - | name: "in-place repaint does not merge with the row below", | |||
| 140 | - | run: func(t *testing.T) { | |||
| 141 | - | // Status UIs (docker buildx, shell prompt redraws) repaint a | |||
| 142 | - | // previously soft-wrapped row with CUP + text + EL and no | |||
| 143 | - | // newline. The stale soft link must not glue the repainted row | |||
| 144 | - | // to the unrelated row below: reflow would merge them, and the | |||
| 145 | - | // next shrink would truncate the merged line, losing cells. | |||
| 146 | - | s := NewScreen(40, 8) | |||
| 147 | - | s.Write([]byte(strings.Repeat("A", 40) + "BBBB\r\n")) | |||
| 148 | - | s.Write([]byte("tail-line\r\n")) | |||
| 149 | - | s.Write([]byte("\x1b[1;1HSHORT\x1b[K")) | |||
| 150 | - | s.Write([]byte("\x1b[4;1H")) | |||
| 151 | - | ||||
| 152 | - | s.Resize(60, 8) | |||
| 153 | - | if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.TrimRight(got[0], " ") != "SHORT" || strings.TrimRight(got[1], " ") != "BBBB" || strings.TrimRight(got[2], " ") != "tail-line" { | |||
| 154 | - | t.Fatalf("grow rows = %q", got) | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | s.Resize(40, 8) | |||
| 158 | - | if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.TrimRight(got[0], " ") != "SHORT" || strings.TrimRight(got[1], " ") != "BBBB" || strings.TrimRight(got[2], " ") != "tail-line" { | |||
| 159 | - | t.Fatalf("round-trip rows = %q", got) | |||
| 160 | - | } | |||
| 161 | - | }, | |||
| 162 | - | }, | |||
| 163 | - | { | |||
| 164 | - | name: "wide edge padding does not become logical content", | |||
| 165 | - | run: func(t *testing.T) { | |||
| 166 | - | s := NewScreen(4, 3) | |||
| 167 | - | s.Write([]byte("abc界x")) | |||
| 168 | - | ||||
| 169 | - | s.Resize(3, 3) | |||
| 170 | - | if got := rowString(s, 0); got != "abc" { | |||
| 171 | - | t.Fatalf("first reflow row = %q", got) | |||
| 172 | - | } | |||
| 173 | - | assertCell(t, s, 0, 1, '界') | |||
| 174 | - | if !cellAt(s, 1, 1).Continuation { | |||
| 175 | - | t.Fatal("wide continuation was not retained") | |||
| 176 | - | } | |||
| 177 | - | assertCell(t, s, 2, 1, 'x') | |||
| 178 | - | assertNoOrphanWideCells(t, s) | |||
| 179 | - | }, | |||
| 180 | - | }, | |||
| 181 | - | { | |||
| 182 | - | name: "width truncates and pads without reflow", | |||
| 183 | - | run: func(t *testing.T) { | |||
| 184 | - | s := NewScreen(6, 2) | |||
| 185 | - | setRow(s, 0, "abcdef") | |||
| 186 | - | setRow(s, 1, "ghijkl") | |||
| 187 | - | ||||
| 188 | - | s.Resize(3, 2) | |||
| 189 | - | if got := rowString(s, 0); got != "abc" { | |||
| 190 | - | t.Fatalf("truncated row = %q", got) | |||
| 191 | - | } | |||
| 192 | - | s.Resize(5, 2) | |||
| 193 | - | if got := rowString(s, 1); got != "ghi " { | |||
| 194 | - | t.Fatalf("padded row = %q", got) | |||
| 195 | - | } | |||
| 196 | - | }, | |||
| 197 | - | }, | |||
| 198 | - | { | |||
| 199 | - | name: "width shrink repairs truncated wide head", | |||
| 200 | - | run: func(t *testing.T) { | |||
| 201 | - | s := NewScreen(4, 1) | |||
| 202 | - | s.Write([]byte("AB界")) | |||
| 203 | - | ||||
| 204 | - | s.Resize(3, 1) | |||
| 205 | - | ||||
| 206 | - | assertCell(t, s, 0, 0, 'A') | |||
| 207 | - | assertCell(t, s, 1, 0, 'B') | |||
| 208 | - | assertBlank(t, s, 2, 0) | |||
| 209 | - | assertNoOrphanWideCells(t, s) | |||
| 210 | - | }, | |||
| 211 | - | }, | |||
| 212 | - | { | |||
| 213 | - | name: "random wide resize repairs copied rows", | |||
| 214 | - | run: func(t *testing.T) { | |||
| 215 | - | rng := rand.New(rand.NewSource(1)) | |||
| 216 | - | for range 200 { | |||
| 217 | - | s := NewScreen(8, 4) | |||
| 218 | - | s.Write([]byte("a界b好c語d")) | |||
| 219 | - | s.Row = rng.Intn(s.Frame.Height) | |||
| 220 | - | ||||
| 221 | - | s.Resize(rng.Intn(8)+1, rng.Intn(4)+1) | |||
| 222 | - | ||||
| 223 | - | assertNoOrphanWideCells(t, s) | |||
| 224 | - | } | |||
| 225 | - | }, | |||
| 226 | - | }, | |||
| 227 | - | { | |||
| 228 | - | name: "cursor and saved cursor clamp", | |||
| 229 | - | run: func(t *testing.T) { | |||
| 230 | - | s := NewScreen(6, 4) | |||
| 231 | - | s.Row, s.Col = 3, 5 | |||
| 232 | - | s.saveCursor() | |||
| 233 | - | ||||
| 234 | - | s.Resize(3, 2) | |||
| 235 | - | ||||
| 236 | - | if s.Row != 1 || s.Col != 2 { | |||
| 237 | - | t.Fatalf("cursor = (%d,%d), want (1,2)", s.Row, s.Col) | |||
| 238 | - | } | |||
| 239 | - | s.Row, s.Col = 0, 0 | |||
| 240 | - | s.restoreCursor() | |||
| 241 | - | if s.Row != 1 || s.Col != 2 { | |||
| 242 | - | t.Fatalf("restored cursor = (%d,%d), want (1,2)", s.Row, s.Col) | |||
| 243 | - | } | |||
| 244 | - | }, | |||
| 245 | - | }, | |||
| 246 | - | { | |||
| 247 | - | name: "alternate resize keeps saved normal content", | |||
| 248 | - | run: func(t *testing.T) { | |||
| 249 | - | s := NewScreen(5, 3) | |||
| 250 | - | setRow(s, 0, "shell") | |||
| 251 | - | s.Row = 0 | |||
| 252 | - | s.Write([]byte("\x1b[?1049h")) | |||
| 253 | - | setRow(s, 0, "alt!!") | |||
| 254 | - | ||||
| 255 | - | s.Resize(7, 4) | |||
| 256 | - | s.Write([]byte("\x1b[?1049l")) | |||
| 257 | - | ||||
| 258 | - | if got := rowString(s, 0); got != "shell " { | |||
| 259 | - | t.Fatalf("normal row after alt resize = %q", got) | |||
| 260 | - | } | |||
| 261 | - | }, | |||
| 262 | - | }, | |||
| 263 | - | { | |||
| 264 | - | name: "alternate resize shifts active and saved cursors", | |||
| 265 | - | run: func(t *testing.T) { | |||
| 266 | - | s := NewScreen(5, 5) | |||
| 267 | - | s.Row, s.Col = 3, 4 | |||
| 268 | - | s.saveCursor() | |||
| 269 | - | s.scrollTop, s.scrollBottom = 1, 4 | |||
| 270 | - | s.Row = 4 | |||
| 271 | - | s.Write([]byte("\x1b[?1049h")) | |||
| 272 | - | s.Row, s.Col = 3, 4 | |||
| 273 | - | s.saveCursor() | |||
| 274 | - | s.Row = 4 | |||
| 275 | - | ||||
| 276 | - | // The expected rows account for the resize shift that keeps the | |||
| 277 | - | // cursor visible after NewScreen -> saveCursor -> Resize -> restoreCursor, | |||
| 278 | - | // not just width/height clamping. | |||
| 279 | - | s.Resize(3, 3) | |||
| 280 | - | ||||
| 281 | - | if s.Row != 2 || s.Col != 2 { | |||
| 282 | - | t.Fatalf("active alt cursor = (%d,%d), want (2,2)", s.Row, s.Col) | |||
| 283 | - | } | |||
| 284 | - | s.Row, s.Col = 0, 0 | |||
| 285 | - | s.restoreCursor() | |||
| 286 | - | if s.Row != 1 || s.Col != 2 { | |||
| 287 | - | t.Fatalf("active alt saved cursor = (%d,%d), want (1,2)", s.Row, s.Col) | |||
| 288 | - | } | |||
| 289 | - | ||||
| 290 | - | s.Write([]byte("\x1b[?1049l")) | |||
| 291 | - | if s.scrollTop != 0 || s.scrollBottom != 2 { | |||
| 292 | - | t.Fatalf("normal scroll region = (%d,%d), want (0,2)", s.scrollTop, s.scrollBottom) | |||
| 293 | - | } | |||
| 294 | - | s.Row, s.Col = 0, 0 | |||
| 295 | - | s.restoreCursor() | |||
| 296 | - | if s.Row != 1 || s.Col != 2 { | |||
| 297 | - | t.Fatalf("normal saved cursor = (%d,%d), want (1,2)", s.Row, s.Col) | |||
| 298 | - | } | |||
| 299 | - | }, | |||
| 300 | - | }, | |||
| 301 | - | { | |||
| 302 | - | name: "style mouse cursor and bracketed paste modes survive", | |||
| 303 | - | run: func(t *testing.T) { | |||
| 304 | - | s := NewScreen(5, 2) | |||
| 305 | - | s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[3 q")) | |||
| 306 | - | ||||
| 307 | - | s.Resize(6, 3) | |||
| 308 | - | ||||
| 309 | - | if !s.Style.Bold { | |||
| 310 | - | t.Fatal("bold style was reset") | |||
| 311 | - | } | |||
| 312 | - | if mode, sgr := s.MouseMode(); mode != 1000 || !sgr { | |||
| 313 | - | t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr) | |||
| 314 | - | } | |||
| 315 | - | if !s.BracketedPasteMode() { | |||
| 316 | - | t.Fatal("bracketed paste mode was reset") | |||
| 317 | - | } | |||
| 318 | - | if s.CursorVisible() { | |||
| 319 | - | t.Fatal("cursor visibility was reset") | |||
| 320 | - | } | |||
| 321 | - | if style, ok := s.CursorStyle(); style != 3 || !ok { | |||
| 322 | - | t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok) | |||
| 323 | - | } | |||
| 324 | - | s.Write([]byte("\x1b[?2004l")) | |||
| 325 | - | if s.BracketedPasteMode() { | |||
| 326 | - | t.Fatal("bracketed paste mode remained enabled after reset") | |||
| 327 | - | } | |||
| 328 | - | }, | |||
| 329 | - | }, | |||
| 330 | - | } | |||
| 331 | - | ||||
| 332 | - | for _, tt := range tests { | |||
| 333 | - | t.Run(tt.name, tt.run) | |||
| 334 | - | } | |||
| 335 | - | } | |||
D pkg/vt/screen_scroll.go +0 -149
| @@ -1,149 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | // index moves to the next physical row, scrolling the region when the cursor | |||
| 10 | - | // already sits on its last row. It reports whether the row the cursor was on | |||
| 11 | - | // survived that scroll by moving up to s.Row-1, which only a deferred wrap | |||
| 12 | - | // cares about: a wrap must relink to the row it flowed out of, and | |||
| 13 | - | // buffer.scrollUp severs that link on the way. Callers that merely advance, | |||
| 14 | - | // such as LF and ESC D, ignore the result. | |||
| 15 | - | func (s *Screen) index() (movedUp bool) { | |||
| 16 | - | if s.Frame.Height == 0 { | |||
| 17 | - | return false | |||
| 18 | - | } | |||
| 19 | - | if s.Row == s.scrollBottom && s.Row >= s.scrollTop { | |||
| 20 | - | return s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1) | |||
| 21 | - | } | |||
| 22 | - | if s.Row+1 < s.Frame.Height { | |||
| 23 | - | s.Row++ | |||
| 24 | - | return false | |||
| 25 | - | } | |||
| 26 | - | s.Row = s.Frame.Height - 1 | |||
| 27 | - | return false | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | func (s *Screen) nextLine() { | |||
| 31 | - | s.buffer.hard(s.Row) | |||
| 32 | - | s.Col = 0 | |||
| 33 | - | s.index() | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | func (s *Screen) reverseIndex() { | |||
| 37 | - | if s.Frame.Height == 0 { | |||
| 38 | - | return | |||
| 39 | - | } | |||
| 40 | - | if s.Row == s.scrollTop && s.Row <= s.scrollBottom { | |||
| 41 | - | s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1) | |||
| 42 | - | return | |||
| 43 | - | } | |||
| 44 | - | if s.Row > 0 { | |||
| 45 | - | s.Row-- | |||
| 46 | - | } | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | // scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU). | |||
| 50 | - | // The cursor position is preserved. | |||
| 51 | - | func (s *Screen) scrollUpBy(n int) { | |||
| 52 | - | if n <= 0 { | |||
| 53 | - | return | |||
| 54 | - | } | |||
| 55 | - | s.scrollUpRegion(s.scrollTop, s.scrollBottom, n) | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | func (s *Screen) scrollDownBy(n int) { | |||
| 59 | - | if n <= 0 { | |||
| 60 | - | return | |||
| 61 | - | } | |||
| 62 | - | s.scrollDownRegion(s.scrollTop, s.scrollBottom, n) | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | // scrollUpRegion scrolls [top,bottom] up by n. It reports whether any row | |||
| 66 | - | // survived by moving up: a region exactly n rows tall is blanked in place, so | |||
| 67 | - | // the receiving range [top,bottom-n] is empty and nothing survives. Reporting | |||
| 68 | - | // what the scroll observed keeps callers from re-deriving a predicate that this | |||
| 69 | - | // function may have declined to act on at all. | |||
| 70 | - | func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) { | |||
| 71 | - | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |||
| 72 | - | return false | |||
| 73 | - | } | |||
| 74 | - | top, bottom, ok := s.normalizedRegion(top, bottom) | |||
| 75 | - | if !ok { | |||
| 76 | - | return false | |||
| 77 | - | } | |||
| 78 | - | w := s.Frame.Width | |||
| 79 | - | height := bottom - top + 1 | |||
| 80 | - | if n > height { | |||
| 81 | - | n = height | |||
| 82 | - | } | |||
| 83 | - | // VT scroll regions always span the full frame width, so we rotate the | |||
| 84 | - | // frame's line offsets (recycling and blanking the evicted rows in place) | |||
| 85 | - | // instead of copying cells. See renderer.Frame.ScrollUp. | |||
| 86 | - | s.emitLineEvicted(top, n) | |||
| 87 | - | s.Frame.ScrollUp(top, bottom, n) | |||
| 88 | - | s.buffer.scrollUp(top, bottom, n) | |||
| 89 | - | s.fillMissingRowIDs(s.buffer) | |||
| 90 | - | s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n}) | |||
| 91 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1}) | |||
| 92 | - | return bottom-n >= top | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | func (s *Screen) emitLineEvicted(top, n int) { | |||
| 96 | - | // Only rows leaving the top edge of the primary screen belong to global | |||
| 97 | - | // scrollback. Interior DECSTBM scroll regions are local mutations. | |||
| 98 | - | if s.alternate != nil || top != 0 { | |||
| 99 | - | return | |||
| 100 | - | } | |||
| 101 | - | // Read boundaries and IDs before the caller rotates the frame: a soft link | |||
| 102 | - | // belongs to the row it follows, and rotation reassigns row indices. | |||
| 103 | - | for y := top; y < top+n; y++ { | |||
| 104 | - | s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y]) | |||
| 105 | - | } | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) { | |||
| 109 | - | if s.history != nil { | |||
| 110 | - | err := s.history.AppendWithID(row, bound, id) | |||
| 111 | - | if err != nil && !errors.Is(err, ErrHistoryRowTooWide) { | |||
| 112 | - | panic(err) | |||
| 113 | - | } | |||
| 114 | - | // A nil error records the row; ErrHistoryRowTooWide explicitly leaves it | |||
| 115 | - | // unrecorded. Both cases continue to the eviction observer below. | |||
| 116 | - | } | |||
| 117 | - | if s.OnLineEvicted != nil { | |||
| 118 | - | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |||
| 119 | - | } | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | func (s *Screen) scrollDownRegion(top, bottom, n int) { | |||
| 123 | - | if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 { | |||
| 124 | - | return | |||
| 125 | - | } | |||
| 126 | - | top, bottom, ok := s.normalizedRegion(top, bottom) | |||
| 127 | - | if !ok { | |||
| 128 | - | return | |||
| 129 | - | } | |||
| 130 | - | w := s.Frame.Width | |||
| 131 | - | height := bottom - top + 1 | |||
| 132 | - | if n > height { | |||
| 133 | - | n = height | |||
| 134 | - | } | |||
| 135 | - | // Full-width region: rotate line offsets instead of copying cells. | |||
| 136 | - | s.Frame.ScrollDown(top, bottom, n) | |||
| 137 | - | s.buffer.scrollDown(top, bottom, n) | |||
| 138 | - | s.fillMissingRowIDs(s.buffer) | |||
| 139 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1}) | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |||
| 143 | - | if s.Frame.Height == 0 || top > bottom { | |||
| 144 | - | return 0, 0, false | |||
| 145 | - | } | |||
| 146 | - | top = clamp(top, 0, s.Frame.Height-1) | |||
| 147 | - | bottom = clamp(bottom, 0, s.Frame.Height-1) | |||
| 148 | - | return top, bottom, top <= bottom | |||
| 149 | - | } | |||
D pkg/vt/screen_scroll_test.go +0 -79
| @@ -1,79 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ) | |||
| 6 | - | ||||
| 7 | - | // TestWideCharSurvivesRotatedScroll proves a wide-character pair travels intact | |||
| 8 | - | // with its line when a full-width scroll rotates line offsets rather than | |||
| 9 | - | // copying cells. | |||
| 10 | - | func TestWideCharSurvivesRotatedScroll(t *testing.T) { | |||
| 11 | - | tests := []struct { | |||
| 12 | - | name string | |||
| 13 | - | run func(t *testing.T) | |||
| 14 | - | }{ | |||
| 15 | - | { | |||
| 16 | - | name: "wide pair moves up one row on newline scroll", | |||
| 17 | - | run: func(t *testing.T) { | |||
| 18 | - | s := NewScreen(4, 3) | |||
| 19 | - | // Place a wide rune (世, width 2) on the bottom row, then scroll up. | |||
| 20 | - | s.Write([]byte("\x1b[3;1H世")) | |||
| 21 | - | assertCell(t, s, 0, 2, '世') | |||
| 22 | - | assertContinuation(t, s, 1, 2) | |||
| 23 | - | // Newline at the bottom row scrolls the region up by one. | |||
| 24 | - | s.Write([]byte("\n")) | |||
| 25 | - | // The wide pair is now on row 1, intact; bottom row blank. | |||
| 26 | - | assertCell(t, s, 0, 1, '世') | |||
| 27 | - | assertContinuation(t, s, 1, 1) | |||
| 28 | - | assertBlank(t, s, 0, 2) | |||
| 29 | - | assertBlank(t, s, 1, 2) | |||
| 30 | - | if err := s.Frame.CheckInvariants(); err != nil { | |||
| 31 | - | t.Fatalf("invariants: %v", err) | |||
| 32 | - | } | |||
| 33 | - | }, | |||
| 34 | - | }, | |||
| 35 | - | { | |||
| 36 | - | name: "wide pair moves down on reverse index", | |||
| 37 | - | run: func(t *testing.T) { | |||
| 38 | - | s := NewScreen(4, 3) | |||
| 39 | - | s.Write([]byte("\x1b[1;1H世")) // top row | |||
| 40 | - | assertCell(t, s, 0, 0, '世') | |||
| 41 | - | assertContinuation(t, s, 1, 0) | |||
| 42 | - | s.Write([]byte("\x1b[1;1H\x1bM")) // cursor top, reverse index -> scroll down | |||
| 43 | - | assertCell(t, s, 0, 1, '世') | |||
| 44 | - | assertContinuation(t, s, 1, 1) | |||
| 45 | - | assertBlank(t, s, 0, 0) | |||
| 46 | - | if err := s.Frame.CheckInvariants(); err != nil { | |||
| 47 | - | t.Fatalf("invariants: %v", err) | |||
| 48 | - | } | |||
| 49 | - | }, | |||
| 50 | - | }, | |||
| 51 | - | } | |||
| 52 | - | for _, tt := range tests { | |||
| 53 | - | t.Run(tt.name, tt.run) | |||
| 54 | - | } | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | // TestLineOffsetInvariantAfterVTSequences drives interleaved writes and scrolls | |||
| 58 | - | // through the VT layer and asserts the frame's line-offset stays a valid | |||
| 59 | - | // permutation. | |||
| 60 | - | func TestLineOffsetInvariantAfterVTSequences(t *testing.T) { | |||
| 61 | - | s := NewScreen(10, 6) | |||
| 62 | - | seqs := [][]byte{ | |||
| 63 | - | []byte("line0\r\nline1\r\nline2\r\nline3\r\nline4\r\nline5"), | |||
| 64 | - | []byte("\x1b[2;5r"), // scroll region rows 2..5 | |||
| 65 | - | []byte("\x1b[5;1Hmore\n"), // scroll within region | |||
| 66 | - | []byte("\x1b[1;1H\x1bM"), // reverse index at top | |||
| 67 | - | []byte("\x1b[3S"), // scroll up 3 | |||
| 68 | - | []byte("\x1b[2T"), // scroll down 2 | |||
| 69 | - | []byte("\x1b[r"), // reset region | |||
| 70 | - | []byte("\x1b[6;1Hbottom\n"), | |||
| 71 | - | } | |||
| 72 | - | for i, seq := range seqs { | |||
| 73 | - | s.Write(seq) | |||
| 74 | - | s.ClearDamage() | |||
| 75 | - | if err := s.Frame.CheckInvariants(); err != nil { | |||
| 76 | - | t.Fatalf("after seq %d: invariants broken: %v", i, err) | |||
| 77 | - | } | |||
| 78 | - | } | |||
| 79 | - | } | |||
D pkg/vt/screen_snapshot.go +0 -116
| @@ -1,116 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | - | ||||
| 5 | - | // CursorSnapshot is the cursor state captured with a visible screen snapshot. | |||
| 6 | - | type CursorSnapshot struct { | |||
| 7 | - | Row int | |||
| 8 | - | Col int | |||
| 9 | - | Visible bool | |||
| 10 | - | Style int | |||
| 11 | - | StyleSet bool | |||
| 12 | - | } | |||
| 13 | - | ||||
| 14 | - | // ModeSnapshot is the renderer-relevant VT mode state captured with a screen. | |||
| 15 | - | type ModeSnapshot struct { | |||
| 16 | - | AlternateScreen bool | |||
| 17 | - | BracketedPaste bool | |||
| 18 | - | SynchronizedUpdate bool | |||
| 19 | - | ColorSchemeMode bool | |||
| 20 | - | MouseTracking int | |||
| 21 | - | MouseSGR bool | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | // ScreenSnapshot is an owned immutable-by-convention capture of the active | |||
| 25 | - | // terminal viewport. Row returns caller-owned storage; BorrowedRow must not be | |||
| 26 | - | // mutated and remains valid while the snapshot is retained. | |||
| 27 | - | type ScreenSnapshot struct { | |||
| 28 | - | frame renderer.Frame | |||
| 29 | - | bounds []LineBound | |||
| 30 | - | rowIDs []RowID | |||
| 31 | - | cursor CursorSnapshot | |||
| 32 | - | modes ModeSnapshot | |||
| 33 | - | title string | |||
| 34 | - | nextRowID RowID | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | // Snapshot captures the active visible viewport without mutating Screen, | |||
| 38 | - | // history, or pending damage. Screen remains single-owner; callers must | |||
| 39 | - | // serialize Snapshot with Write, Resize, and host mutations. | |||
| 40 | - | func (s *Screen) Snapshot() ScreenSnapshot { | |||
| 41 | - | if s == nil { | |||
| 42 | - | return ScreenSnapshot{} | |||
| 43 | - | } | |||
| 44 | - | cursorStyle, cursorStyleSet := s.CursorStyle() | |||
| 45 | - | mouseTracking, mouseSGR := s.MouseMode() | |||
| 46 | - | nextRowID := s.nextRowID | |||
| 47 | - | if nextRowID < ^RowID(0) { | |||
| 48 | - | nextRowID++ | |||
| 49 | - | } | |||
| 50 | - | return ScreenSnapshot{ | |||
| 51 | - | frame: s.Frame.Clone(), | |||
| 52 | - | bounds: s.LineBounds(), | |||
| 53 | - | rowIDs: s.RowIDs(), | |||
| 54 | - | nextRowID: nextRowID, | |||
| 55 | - | cursor: CursorSnapshot{ | |||
| 56 | - | Row: s.CursorRow(), | |||
| 57 | - | Col: s.CursorCol(), | |||
| 58 | - | Visible: s.CursorVisible(), | |||
| 59 | - | Style: cursorStyle, | |||
| 60 | - | StyleSet: cursorStyleSet, | |||
| 61 | - | }, | |||
| 62 | - | modes: ModeSnapshot{ | |||
| 63 | - | AlternateScreen: s.AltScreenActive(), | |||
| 64 | - | BracketedPaste: s.BracketedPasteMode(), | |||
| 65 | - | SynchronizedUpdate: s.SyncUpdateActive(), | |||
| 66 | - | ColorSchemeMode: s.ColorSchemeMode(), | |||
| 67 | - | MouseTracking: mouseTracking, | |||
| 68 | - | MouseSGR: mouseSGR, | |||
| 69 | - | }, | |||
| 70 | - | title: s.TerminalTitle(), | |||
| 71 | - | } | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | func (s ScreenSnapshot) Columns() int { return s.frame.Width } | |||
| 75 | - | func (s ScreenSnapshot) Rows() int { return s.frame.Height } | |||
| 76 | - | ||||
| 77 | - | func (s ScreenSnapshot) Row(y int) []renderer.Cell { | |||
| 78 | - | return append([]renderer.Cell(nil), s.BorrowedRow(y)...) | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | func (s ScreenSnapshot) BorrowedRow(y int) []renderer.Cell { | |||
| 82 | - | if y < 0 || y >= s.frame.Height { | |||
| 83 | - | return nil | |||
| 84 | - | } | |||
| 85 | - | return s.frame.Row(y) | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | func (s ScreenSnapshot) Bound(y int) LineBound { | |||
| 89 | - | if y < 0 || y >= len(s.bounds) { | |||
| 90 | - | return LineBound{} | |||
| 91 | - | } | |||
| 92 | - | return s.bounds[y] | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | // RowIDs returns an owned copy of the visible physical-row identities. | |||
| 96 | - | func (s ScreenSnapshot) RowIDs() []RowID { return append([]RowID(nil), s.rowIDs...) } | |||
| 97 | - | ||||
| 98 | - | // RowID returns the identity of visible row y, or zero when out of range. | |||
| 99 | - | func (s ScreenSnapshot) RowID(y int) RowID { | |||
| 100 | - | if y < 0 || y >= len(s.rowIDs) { | |||
| 101 | - | return 0 | |||
| 102 | - | } | |||
| 103 | - | return s.rowIDs[y] | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | // NextRowID returns the next identity allocated by the captured screen. | |||
| 107 | - | func (s ScreenSnapshot) NextRowID() RowID { | |||
| 108 | - | if s.nextRowID == 0 { | |||
| 109 | - | return 1 | |||
| 110 | - | } | |||
| 111 | - | return s.nextRowID | |||
| 112 | - | } | |||
| 113 | - | ||||
| 114 | - | func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor } | |||
| 115 | - | func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes } | |||
| 116 | - | func (s ScreenSnapshot) Title() string { return s.title } | |||
D pkg/vt/screen_snapshot_test.go +0 -437
| @@ -1,437 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | "github.com/stretchr/testify/require" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func TestScreenSnapshotGeometryAndMetadata(t *testing.T) { | |||
| 11 | - | tests := []struct { | |||
| 12 | - | name string | |||
| 13 | - | screen func() *Screen | |||
| 14 | - | wantColumns int | |||
| 15 | - | wantRows int | |||
| 16 | - | wantTitle string | |||
| 17 | - | wantCursor CursorSnapshot | |||
| 18 | - | wantModes ModeSnapshot | |||
| 19 | - | checkRows func(t *testing.T, snapshot ScreenSnapshot) | |||
| 20 | - | }{ | |||
| 21 | - | { | |||
| 22 | - | name: "nil receiver is the zero snapshot", | |||
| 23 | - | screen: func() *Screen { return nil }, | |||
| 24 | - | wantCursor: CursorSnapshot{}, | |||
| 25 | - | wantModes: ModeSnapshot{}, | |||
| 26 | - | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |||
| 27 | - | require.Nil(t, snapshot.Row(0)) | |||
| 28 | - | require.Nil(t, snapshot.BorrowedRow(0)) | |||
| 29 | - | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |||
| 30 | - | }, | |||
| 31 | - | }, | |||
| 32 | - | { | |||
| 33 | - | name: "normal viewport captures cursor modes and title", | |||
| 34 | - | screen: func() *Screen { | |||
| 35 | - | screen := NewScreen(6, 3) | |||
| 36 | - | screen.Write([]byte("\x1b]2;editor\x07\x1b[2;3H\x1b[?25l\x1b[2 q\x1b[?2004h\x1b[?2026h\x1b[?2031h\x1b[?1002h\x1b[?1006h")) | |||
| 37 | - | return screen | |||
| 38 | - | }, | |||
| 39 | - | wantColumns: 6, | |||
| 40 | - | wantRows: 3, | |||
| 41 | - | wantTitle: "editor", | |||
| 42 | - | wantCursor: CursorSnapshot{Row: 1, Col: 2, Visible: false, Style: 2, StyleSet: true}, | |||
| 43 | - | wantModes: ModeSnapshot{ | |||
| 44 | - | BracketedPaste: true, | |||
| 45 | - | SynchronizedUpdate: true, | |||
| 46 | - | ColorSchemeMode: true, | |||
| 47 | - | MouseTracking: 1002, | |||
| 48 | - | MouseSGR: true, | |||
| 49 | - | }, | |||
| 50 | - | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |||
| 51 | - | require.Len(t, snapshot.BorrowedRow(0), 6) | |||
| 52 | - | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |||
| 53 | - | }, | |||
| 54 | - | }, | |||
| 55 | - | { | |||
| 56 | - | name: "zero by zero preserves screen metadata", | |||
| 57 | - | screen: func() *Screen { | |||
| 58 | - | screen := NewScreen(2, 2) | |||
| 59 | - | screen.Write([]byte("\x1b]2;collapsed\x07\x1b[?2004h")) | |||
| 60 | - | screen.Resize(0, 0) | |||
| 61 | - | return screen | |||
| 62 | - | }, | |||
| 63 | - | wantTitle: "collapsed", | |||
| 64 | - | wantCursor: CursorSnapshot{Visible: true}, | |||
| 65 | - | wantModes: ModeSnapshot{BracketedPaste: true}, | |||
| 66 | - | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |||
| 67 | - | require.Nil(t, snapshot.Row(0)) | |||
| 68 | - | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |||
| 69 | - | }, | |||
| 70 | - | }, | |||
| 71 | - | { | |||
| 72 | - | name: "zero columns retains empty physical rows and bounds", | |||
| 73 | - | screen: func() *Screen { | |||
| 74 | - | screen := NewScreen(2, 2) | |||
| 75 | - | screen.Write([]byte("\x1b]2;zero-columns\x07")) | |||
| 76 | - | screen.Resize(0, 2) | |||
| 77 | - | return screen | |||
| 78 | - | }, | |||
| 79 | - | wantRows: 2, | |||
| 80 | - | wantTitle: "zero-columns", | |||
| 81 | - | wantCursor: CursorSnapshot{Visible: true}, | |||
| 82 | - | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |||
| 83 | - | require.NotNil(t, snapshot.BorrowedRow(0)) | |||
| 84 | - | require.Empty(t, snapshot.BorrowedRow(0)) | |||
| 85 | - | require.Nil(t, snapshot.Row(0)) | |||
| 86 | - | require.Empty(t, snapshot.Row(1)) | |||
| 87 | - | require.Equal(t, LineBound{}, snapshot.Bound(1)) | |||
| 88 | - | require.Nil(t, snapshot.Row(2)) | |||
| 89 | - | }, | |||
| 90 | - | }, | |||
| 91 | - | { | |||
| 92 | - | name: "zero rows retains columns without rows", | |||
| 93 | - | screen: func() *Screen { | |||
| 94 | - | screen := NewScreen(2, 2) | |||
| 95 | - | screen.Write([]byte("\x1b]2;zero-rows\x07")) | |||
| 96 | - | screen.Resize(2, 0) | |||
| 97 | - | return screen | |||
| 98 | - | }, | |||
| 99 | - | wantColumns: 2, | |||
| 100 | - | wantTitle: "zero-rows", | |||
| 101 | - | wantCursor: CursorSnapshot{Visible: true}, | |||
| 102 | - | checkRows: func(t *testing.T, snapshot ScreenSnapshot) { | |||
| 103 | - | require.Nil(t, snapshot.BorrowedRow(0)) | |||
| 104 | - | require.Equal(t, LineBound{}, snapshot.Bound(0)) | |||
| 105 | - | }, | |||
| 106 | - | }, | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | for _, test := range tests { | |||
| 110 | - | t.Run(test.name, func(t *testing.T) { | |||
| 111 | - | snapshot := test.screen().Snapshot() | |||
| 112 | - | require.Equal(t, test.wantColumns, snapshot.Columns()) | |||
| 113 | - | require.Equal(t, test.wantRows, snapshot.Rows()) | |||
| 114 | - | require.Equal(t, test.wantTitle, snapshot.Title()) | |||
| 115 | - | require.Equal(t, test.wantCursor, snapshot.Cursor()) | |||
| 116 | - | require.Equal(t, test.wantModes, snapshot.Modes()) | |||
| 117 | - | test.checkRows(t, snapshot) | |||
| 118 | - | }) | |||
| 119 | - | } | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | func TestScreenSnapshotColorSchemeModeExcludesHostPreference(t *testing.T) { | |||
| 123 | - | tests := []struct { | |||
| 124 | - | name string | |||
| 125 | - | run func(*Screen) | |||
| 126 | - | want bool | |||
| 127 | - | }{ | |||
| 128 | - | { | |||
| 129 | - | name: "host preference alone is not DEC 2031 mode", | |||
| 130 | - | run: func(screen *Screen) { screen.SetColorScheme(true) }, | |||
| 131 | - | want: false, | |||
| 132 | - | }, | |||
| 133 | - | { | |||
| 134 | - | name: "DEC 2031 mode is captured independently", | |||
| 135 | - | run: func(screen *Screen) { | |||
| 136 | - | screen.SetColorScheme(false) | |||
| 137 | - | screen.Write([]byte("\x1b[?2031h")) | |||
| 138 | - | }, | |||
| 139 | - | want: true, | |||
| 140 | - | }, | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | for _, test := range tests { | |||
| 144 | - | t.Run(test.name, func(t *testing.T) { | |||
| 145 | - | screen := NewScreen(4, 2) | |||
| 146 | - | test.run(screen) | |||
| 147 | - | require.Equal(t, test.want, screen.Snapshot().Modes().ColorSchemeMode) | |||
| 148 | - | }) | |||
| 149 | - | } | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | func TestScreenSnapshotTitle(t *testing.T) { | |||
| 153 | - | tests := []struct { | |||
| 154 | - | name string | |||
| 155 | - | feed string | |||
| 156 | - | want string | |||
| 157 | - | }{ | |||
| 158 | - | {name: "empty title", want: ""}, | |||
| 159 | - | {name: "latest title replaces the previous value", feed: "\x1b]2;first\x07\x1b]0;second\x07", want: "second"}, | |||
| 160 | - | {name: "UTF-8 title is preserved", feed: "\x1b]2;éditeur 界\x07", want: "éditeur 界"}, | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | for _, test := range tests { | |||
| 164 | - | t.Run(test.name, func(t *testing.T) { | |||
| 165 | - | screen := NewScreen(8, 2) | |||
| 166 | - | screen.Write([]byte(test.feed)) | |||
| 167 | - | require.Equal(t, test.want, screen.Snapshot().Title()) | |||
| 168 | - | }) | |||
| 169 | - | } | |||
| 170 | - | } | |||
| 171 | - | ||||
| 172 | - | func TestScreenSnapshotFidelity(t *testing.T) { | |||
| 173 | - | tests := []struct { | |||
| 174 | - | name string | |||
| 175 | - | make func() (*Screen, []renderer.Cell, []LineBound) | |||
| 176 | - | wantModes ModeSnapshot | |||
| 177 | - | }{ | |||
| 178 | - | { | |||
| 179 | - | name: "styles wide continuation and bounds are exact", | |||
| 180 | - | make: func() (*Screen, []renderer.Cell, []LineBound) { | |||
| 181 | - | screen := NewScreen(4, 2) | |||
| 182 | - | style := renderer.Style{ | |||
| 183 | - | Bold: true, Italic: true, Inverse: true, | |||
| 184 | - | Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough, | |||
| 185 | - | HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3}, | |||
| 186 | - | Background: 17, | |||
| 187 | - | UnderlineStyle: renderer.UnderlineCurly, | |||
| 188 | - | HasUnderlineColorRGB: true, UnderlineColorRGB: renderer.RGB{R: 4, G: 5, B: 6}, | |||
| 189 | - | } | |||
| 190 | - | cells := []renderer.Cell{ | |||
| 191 | - | {Rune: '界', Style: style}, | |||
| 192 | - | {Continuation: true, Style: style}, | |||
| 193 | - | {Rune: 'x', Style: renderer.DefaultStyle()}, | |||
| 194 | - | renderer.BlankCell(), | |||
| 195 | - | } | |||
| 196 | - | copy(screen.Frame.Row(0), cells) | |||
| 197 | - | screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true} | |||
| 198 | - | return screen, cells, []LineBound{{End: 3, Soft: true}, {}} | |||
| 199 | - | }, | |||
| 200 | - | }, | |||
| 201 | - | { | |||
| 202 | - | name: "active alternate screen is captured", | |||
| 203 | - | make: func() (*Screen, []renderer.Cell, []LineBound) { | |||
| 204 | - | screen := NewScreen(3, 1) | |||
| 205 | - | screen.Write([]byte("primary\x1b[?1049halt")) | |||
| 206 | - | cells := append([]renderer.Cell(nil), screen.Frame.Row(0)...) | |||
| 207 | - | return screen, cells, screen.LineBounds() | |||
| 208 | - | }, | |||
| 209 | - | wantModes: ModeSnapshot{AlternateScreen: true}, | |||
| 210 | - | }, | |||
| 211 | - | } | |||
| 212 | - | ||||
| 213 | - | for _, test := range tests { | |||
| 214 | - | t.Run(test.name, func(t *testing.T) { | |||
| 215 | - | screen, wantCells, wantBounds := test.make() | |||
| 216 | - | snapshot := screen.Snapshot() | |||
| 217 | - | require.Equal(t, screen.Frame.Width, snapshot.Columns()) | |||
| 218 | - | require.Equal(t, screen.Frame.Height, snapshot.Rows()) | |||
| 219 | - | require.Equal(t, wantCells, snapshot.Row(0)) | |||
| 220 | - | require.Equal(t, test.wantModes, snapshot.Modes()) | |||
| 221 | - | for row, want := range wantBounds { | |||
| 222 | - | require.Equal(t, want, snapshot.Bound(row)) | |||
| 223 | - | } | |||
| 224 | - | }) | |||
| 225 | - | } | |||
| 226 | - | } | |||
| 227 | - | ||||
| 228 | - | func TestScreenSnapshotPreservesLogicalRowsAfterScroll(t *testing.T) { | |||
| 229 | - | screen := NewScreen(3, 2) | |||
| 230 | - | screen.Write([]byte("ABC")) | |||
| 231 | - | screen.Write([]byte("DEF")) | |||
| 232 | - | screen.Write([]byte("G")) | |||
| 233 | - | ||||
| 234 | - | snapshot := screen.Snapshot() | |||
| 235 | - | for y, want := range []string{"DEF", "G "} { | |||
| 236 | - | require.Equal(t, want, rowText(snapshot.Row(y))) | |||
| 237 | - | require.Equal(t, screen.Frame.Row(y), snapshot.Row(y)) | |||
| 238 | - | require.Equal(t, screen.LineBounds()[y], snapshot.Bound(y)) | |||
| 239 | - | } | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | func TestScreenSnapshotOwnership(t *testing.T) { | |||
| 243 | - | tests := []struct { | |||
| 244 | - | name string | |||
| 245 | - | mutate func(*Screen) | |||
| 246 | - | }{ | |||
| 247 | - | {name: "later write", mutate: func(screen *Screen) { screen.Write([]byte("later")) }}, | |||
| 248 | - | {name: "later scroll", mutate: func(screen *Screen) { screen.Write([]byte("1111222233334444")) }}, | |||
| 249 | - | {name: "later resize", mutate: func(screen *Screen) { screen.Resize(2, 4) }}, | |||
| 250 | - | {name: "later alternate entry", mutate: func(screen *Screen) { screen.Write([]byte("\x1b[?1049halt")) }}, | |||
| 251 | - | {name: "later alternate entry and exit", mutate: func(screen *Screen) { screen.Write([]byte("\x1b[?1049halt\x1b[?1049l")) }}, | |||
| 252 | - | } | |||
| 253 | - | ||||
| 254 | - | for _, test := range tests { | |||
| 255 | - | t.Run(test.name, func(t *testing.T) { | |||
| 256 | - | screen := NewScreen(4, 2) | |||
| 257 | - | screen.Write([]byte("abcdEF")) | |||
| 258 | - | screen.buffer.boundaries[0] = LineBound{End: 4, Soft: true} | |||
| 259 | - | snapshot := screen.Snapshot() | |||
| 260 | - | wantRows := [][]renderer.Cell{snapshot.Row(0), snapshot.Row(1)} | |||
| 261 | - | wantBounds := []LineBound{snapshot.Bound(0), snapshot.Bound(1)} | |||
| 262 | - | ||||
| 263 | - | test.mutate(screen) | |||
| 264 | - | ||||
| 265 | - | require.Equal(t, wantRows[0], snapshot.Row(0)) | |||
| 266 | - | require.Equal(t, wantRows[1], snapshot.Row(1)) | |||
| 267 | - | require.Equal(t, wantBounds[0], snapshot.Bound(0)) | |||
| 268 | - | require.Equal(t, wantBounds[1], snapshot.Bound(1)) | |||
| 269 | - | ||||
| 270 | - | liveBeforeCallerMutation := append([]renderer.Cell(nil), screen.Frame.Row(0)...) | |||
| 271 | - | secondSnapshot := screen.Snapshot() | |||
| 272 | - | secondSnapshotBefore := secondSnapshot.Row(0) | |||
| 273 | - | ||||
| 274 | - | callerCopy := snapshot.Row(0) | |||
| 275 | - | callerCopy[0] = renderer.Cell{Rune: 'z'} | |||
| 276 | - | ||||
| 277 | - | require.Equal(t, wantRows[0], snapshot.BorrowedRow(0)) | |||
| 278 | - | require.Equal(t, liveBeforeCallerMutation, screen.Frame.Row(0), "mutating Row copy must not change live Screen") | |||
| 279 | - | require.Equal(t, secondSnapshotBefore, secondSnapshot.Row(0), "mutating Row copy must not change another snapshot") | |||
| 280 | - | }) | |||
| 281 | - | } | |||
| 282 | - | } | |||
| 283 | - | ||||
| 284 | - | func TestScreenSnapshotBoundsAreIndependent(t *testing.T) { | |||
| 285 | - | tests := []struct { | |||
| 286 | - | name string | |||
| 287 | - | mutate func(*Screen) | |||
| 288 | - | }{ | |||
| 289 | - | { | |||
| 290 | - | name: "mutating a single boundary entry after the snapshot", | |||
| 291 | - | mutate: func(screen *Screen) { | |||
| 292 | - | screen.buffer.boundaries[0] = LineBound{End: 1, Soft: false} | |||
| 293 | - | }, | |||
| 294 | - | }, | |||
| 295 | - | { | |||
| 296 | - | name: "replacing the whole boundaries slice after the snapshot", | |||
| 297 | - | mutate: func(screen *Screen) { | |||
| 298 | - | screen.buffer.boundaries = []LineBound{{End: 1, Soft: false}, {End: 1, Soft: false}} | |||
| 299 | - | }, | |||
| 300 | - | }, | |||
| 301 | - | } | |||
| 302 | - | ||||
| 303 | - | for _, test := range tests { | |||
| 304 | - | t.Run(test.name, func(t *testing.T) { | |||
| 305 | - | screen := NewScreen(4, 2) | |||
| 306 | - | screen.Write([]byte("abcdEF")) | |||
| 307 | - | screen.buffer.boundaries[0] = LineBound{End: 4, Soft: true} | |||
| 308 | - | snapshot := screen.Snapshot() | |||
| 309 | - | want := snapshot.Bound(0) | |||
| 310 | - | ||||
| 311 | - | test.mutate(screen) | |||
| 312 | - | ||||
| 313 | - | require.Equal(t, want, snapshot.Bound(0), "snapshot bounds must not alias Screen.buffer.boundaries") | |||
| 314 | - | }) | |||
| 315 | - | } | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | - | func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) { | |||
| 319 | - | tests := []struct { | |||
| 320 | - | name string | |||
| 321 | - | make func() *Screen | |||
| 322 | - | }{ | |||
| 323 | - | { | |||
| 324 | - | name: "screen without history", | |||
| 325 | - | make: func() *Screen { | |||
| 326 | - | screen := NewScreen(4, 2) | |||
| 327 | - | screen.ClearDamage() | |||
| 328 | - | screen.Write([]byte("abcdef")) | |||
| 329 | - | return screen | |||
| 330 | - | }, | |||
| 331 | - | }, | |||
| 332 | - | { | |||
| 333 | - | name: "screen with sealed chunks and mutable tail", | |||
| 334 | - | make: func() *Screen { | |||
| 335 | - | screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 64, ChunkRows: 2}) | |||
| 336 | - | screen.Write([]byte("111122223333444455")) | |||
| 337 | - | return screen | |||
| 338 | - | }, | |||
| 339 | - | }, | |||
| 340 | - | } | |||
| 341 | - | ||||
| 342 | - | for _, test := range tests { | |||
| 343 | - | t.Run(test.name, func(t *testing.T) { | |||
| 344 | - | screen := test.make() | |||
| 345 | - | beforeDamage := screen.CaptureDamage() | |||
| 346 | - | var beforeHistory HistorySnapshotView | |||
| 347 | - | var beforeRows [][]renderer.Cell | |||
| 348 | - | var beforeBounds []LineBound | |||
| 349 | - | beforeCap, beforeCellCap := 0, 0 | |||
| 350 | - | if screen.History() != nil { | |||
| 351 | - | beforeHistory = screen.History().SnapshotView() | |||
| 352 | - | beforeView := screen.History().View() | |||
| 353 | - | beforeRows = make([][]renderer.Cell, beforeView.Len()) | |||
| 354 | - | beforeBounds = make([]LineBound, beforeView.Len()) | |||
| 355 | - | for i := range beforeRows { | |||
| 356 | - | beforeRows[i] = beforeView.Row(i) | |||
| 357 | - | beforeBounds[i] = beforeView.Bound(i) | |||
| 358 | - | } | |||
| 359 | - | beforeCap = screen.History().Cap() | |||
| 360 | - | beforeCellCap = screen.History().CellCap() | |||
| 361 | - | } | |||
| 362 | - | ||||
| 363 | - | _ = screen.Snapshot() | |||
| 364 | - | ||||
| 365 | - | afterDamage := screen.CaptureDamage() | |||
| 366 | - | require.Equal(t, beforeDamage, afterDamage) | |||
| 367 | - | if screen.History() != nil { | |||
| 368 | - | afterHistory := screen.History().SnapshotView() | |||
| 369 | - | afterView := screen.History().View() | |||
| 370 | - | afterRows := make([][]renderer.Cell, afterView.Len()) | |||
| 371 | - | afterBounds := make([]LineBound, afterView.Len()) | |||
| 372 | - | for i := range afterRows { | |||
| 373 | - | afterRows[i] = afterView.Row(i) | |||
| 374 | - | afterBounds[i] = afterView.Bound(i) | |||
| 375 | - | } | |||
| 376 | - | require.Equal(t, beforeHistory.ChunkCount(), afterHistory.ChunkCount()) | |||
| 377 | - | require.Equal(t, beforeHistory.Len(), afterHistory.Len()) | |||
| 378 | - | require.Equal(t, beforeHistory.Cells(), afterHistory.Cells()) | |||
| 379 | - | require.Equal(t, beforeHistory.Tail().Len(), afterHistory.Tail().Len()) | |||
| 380 | - | require.Equal(t, beforeRows, afterRows) | |||
| 381 | - | require.Equal(t, beforeBounds, afterBounds) | |||
| 382 | - | require.Equal(t, beforeCap, screen.History().Cap()) | |||
| 383 | - | require.Equal(t, beforeCellCap, screen.History().CellCap()) | |||
| 384 | - | } | |||
| 385 | - | }) | |||
| 386 | - | } | |||
| 387 | - | } | |||
| 388 | - | ||||
| 389 | - | func TestScreenSnapshotPreservesStaleDamageAcknowledge(t *testing.T) { | |||
| 390 | - | tests := []struct { | |||
| 391 | - | name string | |||
| 392 | - | mutate func(*Screen) | |||
| 393 | - | }{ | |||
| 394 | - | {name: "write after capture", mutate: func(screen *Screen) { screen.Write([]byte("b")) }}, | |||
| 395 | - | {name: "resize after capture", mutate: func(screen *Screen) { screen.Resize(6, 2) }}, | |||
| 396 | - | } | |||
| 397 | - | ||||
| 398 | - | for _, test := range tests { | |||
| 399 | - | t.Run(test.name, func(t *testing.T) { | |||
| 400 | - | screen := NewScreen(4, 2) | |||
| 401 | - | screen.ClearDamage() | |||
| 402 | - | screen.Write([]byte("a")) | |||
| 403 | - | captured := screen.CaptureDamage() | |||
| 404 | - | ||||
| 405 | - | _ = screen.Snapshot() | |||
| 406 | - | test.mutate(screen) | |||
| 407 | - | ||||
| 408 | - | require.False(t, screen.AcknowledgeDamage(captured.Generation)) | |||
| 409 | - | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage()) | |||
| 410 | - | ||||
| 411 | - | screen.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}) | |||
| 412 | - | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage(), "full redraw stays sticky until exact acknowledgement") | |||
| 413 | - | }) | |||
| 414 | - | } | |||
| 415 | - | } | |||
| 416 | - | ||||
| 417 | - | func TestScreenSnapshotPreservesExactDamageAcknowledge(t *testing.T) { | |||
| 418 | - | tests := []struct { | |||
| 419 | - | name string | |||
| 420 | - | }{ | |||
| 421 | - | {name: "snapshot does not stale an exact damage capture"}, | |||
| 422 | - | } | |||
| 423 | - | ||||
| 424 | - | for _, test := range tests { | |||
| 425 | - | t.Run(test.name, func(t *testing.T) { | |||
| 426 | - | screen := NewScreen(4, 2) | |||
| 427 | - | screen.ClearDamage() | |||
| 428 | - | screen.Write([]byte("a")) | |||
| 429 | - | captured := screen.CaptureDamage() | |||
| 430 | - | ||||
| 431 | - | _ = screen.Snapshot() | |||
| 432 | - | ||||
| 433 | - | require.True(t, screen.AcknowledgeDamage(captured.Generation)) | |||
| 434 | - | require.Empty(t, screen.Damage()) | |||
| 435 | - | }) | |||
| 436 | - | } | |||
| 437 | - | } | |||
D pkg/vt/screen_write.go +0 -250
| @@ -1,250 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | - | ||||
| 5 | - | func (s *Screen) putRune(r rune) { | |||
| 6 | - | switch r { | |||
| 7 | - | case '\a': | |||
| 8 | - | if s.OnBell != nil { | |||
| 9 | - | s.OnBell() | |||
| 10 | - | } | |||
| 11 | - | return | |||
| 12 | - | case 0x00, 0x0e, 0x0f, 0x7f: | |||
| 13 | - | return | |||
| 14 | - | case '\r': | |||
| 15 | - | s.Col = 0 | |||
| 16 | - | case '\n', '\v', '\f': | |||
| 17 | - | if s.Frame.Width > 0 && s.Col >= s.Frame.Width { | |||
| 18 | - | s.Col = s.Frame.Width - 1 | |||
| 19 | - | } | |||
| 20 | - | s.buffer.hard(s.Row) | |||
| 21 | - | s.index() | |||
| 22 | - | case '\b': | |||
| 23 | - | if s.Col > 0 { | |||
| 24 | - | s.Col-- | |||
| 25 | - | } | |||
| 26 | - | case '\t': | |||
| 27 | - | // HT moves to the next tab stop without modifying the frame. Clamp at | |||
| 28 | - | // the right edge rather than triggering deferred wrap or a scroll. | |||
| 29 | - | if s.Frame.Width > 0 { | |||
| 30 | - | s.Col = min(s.Col+8-s.Col%8, s.Frame.Width-1) | |||
| 31 | - | } | |||
| 32 | - | default: | |||
| 33 | - | // Drop C1 control characters (U+0080-U+009F). | |||
| 34 | - | if r >= 0x80 && r <= 0x9F { | |||
| 35 | - | return | |||
| 36 | - | } | |||
| 37 | - | if r >= 0x20 { | |||
| 38 | - | s.putPrintable(r) | |||
| 39 | - | } | |||
| 40 | - | } | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | // relinkWrap restores the soft link a deferred wrap just set when index() | |||
| 44 | - | // scrolled the row out from under it. buffer.scrollUp severs the last moved | |||
| 45 | - | // row on purpose — a linefeed there really does stop preceding anything — but | |||
| 46 | - | // a wrap is the one case where that row flowed into the row below it, so the | |||
| 47 | - | // link has to be put back after the rotation moved it to s.Row-1. movedUp is | |||
| 48 | - | // false when nothing survived the scroll, and then there is no row to link. | |||
| 49 | - | func (s *Screen) relinkWrap(movedUp bool) { | |||
| 50 | - | if movedUp { | |||
| 51 | - | s.buffer.continueRow(s.Row - 1) | |||
| 52 | - | } | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | func (s *Screen) putPrintable(r rune) { | |||
| 56 | - | w := renderer.RuneWidth(r) | |||
| 57 | - | // Skip combining marks and zero-width characters. | |||
| 58 | - | if w == 0 { | |||
| 59 | - | return | |||
| 60 | - | } | |||
| 61 | - | if s.Frame.Width == 0 || s.Frame.Height == 0 { | |||
| 62 | - | return | |||
| 63 | - | } | |||
| 64 | - | // Deferred wrap: cursor sits past the last column. | |||
| 65 | - | if s.Col >= s.Frame.Width { | |||
| 66 | - | s.buffer.soft(s.Row) | |||
| 67 | - | s.Col = 0 | |||
| 68 | - | s.relinkWrap(s.index()) | |||
| 69 | - | } | |||
| 70 | - | if s.Row >= s.Frame.Height { | |||
| 71 | - | s.Row = s.Frame.Height - 1 | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | // A wide rune must never straddle the right edge. If it does not fit on the | |||
| 75 | - | // current line, clear the abandoned last cell and wrap to the next line. | |||
| 76 | - | if w == 2 && s.Col+1 >= s.Frame.Width { | |||
| 77 | - | if s.Frame.Width < 2 { | |||
| 78 | - | // The screen is too narrow to ever hold a wide rune; store a narrow | |||
| 79 | - | // replacement so the cell's renderer width matches its layout. | |||
| 80 | - | r = '\uFFFD' | |||
| 81 | - | w = renderer.RuneWidth(r) | |||
| 82 | - | } else { | |||
| 83 | - | // The abandoned cell may itself be the continuation of a pair whose | |||
| 84 | - | // left half sits one column back; report damage over the full span. | |||
| 85 | - | cx := s.Col | |||
| 86 | - | if s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation { | |||
| 87 | - | cx = s.Col - 1 | |||
| 88 | - | } | |||
| 89 | - | s.clearWidePairAt(s.Col, s.Row) | |||
| 90 | - | s.Frame.Set(s.Col, s.Row, renderer.BlankCell()) | |||
| 91 | - | s.buffer.truncate(s.Row, cx) | |||
| 92 | - | s.buffer.continueRow(s.Row) | |||
| 93 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1}) | |||
| 94 | - | s.Col = 0 | |||
| 95 | - | s.relinkWrap(s.index()) | |||
| 96 | - | if s.Row >= s.Frame.Height { | |||
| 97 | - | s.Row = s.Frame.Height - 1 | |||
| 98 | - | } | |||
| 99 | - | } | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | insertDamageX := s.Col | |||
| 103 | - | insertDamageWidth := 0 | |||
| 104 | - | if s.insertMode { | |||
| 105 | - | row := s.Frame.Row(s.Row) | |||
| 106 | - | leftSplit := s.Col > 0 && row[s.Col].Continuation | |||
| 107 | - | for x := s.Frame.Width - 1; x >= s.Col+w; x-- { | |||
| 108 | - | row[x] = row[x-w] | |||
| 109 | - | } | |||
| 110 | - | for x := s.Col; x < s.Col+w; x++ { | |||
| 111 | - | row[x] = renderer.BlankCell() | |||
| 112 | - | } | |||
| 113 | - | s.repairRow(s.Row) | |||
| 114 | - | s.buffer.insert(s.Row, s.Col, w) | |||
| 115 | - | if leftSplit { | |||
| 116 | - | insertDamageX = s.Col - 1 | |||
| 117 | - | } | |||
| 118 | - | insertDamageWidth = s.Frame.Width - insertDamageX | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | // Determine the range of cells actually modified, extending over any wide | |||
| 122 | - | // pair the write lands on so no orphaned half is left behind. | |||
| 123 | - | lo, hi := s.Col, s.Col+w-1 | |||
| 124 | - | if s.Frame.At(s.Col, s.Row).Continuation { | |||
| 125 | - | lo = s.Col - 1 | |||
| 126 | - | } | |||
| 127 | - | if right := s.Col + w; right < s.Frame.Width && s.Frame.At(right, s.Row).Continuation { | |||
| 128 | - | hi = right | |||
| 129 | - | } | |||
| 130 | - | for x := lo; x <= hi; x++ { | |||
| 131 | - | s.Frame.Set(x, s.Row, renderer.BlankCell()) | |||
| 132 | - | } | |||
| 133 | - | s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style}) | |||
| 134 | - | s.buffer.content(s.Row, s.Col+w) | |||
| 135 | - | if w == 2 { | |||
| 136 | - | s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style}) | |||
| 137 | - | } | |||
| 138 | - | if insertDamageWidth > 0 { | |||
| 139 | - | lo = min(lo, insertDamageX) | |||
| 140 | - | hi = max(hi, insertDamageX+insertDamageWidth-1) | |||
| 141 | - | } | |||
| 142 | - | s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1}) | |||
| 143 | - | s.Col += w | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | // clearWidePairAt blanks both halves of a wide-character pair when the cell at | |||
| 147 | - | // (x,y) is either the left (wide) half or the right (continuation) half. It is | |||
| 148 | - | // O(1) and assumes the pair invariant (a continuation is preceded by its wide | |||
| 149 | - | // left half) holds — which the writer maintains. | |||
| 150 | - | ||||
| 151 | - | func (s *Screen) clearWidePairAt(x, y int) { | |||
| 152 | - | if x < 0 || x >= s.Frame.Width || y < 0 || y >= s.Frame.Height { | |||
| 153 | - | return | |||
| 154 | - | } | |||
| 155 | - | if s.Frame.At(x, y).Continuation { | |||
| 156 | - | if x-1 >= 0 { | |||
| 157 | - | s.Frame.Set(x-1, y, renderer.BlankCell()) | |||
| 158 | - | } | |||
| 159 | - | s.Frame.Set(x, y, renderer.BlankCell()) | |||
| 160 | - | return | |||
| 161 | - | } | |||
| 162 | - | if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation { | |||
| 163 | - | s.Frame.Set(x, y, renderer.BlankCell()) | |||
| 164 | - | s.Frame.Set(x+1, y, renderer.BlankCell()) | |||
| 165 | - | } | |||
| 166 | - | } | |||
| 167 | - | ||||
| 168 | - | func (s *Screen) eraseCell() renderer.Cell { | |||
| 169 | - | style := renderer.DefaultStyle() | |||
| 170 | - | if s.Style.HasBackgroundRGB { | |||
| 171 | - | style.HasBackgroundRGB = true | |||
| 172 | - | style.BackgroundRGB = s.Style.BackgroundRGB | |||
| 173 | - | } else { | |||
| 174 | - | style.Background = s.Style.Background | |||
| 175 | - | } | |||
| 176 | - | return renderer.Cell{Rune: ' ', Style: style} | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | // clearRow blanks cells [x0,x1) on row y, extending the range to swallow either | |||
| 180 | - | // half of a wide pair that straddles a boundary so no orphan half is left. It | |||
| 181 | - | // returns the actual modified span [start, start+width). | |||
| 182 | - | ||||
| 183 | - | func (s *Screen) clearRow(y, x0, x1 int) (start, width int) { | |||
| 184 | - | if y < 0 || y >= s.Frame.Height { | |||
| 185 | - | return x0, 0 | |||
| 186 | - | } | |||
| 187 | - | if x0 < 0 { | |||
| 188 | - | x0 = 0 | |||
| 189 | - | } | |||
| 190 | - | if x1 > s.Frame.Width { | |||
| 191 | - | x1 = s.Frame.Width | |||
| 192 | - | } | |||
| 193 | - | if x0 >= x1 { | |||
| 194 | - | return x0, 0 | |||
| 195 | - | } | |||
| 196 | - | // Left boundary: a continuation at x0 means its wide left half sits at x0-1. | |||
| 197 | - | if x0 > 0 && s.Frame.At(x0, y).Continuation { | |||
| 198 | - | x0-- | |||
| 199 | - | } | |||
| 200 | - | // Right boundary: a continuation at x1 means its wide left half (at x1-1) is | |||
| 201 | - | // inside the range and will be blanked, so swallow the continuation too. | |||
| 202 | - | if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation { | |||
| 203 | - | x1++ | |||
| 204 | - | } | |||
| 205 | - | fullRow := x0 <= 0 && x1 >= s.Frame.Width | |||
| 206 | - | blank := s.eraseCell() | |||
| 207 | - | for x := x0; x < x1; x++ { | |||
| 208 | - | s.Frame.Set(x, y, blank) | |||
| 209 | - | } | |||
| 210 | - | s.buffer.clear(y, x0, x1) | |||
| 211 | - | if fullRow { | |||
| 212 | - | s.buffer.rowIDs[y] = s.nextRowIDValue() | |||
| 213 | - | } | |||
| 214 | - | return x0, x1 - x0 | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | // repairRow blanks any orphaned wide-character halves on row y: a continuation | |||
| 218 | - | // cell not preceded by its wide left half, or a wide rune not followed by its | |||
| 219 | - | // continuation. Used after erase/insert/delete operations that may split a pair | |||
| 220 | - | // at a boundary or by shifting cells. | |||
| 221 | - | ||||
| 222 | - | func (s *Screen) repairRow(y int) { | |||
| 223 | - | repairFrameRow(s.Frame, y) | |||
| 224 | - | } | |||
| 225 | - | ||||
| 226 | - | func repairFrameRow(frame renderer.Frame, y int) { | |||
| 227 | - | if y < 0 || y >= frame.Height { | |||
| 228 | - | return | |||
| 229 | - | } | |||
| 230 | - | w := frame.Width | |||
| 231 | - | for x := range w { | |||
| 232 | - | c := frame.At(x, y) | |||
| 233 | - | if c.Continuation { | |||
| 234 | - | if x == 0 { | |||
| 235 | - | frame.Set(x, y, renderer.BlankCell()) | |||
| 236 | - | continue | |||
| 237 | - | } | |||
| 238 | - | left := frame.At(x-1, y) | |||
| 239 | - | if left.Continuation || renderer.RuneWidth(left.Rune) != 2 { | |||
| 240 | - | frame.Set(x, y, renderer.BlankCell()) | |||
| 241 | - | } | |||
| 242 | - | continue | |||
| 243 | - | } | |||
| 244 | - | if renderer.RuneWidth(c.Rune) == 2 { | |||
| 245 | - | if x+1 >= w || !frame.At(x+1, y).Continuation { | |||
| 246 | - | frame.Set(x, y, renderer.BlankCell()) | |||
| 247 | - | } | |||
| 248 | - | } | |||
| 249 | - | } | |||
| 250 | - | } | |||
D pkg/vt/screen_write_test.go +0 -968
| @@ -1,968 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | "github.com/stretchr/testify/require" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func TestWritePrintableAndUTF8(t *testing.T) { | |||
| 11 | - | tests := []struct { | |||
| 12 | - | name string | |||
| 13 | - | run func(t *testing.T) | |||
| 14 | - | }{ | |||
| 15 | - | { | |||
| 16 | - | name: "printable ASCII advances cursor and records damage", | |||
| 17 | - | run: func(t *testing.T) { | |||
| 18 | - | s := NewScreen(5, 3) | |||
| 19 | - | s.Write([]byte("Hi")) | |||
| 20 | - | ||||
| 21 | - | assertCell(t, s, 0, 0, 'H') | |||
| 22 | - | assertCell(t, s, 1, 0, 'i') | |||
| 23 | - | if s.Col != 2 || s.Row != 0 { | |||
| 24 | - | t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row) | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | // Should have damage. | |||
| 28 | - | d := s.Damage() | |||
| 29 | - | if len(d) == 0 { | |||
| 30 | - | t.Fatal("expected damage after writing") | |||
| 31 | - | } | |||
| 32 | - | }, | |||
| 33 | - | }, | |||
| 34 | - | { | |||
| 35 | - | name: "UTF-8 multi-byte runes occupy one cell each", | |||
| 36 | - | run: func(t *testing.T) { | |||
| 37 | - | s := NewScreen(10, 3) | |||
| 38 | - | s.Write([]byte("aäéø©")) | |||
| 39 | - | ||||
| 40 | - | assertCell(t, s, 0, 0, 'a') | |||
| 41 | - | assertCell(t, s, 1, 0, 'ä') | |||
| 42 | - | assertCell(t, s, 2, 0, 'é') | |||
| 43 | - | assertCell(t, s, 3, 0, 'ø') | |||
| 44 | - | assertCell(t, s, 4, 0, '©') | |||
| 45 | - | }, | |||
| 46 | - | }, | |||
| 47 | - | { | |||
| 48 | - | name: "printable beyond width wraps to next line", | |||
| 49 | - | run: func(t *testing.T) { | |||
| 50 | - | s := NewScreen(4, 2) | |||
| 51 | - | s.Write([]byte("ABCDE")) | |||
| 52 | - | ||||
| 53 | - | assertCell(t, s, 0, 0, 'A') | |||
| 54 | - | assertCell(t, s, 1, 0, 'B') | |||
| 55 | - | assertCell(t, s, 2, 0, 'C') | |||
| 56 | - | assertCell(t, s, 3, 0, 'D') | |||
| 57 | - | // E wrapped to next line. | |||
| 58 | - | assertCell(t, s, 0, 1, 'E') | |||
| 59 | - | }, | |||
| 60 | - | }, | |||
| 61 | - | { | |||
| 62 | - | name: "printable beyond screen height scrolls", | |||
| 63 | - | run: func(t *testing.T) { | |||
| 64 | - | s := NewScreen(3, 2) | |||
| 65 | - | // Fill both lines. | |||
| 66 | - | s.Write([]byte("ABC")) | |||
| 67 | - | s.Write([]byte("DEF")) | |||
| 68 | - | // Row is now 1 (bottom). One more char should scroll. | |||
| 69 | - | s.Write([]byte("G")) | |||
| 70 | - | ||||
| 71 | - | // After scroll: old row 0 = "DEF", new row 1 = "G ". | |||
| 72 | - | assertCell(t, s, 0, 0, 'D') | |||
| 73 | - | assertCell(t, s, 1, 0, 'E') | |||
| 74 | - | assertCell(t, s, 2, 0, 'F') | |||
| 75 | - | assertCell(t, s, 0, 1, 'G') | |||
| 76 | - | assertCell(t, s, 1, 1, ' ') | |||
| 77 | - | ||||
| 78 | - | // Check scroll damage. | |||
| 79 | - | d := s.Damage() | |||
| 80 | - | if !hasDamageKind(d, renderer.DamageScrollUp) { | |||
| 81 | - | t.Error("expected scroll damage") | |||
| 82 | - | } | |||
| 83 | - | }, | |||
| 84 | - | }, | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | for _, tt := range tests { | |||
| 88 | - | t.Run(tt.name, tt.run) | |||
| 89 | - | } | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | func TestCursorControlChars(t *testing.T) { | |||
| 93 | - | tests := []struct { | |||
| 94 | - | name string | |||
| 95 | - | run func(t *testing.T) | |||
| 96 | - | }{ | |||
| 97 | - | { | |||
| 98 | - | name: "CR moves column back to 0", | |||
| 99 | - | run: func(t *testing.T) { | |||
| 100 | - | s := NewScreen(10, 3) | |||
| 101 | - | s.Write([]byte("Hello")) | |||
| 102 | - | // CR moves column back to 0. | |||
| 103 | - | s.Write([]byte("\rWorld")) | |||
| 104 | - | ||||
| 105 | - | assertCell(t, s, 0, 0, 'W') | |||
| 106 | - | assertCell(t, s, 1, 0, 'o') | |||
| 107 | - | assertCell(t, s, 2, 0, 'r') | |||
| 108 | - | assertCell(t, s, 3, 0, 'l') | |||
| 109 | - | assertCell(t, s, 4, 0, 'd') | |||
| 110 | - | if s.Col != 5 { | |||
| 111 | - | t.Errorf("col after CR + World = %d, want 5", s.Col) | |||
| 112 | - | } | |||
| 113 | - | }, | |||
| 114 | - | }, | |||
| 115 | - | { | |||
| 116 | - | name: "LF with CR moves to next line column 0", | |||
| 117 | - | run: func(t *testing.T) { | |||
| 118 | - | s := NewScreen(5, 3) | |||
| 119 | - | s.Write([]byte("A\r\nB\r\nC")) | |||
| 120 | - | ||||
| 121 | - | assertCell(t, s, 0, 0, 'A') | |||
| 122 | - | assertCell(t, s, 0, 1, 'B') | |||
| 123 | - | assertCell(t, s, 0, 2, 'C') | |||
| 124 | - | if s.Row != 2 || s.Col != 1 { | |||
| 125 | - | t.Errorf("cursor at row=%d col=%d, want row=2 col=1", s.Row, s.Col) | |||
| 126 | - | } | |||
| 127 | - | }, | |||
| 128 | - | }, | |||
| 129 | - | { | |||
| 130 | - | name: "LF alone does not reset column", | |||
| 131 | - | run: func(t *testing.T) { | |||
| 132 | - | s := NewScreen(5, 3) | |||
| 133 | - | s.Write([]byte("AB\nC")) | |||
| 134 | - | ||||
| 135 | - | assertCell(t, s, 0, 0, 'A') | |||
| 136 | - | assertCell(t, s, 1, 0, 'B') | |||
| 137 | - | assertCell(t, s, 0, 1, ' ') | |||
| 138 | - | assertCell(t, s, 2, 1, 'C') | |||
| 139 | - | if s.Row != 1 || s.Col != 3 { | |||
| 140 | - | t.Errorf("cursor at row=%d col=%d, want row=1 col=3", s.Row, s.Col) | |||
| 141 | - | } | |||
| 142 | - | }, | |||
| 143 | - | }, | |||
| 144 | - | { | |||
| 145 | - | name: "LF at pending wrap boundary advances exactly one line", | |||
| 146 | - | run: func(t *testing.T) { | |||
| 147 | - | s := NewScreen(5, 4) | |||
| 148 | - | s.Write([]byte("ABCDE\nZ")) | |||
| 149 | - | ||||
| 150 | - | assertCell(t, s, 4, 0, 'E') | |||
| 151 | - | assertCell(t, s, 4, 1, 'Z') | |||
| 152 | - | if s.Row != 1 || s.Col != 5 { | |||
| 153 | - | t.Errorf("cursor at row=%d col=%d, want row=1 col=5", s.Row, s.Col) | |||
| 154 | - | } | |||
| 155 | - | }, | |||
| 156 | - | }, | |||
| 157 | - | { | |||
| 158 | - | name: "LF scrolls when both lines are full", | |||
| 159 | - | run: func(t *testing.T) { | |||
| 160 | - | s := NewScreen(5, 2) | |||
| 161 | - | // Fill both lines then CRLF to scroll and return to column 0. | |||
| 162 | - | s.Write([]byte("AAAAA")) | |||
| 163 | - | s.Write([]byte("BBBBB")) | |||
| 164 | - | s.Write([]byte("\r\nCCCCC")) | |||
| 165 | - | ||||
| 166 | - | assertCell(t, s, 0, 0, 'B') | |||
| 167 | - | assertCell(t, s, 4, 1, 'C') | |||
| 168 | - | }, | |||
| 169 | - | }, | |||
| 170 | - | { | |||
| 171 | - | name: "BS moves cursor back without touching the cell", | |||
| 172 | - | run: func(t *testing.T) { | |||
| 173 | - | s := NewScreen(10, 3) | |||
| 174 | - | s.Write([]byte("Hello\b")) | |||
| 175 | - | ||||
| 176 | - | if s.Col != 4 { | |||
| 177 | - | t.Errorf("col after BS = %d, want 4", s.Col) | |||
| 178 | - | } | |||
| 179 | - | assertCell(t, s, 4, 0, 'o') // cell unchanged, just cursor moved | |||
| 180 | - | }, | |||
| 181 | - | }, | |||
| 182 | - | { | |||
| 183 | - | name: "BS at column zero is a no-op", | |||
| 184 | - | run: func(t *testing.T) { | |||
| 185 | - | s := NewScreen(10, 3) | |||
| 186 | - | s.Write([]byte("\b")) // BS at col 0 should be no-op | |||
| 187 | - | ||||
| 188 | - | if s.Col != 0 { | |||
| 189 | - | t.Errorf("col = %d, want 0", s.Col) | |||
| 190 | - | } | |||
| 191 | - | }, | |||
| 192 | - | }, | |||
| 193 | - | { | |||
| 194 | - | name: "tab moves cursor without changing cells or damage", | |||
| 195 | - | run: func(t *testing.T) { | |||
| 196 | - | s := NewScreen(10, 2) | |||
| 197 | - | for y := range s.Frame.Height { | |||
| 198 | - | for x := range s.Frame.Width { | |||
| 199 | - | s.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.Frame.Width + x)}) | |||
| 200 | - | } | |||
| 201 | - | } | |||
| 202 | - | before := s.Frame.Clone() | |||
| 203 | - | s.Row, s.Col = 1, 6 | |||
| 204 | - | s.ClearDamage() | |||
| 205 | - | ||||
| 206 | - | s.Write([]byte("\t")) | |||
| 207 | - | if s.Row != 1 || s.Col != 8 { | |||
| 208 | - | t.Errorf("cursor after tab = row=%d col=%d, want row=1 col=8", s.Row, s.Col) | |||
| 209 | - | } | |||
| 210 | - | assertFramesEqual(t, s.Frame, before) | |||
| 211 | - | if got := s.Damage(); len(got) != 0 { | |||
| 212 | - | t.Errorf("tab damage = %+v, want none", got) | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | // A tab whose next stop lies past the edge clamps at the last | |||
| 216 | - | // column; it must not wrap or scroll the bottom row. | |||
| 217 | - | s.Write([]byte("\t")) | |||
| 218 | - | if s.Row != 1 || s.Col != 9 { | |||
| 219 | - | t.Errorf("cursor after clamped tab = row=%d col=%d, want row=1 col=9", s.Row, s.Col) | |||
| 220 | - | } | |||
| 221 | - | assertFramesEqual(t, s.Frame, before) | |||
| 222 | - | if got := s.Damage(); len(got) != 0 { | |||
| 223 | - | t.Errorf("clamped tab damage = %+v, want none", got) | |||
| 224 | - | } | |||
| 225 | - | }, | |||
| 226 | - | }, | |||
| 227 | - | } | |||
| 228 | - | ||||
| 229 | - | for _, tt := range tests { | |||
| 230 | - | t.Run(tt.name, tt.run) | |||
| 231 | - | } | |||
| 232 | - | } | |||
| 233 | - | ||||
| 234 | - | func TestInvalidInputIgnored(t *testing.T) { | |||
| 235 | - | tests := []struct { | |||
| 236 | - | name string | |||
| 237 | - | input []byte | |||
| 238 | - | }{ | |||
| 239 | - | { | |||
| 240 | - | name: "invalid UTF-8 continuation byte without start byte", | |||
| 241 | - | input: []byte{0x80, 0x81}, | |||
| 242 | - | }, | |||
| 243 | - | { | |||
| 244 | - | name: "unhandled control characters below 0x20", | |||
| 245 | - | input: []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f}, | |||
| 246 | - | }, | |||
| 247 | - | } | |||
| 248 | - | ||||
| 249 | - | for _, tt := range tests { | |||
| 250 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 251 | - | s := NewScreen(10, 3) | |||
| 252 | - | s.Write(tt.input) | |||
| 253 | - | // Should not panic, and no cells should be modified. | |||
| 254 | - | for y := range 3 { | |||
| 255 | - | for x := range 10 { | |||
| 256 | - | if c := cellAt(s, x, y); c.Rune != ' ' { | |||
| 257 | - | t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune) | |||
| 258 | - | } | |||
| 259 | - | } | |||
| 260 | - | } | |||
| 261 | - | }) | |||
| 262 | - | } | |||
| 263 | - | } | |||
| 264 | - | ||||
| 265 | - | func TestWriteAtEdgeNoPanic(t *testing.T) { | |||
| 266 | - | tests := []struct { | |||
| 267 | - | name string | |||
| 268 | - | }{ | |||
| 269 | - | {name: "writing past a 1x1 screen scrolls instead of panicking"}, | |||
| 270 | - | } | |||
| 271 | - | ||||
| 272 | - | for _, tt := range tests { | |||
| 273 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 274 | - | s := NewScreen(1, 1) | |||
| 275 | - | // Fill the only cell then try to write more (should scroll). | |||
| 276 | - | s.Write([]byte("ABC")) | |||
| 277 | - | // Should not panic. | |||
| 278 | - | _ = cellAt(s, 0, 0) | |||
| 279 | - | }) | |||
| 280 | - | } | |||
| 281 | - | } | |||
| 282 | - | ||||
| 283 | - | func TestStylePersists(t *testing.T) { | |||
| 284 | - | tests := []struct { | |||
| 285 | - | name string | |||
| 286 | - | }{ | |||
| 287 | - | {name: "bold persists across separate Write calls"}, | |||
| 288 | - | } | |||
| 289 | - | ||||
| 290 | - | for _, tt := range tests { | |||
| 291 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 292 | - | s := NewScreen(10, 3) | |||
| 293 | - | s.Write([]byte("\x1b[1mAB")) | |||
| 294 | - | s.Write([]byte("CD")) | |||
| 295 | - | ||||
| 296 | - | c1 := cellAt(s, 0, 0) | |||
| 297 | - | c2 := cellAt(s, 2, 0) | |||
| 298 | - | if !c1.Style.Bold || !c2.Style.Bold { | |||
| 299 | - | t.Error("bold should persist across Write calls") | |||
| 300 | - | } | |||
| 301 | - | }) | |||
| 302 | - | } | |||
| 303 | - | } | |||
| 304 | - | ||||
| 305 | - | func TestWideAndZeroWidthRunes(t *testing.T) { | |||
| 306 | - | tests := []struct { | |||
| 307 | - | name string | |||
| 308 | - | run func(t *testing.T) | |||
| 309 | - | }{ | |||
| 310 | - | { | |||
| 311 | - | name: "CJK writes wide left cell plus continuation", | |||
| 312 | - | run: func(t *testing.T) { | |||
| 313 | - | s := NewScreen(10, 3) | |||
| 314 | - | // Two CJK characters, each width 2: left cell holds the rune, | |||
| 315 | - | // right cell is a continuation marker. | |||
| 316 | - | s.Write([]byte("你好")) | |||
| 317 | - | ||||
| 318 | - | assertCell(t, s, 0, 0, '你') | |||
| 319 | - | assertContinuation(t, s, 1, 0) | |||
| 320 | - | assertCell(t, s, 2, 0, '好') | |||
| 321 | - | assertContinuation(t, s, 3, 0) | |||
| 322 | - | if s.Col != 4 || s.Row != 0 { | |||
| 323 | - | t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row) | |||
| 324 | - | } | |||
| 325 | - | }, | |||
| 326 | - | }, | |||
| 327 | - | { | |||
| 328 | - | name: "CJK mixed with ASCII", | |||
| 329 | - | run: func(t *testing.T) { | |||
| 330 | - | s := NewScreen(10, 3) | |||
| 331 | - | s.Write([]byte("A你B")) | |||
| 332 | - | ||||
| 333 | - | assertCell(t, s, 0, 0, 'A') | |||
| 334 | - | assertCell(t, s, 1, 0, '你') | |||
| 335 | - | assertContinuation(t, s, 2, 0) | |||
| 336 | - | assertCell(t, s, 3, 0, 'B') | |||
| 337 | - | if s.Col != 4 { | |||
| 338 | - | t.Errorf("cursor at col=%d, want 4", s.Col) | |||
| 339 | - | } | |||
| 340 | - | }, | |||
| 341 | - | }, | |||
| 342 | - | { | |||
| 343 | - | name: "emoji writes wide pair", | |||
| 344 | - | run: func(t *testing.T) { | |||
| 345 | - | s := NewScreen(10, 3) | |||
| 346 | - | s.Write([]byte("\U0001f600")) // 😀 grinning face | |||
| 347 | - | ||||
| 348 | - | assertCell(t, s, 0, 0, '\U0001f600') | |||
| 349 | - | assertContinuation(t, s, 1, 0) | |||
| 350 | - | if s.Col != 2 { | |||
| 351 | - | t.Errorf("cursor at col=%d, want 2", s.Col) | |||
| 352 | - | } | |||
| 353 | - | }, | |||
| 354 | - | }, | |||
| 355 | - | { | |||
| 356 | - | name: "combining mark is skipped without advancing cursor", | |||
| 357 | - | run: func(t *testing.T) { | |||
| 358 | - | s := NewScreen(10, 3) | |||
| 359 | - | // 'A' followed by combining acute accent U+0301 | |||
| 360 | - | s.Write([]byte("A\xcc\x81")) | |||
| 361 | - | ||||
| 362 | - | assertCell(t, s, 0, 0, 'A') | |||
| 363 | - | // Combining mark should be skipped — no cell written, no cursor advance. | |||
| 364 | - | if s.Col != 1 { | |||
| 365 | - | t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)", s.Col) | |||
| 366 | - | } | |||
| 367 | - | }, | |||
| 368 | - | }, | |||
| 369 | - | { | |||
| 370 | - | name: "zero-width characters are skipped without advancing cursor", | |||
| 371 | - | run: func(t *testing.T) { | |||
| 372 | - | s := NewScreen(10, 3) | |||
| 373 | - | // Zero-width space U+200B | |||
| 374 | - | s.Write([]byte("A\xe2\x80\x8bB")) | |||
| 375 | - | ||||
| 376 | - | assertCell(t, s, 0, 0, 'A') | |||
| 377 | - | assertCell(t, s, 1, 0, 'B') | |||
| 378 | - | if s.Col != 2 { | |||
| 379 | - | t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)", s.Col) | |||
| 380 | - | } | |||
| 381 | - | }, | |||
| 382 | - | }, | |||
| 383 | - | { | |||
| 384 | - | name: "CJK surrounded by ASCII on both sides", | |||
| 385 | - | run: func(t *testing.T) { | |||
| 386 | - | s := NewScreen(10, 3) | |||
| 387 | - | s.Write([]byte("a你b好c")) | |||
| 388 | - | ||||
| 389 | - | assertCell(t, s, 0, 0, 'a') | |||
| 390 | - | assertCell(t, s, 1, 0, '你') | |||
| 391 | - | assertContinuation(t, s, 2, 0) | |||
| 392 | - | assertCell(t, s, 3, 0, 'b') | |||
| 393 | - | assertCell(t, s, 4, 0, '好') | |||
| 394 | - | assertContinuation(t, s, 5, 0) | |||
| 395 | - | assertCell(t, s, 6, 0, 'c') | |||
| 396 | - | if s.Col != 7 { | |||
| 397 | - | t.Errorf("cursor at col=%d, want 7", s.Col) | |||
| 398 | - | } | |||
| 399 | - | }, | |||
| 400 | - | }, | |||
| 401 | - | { | |||
| 402 | - | name: "wide char at last column wraps to next line, abandoned cell cleared", | |||
| 403 | - | run: func(t *testing.T) { | |||
| 404 | - | s := NewScreen(3, 2) | |||
| 405 | - | // Fill the first two columns; cursor lands on the last column. | |||
| 406 | - | s.Write([]byte("AB")) // cells [A B _], Col=2 | |||
| 407 | - | // A wide char at the last column cannot straddle the edge: it | |||
| 408 | - | // wraps to the next line, clearing the abandoned last cell. | |||
| 409 | - | s.Write([]byte("你")) | |||
| 410 | - | ||||
| 411 | - | assertCell(t, s, 0, 0, 'A') | |||
| 412 | - | assertCell(t, s, 1, 0, 'B') | |||
| 413 | - | assertBlank(t, s, 2, 0) // abandoned last cell cleared | |||
| 414 | - | assertCell(t, s, 0, 1, '你') | |||
| 415 | - | assertContinuation(t, s, 1, 1) | |||
| 416 | - | if s.Col != 2 || s.Row != 1 { | |||
| 417 | - | t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row) | |||
| 418 | - | } | |||
| 419 | - | }, | |||
| 420 | - | }, | |||
| 421 | - | { | |||
| 422 | - | name: "CJK damage width is 2", | |||
| 423 | - | run: func(t *testing.T) { | |||
| 424 | - | s := NewScreen(10, 3) | |||
| 425 | - | s.ClearDamage() | |||
| 426 | - | s.Write([]byte("你")) | |||
| 427 | - | ||||
| 428 | - | d := s.Damage() | |||
| 429 | - | if len(d) == 0 { | |||
| 430 | - | t.Fatal("expected damage") | |||
| 431 | - | } | |||
| 432 | - | if d[0].Width != 2 { | |||
| 433 | - | t.Errorf("damage width = %d, want 2", d[0].Width) | |||
| 434 | - | } | |||
| 435 | - | }, | |||
| 436 | - | }, | |||
| 437 | - | { | |||
| 438 | - | name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned", | |||
| 439 | - | run: func(t *testing.T) { | |||
| 440 | - | s := NewScreen(10, 3) | |||
| 441 | - | // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute. | |||
| 442 | - | s.Write([]byte("a你\U0001f600b\xcc\x81")) | |||
| 443 | - | ||||
| 444 | - | assertCell(t, s, 0, 0, 'a') | |||
| 445 | - | assertCell(t, s, 1, 0, '你') | |||
| 446 | - | assertContinuation(t, s, 2, 0) | |||
| 447 | - | assertCell(t, s, 3, 0, '\U0001f600') | |||
| 448 | - | assertContinuation(t, s, 4, 0) | |||
| 449 | - | assertCell(t, s, 5, 0, 'b') | |||
| 450 | - | // Combining mark skipped, no cell at col 6. | |||
| 451 | - | if s.Col != 6 || s.Row != 0 { | |||
| 452 | - | t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row) | |||
| 453 | - | } | |||
| 454 | - | }, | |||
| 455 | - | }, | |||
| 456 | - | { | |||
| 457 | - | name: "overwrite left half of wide pair with narrow clears both", | |||
| 458 | - | run: func(t *testing.T) { | |||
| 459 | - | s := NewScreen(10, 3) | |||
| 460 | - | s.Write([]byte("你")) // (0)=你 (1)=cont | |||
| 461 | - | s.Write([]byte("\x1b[1;1H")) // cursor home | |||
| 462 | - | s.Write([]byte("X")) // overwrite the wide left half | |||
| 463 | - | ||||
| 464 | - | assertCell(t, s, 0, 0, 'X') | |||
| 465 | - | assertBlank(t, s, 1, 0) // orphaned continuation cleared | |||
| 466 | - | }, | |||
| 467 | - | }, | |||
| 468 | - | { | |||
| 469 | - | name: "overwrite right half (continuation) with narrow clears both", | |||
| 470 | - | run: func(t *testing.T) { | |||
| 471 | - | s := NewScreen(10, 3) | |||
| 472 | - | s.Write([]byte("你")) // (0)=你 (1)=cont | |||
| 473 | - | s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (continuation) | |||
| 474 | - | s.Write([]byte("X")) // overwrite the continuation | |||
| 475 | - | ||||
| 476 | - | assertBlank(t, s, 0, 0) // orphaned wide left cleared | |||
| 477 | - | assertCell(t, s, 1, 0, 'X') | |||
| 478 | - | }, | |||
| 479 | - | }, | |||
| 480 | - | { | |||
| 481 | - | name: "overwrite half of wide pair with a new wide char", | |||
| 482 | - | run: func(t *testing.T) { | |||
| 483 | - | s := NewScreen(10, 3) | |||
| 484 | - | s.Write([]byte("你好")) // (0)你 (1)cont (2)好 (3)cont | |||
| 485 | - | s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (cont of 你) | |||
| 486 | - | s.Write([]byte("学")) // wide write at cols 1,2 | |||
| 487 | - | ||||
| 488 | - | assertBlank(t, s, 0, 0) // 你 left half orphaned → cleared | |||
| 489 | - | assertCell(t, s, 1, 0, '学') | |||
| 490 | - | assertContinuation(t, s, 2, 0) | |||
| 491 | - | assertBlank(t, s, 3, 0) // 好 continuation orphaned → cleared | |||
| 492 | - | }, | |||
| 493 | - | }, | |||
| 494 | - | { | |||
| 495 | - | name: "erase to end of line covering a continuation clears its wide left half", | |||
| 496 | - | run: func(t *testing.T) { | |||
| 497 | - | s := NewScreen(10, 3) | |||
| 498 | - | s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3) | |||
| 499 | - | s.Write([]byte("\x1b[1;3H")) // cursor to col 2 (continuation) | |||
| 500 | - | s.Write([]byte("\x1b[K")) // erase from col 2 to end of line | |||
| 501 | - | ||||
| 502 | - | assertCell(t, s, 0, 0, 'A') | |||
| 503 | - | assertBlank(t, s, 1, 0) // 你 left half orphaned by erase → cleared | |||
| 504 | - | assertBlank(t, s, 2, 0) | |||
| 505 | - | assertBlank(t, s, 3, 0) | |||
| 506 | - | }, | |||
| 507 | - | }, | |||
| 508 | - | { | |||
| 509 | - | name: "erase to start of line covering a wide left clears its continuation", | |||
| 510 | - | run: func(t *testing.T) { | |||
| 511 | - | s := NewScreen(10, 3) | |||
| 512 | - | s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3) | |||
| 513 | - | s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (wide left) | |||
| 514 | - | s.Write([]byte("\x1b[1K")) // erase from start to col 1 inclusive | |||
| 515 | - | ||||
| 516 | - | assertBlank(t, s, 0, 0) | |||
| 517 | - | assertBlank(t, s, 1, 0) | |||
| 518 | - | assertBlank(t, s, 2, 0) // continuation orphaned by erase → cleared | |||
| 519 | - | assertCell(t, s, 3, 0, 'B') | |||
| 520 | - | }, | |||
| 521 | - | }, | |||
| 522 | - | { | |||
| 523 | - | name: "wrap abandoning a continuation cell clears its pair and extends damage", | |||
| 524 | - | run: func(t *testing.T) { | |||
| 525 | - | s := NewScreen(4, 2) | |||
| 526 | - | s.Write([]byte("AB你")) // A(0) B(1) 你(2) cont(3) | |||
| 527 | - | s.Write([]byte("\x1b[1;4H")) // cursor to col 3: the continuation cell | |||
| 528 | - | s.ClearDamage() | |||
| 529 | - | // A wide rune at the last column wraps; the abandoned last cell | |||
| 530 | - | // is a continuation, so its wide left half (col 2) must be | |||
| 531 | - | // cleared too and the damage must span both columns. | |||
| 532 | - | s.Write([]byte("好")) | |||
| 533 | - | ||||
| 534 | - | assertCell(t, s, 0, 0, 'A') | |||
| 535 | - | assertCell(t, s, 1, 0, 'B') | |||
| 536 | - | assertBlank(t, s, 2, 0) // orphaned wide left cleared | |||
| 537 | - | assertBlank(t, s, 3, 0) // abandoned continuation cleared | |||
| 538 | - | assertCell(t, s, 0, 1, '好') | |||
| 539 | - | assertContinuation(t, s, 1, 1) | |||
| 540 | - | if s.Col != 2 || s.Row != 1 { | |||
| 541 | - | t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row) | |||
| 542 | - | } | |||
| 543 | - | ||||
| 544 | - | d := s.Damage() | |||
| 545 | - | if len(d) == 0 { | |||
| 546 | - | t.Fatal("expected damage") | |||
| 547 | - | } | |||
| 548 | - | // First damage item covers the cleared pair on row 0. | |||
| 549 | - | if d[0].X != 2 || d[0].Y != 0 || d[0].Width != 2 { | |||
| 550 | - | t.Errorf("wrap damage = {X:%d Y:%d W:%d}, want {X:2 Y:0 W:2}", d[0].X, d[0].Y, d[0].Width) | |||
| 551 | - | } | |||
| 552 | - | }, | |||
| 553 | - | }, | |||
| 554 | - | { | |||
| 555 | - | name: "insert chars at a continuation splits the pair and repairs orphans", | |||
| 556 | - | run: func(t *testing.T) { | |||
| 557 | - | s := NewScreen(6, 2) | |||
| 558 | - | s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3) | |||
| 559 | - | s.Write([]byte("\x1b[1;4H")) // cursor to col 3: continuation of 好 | |||
| 560 | - | s.ClearDamage() | |||
| 561 | - | s.Write([]byte("\x1b[1@")) // ICH 1: shift right from col 3 | |||
| 562 | - | ||||
| 563 | - | // The shift splits 好/cont: 好 stays at col 2, its continuation | |||
| 564 | - | // moves to col 4. Both orphans must be repaired to blanks. | |||
| 565 | - | assertCell(t, s, 0, 0, '你') | |||
| 566 | - | assertContinuation(t, s, 1, 0) | |||
| 567 | - | assertBlank(t, s, 2, 0) // 好 orphaned by the split → cleared | |||
| 568 | - | assertBlank(t, s, 3, 0) // inserted blank | |||
| 569 | - | assertBlank(t, s, 4, 0) // shifted continuation orphaned → cleared | |||
| 570 | - | assertBlank(t, s, 5, 0) | |||
| 571 | - | ||||
| 572 | - | d := s.Damage() | |||
| 573 | - | if len(d) == 0 { | |||
| 574 | - | t.Fatal("expected damage") | |||
| 575 | - | } | |||
| 576 | - | // Damage must extend one column left to cover the repaired 好. | |||
| 577 | - | if d[0].X != 2 || d[0].Width != 4 { | |||
| 578 | - | t.Errorf("ICH damage = {X:%d W:%d}, want {X:2 W:4}", d[0].X, d[0].Width) | |||
| 579 | - | } | |||
| 580 | - | }, | |||
| 581 | - | }, | |||
| 582 | - | { | |||
| 583 | - | name: "insert chars shifting a wide pair off the right edge repairs the left half", | |||
| 584 | - | run: func(t *testing.T) { | |||
| 585 | - | s := NewScreen(4, 2) | |||
| 586 | - | s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3) | |||
| 587 | - | s.Write([]byte("\x1b[1;1H")) // cursor home | |||
| 588 | - | s.Write([]byte("\x1b[1@")) // ICH 1: shift the row right by 1 | |||
| 589 | - | ||||
| 590 | - | // 好 lands on the last column with its continuation pushed off | |||
| 591 | - | // the edge; the orphaned wide left must be blanked. | |||
| 592 | - | assertBlank(t, s, 0, 0) | |||
| 593 | - | assertCell(t, s, 1, 0, '你') | |||
| 594 | - | assertContinuation(t, s, 2, 0) | |||
| 595 | - | assertBlank(t, s, 3, 0) // 好 lost its continuation → cleared | |||
| 596 | - | }, | |||
| 597 | - | }, | |||
| 598 | - | { | |||
| 599 | - | name: "delete char at the left half of a wide pair repairs the orphan", | |||
| 600 | - | run: func(t *testing.T) { | |||
| 601 | - | s := NewScreen(6, 2) | |||
| 602 | - | s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5) | |||
| 603 | - | s.Write([]byte("\x1b[1;1H")) // cursor to col 0: wide left of 你 | |||
| 604 | - | s.ClearDamage() | |||
| 605 | - | s.Write([]byte("\x1b[1P")) // DCH 1 | |||
| 606 | - | ||||
| 607 | - | // 你's continuation shifts to col 0 with no left half → repaired. | |||
| 608 | - | assertBlank(t, s, 0, 0) | |||
| 609 | - | assertCell(t, s, 1, 0, '好') | |||
| 610 | - | assertContinuation(t, s, 2, 0) | |||
| 611 | - | assertCell(t, s, 3, 0, 'A') | |||
| 612 | - | assertCell(t, s, 4, 0, 'B') | |||
| 613 | - | assertBlank(t, s, 5, 0) | |||
| 614 | - | ||||
| 615 | - | d := s.Damage() | |||
| 616 | - | if len(d) == 0 { | |||
| 617 | - | t.Fatal("expected damage") | |||
| 618 | - | } | |||
| 619 | - | if d[0].X != 0 || d[0].Width != 6 { | |||
| 620 | - | t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width) | |||
| 621 | - | } | |||
| 622 | - | }, | |||
| 623 | - | }, | |||
| 624 | - | { | |||
| 625 | - | name: "delete char at a continuation repairs the wide left and extends damage", | |||
| 626 | - | run: func(t *testing.T) { | |||
| 627 | - | s := NewScreen(6, 2) | |||
| 628 | - | s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5) | |||
| 629 | - | s.Write([]byte("\x1b[1;2H")) // cursor to col 1: continuation of 你 | |||
| 630 | - | s.ClearDamage() | |||
| 631 | - | s.Write([]byte("\x1b[1P")) // DCH 1: delete the continuation | |||
| 632 | - | ||||
| 633 | - | // 你 at col 0 loses its continuation → repaired to blank. | |||
| 634 | - | assertBlank(t, s, 0, 0) | |||
| 635 | - | assertCell(t, s, 1, 0, '好') | |||
| 636 | - | assertContinuation(t, s, 2, 0) | |||
| 637 | - | assertCell(t, s, 3, 0, 'A') | |||
| 638 | - | assertCell(t, s, 4, 0, 'B') | |||
| 639 | - | assertBlank(t, s, 5, 0) | |||
| 640 | - | ||||
| 641 | - | d := s.Damage() | |||
| 642 | - | if len(d) == 0 { | |||
| 643 | - | t.Fatal("expected damage") | |||
| 644 | - | } | |||
| 645 | - | // Damage must extend one column left (to col 0) to cover the | |||
| 646 | - | // repaired wide left half. | |||
| 647 | - | if d[0].X != 0 || d[0].Width != 6 { | |||
| 648 | - | t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width) | |||
| 649 | - | } | |||
| 650 | - | }, | |||
| 651 | - | }, | |||
| 652 | - | { | |||
| 653 | - | name: "IRM inserts narrow printable cells and reset restores overwrite", | |||
| 654 | - | run: func(t *testing.T) { | |||
| 655 | - | s := NewScreen(8, 1) | |||
| 656 | - | s.Write([]byte("abcd\x1b[1;3H\x1b[4hX")) | |||
| 657 | - | ||||
| 658 | - | if got := lineText(s, 0); got != "abXcd " { | |||
| 659 | - | t.Fatalf("IRM inserted line = %q, want %q", got, "abXcd ") | |||
| 660 | - | } | |||
| 661 | - | s.Write([]byte("\x1b[4l\x1b[1;3HY")) | |||
| 662 | - | if got := lineText(s, 0); got != "abYcd " { | |||
| 663 | - | t.Fatalf("IRM reset overwrite line = %q, want %q", got, "abYcd ") | |||
| 664 | - | } | |||
| 665 | - | }, | |||
| 666 | - | }, | |||
| 667 | - | { | |||
| 668 | - | name: "IRM inserts wide printable cells and repairs evicted wide head", | |||
| 669 | - | run: func(t *testing.T) { | |||
| 670 | - | s := NewScreen(6, 1) | |||
| 671 | - | s.Write([]byte("ab界cd")) | |||
| 672 | - | s.Write([]byte("\x1b[1;3H\x1b[4h好")) | |||
| 673 | - | ||||
| 674 | - | assertCell(t, s, 0, 0, 'a') | |||
| 675 | - | assertCell(t, s, 1, 0, 'b') | |||
| 676 | - | assertCell(t, s, 2, 0, '好') | |||
| 677 | - | assertContinuation(t, s, 3, 0) | |||
| 678 | - | assertCell(t, s, 4, 0, '界') | |||
| 679 | - | assertContinuation(t, s, 5, 0) | |||
| 680 | - | assertNoOrphanWideCells(t, s) | |||
| 681 | - | }, | |||
| 682 | - | }, | |||
| 683 | - | { | |||
| 684 | - | name: "IRM wide insert at right edge repairs orphan", | |||
| 685 | - | run: func(t *testing.T) { | |||
| 686 | - | s := NewScreen(5, 1) | |||
| 687 | - | s.Write([]byte("abc界")) | |||
| 688 | - | s.Write([]byte("\x1b[1;2H\x1b[4h好")) | |||
| 689 | - | ||||
| 690 | - | assertCell(t, s, 0, 0, 'a') | |||
| 691 | - | assertCell(t, s, 1, 0, '好') | |||
| 692 | - | assertContinuation(t, s, 2, 0) | |||
| 693 | - | assertCell(t, s, 3, 0, 'b') | |||
| 694 | - | assertCell(t, s, 4, 0, 'c') | |||
| 695 | - | assertNoOrphanWideCells(t, s) | |||
| 696 | - | }, | |||
| 697 | - | }, | |||
| 698 | - | { | |||
| 699 | - | name: "wide char on a width-1 screen becomes a narrow replacement rune", | |||
| 700 | - | run: func(t *testing.T) { | |||
| 701 | - | s := NewScreen(1, 2) | |||
| 702 | - | // A wide rune cannot fit on a 1-column screen. Store a narrow | |||
| 703 | - | // replacement instead so renderer width matches the cell layout. | |||
| 704 | - | s.Write([]byte("你")) | |||
| 705 | - | ||||
| 706 | - | assertCell(t, s, 0, 0, '\uFFFD') | |||
| 707 | - | if got := renderer.RuneWidth(cellAt(s, 0, 0).Rune); got != 1 { | |||
| 708 | - | t.Errorf("stored rune width = %d, want 1", got) | |||
| 709 | - | } | |||
| 710 | - | if cellAt(s, 0, 0).Continuation { | |||
| 711 | - | t.Error("width-1 replacement must not be a continuation") | |||
| 712 | - | } | |||
| 713 | - | assertNoOrphanWideCells(t, s) | |||
| 714 | - | if s.Col != 1 { | |||
| 715 | - | t.Errorf("cursor at col=%d, want 1", s.Col) | |||
| 716 | - | } | |||
| 717 | - | }, | |||
| 718 | - | }, | |||
| 719 | - | { | |||
| 720 | - | name: "wide pair survives a scroll", | |||
| 721 | - | run: func(t *testing.T) { | |||
| 722 | - | s := NewScreen(4, 2) | |||
| 723 | - | s.Write([]byte("你\r\n好")) // row0: 你, row1: 好 | |||
| 724 | - | s.Write([]byte("\r\n")) // bottom row → scroll up | |||
| 725 | - | ||||
| 726 | - | // After scrolling up, row1's 好 moves to row0 intact. | |||
| 727 | - | assertCell(t, s, 0, 0, '好') | |||
| 728 | - | assertContinuation(t, s, 1, 0) | |||
| 729 | - | }, | |||
| 730 | - | }, | |||
| 731 | - | { | |||
| 732 | - | name: "combining mark alone writes no cell and produces no damage", | |||
| 733 | - | run: func(t *testing.T) { | |||
| 734 | - | s := NewScreen(10, 3) | |||
| 735 | - | s.ClearDamage() | |||
| 736 | - | s.Write([]byte("\xcc\x81")) // combining acute accent alone (no base char) | |||
| 737 | - | ||||
| 738 | - | // No cells should be modified. | |||
| 739 | - | for y := range 3 { | |||
| 740 | - | for x := range 10 { | |||
| 741 | - | if c := cellAt(s, x, y); c.Rune != ' ' { | |||
| 742 | - | t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune) | |||
| 743 | - | } | |||
| 744 | - | } | |||
| 745 | - | } | |||
| 746 | - | if s.Col != 0 || s.Row != 0 { | |||
| 747 | - | t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row) | |||
| 748 | - | } | |||
| 749 | - | d := s.Damage() | |||
| 750 | - | if len(d) != 0 { | |||
| 751 | - | t.Errorf("expected no damage for combining mark alone, got %+v", d) | |||
| 752 | - | } | |||
| 753 | - | }, | |||
| 754 | - | }, | |||
| 755 | - | } | |||
| 756 | - | ||||
| 757 | - | for _, tt := range tests { | |||
| 758 | - | t.Run(tt.name, tt.run) | |||
| 759 | - | } | |||
| 760 | - | } | |||
| 761 | - | ||||
| 762 | - | func TestRuneWidth(t *testing.T) { | |||
| 763 | - | tests := []struct { | |||
| 764 | - | name string | |||
| 765 | - | r rune | |||
| 766 | - | width int | |||
| 767 | - | }{ | |||
| 768 | - | {"space", ' ', 1}, | |||
| 769 | - | {"lowercase ascii letter", 'a', 1}, | |||
| 770 | - | {"uppercase ascii letter", 'A', 1}, | |||
| 771 | - | {"ascii digit", '1', 1}, | |||
| 772 | - | {"soft hyphen", 0x00AD, 0}, | |||
| 773 | - | {"combining grapheme joiner", 0x034F, 0}, | |||
| 774 | - | {"combining acute accent", 0x0301, 0}, | |||
| 775 | - | {"combining grave accent", 0x0300, 0}, | |||
| 776 | - | {"zero-width space", 0x200B, 0}, | |||
| 777 | - | {"zero-width non-joiner", 0x200C, 0}, | |||
| 778 | - | {"zero-width joiner", 0x200D, 0}, | |||
| 779 | - | {"BOM / zero-width no-break space", 0xFEFF, 0}, | |||
| 780 | - | {"CJK Unified Ideograph (一)", 0x4E00, 2}, | |||
| 781 | - | {"CJK (二)", 0x4E8C, 2}, | |||
| 782 | - | {"CJK end of BMP", 0x9FFF, 2}, | |||
| 783 | - | {"CJK Extension A", 0x3400, 2}, | |||
| 784 | - | {"Hangul Syllable (가)", 0xAC00, 2}, | |||
| 785 | - | {"Hangul Syllable end", 0xD7AF, 2}, | |||
| 786 | - | {"CJK Ideographic Space", 0x3000, 2}, | |||
| 787 | - | {"Fullwidth Exclamation Mark", 0xFF01, 2}, | |||
| 788 | - | {"Fullwidth Cent Sign", 0xFFE0, 2}, | |||
| 789 | - | {"grinning face emoji", 0x1F600, 2}, | |||
| 790 | - | {"rocket emoji", 0x1F680, 2}, | |||
| 791 | - | {"hiragana a", 0x3042, 2}, | |||
| 792 | - | {"katakana a", 0x30A2, 2}, | |||
| 793 | - | {"CJK Extension B", 0x20000, 2}, | |||
| 794 | - | {"supplemental symbols (robot)", 0x1F916, 2}, | |||
| 795 | - | {"symbols extended-A (sari)", 0x1FA71, 2}, | |||
| 796 | - | {"ideographic full stop", 0x3002, 2}, | |||
| 797 | - | {"NUL", '\x00', 0}, | |||
| 798 | - | {"SOH", '\x01', 0}, | |||
| 799 | - | {"ESC", '\x1b', 0}, | |||
| 800 | - | {"DEL", 0x7F, 0}, | |||
| 801 | - | } | |||
| 802 | - | ||||
| 803 | - | for _, tt := range tests { | |||
| 804 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 805 | - | got := renderer.RuneWidth(tt.r) | |||
| 806 | - | if got != tt.width { | |||
| 807 | - | t.Errorf("renderer.RuneWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width) | |||
| 808 | - | } | |||
| 809 | - | }) | |||
| 810 | - | } | |||
| 811 | - | } | |||
| 812 | - | ||||
| 813 | - | func TestC1Controls(t *testing.T) { | |||
| 814 | - | tests := []struct { | |||
| 815 | - | name string | |||
| 816 | - | run func(t *testing.T) | |||
| 817 | - | }{ | |||
| 818 | - | { | |||
| 819 | - | name: "C1 controls are dropped without moving the cursor", | |||
| 820 | - | run: func(t *testing.T) { | |||
| 821 | - | s := NewScreen(10, 3) | |||
| 822 | - | s.Write([]byte{0xC2, 0x80}) // U+0080 PAD | |||
| 823 | - | s.Write([]byte{0xC2, 0x9F}) // U+009F APC | |||
| 824 | - | ||||
| 825 | - | // No cells should be modified. | |||
| 826 | - | for y := range 3 { | |||
| 827 | - | for x := range 10 { | |||
| 828 | - | if c := cellAt(s, x, y); c.Rune != ' ' { | |||
| 829 | - | t.Errorf("cell(%d,%d) = %q, want space after C1 controls", x, y, c.Rune) | |||
| 830 | - | } | |||
| 831 | - | } | |||
| 832 | - | } | |||
| 833 | - | // Cursor should not have moved. | |||
| 834 | - | if s.Col != 0 || s.Row != 0 { | |||
| 835 | - | t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row) | |||
| 836 | - | } | |||
| 837 | - | }, | |||
| 838 | - | }, | |||
| 839 | - | { | |||
| 840 | - | name: "the full C1 control range U+0080-U+009F is dropped", | |||
| 841 | - | run: func(t *testing.T) { | |||
| 842 | - | s := NewScreen(10, 3) | |||
| 843 | - | // Send all C1 control runes U+0080 through U+009F. | |||
| 844 | - | for r := 0x80; r <= 0x9F; r++ { | |||
| 845 | - | s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)}) | |||
| 846 | - | } | |||
| 847 | - | ||||
| 848 | - | // No cells should be modified. | |||
| 849 | - | for y := range 3 { | |||
| 850 | - | for x := range 10 { | |||
| 851 | - | if c := cellAt(s, x, y); c.Rune != ' ' { | |||
| 852 | - | t.Errorf("cell(%d,%d) = %q, want space after C1 range", x, y, c.Rune) | |||
| 853 | - | } | |||
| 854 | - | } | |||
| 855 | - | } | |||
| 856 | - | if s.Col != 0 || s.Row != 0 { | |||
| 857 | - | t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row) | |||
| 858 | - | } | |||
| 859 | - | }, | |||
| 860 | - | }, | |||
| 861 | - | { | |||
| 862 | - | name: "printable characters still work after C1 controls", | |||
| 863 | - | run: func(t *testing.T) { | |||
| 864 | - | s := NewScreen(10, 3) | |||
| 865 | - | // Printable characters should still work after C1 controls. | |||
| 866 | - | s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) | |||
| 867 | - | s.Write([]byte("AB")) | |||
| 868 | - | s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped) | |||
| 869 | - | s.Write([]byte("CD")) | |||
| 870 | - | ||||
| 871 | - | assertCell(t, s, 0, 0, 'A') | |||
| 872 | - | assertCell(t, s, 1, 0, 'B') | |||
| 873 | - | assertCell(t, s, 2, 0, 'C') | |||
| 874 | - | assertCell(t, s, 3, 0, 'D') | |||
| 875 | - | if s.Col != 4 { | |||
| 876 | - | t.Errorf("cursor at col=%d, want 4", s.Col) | |||
| 877 | - | } | |||
| 878 | - | }, | |||
| 879 | - | }, | |||
| 880 | - | { | |||
| 881 | - | name: "C1 controls mixed with CR preserve CR behavior", | |||
| 882 | - | run: func(t *testing.T) { | |||
| 883 | - | s := NewScreen(10, 3) | |||
| 884 | - | // C1 controls mixed with CR/LF should preserve CR/LF behavior. | |||
| 885 | - | s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) | |||
| 886 | - | s.Write([]byte("A\rB")) // CR moves cursor back, overwrite A | |||
| 887 | - | s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped) | |||
| 888 | - | ||||
| 889 | - | assertCell(t, s, 0, 0, 'B') | |||
| 890 | - | }, | |||
| 891 | - | }, | |||
| 892 | - | { | |||
| 893 | - | name: "C1 controls mixed with LF preserve LF behavior", | |||
| 894 | - | run: func(t *testing.T) { | |||
| 895 | - | s := NewScreen(10, 3) | |||
| 896 | - | s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped) | |||
| 897 | - | s.Write([]byte("A\nB")) | |||
| 898 | - | ||||
| 899 | - | assertCell(t, s, 0, 0, 'A') | |||
| 900 | - | assertCell(t, s, 1, 1, 'B') | |||
| 901 | - | if s.Row != 1 || s.Col != 2 { | |||
| 902 | - | t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)", s.Row, s.Col) | |||
| 903 | - | } | |||
| 904 | - | }, | |||
| 905 | - | }, | |||
| 906 | - | { | |||
| 907 | - | name: "C1 controls produce no damage", | |||
| 908 | - | run: func(t *testing.T) { | |||
| 909 | - | s := NewScreen(10, 3) | |||
| 910 | - | s.ClearDamage() | |||
| 911 | - | // C1 controls should produce no damage. | |||
| 912 | - | s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F}) | |||
| 913 | - | ||||
| 914 | - | d := s.Damage() | |||
| 915 | - | if len(d) != 0 { | |||
| 916 | - | t.Errorf("expected no damage from C1 controls, got %+v", d) | |||
| 917 | - | } | |||
| 918 | - | }, | |||
| 919 | - | }, | |||
| 920 | - | } | |||
| 921 | - | ||||
| 922 | - | for _, tt := range tests { | |||
| 923 | - | t.Run(tt.name, tt.run) | |||
| 924 | - | } | |||
| 925 | - | } | |||
| 926 | - | ||||
| 927 | - | func TestScreenLineBoundsDescribesTheLiveGrid(t *testing.T) { | |||
| 928 | - | tests := []struct { | |||
| 929 | - | name string | |||
| 930 | - | width int | |||
| 931 | - | height int | |||
| 932 | - | write string | |||
| 933 | - | want []LineBound | |||
| 934 | - | // checkOwned exercises the caller-owned-copy guarantee: it only makes | |||
| 935 | - | // sense to check once per LineBounds() call, so it lives on the case | |||
| 936 | - | // whose write actually produces a soft-wrapped row 0 to corrupt. | |||
| 937 | - | checkOwned bool | |||
| 938 | - | }{ | |||
| 939 | - | { | |||
| 940 | - | name: "wraps after column width", | |||
| 941 | - | width: 4, | |||
| 942 | - | height: 3, | |||
| 943 | - | write: "abcdef", // wraps after column 4 | |||
| 944 | - | want: []LineBound{ | |||
| 945 | - | {End: 4, Soft: true}, // row 0: filled the row and wrapped | |||
| 946 | - | {End: 2, Soft: false}, // row 1: "ef", no wrap | |||
| 947 | - | {End: 0, Soft: false}, // row 2: untouched | |||
| 948 | - | }, | |||
| 949 | - | checkOwned: true, | |||
| 950 | - | }, | |||
| 951 | - | } | |||
| 952 | - | ||||
| 953 | - | for _, tt := range tests { | |||
| 954 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 955 | - | s := NewScreen(tt.width, tt.height) | |||
| 956 | - | s.Write([]byte(tt.write)) | |||
| 957 | - | ||||
| 958 | - | bounds := s.LineBounds() | |||
| 959 | - | require.Equal(t, tt.want, bounds) | |||
| 960 | - | ||||
| 961 | - | if tt.checkOwned { | |||
| 962 | - | // The result is owned by the caller. | |||
| 963 | - | bounds[0] = LineBound{} | |||
| 964 | - | require.True(t, s.LineBounds()[0].Soft, "mutating the result changed the screen's own boundaries") | |||
| 965 | - | } | |||
| 966 | - | }) | |||
| 967 | - | } | |||
| 968 | - | } | |||
D pkg/vt/scrollback_test.go +0 -171
| @@ -1,171 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func evictedTexts(rows [][]renderer.Cell) []string { | |||
| 10 | - | out := make([]string, len(rows)) | |||
| 11 | - | for i, row := range rows { | |||
| 12 | - | runes := make([]rune, len(row)) | |||
| 13 | - | for x, c := range row { | |||
| 14 | - | runes[x] = c.Rune | |||
| 15 | - | } | |||
| 16 | - | out[i] = string(runes) | |||
| 17 | - | } | |||
| 18 | - | return out | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | - | func TestOnLineEvicted(t *testing.T) { | |||
| 22 | - | tests := []struct { | |||
| 23 | - | name string | |||
| 24 | - | write []byte | |||
| 25 | - | wantRows []string | |||
| 26 | - | wantFrame []string | |||
| 27 | - | }{ | |||
| 28 | - | { | |||
| 29 | - | name: "newline scroll evicts top row", | |||
| 30 | - | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"), | |||
| 31 | - | wantRows: []string{"AAAA"}, | |||
| 32 | - | wantFrame: []string{"BBBB", "CCCC", " "}, | |||
| 33 | - | }, | |||
| 34 | - | { | |||
| 35 | - | name: "CSI S count evicts each top row in order", | |||
| 36 | - | write: []byte("AAAA\r\nBBBB\r\nCCCC\x1b[2S"), | |||
| 37 | - | wantRows: []string{"AAAA", "BBBB"}, | |||
| 38 | - | wantFrame: []string{"CCCC", " ", " "}, | |||
| 39 | - | }, | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | for _, tt := range tests { | |||
| 43 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 44 | - | s := NewScreen(4, 3) | |||
| 45 | - | var evicted [][]renderer.Cell | |||
| 46 | - | s.OnLineEvicted = func(row []renderer.Cell) { | |||
| 47 | - | evicted = append(evicted, append([]renderer.Cell(nil), row...)) | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | s.Write(tt.write) | |||
| 51 | - | ||||
| 52 | - | if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) { | |||
| 53 | - | t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows) | |||
| 54 | - | } | |||
| 55 | - | for y, want := range tt.wantFrame { | |||
| 56 | - | if got := lineText(s, y); got != want { | |||
| 57 | - | t.Fatalf("line %d = %q, want %q", y, got, want) | |||
| 58 | - | } | |||
| 59 | - | } | |||
| 60 | - | }) | |||
| 61 | - | } | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | func TestOnLineEvictedAltScreenAndRotation(t *testing.T) { | |||
| 65 | - | tests := []struct { | |||
| 66 | - | name string | |||
| 67 | - | write []byte | |||
| 68 | - | wantRows []string | |||
| 69 | - | }{ | |||
| 70 | - | { | |||
| 71 | - | name: "alternate screen scroll does not evict", | |||
| 72 | - | write: []byte("main\x1b[?1049hAAAA\r\nBBBB\r\nCCCC\r\nDDDD\x1b[?1049lEEEE\r\n"), | |||
| 73 | - | wantRows: nil, | |||
| 74 | - | }, | |||
| 75 | - | { | |||
| 76 | - | name: "evicted row follows logical top after prior lineOffset rotation", | |||
| 77 | - | write: []byte("AAAA\r\nBBBB\r\nCCCC\r\nDDDD\r\n"), | |||
| 78 | - | wantRows: []string{"AAAA", "BBBB"}, | |||
| 79 | - | }, | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | for _, tt := range tests { | |||
| 83 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 84 | - | s := NewScreen(4, 3) | |||
| 85 | - | var evicted [][]renderer.Cell | |||
| 86 | - | s.OnLineEvicted = func(row []renderer.Cell) { | |||
| 87 | - | evicted = append(evicted, append([]renderer.Cell(nil), row...)) | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | s.Write(tt.write) | |||
| 91 | - | ||||
| 92 | - | if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) { | |||
| 93 | - | t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows) | |||
| 94 | - | } | |||
| 95 | - | if err := s.Frame.CheckInvariants(); err != nil { | |||
| 96 | - | t.Fatalf("frame invariants after scrollback callback: %v", err) | |||
| 97 | - | } | |||
| 98 | - | }) | |||
| 99 | - | } | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | func TestEvictedLinesCarryTheirBound(t *testing.T) { | |||
| 103 | - | tests := []struct { | |||
| 104 | - | name string | |||
| 105 | - | evict func(s *Screen) | |||
| 106 | - | wantSoft bool | |||
| 107 | - | }{ | |||
| 108 | - | { | |||
| 109 | - | name: "scroll evicts a soft-wrapped row", | |||
| 110 | - | evict: func(s *Screen) { | |||
| 111 | - | // Filling all four columns only leaves deferred wrap pending. The | |||
| 112 | - | // fifth printable character triggers the soft wrap before scrolling. | |||
| 113 | - | s.Write([]byte("abcde")) | |||
| 114 | - | s.Write([]byte("\r\n\r\n\r\n\r\n")) | |||
| 115 | - | }, | |||
| 116 | - | wantSoft: true, | |||
| 117 | - | }, | |||
| 118 | - | { | |||
| 119 | - | name: "scroll evicts a hard row", | |||
| 120 | - | evict: func(s *Screen) { | |||
| 121 | - | s.Write([]byte("ab\r\n\r\n\r\n\r\n")) | |||
| 122 | - | }, | |||
| 123 | - | wantSoft: false, | |||
| 124 | - | }, | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | for _, tc := range tests { | |||
| 128 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 129 | - | s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 1024}) | |||
| 130 | - | tc.evict(s) | |||
| 131 | - | view := s.History().SealAndView() | |||
| 132 | - | if view.Len() == 0 { | |||
| 133 | - | t.Fatal("nothing was evicted to history") | |||
| 134 | - | } | |||
| 135 | - | if got := view.Bound(0).Soft; got != tc.wantSoft { | |||
| 136 | - | t.Errorf("Bound(0).Soft = %v, want %v", got, tc.wantSoft) | |||
| 137 | - | } | |||
| 138 | - | }) | |||
| 139 | - | } | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | func TestReflowEvictedLinesCarryTheirBound(t *testing.T) { | |||
| 143 | - | s := NewScreenWithHistory(8, 3, HistoryConfig{MaxRows: 16, MaxCells: 1024}) | |||
| 144 | - | // Fill the grid with wrapped content, then shrink so rows are evicted by | |||
| 145 | - | // the reflow path in buffer.resize rather than by scrolling. | |||
| 146 | - | s.Write([]byte("aaaaaaaabbbbbbbbcccccccc")) | |||
| 147 | - | s.Resize(4, 2) | |||
| 148 | - | ||||
| 149 | - | view := s.History().SealAndView() | |||
| 150 | - | if view.Len() == 0 { | |||
| 151 | - | t.Fatal("reflow evicted nothing to history") | |||
| 152 | - | } | |||
| 153 | - | for i := range view.Len() { | |||
| 154 | - | bound := view.Bound(i) | |||
| 155 | - | if bound.End == 0 { | |||
| 156 | - | t.Errorf("Bound(%d).End = 0, want the reflowed row extent", i) | |||
| 157 | - | } | |||
| 158 | - | } | |||
| 159 | - | } | |||
| 160 | - | ||||
| 161 | - | func equalStrings(a, b []string) bool { | |||
| 162 | - | if len(a) != len(b) { | |||
| 163 | - | return false | |||
| 164 | - | } | |||
| 165 | - | for i := range a { | |||
| 166 | - | if a[i] != b[i] { | |||
| 167 | - | return false | |||
| 168 | - | } | |||
| 169 | - | } | |||
| 170 | - | return true | |||
| 171 | - | } | |||
D pkg/vt/snapshot.go +0 -201
| @@ -1,201 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "math" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | // DecodeStats describes resources declared by one canonical VT blob. | |||
| 9 | - | type DecodeStats struct { | |||
| 10 | - | Chunks uint64 | |||
| 11 | - | Rows uint64 | |||
| 12 | - | Cells uint64 | |||
| 13 | - | Styles uint64 | |||
| 14 | - | Bytes uint64 | |||
| 15 | - | } | |||
| 16 | - | ||||
| 17 | - | // Add adds another preflight result with overflow checking. | |||
| 18 | - | func (s *DecodeStats) Add(other DecodeStats) bool { | |||
| 19 | - | for _, totals := range [][2]*uint64{{&s.Chunks, &other.Chunks}, {&s.Rows, &other.Rows}, {&s.Cells, &other.Cells}, {&s.Styles, &other.Styles}, {&s.Bytes, &other.Bytes}} { | |||
| 20 | - | if math.MaxUint64-*totals[0] < *totals[1] { | |||
| 21 | - | return false | |||
| 22 | - | } | |||
| 23 | - | *totals[0] += *totals[1] | |||
| 24 | - | } | |||
| 25 | - | return true | |||
| 26 | - | } | |||
| 27 | - | ||||
| 28 | - | // PreflightHistoryBlob validates one self-contained history blob without | |||
| 29 | - | // allocating decoded rows. | |||
| 30 | - | func PreflightHistoryBlob(data []byte) (DecodeStats, error) { | |||
| 31 | - | stats, ok := preflightHistory(data) | |||
| 32 | - | if !ok { | |||
| 33 | - | return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory) | |||
| 34 | - | } | |||
| 35 | - | return DecodeStats{Chunks: stats.chunks, Rows: stats.rows, Cells: stats.cells, Styles: stats.styles, Bytes: stats.bytes}, nil | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | // MarshalHistoryChunk encodes one immutable chunk as a self-contained blob. | |||
| 39 | - | func MarshalHistoryChunk(chunk *HistoryChunk) ([]byte, error) { | |||
| 40 | - | if chunk == nil { | |||
| 41 | - | return nil, fmt.Errorf("marshal history chunk: %w", errInvalidHistory) | |||
| 42 | - | } | |||
| 43 | - | return MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: len(chunk.rows)}) | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | // MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob. | |||
| 47 | - | func MarshalEmptyHistoryTail() ([]byte, error) { return MarshalHistory(HistoryView{}) } | |||
| 48 | - | ||||
| 49 | - | // MarshalHistoryTail encodes the copied mutable tail of a snapshot view as one | |||
| 50 | - | // canonical history blob. It does not seal or otherwise mutate live history. | |||
| 51 | - | func MarshalHistoryTail(view HistorySnapshotView) ([]byte, error) { | |||
| 52 | - | return MarshalHistory(view.Tail()) | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | // MarshalSealedHistory serializes a SealAndView result as oldest-first, | |||
| 56 | - | // self-contained sealed blobs plus a mandatory empty tail blob. The empty tail | |||
| 57 | - | // carries NextRowID and is not the canonical default empty encoding. | |||
| 58 | - | func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) { | |||
| 59 | - | if view.rows != historyViewRowCount(view) { | |||
| 60 | - | return nil, nil, fmt.Errorf("marshal sealed history: %w", errInvalidHistory) | |||
| 61 | - | } | |||
| 62 | - | sealed := make([][]byte, len(view.chunks)) | |||
| 63 | - | for i, chunk := range view.chunks { | |||
| 64 | - | blob, err := MarshalHistoryChunk(chunk) | |||
| 65 | - | if err != nil { | |||
| 66 | - | return nil, nil, err | |||
| 67 | - | } | |||
| 68 | - | sealed[i] = blob | |||
| 69 | - | } | |||
| 70 | - | tail, err := MarshalHistory(HistoryView{nextRowID: view.NextRowID()}) | |||
| 71 | - | if err != nil { | |||
| 72 | - | return nil, nil, err | |||
| 73 | - | } | |||
| 74 | - | return sealed, tail, nil | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | // HistoryFromBlobs restores history directly from sealed, oldest-first blobs | |||
| 78 | - | // and the mandatory tail blob. It never feeds decoded rows through Append. | |||
| 79 | - | func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) { | |||
| 80 | - | sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail) | |||
| 81 | - | if err != nil { | |||
| 82 | - | return nil, err | |||
| 83 | - | } | |||
| 84 | - | return restoreHistoryViews(config, sealedViews, tailView), nil | |||
| 85 | - | } | |||
| 86 | - | ||||
| 87 | - | func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryView) ([]HistoryView, HistoryView, error) { | |||
| 88 | - | seen := make(map[RowID]struct{}) | |||
| 89 | - | sealedViews := make([]HistoryView, len(sealed)) | |||
| 90 | - | for i, blob := range sealed { | |||
| 91 | - | view, err := UnmarshalHistory(blob) | |||
| 92 | - | if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) { | |||
| 93 | - | return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory) | |||
| 94 | - | } | |||
| 95 | - | sealedViews[i] = view | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | tailView, err := UnmarshalHistory(tail) | |||
| 99 | - | if err != nil || len(tailView.chunks) > 1 || !validateRestoredHistoryView(tailView, seen) { | |||
| 100 | - | return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory) | |||
| 101 | - | } | |||
| 102 | - | for _, view := range extra { | |||
| 103 | - | if !validateRestoredHistoryView(view, seen) { | |||
| 104 | - | return nil, HistoryView{}, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory) | |||
| 105 | - | } | |||
| 106 | - | } | |||
| 107 | - | return sealedViews, tailView, nil | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | func restoreHistoryViews(config HistoryConfig, sealed []HistoryView, tail HistoryView) *History { | |||
| 111 | - | h := NewHistory(config) | |||
| 112 | - | for _, view := range sealed { | |||
| 113 | - | h.nextRowID = max(h.nextRowID, view.nextRowID) | |||
| 114 | - | if h.maxRows == 0 { | |||
| 115 | - | continue | |||
| 116 | - | } | |||
| 117 | - | chunk := view.chunks[0] | |||
| 118 | - | h.chunks = append(h.chunks, chunk) | |||
| 119 | - | h.rows += len(chunk.rows) | |||
| 120 | - | h.cells += view.Cells() | |||
| 121 | - | h.evict() | |||
| 122 | - | } | |||
| 123 | - | h.nextRowID = max(h.nextRowID, tail.nextRowID) | |||
| 124 | - | if h.maxRows == 0 || len(tail.chunks) == 0 { | |||
| 125 | - | return h | |||
| 126 | - | } | |||
| 127 | - | chunk := tail.chunks[0] | |||
| 128 | - | h.tail = chunk.rows | |||
| 129 | - | h.tailBounds = append([]LineBound(nil), chunk.bounds...) | |||
| 130 | - | h.tailIDs = append([]RowID(nil), chunk.rowIDs...) | |||
| 131 | - | h.rows += len(h.tail) | |||
| 132 | - | h.cells += tail.Cells() | |||
| 133 | - | h.evict() | |||
| 134 | - | h.normalizeTail() | |||
| 135 | - | return h | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool { | |||
| 139 | - | if view.nextRowID == 0 || view.nextRowID == ^RowID(0) { | |||
| 140 | - | return false | |||
| 141 | - | } | |||
| 142 | - | var maxID RowID | |||
| 143 | - | for _, chunk := range view.chunks { | |||
| 144 | - | if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) { | |||
| 145 | - | return false | |||
| 146 | - | } | |||
| 147 | - | for _, id := range chunk.rowIDs { | |||
| 148 | - | if id == 0 || id >= ^RowID(0)-1 { | |||
| 149 | - | return false | |||
| 150 | - | } | |||
| 151 | - | if _, duplicate := seen[id]; duplicate { | |||
| 152 | - | return false | |||
| 153 | - | } | |||
| 154 | - | seen[id] = struct{}{} | |||
| 155 | - | maxID = max(maxID, id) | |||
| 156 | - | } | |||
| 157 | - | } | |||
| 158 | - | return view.nextRowID > maxID | |||
| 159 | - | } | |||
| 160 | - | ||||
| 161 | - | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | |||
| 162 | - | // history contains the restored bounded history followed by the recovery | |||
| 163 | - | // transcript. The transcript is decoded in full before history is restored. | |||
| 164 | - | func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, sealed [][]byte, tail, transcript []byte) (*Screen, error) { | |||
| 165 | - | transcriptView, err := UnmarshalHistory(transcript) | |||
| 166 | - | if err != nil { | |||
| 167 | - | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail, transcriptView) | |||
| 171 | - | if err != nil { | |||
| 172 | - | return nil, err | |||
| 173 | - | } | |||
| 174 | - | history := restoreHistoryViews(config, sealedViews, tailView) | |||
| 175 | - | remainingRows := transcriptView.rows | |||
| 176 | - | for _, chunk := range transcriptView.chunks { | |||
| 177 | - | for i, row := range chunk.rows { | |||
| 178 | - | remainingRows-- | |||
| 179 | - | bound := chunk.bounds[i] | |||
| 180 | - | if remainingRows == 0 { | |||
| 181 | - | bound.Soft = false | |||
| 182 | - | } | |||
| 183 | - | if err := history.AppendWithID(row, bound, chunk.rowIDs[i]); err != nil { | |||
| 184 | - | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |||
| 185 | - | } | |||
| 186 | - | } | |||
| 187 | - | } | |||
| 188 | - | ||||
| 189 | - | screen := NewScreenWithHistory(width, height, config) | |||
| 190 | - | screen.history = history | |||
| 191 | - | counter := max(history.nextRowID, transcriptView.nextRowID, RowID(1)) | |||
| 192 | - | if counter == 0 || counter >= ^RowID(0) || uint64(height) > uint64(^RowID(0)-1-counter) { | |||
| 193 | - | return nil, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory) | |||
| 194 | - | } | |||
| 195 | - | screen.nextRowID = counter - 1 | |||
| 196 | - | for i := range screen.buffer.rowIDs { | |||
| 197 | - | screen.buffer.rowIDs[i] = screen.nextRowIDValue() | |||
| 198 | - | } | |||
| 199 | - | screen.Frame = screen.buffer.frame | |||
| 200 | - | return screen, nil | |||
| 201 | - | } | |||
D pkg/vt/snapshot_test.go +0 -86
| @@ -1,86 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 7 | - | "github.com/stretchr/testify/require" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) { | |||
| 11 | - | history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2}) | |||
| 12 | - | requireHistoryAppend(t, history, historyRow("AAAA")) | |||
| 13 | - | requireHistoryAppend(t, history, historyRow("BBBB")) | |||
| 14 | - | requireHistoryAppend(t, history, historyRow("CCCC")) | |||
| 15 | - | sealed := history.View().Chunk(0) | |||
| 16 | - | ||||
| 17 | - | view := history.SnapshotView() | |||
| 18 | - | if got, want := view.ChunkCount(), 1; got != want { | |||
| 19 | - | t.Fatalf("sealed chunks = %d, want %d", got, want) | |||
| 20 | - | } | |||
| 21 | - | if view.Chunk(0) != sealed { | |||
| 22 | - | t.Fatal("snapshot copied an immutable sealed chunk") | |||
| 23 | - | } | |||
| 24 | - | if got, want := len(history.tail), 1; got != want { | |||
| 25 | - | t.Fatalf("live tail rows = %d, want %d; snapshot sealed it", got, want) | |||
| 26 | - | } | |||
| 27 | - | history.tail[0][0].Rune = 'X' | |||
| 28 | - | if got, want := rowText(view.Tail().Row(0)), "CCCC"; got != want { | |||
| 29 | - | t.Fatalf("snapshot tail = %q, want %q", got, want) | |||
| 30 | - | } | |||
| 31 | - | if got, want := view.Len(), 3; got != want { | |||
| 32 | - | t.Fatalf("snapshot rows = %d, want %d", got, want) | |||
| 33 | - | } | |||
| 34 | - | if got, want := view.Cells(), 12; got != want { | |||
| 35 | - | t.Fatalf("snapshot cells = %d, want %d", got, want) | |||
| 36 | - | } | |||
| 37 | - | wantID := view.Tail().RowID(0) | |||
| 38 | - | history.tailIDs[0] = 99 | |||
| 39 | - | if got := view.Tail().RowID(0); got != wantID { | |||
| 40 | - | t.Fatalf("snapshot tail ID = %d after live mutation, want %d", got, wantID) | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | tail, err := MarshalHistoryTail(view) | |||
| 44 | - | require.NoError(t, err) | |||
| 45 | - | decoded, err := UnmarshalHistory(tail) | |||
| 46 | - | require.NoError(t, err) | |||
| 47 | - | require.Equal(t, []string{"CCCC"}, historyViewTexts(decoded)) | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) { | |||
| 51 | - | fullTail, err := MarshalHistory(HistoryView{ | |||
| 52 | - | chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}, rowIDs: []RowID{1, 2}}}, | |||
| 53 | - | rows: 2, | |||
| 54 | - | nextRowID: 3, | |||
| 55 | - | }) | |||
| 56 | - | require.NoError(t, err) | |||
| 57 | - | ||||
| 58 | - | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail) | |||
| 59 | - | require.NoError(t, err) | |||
| 60 | - | requireHistoryAppend(t, restored, historyRow("CCCC")) | |||
| 61 | - | ||||
| 62 | - | require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View())) | |||
| 63 | - | require.Len(t, restored.chunks, 1, "a full restored tail must become sealed") | |||
| 64 | - | require.Len(t, restored.tail, 1, "the appended row must be the mutable tail") | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) { | |||
| 68 | - | history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2}) | |||
| 69 | - | for _, text := range []string{"a", "b", "cc"} { | |||
| 70 | - | requireHistoryAppend(t, history, historyRow(text)) | |||
| 71 | - | } | |||
| 72 | - | view := history.SnapshotView() | |||
| 73 | - | sealed := make([][]byte, view.ChunkCount()) | |||
| 74 | - | for i := range sealed { | |||
| 75 | - | var err error | |||
| 76 | - | sealed[i], err = MarshalHistoryChunk(view.Chunk(i)) | |||
| 77 | - | require.NoError(t, err) | |||
| 78 | - | } | |||
| 79 | - | tail, err := MarshalHistoryTail(view) | |||
| 80 | - | require.NoError(t, err) | |||
| 81 | - | ||||
| 82 | - | restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, MaxCells: 2, ChunkRows: 2}, sealed, tail) | |||
| 83 | - | require.NoError(t, err) | |||
| 84 | - | require.Equal(t, []string{"cc"}, historyViewTexts(restored.View())) | |||
| 85 | - | require.Equal(t, 2, restored.Cells()) | |||
| 86 | - | } | |||
D pkg/vt/wrap_scroll_soft_test.go +0 -135
| @@ -1,135 +0,0 @@ | |||||
| 1 | - | package vt | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/stretchr/testify/require" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | // A deferred wrap out of the last row of a scroll region scrolls that region, | |||
| 10 | - | // and buffer.scrollUp deliberately severs the link on the last moved row. That | |||
| 11 | - | // sever is right for a linefeed and wrong when the wrap itself caused the | |||
| 12 | - | // scroll, so these cases pin both sides of the distinction. | |||
| 13 | - | func TestSoftLinkSurvivesTheScrollItCaused(t *testing.T) { | |||
| 14 | - | const width = 8 | |||
| 15 | - | cases := []struct { | |||
| 16 | - | name string | |||
| 17 | - | height int | |||
| 18 | - | input string | |||
| 19 | - | // wantBounds is indexed like the live grid. | |||
| 20 | - | wantBounds map[int]LineBound | |||
| 21 | - | // wantRows pins the grid text so a bound is never read off a row that | |||
| 22 | - | // holds something other than what the case describes. | |||
| 23 | - | wantRows map[int]string | |||
| 24 | - | }{ | |||
| 25 | - | { | |||
| 26 | - | // The bug: the cursor sits on the last row, as it does in any full | |||
| 27 | - | // shell session, so the wrap scrolls and the link must survive. | |||
| 28 | - | name: "narrow wrap out of the bottom row keeps the link", | |||
| 29 | - | height: 4, | |||
| 30 | - | input: "1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij", | |||
| 31 | - | wantBounds: map[int]LineBound{ | |||
| 32 | - | 2: {End: 8, Soft: true}, | |||
| 33 | - | 3: {End: 2}, | |||
| 34 | - | }, | |||
| 35 | - | wantRows: map[int]string{2: "abcdefgh", 3: "ij "}, | |||
| 36 | - | }, | |||
| 37 | - | { | |||
| 38 | - | // Same shape at the wide-rune wrap site: the wide rune does not fit | |||
| 39 | - | // in the last column, so the writer abandons that cell and wraps. | |||
| 40 | - | name: "wide-rune wrap out of the bottom row keeps the link", | |||
| 41 | - | height: 4, | |||
| 42 | - | input: "1\r\n2\r\n3\r\nabcdefg漢", | |||
| 43 | - | wantBounds: map[int]LineBound{ | |||
| 44 | - | 2: {End: 7, Soft: true}, | |||
| 45 | - | 3: {End: 2}, | |||
| 46 | - | }, | |||
| 47 | - | wantRows: map[int]string{2: "abcdefg "}, | |||
| 48 | - | }, | |||
| 49 | - | { | |||
| 50 | - | // The sever that must survive: a full-width row ending in an explicit | |||
| 51 | - | // newline is a hard line that merely happens to fill the grid. | |||
| 52 | - | name: "explicit newline after a full-width bottom row severs", | |||
| 53 | - | height: 4, | |||
| 54 | - | input: "1\r\n2\r\n3\r\nabcdefgh\r\n", | |||
| 55 | - | wantBounds: map[int]LineBound{ | |||
| 56 | - | 2: {End: 8, Soft: false}, | |||
| 57 | - | }, | |||
| 58 | - | wantRows: map[int]string{2: "abcdefgh"}, | |||
| 59 | - | }, | |||
| 60 | - | { | |||
| 61 | - | // A DECSTBM region scroll is a local mutation, not a wrap: it must | |||
| 62 | - | // still sever at the row above the region (top-1), whose | |||
| 63 | - | // continuation the scroll just destroyed, and at the last moved row. | |||
| 64 | - | name: "DECSTBM region scroll severs at both edges", | |||
| 65 | - | height: 4, | |||
| 66 | - | input: "abcdefghij" + "\x1b[2;3r" + "\x1b[3;1H" + "\n", | |||
| 67 | - | wantBounds: map[int]LineBound{ | |||
| 68 | - | 0: {End: 8, Soft: false}, | |||
| 69 | - | 1: {End: 0, Soft: false}, | |||
| 70 | - | }, | |||
| 71 | - | wantRows: map[int]string{0: "abcdefgh"}, | |||
| 72 | - | }, | |||
| 73 | - | { | |||
| 74 | - | // A region exactly one row tall blanks the wrapped row in place | |||
| 75 | - | // instead of moving it up, so no surviving grid row may be linked. | |||
| 76 | - | // setScrollRegion rejects top >= bottom, so the only reachable | |||
| 77 | - | // one-row region is a one-row screen. | |||
| 78 | - | name: "wrap on a one-row screen links nothing on the grid", | |||
| 79 | - | height: 1, | |||
| 80 | - | input: "abcdefghij", | |||
| 81 | - | wantBounds: map[int]LineBound{ | |||
| 82 | - | 0: {End: 2, Soft: false}, | |||
| 83 | - | }, | |||
| 84 | - | wantRows: map[int]string{0: "ij "}, | |||
| 85 | - | }, | |||
| 86 | - | } | |||
| 87 | - | for _, tc := range cases { | |||
| 88 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 89 | - | s := NewScreenWithHistory(width, tc.height, HistoryConfig{MaxRows: 64, MaxCells: 4096}) | |||
| 90 | - | s.Write([]byte(tc.input)) | |||
| 91 | - | ||||
| 92 | - | bounds := s.LineBounds() | |||
| 93 | - | for y, want := range tc.wantBounds { | |||
| 94 | - | require.Equal(t, want, bounds[y], "LineBounds()[%d]", y) | |||
| 95 | - | } | |||
| 96 | - | for y, want := range tc.wantRows { | |||
| 97 | - | require.Equal(t, want, lineText(s, y), "row %d text", y) | |||
| 98 | - | } | |||
| 99 | - | }) | |||
| 100 | - | } | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | // The soft link also drives reflow, so restoring it fixes resize too: a line | |||
| 104 | - | // wrapped out of the bottom row used to be two unrelated physical rows, and | |||
| 105 | - | // widening the pane left it split instead of rejoining it. | |||
| 106 | - | func TestResizeRejoinsALineWrappedOutOfTheBottomRow(t *testing.T) { | |||
| 107 | - | s := NewScreenWithHistory(8, 4, HistoryConfig{MaxRows: 64, MaxCells: 4096}) | |||
| 108 | - | s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij")) | |||
| 109 | - | ||||
| 110 | - | s.Resize(16, 4) | |||
| 111 | - | ||||
| 112 | - | require.Equal(t, "abcdefghij ", lineText(s, 2), "the wrapped line must rejoin on one row") | |||
| 113 | - | require.Equal(t, LineBound{End: 10}, s.LineBounds()[2]) | |||
| 114 | - | require.Equal(t, " ", lineText(s, 3), "its continuation row must be consumed by the rejoin") | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | // A wrap that scrolls the primary screen must hand history the same soft link | |||
| 118 | - | // it leaves on the grid, so a line wrapped at the bottom of a full pane still | |||
| 119 | - | // rejoins after it scrolls away. | |||
| 120 | - | func TestSoftLinkFromABottomRowWrapReachesHistory(t *testing.T) { | |||
| 121 | - | const ( | |||
| 122 | - | width = 8 | |||
| 123 | - | height = 4 | |||
| 124 | - | ) | |||
| 125 | - | s := NewScreenWithHistory(width, height, HistoryConfig{MaxRows: 64, MaxCells: 4096}) | |||
| 126 | - | // Wrap out of the bottom row, then push both physical rows into history. | |||
| 127 | - | s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij\r\nx\r\ny\r\nz\r\n")) | |||
| 128 | - | ||||
| 129 | - | view := s.History().SealAndView() | |||
| 130 | - | require.Equal(t, 7, view.Len(), "history row count") | |||
| 131 | - | require.Equal(t, "abcdefgh", rowString(view.BorrowedRow(5))) | |||
| 132 | - | require.Equal(t, LineBound{End: 8, Soft: true}, view.Bound(5), "the wrapped row must reach history soft") | |||
| 133 | - | require.Equal(t, "ij ", rowString(view.BorrowedRow(6))) | |||
| 134 | - | require.Equal(t, LineBound{End: 2, Soft: false}, view.Bound(6), "its continuation is a hard line end") | |||
| 135 | - | } | |||
M scripts/remote-picker-harness/visual_probe.go +1 -1
| @@ -9,9 +9,9 @@ import ( | |||||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | 11 | ||||
| 12 | + | vt "github.com/bnema/vev-vt" | |||
| 12 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 14 | - | "github.com/bnema/vev/pkg/vt" | |||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | const ( | 17 | const ( | ||
M transport_replay_integration_test.go +2 -2
| @@ -6,12 +6,12 @@ import ( | |||||
| 6 | "sync" | 6 | "sync" | ||
| 7 | "testing" | 7 | "testing" | ||
| 8 | 8 | ||||
| 9 | + | vt "github.com/bnema/vev-vt" | |||
| 10 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 9 | "github.com/bnema/vev/internal/adapters/ipc" | 11 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 10 | "github.com/bnema/vev/internal/adapters/sshstdio" | 12 | "github.com/bnema/vev/internal/adapters/sshstdio" | ||
| 11 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 12 | "github.com/bnema/vev/internal/testutil/replaytest" | 14 | "github.com/bnema/vev/internal/testutil/replaytest" | ||
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 14 | - | "github.com/bnema/vev/pkg/vt" | |||
| 15 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||