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 | 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 | 10 | require ( | |
| 8 | 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 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | + | renderer "github.com/bnema/vev-vt" | |
| 15 | 16 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 16 | 17 | "github.com/bnema/vev/internal/domain" | |
| 17 | 18 | "github.com/bnema/vev/internal/ports" | |
| 18 | - | "github.com/bnema/vev/pkg/renderer" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 21 | func previewClientTargetForTest() domain.RemoteSessionTarget { | |
M internal/adapters/snapshot/test_helpers_test.go +2 -2
| @@ -4,8 +4,8 @@ import ( | |||
| 4 | 4 | "path/filepath" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | - | "github.com/bnema/vev/pkg/vt" | |
| 7 | + | vt "github.com/bnema/vev-vt" | |
| 8 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | func privateDir(t *testing.T) string { | |
M internal/app/daemon_integration_test.go +1 -1
| @@ -19,6 +19,7 @@ import ( | |||
| 19 | 19 | ||
| 20 | 20 | "github.com/stretchr/testify/require" | |
| 21 | 21 | ||
| 22 | + | vt "github.com/bnema/vev-vt" | |
| 22 | 23 | "github.com/bnema/vev/internal/adapters/clock" | |
| 23 | 24 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 24 | 25 | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| @@ -29,7 +30,6 @@ import ( | |||
| 29 | 30 | "github.com/bnema/vev/internal/ports" | |
| 30 | 31 | "github.com/bnema/vev/internal/usecase/daemon" | |
| 31 | 32 | "github.com/bnema/vev/internal/usecase/recovery" | |
| 32 | - | "github.com/bnema/vev/pkg/vt" | |
| 33 | 33 | ) | |
| 34 | 34 | ||
| 35 | 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 | 3 | import ( | |
| 4 | 4 | "time" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | // HandshakeTimeout bounds every transport handshake from connect through the | |
M internal/ports/remote_preview.go +1 -1
| @@ -6,8 +6,8 @@ import ( | |||
| 6 | 6 | "unicode" | |
| 7 | 7 | "unicode/utf8" | |
| 8 | 8 | ||
| 9 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | const ( | |
M internal/ports/remote_preview_hardening_test.go +1 -1
| @@ -6,8 +6,8 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | const ( | |
M internal/ports/remote_preview_test.go +1 -1
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | func previewTargetForTest() domain.RemoteSessionTarget { | |
M internal/ports/wire.go +1 -1
| @@ -12,8 +12,8 @@ import ( | |||
| 12 | 12 | "fmt" | |
| 13 | 13 | "math" | |
| 14 | 14 | ||
| 15 | + | renderer "github.com/bnema/vev-vt" | |
| 15 | 16 | "github.com/bnema/vev/internal/domain" | |
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 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 | 7 | "reflect" | |
| 8 | 8 | "testing" | |
| 9 | 9 | ||
| 10 | + | renderer "github.com/bnema/vev-vt" | |
| 10 | 11 | "github.com/bnema/vev/internal/domain" | |
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
M internal/usecase/client/client.go +1 -1
| @@ -19,10 +19,10 @@ import ( | |||
| 19 | 19 | "sync/atomic" | |
| 20 | 20 | "time" | |
| 21 | 21 | ||
| 22 | + | renderer "github.com/bnema/vev-vt" | |
| 22 | 23 | "github.com/bnema/vev/internal/domain" | |
| 23 | 24 | "github.com/bnema/vev/internal/ports" | |
| 24 | 25 | "github.com/bnema/vev/internal/usecase/theme" | |
| 25 | - | "github.com/bnema/vev/pkg/renderer" | |
| 26 | 26 | ) | |
| 27 | 27 | ||
| 28 | 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 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ||
| 16 | + | renderer "github.com/bnema/vev-vt" | |
| 16 | 17 | "github.com/bnema/vev/internal/domain" | |
| 17 | 18 | "github.com/bnema/vev/internal/ports" | |
| 18 | - | "github.com/bnema/vev/pkg/renderer" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 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 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | + | renderer "github.com/bnema/vev-vt" | |
| 14 | 15 | "github.com/bnema/vev/internal/domain" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | type paletteAttachClock struct{} | |
M internal/usecase/client/palette_generation.go +1 -1
| @@ -3,8 +3,8 @@ package client | |||
| 3 | 3 | import ( | |
| 4 | 4 | "time" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/ports" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 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 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ||
| 9 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | // manualPaletteClock applies only declarative coordinator timer actions. Its | |
M internal/usecase/client/reconnect_toast.go +1 -1
| @@ -5,9 +5,9 @@ import ( | |||
| 5 | 5 | "io" | |
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/ui" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | const reconnectToastMessage = "offline; retrying…" | |
M internal/usecase/client/reconnect_toast_test.go +1 -1
| @@ -16,10 +16,10 @@ import ( | |||
| 16 | 16 | "github.com/stretchr/testify/mock" | |
| 17 | 17 | "github.com/stretchr/testify/require" | |
| 18 | 18 | ||
| 19 | + | renderer "github.com/bnema/vev-vt" | |
| 19 | 20 | "github.com/bnema/vev/internal/domain" | |
| 20 | 21 | "github.com/bnema/vev/internal/ports" | |
| 21 | 22 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 22 | - | "github.com/bnema/vev/pkg/renderer" | |
| 23 | 23 | ) | |
| 24 | 24 | ||
| 25 | 25 | type reconnectToastTerminalHarness struct { | |
M internal/usecase/copy/document.go +2 -2
| @@ -4,8 +4,8 @@ import ( | |||
| 4 | 4 | "strings" | |
| 5 | 5 | "unicode" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | - | "github.com/bnema/vev/pkg/vt" | |
| 7 | + | vt "github.com/bnema/vev-vt" | |
| 8 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 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 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |
| 7 | - | "github.com/bnema/vev/pkg/vt" | |
| 6 | + | vt "github.com/bnema/vev-vt" | |
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
M internal/usecase/copy/mode.go +2 -2
| @@ -7,8 +7,8 @@ import ( | |||
| 7 | 7 | "strings" | |
| 8 | 8 | "unicode" | |
| 9 | 9 | ||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | - | "github.com/bnema/vev/pkg/vt" | |
| 10 | + | vt "github.com/bnema/vev-vt" | |
| 11 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | const OSC52MaxPayloadBytes = 75_000 | |
M internal/usecase/copy/mode_test.go +2 -2
| @@ -6,9 +6,9 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | vt "github.com/bnema/vev-vt" | |
| 10 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 9 | 11 | "github.com/bnema/vev/internal/domain" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | - | "github.com/bnema/vev/pkg/vt" | |
| 12 | 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 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
M internal/usecase/copy/selection.go +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | package copy | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import renderer "github.com/bnema/vev-vt" | |
| 4 | 4 | ||
| 5 | 5 | // Granularity controls how a selection's endpoints become display-cell ranges. | |
| 6 | 6 | type Granularity uint8 | |
M internal/usecase/copy/selection_test.go +1 -1
| @@ -3,7 +3,7 @@ package copy | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 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 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | + | renderer "github.com/bnema/vev-vt" | |
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/keys" | |
| 15 | - | "github.com/bnema/vev/pkg/renderer" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 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 | 5 | "cmp" | |
| 6 | 6 | "slices" | |
| 7 | 7 | ||
| 8 | + | vt "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/pkg/vt" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 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 | 10 | "github.com/stretchr/testify/mock" | |
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | + | renderer "github.com/bnema/vev-vt" | |
| 13 | 14 | "github.com/bnema/vev/internal/domain" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | 17 | "github.com/bnema/vev/internal/usecase/keys" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| 18 | 19 | "github.com/bnema/vev/internal/usecase/ui" | |
| 19 | - | "github.com/bnema/vev/pkg/renderer" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | 22 | func TestNoteAttentionFlagsBackgroundTab(t *testing.T) { | |
M internal/usecase/daemon/config_test.go +1 -1
| @@ -6,11 +6,11 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ||
| 9 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/ports" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/keys" | |
| 12 | 13 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | type captureKeyHandler struct { | |
M internal/usecase/daemon/copy_mouse_map_test.go +1 -1
| @@ -5,10 +5,10 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | func TestCopyMouseMapTopBarAndContent(t *testing.T) { | |
M internal/usecase/daemon/copymode.go +1 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "strconv" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| @@ -13,7 +14,6 @@ import ( | |||
| 13 | 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/ui" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 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 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ||
| 10 | + | renderer "github.com/bnema/vev-vt" | |
| 10 | 11 | "github.com/bnema/vev/internal/domain" | |
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 12 | 13 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | type manualMouseClock struct{ now time.Time } | |
M internal/usecase/daemon/copymode_test.go +2 -2
| @@ -13,14 +13,14 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/mock" | |
| 14 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ||
| 16 | + | vt "github.com/bnema/vev-vt" | |
| 17 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 16 | 18 | "github.com/bnema/vev/internal/domain" | |
| 17 | 19 | "github.com/bnema/vev/internal/ports" | |
| 18 | 20 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | 21 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 20 | 22 | "github.com/bnema/vev/internal/usecase/layout" | |
| 21 | 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 | 26 | // --- test doubles ----------------------------------------------------------- | |
M internal/usecase/daemon/daemon_test.go +2 -2
| @@ -11,12 +11,12 @@ import ( | |||
| 11 | 11 | "github.com/stretchr/testify/mock" | |
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | + | vt "github.com/bnema/vev-vt" | |
| 15 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 14 | 16 | "github.com/bnema/vev/internal/domain" | |
| 15 | 17 | "github.com/bnema/vev/internal/ports" | |
| 16 | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | 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 | 22 | // --- test doubles ----------------------------------------------------------- | |
M internal/usecase/daemon/final_acceptance_test.go +1 -1
| @@ -3,10 +3,10 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | vt "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/bnema/vev/internal/ports" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 9 | - | "github.com/bnema/vev/pkg/vt" | |
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
M internal/usecase/daemon/floating_render.go +1 -1
| @@ -1,10 +1,10 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 4 | 5 | "github.com/bnema/vev/internal/domain" | |
| 5 | 6 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 6 | 7 | "github.com/bnema/vev/internal/usecase/ui" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 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 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | 15 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/ui" | |
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // applyFloatingResizePlanForTest exercises the transactional apply primitive. | |
M internal/usecase/daemon/history_test_helper_test.go +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/vt" | |
| 3 | + | import vt "github.com/bnema/vev-vt" | |
| 4 | 4 | ||
| 5 | 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 | 13 | "github.com/stretchr/testify/mock" | |
| 14 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ||
| 16 | + | vt "github.com/bnema/vev-vt" | |
| 16 | 17 | "github.com/bnema/vev/internal/domain" | |
| 17 | 18 | "github.com/bnema/vev/internal/ports" | |
| 18 | 19 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| @@ -22,7 +23,6 @@ import ( | |||
| 22 | 23 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 23 | 24 | "github.com/bnema/vev/internal/usecase/picker" | |
| 24 | 25 | "github.com/bnema/vev/internal/usecase/ui" | |
| 25 | - | "github.com/bnema/vev/pkg/vt" | |
| 26 | 26 | ) | |
| 27 | 27 | ||
| 28 | 28 | // --- test doubles ----------------------------------------------------------- | |
M internal/usecase/daemon/modal_compose.go +1 -1
| @@ -1,10 +1,10 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 4 | 5 | "github.com/bnema/vev/internal/domain" | |
| 5 | 6 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 6 | 7 | "github.com/bnema/vev/internal/usecase/ui" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 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 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 14 | - | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) { | |
M internal/usecase/daemon/notice_render_test.go +1 -1
| @@ -7,9 +7,9 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ||
| 10 | + | vt "github.com/bnema/vev-vt" | |
| 10 | 11 | "github.com/bnema/vev/internal/domain" | |
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 12 | - | "github.com/bnema/vev/pkg/vt" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 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 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | + | renderer "github.com/bnema/vev-vt" | |
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/notices" | |
| 15 | 16 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | func TestCaptureNoticesOverlayUsesTerminalBackgroundOutsideSelection(t *testing.T) { | |
M internal/usecase/daemon/notify_test.go +1 -1
| @@ -12,10 +12,10 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | + | vt "github.com/bnema/vev-vt" | |
| 15 | 16 | "github.com/bnema/vev/internal/domain" | |
| 16 | 17 | "github.com/bnema/vev/internal/ports" | |
| 17 | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | - | "github.com/bnema/vev/pkg/vt" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 21 | func TestReportAttachmentErrorPreservesLocalOrNilScope(t *testing.T) { | |
M internal/usecase/daemon/output_state_stream.go +1 -1
| @@ -5,9 +5,9 @@ import ( | |||
| 5 | 5 | "sync" | |
| 6 | 6 | "sync/atomic" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 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 | 5 | "runtime" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | + | vt "github.com/bnema/vev-vt" | |
| 9 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | 10 | "github.com/bnema/vev/internal/domain" | |
| 9 | 11 | "github.com/bnema/vev/internal/ports" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | - | "github.com/bnema/vev/pkg/vt" | |
| 12 | 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 | 8 | "testing" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | + | vt "github.com/bnema/vev-vt" | |
| 12 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 11 | 13 | "github.com/bnema/vev/internal/domain" | |
| 12 | 14 | "github.com/bnema/vev/internal/ports" | |
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |
| 14 | - | "github.com/bnema/vev/pkg/vt" | |
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
M internal/usecase/daemon/overlay_backdrop.go +1 -1
| @@ -1,9 +1,9 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 4 | 5 | "github.com/bnema/vev/internal/domain" | |
| 5 | 6 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 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 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func backdropTheme() themeui.Theme { | |
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
| @@ -5,11 +5,11 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 10 | 11 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 12 | - | "github.com/bnema/vev/pkg/renderer" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | func TestOverlayRuntimeActive(t *testing.T) { | |
M internal/usecase/daemon/palette.go +1 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "sort" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/command" | |
| @@ -13,7 +14,6 @@ import ( | |||
| 13 | 14 | "github.com/bnema/vev/internal/usecase/picker" | |
| 14 | 15 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/ui" | |
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | const ( | |
M internal/usecase/daemon/palette_test.go +1 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/stretchr/testify/mock" | |
| 9 | 9 | "github.com/stretchr/testify/require" | |
| 10 | 10 | ||
| 11 | + | renderer "github.com/bnema/vev-vt" | |
| 11 | 12 | "github.com/bnema/vev/internal/domain" | |
| 12 | 13 | "github.com/bnema/vev/internal/ports" | |
| 13 | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| @@ -15,7 +16,6 @@ import ( | |||
| 15 | 16 | "github.com/bnema/vev/internal/usecase/palette" | |
| 16 | 17 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/ui" | |
| 18 | - | "github.com/bnema/vev/pkg/renderer" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 21 | func testRecentRouteSnapshot() ports.RecentRouteSnapshot { | |
M internal/usecase/daemon/pane.go +1 -1
| @@ -5,10 +5,10 @@ import ( | |||
| 5 | 5 | "sync" | |
| 6 | 6 | "sync/atomic" | |
| 7 | 7 | ||
| 8 | + | vt "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | - | "github.com/bnema/vev/pkg/vt" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 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 | 12 | "github.com/stretchr/testify/mock" | |
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | + | renderer "github.com/bnema/vev-vt" | |
| 15 | 16 | "github.com/bnema/vev/internal/domain" | |
| 16 | 17 | "github.com/bnema/vev/internal/ports" | |
| 17 | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | 19 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 19 | 20 | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | - | "github.com/bnema/vev/pkg/renderer" | |
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | func TestLayoutGenerationMutationBookkeeping(t *testing.T) { | |
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
| @@ -12,11 +12,11 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | + | renderer "github.com/bnema/vev-vt" | |
| 15 | 16 | "github.com/bnema/vev/internal/domain" | |
| 16 | 17 | "github.com/bnema/vev/internal/ports" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| 18 | 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 19 | - | "github.com/bnema/vev/pkg/renderer" | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | 22 | func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) { | |
M internal/usecase/daemon/picker.go +1 -1
| @@ -7,12 +7,12 @@ import ( | |||
| 7 | 7 | "sort" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | renderer "github.com/bnema/vev-vt" | |
| 10 | 11 | "github.com/bnema/vev/internal/domain" | |
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 12 | 13 | "github.com/bnema/vev/internal/usecase/layout" | |
| 13 | 14 | "github.com/bnema/vev/internal/usecase/picker" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/ui" | |
| 15 | - | "github.com/bnema/vev/pkg/renderer" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 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 | 11 | "github.com/stretchr/testify/mock" | |
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 14 | 15 | "github.com/bnema/vev/internal/domain" | |
| 15 | 16 | "github.com/bnema/vev/internal/persist" | |
| 16 | 17 | "github.com/bnema/vev/internal/ports" | |
| @@ -19,7 +20,6 @@ import ( | |||
| 19 | 20 | "github.com/bnema/vev/internal/usecase/picker" | |
| 20 | 21 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 21 | 22 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 22 | - | "github.com/bnema/vev/pkg/renderer" | |
| 23 | 23 | ) | |
| 24 | 24 | ||
| 25 | 25 | var ( | |
M internal/usecase/daemon/remote_preview.go +1 -1
| @@ -3,9 +3,9 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/bnema/vev/internal/ports" | |
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 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 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | + | renderer "github.com/bnema/vev-vt" | |
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/picker" | |
| 15 | - | "github.com/bnema/vev/pkg/renderer" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | type remotePreviewTestClock struct { | |
M internal/usecase/daemon/remote_preview_capture_test.go +2 -2
| @@ -5,10 +5,10 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | + | vt "github.com/bnema/vev-vt" | |
| 9 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | 10 | "github.com/bnema/vev/internal/domain" | |
| 9 | 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 | 14 | func TestCaptureRemotePreviewUsesBottomRowsForShorterPreview(t *testing.T) { | |
M internal/usecase/daemon/render.go +1 -1
| @@ -4,11 +4,11 @@ package daemon | |||
| 4 | 4 | import ( | |
| 5 | 5 | "bytes" | |
| 6 | 6 | ||
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 11 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 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 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 4 | 5 | "github.com/bnema/vev/internal/domain" | |
| 5 | 6 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 6 | 7 | "github.com/bnema/vev/internal/usecase/layout" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/picker" | |
| 8 | 9 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/ui" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 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 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/bnema/vev/internal/ports" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/picker" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 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 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 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 | 4 | "errors" | |
| 5 | 5 | "strconv" | |
| 6 | 6 | ||
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -14,7 +15,6 @@ import ( | |||
| 14 | 15 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/ui" | |
| 16 | 17 | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 17 | - | "github.com/bnema/vev/pkg/renderer" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 20 | 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 | 6 | "maps" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/ports" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 13 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | 14 | "github.com/bnema/vev/internal/usecase/ui" | |
| 14 | - | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 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 | 11 | "testing" | |
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | + | vt "github.com/bnema/vev-vt" | |
| 15 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 14 | 16 | "github.com/bnema/vev/internal/domain" | |
| 15 | 17 | "github.com/bnema/vev/internal/ports" | |
| 16 | 18 | "github.com/bnema/vev/internal/testutil/replaytest" | |
| @@ -23,8 +25,6 @@ import ( | |||
| 23 | 25 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 24 | 26 | "github.com/bnema/vev/internal/usecase/ui" | |
| 25 | 27 | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 26 | - | "github.com/bnema/vev/pkg/renderer" | |
| 27 | - | "github.com/bnema/vev/pkg/vt" | |
| 28 | 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 | 16 | "github.com/stretchr/testify/mock" | |
| 17 | 17 | "github.com/stretchr/testify/require" | |
| 18 | 18 | ||
| 19 | + | vt "github.com/bnema/vev-vt" | |
| 20 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 19 | 21 | "github.com/bnema/vev/internal/domain" | |
| 20 | 22 | "github.com/bnema/vev/internal/ports" | |
| 21 | 23 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 22 | 24 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 23 | 25 | "github.com/bnema/vev/internal/usecase/layout" | |
| 24 | 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 | 29 | func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | |
M internal/usecase/daemon/resume_test.go +1 -1
| @@ -11,11 +11,11 @@ import ( | |||
| 11 | 11 | "github.com/stretchr/testify/mock" | |
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | + | vt "github.com/bnema/vev-vt" | |
| 14 | 15 | "github.com/bnema/vev/internal/domain" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 16 | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| 18 | - | "github.com/bnema/vev/pkg/vt" | |
| 19 | 19 | ) | |
| 20 | 20 | ||
| 21 | 21 | type closeTrackingTransport struct { | |
M internal/usecase/daemon/snapshot.go +1 -1
| @@ -7,10 +7,10 @@ import ( | |||
| 7 | 7 | "sync" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | vt "github.com/bnema/vev-vt" | |
| 10 | 11 | "github.com/bnema/vev/internal/domain" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 13 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 13 | - | "github.com/bnema/vev/pkg/vt" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 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 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |
| 10 | - | "github.com/bnema/vev/pkg/vt" | |
| 9 | + | vt "github.com/bnema/vev-vt" | |
| 10 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func TestSnapshotChunkCacheIsScopedToNamedSession(t *testing.T) { | |
M internal/usecase/daemon/snapshot_incremental.go +1 -1
| @@ -3,10 +3,10 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | ||
| 6 | + | vt "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/bnema/vev/internal/ports" | |
| 8 | 9 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 9 | - | "github.com/bnema/vev/pkg/vt" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 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 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ||
| 10 | + | vt "github.com/bnema/vev-vt" | |
| 11 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 10 | 12 | "github.com/bnema/vev/internal/domain" | |
| 11 | 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 | 16 | func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) { | |
M internal/usecase/daemon/snapshot_restore.go +1 -1
| @@ -7,11 +7,11 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "math" | |
| 9 | 9 | ||
| 10 | + | vt "github.com/bnema/vev-vt" | |
| 10 | 11 | "github.com/bnema/vev/internal/domain" | |
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 12 | 13 | "github.com/bnema/vev/internal/usecase/layout" | |
| 13 | 14 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 14 | - | "github.com/bnema/vev/pkg/vt" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | func (d *Daemon) closeRestoreDone() { | |
M internal/usecase/daemon/snapshot_restore_test.go +2 -2
| @@ -11,14 +11,14 @@ import ( | |||
| 11 | 11 | "github.com/stretchr/testify/mock" | |
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | + | vt "github.com/bnema/vev-vt" | |
| 15 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 14 | 16 | "github.com/bnema/vev/internal/domain" | |
| 15 | 17 | "github.com/bnema/vev/internal/ports" | |
| 16 | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | 19 | "github.com/bnema/vev/internal/usecase/layout" | |
| 18 | 20 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 19 | 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 | 24 | type snapshotAcceptanceRepository struct { | |
M internal/usecase/daemon/snapshot_test.go +1 -1
| @@ -9,11 +9,11 @@ import ( | |||
| 9 | 9 | "github.com/stretchr/testify/mock" | |
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | + | renderer "github.com/bnema/vev-vt" | |
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| 14 | 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | 16 | recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" | |
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // TestWithSnapshotRepositoryRejectsTypedNil verifies typed nil repositories are disabled. | |
M internal/usecase/daemon/status.go +1 -1
| @@ -6,13 +6,13 @@ import ( | |||
| 6 | 6 | "strconv" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/ports" | |
| 11 | 12 | "github.com/bnema/vev/internal/usecase/command" | |
| 12 | 13 | "github.com/bnema/vev/internal/usecase/palette" | |
| 13 | 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/ui" | |
| 15 | - | "github.com/bnema/vev/pkg/renderer" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | const ( | |
M internal/usecase/daemon/status_test.go +1 -1
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ||
| 14 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 14 | 15 | "github.com/bnema/vev/internal/domain" | |
| 15 | 16 | "github.com/bnema/vev/internal/ports" | |
| 16 | 17 | "github.com/bnema/vev/internal/usecase/command" | |
| @@ -19,7 +20,6 @@ import ( | |||
| 19 | 20 | "github.com/bnema/vev/internal/usecase/picker" | |
| 20 | 21 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 21 | 22 | "github.com/bnema/vev/internal/usecase/ui" | |
| 22 | - | "github.com/bnema/vev/pkg/renderer" | |
| 23 | 23 | ) | |
| 24 | 24 | ||
| 25 | 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 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 12 | - | "github.com/bnema/vev/pkg/renderer" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | func cacheTheme() themeui.Theme { | |
M internal/usecase/daemon/theme_integration_test.go +1 -1
| @@ -5,10 +5,10 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 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 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | vt "github.com/bnema/vev-vt" | |
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 6 | 8 | "github.com/bnema/vev/internal/domain" | |
| 7 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 9 | - | "github.com/bnema/vev/pkg/vt" | |
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
M internal/usecase/notices/model.go +1 -1
| @@ -6,9 +6,9 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/ui" | |
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 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 | 6 | "testing" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | func testStyles() RenderStyles { | |
M internal/usecase/palette/model.go +1 -1
| @@ -4,10 +4,10 @@ import ( | |||
| 4 | 4 | "unicode" | |
| 5 | 5 | "unicode/utf8" | |
| 6 | 6 | ||
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/command" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/ui" | |
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | type RenderStyles struct { | |
M internal/usecase/palette/model_test.go +1 -1
| @@ -5,10 +5,10 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/usecase/command" | |
| 10 | 11 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
M internal/usecase/picker/model.go +1 -1
| @@ -1,9 +1,9 @@ | |||
| 1 | 1 | package picker | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 4 | 5 | "github.com/bnema/vev/internal/domain" | |
| 5 | 6 | "github.com/bnema/vev/internal/usecase/ui" | |
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | const ( | |
M internal/usecase/picker/model_test.go +1 -1
| @@ -5,10 +5,10 @@ import ( | |||
| 5 | 5 | "strings" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/ui" | |
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 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 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func remoteParityLifecycle(seed byte) domain.SessionLifecycleID { | |
M internal/usecase/prompt/model.go +1 -1
| @@ -1,9 +1,9 @@ | |||
| 1 | 1 | package prompt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 4 | 5 | "github.com/bnema/vev/internal/domain" | |
| 5 | 6 | "github.com/bnema/vev/internal/usecase/ui" | |
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 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 | 4 | "strings" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 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 | 9 | "strconv" | |
| 10 | 10 | "testing" | |
| 11 | 11 | ||
| 12 | + | vt "github.com/bnema/vev-vt" | |
| 13 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 12 | 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 | 17 | func TestPayloadReaderGetBytes(t *testing.T) { | |
M internal/usecase/snapshot/preflight.go +1 -1
| @@ -4,8 +4,8 @@ import ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "math" | |
| 6 | 6 | ||
| 7 | + | vt "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | - | "github.com/bnema/vev/pkg/vt" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | func preflightSession(body []byte) error { | |
M internal/usecase/snapshot/preflight_acceptance_test.go +1 -1
| @@ -4,8 +4,8 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 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 | 1 | package theme | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 4 | 5 | "github.com/bnema/vev/internal/domain" | |
| 5 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | const ( | |
M internal/usecase/theme/accent_resolve_test.go +1 -1
| @@ -4,8 +4,8 @@ import ( | |||
| 4 | 4 | "math/rand" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 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 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | var ( | |
M internal/usecase/theme/color_oklab.go +1 -1
| @@ -3,7 +3,7 @@ package theme | |||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 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 | 4 | "math" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | func TestRGBToOKLabKnownValues(t *testing.T) { | |
M internal/usecase/theme/palette_scan_test.go +1 -1
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "strconv" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |
| 9 | + | renderer "github.com/bnema/vev-vt" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | type paletteEvent struct { | |
M internal/usecase/theme/phase3_review_test.go +1 -1
| @@ -3,8 +3,8 @@ package theme | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
M internal/usecase/theme/ramp.go +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | package theme | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import renderer "github.com/bnema/vev-vt" | |
| 4 | 4 | ||
| 5 | 5 | const ( | |
| 6 | 6 | normalTextContrast = 4.5 | |
M internal/usecase/theme/ramp_test.go +1 -1
| @@ -4,8 +4,8 @@ import ( | |||
| 4 | 4 | "math" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 9 | "github.com/stretchr/testify/require" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
M internal/usecase/theme/resolved_styles.go +1 -1
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | package theme | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 4 | 5 | "github.com/bnema/vev/internal/domain" | |
| 5 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | const neutralAccentBlend = 0.25 | |
M internal/usecase/theme/styles.go +1 -1
| @@ -3,8 +3,8 @@ package theme | |||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | const accentContrastMin = 3.0 | |
M internal/usecase/theme/styles_test.go +1 -1
| @@ -3,7 +3,7 @@ package theme | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
M internal/usecase/theme/theme.go +2 -2
| @@ -5,8 +5,8 @@ import ( | |||
| 5 | 5 | "strconv" | |
| 6 | 6 | "strings" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 9 | - | "github.com/bnema/vev/pkg/vt" | |
| 8 | + | vt "github.com/bnema/vev-vt" | |
| 9 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | const maxPending = 64 | |
M internal/usecase/theme/theme_test.go +1 -1
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 5 | "reflect" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | 9 | "github.com/stretchr/testify/require" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
M internal/usecase/ui/frame.go +1 -1
| @@ -1,8 +1,8 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 4 | 5 | "github.com/bnema/vev/internal/domain" | |
| 5 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 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 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 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 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 4 | 5 | "github.com/bnema/vev/internal/domain" | |
| 5 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 8 | // Modal describes a rectangular overlay. | |
M internal/usecase/ui/modal_test.go +1 -1
| @@ -3,8 +3,8 @@ package ui | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
M internal/usecase/ui/notice.go +1 -1
| @@ -4,8 +4,8 @@ import ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "strings" | |
| 6 | 6 | ||
| 7 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | const ( | |
M internal/usecase/ui/notice_test.go +1 -1
| @@ -5,8 +5,8 @@ import ( | |||
| 5 | 5 | "strings" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 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 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 4 | 5 | "github.com/bnema/vev/internal/domain" | |
| 5 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | 6 | ) | |
| 7 | 7 | ||
| 8 | 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 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | func TestDrawSeparator(t *testing.T) { | |
M internal/usecase/ui/textinput.go +1 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/vev/pkg/renderer" | |
| 3 | + | import renderer "github.com/bnema/vev-vt" | |
| 4 | 4 | ||
| 5 | 5 | // TextInput stores editable text as runes for terminal overlay input models. | |
| 6 | 6 | type TextInput struct { | |
M internal/usecase/ui/textinput_test.go +1 -1
| @@ -3,7 +3,7 @@ package ui | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | 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 | 3 | import ( | |
| 4 | 4 | "time" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | const ( | |
M internal/usecase/ui/toast_test.go +1 -1
| @@ -5,8 +5,8 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | + | renderer "github.com/bnema/vev-vt" | |
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestToastManagerDurationDismissAndClear(t *testing.T) { | |
M internal/usecase/visualsearch/model.go +1 -1
| @@ -3,10 +3,10 @@ package visualsearch | |||
| 3 | 3 | import ( | |
| 4 | 4 | "strconv" | |
| 5 | 5 | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 6 | 7 | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | type Model struct { | |
M internal/usecase/visualsearch/model_test.go +1 -1
| @@ -6,9 +6,9 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ||
| 9 | + | renderer "github.com/bnema/vev-vt" | |
| 9 | 10 | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 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 | 9 | "path/filepath" | |
| 10 | 10 | "strings" | |
| 11 | 11 | ||
| 12 | + | vt "github.com/bnema/vev-vt" | |
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| 14 | - | "github.com/bnema/vev/pkg/vt" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | 17 | const ( | |
M transport_replay_integration_test.go +2 -2
| @@ -6,12 +6,12 @@ import ( | |||
| 6 | 6 | "sync" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | + | vt "github.com/bnema/vev-vt" | |
| 10 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 9 | 11 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 10 | 12 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 11 | 13 | "github.com/bnema/vev/internal/ports" | |
| 12 | 14 | "github.com/bnema/vev/internal/testutil/replaytest" | |
| 13 | - | "github.com/bnema/vev/pkg/renderer" | |
| 14 | - | "github.com/bnema/vev/pkg/vt" | |
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | 16 | ) | |
| 17 | 17 | ||