detached 32ba649

Merge pull request #189 from bnema/refactor/standalone-vev-vt-extraction

refactor(vt): extract VT engine into standalone module
commit
32ba6497df2b219507b31bf1dedbbacdd7c33a5a
author
bnema · · 2026-08-10 05:23 UTC
committer
GitHub · · 2026-08-10 05:23 UTC
merge of
e9e3f59 + 6820eef · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
190 files · +358 -14851
A docs/vt-extraction-preparation.md +39 -0
@@ -0,0 +1,39 @@
1+# VT extraction preparation checkpoint
2+
3+The standalone module owns the frontend-neutral VT engine and ANSI renderer;
4+vev consumes the released `github.com/bnema/vev-vt v0.2.1` module. VT behavior,
5+renderer output, wire bytes, transport, ACK/rebase, and cursor-tail policy remain
6+unchanged.
7+
8+## Evidence
9+
10+- The feature worktree was created from a clean checkout.
11+- `make test` passes (`go test ./... -race`).
12+- `make lint` passes (`goimports` has no changes and `go vet ./...` passes).
13+- External-package contract tests pass under `-race` for parsing, callbacks,
14+ borrowing, snapshots, history views, stable chunk identity, and single-owner
15+ usage.
16+- VTH3 golden vectors round-trip byte-for-byte across empty, plain,
17+ continuation, styled/RGB, bounded, and line-bound cases. Truncation,
18+ corruption, invalid fields, and trailing bytes are rejected.
19+- The VEVS v4 restore envelope golden decodes through the existing restore path,
20+ including sealed history, tail history, and recovery transcript blobs.
21+ Truncated, corrupted, trailing, and malformed nested VT payloads are rejected.
22+- Representative VT/history/snapshot benchmarks pass; benchmark output is
23+ retained in the implementation run rather than committed as machine-specific
24+ data.
25+- `git diff --check` passes.
26+- The standalone module owns the frontend-neutral Cell, Style, RGB, Frame,
27+ Damage, RuneWidth, VT parser, history, snapshots, and ANSI output packages.
28+- The cutover has no local VT, core, ANSI, or renderer implementation owner;
29+ vev resolves the released module through normal module download semantics.
30+- A standalone core-only frame consumer test builds ANSI output without
31+ importing the VT parser.
32+
33+## Scope guard
34+
35+The vev cutover changes only module ownership and import paths; no VT parser
36+behavior, VTH3/VEVS bytes, or wire/protocol definition changed. There is no
37+committed `replace` directive or compatibility owner in vev. Any unrelated
38+behavior, wire, or dependency change discovered while maintaining the standalone
39+module must be split into a separate plan.
A docs/vt-extraction-verification.md +44 -0
@@ -0,0 +1,44 @@
1+# VT extraction verification evidence
2+
3+## Release
4+
5+- Module: `github.com/bnema/vev-vt v0.2.1`
6+- Module checksum: `h1:sb5XWVVIgiYd82KSYb3gHvkHVt9r5oRoEK0hqO1B+Aw=`
7+- Go module checksum: `h1:QdV0QI7vUnlAF+7+CiPhThHDpDSkxMEZmCYl/Eo+xCA=`
8+- Standalone release commit: `7bd1a3c4`
9+- vev cutover commit: `3fd4a02d`
10+
11+## Acceptance evidence
12+
13+- vev: `make test`, `make lint`, `go mod verify`, and clean-checkout module
14+ resolution pass without a `replace` directive.
15+- Standalone module: tests, race tests, `go vet`, `golangci-lint`, external
16+ consumer smoke, import-boundary checks, and signed release tag pass.
17+- Fuzz smoke passes for VT input, ANSI rendering, VTH3 decoding, and VEVS v4
18+ restore decoding. Seeds include escape sequences, Unicode, clipboard input,
19+ malformed history, and the VEVS restore golden.
20+- VTH3 and VEVS fixtures remain byte-compatible. VEVS framing and session policy
21+ remain vev-owned.
22+- History/codec benchmark allocations and bytes are unchanged in the selected
23+ pre/post comparison. Representative daemon frame benchmarks remained within
24+ normal run variance; no output-byte regression was observed.
25+
26+## Diff classification
27+
28+- **Extraction:** standalone module release, vev module dependency cutover, and
29+ removal of the in-repository VT/core/ANSI owners.
30+- **Tests and fixtures:** external ownership contracts, VTH3 golden vectors,
31+ VEVS restore fixtures, and fuzz smoke coverage.
32+- **Documentation:** ownership, callback, borrowing, release, and verification
33+ records.
34+- **CI:** standalone formatting, lint, race, fuzz, benchmark, and consumer jobs.
35+
36+## Risks and follow-up
37+
38+- A broad baseline run including `BenchmarkScreenResizeReflowViewport` still
39+ exposes the pre-existing `invalid history row ID` benchmark panic. The
40+ selected extraction comparison excludes that benchmark rather than masking it.
41+- There is no dedicated standalone `RuneWidth` benchmark; width behavior is
42+ covered by public contract tests and the screen/parser benchmark suite.
43+- The outer VEVS codec intentionally remains in vev and must not move into the
44+ standalone module.
M go.mod +4 -1
@@ -2,7 +2,10 @@ module github.com/bnema/vev
2 2
3 go 1.263 go 1.26
4 4
5-require github.com/stretchr/testify v1.11.15+require (
6+ github.com/bnema/vev-vt v0.2.1
7+ github.com/stretchr/testify v1.11.1
8+)
6 9
7 require (10 require (
8 github.com/davecgh/go-spew v1.1.1 // indirect11 github.com/davecgh/go-spew v1.1.1 // indirect
M go.sum +2 -0 generated file: collapsed
M internal/adapters/remote/preview_test.go +1 -1
@@ -12,10 +12,10 @@ import (
12 12
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15+ renderer "github.com/bnema/vev-vt"
15 "github.com/bnema/vev/internal/adapters/sshstdio"16 "github.com/bnema/vev/internal/adapters/sshstdio"
16 "github.com/bnema/vev/internal/domain"17 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18- "github.com/bnema/vev/pkg/renderer"
19 )19 )
20 20
21 func previewClientTargetForTest() domain.RemoteSessionTarget {21 func previewClientTargetForTest() domain.RemoteSessionTarget {
M internal/adapters/snapshot/test_helpers_test.go +2 -2
@@ -4,8 +4,8 @@ import (
4 "path/filepath"4 "path/filepath"
5 "testing"5 "testing"
6 6
7- "github.com/bnema/vev/pkg/renderer"7+ vt "github.com/bnema/vev-vt"
8- "github.com/bnema/vev/pkg/vt"8+ renderer "github.com/bnema/vev-vt/ansi"
9 )9 )
10 10
11 func privateDir(t *testing.T) string {11 func privateDir(t *testing.T) string {
M internal/app/daemon_integration_test.go +1 -1
@@ -19,6 +19,7 @@ import (
19 19
20 "github.com/stretchr/testify/require"20 "github.com/stretchr/testify/require"
21 21
22+ vt "github.com/bnema/vev-vt"
22 "github.com/bnema/vev/internal/adapters/clock"23 "github.com/bnema/vev/internal/adapters/clock"
23 "github.com/bnema/vev/internal/adapters/ipc"24 "github.com/bnema/vev/internal/adapters/ipc"
24 "github.com/bnema/vev/internal/adapters/lifecycle"25 "github.com/bnema/vev/internal/adapters/lifecycle"
@@ -29,7 +30,6 @@ import (
29 "github.com/bnema/vev/internal/ports"30 "github.com/bnema/vev/internal/ports"
30 "github.com/bnema/vev/internal/usecase/daemon"31 "github.com/bnema/vev/internal/usecase/daemon"
31 "github.com/bnema/vev/internal/usecase/recovery"32 "github.com/bnema/vev/internal/usecase/recovery"
32- "github.com/bnema/vev/pkg/vt"
33 )33 )
34 34
35 // These integration tests drive the real daemon over a real unix socket with a35 // These integration tests drive the real daemon over a real unix socket with a
M internal/ports/frame.go +1 -1
@@ -3,8 +3,8 @@ package ports
3 import (3 import (
4 "time"4 "time"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/pkg/renderer"
8 )8 )
9 9
10 // HandshakeTimeout bounds every transport handshake from connect through the10 // HandshakeTimeout bounds every transport handshake from connect through the
M internal/ports/remote_preview.go +1 -1
@@ -6,8 +6,8 @@ import (
6 "unicode"6 "unicode"
7 "unicode/utf8"7 "unicode/utf8"
8 8
9+ renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/pkg/renderer"
11 )11 )
12 12
13 const (13 const (
M internal/ports/remote_preview_hardening_test.go +1 -1
@@ -6,8 +6,8 @@ import (
6 "strings"6 "strings"
7 "testing"7 "testing"
8 8
9+ renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/pkg/renderer"
11 )11 )
12 12
13 const (13 const (
M internal/ports/remote_preview_test.go +1 -1
@@ -3,8 +3,8 @@ package ports
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/pkg/renderer"
8 )8 )
9 9
10 func previewTargetForTest() domain.RemoteSessionTarget {10 func previewTargetForTest() domain.RemoteSessionTarget {
M internal/ports/wire.go +1 -1
@@ -12,8 +12,8 @@ import (
12 "fmt"12 "fmt"
13 "math"13 "math"
14 14
15+ renderer "github.com/bnema/vev-vt"
15 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
16- "github.com/bnema/vev/pkg/renderer"
17 )17 )
18 18
19 // errShortPayload is returned when a payload ends before a required field19 // errShortPayload is returned when a payload ends before a required field
M internal/ports/wire_test.go +1 -1
@@ -7,8 +7,8 @@ import (
7 "reflect"7 "reflect"
8 "testing"8 "testing"
9 9
10+ renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/pkg/renderer"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
14 14
M internal/usecase/client/client.go +1 -1
@@ -19,10 +19,10 @@ import (
19 "sync/atomic"19 "sync/atomic"
20 "time"20 "time"
21 21
22+ renderer "github.com/bnema/vev-vt"
22 "github.com/bnema/vev/internal/domain"23 "github.com/bnema/vev/internal/domain"
23 "github.com/bnema/vev/internal/ports"24 "github.com/bnema/vev/internal/ports"
24 "github.com/bnema/vev/internal/usecase/theme"25 "github.com/bnema/vev/internal/usecase/theme"
25- "github.com/bnema/vev/pkg/renderer"
26 )26 )
27 27
28 // ProtocolError is a session- or protocol-level failure reported by the28 // ProtocolError is a session- or protocol-level failure reported by the
M internal/usecase/client/palette_attach_generation_test.go +1 -1
@@ -13,9 +13,9 @@ import (
13 13
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16+ renderer "github.com/bnema/vev-vt"
16 "github.com/bnema/vev/internal/domain"17 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18- "github.com/bnema/vev/pkg/renderer"
19 )19 )
20 20
21 // attachPaletteClock deliberately exposes each independently-created timer.21 // attachPaletteClock deliberately exposes each independently-created timer.
M internal/usecase/client/palette_attach_test.go +1 -1
@@ -11,9 +11,9 @@ import (
11 11
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14+ renderer "github.com/bnema/vev-vt"
14 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16- "github.com/bnema/vev/pkg/renderer"
17 )17 )
18 18
19 type paletteAttachClock struct{}19 type paletteAttachClock struct{}
M internal/usecase/client/palette_generation.go +1 -1
@@ -3,8 +3,8 @@ package client
3 import (3 import (
4 "time"4 "time"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
7- "github.com/bnema/vev/pkg/renderer"
8 )8 )
9 9
10 // Palette queries always have a DECRQM boundary. The first marker drains10 // Palette queries always have a DECRQM boundary. The first marker drains
M internal/usecase/client/palette_generation_test.go +1 -1
@@ -6,8 +6,8 @@ import (
6 6
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 8
9+ renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10- "github.com/bnema/vev/pkg/renderer"
11 )11 )
12 12
13 // manualPaletteClock applies only declarative coordinator timer actions. Its13 // manualPaletteClock applies only declarative coordinator timer actions. Its
M internal/usecase/client/reconnect_toast.go +1 -1
@@ -5,9 +5,9 @@ import (
5 "io"5 "io"
6 "strings"6 "strings"
7 7
8+ renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/usecase/ui"10 "github.com/bnema/vev/internal/usecase/ui"
10- "github.com/bnema/vev/pkg/renderer"
11 )11 )
12 12
13 const reconnectToastMessage = "offline; retrying…"13 const reconnectToastMessage = "offline; retrying…"
M internal/usecase/client/reconnect_toast_test.go +1 -1
@@ -16,10 +16,10 @@ import (
16 "github.com/stretchr/testify/mock"16 "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
18 18
19+ renderer "github.com/bnema/vev-vt"
19 "github.com/bnema/vev/internal/domain"20 "github.com/bnema/vev/internal/domain"
20 "github.com/bnema/vev/internal/ports"21 "github.com/bnema/vev/internal/ports"
21 portsmocks "github.com/bnema/vev/internal/ports/mocks"22 portsmocks "github.com/bnema/vev/internal/ports/mocks"
22- "github.com/bnema/vev/pkg/renderer"
23 )23 )
24 24
25 type reconnectToastTerminalHarness struct {25 type reconnectToastTerminalHarness struct {
M internal/usecase/copy/document.go +2 -2
@@ -4,8 +4,8 @@ import (
4 "strings"4 "strings"
5 "unicode"5 "unicode"
6 6
7- "github.com/bnema/vev/pkg/renderer"7+ vt "github.com/bnema/vev-vt"
8- "github.com/bnema/vev/pkg/vt"8+ renderer "github.com/bnema/vev-vt/ansi"
9 )9 )
10 10
11 // Pos identifies a terminal display cell in a document. Valid positions are11 // Pos identifies a terminal display cell in a document. Valid positions are
M internal/usecase/copy/document_test.go +2 -2
@@ -3,8 +3,8 @@ package copy
3 import (3 import (
4 "testing"4 "testing"
5 5
6- "github.com/bnema/vev/pkg/renderer"6+ vt "github.com/bnema/vev-vt"
7- "github.com/bnema/vev/pkg/vt"7+ renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
M internal/usecase/copy/mode.go +2 -2
@@ -7,8 +7,8 @@ import (
7 "strings"7 "strings"
8 "unicode"8 "unicode"
9 9
10- "github.com/bnema/vev/pkg/renderer"10+ vt "github.com/bnema/vev-vt"
11- "github.com/bnema/vev/pkg/vt"11+ renderer "github.com/bnema/vev-vt/ansi"
12 )12 )
13 13
14 const OSC52MaxPayloadBytes = 75_00014 const OSC52MaxPayloadBytes = 75_000
M internal/usecase/copy/mode_test.go +2 -2
@@ -6,9 +6,9 @@ import (
6 "strings"6 "strings"
7 "testing"7 "testing"
8 8
9+ vt "github.com/bnema/vev-vt"
10+ renderer "github.com/bnema/vev-vt/ansi"
9 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/pkg/renderer"
11- "github.com/bnema/vev/pkg/vt"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
14 14
M internal/usecase/copy/navigator_test.go +1 -1
@@ -3,7 +3,7 @@ package copy
3 import (3 import (
4 "testing"4 "testing"
5 5
6- "github.com/bnema/vev/pkg/renderer"6+ renderer "github.com/bnema/vev-vt"
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 )8 )
9 9
M internal/usecase/copy/selection.go +1 -1
@@ -1,6 +1,6 @@
1 package copy1 package copy
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import renderer "github.com/bnema/vev-vt"
4 4
5 // Granularity controls how a selection's endpoints become display-cell ranges.5 // Granularity controls how a selection's endpoints become display-cell ranges.
6 type Granularity uint86 type Granularity uint8
M internal/usecase/copy/selection_test.go +1 -1
@@ -3,7 +3,7 @@ package copy
3 import (3 import (
4 "testing"4 "testing"
5 5
6- "github.com/bnema/vev/pkg/renderer"6+ renderer "github.com/bnema/vev-vt"
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 )8 )
9 9
M internal/usecase/daemon/attachment_effect_gate_test.go +1 -1
@@ -9,10 +9,10 @@ import (
9 9
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12+ renderer "github.com/bnema/vev-vt"
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/usecase/keys"15 "github.com/bnema/vev/internal/usecase/keys"
15- "github.com/bnema/vev/pkg/renderer"
16 )16 )
17 17
18 func releaseTestGate(t *testing.T, ch chan struct{}) func() {18 func releaseTestGate(t *testing.T, ch chan struct{}) func() {
M internal/usecase/daemon/attachment_registry.go +1 -1
@@ -5,8 +5,8 @@ import (
5 "cmp"5 "cmp"
6 "slices"6 "slices"
7 7
8+ vt "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/pkg/vt"
10 )10 )
11 11
12 // attachmentView is the mutable viewport owned by one attachment. Tab and12 // attachmentView is the mutable viewport owned by one attachment. Tab and
M internal/usecase/daemon/attention_test.go +1 -1
@@ -10,13 +10,13 @@ import (
10 "github.com/stretchr/testify/mock"10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13+ renderer "github.com/bnema/vev-vt"
13 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/usecase/keys"17 "github.com/bnema/vev/internal/usecase/keys"
17 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
18 "github.com/bnema/vev/internal/usecase/ui"19 "github.com/bnema/vev/internal/usecase/ui"
19- "github.com/bnema/vev/pkg/renderer"
20 )20 )
21 21
22 func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {22 func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
M internal/usecase/daemon/config_test.go +1 -1
@@ -6,11 +6,11 @@ import (
6 6
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 8
9+ renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/keys"12 "github.com/bnema/vev/internal/usecase/keys"
12 themeui "github.com/bnema/vev/internal/usecase/theme"13 themeui "github.com/bnema/vev/internal/usecase/theme"
13- "github.com/bnema/vev/pkg/renderer"
14 )14 )
15 15
16 type captureKeyHandler struct {16 type captureKeyHandler struct {
M internal/usecase/daemon/copy_mouse_map_test.go +1 -1
@@ -5,10 +5,10 @@ import (
5 5
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8+ renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 scopy "github.com/bnema/vev/internal/usecase/copy"10 scopy "github.com/bnema/vev/internal/usecase/copy"
10 "github.com/bnema/vev/internal/usecase/mouse"11 "github.com/bnema/vev/internal/usecase/mouse"
11- "github.com/bnema/vev/pkg/renderer"
12 )12 )
13 13
14 func TestCopyMouseMapTopBarAndContent(t *testing.T) {14 func TestCopyMouseMapTopBarAndContent(t *testing.T) {
M internal/usecase/daemon/copymode.go +1 -1
@@ -5,6 +5,7 @@ import (
5 "strconv"5 "strconv"
6 "time"6 "time"
7 7
8+ renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 scopy "github.com/bnema/vev/internal/usecase/copy"11 scopy "github.com/bnema/vev/internal/usecase/copy"
@@ -13,7 +14,6 @@ import (
13 themeui "github.com/bnema/vev/internal/usecase/theme"14 themeui "github.com/bnema/vev/internal/usecase/theme"
14 "github.com/bnema/vev/internal/usecase/ui"15 "github.com/bnema/vev/internal/usecase/ui"
15 "github.com/bnema/vev/internal/usecase/visualsearch"16 "github.com/bnema/vev/internal/usecase/visualsearch"
16- "github.com/bnema/vev/pkg/renderer"
17 )17 )
18 18
19 var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}19 var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: domain.AnchorBottom, Margins: ui.Margins{Bottom: 1}}
M internal/usecase/daemon/copymode_mouse_test.go +1 -1
@@ -7,10 +7,10 @@ import (
7 7
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 9
10+ renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12 scopy "github.com/bnema/vev/internal/usecase/copy"13 scopy "github.com/bnema/vev/internal/usecase/copy"
13- "github.com/bnema/vev/pkg/renderer"
14 )14 )
15 15
16 type manualMouseClock struct{ now time.Time }16 type manualMouseClock struct{ now time.Time }
M internal/usecase/daemon/copymode_test.go +2 -2
@@ -13,14 +13,14 @@ import (
13 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16+ vt "github.com/bnema/vev-vt"
17+ renderer "github.com/bnema/vev-vt/ansi"
16 "github.com/bnema/vev/internal/domain"18 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"19 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"20 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 scopy "github.com/bnema/vev/internal/usecase/copy"21 scopy "github.com/bnema/vev/internal/usecase/copy"
20 "github.com/bnema/vev/internal/usecase/layout"22 "github.com/bnema/vev/internal/usecase/layout"
21 "github.com/bnema/vev/internal/usecase/ui"23 "github.com/bnema/vev/internal/usecase/ui"
22- "github.com/bnema/vev/pkg/renderer"
23- "github.com/bnema/vev/pkg/vt"
24 )24 )
25 25
26 // --- test doubles -----------------------------------------------------------26 // --- test doubles -----------------------------------------------------------
M internal/usecase/daemon/daemon_test.go +2 -2
@@ -11,12 +11,12 @@ import (
11 "github.com/stretchr/testify/mock"11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14+ vt "github.com/bnema/vev-vt"
15+ renderer "github.com/bnema/vev-vt/ansi"
14 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 "github.com/bnema/vev/internal/usecase/keys"19 "github.com/bnema/vev/internal/usecase/keys"
18- "github.com/bnema/vev/pkg/renderer"
19- "github.com/bnema/vev/pkg/vt"
20 )20 )
21 21
22 // --- test doubles -----------------------------------------------------------22 // --- test doubles -----------------------------------------------------------
M internal/usecase/daemon/final_acceptance_test.go +1 -1
@@ -3,10 +3,10 @@ package daemon
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ vt "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
9- "github.com/bnema/vev/pkg/vt"
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 )11 )
12 12
M internal/usecase/daemon/floating_render.go +1 -1
@@ -1,10 +1,10 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ renderer "github.com/bnema/vev-vt"
4 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
5 themeui "github.com/bnema/vev/internal/usecase/theme"6 themeui "github.com/bnema/vev/internal/usecase/theme"
6 "github.com/bnema/vev/internal/usecase/ui"7 "github.com/bnema/vev/internal/usecase/ui"
7- "github.com/bnema/vev/pkg/renderer"
8 )8 )
9 9
10 // floatingGeometry describes a popup's outer frame and terminal content area.10 // floatingGeometry describes a popup's outer frame and terminal content area.
M internal/usecase/daemon/floating_render_test.go +1 -1
@@ -9,11 +9,11 @@ import (
9 9
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12+ renderer "github.com/bnema/vev-vt/ansi"
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/usecase/layout"14 "github.com/bnema/vev/internal/usecase/layout"
14 themeui "github.com/bnema/vev/internal/usecase/theme"15 themeui "github.com/bnema/vev/internal/usecase/theme"
15 "github.com/bnema/vev/internal/usecase/ui"16 "github.com/bnema/vev/internal/usecase/ui"
16- "github.com/bnema/vev/pkg/renderer"
17 )17 )
18 18
19 // applyFloatingResizePlanForTest exercises the transactional apply primitive.19 // applyFloatingResizePlanForTest exercises the transactional apply primitive.
M internal/usecase/daemon/history_test_helper_test.go +1 -1
@@ -1,6 +1,6 @@
1 package daemon1 package daemon
2 2
3-import "github.com/bnema/vev/pkg/vt"3+import vt "github.com/bnema/vev-vt"
4 4
5 func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }5 func newTestHistory(rows int) *vt.History { return vt.NewHistory(vt.HistoryConfig{MaxRows: rows}) }
6 6
M internal/usecase/daemon/input_test.go +1 -1
@@ -13,6 +13,7 @@ import (
13 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16+ vt "github.com/bnema/vev-vt"
16 "github.com/bnema/vev/internal/domain"17 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"19 portsmocks "github.com/bnema/vev/internal/ports/mocks"
@@ -22,7 +23,6 @@ import (
22 "github.com/bnema/vev/internal/usecase/mouse"23 "github.com/bnema/vev/internal/usecase/mouse"
23 "github.com/bnema/vev/internal/usecase/picker"24 "github.com/bnema/vev/internal/usecase/picker"
24 "github.com/bnema/vev/internal/usecase/ui"25 "github.com/bnema/vev/internal/usecase/ui"
25- "github.com/bnema/vev/pkg/vt"
26 )26 )
27 27
28 // --- test doubles -----------------------------------------------------------28 // --- test doubles -----------------------------------------------------------
M internal/usecase/daemon/modal_compose.go +1 -1
@@ -1,10 +1,10 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ renderer "github.com/bnema/vev-vt"
4 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
5 themeui "github.com/bnema/vev/internal/usecase/theme"6 themeui "github.com/bnema/vev/internal/usecase/theme"
6 "github.com/bnema/vev/internal/usecase/ui"7 "github.com/bnema/vev/internal/usecase/ui"
7- "github.com/bnema/vev/pkg/renderer"
8 )8 )
9 9
10 func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) {10 func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) {
M internal/usecase/daemon/move_tab_test.go +1 -1
@@ -9,9 +9,9 @@ import (
9 9
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12+ renderer "github.com/bnema/vev-vt/ansi"
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
13 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"14 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
14- "github.com/bnema/vev/pkg/renderer"
15 )15 )
16 16
17 func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {17 func TestMoveTabPreservesWholeTabAndActivatesLogicalNeighbor(t *testing.T) {
M internal/usecase/daemon/notice_render_test.go +1 -1
@@ -7,9 +7,9 @@ import (
7 7
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 9
10+ vt "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12- "github.com/bnema/vev/pkg/vt"
13 )13 )
14 14
15 // replayOutputFrames decodes and writes each MsgOutput frame's terminal bytes15 // replayOutputFrames decodes and writes each MsgOutput frame's terminal bytes
M internal/usecase/daemon/notices_overlay_test.go +1 -1
@@ -9,11 +9,11 @@ import (
9 9
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12+ renderer "github.com/bnema/vev-vt"
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/usecase/notices"15 "github.com/bnema/vev/internal/usecase/notices"
15 themeui "github.com/bnema/vev/internal/usecase/theme"16 themeui "github.com/bnema/vev/internal/usecase/theme"
16- "github.com/bnema/vev/pkg/renderer"
17 )17 )
18 18
19 func TestCaptureNoticesOverlayUsesTerminalBackgroundOutsideSelection(t *testing.T) {19 func TestCaptureNoticesOverlayUsesTerminalBackgroundOutsideSelection(t *testing.T) {
M internal/usecase/daemon/notify_test.go +1 -1
@@ -12,10 +12,10 @@ import (
12 12
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15+ vt "github.com/bnema/vev-vt"
15 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18- "github.com/bnema/vev/pkg/vt"
19 )19 )
20 20
21 func TestReportAttachmentErrorPreservesLocalOrNilScope(t *testing.T) {21 func TestReportAttachmentErrorPreservesLocalOrNilScope(t *testing.T) {
M internal/usecase/daemon/output_state_stream.go +1 -1
@@ -5,9 +5,9 @@ import (
5 "sync"5 "sync"
6 "sync/atomic"6 "sync/atomic"
7 7
8+ renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10- "github.com/bnema/vev/pkg/renderer"
11 )11 )
12 12
13 // outputStateStream owns the terminal-output dependency chain for one13 // outputStateStream owns the terminal-output dependency chain for one
M internal/usecase/daemon/output_state_stream_test.go +2 -2
@@ -5,10 +5,10 @@ import (
5 "runtime"5 "runtime"
6 "testing"6 "testing"
7 7
8+ vt "github.com/bnema/vev-vt"
9+ renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
10- "github.com/bnema/vev/pkg/renderer"
11- "github.com/bnema/vev/pkg/vt"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
14 14
M internal/usecase/daemon/output_state_sync_test.go +2 -2
@@ -8,10 +8,10 @@ import (
8 "testing"8 "testing"
9 "time"9 "time"
10 10
11+ vt "github.com/bnema/vev-vt"
12+ renderer "github.com/bnema/vev-vt/ansi"
11 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
13- "github.com/bnema/vev/pkg/renderer"
14- "github.com/bnema/vev/pkg/vt"
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 )16 )
17 17
M internal/usecase/daemon/overlay_backdrop.go +1 -1
@@ -1,9 +1,9 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ renderer "github.com/bnema/vev-vt"
4 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
5 themeui "github.com/bnema/vev/internal/usecase/theme"6 themeui "github.com/bnema/vev/internal/usecase/theme"
6- "github.com/bnema/vev/pkg/renderer"
7 )7 )
8 8
9 // applyOverlayBackdrop dims every cell already composed into the complete9 // applyOverlayBackdrop dims every cell already composed into the complete
M internal/usecase/daemon/overlay_backdrop_test.go +1 -1
@@ -5,9 +5,9 @@ import (
5 5
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8+ renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 themeui "github.com/bnema/vev/internal/usecase/theme"10 themeui "github.com/bnema/vev/internal/usecase/theme"
10- "github.com/bnema/vev/pkg/renderer"
11 )11 )
12 12
13 func backdropTheme() themeui.Theme {13 func backdropTheme() themeui.Theme {
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
@@ -5,11 +5,11 @@ import (
5 5
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8+ renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 scopy "github.com/bnema/vev/internal/usecase/copy"10 scopy "github.com/bnema/vev/internal/usecase/copy"
10 promptui "github.com/bnema/vev/internal/usecase/prompt"11 promptui "github.com/bnema/vev/internal/usecase/prompt"
11 "github.com/bnema/vev/internal/usecase/visualsearch"12 "github.com/bnema/vev/internal/usecase/visualsearch"
12- "github.com/bnema/vev/pkg/renderer"
13 )13 )
14 14
15 func TestOverlayRuntimeActive(t *testing.T) {15 func TestOverlayRuntimeActive(t *testing.T) {
M internal/usecase/daemon/palette.go +1 -1
@@ -5,6 +5,7 @@ import (
5 "sort"5 "sort"
6 "time"6 "time"
7 7
8+ renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/command"11 "github.com/bnema/vev/internal/usecase/command"
@@ -13,7 +14,6 @@ import (
13 "github.com/bnema/vev/internal/usecase/picker"14 "github.com/bnema/vev/internal/usecase/picker"
14 themeui "github.com/bnema/vev/internal/usecase/theme"15 themeui "github.com/bnema/vev/internal/usecase/theme"
15 "github.com/bnema/vev/internal/usecase/ui"16 "github.com/bnema/vev/internal/usecase/ui"
16- "github.com/bnema/vev/pkg/renderer"
17 )17 )
18 18
19 const (19 const (
M internal/usecase/daemon/palette_test.go +1 -1
@@ -8,6 +8,7 @@ import (
8 "github.com/stretchr/testify/mock"8 "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 10
11+ renderer "github.com/bnema/vev-vt"
11 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
13 portsmocks "github.com/bnema/vev/internal/ports/mocks"14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
@@ -15,7 +16,6 @@ import (
15 "github.com/bnema/vev/internal/usecase/palette"16 "github.com/bnema/vev/internal/usecase/palette"
16 themeui "github.com/bnema/vev/internal/usecase/theme"17 themeui "github.com/bnema/vev/internal/usecase/theme"
17 "github.com/bnema/vev/internal/usecase/ui"18 "github.com/bnema/vev/internal/usecase/ui"
18- "github.com/bnema/vev/pkg/renderer"
19 )19 )
20 20
21 func testRecentRouteSnapshot() ports.RecentRouteSnapshot {21 func testRecentRouteSnapshot() ports.RecentRouteSnapshot {
M internal/usecase/daemon/pane.go +1 -1
@@ -5,10 +5,10 @@ import (
5 "sync"5 "sync"
6 "sync/atomic"6 "sync/atomic"
7 7
8+ vt "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
11- "github.com/bnema/vev/pkg/vt"
12 )12 )
13 13
14 // paneProcessLifetime owns the context passed to PTYFactory.Open. Its daemon14 // paneProcessLifetime owns the context passed to PTYFactory.Open. Its daemon
M internal/usecase/daemon/pane_actions_test.go +1 -1
@@ -12,12 +12,12 @@ import (
12 "github.com/stretchr/testify/mock"12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15+ renderer "github.com/bnema/vev-vt"
15 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 scopy "github.com/bnema/vev/internal/usecase/copy"19 scopy "github.com/bnema/vev/internal/usecase/copy"
19 "github.com/bnema/vev/internal/usecase/layout"20 "github.com/bnema/vev/internal/usecase/layout"
20- "github.com/bnema/vev/pkg/renderer"
21 )21 )
22 22
23 func TestLayoutGenerationMutationBookkeeping(t *testing.T) {23 func TestLayoutGenerationMutationBookkeeping(t *testing.T) {
M internal/usecase/daemon/performance_benchmark_test.go +1 -1
@@ -12,11 +12,11 @@ import (
12 12
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15+ renderer "github.com/bnema/vev-vt"
15 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
17 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
18 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
19- "github.com/bnema/vev/pkg/renderer"
20 )20 )
21 21
22 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {22 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
M internal/usecase/daemon/picker.go +1 -1
@@ -7,12 +7,12 @@ import (
7 "sort"7 "sort"
8 "time"8 "time"
9 9
10+ renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12 "github.com/bnema/vev/internal/usecase/layout"13 "github.com/bnema/vev/internal/usecase/layout"
13 "github.com/bnema/vev/internal/usecase/picker"14 "github.com/bnema/vev/internal/usecase/picker"
14 "github.com/bnema/vev/internal/usecase/ui"15 "github.com/bnema/vev/internal/usecase/ui"
15- "github.com/bnema/vev/pkg/renderer"
16 )16 )
17 17
18 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}18 var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
M internal/usecase/daemon/picker_test.go +1 -1
@@ -11,6 +11,7 @@ import (
11 "github.com/stretchr/testify/mock"11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14+ renderer "github.com/bnema/vev-vt/ansi"
14 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/persist"16 "github.com/bnema/vev/internal/persist"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
@@ -19,7 +20,6 @@ import (
19 "github.com/bnema/vev/internal/usecase/picker"20 "github.com/bnema/vev/internal/usecase/picker"
20 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"21 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
21 themeui "github.com/bnema/vev/internal/usecase/theme"22 themeui "github.com/bnema/vev/internal/usecase/theme"
22- "github.com/bnema/vev/pkg/renderer"
23 )23 )
24 24
25 var (25 var (
M internal/usecase/daemon/remote_preview.go +1 -1
@@ -3,9 +3,9 @@ package daemon
3 import (3 import (
4 "errors"4 "errors"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8- "github.com/bnema/vev/pkg/renderer"
9 )9 )
10 10
11 func (d *Daemon) handleRemotePreview(tr ports.Transport, frame ports.Frame) error {11 func (d *Daemon) handleRemotePreview(tr ports.Transport, frame ports.Frame) error {
M internal/usecase/daemon/remote_preview_cache_test.go +1 -1
@@ -9,10 +9,10 @@ import (
9 9
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12+ renderer "github.com/bnema/vev-vt"
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/usecase/picker"15 "github.com/bnema/vev/internal/usecase/picker"
15- "github.com/bnema/vev/pkg/renderer"
16 )16 )
17 17
18 type remotePreviewTestClock struct {18 type remotePreviewTestClock struct {
M internal/usecase/daemon/remote_preview_capture_test.go +2 -2
@@ -5,10 +5,10 @@ import (
5 5
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8+ vt "github.com/bnema/vev-vt"
9+ renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
10- "github.com/bnema/vev/pkg/renderer"
11- "github.com/bnema/vev/pkg/vt"
12 )12 )
13 13
14 func TestCaptureRemotePreviewUsesBottomRowsForShorterPreview(t *testing.T) {14 func TestCaptureRemotePreviewUsesBottomRowsForShorterPreview(t *testing.T) {
M internal/usecase/daemon/render.go +1 -1
@@ -4,11 +4,11 @@ package daemon
4 import (4 import (
5 "bytes"5 "bytes"
6 6
7+ renderer "github.com/bnema/vev-vt/ansi"
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
10 themeui "github.com/bnema/vev/internal/usecase/theme"11 themeui "github.com/bnema/vev/internal/usecase/theme"
11- "github.com/bnema/vev/pkg/renderer"
12 )12 )
13 13
14 // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one14 // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one
M internal/usecase/daemon/render_capture.go +1 -1
@@ -1,13 +1,13 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ renderer "github.com/bnema/vev-vt"
4 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
5 scopy "github.com/bnema/vev/internal/usecase/copy"6 scopy "github.com/bnema/vev/internal/usecase/copy"
6 "github.com/bnema/vev/internal/usecase/layout"7 "github.com/bnema/vev/internal/usecase/layout"
7 "github.com/bnema/vev/internal/usecase/picker"8 "github.com/bnema/vev/internal/usecase/picker"
8 themeui "github.com/bnema/vev/internal/usecase/theme"9 themeui "github.com/bnema/vev/internal/usecase/theme"
9 "github.com/bnema/vev/internal/usecase/ui"10 "github.com/bnema/vev/internal/usecase/ui"
10- "github.com/bnema/vev/pkg/renderer"
11 )11 )
12 12
13 // capturedRenderState is the immutable hand-off from authoritative daemon13 // capturedRenderState is the immutable hand-off from authoritative daemon
M internal/usecase/daemon/render_damage_receipt_test.go +1 -1
@@ -3,11 +3,11 @@ package daemon
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
9 "github.com/bnema/vev/internal/usecase/picker"10 "github.com/bnema/vev/internal/usecase/picker"
10- "github.com/bnema/vev/pkg/renderer"
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 )12 )
13 13
M internal/usecase/daemon/render_divider_geometry_test.go +1 -1
@@ -3,9 +3,9 @@ package daemon
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/usecase/layout"8 "github.com/bnema/vev/internal/usecase/layout"
8- "github.com/bnema/vev/pkg/renderer"
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 11
M internal/usecase/daemon/render_pipeline.go +1 -1
@@ -4,6 +4,7 @@ import (
4 "errors"4 "errors"
5 "strconv"5 "strconv"
6 6
7+ renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
@@ -14,7 +15,6 @@ import (
14 themeui "github.com/bnema/vev/internal/usecase/theme"15 themeui "github.com/bnema/vev/internal/usecase/theme"
15 "github.com/bnema/vev/internal/usecase/ui"16 "github.com/bnema/vev/internal/usecase/ui"
16 "github.com/bnema/vev/internal/usecase/visualsearch"17 "github.com/bnema/vev/internal/usecase/visualsearch"
17- "github.com/bnema/vev/pkg/renderer"
18 )18 )
19 19
20 // composeCacheInput is an attachment-local value snapshot. composeFrame never20 // composeCacheInput is an attachment-local value snapshot. composeFrame never
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -1
@@ -6,12 +6,12 @@ import (
6 "maps"6 "maps"
7 "testing"7 "testing"
8 8
9+ renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/layout"12 "github.com/bnema/vev/internal/usecase/layout"
12 themeui "github.com/bnema/vev/internal/usecase/theme"13 themeui "github.com/bnema/vev/internal/usecase/theme"
13 "github.com/bnema/vev/internal/usecase/ui"14 "github.com/bnema/vev/internal/usecase/ui"
14- "github.com/bnema/vev/pkg/renderer"
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 )16 )
17 17
M internal/usecase/daemon/render_pipeline_test.go +2 -2
@@ -11,6 +11,8 @@ import (
11 "testing"11 "testing"
12 "time"12 "time"
13 13
14+ vt "github.com/bnema/vev-vt"
15+ renderer "github.com/bnema/vev-vt/ansi"
14 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/testutil/replaytest"18 "github.com/bnema/vev/internal/testutil/replaytest"
@@ -23,8 +25,6 @@ import (
23 themeui "github.com/bnema/vev/internal/usecase/theme"25 themeui "github.com/bnema/vev/internal/usecase/theme"
24 "github.com/bnema/vev/internal/usecase/ui"26 "github.com/bnema/vev/internal/usecase/ui"
25 "github.com/bnema/vev/internal/usecase/visualsearch"27 "github.com/bnema/vev/internal/usecase/visualsearch"
26- "github.com/bnema/vev/pkg/renderer"
27- "github.com/bnema/vev/pkg/vt"
28 "github.com/stretchr/testify/require"28 "github.com/stretchr/testify/require"
29 )29 )
30 30
M internal/usecase/daemon/render_test.go +2 -2
@@ -16,14 +16,14 @@ import (
16 "github.com/stretchr/testify/mock"16 "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
18 18
19+ vt "github.com/bnema/vev-vt"
20+ renderer "github.com/bnema/vev-vt/ansi"
19 "github.com/bnema/vev/internal/domain"21 "github.com/bnema/vev/internal/domain"
20 "github.com/bnema/vev/internal/ports"22 "github.com/bnema/vev/internal/ports"
21 portsmocks "github.com/bnema/vev/internal/ports/mocks"23 portsmocks "github.com/bnema/vev/internal/ports/mocks"
22 scopy "github.com/bnema/vev/internal/usecase/copy"24 scopy "github.com/bnema/vev/internal/usecase/copy"
23 "github.com/bnema/vev/internal/usecase/layout"25 "github.com/bnema/vev/internal/usecase/layout"
24 themeui "github.com/bnema/vev/internal/usecase/theme"26 themeui "github.com/bnema/vev/internal/usecase/theme"
25- "github.com/bnema/vev/pkg/renderer"
26- "github.com/bnema/vev/pkg/vt"
27 )27 )
28 28
29 func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {29 func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
M internal/usecase/daemon/resume_test.go +1 -1
@@ -11,11 +11,11 @@ import (
11 "github.com/stretchr/testify/mock"11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14+ vt "github.com/bnema/vev-vt"
14 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
18- "github.com/bnema/vev/pkg/vt"
19 )19 )
20 20
21 type closeTrackingTransport struct {21 type closeTrackingTransport struct {
M internal/usecase/daemon/snapshot.go +1 -1
@@ -7,10 +7,10 @@ import (
7 "sync"7 "sync"
8 "time"8 "time"
9 9
10+ vt "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/usecase/layout"12 "github.com/bnema/vev/internal/usecase/layout"
12 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"13 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13- "github.com/bnema/vev/pkg/vt"
14 )14 )
15 15
16 // snapshotQueueCapacity bounds retained immutable captures. A full queue never16 // snapshotQueueCapacity bounds retained immutable captures. A full queue never
M internal/usecase/daemon/snapshot_acceptance_test.go +2 -2
@@ -6,8 +6,8 @@ import (
6 6
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 8
9- "github.com/bnema/vev/pkg/renderer"9+ vt "github.com/bnema/vev-vt"
10- "github.com/bnema/vev/pkg/vt"10+ renderer "github.com/bnema/vev-vt/ansi"
11 )11 )
12 12
13 func TestSnapshotChunkCacheIsScopedToNamedSession(t *testing.T) {13 func TestSnapshotChunkCacheIsScopedToNamedSession(t *testing.T) {
M internal/usecase/daemon/snapshot_incremental.go +1 -1
@@ -3,10 +3,10 @@ package daemon
3 import (3 import (
4 "fmt"4 "fmt"
5 5
6+ vt "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
8 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"9 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
9- "github.com/bnema/vev/pkg/vt"
10 )10 )
11 11
12 // snapshotChunkCacheLimit is deliberately small: history chunks remain owned12 // snapshotChunkCacheLimit is deliberately small: history chunks remain owned
M internal/usecase/daemon/snapshot_incremental_test.go +2 -2
@@ -7,10 +7,10 @@ import (
7 7
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 9
10+ vt "github.com/bnema/vev-vt"
11+ renderer "github.com/bnema/vev-vt/ansi"
10 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
11 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"13 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
12- "github.com/bnema/vev/pkg/renderer"
13- "github.com/bnema/vev/pkg/vt"
14 )14 )
15 15
16 func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) {16 func TestMarshalSnapshotTailSelectsCanonicalEncoder(t *testing.T) {
M internal/usecase/daemon/snapshot_restore.go +1 -1
@@ -7,11 +7,11 @@ import (
7 "fmt"7 "fmt"
8 "math"8 "math"
9 9
10+ vt "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12 "github.com/bnema/vev/internal/usecase/layout"13 "github.com/bnema/vev/internal/usecase/layout"
13 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"14 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
14- "github.com/bnema/vev/pkg/vt"
15 )15 )
16 16
17 func (d *Daemon) closeRestoreDone() {17 func (d *Daemon) closeRestoreDone() {
M internal/usecase/daemon/snapshot_restore_test.go +2 -2
@@ -11,14 +11,14 @@ import (
11 "github.com/stretchr/testify/mock"11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14+ vt "github.com/bnema/vev-vt"
15+ renderer "github.com/bnema/vev-vt/ansi"
14 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 "github.com/bnema/vev/internal/usecase/layout"19 "github.com/bnema/vev/internal/usecase/layout"
18 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"20 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"21 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20- "github.com/bnema/vev/pkg/renderer"
21- "github.com/bnema/vev/pkg/vt"
22 )22 )
23 23
24 type snapshotAcceptanceRepository struct {24 type snapshotAcceptanceRepository struct {
M internal/usecase/daemon/snapshot_test.go +1 -1
@@ -9,11 +9,11 @@ import (
9 "github.com/stretchr/testify/mock"9 "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12+ renderer "github.com/bnema/vev-vt"
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
14 portsmocks "github.com/bnema/vev/internal/ports/mocks"15 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"16 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
16- "github.com/bnema/vev/pkg/renderer"
17 )17 )
18 18
19 // TestWithSnapshotRepositoryRejectsTypedNil verifies typed nil repositories are disabled.19 // TestWithSnapshotRepositoryRejectsTypedNil verifies typed nil repositories are disabled.
M internal/usecase/daemon/status.go +1 -1
@@ -6,13 +6,13 @@ import (
6 "strconv"6 "strconv"
7 "time"7 "time"
8 8
9+ renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/command"12 "github.com/bnema/vev/internal/usecase/command"
12 "github.com/bnema/vev/internal/usecase/palette"13 "github.com/bnema/vev/internal/usecase/palette"
13 themeui "github.com/bnema/vev/internal/usecase/theme"14 themeui "github.com/bnema/vev/internal/usecase/theme"
14 "github.com/bnema/vev/internal/usecase/ui"15 "github.com/bnema/vev/internal/usecase/ui"
15- "github.com/bnema/vev/pkg/renderer"
16 )16 )
17 17
18 const (18 const (
M internal/usecase/daemon/status_test.go +1 -1
@@ -11,6 +11,7 @@ import (
11 11
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 13
14+ renderer "github.com/bnema/vev-vt/ansi"
14 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/usecase/command"17 "github.com/bnema/vev/internal/usecase/command"
@@ -19,7 +20,6 @@ import (
19 "github.com/bnema/vev/internal/usecase/picker"20 "github.com/bnema/vev/internal/usecase/picker"
20 themeui "github.com/bnema/vev/internal/usecase/theme"21 themeui "github.com/bnema/vev/internal/usecase/theme"
21 "github.com/bnema/vev/internal/usecase/ui"22 "github.com/bnema/vev/internal/usecase/ui"
22- "github.com/bnema/vev/pkg/renderer"
23 )23 )
24 24
25 // stubClock returns timers whose channel never fires, so a scheduler under it25 // stubClock returns timers whose channel never fires, so a scheduler under it
M internal/usecase/daemon/theme_cache_test.go +1 -1
@@ -5,11 +5,11 @@ import (
5 5
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8+ renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
11 themeui "github.com/bnema/vev/internal/usecase/theme"12 themeui "github.com/bnema/vev/internal/usecase/theme"
12- "github.com/bnema/vev/pkg/renderer"
13 )13 )
14 14
15 func cacheTheme() themeui.Theme {15 func cacheTheme() themeui.Theme {
M internal/usecase/daemon/theme_integration_test.go +1 -1
@@ -5,10 +5,10 @@ import (
5 5
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8+ renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 themeui "github.com/bnema/vev/internal/usecase/theme"11 themeui "github.com/bnema/vev/internal/usecase/theme"
11- "github.com/bnema/vev/pkg/renderer"
12 )12 )
13 13
14 func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) ports.Theme {14 func lifecycleTheme(foreground, background renderer.RGB, palette map[uint8]renderer.RGB) ports.Theme {
M internal/usecase/daemon/toast_damage_test.go +2 -2
@@ -3,10 +3,10 @@ package daemon
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ vt "github.com/bnema/vev-vt"
7+ renderer "github.com/bnema/vev-vt/ansi"
6 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
8- "github.com/bnema/vev/pkg/renderer"
9- "github.com/bnema/vev/pkg/vt"
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 )11 )
12 12
M internal/usecase/notices/model.go +1 -1
@@ -6,9 +6,9 @@ import (
6 "fmt"6 "fmt"
7 "time"7 "time"
8 8
9+ renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/usecase/ui"11 "github.com/bnema/vev/internal/usecase/ui"
11- "github.com/bnema/vev/pkg/renderer"
12 )12 )
13 13
14 // RenderStyles are the styles Render uses to draw list rows. The zero value14 // RenderStyles are the styles Render uses to draw list rows. The zero value
M internal/usecase/notices/model_test.go +1 -1
@@ -6,8 +6,8 @@ import (
6 "testing"6 "testing"
7 "time"7 "time"
8 8
9+ renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/pkg/renderer"
11 )11 )
12 12
13 func testStyles() RenderStyles {13 func testStyles() RenderStyles {
M internal/usecase/palette/model.go +1 -1
@@ -4,10 +4,10 @@ import (
4 "unicode"4 "unicode"
5 "unicode/utf8"5 "unicode/utf8"
6 6
7+ renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/usecase/command"9 "github.com/bnema/vev/internal/usecase/command"
9 "github.com/bnema/vev/internal/usecase/ui"10 "github.com/bnema/vev/internal/usecase/ui"
10- "github.com/bnema/vev/pkg/renderer"
11 )11 )
12 12
13 type RenderStyles struct {13 type RenderStyles struct {
M internal/usecase/palette/model_test.go +1 -1
@@ -5,10 +5,10 @@ import (
5 "testing"5 "testing"
6 "time"6 "time"
7 7
8+ renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/usecase/command"10 "github.com/bnema/vev/internal/usecase/command"
10 themeui "github.com/bnema/vev/internal/usecase/theme"11 themeui "github.com/bnema/vev/internal/usecase/theme"
11- "github.com/bnema/vev/pkg/renderer"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
14 14
M internal/usecase/picker/model.go +1 -1
@@ -1,9 +1,9 @@
1 package picker1 package picker
2 2
3 import (3 import (
4+ renderer "github.com/bnema/vev-vt"
4 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/usecase/ui"6 "github.com/bnema/vev/internal/usecase/ui"
6- "github.com/bnema/vev/pkg/renderer"
7 )7 )
8 8
9 const (9 const (
M internal/usecase/picker/model_test.go +1 -1
@@ -5,10 +5,10 @@ import (
5 "strings"5 "strings"
6 "testing"6 "testing"
7 7
8+ renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 themeui "github.com/bnema/vev/internal/usecase/theme"10 themeui "github.com/bnema/vev/internal/usecase/theme"
10 "github.com/bnema/vev/internal/usecase/ui"11 "github.com/bnema/vev/internal/usecase/ui"
11- "github.com/bnema/vev/pkg/renderer"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
14 14
M internal/usecase/picker/remote_parity_test.go +1 -1
@@ -5,8 +5,8 @@ import (
5 5
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8+ renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/pkg/renderer"
10 )10 )
11 11
12 func remoteParityLifecycle(seed byte) domain.SessionLifecycleID {12 func remoteParityLifecycle(seed byte) domain.SessionLifecycleID {
M internal/usecase/prompt/model.go +1 -1
@@ -1,9 +1,9 @@
1 package prompt1 package prompt
2 2
3 import (3 import (
4+ renderer "github.com/bnema/vev-vt"
4 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/usecase/ui"6 "github.com/bnema/vev/internal/usecase/ui"
6- "github.com/bnema/vev/pkg/renderer"
7 )7 )
8 8
9 // RenderStyles contains daemon-supplied semantic chrome roles. The zero value9 // RenderStyles contains daemon-supplied semantic chrome roles. The zero value
M internal/usecase/prompt/model_test.go +1 -1
@@ -4,8 +4,8 @@ import (
4 "strings"4 "strings"
5 "testing"5 "testing"
6 6
7+ renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/pkg/renderer"
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 11
A internal/usecase/snapshot/codec_fuzz_test.go +20 -0
@@ -0,0 +1,20 @@
1+package snapshot
2+
3+import (
4+ "encoding/hex"
5+ "testing"
6+)
7+
8+func FuzzVEVSRestoreDoesNotPanic(f *testing.F) {
9+ golden, err := hex.DecodeString("5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100")
10+ if err != nil {
11+ f.Fatalf("decode VEVS seed: %v", err)
12+ }
13+ f.Add(golden)
14+ f.Add([]byte("VEVS\x00\x04"))
15+ f.Add([]byte("not a snapshot"))
16+
17+ f.Fuzz(func(t *testing.T, data []byte) {
18+ _, _ = Unmarshal(data)
19+ })
20+}
M internal/usecase/snapshot/codec_test.go +2 -2
@@ -9,9 +9,9 @@ import (
9 "strconv"9 "strconv"
10 "testing"10 "testing"
11 11
12+ vt "github.com/bnema/vev-vt"
13+ renderer "github.com/bnema/vev-vt/ansi"
12 "github.com/bnema/vev/internal/usecase/layout"14 "github.com/bnema/vev/internal/usecase/layout"
13- "github.com/bnema/vev/pkg/renderer"
14- "github.com/bnema/vev/pkg/vt"
15 )15 )
16 16
17 func TestPayloadReaderGetBytes(t *testing.T) {17 func TestPayloadReaderGetBytes(t *testing.T) {
M internal/usecase/snapshot/preflight.go +1 -1
@@ -4,8 +4,8 @@ import (
4 "fmt"4 "fmt"
5 "math"5 "math"
6 6
7+ vt "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/usecase/layout"8 "github.com/bnema/vev/internal/usecase/layout"
8- "github.com/bnema/vev/pkg/vt"
9 )9 )
10 10
11 func preflightSession(body []byte) error {11 func preflightSession(body []byte) error {
M internal/usecase/snapshot/preflight_acceptance_test.go +1 -1
@@ -4,8 +4,8 @@ import (
4 "errors"4 "errors"
5 "testing"5 "testing"
6 6
7+ renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/usecase/layout"8 "github.com/bnema/vev/internal/usecase/layout"
8- "github.com/bnema/vev/pkg/renderer"
9 )9 )
10 10
11 func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) {11 func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) {
A internal/usecase/snapshot/vevs_v4_golden_test.go +109 -0
@@ -0,0 +1,109 @@
1+package snapshot
2+
3+import (
4+ "bytes"
5+ "encoding/hex"
6+ "testing"
7+
8+ vt "github.com/bnema/vev-vt"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+)
11+
12+func TestVEVSV4GoldenRestoreCompatibility(t *testing.T) {
13+ const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100"
14+ data, err := hex.DecodeString(golden)
15+ if err != nil {
16+ t.Fatal(err)
17+ }
18+
19+ got, err := Unmarshal(data)
20+ if err != nil {
21+ t.Fatalf("Unmarshal golden VEVS: %v", err)
22+ }
23+ if got.Name != "restore" || got.CreatedAt != 123 || len(got.Tabs) != 1 {
24+ t.Fatalf("session header = %#v", got)
25+ }
26+ tab := got.Tabs[0]
27+ if tab.StableID != "tab" || tab.Cols != 80 || tab.Rows != 24 || tab.NextPaneID != 2 || tab.Focus != layout.PaneID("p") || len(tab.Panes) != 1 {
28+ t.Fatalf("tab header = %#v", tab)
29+ }
30+ pane := tab.Panes[0]
31+ if pane.ID != layout.PaneID("p") || pane.StableID != "stable" || pane.Cwd != "/tmp" || pane.Process != nil || len(pane.SealedChunks) != 1 {
32+ t.Fatalf("pane header = %#v", pane)
33+ }
34+
35+ sealed, err := vt.UnmarshalHistory(pane.SealedChunks[0])
36+ if err != nil {
37+ t.Fatalf("sealed history: %v", err)
38+ }
39+ if sealed.RowID(0) != 41 || sealed.Row(0)[0].Rune != 'A' {
40+ t.Fatalf("sealed history = row %d %#v", sealed.RowID(0), sealed.Row(0))
41+ }
42+ tail, err := vt.UnmarshalHistory(pane.Tail)
43+ if err != nil {
44+ t.Fatalf("history tail: %v", err)
45+ }
46+ if tail.Len() != 0 || tail.NextRowID() != 42 {
47+ t.Fatalf("history tail = len %d next %d", tail.Len(), tail.NextRowID())
48+ }
49+ transcript, err := vt.UnmarshalHistory(pane.Transcript)
50+ if err != nil {
51+ t.Fatalf("recovery transcript: %v", err)
52+ }
53+ if transcript.RowID(0) != 42 || transcript.Row(0)[0].Rune != '界' {
54+ t.Fatalf("recovery transcript = row %d %#v", transcript.RowID(0), transcript.Row(0))
55+ }
56+
57+ screen, err := vt.NewScreenWithRecoveryTranscript(8, 2, vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, pane.SealedChunks, pane.Tail, pane.Transcript)
58+ if err != nil {
59+ t.Fatalf("restore VT history: %v", err)
60+ }
61+ view := screen.History().View()
62+ if view.Len() != 2 || view.RowID(0) != 41 || view.RowID(1) != 42 || view.NextRowID() != 43 {
63+ t.Fatalf("restored history = len %d IDs %d/%d next %d", view.Len(), view.RowID(0), view.RowID(1), view.NextRowID())
64+ }
65+}
66+
67+func TestVEVSV4GoldenRejectsMalformedInput(t *testing.T) {
68+ const golden = "5645565300040000000001291707ae820007726573746f7265000000000000007b0000000100037461620050001800000000000000020001700000017000010001700006737461626c6500042f746d700000000100000078565448310300000001000000000000002a0000000100000000000000290000000200000041000000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff00000000000000010000ffffffffffffffffffffffffffffffff00000000000000ffffffffffffffff000000000000020000000011565448310300000000000000000000002a0000004f565448310300000001000000000000002b00000001000000000000002a000000010000754cff000ffffffffffffffff9000000000000010101020304050605fffffffffffffff7070809000000010100"
69+ valid, err := hex.DecodeString(golden)
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ mutate := func(fn func([]byte)) []byte {
74+ out := append([]byte(nil), valid...)
75+ fn(out)
76+ return out
77+ }
78+ cases := []struct {
79+ name string
80+ data []byte
81+ }{
82+ {name: "truncated", data: valid[:len(valid)-1]},
83+ {name: "trailing bytes", data: append(append([]byte(nil), valid...), 0)},
84+ {name: "bad magic", data: mutate(func(data []byte) { data[0] ^= 0xff })},
85+ {name: "bad version", data: mutate(func(data []byte) { data[4] = 3 })},
86+ {name: "bad flags", data: mutate(func(data []byte) { data[7] = 1 })},
87+ {name: "bad crc", data: mutate(func(data []byte) { data[15] ^= 0xff })},
88+ }
89+
90+ body := append([]byte(nil), valid[16:]...)
91+ if offset := bytes.Index(body, []byte("VTH1")); offset < 0 {
92+ t.Fatal("golden has no VT blob")
93+ } else {
94+ body[offset+4] = 2
95+ cases = append(cases, struct {
96+ name string
97+ data []byte
98+ }{name: "bad nested VT version", data: v4Envelope(4, 0, body)})
99+ }
100+
101+ for _, test := range cases {
102+ t.Run(test.name, func(t *testing.T) {
103+ reject(t, test.data, nil)
104+ })
105+ }
106+ for length := 0; length < len(valid); length++ {
107+ reject(t, valid[:length], nil)
108+ }
109+}
M internal/usecase/theme/accent_resolve.go +1 -1
@@ -1,8 +1,8 @@
1 package theme1 package theme
2 2
3 import (3 import (
4+ renderer "github.com/bnema/vev-vt"
4 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/pkg/renderer"
6 )6 )
7 7
8 const (8 const (
M internal/usecase/theme/accent_resolve_test.go +1 -1
@@ -4,8 +4,8 @@ import (
4 "math/rand"4 "math/rand"
5 "testing"5 "testing"
6 6
7+ renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/pkg/renderer"
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 11
M internal/usecase/theme/benchmark_test.go +1 -1
@@ -3,8 +3,8 @@ package theme
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/pkg/renderer"
8 )8 )
9 9
10 var (10 var (
M internal/usecase/theme/color_oklab.go +1 -1
@@ -3,7 +3,7 @@ package theme
3 import (3 import (
4 "math"4 "math"
5 5
6- "github.com/bnema/vev/pkg/renderer"6+ renderer "github.com/bnema/vev-vt"
7 )7 )
8 8
9 // oklab is the perceptually uniform OKLab representation of an sRGB color.9 // oklab is the perceptually uniform OKLab representation of an sRGB color.
M internal/usecase/theme/color_oklab_test.go +1 -1
@@ -4,7 +4,7 @@ import (
4 "math"4 "math"
5 "testing"5 "testing"
6 6
7- "github.com/bnema/vev/pkg/renderer"7+ renderer "github.com/bnema/vev-vt"
8 )8 )
9 9
10 func TestRGBToOKLabKnownValues(t *testing.T) {10 func TestRGBToOKLabKnownValues(t *testing.T) {
M internal/usecase/theme/palette_scan_test.go +1 -1
@@ -6,7 +6,7 @@ import (
6 "strconv"6 "strconv"
7 "testing"7 "testing"
8 8
9- "github.com/bnema/vev/pkg/renderer"9+ renderer "github.com/bnema/vev-vt"
10 )10 )
11 11
12 type paletteEvent struct {12 type paletteEvent struct {
M internal/usecase/theme/phase3_review_test.go +1 -1
@@ -3,8 +3,8 @@ package theme
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/pkg/renderer"
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
M internal/usecase/theme/ramp.go +1 -1
@@ -1,6 +1,6 @@
1 package theme1 package theme
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import renderer "github.com/bnema/vev-vt"
4 4
5 const (5 const (
6 normalTextContrast = 4.56 normalTextContrast = 4.5
M internal/usecase/theme/ramp_test.go +1 -1
@@ -4,8 +4,8 @@ import (
4 "math"4 "math"
5 "testing"5 "testing"
6 6
7+ renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/pkg/renderer"
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 11
M internal/usecase/theme/resolved_styles.go +1 -1
@@ -1,8 +1,8 @@
1 package theme1 package theme
2 2
3 import (3 import (
4+ renderer "github.com/bnema/vev-vt"
4 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/pkg/renderer"
6 )6 )
7 7
8 const neutralAccentBlend = 0.258 const neutralAccentBlend = 0.25
M internal/usecase/theme/styles.go +1 -1
@@ -3,8 +3,8 @@ package theme
3 import (3 import (
4 "math"4 "math"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/pkg/renderer"
8 )8 )
9 9
10 const accentContrastMin = 3.010 const accentContrastMin = 3.0
M internal/usecase/theme/styles_test.go +1 -1
@@ -3,7 +3,7 @@ package theme
3 import (3 import (
4 "testing"4 "testing"
5 5
6- "github.com/bnema/vev/pkg/renderer"6+ renderer "github.com/bnema/vev-vt"
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 )8 )
9 9
M internal/usecase/theme/theme.go +2 -2
@@ -5,8 +5,8 @@ import (
5 "strconv"5 "strconv"
6 "strings"6 "strings"
7 7
8- "github.com/bnema/vev/pkg/renderer"8+ vt "github.com/bnema/vev-vt"
9- "github.com/bnema/vev/pkg/vt"9+ renderer "github.com/bnema/vev-vt/ansi"
10 )10 )
11 11
12 const maxPending = 6412 const maxPending = 64
M internal/usecase/theme/theme_test.go +1 -1
@@ -5,7 +5,7 @@ import (
5 "reflect"5 "reflect"
6 "testing"6 "testing"
7 7
8- "github.com/bnema/vev/pkg/renderer"8+ renderer "github.com/bnema/vev-vt"
9 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
10 )10 )
11 11
M internal/usecase/ui/frame.go +1 -1
@@ -1,8 +1,8 @@
1 package ui1 package ui
2 2
3 import (3 import (
4+ renderer "github.com/bnema/vev-vt"
4 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/pkg/renderer"
6 )6 )
7 7
8 // VerticalAnchor controls which end of a source is retained when its height8 // VerticalAnchor controls which end of a source is retained when its height
M internal/usecase/ui/frame_test.go +1 -1
@@ -3,8 +3,8 @@ package ui
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/pkg/renderer"
8 )8 )
9 9
10 func cell(r rune, style renderer.Style, continuation bool) renderer.Cell {10 func cell(r rune, style renderer.Style, continuation bool) renderer.Cell {
M internal/usecase/ui/modal.go +1 -1
@@ -1,8 +1,8 @@
1 package ui1 package ui
2 2
3 import (3 import (
4+ renderer "github.com/bnema/vev-vt"
4 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/pkg/renderer"
6 )6 )
7 7
8 // Modal describes a rectangular overlay.8 // Modal describes a rectangular overlay.
M internal/usecase/ui/modal_test.go +1 -1
@@ -3,8 +3,8 @@ package ui
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/pkg/renderer"
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
M internal/usecase/ui/notice.go +1 -1
@@ -4,8 +4,8 @@ import (
4 "fmt"4 "fmt"
5 "strings"5 "strings"
6 6
7+ renderer "github.com/bnema/vev-vt"
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/pkg/renderer"
9 )9 )
10 10
11 const (11 const (
M internal/usecase/ui/notice_test.go +1 -1
@@ -5,8 +5,8 @@ import (
5 "strings"5 "strings"
6 "testing"6 "testing"
7 7
8+ renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/pkg/renderer"
10 )10 )
11 11
12 // frameText joins every row of frame into one newline-separated string so12 // frameText joins every row of frame into one newline-separated string so
M internal/usecase/ui/separator.go +1 -1
@@ -1,8 +1,8 @@
1 package ui1 package ui
2 2
3 import (3 import (
4+ renderer "github.com/bnema/vev-vt"
4 "github.com/bnema/vev/internal/domain"5 "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/pkg/renderer"
6 )6 )
7 7
8 // SeparatorOrientation selects the axis of a separator slice.8 // SeparatorOrientation selects the axis of a separator slice.
M internal/usecase/ui/separator_test.go +1 -1
@@ -3,8 +3,8 @@ package ui
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/pkg/renderer"
8 )8 )
9 9
10 func TestDrawSeparator(t *testing.T) {10 func TestDrawSeparator(t *testing.T) {
M internal/usecase/ui/textinput.go +1 -1
@@ -1,6 +1,6 @@
1 package ui1 package ui
2 2
3-import "github.com/bnema/vev/pkg/renderer"3+import renderer "github.com/bnema/vev-vt"
4 4
5 // TextInput stores editable text as runes for terminal overlay input models.5 // TextInput stores editable text as runes for terminal overlay input models.
6 type TextInput struct {6 type TextInput struct {
M internal/usecase/ui/textinput_test.go +1 -1
@@ -3,7 +3,7 @@ package ui
3 import (3 import (
4 "testing"4 "testing"
5 5
6- "github.com/bnema/vev/pkg/renderer"6+ renderer "github.com/bnema/vev-vt"
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 )8 )
9 9
M internal/usecase/ui/toast.go +1 -1
@@ -3,8 +3,8 @@ package ui
3 import (3 import (
4 "time"4 "time"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/pkg/renderer"
8 )8 )
9 9
10 const (10 const (
M internal/usecase/ui/toast_test.go +1 -1
@@ -5,8 +5,8 @@ import (
5 "testing"5 "testing"
6 "time"6 "time"
7 7
8+ renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/pkg/renderer"
10 )10 )
11 11
12 func TestToastManagerDurationDismissAndClear(t *testing.T) {12 func TestToastManagerDurationDismissAndClear(t *testing.T) {
M internal/usecase/visualsearch/model.go +1 -1
@@ -3,10 +3,10 @@ package visualsearch
3 import (3 import (
4 "strconv"4 "strconv"
5 5
6+ renderer "github.com/bnema/vev-vt"
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
7 scopy "github.com/bnema/vev/internal/usecase/copy"8 scopy "github.com/bnema/vev/internal/usecase/copy"
8 "github.com/bnema/vev/internal/usecase/ui"9 "github.com/bnema/vev/internal/usecase/ui"
9- "github.com/bnema/vev/pkg/renderer"
10 )10 )
11 11
12 type Model struct {12 type Model struct {
M internal/usecase/visualsearch/model_test.go +1 -1
@@ -6,9 +6,9 @@ import (
6 6
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 8
9+ renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10 scopy "github.com/bnema/vev/internal/usecase/copy"11 scopy "github.com/bnema/vev/internal/usecase/copy"
11- "github.com/bnema/vev/pkg/renderer"
12 )12 )
13 13
14 var benchmarkModelSink *Model14 var benchmarkModelSink *Model
D pkg/renderer/benchmark_test.go +0 -235
@@ -1,235 +0,0 @@
1-package renderer
2-
3-import "testing"
4-
5-func markBenchmarkFrame(f *Frame) {
6- for y := range f.Height {
7- for x := range f.Width {
8- f.Set(x, y, Cell{Rune: rune('A' + (y*f.Width+x)%26), Style: DefaultStyle()})
9- }
10- }
11-}
12-
13-func setBenchmarkShadow(r *Renderer, frame Frame) {
14- committed := r.committedFrame()
15- replaceFrame(&committed, frame)
16- r.setCommittedFrame(committed)
17-}
18-
19-func BenchmarkRendererFullFrameDraw(b *testing.B) {
20- frame := NewFrame(120, 40)
21- markBenchmarkFrame(&frame)
22- r := New(Capabilities{})
23- damage := []Damage{FullRedraw()}
24- b.ReportAllocs()
25- var outBytes int64
26- for b.Loop() {
27- r.Reset()
28- out, err := r.Draw(frame, damage)
29- if err != nil {
30- b.Fatal(err)
31- }
32- if len(out) == 0 {
33- b.Fatal("expected renderer output")
34- }
35- outBytes += int64(len(out))
36- }
37- b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
38-}
39-
40-func BenchmarkRendererFragmentedDamage(b *testing.B) {
41- const (
42- width = 120
43- height = 40
44- )
45-
46- baseline := NewFrame(width, height)
47- markBenchmarkFrame(&baseline)
48- frame := NewFrame(width, height)
49- markBenchmarkFrame(&frame)
50-
51- // Create 256 overlapping or vertically adjacent text rectangles in reverse
52- // row/column order. Canonical damage planning must sort and merge these into
53- // stable per-row spans before emission.
54- damage := make([]Damage, 0, 256)
55- for row := 15; row >= 0; row-- {
56- for col := 15; col >= 0; col-- {
57- x, y := col*7, row*2
58- damage = append(damage, Damage{Kind: DamageText, X: x, Y: y, Width: 8, Height: 2})
59- for dy := range 2 {
60- for dx := range 8 {
61- frame.Set(x+dx, y+dy, Cell{Rune: 'z', Style: DefaultStyle()})
62- }
63- }
64- }
65- }
66-
67- r := New(Capabilities{})
68- b.ReportAllocs()
69- var outBytes int64
70- for b.Loop() {
71- // Each iteration starts with the identical pre-update terminal shadow.
72- setBenchmarkShadow(r, baseline)
73- out, err := r.Draw(frame, damage)
74- if err != nil {
75- b.Fatal(err)
76- }
77- if len(out) == 0 {
78- b.Fatal("expected renderer output")
79- }
80- outBytes += int64(len(out))
81- }
82- b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
83-}
84-
85-func BenchmarkRendererIncrementalOneCell(b *testing.B) {
86- for _, tt := range []struct {
87- name string
88- prepareCommit bool
89- }{
90- {name: "PrepareCommit", prepareCommit: true},
91- {name: "Draw"},
92- } {
93- b.Run(tt.name, func(b *testing.B) {
94- frame := NewFrame(120, 40)
95- markBenchmarkFrame(&frame)
96- r := New(Capabilities{})
97- if tt.prepareCommit {
98- initial, err := r.Prepare(frame, []Damage{FullRedraw()}, false)
99- if err != nil {
100- b.Fatal(err)
101- }
102- initial.Commit()
103- } else if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
104- b.Fatal(err)
105- }
106- damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}
107-
108- b.ReportAllocs()
109- var outBytes int64
110- changed := false
111- for b.Loop() {
112- changed = !changed
113- cell := Cell{Rune: 'X', Style: DefaultStyle()}
114- if changed {
115- cell.Rune = 'Y'
116- }
117- frame.Set(60, 20, cell)
118- if tt.prepareCommit {
119- prepared, err := r.Prepare(frame, damage, false)
120- if err != nil {
121- b.Fatal(err)
122- }
123- if len(prepared.Bytes()) == 0 {
124- b.Fatal("expected renderer output")
125- }
126- outBytes += int64(len(prepared.Bytes()))
127- prepared.Commit()
128- continue
129- }
130- out, err := r.Draw(frame, damage)
131- if err != nil {
132- b.Fatal(err)
133- }
134- if len(out) == 0 {
135- b.Fatal("expected renderer output")
136- }
137- outBytes += int64(len(out))
138- }
139- b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
140- })
141- }
142-}
143-
144-func BenchmarkRendererIncrementalNoBytePrepareCommit(b *testing.B) {
145- frame := NewFrame(120, 40)
146- markBenchmarkFrame(&frame)
147- r := New(Capabilities{})
148- initial, err := r.Prepare(frame, []Damage{FullRedraw()}, false)
149- if err != nil {
150- b.Fatal(err)
151- }
152- initial.Commit()
153-
154- b.ReportAllocs()
155- for b.Loop() {
156- prepared, err := r.Prepare(frame, nil, false)
157- if err != nil {
158- b.Fatal(err)
159- }
160- if len(prepared.Bytes()) != 0 {
161- b.Fatal("expected no output")
162- }
163- prepared.Commit()
164- }
165-}
166-
167-func BenchmarkRendererBroadRegularDamage(b *testing.B) {
168- const (
169- width = 120
170- height = 40
171- )
172- baseline := NewFrame(width, height)
173- markBenchmarkFrame(&baseline)
174- frame := baseline.Clone()
175- for y := range height {
176- frame.Set(0, y, Cell{Rune: 'z', Style: DefaultStyle()})
177- }
178- damage := make([]Damage, height)
179- for y := range height {
180- damage[y] = Damage{Kind: DamageText, X: 0, Y: y, Width: width, Height: 1}
181- }
182-
183- r := New(Capabilities{})
184- b.ReportAllocs()
185- var outBytes int64
186- for b.Loop() {
187- setBenchmarkShadow(r, baseline)
188- out, err := r.Draw(frame, damage)
189- if err != nil {
190- b.Fatal(err)
191- }
192- if len(out) == 0 {
193- b.Fatal("expected renderer output")
194- }
195- outBytes += int64(len(out))
196- }
197- b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
198-}
199-
200-func BenchmarkRendererScrollFastPath(b *testing.B) {
201- frame := NewFrame(120, 40)
202- markBenchmarkFrame(&frame)
203- r := New(Capabilities{})
204- if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
205- b.Fatal(err)
206- }
207-
208- scrolled := NewFrame(120, 40)
209- copy(scrolled.Cells[0:39*120], frame.Cells[120:40*120])
210- for i := 39 * 120; i < 40*120; i++ {
211- scrolled.Cells[i] = BlankCell()
212- }
213- for x, r := range []rune("new output line") {
214- scrolled.Set(x, 39, Cell{Rune: r, Style: DefaultStyle()})
215- }
216- damage := []Damage{
217- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 120, Height: 40, Count: 1},
218- {Kind: DamageText, X: 0, Y: 39, Width: 120, Height: 1, Count: 1},
219- }
220-
221- b.ReportAllocs()
222- var outBytes int64
223- for b.Loop() {
224- setBenchmarkShadow(r, frame)
225- out, err := r.Draw(scrolled, damage)
226- if err != nil {
227- b.Fatal(err)
228- }
229- if len(out) == 0 {
230- b.Fatal("expected renderer output")
231- }
232- outBytes += int64(len(out))
233- }
234- b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
235-}
D pkg/renderer/cell.go +0 -19
@@ -1,19 +0,0 @@
1-package renderer
2-
3-// Cell is a single terminal grid cell.
4-//
5-// A double-width rune (CJK, emoji) occupies TWO adjacent cells: the left cell
6-// holds the rune ({Rune: r, Style: s}) and the right cell is a continuation
7-// marker ({Continuation: true, Rune: 0}). Continuation cells are never emitted
8-// by the renderer — the terminal advances two columns for the wide rune itself.
9-type Cell struct {
10- Rune rune
11- Style Style
12- Continuation bool
13-}
14-
15-func BlankCell() Cell { return Cell{Rune: ' ', Style: DefaultStyle()} }
16-
17-func (c Cell) Equal(other Cell) bool {
18- return c.Rune == other.Rune && c.Continuation == other.Continuation && c.Style.Equal(other.Style)
19-}
D pkg/renderer/damage.go +0 -21
@@ -1,21 +0,0 @@
1-package renderer
2-
3-type DamageKind int
4-
5-const (
6- DamageText DamageKind = iota
7- DamageClear
8- DamageScrollUp
9- DamageFullRedraw
10-)
11-
12-type Damage struct {
13- Kind DamageKind
14- X int
15- Y int
16- Width int
17- Height int
18- Count int
19-}
20-
21-func FullRedraw() Damage { return Damage{Kind: DamageFullRedraw, Count: 1} }
D pkg/renderer/damage_plan.go +0 -95
@@ -1,95 +0,0 @@
1-package renderer
2-
3-import (
4- "cmp"
5- "slices"
6-)
7-
8-const maxPlannedDamageSpans = 4096
9-
10-// buildDamagePlan creates the bounded, canonical view of text and clear damage
11-// used for terminal emission. The source damage remains untouched because the
12-// scroll and shadow paths need its original structural information.
13-func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool) {
14- if len(damage) == 1 {
15- d := damage[0]
16- if skip != nil && sameDamage(d, *skip) {
17- return nil, false
18- }
19- if d.Kind != DamageText && d.Kind != DamageClear {
20- return nil, false
21- }
22- return buildSingleDamageSpans(frame, d)
23- }
24-
25- spans := make([]Span, 0)
26- for _, d := range damage {
27- if skip != nil && sameDamage(d, *skip) {
28- continue
29- }
30- if d.Kind != DamageText && d.Kind != DamageClear {
31- continue
32- }
33- x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
34- if !ok {
35- continue
36- }
37- for row := y; row < y+height; row++ {
38- if len(spans) == maxPlannedDamageSpans {
39- return nil, true
40- }
41- spans = append(spans, Span{Y: row, X: x, Width: width})
42- }
43- }
44-
45- slices.SortFunc(spans, func(a, b Span) int {
46- if c := cmp.Compare(a.Y, b.Y); c != 0 {
47- return c
48- }
49- return cmp.Compare(a.X, b.X)
50- })
51-
52- return mergeDamageSpans(spans), false
53-}
54-
55-func buildSingleDamageSpans(frame Frame, d Damage) ([]Span, bool) {
56- x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
57- if !ok {
58- return nil, false
59- }
60- if height > maxPlannedDamageSpans {
61- return nil, true
62- }
63- spans := make([]Span, height)
64- for row := range height {
65- spans[row] = Span{Y: y + row, X: x, Width: width}
66- }
67- return spans, false
68-}
69-
70-func mergeDamageSpans(spans []Span) []Span {
71- if len(spans) < 2 {
72- return spans
73- }
74-
75- out := spans[:0]
76- for _, span := range spans {
77- if len(out) == 0 {
78- out = append(out, span)
79- continue
80- }
81- last := &out[len(out)-1]
82- // Both endpoints are bounded by frame.Width after clampRect, so these
83- // additions cannot overflow. The comparison includes adjacency.
84- lastEnd := last.X + last.Width
85- spanEnd := span.X + span.Width
86- if span.Y != last.Y || span.X > lastEnd {
87- out = append(out, span)
88- continue
89- }
90- if spanEnd > lastEnd {
91- last.Width = spanEnd - last.X
92- }
93- }
94- return out
95-}
D pkg/renderer/damage_plan_test.go +0 -99
@@ -1,99 +0,0 @@
1-package renderer
2-
3-import (
4- "math"
5- "reflect"
6- "testing"
7-)
8-
9-func TestBuildDamagePlan(t *testing.T) {
10- tests := []struct {
11- name string
12- frame Frame
13- damage []Damage
14- want []Span
15- full bool
16- }{
17- {
18- name: "merges reverse overlaps",
19- frame: NewFrame(10, 2),
20- damage: []Damage{
21- {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1},
22- {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
23- },
24- want: []Span{{Y: 0, X: 0, Width: 9}},
25- },
26- {
27- name: "merges text and clear",
28- frame: NewFrame(10, 1),
29- damage: []Damage{
30- {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
31- {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
32- },
33- want: []Span{{Y: 0, X: 0, Width: 9}},
34- },
35- {
36- name: "orders multi-row spans",
37- frame: NewFrame(10, 3),
38- damage: []Damage{
39- {Kind: DamageText, X: 5, Y: 2, Width: 2, Height: 1},
40- {Kind: DamageText, X: 4, Y: 0, Width: 2, Height: 2},
41- {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2},
42- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1},
43- },
44- want: []Span{
45- {Y: 0, X: 1, Width: 2}, {Y: 0, X: 4, Width: 2},
46- {Y: 1, X: 1, Width: 2}, {Y: 1, X: 4, Width: 2},
47- {Y: 2, X: 5, Width: 2},
48- },
49- },
50- {
51- name: "clamps rectangles",
52- frame: NewFrame(10, 3),
53- damage: []Damage{
54- {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3},
55- {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3},
56- },
57- want: []Span{{Y: 0, X: 0, Width: 3}, {Y: 1, X: 0, Width: 3}, {Y: 1, X: 8, Width: 2}, {Y: 2, X: 8, Width: 2}},
58- },
59- {
60- name: "does not mutate input",
61- frame: NewFrame(10, 2),
62- damage: []Damage{
63- {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1},
64- {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
65- },
66- want: []Span{{Y: 0, X: 1, Width: 2}, {Y: 1, X: 5, Width: 2}},
67- },
68- {
69- name: "exceeding budget requests full redraw",
70- frame: NewFrame(1, maxPlannedDamageSpans+1),
71- damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}},
72- want: nil,
73- full: true,
74- },
75- }
76-
77- for _, tt := range tests {
78- t.Run(tt.name, func(t *testing.T) {
79- original := append([]Damage(nil), tt.damage...)
80- got, full := buildDamagePlan(tt.frame, tt.damage, nil)
81- if full != tt.full || !reflect.DeepEqual(got, tt.want) {
82- t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full)
83- }
84- if !reflect.DeepEqual(tt.damage, original) {
85- t.Fatalf("buildDamagePlan mutated damage: got %#v, want %#v", tt.damage, original)
86- }
87- })
88- }
89-}
90-
91-func TestClampRectRejectsOverflowingBounds(t *testing.T) {
92- frame := NewFrame(10, 10)
93- if _, _, _, _, ok := clampRect(frame, math.MaxInt-1, 0, 4, 1); ok {
94- t.Fatal("clampRect accepted overflowing x bound")
95- }
96- if _, _, _, _, ok := clampRect(frame, 0, math.MaxInt-1, 1, 4); ok {
97- t.Fatal("clampRect accepted overflowing y bound")
98- }
99-}
D pkg/renderer/delta_plan.go +0 -233
@@ -1,233 +0,0 @@
1-package renderer
2-
3-// Span identifies a changed horizontal range in a logical frame row.
4-type Span struct {
5- Y int
6- X int
7- Width int
8-}
9-
10-// Scroll identifies a safe full-width upward scroll.
11-type Scroll struct {
12- Y int
13- Height int
14- Count int
15-}
16-
17-// DeltaPlan describes how to advance a committed frame to the candidate frame.
18-// Snapshot requests replacement of the committed frame; Spans, when present,
19-// are the canonical ranges to encode before that replacement.
20-type DeltaPlan struct {
21- Snapshot bool
22- Scroll Scroll
23- Spans []Span
24-}
25-
26-// DeltaCandidate holds a plan and owns a source frame snapshot when the plan
27-// has work to commit. Empty candidates are true no-ops.
28-type DeltaCandidate struct {
29- Plan DeltaPlan
30- frame Frame
31-}
32-
33-func newDeltaCandidate(frame Frame, plan DeltaPlan) DeltaCandidate {
34- candidate := DeltaCandidate{Plan: plan}
35- if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
36- candidate.frame = frame.Clone()
37- }
38- return candidate
39-}
40-
41-// PlanDelta prepares a non-mutating update from committed to frame.
42-func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {
43- if err := frame.Validate(); err != nil {
44- return DeltaCandidate{}, err
45- }
46-
47- if reset || frame.Width != committed.Width || frame.Height != committed.Height {
48- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil
49- }
50- if err := committed.Validate(); err != nil {
51- return DeltaCandidate{}, err
52- }
53-
54- if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
55- return newDeltaCandidate(frame, planSingleDamage(frame, damage[0])), nil
56- }
57-
58- if needsFull(damage) {
59- dirty, full := buildDirtyLinePlan(frame, committed)
60- if len(dirty) > 0 || full {
61- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}), nil
62- }
63- return DeltaCandidate{}, nil
64- }
65-
66- var plan DeltaPlan
67- var skip *Damage
68- if scroll, ok := findSafeScroll(frame, damage); ok && canApplyScrollAgainst(frame, scroll, damage, committed) {
69- plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count}
70- skip = &scroll
71- } else if hasScrollDamage(damage) {
72- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil
73- }
74-
75- var spans []Span
76- var full bool
77- if len(damage) == 0 {
78- spans, full = buildDirtyLinePlan(frame, committed)
79- } else {
80- spans, full = buildDamagePlan(frame, damage, skip)
81- }
82- if full {
83- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil
84- }
85- if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) {
86- if plan.Scroll.Height != 0 {
87- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil
88- }
89- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}), nil
90- }
91- plan.Spans = spans
92- return newDeltaCandidate(frame, plan), nil
93-}
94-
95-func planSingleDamage(frame Frame, d Damage) DeltaPlan {
96- spans, full := buildSingleDamageSpans(frame, d)
97- if full {
98- return DeltaPlan{Snapshot: true}
99- }
100- plan := DeltaPlan{Spans: spans}
101- if deltaCostsSnapshot(frame, spans, false) {
102- plan.Snapshot = true
103- }
104- return plan
105-}
106-
107-func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) {
108- var spans []Span
109- for y := range frame.Height {
110- frameRow := frame.Row(y)
111- committedRow := committed.Row(y)
112- dirty := false
113- for x := range frame.Width {
114- if !frameRow[x].Equal(committedRow[x]) {
115- dirty = true
116- break
117- }
118- }
119- if dirty {
120- if len(spans) == maxPlannedDamageSpans {
121- return nil, true
122- }
123- spans = append(spans, Span{Y: y, Width: frame.Width})
124- }
125- }
126- return spans, false
127-}
128-
129-func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool {
130- if !hasScroll && frame.Height > 1 && len(spans) == 1 {
131- return false
132- }
133-
134- var deltaCost int64
135- if hasScroll {
136- deltaCost = 6
137- }
138- for _, span := range spans {
139- deltaCost += 12 + int64(span.Width)
140- }
141-
142- minimumSnapshotCost := int64(frame.Height) * (12 + int64(frame.Width))
143- if deltaCost < minimumSnapshotCost {
144- return false
145- }
146- maximumSnapshotCost := int64(frame.Height) * (8 + 5*int64(frame.Width))
147- if deltaCost >= maximumSnapshotCost {
148- return true
149- }
150- if !hasScroll && fullWidthRows(frame, spans) {
151- return true
152- }
153-
154- for _, span := range spans {
155- deltaCost += 4 * int64(styleRunCount(frame.Row(span.Y)[span.X:span.X+span.Width])-1)
156- }
157- var snapshotCost int64
158- for y := range frame.Height {
159- snapshotCost += spanCost(frame, y, 0, frame.Width)
160- }
161- return deltaCost >= snapshotCost
162-}
163-
164-func fullWidthRows(frame Frame, spans []Span) bool {
165- if len(spans) != frame.Height {
166- return false
167- }
168- for y, span := range spans {
169- if span.Y != y || span.X != 0 || span.Width != frame.Width {
170- return false
171- }
172- }
173- return true
174-}
175-
176-func spanCost(frame Frame, y, x, width int) int64 {
177- return 8 + int64(width) + 4*int64(styleRunCount(frame.Row(y)[x:x+width]))
178-}
179-
180-func styleRunCount(cells []Cell) int {
181- if len(cells) == 0 {
182- return 0
183- }
184- runs := 1
185- for i := 1; i < len(cells); i++ {
186- if cells[i-1].Style != cells[i].Style && !cells[i-1].Style.Equal(cells[i].Style) {
187- runs++
188- }
189- }
190- return runs
191-}
192-
193-// Commit advances dst to the frame snapshot owned by the candidate.
194-func (c DeltaCandidate) Commit(dst *Frame) {
195- if !c.Plan.Snapshot && c.Plan.Scroll.Height == 0 && len(c.Plan.Spans) == 0 {
196- return
197- }
198- if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height {
199- replaceFrame(dst, c.frame)
200- return
201- }
202- if c.Plan.Scroll.Height != 0 {
203- scroll := c.Plan.Scroll
204- dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)
205- }
206- for _, span := range c.Plan.Spans {
207- dstStart := dst.lineOffset[span.Y] + span.X
208- srcStart := c.frame.lineOffset[span.Y] + span.X
209- copy(dst.Cells[dstStart:dstStart+span.Width], c.frame.Cells[srcStart:srcStart+span.Width])
210- }
211-}
212-
213-func replaceFrame(dst *Frame, src Frame) {
214- sameDimensions := dst.Width == src.Width && dst.Height == src.Height
215- cellCount := src.Width * src.Height
216- if sameDimensions && cap(dst.Cells) >= cellCount {
217- dst.Cells = dst.Cells[:cellCount]
218- } else {
219- dst.Cells = make([]Cell, cellCount)
220- }
221- if sameDimensions && cap(dst.lineOffset) >= src.Height {
222- dst.lineOffset = dst.lineOffset[:src.Height]
223- } else {
224- dst.lineOffset = make([]int, src.Height)
225- }
226- dst.Width = src.Width
227- dst.Height = src.Height
228- for y := range src.Height {
229- start := y * src.Width
230- copy(dst.Cells[start:start+src.Width], src.Row(y))
231- dst.lineOffset[y] = start
232- }
233-}
D pkg/renderer/delta_plan_test.go +0 -271
@@ -1,271 +0,0 @@
1-package renderer
2-
3-import (
4- "reflect"
5- "testing"
6-)
7-
8-func testFrame(rows ...string) Frame {
9- frame := NewFrame(len([]rune(rows[0])), len(rows))
10- fillFrame(frame, rows)
11- return frame
12-}
13-
14-func TestPlanDelta(t *testing.T) {
15- scrollCommitted := testFrame("0000", "1111", "2222", "3333", "4444", "5555", "6666", "7777")
16- scrolled := testFrame("0000", "2222", "3333", "4444", "5555", "6666", "new!", "7777")
17- largeFrame := NewFrame(1, maxPlannedDamageSpans+1)
18- styledCommitted := NewFrame(10, 1)
19- styledCommitted.Set(3, 0, Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}})
20- styledFrame := styledCommitted.Clone()
21- styledFrame.Set(0, 0, Cell{Rune: 'a', Style: DefaultStyle()})
22- styledFrame.Set(9, 0, Cell{Rune: 'z', Style: DefaultStyle()})
23-
24- tests := []struct {
25- name string
26- frame Frame
27- damage []Damage
28- committed Frame
29- reset bool
30- want DeltaPlan
31- }{
32- {
33- name: "reset snapshots",
34- frame: NewFrame(4, 2),
35- committed: NewFrame(4, 2),
36- reset: true,
37- want: DeltaPlan{Snapshot: true},
38- },
39- {
40- name: "dimension mismatch snapshots",
41- frame: NewFrame(4, 2),
42- committed: NewFrame(3, 2),
43- want: DeltaPlan{Snapshot: true},
44- },
45- {
46- name: "unchanged same size has no spans",
47- frame: NewFrame(4, 2),
48- committed: NewFrame(4, 2),
49- want: DeltaPlan{},
50- },
51- {
52- name: "unchanged full redraw has no spans",
53- frame: NewFrame(4, 2),
54- damage: []Damage{FullRedraw()},
55- committed: NewFrame(4, 2),
56- want: DeltaPlan{},
57- },
58- {
59- name: "changed row without damage becomes a full width span",
60- frame: testFrame(" ", "edit", " "),
61- committed: NewFrame(4, 3),
62- want: DeltaPlan{Spans: []Span{{Y: 1, X: 0, Width: 4}}},
63- },
64- {
65- name: "adjacent and overlapping damage is ordered and merged",
66- frame: testFrame(" ", "aaaaaaaaa ", " "),
67- damage: []Damage{
68- {Kind: DamageText, X: 4, Y: 1, Width: 5, Height: 1},
69- {Kind: DamageClear, X: 0, Y: 1, Width: 5, Height: 1},
70- {Kind: DamageText, X: 2, Y: 1, Width: 2, Height: 1},
71- },
72- committed: NewFrame(10, 3),
73- want: DeltaPlan{Spans: []Span{{Y: 1, X: 0, Width: 9}}},
74- },
75- {
76- name: "safe full width scroll retains exposed span",
77- frame: scrolled,
78- damage: []Damage{
79- {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 6, Count: 1},
80- {Kind: DamageText, X: 0, Y: 6, Width: 4, Height: 1},
81- },
82- committed: scrollCommitted,
83- want: DeltaPlan{
84- Scroll: Scroll{Y: 1, Height: 6, Count: 1},
85- Spans: []Span{{Y: 6, X: 0, Width: 4}},
86- },
87- },
88- {
89- name: "safe full width scroll with expensive delta snapshots",
90- frame: testFrame("1111", "new!"),
91- damage: []Damage{
92- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 2, Count: 1},
93- {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2},
94- },
95- committed: testFrame("0000", "1111"),
96- want: DeltaPlan{Snapshot: true},
97- },
98- {
99- name: "rectangular scroll snapshots",
100- frame: scrolled,
101- damage: []Damage{
102- {Kind: DamageScrollUp, X: 1, Y: 1, Width: 3, Height: 6, Count: 1},
103- },
104- committed: scrollCommitted,
105- want: DeltaPlan{Snapshot: true},
106- },
107- {
108- name: "unsafe scroll content snapshots",
109- frame: testFrame("0000", "2222", "xxxx", "4444", "5555", "6666", "new!", "7777"),
110- damage: []Damage{
111- {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 6, Count: 1},
112- {Kind: DamageText, X: 0, Y: 6, Width: 4, Height: 1},
113- },
114- committed: scrollCommitted,
115- want: DeltaPlan{Snapshot: true},
116- },
117- {
118- name: "more than 4096 canonical spans snapshots",
119- frame: largeFrame,
120- damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}},
121- committed: NewFrame(1, maxPlannedDamageSpans+1),
122- want: DeltaPlan{Snapshot: true},
123- },
124- {
125- name: "delta cost equal to snapshot cost snapshots",
126- frame: testFrame("changed!"),
127- damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 8, Height: 1}},
128- committed: NewFrame(8, 1),
129- want: DeltaPlan{Snapshot: true, Spans: []Span{{Y: 0, X: 0, Width: 8}}},
130- },
131- {
132- name: "single broad damage retains spans for snapshot encoding",
133- frame: testFrame("aaaa", "bbbb"),
134- damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}},
135- committed: NewFrame(4, 2),
136- want: DeltaPlan{
137- Snapshot: true,
138- Spans: []Span{{Y: 0, X: 0, Width: 4}, {Y: 1, X: 0, Width: 4}},
139- },
140- },
141- {
142- name: "snapshot cost includes style runs",
143- frame: styledFrame,
144- damage: []Damage{
145- {Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1},
146- {Kind: DamageText, X: 9, Y: 0, Width: 1, Height: 1},
147- },
148- committed: styledCommitted,
149- want: DeltaPlan{Spans: []Span{{Y: 0, X: 0, Width: 1}, {Y: 0, X: 9, Width: 1}}},
150- },
151- }
152-
153- for _, tt := range tests {
154- t.Run(tt.name, func(t *testing.T) {
155- candidate, err := PlanDelta(tt.frame, tt.damage, tt.committed, tt.reset)
156- if err != nil {
157- t.Fatalf("PlanDelta() error = %v", err)
158- }
159- if !reflect.DeepEqual(candidate.Plan, tt.want) {
160- t.Fatalf("PlanDelta() = %#v, want %#v", candidate.Plan, tt.want)
161- }
162- })
163- }
164-}
165-
166-func TestNoOpDeltaCandidateDoesNotCloneFrame(t *testing.T) {
167- frame := testFrame("abcd", "efgh")
168- candidate, err := PlanDelta(frame, nil, frame, false)
169- if err != nil {
170- t.Fatalf("PlanDelta() error = %v", err)
171- }
172- if candidate.frame.Cells != nil || candidate.frame.lineOffset != nil {
173- t.Fatal("no-op candidate owns frame storage")
174- }
175-
176- committed := frame.Clone()
177- before := committed.Clone()
178- candidate.Commit(&committed)
179- if !reflect.DeepEqual(committed, before) {
180- t.Fatal("no-op candidate changed committed frame")
181- }
182-}
183-
184-func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) {
185- committed := testFrame("0000", "1111", "2222", "3333", "4444")
186- committed.ScrollUp(1, 3, 1)
187- beforeCells := append([]Cell(nil), committed.Cells...)
188- beforeOffsets := append([]int(nil), committed.lineOffset...)
189- frame := testFrame("0000", "2222", "3333", "new!", "4444")
190- damage := []Damage{
191- {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 3, Count: 1},
192- {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
193- }
194-
195- if _, err := PlanDelta(frame, damage, committed, false); err != nil {
196- t.Fatalf("PlanDelta() error = %v", err)
197- }
198-
199- if !reflect.DeepEqual(committed.Cells, beforeCells) {
200- t.Fatal("PlanDelta mutated committed cells")
201- }
202- if !reflect.DeepEqual(committed.lineOffset, beforeOffsets) {
203- t.Fatal("PlanDelta mutated committed row offsets")
204- }
205-}
206-
207-func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) {
208- frame := testFrame("aaaa", "bbbb", "cccc", "dddd")
209- frame.ScrollUp(0, 3, 1)
210- fillFrameRow(frame, 3, "new!")
211- committed := NewFrame(4, 4)
212- cellsBase := &committed.Cells[0]
213-
214- candidate, err := PlanDelta(frame, nil, committed, true)
215- if err != nil {
216- t.Fatalf("PlanDelta() error = %v", err)
217- }
218- candidate.Commit(&committed)
219-
220- if &committed.Cells[0] != cellsBase {
221- t.Fatal("Commit replaced same-sized cell storage")
222- }
223- for y := range frame.Height {
224- if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) {
225- t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y))
226- }
227- }
228- if err := committed.CheckInvariants(); err != nil {
229- t.Fatalf("committed frame invariants: %v", err)
230- }
231-}
232-
233-func TestPlanDeltaRejectsInvalidFrame(t *testing.T) {
234- frame := NewFrame(2, 1)
235- frame.Cells = nil
236- if _, err := PlanDelta(frame, nil, Frame{}, false); err == nil {
237- t.Fatal("PlanDelta accepted an invalid frame")
238- }
239-}
240-
241-func TestDeltaCandidateCommitAppliesScrollToLogicalRows(t *testing.T) {
242- committed := testFrame("0000", "1111", "2222", "3333", "4444", "5555", "6666", "7777")
243- frame := committed.Clone()
244- frame.ScrollUp(1, 6, 1)
245- fillFrameRow(frame, 6, "new!")
246- damage := []Damage{
247- {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 6, Count: 1},
248- {Kind: DamageText, X: 0, Y: 6, Width: 4, Height: 1},
249- }
250-
251- candidate, err := PlanDelta(frame, damage, committed, false)
252- if err != nil {
253- t.Fatalf("PlanDelta() error = %v", err)
254- }
255- if candidate.Plan.Scroll.Height == 0 {
256- t.Fatal("PlanDelta dropped safe scroll")
257- }
258- candidate.Commit(&committed)
259-
260- for y := range frame.Height {
261- if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) {
262- t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y))
263- }
264- }
265-}
266-
267-func fillFrameRow(frame Frame, y int, row string) {
268- for x, r := range row {
269- frame.Set(x, y, Cell{Rune: r, Style: DefaultStyle()})
270- }
271-}
D pkg/renderer/doc.go +0 -2
@@ -1,2 +0,0 @@
1-// Package renderer draws terminal state to the screen.
2-package renderer
D pkg/renderer/draw_state.go +0 -250
@@ -1,250 +0,0 @@
1-package renderer
2-
3-import (
4- "bytes"
5- "strconv"
6-)
7-
8-// drawState carries the per-Draw terminal state model: the cursor position we
9-// last put the terminal at (when it is known with certainty) and the SGR pen
10-// currently active. It lives for exactly one Draw call: between Draws the
11-// terminal may be moved by other writers, so cursor tracking never persists
12-// (this also trivially satisfies "invalidate on Reset"). The pen starts as
13-// the default style because every Draw that emits output ends with \x1b[0m,
14-// which is this package's output contract.
15-type drawState struct {
16- curKnown bool
17- curRow int
18- curCol int
19- pen Style
20-}
21-
22-func newDrawState() drawState {
23- return drawState{pen: DefaultStyle()}
24-}
25-
26-func (st *drawState) setCursor(row, col int) {
27- st.curKnown = true
28- st.curRow = row
29- st.curCol = col
30-}
31-
32-// invalidateCursor marks the tracked position as uncertain. It is called
33-// whenever an emission leaves the cursor in a terminal-dependent state, most
34-// importantly after writing into the last column: terminals differ on
35-// wrap-pending behavior, so we never model it and fall back to absolute CUP.
36-func (st *drawState) invalidateCursor() {
37- st.curKnown = false
38-}
39-
40-// Cursor-motion candidates considered by planMove. Absolute CUP is always
41-// correct; the others are only valid relative to a certain tracked position.
42-const (
43- moveCUP = 'H' // \x1b[<r>;<c>H
44- moveCR = '\r' // carriage return: same row, column 0
45- moveCUU = 'A' // \x1b[<n>A: up, same column
46- moveCUD = 'B' // \x1b[<n>B: down, same column
47- moveCUF = 'C' // \x1b[<n>C: forward, same row
48- moveCUB = 'D' // \x1b[<n>D: backward, same row
49-)
50-
51-// planMove picks the cheapest correct sequence to move from the tracked
52-// position to (row, col). "Cheapest" is decided by comparing the actual
53-// encoded byte lengths of every applicable candidate (no fixed thresholds):
54-// CUP costs 4 + digits(row+1) + digits(col+1); CR costs 1; CUU/CUD/CUF/CUB
55-// cost 3 for a delta of 1 (count omitted) and 3 + digits(n) otherwise.
56-// Relative candidates are considered only when the tracked position is
57-// certain; ties go to absolute CUP, the safest form.
58-func (st *drawState) planMove(row, col int) (op byte, n, cost int) {
59- op, n, cost = moveCUP, 0, 4+decimalLen(row+1)+decimalLen(col+1)
60- if !st.curKnown {
61- return op, n, cost
62- }
63- consider := func(candOp byte, candN, candCost int) {
64- if candCost < cost {
65- op, n, cost = candOp, candN, candCost
66- }
67- }
68- if row == st.curRow {
69- if col == 0 {
70- consider(moveCR, 0, 1)
71- }
72- if dc := col - st.curCol; dc > 0 {
73- consider(moveCUF, dc, csiRelLen(dc))
74- } else if dc < 0 {
75- consider(moveCUB, -dc, csiRelLen(-dc))
76- }
77- } else if col == st.curCol {
78- if dr := row - st.curRow; dr > 0 {
79- consider(moveCUD, dr, csiRelLen(dr))
80- } else {
81- consider(moveCUU, -dr, csiRelLen(-dr))
82- }
83- }
84- return op, n, cost
85-}
86-
87-// moveTo emits the cheapest correct cursor-positioning sequence for (row, col)
88-// and updates tracking. Moving to the already-tracked position emits nothing.
89-func (st *drawState) moveTo(out *bytes.Buffer, row, col int) {
90- if st.curKnown && st.curRow == row && st.curCol == col {
91- return
92- }
93- op, n, _ := st.planMove(row, col)
94- switch op {
95- case moveCUP:
96- writeCursor(out, row, col)
97- case moveCR:
98- out.WriteByte('\r')
99- default:
100- writeCSIRel(out, n, op)
101- }
102- st.setCursor(row, col)
103-}
104-
105-// moveCost returns the encoded length moveTo would emit for (row, col).
106-func (st *drawState) moveCost(row, col int) int {
107- if st.curKnown && st.curRow == row && st.curCol == col {
108- return 0
109- }
110- _, _, cost := st.planMove(row, col)
111- return cost
112-}
113-
114-// setPen emits an SGR sequence if style differs from the current pen.
115-func (st *drawState) setPen(out *bytes.Buffer, style Style) {
116- if style.Equal(st.pen) {
117- return
118- }
119- writeStyle(out, style)
120- st.pen = style
121-}
122-
123-// isBlank reports whether a cell renders as a default-style space. This is
124-// the differ's notion of blank: rune ' ' or 0, default style, and not the
125-// continuation half of a wide pair.
126-func isBlank(c Cell) bool {
127- return !c.Continuation && (c.Rune == ' ' || c.Rune == 0) && c.Style.Equal(DefaultStyle())
128-}
129-
130-// Native-clear encoded lengths: EL "\x1b[K" and EL2 "\x1b[2K".
131-const (
132- elLen = 3
133- el2Len = 4
134-)
135-
136-// emitSpan positions the cursor and emits the cells of logical row y in
137-// [x, x+width), already clamped by the caller. It applies the three output
138-// minimizations:
139-//
140-// - cursor motion: moveTo picks the cheapest sequence, and after emission
141-// the cursor is tracked at x+width (continuation cells count as columns:
142-// the terminal advances two columns per wide rune). A span that touches
143-// the last column invalidates tracking instead — wrap-pending state is
144-// terminal-dependent and never modeled.
145-// - SGR: the pen persists across spans (st is Draw-scoped); a style change
146-// is emitted only when a cell's style differs from the pen.
147-// - native clears: a trailing run of blank cells that reaches the frame's
148-// right edge is replaced by EL when EL is not longer than the spaces it
149-// replaces (ties prefer EL: it keeps the cursor position certain). An
150-// entirely blank full-width line may instead use a vertical-only move
151-// plus 2K when that encoding is cheaper. Both clears require the default
152-// pen (BCE: terminals fill cleared cells with the pen background).
153-func (r *Renderer) emitSpan(out *bytes.Buffer, frame Frame, y, x, width int, st *drawState) {
154- end := x + width
155- // Trailing blank suffix is EL-eligible only when the span reaches the
156- // frame's right edge (EL clears to the end of the line, nothing less).
157- t := end
158- if end == frame.Width {
159- for t > x && isBlank(frame.At(t-1, y)) {
160- t--
161- }
162- }
163- useEL := end == frame.Width && end-t >= elLen
164- if !useEL {
165- t = end
166- }
167-
168- if useEL && t == x && x == 0 && width == frame.Width {
169- // Entire line blank: compare "move to col 0 + EL" against
170- // "vertical-only move + 2K" (2K clears the whole line from any
171- // column, so a cheap same-column CUD/CUU can be reused).
172- elCost := st.moveCost(y, 0) + elLen
173- if st.curKnown && st.moveCost(y, st.curCol)+el2Len < elCost {
174- st.setPen(out, DefaultStyle())
175- st.moveTo(out, y, st.curCol)
176- out.WriteString("\x1b[2K")
177- return
178- }
179- }
180-
181- st.moveTo(out, y, x)
182- for col := x; col < t; col++ {
183- cell := frame.At(col, y)
184- // Continuation cells are the right half of a wide rune. Emit nothing:
185- // the terminal already advanced two columns for the left cell's rune.
186- if cell.Continuation {
187- continue
188- }
189- // Pen check kept inline (not via setPen): this is the hottest loop in
190- // the renderer and the method call costs measurably on full redraws.
191- if !cell.Style.Equal(st.pen) {
192- writeStyle(out, cell.Style)
193- st.pen = cell.Style
194- }
195- if cell.Rune == 0 {
196- out.WriteByte(' ')
197- } else {
198- out.WriteRune(cell.Rune)
199- }
200- }
201- if useEL {
202- st.setPen(out, DefaultStyle())
203- out.WriteString("\x1b[K")
204- // EL does not move the cursor: it stays at the start of the blanks.
205- st.setCursor(y, t)
206- } else if end >= frame.Width {
207- st.invalidateCursor()
208- } else {
209- st.setCursor(y, end)
210- }
211- // A span that splits a wide pair breaks the "terminal advance == span
212- // width" assumption tracking relies on: a leading orphan continuation
213- // (head outside the span) advances one column less, and a span ending on
214- // a wide head whose continuation lies outside advances one more. Both are
215- // malformed inputs the VT layer should never produce, but tracking must
216- // stay certain, so drop it. (t is never a continuation on the EL path —
217- // blanks are not continuations — so this check is exact for both paths.)
218- if frame.At(x, y).Continuation || (t < frame.Width && frame.At(t, y).Continuation) {
219- st.invalidateCursor()
220- }
221-}
222-
223-// writeCSIRel emits a relative cursor move "\x1b[<n><letter>", omitting the
224-// count when n == 1 as the CSI default allows.
225-func writeCSIRel(out *bytes.Buffer, n int, letter byte) {
226- out.WriteString("\x1b[")
227- if n != 1 {
228- var b [16]byte
229- out.Write(strconv.AppendInt(b[:0], int64(n), 10))
230- }
231- out.WriteByte(letter)
232-}
233-
234-// csiRelLen is the encoded length of writeCSIRel's output.
235-func csiRelLen(n int) int {
236- if n == 1 {
237- return 3
238- }
239- return 3 + decimalLen(n)
240-}
241-
242-// decimalLen is the number of decimal digits of a non-negative int.
243-func decimalLen(n int) int {
244- l := 1
245- for n >= 10 {
246- n /= 10
247- l++
248- }
249- return l
250-}
D pkg/renderer/frame.go +0 -135
@@ -1,135 +0,0 @@
1-package renderer
2-
3-import "fmt"
4-
5-// Frame is a fixed-size grid of cells. Logical rows are decoupled from their
6-// physical storage through lineOffset: logical row y lives at
7-// Cells[lineOffset[y] : lineOffset[y]+Width]. This indirection lets full-width
8-// scrolls rotate offsets instead of copying cell memory (NeoVim's grid
9-// technique; cf. nvim grid.c line_offset[]). All cell access must go through
10-// the accessors (At/Set/Row) so callers never assume a physical layout.
11-type Frame struct {
12- Width int
13- Height int
14- Cells []Cell
15- // lineOffset maps a logical row index to its physical base index into
16- // Cells. It is always a permutation of the canonical offsets y*Width.
17- lineOffset []int
18-}
19-
20-func NewFrame(width, height int) Frame {
21- cells := make([]Cell, width*height)
22- for i := range cells {
23- cells[i] = BlankCell()
24- }
25- lineOffset := make([]int, height)
26- for y := range lineOffset {
27- lineOffset[y] = y * width
28- }
29- return Frame{Width: width, Height: height, Cells: cells, lineOffset: lineOffset}
30-}
31-
32-// Clone returns an independent copy preserving logical row contents. The clone
33-// uses canonical row storage so later scrolls or writes to the source frame do
34-// not affect it.
35-func (f Frame) Clone() Frame {
36- clone := NewFrame(f.Width, f.Height)
37- for y := range f.Height {
38- copy(clone.Row(y), f.Row(y))
39- }
40- return clone
41-}
42-
43-func (f Frame) Validate() error {
44- if f.Width <= 0 || f.Height <= 0 {
45- return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height)
46- }
47- if len(f.Cells) != f.Width*f.Height {
48- return fmt.Errorf("invalid cell count: got %d want %d", len(f.Cells), f.Width*f.Height)
49- }
50- if len(f.lineOffset) != f.Height {
51- return fmt.Errorf("invalid lineOffset length: got %d want %d", len(f.lineOffset), f.Height)
52- }
53- return nil
54-}
55-
56-// CheckInvariants verifies that lineOffset is a permutation of the canonical
57-// row offsets: every entry is a non-negative multiple of Width within bounds,
58-// and every logical row maps to a distinct physical row. It is used by tests
59-// and assertions; the hot path uses the lighter Validate.
60-func (f Frame) CheckInvariants() error {
61- if f.Width <= 0 {
62- return fmt.Errorf("invalid width %d", f.Width)
63- }
64- if len(f.lineOffset) != f.Height {
65- return fmt.Errorf("lineOffset length %d != height %d", len(f.lineOffset), f.Height)
66- }
67- seen := make([]bool, f.Height)
68- for y, off := range f.lineOffset {
69- if off < 0 || off%f.Width != 0 {
70- return fmt.Errorf("row %d: offset %d is not a non-negative multiple of width %d", y, off, f.Width)
71- }
72- phys := off / f.Width
73- if phys >= f.Height {
74- return fmt.Errorf("row %d: offset %d maps to physical row %d out of range [0,%d)", y, off, phys, f.Height)
75- }
76- if seen[phys] {
77- return fmt.Errorf("row %d: physical row %d already mapped by another logical row", y, phys)
78- }
79- seen[phys] = true
80- }
81- return nil
82-}
83-
84-func (f Frame) At(x, y int) Cell {
85- return f.Cells[f.lineOffset[y]+x]
86-}
87-
88-func (f Frame) Set(x, y int, cell Cell) {
89- f.Cells[f.lineOffset[y]+x] = cell
90-}
91-
92-// Row returns the backing slice for logical row y. Mutating the returned slice
93-// mutates the frame in place. The slice is valid until the next scroll or
94-// resize that reassigns offsets.
95-func (f Frame) Row(y int) []Cell {
96- base := f.lineOffset[y]
97- return f.Cells[base : base+f.Width]
98-}
99-
100-// ScrollUp scrolls the logical rows in [top,bottom] up by n lines by rotating
101-// their physical offsets: no per-cell copying. Rows scrolled off the top are
102-// recycled to the bottom and blanked in place. It assumes the full frame width
103-// and 0 <= top <= bottom < Height and 0 < n <= bottom-top+1 (the caller
104-// clamps). The receiver is a value, but lineOffset and Cells alias the caller's
105-// backing arrays, so in-place mutations are visible to the caller.
106-func (f Frame) ScrollUp(top, bottom, n int) {
107- for ; n > 0; n-- {
108- recycled := f.lineOffset[top]
109- for y := top; y < bottom; y++ {
110- f.lineOffset[y] = f.lineOffset[y+1]
111- }
112- f.lineOffset[bottom] = recycled
113- blankRow(f.Cells[recycled : recycled+f.Width])
114- }
115-}
116-
117-// ScrollDown scrolls the logical rows in [top,bottom] down by n lines by
118-// rotating their physical offsets. Rows scrolled off the bottom are recycled to
119-// the top and blanked in place. See ScrollUp for preconditions.
120-func (f Frame) ScrollDown(top, bottom, n int) {
121- for ; n > 0; n-- {
122- recycled := f.lineOffset[bottom]
123- for y := bottom; y > top; y-- {
124- f.lineOffset[y] = f.lineOffset[y-1]
125- }
126- f.lineOffset[top] = recycled
127- blankRow(f.Cells[recycled : recycled+f.Width])
128- }
129-}
130-
131-func blankRow(row []Cell) {
132- for i := range row {
133- row[i] = BlankCell()
134- }
135-}
D pkg/renderer/frame_test.go +0 -165
@@ -1,165 +0,0 @@
1-package renderer
2-
3-import "testing"
4-
5-// rowRunes renders a logical row to a string for readable assertions.
6-func rowRunes(f Frame, y int) string {
7- out := make([]rune, f.Width)
8- for x := range f.Width {
9- out[x] = f.At(x, y).Rune
10- }
11- return string(out)
12-}
13-
14-// fillFrame writes one distinct rune per logical row so rotation is observable.
15-func fillFrame(f Frame, rows []string) {
16- for y, s := range rows {
17- for x, r := range s {
18- f.Set(x, y, Cell{Rune: r, Style: DefaultStyle()})
19- }
20- }
21-}
22-
23-func TestNewFrameCanonicalOffsets(t *testing.T) {
24- f := NewFrame(4, 3)
25- for y := range f.Height {
26- if f.lineOffset[y] != y*f.Width {
27- t.Fatalf("lineOffset[%d] = %d, want %d", y, f.lineOffset[y], y*f.Width)
28- }
29- }
30- if err := f.CheckInvariants(); err != nil {
31- t.Fatalf("fresh frame invariants: %v", err)
32- }
33-}
34-
35-// TestCheckInvariantsDetectsBrokenRotation is the RED guard: a rotation that
36-// duplicates a physical offset (the classic off-by-one bug) must be rejected.
37-func TestCheckInvariantsDetectsBrokenRotation(t *testing.T) {
38- f := NewFrame(4, 3)
39- // Corrupt: two logical rows map to the same physical row.
40- f.lineOffset[1] = f.lineOffset[0]
41- if err := f.CheckInvariants(); err == nil {
42- t.Fatal("expected invariant violation for duplicate physical row, got nil")
43- }
44-
45- f = NewFrame(4, 3)
46- f.lineOffset[0] = 1 // not a multiple of width
47- if err := f.CheckInvariants(); err == nil {
48- t.Fatal("expected invariant violation for non-multiple offset, got nil")
49- }
50-
51- f = NewFrame(4, 3)
52- f.lineOffset[2] = 99 // out of range
53- if err := f.CheckInvariants(); err == nil {
54- t.Fatal("expected invariant violation for out-of-range offset, got nil")
55- }
56-}
57-
58-func TestScrollUpEquivalence(t *testing.T) {
59- tests := []struct {
60- name string
61- width, height int
62- rows []string
63- top, bottom int
64- n int
65- want []string
66- }{
67- {
68- name: "scroll up 1 full screen", width: 3, height: 4,
69- rows: []string{"aaa", "bbb", "ccc", "ddd"},
70- top: 0, bottom: 3, n: 1,
71- want: []string{"bbb", "ccc", "ddd", " "},
72- },
73- {
74- name: "scroll up 2 full screen", width: 3, height: 4,
75- rows: []string{"aaa", "bbb", "ccc", "ddd"},
76- top: 0, bottom: 3, n: 2,
77- want: []string{"ccc", "ddd", " ", " "},
78- },
79- {
80- name: "scroll up region subset", width: 3, height: 5,
81- rows: []string{"aaa", "bbb", "ccc", "ddd", "eee"},
82- top: 1, bottom: 3, n: 1,
83- want: []string{"aaa", "ccc", "ddd", " ", "eee"},
84- },
85- }
86- for _, tt := range tests {
87- t.Run(tt.name, func(t *testing.T) {
88- f := NewFrame(tt.width, tt.height)
89- fillFrame(f, tt.rows)
90- f.ScrollUp(tt.top, tt.bottom, tt.n)
91- for y := range f.Height {
92- if got := rowRunes(f, y); got != tt.want[y] {
93- t.Errorf("row %d = %q, want %q", y, got, tt.want[y])
94- }
95- }
96- if err := f.CheckInvariants(); err != nil {
97- t.Errorf("invariants after scroll up: %v", err)
98- }
99- })
100- }
101-}
102-
103-func TestScrollDownEquivalence(t *testing.T) {
104- tests := []struct {
105- name string
106- width, height int
107- rows []string
108- top, bottom int
109- n int
110- want []string
111- }{
112- {
113- name: "scroll down 1 full screen", width: 3, height: 4,
114- rows: []string{"aaa", "bbb", "ccc", "ddd"},
115- top: 0, bottom: 3, n: 1,
116- want: []string{" ", "aaa", "bbb", "ccc"},
117- },
118- {
119- name: "scroll down 2 region subset", width: 3, height: 5,
120- rows: []string{"aaa", "bbb", "ccc", "ddd", "eee"},
121- top: 1, bottom: 4, n: 2,
122- want: []string{"aaa", " ", " ", "bbb", "ccc"},
123- },
124- }
125- for _, tt := range tests {
126- t.Run(tt.name, func(t *testing.T) {
127- f := NewFrame(tt.width, tt.height)
128- fillFrame(f, tt.rows)
129- f.ScrollDown(tt.top, tt.bottom, tt.n)
130- for y := range f.Height {
131- if got := rowRunes(f, y); got != tt.want[y] {
132- t.Errorf("row %d = %q, want %q", y, got, tt.want[y])
133- }
134- }
135- if err := f.CheckInvariants(); err != nil {
136- t.Errorf("invariants after scroll down: %v", err)
137- }
138- })
139- }
140-}
141-
142-// TestRotationIsPermutationUnderRandomOps drives an arbitrary sequence of
143-// scrolls and asserts lineOffset stays a valid permutation throughout.
144-func TestRotationIsPermutationUnderRandomOps(t *testing.T) {
145- f := NewFrame(5, 6)
146- fillFrame(f, []string{"aaaaa", "bbbbb", "ccccc", "ddddd", "eeeee", "fffff"})
147- ops := []struct {
148- up bool
149- top, bottom int
150- n int
151- }{
152- {true, 0, 5, 1}, {false, 1, 4, 2}, {true, 2, 5, 3},
153- {false, 0, 5, 1}, {true, 0, 2, 2}, {false, 3, 5, 1},
154- }
155- for i, op := range ops {
156- if op.up {
157- f.ScrollUp(op.top, op.bottom, op.n)
158- } else {
159- f.ScrollDown(op.top, op.bottom, op.n)
160- }
161- if err := f.CheckInvariants(); err != nil {
162- t.Fatalf("op %d (%+v): invariants broken: %v", i, op, err)
163- }
164- }
165-}
D pkg/renderer/output_min_test.go +0 -487
@@ -1,487 +0,0 @@
1-package renderer
2-
3-import (
4- "bytes"
5- "strconv"
6- "strings"
7- "testing"
8-)
9-
10-// ---------------------------------------------------------------------------
11-// Cursor-motion minimization (drawState.moveTo)
12-// ---------------------------------------------------------------------------
13-
14-func TestMoveToChoosesCheapestSequence(t *testing.T) {
15- tests := []struct {
16- name string
17- known bool
18- fromRow int
19- fromCol int
20- toRow int
21- toCol int
22- want string
23- }{
24- {name: "unknown position uses absolute CUP", known: false, toRow: 2, toCol: 3, want: "\x1b[3;4H"},
25- {name: "same position emits nothing", known: true, fromRow: 2, fromCol: 3, toRow: 2, toCol: 3, want: ""},
26- {name: "same row col0 uses CR", known: true, fromRow: 2, fromCol: 5, toRow: 2, toCol: 0, want: "\r"},
27- {name: "same row forward uses CUF", known: true, fromRow: 2, fromCol: 2, toRow: 2, toCol: 7, want: "\x1b[5C"},
28- {name: "same row backward uses CUB", known: true, fromRow: 2, fromCol: 7, toRow: 2, toCol: 2, want: "\x1b[5D"},
29- {name: "same row forward by one omits count", known: true, fromRow: 2, fromCol: 3, toRow: 2, toCol: 4, want: "\x1b[C"},
30- {name: "same col up uses CUU", known: true, fromRow: 5, fromCol: 3, toRow: 2, toCol: 3, want: "\x1b[3A"},
31- {name: "same col down uses CUD", known: true, fromRow: 2, fromCol: 3, toRow: 5, toCol: 3, want: "\x1b[3B"},
32- {name: "same col up by one omits count", known: true, fromRow: 5, fromCol: 3, toRow: 4, toCol: 3, want: "\x1b[A"},
33- {name: "diagonal move uses absolute CUP", known: true, fromRow: 2, fromCol: 2, toRow: 5, toCol: 7, want: "\x1b[6;8H"},
34- {name: "short forward beats absolute", known: true, fromRow: 2, fromCol: 0, toRow: 2, toCol: 1, want: "\x1b[C"},
35- }
36-
37- for _, tt := range tests {
38- t.Run(tt.name, func(t *testing.T) {
39- st := newDrawState()
40- if tt.known {
41- st.setCursor(tt.fromRow, tt.fromCol)
42- }
43- var buf bytes.Buffer
44- st.moveTo(&buf, tt.toRow, tt.toCol)
45- if buf.String() != tt.want {
46- t.Errorf("moveTo() = %q, want %q", buf.String(), tt.want)
47- }
48- if !st.curKnown || st.curRow != tt.toRow || st.curCol != tt.toCol {
49- t.Errorf("cursor not tracked to (%d,%d): got known=%v (%d,%d)", tt.toRow, tt.toCol, st.curKnown, st.curRow, st.curCol)
50- }
51- })
52- }
53-}
54-
55-// ---------------------------------------------------------------------------
56-// End-to-end golden byte output (exact bytes emitted by Draw)
57-// ---------------------------------------------------------------------------
58-
59-func drawGolden(t *testing.T, r *Renderer, frame Frame, damage []Damage) string {
60- t.Helper()
61- out, err := r.Draw(frame, damage)
62- if err != nil {
63- t.Fatal(err)
64- }
65- return string(out)
66-}
67-
68-func setRow(f Frame, y int, s string, st Style) {
69- col := 0
70- for _, ru := range s {
71- f.Set(col, y, Cell{Rune: ru, Style: st})
72- col++
73- }
74-}
75-
76-func hasCUB(out string) bool {
77- for i := 0; i+2 < len(out); i++ {
78- if out[i] != '\x1b' || out[i+1] != '[' {
79- continue
80- }
81- j := i + 2
82- for j < len(out) && out[j] >= '0' && out[j] <= '9' {
83- j++
84- }
85- if j < len(out) && out[j] == 'D' {
86- return true
87- }
88- }
89- return false
90-}
91-
92-func TestBackwardOverlappingDamageRendersFinalSpanOnce(t *testing.T) {
93- r := New(Capabilities{})
94- base := NewFrame(10, 1)
95- if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
96- t.Fatal(err)
97- }
98-
99- frame := NewFrame(10, 1)
100- setRow(frame, 0, "abcdefghi", DefaultStyle())
101- got := drawGolden(t, r, frame, []Damage{
102- {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
103- {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
104- })
105- want := "\x1b[1;1Habcdefghi\x1b[0m"
106- if got != want {
107- t.Fatalf("output = %q, want %q", got, want)
108- }
109-}
110-
111-func TestDamageOrderPermutationProducesCanonicalOutput(t *testing.T) {
112- tests := []struct {
113- name string
114- spans []Damage
115- }{
116- {
117- name: "overlapping spans",
118- spans: []Damage{
119- {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
120- {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
121- },
122- },
123- {
124- name: "adjacent spans",
125- spans: []Damage{
126- {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
127- {Kind: DamageText, X: 5, Y: 0, Width: 4, Height: 1, Count: 1},
128- },
129- },
130- }
131-
132- for _, tt := range tests {
133- t.Run(tt.name, func(t *testing.T) {
134- render := func(spans []Damage) string {
135- t.Helper()
136- r := New(Capabilities{})
137- if _, err := r.Draw(NewFrame(10, 1), []Damage{FullRedraw()}); err != nil {
138- t.Fatal(err)
139- }
140- frame := NewFrame(10, 1)
141- setRow(frame, 0, "abcdefghi", DefaultStyle())
142- return drawGolden(t, r, frame, spans)
143- }
144-
145- forward := render(tt.spans)
146- reverse := render([]Damage{tt.spans[1], tt.spans[0]})
147- if forward != reverse {
148- t.Fatalf("forward output = %q, reverse output = %q", forward, reverse)
149- }
150- if hasCUB(forward) {
151- t.Fatalf("canonical output contains CUB: %q", forward)
152- }
153- want := "\x1b[1;1Habcdefghi\x1b[0m"
154- if forward != want {
155- t.Fatalf("output = %q, want canonical output %q", forward, want)
156- }
157- })
158- }
159-}
160-
161-func TestNativeClearGolden(t *testing.T) {
162- tests := []struct {
163- name string
164- build func() Frame
165- want string
166- }{
167- {
168- name: "trailing blank span becomes EL",
169- build: func() Frame {
170- f := NewFrame(10, 1)
171- setRow(f, 0, "Hi", DefaultStyle())
172- return f
173- },
174- // content, then EL to clear the 8 trailing blanks, then reset
175- want: "\x1b[1;1HHi\x1b[K\x1b[0m",
176- },
177- {
178- name: "whole blank line cleared natively",
179- build: func() Frame {
180- f := NewFrame(10, 2)
181- setRow(f, 0, "AB", DefaultStyle())
182- // row 1 stays entirely blank
183- return f
184- },
185- // Row 0 leaves the cursor tracked at (0,2); the cheapest whole-line
186- // clear for row 1 is a one-row CUD in the same column plus 2K
187- // (3+4 bytes) rather than CUP to col 0 plus EL (6+3 bytes).
188- want: "\x1b[1;1HAB\x1b[K\x1b[B\x1b[2K\x1b[0m",
189- },
190- {
191- name: "short trailing blank span kept as spaces",
192- build: func() Frame {
193- f := NewFrame(5, 1)
194- setRow(f, 0, "ABC", DefaultStyle())
195- // two trailing blanks (< 3) → cheaper as spaces
196- return f
197- },
198- want: "\x1b[1;1HABC \x1b[0m",
199- },
200- }
201-
202- for _, tt := range tests {
203- t.Run(tt.name, func(t *testing.T) {
204- r := New(Capabilities{})
205- frame := tt.build()
206- got := drawGolden(t, r, frame, []Damage{FullRedraw()})
207- if got != tt.want {
208- t.Fatalf("output = %q, want %q", got, tt.want)
209- }
210- // Shadow consistency: an identical redraw must be a no-op.
211- out2, err := r.Draw(frame, nil)
212- if err != nil {
213- t.Fatal(err)
214- }
215- if len(out2) != 0 {
216- t.Fatalf("second draw not a no-op: %q", string(out2))
217- }
218- })
219- }
220-}
221-
222-func TestSGRPersistsAcrossDamageRects(t *testing.T) {
223- // Two same-styled rects on different rows: the SGR sequence must be emitted
224- // exactly once (the pen persists across rects for the whole Draw).
225- r := New(Capabilities{})
226- base := NewFrame(4, 3)
227- if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
228- t.Fatal(err)
229- }
230-
231- styled := Style{Bold: true, Foreground: 1, Background: -1}
232- frame := NewFrame(4, 3)
233- setRow(frame, 0, "XXXX", styled)
234- setRow(frame, 2, "YYYY", styled)
235- damage := []Damage{
236- {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 1, Count: 1},
237- {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1, Count: 1},
238- }
239- got := drawGolden(t, r, frame, damage)
240- // Row0 reaches the right edge (no trailing blanks) so tracking invalidates;
241- // row2 is therefore positioned with an absolute CUP. The SGR appears once.
242- want := "\x1b[1;1H\x1b[0;1;38;5;1mXXXX\x1b[3;1HYYYY\x1b[0m"
243- if got != want {
244- t.Fatalf("output = %q, want %q", got, want)
245- }
246-}
247-
248-func TestRightEdgeRunInvalidatesTracking(t *testing.T) {
249- // Adversarial: a run printed up to the terminal's right edge leaves the
250- // cursor in a terminal-dependent "wrap pending" state. The renderer must
251- // NOT assume the cursor wrapped to the next row; the following row must be
252- // positioned with an absolute CUP, never an implicit newline/relative move.
253- r := New(Capabilities{})
254- frame := NewFrame(5, 2)
255- setRow(frame, 0, "ABCDE", DefaultStyle())
256- setRow(frame, 1, "FGHIJ", DefaultStyle())
257- got := drawGolden(t, r, frame, []Damage{FullRedraw()})
258- want := "\x1b[1;1HABCDE\x1b[2;1HFGHIJ\x1b[0m"
259- if got != want {
260- t.Fatalf("output = %q, want %q", got, want)
261- }
262-}
263-
264-func TestWideRunCursorTrackingAndEL(t *testing.T) {
265- t.Run("cursor advances two columns per wide rune", func(t *testing.T) {
266- // Rect A ends with a wide pair (not at the edge); rect B starts one
267- // column later. The gap between them must be crossed with the cursor
268- // position that reflects the terminal advancing two columns for the
269- // wide rune — otherwise the relative move would be wrong.
270- r := New(Capabilities{})
271- base := NewFrame(8, 1)
272- if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
273- t.Fatal(err)
274- }
275- frame := NewFrame(8, 1)
276- frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
277- frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
278- frame.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()})
279- frame.Set(3, 0, Cell{Rune: 'B', Style: DefaultStyle()})
280- // column 4 stays blank (undamaged gap)
281- frame.Set(5, 0, Cell{Rune: 'C', Style: DefaultStyle()})
282- frame.Set(6, 0, Cell{Rune: 'D', Style: DefaultStyle()})
283- frame.Set(7, 0, Cell{Rune: 'E', Style: DefaultStyle()})
284- damage := []Damage{
285- {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 1, Count: 1},
286- {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1},
287- }
288- got := drawGolden(t, r, frame, damage)
289- // After 你AB the cursor is at col 4; moving to col 5 is a one-column CUF.
290- want := "\x1b[1;1H你AB\x1b[CCDE\x1b[0m"
291- if got != want {
292- t.Fatalf("output = %q, want %q", got, want)
293- }
294- })
295-
296- t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {
297- // A damage rect that ends on a wide head whose continuation lies
298- // outside the rect makes the terminal advance one column further than
299- // the rect width accounts for. Tracking must be dropped: the next
300- // rect must be positioned with absolute CUP, not a relative move.
301- r := New(Capabilities{})
302- base := NewFrame(8, 1)
303- if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
304- t.Fatal(err)
305- }
306- frame := NewFrame(8, 1)
307- frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
308- frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
309- frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
310- frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
311- damage := []Damage{
312- // Ends on the wide head at col 1; its continuation (col 2) is outside.
313- {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1},
314- {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
315- }
316- got := drawGolden(t, r, frame, damage)
317- // After A你 the real cursor is at col 3, not col 2: a relative CUF
318- // would be off by one, so the second rect must use absolute CUP.
319- want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"
320- if got != want {
321- t.Fatalf("output = %q, want %q", got, want)
322- }
323- })
324-
325- t.Run("EL after a wide pair", func(t *testing.T) {
326- r := New(Capabilities{})
327- frame := NewFrame(7, 1)
328- frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
329- frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
330- frame.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
331- frame.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
332- // columns 4,5,6 blank → EL
333- got := drawGolden(t, r, frame, []Damage{FullRedraw()})
334- want := "\x1b[1;1H你好\x1b[K\x1b[0m"
335- if got != want {
336- t.Fatalf("output = %q, want %q", got, want)
337- }
338- out2, err := r.Draw(frame, nil)
339- if err != nil {
340- t.Fatal(err)
341- }
342- if len(out2) != 0 {
343- t.Fatalf("second draw not a no-op: %q", string(out2))
344- }
345- })
346-}
347-
348-func TestScrollFastPathEmitsCanonicalTextDamage(t *testing.T) {
349- render := func(damage []Damage) string {
350- t.Helper()
351- r := New(Capabilities{})
352- base := NewFrame(10, 3)
353- setRow(base, 0, "aaaaaaaaaa", DefaultStyle())
354- setRow(base, 1, "bbbbbbbbbb", DefaultStyle())
355- setRow(base, 2, "cccccccccc", DefaultStyle())
356- if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
357- t.Fatal(err)
358- }
359-
360- frame := NewFrame(10, 3)
361- setRow(frame, 0, "bbbbbbbbbb", DefaultStyle())
362- setRow(frame, 1, "cccccccccc", DefaultStyle())
363- setRow(frame, 2, "abcdefghij", DefaultStyle())
364- out, err := r.Draw(frame, damage)
365- if err != nil {
366- t.Fatal(err)
367- }
368- return string(out)
369- }
370-
371- scroll := Damage{Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1}
372- forward := render([]Damage{
373- scroll,
374- {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
375- {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
376- })
377- reverse := render([]Damage{
378- {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
379- scroll,
380- {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
381- })
382- if forward != reverse {
383- t.Fatalf("scroll output depends on text damage order: forward %q, reverse %q", forward, reverse)
384- }
385- if strings.Contains(forward, "\r") || !strings.Contains(forward, "abcdefghij") {
386- t.Fatalf("scroll output did not emit one forward canonical span: %q", forward)
387- }
388-}
389-
390-func TestCanonicalDamageMergesClearAndText(t *testing.T) {
391- r := New(Capabilities{})
392- base := NewFrame(10, 1)
393- if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
394- t.Fatal(err)
395- }
396- frame := NewFrame(10, 1)
397- setRow(frame, 0, "abcdefghi", DefaultStyle())
398- out, err := r.Draw(frame, []Damage{
399- {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
400- {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
401- })
402- if err != nil {
403- t.Fatal(err)
404- }
405- if got, want := string(out), "\x1b[1;1Habcdefghi\x1b[0m"; got != want {
406- t.Fatalf("output = %q, want %q", got, want)
407- }
408-}
409-
410-func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
411- r := New(Capabilities{})
412- base := NewFrame(8, 1)
413- if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
414- t.Fatal(err)
415- }
416- frame := NewFrame(8, 1)
417- frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
418- frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
419- frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
420- frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
421- out, err := r.Draw(frame, []Damage{
422- {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1},
423- {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1},
424- })
425- if err != nil {
426- t.Fatal(err)
427- }
428- if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want {
429- t.Fatalf("output = %q, want %q", got, want)
430- }
431-}
432-
433-func TestScrollPlannerFallbackRedrawsWithoutScrollEscape(t *testing.T) {
434- r := New(Capabilities{})
435- base := NewFrame(1, maxPlannedDamageSpans+1)
436- if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
437- t.Fatal(err)
438- }
439-
440- frame := base.Clone()
441- frame.ScrollUp(0, frame.Height-1, 1)
442- frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
443- damage := []Damage{
444- {Kind: DamageScrollUp, X: 0, Y: 0, Width: frame.Width, Height: frame.Height, Count: 1},
445- {Kind: DamageText, X: 0, Y: 0, Width: frame.Width, Height: frame.Height},
446- }
447- out, err := r.Draw(frame, damage)
448- if err != nil {
449- t.Fatal(err)
450- }
451- if scrollEscape := "\x1b[1;" + strconv.Itoa(frame.Height) + "r"; strings.Contains(string(out), scrollEscape) {
452- t.Fatalf("planner fallback emitted scroll escape %q before full redraw: %q", scrollEscape, string(out))
453- }
454- if !strings.Contains(string(out), "X") {
455- t.Fatalf("planner fallback did not redraw the frame: %q", string(out))
456- }
457- out, err = r.Draw(frame, nil)
458- if err != nil {
459- t.Fatal(err)
460- }
461- if len(out) != 0 {
462- t.Fatalf("planner fallback did not synchronize shadow; follow-up output = %q", string(out))
463- }
464-}
465-
466-func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) {
467- r := New(Capabilities{})
468- frame := NewFrame(1, maxPlannedDamageSpans+1)
469- if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
470- t.Fatal(err)
471- }
472- frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
473- out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}})
474- if err != nil {
475- t.Fatal(err)
476- }
477- if !strings.Contains(string(out), "X") {
478- t.Fatalf("fallback output did not redraw the frame: %q", string(out))
479- }
480- out, err = r.Draw(frame, nil)
481- if err != nil {
482- t.Fatal(err)
483- }
484- if len(out) != 0 {
485- t.Fatalf("fallback did not synchronize shadow; follow-up output = %q", string(out))
486- }
487-}
D pkg/renderer/renderer.go +0 -313
@@ -1,313 +0,0 @@
1-package renderer
2-
3-import (
4- "bytes"
5- "strconv"
6- "sync"
7-)
8-
9-const maxPooledBufferCap = 1 << 20
10-
11-var bufferPool = sync.Pool{
12- New: func() any { return new(bytes.Buffer) },
13-}
14-
15-type Capabilities struct {
16- SynchronizedOutput bool
17-}
18-
19-type Renderer struct {
20- caps Capabilities
21- width int
22- height int
23- shadow []Cell
24- lineOffset []int
25-}
26-
27-// PreparedDraw owns encoded output and its transactional delta until Commit.
28-// It is returned by value so ordinary prepared draws do not allocate.
29-type PreparedDraw struct {
30- renderer *Renderer
31- candidate DeltaCandidate
32- data []byte
33- commitOnce *sync.Once
34-}
35-
36-func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} }
37-
38-func (r *Renderer) Reset() {
39- r.width = 0
40- r.height = 0
41- r.shadow = nil
42- r.lineOffset = nil
43-}
44-
45-// Bytes returns the prepared ANSI output. The returned bytes remain valid after
46-// another draw.
47-func (p PreparedDraw) Bytes() []byte { return p.data }
48-
49-// Commit applies the prepared delta exactly once. Discarding it leaves the
50-// renderer's committed state unchanged.
51-func (p *PreparedDraw) Commit() {
52- if p == nil || p.renderer == nil || p.commitOnce == nil {
53- return
54- }
55- p.commitOnce.Do(func() {
56- committed := p.renderer.committedFrame()
57- p.candidate.Commit(&committed)
58- p.renderer.setCommittedFrame(committed)
59- })
60-}
61-
62-// Prepare plans and encodes a transactional draw. The renderer advances only
63-// when the returned draw is committed. Keep at most one prepared draw
64-// outstanding; commit or discard it before calling Prepare again.
65-func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDraw, error) {
66- var candidate DeltaCandidate
67- var err error
68- if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
69- if err = frame.Validate(); err == nil {
70- plan := planSingleDamage(frame, damage[0])
71- candidate = DeltaCandidate{Plan: plan}
72- if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
73- candidate.frame = frame.Clone()
74- }
75- }
76- } else {
77- candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0)
78- }
79- if err != nil {
80- return PreparedDraw{}, err
81- }
82- prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)}
83- plan := candidate.Plan
84- if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
85- return prepared, nil
86- }
87-
88- buf, ok := bufferPool.Get().(*bytes.Buffer)
89- if !ok {
90- buf = new(bytes.Buffer)
91- }
92- buf.Reset()
93- defer putBuffer(buf)
94- if r.caps.SynchronizedOutput {
95- buf.WriteString(SyncStartCSI)
96- }
97-
98- st := newDrawState()
99- if plan.Snapshot {
100- if len(plan.Spans) > 0 {
101- r.emitDamageSpans(buf, frame, plan.Spans, &st)
102- } else {
103- r.writeFull(buf, frame, &st)
104- }
105- } else {
106- if plan.Scroll.Height != 0 {
107- scroll := plan.Scroll
108- emitScrollUp(buf, Damage{Kind: DamageScrollUp, X: 0, Y: scroll.Y, Width: frame.Width, Height: scroll.Height, Count: scroll.Count})
109- }
110- for _, span := range plan.Spans {
111- r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st)
112- }
113- buf.WriteString("\x1b[0m")
114- }
115- if r.caps.SynchronizedOutput {
116- buf.WriteString(SyncEndCSI)
117- }
118- prepared.data = copyBytes(buf)
119- return prepared, nil
120-}
121-
122-func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
123- prepared, err := r.Prepare(frame, damage, false)
124- if err != nil {
125- return nil, err
126- }
127- prepared.Commit()
128- return prepared.Bytes(), nil
129-}
130-
131-func (r *Renderer) committedFrame() Frame {
132- return Frame{Width: r.width, Height: r.height, Cells: r.shadow, lineOffset: r.lineOffset}
133-}
134-
135-func (r *Renderer) setCommittedFrame(frame Frame) {
136- r.width = frame.Width
137- r.height = frame.Height
138- r.shadow = frame.Cells
139- r.lineOffset = frame.lineOffset
140-}
141-
142-// copyBytes copies the buffer contents into a fresh byte slice and is used
143-// to return output that is independent of the pooled scratch buffer.
144-func copyBytes(buf *bytes.Buffer) []byte {
145- b := buf.Bytes()
146- out := make([]byte, len(b))
147- copy(out, b)
148- return out
149-}
150-
151-func putBuffer(buf *bytes.Buffer) {
152- if buf.Cap() > maxPooledBufferCap {
153- return
154- }
155- bufferPool.Put(buf)
156-}
157-
158-func needsFull(damage []Damage) bool {
159- if len(damage) == 0 {
160- return false
161- }
162- for _, d := range damage {
163- if d.Kind == DamageFullRedraw {
164- return true
165- }
166- }
167- return false
168-}
169-
170-func hasScrollDamage(damage []Damage) bool {
171- for _, d := range damage {
172- if d.Kind == DamageScrollUp {
173- return true
174- }
175- }
176- return false
177-}
178-
179-func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
180- for y := range frame.Height {
181- r.emitSpan(out, frame, y, 0, frame.Width, st)
182- }
183- out.WriteString("\x1b[0m")
184-}
185-
186-func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []Span, st *drawState) {
187- for _, span := range spans {
188- r.emitSpan(out, frame, span.Y, span.X, span.Width, st)
189- }
190- out.WriteString("\x1b[0m")
191-}
192-func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {
193- x, width, okX := clampRange(x, width, frame.Width)
194- y, height, okY := clampRange(y, height, frame.Height)
195- if !okX || !okY {
196- return 0, 0, 0, 0, false
197- }
198- return x, y, width, height, true
199-}
200-
201-// clampRange intersects [pos, pos+size) with [0, limit) without evaluating an
202-// overflowing endpoint from untrusted damage coordinates.
203-func clampRange(pos, size, limit int) (int, int, bool) {
204- if size <= 0 || limit <= 0 || pos >= limit {
205- return 0, 0, false
206- }
207- if pos < 0 {
208- // -size is safe because size is positive. If pos is at or before that
209- // point, the rectangle ends at or before zero.
210- if pos <= -size {
211- return 0, 0, false
212- }
213- end := pos + size // pos > -size proves this addition cannot overflow.
214- if end > limit {
215- end = limit
216- }
217- return 0, end, true
218- }
219-
220- available := limit - pos
221- return pos, min(size, available), true
222-}
223-
224-// writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf
225-// allocations. It uses a stack-allocated buffer for integer formatting.
226-func writeCursor(out *bytes.Buffer, y, x int) {
227- out.WriteString("\x1b[")
228- var b [16]byte
229- n := strconv.AppendInt(b[:0], int64(y+1), 10)
230- out.Write(n)
231- out.WriteByte(';')
232- n = strconv.AppendInt(b[:0], int64(x+1), 10)
233- out.Write(n)
234- out.WriteByte('H')
235-}
236-
237-// writeStyle emits SGR style parameters without fmt.Fprintf allocations.
238-func writeStyle(out *bytes.Buffer, style Style) {
239- out.WriteString("\x1b[0")
240- if style.Bold {
241- out.WriteString(";1")
242- }
243- if style.Attrs&AttrDim != 0 {
244- out.WriteString(";2")
245- }
246- if style.Italic {
247- out.WriteString(";3")
248- }
249- if style.Attrs&AttrUnderline != 0 {
250- switch style.UnderlineStyle {
251- case UnderlineDouble:
252- out.WriteString(";21")
253- case UnderlineCurly:
254- out.WriteString(";4:3")
255- case UnderlineDotted:
256- out.WriteString(";4:4")
257- case UnderlineDashed:
258- out.WriteString(";4:5")
259- default:
260- out.WriteString(";4")
261- }
262- }
263- if style.Attrs&AttrBlink != 0 {
264- out.WriteString(";5")
265- }
266- if style.Inverse {
267- out.WriteString(";7")
268- }
269- if style.Attrs&AttrStrikethrough != 0 {
270- out.WriteString(";9")
271- }
272- var b [16]byte
273- if style.HasForegroundRGB {
274- out.WriteString(";38;2;")
275- writeRGB(out, &b, style.ForegroundRGB)
276- } else if style.Foreground >= 0 {
277- out.WriteString(";38;5;")
278- n := strconv.AppendInt(b[:0], int64(style.Foreground), 10)
279- out.Write(n)
280- }
281- if style.HasBackgroundRGB {
282- out.WriteString(";48;2;")
283- writeRGB(out, &b, style.BackgroundRGB)
284- } else if style.Background >= 0 {
285- out.WriteString(";48;5;")
286- n := strconv.AppendInt(b[:0], int64(style.Background), 10)
287- out.Write(n)
288- }
289- if style.HasUnderlineColorRGB {
290- out.WriteString(";58;2;")
291- writeRGB(out, &b, style.UnderlineColorRGB)
292- } else if style.HasUnderlineColor {
293- out.WriteString(";58;5;")
294- n := strconv.AppendInt(b[:0], int64(style.UnderlineColor), 10)
295- out.Write(n)
296- }
297- out.WriteByte('m')
298-}
299-
300-func writeRGB(out *bytes.Buffer, b *[16]byte, rgb RGB) {
301- n := strconv.AppendInt(b[:0], int64(rgb.R), 10)
302- out.Write(n)
303- out.WriteByte(';')
304- n = strconv.AppendInt(b[:0], int64(rgb.G), 10)
305- out.Write(n)
306- out.WriteByte(';')
307- n = strconv.AppendInt(b[:0], int64(rgb.B), 10)
308- out.Write(n)
309-}
310-
311-func sameDamage(a, b Damage) bool {
312- return a.Kind == b.Kind && a.X == b.X && a.Y == b.Y && a.Width == b.Width && a.Height == b.Height && a.Count == b.Count
313-}
D pkg/renderer/renderer_test.go +0 -1436
@@ -1,1436 +0,0 @@
1-package renderer
2-
3-import (
4- "bytes"
5- "strings"
6- "testing"
7-)
8-
9-// markFrame writes uniform printable content so each row is distinguishable.
10-func markFrame(f *Frame) {
11- for y := range f.Height {
12- for x := range f.Width {
13- f.Set(x, y, Cell{Rune: rune('A' + (y*f.Width+x)%26), Style: DefaultStyle()})
14- }
15- }
16-}
17-
18-// outputContains checks that the output contains all the given substrings.
19-func outputContains(t *testing.T, data []byte, subs ...string) {
20- t.Helper()
21- s := string(data)
22- for _, sub := range subs {
23- if !strings.Contains(s, sub) {
24- t.Errorf("output should contain %q", sub)
25- }
26- }
27-}
28-
29-func outputEndsWith(t *testing.T, data []byte, suffix string) {
30- t.Helper()
31- s := string(data)
32- if !strings.HasSuffix(s, suffix) {
33- t.Errorf("output should end with %q, got %q", suffix, s)
34- }
35-}
36-
37-// ---------------------------------------------------------------------------
38-// First draw
39-// ---------------------------------------------------------------------------
40-
41-func TestFirstDraw(t *testing.T) {
42- tests := []struct {
43- name string
44- damage []Damage
45- minCSICount int
46- }{
47- {
48- name: "explicit text damage",
49- damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}},
50- minCSICount: 3,
51- },
52- {
53- name: "full redraw damage",
54- damage: []Damage{FullRedraw()},
55- },
56- }
57-
58- for _, tt := range tests {
59- t.Run(tt.name, func(t *testing.T) {
60- r := New(Capabilities{})
61- frame := NewFrame(5, 3)
62- markFrame(&frame)
63-
64- out, err := r.Draw(frame, tt.damage)
65- if err != nil {
66- t.Fatal(err)
67- }
68- if len(out) == 0 {
69- t.Fatal("expected non-empty output on first draw")
70- }
71- outputContains(t, out, "\x1b[0m")
72- if tt.minCSICount > 0 {
73- // Should have cursor-position sequences for all three rows.
74- if c := strings.Count(string(out), "\x1b["); c < tt.minCSICount {
75- t.Fatalf("expected at least %d CSI sequences, got %d", tt.minCSICount, c)
76- }
77- }
78- })
79- }
80-}
81-
82-// ---------------------------------------------------------------------------
83-// No-op
84-// ---------------------------------------------------------------------------
85-
86-func TestNoOp(t *testing.T) {
87- tests := []struct {
88- name string
89- width int
90- height int
91- firstDamage []Damage
92- secondDamage []Damage
93- }{
94- {
95- name: "nil damage on unchanged frame",
96- width: 5,
97- height: 3,
98- firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}},
99- secondDamage: nil,
100- },
101- {
102- name: "empty damage slice on unchanged frame",
103- width: 3,
104- height: 2,
105- firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}},
106- secondDamage: []Damage{},
107- },
108- }
109-
110- for _, tt := range tests {
111- t.Run(tt.name, func(t *testing.T) {
112- r := New(Capabilities{})
113- frame := NewFrame(tt.width, tt.height)
114- markFrame(&frame)
115-
116- // First draw – populate shadow.
117- out1, err := r.Draw(frame, tt.firstDamage)
118- if err != nil {
119- t.Fatal(err)
120- }
121- if len(out1) == 0 {
122- t.Fatal("first draw must produce output")
123- }
124-
125- // Second draw with no changes.
126- out2, err := r.Draw(frame, tt.secondDamage)
127- if err != nil {
128- t.Fatal(err)
129- }
130- if len(out2) != 0 {
131- t.Fatalf("expected no-op (empty output), got %q", string(out2))
132- }
133- })
134- }
135-}
136-
137-func TestPrepareNoOpDoesNotCloneFrame(t *testing.T) {
138- r := New(Capabilities{})
139- frame := NewFrame(3, 2)
140- markFrame(&frame)
141- if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}); err != nil {
142- t.Fatal(err)
143- }
144-
145- prepared, err := r.Prepare(frame, []Damage{{Kind: DamageText, X: frame.Width, Y: 0, Width: 1, Height: 1}}, false)
146- if err != nil {
147- t.Fatal(err)
148- }
149- if prepared.candidate.frame.Cells != nil || prepared.candidate.frame.lineOffset != nil {
150- t.Fatal("no-op prepared draw owns frame storage")
151- }
152- prepared.Commit()
153-}
154-
155-// ---------------------------------------------------------------------------
156-// Style reset discipline
157-// ---------------------------------------------------------------------------
158-
159-func TestStyleResetDiscipline(t *testing.T) {
160- tests := []struct {
161- name string
162- run func(t *testing.T)
163- }{
164- {
165- name: "single styled cell among default cells",
166- run: func(t *testing.T) {
167- r := New(Capabilities{})
168- frame := NewFrame(4, 2)
169-
170- // One styled cell, rest default.
171- frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}})
172-
173- out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
174- if err != nil {
175- t.Fatal(err)
176- }
177- // Must end with style reset.
178- outputEndsWith(t, out, "\x1b[0m")
179- },
180- },
181- {
182- name: "after scroll damage",
183- run: func(t *testing.T) {
184- r := New(Capabilities{})
185- frame := NewFrame(4, 3)
186- markFrame(&frame)
187-
188- // Populate shadow.
189- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
190- if err != nil {
191- t.Fatal(err)
192- }
193-
194- // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage.
195- frame2 := NewFrame(4, 3)
196- copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2
197- for i := 8; i < 12; i++ {
198- frame2.Cells[i] = BlankCell() // bottom row blank
199- }
200- frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row
201-
202- damage := []Damage{
203- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
204- {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
205- }
206-
207- out, err := r.Draw(frame2, damage)
208- if err != nil {
209- t.Fatal(err)
210- }
211- // The output must end with a style reset.
212- outputEndsWith(t, out, "\x1b[0m")
213- },
214- },
215- }
216-
217- for _, tt := range tests {
218- t.Run(tt.name, tt.run)
219- }
220-}
221-
222-func TestStyleEqualUsesActiveColorMode(t *testing.T) {
223- tests := []struct {
224- name string
225- left Style
226- right Style
227- wantEqual bool
228- }{
229- {
230- name: "ignores inactive indexed foreground",
231- left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
232- right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
233- wantEqual: true,
234- },
235- {
236- name: "compares active RGB values",
237- left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
238- right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 13, G: 34, B: 56}, Background: -1},
239- wantEqual: false,
240- },
241- }
242-
243- for _, tt := range tests {
244- t.Run(tt.name, func(t *testing.T) {
245- if got := tt.left.Equal(tt.right); got != tt.wantEqual {
246- t.Fatalf("Equal() = %v, want %v", got, tt.wantEqual)
247- }
248- })
249- }
250-}
251-
252-func TestRendererEmitsSGR(t *testing.T) {
253- tests := []struct {
254- name string
255- width int
256- height int
257- setup func(frame Frame)
258- damage []Damage
259- wantAll []string
260- }{
261- {
262- name: "truecolor foreground and background",
263- width: 1,
264- height: 1,
265- setup: func(frame Frame) {
266- frame.Set(0, 0, Cell{
267- Rune: 'X',
268- Style: Style{
269- Foreground: -1,
270- Background: -1,
271- HasForegroundRGB: true,
272- ForegroundRGB: RGB{R: 12, G: 34, B: 56},
273- HasBackgroundRGB: true,
274- BackgroundRGB: RGB{R: 200, G: 100, B: 50},
275- },
276- })
277- },
278- damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
279- wantAll: []string{"\x1b[0;38;2;12;34;56;48;2;200;100;50m", "X"},
280- },
281- {
282- name: "style changes across adjacent color modes",
283- width: 3,
284- height: 1,
285- setup: func(frame Frame) {
286- frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}})
287- frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}})
288- frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()})
289- },
290- damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}},
291- wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"},
292- },
293- {
294- name: "bold italic inverse",
295- width: 1,
296- height: 1,
297- setup: func(frame Frame) {
298- frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Italic: true, Inverse: true, Foreground: -1, Background: -1}})
299- },
300- damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
301- wantAll: []string{"\x1b[0;1;3;7m", "X"},
302- },
303- {
304- name: "extended SGR attributes and underline color",
305- width: 1,
306- height: 1,
307- setup: func(frame Frame) {
308- frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Foreground: -1, Background: -1, Attrs: AttrDim | AttrUnderline | AttrBlink | AttrStrikethrough, UnderlineStyle: UnderlineCurly, HasUnderlineColor: true, UnderlineColor: 9}})
309- },
310- damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
311- wantAll: []string{"\x1b[0;2;4:3;5;9;58;5;9m", "X"},
312- },
313- }
314-
315- for _, tt := range tests {
316- t.Run(tt.name, func(t *testing.T) {
317- r := New(Capabilities{})
318- frame := NewFrame(tt.width, tt.height)
319- tt.setup(frame)
320-
321- out, err := r.Draw(frame, tt.damage)
322- if err != nil {
323- t.Fatal(err)
324- }
325-
326- outputContains(t, out, tt.wantAll...)
327- })
328- }
329-}
330-
331-// ---------------------------------------------------------------------------
332-// Scroll fast path
333-// ---------------------------------------------------------------------------
334-
335-func TestScrollFastPath(t *testing.T) {
336- tests := []struct {
337- name string
338- }{
339- {name: "scroll up by one row exposes new bottom row via scroll region"},
340- }
341-
342- for _, tt := range tests {
343- t.Run(tt.name, func(t *testing.T) {
344- r := New(Capabilities{})
345- frame := NewFrame(5, 4)
346- markFrame(&frame)
347-
348- // Populate shadow.
349- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}})
350- if err != nil {
351- t.Fatal(err)
352- }
353-
354- // Build a new frame that has been scrolled up by 1 (like the VT model does).
355- scrolled := NewFrame(5, 4)
356- copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3
357- for i := 15; i < 20; i++ {
358- scrolled.Cells[i] = BlankCell() // row 3 blanked
359- }
360- scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()})
361- scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()})
362- scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()})
363-
364- damage := []Damage{
365- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
366- {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
367- }
368-
369- out, err := r.Draw(scrolled, damage)
370- if err != nil {
371- t.Fatal(err)
372- }
373- if len(out) == 0 {
374- t.Fatal("expected non-empty output for scroll")
375- }
376- // Should contain scroll-region sequences.
377- outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4
378- outputContains(t, out, "\x1b[r") // restore scroll region
379- // Should contain the new text on the exposed row.
380- outputContains(t, out, "N")
381- outputContains(t, out, "e")
382- outputContains(t, out, "w")
383- })
384- }
385-}
386-
387-// ---------------------------------------------------------------------------
388-// Synchronized output wrapper
389-// ---------------------------------------------------------------------------
390-
391-func TestSynchronizedOutput(t *testing.T) {
392- tests := []struct {
393- name string
394- run func(t *testing.T)
395- }{
396- {
397- name: "wraps output in sync CSI",
398- run: func(t *testing.T) {
399- r := New(Capabilities{SynchronizedOutput: true})
400- frame := NewFrame(3, 2)
401- markFrame(&frame)
402-
403- out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
404- if err != nil {
405- t.Fatal(err)
406- }
407- if !strings.HasPrefix(string(out), SyncStartCSI) {
408- t.Errorf("expected output to start with sync start CSI")
409- }
410- if !strings.HasSuffix(string(out), SyncEndCSI) {
411- t.Errorf("expected output to end with sync end CSI, got %q", string(out))
412- }
413- },
414- },
415- {
416- name: "no-op returns empty output, not wrapped empty output",
417- run: func(t *testing.T) {
418- r := New(Capabilities{SynchronizedOutput: true})
419- frame := NewFrame(3, 2)
420- markFrame(&frame)
421-
422- // First draw.
423- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
424- if err != nil {
425- t.Fatal(err)
426- }
427-
428- // No-op – must return nil, not wrapped empty output.
429- out, err := r.Draw(frame, nil)
430- if err != nil {
431- t.Fatal(err)
432- }
433- if len(out) != 0 {
434- t.Fatalf("expected empty output for no-op, got %q", string(out))
435- }
436- },
437- },
438- }
439-
440- for _, tt := range tests {
441- t.Run(tt.name, tt.run)
442- }
443-}
444-
445-// ---------------------------------------------------------------------------
446-// WrapSynchronized standalone function
447-// ---------------------------------------------------------------------------
448-
449-func TestWrapSynchronized(t *testing.T) {
450- content := []byte("\x1b[0mhello")
451-
452- tests := []struct {
453- name string
454- content []byte
455- enabled bool
456- check func(t *testing.T, got []byte)
457- }{
458- {
459- name: "enabled wraps with sync start/end and preserves content",
460- content: content,
461- enabled: true,
462- check: func(t *testing.T, got []byte) {
463- if !strings.HasPrefix(string(got), SyncStartCSI) {
464- t.Errorf("missing sync start")
465- }
466- if !strings.HasSuffix(string(got), SyncEndCSI) {
467- t.Errorf("missing sync end")
468- }
469- if !strings.Contains(string(got), "hello") {
470- t.Errorf("missing original content")
471- }
472- },
473- },
474- {
475- name: "disabled returns content unchanged",
476- content: content,
477- enabled: false,
478- check: func(t *testing.T, got []byte) {
479- if string(got) != string(content) {
480- t.Errorf("disabled wrapping should return content unchanged")
481- }
482- },
483- },
484- {
485- name: "empty input returns empty",
486- content: nil,
487- enabled: true,
488- check: func(t *testing.T, got []byte) {
489- if len(got) != 0 {
490- t.Errorf("empty input should return empty")
491- }
492- },
493- },
494- }
495-
496- for _, tt := range tests {
497- t.Run(tt.name, func(t *testing.T) {
498- got := WrapSynchronized(tt.content, tt.enabled)
499- tt.check(t, got)
500- })
501- }
502-}
503-
504-// ---------------------------------------------------------------------------
505-// Partial damage draw
506-// ---------------------------------------------------------------------------
507-
508-func TestPartialDamage(t *testing.T) {
509- tests := []struct {
510- name string
511- }{
512- {name: "single changed cell repositions cursor and resets style"},
513- }
514-
515- for _, tt := range tests {
516- t.Run(tt.name, func(t *testing.T) {
517- r := New(Capabilities{})
518- frame := NewFrame(4, 2)
519- markFrame(&frame)
520-
521- // Populate shadow.
522- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
523- if err != nil {
524- t.Fatal(err)
525- }
526-
527- // Change one cell.
528- frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
529- damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}}
530-
531- out, err := r.Draw(frame, damage)
532- if err != nil {
533- t.Fatal(err)
534- }
535- if len(out) == 0 {
536- t.Fatal("expected non-empty output for partial damage")
537- }
538- // Should position cursor at (2,1) — 1-indexed (3;2).
539- outputContains(t, out, "\x1b[2;3H")
540- outputContains(t, out, "Z")
541- outputEndsWith(t, out, "\x1b[0m")
542- })
543- }
544-}
545-
546-// ---------------------------------------------------------------------------
547-// Reset
548-// ---------------------------------------------------------------------------
549-
550-func TestReset(t *testing.T) {
551- tests := []struct {
552- name string
553- }{
554- {name: "clears shadow state and forces a full draw next time"},
555- }
556-
557- for _, tt := range tests {
558- t.Run(tt.name, func(t *testing.T) {
559- r := New(Capabilities{})
560- frame := NewFrame(3, 2)
561- markFrame(&frame)
562-
563- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
564- if err != nil {
565- t.Fatal(err)
566- }
567-
568- r.Reset()
569-
570- if r.width != 0 || r.height != 0 || r.shadow != nil {
571- t.Fatal("Reset should clear width/height/shadow")
572- }
573-
574- // After reset, the next draw should be a full draw.
575- out, err := r.Draw(frame, nil)
576- if err != nil {
577- t.Fatal(err)
578- }
579- if len(out) == 0 {
580- t.Fatal("expected non-empty output after reset")
581- }
582- })
583- }
584-}
585-
586-// ---------------------------------------------------------------------------
587-// Damage helper
588-// ---------------------------------------------------------------------------
589-
590-func TestFullRedraw(t *testing.T) {
591- tests := []struct {
592- name string
593- run func(t *testing.T)
594- }{
595- {
596- name: "known same-size full redraw diffs against shadow",
597- run: func(t *testing.T) {
598- r := New(Capabilities{})
599- frame := NewFrame(3, 2)
600- markFrame(&frame)
601-
602- if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
603- t.Fatal(err)
604- }
605-
606- changed := frame
607- changed.Set(0, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
608- out, err := r.Draw(changed, []Damage{FullRedraw()})
609- if err != nil {
610- t.Fatal(err)
611- }
612- got := string(out)
613- if strings.Contains(got, "ABC") {
614- t.Fatalf("unchanged row was re-emitted on dimension-stable FullRedraw: %q", got)
615- }
616- if !strings.Contains(got, "ZEF") {
617- t.Fatalf("changed row was not emitted on dimension-stable FullRedraw: %q", got)
618- }
619- outputEndsWith(t, out, "\x1b[0m")
620- },
621- },
622- {
623- name: "unchanged known same-size full redraw is a no-op",
624- run: func(t *testing.T) {
625- r := New(Capabilities{})
626- frame := NewFrame(3, 2)
627- markFrame(&frame)
628-
629- if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
630- t.Fatal(err)
631- }
632- out, err := r.Draw(frame, []Damage{FullRedraw()})
633- if err != nil {
634- t.Fatal(err)
635- }
636- if len(out) != 0 {
637- t.Fatalf("unchanged dimension-stable FullRedraw emitted output: %q", string(out))
638- }
639- },
640- },
641- {
642- name: "reset full redraw emits complete frame",
643- run: func(t *testing.T) {
644- r := New(Capabilities{})
645- frame := NewFrame(3, 2)
646- markFrame(&frame)
647-
648- if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
649- t.Fatal(err)
650- }
651- r.Reset()
652-
653- out, err := r.Draw(frame, []Damage{FullRedraw()})
654- if err != nil {
655- t.Fatal(err)
656- }
657- got := string(out)
658- if !strings.Contains(got, "ABC") || !strings.Contains(got, "DEF") {
659- t.Fatalf("reset FullRedraw did not emit complete frame: %q", got)
660- }
661- outputEndsWith(t, out, "\x1b[0m")
662- },
663- },
664- }
665-
666- for _, tt := range tests {
667- t.Run(tt.name, tt.run)
668- }
669-}
670-
671-// ---------------------------------------------------------------------------
672-// Scroll with no remaining damage
673-// ---------------------------------------------------------------------------
674-
675-func TestScrollOnlyDamage(t *testing.T) {
676- tests := []struct {
677- name string
678- }{
679- {name: "scroll damage plus new-row text damage from the VT model"},
680- }
681-
682- for _, tt := range tests {
683- t.Run(tt.name, func(t *testing.T) {
684- r := New(Capabilities{})
685- frame := NewFrame(4, 3)
686- markFrame(&frame)
687-
688- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
689- if err != nil {
690- t.Fatal(err)
691- }
692-
693- // Scroll up 1 but the VT model would also emit DamageText for the new bottom row.
694- scrolled := NewFrame(4, 3)
695- copy(scrolled.Cells[0:8], frame.Cells[4:12])
696- for i := 8; i < 12; i++ {
697- scrolled.Cells[i] = BlankCell()
698- }
699- scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()})
700-
701- damage := []Damage{
702- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
703- {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
704- }
705-
706- out, err := r.Draw(scrolled, damage)
707- if err != nil {
708- t.Fatal(err)
709- }
710- if len(out) == 0 {
711- t.Fatal("expected output for scroll")
712- }
713- // Should include scroll region
714- outputContains(t, out, "\x1b[1;3r")
715- outputContains(t, out, "\x1b[r")
716- outputEndsWith(t, out, "\x1b[0m")
717- })
718- }
719-}
720-
721-// ---------------------------------------------------------------------------
722-// writeCursor output format
723-// ---------------------------------------------------------------------------
724-
725-func TestWriteCursor(t *testing.T) {
726- tests := []struct {
727- name string
728- y, x int
729- want string
730- }{
731- {name: "origin", y: 0, x: 0, want: "\x1b[1;1H"},
732- {name: "offset", y: 2, x: 5, want: "\x1b[3;6H"},
733- }
734-
735- for _, tt := range tests {
736- t.Run(tt.name, func(t *testing.T) {
737- var buf bytes.Buffer
738- writeCursor(&buf, tt.y, tt.x)
739- if buf.String() != tt.want {
740- t.Errorf("unexpected cursor positioning: %q", buf.String())
741- }
742- })
743- }
744-}
745-
746-// ---------------------------------------------------------------------------
747-// isSafeScroll edge cases
748-// ---------------------------------------------------------------------------
749-
750-func TestIsSafeScroll(t *testing.T) {
751- frame := NewFrame(10, 8)
752-
753- tests := []struct {
754- name string
755- d Damage
756- want bool
757- }{
758- {"full width, partial height", Damage{X: 0, Y: 1, Width: 10, Height: 5, Count: 2}, true},
759- {"not full width", Damage{X: 1, Y: 1, Width: 8, Height: 5, Count: 2}, false},
760- {"count zero", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 0}, false},
761- {"count equals height", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 8}, false},
762- {"extends past frame", Damage{X: 0, Y: 6, Width: 10, Height: 6, Count: 2}, false},
763- {"negative Y", Damage{X: 0, Y: -1, Width: 10, Height: 5, Count: 1}, false},
764- {"height zero", Damage{X: 0, Y: 0, Width: 10, Height: 0, Count: 1}, false},
765- }
766-
767- for _, tt := range tests {
768- t.Run(tt.name, func(t *testing.T) {
769- got := isSafeScroll(frame, tt.d)
770- if got != tt.want {
771- t.Errorf("isSafeScroll(%+v) = %v, want %v", tt.d, got, tt.want)
772- }
773- })
774- }
775-}
776-
777-// ---------------------------------------------------------------------------
778-// BlankCell / Cell.Equal
779-// ---------------------------------------------------------------------------
780-
781-func TestCellEqual(t *testing.T) {
782- tests := []struct {
783- name string
784- a Cell
785- b Cell
786- want bool
787- }{
788- {
789- name: "blank cells should be equal",
790- a: BlankCell(),
791- b: BlankCell(),
792- want: true,
793- },
794- {
795- name: "different runes should not be equal",
796- a: Cell{Rune: 'A', Style: DefaultStyle()},
797- b: BlankCell(),
798- want: false,
799- },
800- {
801- name: "different styles should not be equal",
802- a: Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}},
803- b: BlankCell(),
804- want: false,
805- },
806- {
807- name: "continuation flag participates in equality",
808- a: Cell{Continuation: true},
809- b: Cell{},
810- want: false,
811- },
812- {
813- name: "two continuation cells are equal",
814- a: Cell{Continuation: true},
815- b: Cell{Continuation: true},
816- want: true,
817- },
818- }
819-
820- for _, tt := range tests {
821- t.Run(tt.name, func(t *testing.T) {
822- if got := tt.a.Equal(tt.b); got != tt.want {
823- t.Errorf("Equal() = %v, want %v", got, tt.want)
824- }
825- })
826- }
827-}
828-
829-// ---------------------------------------------------------------------------
830-// Frame helpers
831-// ---------------------------------------------------------------------------
832-
833-func TestFrameValidate(t *testing.T) {
834- tests := []struct {
835- name string
836- frame Frame
837- wantErr bool
838- }{
839- {name: "valid frame", frame: NewFrame(5, 3), wantErr: false},
840- {name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true},
841- {name: "wrong cell count", frame: Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}, wantErr: true},
842- }
843-
844- for _, tt := range tests {
845- t.Run(tt.name, func(t *testing.T) {
846- err := tt.frame.Validate()
847- if tt.wantErr && err == nil {
848- t.Fatal("expected error, got nil")
849- }
850- if !tt.wantErr && err != nil {
851- t.Fatalf("unexpected error: %v", err)
852- }
853- })
854- }
855-}
856-
857-func TestFrameAtSet(t *testing.T) {
858- tests := []struct {
859- name string
860- }{
861- {name: "At/Set round-trip and unaffected cells stay blank"},
862- }
863-
864- for _, tt := range tests {
865- t.Run(tt.name, func(t *testing.T) {
866- f := NewFrame(3, 2)
867- cell := Cell{Rune: 'X', Style: Style{Bold: true}}
868- f.Set(1, 1, cell)
869-
870- got := f.At(1, 1)
871- if got.Rune != 'X' || !got.Style.Bold {
872- t.Errorf("At/Set round-trip failed: got %+v", got)
873- }
874-
875- // Unaffected cells are blank.
876- got = f.At(0, 0)
877- if got != BlankCell() {
878- t.Errorf("unexpected cell at (0,0): %+v", got)
879- }
880- })
881- }
882-}
883-
884-// ---------------------------------------------------------------------------
885-// Damage helpers
886-// ---------------------------------------------------------------------------
887-
888-func TestSameDamage(t *testing.T) {
889- tests := []struct {
890- name string
891- a Damage
892- b Damage
893- want bool
894- }{
895- {
896- name: "identical damages should be equal",
897- a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
898- b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
899- want: true,
900- },
901- {
902- name: "different counts should not be equal",
903- a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
904- b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0},
905- want: false,
906- },
907- }
908-
909- for _, tt := range tests {
910- t.Run(tt.name, func(t *testing.T) {
911- if got := sameDamage(tt.a, tt.b); got != tt.want {
912- t.Errorf("sameDamage() = %v, want %v", got, tt.want)
913- }
914- })
915- }
916-}
917-
918-func TestDamageCoversCell(t *testing.T) {
919- damage := []Damage{
920- {Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2},
921- }
922-
923- tests := []struct {
924- name string
925- x, y int
926- want bool
927- }{
928- {name: "should cover (3,3)", x: 3, y: 3, want: true},
929- {name: "should cover (6,4)", x: 6, y: 4, want: true},
930- {name: "should not cover (1,3)", x: 1, y: 3, want: false},
931- {name: "should not cover (3,5)", x: 3, y: 5, want: false},
932- {name: "should not cover (7,3)", x: 7, y: 3, want: false},
933- }
934-
935- for _, tt := range tests {
936- t.Run(tt.name, func(t *testing.T) {
937- if got := damageCoversCell(damage, tt.x, tt.y); got != tt.want {
938- t.Errorf("damageCoversCell(%d,%d) = %v, want %v", tt.x, tt.y, got, tt.want)
939- }
940- })
941- }
942-}
943-
944-// ---------------------------------------------------------------------------
945-// needsFull
946-// ---------------------------------------------------------------------------
947-
948-func TestNeedsFull(t *testing.T) {
949- tests := []struct {
950- name string
951- damage []Damage
952- want bool
953- }{
954- {name: "nil damage should not need full", damage: nil, want: false},
955- {name: "empty damage should not need full", damage: []Damage{}, want: false},
956- {name: "text damage should not need full", damage: []Damage{{Kind: DamageText}}, want: false},
957- {name: "FullRedraw should need full", damage: []Damage{FullRedraw()}, want: true},
958- }
959-
960- for _, tt := range tests {
961- t.Run(tt.name, func(t *testing.T) {
962- if got := needsFull(tt.damage); got != tt.want {
963- t.Errorf("needsFull() = %v, want %v", got, tt.want)
964- }
965- })
966- }
967-}
968-
969-func TestDrawFallbackAndClampingEdgeCases(t *testing.T) {
970- tests := []struct {
971- name string
972- run func(t *testing.T)
973- }{
974- {
975- name: "falls back to full redraw when scroll fast path is unsafe",
976- run: func(t *testing.T) {
977- r := New(Capabilities{})
978- frame := NewFrame(4, 3)
979- markFrame(&frame)
980- if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil {
981- t.Fatal(err)
982- }
983-
984- changed := NewFrame(4, 3)
985- markFrame(&changed)
986- changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship
987- damage := []Damage{
988- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
989- {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
990- }
991- out, err := r.Draw(changed, damage)
992- if err != nil {
993- t.Fatal(err)
994- }
995- if rows := strings.Count(string(out), "H"); rows < changed.Height {
996- t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out))
997- }
998- for i := range changed.Cells {
999- if !r.shadow[i].Equal(changed.Cells[i]) {
1000- t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i])
1001- }
1002- }
1003- },
1004- },
1005- {
1006- name: "out-of-bounds damage rectangles are clamped without panicking",
1007- run: func(t *testing.T) {
1008- r := New(Capabilities{})
1009- frame := NewFrame(3, 2)
1010- markFrame(&frame)
1011- if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
1012- t.Fatal(err)
1013- }
1014- frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()})
1015- defer func() {
1016- if r := recover(); r != nil {
1017- t.Fatalf("Draw panicked for out-of-bounds damage: %v", r)
1018- }
1019- }()
1020- if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil {
1021- t.Fatal(err)
1022- }
1023- },
1024- },
1025- }
1026-
1027- for _, tt := range tests {
1028- t.Run(tt.name, tt.run)
1029- }
1030-}
1031-
1032-// ---------------------------------------------------------------------------
1033-// Wide (double-width) character rendering
1034-// ---------------------------------------------------------------------------
1035-
1036-func TestWideCharRendering(t *testing.T) {
1037- // A wide left cell {Rune: r} followed by a continuation cell
1038- // {Continuation: true}. The renderer must emit the rune once and emit
1039- // nothing at all for the continuation cell (the terminal advances two
1040- // columns by itself). It must never emit a space for a continuation.
1041- tests := []struct {
1042- name string
1043- build func() Frame
1044- want string
1045- }{
1046- {
1047- name: "single wide char followed by ascii",
1048- build: func() Frame {
1049- f := NewFrame(4, 1)
1050- f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1051- f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1052- f.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()})
1053- return f
1054- },
1055- // row 0: cursor home, 你, (skip continuation), A, trailing space, reset
1056- want: "\x1b[1;1H你A \x1b[0m",
1057- },
1058- {
1059- name: "two adjacent wide chars then ascii",
1060- build: func() Frame {
1061- f := NewFrame(6, 1)
1062- f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1063- f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1064- f.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
1065- f.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
1066- f.Set(4, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1067- return f
1068- },
1069- want: "\x1b[1;1H你好X \x1b[0m",
1070- },
1071- }
1072-
1073- for _, tt := range tests {
1074- t.Run(tt.name, func(t *testing.T) {
1075- r := New(Capabilities{})
1076- frame := tt.build()
1077- out, err := r.Draw(frame, []Damage{FullRedraw()})
1078- if err != nil {
1079- t.Fatal(err)
1080- }
1081- if string(out) != tt.want {
1082- t.Errorf("output = %q, want %q", string(out), tt.want)
1083- }
1084- // No continuation must ever be rendered as a space: the visible
1085- // glyph count is exactly the number of non-continuation cells.
1086- // A second draw with no damage must be a no-op, proving the shadow
1087- // matches the frame (continuation cells included).
1088- out2, err := r.Draw(frame, nil)
1089- if err != nil {
1090- t.Fatal(err)
1091- }
1092- if len(out2) != 0 {
1093- t.Errorf("second draw not a no-op: %q (shadow inconsistent)", string(out2))
1094- }
1095- })
1096- }
1097-}
1098-
1099-func TestRendererPrepare(t *testing.T) {
1100- fullFrame := testFrame("ABC", "DEF")
1101- fullDamage := []Damage{FullRedraw()}
1102-
1103- tests := []struct {
1104- name string
1105- run func(*testing.T)
1106- }{
1107- {name: "zero commit is safe", run: func(t *testing.T) {
1108- var prepared PreparedDraw
1109- prepared.Commit()
1110- }},
1111-
1112- {name: "discard and reprepare returns identical owned bytes", run: func(t *testing.T) {
1113- r := New(Capabilities{})
1114- first, err := r.Prepare(fullFrame, fullDamage, false)
1115- if err != nil {
1116- t.Fatal(err)
1117- }
1118- want := append([]byte(nil), first.Bytes()...)
1119-
1120- second, err := r.Prepare(fullFrame, fullDamage, false)
1121- if err != nil {
1122- t.Fatal(err)
1123- }
1124- if !bytes.Equal(second.Bytes(), want) {
1125- t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want)
1126- }
1127-
1128- other := testFrame("12345678", "abcdefgh", "ABCDEFGH")
1129- if _, err := r.Prepare(other, fullDamage, false); err != nil {
1130- t.Fatal(err)
1131- }
1132- if !bytes.Equal(first.Bytes(), want) {
1133- t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want)
1134- }
1135- }},
1136-
1137- {name: "commit then no-op", run: func(t *testing.T) {
1138- r := New(Capabilities{})
1139- prepared, err := r.Prepare(fullFrame, fullDamage, false)
1140- if err != nil {
1141- t.Fatal(err)
1142- }
1143- prepared.Commit()
1144-
1145- next, err := r.Prepare(fullFrame, nil, false)
1146- if err != nil {
1147- t.Fatal(err)
1148- }
1149- if len(next.Bytes()) != 0 {
1150- t.Fatalf("unchanged frame emitted %q", next.Bytes())
1151- }
1152- }},
1153-
1154- {name: "reset preparation is transactional", run: func(t *testing.T) {
1155- r := New(Capabilities{})
1156- initial, err := r.Prepare(fullFrame, fullDamage, false)
1157- if err != nil {
1158- t.Fatal(err)
1159- }
1160- initial.Commit()
1161-
1162- resetFrame := testFrame("ABC", "XYZ")
1163- resetPrepared, err := r.Prepare(resetFrame, nil, true)
1164- if err != nil {
1165- t.Fatal(err)
1166- }
1167- want, err := New(Capabilities{}).Draw(resetFrame, nil)
1168- if err != nil {
1169- t.Fatal(err)
1170- }
1171- if !bytes.Equal(resetPrepared.Bytes(), want) {
1172- t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want)
1173- }
1174-
1175- unchanged, err := r.Prepare(fullFrame, nil, false)
1176- if err != nil {
1177- t.Fatal(err)
1178- }
1179- if len(unchanged.Bytes()) != 0 {
1180- t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes())
1181- }
1182-
1183- resetPrepared, err = r.Prepare(resetFrame, nil, true)
1184- if err != nil {
1185- t.Fatal(err)
1186- }
1187- resetPrepared.Commit()
1188- afterCommit, err := r.Prepare(resetFrame, nil, false)
1189- if err != nil {
1190- t.Fatal(err)
1191- }
1192- if len(afterCommit.Bytes()) != 0 {
1193- t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes())
1194- }
1195- }},
1196-
1197- {name: "successful no-byte commit", run: func(t *testing.T) {
1198- r := New(Capabilities{})
1199- initial, err := r.Prepare(fullFrame, fullDamage, false)
1200- if err != nil {
1201- t.Fatal(err)
1202- }
1203- initial.Commit()
1204-
1205- noOp, err := r.Prepare(fullFrame, nil, false)
1206- if err != nil {
1207- t.Fatal(err)
1208- }
1209- if len(noOp.Bytes()) != 0 {
1210- t.Fatalf("no-op emitted %q", noOp.Bytes())
1211- }
1212- noOp.Commit()
1213-
1214- again, err := r.Prepare(fullFrame, nil, false)
1215- if err != nil {
1216- t.Fatal(err)
1217- }
1218- if len(again.Bytes()) != 0 {
1219- t.Fatalf("committed no-op changed renderer state: %q", again.Bytes())
1220- }
1221- }},
1222-
1223- {name: "double commit is idempotent", run: func(t *testing.T) {
1224- base := testFrame("0000", "1111", "2222", "3333")
1225- scrolled := testFrame("1111", "2222", "3333", "new!")
1226- damage := []Damage{
1227- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1228- {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1229- }
1230- r := New(Capabilities{})
1231- if _, err := r.Draw(base, fullDamage); err != nil {
1232- t.Fatal(err)
1233- }
1234-
1235- prepared, err := r.Prepare(scrolled, damage, false)
1236- if err != nil {
1237- t.Fatal(err)
1238- }
1239- prepared.Commit()
1240- prepared.Commit()
1241-
1242- next, err := r.Prepare(scrolled, nil, false)
1243- if err != nil {
1244- t.Fatal(err)
1245- }
1246- if len(next.Bytes()) != 0 {
1247- t.Fatalf("double commit advanced state twice: %q", next.Bytes())
1248- }
1249- }},
1250-
1251- {name: "copied prepared draw commits scroll once", run: func(t *testing.T) {
1252- base := testFrame("0000", "1111", "2222", "3333")
1253- scrolled := testFrame("1111", "2222", "3333", "new!")
1254- damage := []Damage{
1255- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1256- {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1257- }
1258- r := New(Capabilities{})
1259- if _, err := r.Draw(base, fullDamage); err != nil {
1260- t.Fatal(err)
1261- }
1262-
1263- prepared, err := r.Prepare(scrolled, damage, false)
1264- if err != nil {
1265- t.Fatal(err)
1266- }
1267- copied := prepared
1268- prepared.Commit()
1269- copied.Commit()
1270-
1271- next, err := r.Prepare(scrolled, nil, false)
1272- if err != nil {
1273- t.Fatal(err)
1274- }
1275- if len(next.Bytes()) != 0 {
1276- t.Fatalf("copied commit advanced state twice: %q", next.Bytes())
1277- }
1278- }},
1279-
1280- {name: "commit uses immutable frame snapshot", run: func(t *testing.T) {
1281- r := New(Capabilities{})
1282- base := testFrame("ABC", "DEF")
1283- if _, err := r.Draw(base, fullDamage); err != nil {
1284- t.Fatal(err)
1285- }
1286-
1287- changed := base.Clone()
1288- changed.Set(1, 1, Cell{Rune: 'X', Style: DefaultStyle()})
1289- want := changed.Clone()
1290- prepared, err := r.Prepare(changed, []Damage{{Kind: DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false)
1291- if err != nil {
1292- t.Fatal(err)
1293- }
1294- if !strings.Contains(string(prepared.Bytes()), "X") {
1295- t.Fatalf("prepared output = %q, want changed cell", prepared.Bytes())
1296- }
1297-
1298- changed.Set(1, 1, Cell{Rune: 'Y', Style: DefaultStyle()})
1299- prepared.Commit()
1300-
1301- next, err := r.Prepare(want, nil, false)
1302- if err != nil {
1303- t.Fatal(err)
1304- }
1305- if len(next.Bytes()) != 0 {
1306- t.Fatalf("snapshot did not preserve prepared frame: %q", next.Bytes())
1307- }
1308- }},
1309-
1310- {name: "matches concrete ANSI fixtures", run: func(t *testing.T) {
1311- tests := []struct {
1312- name string
1313- prepare func(t *testing.T) PreparedDraw
1314- want string
1315- }{
1316- {
1317- name: "full draw",
1318- prepare: func(t *testing.T) PreparedDraw {
1319- prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false)
1320- if err != nil {
1321- t.Fatal(err)
1322- }
1323- return prepared
1324- },
1325- want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m",
1326- },
1327- {
1328- name: "synchronized full draw",
1329- prepare: func(t *testing.T) PreparedDraw {
1330- prepared, err := New(Capabilities{SynchronizedOutput: true}).Prepare(fullFrame, fullDamage, false)
1331- if err != nil {
1332- t.Fatal(err)
1333- }
1334- return prepared
1335- },
1336- want: SyncStartCSI + "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m" + SyncEndCSI,
1337- },
1338- {
1339- name: "fragmented damage",
1340- prepare: func(t *testing.T) PreparedDraw {
1341- base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx")
1342- frame := base.Clone()
1343- frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1344- frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()})
1345- frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()})
1346- damage := []Damage{
1347- {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1},
1348- {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1},
1349- {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1},
1350- }
1351- r := New(Capabilities{})
1352- if _, err := r.Draw(base, fullDamage); err != nil {
1353- t.Fatal(err)
1354- }
1355- prepared, err := r.Prepare(frame, damage, false)
1356- if err != nil {
1357- t.Fatal(err)
1358- }
1359- return prepared
1360- },
1361- want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m",
1362- },
1363- {
1364- name: "scroll",
1365- prepare: func(t *testing.T) PreparedDraw {
1366- base := testFrame("00000", "11111", "22222", "33333")
1367- frame := testFrame("11111", "22222", "33333", "new!!")
1368- damage := []Damage{
1369- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
1370- {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
1371- }
1372- r := New(Capabilities{})
1373- if _, err := r.Draw(base, fullDamage); err != nil {
1374- t.Fatal(err)
1375- }
1376- prepared, err := r.Prepare(frame, damage, false)
1377- if err != nil {
1378- t.Fatal(err)
1379- }
1380- return prepared
1381- },
1382- want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m",
1383- },
1384- }
1385-
1386- for _, tt := range tests {
1387- t.Run(tt.name, func(t *testing.T) {
1388- if got := string(tt.prepare(t).Bytes()); got != tt.want {
1389- t.Fatalf("prepared ANSI = %q, want %q", got, tt.want)
1390- }
1391- })
1392- }
1393- }},
1394- }
1395-
1396- for _, tt := range tests {
1397- t.Run(tt.name, tt.run)
1398- }
1399-}
1400-
1401-func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {
1402- frame := NewFrame(2, 1)
1403- committed := NewFrame(2, 1)
1404- committed.lineOffset = nil
1405-
1406- if _, err := PlanDelta(frame, nil, committed, false); err == nil {
1407- t.Fatal("PlanDelta accepted an invalid committed frame")
1408- }
1409-}
1410-
1411-func TestWideCharDamageEmission(t *testing.T) {
1412- // Populate the shadow with a blank frame, then place a wide char and draw
1413- // with a text-damage rect covering the pair. The continuation cell must
1414- // not produce a space.
1415- r := New(Capabilities{})
1416- blank := NewFrame(4, 1)
1417- if _, err := r.Draw(blank, []Damage{FullRedraw()}); err != nil {
1418- t.Fatal(err)
1419- }
1420-
1421- frame := NewFrame(4, 1)
1422- frame.Set(0, 0, Cell{Rune: '好', Style: DefaultStyle()})
1423- frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1424-
1425- out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1}})
1426- if err != nil {
1427- t.Fatal(err)
1428- }
1429- got := string(out)
1430- if !strings.Contains(got, "好") {
1431- t.Errorf("output %q missing wide rune", got)
1432- }
1433- if strings.Contains(got, "好 ") {
1434- t.Errorf("output %q emitted a space for the continuation cell", got)
1435- }
1436-}
D pkg/renderer/rune_width.go +0 -120
@@ -1,120 +0,0 @@
1-package renderer
2-
3-import "unicode"
4-
5-// RuneWidth returns the terminal cell width of a rune.
6-//
7-// Returns 0 for combining marks, zero-width characters, and control codes.
8-// Returns 2 for CJK, fullwidth, and wide emoji characters.
9-// Returns 1 for all other printable characters.
10-//
11-// Wide runes (width 2) occupy two grid cells: a left cell holding the rune and
12-// a right continuation cell. Combining marks (Mn/Me) and zero-width joiner
13-// sequences are out of scope: combining marks report width 0 (current
14-// behavior), ZWJ sequences are not coalesced (each rune is measured on its own).
15-func RuneWidth(r rune) int {
16- switch {
17- case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F):
18- return 0
19- // Soft hyphen, combining grapheme joiner, Arabic sign, etc.
20- case r == 0x00AD || r == 0x034F || r == 0x061C:
21- return 0
22- // Hangul Jungseong O/E, Hangul Jungseong Yu
23- case r == 0x115F || r == 0x1160:
24- return 0
25- // Balinese musical symbols
26- case r == 0x17B4 || r == 0x17B5:
27- return 0
28- // Mongolian variation selectors, free variation selector
29- case r == 0x180B || r == 0x180E || r == 0x200B || r == 0x200C || r == 0x200D || r == 0x200E || r == 0x200F:
30- return 0
31- // Line/paragraph separator, bidirectional overrides
32- case r >= 0x2028 && r <= 0x202E:
33- return 0
34- // Word joiner, invisible operators, Arabic/Syriac number marks, etc.
35- case r >= 0x2060 && r <= 0x206F:
36- return 0
37- case r == 0xFEFF || r == 0xFFF9 || r == 0xFFFA || r == 0xFFFB:
38- return 0
39- // Tags
40- case r >= 0xE0001 && r <= 0xE007F:
41- return 0
42- // Combining diacritical marks (Unicode categories Mn, Me)
43- case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r):
44- return 0
45- // --- Wide characters (width 2) ---
46- // Hangul Jamo
47- case r >= 0x1100 && r <= 0x115F:
48- return 2
49- // Left/right-pointing angle bracket (for compatibility)
50- case r == 0x2329 || r == 0x232A:
51- return 2
52- // CJK Radicals Supplement, Kangxi Radicals, Ideographic Description
53- // CJK Symbols, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo
54- // Kanbun, CJK Strokes, Enclosed CJK Letters
55- case r >= 0x2E80 && r <= 0x303E:
56- return 2
57- // Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, Kanbun
58- case r >= 0x3040 && r <= 0x33FF:
59- return 2
60- // CJK Unified Ideographs Extension A
61- case r >= 0x3400 && r <= 0x4DBF:
62- return 2
63- // CJK Unified Ideographs, Yi Syllables, Yi Radicals
64- case r >= 0x4E00 && r <= 0xA4CF:
65- return 2
66- // Hangul Jamo Extended-A
67- case r >= 0xA960 && r <= 0xA97C:
68- return 2
69- // Hangul Syllables
70- case r >= 0xAC00 && r <= 0xD7AF:
71- return 2
72- // Hangul Jamo Extended-B
73- case r >= 0xD7B0 && r <= 0xD7FF:
74- return 2
75- // CJK Compatibility Ideographs
76- case r >= 0xF900 && r <= 0xFAFF:
77- return 2
78- // Vertical Forms
79- case r >= 0xFE10 && r <= 0xFE19:
80- return 2
81- // CJK Compatibility Forms
82- case r >= 0xFE30 && r <= 0xFE6F:
83- return 2
84- // Fullwidth Forms (variation selectors, fullwidth punctuation, etc.)
85- case r >= 0xFF01 && r <= 0xFF60:
86- return 2
87- // Fullwidth Signs (cent, pound, yen, won, etc.)
88- case r >= 0xFFE0 && r <= 0xFFE6:
89- return 2
90- // Kana Supplement
91- case r >= 0x1B000 && r <= 0x1B0FF:
92- return 2
93- // Kana Extended-A
94- case r >= 0x1B100 && r <= 0x1B12F:
95- return 2
96- // Mahjong Tiles, Domino Tiles, Playing Cards
97- case r >= 0x1F000 && r <= 0x1F09F:
98- return 2
99- // Miscellaneous Symbols and Pictographs, Emoticons, Ornamental Dingbats
100- case r >= 0x1F0A0 && r <= 0x1F64F:
101- return 2
102- // Transport and Map Symbols
103- case r >= 0x1F680 && r <= 0x1F6FF:
104- return 2
105- // Supplemental Symbols and Pictographs
106- case r >= 0x1F900 && r <= 0x1F9FF:
107- return 2
108- // Symbols and Pictographs Extended-A (emoji added in recent Unicode)
109- case r >= 0x1FA70 && r <= 0x1FAFF:
110- return 2
111- // CJK Unified Ideographs Extension B and beyond
112- case r >= 0x20000 && r <= 0x2FFFF:
113- return 2
114- // CJK Unified Ideographs Extension G and beyond
115- case r >= 0x30000 && r <= 0x3FFFF:
116- return 2
117- default:
118- return 1
119- }
120-}
D pkg/renderer/scroll.go +0 -70
@@ -1,70 +0,0 @@
1-package renderer
2-
3-import (
4- "bytes"
5- "strconv"
6-)
7-
8-func findSafeScroll(frame Frame, damage []Damage) (Damage, bool) {
9- for _, d := range damage {
10- if d.Kind == DamageScrollUp && isSafeScroll(frame, d) {
11- return d, true
12- }
13- }
14- return Damage{}, false
15-}
16-
17-func isSafeScroll(frame Frame, d Damage) bool {
18- return d.X == 0 && d.Width == frame.Width && d.Y >= 0 && d.Y <= frame.Height && d.Height > 0 && d.Height <= frame.Height-d.Y && d.Count > 0 && d.Count < d.Height
19-}
20-
21-func emitScrollUp(out *bytes.Buffer, d Damage) {
22- out.WriteString("\x1b[0m")
23- // \x1b[%d;%dr — set scroll region
24- out.WriteString("\x1b[")
25- var b [16]byte
26- n := strconv.AppendInt(b[:0], int64(d.Y+1), 10)
27- out.Write(n)
28- out.WriteByte(';')
29- n = strconv.AppendInt(b[:0], int64(d.Y+d.Height), 10)
30- out.Write(n)
31- out.WriteByte('r')
32- // Position cursor at bottom of scroll region.
33- writeCursor(out, d.Y+d.Height-1, 0)
34- if d.Count == 1 {
35- out.WriteByte('\n')
36- } else {
37- out.WriteString("\x1b[")
38- n = strconv.AppendInt(b[:0], int64(d.Count), 10)
39- out.Write(n)
40- out.WriteByte('S')
41- }
42- out.WriteString("\x1b[r")
43-}
44-
45-func canApplyScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {
46- for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {
47- frameRow := frame.Row(y)
48- committedRow := committed.Row(y + scroll.Count)
49- for x := range scroll.Width {
50- column := scroll.X + x
51- committedCell, frameCell := committedRow[column], frameRow[column]
52- if committedCell == frameCell || committedCell.Equal(frameCell) {
53- continue
54- }
55- if !damageCoversCell(damage, column, y) {
56- return false
57- }
58- }
59- }
60- return true
61-}
62-
63-func damageCoversCell(damage []Damage, x, y int) bool {
64- for _, d := range damage {
65- if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height {
66- return true
67- }
68- }
69- return false
70-}
D pkg/renderer/style.go +0 -82
@@ -1,82 +0,0 @@
1-package renderer
2-
3-type RGB struct {
4- R uint8
5- G uint8
6- B uint8
7-}
8-
9-type StyleAttrs uint16
10-
11-const (
12- AttrDim StyleAttrs = 1 << iota
13- AttrUnderline
14- AttrBlink
15- AttrStrikethrough
16-)
17-
18-type UnderlineStyle uint8
19-
20-const (
21- UnderlineNone UnderlineStyle = iota
22- UnderlineSingle
23- UnderlineDouble
24- UnderlineCurly
25- UnderlineDotted
26- UnderlineDashed
27-)
28-
29-type Style struct {
30- Bold bool
31- Italic bool
32- Inverse bool
33- Attrs StyleAttrs
34- Foreground int // -1 means unset; ignored when HasForegroundRGB is true
35- Background int // -1 means unset; ignored when HasBackgroundRGB is true
36-
37- HasForegroundRGB bool
38- ForegroundRGB RGB
39- HasBackgroundRGB bool
40- BackgroundRGB RGB
41-
42- UnderlineStyle UnderlineStyle
43- HasUnderlineColor bool
44- UnderlineColor int // ignored when HasUnderlineColorRGB is true
45- HasUnderlineColorRGB bool
46- UnderlineColorRGB RGB
47-}
48-
49-func DefaultStyle() Style { return Style{Foreground: -1, Background: -1} }
50-
51-func (s Style) Equal(other Style) bool {
52- if s.Bold != other.Bold || s.Italic != other.Italic || s.Inverse != other.Inverse || s.Attrs != other.Attrs || s.UnderlineStyle != other.UnderlineStyle {
53- return false
54- }
55- if s.HasForegroundRGB != other.HasForegroundRGB || s.HasBackgroundRGB != other.HasBackgroundRGB || s.HasUnderlineColorRGB != other.HasUnderlineColorRGB {
56- return false
57- }
58- if s.HasForegroundRGB {
59- if s.ForegroundRGB != other.ForegroundRGB {
60- return false
61- }
62- } else if s.Foreground != other.Foreground {
63- return false
64- }
65- if s.HasBackgroundRGB {
66- if s.BackgroundRGB != other.BackgroundRGB {
67- return false
68- }
69- } else if s.Background != other.Background {
70- return false
71- }
72- if s.HasUnderlineColorRGB {
73- return s.UnderlineColorRGB == other.UnderlineColorRGB
74- }
75- if s.HasUnderlineColor != other.HasUnderlineColor {
76- return false
77- }
78- if s.HasUnderlineColor {
79- return s.UnderlineColor == other.UnderlineColor
80- }
81- return true
82-}
D pkg/renderer/sync_output.go +0 -17
@@ -1,17 +0,0 @@
1-package renderer
2-
3-const (
4- SyncStartCSI = "\x1b[?2026h"
5- SyncEndCSI = "\x1b[?2026l"
6-)
7-
8-func WrapSynchronized(content []byte, enabled bool) []byte {
9- if !enabled || len(content) == 0 {
10- return content
11- }
12- out := make([]byte, 0, len(SyncStartCSI)+len(content)+len(SyncEndCSI))
13- out = append(out, SyncStartCSI...)
14- out = append(out, content...)
15- out = append(out, SyncEndCSI...)
16- return out
17-}
D pkg/vt/benchmark_test.go +0 -242
@@ -1,242 +0,0 @@
1-package vt
2-
3-import (
4- "bytes"
5- "testing"
6-
7- "github.com/bnema/vev/pkg/renderer"
8-)
9-
10-var (
11- benchmarkHistoryViewSink HistoryView
12- benchmarkHistorySnapshotSink HistorySnapshotView
13- benchmarkHistoryBlobSink []byte
14- benchmarkHistoryBlobsSink [][]byte
15-)
16-
17-// TestHistoryViewPartialTailAllocationBounded guards the capture property used
18-// by persistence: retained sealed chunks are shared, while only the small
19-// mutable tail is copied into a view.
20-func TestHistoryViewPartialTailAllocationBounded(t *testing.T) {
21- const (
22- width = 120
23- chunkRows = 64
24- tailRows = 7
25- )
26- sealed := benchmarkHistory(t, chunkRows*4, width, chunkRows)
27- sealed.SealAndView()
28- partial := benchmarkHistory(t, chunkRows*4+tailRows, width, chunkRows)
29- partialBefore := partial.View()
30-
31- // Copying a partial tail costs one allocation per row plus a fixed set of
32- // headers: the chunk slice, its growth for the tail chunk, the chunk itself,
33- // the row slice, and the bounds slice parallel to it.
34- const tailHeaders = 4
35-
36- sealedAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = sealed.View() })
37- partialAllocs := testing.AllocsPerRun(20, func() { benchmarkHistoryViewSink = partial.View() })
38- if partialAllocs > sealedAllocs+tailRows+tailHeaders {
39- t.Fatalf("partial-tail View allocations = %v, sealed=%v, tail rows=%d; sealed history was copied", partialAllocs, sealedAllocs, tailRows)
40- }
41- if partial.View().Chunk(0) != partialBefore.Chunk(0) {
42- t.Fatal("unchanged sealed chunks must retain identity across partial-tail views")
43- }
44-}
45-
46-func BenchmarkHistoryView10KRowsSealedReuse(b *testing.B) {
47- history := benchmarkHistory(b, 10_000, 120, 256)
48- sealed := history.SealAndView()
49- if sealed.Len() != 10_000 || sealed.ChunkCount() != 40 || sealed.Chunk(0) == nil {
50- b.Fatalf("invalid sealed history fixture: rows=%d chunks=%d", sealed.Len(), sealed.ChunkCount())
51- }
52-
53- b.ReportAllocs()
54- b.ResetTimer()
55- for b.Loop() {
56- benchmarkHistoryViewSink = history.View()
57- }
58- b.StopTimer()
59- if got := benchmarkHistoryViewSink; got.Len() != sealed.Len() || got.Chunk(0) != sealed.Chunk(0) {
60- b.Fatal("unchanged sealed history chunks were not reused")
61- }
62- b.ReportMetric(float64(sealed.ChunkCount()), "sealedchunks/view")
63-}
64-
65-func BenchmarkHistoryView10KRowsPartialTail(b *testing.B) {
66- history := benchmarkHistory(b, 10_000, 120, 256)
67- before := history.View()
68- if before.Len() != 10_000 || before.ChunkCount() != 40 || before.Chunk(0) == nil {
69- b.Fatalf("invalid partial-tail history fixture: rows=%d chunks=%d", before.Len(), before.ChunkCount())
70- }
71-
72- b.ReportAllocs()
73- b.ResetTimer()
74- for b.Loop() {
75- benchmarkHistoryViewSink = history.View()
76- }
77- b.StopTimer()
78- if got := benchmarkHistoryViewSink; got.Len() != before.Len() || got.Chunk(0) != before.Chunk(0) {
79- b.Fatal("partial-tail capture did not reuse its sealed chunks")
80- }
81- b.ReportMetric(float64(10_000%256), "tailrows/view")
82-}
83-
84-func BenchmarkHistorySnapshotView10KRowsPartialTail(b *testing.B) {
85- history := benchmarkHistory(b, 10_000, 120, 256)
86- if got, want := len(history.tail), 16; got != want {
87- b.Fatalf("partial tail rows = %d, want %d", got, want)
88- }
89- sealed := history.View().Chunk(0)
90-
91- b.ReportAllocs()
92- b.ResetTimer()
93- for b.Loop() {
94- benchmarkHistorySnapshotSink = history.SnapshotView()
95- }
96- b.StopTimer()
97- if got := benchmarkHistorySnapshotSink; got.Chunk(0) != sealed || got.Tail().Len() != 16 {
98- b.Fatal("snapshot did not preserve chunk identity and tail rows")
99- }
100- b.ReportMetric(float64(benchmarkHistorySnapshotSink.ChunkCount()), "sealedchunks/view")
101-}
102-
103-func BenchmarkMarshalHistoryChunk256x120(b *testing.B) {
104- history := benchmarkHistory(b, 256, 120, 256)
105- chunk := history.SealAndView().Chunk(0)
106- if chunk == nil {
107- b.Fatal("invalid history chunk fixture")
108- }
109-
110- b.ReportAllocs()
111- b.ResetTimer()
112- for b.Loop() {
113- var err error
114- benchmarkHistoryBlobSink, err = MarshalHistoryChunk(chunk)
115- if err != nil {
116- b.Fatal(err)
117- }
118- }
119- b.StopTimer()
120- if _, err := PreflightHistoryBlob(benchmarkHistoryBlobSink); err != nil {
121- b.Fatalf("encoded history chunk is invalid: %v", err)
122- }
123- b.SetBytes(int64(len(benchmarkHistoryBlobSink)))
124-}
125-
126-func BenchmarkUnmarshalHistoryChunk256x120(b *testing.B) {
127- history := benchmarkHistory(b, 256, 120, 256)
128- blob, err := MarshalHistoryChunk(history.SealAndView().Chunk(0))
129- if err != nil {
130- b.Fatal(err)
131- }
132- if stats, err := PreflightHistoryBlob(blob); err != nil || stats.Rows != 256 || stats.Cells != 256*120 {
133- b.Fatalf("invalid encoded history chunk fixture: stats=%+v err=%v", stats, err)
134- }
135-
136- b.ReportAllocs()
137- b.SetBytes(int64(len(blob)))
138- b.ResetTimer()
139- for b.Loop() {
140- var err error
141- benchmarkHistoryViewSink, err = UnmarshalHistory(blob)
142- if err != nil {
143- b.Fatal(err)
144- }
145- }
146- b.StopTimer()
147- if benchmarkHistoryViewSink.Len() != 256 || benchmarkHistoryViewSink.ChunkCount() != 1 {
148- b.Fatal("decoded history chunk lost rows")
149- }
150-}
151-
152-func BenchmarkMarshalSealedHistory10KRows(b *testing.B) {
153- history := benchmarkHistory(b, 10_000, 120, 256)
154- view := history.SealAndView()
155- if view.Len() != 10_000 || view.ChunkCount() != 40 {
156- b.Fatalf("invalid sealed history fixture: rows=%d chunks=%d", view.Len(), view.ChunkCount())
157- }
158-
159- b.ReportAllocs()
160- b.ResetTimer()
161- for b.Loop() {
162- var err error
163- benchmarkHistoryBlobsSink, benchmarkHistoryBlobSink, err = MarshalSealedHistory(view)
164- if err != nil {
165- b.Fatal(err)
166- }
167- }
168- b.StopTimer()
169- if len(benchmarkHistoryBlobsSink) != view.ChunkCount() || len(benchmarkHistoryBlobSink) == 0 {
170- b.Fatal("sealed-history encoder produced an incomplete capture")
171- }
172- b.ReportMetric(float64(len(benchmarkHistoryBlobsSink)), "sealedchunks/op")
173-}
174-
175-func benchmarkHistory(t testing.TB, rows, width, chunkRows int) *History {
176- t.Helper()
177- history := NewHistory(HistoryConfig{MaxRows: rows, ChunkRows: chunkRows})
178- for row := range rows {
179- cells := make([]renderer.Cell, width)
180- for col := range cells {
181- cells[col] = renderer.Cell{Rune: rune('a' + (row+col)%26)}
182- }
183- requireHistoryAppend(t, history, cells)
184- }
185- return history
186-}
187-
188-// BenchmarkScreenResizeReflowViewport verifies resize only visits the live
189-// viewport. The 10K-history variant must track the control rather than history
190-// length; history is deliberately populated without changing the viewport.
191-func BenchmarkScreenResizeReflowViewport(b *testing.B) {
192- b.Run("control", func(b *testing.B) { benchmarkScreenResizeReflow(b, 0) })
193- b.Run("10k-history", func(b *testing.B) { benchmarkScreenResizeReflow(b, 10_000) })
194-}
195-
196-func benchmarkScreenResizeReflow(b *testing.B, historyRows int) {
197- b.Helper()
198- s := NewScreenWithHistory(120, 40, HistoryConfig{MaxRows: historyRows + 500})
199- if historyRows > 0 {
200- for range historyRows {
201- requireHistoryAppend(b, s.history, make([]renderer.Cell, 120))
202- }
203- }
204- s.Write(bytes.Repeat([]byte("x"), 120*39))
205- s.ClearDamage()
206- sizes := [2][2]int{{80, 40}, {120, 40}}
207-
208- b.ReportAllocs()
209- b.ResetTimer()
210- for i := 0; b.Loop(); i++ {
211- s.Resize(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1])
212- s.ClearDamage()
213- }
214- b.StopTimer()
215- if got := s.History(); historyRows > 0 && got.Len() < historyRows {
216- b.Fatalf("resize discarded retained history rows: got %d want at least %d", got.Len(), historyRows)
217- }
218-}
219-
220-func BenchmarkScreenShellRedrawBurst(b *testing.B) {
221- chunk := []byte("\r\x1b[K❯ abc\x1b[90m autosuggestion\x1b[39m\r\x1b[5C")
222- b.ReportAllocs()
223- for b.Loop() {
224- s := NewScreen(120, 40)
225- for range 200 {
226- s.Write(chunk)
227- s.ClearDamage()
228- }
229- }
230-}
231-
232-func BenchmarkScreenFullscreenScrollRegion(b *testing.B) {
233- chunk := []byte("\x1b[2;39r\x1b[39;1Hline\n")
234- b.ReportAllocs()
235- for b.Loop() {
236- s := NewScreen(120, 40)
237- for range 500 {
238- s.Write(chunk)
239- s.ClearDamage()
240- }
241- }
242-}
D pkg/vt/buffer.go +0 -435
@@ -1,435 +0,0 @@
1-package vt
2-
3-import "github.com/bnema/vev/pkg/renderer"
4-
5-// buffer owns the visible VT grid and the physical-row boundaries needed to
6-// reconstruct logical lines. History deliberately is not part of this type:
7-// reflow is bounded by the live grid.
8-type buffer struct {
9- frame renderer.Frame
10- boundaries []LineBound
11- rowIDs []RowID
12-}
13-
14-// LineBound describes a physical row's logical extent. End is exclusive: it is
15-// the count of meaningful cells, so the last significant column is End-1. It
16-// excludes padding introduced when a wide rune was moved off the right edge.
17-// Soft reports that the row continues into the following physical row.
18-type LineBound struct {
19- End int
20- Soft bool
21-}
22-
23-func newBuffer(width, height int) *buffer {
24- return &buffer{frame: renderer.NewFrame(width, height), boundaries: make([]LineBound, height), rowIDs: make([]RowID, height)}
25-}
26-
27-func bufferFromFrame(frame renderer.Frame) *buffer {
28- return &buffer{frame: frame, boundaries: make([]LineBound, frame.Height), rowIDs: make([]RowID, frame.Height)}
29-}
30-
31-func (b *buffer) clone() *buffer {
32- out := &buffer{
33- frame: cloneFrame(b.frame),
34- boundaries: append([]LineBound(nil), b.boundaries...),
35- rowIDs: append([]RowID(nil), b.rowIDs...),
36- }
37- return out
38-}
39-
40-func (b *buffer) content(y, end int) {
41- if y < 0 || y >= len(b.boundaries) {
42- return
43- }
44- b.boundaries[y].End = max(b.boundaries[y].End, clamp(end, 0, b.frame.Width))
45-}
46-
47-// bound reports the logical extent of row y. Callers on eviction paths may run
48-// against a nil or shorter buffer than the frame they are walking, so an
49-// unknown row reads as a hard row of no extent rather than panicking.
50-func (b *buffer) bound(y int) LineBound {
51- if b == nil || y < 0 || y >= len(b.boundaries) {
52- return LineBound{}
53- }
54- return b.boundaries[y]
55-}
56-
57-func (b *buffer) truncate(y, end int) {
58- if y < 0 || y >= len(b.boundaries) {
59- return
60- }
61- b.boundaries[y].End = min(b.boundaries[y].End, clamp(end, 0, b.frame.Width))
62-}
63-
64-// insert retains the meaningful shifted tail when insertion happens within it.
65-func (b *buffer) insert(y, at, width int) {
66- if y < 0 || y >= len(b.boundaries) || at >= b.boundaries[y].End {
67- return
68- }
69- b.boundaries[y].End = min(b.boundaries[y].End+width, b.frame.Width)
70-}
71-
72-func (b *buffer) hard(y int) {
73- if y >= 0 && y < len(b.boundaries) {
74- b.boundaries[y].Soft = false
75- }
76-}
77-
78-func (b *buffer) soft(y int) {
79- if y >= 0 && y < len(b.boundaries) {
80- b.boundaries[y].End = b.frame.Width
81- b.boundaries[y].Soft = true
82- }
83-}
84-
85-func (b *buffer) continueRow(y int) {
86- if y >= 0 && y < len(b.boundaries) {
87- b.boundaries[y].Soft = true
88- }
89-}
90-
91-func (b *buffer) clear(y, x0, x1 int) {
92- if y < 0 || y >= len(b.boundaries) {
93- return
94- }
95- b.content(y, x1)
96- if x1 >= b.frame.Width {
97- // Erasing through the right edge leaves nothing flowing onto the next
98- // row: the logical line ends here. Keeping a stale soft link would let
99- // reflow merge a repainted row with the unrelated row below it.
100- b.boundaries[y].Soft = false
101- }
102- if x0 == 0 && x1 >= b.frame.Width {
103- b.boundaries[y] = LineBound{}
104- }
105-}
106-
107-func (b *buffer) scrollUp(top, bottom, n int) {
108- // A region operation changes which rows meet at both region edges. A soft
109- // boundary belongs to the row it follows, not the cells moved into its
110- // neighbor, so sever links crossing either edge before reflow can observe
111- // them.
112- b.hard(top - 1)
113- copy(b.boundaries[top:bottom-n+1], b.boundaries[top+n:bottom+1])
114- copy(b.rowIDs[top:bottom-n+1], b.rowIDs[top+n:bottom+1])
115- for y := bottom - n + 1; y <= bottom; y++ {
116- b.boundaries[y] = LineBound{}
117- b.rowIDs[y] = 0
118- }
119- b.hard(bottom - n)
120-}
121-
122-func (b *buffer) scrollDown(top, bottom, n int) {
123- // See scrollUp: the old top no longer follows the row above the region, and
124- // the last moved row no longer precedes the row below it.
125- b.hard(top - 1)
126- copy(b.boundaries[top+n:bottom+1], b.boundaries[top:bottom-n+1])
127- copy(b.rowIDs[top+n:bottom+1], b.rowIDs[top:bottom-n+1])
128- for y := top; y < top+n; y++ {
129- b.boundaries[y] = LineBound{}
130- b.rowIDs[y] = 0
131- }
132- b.hard(bottom)
133-}
134-
135-func (b *buffer) hydrate() {
136- for y := range b.boundaries {
137- if b.boundaries[y].End != 0 {
138- continue
139- }
140- for x := b.frame.Width - 1; x >= 0; x-- {
141- if !b.frame.At(x, y).Equal(renderer.BlankCell()) {
142- b.boundaries[y].End = x + 1
143- break
144- }
145- }
146- }
147-}
148-
149-type bufferCursor struct{ row, col int }
150-
151-func (b *buffer) hasSoft() bool {
152- for _, boundary := range b.boundaries {
153- if boundary.Soft {
154- return true
155- }
156- }
157- return false
158-}
159-
160-// resizeFixed keeps hard physical lines independent. It is the common shell
161-// path and avoids constructing logical-line scratch state when nothing wraps.
162-func (b *buffer) resizeFixed(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound, []RowID) {
163- anchor := 0
164- if active != nil {
165- anchor = active.row
166- }
167- shift := clamp(anchor-(height-1), 0, max(b.frame.Height-height, 0))
168- evicted := make([][]renderer.Cell, 0, shift)
169- evictedBounds := make([]LineBound, 0, shift)
170- evictedIDs := make([]RowID, 0, shift)
171- for y := range shift {
172- evicted = append(evicted, append([]renderer.Cell(nil), b.frame.Row(y)...))
173- // The evicted row keeps its source width, so its extent needs no clamping.
174- evictedBounds = append(evictedBounds, b.boundaries[y])
175- evictedIDs = append(evictedIDs, b.rowIDs[y])
176- }
177- // Fixed-line resizes can retain the boundary backing store. In particular,
178- // keep its capacity through a short viewport so the common shrink/grow
179- // sequence does not add metadata allocation to every resize epoch.
180- boundaries := b.boundaries
181- if cap(boundaries) < height {
182- boundaries = make([]LineBound, height)
183- } else {
184- boundaries = boundaries[:height]
185- }
186- next := buffer{frame: renderer.NewFrame(width, height), boundaries: boundaries, rowIDs: make([]RowID, height)}
187- copied := 0
188- for y := range height {
189- sy := y + shift
190- if sy >= b.frame.Height {
191- break
192- }
193- copy(next.frame.Row(y), b.frame.Row(sy))
194- next.boundaries[y] = b.boundaries[sy]
195- next.rowIDs[y] = b.rowIDs[sy]
196- next.boundaries[y].End = min(next.boundaries[y].End, width)
197- repairFrameRow(next.frame, y)
198- copied++
199- }
200- clear(next.boundaries[copied:])
201- for _, cur := range [2]*bufferCursor{active, saved} {
202- if cur != nil {
203- cur.row = clamp(cur.row-shift, 0, height-1)
204- cur.col = clamp(cur.col, 0, width)
205- }
206- }
207- *b = next
208- return evicted, evictedBounds, evictedIDs
209-}
210-
211-type reflowPoint struct {
212- line, offset int
213- row, col int
214-}
215-
216-// cursorReflowPoints maps source cursor positions to offsets in their logical
217-// lines. There are exactly two callers (active and DECSC), so this stays flat
218-// and stack allocated rather than building a row/column lookup map.
219-func (b *buffer) cursorReflowPoints(active, saved *bufferCursor) [2]reflowPoint {
220- points := [2]reflowPoint{{line: -1}, {line: -1}}
221- cursors := [2]*bufferCursor{active, saved}
222- for start := 0; start < b.frame.Height; {
223- end := start
224- for b.boundaries[end].Soft && end+1 < b.frame.Height {
225- end++
226- }
227- for i, cur := range cursors {
228- if cur == nil || cur.row < start || cur.row > end {
229- continue
230- }
231- offset := 0
232- for y := start; y < cur.row; y++ {
233- offset += b.boundaries[y].End
234- }
235- points[i] = reflowPoint{
236- line: start,
237- offset: offset + min(clamp(cur.col, 0, b.frame.Width), b.boundaries[cur.row].End),
238- }
239- }
240- start = end + 1
241- }
242- return points
243-}
244-
245-// resize lays out only the current grid. It does two direct, bounded passes:
246-// the first maps both cursors and counts rows; the second writes only the
247-// retained viewport (and the rows genuinely evicted to history). It never
248-// materializes logical lines, per-cell position maps, or temporary output rows.
249-func (b *buffer) resize(width, height int, active, saved *bufferCursor) ([][]renderer.Cell, []LineBound, []RowID) {
250- if !b.hasSoft() {
251- return b.resizeFixed(width, height, active, saved)
252- }
253- // Only reflow needs meaningful extents; hard physical rows are copied as
254- // cells, so scanning blank rows on the shell fast path is wasted work.
255- b.hydrate()
256- for _, cur := range [2]*bufferCursor{active, saved} {
257- if cur != nil {
258- b.content(cur.row, cur.col)
259- }
260- }
261-
262- points := b.cursorReflowPoints(active, saved)
263- rows := b.layoutReflow(width, &points, nil, 0, nil, nil, nil, nil)
264- anchor := 0
265- if active != nil {
266- anchor = points[0].row
267- }
268- shift := clamp(anchor-(height-1), 0, max(rows-height, 0))
269-
270- next := newBuffer(width, height)
271- var evicted [][]renderer.Cell
272- var evictedCells []renderer.Cell
273- var evictedBounds []LineBound
274- var evictedIDs []RowID
275- if shift > 0 {
276- // History needs owned rows. One flat backing store replaces a temporary
277- // allocation per evicted output row.
278- evicted = make([][]renderer.Cell, shift)
279- evictedCells = make([]renderer.Cell, shift*width)
280- evictedBounds = make([]LineBound, shift)
281- evictedIDs = make([]RowID, shift)
282- for y := range evicted {
283- evicted[y] = evictedCells[y*width : (y+1)*width]
284- }
285- }
286- b.layoutReflow(width, &points, next, shift, b.rowIDs, evictedCells, evictedBounds, evictedIDs)
287- for i, cur := range [2]*bufferCursor{active, saved} {
288- if cur != nil {
289- cur.row = clamp(points[i].row-shift, 0, height-1)
290- cur.col = clamp(points[i].col, 0, width)
291- }
292- }
293- *b = *next
294- return evicted, evictedBounds, evictedIDs
295-}
296-
297-// layoutReflow maps source offsets and, when dst is non-nil, emits only rows in
298-// [shift, shift+dst.height). Rows before shift are written straight into the
299-// contiguous eviction backing store. The return is the logical output height.
300-func (b *buffer) layoutReflow(width int, points *[2]reflowPoint, dst *buffer, shift int, sourceIDs []RowID, evicted []renderer.Cell, evictedBounds []LineBound, evictedIDs []RowID) int {
301- row, col := 0, 0
302- setRowID := func(outputRow int, id RowID) {
303- if id == 0 {
304- return
305- }
306- switch {
307- case outputRow < shift:
308- if outputRow < len(evictedIDs) {
309- evictedIDs[outputRow] = id
310- }
311- case dst != nil && outputRow < shift+dst.frame.Height:
312- dst.rowIDs[outputRow-shift] = id
313- }
314- }
315- blankEvicted := func(outputRow int) []renderer.Cell {
316- if outputRow < shift {
317- out := evicted[outputRow*width : (outputRow+1)*width]
318- for i := range out {
319- out[i] = renderer.BlankCell()
320- }
321- return out
322- }
323- if dst != nil && outputRow < shift+dst.frame.Height {
324- return dst.frame.Row(outputRow - shift)
325- }
326- return nil
327- }
328- var output []renderer.Cell
329- if dst != nil && (shift > 0 || dst.frame.Height > 0) {
330- output = blankEvicted(0)
331- }
332- finishRow := func(soft bool) {
333- // An output row belongs either to history or to the retained viewport, and
334- // only this pass knows where it ended, so both destinations are written here.
335- bound := LineBound{End: col, Soft: soft}
336- switch {
337- case row < shift:
338- if evictedBounds != nil {
339- evictedBounds[row] = bound
340- }
341- case dst != nil && row < shift+dst.frame.Height:
342- dst.boundaries[row-shift] = bound
343- }
344- row++
345- col = 0
346- if dst != nil {
347- output = blankEvicted(row)
348- }
349- }
350- setPoint := func(line, offset, pointRow, pointCol int) {
351- for i := range points {
352- if points[i].line == line && points[i].offset == offset {
353- points[i].row, points[i].col = pointRow, pointCol
354- }
355- }
356- }
357- setRemainingPoints := func(line, offset, pointRow, pointCol int) {
358- for i := range points {
359- if points[i].line == line && points[i].offset >= offset {
360- points[i].row, points[i].col = pointRow, pointCol
361- }
362- }
363- }
364-
365- for start := 0; start < b.frame.Height; {
366- end := start
367- for b.boundaries[end].Soft && end+1 < b.frame.Height {
368- end++
369- }
370- reflow := end > start
371- offset := 0
372- sourceID := RowID(0)
373- if start < len(sourceIDs) {
374- sourceID = sourceIDs[start]
375- }
376- // A source row that starts in a partially filled output row has already
377- // inherited that row's identity. Continuation rows created by wrapping
378- // receive fresh IDs from the owning Screen after this pass.
379- if col == 0 {
380- setRowID(row, sourceID)
381- }
382- truncated := false
383- for y := start; y <= end && !truncated; y++ {
384- cells := b.frame.Row(y)
385- limit := b.boundaries[y].End
386- for x := 0; x < limit; {
387- cell := cells[x]
388- if cell.Continuation { // Repair malformed rows by dropping orphaned tails.
389- setPoint(start, offset, row, col)
390- x++
391- offset++
392- continue
393- }
394- wide := renderer.RuneWidth(cell.Rune) == 2 && x+1 < limit && cells[x+1].Continuation
395- w := 1
396- if wide && width >= 2 {
397- w = 2
398- }
399- if col+w > width && col > 0 {
400- if !reflow {
401- setRemainingPoints(start, offset, row, col)
402- truncated = true
403- break
404- }
405- finishRow(true)
406- }
407- setPoint(start, offset, row, col)
408- if wide && width >= 2 {
409- if output != nil {
410- output[col], output[col+1] = cell, cells[x+1]
411- }
412- setPoint(start, offset+1, row, col+1)
413- col += 2
414- x += 2
415- offset += 2
416- continue
417- }
418- if wide {
419- cell.Rune = '\uFFFD'
420- cell.Continuation = false
421- }
422- if output != nil {
423- output[col] = cell
424- }
425- col++
426- x++
427- offset++
428- }
429- }
430- setPoint(start, offset, row, col)
431- finishRow(false)
432- start = end + 1
433- }
434- return row
435-}
D pkg/vt/buffer_cursor_reflow_test.go +0 -21
@@ -1,21 +0,0 @@
1-package vt
2-
3-import "testing"
4-
5-func TestResizeReflowMapsActiveAndSavedCursorAtNewRowBoundary(t *testing.T) {
6- s := NewScreen(5, 3)
7- s.Write([]byte("abcdefgh"))
8- s.Row, s.Col = 0, 3
9- s.saveCursor()
10-
11- s.Resize(3, 3)
12-
13- if s.Row != 1 || s.Col != 0 {
14- t.Fatalf("active cursor = (%d,%d), want (1,0)", s.Row, s.Col)
15- }
16- s.Row, s.Col = 0, 0
17- s.restoreCursor()
18- if s.Row != 1 || s.Col != 0 {
19- t.Fatalf("saved cursor = (%d,%d), want (1,0)", s.Row, s.Col)
20- }
21-}
D pkg/vt/damage.go +0 -12
@@ -1,12 +0,0 @@
1-package vt
2-
3-import "github.com/bnema/vev/pkg/renderer"
4-
5-type Damage = renderer.Damage
6-
7-const (
8- DamageText = renderer.DamageText
9- DamageClear = renderer.DamageClear
10- DamageScrollUp = renderer.DamageScrollUp
11- DamageFullRedraw = renderer.DamageFullRedraw
12-)
D pkg/vt/doc.go +0 -13
@@ -1,13 +0,0 @@
1-// Package vt implements the server-side terminal emulator core.
2-//
3-// It tracks the visible cell grid, damage rectangles, scroll regions,
4-// alternate-screen state, cursor/reporting modes, and common xterm/VT control
5-// sequences. Wide runes that fit are stored as a head cell plus a continuation
6-// cell, and resize or edit operations repair row-boundary splits instead of
7-// leaving orphaned halves.
8-//
9-// Screen is single-owner. Snapshot returns an owned capture of the active
10-// visible viewport and renderer-relevant metadata; callers must serialize it
11-// with Write, Resize, and host mutations. Terminal history remains available
12-// through HistoryView and HistorySnapshotView rather than ScreenSnapshot.
13-package vt
D pkg/vt/history.go +0 -551
@@ -1,551 +0,0 @@
1-package vt
2-
3-import (
4- "errors"
5- "math"
6- "slices"
7-
8- "github.com/bnema/vev/pkg/renderer"
9-)
10-
11-// ErrHistoryRowTooWide is returned when a row cannot fit within the configured
12-// cell budget. The history is not modified when this error is returned.
13-var ErrHistoryRowTooWide = errors.New("history row exceeds cell capacity")
14-
15-var errInvalidHistoryRowID = errors.New("invalid history row ID")
16-
17-// HistoryConfig controls the bounded terminal history retained by a Screen.
18-type HistoryConfig struct {
19- MaxRows int
20- MaxCells int
21- ChunkRows int
22-}
23-
24-// History stores terminal rows in immutable chunks. It is intended to be
25-// mutated by the owner of a Screen; views are safe to retain after later
26-// appends.
27-type History struct {
28- maxRows int
29- maxCells int
30- chunkRows int
31- chunks []*HistoryChunk
32- tail [][]renderer.Cell
33- tailBounds []LineBound
34- tailIDs []RowID
35- rows int
36- cells int
37- nextRowID RowID
38-}
39-
40-// HistoryChunk is an immutable group of history rows. Its identity is stable
41-// and can be used by consumers to avoid copying unchanged sealed chunks.
42-// bounds is parallel to rows and always has the same length.
43-type HistoryChunk struct {
44- rows [][]renderer.Cell
45- bounds []LineBound
46- rowIDs []RowID
47-}
48-
49-// HistoryView is an immutable snapshot of history. Row returns a copy so the
50-// storage behind a sealed chunk remains owned by VT.
51-type HistoryView struct {
52- chunks []*HistoryChunk
53- rows int
54- cells int
55- nextRowID RowID
56-}
57-
58-// HistorySnapshotView captures sealed history chunks and the mutable tail
59-// independently. Sealed chunks are shared by identity; Tail is owned by the
60-// view and can be serialized without rotating the live tail into a chunk.
61-type HistorySnapshotView struct {
62- chunks []*HistoryChunk
63- tail [][]renderer.Cell
64- tailBounds []LineBound
65- tailIDs []RowID
66- rows int
67- cells int
68- nextRowID RowID
69-}
70-
71-func NewHistory(config HistoryConfig) *History {
72- if config.MaxRows <= 0 {
73- return &History{}
74- }
75- chunkRows := config.ChunkRows
76- if chunkRows <= 0 {
77- chunkRows = 256
78- }
79- chunkRows = min(chunkRows, 256)
80- chunkRows = min(chunkRows, config.MaxRows)
81- maxCells := config.MaxCells
82- if maxCells <= 0 {
83- maxCells = defaultHistoryMaxCells(config.MaxRows)
84- }
85- return &History{maxRows: config.MaxRows, maxCells: maxCells, chunkRows: chunkRows, nextRowID: 1}
86-}
87-
88-func defaultHistoryMaxCells(maxRows int) int {
89- if maxRows > math.MaxInt/160 {
90- return math.MaxInt
91- }
92- return maxRows * 160
93-}
94-
95-// Append records a copy of row along with its logical extent and an
96-// automatically allocated nonzero ID. Once a chunk is full it is sealed
97-// forever. Rows wider than the total cell capacity are rejected without
98-// mutation.
99-func (h *History) Append(row []renderer.Cell, bound LineBound) error {
100- if h == nil || h.maxRows == 0 {
101- return nil
102- }
103- if len(row) > h.maxCells {
104- return ErrHistoryRowTooWide
105- }
106- id, err := h.allocateRowID()
107- if err != nil {
108- return err
109- }
110- return h.appendRow(row, bound, id)
111-}
112-
113-// AppendWithID records a copy of row with an explicit persisted identity.
114-// Unlike Append, malformed identities are rejected rather than synthesized.
115-func (h *History) AppendWithID(row []renderer.Cell, bound LineBound, id RowID) error {
116- if id == 0 || id >= ^RowID(0)-1 {
117- return errInvalidHistoryRowID
118- }
119- if h == nil || h.maxRows == 0 {
120- return nil
121- }
122- if len(row) > h.maxCells {
123- return ErrHistoryRowTooWide
124- }
125- if h.hasRowID(id) {
126- return errInvalidHistoryRowID
127- }
128- return h.appendRow(row, bound, id)
129-}
130-
131-func (h *History) appendRow(row []renderer.Cell, bound LineBound, id RowID) error {
132- if h.nextRowID == ^RowID(0) {
133- return errInvalidHistoryRowID
134- }
135- if len(row) > h.maxCells {
136- return ErrHistoryRowTooWide
137- }
138- if id >= ^RowID(0)-1 {
139- return errInvalidHistoryRowID
140- }
141- if id >= h.nextRowID {
142- h.nextRowID = id + 1
143- }
144- // Make room before adding so Cells cannot overflow even when the default
145- // capacity is MaxInt.
146- h.evictFor(len(row))
147- h.tail = append(h.tail, append([]renderer.Cell(nil), row...))
148- h.tailBounds = append(h.tailBounds, clampBound(bound, len(row)))
149- h.tailIDs = append(h.tailIDs, id)
150- h.rows++
151- h.cells += len(row)
152- if len(h.tail) == h.chunkRows {
153- h.sealTail()
154- }
155- return nil
156-}
157-
158-// allocateRowID relies on appendRow advancing nextRowID above every accepted
159-// explicit ID; automatic IDs therefore never need to scan retained rows.
160-func (h *History) allocateRowID() (RowID, error) {
161- if h.nextRowID == 0 {
162- h.nextRowID = 1
163- }
164- if h.nextRowID >= ^RowID(0)-1 {
165- return 0, errInvalidHistoryRowID
166- }
167- id := h.nextRowID
168- h.nextRowID++
169- return id, nil
170-}
171-
172-func (h *History) hasRowID(id RowID) bool {
173- for _, chunk := range h.chunks {
174- if slices.Contains(chunk.rowIDs, id) {
175- return true
176- }
177- }
178- return slices.Contains(h.tailIDs, id)
179-}
180-
181-// clampBound keeps End inside the row it describes so a persisted bound can be
182-// validated without consulting its row again.
183-func clampBound(b LineBound, cells int) LineBound {
184- b.End = min(max(b.End, 0), cells)
185- return b
186-}
187-
188-// growBounds returns bounds sized to rows, padding with zero values. It keeps
189-// the parallel-slice invariant when a chunk arrives with fewer bounds than rows.
190-func growBounds(bounds []LineBound, rows int) []LineBound {
191- if len(bounds) == rows {
192- return bounds
193- }
194- out := make([]LineBound, rows)
195- copy(out, bounds)
196- return out
197-}
198-
199-func growRowIDs(ids []RowID, rows int) []RowID {
200- if len(ids) == rows {
201- return ids
202- }
203- out := make([]RowID, rows)
204- copy(out, ids)
205- return out
206-}
207-
208-func cloneRowIDs(ids []RowID, rows int) []RowID {
209- out := make([]RowID, rows)
210- copy(out, ids)
211- return out
212-}
213-
214-func (h *History) sealTail() {
215- if len(h.tail) == 0 {
216- return
217- }
218- h.chunks = append(h.chunks, &HistoryChunk{
219- rows: h.tail,
220- bounds: growBounds(h.tailBounds, len(h.tail)),
221- rowIDs: h.tailIDs,
222- })
223- h.tail = nil
224- h.tailBounds = nil
225- h.tailIDs = nil
226-}
227-
228-// normalizeTail seals complete chunks so the mutable tail remains shorter than
229-// chunkRows. Restored snapshots may have been written with a different chunk
230-// size than the current history configuration.
231-func (h *History) normalizeTail() {
232- h.tailIDs = growRowIDs(h.tailIDs, len(h.tail))
233- for h.chunkRows > 0 && len(h.tail) >= h.chunkRows {
234- bounds := growBounds(h.tailBounds, len(h.tail))
235- h.chunks = append(h.chunks, &HistoryChunk{
236- rows: h.tail[:h.chunkRows],
237- bounds: bounds[:h.chunkRows],
238- rowIDs: h.tailIDs[:h.chunkRows],
239- })
240- h.tail = h.tail[h.chunkRows:]
241- h.tailBounds = bounds[h.chunkRows:]
242- h.tailIDs = h.tailIDs[h.chunkRows:]
243- }
244-}
245-
246-func (h *History) evict() {
247- h.evictUntil(0, 0)
248-}
249-
250-// evictFor discards oldest rows until another row using cellCount cells can fit.
251-func (h *History) evictFor(cellCount int) {
252- h.evictUntil(1, cellCount)
253-}
254-
255-// evictUntil makes room for rowCount rows and cellCount cells without
256-// overflowing either accounting total.
257-func (h *History) evictUntil(rowCount, cellCount int) {
258- for h.rows > h.maxRows-rowCount || h.cells > h.maxCells-cellCount {
259- if len(h.chunks) > 0 {
260- chunk := h.chunks[0]
261- row := chunk.rows[0]
262- h.rows--
263- h.cells -= len(row)
264- if len(chunk.rows) == 1 {
265- copy(h.chunks, h.chunks[1:])
266- h.chunks[len(h.chunks)-1] = nil
267- h.chunks = h.chunks[:len(h.chunks)-1]
268- } else {
269- // Preserve cell storage while replacing only the chunk wrapper: a
270- // retained view may still refer to the original immutable chunk.
271- bounds := growBounds(chunk.bounds, len(chunk.rows))
272- rowIDs := growRowIDs(chunk.rowIDs, len(chunk.rows))
273- h.chunks[0] = &HistoryChunk{
274- rows: chunk.rows[1:],
275- bounds: bounds[1:],
276- rowIDs: rowIDs[1:],
277- }
278- }
279- continue
280- }
281- if len(h.tail) == 0 {
282- return
283- }
284- row := h.tail[0]
285- h.rows--
286- h.cells -= len(row)
287- h.tail[0] = nil
288- h.tail = h.tail[1:]
289- h.tailBounds = growBounds(h.tailBounds, len(h.tail)+1)[1:]
290- h.tailIDs = growRowIDs(h.tailIDs, len(h.tail)+1)[1:]
291- }
292-}
293-
294-// SealAndView rotates the mutable tail into an immutable chunk, then captures
295-// the sealed chunks by identity. Callers must synchronize access to History.
296-func (h *History) SealAndView() HistoryView {
297- if h != nil {
298- h.sealTail()
299- }
300- return h.View()
301-}
302-
303-// View captures the current history. Sealed chunks are shared by identity; a
304-// partially-filled tail is copied into a new immutable chunk for this view.
305-func (h *History) View() HistoryView {
306- if h == nil {
307- return HistoryView{}
308- }
309- if h.rows == 0 {
310- return HistoryView{nextRowID: h.nextRowID}
311- }
312- chunks := make([]*HistoryChunk, len(h.chunks), len(h.chunks)+1)
313- copy(chunks, h.chunks)
314- if len(h.tail) > 0 {
315- chunks = append(chunks, &HistoryChunk{
316- rows: cloneHistoryRows(h.tail),
317- bounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
318- rowIDs: cloneRowIDs(h.tailIDs, len(h.tail)),
319- })
320- }
321- return HistoryView{chunks: chunks, rows: h.rows, cells: h.cells, nextRowID: h.nextRowID}
322-}
323-
324-// SnapshotView captures history for persistence without sealing the mutable
325-// tail. Sealed chunks are shared by identity and the tail is deeply copied.
326-func (h *History) SnapshotView() HistorySnapshotView {
327- if h == nil {
328- return HistorySnapshotView{}
329- }
330- if h.rows == 0 {
331- return HistorySnapshotView{nextRowID: h.nextRowID}
332- }
333- return HistorySnapshotView{
334- chunks: append([]*HistoryChunk(nil), h.chunks...),
335- tail: cloneHistoryRows(h.tail),
336- tailBounds: append([]LineBound(nil), growBounds(h.tailBounds, len(h.tail))...),
337- tailIDs: cloneRowIDs(h.tailIDs, len(h.tail)),
338- rows: h.rows,
339- cells: h.cells,
340- nextRowID: h.nextRowID,
341- }
342-}
343-
344-func cloneHistoryRows(rows [][]renderer.Cell) [][]renderer.Cell {
345- if len(rows) == 0 {
346- return nil
347- }
348- copyRows := make([][]renderer.Cell, len(rows))
349- for i, row := range rows {
350- copyRows[i] = append([]renderer.Cell(nil), row...)
351- }
352- return copyRows
353-}
354-
355-// Len returns the currently retained history row count.
356-func (h *History) Len() int {
357- if h == nil {
358- return 0
359- }
360- return h.rows
361-}
362-
363-// Cells returns the currently retained history cell count.
364-func (h *History) Cells() int {
365- if h == nil {
366- return 0
367- }
368- return h.cells
369-}
370-
371-// NextRowID returns the next identity allocated by this history.
372-func (h *History) NextRowID() RowID {
373- if h == nil || h.nextRowID == 0 {
374- return 1
375- }
376- return h.nextRowID
377-}
378-
379-// Cap returns the configured bounded row capacity.
380-func (h *History) Cap() int {
381- if h == nil {
382- return 0
383- }
384- return h.maxRows
385-}
386-
387-// CellCap returns the configured bounded cell capacity.
388-func (h *History) CellCap() int {
389- if h == nil {
390- return 0
391- }
392- return h.maxCells
393-}
394-
395-func (v HistoryView) Len() int { return v.rows }
396-func (v HistoryView) Cells() int { return v.cells }
397-func (v HistoryView) ChunkCount() int { return len(v.chunks) }
398-
399-// NextRowID returns the next identity recorded by this view.
400-func (v HistoryView) NextRowID() RowID {
401- if v.nextRowID == 0 {
402- next, ok := historyViewNextRowID(v)
403- if !ok {
404- return 1
405- }
406- return next
407- }
408- return v.nextRowID
409-}
410-
411-// Chunk returns the immutable chunk at i, or nil when i is out of range.
412-func (v HistoryView) Chunk(i int) *HistoryChunk {
413- if i < 0 || i >= len(v.chunks) {
414- return nil
415- }
416- return v.chunks[i]
417-}
418-
419-// Range calls yield for each row in oldest-first order. The row is borrowed
420-// immutable storage: yield must not mutate or retain it after returning.
421-func (v HistoryView) Range(yield func([]renderer.Cell) bool) {
422- for _, chunk := range v.chunks {
423- for _, row := range chunk.rows {
424- if !yield(row) {
425- return
426- }
427- }
428- }
429-}
430-
431-// Row returns a copy of the row at i, or nil when i is out of range.
432-func (v HistoryView) Row(i int) []renderer.Cell {
433- return append([]renderer.Cell(nil), v.BorrowedRow(i)...)
434-}
435-
436-// BorrowedRow returns immutable storage for the row at i, or nil when i is out
437-// of range. The caller must not mutate or retain the result after it no longer
438-// retains v. Consumers that need ownership should use Row instead.
439-func (v HistoryView) BorrowedRow(i int) []renderer.Cell {
440- if i < 0 {
441- return nil
442- }
443- for _, chunk := range v.chunks {
444- if i < len(chunk.rows) {
445- return chunk.rows[i]
446- }
447- i -= len(chunk.rows)
448- }
449- return nil
450-}
451-
452-// RowID returns the identity of the row at i, or zero when i is out of range.
453-func (v HistoryView) RowID(i int) RowID {
454- if i < 0 {
455- return 0
456- }
457- for _, chunk := range v.chunks {
458- if i < len(chunk.rows) {
459- if i < len(chunk.rowIDs) {
460- return chunk.rowIDs[i]
461- }
462- return 0
463- }
464- i -= len(chunk.rows)
465- }
466- return 0
467-}
468-
469-// FindRowID returns the oldest-first index of id, or -1 when id is absent.
470-func (v HistoryView) FindRowID(id RowID) int {
471- if id == 0 {
472- return -1
473- }
474- index := 0
475- for _, chunk := range v.chunks {
476- for row := range chunk.rows {
477- if row < len(chunk.rowIDs) && chunk.rowIDs[row] == id {
478- return index
479- }
480- index++
481- }
482- }
483- return -1
484-}
485-
486-// RowID returns the identity of the row within an immutable chunk.
487-func (c *HistoryChunk) RowID(i int) RowID {
488- if c == nil || i < 0 || i >= len(c.rowIDs) {
489- return 0
490- }
491- return c.rowIDs[i]
492-}
493-
494-// Bound returns the logical extent of the row at i, or the zero value when i is
495-// out of range. A zero value describes a hard row whose End is not meaningful.
496-func (v HistoryView) Bound(i int) LineBound {
497- if i < 0 {
498- return LineBound{}
499- }
500- for _, chunk := range v.chunks {
501- if i < len(chunk.rows) {
502- if i < len(chunk.bounds) {
503- return chunk.bounds[i]
504- }
505- return LineBound{}
506- }
507- i -= len(chunk.rows)
508- }
509- return LineBound{}
510-}
511-
512-func (v HistorySnapshotView) Len() int { return v.rows }
513-func (v HistorySnapshotView) Cells() int { return v.cells }
514-func (v HistorySnapshotView) ChunkCount() int { return len(v.chunks) }
515-
516-// NextRowID returns the next identity recorded by this snapshot.
517-func (v HistorySnapshotView) NextRowID() RowID {
518- if v.nextRowID == 0 {
519- return v.Tail().NextRowID()
520- }
521- return v.nextRowID
522-}
523-
524-// Chunk returns a sealed immutable chunk at i, or nil when i is out of range.
525-func (v HistorySnapshotView) Chunk(i int) *HistoryChunk {
526- if i < 0 || i >= len(v.chunks) {
527- return nil
528- }
529- return v.chunks[i]
530-}
531-
532-// Tail returns an immutable view of the copied mutable tail.
533-func (v HistorySnapshotView) Tail() HistoryView {
534- if len(v.tail) == 0 {
535- return HistoryView{nextRowID: v.nextRowID}
536- }
537- return HistoryView{
538- chunks: []*HistoryChunk{{rows: v.tail, bounds: growBounds(v.tailBounds, len(v.tail)), rowIDs: growRowIDs(v.tailIDs, len(v.tail))}},
539- rows: len(v.tail),
540- cells: historyRowsCells(v.tail),
541- nextRowID: v.nextRowID,
542- }
543-}
544-
545-func historyRowsCells(rows [][]renderer.Cell) int {
546- cells := 0
547- for _, row := range rows {
548- cells += len(row)
549- }
550- return cells
551-}
D pkg/vt/history_acceptance_test.go +0 -106
@@ -1,106 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7-)
8-
9-func TestSnapshotCodecsPreserveEveryTerminalCellAcrossHistoryAndRecoveryTranscript(t *testing.T) {
10- indexed := renderer.Style{
11- Bold: true,
12- Italic: true,
13- Inverse: true,
14- Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough,
15- Foreground: 196,
16- Background: 17,
17- HasUnderlineColor: true,
18- UnderlineColor: 203,
19- }
20- rgb := renderer.Style{
21- Bold: true,
22- Italic: true,
23- Inverse: true,
24- Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough,
25- HasForegroundRGB: true,
26- ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3},
27- HasBackgroundRGB: true,
28- BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6},
29- HasUnderlineColorRGB: true,
30- UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9},
31- }
32- underlines := []renderer.UnderlineStyle{
33- renderer.UnderlineNone,
34- renderer.UnderlineSingle,
35- renderer.UnderlineDouble,
36- renderer.UnderlineCurly,
37- renderer.UnderlineDotted,
38- renderer.UnderlineDashed,
39- }
40- makeRow := func(r rune, style renderer.Style) []renderer.Cell {
41- style.UnderlineStyle = underlines[int(r)%len(underlines)]
42- return []renderer.Cell{
43- {Rune: r, Style: style},
44- {Rune: ' ', Style: renderer.DefaultStyle()},
45- {Rune: '界', Style: style},
46- {Continuation: true, Style: style},
47- }
48- }
49-
50- tests := []struct {
51- name string
52- run func(*testing.T)
53- }{
54- {
55- name: "history view retains sealed chunks and mutable tail",
56- run: func(t *testing.T) {
57- history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
58- // A through F exercise every underline style; G remains in the
59- // mutable tail so both history ownership paths are encoded.
60- want := [][]renderer.Cell{
61- makeRow('A', indexed), makeRow('B', rgb), makeRow('C', indexed),
62- makeRow('D', rgb), makeRow('E', indexed), makeRow('F', rgb), makeRow('G', indexed),
63- }
64- for _, row := range want {
65- requireHistoryAppend(t, history, row)
66- }
67- if got, wantChunks := history.View().ChunkCount(), 4; got != wantChunks {
68- t.Fatalf("chunk count = %d, want three sealed chunks plus copied tail %d", got, wantChunks)
69- }
70- encoded, err := MarshalHistory(history.View())
71- if err != nil {
72- t.Fatalf("MarshalHistory: %v", err)
73- }
74- got, err := UnmarshalHistory(encoded)
75- if err != nil {
76- t.Fatalf("UnmarshalHistory: %v", err)
77- }
78- assertHistoryRowsEqual(t, got, want)
79- },
80- },
81- {
82- name: "recovery transcript retains blanks wide heads continuations and styles",
83- run: func(t *testing.T) {
84- screen := NewScreen(4, 2)
85- want := [][]renderer.Cell{makeRow('D', rgb), makeRow('E', indexed)}
86- for y, row := range want {
87- copy(screen.Frame.Row(y), row)
88- screen.buffer.boundaries[y] = LineBound{End: len(row)}
89- }
90-
91- encoded, err := screen.RecoveryTranscriptSnapshot().Marshal()
92- if err != nil {
93- t.Fatalf("marshal recovery transcript: %v", err)
94- }
95- got, err := UnmarshalHistory(encoded)
96- if err != nil {
97- t.Fatalf("unmarshal recovery transcript: %v", err)
98- }
99- assertHistoryRowsEqual(t, got, want)
100- },
101- },
102- }
103- for _, tt := range tests {
104- t.Run(tt.name, tt.run)
105- }
106-}
D pkg/vt/history_codec.go +0 -447
@@ -1,447 +0,0 @@
1-package vt
2-
3-import (
4- "encoding/binary"
5- "errors"
6- "fmt"
7- "math"
8- "slices"
9- "unicode/utf8"
10-
11- "github.com/bnema/vev/pkg/renderer"
12-)
13-
14-const (
15- historyMagic = "VTH1"
16- historyVersion = 3
17- historyCellBytes = 41
18- historyBoundBytes = 5
19- maxHistoryChunkRows = 256
20-
21- // The daemon retains 10,000 history rows. Support a 20% row margin and
22- // 160-cell rows, which leaves headroom above the representative 10k×120
23- // terminal while bounding snapshot allocation.
24- maxHistoryChunks = 12_000
25- maxHistoryRows = 12_000
26- maxHistoryRowCells = 160
27- maxHistoryCells = maxHistoryRows * maxHistoryRowCells
28-
29- maxHistoryDecodeStyles = maxHistoryCells
30- maxHistoryDecodedBytes = maxHistoryCells * historyCellBytes
31-)
32-
33-var errInvalidHistory = errors.New("invalid history payload")
34-
35-// MarshalHistory encodes a HistoryView in a deterministic, self-contained
36-// format. It preserves chunk boundaries as well as every Cell and Style field.
37-func MarshalHistory(view HistoryView) ([]byte, error) {
38- if view.rows < 0 || view.rows != historyViewRowCount(view) || uint64(len(view.chunks)) > math.MaxUint32 || uint64(len(view.chunks)) > maxHistoryChunks {
39- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
40- }
41- nextRowID, ok := historyViewNextRowID(view)
42- if !ok {
43- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
44- }
45- stats := historyDecodeStats{}
46- seen := make(map[RowID]struct{}, view.rows)
47- for _, chunk := range view.chunks {
48- if chunk == nil || len(chunk.rows) == 0 || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) > maxHistoryChunkRows ||
49- !addHistoryDecodeBudget(&stats.rows, uint64(len(chunk.rows)), maxHistoryRows) {
50- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
51- }
52- for i, row := range chunk.rows {
53- if uint64(len(row)) > math.MaxUint32 {
54- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
55- }
56- id := chunk.rowIDs[i]
57- if id == 0 || id >= ^RowID(0)-1 {
58- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
59- }
60- if _, duplicate := seen[id]; duplicate {
61- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
62- }
63- seen[id] = struct{}{}
64- cellCount := uint64(len(row))
65- cellBytes, ok := historyCellByteCount(cellCount)
66- if !ok ||
67- !addHistoryDecodeBudget(&stats.cells, cellCount, maxHistoryCells) ||
68- !addHistoryDecodeBudget(&stats.styles, cellCount, maxHistoryDecodeStyles) ||
69- !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
70- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
71- }
72- bound := chunkBound(chunk, i)
73- if !validHistoryBound(bound, len(row)) {
74- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
75- }
76- for _, cell := range row {
77- if !validHistoryCell(cell) {
78- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
79- }
80- }
81- }
82- }
83- if nextRowID <= maxHistoryRowID(seen) {
84- return nil, fmt.Errorf("marshal history: %w", errInvalidHistory)
85- }
86-
87- out := make([]byte, 0, 17)
88- out = append(out, historyMagic...)
89- out = append(out, historyVersion)
90- out = appendUint32(out, uint32(len(view.chunks)))
91- out = binary.BigEndian.AppendUint64(out, uint64(nextRowID))
92- for _, chunk := range view.chunks {
93- out = appendUint32(out, uint32(len(chunk.rows)))
94- for i, row := range chunk.rows {
95- out = binary.BigEndian.AppendUint64(out, uint64(chunk.rowIDs[i]))
96- out = appendUint32(out, uint32(len(row)))
97- for _, cell := range row {
98- out = appendHistoryCell(out, cell)
99- }
100- out = appendHistoryBound(out, chunkBound(chunk, i))
101- }
102- }
103- return out, nil
104-}
105-
106-func historyViewNextRowID(view HistoryView) (RowID, bool) {
107- seen := make(map[RowID]struct{}, view.rows)
108- for _, chunk := range view.chunks {
109- if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) {
110- return 0, false
111- }
112- for _, id := range chunk.rowIDs {
113- if id == 0 || id >= ^RowID(0)-1 {
114- return 0, false
115- }
116- if _, duplicate := seen[id]; duplicate {
117- return 0, false
118- }
119- seen[id] = struct{}{}
120- }
121- }
122- next := view.nextRowID
123- if next == 0 {
124- maxID := maxHistoryRowID(seen)
125- if maxID == ^RowID(0) {
126- return 0, false
127- }
128- next = maxID + 1
129- }
130- if next == 0 || next == ^RowID(0) || next <= maxHistoryRowID(seen) {
131- return 0, false
132- }
133- return next, true
134-}
135-
136-func maxHistoryRowID(ids map[RowID]struct{}) RowID {
137- var maxID RowID
138- for id := range ids {
139- if id > maxID {
140- maxID = id
141- }
142- }
143- return maxID
144-}
145-
146-func historyViewRowCount(view HistoryView) int {
147- n := 0
148- for _, chunk := range view.chunks {
149- if chunk != nil && len(chunk.rows) <= math.MaxInt-n {
150- n += len(chunk.rows)
151- continue
152- }
153- return -1
154- }
155- return n
156-}
157-
158-func appendUint32(dst []byte, v uint32) []byte {
159- return binary.BigEndian.AppendUint32(dst, v)
160-}
161-
162-func appendHistoryCell(dst []byte, cell renderer.Cell) []byte {
163- style := cell.Style
164- dst = binary.BigEndian.AppendUint32(dst, uint32(cell.Rune))
165- flags := byte(0)
166- if cell.Continuation {
167- flags |= 1 << 0
168- }
169- if style.Bold {
170- flags |= 1 << 1
171- }
172- if style.Italic {
173- flags |= 1 << 2
174- }
175- if style.Inverse {
176- flags |= 1 << 3
177- }
178- if style.HasForegroundRGB {
179- flags |= 1 << 4
180- }
181- if style.HasBackgroundRGB {
182- flags |= 1 << 5
183- }
184- if style.HasUnderlineColor {
185- flags |= 1 << 6
186- }
187- if style.HasUnderlineColorRGB {
188- flags |= 1 << 7
189- }
190- dst = append(dst, flags)
191- dst = binary.BigEndian.AppendUint16(dst, uint16(style.Attrs))
192- dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Foreground)))
193- dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.Background)))
194- dst = append(dst, style.ForegroundRGB.R, style.ForegroundRGB.G, style.ForegroundRGB.B)
195- dst = append(dst, style.BackgroundRGB.R, style.BackgroundRGB.G, style.BackgroundRGB.B)
196- dst = append(dst, byte(style.UnderlineStyle))
197- dst = binary.BigEndian.AppendUint64(dst, uint64(int64(style.UnderlineColor)))
198- return append(dst, style.UnderlineColorRGB.R, style.UnderlineColorRGB.G, style.UnderlineColorRGB.B)
199-}
200-
201-func chunkBound(chunk *HistoryChunk, i int) LineBound {
202- if i < 0 || i >= len(chunk.bounds) {
203- return LineBound{}
204- }
205- return chunk.bounds[i]
206-}
207-
208-func appendHistoryBound(dst []byte, b LineBound) []byte {
209- dst = binary.BigEndian.AppendUint32(dst, uint32(b.End))
210- if b.Soft {
211- return append(dst, 1)
212- }
213- return append(dst, 0)
214-}
215-
216-// validHistoryBound rejects extents that cannot describe their own row.
217-func validHistoryBound(b LineBound, cells int) bool {
218- return b.End >= 0 && b.End <= cells
219-}
220-
221-// UnmarshalHistory strictly decodes a MarshalHistory payload. It rejects
222-// malformed declarations, cells, truncated data, and trailing bytes.
223-func UnmarshalHistory(data []byte) (HistoryView, error) {
224- view, _, ok := parseHistory(data, true)
225- if !ok {
226- return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
227- }
228- return view, nil
229-}
230-
231-type historyDecodeStats struct {
232- chunks uint64
233- rows uint64
234- cells uint64
235- styles uint64
236- bytes uint64
237-}
238-
239-// preflightHistory validates a history blob without allocating decoded rows.
240-// It shares parseHistory with UnmarshalHistory so both paths enforce the same
241-// structural, cell, and aggregate-budget constraints.
242-func preflightHistory(data []byte) (historyDecodeStats, bool) {
243- _, stats, ok := parseHistory(data, false)
244- return stats, ok
245-}
246-
247-// parseHistory is the authoritative history payload validation path. populate
248-// controls whether validated cells are retained in a HistoryView.
249-func parseHistory(data []byte, populate bool) (HistoryView, historyDecodeStats, bool) {
250- if len(data) < 17 || string(data[:4]) != historyMagic || data[4] != historyVersion {
251- return HistoryView{}, historyDecodeStats{}, false
252- }
253-
254- p := historyParser{data: data[5:]}
255- chunkCount, ok := p.uint32()
256- if !ok || uint64(chunkCount) > maxHistoryChunks || uint64(chunkCount) > uint64(len(p.data))/4 {
257- return HistoryView{}, historyDecodeStats{}, false
258- }
259- nextRowID, ok := p.uint64()
260- if !ok || nextRowID == 0 || nextRowID == uint64(^RowID(0)) {
261- return HistoryView{}, historyDecodeStats{}, false
262- }
263- stats := historyDecodeStats{chunks: uint64(chunkCount)}
264- var seenIDs [maxHistoryRows]RowID
265- seenCount := 0
266- var maxID RowID
267- var chunks []*HistoryChunk
268- if populate {
269- chunks = make([]*HistoryChunk, 0, chunkCount)
270- }
271- for range chunkCount {
272- rowCount, ok := p.uint32()
273- if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows ||
274- uint64(rowCount) > uint64(len(p.data))/(8+4+historyBoundBytes) ||
275- !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryRows) {
276- return HistoryView{}, historyDecodeStats{}, false
277- }
278-
279- var rows [][]renderer.Cell
280- var bounds []LineBound
281- var rowIDs []RowID
282- if populate {
283- rows = make([][]renderer.Cell, 0, rowCount)
284- bounds = make([]LineBound, 0, rowCount)
285- rowIDs = make([]RowID, 0, rowCount)
286- }
287- for range rowCount {
288- id, ok := p.uint64()
289- if !ok || id == 0 || id >= uint64(^RowID(0)-1) {
290- return HistoryView{}, historyDecodeStats{}, false
291- }
292- rowID := RowID(id)
293- seenIDs[seenCount] = rowID
294- seenCount++
295- if rowID > maxID {
296- maxID = rowID
297- }
298- cellCount, ok := p.uint32()
299- cellBytes, validCellBytes := historyCellByteCount(uint64(cellCount))
300- if !ok || !validCellBytes ||
301- !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryCells) ||
302- !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||
303- !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) ||
304- uint64(cellCount) > uint64(len(p.data))/historyCellBytes {
305- return HistoryView{}, historyDecodeStats{}, false
306- }
307-
308- var row []renderer.Cell
309- if populate {
310- row = make([]renderer.Cell, cellCount)
311- }
312- for i := range cellCount {
313- cell, ok := p.cell()
314- if !ok || !validHistoryCell(cell) {
315- return HistoryView{}, historyDecodeStats{}, false
316- }
317- if populate {
318- row[i] = cell
319- }
320- }
321- bound, ok := p.bound()
322- if !ok || !validHistoryBound(bound, int(cellCount)) {
323- return HistoryView{}, historyDecodeStats{}, false
324- }
325- if populate {
326- rows = append(rows, row)
327- bounds = append(bounds, bound)
328- rowIDs = append(rowIDs, rowID)
329- }
330- }
331- if populate {
332- chunks = append(chunks, &HistoryChunk{rows: rows, bounds: bounds, rowIDs: rowIDs})
333- }
334- }
335- slices.Sort(seenIDs[:seenCount])
336- for i := 1; i < seenCount; i++ {
337- if seenIDs[i] == seenIDs[i-1] {
338- return HistoryView{}, historyDecodeStats{}, false
339- }
340- }
341- if len(p.data) != 0 || RowID(nextRowID) <= maxID {
342- return HistoryView{}, historyDecodeStats{}, false
343- }
344- return HistoryView{chunks: chunks, rows: int(stats.rows), cells: int(stats.cells), nextRowID: RowID(nextRowID)}, stats, true
345-}
346-
347-func historyCellByteCount(cellCount uint64) (uint64, bool) {
348- if cellCount > math.MaxUint64/historyCellBytes {
349- return 0, false
350- }
351- return cellCount * historyCellBytes, true
352-}
353-
354-func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool {
355- if *total > limit || amount > limit-*total {
356- return false
357- }
358- *total += amount
359- return true
360-}
361-
362-type historyParser struct{ data []byte }
363-
364-func (p *historyParser) uint32() (uint32, bool) {
365- if len(p.data) < 4 {
366- return 0, false
367- }
368- v := binary.BigEndian.Uint32(p.data)
369- p.data = p.data[4:]
370- return v, true
371-}
372-
373-func (p *historyParser) uint64() (uint64, bool) {
374- if len(p.data) < 8 {
375- return 0, false
376- }
377- v := binary.BigEndian.Uint64(p.data)
378- p.data = p.data[8:]
379- return v, true
380-}
381-
382-func (p *historyParser) cell() (renderer.Cell, bool) {
383- if len(p.data) < historyCellBytes {
384- return renderer.Cell{}, false
385- }
386- b := p.data[:historyCellBytes]
387- p.data = p.data[historyCellBytes:]
388- foreground, ok := historyInt(binary.BigEndian.Uint64(b[7:15]))
389- if !ok {
390- return renderer.Cell{}, false
391- }
392- background, ok := historyInt(binary.BigEndian.Uint64(b[15:23]))
393- if !ok {
394- return renderer.Cell{}, false
395- }
396- underlineColor, ok := historyInt(binary.BigEndian.Uint64(b[30:38]))
397- if !ok {
398- return renderer.Cell{}, false
399- }
400- flags := b[4]
401- return renderer.Cell{
402- Rune: rune(binary.BigEndian.Uint32(b[0:4])),
403- Continuation: flags&1 != 0,
404- Style: renderer.Style{
405- Bold: flags&(1<<1) != 0,
406- Italic: flags&(1<<2) != 0,
407- Inverse: flags&(1<<3) != 0,
408- Attrs: renderer.StyleAttrs(binary.BigEndian.Uint16(b[5:7])),
409- Foreground: foreground,
410- Background: background,
411- HasForegroundRGB: flags&(1<<4) != 0,
412- ForegroundRGB: renderer.RGB{R: b[23], G: b[24], B: b[25]},
413- HasBackgroundRGB: flags&(1<<5) != 0,
414- BackgroundRGB: renderer.RGB{R: b[26], G: b[27], B: b[28]},
415- UnderlineStyle: renderer.UnderlineStyle(b[29]),
416- HasUnderlineColor: flags&(1<<6) != 0,
417- UnderlineColor: underlineColor,
418- HasUnderlineColorRGB: flags&(1<<7) != 0,
419- UnderlineColorRGB: renderer.RGB{R: b[38], G: b[39], B: b[40]},
420- },
421- }, true
422-}
423-
424-func (p *historyParser) bound() (LineBound, bool) {
425- if len(p.data) < historyBoundBytes {
426- return LineBound{}, false
427- }
428- end := binary.BigEndian.Uint32(p.data[:4])
429- flag := p.data[4]
430- p.data = p.data[historyBoundBytes:]
431- if flag > 1 || uint64(end) > math.MaxInt32 {
432- return LineBound{}, false
433- }
434- return LineBound{End: int(end), Soft: flag == 1}, true
435-}
436-
437-func historyInt(raw uint64) (int, bool) {
438- v := int64(raw)
439- if int64(int(v)) != v {
440- return 0, false
441- }
442- return int(v), true
443-}
444-
445-func validHistoryCell(cell renderer.Cell) bool {
446- return utf8.ValidRune(cell.Rune) && cell.Style.UnderlineStyle <= renderer.UnderlineDashed
447-}
D pkg/vt/history_codec_bound_test.go +0 -50
@@ -1,50 +0,0 @@
1-package vt
2-
3-import (
4- "encoding/binary"
5- "testing"
6-
7- "github.com/bnema/vev/pkg/renderer"
8- "github.com/stretchr/testify/require"
9-)
10-
11-func TestHistoryCodecRejectsMalformedBounds(t *testing.T) {
12- blob, err := MarshalHistory(HistoryView{
13- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("abcd")}, bounds: []LineBound{{End: 4, Soft: true}}, rowIDs: []RowID{1}}},
14- rows: 1,
15- cells: 4,
16- nextRowID: 2,
17- })
18- require.NoError(t, err)
19- boundOffset := len(blob) - historyBoundBytes
20-
21- tests := []struct {
22- name string
23- mutate func([]byte)
24- }{
25- {
26- name: "end exceeds row width",
27- mutate: func(data []byte) {
28- binary.BigEndian.PutUint32(data[boundOffset:boundOffset+4], 5)
29- },
30- },
31- {
32- name: "soft flag is not canonical",
33- mutate: func(data []byte) {
34- data[boundOffset+4] = 2
35- },
36- },
37- }
38-
39- for _, tt := range tests {
40- t.Run(tt.name, func(t *testing.T) {
41- data := append([]byte(nil), blob...)
42- tt.mutate(data)
43-
44- _, preflightErr := PreflightHistoryBlob(data)
45- require.Error(t, preflightErr)
46- _, unmarshalErr := UnmarshalHistory(data)
47- require.Error(t, unmarshalErr)
48- })
49- }
50-}
D pkg/vt/history_codec_test.go +0 -432
@@ -1,432 +0,0 @@
1-package vt
2-
3-import (
4- "bytes"
5- "encoding/binary"
6- "fmt"
7- "math"
8- "testing"
9-
10- "github.com/bnema/vev/pkg/renderer"
11-)
12-
13-func TestHistoryCodecV3Bytes(t *testing.T) {
14- h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
15- if err := h.Append([]renderer.Cell{{Rune: 'a'}}, LineBound{End: 1, Soft: true}); err != nil {
16- t.Fatalf("append: %v", err)
17- }
18- got, err := MarshalHistory(h.SealAndView())
19- if err != nil {
20- t.Fatalf("marshal: %v", err)
21- }
22-
23- want := []byte{
24- 'V', 'T', 'H', '1', 3, // magic, version
25- 0, 0, 0, 1, // chunk count
26- 0, 0, 0, 0, 0, 0, 0, 2, // next row ID
27- 0, 0, 0, 1, // row count
28- 0, 0, 0, 0, 0, 0, 0, 1, // row ID
29- 0, 0, 0, 1, // cell count
30- 0, 0, 0, 'a', // Rune
31- 0, // flags
32- 0, 0, // Attrs
33- 0, 0, 0, 0, 0, 0, 0, 0, // Foreground
34- 0, 0, 0, 0, 0, 0, 0, 0, // Background
35- 0, 0, 0, // ForegroundRGB
36- 0, 0, 0, // BackgroundRGB
37- 0, // UnderlineStyle
38- 0, 0, 0, 0, 0, 0, 0, 0, // UnderlineColor
39- 0, 0, 0, // UnderlineColorRGB — 41 cell bytes through here
40- 0, 0, 0, 1, 1, // bound: End=1, Soft=true
41- }
42- if !bytes.Equal(got, want) {
43- t.Fatalf("MarshalHistory() = % x, want % x", got, want)
44- }
45-}
46-
47-func TestHistoryCodecRoundTripsBounds(t *testing.T) {
48- h := NewHistory(HistoryConfig{MaxRows: 8, MaxCells: 1024, ChunkRows: 2})
49- bounds := []LineBound{{End: 2, Soft: true}, {End: 1}, {End: 2, Soft: true}}
50- for _, b := range bounds {
51- if err := h.Append([]renderer.Cell{{Rune: 'a'}, {Rune: 'b'}}, b); err != nil {
52- t.Fatalf("append: %v", err)
53- }
54- }
55- blob, err := MarshalHistory(h.SealAndView())
56- if err != nil {
57- t.Fatalf("marshal: %v", err)
58- }
59- view, err := UnmarshalHistory(blob)
60- if err != nil {
61- t.Fatalf("unmarshal: %v", err)
62- }
63- for i, want := range bounds {
64- if got := view.Bound(i); got != want {
65- t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
66- }
67- }
68-}
69-
70-func TestHistoryCodecRejectsLegacyPayloads(t *testing.T) {
71- // The previous layouts are deliberately incompatible with stable IDs.
72- legacy := []byte{
73- 'V', 'T', 'H', '1', 2,
74- 0, 0, 0, 1, 0, 0, 0, 1,
75- 0, 0, 0, 1, 0, 0, 0, 'a',
76- 0, 0, 0,
77- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
78- 0, 0, 0, 0, 0, 0,
79- 0,
80- 0, 0, 0, 0, 0, 0, 0, 0,
81- 0, 0, 0,
82- 0, 0, 0, 1, 1,
83- }
84- for _, version := range []byte{1, 2} {
85- data := append([]byte(nil), legacy...)
86- data[4] = version
87- if _, err := UnmarshalHistory(data); err == nil {
88- t.Fatalf("accepted legacy history version %d", version)
89- }
90- }
91-}
92-
93-func TestChunkCodecRoundTripsLosslessCellsAndStyles(t *testing.T) {
94- indexed := renderer.Style{
95- Bold: true,
96- Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrStrikethrough,
97- Foreground: 123,
98- Background: 45,
99- UnderlineStyle: renderer.UnderlineDashed,
100- HasUnderlineColor: true,
101- UnderlineColor: 201,
102- }
103- rgb := renderer.Style{
104- Italic: true,
105- Inverse: true,
106- Attrs: renderer.AttrBlink,
107- HasForegroundRGB: true,
108- ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3},
109- HasBackgroundRGB: true,
110- BackgroundRGB: renderer.RGB{R: 4, G: 5, B: 6},
111- UnderlineStyle: renderer.UnderlineCurly,
112- HasUnderlineColorRGB: true,
113- UnderlineColorRGB: renderer.RGB{R: 7, G: 8, B: 9},
114- }
115- tests := []struct {
116- name string
117- rows [][]renderer.Cell
118- }{
119- {
120- name: "indexed RGB blank and wide continuation cells are exact",
121- rows: [][]renderer.Cell{
122- {
123- {Rune: 'I', Style: indexed},
124- {Rune: 'R', Style: rgb},
125- {Rune: ' ', Style: renderer.DefaultStyle()},
126- {Rune: '界', Style: rgb},
127- {Continuation: true, Style: rgb},
128- },
129- {
130- {Rune: ' ', Style: renderer.DefaultStyle()},
131- {Rune: 'X', Style: indexed},
132- },
133- },
134- },
135- }
136-
137- for _, tt := range tests {
138- t.Run(tt.name, func(t *testing.T) {
139- history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
140- for _, row := range tt.rows {
141- requireHistoryAppend(t, history, row)
142- }
143-
144- encoded, err := MarshalHistory(history.View())
145- if err != nil {
146- t.Fatalf("marshal history: %v", err)
147- }
148- decoded, err := UnmarshalHistory(encoded)
149- if err != nil {
150- t.Fatalf("unmarshal history: %v", err)
151- }
152- assertHistoryRowsEqual(t, decoded, tt.rows)
153- })
154- }
155-}
156-
157-func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
158- history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 2})
159- requireHistoryAppend(t, history, historyRow("AAAA"))
160- requireHistoryAppend(t, history, historyRow("BBBB"))
161- encoded, err := MarshalHistory(history.View())
162- if err != nil {
163- t.Fatalf("marshal history: %v", err)
164- }
165-
166- for i := range len(encoded) {
167- if _, err := UnmarshalHistory(encoded[:i]); err == nil {
168- t.Fatalf("accepted truncated version 3 prefix of length %d", i)
169- }
170- }
171- if _, err := UnmarshalHistory(append(append([]byte(nil), encoded...), 0)); err == nil {
172- t.Fatal("accepted trailing garbage after version 3 payload")
173- }
174-}
175-
176-func TestChunkCodecSupportsRepresentativeHistory(t *testing.T) {
177- const (
178- rows = 10_000
179- width = 120
180- )
181-
182- encoded, err := MarshalHistory(historyViewWithDimensions(rows, width))
183- if err != nil {
184- t.Fatalf("marshal representative history: %v", err)
185- }
186- decoded, err := UnmarshalHistory(encoded)
187- if err != nil {
188- t.Fatalf("unmarshal representative history: %v", err)
189- }
190- if got := decoded.Len(); got != rows {
191- t.Fatalf("decoded rows = %d, want %d", got, rows)
192- }
193- for _, rowIndex := range []int{0, rows - 1} {
194- if got := len(decoded.Row(rowIndex)); got != width {
195- t.Fatalf("decoded row %d width = %d, want %d", rowIndex, got, width)
196- }
197- }
198-}
199-
200-func TestChunkCodecRejectsDimensionsBeyondSupportedLimits(t *testing.T) {
201- const supportedRows = 12_000
202- tests := []struct {
203- name string
204- view HistoryView
205- data []byte
206- }{
207- {
208- name: "too many rows",
209- view: historyViewWithDimensions(supportedRows+1, 0),
210- data: historyPayloadWithDimensions(supportedRows+1, 0),
211- },
212- }
213-
214- for _, tt := range tests {
215- t.Run(tt.name, func(t *testing.T) {
216- if _, err := MarshalHistory(tt.view); err == nil {
217- t.Fatal("marshal accepted dimensions beyond the supported limits")
218- }
219- if _, err := UnmarshalHistory(tt.data); err == nil {
220- t.Fatal("unmarshal accepted dimensions beyond the supported limits")
221- }
222- })
223- }
224-}
225-
226-func TestChunkCodecRejectsAggregateCellDeclarationBeforePayloadAllocation(t *testing.T) {
227- data := aggregateCellLimitDeclaration()
228- if len(data) >= 1024 {
229- t.Fatalf("aggregate declaration length = %d, want compact fixture", len(data))
230- }
231- if _, err := PreflightHistoryBlob(data); err == nil {
232- t.Fatal("preflight accepted aggregate cell declaration")
233- }
234- if _, err := UnmarshalHistory(data); err == nil {
235- t.Fatal("unmarshal accepted aggregate cell declaration")
236- }
237-}
238-
239-func TestChunkCodecAcceptsWideRowsWithinAggregateBudget(t *testing.T) {
240- for _, width := range []int{161, 293, math.MaxUint16} {
241- t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
242- view := historyViewWithDimensions(1, width)
243- encoded, err := MarshalHistory(view)
244- if err != nil {
245- t.Fatalf("marshal width %d: %v", width, err)
246- }
247- decoded, err := UnmarshalHistory(encoded)
248- if err != nil {
249- t.Fatalf("unmarshal width %d: %v", width, err)
250- }
251- if got := len(decoded.Row(0)); got != width {
252- t.Fatalf("decoded width = %d, want %d", got, width)
253- }
254- if stats, err := PreflightHistoryBlob(encoded); err != nil || stats.Cells != uint64(width) {
255- t.Fatalf("preflight stats = %+v, err = %v", stats, err)
256- }
257- })
258- }
259-}
260-
261-func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) {
262- tests := []struct {
263- name string
264- chunkCount int
265- rowCount int
266- }{
267- {name: "more than twelve thousand chunks", chunkCount: 12_001, rowCount: 1},
268- {name: "more than twelve thousand rows", chunkCount: 47, rowCount: maxHistoryChunkRows},
269- }
270-
271- for _, tt := range tests {
272- t.Run(tt.name, func(t *testing.T) {
273- if _, err := UnmarshalHistory(hostileHistoryDeclarations(tt.chunkCount, tt.rowCount)); err == nil {
274- t.Fatal("unmarshal accepted resource-exhausting history declarations")
275- }
276- })
277- }
278-}
279-
280-func historyViewWithDimensions(rowCount, width int) HistoryView {
281- totalRows := rowCount
282- row := make([]renderer.Cell, width)
283- for i := range row {
284- row[i].Rune = 'x'
285- }
286- chunks := make([]*HistoryChunk, 0, (rowCount+maxHistoryChunkRows-1)/maxHistoryChunkRows)
287- rowID := RowID(1)
288- for rowCount > 0 {
289- chunkRows := min(rowCount, maxHistoryChunkRows)
290- rows := make([][]renderer.Cell, chunkRows)
291- rowIDs := make([]RowID, chunkRows)
292- for i := range rows {
293- rows[i] = row
294- rowIDs[i] = rowID
295- rowID++
296- }
297- chunks = append(chunks, &HistoryChunk{rows: rows, rowIDs: rowIDs})
298- rowCount -= chunkRows
299- }
300- return HistoryView{chunks: chunks, rows: totalRows, nextRowID: rowID}
301-}
302-
303-func historyPayloadWithDimensions(rowCount, width int) []byte {
304- data := make([]byte, 17)
305- copy(data, historyMagic)
306- data[4] = historyVersion
307- chunkCount := (rowCount + maxHistoryChunkRows - 1) / maxHistoryChunkRows
308- binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
309- binary.BigEndian.PutUint64(data[9:], uint64(rowCount+1))
310- rowID := uint64(1)
311- for rowCount > 0 {
312- chunkRows := min(rowCount, maxHistoryChunkRows)
313- data = binary.BigEndian.AppendUint32(data, uint32(chunkRows))
314- for range chunkRows {
315- data = binary.BigEndian.AppendUint64(data, rowID)
316- rowID++
317- data = binary.BigEndian.AppendUint32(data, uint32(width))
318- //nolint:makezero // The header prefix is retained while zeroed cell records are appended.
319- data = append(data, make([]byte, width*historyCellBytes)...)
320- data = binary.BigEndian.AppendUint32(data, uint32(width))
321- data = append(data, 0) //nolint:makezero // The header prefix is retained while the bound is appended.
322- }
323- rowCount -= chunkRows
324- }
325- return data
326-}
327-
328-// aggregateCellLimitDeclaration declares one row, which is within the row
329-// budget, but its cells exceed the aggregate budget. It deliberately omits
330-// cell payload bytes: the aggregate limit must reject it before allocation.
331-func aggregateCellLimitDeclaration() []byte {
332- data := make([]byte, 17, 29)
333- copy(data, historyMagic)
334- data[4] = historyVersion
335- binary.BigEndian.PutUint32(data[5:], 1)
336- binary.BigEndian.PutUint64(data[9:], 2)
337- data = binary.BigEndian.AppendUint32(data, 1)
338- data = binary.BigEndian.AppendUint64(data, 1)
339- return binary.BigEndian.AppendUint32(data, uint32(maxHistoryCells+1))
340-}
341-
342-func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {
343- data := make([]byte, 17, 17+chunkCount*(4+rowCount*12))
344- copy(data, historyMagic)
345- data[4] = historyVersion
346- binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
347- binary.BigEndian.PutUint64(data[9:], uint64(chunkCount*rowCount+1))
348- id := uint64(1)
349- for range chunkCount {
350- data = binary.BigEndian.AppendUint32(data, uint32(rowCount))
351- for range rowCount {
352- data = binary.BigEndian.AppendUint64(data, id)
353- id++
354- data = binary.BigEndian.AppendUint32(data, 0)
355- }
356- }
357- return data
358-}
359-
360-func TestHistoryPreflightMatchesUnmarshalMalformedInput(t *testing.T) {
361- valid, err := MarshalHistory(historyViewWithDimensions(1, 1))
362- if err != nil {
363- t.Fatalf("marshal valid history: %v", err)
364- }
365- invalidRune := append([]byte(nil), valid...)
366- binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0))
367- invalidUnderlineStyle := append([]byte(nil), valid...)
368- invalidUnderlineStyle[33+29] = byte(renderer.UnderlineDashed + 1)
369-
370- tests := []struct {
371- name string
372- data []byte
373- valid bool
374- }{
375- {name: "valid", data: valid, valid: true},
376- {name: "truncated cell", data: valid[:len(valid)-1]},
377- {name: "trailing byte", data: append(append([]byte(nil), valid...), 0)},
378- {name: "invalid rune", data: invalidRune},
379- {name: "invalid underline style", data: invalidUnderlineStyle},
380- {name: "aggregate row budget", data: hostileHistoryDeclarations(47, maxHistoryChunkRows)},
381- {name: "aggregate cell budget", data: aggregateCellLimitDeclaration()},
382- {name: "zero row count", data: []byte{'V', 'T', 'H', '1', historyVersion, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0}},
383- }
384- for _, tt := range tests {
385- t.Run(tt.name, func(t *testing.T) {
386- _, preflightErr := PreflightHistoryBlob(tt.data)
387- _, unmarshalErr := UnmarshalHistory(tt.data)
388- if (preflightErr == nil) != tt.valid || (unmarshalErr == nil) != tt.valid {
389- t.Fatalf("preflight error = %v, unmarshal error = %v, want valid = %t", preflightErr, unmarshalErr, tt.valid)
390- }
391- })
392- }
393-}
394-
395-func FuzzHistoryPreflightMatchesUnmarshal(f *testing.F) {
396- valid, err := MarshalHistory(historyViewWithDimensions(1, 1))
397- if err != nil {
398- f.Fatalf("marshal valid history: %v", err)
399- }
400- invalidRune := append([]byte(nil), valid...)
401- binary.BigEndian.PutUint32(invalidRune[33:37], ^uint32(0))
402- f.Add(valid)
403- f.Add(invalidRune)
404- f.Add([]byte("VTH1\x01\x00\x00\x00\x00"))
405- f.Add([]byte("not a history payload"))
406-
407- f.Fuzz(func(t *testing.T, data []byte) {
408- _, preflightErr := PreflightHistoryBlob(data)
409- _, unmarshalErr := UnmarshalHistory(data)
410- if (preflightErr == nil) != (unmarshalErr == nil) {
411- t.Fatalf("preflight error = %v, unmarshal error = %v", preflightErr, unmarshalErr)
412- }
413- })
414-}
415-
416-func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) {
417- t.Helper()
418- if got := view.Len(); got != len(want) {
419- t.Fatalf("row count = %d, want %d", got, len(want))
420- }
421- for y, wantRow := range want {
422- gotRow := view.Row(y)
423- if len(gotRow) != len(wantRow) {
424- t.Fatalf("row %d width = %d, want %d", y, len(gotRow), len(wantRow))
425- }
426- for x, wantCell := range wantRow {
427- if !gotRow[x].Equal(wantCell) {
428- t.Fatalf("cell (%d,%d) = %#v, want %#v", x, y, gotRow[x], wantCell)
429- }
430- }
431- }
432-}
D pkg/vt/history_final_acceptance_test.go +0 -57
@@ -1,57 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7- "github.com/stretchr/testify/require"
8-)
9-
10-func TestHistoryAcceptanceKeepsBookmarksAcrossLiveScrollEvictionAndRestore(t *testing.T) {
11- history := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 32, ChunkRows: 2})
12- for _, text := range []string{"live-1", "live-2", "live-3"} {
13- require.NoError(t, history.Append(historyAcceptanceRow(text), LineBound{End: len(text)}))
14- }
15-
16- beforeScroll := history.View()
17- bookmark := beforeScroll.RowID(1)
18- immutable := beforeScroll.Row(1)
19- immutable[0].Rune = 'X'
20- require.Equal(t, 'l', beforeScroll.Row(1)[0].Rune, "immutable history view exposed mutable cells")
21-
22- require.NoError(t, history.Append(historyAcceptanceRow("live-4"), LineBound{End: 6}))
23- afterScroll := history.View()
24- require.Equal(t, 3, afterScroll.Len())
25- require.Equal(t, 0, afterScroll.FindRowID(bookmark), "bookmark row was not retained after one live scroll")
26- require.Equal(t, "live-2", historyAcceptanceText(afterScroll.Row(0)))
27- require.Equal(t, "live-1", historyAcceptanceText(beforeScroll.Row(0)), "retained view changed after eviction")
28-
29- sealed, tail, err := MarshalSealedHistory(history.SealAndView())
30- require.NoError(t, err)
31- restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 3, MaxCells: 32, ChunkRows: 2}, sealed, tail)
32- require.NoError(t, err)
33- restoredView := restored.View()
34- require.Equal(t, afterScroll.Len(), restoredView.Len())
35- require.Equal(t, bookmark, restoredView.RowID(0), "bookmark row identity changed during restore")
36- require.Equal(t, "live-2", historyAcceptanceText(restoredView.Row(0)))
37-
38- next := restoredView.NextRowID()
39- require.NoError(t, restored.Append(historyAcceptanceRow("live-5"), LineBound{End: 6}))
40- require.Greater(t, restored.View().NextRowID(), next)
41-}
42-
43-func historyAcceptanceRow(text string) []renderer.Cell {
44- row := make([]renderer.Cell, len(text))
45- for i, r := range text {
46- row[i] = renderer.Cell{Rune: r}
47- }
48- return row
49-}
50-
51-func historyAcceptanceText(row []renderer.Cell) string {
52- text := make([]rune, len(row))
53- for i, cell := range row {
54- text[i] = cell.Rune
55- }
56- return string(text)
57-}
D pkg/vt/history_test.go +0 -383
@@ -1,383 +0,0 @@
1-package vt
2-
3-import (
4- "errors"
5- "math"
6- "testing"
7-
8- "github.com/bnema/vev/pkg/renderer"
9-)
10-
11-func requireHistoryAppend(t testing.TB, history *History, row []renderer.Cell) {
12- t.Helper()
13- if err := history.Append(row, LineBound{End: len(row)}); err != nil {
14- t.Fatalf("append history row: %v", err)
15- }
16-}
17-
18-func TestHistoryOrdersSealedChunksAndEvictsAtCapacity(t *testing.T) {
19- tests := []struct {
20- name string
21- maxRows int
22- chunkRows int
23- rows []string
24- want []string
25- }{
26- {
27- name: "oldest chunks are evicted before newer chunks",
28- maxRows: 4,
29- chunkRows: 2,
30- rows: []string{"aaaa", "bbbb", "cccc", "dddd", "eeee", "ffff"},
31- want: []string{"cccc", "dddd", "eeee", "ffff"},
32- },
33- {
34- name: "nonmultiple capacity evicts exactly the oldest row",
35- maxRows: 5,
36- chunkRows: 2,
37- rows: []string{"0000", "1111", "2222", "3333", "4444", "5555"},
38- want: []string{"1111", "2222", "3333", "4444", "5555"},
39- },
40- {
41- name: "rows preserve append order across chunk boundaries",
42- maxRows: 4,
43- chunkRows: 2,
44- rows: []string{"0000", "1111", "2222", "3333"},
45- want: []string{"0000", "1111", "2222", "3333"},
46- },
47- }
48-
49- for _, tt := range tests {
50- t.Run(tt.name, func(t *testing.T) {
51- history := NewHistory(HistoryConfig{MaxRows: tt.maxRows, ChunkRows: tt.chunkRows})
52- for _, text := range tt.rows {
53- requireHistoryAppend(t, history, historyRow(text))
54- }
55-
56- view := history.View()
57- if got := view.Len(); got > tt.maxRows {
58- t.Fatalf("retained row count = %d, exceeds capacity %d", got, tt.maxRows)
59- }
60- if got := historyViewTexts(view); !equalStrings(got, tt.want) {
61- t.Fatalf("view rows = %#v, want %#v", got, tt.want)
62- }
63- if got, want := view.ChunkCount(), (len(tt.want)+1)/2; got != want {
64- t.Fatalf("chunk count = %d, want %d", got, want)
65- }
66- })
67- }
68-}
69-
70-func TestHistoryTailRotationAndStableViews(t *testing.T) {
71- tests := []struct {
72- name string
73- }{
74- {name: "a full tail seals and a later append cannot change an existing view"},
75- {name: "rows passed to append are copied before the screen recycles them"},
76- }
77-
78- for _, tt := range tests {
79- t.Run(tt.name, func(t *testing.T) {
80- history := NewHistory(HistoryConfig{MaxRows: 6, ChunkRows: 2})
81- first := historyRow("AAAA")
82- requireHistoryAppend(t, history, first)
83- requireHistoryAppend(t, history, historyRow("BBBB"))
84- view := history.View()
85- if got, want := view.ChunkCount(), 1; got != want {
86- t.Fatalf("sealed chunk count = %d, want %d", got, want)
87- }
88-
89- first[0].Rune = 'X'
90- requireHistoryAppend(t, history, historyRow("CCCC"))
91-
92- if got, want := historyViewTexts(view), []string{"AAAA", "BBBB"}; !equalStrings(got, want) {
93- t.Fatalf("stable view rows = %#v, want %#v", got, want)
94- }
95- if got, want := historyViewTexts(history.View()), []string{"AAAA", "BBBB", "CCCC"}; !equalStrings(got, want) {
96- t.Fatalf("current view rows = %#v, want %#v", got, want)
97- }
98- })
99- }
100-}
101-
102-func TestHistoryReusesSealedChunkIdentityAcrossViews(t *testing.T) {
103- tests := []struct {
104- name string
105- }{
106- {name: "unchanged sealed chunks are shared by successive views"},
107- }
108-
109- for _, tt := range tests {
110- t.Run(tt.name, func(t *testing.T) {
111- history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
112- requireHistoryAppend(t, history, historyRow("AAAA"))
113- requireHistoryAppend(t, history, historyRow("BBBB"))
114- before := history.View()
115-
116- requireHistoryAppend(t, history, historyRow("CCCC"))
117- after := history.View()
118-
119- if before.ChunkCount() == 0 || after.ChunkCount() == 0 {
120- t.Fatal("sealed chunk missing from view")
121- }
122- if before.Chunk(0) != after.Chunk(0) {
123- t.Fatal("unchanged sealed chunk was copied instead of reused")
124- }
125- })
126- }
127-}
128-
129-func TestHistoryOwnedByScreenIgnoresAlternateScreenEvictions(t *testing.T) {
130- tests := []struct {
131- name string
132- write []byte
133- want []string
134- }{
135- {
136- name: "primary screen evictions enter terminal history",
137- write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
138- want: []string{"AAAA"},
139- },
140- {
141- name: "alternate screen scrolling never enters terminal history",
142- write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\x1b[?1049l"),
143- want: []string{"AAAA"},
144- },
145- }
146-
147- for _, tt := range tests {
148- t.Run(tt.name, func(t *testing.T) {
149- screen := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2})
150- screen.Write(tt.write)
151-
152- if got := historyViewTexts(screen.History().View()); !equalStrings(got, tt.want) {
153- t.Fatalf("terminal history rows = %#v, want %#v", got, tt.want)
154- }
155- })
156- }
157-}
158-
159-func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
160- tests := []struct {
161- name string
162- top int
163- bottom int
164- wantRows int
165- wantEvents int
166- }{
167- {
168- name: "interior scroll region does not enter global history",
169- top: 1,
170- bottom: 3,
171- wantRows: 0,
172- wantEvents: 0,
173- },
174- {
175- name: "top-edge scroll enters global history",
176- top: 0,
177- bottom: 3,
178- wantRows: 1,
179- wantEvents: 1,
180- },
181- }
182-
183- for _, tt := range tests {
184- t.Run(tt.name, func(t *testing.T) {
185- s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8, ChunkRows: 2})
186- for y := range s.Frame.Height {
187- copy(s.Frame.Row(y), historyRow(string([]byte{byte('A' + y), byte('A' + y), byte('A' + y), byte('A' + y)})))
188- }
189- events := 0
190- s.OnLineEvicted = func([]renderer.Cell) { events++ }
191-
192- s.scrollUpRegion(tt.top, tt.bottom, 1)
193-
194- if got := s.History().View().Len(); got != tt.wantRows {
195- t.Errorf("history rows = %d, want %d", got, tt.wantRows)
196- }
197- if events != tt.wantEvents {
198- t.Errorf("eviction events = %d, want %d", events, tt.wantEvents)
199- }
200- })
201- }
202-}
203-
204-func TestHistoryBoundsRowsAndCellsWithExactRowEviction(t *testing.T) {
205- history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 5, ChunkRows: 2})
206- for _, text := range []string{"aa", "bbb", "c", "dd"} {
207- if err := history.Append(historyRow(text), LineBound{End: len(text)}); err != nil {
208- t.Fatalf("append %q: %v", text, err)
209- }
210- }
211-
212- view := history.View()
213- if got, want := historyViewTexts(view), []string{"c", "dd"}; !equalStrings(got, want) {
214- t.Fatalf("retained rows = %#v, want %#v", got, want)
215- }
216- if got, want := history.Len(), 2; got != want {
217- t.Fatalf("retained rows = %d, want %d", got, want)
218- }
219- if got, want := history.Cells(), 3; got != want {
220- t.Fatalf("retained cells = %d, want %d", got, want)
221- }
222- if got, want := view.Cells(), 3; got != want {
223- t.Fatalf("view cells = %d, want %d", got, want)
224- }
225-}
226-
227-func TestHistoryAppendIsNoOpForNilAndZeroValue(t *testing.T) {
228- tests := []struct {
229- name string
230- history *History
231- }{
232- {name: "nil history"},
233- {name: "zero-value history", history: &History{}},
234- }
235-
236- for _, tt := range tests {
237- t.Run(tt.name, func(t *testing.T) {
238- row := historyRow("row")
239- if err := tt.history.Append(row, LineBound{End: len(row)}); err != nil {
240- t.Fatalf("append error = %v, want nil", err)
241- }
242- if got := tt.history.Len(); got != 0 {
243- t.Fatalf("retained rows = %d, want 0", got)
244- }
245- })
246- }
247-}
248-
249-func TestHistoryRejectsRowWiderThanCellBudgetWithoutMutation(t *testing.T) {
250- history := NewHistory(HistoryConfig{MaxRows: 2, MaxCells: 2, ChunkRows: 2})
251- kept := historyRow("ok")
252- if err := history.Append(kept, LineBound{End: len(kept)}); err != nil {
253- t.Fatalf("append retained row: %v", err)
254- }
255- before := history.View()
256-
257- wide := historyRow("wide")
258- err := history.Append(wide, LineBound{End: len(wide)})
259- if !errors.Is(err, ErrHistoryRowTooWide) {
260- t.Fatalf("append oversized row error = %v, want ErrHistoryRowTooWide", err)
261- }
262- if got, want := historyViewTexts(history.View()), historyViewTexts(before); !equalStrings(got, want) {
263- t.Fatalf("history mutated after rejected append: got %#v, want %#v", got, want)
264- }
265- if got, want := history.Cells(), 2; got != want {
266- t.Fatalf("retained cells after rejected append = %d, want %d", got, want)
267- }
268-}
269-
270-func TestScreenDropsOversizedHistoryRowsWithoutInterruptingScroll(t *testing.T) {
271- screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2, MaxCells: 3})
272- var events []string
273- screen.OnLineEvicted = func(row []renderer.Cell) { events = append(events, rowText(row)) }
274- copy(screen.Frame.Row(0), historyRow("AAAA"))
275- copy(screen.Frame.Row(1), historyRow("BBBB"))
276- screen.scrollUpRegion(0, 1, 1)
277-
278- if got := screen.History().Len(); got != 0 {
279- t.Fatalf("oversized scrollback rows = %d, want 0", got)
280- }
281- if got, want := events, []string{"AAAA"}; !equalStrings(got, want) {
282- t.Fatalf("scroll callbacks = %#v, want %#v", got, want)
283- }
284- if got := lineText(screen, 0); got != "BBBB" {
285- t.Fatalf("screen did not scroll after history drop: row 0 = %q", got)
286- }
287-}
288-
289-func TestHistoryAppendRetainsBounds(t *testing.T) {
290- row := func(s string) []renderer.Cell {
291- cells := make([]renderer.Cell, 0, len(s))
292- for _, r := range s {
293- cells = append(cells, renderer.Cell{Rune: r})
294- }
295- return cells
296- }
297-
298- tests := []struct {
299- name string
300- chunkRows int
301- bounds []LineBound
302- }{
303- {
304- name: "within the mutable tail",
305- chunkRows: 8,
306- bounds: []LineBound{{End: 3, Soft: true}, {End: 2}},
307- },
308- {
309- name: "across a sealed chunk boundary",
310- chunkRows: 2,
311- bounds: []LineBound{{End: 3, Soft: true}, {End: 3, Soft: true}, {End: 2}},
312- },
313- }
314-
315- for _, tc := range tests {
316- t.Run(tc.name, func(t *testing.T) {
317- h := NewHistory(HistoryConfig{MaxRows: 16, MaxCells: 1024, ChunkRows: tc.chunkRows})
318- for i, b := range tc.bounds {
319- if err := h.Append(row("abc"[:b.End]), b); err != nil {
320- t.Fatalf("append %d: %v", i, err)
321- }
322- }
323- view := h.SealAndView()
324- for i, want := range tc.bounds {
325- if got := view.Bound(i); got != want {
326- t.Errorf("Bound(%d) = %+v, want %+v", i, got, want)
327- }
328- }
329- })
330- }
331-}
332-
333-func TestHistoryEvictionKeepsRowsAndBoundsAligned(t *testing.T) {
334- h := NewHistory(HistoryConfig{MaxRows: 3, MaxCells: 1024, ChunkRows: 2})
335- for i := range 5 {
336- cells := []renderer.Cell{{Rune: rune('a' + i)}}
337- if err := h.Append(cells, LineBound{End: 1, Soft: i%2 == 0}); err != nil {
338- t.Fatalf("append %d: %v", i, err)
339- }
340- }
341- view := h.SealAndView()
342- if view.Len() != 3 {
343- t.Fatalf("Len() = %d, want 3", view.Len())
344- }
345- // Rows 2, 3, 4 survived; their Soft flags were true, false, true.
346- for i, want := range []bool{true, false, true} {
347- if got := view.Bound(i).Soft; got != want {
348- t.Errorf("Bound(%d).Soft = %v, want %v (row %q)", i, got, want, view.BorrowedRow(i)[0].Rune)
349- }
350- }
351-}
352-
353-func TestHistoryDefaultCellBudgetDoesNotOverflow(t *testing.T) {
354- history := NewHistory(HistoryConfig{MaxRows: math.MaxInt, ChunkRows: 1})
355- if got, want := history.CellCap(), math.MaxInt; got != want {
356- t.Fatalf("default cell cap = %d, want %d", got, want)
357- }
358-}
359-
360-func historyRow(text string) []renderer.Cell {
361- row := make([]renderer.Cell, len(text))
362- for i, r := range text {
363- row[i] = renderer.BlankCell()
364- row[i].Rune = r
365- }
366- return row
367-}
368-
369-func historyViewTexts(view HistoryView) []string {
370- rows := make([]string, view.Len())
371- for i := range rows {
372- rows[i] = rowText(view.Row(i))
373- }
374- return rows
375-}
376-
377-func rowText(row []renderer.Cell) string {
378- runes := make([]rune, len(row))
379- for i, cell := range row {
380- runes[i] = cell.Rune
381- }
382- return string(runes)
383-}
D pkg/vt/query_test.go +0 -207
@@ -1,207 +0,0 @@
1-package vt
2-
3-import (
4- "bytes"
5- "testing"
6-
7- "github.com/bnema/vev/pkg/renderer"
8-)
9-
10-func TestScreenQueryResponses(t *testing.T) {
11- cases := []struct {
12- name string
13- input string
14- want string
15- }{
16- {name: "primary DA bare", input: "\x1b[c", want: "\x1b[?6c"},
17- {name: "primary DA with 0", input: "\x1b[0c", want: "\x1b[?6c"},
18- {name: "secondary DA", input: "\x1b[>c", want: "\x1b[>0;0;0c"},
19- {name: "secondary DA with 0", input: "\x1b[>0c", want: "\x1b[>0;0;0c"},
20- {name: "DSR status report", input: "\x1b[5n", want: "\x1b[0n"},
21- {name: "CPR at home", input: "\x1b[6n", want: "\x1b[1;1R"},
22- {name: "CPR after CUP", input: "\x1b[3;7H\x1b[6n", want: "\x1b[3;7R"},
23- {name: "private CPR", input: "\x1b[3;7H\x1b[?6n", want: "\x1b[?3;7R"},
24- {name: "CPR in DECOM reports origin-relative row", input: "\x1b[2;4r\x1b[?6h\x1b[6n", want: "\x1b[1;1R"},
25- {name: "DECRQM DECOM reset", input: "\x1b[?6$p", want: "\x1b[?6;2$y"},
26- {name: "DECRQM DECOM set", input: "\x1b[?6h\x1b[?6$p", want: "\x1b[?6;1$y"},
27- {name: "DECRQM IRM reset", input: "\x1b[4$p", want: "\x1b[4;2$y"},
28- {name: "DECRQM IRM set", input: "\x1b[4h\x1b[4$p", want: "\x1b[4;1$y"},
29- {name: "DECRQM 2026 reset", input: "\x1b[?2026$p", want: "\x1b[?2026;2$y"},
30- {name: "DECRQM 2026 set", input: "\x1b[?2026h\x1b[?2026$p", want: "\x1b[?2026;1$y"},
31- {name: "DECRQM 2031 reset", input: "\x1b[?2031$p", want: "\x1b[?2031;2$y"},
32- {name: "DECRQM 2031 set", input: "\x1b[?2031h\x1b[?2031$p", want: "\x1b[?2031;1$y"},
33- {name: "color scheme DSR dark", input: "\x1b[?996n", want: ColorSchemeReportDark},
34- {name: "color scheme DSR light", input: "\x1b[?996n", want: ColorSchemeReportLight},
35- {name: "DECRQM unknown mode", input: "\x1b[?1337$p", want: "\x1b[?1337;0$y"},
36- {name: "kitty keyboard query unanswered", input: "\x1b[?u", want: ""},
37- {name: "XTVERSION unanswered", input: "\x1b[>0q", want: ""},
38- {name: "DA split across writes", input: "", want: "\x1b[?6c"},
39- }
40- for _, tc := range cases {
41- t.Run(tc.name, func(t *testing.T) {
42- s := NewScreen(80, 24)
43- var got bytes.Buffer
44- s.OnResponse = func(b []byte) { got.Write(b) }
45- switch tc.name {
46- case "DA split across writes":
47- s.Write([]byte("\x1b["))
48- s.Write([]byte("0c"))
49- case "color scheme DSR light":
50- s.SetColorScheme(true)
51- s.Write([]byte(tc.input))
52- case "color scheme DSR dark":
53- s.SetColorScheme(false)
54- s.Write([]byte(tc.input))
55- default:
56- s.Write([]byte(tc.input))
57- }
58- if got.String() != tc.want {
59- t.Fatalf("response = %q, want %q", got.String(), tc.want)
60- }
61- })
62- }
63-}
64-
65-func TestScreenOSCDefaultColorQueries(t *testing.T) {
66- cases := []struct {
67- name string
68- input string
69- want string
70- }{
71- {name: "foreground BEL", input: "\x1b]10;?\a", want: "\x1b]10;rgb:1212/3434/5656\a"},
72- {name: "background BEL", input: "\x1b]11;?\a", want: "\x1b]11;rgb:7878/9a9a/bcbc\a"},
73- {name: "foreground ST", input: "\x1b]10;?\x1b\\", want: "\x1b]10;rgb:1212/3434/5656\x1b\\"},
74- {name: "background ST", input: "\x1b]11;?\x1b\\", want: "\x1b]11;rgb:7878/9a9a/bcbc\x1b\\"},
75- }
76- for _, tc := range cases {
77- t.Run(tc.name, func(t *testing.T) {
78- s := NewScreen(80, 24)
79- s.SetDefaultColors(
80- renderer.RGB{R: 0x12, G: 0x34, B: 0x56},
81- renderer.RGB{R: 0x78, G: 0x9a, B: 0xbc},
82- true,
83- )
84- var got bytes.Buffer
85- s.OnResponse = func(b []byte) { got.Write(b) }
86- s.Write([]byte(tc.input))
87- if got.String() != tc.want {
88- t.Fatalf("response = %q, want %q", got.String(), tc.want)
89- }
90- })
91- }
92-}
93-
94-func TestScreenOSCDefaultColorQueriesUnknownAreSilent(t *testing.T) {
95- s := NewScreen(80, 24)
96- s.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, false)
97- var got bytes.Buffer
98- s.OnResponse = func(b []byte) { got.Write(b) }
99- s.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
100- if got.Len() != 0 {
101- t.Fatalf("response = %q, want silence", got.String())
102- }
103-}
104-
105-func TestScreenOSCDefaultColorQuerySplitAcrossWrites(t *testing.T) {
106- s := NewScreen(80, 24)
107- s.SetDefaultColors(renderer.RGB{R: 0x01, G: 0x02, B: 0x03}, renderer.RGB{R: 0x04, G: 0x05, B: 0x06}, true)
108- var got bytes.Buffer
109- s.OnResponse = func(b []byte) { got.Write(b) }
110- s.Write([]byte("\x1b]10"))
111- s.Write([]byte(";?\a"))
112- if got.String() != "\x1b]10;rgb:0101/0202/0303\a" {
113- t.Fatalf("response = %q", got.String())
114- }
115-}
116-
117-func TestScreenQueriesWithNilResponderDoNotPanic(t *testing.T) {
118- s := NewScreen(80, 24)
119- s.Write([]byte("\x1b[c\x1b[6n\x1b[?2026$p"))
120- s.SetDefaultColors(renderer.RGB{R: 1, G: 2, B: 3}, renderer.RGB{R: 4, G: 5, B: 6}, true)
121- s.Write([]byte("\x1b]10;?\a\x1b]11;?\x1b\\"))
122-}
123-
124-func TestCSIuDispatch(t *testing.T) {
125- cases := []struct {
126- name string
127- input string
128- wantRow int
129- wantCol int
130- }{
131- {name: "bare u restores cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[u", wantRow: 4, wantCol: 9},
132- {name: "kitty query leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[?u", wantRow: 1, wantCol: 1},
133- {name: "kitty push leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[>1u", wantRow: 1, wantCol: 1},
134- {name: "kitty pop leaves cursor", input: "\x1b[5;10H\x1b[s\x1b[2;2H\x1b[<u", wantRow: 1, wantCol: 1},
135- }
136- for _, tc := range cases {
137- t.Run(tc.name, func(t *testing.T) {
138- s := NewScreen(80, 24)
139- s.Write([]byte(tc.input))
140- if s.Row != tc.wantRow || s.Col != tc.wantCol {
141- t.Fatalf("cursor = %d;%d, want %d;%d", s.Row, s.Col, tc.wantRow, tc.wantCol)
142- }
143- })
144- }
145-}
146-
147-func TestScreenColorSchemeQueriesUnknownAreSilent(t *testing.T) {
148- s := NewScreen(80, 24)
149- s.SetColorScheme(true)
150- s.ClearColorScheme()
151- var got bytes.Buffer
152- s.OnResponse = func(b []byte) { got.Write(b) }
153- s.Write([]byte("\x1b[?996n"))
154- if got.Len() != 0 {
155- t.Fatalf("response = %q, want silence", got.String())
156- }
157-}
158-
159-func TestScreenResetClearsColorSchemeModeSubscription(t *testing.T) {
160- s := NewScreen(80, 24)
161- var got bytes.Buffer
162- s.OnResponse = func(b []byte) { got.Write(b) }
163-
164- s.Write([]byte("\x1b[?2031h"))
165- s.Write([]byte("\x1bc"))
166-
167- s.SetColorScheme(true)
168- if got.Len() != 0 {
169- t.Fatalf("unsolicited response after reset = %q, want silence", got.String())
170- }
171-
172- got.Reset()
173- s.Write([]byte("\x1b[?2031$p"))
174- if got.String() != "\x1b[?2031;2$y" {
175- t.Fatalf("DECRQM 2031 after reset = %q, want reset state", got.String())
176- }
177-}
178-
179-func TestScreenColorSchemeUnsolicitedOnlySubscribedAndChanged(t *testing.T) {
180- s := NewScreen(80, 24)
181- var got bytes.Buffer
182- s.OnResponse = func(b []byte) { got.Write(b) }
183-
184- s.SetColorScheme(true)
185- if got.Len() != 0 {
186- t.Fatalf("unsubscribed response = %q, want silence", got.String())
187- }
188-
189- s.Write([]byte("\x1b[?2031h"))
190- if !s.ColorSchemeMode() {
191- t.Fatalf("ColorSchemeMode() = false, want true")
192- }
193- s.SetColorScheme(true)
194- if got.Len() != 0 {
195- t.Fatalf("unchanged response = %q, want silence", got.String())
196- }
197- s.SetColorScheme(false)
198- if got.String() != ColorSchemeReportDark {
199- t.Fatalf("changed response = %q, want dark notification", got.String())
200- }
201- got.Reset()
202- s.Write([]byte("\x1b[?2031l"))
203- s.SetColorScheme(true)
204- if got.Len() != 0 {
205- t.Fatalf("disabled response = %q, want silence", got.String())
206- }
207-}
D pkg/vt/recovery_transcript.go +0 -125
@@ -1,125 +0,0 @@
1-package vt
2-
3-import "github.com/bnema/vev/pkg/renderer"
4-
5-// RecoveryTranscriptSnapshot is an owned, immutable capture of the viewport
6-// rows that should be replayed after retained terminal history during recovery.
7-type RecoveryTranscriptSnapshot struct {
8- segments []recoveryTranscriptSegment
9- nextRowID RowID
10-}
11-
12-type recoveryTranscriptSegment struct {
13- rows [][]renderer.Cell
14- bounds []LineBound
15- rowIDs []RowID
16-}
17-
18-// RecoveryTranscriptSnapshot captures the active primary viewport, or the
19-// saved primary viewport followed by the active alternate viewport.
20-func (s *Screen) RecoveryTranscriptSnapshot() RecoveryTranscriptSnapshot {
21- if s == nil {
22- return RecoveryTranscriptSnapshot{}
23- }
24-
25- buffers := []*buffer{s.buffer}
26- if s.alternate != nil {
27- buffers = []*buffer{s.alternate.buffer, s.buffer}
28- }
29-
30- nextRowID := s.nextRowID
31- if nextRowID < ^RowID(0) {
32- nextRowID++
33- }
34- snapshot := RecoveryTranscriptSnapshot{segments: make([]recoveryTranscriptSegment, 0, len(buffers)), nextRowID: nextRowID}
35- for _, b := range buffers {
36- segment := captureRecoveryTranscriptSegment(b)
37- if len(segment.rows) > 0 {
38- snapshot.segments = append(snapshot.segments, segment)
39- }
40- }
41- return snapshot
42-}
43-
44-func captureRecoveryTranscriptSegment(b *buffer) recoveryTranscriptSegment {
45- if b == nil {
46- return recoveryTranscriptSegment{}
47- }
48-
49- rowCount := b.frame.Height
50- for rowCount > 0 && recoveryTranscriptRowUntouched(b.frame.Row(rowCount-1), b.bound(rowCount-1)) {
51- rowCount--
52- }
53- if rowCount == 0 {
54- return recoveryTranscriptSegment{}
55- }
56-
57- segment := recoveryTranscriptSegment{
58- rows: make([][]renderer.Cell, rowCount),
59- bounds: append([]LineBound(nil), b.boundaries[:rowCount]...),
60- rowIDs: append([]RowID(nil), b.rowIDs[:rowCount]...),
61- }
62- cells := make([]renderer.Cell, rowCount*b.frame.Width)
63- for y := range rowCount {
64- start := y * b.frame.Width
65- end := start + b.frame.Width
66- segment.rows[y] = cells[start:end:end]
67- copy(segment.rows[y], b.frame.Row(y))
68- }
69- segment.bounds[rowCount-1].Soft = false
70- return segment
71-}
72-
73-func recoveryTranscriptRowUntouched(row []renderer.Cell, bound LineBound) bool {
74- if bound.End != 0 || bound.Soft {
75- return false
76- }
77- blank := renderer.BlankCell()
78- for _, cell := range row {
79- if !cell.Equal(blank) {
80- return false
81- }
82- }
83- return true
84-}
85-
86-// Marshal encodes the captured rows as canonical terminal history without
87-// consulting or mutating live Screen history.
88-func (snapshot RecoveryTranscriptSnapshot) Marshal() ([]byte, error) {
89- rows := 0
90- cells := 0
91- for _, segment := range snapshot.segments {
92- rows += len(segment.rows)
93- for _, row := range segment.rows {
94- cells += len(row)
95- }
96- }
97-
98- view := HistoryView{rows: rows, cells: cells, nextRowID: snapshot.nextRowID}
99- for _, segment := range snapshot.segments {
100- for start := 0; start < len(segment.rows); {
101- space := maxHistoryChunkRows
102- if len(view.chunks) > 0 {
103- last := view.chunks[len(view.chunks)-1]
104- if len(last.rows) < maxHistoryChunkRows {
105- space = maxHistoryChunkRows - len(last.rows)
106- end := min(start+space, len(segment.rows))
107- last.rows = append(last.rows, segment.rows[start:end]...)
108- last.bounds = append(last.bounds, segment.bounds[start:end]...)
109- last.rowIDs = append(last.rowIDs, segment.rowIDs[start:end]...)
110- start = end
111- continue
112- }
113- }
114-
115- end := min(start+space, len(segment.rows))
116- view.chunks = append(view.chunks, &HistoryChunk{
117- rows: append([][]renderer.Cell(nil), segment.rows[start:end]...),
118- bounds: append([]LineBound(nil), segment.bounds[start:end]...),
119- rowIDs: append([]RowID(nil), segment.rowIDs[start:end]...),
120- })
121- start = end
122- }
123- }
124- return MarshalHistory(view)
125-}
D pkg/vt/recovery_transcript_ownership_test.go +0 -29
@@ -1,29 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-)
8-
9-func TestRecoveryTranscriptSnapshotOwnsSavedPrimaryAcrossLiveMutations(t *testing.T) {
10- s := NewScreen(8, 2)
11- s.Write([]byte("primary"))
12- s.Write([]byte("\x1b[?1049h"))
13-
14- captured := s.RecoveryTranscriptSnapshot()
15- mutated := make(chan struct{})
16- go func() {
17- s.Write([]byte("alternate"))
18- s.Resize(3, 3)
19- s.Write([]byte("\x1b[?1049lmutated"))
20- close(mutated)
21- }()
22-
23- blob, err := captured.Marshal()
24- <-mutated
25- require.NoError(t, err)
26- view, err := UnmarshalHistory(blob)
27- require.NoError(t, err)
28- require.Equal(t, []string{"primary "}, historyViewTexts(view))
29-}
D pkg/vt/recovery_transcript_test.go +0 -419
@@ -1,419 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7- "github.com/stretchr/testify/require"
8-)
9-
10-var (
11- recoveryTranscriptSnapshotSink RecoveryTranscriptSnapshot
12- recoveryTranscriptScreenSink *Screen
13-)
14-
15-func TestRecoveryTranscriptSnapshotOwnsPrimaryCellsAndBounds(t *testing.T) {
16- s := NewScreen(4, 2)
17- s.Write([]byte("keep"))
18-
19- snapshot := s.RecoveryTranscriptSnapshot()
20- s.Frame.Set(0, 0, renderer.BlankCell())
21- s.buffer.boundaries[0] = LineBound{}
22-
23- view := decodeRecoveryTranscript(t, snapshot)
24- require.Equal(t, []string{"keep"}, historyViewTexts(view))
25- require.Equal(t, LineBound{End: 4}, view.Bound(0))
26-}
27-
28-func TestRecoveryTranscriptSnapshotRetainsReflowBounds(t *testing.T) {
29- s := NewScreen(5, 3)
30- s.Write([]byte("abcdefgh"))
31-
32- view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
33-
34- require.Equal(t, []string{"abcde", "fgh "}, historyViewTexts(view))
35- require.Equal(t, LineBound{End: 5, Soft: true}, view.Bound(0))
36- require.Equal(t, LineBound{End: 3}, view.Bound(1))
37-}
38-
39-func TestRecoveryTranscriptSnapshotAllocationsDoNotScaleWithRetainedRows(t *testing.T) {
40- const (
41- shortHeight = 8
42- tallHeight = 512
43- )
44- newRetainedScreen := func(height int) *Screen {
45- screen := NewScreen(4, height)
46- for y := range height {
47- screen.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
48- screen.buffer.boundaries[y] = LineBound{End: 1}
49- }
50- return screen
51- }
52- short, tall := newRetainedScreen(shortHeight), newRetainedScreen(tallHeight)
53-
54- shortAllocs := testing.AllocsPerRun(20, func() {
55- recoveryTranscriptSnapshotSink = short.RecoveryTranscriptSnapshot()
56- })
57- tallAllocs := testing.AllocsPerRun(20, func() {
58- recoveryTranscriptSnapshotSink = tall.RecoveryTranscriptSnapshot()
59- })
60-
61- // Allow a small fixed variance while rejecting the former allocation per row.
62- if tallAllocs > shortAllocs+4 {
63- t.Fatalf("snapshot allocations scale with retained rows: height %d = %.0f, height %d = %.0f", shortHeight, shortAllocs, tallHeight, tallAllocs)
64- }
65-}
66-
67-func TestRecoveryTranscriptSnapshotOrdersPrimaryThenActiveAlternateAndHardensSeams(t *testing.T) {
68- s := NewScreen(4, 3)
69- s.Write([]byte("prim"))
70- s.buffer.boundaries[0].Soft = true
71- s.Write([]byte("\x1b[?1049h"))
72- s.Write([]byte("alt"))
73- s.buffer.boundaries[0].Soft = true
74-
75- snapshot := s.RecoveryTranscriptSnapshot()
76- s.Frame.Set(0, 0, renderer.BlankCell())
77- s.buffer.boundaries[0] = LineBound{}
78- s.alternate.buffer.frame.Set(0, 0, renderer.BlankCell())
79- s.alternate.buffer.boundaries[0] = LineBound{}
80-
81- view := decodeRecoveryTranscript(t, snapshot)
82-
83- require.Equal(t, []string{"prim", "alt "}, historyViewTexts(view))
84- require.Equal(t, LineBound{End: 4}, view.Bound(0), "saved-primary seam must be hard")
85- require.Equal(t, LineBound{End: 3}, view.Bound(1), "final alternate seam must be hard")
86-}
87-
88-func TestRecoveryTranscriptSnapshotTrimsEachViewportIndependently(t *testing.T) {
89- s := NewScreen(4, 3)
90- s.Write([]byte("main"))
91- s.Write([]byte("\x1b[?1049h"))
92- s.Write([]byte("alt"))
93-
94- view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
95-
96- require.Equal(t, []string{"main", "alt "}, historyViewTexts(view))
97-}
98-
99-func TestRecoveryTranscriptSnapshotExcludesExistingBoundedHistory(t *testing.T) {
100- s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 2})
101- require.NoError(t, s.History().Append(historyRow("old"), LineBound{End: 3}))
102- s.Write([]byte("live"))
103-
104- view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
105-
106- require.Equal(t, []string{"live"}, historyViewTexts(view))
107-}
108-
109-func TestRecoveryTranscriptSnapshotTrimsOnlyTrailingUntouchedRows(t *testing.T) {
110- styledBlank := renderer.BlankCell()
111- styledBlank.Style.Bold = true
112-
113- tests := []struct {
114- name string
115- candidateCell renderer.Cell
116- candidateBound LineBound
117- wantRows int
118- wantCell renderer.Cell
119- }{
120- {
121- name: "untouched default blank with zero hard bound",
122- candidateCell: renderer.BlankCell(),
123- wantRows: 1,
124- },
125- {
126- name: "written default space",
127- candidateCell: renderer.BlankCell(),
128- candidateBound: LineBound{End: 1},
129- wantRows: 2,
130- wantCell: renderer.BlankCell(),
131- },
132- {
133- name: "styled blank",
134- candidateCell: styledBlank,
135- wantRows: 2,
136- wantCell: styledBlank,
137- },
138- {
139- name: "soft blank",
140- candidateCell: renderer.BlankCell(),
141- candidateBound: LineBound{Soft: true},
142- wantRows: 2,
143- wantCell: renderer.BlankCell(),
144- },
145- }
146-
147- for _, tt := range tests {
148- t.Run(tt.name, func(t *testing.T) {
149- s := NewScreen(3, 3)
150- s.Frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
151- s.buffer.boundaries[0] = LineBound{End: 1}
152- s.Frame.Set(0, 1, tt.candidateCell)
153- s.buffer.boundaries[1] = tt.candidateBound
154-
155- view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
156-
157- require.Equal(t, tt.wantRows, view.Len())
158- if tt.wantRows == 2 {
159- require.True(t, view.Row(1)[0].Equal(tt.wantCell))
160- require.False(t, view.Bound(1).Soft, "the retained segment's final row must be hardened")
161- }
162- })
163- }
164-}
165-
166-func TestRecoveryTranscriptSnapshotPreservesLeadingAndInternalBlankRows(t *testing.T) {
167- s := NewScreen(3, 4)
168- s.Frame.Set(0, 2, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
169- s.buffer.boundaries[2] = LineBound{End: 1}
170-
171- view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
172-
173- require.Equal(t, []string{" ", " ", "x "}, historyViewTexts(view))
174- require.Equal(t, []LineBound{{}, {}, {End: 1}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)})
175-}
176-
177-func TestRecoveryTranscriptSnapshotPreservesBoundsAndHardensOnlyTheFinalRow(t *testing.T) {
178- s := NewScreen(4, 3)
179- s.buffer.boundaries = []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3, Soft: true}}
180-
181- view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
182-
183- require.Equal(t, []LineBound{{End: 4, Soft: true}, {End: 2}, {End: 3}}, []LineBound{view.Bound(0), view.Bound(1), view.Bound(2)})
184-}
185-
186-func TestRecoveryTranscriptSnapshotMarshalsCanonicalEmptyHistory(t *testing.T) {
187- s := NewScreen(4, 2)
188-
189- got, err := s.RecoveryTranscriptSnapshot().Marshal()
190- require.NoError(t, err)
191- want, err := MarshalHistory(HistoryView{nextRowID: 3})
192- require.NoError(t, err)
193-
194- require.Equal(t, want, got)
195- view, err := UnmarshalHistory(got)
196- require.NoError(t, err)
197- require.Zero(t, view.Len())
198- require.Zero(t, view.ChunkCount())
199-}
200-
201-func TestRecoveryTranscriptSnapshotChunksMoreThan256RowsCanonically(t *testing.T) {
202- s := NewScreen(1, 257)
203- for y := range s.Frame.Height {
204- s.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
205- s.buffer.boundaries[y] = LineBound{End: 1, Soft: true}
206- }
207-
208- view := decodeRecoveryTranscript(t, s.RecoveryTranscriptSnapshot())
209-
210- require.Equal(t, 257, view.Len())
211- require.Equal(t, 2, view.ChunkCount())
212- require.Len(t, view.Chunk(0).rows, 256)
213- require.Len(t, view.Chunk(1).rows, 1)
214- require.True(t, view.Bound(255).Soft, "a chunk boundary is not a transcript seam")
215- require.False(t, view.Bound(256).Soft, "the transcript's final seam must be hard")
216-}
217-
218-func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) {
219- s := NewScreen(4, 2)
220- s.Write([]byte("界"))
221- want := append([]renderer.Cell(nil), s.Frame.Row(0)...)
222-
223- snapshot := s.RecoveryTranscriptSnapshot()
224- for x := range s.Frame.Width {
225- s.Frame.Set(x, 0, renderer.BlankCell())
226- }
227-
228- view := decodeRecoveryTranscript(t, snapshot)
229- require.Equal(t, want, view.Row(0))
230- require.Equal(t, LineBound{End: 2}, view.Bound(0))
231- require.True(t, view.Row(0)[1].Continuation)
232-}
233-
234-func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBlank(t *testing.T) {
235- sealed, tail := recoveryHistoryBlobs(t, []string{"sealed-a", "sealed-b", "tail"})
236- transcript := recoveryTranscriptBlob(t,
237- []string{"primary", "alternate"},
238- []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}},
239- )
240-
241- screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxCells: 128, ChunkRows: 2}, sealed, tail, transcript)
242- require.NoError(t, err)
243-
244- view := screen.History().View()
245- require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view))
246- require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3))
247- require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam")
248- for y := range screen.Frame.Height {
249- for x := range screen.Frame.Width {
250- require.True(t, screen.Frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y)
251- }
252- }
253- require.Equal(t, []LineBound{{}, {}}, screen.LineBounds())
254-}
255-
256-func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing.T) {
257- tests := []struct {
258- name string
259- config HistoryConfig
260- history []string
261- transcript []string
262- want []string
263- }{
264- {
265- name: "row budget",
266- config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2},
267- history: []string{"old-1", "old-2", "old-3"},
268- transcript: []string{"new-1", "new-2"},
269- want: []string{"old-3", "new-1", "new-2"},
270- },
271- {
272- name: "cell budget",
273- config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4},
274- history: []string{"aa", "b", "ccc"},
275- transcript: []string{"d", "ee"},
276- want: []string{"d", "ee"},
277- },
278- }
279-
280- for _, tt := range tests {
281- t.Run(tt.name, func(t *testing.T) {
282- sealed, tail := recoveryHistoryBlobs(t, tt.history)
283- transcript := recoveryTranscriptBlob(t, tt.transcript, nil)
284-
285- screen, err := NewScreenWithRecoveryTranscript(4, 2, tt.config, sealed, tail, transcript)
286- require.NoError(t, err)
287- require.Equal(t, tt.want, historyViewTexts(screen.History().View()))
288- })
289- }
290-}
291-
292-func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing.T) {
293- sealed, tail := recoveryHistoryBlobs(t, []string{"ok"})
294- transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil)
295-
296- screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxCells: 3}, sealed, tail, transcript)
297-
298- require.ErrorIs(t, err, ErrHistoryRowTooWide)
299- require.Nil(t, screen)
300-}
301-
302-func BenchmarkRecoveryTranscriptSnapshot(b *testing.B) {
303- for _, tt := range []struct {
304- name string
305- alternate bool
306- wantRows int
307- }{
308- {name: "primary", wantRows: 40},
309- {name: "primary-and-active-alternate", alternate: true, wantRows: 80},
310- } {
311- b.Run(tt.name, func(b *testing.B) {
312- screen := NewScreen(120, 40)
313- fillRecoveryTranscriptBenchmarkFrame(screen)
314- if tt.alternate {
315- screen.Write([]byte("\x1b[?1049h"))
316- fillRecoveryTranscriptBenchmarkFrame(screen)
317- }
318-
319- b.ReportAllocs()
320- b.ReportMetric(float64(tt.wantRows), "rows/snapshot")
321- b.ResetTimer()
322- for b.Loop() {
323- recoveryTranscriptSnapshotSink = screen.RecoveryTranscriptSnapshot()
324- }
325- b.StopTimer()
326- if got := decodeRecoveryTranscript(b, recoveryTranscriptSnapshotSink).Len(); got != tt.wantRows {
327- b.Fatalf("captured rows = %d, want %d", got, tt.wantRows)
328- }
329- })
330- }
331-}
332-
333-func fillRecoveryTranscriptBenchmarkFrame(screen *Screen) {
334- for y := range screen.Frame.Height {
335- for x := range screen.Frame.Width {
336- screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + (x+y)%26), Style: renderer.DefaultStyle()})
337- }
338- screen.buffer.boundaries[y] = LineBound{End: screen.Frame.Width}
339- }
340-}
341-
342-func BenchmarkNewScreenWithRecoveryTranscriptManyChunks(b *testing.B) {
343- const rows = maxHistoryRows
344- source := NewScreen(1, rows)
345- for y := range rows {
346- source.Frame.Set(0, y, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
347- source.buffer.boundaries[y] = LineBound{End: 1}
348- }
349- transcript, err := source.RecoveryTranscriptSnapshot().Marshal()
350- require.NoError(b, err)
351- tail, err := MarshalEmptyHistoryTail()
352- require.NoError(b, err)
353- config := HistoryConfig{MaxRows: rows, MaxCells: rows, ChunkRows: maxHistoryChunkRows}
354-
355- b.ReportAllocs()
356- b.ReportMetric(float64(rows), "rows/restore")
357- b.ReportMetric(float64((rows+maxHistoryChunkRows-1)/maxHistoryChunkRows), "chunks/restore")
358- b.ResetTimer()
359- for b.Loop() {
360- recoveryTranscriptScreenSink, err = NewScreenWithRecoveryTranscript(1, 1, config, nil, tail, transcript)
361- if err != nil {
362- b.Fatal(err)
363- }
364- }
365-}
366-
367-func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) {
368- t.Helper()
369- history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2})
370- for _, text := range texts {
371- require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)}))
372- }
373- view := history.SnapshotView()
374- sealed := make([][]byte, view.ChunkCount())
375- for i := range sealed {
376- var err error
377- sealed[i], err = MarshalHistoryChunk(view.Chunk(i))
378- require.NoError(t, err)
379- }
380- tail, err := MarshalHistoryTail(view)
381- require.NoError(t, err)
382- return sealed, tail
383-}
384-
385-func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []byte {
386- t.Helper()
387- rows := make([][]renderer.Cell, len(texts))
388- cells := 0
389- for i, text := range texts {
390- rows[i] = historyRow(text)
391- cells += len(rows[i])
392- }
393- if bounds == nil {
394- bounds = make([]LineBound, len(rows))
395- for i, row := range rows {
396- bounds[i].End = len(row)
397- }
398- }
399- rowIDs := make([]RowID, len(rows))
400- for i := range rowIDs {
401- rowIDs[i] = RowID(100 + i)
402- }
403- view := HistoryView{rows: len(rows), cells: cells, nextRowID: RowID(100 + len(rows))}
404- if len(rows) > 0 {
405- view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds, rowIDs: rowIDs}}
406- }
407- blob, err := MarshalHistory(view)
408- require.NoError(t, err)
409- return blob
410-}
411-
412-func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView {
413- t.Helper()
414- blob, err := snapshot.Marshal()
415- require.NoError(t, err)
416- view, err := UnmarshalHistory(blob)
417- require.NoError(t, err)
418- return view
419-}
D pkg/vt/region_reflow_test.go +0 -52
@@ -1,52 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-)
8-
9-func TestResizeSeversSoftWrapsAtRegionalScrollEdges(t *testing.T) {
10- tests := []struct {
11- name string
12- seq string
13- want []string
14- }{
15- {
16- name: "CSI S",
17- seq: "\x1b[2;4r\x1b[S",
18- want: []string{"ABC", "IJK", "LMN", "OP ", " ", "QRS"},
19- },
20- {
21- name: "CSI T",
22- seq: "\x1b[2;4r\x1b[T",
23- want: []string{"ABC", " ", "EFG", "HIJ", "KL ", "QRS"},
24- },
25- {
26- name: "insert lines",
27- seq: "\x1b[2;4r\x1b[2;1H\x1b[L",
28- want: []string{"ABC", " ", "EFG", "HIJ", "KL ", "QRS"},
29- },
30- {
31- name: "delete lines",
32- seq: "\x1b[2;4r\x1b[2;1H\x1b[M",
33- want: []string{"ABC", "IJK", "LMN", "OP ", " ", "QRS"},
34- },
35- }
36-
37- for _, tt := range tests {
38- t.Run(tt.name, func(t *testing.T) {
39- s := NewScreen(4, 5)
40- s.Write([]byte("ABCDEFGHIJKLMNOPQRST"))
41- s.Write([]byte(tt.seq))
42-
43- s.Resize(3, 6)
44-
45- got := make([]string, s.Frame.Height)
46- for y := range s.Frame.Height {
47- got[y] = rowString(s.Frame.Row(y))
48- }
49- require.Equal(t, tt.want, got)
50- })
51- }
52-}
D pkg/vt/resize_reflow_regression_test.go +0 -54
@@ -1,54 +0,0 @@
1-package vt
2-
3-import "testing"
4-
5-func TestCollapsedResizeUpdatesSavedPrimaryAndClearsEscape(t *testing.T) {
6- s := NewScreen(5, 3)
7- s.Write([]byte("shell"))
8- s.Write([]byte("\x1b[?1049h"))
9- s.Write([]byte("\x1b["))
10-
11- s.Resize(0, 0)
12-
13- if len(s.escapeBuf) != 0 {
14- t.Fatalf("partial escape survives collapsed resize: %q", s.escapeBuf)
15- }
16- if s.Frame.Width != 0 || s.Frame.Height != 0 {
17- t.Fatalf("active frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height)
18- }
19- s.Write([]byte("\x1b[?1049l"))
20- if s.Frame.Width != 0 || s.Frame.Height != 0 {
21- t.Fatalf("saved primary frame = %dx%d, want 0x0", s.Frame.Width, s.Frame.Height)
22- }
23-}
24-
25-func TestResizeReflowDropsAbandonedWideEdgePadding(t *testing.T) {
26- s := NewScreen(4, 3)
27- s.Write([]byte("ab界"))
28- s.Write([]byte("\x1b[1;4H界x"))
29-
30- s.Resize(6, 3)
31-
32- assertCell(t, s, 0, 0, 'a')
33- assertCell(t, s, 1, 0, 'b')
34- assertCell(t, s, 2, 0, '界')
35- if !cellAt(s, 3, 0).Continuation {
36- t.Fatal("reflowed wide rune lost continuation")
37- }
38- assertCell(t, s, 4, 0, 'x')
39-}
40-
41-func TestInsertModeRetainsShiftedTailInReflowExtent(t *testing.T) {
42- s := NewScreen(6, 2)
43- s.Write([]byte("abcd"))
44- s.Write([]byte("\x1b[1;2H\x1b[4hX"))
45- // Simulate the subsequent logical continuation that causes the row to be
46- // reflowed. Its meaningful extent must include the shifted tail.
47- s.buffer.continueRow(0)
48-
49- s.Resize(5, 2)
50-
51- for x, r := range []rune("aXbcd") {
52- assertCell(t, s, x, 0, r)
53- }
54-}
D pkg/vt/row_id.go +0 -49
@@ -1,49 +0,0 @@
1-package vt
2-
3-// RowID identifies one physical terminal row for the lifetime of a Screen.
4-// Row IDs are never zero and are never reused by their owning Screen.
5-type RowID uint64
6-
7-func (s *Screen) nextRowIDValue() RowID {
8- if s.nextRowID >= ^RowID(0)-2 {
9- panic("vt: row ID space exhausted")
10- }
11- s.nextRowID++
12- return s.nextRowID
13-}
14-
15-func (s *Screen) newBuffer(width, height int) *buffer {
16- b := newBuffer(width, height)
17- for i := range b.rowIDs {
18- b.rowIDs[i] = s.nextRowIDValue()
19- }
20- return b
21-}
22-
23-func (s *Screen) fillMissingRowIDs(b *buffer) {
24- if b == nil {
25- return
26- }
27- for i := range b.rowIDs {
28- if b.rowIDs[i] == 0 {
29- b.rowIDs[i] = s.nextRowIDValue()
30- }
31- }
32-}
33-
34-// RowIDs returns an owned copy of the active live grid's row identities.
35-func (s *Screen) RowIDs() []RowID {
36- if s == nil || s.buffer == nil {
37- return nil
38- }
39- return append([]RowID(nil), s.buffer.rowIDs...)
40-}
41-
42-// RowID returns the identity of active live row y, or zero when y is out of
43-// range.
44-func (s *Screen) RowID(y int) RowID {
45- if s == nil || s.buffer == nil || y < 0 || y >= len(s.buffer.rowIDs) {
46- return 0
47- }
48- return s.buffer.rowIDs[y]
49-}
D pkg/vt/row_id_persistence_test.go +0 -222
@@ -1,222 +0,0 @@
1-package vt
2-
3-import (
4- "encoding/binary"
5- "testing"
6-
7- "github.com/bnema/vev/pkg/renderer"
8- "github.com/stretchr/testify/require"
9-)
10-
11-func TestHistoryCodecRejectsMalformedRowIDsAndCounters(t *testing.T) {
12- history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 4})
13- require.NoError(t, history.Append(historyRow("a"), LineBound{End: 1}))
14- require.NoError(t, history.Append(historyRow("b"), LineBound{End: 1}))
15- encoded, err := MarshalHistory(history.SealAndView())
16- require.NoError(t, err)
17-
18- firstIDOffset := 17 + 4
19- secondIDOffset := firstIDOffset + 8 + 4 + historyCellBytes + historyBoundBytes
20- for _, test := range []struct {
21- name string
22- mutate func([]byte)
23- }{
24- {name: "zero row ID", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[firstIDOffset:], 0) }},
25- {name: "duplicate row ID", mutate: func(data []byte) { copy(data[secondIDOffset:secondIDOffset+8], data[firstIDOffset:firstIDOffset+8]) }},
26- {name: "counter does not exceed IDs", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], 2) }},
27- {name: "max counter", mutate: func(data []byte) { binary.BigEndian.PutUint64(data[9:17], ^uint64(0)) }},
28- } {
29- t.Run(test.name, func(t *testing.T) {
30- data := append([]byte(nil), encoded...)
31- test.mutate(data)
32- _, unmarshalErr := UnmarshalHistory(data)
33- require.Error(t, unmarshalErr)
34- _, preflightErr := PreflightHistoryBlob(data)
35- require.Error(t, preflightErr)
36- })
37- }
38-}
39-
40-func TestHistoryAppendRejectsExhaustedRowIDCounter(t *testing.T) {
41- history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1})
42- history.nextRowID = ^RowID(0)
43- require.Error(t, history.Append(historyRow("row"), LineBound{End: 3}))
44-}
45-
46-func TestHistoryAppendWithIDRejectsMalformedIDs(t *testing.T) {
47- const maxPersistedRowID = ^RowID(0) - 2
48- for _, test := range []struct {
49- name string
50- id RowID
51- existing bool
52- }{
53- {name: "zero", id: 0},
54- {name: "duplicate", id: 7, existing: true},
55- {name: "first unpersistable", id: maxPersistedRowID + 1},
56- {name: "maximum", id: ^RowID(0)},
57- } {
58- t.Run(test.name, func(t *testing.T) {
59- history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 2})
60- if test.existing {
61- require.NoError(t, history.AppendWithID(historyRow("existing"), LineBound{End: 8}, test.id))
62- }
63- before := history.View()
64- require.Error(t, history.AppendWithID(historyRow("row"), LineBound{End: 3}, test.id))
65- require.Equal(t, before.Len(), history.Len())
66- require.Equal(t, before.NextRowID(), history.NextRowID())
67- })
68- }
69-}
70-
71-func TestHistoryAppendWithIDValidatesOutOfOrderDuplicates(t *testing.T) {
72- history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1})
73- require.NoError(t, history.AppendWithID(historyRow("high"), LineBound{End: 4}, RowID(100)))
74- require.NoError(t, history.AppendWithID(historyRow("low"), LineBound{End: 3}, RowID(7)))
75- require.Equal(t, RowID(101), history.NextRowID())
76-
77- before := history.View()
78- require.Error(t, history.AppendWithID(historyRow("duplicate"), LineBound{End: 9}, RowID(7)))
79- require.Equal(t, before.Len(), history.Len())
80- require.Equal(t, before.NextRowID(), history.NextRowID())
81-}
82-
83-func TestHistoryAutomaticAllocationStaysAboveAcceptedIDsAfterEviction(t *testing.T) {
84- history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
85- require.NoError(t, history.AppendWithID(historyRow("high"), LineBound{End: 4}, RowID(100)))
86- require.NoError(t, history.AppendWithID(historyRow("low"), LineBound{End: 3}, RowID(7)))
87- require.NoError(t, history.AppendWithID(historyRow("next-low"), LineBound{End: 8}, RowID(8)))
88- require.NoError(t, history.AppendWithID(historyRow("evict"), LineBound{End: 5}, RowID(9)))
89- require.Equal(t, RowID(101), history.NextRowID())
90-
91- require.NoError(t, history.Append(historyRow("automatic"), LineBound{End: 9}))
92- view := history.View()
93- require.Equal(t, RowID(101), view.RowID(view.Len()-1))
94- require.Equal(t, RowID(102), history.NextRowID())
95- for i := range view.Len() {
96- require.Less(t, view.RowID(i), history.NextRowID())
97- }
98-}
99-
100-func TestHistoryAutomaticAllocationBoundary(t *testing.T) {
101- lastPersistableID := ^RowID(0) - 2
102- history := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
103- history.nextRowID = lastPersistableID
104-
105- require.NoError(t, history.Append(historyRow("last"), LineBound{End: 4}))
106- require.Equal(t, lastPersistableID+1, history.NextRowID())
107- before := history.View()
108- require.Error(t, history.Append(historyRow("exhausted"), LineBound{End: 9}))
109- require.Equal(t, before.Len(), history.Len())
110- require.Equal(t, before.NextRowID(), history.NextRowID())
111-}
112-
113-func TestScreenRowIDBoundaryMatchesHistoryPersistence(t *testing.T) {
114- const maxPersistedRowID = ^RowID(0) - 2
115- for _, test := range []struct {
116- name string
117- current RowID
118- want RowID
119- persist bool
120- }{
121- {name: "last persistable ID", current: maxPersistedRowID - 1, want: maxPersistedRowID, persist: true},
122- {name: "first non-persistable ID", current: maxPersistedRowID, want: maxPersistedRowID + 1},
123- } {
124- t.Run(test.name, func(t *testing.T) {
125- screen := NewScreen(1, 1)
126- screen.nextRowID = test.current
127- allocate := func() {
128- require.Equal(t, test.want, screen.nextRowIDValue())
129- }
130- if !test.persist {
131- require.Panics(t, allocate)
132- return
133- }
134-
135- allocate()
136- row := historyRow("x")
137- screen.buffer.rowIDs[0] = test.want
138- screen.buffer.frame.Row(0)[0] = row[0]
139- screen.buffer.boundaries[0] = LineBound{End: 1}
140- transcript, err := screen.RecoveryTranscriptSnapshot().Marshal()
141- require.NoError(t, err)
142- view, err := UnmarshalHistory(transcript)
143- require.NoError(t, err)
144- require.Equal(t, test.want, view.RowID(0))
145- require.Equal(t, test.want+1, view.NextRowID())
146-
147- history := NewHistory(HistoryConfig{MaxRows: 1, ChunkRows: 1})
148- require.NoError(t, history.AppendWithID(row, LineBound{End: 1}, screen.RowID(0)))
149- _, err = MarshalHistory(history.SealAndView())
150- require.NoError(t, err)
151- })
152- }
153-}
154-
155-func TestHistoryCodecRejectsMissingRowIDsOnMarshal(t *testing.T) {
156- _, err := MarshalHistory(HistoryView{
157- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("row")}, bounds: []LineBound{{End: 3}}}},
158- rows: 1,
159- })
160- require.Error(t, err)
161-}
162-
163-func TestRestoredScreenAllocatesAbovePersistedRowIDs(t *testing.T) {
164- history := NewHistory(HistoryConfig{MaxRows: 4, ChunkRows: 1})
165- require.NoError(t, history.AppendWithID(historyRow("old"), LineBound{End: 3}, RowID(40)))
166- sealed, tail, err := MarshalSealedHistory(history.SealAndView())
167- require.NoError(t, err)
168- transcript, err := MarshalHistory(HistoryView{
169- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{50}}},
170- rows: 1,
171- nextRowID: 51,
172- })
173- require.NoError(t, err)
174-
175- screen, err := NewScreenWithRecoveryTranscript(3, 1, HistoryConfig{MaxRows: 8, ChunkRows: 2}, sealed, tail, transcript)
176- require.NoError(t, err)
177- require.Equal(t, RowID(40), screen.History().View().RowID(0))
178- require.Equal(t, RowID(50), screen.History().View().RowID(1))
179- require.Equal(t, RowID(51), screen.History().NextRowID())
180- require.Greater(t, screen.RowID(0), RowID(50))
181-
182- before := screen.RowID(0)
183- screen.Write([]byte("\x1b[2J"))
184- require.Greater(t, screen.RowID(0), before)
185-}
186-
187-func TestHistoryRestoreRejectsDuplicateIDsAcrossEvictedHistoryAndTranscript(t *testing.T) {
188- sealedView := HistoryView{
189- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("old")}, bounds: []LineBound{{End: 3}}, rowIDs: []RowID{7}}},
190- rows: 1,
191- nextRowID: 8,
192- }
193- sealed, err := MarshalHistoryChunk(sealedView.Chunk(0))
194- require.NoError(t, err)
195- tail, err := MarshalEmptyHistoryTail()
196- require.NoError(t, err)
197- transcript, err := MarshalHistory(HistoryView{
198- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("live")}, bounds: []LineBound{{End: 4}}, rowIDs: []RowID{7}}},
199- rows: 1,
200- nextRowID: 8,
201- })
202- require.NoError(t, err)
203-
204- _, err = NewScreenWithRecoveryTranscript(4, 1, HistoryConfig{MaxRows: 1, ChunkRows: 1}, [][]byte{sealed}, tail, transcript)
205- require.Error(t, err)
206-}
207-
208-func TestHistoryRestoreRejectsDuplicateIDsAcrossBlobs(t *testing.T) {
209- first := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
210- second := NewHistory(HistoryConfig{MaxRows: 2, ChunkRows: 1})
211- require.NoError(t, first.AppendWithID(historyRow("one"), LineBound{End: 3}, RowID(7)))
212- require.NoError(t, second.AppendWithID(historyRow("two"), LineBound{End: 3}, RowID(7)))
213- firstBlob, err := MarshalHistoryChunk(first.SealAndView().Chunk(0))
214- require.NoError(t, err)
215- secondBlob, err := MarshalHistoryChunk(second.SealAndView().Chunk(0))
216- require.NoError(t, err)
217- tail, err := MarshalEmptyHistoryTail()
218- require.NoError(t, err)
219-
220- _, err = HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 1}, [][]byte{firstBlob, secondBlob}, tail)
221- require.Error(t, err)
222-}
D pkg/vt/row_id_test.go +0 -180
@@ -1,180 +0,0 @@
1-package vt
2-
3-import "testing"
4-
5-func TestScreenRowIDsRotateWithRowsAndHistory(t *testing.T) {
6- s := NewScreenWithHistory(4, 3, HistoryConfig{MaxRows: 8, ChunkRows: 2})
7- initial := s.RowIDs()
8- assertUniqueNonzeroRowIDs(t, initial)
9-
10- s.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\n"))
11-
12- live := s.RowIDs()
13- if got, want := live, []RowID{initial[1], initial[2], live[2]}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
14- t.Fatalf("live row IDs = %v, want rows moved from %v with a fresh blank row", got, initial)
15- }
16- if live[2] == initial[0] || live[2] == initial[1] || live[2] == initial[2] {
17- t.Fatalf("new blank row reused a row ID: live=%v initial=%v", live, initial)
18- }
19- assertUniqueNonzeroRowIDs(t, live)
20-
21- view := s.History().SealAndView()
22- if got, want := view.RowID(0), initial[0]; got != want {
23- t.Fatalf("evicted row ID = %d, want %d", got, want)
24- }
25- if got := view.FindRowID(initial[0]); got != 0 {
26- t.Fatalf("FindRowID(%d) = %d, want 0", initial[0], got)
27- }
28-}
29-
30-func TestScreenRowIDsDoNotAppendInteriorOrAlternateScrollHistory(t *testing.T) {
31- s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8})
32- s.Write([]byte("AAAA\r\nBBBB\r\nCCCC\r\nDDDD"))
33- s.Write([]byte("\x1b[2;4r\x1b[4;1H\n"))
34- if got := s.History().Len(); got != 0 {
35- t.Fatalf("interior scroll history rows = %d, want 0", got)
36- }
37- assertUniqueNonzeroRowIDs(t, s.RowIDs())
38- primary := s.RowIDs()
39-
40- s.Write([]byte("\x1b[?1049h1111\r\n2222\r\n3333\r\n4444\r\n"))
41- if got := s.History().Len(); got != 0 {
42- t.Fatalf("alternate scroll history rows = %d, want 0", got)
43- }
44- s.Write([]byte("\x1b[?1049l"))
45- for i, id := range primary {
46- if got := s.RowID(i); got != id {
47- t.Fatalf("restored primary row %d ID = %d, want %d", i, got, id)
48- }
49- }
50-}
51-
52-func TestScreenRowIDRefreshesOnSnapExpandedFullClear(t *testing.T) {
53- tests := []struct {
54- name string
55- x0, x1 int
56- wantRefresh bool
57- }{
58- {name: "snap-expanded full clear", x0: 1, x1: 4, wantRefresh: true},
59- {name: "partial clear", x0: 0, x1: 1, wantRefresh: false},
60- }
61-
62- for _, tt := range tests {
63- t.Run(tt.name, func(t *testing.T) {
64- s := NewScreen(4, 1)
65- s.Write([]byte("界"))
66- before := s.RowID(0)
67-
68- s.clearRow(0, tt.x0, tt.x1)
69-
70- if got := s.RowID(0) != before; got != tt.wantRefresh {
71- t.Fatalf("row ID refreshed = %t, want %t", got, tt.wantRefresh)
72- }
73- })
74- }
75-}
76-
77-func TestScreenRowIDsRefreshOnClearResetAndAlternateClone(t *testing.T) {
78- s := NewScreen(4, 3)
79- before := s.RowIDs()
80- s.Write([]byte("AAAA\x1b[2J"))
81- cleared := s.RowIDs()
82- assertFreshRowIDs(t, before, cleared)
83-
84- s.Write([]byte("\x1bc"))
85- reset := s.RowIDs()
86- assertFreshRowIDs(t, cleared, reset)
87-
88- s.Write([]byte("BBBB\x1b[?1049h"))
89- alternate := s.RowIDs()
90- assertFreshRowIDs(t, reset, alternate)
91- s.Write([]byte("CCCC\x1b[?1049l"))
92- for i, id := range reset {
93- if got := s.RowID(i); got != id {
94- t.Fatalf("cloned primary row %d ID = %d, want %d", i, got, id)
95- }
96- }
97-}
98-
99-func TestResizeReflowCarriesSourceRowIDsIntoHistoryAndViewport(t *testing.T) {
100- b := newBuffer(8, 3)
101- copy(b.frame.Row(0), historyRow("abcdefgh"))
102- copy(b.frame.Row(1), historyRow("ijkl"))
103- copy(b.frame.Row(2), historyRow("mnop"))
104- b.boundaries[0] = LineBound{End: 8, Soft: true}
105- b.boundaries[1] = LineBound{End: 4}
106- b.boundaries[2] = LineBound{End: 4}
107- b.rowIDs = []RowID{11, 12, 13}
108-
109- active := &bufferCursor{row: 2, col: 4}
110- evicted, bounds, ids := b.resize(4, 2, active, nil)
111-
112- if len(evicted) != 2 || bounds[0].End != 4 || ids[0] != 11 || ids[1] != 0 {
113- t.Fatalf("reflow eviction = rows=%d bound=%v IDs=%v, want two rows with source ID 11 then a generated ID", len(evicted), bounds, ids)
114- }
115- if got, want := b.rowIDs, []RowID{0, 13}; got[1] != want[1] || len(got) != len(want) {
116- t.Fatalf("reflow viewport IDs = %v, want generated continuation then source ID %d", got, want[1])
117- }
118-}
119-
120-func TestHistoryRowIDsRemainStableAcrossViewsAndEviction(t *testing.T) {
121- h := NewHistory(HistoryConfig{MaxRows: 3, ChunkRows: 2})
122- for i, text := range []string{"one", "two"} {
123- if err := h.AppendWithID(historyRow(text), LineBound{End: len(text)}, RowID(i+1)); err != nil {
124- t.Fatal(err)
125- }
126- }
127- before := h.SealAndView()
128- if got, want := before.RowID(0), RowID(1); got != want {
129- t.Fatalf("sealed row ID = %d, want %d", got, want)
130- }
131-
132- if err := h.AppendWithID(historyRow("three"), LineBound{End: 5}, RowID(3)); err != nil {
133- t.Fatal(err)
134- }
135- if got := before.FindRowID(2); got != 1 {
136- t.Fatalf("retained view FindRowID(2) = %d, want 1", got)
137- }
138- after := h.SealAndView()
139- if got := after.FindRowID(3); got != 2 {
140- t.Fatalf("new view FindRowID(3) = %d, want 2", got)
141- }
142-
143- if err := h.AppendWithID(historyRow("four"), LineBound{End: 4}, RowID(4)); err != nil {
144- t.Fatal(err)
145- }
146- if got := before.FindRowID(1); got != 0 {
147- t.Fatalf("eviction mutated retained view FindRowID(1) = %d, want 0", got)
148- }
149- if got := h.View().FindRowID(1); got != -1 {
150- t.Fatalf("evicted current FindRowID(1) = %d, want -1", got)
151- }
152-}
153-
154-func assertUniqueNonzeroRowIDs(t *testing.T, ids []RowID) {
155- t.Helper()
156- seen := make(map[RowID]struct{}, len(ids))
157- for _, id := range ids {
158- if id == 0 {
159- t.Fatal("row ID is zero")
160- }
161- if _, ok := seen[id]; ok {
162- t.Fatalf("duplicate row ID %d in %v", id, ids)
163- }
164- seen[id] = struct{}{}
165- }
166-}
167-
168-func assertFreshRowIDs(t *testing.T, before, after []RowID) {
169- t.Helper()
170- assertUniqueNonzeroRowIDs(t, after)
171- seen := make(map[RowID]struct{}, len(before))
172- for _, id := range before {
173- seen[id] = struct{}{}
174- }
175- for _, id := range after {
176- if _, ok := seen[id]; ok {
177- t.Fatalf("row ID %d was reused: before=%v after=%v", id, before, after)
178- }
179- }
180-}
D pkg/vt/screen.go +0 -215
@@ -1,215 +0,0 @@
1-package vt
2-
3-import (
4- "unicode/utf8"
5-
6- "github.com/bnema/vev/pkg/renderer"
7-)
8-
9-// maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
10-// forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
11-// expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
12-// so keep this value in sync if that payload cap changes.
13-const maxEscapeBufferLen = 128 * 1024
14-
15-const (
16- // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
17- ColorSchemeReportDark = "\x1b[?997;1n"
18- // ColorSchemeReportLight is the DEC 2031 light-scheme report.
19- ColorSchemeReportLight = "\x1b[?997;2n"
20-)
21-
22-type Screen struct {
23- Frame renderer.Frame
24- Row int
25- Col int
26- Style renderer.Style
27- // OnLineEvicted is called just before a full-width upward scroll recycles
28- // and blanks rows. The callback receives a stable copy of each evicted row.
29- OnLineEvicted func([]renderer.Cell)
30- // OnResponse is called synchronously from Write with reply bytes that the
31- // emulator must send back to the child process (DA, DSR, and ANSI/DEC mode
32- // query reports). The host wires it to the PTY input. Nil disables responses.
33- OnResponse func([]byte)
34- // OnBell is called synchronously from Write for each lone BEL (0x07)
35- // outside escape sequences. BELs that terminate an OSC never fire it.
36- // Nil disables bell reporting.
37- OnBell func()
38- // OnNotify is called synchronously from Write for explicit terminal
39- // notifications: OSC 9 (body only) and OSC 777 "notify" (title;body).
40- // Other non-clipboard OSC payloads remain discarded. Nil disables it.
41- OnNotify func(title, body string)
42- // OnProgress is called synchronously from Write for OSC 9;4 progress
43- // transitions that request attention: active progress cleared or first entry
44- // into error state. Nil disables progress reporting.
45- OnProgress func(errored bool)
46- // OnClipboard is called synchronously from Write for a complete OSC 52
47- // clipboard set request from the child. The OSC 52 selection field is
48- // accepted but ignored; the callback receives only the raw base64 payload.
49- // Clipboard queries (data == "?") and malformed payloads are ignored and
50- // never invoke it. Nil disables it.
51- OnClipboard func(b64 string)
52-
53- history *History
54- buffer *buffer
55-
56- nextRowID RowID
57-
58- defaultFG renderer.RGB
59- defaultBG renderer.RGB
60- defaultColorsKnown bool
61- terminalTitle string
62-
63- damage []renderer.Damage
64- damageGeneration uint64
65- damageSaturated bool
66- damageFullRedrawSticky bool
67- escapeBuf []byte
68- csiScratch []int
69- sgrScratch []int
70-
71- scrollTop int
72- scrollBottom int
73- savedCursor cursorState
74- alternate *screenState
75- syncUpdateActive bool
76- progressState int
77- cursorVisible bool
78- cursorStyle int
79- cursorStyleSet bool
80- mouseMode int
81- mouseSGR bool
82- bracketedPaste bool
83- originMode bool
84- insertMode bool
85- colorSchemeMode bool
86- colorSchemeLight bool
87- colorSchemeSet bool
88-}
89-
90-func NewScreen(width, height int) *Screen {
91- s := &Screen{
92- Style: renderer.DefaultStyle(),
93- damage: []renderer.Damage{renderer.FullRedraw()},
94- damageGeneration: 1,
95- cursorVisible: true,
96- }
97- s.buffer = s.newBuffer(width, height)
98- s.Frame = s.buffer.frame
99- s.resetScrollRegion()
100- return s
101-}
102-
103-// NewScreenWithHistory creates a screen that records rows evicted from its
104-// primary screen into bounded immutable terminal history.
105-func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
106- s := NewScreen(width, height)
107- s.history = NewHistory(config)
108- return s
109-}
110-
111-// History returns this screen's terminal history, or nil when history was not
112-// configured with NewScreenWithHistory.
113-func (s *Screen) History() *History { return s.history }
114-
115-// LineBounds returns an owned copy of the live grid's per-row logical extents,
116-// indexed like Frame rows. It returns nil when the screen has no buffer.
117-func (s *Screen) LineBounds() []LineBound {
118- if s == nil || s.buffer == nil {
119- return nil
120- }
121- return append([]LineBound(nil), s.buffer.boundaries...)
122-}
123-
124-func (s *Screen) Write(data []byte) {
125- if len(s.escapeBuf) > 0 {
126- combined := make([]byte, 0, len(s.escapeBuf)+len(data))
127- combined = append(combined, s.escapeBuf...)
128- combined = append(combined, data...)
129- data = combined
130- s.escapeBuf = s.escapeBuf[:0]
131- }
132-
133- for len(data) > 0 {
134- if data[0] == 0x1b {
135- consumed, partial := s.consumeEscape(data)
136- if consumed > 0 {
137- data = data[consumed:]
138- continue
139- }
140- if partial {
141- if len(data) <= maxEscapeBufferLen {
142- s.escapeBuf = append(s.escapeBuf[:0], data...)
143- }
144- return
145- }
146- }
147- r, size := utf8.DecodeRune(data)
148- if r == utf8.RuneError && size == 1 {
149- data = data[1:]
150- continue
151- }
152- s.putRune(r)
153- data = data[size:]
154- }
155-}
156-
157-func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
158- if len(data) < 2 {
159- return 0, true
160- }
161- switch data[1] {
162- case ']':
163- return s.consumeOSC(data)
164- case 'P':
165- return consumeSTString(data)
166- case '_', '^', 'X':
167- return consumeSTString(data)
168- case '[':
169- return s.consumeCSI(data)
170- case '=':
171- return 2, false
172- case '>':
173- return 2, false
174- case '7':
175- s.saveCursor()
176- return 2, false
177- case '8':
178- s.restoreCursor()
179- return 2, false
180- case 'D':
181- s.buffer.hard(s.Row)
182- s.index()
183- return 2, false
184- case 'E':
185- s.nextLine()
186- return 2, false
187- case 'M':
188- s.reverseIndex()
189- return 2, false
190- case 'c':
191- s.reset()
192- return 2, false
193- case 'H':
194- return 2, false
195- case '(', ')', '*', '+', '-', '.', '/':
196- if len(data) < 3 {
197- return 0, true
198- }
199- return 3, false
200- default:
201- if data[1] >= 0x30 && data[1] <= 0x7e {
202- return 2, false
203- }
204- return 0, false
205- }
206-}
207-
208-func consumeSTString(data []byte) (consumed int, partial bool) {
209- for i := 2; i < len(data); i++ {
210- if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
211- return i + 2, false
212- }
213- }
214- return 0, true
215-}
D pkg/vt/screen_csi.go +0 -598
@@ -1,598 +0,0 @@
1-package vt
2-
3-import (
4- "strconv"
5- "strings"
6-
7- "github.com/bnema/vev/pkg/renderer"
8-)
9-
10-func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) {
11- end := -1
12- for i := 2; i < len(data); i++ {
13- if data[i] >= '@' && data[i] <= '~' {
14- end = i
15- break
16- }
17- }
18- if end == -1 {
19- return 0, true
20- }
21- params := string(data[2:end])
22- cmd := data[end]
23- s.applyCSI(params, cmd)
24- return end + 1, false
25-}
26-
27-func (s *Screen) applyCSI(params string, cmd byte) {
28- private := strings.HasPrefix(params, "?")
29- parts := s.parseCSIInts(params)
30- switch cmd {
31- case 'c':
32- switch params {
33- case "", "0":
34- s.respond([]byte("\x1b[?6c"))
35- case ">", ">0":
36- s.respond([]byte("\x1b[>0;0;0c"))
37- }
38- case 'n':
39- switch firstPositive(parts, 0) {
40- case 5:
41- if !private {
42- s.respond([]byte("\x1b[0n"))
43- }
44- case 996:
45- if private {
46- if report := s.colorSchemeReport(); report != nil {
47- s.respond(report)
48- }
49- }
50- case 6:
51- resp := make([]byte, 0, 16)
52- resp = append(resp, "\x1b["...)
53- if private {
54- resp = append(resp, '?')
55- }
56- resp = strconv.AppendInt(resp, int64(s.cursorReportRow()), 10)
57- resp = append(resp, ';')
58- resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
59- resp = append(resp, 'R')
60- s.respond(resp)
61- }
62- case 'p':
63- if modeText, ok := strings.CutSuffix(params, "$"); ok {
64- if private {
65- modeText = strings.TrimPrefix(modeText, "?")
66- }
67- mode, err := strconv.Atoi(modeText)
68- if err != nil {
69- return
70- }
71- state := s.modeReportState(private, mode)
72- resp := make([]byte, 0, 16)
73- resp = append(resp, "\x1b["...)
74- if private {
75- resp = append(resp, '?')
76- }
77- resp = strconv.AppendInt(resp, int64(mode), 10)
78- resp = append(resp, ';')
79- resp = strconv.AppendInt(resp, int64(state), 10)
80- resp = append(resp, "$y"...)
81- s.respond(resp)
82- }
83- case 'm':
84- if strings.HasPrefix(params, ">") {
85- return
86- }
87- s.applySGR(params)
88- case 'q':
89- s.applyCursorStyle(params)
90- case 'J':
91- mode := 0
92- if len(parts) > 0 {
93- mode = parts[0]
94- }
95- s.clearScreenMode(mode)
96- case 'K':
97- mode := 0
98- if len(parts) > 0 {
99- mode = parts[0]
100- }
101- s.clearLineMode(mode)
102- case 'X':
103- s.eraseChars(firstPositive(parts, 1))
104- case 'S':
105- s.scrollUpBy(firstPositive(parts, 1))
106- case 'T':
107- s.scrollDownBy(firstPositive(parts, 1))
108- case 'H', 'f':
109- row, col := 1, 1
110- if len(parts) > 0 && parts[0] > 0 {
111- row = parts[0]
112- }
113- if len(parts) > 1 && parts[1] > 0 {
114- col = parts[1]
115- }
116- s.addressCursor(row, col)
117- case 'A':
118- s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
119- case 'B':
120- s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
121- case 'C':
122- s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)
123- case 'D':
124- s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)
125- case 'E':
126- s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
127- s.Col = 0
128- case 'F':
129- s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
130- s.Col = 0
131- case 'G':
132- col := firstPositive(parts, 1)
133- s.Col = clamp(col-1, 0, s.Frame.Width-1)
134- case 'd':
135- row := firstPositive(parts, 1)
136- s.Row = s.addressedRow(row)
137- case '@':
138- s.insertChars(firstPositive(parts, 1))
139- case 'P':
140- s.deleteChars(firstPositive(parts, 1))
141- case 'L':
142- s.insertLines(firstPositive(parts, 1))
143- case 'M':
144- s.deleteLines(firstPositive(parts, 1))
145- case 'r':
146- s.setScrollRegion(parts)
147- case 's':
148- s.saveCursor()
149- case 'u':
150- if params == "" {
151- s.restoreCursor()
152- }
153- case 'h':
154- s.setMode(private, parts, true)
155- case 'l':
156- s.setMode(private, parts, false)
157- }
158-}
159-
160-func firstPositive(parts []int, fallback int) int {
161- if len(parts) == 0 || parts[0] <= 0 {
162- return fallback
163- }
164- return parts[0]
165-}
166-
167-func (s *Screen) modeReportState(private bool, mode int) int {
168- if private {
169- switch mode {
170- case 6:
171- return boolModeReportState(s.originMode)
172- case 2026:
173- return boolModeReportState(s.syncUpdateActive)
174- case 2031:
175- return boolModeReportState(s.colorSchemeMode)
176- }
177- return 0
178- }
179- if mode == 4 {
180- return boolModeReportState(s.insertMode)
181- }
182- return 0
183-}
184-
185-func boolModeReportState(enabled bool) int {
186- if enabled {
187- return 1
188- }
189- return 2
190-}
191-
192-const sgrUnderlineStyleMarker = -1000
193-
194-func (s *Screen) applySGR(params string) {
195- if params == "" {
196- s.Style = renderer.DefaultStyle()
197- return
198- }
199- parts := s.parseSGRInts(params)
200- for i := 0; i < len(parts); i++ {
201- switch parts[i] {
202- case 0:
203- s.Style = renderer.DefaultStyle()
204- case 1:
205- s.Style.Bold = true
206- case 2:
207- s.Style.Attrs |= renderer.AttrDim
208- case 3:
209- s.Style.Italic = true
210- case 4:
211- s.Style.Attrs |= renderer.AttrUnderline
212- s.Style.UnderlineStyle = renderer.UnderlineSingle
213- if i+2 < len(parts) && parts[i+1] == sgrUnderlineStyleMarker {
214- underlineStyle := sgrUnderlineStyle(parts[i+2])
215- if underlineStyle == renderer.UnderlineNone {
216- s.Style.Attrs &^= renderer.AttrUnderline
217- } else {
218- s.Style.Attrs |= renderer.AttrUnderline
219- }
220- s.Style.UnderlineStyle = underlineStyle
221- i += 2
222- }
223- case 5, 6:
224- s.Style.Attrs |= renderer.AttrBlink
225- case 7:
226- s.Style.Inverse = true
227- case 9:
228- s.Style.Attrs |= renderer.AttrStrikethrough
229- case 21:
230- s.Style.Attrs |= renderer.AttrUnderline
231- s.Style.UnderlineStyle = renderer.UnderlineDouble
232- case 22:
233- s.Style.Bold = false
234- s.Style.Attrs &^= renderer.AttrDim
235- case 23:
236- s.Style.Italic = false
237- case 24:
238- s.Style.Attrs &^= renderer.AttrUnderline
239- s.Style.UnderlineStyle = renderer.UnderlineNone
240- case 25:
241- s.Style.Attrs &^= renderer.AttrBlink
242- case 27:
243- s.Style.Inverse = false
244- case 29:
245- s.Style.Attrs &^= renderer.AttrStrikethrough
246- case 30, 31, 32, 33, 34, 35, 36, 37:
247- s.Style.Foreground = parts[i] - 30
248- s.Style.HasForegroundRGB = false
249- case 39:
250- s.Style.Foreground = -1
251- s.Style.HasForegroundRGB = false
252- case 40, 41, 42, 43, 44, 45, 46, 47:
253- s.Style.Background = parts[i] - 40
254- s.Style.HasBackgroundRGB = false
255- case 49:
256- s.Style.Background = -1
257- s.Style.HasBackgroundRGB = false
258- case 90, 91, 92, 93, 94, 95, 96, 97:
259- s.Style.Foreground = parts[i] - 90 + 8
260- s.Style.HasForegroundRGB = false
261- case 100, 101, 102, 103, 104, 105, 106, 107:
262- s.Style.Background = parts[i] - 100 + 8
263- s.Style.HasBackgroundRGB = false
264- case 38:
265- if i+2 < len(parts) && parts[i+1] == 5 {
266- s.Style.Foreground = parts[i+2]
267- s.Style.HasForegroundRGB = false
268- i += 2
269- } else if i+4 < len(parts) && parts[i+1] == 2 {
270- s.Style.Foreground = -1
271- s.Style.HasForegroundRGB = true
272- s.Style.ForegroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
273- i += 4
274- }
275- case 48:
276- if i+2 < len(parts) && parts[i+1] == 5 {
277- s.Style.Background = parts[i+2]
278- s.Style.HasBackgroundRGB = false
279- i += 2
280- } else if i+4 < len(parts) && parts[i+1] == 2 {
281- s.Style.Background = -1
282- s.Style.HasBackgroundRGB = true
283- s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
284- i += 4
285- }
286- case 58:
287- if i+2 < len(parts) && parts[i+1] == 5 {
288- s.Style.UnderlineColor = parts[i+2]
289- s.Style.HasUnderlineColor = true
290- s.Style.HasUnderlineColorRGB = false
291- i += 2
292- } else if i+4 < len(parts) && parts[i+1] == 2 {
293- s.Style.HasUnderlineColor = false
294- s.Style.HasUnderlineColorRGB = true
295- s.Style.UnderlineColorRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
296- i += 4
297- }
298- case 59:
299- s.Style.HasUnderlineColor = false
300- s.Style.HasUnderlineColorRGB = false
301- }
302- }
303-}
304-
305-func sgrRGB(r, g, b int) renderer.RGB {
306- return renderer.RGB{
307- R: uint8(clamp(r, 0, 255)),
308- G: uint8(clamp(g, 0, 255)),
309- B: uint8(clamp(b, 0, 255)),
310- }
311-}
312-
313-func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
314- switch param {
315- case 0:
316- return renderer.UnderlineNone
317- case 2:
318- return renderer.UnderlineDouble
319- case 3:
320- return renderer.UnderlineCurly
321- case 4:
322- return renderer.UnderlineDotted
323- case 5:
324- return renderer.UnderlineDashed
325- default:
326- return renderer.UnderlineSingle
327- }
328-}
329-
330-func (s *Screen) insertChars(n int) {
331- if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
332- return
333- }
334- w := s.Frame.Width
335- if n > w-s.Col {
336- n = w - s.Col
337- }
338- row := s.Frame.Row(s.Row)
339- // A wide left half at Col-1 whose continuation sits at Col will be orphaned
340- // by the shift; its repair falls outside the default damage rect.
341- leftSplit := s.Col > 0 && row[s.Col].Continuation
342- for x := w - 1; x >= s.Col+n; x-- {
343- row[x] = row[x-n]
344- }
345- for x := s.Col; x < s.Col+n; x++ {
346- row[x] = renderer.BlankCell()
347- }
348- s.repairRow(s.Row)
349- dmgX := s.Col
350- if leftSplit {
351- dmgX = s.Col - 1
352- }
353- s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
354-}
355-
356-func (s *Screen) deleteChars(n int) {
357- if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
358- return
359- }
360- w := s.Frame.Width
361- if n > w-s.Col {
362- n = w - s.Col
363- }
364- row := s.Frame.Row(s.Row)
365- // A wide left half at Col-1 whose continuation sits at Col will be orphaned
366- // by the shift; its repair falls outside the default damage rect.
367- leftSplit := s.Col > 0 && row[s.Col].Continuation
368- for x := s.Col; x < w-n; x++ {
369- row[x] = row[x+n]
370- }
371- for x := w - n; x < w; x++ {
372- row[x] = renderer.BlankCell()
373- }
374- s.repairRow(s.Row)
375- dmgX := s.Col
376- if leftSplit {
377- dmgX = s.Col - 1
378- }
379- s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
380-}
381-
382-func (s *Screen) insertLines(n int) {
383- if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
384- return
385- }
386- s.scrollDownRegion(s.Row, s.scrollBottom, n)
387-}
388-
389-func (s *Screen) deleteLines(n int) {
390- if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
391- return
392- }
393- s.scrollUpRegion(s.Row, s.scrollBottom, n)
394-}
395-
396-func (s *Screen) clearScreenMode(mode int) {
397- s.clampCursor()
398- switch mode {
399- case 1:
400- for y := range min(s.Row+1, s.Frame.Height) {
401- end := s.Frame.Width
402- if y == s.Row {
403- end = min(s.Col+1, s.Frame.Width)
404- }
405- s.clearRow(y, 0, end)
406- }
407- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: min(s.Row+1, s.Frame.Height), Count: 1})
408- case 2, 3:
409- blank := s.eraseCell()
410- for i := range s.Frame.Cells {
411- s.Frame.Cells[i] = blank
412- }
413- for y := range s.buffer.boundaries {
414- s.buffer.boundaries[y] = LineBound{Soft: s.buffer.boundaries[y].Soft}
415- s.buffer.rowIDs[y] = s.nextRowIDValue()
416- }
417- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
418- default:
419- for y := s.Row; y < s.Frame.Height; y++ {
420- start := 0
421- if y == s.Row {
422- start = s.Col
423- }
424- s.clearRow(y, start, s.Frame.Width)
425- }
426- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1})
427- }
428-}
429-
430-func (s *Screen) clearLineMode(mode int) {
431- s.clampCursor()
432- switch mode {
433- case 1:
434- start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.Frame.Width))
435- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
436- case 2:
437- start, width := s.clearRow(s.Row, 0, s.Frame.Width)
438- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
439- default:
440- start, width := s.clearRow(s.Row, s.Col, s.Frame.Width)
441- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
442- }
443-}
444-
445-func (s *Screen) eraseChars(n int) {
446- if n <= 0 {
447- return
448- }
449- s.clampCursor()
450- if remaining := s.Frame.Width - s.Col; n > remaining {
451- n = remaining
452- }
453- end := s.Col + n
454- start, width := s.clearRow(s.Row, s.Col, end)
455- if width > 0 {
456- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
457- }
458-}
459-
460-func (s *Screen) clampCursor() {
461- s.Row = clamp(s.Row, 0, s.Frame.Height-1)
462- s.Col = clamp(s.Col, 0, s.Frame.Width-1)
463-}
464-
465-func (s *Screen) parseCSIInts(params string) []int {
466- s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
467- return s.csiScratch
468-}
469-
470-func parseCSIInts(params string) []int {
471- return parseCSIIntsInto(nil, params)
472-}
473-
474-func parseCSIIntsInto(out []int, params string) []int {
475- if params == "" {
476- return out
477- }
478- params = strings.TrimPrefix(params, "?")
479- params = strings.TrimPrefix(params, ">")
480- start := 0
481- for i := 0; i <= len(params); i++ {
482- if i == len(params) || params[i] == ';' {
483- out = append(out, parseCSIInt(params[start:i]))
484- start = i + 1
485- }
486- }
487- return out
488-}
489-
490-func (s *Screen) parseSGRInts(params string) []int {
491- s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
492- return s.sgrScratch
493-}
494-
495-func parseSGRInts(params string) []int {
496- return parseSGRIntsInto(nil, params)
497-}
498-
499-func parseSGRIntsInto(out []int, params string) []int {
500- if params == "" {
501- return out
502- }
503- start := 0
504- for i := 0; i <= len(params); i++ {
505- if i == len(params) || params[i] == ';' {
506- out = appendSGRGroup(out, params[start:i])
507- start = i + 1
508- }
509- }
510- return out
511-}
512-
513-func appendSGRGroup(out []int, group string) []int {
514- colon := false
515- for i := 0; i < len(group); i++ {
516- if group[i] == ':' {
517- colon = true
518- break
519- }
520- }
521- if !colon {
522- return append(out, parseCSIInt(group))
523- }
524-
525- parts := countSGRColonFields(group)
526- code := parseCSIInt(sgrColonField(group, 0))
527- if code == 4 {
528- if parts < 2 {
529- return append(out, code)
530- }
531- return append(out, code, sgrUnderlineStyleMarker, parseCSIInt(sgrColonField(group, 1)))
532- }
533- if code != 38 && code != 48 && code != 58 {
534- return append(out, code)
535- }
536- mode := 0
537- if parts > 1 {
538- mode = parseCSIInt(sgrColonField(group, 1))
539- }
540- switch mode {
541- case 5:
542- if parts < 3 {
543- return out
544- }
545- out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
546- case 2:
547- // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
548- // the colorspace slot; code:mode:R:G:B omits that slot.
549- start := 2
550- if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
551- start = 3
552- }
553- if parts < start+3 {
554- return out
555- }
556- out = append(out, code, mode)
557- for i := range 3 {
558- out = append(out, parseCSIInt(sgrColonField(group, start+i)))
559- }
560- }
561- return out
562-}
563-
564-func countSGRColonFields(group string) int {
565- count := 1
566- for i := 0; i < len(group); i++ {
567- if group[i] == ':' {
568- count++
569- }
570- }
571- return count
572-}
573-
574-func sgrColonField(group string, index int) string {
575- start := 0
576- field := 0
577- for i := 0; i <= len(group); i++ {
578- if i == len(group) || group[i] == ':' {
579- if field == index {
580- return group[start:i]
581- }
582- field++
583- start = i + 1
584- }
585- }
586- return ""
587-}
588-
589-func parseCSIInt(param string) int {
590- if param == "" {
591- return 0
592- }
593- v, err := strconv.Atoi(param)
594- if err != nil {
595- return 0
596- }
597- return v
598-}
D pkg/vt/screen_csi_test.go +0 -1066
@@ -1,1066 +0,0 @@
1-package vt
2-
3-import (
4- "strconv"
5- "testing"
6-
7- "github.com/bnema/vev/pkg/renderer"
8- "github.com/stretchr/testify/require"
9-)
10-
11-func TestParseCSIInts(t *testing.T) {
12- t.Parallel()
13-
14- tests := []struct {
15- name string
16- params string
17- want []int
18- }{
19- {name: "empty", params: "", want: nil},
20- {name: "single", params: "5", want: []int{5}},
21- {name: "multiple", params: "1;2;3", want: []int{1, 2, 3}},
22- {name: "leading empty token", params: ";5", want: []int{0, 5}},
23- {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
24- {name: "trailing empty token", params: "5;", want: []int{5, 0}},
25- {name: "question prefix", params: "?25", want: []int{25}},
26- {name: "greater prefix", params: ">0;1", want: []int{0, 1}},
27- {name: "prefix order", params: "?>7", want: []int{7}},
28- {name: "junk token", params: "12x;4", want: []int{0, 4}},
29- }
30-
31- for _, tt := range tests {
32- t.Run(tt.name, func(t *testing.T) {
33- require.Equal(t, tt.want, parseCSIInts(tt.params))
34- })
35- }
36-}
37-
38-func TestParseSGRInts(t *testing.T) {
39- t.Parallel()
40-
41- tests := []struct {
42- name string
43- params string
44- want []int
45- }{
46- {name: "empty", params: "", want: nil},
47- {name: "single", params: "1", want: []int{1}},
48- {name: "multiple", params: "1;31", want: []int{1, 31}},
49- {name: "leading empty token", params: ";5", want: []int{0, 5}},
50- {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
51- {name: "trailing empty token", params: "5;", want: []int{5, 0}},
52- {name: "indexed foreground colon", params: "38:5:196", want: []int{38, 5, 196}},
53- {name: "rgb foreground colon", params: "38:2:1:2:3", want: []int{38, 2, 1, 2, 3}},
54- {name: "indexed background colon", params: "48:5:10", want: []int{48, 5, 10}},
55- {name: "rgb foreground empty colorspace", params: "38:2::1:2:3", want: []int{38, 2, 1, 2, 3}},
56- {name: "rgb foreground explicit colorspace", params: "38:2:9:1:2:3", want: []int{38, 2, 1, 2, 3}},
57- {name: "non color colon group", params: "1:2:3", want: []int{1}},
58- {name: "junk token", params: "abc;31", want: []int{0, 31}},
59- }
60-
61- for _, tt := range tests {
62- t.Run(tt.name, func(t *testing.T) {
63- require.Equal(t, tt.want, parseSGRInts(tt.params))
64- })
65- }
66-}
67-
68-func TestSGR(t *testing.T) {
69- tests := []struct {
70- name string
71- seq string
72- check func(t *testing.T, s *Screen)
73- }{
74- {
75- name: "SGR 0 resets to default style",
76- seq: "\x1b[0m",
77- check: func(t *testing.T, s *Screen) {
78- if s.Style != (renderer.DefaultStyle()) {
79- t.Errorf("style after SGR 0 = %+v, want default", s.Style)
80- }
81- },
82- },
83- {
84- name: "SGR 1 sets bold",
85- seq: "\x1b[1m",
86- check: func(t *testing.T, s *Screen) {
87- if !s.Style.Bold {
88- t.Error("expected bold")
89- }
90- },
91- },
92- {
93- name: "SGR 7 sets inverse",
94- seq: "\x1b[7m",
95- check: func(t *testing.T, s *Screen) {
96- if !s.Style.Inverse {
97- t.Error("expected inverse")
98- }
99- },
100- },
101- {
102- name: "SGR 31 sets red foreground",
103- seq: "\x1b[31m",
104- check: func(t *testing.T, s *Screen) {
105- if s.Style.Foreground != 1 {
106- t.Errorf("foreground = %d, want 1 (red)", s.Style.Foreground)
107- }
108- },
109- },
110- {
111- name: "SGR 44 sets blue background",
112- seq: "\x1b[44m",
113- check: func(t *testing.T, s *Screen) {
114- if s.Style.Background != 4 {
115- t.Errorf("background = %d, want 4 (blue)", s.Style.Background)
116- }
117- },
118- },
119- {
120- name: "SGR 38;5 sets 256-color foreground",
121- seq: "\x1b[38;5;82m",
122- check: func(t *testing.T, s *Screen) {
123- if s.Style.Foreground != 82 {
124- t.Errorf("foreground = %d, want 82", s.Style.Foreground)
125- }
126- },
127- },
128- {
129- name: "SGR 48;5 sets 256-color background",
130- seq: "\x1b[48;5;200m",
131- check: func(t *testing.T, s *Screen) {
132- if s.Style.Background != 200 {
133- t.Errorf("background = %d, want 200", s.Style.Background)
134- }
135- },
136- },
137- {
138- name: "SGR 91 sets bright red foreground",
139- seq: "\x1b[91m",
140- check: func(t *testing.T, s *Screen) {
141- if s.Style.Foreground != 9 {
142- t.Errorf("foreground = %d, want 9 (bright red)", s.Style.Foreground)
143- }
144- },
145- },
146- {
147- name: "SGR 107 sets bright white background",
148- seq: "\x1b[107m",
149- check: func(t *testing.T, s *Screen) {
150- if s.Style.Background != 15 {
151- t.Errorf("background = %d, want 15 (bright white)", s.Style.Background)
152- }
153- },
154- },
155- {
156- name: "SGR 22 resets bold",
157- seq: "\x1b[1mHello\x1b[22mWorld",
158- check: func(t *testing.T, s *Screen) {
159- if s.Style.Bold {
160- t.Error("bold should be reset after 22")
161- }
162- },
163- },
164- {
165- name: "SGR 27 resets inverse",
166- seq: "\x1b[7m\x1b[27m",
167- check: func(t *testing.T, s *Screen) {
168- if s.Style.Inverse {
169- t.Error("inverse should be reset after 27")
170- }
171- },
172- },
173- {
174- name: "SGR 39 resets foreground to default",
175- seq: "\x1b[31m\x1b[39m",
176- check: func(t *testing.T, s *Screen) {
177- if s.Style.Foreground != -1 {
178- t.Errorf("foreground should be default after 39, got %d", s.Style.Foreground)
179- }
180- },
181- },
182- {
183- name: "SGR 49 resets background to default",
184- seq: "\x1b[44m\x1b[49m",
185- check: func(t *testing.T, s *Screen) {
186- if s.Style.Background != -1 {
187- t.Errorf("background should be default after 49, got %d", s.Style.Background)
188- }
189- },
190- },
191- {
192- name: "SGR style is applied to written cells",
193- seq: "\x1b[1;31mX",
194- check: func(t *testing.T, s *Screen) {
195- c := cellAt(s, 0, 0)
196- if !c.Style.Bold {
197- t.Error("cell should be bold")
198- }
199- if c.Style.Foreground != 1 {
200- t.Errorf("cell foreground = %d, want 1", c.Style.Foreground)
201- }
202- },
203- },
204- {
205- name: "multiple sequential SGR sequences accumulate",
206- seq: "\x1b[31m\x1b[1m\x1b[44mX",
207- check: func(t *testing.T, s *Screen) {
208- c := cellAt(s, 0, 0)
209- if c.Style.Foreground != 1 || !c.Style.Bold || c.Style.Background != 4 {
210- t.Errorf("cell style = %+v, want fg=1 bold bg=4", c.Style)
211- }
212- },
213- },
214- {
215- name: "SGR 3 sets italic on style and cell, SGR 23 clears it",
216- seq: "\x1b[3mX\x1b[23mY",
217- check: func(t *testing.T, s *Screen) {
218- if !cellAt(s, 0, 0).Style.Italic {
219- t.Errorf("first cell italic = false, want true")
220- }
221- if cellAt(s, 1, 0).Style.Italic {
222- t.Errorf("second cell italic = true, want false after SGR 23")
223- }
224- if s.Style.Italic {
225- t.Errorf("current style italic = true, want false after SGR 23")
226- }
227- },
228- },
229- {
230- name: "SGR preserves dim underline blink and strikethrough with resets",
231- seq: "\x1b[2;4;5;9mA\x1b[22;24;25;29mB",
232- check: func(t *testing.T, s *Screen) {
233- first := cellAt(s, 0, 0).Style
234- if first.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough {
235- t.Errorf("first cell attrs = %b, want dim underline blink strikethrough", first.Attrs)
236- }
237- second := cellAt(s, 1, 0).Style
238- if second.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 {
239- t.Errorf("second cell attrs = %b, want resets cleared", second.Attrs)
240- }
241- },
242- },
243- {
244- name: "SGR 22 resets both bold and dim",
245- seq: "\x1b[1;2m\x1b[22m",
246- check: func(t *testing.T, s *Screen) {
247- if s.Style.Bold || s.Style.Attrs&renderer.AttrDim != 0 {
248- t.Errorf("style after SGR 22 = bold:%v attrs:%b, want neither bold nor dim", s.Style.Bold, s.Style.Attrs)
249- }
250- },
251- },
252- {
253- name: "SGR underline style and color are preserved and reset",
254- seq: "\x1b[4:3;58:2::255:0:0mA\x1b[59mB",
255- check: func(t *testing.T, s *Screen) {
256- first := cellAt(s, 0, 0).Style
257- if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
258- t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
259- }
260- want := renderer.RGB{R: 255, G: 0, B: 0}
261- if !first.HasUnderlineColorRGB || first.UnderlineColorRGB != want {
262- t.Errorf("first underline color = rgb:%v %+v, want %+v", first.HasUnderlineColorRGB, first.UnderlineColorRGB, want)
263- }
264- second := cellAt(s, 1, 0).Style
265- if second.HasUnderlineColorRGB || second.HasUnderlineColor {
266- t.Errorf("second underline color flags = indexed:%v rgb:%v, want reset", second.HasUnderlineColor, second.HasUnderlineColorRGB)
267- }
268- },
269- },
270- {
271- name: "SGR 4 colon zero clears underline",
272- seq: "\x1b[4:3mA\x1b[4:0mB",
273- check: func(t *testing.T, s *Screen) {
274- first := cellAt(s, 0, 0).Style
275- if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
276- t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
277- }
278- second := cellAt(s, 1, 0).Style
279- if second.Attrs&renderer.AttrUnderline != 0 || second.UnderlineStyle != renderer.UnderlineNone {
280- t.Errorf("second underline = attrs:%b style:%d, want cleared", second.Attrs, second.UnderlineStyle)
281- }
282- },
283- },
284- {
285- name: "empty SGR params reset style",
286- seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset
287- check: func(t *testing.T, s *Screen) {
288- if s.Style != (renderer.DefaultStyle()) {
289- t.Errorf("style after empty SGR = %+v, want default", s.Style)
290- }
291- },
292- },
293- }
294-
295- for _, tt := range tests {
296- t.Run(tt.name, func(t *testing.T) {
297- s := NewScreen(10, 3)
298- s.Write([]byte(tt.seq))
299- tt.check(t, s)
300- })
301- }
302-}
303-
304-func TestSGRTruecolor(t *testing.T) {
305- tests := []struct {
306- name string
307- seq string
308- check func(t *testing.T, s *Screen)
309- }{
310- {
311- name: "SGR 38;2 sets truecolor foreground on style and cell",
312- seq: "\x1b[38;2;12;34;56mX",
313- check: func(t *testing.T, s *Screen) {
314- want := renderer.RGB{R: 12, G: 34, B: 56}
315- if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
316- t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
317- }
318- if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
319- t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
320- }
321- },
322- },
323- {
324- name: "SGR 38:2 sets truecolor foreground",
325- seq: "\x1b[38:2:12:34:56mX",
326- check: func(t *testing.T, s *Screen) {
327- want := renderer.RGB{R: 12, G: 34, B: 56}
328- if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
329- t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
330- }
331- if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
332- t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
333- }
334- },
335- },
336- {
337- name: "SGR 38:2 empty color space sets truecolor foreground",
338- seq: "\x1b[38:2::12:34:56mX",
339- check: func(t *testing.T, s *Screen) {
340- want := renderer.RGB{R: 12, G: 34, B: 56}
341- if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
342- t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
343- }
344- if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
345- t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
346- }
347- },
348- },
349- {
350- name: "SGR 38:2 color space id sets truecolor foreground",
351- seq: "\x1b[38:2:1:12:34:56mX",
352- check: func(t *testing.T, s *Screen) {
353- want := renderer.RGB{R: 12, G: 34, B: 56}
354- if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
355- t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
356- }
357- if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
358- t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
359- }
360- },
361- },
362- {
363- name: "mixed semicolon and colon SGR groups",
364- seq: "\x1b[1;38:2:10:20:30;7mX",
365- check: func(t *testing.T, s *Screen) {
366- want := renderer.RGB{R: 10, G: 20, B: 30}
367- if !s.Style.Bold || !s.Style.Inverse {
368- t.Errorf("bold/inverse = %v/%v, want true/true", s.Style.Bold, s.Style.Inverse)
369- }
370- if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
371- t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
372- }
373- },
374- },
375- {
376- name: "truncated colon truecolor group does not consume next parameter",
377- seq: "\x1b[38:2:1:2;31m",
378- check: func(t *testing.T, s *Screen) {
379- if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
380- t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
381- }
382- },
383- },
384- {
385- name: "SGR 48;2 sets truecolor background",
386- seq: "\x1b[48;2;200;100;50m",
387- check: func(t *testing.T, s *Screen) {
388- want := renderer.RGB{R: 200, G: 100, B: 50}
389- if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
390- t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
391- }
392- },
393- },
394- {
395- name: "SGR 48:2 sets truecolor background",
396- seq: "\x1b[48:2:200:100:50m",
397- check: func(t *testing.T, s *Screen) {
398- want := renderer.RGB{R: 200, G: 100, B: 50}
399- if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
400- t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
401- }
402- },
403- },
404- {
405- name: "SGR 38:5 sets 256-color foreground",
406- seq: "\x1b[38:5:82m",
407- check: func(t *testing.T, s *Screen) {
408- if s.Style.Foreground != 82 || s.Style.HasForegroundRGB {
409- t.Errorf("foreground = %d rgb:%v, want 82 false", s.Style.Foreground, s.Style.HasForegroundRGB)
410- }
411- },
412- },
413- {
414- name: "SGR 48:5 sets 256-color background",
415- seq: "\x1b[48:5:200m",
416- check: func(t *testing.T, s *Screen) {
417- if s.Style.Background != 200 || s.Style.HasBackgroundRGB {
418- t.Errorf("background = %d rgb:%v, want 200 false", s.Style.Background, s.Style.HasBackgroundRGB)
419- }
420- },
421- },
422- {
423- name: "truncated colon foreground index group does not consume next parameter",
424- seq: "\x1b[38:5;31m",
425- check: func(t *testing.T, s *Screen) {
426- if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
427- t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
428- }
429- },
430- },
431- {
432- name: "truncated colon background index group does not consume next parameter",
433- seq: "\x1b[48:5;44m",
434- check: func(t *testing.T, s *Screen) {
435- if s.Style.Background != 4 || s.Style.HasBackgroundRGB {
436- t.Errorf("background = %d rgb:%v, want 4 false", s.Style.Background, s.Style.HasBackgroundRGB)
437- }
438- },
439- },
440- {
441- name: "SGR 39 after truecolor clears truecolor foreground",
442- seq: "\x1b[38;2;12;34;56m\x1b[39m",
443- check: func(t *testing.T, s *Screen) {
444- if s.Style.HasForegroundRGB || s.Style.Foreground != -1 {
445- t.Errorf("foreground after reset = rgb:%v index:%d, want default", s.Style.HasForegroundRGB, s.Style.Foreground)
446- }
447- },
448- },
449- {
450- name: "truecolor components are clamped to 0-255",
451- seq: "\x1b[38;2;-1;300;42m",
452- check: func(t *testing.T, s *Screen) {
453- want := renderer.RGB{R: 0, G: 255, B: 42}
454- if s.Style.ForegroundRGB != want {
455- t.Errorf("foreground RGB = %+v, want %+v", s.Style.ForegroundRGB, want)
456- }
457- },
458- },
459- }
460-
461- for _, tt := range tests {
462- t.Run(tt.name, func(t *testing.T) {
463- s := NewScreen(10, 3)
464- s.Write([]byte(tt.seq))
465- tt.check(t, s)
466- })
467- }
468-}
469-
470-func TestSGRColorModeTransitionsClearInactiveState(t *testing.T) {
471- tests := []struct {
472- name string
473- seq string
474- foreground int
475- background int
476- hasForeground bool
477- hasBackground bool
478- }{
479- {name: "foreground rgb to bright", seq: "\x1b[38;2;1;2;3m\x1b[91m", foreground: 9, background: -1},
480- {name: "foreground rgb to indexed", seq: "\x1b[38;2;1;2;3m\x1b[38;5;82m", foreground: 82, background: -1},
481- {name: "foreground indexed to rgb", seq: "\x1b[38;5;82m\x1b[38;2;4;5;6m", foreground: -1, background: -1, hasForeground: true},
482- {name: "background rgb to bright", seq: "\x1b[48;2;1;2;3m\x1b[107m", foreground: -1, background: 15},
483- {name: "background rgb to indexed", seq: "\x1b[48;2;1;2;3m\x1b[48;5;200m", foreground: -1, background: 200},
484- {name: "background rgb to default", seq: "\x1b[48;2;1;2;3m\x1b[49m", foreground: -1, background: -1},
485- {name: "background indexed to rgb", seq: "\x1b[48;5;200m\x1b[48;2;4;5;6m", foreground: -1, background: -1, hasBackground: true},
486- }
487-
488- for _, tt := range tests {
489- t.Run(tt.name, func(t *testing.T) {
490- s := NewScreen(10, 3)
491- s.Write([]byte(tt.seq))
492-
493- if s.Style.Foreground != tt.foreground || s.Style.HasForegroundRGB != tt.hasForeground {
494- t.Errorf("foreground = %d rgb:%v, want %d rgb:%v", s.Style.Foreground, s.Style.HasForegroundRGB, tt.foreground, tt.hasForeground)
495- }
496- if s.Style.Background != tt.background || s.Style.HasBackgroundRGB != tt.hasBackground {
497- t.Errorf("background = %d rgb:%v, want %d rgb:%v", s.Style.Background, s.Style.HasBackgroundRGB, tt.background, tt.hasBackground)
498- }
499- })
500- }
501-}
502-
503-func TestClearAndErase(t *testing.T) {
504- tests := []struct {
505- name string
506- run func(t *testing.T)
507- }{
508- {
509- name: "CSI 2J clears the whole screen",
510- run: func(t *testing.T) {
511- s := NewScreen(5, 3)
512- s.Write([]byte("HelloWorldABCDE"))
513- // Clear damage from writing.
514- s.ClearDamage()
515-
516- s.Write([]byte("\x1b[2J"))
517-
518- // All cells should be blank.
519- for y := range 3 {
520- for x := range 5 {
521- if c := cellAt(s, x, y); c.Rune != ' ' {
522- t.Errorf("cell(%d,%d) = %q, want space after clear", x, y, c.Rune)
523- }
524- }
525- }
526-
527- d := s.Damage()
528- if !hasDamageKind(d, renderer.DamageClear) {
529- t.Error("expected DamageClear after CSI 2 J")
530- }
531- },
532- },
533- {
534- name: "CSI K clears from cursor to end of line",
535- run: func(t *testing.T) {
536- s := NewScreen(10, 3)
537- s.Write([]byte("HelloWorld"))
538- s.ClearDamage()
539-
540- // Position cursor at row 1 col 6 (0-indexed: 0, 5) then clear to end.
541- s.Write([]byte("\x1b[1;6H"))
542- s.Write([]byte("\x1b[K"))
543-
544- for x := 5; x < 10; x++ {
545- if c := cellAt(s, x, 0); c.Rune != ' ' {
546- t.Errorf("cell(%d,0) = %q, want space after clear line", x, c.Rune)
547- }
548- }
549- // First 5 chars should remain.
550- assertCell(t, s, 0, 0, 'H')
551-
552- d := s.Damage()
553- if !hasDamageKind(d, renderer.DamageClear) {
554- t.Error("expected DamageClear after CSI K")
555- }
556- },
557- },
558- {
559- name: "erase display modes 0/1/2 (implicit, 1, 2/3)",
560- run: func(t *testing.T) {
561- s := NewScreen(5, 3)
562- s.Write([]byte("abcdefghijklmno"))
563- s.Write([]byte("\x1b[2;3H"))
564- s.ClearDamage()
565- s.Write([]byte("\x1b[J"))
566- assertCell(t, s, 0, 0, 'a')
567- assertCell(t, s, 1, 1, 'g')
568- if c := cellAt(s, 2, 1); c.Rune != ' ' {
569- t.Fatalf("cursor-to-end clear left %q at cursor", c.Rune)
570- }
571-
572- s = NewScreen(5, 3)
573- s.Write([]byte("abcdefghijklmno"))
574- s.Write([]byte("\x1b[2;3H"))
575- s.Write([]byte("\x1b[1J"))
576- if c := cellAt(s, 0, 0); c.Rune != ' ' {
577- t.Fatalf("start-to-cursor clear left %q at start", c.Rune)
578- }
579- assertCell(t, s, 3, 1, 'i')
580- },
581- },
582- {
583- name: "erase line modes 0/1/2",
584- run: func(t *testing.T) {
585- s := NewScreen(5, 2)
586- s.Write([]byte("abcde"))
587- s.Write([]byte("\x1b[1;3H"))
588- s.Write([]byte("\x1b[1K"))
589- if c := cellAt(s, 0, 0); c.Rune != ' ' {
590- t.Fatalf("line start clear left %q", c.Rune)
591- }
592- assertCell(t, s, 3, 0, 'd')
593-
594- s = NewScreen(5, 2)
595- s.Write([]byte("abcde"))
596- s.Write([]byte("\x1b[1;3H"))
597- s.Write([]byte("\x1b[2K"))
598- for x := range 5 {
599- if c := cellAt(s, x, 0); c.Rune != ' ' {
600- t.Fatalf("line clear left %q at %d", c.Rune, x)
601- }
602- }
603- },
604- },
605- }
606-
607- for _, tt := range tests {
608- t.Run(tt.name, tt.run)
609- }
610-}
611-
612-func TestEraseCharacters(t *testing.T) {
613- maxInt := strconv.Itoa(int(^uint(0) >> 1))
614- tests := []struct {
615- name string
616- width int
617- row int
618- initial string
619- seq string
620- wantRow string
621- wantCol int
622- wantDamage []renderer.Damage
623- }{
624- {
625- name: "explicit count",
626- width: 50,
627- row: 1,
628- initial: ` // scroll-method "on-button-down"`,
629- seq: "\x1b[19Gbutton 273\x1b[13X",
630- wantRow: " // scroll-button 273",
631- wantCol: 28,
632- wantDamage: []renderer.Damage{
633- {Kind: renderer.DamageText, X: 18, Y: 1, Width: 10, Height: 1, Count: 1},
634- {Kind: renderer.DamageClear, X: 28, Y: 1, Width: 13, Height: 1, Count: 1},
635- },
636- },
637- {
638- name: "omitted count erases exactly one cell",
639- width: 6,
640- row: 1,
641- initial: "abcdef",
642- seq: "\x1b[3G\x1b[X",
643- wantRow: "ab def",
644- wantCol: 2,
645- wantDamage: []renderer.Damage{{Kind: renderer.DamageClear, X: 2, Y: 1, Width: 1, Height: 1, Count: 1}},
646- },
647- {
648- name: "maximum count clips to screen edge",
649- width: 6,
650- row: 1,
651- initial: "abcdef",
652- seq: "\x1b[4G\x1b[" + maxInt + "X",
653- wantRow: "abc",
654- wantCol: 3,
655- wantDamage: []renderer.Damage{{Kind: renderer.DamageClear, X: 3, Y: 1, Width: 3, Height: 1, Count: 1}},
656- },
657- }
658-
659- for _, tt := range tests {
660- t.Run(tt.name, func(t *testing.T) {
661- s := NewScreen(tt.width, 2)
662- s.Write([]byte("\x1b[2;1H"))
663- s.Write([]byte(tt.initial))
664- s.ClearDamage()
665- s.Write([]byte(tt.seq))
666-
667- for x := range tt.width {
668- want := ' '
669- if x < len(tt.wantRow) {
670- want = rune(tt.wantRow[x])
671- }
672- require.Equal(t, want, cellAt(s, x, tt.row).Rune, "cell(%d,%d)", x, tt.row)
673- }
674- require.Equal(t, tt.row, s.Row)
675- require.Equal(t, tt.wantCol, s.Col)
676- require.Equal(t, tt.wantDamage, s.Damage())
677- })
678- }
679-}
680-
681-func TestBackgroundColorErase(t *testing.T) {
682- tests := []struct {
683- name string
684- width int
685- height int
686- seq string
687- startX int
688- wantStyle renderer.Style
689- }{
690- {
691- name: "indexed background for EL",
692- width: 8,
693- height: 1,
694- seq: "\x1b[1;31;48;5;236mX\x1b[K",
695- startX: 1,
696- wantStyle: renderer.Style{Foreground: -1, Background: 236},
697- },
698- {
699- name: "truecolor background for full ED",
700- width: 3,
701- height: 2,
702- seq: "abcdef\x1b[1;31;48;2;12;34;56m\x1b[2J",
703- wantStyle: renderer.Style{
704- Foreground: -1,
705- Background: -1,
706- HasBackgroundRGB: true,
707- BackgroundRGB: renderer.RGB{R: 12, G: 34, B: 56},
708- },
709- },
710- {
711- name: "default background after SGR 49",
712- width: 2,
713- height: 1,
714- seq: "\x1b[48;2;12;34;56m\x1b[49m\x1b[K",
715- wantStyle: renderer.DefaultStyle(),
716- },
717- {
718- name: "default background after SGR 0",
719- width: 2,
720- height: 1,
721- seq: "\x1b[48;2;12;34;56m\x1b[0m\x1b[K",
722- wantStyle: renderer.DefaultStyle(),
723- },
724- }
725-
726- for _, tt := range tests {
727- t.Run(tt.name, func(t *testing.T) {
728- s := NewScreen(tt.width, tt.height)
729- s.Write([]byte(tt.seq))
730-
731- for y := range tt.height {
732- for x := tt.startX; x < tt.width; x++ {
733- cell := cellAt(s, x, y)
734- if cell.Rune != ' ' || cell.Style != tt.wantStyle {
735- t.Errorf("cell(%d,%d) = %+v, want space with style %+v", x, y, cell, tt.wantStyle)
736- }
737- }
738- }
739- })
740- }
741-}
742-
743-func TestCursorMove(t *testing.T) {
744- tests := []struct {
745- name string
746- seq string
747- initRow int
748- initCol int
749- wantRow int
750- wantCol int
751- }{
752- {name: "CSI H moves to explicit row/col", seq: "\x1b[3;4H", wantRow: 2, wantCol: 3},
753- {name: "CSI H clamps to bottom-right when out of bounds", seq: "\x1b[100;200H", wantRow: 4, wantCol: 9},
754- {name: "CSI f moves to explicit row/col", seq: "\x1b[2;8f", wantRow: 1, wantCol: 7},
755- {name: "CSI C moves forward after CR", seq: "\r\x1b[3C", wantRow: 0, wantCol: 3},
756- {name: "CSI H with no params moves to (1,1)", seq: "\x1b[H", wantRow: 0, wantCol: 0},
757- {name: "CSI ;5H uses default row with explicit column", seq: "\x1b[;5H", initRow: 3, initCol: 4, wantRow: 0, wantCol: 4},
758- }
759-
760- for _, tt := range tests {
761- t.Run(tt.name, func(t *testing.T) {
762- s := NewScreen(10, 5)
763- s.Row, s.Col = tt.initRow, tt.initCol
764- s.Write([]byte(tt.seq))
765-
766- if s.Row != tt.wantRow || s.Col != tt.wantCol {
767- t.Errorf("cursor at row=%d col=%d, want row=%d col=%d", s.Row, s.Col, tt.wantRow, tt.wantCol)
768- }
769- })
770- }
771-}
772-
773-func TestCSIEditingSequences(t *testing.T) {
774- tests := []struct {
775- name string
776- run func(t *testing.T)
777- }{
778- {
779- name: "directional cursor moves (A/B/C/D)",
780- run: func(t *testing.T) {
781- s := NewScreen(10, 4)
782- s.Write([]byte("\x1b[3;5HX\x1b[2D<\x1b[1A^\x1b[1Bv\x1b[3C>"))
783-
784- assertCell(t, s, 4, 2, 'X')
785- assertCell(t, s, 3, 2, '<')
786- assertCell(t, s, 4, 1, '^')
787- assertCell(t, s, 5, 2, 'v')
788- assertCell(t, s, 9, 2, '>')
789- },
790- },
791- {
792- name: "insert and delete characters (@ and P)",
793- run: func(t *testing.T) {
794- s := NewScreen(8, 2)
795- s.Write([]byte("abcdef"))
796- s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
797- if got := lineText(s, 0); got != "abXYcdef" {
798- t.Fatalf("line after ICH = %q, want %q", got, "abXYcdef")
799- }
800-
801- s.Write([]byte("\x1b[1;4H\x1b[3P"))
802- if got := lineText(s, 0); got != "abXef " {
803- t.Fatalf("line after DCH = %q, want %q", got, "abXef ")
804- }
805- },
806- },
807- {
808- name: "insert and delete lines (L and M)",
809- run: func(t *testing.T) {
810- s := NewScreen(5, 4)
811- s.Write([]byte("11111222223333344444"))
812- s.Write([]byte("\x1b[2;1H\x1b[L"))
813- if got := lineText(s, 1); got != " " {
814- t.Fatalf("inserted line = %q, want blank", got)
815- }
816- if got := lineText(s, 2); got != "22222" {
817- t.Fatalf("shifted line = %q, want 22222", got)
818- }
819-
820- s.Write([]byte("\x1b[2;1H\x1b[M"))
821- if got := lineText(s, 1); got != "22222" {
822- t.Fatalf("line after delete = %q, want 22222", got)
823- }
824- },
825- },
826- {
827- name: "scroll region (CSI r) confines LF scrolling",
828- run: func(t *testing.T) {
829- s := NewScreen(5, 4)
830- s.Write([]byte("11111222223333344444"))
831- s.Write([]byte("\x1b[2;3r\x1b[3;1H\n"))
832-
833- if got := lineText(s, 0); got != "11111" {
834- t.Fatalf("row outside region changed: %q", got)
835- }
836- if got := lineText(s, 1); got != "33333" {
837- t.Fatalf("region did not scroll up, row 1 = %q", got)
838- }
839- if got := lineText(s, 2); got != " " {
840- t.Fatalf("region bottom not blanked, row 2 = %q", got)
841- }
842- if got := lineText(s, 3); got != "44444" {
843- t.Fatalf("row below region changed: %q", got)
844- }
845- },
846- },
847- {
848- name: "DECOM homes and addresses relative to scroll region",
849- run: func(t *testing.T) {
850- s := NewScreen(6, 5)
851- s.Write([]byte("\x1b[2;4r\x1b[?6h"))
852- if s.Row != 1 || s.Col != 0 {
853- t.Fatalf("DECOM set cursor = (%d,%d), want (1,0)", s.Row, s.Col)
854- }
855-
856- s.Write([]byte("\x1b[2;3H"))
857- if s.Row != 2 || s.Col != 2 {
858- t.Fatalf("DECOM addressed cursor = (%d,%d), want (2,2)", s.Row, s.Col)
859- }
860- s.Write([]byte("\x1b[99;1H"))
861- if s.Row != 3 || s.Col != 0 {
862- t.Fatalf("DECOM clamped cursor = (%d,%d), want (3,0)", s.Row, s.Col)
863- }
864- },
865- },
866- {
867- name: "DECOM already set homes DECSTBM to scroll region",
868- run: func(t *testing.T) {
869- s := NewScreen(6, 5)
870- s.Write([]byte("\x1b[?6h\x1b[2;4r"))
871- if s.Row != 1 || s.Col != 0 {
872- t.Fatalf("DECSTBM cursor with DECOM set = (%d,%d), want (1,0)", s.Row, s.Col)
873- }
874-
875- s.Write([]byte("\x1b[2;3H"))
876- if s.Row != 2 || s.Col != 2 {
877- t.Fatalf("DECOM addressed cursor after DECSTBM = (%d,%d), want (2,2)", s.Row, s.Col)
878- }
879- s.Write([]byte("\x1b[99;1H"))
880- if s.Row != 3 || s.Col != 0 {
881- t.Fatalf("DECOM clamped cursor after DECSTBM = (%d,%d), want (3,0)", s.Row, s.Col)
882- }
883- },
884- },
885- {
886- name: "DECOM reset homes and restores full-frame addressing",
887- run: func(t *testing.T) {
888- s := NewScreen(6, 5)
889- s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[?6l"))
890- if s.Row != 0 || s.Col != 0 {
891- t.Fatalf("DECOM reset cursor = (%d,%d), want (0,0)", s.Row, s.Col)
892- }
893-
894- s.Write([]byte("\x1b[5;6H"))
895- if s.Row != 4 || s.Col != 5 {
896- t.Fatalf("full-frame cursor = (%d,%d), want (4,5)", s.Row, s.Col)
897- }
898- },
899- },
900- {
901- name: "private alternate screen mode restores normal screen on exit",
902- run: func(t *testing.T) {
903- s := NewScreen(20, 5)
904- s.Write([]byte("normal-line"))
905- s.Write([]byte("\x1b[?1049h\x1b[2J\x1b[HLOCKED\x1b[3;4HAPP"))
906- s.Write([]byte("\x1b[?1049lafter"))
907-
908- if got := lineText(s, 0); got != "normal-lineafter " {
909- t.Fatalf("row 0 = %q, want restored normal screen with post-exit text", got)
910- }
911- if got := lineText(s, 2); got != " " {
912- t.Fatalf("alternate content leaked into row 2: %q", got)
913- }
914- },
915- },
916- }
917-
918- for _, tt := range tests {
919- t.Run(tt.name, tt.run)
920- }
921-}
922-
923-func TestCSIScrollUp(t *testing.T) {
924- tests := []struct {
925- name string
926- run func(t *testing.T)
927- }{
928- {
929- name: "CSI S with no param scrolls up by 1 and preserves cursor",
930- run: func(t *testing.T) {
931- s := NewScreen(5, 4)
932- s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
933- s.ClearDamage()
934-
935- // CSI S with no param → scroll up by 1.
936- s.Write([]byte("\x1b[S"))
937-
938- // Content should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = DDDDD, row 3 = blank.
939- assertCell(t, s, 0, 0, 'B')
940- assertCell(t, s, 0, 1, 'C')
941- assertCell(t, s, 0, 2, 'D')
942- assertCell(t, s, 0, 3, ' ')
943-
944- // Cursor should NOT have moved.
945- if s.Col != 5 || s.Row != 3 {
946- t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
947- }
948-
949- d := s.Damage()
950- if !hasDamageKind(d, renderer.DamageScrollUp) {
951- t.Errorf("expected DamageScrollUp, got %v", damageKinds(d))
952- }
953- },
954- },
955- {
956- name: "CSI 2S scrolls up by an explicit count",
957- run: func(t *testing.T) {
958- s := NewScreen(5, 4)
959- s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
960- s.ClearDamage()
961-
962- // CSI 2 S → scroll up by 2.
963- s.Write([]byte("\x1b[2S"))
964-
965- // Content: row 0 = CCCCC, row 1 = DDDDD, row 2 = blank, row 3 = blank.
966- assertCell(t, s, 0, 0, 'C')
967- assertCell(t, s, 0, 1, 'D')
968- assertCell(t, s, 0, 2, ' ')
969- assertCell(t, s, 0, 3, ' ')
970-
971- // Cursor preserved.
972- if s.Col != 5 || s.Row != 3 {
973- t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
974- }
975- },
976- },
977- {
978- name: "CSI 100S on a 3-row screen clamps to the full screen",
979- run: func(t *testing.T) {
980- s := NewScreen(5, 3)
981- s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
982- s.ClearDamage()
983-
984- // CSI 100 S on a 3-row screen → clamp to 3 (entire screen blank).
985- s.Write([]byte("\x1b[100S"))
986-
987- // All rows should be blank.
988- for y := range 3 {
989- for x := range 5 {
990- assertCell(t, s, x, y, ' ')
991- }
992- }
993- },
994- },
995- {
996- name: "CSI 0S defaults to a scroll count of 1",
997- run: func(t *testing.T) {
998- s := NewScreen(5, 3)
999- s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
1000- s.ClearDamage()
1001-
1002- // CSI 0 S → default to 1 (parameter 0 means default in VT spec).
1003- s.Write([]byte("\x1b[0S"))
1004-
1005- // Row 0 should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = blank.
1006- assertCell(t, s, 0, 0, 'B')
1007- assertCell(t, s, 0, 1, 'C')
1008- assertCell(t, s, 0, 2, ' ')
1009- },
1010- },
1011- {
1012- name: "CSI 3S reports scroll count 3 and blanked-row text damage",
1013- run: func(t *testing.T) {
1014- s := NewScreen(5, 4)
1015- s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
1016- s.ClearDamage()
1017-
1018- s.Write([]byte("\x1b[3S")) // scroll up by 3
1019-
1020- d := s.Damage()
1021- var scrollDamage *renderer.Damage
1022- for i, dd := range d {
1023- if dd.Kind == renderer.DamageScrollUp {
1024- scrollDamage = &d[i]
1025- break
1026- }
1027- }
1028- if scrollDamage == nil {
1029- t.Fatal("expected DamageScrollUp")
1030- }
1031- if scrollDamage.Count != 3 {
1032- t.Errorf("scroll count = %d, want 3", scrollDamage.Count)
1033- }
1034- if scrollDamage.Width != 5 || scrollDamage.Height != 4 {
1035- t.Errorf("scroll size = %dx%d, want 5x4", scrollDamage.Width, scrollDamage.Height)
1036- }
1037-
1038- // Should also have text damage for the blanked rows (bottom 3 rows).
1039- if !hasDamageKind(d, renderer.DamageText) {
1040- t.Error("expected DamageText for blanked rows")
1041- }
1042- },
1043- },
1044- {
1045- name: "cursor position is preserved across a scroll-up",
1046- run: func(t *testing.T) {
1047- s := NewScreen(10, 5)
1048- s.Write([]byte("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE")) // fill all 5 rows
1049- s.ClearDamage()
1050-
1051- // Move cursor to middle of row 2.
1052- s.Write([]byte("\x1b[3;3H")) // row=3, col=3
1053- s.Write([]byte("\x1b[2S")) // scroll up by 2
1054-
1055- // Cursor should still be at 1-indexed (3,3) = 0-indexed (2,2).
1056- if s.Row != 2 || s.Col != 2 {
1057- t.Errorf("cursor at row=%d col=%d, want row=2 col=2", s.Row, s.Col)
1058- }
1059- },
1060- },
1061- }
1062-
1063- for _, tt := range tests {
1064- t.Run(tt.name, tt.run)
1065- }
1066-}
D pkg/vt/screen_cursor.go +0 -89
@@ -1,89 +0,0 @@
1-package vt
2-
3-import (
4- "strconv"
5- "strings"
6-
7- "github.com/bnema/vev/pkg/renderer"
8-)
9-
10-type cursorState struct {
11- row int
12- col int
13- style renderer.Style
14- originMode bool
15- insertMode bool
16- saved bool
17-}
18-
19-func (s *Screen) cursorReportRow() int {
20- if s.originMode {
21- return clamp(s.Row, s.cursorMinRow(), s.cursorMaxRow()) - s.cursorMinRow() + 1
22- }
23- return s.Row + 1
24-}
25-
26-func (s *Screen) cursorMinRow() int {
27- if s.originMode {
28- return clamp(s.scrollTop, 0, s.Frame.Height-1)
29- }
30- return 0
31-}
32-
33-func (s *Screen) cursorMaxRow() int {
34- if s.originMode {
35- return clamp(s.scrollBottom, 0, s.Frame.Height-1)
36- }
37- return max(s.Frame.Height-1, 0)
38-}
39-
40-func (s *Screen) addressedRow(row int) int {
41- if s.originMode {
42- return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
43- }
44- return clamp(row-1, 0, s.Frame.Height-1)
45-}
46-
47-func (s *Screen) addressCursor(row, col int) {
48- s.Row = s.addressedRow(row)
49- s.Col = clamp(col-1, 0, s.Frame.Width-1)
50-}
51-
52-func (s *Screen) homeCursor() {
53- s.addressCursor(1, 1)
54-}
55-
56-func (s *Screen) applyCursorStyle(params string) {
57- if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
58- return
59- }
60- styleParam := strings.TrimSuffix(params, " ")
61- style := 0
62- if styleParam != "" {
63- v, err := strconv.Atoi(styleParam)
64- if err != nil {
65- return
66- }
67- style = v
68- }
69- if style < 0 || style > 6 {
70- return
71- }
72- s.cursorStyle = style
73- s.cursorStyleSet = true
74-}
75-
76-func (s *Screen) saveCursor() {
77- s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true}
78-}
79-
80-func (s *Screen) restoreCursor() {
81- if !s.savedCursor.saved {
82- return
83- }
84- s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1)
85- s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1)
86- s.Style = s.savedCursor.style
87- s.originMode = s.savedCursor.originMode
88- s.insertMode = s.savedCursor.insertMode
89-}
D pkg/vt/screen_damage.go +0 -74
@@ -1,74 +0,0 @@
1-package vt
2-
3-import "github.com/bnema/vev/pkg/renderer"
4-
5-// maxPendingDamage bounds metadata retained while no render transaction can
6-// acknowledge a screen. Saturation falls back to one exact full redraw.
7-const maxPendingDamage = 1024
8-
9-// DamageCapture is an immutable copy of pending damage at one screen generation.
10-type DamageCapture struct {
11- Damage []renderer.Damage
12- Generation uint64
13-}
14-
15-// Damage returns the current damage list. The caller must not modify the
16-// returned slice; ClearDamage must be called after the damage is consumed.
17-func (s *Screen) Damage() []renderer.Damage { return s.damage }
18-func (s *Screen) ClearDamage() {
19- s.damage = s.damage[:0]
20- s.damageSaturated = false
21- s.damageFullRedrawSticky = false
22-}
23-
24-// CaptureDamage snapshots pending damage without consuming it.
25-func (s *Screen) CaptureDamage() DamageCapture {
26- return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration}
27-}
28-
29-// AcknowledgeDamage consumes a capture only if no screen mutation occurred
30-// since it was taken. A stale acknowledgement conservatively requests a full
31-// redraw, ensuring intervening writes remain visible to the next capture.
32-func (s *Screen) AcknowledgeDamage(generation uint64) bool {
33- if generation != s.damageGeneration {
34- s.fullRedraw()
35- return false
36- }
37- s.ClearDamage()
38- return true
39-}
40-
41-func (s *Screen) record(d renderer.Damage) {
42- s.damageGeneration++
43- if s.damageSaturated || s.damageFullRedrawSticky {
44- return
45- }
46- if len(s.damage) >= maxPendingDamage {
47- s.damage = []renderer.Damage{renderer.FullRedraw()}
48- s.damageSaturated = true
49- return
50- }
51- // Replace FullRedraw with the first concrete damage item.
52- if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
53- s.damage[0] = d
54- return
55- }
56- // Coalesce adjacent single-cell text damage on the same line.
57- if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 {
58- last := &s.damage[len(s.damage)-1]
59- if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 {
60- last.Width++
61- return
62- }
63- }
64- s.damage = append(s.damage, d)
65-}
66-
67-func (s *Screen) fullRedraw() {
68- s.damageGeneration++
69- s.damage = []renderer.Damage{renderer.FullRedraw()}
70- // A structural frame replacement (such as DEC 1049 exit) cannot be
71- // represented by the following text damage alone. Retain the redraw until
72- // the render owner acknowledges this screen generation.
73- s.damageFullRedrawSticky = true
74-}
D pkg/vt/screen_damage_test.go +0 -240
@@ -1,240 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7- "github.com/stretchr/testify/require"
8-)
9-
10-func TestScrollDamage(t *testing.T) {
11- tests := []struct {
12- name string
13- run func(t *testing.T)
14- }{
15- {
16- name: "scroll produces both DamageScrollUp and DamageText",
17- run: func(t *testing.T) {
18- s := NewScreen(5, 2)
19- // Fill screen then cause a scroll.
20- s.Write([]byte("AAAAABBBBB")) // both rows filled
21- s.ClearDamage()
22-
23- s.Write([]byte("CCCCC")) // forces a scroll up
24-
25- d := s.Damage()
26- if len(d) == 0 {
27- t.Fatal("expected damage after scroll")
28- }
29-
30- if !hasDamageKind(d, renderer.DamageScrollUp) {
31- t.Errorf("expected DamageScrollUp in %v", damageKinds(d))
32- }
33- if !hasDamageKind(d, renderer.DamageText) {
34- t.Errorf("expected DamageText in %v", damageKinds(d))
35- }
36- },
37- },
38- {
39- name: "scroll damage coordinates match the full-width scrolled region",
40- run: func(t *testing.T) {
41- s := NewScreen(5, 3)
42- s.Write([]byte("AAAAABBBBBCCCCC")) // fill all 3 rows
43- s.ClearDamage()
44-
45- // Write another char to force scroll.
46- s.Write([]byte("D"))
47-
48- d := s.Damage()
49- var scrollDamage *renderer.Damage
50- for i, dd := range d {
51- if dd.Kind == renderer.DamageScrollUp {
52- scrollDamage = &d[i]
53- break
54- }
55- }
56- if scrollDamage == nil {
57- t.Fatal("expected scroll damage")
58- }
59- if scrollDamage.X != 0 || scrollDamage.Y != 0 {
60- t.Errorf("scroll position: (%d,%d), want (0,0)", scrollDamage.X, scrollDamage.Y)
61- }
62- if scrollDamage.Width != 5 || scrollDamage.Height != 3 {
63- t.Errorf("scroll size: %dx%d, want 5x3", scrollDamage.Width, scrollDamage.Height)
64- }
65- if scrollDamage.Count != 1 {
66- t.Errorf("scroll count = %d, want 1", scrollDamage.Count)
67- }
68- },
69- },
70- }
71-
72- for _, tt := range tests {
73- t.Run(tt.name, tt.run)
74- }
75-}
76-
77-func TestDamageCoalescingBehavior(t *testing.T) {
78- tests := []struct {
79- name string
80- run func(t *testing.T)
81- }{
82- {
83- name: "adjacent single-cell writes coalesce into one damage entry",
84- run: func(t *testing.T) {
85- s := NewScreen(20, 3)
86- s.Write([]byte("ABC")) // Three adjacent chars on one line
87-
88- d := s.Damage()
89- // Should be coalesced into a single DamageText.
90- if len(d) != 1 {
91- t.Fatalf("expected 1 coalesced damage, got %d: %+v", len(d), d)
92- }
93- if d[0].Kind != renderer.DamageText || d[0].X != 0 || d[0].Y != 0 || d[0].Width != 3 || d[0].Height != 1 {
94- t.Errorf("unexpected coalesced damage: %+v", d[0])
95- }
96- },
97- },
98- {
99- name: "newline breaks coalescing across lines",
100- run: func(t *testing.T) {
101- s := NewScreen(10, 3)
102- s.Write([]byte("AB\nCD"))
103-
104- d := s.Damage()
105- // "AB" on line 0, "CD" on line 1 → two separate DamageText items.
106- textCount := 0
107- for _, dd := range d {
108- if dd.Kind == renderer.DamageText {
109- textCount++
110- }
111- }
112- if textCount < 2 {
113- t.Errorf("expected at least 2 DamageText items (one per line), got %d", textCount)
114- }
115- },
116- },
117- {
118- name: "writing replaces the initial FullRedraw with text damage",
119- run: func(t *testing.T) {
120- s := NewScreen(5, 3)
121- // NewScreen already has FullRedraw in damage.
122- d := s.Damage()
123- if len(d) != 1 || d[0].Kind != renderer.DamageFullRedraw {
124- t.Fatalf("expected single FullRedraw, got %+v", d)
125- }
126-
127- // Writing should replace it with text damage.
128- s.Write([]byte("X"))
129- d = s.Damage()
130- if len(d) == 0 {
131- t.Fatal("expected damage after write")
132- }
133- if d[0].Kind == renderer.DamageFullRedraw {
134- t.Error("FullRedraw should have been replaced by text damage")
135- }
136- },
137- },
138- }
139-
140- for _, tt := range tests {
141- t.Run(tt.name, tt.run)
142- }
143-}
144-
145-func TestDamageSaturationBoundsPendingRecords(t *testing.T) {
146- s := NewScreen(2, 2)
147- s.ClearDamage()
148-
149- for i := range 1_100 {
150- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: i % 2, Width: 1, Height: 1, Count: 1})
151- }
152- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
153-
154- // Saturation is sticky until the render owner acknowledges or clears it.
155- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
156- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
157-
158- s.ClearDamage()
159- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
160- require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, s.Damage())
161-}
162-
163-func TestClearDamage(t *testing.T) {
164- tests := []struct {
165- name string
166- }{
167- {name: "ClearDamage empties the damage list"},
168- }
169-
170- for _, tt := range tests {
171- t.Run(tt.name, func(t *testing.T) {
172- s := NewScreen(5, 3)
173- s.Write([]byte("Hello"))
174- s.ClearDamage()
175- d := s.Damage()
176- if len(d) != 0 {
177- t.Fatalf("expected empty damage after ClearDamage, got %+v", d)
178- }
179- })
180- }
181-}
182-
183-func TestDamageReturnsReference(t *testing.T) {
184- tests := []struct {
185- name string
186- }{
187- {name: "Damage() returns a reference to the internal slice"},
188- }
189-
190- for _, tt := range tests {
191- t.Run(tt.name, func(t *testing.T) {
192- // Damage() should return a reference to internal slice (no copy).
193- s := NewScreen(10, 3)
194- s.Write([]byte("X"))
195- d1 := s.Damage()
196- // Internal slice should be the same (or at least reference the same backing).
197- if len(d1) == 0 {
198- t.Fatal("expected damage")
199- }
200- // Write more to trigger record.
201- s.Write([]byte("Y"))
202- d2 := s.Damage()
203- _ = d2
204- // Both d1 and d2 reference the internal slice; we just don't crash.
205- })
206- }
207-}
208-
209-func TestDamageCaptureAcknowledgementPreservesConcurrentWrite(t *testing.T) {
210- s := NewScreen(8, 2)
211- s.ClearDamage()
212- s.Write([]byte("a"))
213-
214- captured := s.CaptureDamage()
215- require.NotEmpty(t, captured.Damage)
216- s.Write([]byte("b"))
217-
218- require.False(t, s.AcknowledgeDamage(captured.Generation))
219- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage(), "stale acknowledgement must retain later writes conservatively")
220-}
221-
222-func TestDamageCaptureAcknowledgementClearsMatchingGeneration(t *testing.T) {
223- s := NewScreen(8, 2)
224- s.ClearDamage()
225- s.Write([]byte("a"))
226-
227- captured := s.CaptureDamage()
228- require.True(t, s.AcknowledgeDamage(captured.Generation))
229- require.Empty(t, s.Damage())
230-}
231-
232-func TestDamageCaptureOwnsDamageSlice(t *testing.T) {
233- s := NewScreen(8, 2)
234- s.ClearDamage()
235- s.Write([]byte("a"))
236-
237- captured := s.CaptureDamage()
238- s.ClearDamage()
239- require.NotEmpty(t, captured.Damage, "captured damage must not alias mutable screen storage")
240-}
D pkg/vt/screen_helpers_test.go +0 -103
@@ -1,103 +0,0 @@
1-package vt
2-
3-import (
4- "reflect"
5- "testing"
6-
7- "github.com/bnema/vev/pkg/renderer"
8-)
9-
10-func damageKinds(d []renderer.Damage) []renderer.DamageKind {
11- ks := make([]renderer.DamageKind, len(d))
12- for i, dd := range d {
13- ks[i] = dd.Kind
14- }
15- return ks
16-}
17-
18-func hasDamageKind(d []renderer.Damage, kind renderer.DamageKind) bool {
19- for _, dd := range d {
20- if dd.Kind == kind {
21- return true
22- }
23- }
24- return false
25-}
26-
27-func cellAt(s *Screen, x, y int) renderer.Cell {
28- return s.Frame.At(x, y)
29-}
30-
31-func assertFramesEqual(t *testing.T, got, want renderer.Frame) {
32- t.Helper()
33- if !reflect.DeepEqual(got, want) {
34- t.Fatal("frame changed unexpectedly")
35- }
36-}
37-
38-func assertCell(t *testing.T, s *Screen, x, y int, expected rune) {
39- t.Helper()
40- c := cellAt(s, x, y)
41- if c.Rune != expected {
42- t.Errorf("cell(%d,%d) rune = %q, want %q", x, y, c.Rune, expected)
43- }
44- if c.Continuation {
45- t.Errorf("cell(%d,%d) unexpectedly marked as continuation", x, y)
46- }
47-}
48-
49-// assertContinuation asserts the cell at (x,y) is the right half of a
50-// wide-character pair (Continuation set, Rune 0).
51-func assertContinuation(t *testing.T, s *Screen, x, y int) {
52- t.Helper()
53- c := cellAt(s, x, y)
54- if !c.Continuation {
55- t.Errorf("cell(%d,%d) expected continuation, got rune=%q continuation=%v", x, y, c.Rune, c.Continuation)
56- }
57- if c.Rune != 0 {
58- t.Errorf("cell(%d,%d) continuation rune = %q, want 0", x, y, c.Rune)
59- }
60-}
61-
62-// assertBlank asserts the cell at (x,y) is a blank default cell.
63-func assertBlank(t *testing.T, s *Screen, x, y int) {
64- t.Helper()
65- c := cellAt(s, x, y)
66- if c.Rune != ' ' || c.Continuation {
67- t.Errorf("cell(%d,%d) = {rune:%q cont:%v}, want blank space", x, y, c.Rune, c.Continuation)
68- }
69-}
70-
71-func assertNoOrphanWideCells(t *testing.T, s *Screen) {
72- t.Helper()
73- for y := range s.Frame.Height {
74- for x := range s.Frame.Width {
75- c := cellAt(s, x, y)
76- if c.Continuation {
77- if x == 0 || cellAt(s, x-1, y).Continuation || renderer.RuneWidth(cellAt(s, x-1, y).Rune) != 2 {
78- t.Fatalf("orphan continuation at (%d,%d)", x, y)
79- }
80- continue
81- }
82- if renderer.RuneWidth(c.Rune) == 2 && (x+1 >= s.Frame.Width || !cellAt(s, x+1, y).Continuation) {
83- t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune)
84- }
85- }
86- }
87-}
88-
89-func lineText(s *Screen, y int) string {
90- out := make([]rune, s.Frame.Width)
91- for x := range s.Frame.Width {
92- out[x] = s.Frame.At(x, y).Rune
93- }
94- return string(out)
95-}
96-
97-func rowString(row []renderer.Cell) string {
98- out := make([]rune, len(row))
99- for i, cell := range row {
100- out[i] = cell.Rune
101- }
102- return string(out)
103-}
D pkg/vt/screen_integration_test.go +0 -118
@@ -1,118 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7-)
8-
9-func TestZshAutosuggestionRepaintEmitsForwardDamage(t *testing.T) {
10- s := NewScreen(20, 2)
11- r := renderer.New(renderer.Capabilities{})
12-
13- s.Write([]byte("❯ "))
14- if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
15- t.Fatal(err)
16- }
17- s.ClearDamage()
18-
19- s.Write([]byte("h"))
20- if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
21- t.Fatal(err)
22- }
23- s.ClearDamage()
24-
25- s.Write([]byte("\x1b[31mh\x1b[39m"))
26- s.Write([]byte("\x1b[38mello\x1b[39m\r \x1b[31mh\x1b[39m"))
27-
28- for y := range s.Frame.Height {
29- for x := range s.Frame.Width {
30- r := s.Frame.At(x, y).Rune
31- if r == '\r' || r == '\n' {
32- t.Fatalf("cell(%d,%d) contains control rune %q", x, y, r)
33- }
34- }
35- }
36-
37- out, err := r.Draw(s.Frame, s.Damage())
38- if err != nil {
39- t.Fatal(err)
40- }
41- for _, b := range out {
42- if b == '\r' || b == '\n' {
43- t.Fatalf("incremental output contains raw control byte %q: %q", b, string(out))
44- }
45- }
46-}
47-
48-func TestIntegrationScenarios(t *testing.T) {
49- tests := []struct {
50- name string
51- run func(t *testing.T)
52- }{
53- {
54- name: "renderer stays in sync after VT edit damage",
55- run: func(t *testing.T) {
56- s := NewScreen(8, 2)
57- r := renderer.New(renderer.Capabilities{})
58- s.Write([]byte("abcdef"))
59- if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
60- t.Fatal(err)
61- }
62- s.ClearDamage()
63-
64- s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
65- if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
66- t.Fatal(err)
67- }
68- s.ClearDamage()
69-
70- out, err := r.Draw(s.Frame, nil)
71- if err != nil {
72- t.Fatal(err)
73- }
74- if len(out) != 0 {
75- t.Fatalf("renderer emitted stale follow-up output after VT edit damage: %q", string(out))
76- }
77- },
78- },
79- {
80- name: "DCS sequence terminated by ST is ignored",
81- run: func(t *testing.T) {
82- s := NewScreen(20, 2)
83- s.Write([]byte("\x1bP+q696e646e\x1b\\prompt"))
84-
85- assertCell(t, s, 0, 0, 'p')
86- assertCell(t, s, 1, 0, 'r')
87- assertCell(t, s, 2, 0, 'o')
88- assertCell(t, s, 3, 0, 'm')
89- assertCell(t, s, 4, 0, 'p')
90- assertCell(t, s, 5, 0, 't')
91- if s.Col != 6 || s.Row != 0 {
92- t.Errorf("cursor at row=%d col=%d, want row=0 col=6", s.Row, s.Col)
93- }
94- },
95- },
96- {
97- name: "fish-like prompt redraw preserves typed characters",
98- run: func(t *testing.T) {
99- s := NewScreen(10, 2)
100- s.Write([]byte("> "))
101- s.Write([]byte("a\r\x1b[3C"))
102- s.Write([]byte("\x1b[91mb\x1b[39m\x1b[K\r\x1b[4C"))
103-
104- assertCell(t, s, 0, 0, '>')
105- assertCell(t, s, 1, 0, ' ')
106- assertCell(t, s, 2, 0, 'a')
107- assertCell(t, s, 3, 0, 'b')
108- if s.Row != 0 || s.Col != 4 {
109- t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
110- }
111- },
112- },
113- }
114-
115- for _, tt := range tests {
116- t.Run(tt.name, tt.run)
117- }
118-}
D pkg/vt/screen_modes.go +0 -236
@@ -1,236 +0,0 @@
1-package vt
2-
3-import "github.com/bnema/vev/pkg/renderer"
4-
5-type screenState struct {
6- frame renderer.Frame
7- buffer *buffer
8- row int
9- col int
10- style renderer.Style
11- scrollTop int
12- scrollBottom int
13- savedCursor cursorState
14- originMode bool
15- insertMode bool
16-}
17-
18-// SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
19-// is currently enabled by the child process.
20-func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
21-
22-// BracketedPasteMode reports whether DEC private mode 2004 is currently enabled
23-// by the child process.
24-func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
25-
26-// ColorSchemeMode reports whether DEC private mode 2031 is currently enabled.
27-func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode }
28-
29-// SetColorScheme updates the host color scheme and notifies subscribed child apps.
30-func (s *Screen) SetColorScheme(light bool) {
31- if s.colorSchemeSet && s.colorSchemeLight == light {
32- return
33- }
34- s.colorSchemeSet = true
35- s.colorSchemeLight = light
36- if s.colorSchemeMode {
37- s.respond(s.colorSchemeReport())
38- }
39-}
40-
41-// ClearColorScheme marks the host color scheme as unknown. Future child color
42-// scheme queries are silent until SetColorScheme supplies a known value again.
43-func (s *Screen) ClearColorScheme() {
44- s.colorSchemeSet = false
45-}
46-
47-// ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
48-// Hosts use this as a safety valve if a child enters synchronized update mode
49-// and never sends the matching end sequence.
50-func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
51-
52-// SetDefaultColors sets the terminal default foreground/background colors used
53-// to answer child OSC 10/11 color queries. Passing ok=false makes color queries
54-// silent until known colors are supplied again.
55-func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) {
56- s.defaultFG = fg
57- s.defaultBG = bg
58- s.defaultColorsKnown = ok
59-}
60-
61-func (s *Screen) CursorRow() int { return s.Row }
62-func (s *Screen) CursorCol() int { return s.Col }
63-func (s *Screen) CursorVisible() bool {
64- return s.cursorVisible
65-}
66-func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet }
67-func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
68-func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
69-
70-// TerminalTitle returns the latest title set by OSC 0 or OSC 2.
71-func (s *Screen) TerminalTitle() string { return s.terminalTitle }
72-
73-func (s *Screen) respond(b []byte) {
74- if s.OnResponse != nil {
75- s.OnResponse(b)
76- }
77-}
78-
79-func (s *Screen) colorSchemeReport() []byte {
80- if !s.colorSchemeSet {
81- return nil
82- }
83- if s.colorSchemeLight {
84- return []byte(ColorSchemeReportLight)
85- }
86- return []byte(ColorSchemeReportDark)
87-}
88-
89-func (s *Screen) reset() {
90- s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)
91- s.Frame = s.buffer.frame
92- s.Row, s.Col = 0, 0
93- s.Style = renderer.DefaultStyle()
94- s.escapeBuf = s.escapeBuf[:0]
95- s.savedCursor = cursorState{}
96- s.alternate = nil
97- s.originMode = false
98- s.insertMode = false
99- s.cursorVisible = true
100- s.cursorStyle = 0
101- s.cursorStyleSet = false
102- s.mouseMode = 0
103- s.mouseSGR = false
104- s.bracketedPaste = false
105- s.colorSchemeMode = false
106- s.resetScrollRegion()
107- s.fullRedraw()
108-}
109-
110-func (s *Screen) setScrollRegion(parts []int) {
111- if s.Frame.Height == 0 {
112- return
113- }
114- top, bottom := 1, s.Frame.Height
115- if len(parts) > 0 && parts[0] > 0 {
116- top = parts[0]
117- }
118- if len(parts) > 1 && parts[1] > 0 {
119- bottom = parts[1]
120- }
121- if top >= bottom {
122- s.resetScrollRegion()
123- } else {
124- s.scrollTop = clamp(top-1, 0, s.Frame.Height-1)
125- s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1)
126- if s.scrollTop >= s.scrollBottom {
127- s.resetScrollRegion()
128- }
129- }
130- s.homeCursor()
131-}
132-
133-func (s *Screen) resetScrollRegion() {
134- s.scrollTop = 0
135- if s.Frame.Height > 0 {
136- s.scrollBottom = s.Frame.Height - 1
137- } else {
138- s.scrollBottom = 0
139- }
140-}
141-
142-func (s *Screen) setMode(private bool, parts []int, enabled bool) {
143- if !private {
144- for _, mode := range parts {
145- switch mode {
146- case 4:
147- s.insertMode = enabled
148- default:
149- // Other ANSI modes (for example LNM) are intentionally ignored
150- // until the screen model implements their observable behavior.
151- continue
152- }
153- }
154- return
155- }
156- for _, mode := range parts {
157- switch mode {
158- case 6:
159- s.originMode = enabled
160- s.homeCursor()
161- case 47, 1047, 1049:
162- if enabled {
163- s.enterAlternateScreen()
164- } else {
165- s.exitAlternateScreen()
166- }
167- case 2026:
168- s.syncUpdateActive = enabled
169- case 2031:
170- s.colorSchemeMode = enabled
171- case 25:
172- s.cursorVisible = enabled
173- case 1000, 1002, 1003:
174- if enabled {
175- s.mouseMode = mode
176- } else if s.mouseMode == mode {
177- s.mouseMode = 0
178- }
179- case 1006:
180- s.mouseSGR = enabled
181- case 2004:
182- s.bracketedPaste = enabled
183- case 1, 1004, 1005:
184- // Trackable terminal modes that do not directly affect the current
185- // cell model yet. Consuming them prevents mode bytes from leaking.
186- continue
187- }
188- }
189-}
190-
191-func (s *Screen) enterAlternateScreen() {
192- if s.alternate == nil {
193- s.alternate = &screenState{
194- frame: cloneFrame(s.Frame),
195- buffer: s.buffer.clone(),
196- row: s.Row,
197- col: s.Col,
198- style: s.Style,
199- scrollTop: s.scrollTop,
200- scrollBottom: s.scrollBottom,
201- savedCursor: s.savedCursor,
202- originMode: s.originMode,
203- insertMode: s.insertMode,
204- }
205- }
206- s.buffer = s.newBuffer(s.Frame.Width, s.Frame.Height)
207- s.Frame = s.buffer.frame
208- s.Row, s.Col = 0, 0
209- s.Style = renderer.DefaultStyle()
210- s.savedCursor = cursorState{}
211- s.resetScrollRegion()
212- s.fullRedraw()
213-}
214-
215-func (s *Screen) exitAlternateScreen() {
216- if s.alternate == nil {
217- return
218- }
219- state := s.alternate
220- s.buffer = state.buffer
221- if s.buffer == nil {
222- s.buffer = bufferFromFrame(cloneFrame(state.frame))
223- s.fillMissingRowIDs(s.buffer)
224- }
225- s.Frame = s.buffer.frame
226- s.Row = clamp(state.row, 0, s.Frame.Height-1)
227- s.Col = clamp(state.col, 0, s.Frame.Width-1)
228- s.Style = state.style
229- s.scrollTop = state.scrollTop
230- s.scrollBottom = state.scrollBottom
231- s.savedCursor = state.savedCursor
232- s.originMode = state.originMode
233- s.insertMode = state.insertMode
234- s.alternate = nil
235- s.fullRedraw()
236-}
D pkg/vt/screen_modes_test.go +0 -228
@@ -1,228 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-)
6-
7-func TestESCSequences(t *testing.T) {
8- tests := []struct {
9- name string
10- run func(t *testing.T)
11- }{
12- {
13- name: "ESC = (DECKPAM) is ignored, following text is printable",
14- run: func(t *testing.T) {
15- s := NewScreen(10, 2)
16- s.Write([]byte("\x1b=abc"))
17-
18- assertCell(t, s, 0, 0, 'a')
19- assertCell(t, s, 1, 0, 'b')
20- assertCell(t, s, 2, 0, 'c')
21- if s.Col != 3 || s.Row != 0 {
22- t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col)
23- }
24- },
25- },
26- {
27- name: "ESC 7 / ESC 8 save and restore the cursor",
28- run: func(t *testing.T) {
29- s := NewScreen(10, 3)
30- s.Write([]byte("abc\x1b7\x1b[2;5HZZ\x1b8X"))
31-
32- assertCell(t, s, 0, 0, 'a')
33- assertCell(t, s, 1, 0, 'b')
34- assertCell(t, s, 2, 0, 'c')
35- assertCell(t, s, 3, 0, 'X')
36- assertCell(t, s, 4, 1, 'Z')
37- assertCell(t, s, 5, 1, 'Z')
38- if s.Row != 0 || s.Col != 4 {
39- t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
40- }
41- },
42- },
43- {
44- name: "ESC 7 / ESC 8 restore origin and insert modes",
45- run: func(t *testing.T) {
46- s := NewScreen(6, 5)
47- s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[4h\x1b7"))
48- s.Write([]byte("\x1b[?6l\x1b[4l\x1b8"))
49-
50- if !s.originMode {
51- t.Fatal("origin mode was not restored")
52- }
53- if !s.insertMode {
54- t.Fatal("insert mode was not restored")
55- }
56-
57- s.Write([]byte("\x1b[1;1Habcd\x1b[1;3HXY"))
58- if s.Row != 1 || s.Col != 4 {
59- t.Fatalf("cursor after restored origin and insert modes = (%d,%d), want (1,4)", s.Row, s.Col)
60- }
61- if got := lineText(s, 1); got != "abXYcd" {
62- t.Fatalf("line after restored insert mode = %q, want %q", got, "abXYcd")
63- }
64- },
65- },
66- {
67- name: "ESC D (index), ESC E (next line), ESC M (reverse index)",
68- run: func(t *testing.T) {
69- s := NewScreen(5, 3)
70- s.Write([]byte("A\x1bDB\x1bEC"))
71-
72- assertCell(t, s, 0, 0, 'A')
73- assertCell(t, s, 1, 1, 'B')
74- assertCell(t, s, 0, 2, 'C')
75-
76- s = NewScreen(5, 3)
77- s.Write([]byte("\x1b[2;1Hmid\r\x1bMtop"))
78- assertCell(t, s, 0, 0, 't')
79- assertCell(t, s, 0, 1, 'm')
80- },
81- },
82- }
83-
84- for _, tt := range tests {
85- t.Run(tt.name, tt.run)
86- }
87-}
88-
89-func TestScreenReportsSynchronizedUpdateMode(t *testing.T) {
90- s := NewScreen(10, 2)
91- s.Write([]byte("\x1b[?2026h"))
92- if !s.SyncUpdateActive() {
93- t.Fatal("sync update mode should be active")
94- }
95- s.Write([]byte("\x1b[?2026l"))
96- if s.SyncUpdateActive() {
97- t.Fatal("sync update mode should be inactive")
98- }
99-}
100-
101-func TestForceSyncEnd(t *testing.T) {
102- s := NewScreen(10, 2)
103- s.Write([]byte("\x1b[?2026h"))
104- if !s.SyncUpdateActive() {
105- t.Fatal("sync update mode should be active before forcing end")
106- }
107- s.ForceSyncEnd()
108- if s.SyncUpdateActive() {
109- t.Fatal("sync update mode should be inactive after forcing end")
110- }
111-}
112-
113-func TestScreenCursorAndMouseStateAccessors(t *testing.T) {
114- tests := []struct {
115- name string
116- seq string
117- check func(t *testing.T, s *Screen)
118- }{
119- {
120- name: "cursor position and visibility are exposed",
121- seq: "\x1b[2;3H\x1b[?25l",
122- check: func(t *testing.T, s *Screen) {
123- if s.CursorRow() != 1 || s.CursorCol() != 2 {
124- t.Fatalf("cursor = row %d col %d, want row 1 col 2", s.CursorRow(), s.CursorCol())
125- }
126- if s.CursorVisible() {
127- t.Fatal("cursor should be hidden")
128- }
129- s.Write([]byte("\x1b[?25h"))
130- if !s.CursorVisible() {
131- t.Fatal("cursor should be visible")
132- }
133- },
134- },
135- {
136- name: "mouse mode and SGR are exposed",
137- seq: "\x1b[?1002h\x1b[?1006h",
138- check: func(t *testing.T, s *Screen) {
139- mode, sgr := s.MouseMode()
140- if mode != 1002 || !sgr {
141- t.Fatalf("MouseMode() = (%d, %v), want (1002, true)", mode, sgr)
142- }
143- },
144- },
145- {
146- name: "reset restores cursor and mouse defaults",
147- seq: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1bc",
148- check: func(t *testing.T, s *Screen) {
149- mode, sgr := s.MouseMode()
150- if !s.CursorVisible() || mode != 0 || sgr {
151- t.Fatalf("after reset CursorVisible=%v MouseMode=(%d,%v), want true,(0,false)", s.CursorVisible(), mode, sgr)
152- }
153- },
154- },
155- }
156- for _, tt := range tests {
157- t.Run(tt.name, func(t *testing.T) {
158- s := NewScreen(10, 4)
159- s.Write([]byte(tt.seq))
160- tt.check(t, s)
161- })
162- }
163-}
164-
165-func TestScreenMouseModeDisableInactiveIsNoOp(t *testing.T) {
166- s := NewScreen(10, 2)
167- s.Write([]byte("\x1b[?1002h"))
168- s.Write([]byte("\x1b[?1000l"))
169- mode, _ := s.MouseMode()
170- if mode != 1002 {
171- t.Fatalf("mouse mode after disabling inactive 1000 = %d, want 1002", mode)
172- }
173- s.Write([]byte("\x1b[?1002l"))
174- mode, _ = s.MouseMode()
175- if mode != 0 {
176- t.Fatalf("mouse mode after disabling active 1002 = %d, want 0", mode)
177- }
178-}
179-
180-func TestScreenCursorStyleDECSCUSR(t *testing.T) {
181- tests := []struct {
182- name string
183- seq string
184- wantStyle int
185- wantSet bool
186- }{
187- {name: "explicit style", seq: "\x1b[5 q", wantStyle: 5, wantSet: true},
188- {name: "blank style parameter", seq: "\x1b[ q", wantStyle: 0, wantSet: true},
189- {name: "invalid low style is ignored", seq: "\x1b[-1 q", wantStyle: 0, wantSet: false},
190- {name: "invalid high style is ignored", seq: "\x1b[7 q", wantStyle: 0, wantSet: false},
191- {name: "XTVERSION is ignored", seq: "\x1b[>0q", wantStyle: 0, wantSet: false},
192- }
193- for _, tt := range tests {
194- t.Run(tt.name, func(t *testing.T) {
195- s := NewScreen(10, 2)
196- s.Write([]byte(tt.seq))
197- style, set := s.CursorStyle()
198- if style != tt.wantStyle || set != tt.wantSet {
199- t.Fatalf("CursorStyle() = (%d, %v), want (%d, %v)", style, set, tt.wantStyle, tt.wantSet)
200- }
201- })
202- }
203-}
204-
205-func TestScreenInvalidCursorStyleDoesNotOverwriteCurrentStyle(t *testing.T) {
206- s := NewScreen(10, 2)
207- s.Write([]byte("\x1b[5 q"))
208- s.Write([]byte("\x1b[99 q"))
209- style, set := s.CursorStyle()
210- if style != 5 || !set {
211- t.Fatalf("CursorStyle() = (%d, %v), want (5, true)", style, set)
212- }
213-}
214-
215-func TestScreenAltScreenActiveAccessor(t *testing.T) {
216- s := NewScreen(10, 2)
217- if s.AltScreenActive() {
218- t.Fatal("alt screen should start inactive")
219- }
220- s.Write([]byte("\x1b[?1049h"))
221- if !s.AltScreenActive() {
222- t.Fatal("alt screen should be active")
223- }
224- s.Write([]byte("\x1b[?1049l"))
225- if s.AltScreenActive() {
226- t.Fatal("alt screen should be inactive after exit")
227- }
228-}
D pkg/vt/screen_osc.go +0 -159
@@ -1,159 +0,0 @@
1-package vt
2-
3-import (
4- "strconv"
5- "strings"
6-
7- "github.com/bnema/vev/pkg/renderer"
8-)
9-
10-const (
11- progressStateClear = iota
12- progressStateNormal
13- progressStateError
14- progressStateIndeterminate
15- progressStatePaused
16-)
17-
18-func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) {
19- for i := 2; i < len(data); i++ {
20- switch data[i] {
21- case 0x07:
22- s.applyOSC(data[2:i], []byte{0x07})
23- s.handleOSC(data[2:i])
24- return i + 1, false
25- case 0x1b:
26- if i+1 < len(data) && data[i+1] == '\\' {
27- s.applyOSC(data[2:i], []byte{0x1b, '\\'})
28- s.handleOSC(data[2:i])
29- return i + 2, false
30- }
31- }
32- }
33- return 0, true
34-}
35-
36-func (s *Screen) applyOSC(payload, terminator []byte) {
37- if !s.defaultColorsKnown {
38- return
39- }
40-
41- var color renderer.RGB
42- switch string(payload) {
43- case "10;?":
44- color = s.defaultFG
45- case "11;?":
46- color = s.defaultBG
47- default:
48- return
49- }
50-
51- resp := make([]byte, 0, len(payload)+len("\x1b];rgb:0000/0000/0000")+len(terminator))
52- resp = append(resp, "\x1b]"...)
53- resp = append(resp, payload[:2]...)
54- resp = append(resp, ";rgb:"...)
55- resp = appendOSCColorComponent(resp, color.R)
56- resp = append(resp, '/')
57- resp = appendOSCColorComponent(resp, color.G)
58- resp = append(resp, '/')
59- resp = appendOSCColorComponent(resp, color.B)
60- resp = append(resp, terminator...)
61- s.respond(resp)
62-}
63-
64-func appendOSCColorComponent(dst []byte, c uint8) []byte {
65- const hex = "0123456789abcdef"
66- v := uint16(c)<<8 | uint16(c)
67- return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf])
68-}
69-
70-// handleOSC inspects a complete OSC payload (between "ESC ]" and its
71-// terminator). Terminal titles, notification sequences (OSC 9, OSC 777
72-// "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC
73-// is discarded.
74-
75-func (s *Screen) handleOSC(payload []byte) {
76- if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
77- s.handleOSC52(string(payload[len("52;"):]))
78- return
79- }
80- p := string(payload)
81- if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") {
82- s.terminalTitle = title
83- return
84- }
85- if p == "9;4" || strings.HasPrefix(p, "9;4;") {
86- s.handleProgress(p[len("9;4"):])
87- return
88- }
89- if s.OnNotify == nil {
90- return
91- }
92- switch {
93- case strings.HasPrefix(p, "9;"):
94- s.OnNotify("", p[len("9;"):])
95- case strings.HasPrefix(p, "777;"):
96- parts := strings.SplitN(p[len("777;"):], ";", 3)
97- if parts[0] != "notify" {
98- return
99- }
100- var title, body string
101- if len(parts) > 1 {
102- title = parts[1]
103- }
104- if len(parts) > 2 {
105- body = parts[2]
106- }
107- s.OnNotify(title, body)
108- }
109-}
110-
111-func (s *Screen) handleProgress(rest string) {
112- rest = strings.TrimPrefix(rest, ";")
113- if rest == "" {
114- return
115- }
116- token, _, _ := strings.Cut(rest, ";")
117- state, err := strconv.Atoi(token)
118- if err != nil {
119- return
120- }
121- switch state {
122- case progressStateNormal, progressStateIndeterminate, progressStatePaused:
123- s.progressState = state
124- case progressStateClear:
125- previous := s.progressState
126- s.progressState = state
127- if previous == progressStateNormal || previous == progressStateIndeterminate || previous == progressStatePaused {
128- if s.OnProgress != nil {
129- s.OnProgress(false)
130- }
131- }
132- case progressStateError:
133- previous := s.progressState
134- s.progressState = state
135- if previous != progressStateError {
136- if s.OnProgress != nil {
137- s.OnProgress(true)
138- }
139- }
140- }
141-}
142-
143-// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
144-// clipboard payload (selection may be empty; split on the first ";"). A
145-// clipboard query (data == "?") is always ignored — vev never answers
146-// clipboard queries. A payload with no second ";" is malformed and ignored.
147-
148-func (s *Screen) handleOSC52(rest string) {
149- _, data, ok := strings.Cut(rest, ";")
150- if !ok {
151- return
152- }
153- if data == "?" {
154- return
155- }
156- if s.OnClipboard != nil {
157- s.OnClipboard(data)
158- }
159-}
D pkg/vt/screen_osc_test.go +0 -429
@@ -1,429 +0,0 @@
1-package vt
2-
3-import (
4- "strings"
5- "testing"
6-
7- "github.com/stretchr/testify/require"
8-)
9-
10-func TestOnBellFiresForLoneBEL(t *testing.T) {
11- s := NewScreen(10, 2)
12- rang := 0
13- s.OnBell = func() { rang++ }
14- s.Write([]byte("hi\ahi\a"))
15- require.Equal(t, 2, rang)
16- require.Equal(t, "hihi", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
17-}
18-
19-func TestOnBellIgnoresOSCTerminator(t *testing.T) {
20- s := NewScreen(10, 2)
21- rang := 0
22- s.OnBell = func() { rang++ }
23- s.Write([]byte("\x1b]0;title\a")) // BEL terminates the OSC, it is not a bell
24- require.Equal(t, 0, rang)
25-}
26-
27-func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
28- s := NewScreen(10, 2)
29- s.Write([]byte("a\ab")) // no OnBell set: must not panic
30- require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
31-}
32-
33-func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
34- tests := []struct {
35- name string
36- seq string
37- want string
38- }{
39- {name: "OSC 0 BEL terminated", seq: "\x1b]0;zero title\x07", want: "zero title"},
40- {name: "OSC 0 ST terminated", seq: "\x1b]0;zero title\x1b\\", want: "zero title"},
41- {name: "OSC 2 BEL terminated", seq: "\x1b]2;two title\x07", want: "two title"},
42- {name: "OSC 2 ST terminated", seq: "\x1b]2;two title\x1b\\", want: "two title"},
43- }
44-
45- for _, tt := range tests {
46- t.Run(tt.name, func(t *testing.T) {
47- s := NewScreen(10, 2)
48- bells := 0
49- s.OnBell = func() { bells++ }
50- s.ClearDamage()
51-
52- s.Write([]byte(tt.seq))
53-
54- require.Equal(t, tt.want, s.TerminalTitle())
55- require.Equal(t, 0, bells)
56- require.Empty(t, s.Damage())
57- require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0)))
58- })
59- }
60-}
61-
62-func TestTerminalTitleHandlesSplitWritesReplacementAndClear(t *testing.T) {
63- s := NewScreen(10, 2)
64-
65- s.Write([]byte("\x1b]0;split"))
66- s.Write([]byte(" title\x1b"))
67- require.Empty(t, s.TerminalTitle())
68- s.Write([]byte("\\"))
69- require.Equal(t, "split title", s.TerminalTitle())
70-
71- s.Write([]byte("\x1b]2;replacement\x07"))
72- require.Equal(t, "replacement", s.TerminalTitle())
73-
74- s.Write([]byte("\x1b]0;\x07"))
75- require.Empty(t, s.TerminalTitle())
76-}
77-
78-func TestTerminalTitleIgnoresUnrelatedAndOversizedOSC(t *testing.T) {
79- s := NewScreen(10, 2)
80- s.Write([]byte("\x1b]0;retained\x07"))
81-
82- for _, seq := range []string{
83- "\x1b]1;icon title\x07",
84- "\x1b]20;not selector two\x1b\\",
85- "\x1b]777;other;x;y\x07",
86- } {
87- s.Write([]byte(seq))
88- require.Equal(t, "retained", s.TerminalTitle())
89- }
90-
91- payload := strings.Repeat("x", maxEscapeBufferLen+1)
92- s.Write([]byte("\x1b]2;" + payload))
93- require.Equal(t, "retained", s.TerminalTitle())
94- require.Empty(t, s.escapeBuf)
95-}
96-
97-func TestOnNotifyOSC9(t *testing.T) {
98- s := NewScreen(10, 2)
99- var gotTitle, gotBody string
100- calls := 0
101- s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
102- s.Write([]byte("\x1b]9;agent done\x07"))
103- require.Equal(t, 1, calls)
104- require.Equal(t, "", gotTitle)
105- require.Equal(t, "agent done", gotBody)
106-}
107-
108-func TestOSC9ProgressTransitions(t *testing.T) {
109- tests := []struct {
110- name string
111- seqs []string
112- want []bool
113- }{
114- {
115- name: "finish fires once",
116- seqs: []string{"9;4;1;50", "9;4;0;100", "9;4;0;100"},
117- want: []bool{false},
118- },
119- {
120- name: "error fires once repeat no fire",
121- seqs: []string{"9;4;2;0", "9;4;2;50"},
122- want: []bool{true},
123- },
124- {
125- name: "indeterminate clear fires",
126- seqs: []string{"9;4;3", "9;4;0"},
127- want: []bool{false},
128- },
129- {
130- name: "paused clear fires",
131- seqs: []string{"9;4;4;80", "9;4;0;80"},
132- want: []bool{false},
133- },
134- {
135- name: "ongoing updates silent",
136- seqs: []string{"9;4;1;10", "9;4;1;50", "9;4;3;90", "9;4;4;90"},
137- },
138- {
139- name: "bare clear silent",
140- seqs: []string{"9;4;0"},
141- },
142- {
143- name: "bare progress ignored",
144- seqs: []string{"9;4"},
145- },
146- {
147- name: "unparseable ignored",
148- seqs: []string{"9;4;bogus", "9;4;99", "9;4;1", "9;4;0"},
149- want: []bool{false},
150- },
151- {
152- name: "error active clear fires error then finish",
153- seqs: []string{"9;4;2;0", "9;4;1;50", "9;4;0;100"},
154- want: []bool{true, false},
155- },
156- {
157- name: "error clear only fires error",
158- seqs: []string{"9;4;2;0", "9;4;0;100"},
159- want: []bool{true},
160- },
161- }
162-
163- for _, tt := range tests {
164- t.Run(tt.name, func(t *testing.T) {
165- s := NewScreen(10, 2)
166- var got []bool
167- s.OnProgress = func(errored bool) { got = append(got, errored) }
168- for _, seq := range tt.seqs {
169- s.Write([]byte("\x1b]" + seq + "\x07"))
170- }
171- require.Equal(t, tt.want, got)
172- })
173- }
174-}
175-
176-func TestOSC9ProgressSplitAcrossWritesFiresOnce(t *testing.T) {
177- s := NewScreen(10, 2)
178- calls := 0
179- s.OnProgress = func(bool) { calls++ }
180- s.Write([]byte("\x1b]9;4;1"))
181- s.Write([]byte(";50\x07\x1b]9;4;0;100\x07"))
182- require.Equal(t, 1, calls)
183-}
184-
185-func TestOSC9ProgressNilCallbackDoesNotPanic(t *testing.T) {
186- s := NewScreen(10, 2)
187- require.NotPanics(t, func() {
188- s.Write([]byte("\x1b]9;4;1;50\x07"))
189- s.Write([]byte("\x1b]9;4;0;100\x07"))
190- s.Write([]byte("\x1b]9;4;2;0\x07"))
191- })
192-}
193-
194-func TestOSC9ProgressSTTerminated(t *testing.T) {
195- s := NewScreen(10, 2)
196- var got []bool
197- s.OnProgress = func(errored bool) { got = append(got, errored) }
198- s.Write([]byte("\x1b]9;4;1;50\x1b\\\x1b]9;4;0;100\x1b\\"))
199- require.Equal(t, []bool{false}, got)
200-}
201-
202-func TestOSC9ProgressStateTrackedBeforeCallbackSet(t *testing.T) {
203- s := NewScreen(10, 2)
204- s.Write([]byte("\x1b]9;4;1;50\x07"))
205- var got []bool
206- s.OnProgress = func(errored bool) { got = append(got, errored) }
207- s.Write([]byte("\x1b]9;4;0;100\x07"))
208- require.Equal(t, []bool{false}, got)
209-}
210-
211-func TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
212- s := NewScreen(10, 2)
213- calls := 0
214- s.OnNotify = func(string, string) { calls++ }
215- s.Write([]byte("\x1b]9;4;1;50\x07"))
216- s.Write([]byte("\x1b]9;4;0;100\x07"))
217- s.Write([]byte("\x1b]9;4;2;0\x07"))
218- require.Equal(t, 0, calls)
219-}
220-
221-func TestOnNotifyOSC940RemainsGeneric(t *testing.T) {
222- s := NewScreen(10, 2)
223- var gotTitle, gotBody string
224- calls := 0
225- s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
226- s.Write([]byte("\x1b]9;40;not progress\x07"))
227- require.Equal(t, 1, calls)
228- require.Equal(t, "", gotTitle)
229- require.Equal(t, "40;not progress", gotBody)
230-}
231-
232-func TestOnNotifyOSC777STTerminated(t *testing.T) {
233- s := NewScreen(10, 2)
234- var gotTitle, gotBody string
235- calls := 0
236- s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
237- s.Write([]byte("\x1b]777;notify;Claude;needs input\x1b\\"))
238- require.Equal(t, 1, calls)
239- require.Equal(t, "Claude", gotTitle)
240- require.Equal(t, "needs input", gotBody)
241-}
242-
243-func TestOnNotifyIgnoresOtherOSC(t *testing.T) {
244- s := NewScreen(10, 2)
245- calls := 0
246- s.OnNotify = func(string, string) { calls++ }
247- s.Write([]byte("\x1b]0;window title\x07")) // title: discarded
248- s.Write([]byte("\x1b]777;other;x;y\x1b\\")) // not "notify": discarded
249- require.Equal(t, 0, calls)
250-}
251-
252-func TestOnNotifySplitAcrossWrites(t *testing.T) {
253- s := NewScreen(10, 2)
254- calls := 0
255- s.OnNotify = func(string, string) { calls++ }
256- s.Write([]byte("\x1b]9;par"))
257- s.Write([]byte("tial\x07"))
258- require.Equal(t, 1, calls)
259-}
260-
261-func TestOnClipboardBELTerminated(t *testing.T) {
262- s := NewScreen(10, 2)
263- var got string
264- calls := 0
265- s.OnClipboard = func(b64 string) { got = b64; calls++ }
266- s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
267- require.Equal(t, 1, calls)
268- require.Equal(t, "aGVsbG8=", got)
269-}
270-
271-func TestOnClipboardSTTerminated(t *testing.T) {
272- s := NewScreen(10, 2)
273- var got string
274- calls := 0
275- s.OnClipboard = func(b64 string) { got = b64; calls++ }
276- s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
277- require.Equal(t, 1, calls)
278- require.Equal(t, "aGVsbG8=", got)
279-}
280-
281-func TestOnClipboardQueryIgnored(t *testing.T) {
282- s := NewScreen(10, 2)
283- calls := 0
284- s.OnClipboard = func(string) { calls++ }
285- s.Write([]byte("\x1b]52;c;?\x07"))
286- require.Equal(t, 0, calls)
287-}
288-
289-func TestOnClipboardEmptySelection(t *testing.T) {
290- s := NewScreen(10, 2)
291- var got string
292- calls := 0
293- s.OnClipboard = func(b64 string) { got = b64; calls++ }
294- s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
295- require.Equal(t, 1, calls)
296- require.Equal(t, "aGVsbG8=", got)
297-}
298-
299-func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
300- s := NewScreen(10, 2)
301- require.NotPanics(t, func() {
302- s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
303- })
304-}
305-
306-func TestOnClipboardSplitAcrossWrites(t *testing.T) {
307- s := NewScreen(10, 2)
308- var got string
309- calls := 0
310- s.OnClipboard = func(b64 string) { got = b64; calls++ }
311- s.Write([]byte("\x1b]52;c;aGVs"))
312- s.Write([]byte("bG8=\x07"))
313- require.Equal(t, 1, calls)
314- require.Equal(t, "aGVsbG8=", got)
315-}
316-
317-func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
318- s := NewScreen(10, 2)
319- calls := 0
320- s.OnClipboard = func(string) { calls++ }
321- s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
322- require.Equal(t, 0, calls)
323-}
324-
325-func TestOSCSequences(t *testing.T) {
326- tests := []struct {
327- name string
328- run func(t *testing.T)
329- }{
330- {
331- name: "BEL-terminated OSC is ignored",
332- run: func(t *testing.T) {
333- s := NewScreen(20, 2)
334- s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ "))
335-
336- assertCell(t, s, 0, 0, 'f')
337- assertCell(t, s, 1, 0, 'i')
338- assertCell(t, s, 2, 0, 's')
339- assertCell(t, s, 3, 0, 'h')
340- assertCell(t, s, 4, 0, '$')
341- assertCell(t, s, 5, 0, ' ')
342- if s.Col != 6 || s.Row != 0 {
343- t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
344- }
345- },
346- },
347- {
348- name: "ST-terminated OSC is ignored",
349- run: func(t *testing.T) {
350- s := NewScreen(20, 2)
351- s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> "))
352-
353- assertCell(t, s, 0, 0, '>')
354- assertCell(t, s, 1, 0, ' ')
355- if s.Col != 2 || s.Row != 0 {
356- t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
357- }
358- },
359- },
360- {
361- name: "multiple OSC sequences in a row are all ignored",
362- run: func(t *testing.T) {
363- s := NewScreen(20, 2)
364- s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt"))
365-
366- assertCell(t, s, 0, 0, 'p')
367- assertCell(t, s, 1, 0, 'r')
368- assertCell(t, s, 2, 0, 'o')
369- assertCell(t, s, 3, 0, 'm')
370- assertCell(t, s, 4, 0, 'p')
371- assertCell(t, s, 5, 0, 't')
372- if s.Col != 6 || s.Row != 0 {
373- t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
374- }
375- },
376- },
377- {
378- name: "BEL-terminated OSC split across writes is ignored",
379- run: func(t *testing.T) {
380- s := NewScreen(20, 2)
381- s.Write([]byte("\x1b]0;~/Projects/ymux"))
382- s.Write([]byte(" - fish\x07fish$ "))
383-
384- assertCell(t, s, 0, 0, 'f')
385- assertCell(t, s, 1, 0, 'i')
386- assertCell(t, s, 2, 0, 's')
387- assertCell(t, s, 3, 0, 'h')
388- assertCell(t, s, 4, 0, '$')
389- assertCell(t, s, 5, 0, ' ')
390- if s.Col != 6 || s.Row != 0 {
391- t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
392- }
393- },
394- },
395- {
396- name: "ST-terminated OSC split across writes is ignored",
397- run: func(t *testing.T) {
398- s := NewScreen(20, 2)
399- s.Write([]byte("\x1b]133;A;click_events=1\x1b"))
400- s.Write([]byte("\\> "))
401-
402- assertCell(t, s, 0, 0, '>')
403- assertCell(t, s, 1, 0, ' ')
404- if s.Col != 2 || s.Row != 0 {
405- t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
406- }
407- },
408- },
409- {
410- name: "unterminated OSC sequence is bounded and dropped",
411- run: func(t *testing.T) {
412- s := NewScreen(20, 2)
413- payload := strings.Repeat("x", maxEscapeBufferLen+1)
414- s.Write([]byte("\x1b]0;" + payload))
415- s.Write([]byte("OK"))
416-
417- assertCell(t, s, 0, 0, 'O')
418- assertCell(t, s, 1, 0, 'K')
419- if len(s.escapeBuf) != 0 {
420- t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC", len(s.escapeBuf))
421- }
422- },
423- },
424- }
425-
426- for _, tt := range tests {
427- t.Run(tt.name, tt.run)
428- }
429-}
D pkg/vt/screen_resize.go +0 -92
@@ -1,92 +0,0 @@
1-package vt
2-
3-import "github.com/bnema/vev/pkg/renderer"
4-
5-func (s *Screen) Resize(width, height int) {
6- if width == s.Frame.Width && height == s.Frame.Height {
7- return
8- }
9- if width <= 0 || height <= 0 {
10- // renderer.Frame permits a collapsed geometry. There is no layout to
11- // preserve until a usable viewport is supplied again.
12- width, height = max(width, 0), max(height, 0)
13- s.buffer = s.newBuffer(width, height)
14- s.Row, s.Col = 0, 0
15- if state := s.alternate; state != nil {
16- state.buffer = s.newBuffer(width, height)
17- state.frame = state.buffer.frame
18- state.row, state.col = 0, 0
19- state.scrollTop, state.scrollBottom = 0, height-1
20- }
21- } else {
22- resize := func(b *buffer, row, col int, saved *cursorState, evict bool) (int, int) {
23- active := &bufferCursor{row: row, col: col}
24- var savedPoint *bufferCursor
25- if saved != nil && saved.saved {
26- savedPoint = &bufferCursor{row: saved.row, col: saved.col}
27- }
28- evicted, evictedBounds, evictedIDs := b.resize(width, height, active, savedPoint)
29- for i := range evictedIDs {
30- if evictedIDs[i] == 0 {
31- evictedIDs[i] = s.nextRowIDValue()
32- }
33- }
34- s.fillMissingRowIDs(b)
35- if evict {
36- for i, line := range evicted {
37- s.recordEvicted(line, evictedBounds[i], evictedIDs[i])
38- }
39- }
40- if savedPoint != nil {
41- saved.row, saved.col = savedPoint.row, clamp(savedPoint.col, 0, width-1)
42- }
43- return active.row, clamp(active.col, 0, width-1)
44- }
45-
46- if s.alternate != nil {
47- // Both screen states are reflowed independently, but active and saved
48- // cursors for each state are mapped by their single buffer layout pass.
49- s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, false)
50- state := s.alternate
51- if state.buffer == nil {
52- state.buffer = bufferFromFrame(state.frame)
53- s.fillMissingRowIDs(state.buffer)
54- }
55- state.row, state.col = resize(state.buffer, state.row, state.col, &state.savedCursor, true)
56- state.frame = state.buffer.frame
57- state.scrollTop, state.scrollBottom = 0, height-1
58- } else {
59- s.Row, s.Col = resize(s.buffer, s.Row, s.Col, &s.savedCursor, true)
60- }
61- }
62- s.Frame = s.buffer.frame
63- // A resize can split an in-flight escape sequence from the terminal state it
64- // was meant to mutate; keep the durable child state but discard partial bytes.
65- s.escapeBuf = s.escapeBuf[:0]
66- s.resetScrollRegion()
67- s.fullRedraw()
68-}
69-
70-// cloneFrame produces an independent copy in canonical layout: it copies the
71-// source's logical rows (via Row) into a fresh frame whose offsets are already
72-// canonical, so a rotated source is normalized in the clone.
73-func cloneFrame(frame renderer.Frame) renderer.Frame {
74- out := renderer.NewFrame(frame.Width, frame.Height)
75- for y := range frame.Height {
76- copy(out.Row(y), frame.Row(y))
77- }
78- return out
79-}
80-
81-func clamp(v, lo, hi int) int {
82- if hi < lo {
83- return lo
84- }
85- if v < lo {
86- return lo
87- }
88- if v > hi {
89- return hi
90- }
91- return v
92-}
D pkg/vt/screen_resize_test.go +0 -335
@@ -1,335 +0,0 @@
1-package vt
2-
3-import (
4- "math/rand"
5- "strings"
6- "testing"
7-
8- "github.com/bnema/vev/pkg/renderer"
9-)
10-
11-func TestResize(t *testing.T) {
12- setRow := func(s *Screen, y int, text string) {
13- for x, r := range []rune(text) {
14- if x >= s.Frame.Width {
15- break
16- }
17- s.Frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
18- }
19- }
20- rowString := func(s *Screen, y int) string {
21- runes := make([]rune, s.Frame.Width)
22- for x := range s.Frame.Width {
23- runes[x] = s.Frame.At(x, y).Rune
24- }
25- return string(runes)
26- }
27-
28- tests := []struct {
29- name string
30- run func(t *testing.T)
31- }{
32- {
33- name: "same size preserves content and skips FullRedraw",
34- run: func(t *testing.T) {
35- s := NewScreen(5, 3)
36- s.ClearDamage()
37- setRow(s, 0, "Hello")
38-
39- s.Resize(5, 3)
40-
41- if got := rowString(s, 0); got != "Hello" {
42- t.Fatalf("row 0 = %q, want Hello", got)
43- }
44- if d := s.Damage(); len(d) != 0 {
45- t.Fatalf("same-size resize damage = %v, want none", damageKinds(d))
46- }
47- },
48- },
49- {
50- name: "grow preserves visible content and cursor",
51- run: func(t *testing.T) {
52- s := NewScreen(5, 2)
53- setRow(s, 0, "Hello")
54- setRow(s, 1, "World")
55- s.Row, s.Col = 1, 4
56-
57- s.Resize(8, 4)
58-
59- if got := rowString(s, 0); got != "Hello " {
60- t.Fatalf("row 0 = %q", got)
61- }
62- if got := rowString(s, 1); got != "World " {
63- t.Fatalf("row 1 = %q", got)
64- }
65- if s.Row != 1 || s.Col != 4 {
66- t.Fatalf("cursor = (%d,%d), want (1,4)", s.Row, s.Col)
67- }
68- if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) {
69- t.Fatalf("resize damage = %v, want FullRedraw", damageKinds(s.Damage()))
70- }
71- },
72- },
73- {
74- name: "height shrink evicts top lines and follows cursor",
75- run: func(t *testing.T) {
76- s := NewScreen(4, 5)
77- for y, text := range []string{"0000", "1111", "2222", "3333", "4444"} {
78- setRow(s, y, text)
79- }
80- s.Row, s.Col = 4, 2
81- var evicted []string
82- s.OnLineEvicted = func(row []renderer.Cell) {
83- runes := make([]rune, len(row))
84- for i, c := range row {
85- runes[i] = c.Rune
86- }
87- evicted = append(evicted, string(runes))
88- }
89-
90- s.Resize(4, 3)
91-
92- if got, want := evicted, []string{"0000", "1111"}; strings.Join(got, ",") != strings.Join(want, ",") {
93- t.Fatalf("evicted = %v, want %v", got, want)
94- }
95- if got := rowString(s, 0); got != "2222" {
96- t.Fatalf("row 0 = %q", got)
97- }
98- if s.Row != 2 || s.Col != 2 {
99- t.Fatalf("cursor = (%d,%d), want (2,2)", s.Row, s.Col)
100- }
101- if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) {
102- t.Fatalf("shrink damage = %v, want FullRedraw", damageKinds(s.Damage()))
103- }
104- },
105- },
106- {
107- name: "soft wrapped lines reflow on shrink and growth",
108- run: func(t *testing.T) {
109- s := NewScreen(5, 3)
110- s.Write([]byte("abcdefgh"))
111-
112- s.Resize(3, 3)
113- if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.Join(got, ",") != "abc,def,gh " {
114- t.Fatalf("shrink rows = %q", got)
115- }
116-
117- s.Resize(5, 3)
118- if got := []string{rowString(s, 0), rowString(s, 1)}; strings.Join(got, ",") != "abcde,fgh " {
119- t.Fatalf("growth rows = %q", got)
120- }
121- },
122- },
123- {
124- name: "erase severs a soft wrap boundary",
125- run: func(t *testing.T) {
126- s := NewScreen(4, 2)
127- s.Write([]byte("abcdef"))
128- s.Write([]byte("\x1b[1;1H\x1b[2K"))
129-
130- // Erasing through the right edge ends the logical line: the
131- // blank head must not pull "ef" up a row on reflow.
132- s.Resize(3, 2)
133- if got := []string{rowString(s, 0), rowString(s, 1)}; strings.Join(got, ",") != " ,ef " {
134- t.Fatalf("erased soft line reflow = %q", got)
135- }
136- },
137- },
138- {
139- name: "in-place repaint does not merge with the row below",
140- run: func(t *testing.T) {
141- // Status UIs (docker buildx, shell prompt redraws) repaint a
142- // previously soft-wrapped row with CUP + text + EL and no
143- // newline. The stale soft link must not glue the repainted row
144- // to the unrelated row below: reflow would merge them, and the
145- // next shrink would truncate the merged line, losing cells.
146- s := NewScreen(40, 8)
147- s.Write([]byte(strings.Repeat("A", 40) + "BBBB\r\n"))
148- s.Write([]byte("tail-line\r\n"))
149- s.Write([]byte("\x1b[1;1HSHORT\x1b[K"))
150- s.Write([]byte("\x1b[4;1H"))
151-
152- s.Resize(60, 8)
153- if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.TrimRight(got[0], " ") != "SHORT" || strings.TrimRight(got[1], " ") != "BBBB" || strings.TrimRight(got[2], " ") != "tail-line" {
154- t.Fatalf("grow rows = %q", got)
155- }
156-
157- s.Resize(40, 8)
158- if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.TrimRight(got[0], " ") != "SHORT" || strings.TrimRight(got[1], " ") != "BBBB" || strings.TrimRight(got[2], " ") != "tail-line" {
159- t.Fatalf("round-trip rows = %q", got)
160- }
161- },
162- },
163- {
164- name: "wide edge padding does not become logical content",
165- run: func(t *testing.T) {
166- s := NewScreen(4, 3)
167- s.Write([]byte("abc界x"))
168-
169- s.Resize(3, 3)
170- if got := rowString(s, 0); got != "abc" {
171- t.Fatalf("first reflow row = %q", got)
172- }
173- assertCell(t, s, 0, 1, '界')
174- if !cellAt(s, 1, 1).Continuation {
175- t.Fatal("wide continuation was not retained")
176- }
177- assertCell(t, s, 2, 1, 'x')
178- assertNoOrphanWideCells(t, s)
179- },
180- },
181- {
182- name: "width truncates and pads without reflow",
183- run: func(t *testing.T) {
184- s := NewScreen(6, 2)
185- setRow(s, 0, "abcdef")
186- setRow(s, 1, "ghijkl")
187-
188- s.Resize(3, 2)
189- if got := rowString(s, 0); got != "abc" {
190- t.Fatalf("truncated row = %q", got)
191- }
192- s.Resize(5, 2)
193- if got := rowString(s, 1); got != "ghi " {
194- t.Fatalf("padded row = %q", got)
195- }
196- },
197- },
198- {
199- name: "width shrink repairs truncated wide head",
200- run: func(t *testing.T) {
201- s := NewScreen(4, 1)
202- s.Write([]byte("AB界"))
203-
204- s.Resize(3, 1)
205-
206- assertCell(t, s, 0, 0, 'A')
207- assertCell(t, s, 1, 0, 'B')
208- assertBlank(t, s, 2, 0)
209- assertNoOrphanWideCells(t, s)
210- },
211- },
212- {
213- name: "random wide resize repairs copied rows",
214- run: func(t *testing.T) {
215- rng := rand.New(rand.NewSource(1))
216- for range 200 {
217- s := NewScreen(8, 4)
218- s.Write([]byte("a界b好c語d"))
219- s.Row = rng.Intn(s.Frame.Height)
220-
221- s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
222-
223- assertNoOrphanWideCells(t, s)
224- }
225- },
226- },
227- {
228- name: "cursor and saved cursor clamp",
229- run: func(t *testing.T) {
230- s := NewScreen(6, 4)
231- s.Row, s.Col = 3, 5
232- s.saveCursor()
233-
234- s.Resize(3, 2)
235-
236- if s.Row != 1 || s.Col != 2 {
237- t.Fatalf("cursor = (%d,%d), want (1,2)", s.Row, s.Col)
238- }
239- s.Row, s.Col = 0, 0
240- s.restoreCursor()
241- if s.Row != 1 || s.Col != 2 {
242- t.Fatalf("restored cursor = (%d,%d), want (1,2)", s.Row, s.Col)
243- }
244- },
245- },
246- {
247- name: "alternate resize keeps saved normal content",
248- run: func(t *testing.T) {
249- s := NewScreen(5, 3)
250- setRow(s, 0, "shell")
251- s.Row = 0
252- s.Write([]byte("\x1b[?1049h"))
253- setRow(s, 0, "alt!!")
254-
255- s.Resize(7, 4)
256- s.Write([]byte("\x1b[?1049l"))
257-
258- if got := rowString(s, 0); got != "shell " {
259- t.Fatalf("normal row after alt resize = %q", got)
260- }
261- },
262- },
263- {
264- name: "alternate resize shifts active and saved cursors",
265- run: func(t *testing.T) {
266- s := NewScreen(5, 5)
267- s.Row, s.Col = 3, 4
268- s.saveCursor()
269- s.scrollTop, s.scrollBottom = 1, 4
270- s.Row = 4
271- s.Write([]byte("\x1b[?1049h"))
272- s.Row, s.Col = 3, 4
273- s.saveCursor()
274- s.Row = 4
275-
276- // The expected rows account for the resize shift that keeps the
277- // cursor visible after NewScreen -> saveCursor -> Resize -> restoreCursor,
278- // not just width/height clamping.
279- s.Resize(3, 3)
280-
281- if s.Row != 2 || s.Col != 2 {
282- t.Fatalf("active alt cursor = (%d,%d), want (2,2)", s.Row, s.Col)
283- }
284- s.Row, s.Col = 0, 0
285- s.restoreCursor()
286- if s.Row != 1 || s.Col != 2 {
287- t.Fatalf("active alt saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
288- }
289-
290- s.Write([]byte("\x1b[?1049l"))
291- if s.scrollTop != 0 || s.scrollBottom != 2 {
292- t.Fatalf("normal scroll region = (%d,%d), want (0,2)", s.scrollTop, s.scrollBottom)
293- }
294- s.Row, s.Col = 0, 0
295- s.restoreCursor()
296- if s.Row != 1 || s.Col != 2 {
297- t.Fatalf("normal saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
298- }
299- },
300- },
301- {
302- name: "style mouse cursor and bracketed paste modes survive",
303- run: func(t *testing.T) {
304- s := NewScreen(5, 2)
305- s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[3 q"))
306-
307- s.Resize(6, 3)
308-
309- if !s.Style.Bold {
310- t.Fatal("bold style was reset")
311- }
312- if mode, sgr := s.MouseMode(); mode != 1000 || !sgr {
313- t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr)
314- }
315- if !s.BracketedPasteMode() {
316- t.Fatal("bracketed paste mode was reset")
317- }
318- if s.CursorVisible() {
319- t.Fatal("cursor visibility was reset")
320- }
321- if style, ok := s.CursorStyle(); style != 3 || !ok {
322- t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok)
323- }
324- s.Write([]byte("\x1b[?2004l"))
325- if s.BracketedPasteMode() {
326- t.Fatal("bracketed paste mode remained enabled after reset")
327- }
328- },
329- },
330- }
331-
332- for _, tt := range tests {
333- t.Run(tt.name, tt.run)
334- }
335-}
D pkg/vt/screen_scroll.go +0 -149
@@ -1,149 +0,0 @@
1-package vt
2-
3-import (
4- "errors"
5-
6- "github.com/bnema/vev/pkg/renderer"
7-)
8-
9-// index moves to the next physical row, scrolling the region when the cursor
10-// already sits on its last row. It reports whether the row the cursor was on
11-// survived that scroll by moving up to s.Row-1, which only a deferred wrap
12-// cares about: a wrap must relink to the row it flowed out of, and
13-// buffer.scrollUp severs that link on the way. Callers that merely advance,
14-// such as LF and ESC D, ignore the result.
15-func (s *Screen) index() (movedUp bool) {
16- if s.Frame.Height == 0 {
17- return false
18- }
19- if s.Row == s.scrollBottom && s.Row >= s.scrollTop {
20- return s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)
21- }
22- if s.Row+1 < s.Frame.Height {
23- s.Row++
24- return false
25- }
26- s.Row = s.Frame.Height - 1
27- return false
28-}
29-
30-func (s *Screen) nextLine() {
31- s.buffer.hard(s.Row)
32- s.Col = 0
33- s.index()
34-}
35-
36-func (s *Screen) reverseIndex() {
37- if s.Frame.Height == 0 {
38- return
39- }
40- if s.Row == s.scrollTop && s.Row <= s.scrollBottom {
41- s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1)
42- return
43- }
44- if s.Row > 0 {
45- s.Row--
46- }
47-}
48-
49-// scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU).
50-// The cursor position is preserved.
51-func (s *Screen) scrollUpBy(n int) {
52- if n <= 0 {
53- return
54- }
55- s.scrollUpRegion(s.scrollTop, s.scrollBottom, n)
56-}
57-
58-func (s *Screen) scrollDownBy(n int) {
59- if n <= 0 {
60- return
61- }
62- s.scrollDownRegion(s.scrollTop, s.scrollBottom, n)
63-}
64-
65-// scrollUpRegion scrolls [top,bottom] up by n. It reports whether any row
66-// survived by moving up: a region exactly n rows tall is blanked in place, so
67-// the receiving range [top,bottom-n] is empty and nothing survives. Reporting
68-// what the scroll observed keeps callers from re-deriving a predicate that this
69-// function may have declined to act on at all.
70-func (s *Screen) scrollUpRegion(top, bottom, n int) (shifted bool) {
71- if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
72- return false
73- }
74- top, bottom, ok := s.normalizedRegion(top, bottom)
75- if !ok {
76- return false
77- }
78- w := s.Frame.Width
79- height := bottom - top + 1
80- if n > height {
81- n = height
82- }
83- // VT scroll regions always span the full frame width, so we rotate the
84- // frame's line offsets (recycling and blanking the evicted rows in place)
85- // instead of copying cells. See renderer.Frame.ScrollUp.
86- s.emitLineEvicted(top, n)
87- s.Frame.ScrollUp(top, bottom, n)
88- s.buffer.scrollUp(top, bottom, n)
89- s.fillMissingRowIDs(s.buffer)
90- s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
91- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
92- return bottom-n >= top
93-}
94-
95-func (s *Screen) emitLineEvicted(top, n int) {
96- // Only rows leaving the top edge of the primary screen belong to global
97- // scrollback. Interior DECSTBM scroll regions are local mutations.
98- if s.alternate != nil || top != 0 {
99- return
100- }
101- // Read boundaries and IDs before the caller rotates the frame: a soft link
102- // belongs to the row it follows, and rotation reassigns row indices.
103- for y := top; y < top+n; y++ {
104- s.recordEvicted(s.Frame.Row(y), s.buffer.bound(y), s.buffer.rowIDs[y])
105- }
106-}
107-
108-func (s *Screen) recordEvicted(row []renderer.Cell, bound LineBound, id RowID) {
109- if s.history != nil {
110- err := s.history.AppendWithID(row, bound, id)
111- if err != nil && !errors.Is(err, ErrHistoryRowTooWide) {
112- panic(err)
113- }
114- // A nil error records the row; ErrHistoryRowTooWide explicitly leaves it
115- // unrecorded. Both cases continue to the eviction observer below.
116- }
117- if s.OnLineEvicted != nil {
118- s.OnLineEvicted(append([]renderer.Cell(nil), row...))
119- }
120-}
121-
122-func (s *Screen) scrollDownRegion(top, bottom, n int) {
123- if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
124- return
125- }
126- top, bottom, ok := s.normalizedRegion(top, bottom)
127- if !ok {
128- return
129- }
130- w := s.Frame.Width
131- height := bottom - top + 1
132- if n > height {
133- n = height
134- }
135- // Full-width region: rotate line offsets instead of copying cells.
136- s.Frame.ScrollDown(top, bottom, n)
137- s.buffer.scrollDown(top, bottom, n)
138- s.fillMissingRowIDs(s.buffer)
139- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
140-}
141-
142-func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
143- if s.Frame.Height == 0 || top > bottom {
144- return 0, 0, false
145- }
146- top = clamp(top, 0, s.Frame.Height-1)
147- bottom = clamp(bottom, 0, s.Frame.Height-1)
148- return top, bottom, top <= bottom
149-}
D pkg/vt/screen_scroll_test.go +0 -79
@@ -1,79 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-)
6-
7-// TestWideCharSurvivesRotatedScroll proves a wide-character pair travels intact
8-// with its line when a full-width scroll rotates line offsets rather than
9-// copying cells.
10-func TestWideCharSurvivesRotatedScroll(t *testing.T) {
11- tests := []struct {
12- name string
13- run func(t *testing.T)
14- }{
15- {
16- name: "wide pair moves up one row on newline scroll",
17- run: func(t *testing.T) {
18- s := NewScreen(4, 3)
19- // Place a wide rune (世, width 2) on the bottom row, then scroll up.
20- s.Write([]byte("\x1b[3;1H世"))
21- assertCell(t, s, 0, 2, '世')
22- assertContinuation(t, s, 1, 2)
23- // Newline at the bottom row scrolls the region up by one.
24- s.Write([]byte("\n"))
25- // The wide pair is now on row 1, intact; bottom row blank.
26- assertCell(t, s, 0, 1, '世')
27- assertContinuation(t, s, 1, 1)
28- assertBlank(t, s, 0, 2)
29- assertBlank(t, s, 1, 2)
30- if err := s.Frame.CheckInvariants(); err != nil {
31- t.Fatalf("invariants: %v", err)
32- }
33- },
34- },
35- {
36- name: "wide pair moves down on reverse index",
37- run: func(t *testing.T) {
38- s := NewScreen(4, 3)
39- s.Write([]byte("\x1b[1;1H世")) // top row
40- assertCell(t, s, 0, 0, '世')
41- assertContinuation(t, s, 1, 0)
42- s.Write([]byte("\x1b[1;1H\x1bM")) // cursor top, reverse index -> scroll down
43- assertCell(t, s, 0, 1, '世')
44- assertContinuation(t, s, 1, 1)
45- assertBlank(t, s, 0, 0)
46- if err := s.Frame.CheckInvariants(); err != nil {
47- t.Fatalf("invariants: %v", err)
48- }
49- },
50- },
51- }
52- for _, tt := range tests {
53- t.Run(tt.name, tt.run)
54- }
55-}
56-
57-// TestLineOffsetInvariantAfterVTSequences drives interleaved writes and scrolls
58-// through the VT layer and asserts the frame's line-offset stays a valid
59-// permutation.
60-func TestLineOffsetInvariantAfterVTSequences(t *testing.T) {
61- s := NewScreen(10, 6)
62- seqs := [][]byte{
63- []byte("line0\r\nline1\r\nline2\r\nline3\r\nline4\r\nline5"),
64- []byte("\x1b[2;5r"), // scroll region rows 2..5
65- []byte("\x1b[5;1Hmore\n"), // scroll within region
66- []byte("\x1b[1;1H\x1bM"), // reverse index at top
67- []byte("\x1b[3S"), // scroll up 3
68- []byte("\x1b[2T"), // scroll down 2
69- []byte("\x1b[r"), // reset region
70- []byte("\x1b[6;1Hbottom\n"),
71- }
72- for i, seq := range seqs {
73- s.Write(seq)
74- s.ClearDamage()
75- if err := s.Frame.CheckInvariants(); err != nil {
76- t.Fatalf("after seq %d: invariants broken: %v", i, err)
77- }
78- }
79-}
D pkg/vt/screen_snapshot.go +0 -116
@@ -1,116 +0,0 @@
1-package vt
2-
3-import "github.com/bnema/vev/pkg/renderer"
4-
5-// CursorSnapshot is the cursor state captured with a visible screen snapshot.
6-type CursorSnapshot struct {
7- Row int
8- Col int
9- Visible bool
10- Style int
11- StyleSet bool
12-}
13-
14-// ModeSnapshot is the renderer-relevant VT mode state captured with a screen.
15-type ModeSnapshot struct {
16- AlternateScreen bool
17- BracketedPaste bool
18- SynchronizedUpdate bool
19- ColorSchemeMode bool
20- MouseTracking int
21- MouseSGR bool
22-}
23-
24-// ScreenSnapshot is an owned immutable-by-convention capture of the active
25-// terminal viewport. Row returns caller-owned storage; BorrowedRow must not be
26-// mutated and remains valid while the snapshot is retained.
27-type ScreenSnapshot struct {
28- frame renderer.Frame
29- bounds []LineBound
30- rowIDs []RowID
31- cursor CursorSnapshot
32- modes ModeSnapshot
33- title string
34- nextRowID RowID
35-}
36-
37-// Snapshot captures the active visible viewport without mutating Screen,
38-// history, or pending damage. Screen remains single-owner; callers must
39-// serialize Snapshot with Write, Resize, and host mutations.
40-func (s *Screen) Snapshot() ScreenSnapshot {
41- if s == nil {
42- return ScreenSnapshot{}
43- }
44- cursorStyle, cursorStyleSet := s.CursorStyle()
45- mouseTracking, mouseSGR := s.MouseMode()
46- nextRowID := s.nextRowID
47- if nextRowID < ^RowID(0) {
48- nextRowID++
49- }
50- return ScreenSnapshot{
51- frame: s.Frame.Clone(),
52- bounds: s.LineBounds(),
53- rowIDs: s.RowIDs(),
54- nextRowID: nextRowID,
55- cursor: CursorSnapshot{
56- Row: s.CursorRow(),
57- Col: s.CursorCol(),
58- Visible: s.CursorVisible(),
59- Style: cursorStyle,
60- StyleSet: cursorStyleSet,
61- },
62- modes: ModeSnapshot{
63- AlternateScreen: s.AltScreenActive(),
64- BracketedPaste: s.BracketedPasteMode(),
65- SynchronizedUpdate: s.SyncUpdateActive(),
66- ColorSchemeMode: s.ColorSchemeMode(),
67- MouseTracking: mouseTracking,
68- MouseSGR: mouseSGR,
69- },
70- title: s.TerminalTitle(),
71- }
72-}
73-
74-func (s ScreenSnapshot) Columns() int { return s.frame.Width }
75-func (s ScreenSnapshot) Rows() int { return s.frame.Height }
76-
77-func (s ScreenSnapshot) Row(y int) []renderer.Cell {
78- return append([]renderer.Cell(nil), s.BorrowedRow(y)...)
79-}
80-
81-func (s ScreenSnapshot) BorrowedRow(y int) []renderer.Cell {
82- if y < 0 || y >= s.frame.Height {
83- return nil
84- }
85- return s.frame.Row(y)
86-}
87-
88-func (s ScreenSnapshot) Bound(y int) LineBound {
89- if y < 0 || y >= len(s.bounds) {
90- return LineBound{}
91- }
92- return s.bounds[y]
93-}
94-
95-// RowIDs returns an owned copy of the visible physical-row identities.
96-func (s ScreenSnapshot) RowIDs() []RowID { return append([]RowID(nil), s.rowIDs...) }
97-
98-// RowID returns the identity of visible row y, or zero when out of range.
99-func (s ScreenSnapshot) RowID(y int) RowID {
100- if y < 0 || y >= len(s.rowIDs) {
101- return 0
102- }
103- return s.rowIDs[y]
104-}
105-
106-// NextRowID returns the next identity allocated by the captured screen.
107-func (s ScreenSnapshot) NextRowID() RowID {
108- if s.nextRowID == 0 {
109- return 1
110- }
111- return s.nextRowID
112-}
113-
114-func (s ScreenSnapshot) Cursor() CursorSnapshot { return s.cursor }
115-func (s ScreenSnapshot) Modes() ModeSnapshot { return s.modes }
116-func (s ScreenSnapshot) Title() string { return s.title }
D pkg/vt/screen_snapshot_test.go +0 -437
@@ -1,437 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7- "github.com/stretchr/testify/require"
8-)
9-
10-func TestScreenSnapshotGeometryAndMetadata(t *testing.T) {
11- tests := []struct {
12- name string
13- screen func() *Screen
14- wantColumns int
15- wantRows int
16- wantTitle string
17- wantCursor CursorSnapshot
18- wantModes ModeSnapshot
19- checkRows func(t *testing.T, snapshot ScreenSnapshot)
20- }{
21- {
22- name: "nil receiver is the zero snapshot",
23- screen: func() *Screen { return nil },
24- wantCursor: CursorSnapshot{},
25- wantModes: ModeSnapshot{},
26- checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
27- require.Nil(t, snapshot.Row(0))
28- require.Nil(t, snapshot.BorrowedRow(0))
29- require.Equal(t, LineBound{}, snapshot.Bound(0))
30- },
31- },
32- {
33- name: "normal viewport captures cursor modes and title",
34- screen: func() *Screen {
35- screen := NewScreen(6, 3)
36- screen.Write([]byte("\x1b]2;editor\x07\x1b[2;3H\x1b[?25l\x1b[2 q\x1b[?2004h\x1b[?2026h\x1b[?2031h\x1b[?1002h\x1b[?1006h"))
37- return screen
38- },
39- wantColumns: 6,
40- wantRows: 3,
41- wantTitle: "editor",
42- wantCursor: CursorSnapshot{Row: 1, Col: 2, Visible: false, Style: 2, StyleSet: true},
43- wantModes: ModeSnapshot{
44- BracketedPaste: true,
45- SynchronizedUpdate: true,
46- ColorSchemeMode: true,
47- MouseTracking: 1002,
48- MouseSGR: true,
49- },
50- checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
51- require.Len(t, snapshot.BorrowedRow(0), 6)
52- require.Equal(t, LineBound{}, snapshot.Bound(0))
53- },
54- },
55- {
56- name: "zero by zero preserves screen metadata",
57- screen: func() *Screen {
58- screen := NewScreen(2, 2)
59- screen.Write([]byte("\x1b]2;collapsed\x07\x1b[?2004h"))
60- screen.Resize(0, 0)
61- return screen
62- },
63- wantTitle: "collapsed",
64- wantCursor: CursorSnapshot{Visible: true},
65- wantModes: ModeSnapshot{BracketedPaste: true},
66- checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
67- require.Nil(t, snapshot.Row(0))
68- require.Equal(t, LineBound{}, snapshot.Bound(0))
69- },
70- },
71- {
72- name: "zero columns retains empty physical rows and bounds",
73- screen: func() *Screen {
74- screen := NewScreen(2, 2)
75- screen.Write([]byte("\x1b]2;zero-columns\x07"))
76- screen.Resize(0, 2)
77- return screen
78- },
79- wantRows: 2,
80- wantTitle: "zero-columns",
81- wantCursor: CursorSnapshot{Visible: true},
82- checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
83- require.NotNil(t, snapshot.BorrowedRow(0))
84- require.Empty(t, snapshot.BorrowedRow(0))
85- require.Nil(t, snapshot.Row(0))
86- require.Empty(t, snapshot.Row(1))
87- require.Equal(t, LineBound{}, snapshot.Bound(1))
88- require.Nil(t, snapshot.Row(2))
89- },
90- },
91- {
92- name: "zero rows retains columns without rows",
93- screen: func() *Screen {
94- screen := NewScreen(2, 2)
95- screen.Write([]byte("\x1b]2;zero-rows\x07"))
96- screen.Resize(2, 0)
97- return screen
98- },
99- wantColumns: 2,
100- wantTitle: "zero-rows",
101- wantCursor: CursorSnapshot{Visible: true},
102- checkRows: func(t *testing.T, snapshot ScreenSnapshot) {
103- require.Nil(t, snapshot.BorrowedRow(0))
104- require.Equal(t, LineBound{}, snapshot.Bound(0))
105- },
106- },
107- }
108-
109- for _, test := range tests {
110- t.Run(test.name, func(t *testing.T) {
111- snapshot := test.screen().Snapshot()
112- require.Equal(t, test.wantColumns, snapshot.Columns())
113- require.Equal(t, test.wantRows, snapshot.Rows())
114- require.Equal(t, test.wantTitle, snapshot.Title())
115- require.Equal(t, test.wantCursor, snapshot.Cursor())
116- require.Equal(t, test.wantModes, snapshot.Modes())
117- test.checkRows(t, snapshot)
118- })
119- }
120-}
121-
122-func TestScreenSnapshotColorSchemeModeExcludesHostPreference(t *testing.T) {
123- tests := []struct {
124- name string
125- run func(*Screen)
126- want bool
127- }{
128- {
129- name: "host preference alone is not DEC 2031 mode",
130- run: func(screen *Screen) { screen.SetColorScheme(true) },
131- want: false,
132- },
133- {
134- name: "DEC 2031 mode is captured independently",
135- run: func(screen *Screen) {
136- screen.SetColorScheme(false)
137- screen.Write([]byte("\x1b[?2031h"))
138- },
139- want: true,
140- },
141- }
142-
143- for _, test := range tests {
144- t.Run(test.name, func(t *testing.T) {
145- screen := NewScreen(4, 2)
146- test.run(screen)
147- require.Equal(t, test.want, screen.Snapshot().Modes().ColorSchemeMode)
148- })
149- }
150-}
151-
152-func TestScreenSnapshotTitle(t *testing.T) {
153- tests := []struct {
154- name string
155- feed string
156- want string
157- }{
158- {name: "empty title", want: ""},
159- {name: "latest title replaces the previous value", feed: "\x1b]2;first\x07\x1b]0;second\x07", want: "second"},
160- {name: "UTF-8 title is preserved", feed: "\x1b]2;éditeur 界\x07", want: "éditeur 界"},
161- }
162-
163- for _, test := range tests {
164- t.Run(test.name, func(t *testing.T) {
165- screen := NewScreen(8, 2)
166- screen.Write([]byte(test.feed))
167- require.Equal(t, test.want, screen.Snapshot().Title())
168- })
169- }
170-}
171-
172-func TestScreenSnapshotFidelity(t *testing.T) {
173- tests := []struct {
174- name string
175- make func() (*Screen, []renderer.Cell, []LineBound)
176- wantModes ModeSnapshot
177- }{
178- {
179- name: "styles wide continuation and bounds are exact",
180- make: func() (*Screen, []renderer.Cell, []LineBound) {
181- screen := NewScreen(4, 2)
182- style := renderer.Style{
183- Bold: true, Italic: true, Inverse: true,
184- Attrs: renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough,
185- HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1, G: 2, B: 3},
186- Background: 17,
187- UnderlineStyle: renderer.UnderlineCurly,
188- HasUnderlineColorRGB: true, UnderlineColorRGB: renderer.RGB{R: 4, G: 5, B: 6},
189- }
190- cells := []renderer.Cell{
191- {Rune: '界', Style: style},
192- {Continuation: true, Style: style},
193- {Rune: 'x', Style: renderer.DefaultStyle()},
194- renderer.BlankCell(),
195- }
196- copy(screen.Frame.Row(0), cells)
197- screen.buffer.boundaries[0] = LineBound{End: 3, Soft: true}
198- return screen, cells, []LineBound{{End: 3, Soft: true}, {}}
199- },
200- },
201- {
202- name: "active alternate screen is captured",
203- make: func() (*Screen, []renderer.Cell, []LineBound) {
204- screen := NewScreen(3, 1)
205- screen.Write([]byte("primary\x1b[?1049halt"))
206- cells := append([]renderer.Cell(nil), screen.Frame.Row(0)...)
207- return screen, cells, screen.LineBounds()
208- },
209- wantModes: ModeSnapshot{AlternateScreen: true},
210- },
211- }
212-
213- for _, test := range tests {
214- t.Run(test.name, func(t *testing.T) {
215- screen, wantCells, wantBounds := test.make()
216- snapshot := screen.Snapshot()
217- require.Equal(t, screen.Frame.Width, snapshot.Columns())
218- require.Equal(t, screen.Frame.Height, snapshot.Rows())
219- require.Equal(t, wantCells, snapshot.Row(0))
220- require.Equal(t, test.wantModes, snapshot.Modes())
221- for row, want := range wantBounds {
222- require.Equal(t, want, snapshot.Bound(row))
223- }
224- })
225- }
226-}
227-
228-func TestScreenSnapshotPreservesLogicalRowsAfterScroll(t *testing.T) {
229- screen := NewScreen(3, 2)
230- screen.Write([]byte("ABC"))
231- screen.Write([]byte("DEF"))
232- screen.Write([]byte("G"))
233-
234- snapshot := screen.Snapshot()
235- for y, want := range []string{"DEF", "G "} {
236- require.Equal(t, want, rowText(snapshot.Row(y)))
237- require.Equal(t, screen.Frame.Row(y), snapshot.Row(y))
238- require.Equal(t, screen.LineBounds()[y], snapshot.Bound(y))
239- }
240-}
241-
242-func TestScreenSnapshotOwnership(t *testing.T) {
243- tests := []struct {
244- name string
245- mutate func(*Screen)
246- }{
247- {name: "later write", mutate: func(screen *Screen) { screen.Write([]byte("later")) }},
248- {name: "later scroll", mutate: func(screen *Screen) { screen.Write([]byte("1111222233334444")) }},
249- {name: "later resize", mutate: func(screen *Screen) { screen.Resize(2, 4) }},
250- {name: "later alternate entry", mutate: func(screen *Screen) { screen.Write([]byte("\x1b[?1049halt")) }},
251- {name: "later alternate entry and exit", mutate: func(screen *Screen) { screen.Write([]byte("\x1b[?1049halt\x1b[?1049l")) }},
252- }
253-
254- for _, test := range tests {
255- t.Run(test.name, func(t *testing.T) {
256- screen := NewScreen(4, 2)
257- screen.Write([]byte("abcdEF"))
258- screen.buffer.boundaries[0] = LineBound{End: 4, Soft: true}
259- snapshot := screen.Snapshot()
260- wantRows := [][]renderer.Cell{snapshot.Row(0), snapshot.Row(1)}
261- wantBounds := []LineBound{snapshot.Bound(0), snapshot.Bound(1)}
262-
263- test.mutate(screen)
264-
265- require.Equal(t, wantRows[0], snapshot.Row(0))
266- require.Equal(t, wantRows[1], snapshot.Row(1))
267- require.Equal(t, wantBounds[0], snapshot.Bound(0))
268- require.Equal(t, wantBounds[1], snapshot.Bound(1))
269-
270- liveBeforeCallerMutation := append([]renderer.Cell(nil), screen.Frame.Row(0)...)
271- secondSnapshot := screen.Snapshot()
272- secondSnapshotBefore := secondSnapshot.Row(0)
273-
274- callerCopy := snapshot.Row(0)
275- callerCopy[0] = renderer.Cell{Rune: 'z'}
276-
277- require.Equal(t, wantRows[0], snapshot.BorrowedRow(0))
278- require.Equal(t, liveBeforeCallerMutation, screen.Frame.Row(0), "mutating Row copy must not change live Screen")
279- require.Equal(t, secondSnapshotBefore, secondSnapshot.Row(0), "mutating Row copy must not change another snapshot")
280- })
281- }
282-}
283-
284-func TestScreenSnapshotBoundsAreIndependent(t *testing.T) {
285- tests := []struct {
286- name string
287- mutate func(*Screen)
288- }{
289- {
290- name: "mutating a single boundary entry after the snapshot",
291- mutate: func(screen *Screen) {
292- screen.buffer.boundaries[0] = LineBound{End: 1, Soft: false}
293- },
294- },
295- {
296- name: "replacing the whole boundaries slice after the snapshot",
297- mutate: func(screen *Screen) {
298- screen.buffer.boundaries = []LineBound{{End: 1, Soft: false}, {End: 1, Soft: false}}
299- },
300- },
301- }
302-
303- for _, test := range tests {
304- t.Run(test.name, func(t *testing.T) {
305- screen := NewScreen(4, 2)
306- screen.Write([]byte("abcdEF"))
307- screen.buffer.boundaries[0] = LineBound{End: 4, Soft: true}
308- snapshot := screen.Snapshot()
309- want := snapshot.Bound(0)
310-
311- test.mutate(screen)
312-
313- require.Equal(t, want, snapshot.Bound(0), "snapshot bounds must not alias Screen.buffer.boundaries")
314- })
315- }
316-}
317-
318-func TestScreenSnapshotDoesNotMutateHistoryOrDamage(t *testing.T) {
319- tests := []struct {
320- name string
321- make func() *Screen
322- }{
323- {
324- name: "screen without history",
325- make: func() *Screen {
326- screen := NewScreen(4, 2)
327- screen.ClearDamage()
328- screen.Write([]byte("abcdef"))
329- return screen
330- },
331- },
332- {
333- name: "screen with sealed chunks and mutable tail",
334- make: func() *Screen {
335- screen := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 64, ChunkRows: 2})
336- screen.Write([]byte("111122223333444455"))
337- return screen
338- },
339- },
340- }
341-
342- for _, test := range tests {
343- t.Run(test.name, func(t *testing.T) {
344- screen := test.make()
345- beforeDamage := screen.CaptureDamage()
346- var beforeHistory HistorySnapshotView
347- var beforeRows [][]renderer.Cell
348- var beforeBounds []LineBound
349- beforeCap, beforeCellCap := 0, 0
350- if screen.History() != nil {
351- beforeHistory = screen.History().SnapshotView()
352- beforeView := screen.History().View()
353- beforeRows = make([][]renderer.Cell, beforeView.Len())
354- beforeBounds = make([]LineBound, beforeView.Len())
355- for i := range beforeRows {
356- beforeRows[i] = beforeView.Row(i)
357- beforeBounds[i] = beforeView.Bound(i)
358- }
359- beforeCap = screen.History().Cap()
360- beforeCellCap = screen.History().CellCap()
361- }
362-
363- _ = screen.Snapshot()
364-
365- afterDamage := screen.CaptureDamage()
366- require.Equal(t, beforeDamage, afterDamage)
367- if screen.History() != nil {
368- afterHistory := screen.History().SnapshotView()
369- afterView := screen.History().View()
370- afterRows := make([][]renderer.Cell, afterView.Len())
371- afterBounds := make([]LineBound, afterView.Len())
372- for i := range afterRows {
373- afterRows[i] = afterView.Row(i)
374- afterBounds[i] = afterView.Bound(i)
375- }
376- require.Equal(t, beforeHistory.ChunkCount(), afterHistory.ChunkCount())
377- require.Equal(t, beforeHistory.Len(), afterHistory.Len())
378- require.Equal(t, beforeHistory.Cells(), afterHistory.Cells())
379- require.Equal(t, beforeHistory.Tail().Len(), afterHistory.Tail().Len())
380- require.Equal(t, beforeRows, afterRows)
381- require.Equal(t, beforeBounds, afterBounds)
382- require.Equal(t, beforeCap, screen.History().Cap())
383- require.Equal(t, beforeCellCap, screen.History().CellCap())
384- }
385- })
386- }
387-}
388-
389-func TestScreenSnapshotPreservesStaleDamageAcknowledge(t *testing.T) {
390- tests := []struct {
391- name string
392- mutate func(*Screen)
393- }{
394- {name: "write after capture", mutate: func(screen *Screen) { screen.Write([]byte("b")) }},
395- {name: "resize after capture", mutate: func(screen *Screen) { screen.Resize(6, 2) }},
396- }
397-
398- for _, test := range tests {
399- t.Run(test.name, func(t *testing.T) {
400- screen := NewScreen(4, 2)
401- screen.ClearDamage()
402- screen.Write([]byte("a"))
403- captured := screen.CaptureDamage()
404-
405- _ = screen.Snapshot()
406- test.mutate(screen)
407-
408- require.False(t, screen.AcknowledgeDamage(captured.Generation))
409- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage())
410-
411- screen.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
412- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, screen.Damage(), "full redraw stays sticky until exact acknowledgement")
413- })
414- }
415-}
416-
417-func TestScreenSnapshotPreservesExactDamageAcknowledge(t *testing.T) {
418- tests := []struct {
419- name string
420- }{
421- {name: "snapshot does not stale an exact damage capture"},
422- }
423-
424- for _, test := range tests {
425- t.Run(test.name, func(t *testing.T) {
426- screen := NewScreen(4, 2)
427- screen.ClearDamage()
428- screen.Write([]byte("a"))
429- captured := screen.CaptureDamage()
430-
431- _ = screen.Snapshot()
432-
433- require.True(t, screen.AcknowledgeDamage(captured.Generation))
434- require.Empty(t, screen.Damage())
435- })
436- }
437-}
D pkg/vt/screen_write.go +0 -250
@@ -1,250 +0,0 @@
1-package vt
2-
3-import "github.com/bnema/vev/pkg/renderer"
4-
5-func (s *Screen) putRune(r rune) {
6- switch r {
7- case '\a':
8- if s.OnBell != nil {
9- s.OnBell()
10- }
11- return
12- case 0x00, 0x0e, 0x0f, 0x7f:
13- return
14- case '\r':
15- s.Col = 0
16- case '\n', '\v', '\f':
17- if s.Frame.Width > 0 && s.Col >= s.Frame.Width {
18- s.Col = s.Frame.Width - 1
19- }
20- s.buffer.hard(s.Row)
21- s.index()
22- case '\b':
23- if s.Col > 0 {
24- s.Col--
25- }
26- case '\t':
27- // HT moves to the next tab stop without modifying the frame. Clamp at
28- // the right edge rather than triggering deferred wrap or a scroll.
29- if s.Frame.Width > 0 {
30- s.Col = min(s.Col+8-s.Col%8, s.Frame.Width-1)
31- }
32- default:
33- // Drop C1 control characters (U+0080-U+009F).
34- if r >= 0x80 && r <= 0x9F {
35- return
36- }
37- if r >= 0x20 {
38- s.putPrintable(r)
39- }
40- }
41-}
42-
43-// relinkWrap restores the soft link a deferred wrap just set when index()
44-// scrolled the row out from under it. buffer.scrollUp severs the last moved
45-// row on purpose — a linefeed there really does stop preceding anything — but
46-// a wrap is the one case where that row flowed into the row below it, so the
47-// link has to be put back after the rotation moved it to s.Row-1. movedUp is
48-// false when nothing survived the scroll, and then there is no row to link.
49-func (s *Screen) relinkWrap(movedUp bool) {
50- if movedUp {
51- s.buffer.continueRow(s.Row - 1)
52- }
53-}
54-
55-func (s *Screen) putPrintable(r rune) {
56- w := renderer.RuneWidth(r)
57- // Skip combining marks and zero-width characters.
58- if w == 0 {
59- return
60- }
61- if s.Frame.Width == 0 || s.Frame.Height == 0 {
62- return
63- }
64- // Deferred wrap: cursor sits past the last column.
65- if s.Col >= s.Frame.Width {
66- s.buffer.soft(s.Row)
67- s.Col = 0
68- s.relinkWrap(s.index())
69- }
70- if s.Row >= s.Frame.Height {
71- s.Row = s.Frame.Height - 1
72- }
73-
74- // A wide rune must never straddle the right edge. If it does not fit on the
75- // current line, clear the abandoned last cell and wrap to the next line.
76- if w == 2 && s.Col+1 >= s.Frame.Width {
77- if s.Frame.Width < 2 {
78- // The screen is too narrow to ever hold a wide rune; store a narrow
79- // replacement so the cell's renderer width matches its layout.
80- r = '\uFFFD'
81- w = renderer.RuneWidth(r)
82- } else {
83- // The abandoned cell may itself be the continuation of a pair whose
84- // left half sits one column back; report damage over the full span.
85- cx := s.Col
86- if s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation {
87- cx = s.Col - 1
88- }
89- s.clearWidePairAt(s.Col, s.Row)
90- s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
91- s.buffer.truncate(s.Row, cx)
92- s.buffer.continueRow(s.Row)
93- s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
94- s.Col = 0
95- s.relinkWrap(s.index())
96- if s.Row >= s.Frame.Height {
97- s.Row = s.Frame.Height - 1
98- }
99- }
100- }
101-
102- insertDamageX := s.Col
103- insertDamageWidth := 0
104- if s.insertMode {
105- row := s.Frame.Row(s.Row)
106- leftSplit := s.Col > 0 && row[s.Col].Continuation
107- for x := s.Frame.Width - 1; x >= s.Col+w; x-- {
108- row[x] = row[x-w]
109- }
110- for x := s.Col; x < s.Col+w; x++ {
111- row[x] = renderer.BlankCell()
112- }
113- s.repairRow(s.Row)
114- s.buffer.insert(s.Row, s.Col, w)
115- if leftSplit {
116- insertDamageX = s.Col - 1
117- }
118- insertDamageWidth = s.Frame.Width - insertDamageX
119- }
120-
121- // Determine the range of cells actually modified, extending over any wide
122- // pair the write lands on so no orphaned half is left behind.
123- lo, hi := s.Col, s.Col+w-1
124- if s.Frame.At(s.Col, s.Row).Continuation {
125- lo = s.Col - 1
126- }
127- if right := s.Col + w; right < s.Frame.Width && s.Frame.At(right, s.Row).Continuation {
128- hi = right
129- }
130- for x := lo; x <= hi; x++ {
131- s.Frame.Set(x, s.Row, renderer.BlankCell())
132- }
133- s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
134- s.buffer.content(s.Row, s.Col+w)
135- if w == 2 {
136- s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
137- }
138- if insertDamageWidth > 0 {
139- lo = min(lo, insertDamageX)
140- hi = max(hi, insertDamageX+insertDamageWidth-1)
141- }
142- s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
143- s.Col += w
144-}
145-
146-// clearWidePairAt blanks both halves of a wide-character pair when the cell at
147-// (x,y) is either the left (wide) half or the right (continuation) half. It is
148-// O(1) and assumes the pair invariant (a continuation is preceded by its wide
149-// left half) holds — which the writer maintains.
150-
151-func (s *Screen) clearWidePairAt(x, y int) {
152- if x < 0 || x >= s.Frame.Width || y < 0 || y >= s.Frame.Height {
153- return
154- }
155- if s.Frame.At(x, y).Continuation {
156- if x-1 >= 0 {
157- s.Frame.Set(x-1, y, renderer.BlankCell())
158- }
159- s.Frame.Set(x, y, renderer.BlankCell())
160- return
161- }
162- if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation {
163- s.Frame.Set(x, y, renderer.BlankCell())
164- s.Frame.Set(x+1, y, renderer.BlankCell())
165- }
166-}
167-
168-func (s *Screen) eraseCell() renderer.Cell {
169- style := renderer.DefaultStyle()
170- if s.Style.HasBackgroundRGB {
171- style.HasBackgroundRGB = true
172- style.BackgroundRGB = s.Style.BackgroundRGB
173- } else {
174- style.Background = s.Style.Background
175- }
176- return renderer.Cell{Rune: ' ', Style: style}
177-}
178-
179-// clearRow blanks cells [x0,x1) on row y, extending the range to swallow either
180-// half of a wide pair that straddles a boundary so no orphan half is left. It
181-// returns the actual modified span [start, start+width).
182-
183-func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
184- if y < 0 || y >= s.Frame.Height {
185- return x0, 0
186- }
187- if x0 < 0 {
188- x0 = 0
189- }
190- if x1 > s.Frame.Width {
191- x1 = s.Frame.Width
192- }
193- if x0 >= x1 {
194- return x0, 0
195- }
196- // Left boundary: a continuation at x0 means its wide left half sits at x0-1.
197- if x0 > 0 && s.Frame.At(x0, y).Continuation {
198- x0--
199- }
200- // Right boundary: a continuation at x1 means its wide left half (at x1-1) is
201- // inside the range and will be blanked, so swallow the continuation too.
202- if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
203- x1++
204- }
205- fullRow := x0 <= 0 && x1 >= s.Frame.Width
206- blank := s.eraseCell()
207- for x := x0; x < x1; x++ {
208- s.Frame.Set(x, y, blank)
209- }
210- s.buffer.clear(y, x0, x1)
211- if fullRow {
212- s.buffer.rowIDs[y] = s.nextRowIDValue()
213- }
214- return x0, x1 - x0
215-}
216-
217-// repairRow blanks any orphaned wide-character halves on row y: a continuation
218-// cell not preceded by its wide left half, or a wide rune not followed by its
219-// continuation. Used after erase/insert/delete operations that may split a pair
220-// at a boundary or by shifting cells.
221-
222-func (s *Screen) repairRow(y int) {
223- repairFrameRow(s.Frame, y)
224-}
225-
226-func repairFrameRow(frame renderer.Frame, y int) {
227- if y < 0 || y >= frame.Height {
228- return
229- }
230- w := frame.Width
231- for x := range w {
232- c := frame.At(x, y)
233- if c.Continuation {
234- if x == 0 {
235- frame.Set(x, y, renderer.BlankCell())
236- continue
237- }
238- left := frame.At(x-1, y)
239- if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
240- frame.Set(x, y, renderer.BlankCell())
241- }
242- continue
243- }
244- if renderer.RuneWidth(c.Rune) == 2 {
245- if x+1 >= w || !frame.At(x+1, y).Continuation {
246- frame.Set(x, y, renderer.BlankCell())
247- }
248- }
249- }
250-}
D pkg/vt/screen_write_test.go +0 -968
@@ -1,968 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7- "github.com/stretchr/testify/require"
8-)
9-
10-func TestWritePrintableAndUTF8(t *testing.T) {
11- tests := []struct {
12- name string
13- run func(t *testing.T)
14- }{
15- {
16- name: "printable ASCII advances cursor and records damage",
17- run: func(t *testing.T) {
18- s := NewScreen(5, 3)
19- s.Write([]byte("Hi"))
20-
21- assertCell(t, s, 0, 0, 'H')
22- assertCell(t, s, 1, 0, 'i')
23- if s.Col != 2 || s.Row != 0 {
24- t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
25- }
26-
27- // Should have damage.
28- d := s.Damage()
29- if len(d) == 0 {
30- t.Fatal("expected damage after writing")
31- }
32- },
33- },
34- {
35- name: "UTF-8 multi-byte runes occupy one cell each",
36- run: func(t *testing.T) {
37- s := NewScreen(10, 3)
38- s.Write([]byte("aäéø©"))
39-
40- assertCell(t, s, 0, 0, 'a')
41- assertCell(t, s, 1, 0, 'ä')
42- assertCell(t, s, 2, 0, 'é')
43- assertCell(t, s, 3, 0, 'ø')
44- assertCell(t, s, 4, 0, '©')
45- },
46- },
47- {
48- name: "printable beyond width wraps to next line",
49- run: func(t *testing.T) {
50- s := NewScreen(4, 2)
51- s.Write([]byte("ABCDE"))
52-
53- assertCell(t, s, 0, 0, 'A')
54- assertCell(t, s, 1, 0, 'B')
55- assertCell(t, s, 2, 0, 'C')
56- assertCell(t, s, 3, 0, 'D')
57- // E wrapped to next line.
58- assertCell(t, s, 0, 1, 'E')
59- },
60- },
61- {
62- name: "printable beyond screen height scrolls",
63- run: func(t *testing.T) {
64- s := NewScreen(3, 2)
65- // Fill both lines.
66- s.Write([]byte("ABC"))
67- s.Write([]byte("DEF"))
68- // Row is now 1 (bottom). One more char should scroll.
69- s.Write([]byte("G"))
70-
71- // After scroll: old row 0 = "DEF", new row 1 = "G ".
72- assertCell(t, s, 0, 0, 'D')
73- assertCell(t, s, 1, 0, 'E')
74- assertCell(t, s, 2, 0, 'F')
75- assertCell(t, s, 0, 1, 'G')
76- assertCell(t, s, 1, 1, ' ')
77-
78- // Check scroll damage.
79- d := s.Damage()
80- if !hasDamageKind(d, renderer.DamageScrollUp) {
81- t.Error("expected scroll damage")
82- }
83- },
84- },
85- }
86-
87- for _, tt := range tests {
88- t.Run(tt.name, tt.run)
89- }
90-}
91-
92-func TestCursorControlChars(t *testing.T) {
93- tests := []struct {
94- name string
95- run func(t *testing.T)
96- }{
97- {
98- name: "CR moves column back to 0",
99- run: func(t *testing.T) {
100- s := NewScreen(10, 3)
101- s.Write([]byte("Hello"))
102- // CR moves column back to 0.
103- s.Write([]byte("\rWorld"))
104-
105- assertCell(t, s, 0, 0, 'W')
106- assertCell(t, s, 1, 0, 'o')
107- assertCell(t, s, 2, 0, 'r')
108- assertCell(t, s, 3, 0, 'l')
109- assertCell(t, s, 4, 0, 'd')
110- if s.Col != 5 {
111- t.Errorf("col after CR + World = %d, want 5", s.Col)
112- }
113- },
114- },
115- {
116- name: "LF with CR moves to next line column 0",
117- run: func(t *testing.T) {
118- s := NewScreen(5, 3)
119- s.Write([]byte("A\r\nB\r\nC"))
120-
121- assertCell(t, s, 0, 0, 'A')
122- assertCell(t, s, 0, 1, 'B')
123- assertCell(t, s, 0, 2, 'C')
124- if s.Row != 2 || s.Col != 1 {
125- t.Errorf("cursor at row=%d col=%d, want row=2 col=1", s.Row, s.Col)
126- }
127- },
128- },
129- {
130- name: "LF alone does not reset column",
131- run: func(t *testing.T) {
132- s := NewScreen(5, 3)
133- s.Write([]byte("AB\nC"))
134-
135- assertCell(t, s, 0, 0, 'A')
136- assertCell(t, s, 1, 0, 'B')
137- assertCell(t, s, 0, 1, ' ')
138- assertCell(t, s, 2, 1, 'C')
139- if s.Row != 1 || s.Col != 3 {
140- t.Errorf("cursor at row=%d col=%d, want row=1 col=3", s.Row, s.Col)
141- }
142- },
143- },
144- {
145- name: "LF at pending wrap boundary advances exactly one line",
146- run: func(t *testing.T) {
147- s := NewScreen(5, 4)
148- s.Write([]byte("ABCDE\nZ"))
149-
150- assertCell(t, s, 4, 0, 'E')
151- assertCell(t, s, 4, 1, 'Z')
152- if s.Row != 1 || s.Col != 5 {
153- t.Errorf("cursor at row=%d col=%d, want row=1 col=5", s.Row, s.Col)
154- }
155- },
156- },
157- {
158- name: "LF scrolls when both lines are full",
159- run: func(t *testing.T) {
160- s := NewScreen(5, 2)
161- // Fill both lines then CRLF to scroll and return to column 0.
162- s.Write([]byte("AAAAA"))
163- s.Write([]byte("BBBBB"))
164- s.Write([]byte("\r\nCCCCC"))
165-
166- assertCell(t, s, 0, 0, 'B')
167- assertCell(t, s, 4, 1, 'C')
168- },
169- },
170- {
171- name: "BS moves cursor back without touching the cell",
172- run: func(t *testing.T) {
173- s := NewScreen(10, 3)
174- s.Write([]byte("Hello\b"))
175-
176- if s.Col != 4 {
177- t.Errorf("col after BS = %d, want 4", s.Col)
178- }
179- assertCell(t, s, 4, 0, 'o') // cell unchanged, just cursor moved
180- },
181- },
182- {
183- name: "BS at column zero is a no-op",
184- run: func(t *testing.T) {
185- s := NewScreen(10, 3)
186- s.Write([]byte("\b")) // BS at col 0 should be no-op
187-
188- if s.Col != 0 {
189- t.Errorf("col = %d, want 0", s.Col)
190- }
191- },
192- },
193- {
194- name: "tab moves cursor without changing cells or damage",
195- run: func(t *testing.T) {
196- s := NewScreen(10, 2)
197- for y := range s.Frame.Height {
198- for x := range s.Frame.Width {
199- s.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.Frame.Width + x)})
200- }
201- }
202- before := s.Frame.Clone()
203- s.Row, s.Col = 1, 6
204- s.ClearDamage()
205-
206- s.Write([]byte("\t"))
207- if s.Row != 1 || s.Col != 8 {
208- t.Errorf("cursor after tab = row=%d col=%d, want row=1 col=8", s.Row, s.Col)
209- }
210- assertFramesEqual(t, s.Frame, before)
211- if got := s.Damage(); len(got) != 0 {
212- t.Errorf("tab damage = %+v, want none", got)
213- }
214-
215- // A tab whose next stop lies past the edge clamps at the last
216- // column; it must not wrap or scroll the bottom row.
217- s.Write([]byte("\t"))
218- if s.Row != 1 || s.Col != 9 {
219- t.Errorf("cursor after clamped tab = row=%d col=%d, want row=1 col=9", s.Row, s.Col)
220- }
221- assertFramesEqual(t, s.Frame, before)
222- if got := s.Damage(); len(got) != 0 {
223- t.Errorf("clamped tab damage = %+v, want none", got)
224- }
225- },
226- },
227- }
228-
229- for _, tt := range tests {
230- t.Run(tt.name, tt.run)
231- }
232-}
233-
234-func TestInvalidInputIgnored(t *testing.T) {
235- tests := []struct {
236- name string
237- input []byte
238- }{
239- {
240- name: "invalid UTF-8 continuation byte without start byte",
241- input: []byte{0x80, 0x81},
242- },
243- {
244- name: "unhandled control characters below 0x20",
245- input: []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f},
246- },
247- }
248-
249- for _, tt := range tests {
250- t.Run(tt.name, func(t *testing.T) {
251- s := NewScreen(10, 3)
252- s.Write(tt.input)
253- // Should not panic, and no cells should be modified.
254- for y := range 3 {
255- for x := range 10 {
256- if c := cellAt(s, x, y); c.Rune != ' ' {
257- t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
258- }
259- }
260- }
261- })
262- }
263-}
264-
265-func TestWriteAtEdgeNoPanic(t *testing.T) {
266- tests := []struct {
267- name string
268- }{
269- {name: "writing past a 1x1 screen scrolls instead of panicking"},
270- }
271-
272- for _, tt := range tests {
273- t.Run(tt.name, func(t *testing.T) {
274- s := NewScreen(1, 1)
275- // Fill the only cell then try to write more (should scroll).
276- s.Write([]byte("ABC"))
277- // Should not panic.
278- _ = cellAt(s, 0, 0)
279- })
280- }
281-}
282-
283-func TestStylePersists(t *testing.T) {
284- tests := []struct {
285- name string
286- }{
287- {name: "bold persists across separate Write calls"},
288- }
289-
290- for _, tt := range tests {
291- t.Run(tt.name, func(t *testing.T) {
292- s := NewScreen(10, 3)
293- s.Write([]byte("\x1b[1mAB"))
294- s.Write([]byte("CD"))
295-
296- c1 := cellAt(s, 0, 0)
297- c2 := cellAt(s, 2, 0)
298- if !c1.Style.Bold || !c2.Style.Bold {
299- t.Error("bold should persist across Write calls")
300- }
301- })
302- }
303-}
304-
305-func TestWideAndZeroWidthRunes(t *testing.T) {
306- tests := []struct {
307- name string
308- run func(t *testing.T)
309- }{
310- {
311- name: "CJK writes wide left cell plus continuation",
312- run: func(t *testing.T) {
313- s := NewScreen(10, 3)
314- // Two CJK characters, each width 2: left cell holds the rune,
315- // right cell is a continuation marker.
316- s.Write([]byte("你好"))
317-
318- assertCell(t, s, 0, 0, '你')
319- assertContinuation(t, s, 1, 0)
320- assertCell(t, s, 2, 0, '好')
321- assertContinuation(t, s, 3, 0)
322- if s.Col != 4 || s.Row != 0 {
323- t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)
324- }
325- },
326- },
327- {
328- name: "CJK mixed with ASCII",
329- run: func(t *testing.T) {
330- s := NewScreen(10, 3)
331- s.Write([]byte("A你B"))
332-
333- assertCell(t, s, 0, 0, 'A')
334- assertCell(t, s, 1, 0, '你')
335- assertContinuation(t, s, 2, 0)
336- assertCell(t, s, 3, 0, 'B')
337- if s.Col != 4 {
338- t.Errorf("cursor at col=%d, want 4", s.Col)
339- }
340- },
341- },
342- {
343- name: "emoji writes wide pair",
344- run: func(t *testing.T) {
345- s := NewScreen(10, 3)
346- s.Write([]byte("\U0001f600")) // 😀 grinning face
347-
348- assertCell(t, s, 0, 0, '\U0001f600')
349- assertContinuation(t, s, 1, 0)
350- if s.Col != 2 {
351- t.Errorf("cursor at col=%d, want 2", s.Col)
352- }
353- },
354- },
355- {
356- name: "combining mark is skipped without advancing cursor",
357- run: func(t *testing.T) {
358- s := NewScreen(10, 3)
359- // 'A' followed by combining acute accent U+0301
360- s.Write([]byte("A\xcc\x81"))
361-
362- assertCell(t, s, 0, 0, 'A')
363- // Combining mark should be skipped — no cell written, no cursor advance.
364- if s.Col != 1 {
365- t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)", s.Col)
366- }
367- },
368- },
369- {
370- name: "zero-width characters are skipped without advancing cursor",
371- run: func(t *testing.T) {
372- s := NewScreen(10, 3)
373- // Zero-width space U+200B
374- s.Write([]byte("A\xe2\x80\x8bB"))
375-
376- assertCell(t, s, 0, 0, 'A')
377- assertCell(t, s, 1, 0, 'B')
378- if s.Col != 2 {
379- t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)", s.Col)
380- }
381- },
382- },
383- {
384- name: "CJK surrounded by ASCII on both sides",
385- run: func(t *testing.T) {
386- s := NewScreen(10, 3)
387- s.Write([]byte("a你b好c"))
388-
389- assertCell(t, s, 0, 0, 'a')
390- assertCell(t, s, 1, 0, '你')
391- assertContinuation(t, s, 2, 0)
392- assertCell(t, s, 3, 0, 'b')
393- assertCell(t, s, 4, 0, '好')
394- assertContinuation(t, s, 5, 0)
395- assertCell(t, s, 6, 0, 'c')
396- if s.Col != 7 {
397- t.Errorf("cursor at col=%d, want 7", s.Col)
398- }
399- },
400- },
401- {
402- name: "wide char at last column wraps to next line, abandoned cell cleared",
403- run: func(t *testing.T) {
404- s := NewScreen(3, 2)
405- // Fill the first two columns; cursor lands on the last column.
406- s.Write([]byte("AB")) // cells [A B _], Col=2
407- // A wide char at the last column cannot straddle the edge: it
408- // wraps to the next line, clearing the abandoned last cell.
409- s.Write([]byte("你"))
410-
411- assertCell(t, s, 0, 0, 'A')
412- assertCell(t, s, 1, 0, 'B')
413- assertBlank(t, s, 2, 0) // abandoned last cell cleared
414- assertCell(t, s, 0, 1, '你')
415- assertContinuation(t, s, 1, 1)
416- if s.Col != 2 || s.Row != 1 {
417- t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
418- }
419- },
420- },
421- {
422- name: "CJK damage width is 2",
423- run: func(t *testing.T) {
424- s := NewScreen(10, 3)
425- s.ClearDamage()
426- s.Write([]byte("你"))
427-
428- d := s.Damage()
429- if len(d) == 0 {
430- t.Fatal("expected damage")
431- }
432- if d[0].Width != 2 {
433- t.Errorf("damage width = %d, want 2", d[0].Width)
434- }
435- },
436- },
437- {
438- name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned",
439- run: func(t *testing.T) {
440- s := NewScreen(10, 3)
441- // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute.
442- s.Write([]byte("a你\U0001f600b\xcc\x81"))
443-
444- assertCell(t, s, 0, 0, 'a')
445- assertCell(t, s, 1, 0, '你')
446- assertContinuation(t, s, 2, 0)
447- assertCell(t, s, 3, 0, '\U0001f600')
448- assertContinuation(t, s, 4, 0)
449- assertCell(t, s, 5, 0, 'b')
450- // Combining mark skipped, no cell at col 6.
451- if s.Col != 6 || s.Row != 0 {
452- t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
453- }
454- },
455- },
456- {
457- name: "overwrite left half of wide pair with narrow clears both",
458- run: func(t *testing.T) {
459- s := NewScreen(10, 3)
460- s.Write([]byte("你")) // (0)=你 (1)=cont
461- s.Write([]byte("\x1b[1;1H")) // cursor home
462- s.Write([]byte("X")) // overwrite the wide left half
463-
464- assertCell(t, s, 0, 0, 'X')
465- assertBlank(t, s, 1, 0) // orphaned continuation cleared
466- },
467- },
468- {
469- name: "overwrite right half (continuation) with narrow clears both",
470- run: func(t *testing.T) {
471- s := NewScreen(10, 3)
472- s.Write([]byte("你")) // (0)=你 (1)=cont
473- s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (continuation)
474- s.Write([]byte("X")) // overwrite the continuation
475-
476- assertBlank(t, s, 0, 0) // orphaned wide left cleared
477- assertCell(t, s, 1, 0, 'X')
478- },
479- },
480- {
481- name: "overwrite half of wide pair with a new wide char",
482- run: func(t *testing.T) {
483- s := NewScreen(10, 3)
484- s.Write([]byte("你好")) // (0)你 (1)cont (2)好 (3)cont
485- s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (cont of 你)
486- s.Write([]byte("学")) // wide write at cols 1,2
487-
488- assertBlank(t, s, 0, 0) // 你 left half orphaned → cleared
489- assertCell(t, s, 1, 0, '学')
490- assertContinuation(t, s, 2, 0)
491- assertBlank(t, s, 3, 0) // 好 continuation orphaned → cleared
492- },
493- },
494- {
495- name: "erase to end of line covering a continuation clears its wide left half",
496- run: func(t *testing.T) {
497- s := NewScreen(10, 3)
498- s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
499- s.Write([]byte("\x1b[1;3H")) // cursor to col 2 (continuation)
500- s.Write([]byte("\x1b[K")) // erase from col 2 to end of line
501-
502- assertCell(t, s, 0, 0, 'A')
503- assertBlank(t, s, 1, 0) // 你 left half orphaned by erase → cleared
504- assertBlank(t, s, 2, 0)
505- assertBlank(t, s, 3, 0)
506- },
507- },
508- {
509- name: "erase to start of line covering a wide left clears its continuation",
510- run: func(t *testing.T) {
511- s := NewScreen(10, 3)
512- s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
513- s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (wide left)
514- s.Write([]byte("\x1b[1K")) // erase from start to col 1 inclusive
515-
516- assertBlank(t, s, 0, 0)
517- assertBlank(t, s, 1, 0)
518- assertBlank(t, s, 2, 0) // continuation orphaned by erase → cleared
519- assertCell(t, s, 3, 0, 'B')
520- },
521- },
522- {
523- name: "wrap abandoning a continuation cell clears its pair and extends damage",
524- run: func(t *testing.T) {
525- s := NewScreen(4, 2)
526- s.Write([]byte("AB你")) // A(0) B(1) 你(2) cont(3)
527- s.Write([]byte("\x1b[1;4H")) // cursor to col 3: the continuation cell
528- s.ClearDamage()
529- // A wide rune at the last column wraps; the abandoned last cell
530- // is a continuation, so its wide left half (col 2) must be
531- // cleared too and the damage must span both columns.
532- s.Write([]byte("好"))
533-
534- assertCell(t, s, 0, 0, 'A')
535- assertCell(t, s, 1, 0, 'B')
536- assertBlank(t, s, 2, 0) // orphaned wide left cleared
537- assertBlank(t, s, 3, 0) // abandoned continuation cleared
538- assertCell(t, s, 0, 1, '好')
539- assertContinuation(t, s, 1, 1)
540- if s.Col != 2 || s.Row != 1 {
541- t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
542- }
543-
544- d := s.Damage()
545- if len(d) == 0 {
546- t.Fatal("expected damage")
547- }
548- // First damage item covers the cleared pair on row 0.
549- if d[0].X != 2 || d[0].Y != 0 || d[0].Width != 2 {
550- t.Errorf("wrap damage = {X:%d Y:%d W:%d}, want {X:2 Y:0 W:2}", d[0].X, d[0].Y, d[0].Width)
551- }
552- },
553- },
554- {
555- name: "insert chars at a continuation splits the pair and repairs orphans",
556- run: func(t *testing.T) {
557- s := NewScreen(6, 2)
558- s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3)
559- s.Write([]byte("\x1b[1;4H")) // cursor to col 3: continuation of 好
560- s.ClearDamage()
561- s.Write([]byte("\x1b[1@")) // ICH 1: shift right from col 3
562-
563- // The shift splits 好/cont: 好 stays at col 2, its continuation
564- // moves to col 4. Both orphans must be repaired to blanks.
565- assertCell(t, s, 0, 0, '你')
566- assertContinuation(t, s, 1, 0)
567- assertBlank(t, s, 2, 0) // 好 orphaned by the split → cleared
568- assertBlank(t, s, 3, 0) // inserted blank
569- assertBlank(t, s, 4, 0) // shifted continuation orphaned → cleared
570- assertBlank(t, s, 5, 0)
571-
572- d := s.Damage()
573- if len(d) == 0 {
574- t.Fatal("expected damage")
575- }
576- // Damage must extend one column left to cover the repaired 好.
577- if d[0].X != 2 || d[0].Width != 4 {
578- t.Errorf("ICH damage = {X:%d W:%d}, want {X:2 W:4}", d[0].X, d[0].Width)
579- }
580- },
581- },
582- {
583- name: "insert chars shifting a wide pair off the right edge repairs the left half",
584- run: func(t *testing.T) {
585- s := NewScreen(4, 2)
586- s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3)
587- s.Write([]byte("\x1b[1;1H")) // cursor home
588- s.Write([]byte("\x1b[1@")) // ICH 1: shift the row right by 1
589-
590- // 好 lands on the last column with its continuation pushed off
591- // the edge; the orphaned wide left must be blanked.
592- assertBlank(t, s, 0, 0)
593- assertCell(t, s, 1, 0, '你')
594- assertContinuation(t, s, 2, 0)
595- assertBlank(t, s, 3, 0) // 好 lost its continuation → cleared
596- },
597- },
598- {
599- name: "delete char at the left half of a wide pair repairs the orphan",
600- run: func(t *testing.T) {
601- s := NewScreen(6, 2)
602- s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
603- s.Write([]byte("\x1b[1;1H")) // cursor to col 0: wide left of 你
604- s.ClearDamage()
605- s.Write([]byte("\x1b[1P")) // DCH 1
606-
607- // 你's continuation shifts to col 0 with no left half → repaired.
608- assertBlank(t, s, 0, 0)
609- assertCell(t, s, 1, 0, '好')
610- assertContinuation(t, s, 2, 0)
611- assertCell(t, s, 3, 0, 'A')
612- assertCell(t, s, 4, 0, 'B')
613- assertBlank(t, s, 5, 0)
614-
615- d := s.Damage()
616- if len(d) == 0 {
617- t.Fatal("expected damage")
618- }
619- if d[0].X != 0 || d[0].Width != 6 {
620- t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
621- }
622- },
623- },
624- {
625- name: "delete char at a continuation repairs the wide left and extends damage",
626- run: func(t *testing.T) {
627- s := NewScreen(6, 2)
628- s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
629- s.Write([]byte("\x1b[1;2H")) // cursor to col 1: continuation of 你
630- s.ClearDamage()
631- s.Write([]byte("\x1b[1P")) // DCH 1: delete the continuation
632-
633- // 你 at col 0 loses its continuation → repaired to blank.
634- assertBlank(t, s, 0, 0)
635- assertCell(t, s, 1, 0, '好')
636- assertContinuation(t, s, 2, 0)
637- assertCell(t, s, 3, 0, 'A')
638- assertCell(t, s, 4, 0, 'B')
639- assertBlank(t, s, 5, 0)
640-
641- d := s.Damage()
642- if len(d) == 0 {
643- t.Fatal("expected damage")
644- }
645- // Damage must extend one column left (to col 0) to cover the
646- // repaired wide left half.
647- if d[0].X != 0 || d[0].Width != 6 {
648- t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
649- }
650- },
651- },
652- {
653- name: "IRM inserts narrow printable cells and reset restores overwrite",
654- run: func(t *testing.T) {
655- s := NewScreen(8, 1)
656- s.Write([]byte("abcd\x1b[1;3H\x1b[4hX"))
657-
658- if got := lineText(s, 0); got != "abXcd " {
659- t.Fatalf("IRM inserted line = %q, want %q", got, "abXcd ")
660- }
661- s.Write([]byte("\x1b[4l\x1b[1;3HY"))
662- if got := lineText(s, 0); got != "abYcd " {
663- t.Fatalf("IRM reset overwrite line = %q, want %q", got, "abYcd ")
664- }
665- },
666- },
667- {
668- name: "IRM inserts wide printable cells and repairs evicted wide head",
669- run: func(t *testing.T) {
670- s := NewScreen(6, 1)
671- s.Write([]byte("ab界cd"))
672- s.Write([]byte("\x1b[1;3H\x1b[4h好"))
673-
674- assertCell(t, s, 0, 0, 'a')
675- assertCell(t, s, 1, 0, 'b')
676- assertCell(t, s, 2, 0, '好')
677- assertContinuation(t, s, 3, 0)
678- assertCell(t, s, 4, 0, '界')
679- assertContinuation(t, s, 5, 0)
680- assertNoOrphanWideCells(t, s)
681- },
682- },
683- {
684- name: "IRM wide insert at right edge repairs orphan",
685- run: func(t *testing.T) {
686- s := NewScreen(5, 1)
687- s.Write([]byte("abc界"))
688- s.Write([]byte("\x1b[1;2H\x1b[4h好"))
689-
690- assertCell(t, s, 0, 0, 'a')
691- assertCell(t, s, 1, 0, '好')
692- assertContinuation(t, s, 2, 0)
693- assertCell(t, s, 3, 0, 'b')
694- assertCell(t, s, 4, 0, 'c')
695- assertNoOrphanWideCells(t, s)
696- },
697- },
698- {
699- name: "wide char on a width-1 screen becomes a narrow replacement rune",
700- run: func(t *testing.T) {
701- s := NewScreen(1, 2)
702- // A wide rune cannot fit on a 1-column screen. Store a narrow
703- // replacement instead so renderer width matches the cell layout.
704- s.Write([]byte("你"))
705-
706- assertCell(t, s, 0, 0, '\uFFFD')
707- if got := renderer.RuneWidth(cellAt(s, 0, 0).Rune); got != 1 {
708- t.Errorf("stored rune width = %d, want 1", got)
709- }
710- if cellAt(s, 0, 0).Continuation {
711- t.Error("width-1 replacement must not be a continuation")
712- }
713- assertNoOrphanWideCells(t, s)
714- if s.Col != 1 {
715- t.Errorf("cursor at col=%d, want 1", s.Col)
716- }
717- },
718- },
719- {
720- name: "wide pair survives a scroll",
721- run: func(t *testing.T) {
722- s := NewScreen(4, 2)
723- s.Write([]byte("你\r\n好")) // row0: 你, row1: 好
724- s.Write([]byte("\r\n")) // bottom row → scroll up
725-
726- // After scrolling up, row1's 好 moves to row0 intact.
727- assertCell(t, s, 0, 0, '好')
728- assertContinuation(t, s, 1, 0)
729- },
730- },
731- {
732- name: "combining mark alone writes no cell and produces no damage",
733- run: func(t *testing.T) {
734- s := NewScreen(10, 3)
735- s.ClearDamage()
736- s.Write([]byte("\xcc\x81")) // combining acute accent alone (no base char)
737-
738- // No cells should be modified.
739- for y := range 3 {
740- for x := range 10 {
741- if c := cellAt(s, x, y); c.Rune != ' ' {
742- t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
743- }
744- }
745- }
746- if s.Col != 0 || s.Row != 0 {
747- t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
748- }
749- d := s.Damage()
750- if len(d) != 0 {
751- t.Errorf("expected no damage for combining mark alone, got %+v", d)
752- }
753- },
754- },
755- }
756-
757- for _, tt := range tests {
758- t.Run(tt.name, tt.run)
759- }
760-}
761-
762-func TestRuneWidth(t *testing.T) {
763- tests := []struct {
764- name string
765- r rune
766- width int
767- }{
768- {"space", ' ', 1},
769- {"lowercase ascii letter", 'a', 1},
770- {"uppercase ascii letter", 'A', 1},
771- {"ascii digit", '1', 1},
772- {"soft hyphen", 0x00AD, 0},
773- {"combining grapheme joiner", 0x034F, 0},
774- {"combining acute accent", 0x0301, 0},
775- {"combining grave accent", 0x0300, 0},
776- {"zero-width space", 0x200B, 0},
777- {"zero-width non-joiner", 0x200C, 0},
778- {"zero-width joiner", 0x200D, 0},
779- {"BOM / zero-width no-break space", 0xFEFF, 0},
780- {"CJK Unified Ideograph (一)", 0x4E00, 2},
781- {"CJK (二)", 0x4E8C, 2},
782- {"CJK end of BMP", 0x9FFF, 2},
783- {"CJK Extension A", 0x3400, 2},
784- {"Hangul Syllable (가)", 0xAC00, 2},
785- {"Hangul Syllable end", 0xD7AF, 2},
786- {"CJK Ideographic Space", 0x3000, 2},
787- {"Fullwidth Exclamation Mark", 0xFF01, 2},
788- {"Fullwidth Cent Sign", 0xFFE0, 2},
789- {"grinning face emoji", 0x1F600, 2},
790- {"rocket emoji", 0x1F680, 2},
791- {"hiragana a", 0x3042, 2},
792- {"katakana a", 0x30A2, 2},
793- {"CJK Extension B", 0x20000, 2},
794- {"supplemental symbols (robot)", 0x1F916, 2},
795- {"symbols extended-A (sari)", 0x1FA71, 2},
796- {"ideographic full stop", 0x3002, 2},
797- {"NUL", '\x00', 0},
798- {"SOH", '\x01', 0},
799- {"ESC", '\x1b', 0},
800- {"DEL", 0x7F, 0},
801- }
802-
803- for _, tt := range tests {
804- t.Run(tt.name, func(t *testing.T) {
805- got := renderer.RuneWidth(tt.r)
806- if got != tt.width {
807- t.Errorf("renderer.RuneWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)
808- }
809- })
810- }
811-}
812-
813-func TestC1Controls(t *testing.T) {
814- tests := []struct {
815- name string
816- run func(t *testing.T)
817- }{
818- {
819- name: "C1 controls are dropped without moving the cursor",
820- run: func(t *testing.T) {
821- s := NewScreen(10, 3)
822- s.Write([]byte{0xC2, 0x80}) // U+0080 PAD
823- s.Write([]byte{0xC2, 0x9F}) // U+009F APC
824-
825- // No cells should be modified.
826- for y := range 3 {
827- for x := range 10 {
828- if c := cellAt(s, x, y); c.Rune != ' ' {
829- t.Errorf("cell(%d,%d) = %q, want space after C1 controls", x, y, c.Rune)
830- }
831- }
832- }
833- // Cursor should not have moved.
834- if s.Col != 0 || s.Row != 0 {
835- t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
836- }
837- },
838- },
839- {
840- name: "the full C1 control range U+0080-U+009F is dropped",
841- run: func(t *testing.T) {
842- s := NewScreen(10, 3)
843- // Send all C1 control runes U+0080 through U+009F.
844- for r := 0x80; r <= 0x9F; r++ {
845- s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)})
846- }
847-
848- // No cells should be modified.
849- for y := range 3 {
850- for x := range 10 {
851- if c := cellAt(s, x, y); c.Rune != ' ' {
852- t.Errorf("cell(%d,%d) = %q, want space after C1 range", x, y, c.Rune)
853- }
854- }
855- }
856- if s.Col != 0 || s.Row != 0 {
857- t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
858- }
859- },
860- },
861- {
862- name: "printable characters still work after C1 controls",
863- run: func(t *testing.T) {
864- s := NewScreen(10, 3)
865- // Printable characters should still work after C1 controls.
866- s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
867- s.Write([]byte("AB"))
868- s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
869- s.Write([]byte("CD"))
870-
871- assertCell(t, s, 0, 0, 'A')
872- assertCell(t, s, 1, 0, 'B')
873- assertCell(t, s, 2, 0, 'C')
874- assertCell(t, s, 3, 0, 'D')
875- if s.Col != 4 {
876- t.Errorf("cursor at col=%d, want 4", s.Col)
877- }
878- },
879- },
880- {
881- name: "C1 controls mixed with CR preserve CR behavior",
882- run: func(t *testing.T) {
883- s := NewScreen(10, 3)
884- // C1 controls mixed with CR/LF should preserve CR/LF behavior.
885- s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
886- s.Write([]byte("A\rB")) // CR moves cursor back, overwrite A
887- s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
888-
889- assertCell(t, s, 0, 0, 'B')
890- },
891- },
892- {
893- name: "C1 controls mixed with LF preserve LF behavior",
894- run: func(t *testing.T) {
895- s := NewScreen(10, 3)
896- s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
897- s.Write([]byte("A\nB"))
898-
899- assertCell(t, s, 0, 0, 'A')
900- assertCell(t, s, 1, 1, 'B')
901- if s.Row != 1 || s.Col != 2 {
902- t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)", s.Row, s.Col)
903- }
904- },
905- },
906- {
907- name: "C1 controls produce no damage",
908- run: func(t *testing.T) {
909- s := NewScreen(10, 3)
910- s.ClearDamage()
911- // C1 controls should produce no damage.
912- s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F})
913-
914- d := s.Damage()
915- if len(d) != 0 {
916- t.Errorf("expected no damage from C1 controls, got %+v", d)
917- }
918- },
919- },
920- }
921-
922- for _, tt := range tests {
923- t.Run(tt.name, tt.run)
924- }
925-}
926-
927-func TestScreenLineBoundsDescribesTheLiveGrid(t *testing.T) {
928- tests := []struct {
929- name string
930- width int
931- height int
932- write string
933- want []LineBound
934- // checkOwned exercises the caller-owned-copy guarantee: it only makes
935- // sense to check once per LineBounds() call, so it lives on the case
936- // whose write actually produces a soft-wrapped row 0 to corrupt.
937- checkOwned bool
938- }{
939- {
940- name: "wraps after column width",
941- width: 4,
942- height: 3,
943- write: "abcdef", // wraps after column 4
944- want: []LineBound{
945- {End: 4, Soft: true}, // row 0: filled the row and wrapped
946- {End: 2, Soft: false}, // row 1: "ef", no wrap
947- {End: 0, Soft: false}, // row 2: untouched
948- },
949- checkOwned: true,
950- },
951- }
952-
953- for _, tt := range tests {
954- t.Run(tt.name, func(t *testing.T) {
955- s := NewScreen(tt.width, tt.height)
956- s.Write([]byte(tt.write))
957-
958- bounds := s.LineBounds()
959- require.Equal(t, tt.want, bounds)
960-
961- if tt.checkOwned {
962- // The result is owned by the caller.
963- bounds[0] = LineBound{}
964- require.True(t, s.LineBounds()[0].Soft, "mutating the result changed the screen's own boundaries")
965- }
966- })
967- }
968-}
D pkg/vt/scrollback_test.go +0 -171
@@ -1,171 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7-)
8-
9-func evictedTexts(rows [][]renderer.Cell) []string {
10- out := make([]string, len(rows))
11- for i, row := range rows {
12- runes := make([]rune, len(row))
13- for x, c := range row {
14- runes[x] = c.Rune
15- }
16- out[i] = string(runes)
17- }
18- return out
19-}
20-
21-func TestOnLineEvicted(t *testing.T) {
22- tests := []struct {
23- name string
24- write []byte
25- wantRows []string
26- wantFrame []string
27- }{
28- {
29- name: "newline scroll evicts top row",
30- write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
31- wantRows: []string{"AAAA"},
32- wantFrame: []string{"BBBB", "CCCC", " "},
33- },
34- {
35- name: "CSI S count evicts each top row in order",
36- write: []byte("AAAA\r\nBBBB\r\nCCCC\x1b[2S"),
37- wantRows: []string{"AAAA", "BBBB"},
38- wantFrame: []string{"CCCC", " ", " "},
39- },
40- }
41-
42- for _, tt := range tests {
43- t.Run(tt.name, func(t *testing.T) {
44- s := NewScreen(4, 3)
45- var evicted [][]renderer.Cell
46- s.OnLineEvicted = func(row []renderer.Cell) {
47- evicted = append(evicted, append([]renderer.Cell(nil), row...))
48- }
49-
50- s.Write(tt.write)
51-
52- if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) {
53- t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows)
54- }
55- for y, want := range tt.wantFrame {
56- if got := lineText(s, y); got != want {
57- t.Fatalf("line %d = %q, want %q", y, got, want)
58- }
59- }
60- })
61- }
62-}
63-
64-func TestOnLineEvictedAltScreenAndRotation(t *testing.T) {
65- tests := []struct {
66- name string
67- write []byte
68- wantRows []string
69- }{
70- {
71- name: "alternate screen scroll does not evict",
72- write: []byte("main\x1b[?1049hAAAA\r\nBBBB\r\nCCCC\r\nDDDD\x1b[?1049lEEEE\r\n"),
73- wantRows: nil,
74- },
75- {
76- name: "evicted row follows logical top after prior lineOffset rotation",
77- write: []byte("AAAA\r\nBBBB\r\nCCCC\r\nDDDD\r\n"),
78- wantRows: []string{"AAAA", "BBBB"},
79- },
80- }
81-
82- for _, tt := range tests {
83- t.Run(tt.name, func(t *testing.T) {
84- s := NewScreen(4, 3)
85- var evicted [][]renderer.Cell
86- s.OnLineEvicted = func(row []renderer.Cell) {
87- evicted = append(evicted, append([]renderer.Cell(nil), row...))
88- }
89-
90- s.Write(tt.write)
91-
92- if got := evictedTexts(evicted); !equalStrings(got, tt.wantRows) {
93- t.Fatalf("evicted rows = %#v, want %#v", got, tt.wantRows)
94- }
95- if err := s.Frame.CheckInvariants(); err != nil {
96- t.Fatalf("frame invariants after scrollback callback: %v", err)
97- }
98- })
99- }
100-}
101-
102-func TestEvictedLinesCarryTheirBound(t *testing.T) {
103- tests := []struct {
104- name string
105- evict func(s *Screen)
106- wantSoft bool
107- }{
108- {
109- name: "scroll evicts a soft-wrapped row",
110- evict: func(s *Screen) {
111- // Filling all four columns only leaves deferred wrap pending. The
112- // fifth printable character triggers the soft wrap before scrolling.
113- s.Write([]byte("abcde"))
114- s.Write([]byte("\r\n\r\n\r\n\r\n"))
115- },
116- wantSoft: true,
117- },
118- {
119- name: "scroll evicts a hard row",
120- evict: func(s *Screen) {
121- s.Write([]byte("ab\r\n\r\n\r\n\r\n"))
122- },
123- wantSoft: false,
124- },
125- }
126-
127- for _, tc := range tests {
128- t.Run(tc.name, func(t *testing.T) {
129- s := NewScreenWithHistory(4, 2, HistoryConfig{MaxRows: 16, MaxCells: 1024})
130- tc.evict(s)
131- view := s.History().SealAndView()
132- if view.Len() == 0 {
133- t.Fatal("nothing was evicted to history")
134- }
135- if got := view.Bound(0).Soft; got != tc.wantSoft {
136- t.Errorf("Bound(0).Soft = %v, want %v", got, tc.wantSoft)
137- }
138- })
139- }
140-}
141-
142-func TestReflowEvictedLinesCarryTheirBound(t *testing.T) {
143- s := NewScreenWithHistory(8, 3, HistoryConfig{MaxRows: 16, MaxCells: 1024})
144- // Fill the grid with wrapped content, then shrink so rows are evicted by
145- // the reflow path in buffer.resize rather than by scrolling.
146- s.Write([]byte("aaaaaaaabbbbbbbbcccccccc"))
147- s.Resize(4, 2)
148-
149- view := s.History().SealAndView()
150- if view.Len() == 0 {
151- t.Fatal("reflow evicted nothing to history")
152- }
153- for i := range view.Len() {
154- bound := view.Bound(i)
155- if bound.End == 0 {
156- t.Errorf("Bound(%d).End = 0, want the reflowed row extent", i)
157- }
158- }
159-}
160-
161-func equalStrings(a, b []string) bool {
162- if len(a) != len(b) {
163- return false
164- }
165- for i := range a {
166- if a[i] != b[i] {
167- return false
168- }
169- }
170- return true
171-}
D pkg/vt/snapshot.go +0 -201
@@ -1,201 +0,0 @@
1-package vt
2-
3-import (
4- "fmt"
5- "math"
6-)
7-
8-// DecodeStats describes resources declared by one canonical VT blob.
9-type DecodeStats struct {
10- Chunks uint64
11- Rows uint64
12- Cells uint64
13- Styles uint64
14- Bytes uint64
15-}
16-
17-// Add adds another preflight result with overflow checking.
18-func (s *DecodeStats) Add(other DecodeStats) bool {
19- for _, totals := range [][2]*uint64{{&s.Chunks, &other.Chunks}, {&s.Rows, &other.Rows}, {&s.Cells, &other.Cells}, {&s.Styles, &other.Styles}, {&s.Bytes, &other.Bytes}} {
20- if math.MaxUint64-*totals[0] < *totals[1] {
21- return false
22- }
23- *totals[0] += *totals[1]
24- }
25- return true
26-}
27-
28-// PreflightHistoryBlob validates one self-contained history blob without
29-// allocating decoded rows.
30-func PreflightHistoryBlob(data []byte) (DecodeStats, error) {
31- stats, ok := preflightHistory(data)
32- if !ok {
33- return DecodeStats{}, fmt.Errorf("preflight history: %w", errInvalidHistory)
34- }
35- return DecodeStats{Chunks: stats.chunks, Rows: stats.rows, Cells: stats.cells, Styles: stats.styles, Bytes: stats.bytes}, nil
36-}
37-
38-// MarshalHistoryChunk encodes one immutable chunk as a self-contained blob.
39-func MarshalHistoryChunk(chunk *HistoryChunk) ([]byte, error) {
40- if chunk == nil {
41- return nil, fmt.Errorf("marshal history chunk: %w", errInvalidHistory)
42- }
43- return MarshalHistory(HistoryView{chunks: []*HistoryChunk{chunk}, rows: len(chunk.rows)})
44-}
45-
46-// MarshalEmptyHistoryTail returns the mandatory canonical empty tail blob.
47-func MarshalEmptyHistoryTail() ([]byte, error) { return MarshalHistory(HistoryView{}) }
48-
49-// MarshalHistoryTail encodes the copied mutable tail of a snapshot view as one
50-// canonical history blob. It does not seal or otherwise mutate live history.
51-func MarshalHistoryTail(view HistorySnapshotView) ([]byte, error) {
52- return MarshalHistory(view.Tail())
53-}
54-
55-// MarshalSealedHistory serializes a SealAndView result as oldest-first,
56-// self-contained sealed blobs plus a mandatory empty tail blob. The empty tail
57-// carries NextRowID and is not the canonical default empty encoding.
58-func MarshalSealedHistory(view HistoryView) ([][]byte, []byte, error) {
59- if view.rows != historyViewRowCount(view) {
60- return nil, nil, fmt.Errorf("marshal sealed history: %w", errInvalidHistory)
61- }
62- sealed := make([][]byte, len(view.chunks))
63- for i, chunk := range view.chunks {
64- blob, err := MarshalHistoryChunk(chunk)
65- if err != nil {
66- return nil, nil, err
67- }
68- sealed[i] = blob
69- }
70- tail, err := MarshalHistory(HistoryView{nextRowID: view.NextRowID()})
71- if err != nil {
72- return nil, nil, err
73- }
74- return sealed, tail, nil
75-}
76-
77-// HistoryFromBlobs restores history directly from sealed, oldest-first blobs
78-// and the mandatory tail blob. It never feeds decoded rows through Append.
79-func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*History, error) {
80- sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail)
81- if err != nil {
82- return nil, err
83- }
84- return restoreHistoryViews(config, sealedViews, tailView), nil
85-}
86-
87-func decodeRestoredHistoryBlobs(sealed [][]byte, tail []byte, extra ...HistoryView) ([]HistoryView, HistoryView, error) {
88- seen := make(map[RowID]struct{})
89- sealedViews := make([]HistoryView, len(sealed))
90- for i, blob := range sealed {
91- view, err := UnmarshalHistory(blob)
92- if err != nil || len(view.chunks) != 1 || len(view.chunks[0].rows) == 0 || !validateRestoredHistoryView(view, seen) {
93- return nil, HistoryView{}, fmt.Errorf("restore sealed history: %w", errInvalidHistory)
94- }
95- sealedViews[i] = view
96- }
97-
98- tailView, err := UnmarshalHistory(tail)
99- if err != nil || len(tailView.chunks) > 1 || !validateRestoredHistoryView(tailView, seen) {
100- return nil, HistoryView{}, fmt.Errorf("restore history tail: %w", errInvalidHistory)
101- }
102- for _, view := range extra {
103- if !validateRestoredHistoryView(view, seen) {
104- return nil, HistoryView{}, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)
105- }
106- }
107- return sealedViews, tailView, nil
108-}
109-
110-func restoreHistoryViews(config HistoryConfig, sealed []HistoryView, tail HistoryView) *History {
111- h := NewHistory(config)
112- for _, view := range sealed {
113- h.nextRowID = max(h.nextRowID, view.nextRowID)
114- if h.maxRows == 0 {
115- continue
116- }
117- chunk := view.chunks[0]
118- h.chunks = append(h.chunks, chunk)
119- h.rows += len(chunk.rows)
120- h.cells += view.Cells()
121- h.evict()
122- }
123- h.nextRowID = max(h.nextRowID, tail.nextRowID)
124- if h.maxRows == 0 || len(tail.chunks) == 0 {
125- return h
126- }
127- chunk := tail.chunks[0]
128- h.tail = chunk.rows
129- h.tailBounds = append([]LineBound(nil), chunk.bounds...)
130- h.tailIDs = append([]RowID(nil), chunk.rowIDs...)
131- h.rows += len(h.tail)
132- h.cells += tail.Cells()
133- h.evict()
134- h.normalizeTail()
135- return h
136-}
137-
138-func validateRestoredHistoryView(view HistoryView, seen map[RowID]struct{}) bool {
139- if view.nextRowID == 0 || view.nextRowID == ^RowID(0) {
140- return false
141- }
142- var maxID RowID
143- for _, chunk := range view.chunks {
144- if chunk == nil || len(chunk.rows) != len(chunk.rowIDs) || len(chunk.rows) != len(chunk.bounds) {
145- return false
146- }
147- for _, id := range chunk.rowIDs {
148- if id == 0 || id >= ^RowID(0)-1 {
149- return false
150- }
151- if _, duplicate := seen[id]; duplicate {
152- return false
153- }
154- seen[id] = struct{}{}
155- maxID = max(maxID, id)
156- }
157- }
158- return view.nextRowID > maxID
159-}
160-
161-// NewScreenWithRecoveryTranscript constructs a fresh blank screen whose
162-// history contains the restored bounded history followed by the recovery
163-// transcript. The transcript is decoded in full before history is restored.
164-func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, sealed [][]byte, tail, transcript []byte) (*Screen, error) {
165- transcriptView, err := UnmarshalHistory(transcript)
166- if err != nil {
167- return nil, fmt.Errorf("restore recovery transcript: %w", err)
168- }
169-
170- sealedViews, tailView, err := decodeRestoredHistoryBlobs(sealed, tail, transcriptView)
171- if err != nil {
172- return nil, err
173- }
174- history := restoreHistoryViews(config, sealedViews, tailView)
175- remainingRows := transcriptView.rows
176- for _, chunk := range transcriptView.chunks {
177- for i, row := range chunk.rows {
178- remainingRows--
179- bound := chunk.bounds[i]
180- if remainingRows == 0 {
181- bound.Soft = false
182- }
183- if err := history.AppendWithID(row, bound, chunk.rowIDs[i]); err != nil {
184- return nil, fmt.Errorf("restore recovery transcript: %w", err)
185- }
186- }
187- }
188-
189- screen := NewScreenWithHistory(width, height, config)
190- screen.history = history
191- counter := max(history.nextRowID, transcriptView.nextRowID, RowID(1))
192- if counter == 0 || counter >= ^RowID(0) || uint64(height) > uint64(^RowID(0)-1-counter) {
193- return nil, fmt.Errorf("restore recovery transcript: %w", errInvalidHistory)
194- }
195- screen.nextRowID = counter - 1
196- for i := range screen.buffer.rowIDs {
197- screen.buffer.rowIDs[i] = screen.nextRowIDValue()
198- }
199- screen.Frame = screen.buffer.frame
200- return screen, nil
201-}
D pkg/vt/snapshot_test.go +0 -86
@@ -1,86 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/vev/pkg/renderer"
7- "github.com/stretchr/testify/require"
8-)
9-
10-func TestHistorySnapshotViewDoesNotSealTailAndCopiesIt(t *testing.T) {
11- history := NewHistory(HistoryConfig{MaxRows: 8, ChunkRows: 2})
12- requireHistoryAppend(t, history, historyRow("AAAA"))
13- requireHistoryAppend(t, history, historyRow("BBBB"))
14- requireHistoryAppend(t, history, historyRow("CCCC"))
15- sealed := history.View().Chunk(0)
16-
17- view := history.SnapshotView()
18- if got, want := view.ChunkCount(), 1; got != want {
19- t.Fatalf("sealed chunks = %d, want %d", got, want)
20- }
21- if view.Chunk(0) != sealed {
22- t.Fatal("snapshot copied an immutable sealed chunk")
23- }
24- if got, want := len(history.tail), 1; got != want {
25- t.Fatalf("live tail rows = %d, want %d; snapshot sealed it", got, want)
26- }
27- history.tail[0][0].Rune = 'X'
28- if got, want := rowText(view.Tail().Row(0)), "CCCC"; got != want {
29- t.Fatalf("snapshot tail = %q, want %q", got, want)
30- }
31- if got, want := view.Len(), 3; got != want {
32- t.Fatalf("snapshot rows = %d, want %d", got, want)
33- }
34- if got, want := view.Cells(), 12; got != want {
35- t.Fatalf("snapshot cells = %d, want %d", got, want)
36- }
37- wantID := view.Tail().RowID(0)
38- history.tailIDs[0] = 99
39- if got := view.Tail().RowID(0); got != wantID {
40- t.Fatalf("snapshot tail ID = %d after live mutation, want %d", got, wantID)
41- }
42-
43- tail, err := MarshalHistoryTail(view)
44- require.NoError(t, err)
45- decoded, err := UnmarshalHistory(tail)
46- require.NoError(t, err)
47- require.Equal(t, []string{"CCCC"}, historyViewTexts(decoded))
48-}
49-
50-func TestHistoryFromBlobsNormalizesFullTailBeforeAppend(t *testing.T) {
51- fullTail, err := MarshalHistory(HistoryView{
52- chunks: []*HistoryChunk{{rows: [][]renderer.Cell{historyRow("AAAA"), historyRow("BBBB")}, rowIDs: []RowID{1, 2}}},
53- rows: 2,
54- nextRowID: 3,
55- })
56- require.NoError(t, err)
57-
58- restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, ChunkRows: 2}, nil, fullTail)
59- require.NoError(t, err)
60- requireHistoryAppend(t, restored, historyRow("CCCC"))
61-
62- require.Equal(t, []string{"AAAA", "BBBB", "CCCC"}, historyViewTexts(restored.View()))
63- require.Len(t, restored.chunks, 1, "a full restored tail must become sealed")
64- require.Len(t, restored.tail, 1, "the appended row must be the mutable tail")
65-}
66-
67-func TestHistoryFromBlobsRestoresCellAccountingAndEvictsToBothBudgets(t *testing.T) {
68- history := NewHistory(HistoryConfig{MaxRows: 4, MaxCells: 10, ChunkRows: 2})
69- for _, text := range []string{"a", "b", "cc"} {
70- requireHistoryAppend(t, history, historyRow(text))
71- }
72- view := history.SnapshotView()
73- sealed := make([][]byte, view.ChunkCount())
74- for i := range sealed {
75- var err error
76- sealed[i], err = MarshalHistoryChunk(view.Chunk(i))
77- require.NoError(t, err)
78- }
79- tail, err := MarshalHistoryTail(view)
80- require.NoError(t, err)
81-
82- restored, err := HistoryFromBlobs(HistoryConfig{MaxRows: 4, MaxCells: 2, ChunkRows: 2}, sealed, tail)
83- require.NoError(t, err)
84- require.Equal(t, []string{"cc"}, historyViewTexts(restored.View()))
85- require.Equal(t, 2, restored.Cells())
86-}
D pkg/vt/wrap_scroll_soft_test.go +0 -135
@@ -1,135 +0,0 @@
1-package vt
2-
3-import (
4- "testing"
5-
6- "github.com/stretchr/testify/require"
7-)
8-
9-// A deferred wrap out of the last row of a scroll region scrolls that region,
10-// and buffer.scrollUp deliberately severs the link on the last moved row. That
11-// sever is right for a linefeed and wrong when the wrap itself caused the
12-// scroll, so these cases pin both sides of the distinction.
13-func TestSoftLinkSurvivesTheScrollItCaused(t *testing.T) {
14- const width = 8
15- cases := []struct {
16- name string
17- height int
18- input string
19- // wantBounds is indexed like the live grid.
20- wantBounds map[int]LineBound
21- // wantRows pins the grid text so a bound is never read off a row that
22- // holds something other than what the case describes.
23- wantRows map[int]string
24- }{
25- {
26- // The bug: the cursor sits on the last row, as it does in any full
27- // shell session, so the wrap scrolls and the link must survive.
28- name: "narrow wrap out of the bottom row keeps the link",
29- height: 4,
30- input: "1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij",
31- wantBounds: map[int]LineBound{
32- 2: {End: 8, Soft: true},
33- 3: {End: 2},
34- },
35- wantRows: map[int]string{2: "abcdefgh", 3: "ij "},
36- },
37- {
38- // Same shape at the wide-rune wrap site: the wide rune does not fit
39- // in the last column, so the writer abandons that cell and wraps.
40- name: "wide-rune wrap out of the bottom row keeps the link",
41- height: 4,
42- input: "1\r\n2\r\n3\r\nabcdefg漢",
43- wantBounds: map[int]LineBound{
44- 2: {End: 7, Soft: true},
45- 3: {End: 2},
46- },
47- wantRows: map[int]string{2: "abcdefg "},
48- },
49- {
50- // The sever that must survive: a full-width row ending in an explicit
51- // newline is a hard line that merely happens to fill the grid.
52- name: "explicit newline after a full-width bottom row severs",
53- height: 4,
54- input: "1\r\n2\r\n3\r\nabcdefgh\r\n",
55- wantBounds: map[int]LineBound{
56- 2: {End: 8, Soft: false},
57- },
58- wantRows: map[int]string{2: "abcdefgh"},
59- },
60- {
61- // A DECSTBM region scroll is a local mutation, not a wrap: it must
62- // still sever at the row above the region (top-1), whose
63- // continuation the scroll just destroyed, and at the last moved row.
64- name: "DECSTBM region scroll severs at both edges",
65- height: 4,
66- input: "abcdefghij" + "\x1b[2;3r" + "\x1b[3;1H" + "\n",
67- wantBounds: map[int]LineBound{
68- 0: {End: 8, Soft: false},
69- 1: {End: 0, Soft: false},
70- },
71- wantRows: map[int]string{0: "abcdefgh"},
72- },
73- {
74- // A region exactly one row tall blanks the wrapped row in place
75- // instead of moving it up, so no surviving grid row may be linked.
76- // setScrollRegion rejects top >= bottom, so the only reachable
77- // one-row region is a one-row screen.
78- name: "wrap on a one-row screen links nothing on the grid",
79- height: 1,
80- input: "abcdefghij",
81- wantBounds: map[int]LineBound{
82- 0: {End: 2, Soft: false},
83- },
84- wantRows: map[int]string{0: "ij "},
85- },
86- }
87- for _, tc := range cases {
88- t.Run(tc.name, func(t *testing.T) {
89- s := NewScreenWithHistory(width, tc.height, HistoryConfig{MaxRows: 64, MaxCells: 4096})
90- s.Write([]byte(tc.input))
91-
92- bounds := s.LineBounds()
93- for y, want := range tc.wantBounds {
94- require.Equal(t, want, bounds[y], "LineBounds()[%d]", y)
95- }
96- for y, want := range tc.wantRows {
97- require.Equal(t, want, lineText(s, y), "row %d text", y)
98- }
99- })
100- }
101-}
102-
103-// The soft link also drives reflow, so restoring it fixes resize too: a line
104-// wrapped out of the bottom row used to be two unrelated physical rows, and
105-// widening the pane left it split instead of rejoining it.
106-func TestResizeRejoinsALineWrappedOutOfTheBottomRow(t *testing.T) {
107- s := NewScreenWithHistory(8, 4, HistoryConfig{MaxRows: 64, MaxCells: 4096})
108- s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij"))
109-
110- s.Resize(16, 4)
111-
112- require.Equal(t, "abcdefghij ", lineText(s, 2), "the wrapped line must rejoin on one row")
113- require.Equal(t, LineBound{End: 10}, s.LineBounds()[2])
114- require.Equal(t, " ", lineText(s, 3), "its continuation row must be consumed by the rejoin")
115-}
116-
117-// A wrap that scrolls the primary screen must hand history the same soft link
118-// it leaves on the grid, so a line wrapped at the bottom of a full pane still
119-// rejoins after it scrolls away.
120-func TestSoftLinkFromABottomRowWrapReachesHistory(t *testing.T) {
121- const (
122- width = 8
123- height = 4
124- )
125- s := NewScreenWithHistory(width, height, HistoryConfig{MaxRows: 64, MaxCells: 4096})
126- // Wrap out of the bottom row, then push both physical rows into history.
127- s.Write([]byte("1\r\n2\r\n3\r\n4\r\n5\r\nabcdefghij\r\nx\r\ny\r\nz\r\n"))
128-
129- view := s.History().SealAndView()
130- require.Equal(t, 7, view.Len(), "history row count")
131- require.Equal(t, "abcdefgh", rowString(view.BorrowedRow(5)))
132- require.Equal(t, LineBound{End: 8, Soft: true}, view.Bound(5), "the wrapped row must reach history soft")
133- require.Equal(t, "ij ", rowString(view.BorrowedRow(6)))
134- require.Equal(t, LineBound{End: 2, Soft: false}, view.Bound(6), "its continuation is a hard line end")
135-}
M scripts/remote-picker-harness/visual_probe.go +1 -1
@@ -9,9 +9,9 @@ import (
9 "path/filepath"9 "path/filepath"
10 "strings"10 "strings"
11 11
12+ vt "github.com/bnema/vev-vt"
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/pkg/vt"
15 )15 )
16 16
17 const (17 const (
M transport_replay_integration_test.go +2 -2
@@ -6,12 +6,12 @@ import (
6 "sync"6 "sync"
7 "testing"7 "testing"
8 8
9+ vt "github.com/bnema/vev-vt"
10+ renderer "github.com/bnema/vev-vt/ansi"
9 "github.com/bnema/vev/internal/adapters/ipc"11 "github.com/bnema/vev/internal/adapters/ipc"
10 "github.com/bnema/vev/internal/adapters/sshstdio"12 "github.com/bnema/vev/internal/adapters/sshstdio"
11 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
12 "github.com/bnema/vev/internal/testutil/replaytest"14 "github.com/bnema/vev/internal/testutil/replaytest"
13- "github.com/bnema/vev/pkg/renderer"
14- "github.com/bnema/vev/pkg/vt"
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 )16 )
17 17

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close