Merge pull request #50 from bnema/improve/003-session-steal-render-guard
Improve/003 session steal render guard
15 files · +754 -77
M AGENTS.md +4 -1
| @@ -16,11 +16,14 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem | |||
| 16 | 16 | ``` | |
| 17 | 17 | ||
| 18 | 18 | - Format with **goimports**. | |
| 19 | + | - Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v2@latest`. | |
| 19 | 20 | - `make test` runs `go test ./... -race`. | |
| 20 | 21 | - `make lint` checks `goimports -l`, then `go vet`. | |
| 21 | 22 | - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`. | |
| 23 | + | - Set `VEV_LOG=debug|warn|error` to change log verbosity (default info). | |
| 22 | 24 | - Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset. | |
| 23 | - | - Use feature worktrees under `.worktrees/<branch-name>`. | |
| 25 | + | - Use feature worktrees under `.worktrees/<branch-name>`. Remove the worktree once its branch is merged or abandoned (`git worktree remove <path>`). | |
| 26 | + | - Create vev-owned private directories with `pkg/safedir.EnsurePrivate`, not `os.MkdirAll`. | |
| 24 | 27 | ||
| 25 | 28 | ## Architecture rules | |
| 26 | 29 | ||
| @@ -16,11 +16,14 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -bench=. -benchmem | |||||
| 16 | ``` | 16 | ``` | ||
| 17 | 17 | ||||
| 18 | - Format with **goimports**. | 18 | - Format with **goimports**. | ||
| 19 | + | - Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v2@latest`. | |||
| 19 | - `make test` runs `go test ./... -race`. | 20 | - `make test` runs `go test ./... -race`. | ||
| 20 | - `make lint` checks `goimports -l`, then `go vet`. | 21 | - `make lint` checks `goimports -l`, then `go vet`. | ||
| 21 | - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`. | 22 | - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`. | ||
| 23 | + | - Set `VEV_LOG=debug|warn|error` to change log verbosity (default info). | |||
| 22 | - Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset. | 24 | - Logs live in `$XDG_STATE_HOME/vev`, or `~/.local/state/vev` when unset. | ||
| 23 | - | - Use feature worktrees under `.worktrees/<branch-name>`. | 25 | + | - Use feature worktrees under `.worktrees/<branch-name>`. Remove the worktree once its branch is merged or abandoned (`git worktree remove <path>`). |
| 26 | + | - Create vev-owned private directories with `pkg/safedir.EnsurePrivate`, not `os.MkdirAll`. | |||
| 24 | 27 | ||||
| 25 | ## Architecture rules | 28 | ## Architecture rules | ||
| 26 | 29 | ||||
M README.md +2 -0
| @@ -208,6 +208,8 @@ Copy uses OSC 52 clipboard sequences when supported by your terminal. | |||
| 208 | 208 | ## Development | |
| 209 | 209 | ||
| 210 | 210 | ```sh | |
| 211 | + | go install golang.org/x/tools/cmd/goimports@latest | |
| 212 | + | go install github.com/vektra/mockery/v2@latest | |
| 211 | 213 | make test # go test ./... -race | |
| 212 | 214 | make lint # goimports check, then go vet ./... | |
| 213 | 215 | make mocks # regenerate mocks | |
| @@ -208,6 +208,8 @@ Copy uses OSC 52 clipboard sequences when supported by your terminal. | |||||
| 208 | ## Development | 208 | ## Development | ||
| 209 | 209 | ||||
| 210 | ```sh | 210 | ```sh | ||
| 211 | + | go install golang.org/x/tools/cmd/goimports@latest | |||
| 212 | + | go install github.com/vektra/mockery/v2@latest | |||
| 211 | make test # go test ./... -race | 213 | make test # go test ./... -race | ||
| 212 | make lint # goimports check, then go vet ./... | 214 | make lint # goimports check, then go vet ./... | ||
| 213 | make mocks # regenerate mocks | 215 | make mocks # regenerate mocks | ||
A internal/adapters/clock/clock_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package clock | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestRealClockNow(t *testing.T) { | |
| 9 | + | c := New() | |
| 10 | + | before := time.Now().Add(-time.Second) | |
| 11 | + | got := c.Now() | |
| 12 | + | after := time.Now().Add(time.Second) | |
| 13 | + | ||
| 14 | + | if got.Before(before) || got.After(after) { | |
| 15 | + | t.Fatalf("Now() = %v, want between %v and %v", got, before, after) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestRealClockTimerFires(t *testing.T) { | |
| 20 | + | c := New() | |
| 21 | + | timer := c.NewTimer(10 * time.Millisecond) | |
| 22 | + | defer timer.Stop() | |
| 23 | + | ||
| 24 | + | select { | |
| 25 | + | case <-timer.C(): | |
| 26 | + | case <-time.After(5 * time.Second): | |
| 27 | + | t.Fatal("timer did not fire before deadline") | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestRealClockTimerStopKeepsChannelQuiet(t *testing.T) { | |
| 32 | + | c := New() | |
| 33 | + | timer := c.NewTimer(time.Hour) | |
| 34 | + | _ = timer.Stop() | |
| 35 | + | ||
| 36 | + | select { | |
| 37 | + | case <-timer.C(): | |
| 38 | + | t.Fatal("stopped timer fired") | |
| 39 | + | case <-time.After(25 * time.Millisecond): | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestRealClockTimerResetRearms(t *testing.T) { | |
| 44 | + | c := New() | |
| 45 | + | timer := c.NewTimer(time.Hour) | |
| 46 | + | defer timer.Stop() | |
| 47 | + | ||
| 48 | + | _ = timer.Stop() | |
| 49 | + | timer.Reset(10 * time.Millisecond) | |
| 50 | + | ||
| 51 | + | select { | |
| 52 | + | case <-timer.C(): | |
| 53 | + | case <-time.After(5 * time.Second): | |
| 54 | + | t.Fatal("reset timer did not fire before deadline") | |
| 55 | + | } | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package clock | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestRealClockNow(t *testing.T) { | |||
| 9 | + | c := New() | |||
| 10 | + | before := time.Now().Add(-time.Second) | |||
| 11 | + | got := c.Now() | |||
| 12 | + | after := time.Now().Add(time.Second) | |||
| 13 | + | ||||
| 14 | + | if got.Before(before) || got.After(after) { | |||
| 15 | + | t.Fatalf("Now() = %v, want between %v and %v", got, before, after) | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestRealClockTimerFires(t *testing.T) { | |||
| 20 | + | c := New() | |||
| 21 | + | timer := c.NewTimer(10 * time.Millisecond) | |||
| 22 | + | defer timer.Stop() | |||
| 23 | + | ||||
| 24 | + | select { | |||
| 25 | + | case <-timer.C(): | |||
| 26 | + | case <-time.After(5 * time.Second): | |||
| 27 | + | t.Fatal("timer did not fire before deadline") | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestRealClockTimerStopKeepsChannelQuiet(t *testing.T) { | |||
| 32 | + | c := New() | |||
| 33 | + | timer := c.NewTimer(time.Hour) | |||
| 34 | + | _ = timer.Stop() | |||
| 35 | + | ||||
| 36 | + | select { | |||
| 37 | + | case <-timer.C(): | |||
| 38 | + | t.Fatal("stopped timer fired") | |||
| 39 | + | case <-time.After(25 * time.Millisecond): | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestRealClockTimerResetRearms(t *testing.T) { | |||
| 44 | + | c := New() | |||
| 45 | + | timer := c.NewTimer(time.Hour) | |||
| 46 | + | defer timer.Stop() | |||
| 47 | + | ||||
| 48 | + | _ = timer.Stop() | |||
| 49 | + | timer.Reset(10 * time.Millisecond) | |||
| 50 | + | ||||
| 51 | + | select { | |||
| 52 | + | case <-timer.C(): | |||
| 53 | + | case <-time.After(5 * time.Second): | |||
| 54 | + | t.Fatal("reset timer did not fire before deadline") | |||
| 55 | + | } | |||
| 56 | + | } | |||
M internal/app/run.go +15 -0
| @@ -262,6 +262,18 @@ func snapshotDir() string { | |||
| 262 | 262 | return filepath.Join(platform.StateDir(), "snapshots") | |
| 263 | 263 | } | |
| 264 | 264 | ||
| 265 | + | func pprofAddrIsLoopback(addr string) bool { | |
| 266 | + | host, _, err := net.SplitHostPort(addr) | |
| 267 | + | if err != nil || host == "" { | |
| 268 | + | return false | |
| 269 | + | } | |
| 270 | + | if strings.EqualFold(host, "localhost") { | |
| 271 | + | return true | |
| 272 | + | } | |
| 273 | + | ip := net.ParseIP(host) | |
| 274 | + | return ip != nil && ip.IsLoopback() | |
| 275 | + | } | |
| 276 | + | ||
| 265 | 277 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | |
| 266 | 278 | // entered by an auto-spawned child): it sets up logging, binds the socket, | |
| 267 | 279 | // constructs the daemon, and serves until the last session exits or a | |
| @@ -284,6 +296,9 @@ func runDaemon() error { | |||
| 284 | 296 | defer stop() | |
| 285 | 297 | ||
| 286 | 298 | if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" { | |
| 299 | + | if !pprofAddrIsLoopback(addr) { | |
| 300 | + | log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr) | |
| 301 | + | } | |
| 287 | 302 | go func() { | |
| 288 | 303 | if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) { | |
| 289 | 304 | log.Error("pprof server exited", "err", err) | |
| @@ -262,6 +262,18 @@ func snapshotDir() string { | |||||
| 262 | return filepath.Join(platform.StateDir(), "snapshots") | 262 | return filepath.Join(platform.StateDir(), "snapshots") | ||
| 263 | } | 263 | } | ||
| 264 | 264 | ||||
| 265 | + | func pprofAddrIsLoopback(addr string) bool { | |||
| 266 | + | host, _, err := net.SplitHostPort(addr) | |||
| 267 | + | if err != nil || host == "" { | |||
| 268 | + | return false | |||
| 269 | + | } | |||
| 270 | + | if strings.EqualFold(host, "localhost") { | |||
| 271 | + | return true | |||
| 272 | + | } | |||
| 273 | + | ip := net.ParseIP(host) | |||
| 274 | + | return ip != nil && ip.IsLoopback() | |||
| 275 | + | } | |||
| 276 | + | ||||
| 265 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | 277 | // runDaemon runs the daemon in the foreground (the hidden --daemon path, | ||
| 266 | // entered by an auto-spawned child): it sets up logging, binds the socket, | 278 | // entered by an auto-spawned child): it sets up logging, binds the socket, | ||
| 267 | // constructs the daemon, and serves until the last session exits or a | 279 | // constructs the daemon, and serves until the last session exits or a | ||
| @@ -284,6 +296,9 @@ func runDaemon() error { | |||||
| 284 | defer stop() | 296 | defer stop() | ||
| 285 | 297 | ||||
| 286 | if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" { | 298 | if addr := os.Getenv("VEV_PPROF_ADDR"); addr != "" { | ||
| 299 | + | if !pprofAddrIsLoopback(addr) { | |||
| 300 | + | log.Warn("pprof bound to non-loopback address; /debug/pprof is unauthenticated", "addr", addr) | |||
| 301 | + | } | |||
| 287 | go func() { | 302 | go func() { | ||
| 288 | if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) { | 303 | if err := http.ListenAndServe(addr, nil); err != nil && !errors.Is(err, http.ErrServerClosed) { | ||
| 289 | log.Error("pprof server exited", "err", err) | 304 | log.Error("pprof server exited", "err", err) | ||
M internal/app/run_test.go +23 -0
| @@ -753,3 +753,26 @@ func TestListenUDPInRange(t *testing.T) { | |||
| 753 | 753 | } | |
| 754 | 754 | }) | |
| 755 | 755 | } | |
| 756 | + | ||
| 757 | + | func TestPprofAddrIsLoopback(t *testing.T) { | |
| 758 | + | tests := []struct { | |
| 759 | + | addr string | |
| 760 | + | want bool | |
| 761 | + | }{ | |
| 762 | + | {addr: "127.0.0.1:6060", want: true}, | |
| 763 | + | {addr: "localhost:6060", want: true}, | |
| 764 | + | {addr: "[::1]:6060", want: true}, | |
| 765 | + | {addr: ":6060", want: false}, | |
| 766 | + | {addr: "0.0.0.0:6060", want: false}, | |
| 767 | + | {addr: "192.168.1.5:6060", want: false}, | |
| 768 | + | {addr: "garbage", want: false}, | |
| 769 | + | } | |
| 770 | + | ||
| 771 | + | for _, tt := range tests { | |
| 772 | + | t.Run(tt.addr, func(t *testing.T) { | |
| 773 | + | if got := pprofAddrIsLoopback(tt.addr); got != tt.want { | |
| 774 | + | t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want) | |
| 775 | + | } | |
| 776 | + | }) | |
| 777 | + | } | |
| 778 | + | } | |
| @@ -753,3 +753,26 @@ func TestListenUDPInRange(t *testing.T) { | |||||
| 753 | } | 753 | } | ||
| 754 | }) | 754 | }) | ||
| 755 | } | 755 | } | ||
| 756 | + | ||||
| 757 | + | func TestPprofAddrIsLoopback(t *testing.T) { | |||
| 758 | + | tests := []struct { | |||
| 759 | + | addr string | |||
| 760 | + | want bool | |||
| 761 | + | }{ | |||
| 762 | + | {addr: "127.0.0.1:6060", want: true}, | |||
| 763 | + | {addr: "localhost:6060", want: true}, | |||
| 764 | + | {addr: "[::1]:6060", want: true}, | |||
| 765 | + | {addr: ":6060", want: false}, | |||
| 766 | + | {addr: "0.0.0.0:6060", want: false}, | |||
| 767 | + | {addr: "192.168.1.5:6060", want: false}, | |||
| 768 | + | {addr: "garbage", want: false}, | |||
| 769 | + | } | |||
| 770 | + | ||||
| 771 | + | for _, tt := range tests { | |||
| 772 | + | t.Run(tt.addr, func(t *testing.T) { | |||
| 773 | + | if got := pprofAddrIsLoopback(tt.addr); got != tt.want { | |||
| 774 | + | t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want) | |||
| 775 | + | } | |||
| 776 | + | }) | |||
| 777 | + | } | |||
| 778 | + | } | |||
M internal/platform/dir_test.go +102 -4
| @@ -1,24 +1,91 @@ | |||
| 1 | 1 | package platform | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 4 | 5 | "path/filepath" | |
| 6 | + | "strings" | |
| 5 | 7 | "testing" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 10 | + | func TestStateDir(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | xdgState string | |
| 14 | + | home string | |
| 15 | + | want func(*testing.T, string, string) string | |
| 16 | + | }{ | |
| 17 | + | { | |
| 18 | + | name: "xdg state home", | |
| 19 | + | xdgState: filepath.Join(t.TempDir(), "xdg-state"), | |
| 20 | + | home: filepath.Join(t.TempDir(), "home"), | |
| 21 | + | want: func(_ *testing.T, xdgState, _ string) string { | |
| 22 | + | return filepath.Join(xdgState, "vev") | |
| 23 | + | }, | |
| 24 | + | }, | |
| 25 | + | { | |
| 26 | + | name: "home fallback", | |
| 27 | + | home: filepath.Join(t.TempDir(), "home"), | |
| 28 | + | want: func(_ *testing.T, _, home string) string { | |
| 29 | + | return filepath.Join(home, ".local", "state", "vev") | |
| 30 | + | }, | |
| 31 | + | }, | |
| 32 | + | { | |
| 33 | + | name: "temp fallback without home", | |
| 34 | + | want: func(t *testing.T, _, _ string) string { | |
| 35 | + | t.Helper() | |
| 36 | + | return filepath.Join(os.TempDir(), "vev-state-") | |
| 37 | + | }, | |
| 38 | + | }, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | for _, tt := range tests { | |
| 42 | + | t.Run(tt.name, func(t *testing.T) { | |
| 43 | + | t.Setenv("XDG_STATE_HOME", tt.xdgState) | |
| 44 | + | t.Setenv("HOME", tt.home) | |
| 45 | + | ||
| 46 | + | want := tt.want(t, tt.xdgState, tt.home) | |
| 47 | + | got := StateDir() | |
| 48 | + | if strings.HasSuffix(want, "vev-state-") { | |
| 49 | + | if !strings.HasPrefix(got, want) { | |
| 50 | + | t.Fatalf("StateDir() = %q, want prefix %q", got, want) | |
| 51 | + | } | |
| 52 | + | return | |
| 53 | + | } | |
| 54 | + | if got != want { | |
| 55 | + | t.Fatalf("StateDir() = %q, want %q", got, want) | |
| 56 | + | } | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 8 | 61 | func TestConfigPath(t *testing.T) { | |
| 9 | 62 | tests := []struct { | |
| 10 | 63 | name string | |
| 11 | 64 | xdgConfig string | |
| 12 | 65 | home string | |
| 66 | + | want func(*testing.T, string, string) string | |
| 13 | 67 | }{ | |
| 14 | 68 | { | |
| 15 | 69 | name: "xdg config home", | |
| 16 | 70 | xdgConfig: filepath.Join(t.TempDir(), "xdg-config"), | |
| 17 | 71 | home: filepath.Join(t.TempDir(), "home"), | |
| 72 | + | want: func(_ *testing.T, xdgConfig, _ string) string { | |
| 73 | + | return filepath.Join(xdgConfig, "vev", "config") | |
| 74 | + | }, | |
| 18 | 75 | }, | |
| 19 | 76 | { | |
| 20 | 77 | name: "home fallback", | |
| 21 | 78 | home: filepath.Join(t.TempDir(), "home"), | |
| 79 | + | want: func(_ *testing.T, _, home string) string { | |
| 80 | + | return filepath.Join(home, ".config", "vev", "config") | |
| 81 | + | }, | |
| 82 | + | }, | |
| 83 | + | { | |
| 84 | + | name: "temp fallback without home", | |
| 85 | + | want: func(t *testing.T, _, _ string) string { | |
| 86 | + | t.Helper() | |
| 87 | + | return filepath.Join(os.TempDir(), "vev-config-") | |
| 88 | + | }, | |
| 22 | 89 | }, | |
| 23 | 90 | } | |
| 24 | 91 | ||
| @@ -27,13 +94,44 @@ func TestConfigPath(t *testing.T) { | |||
| 27 | 94 | t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig) | |
| 28 | 95 | t.Setenv("HOME", tt.home) | |
| 29 | 96 | ||
| 30 | - | want := filepath.Join(tt.xdgConfig, "vev", "config") | |
| 31 | - | if tt.xdgConfig == "" { | |
| 32 | - | want = filepath.Join(tt.home, ".config", "vev", "config") | |
| 97 | + | want := tt.want(t, tt.xdgConfig, tt.home) | |
| 98 | + | got := ConfigPath() | |
| 99 | + | if strings.HasSuffix(want, "vev-config-") { | |
| 100 | + | if !strings.HasPrefix(got, want) || filepath.Base(got) != "config" { | |
| 101 | + | t.Fatalf("ConfigPath() = %q, want prefix %q and base config", got, want) | |
| 102 | + | } | |
| 103 | + | return | |
| 33 | 104 | } | |
| 34 | - | if got := ConfigPath(); got != want { | |
| 105 | + | if got != want { | |
| 35 | 106 | t.Fatalf("ConfigPath() = %q, want %q", got, want) | |
| 36 | 107 | } | |
| 37 | 108 | }) | |
| 38 | 109 | } | |
| 39 | 110 | } | |
| 111 | + | ||
| 112 | + | func TestDirOrHome(t *testing.T) { | |
| 113 | + | existing := t.TempDir() | |
| 114 | + | home := filepath.Join(t.TempDir(), "home") | |
| 115 | + | nonexistent := filepath.Join(t.TempDir(), "missing") | |
| 116 | + | ||
| 117 | + | tests := []struct { | |
| 118 | + | name string | |
| 119 | + | dir string | |
| 120 | + | home string | |
| 121 | + | want string | |
| 122 | + | }{ | |
| 123 | + | {name: "existing dir", dir: existing, home: home, want: existing}, | |
| 124 | + | {name: "non-existent dir falls back to home", dir: nonexistent, home: home, want: home}, | |
| 125 | + | {name: "non-existent dir without home falls back to root", dir: nonexistent, want: "/"}, | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | for _, tt := range tests { | |
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |
| 130 | + | t.Setenv("HOME", tt.home) | |
| 131 | + | ||
| 132 | + | if got := DirOrHome(tt.dir); got != tt.want { | |
| 133 | + | t.Fatalf("DirOrHome(%q) = %q, want %q", tt.dir, got, tt.want) | |
| 134 | + | } | |
| 135 | + | }) | |
| 136 | + | } | |
| 137 | + | } | |
| @@ -1,24 +1,91 @@ | |||||
| 1 | package platform | 1 | package platform | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "os" | |||
| 4 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | + | "strings" | |||
| 5 | "testing" | 7 | "testing" | ||
| 6 | ) | 8 | ) | ||
| 7 | 9 | ||||
| 10 | + | func TestStateDir(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | xdgState string | |||
| 14 | + | home string | |||
| 15 | + | want func(*testing.T, string, string) string | |||
| 16 | + | }{ | |||
| 17 | + | { | |||
| 18 | + | name: "xdg state home", | |||
| 19 | + | xdgState: filepath.Join(t.TempDir(), "xdg-state"), | |||
| 20 | + | home: filepath.Join(t.TempDir(), "home"), | |||
| 21 | + | want: func(_ *testing.T, xdgState, _ string) string { | |||
| 22 | + | return filepath.Join(xdgState, "vev") | |||
| 23 | + | }, | |||
| 24 | + | }, | |||
| 25 | + | { | |||
| 26 | + | name: "home fallback", | |||
| 27 | + | home: filepath.Join(t.TempDir(), "home"), | |||
| 28 | + | want: func(_ *testing.T, _, home string) string { | |||
| 29 | + | return filepath.Join(home, ".local", "state", "vev") | |||
| 30 | + | }, | |||
| 31 | + | }, | |||
| 32 | + | { | |||
| 33 | + | name: "temp fallback without home", | |||
| 34 | + | want: func(t *testing.T, _, _ string) string { | |||
| 35 | + | t.Helper() | |||
| 36 | + | return filepath.Join(os.TempDir(), "vev-state-") | |||
| 37 | + | }, | |||
| 38 | + | }, | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | for _, tt := range tests { | |||
| 42 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 43 | + | t.Setenv("XDG_STATE_HOME", tt.xdgState) | |||
| 44 | + | t.Setenv("HOME", tt.home) | |||
| 45 | + | ||||
| 46 | + | want := tt.want(t, tt.xdgState, tt.home) | |||
| 47 | + | got := StateDir() | |||
| 48 | + | if strings.HasSuffix(want, "vev-state-") { | |||
| 49 | + | if !strings.HasPrefix(got, want) { | |||
| 50 | + | t.Fatalf("StateDir() = %q, want prefix %q", got, want) | |||
| 51 | + | } | |||
| 52 | + | return | |||
| 53 | + | } | |||
| 54 | + | if got != want { | |||
| 55 | + | t.Fatalf("StateDir() = %q, want %q", got, want) | |||
| 56 | + | } | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 8 | func TestConfigPath(t *testing.T) { | 61 | func TestConfigPath(t *testing.T) { | ||
| 9 | tests := []struct { | 62 | tests := []struct { | ||
| 10 | name string | 63 | name string | ||
| 11 | xdgConfig string | 64 | xdgConfig string | ||
| 12 | home string | 65 | home string | ||
| 66 | + | want func(*testing.T, string, string) string | |||
| 13 | }{ | 67 | }{ | ||
| 14 | { | 68 | { | ||
| 15 | name: "xdg config home", | 69 | name: "xdg config home", | ||
| 16 | xdgConfig: filepath.Join(t.TempDir(), "xdg-config"), | 70 | xdgConfig: filepath.Join(t.TempDir(), "xdg-config"), | ||
| 17 | home: filepath.Join(t.TempDir(), "home"), | 71 | home: filepath.Join(t.TempDir(), "home"), | ||
| 72 | + | want: func(_ *testing.T, xdgConfig, _ string) string { | |||
| 73 | + | return filepath.Join(xdgConfig, "vev", "config") | |||
| 74 | + | }, | |||
| 18 | }, | 75 | }, | ||
| 19 | { | 76 | { | ||
| 20 | name: "home fallback", | 77 | name: "home fallback", | ||
| 21 | home: filepath.Join(t.TempDir(), "home"), | 78 | home: filepath.Join(t.TempDir(), "home"), | ||
| 79 | + | want: func(_ *testing.T, _, home string) string { | |||
| 80 | + | return filepath.Join(home, ".config", "vev", "config") | |||
| 81 | + | }, | |||
| 82 | + | }, | |||
| 83 | + | { | |||
| 84 | + | name: "temp fallback without home", | |||
| 85 | + | want: func(t *testing.T, _, _ string) string { | |||
| 86 | + | t.Helper() | |||
| 87 | + | return filepath.Join(os.TempDir(), "vev-config-") | |||
| 88 | + | }, | |||
| 22 | }, | 89 | }, | ||
| 23 | } | 90 | } | ||
| 24 | 91 | ||||
| @@ -27,13 +94,44 @@ func TestConfigPath(t *testing.T) { | |||||
| 27 | t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig) | 94 | t.Setenv("XDG_CONFIG_HOME", tt.xdgConfig) | ||
| 28 | t.Setenv("HOME", tt.home) | 95 | t.Setenv("HOME", tt.home) | ||
| 29 | 96 | ||||
| 30 | - | want := filepath.Join(tt.xdgConfig, "vev", "config") | 97 | + | want := tt.want(t, tt.xdgConfig, tt.home) |
| 31 | - | if tt.xdgConfig == "" { | 98 | + | got := ConfigPath() |
| 32 | - | want = filepath.Join(tt.home, ".config", "vev", "config") | 99 | + | if strings.HasSuffix(want, "vev-config-") { |
| 100 | + | if !strings.HasPrefix(got, want) || filepath.Base(got) != "config" { | |||
| 101 | + | t.Fatalf("ConfigPath() = %q, want prefix %q and base config", got, want) | |||
| 102 | + | } | |||
| 103 | + | return | |||
| 33 | } | 104 | } | ||
| 34 | - | if got := ConfigPath(); got != want { | 105 | + | if got != want { |
| 35 | t.Fatalf("ConfigPath() = %q, want %q", got, want) | 106 | t.Fatalf("ConfigPath() = %q, want %q", got, want) | ||
| 36 | } | 107 | } | ||
| 37 | }) | 108 | }) | ||
| 38 | } | 109 | } | ||
| 39 | } | 110 | } | ||
| 111 | + | ||||
| 112 | + | func TestDirOrHome(t *testing.T) { | |||
| 113 | + | existing := t.TempDir() | |||
| 114 | + | home := filepath.Join(t.TempDir(), "home") | |||
| 115 | + | nonexistent := filepath.Join(t.TempDir(), "missing") | |||
| 116 | + | ||||
| 117 | + | tests := []struct { | |||
| 118 | + | name string | |||
| 119 | + | dir string | |||
| 120 | + | home string | |||
| 121 | + | want string | |||
| 122 | + | }{ | |||
| 123 | + | {name: "existing dir", dir: existing, home: home, want: existing}, | |||
| 124 | + | {name: "non-existent dir falls back to home", dir: nonexistent, home: home, want: home}, | |||
| 125 | + | {name: "non-existent dir without home falls back to root", dir: nonexistent, want: "/"}, | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | for _, tt := range tests { | |||
| 129 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 130 | + | t.Setenv("HOME", tt.home) | |||
| 131 | + | ||||
| 132 | + | if got := DirOrHome(tt.dir); got != tt.want { | |||
| 133 | + | t.Fatalf("DirOrHome(%q) = %q, want %q", tt.dir, got, tt.want) | |||
| 134 | + | } | |||
| 135 | + | }) | |||
| 136 | + | } | |||
| 137 | + | } | |||
M internal/usecase/client/client_test.go +5 -2
| @@ -617,6 +617,8 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 617 | 617 | defer in.unblock() | |
| 618 | 618 | ||
| 619 | 619 | detachAfterResize := make(chan struct{}) | |
| 620 | + | firstRecv := make(chan struct{}) | |
| 621 | + | var firstRecvOnce sync.Once | |
| 620 | 622 | ||
| 621 | 623 | tr := portsmocks.NewMockTransport(t) | |
| 622 | 624 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| @@ -635,6 +637,7 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 635 | 637 | recvCh <- recvItem{f: welcome} | |
| 636 | 638 | done := make(chan struct{}) | |
| 637 | 639 | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | |
| 640 | + | firstRecvOnce.Do(func() { close(firstRecv) }) | |
| 638 | 641 | select { | |
| 639 | 642 | case it := <-recvCh: | |
| 640 | 643 | return it.f, it.err | |
| @@ -653,9 +656,9 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 653 | 656 | }).Maybe() | |
| 654 | 657 | tr.EXPECT().Close().Return(nil).Once() | |
| 655 | 658 | ||
| 656 | - | // Push a resize event shortly after attach begins. | |
| 659 | + | // Push a resize event once attach begins receiving daemon frames. | |
| 657 | 660 | go func() { | |
| 658 | - | time.Sleep(20 * time.Millisecond) | |
| 661 | + | <-firstRecv | |
| 659 | 662 | resizeCh <- domain.Size{Cols: 120, Rows: 40} | |
| 660 | 663 | }() | |
| 661 | 664 | ||
| @@ -617,6 +617,8 @@ func TestAttachForwardsResize(t *testing.T) { | |||||
| 617 | defer in.unblock() | 617 | defer in.unblock() | ||
| 618 | 618 | ||||
| 619 | detachAfterResize := make(chan struct{}) | 619 | detachAfterResize := make(chan struct{}) | ||
| 620 | + | firstRecv := make(chan struct{}) | |||
| 621 | + | var firstRecvOnce sync.Once | |||
| 620 | 622 | ||||
| 621 | tr := portsmocks.NewMockTransport(t) | 623 | tr := portsmocks.NewMockTransport(t) | ||
| 622 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | 624 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | ||
| @@ -635,6 +637,7 @@ func TestAttachForwardsResize(t *testing.T) { | |||||
| 635 | recvCh <- recvItem{f: welcome} | 637 | recvCh <- recvItem{f: welcome} | ||
| 636 | done := make(chan struct{}) | 638 | done := make(chan struct{}) | ||
| 637 | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | 639 | tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) { | ||
| 640 | + | firstRecvOnce.Do(func() { close(firstRecv) }) | |||
| 638 | select { | 641 | select { | ||
| 639 | case it := <-recvCh: | 642 | case it := <-recvCh: | ||
| 640 | return it.f, it.err | 643 | return it.f, it.err | ||
| @@ -653,9 +656,9 @@ func TestAttachForwardsResize(t *testing.T) { | |||||
| 653 | }).Maybe() | 656 | }).Maybe() | ||
| 654 | tr.EXPECT().Close().Return(nil).Once() | 657 | tr.EXPECT().Close().Return(nil).Once() | ||
| 655 | 658 | ||||
| 656 | - | // Push a resize event shortly after attach begins. | 659 | + | // Push a resize event once attach begins receiving daemon frames. |
| 657 | go func() { | 660 | go func() { | ||
| 658 | - | time.Sleep(20 * time.Millisecond) | 661 | + | <-firstRecv |
| 659 | resizeCh <- domain.Size{Cols: 120, Rows: 40} | 662 | resizeCh <- domain.Size{Cols: 120, Rows: 40} | ||
| 660 | }() | 663 | }() | ||
| 661 | 664 | ||||
M internal/usecase/daemon/client.go +3 -2
| @@ -49,8 +49,9 @@ type attachedClient struct { | |||
| 49 | 49 | nextStateNum uint64 | |
| 50 | 50 | echoAck atomic.Uint64 | |
| 51 | 51 | outputAck atomic.Uint64 | |
| 52 | - | paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag | |
| 53 | - | bars barCache // only touched while sendMu is held | |
| 52 | + | paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag | |
| 53 | + | bars barCache // only touched while sendMu is held | |
| 54 | + | composed composedFrameCache // only touched while sendMu is held | |
| 54 | 55 | size domain.Size | |
| 55 | 56 | keys *keys.Router | |
| 56 | 57 | sess Guarded[*session] | |
| @@ -49,8 +49,9 @@ type attachedClient struct { | |||||
| 49 | nextStateNum uint64 | 49 | nextStateNum uint64 | ||
| 50 | echoAck atomic.Uint64 | 50 | echoAck atomic.Uint64 | ||
| 51 | outputAck atomic.Uint64 | 51 | outputAck atomic.Uint64 | ||
| 52 | - | paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag | 52 | + | paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag |
| 53 | - | bars barCache // only touched while sendMu is held | 53 | + | bars barCache // only touched while sendMu is held |
| 54 | + | composed composedFrameCache // only touched while sendMu is held | |||
| 54 | size domain.Size | 55 | size domain.Size | ||
| 55 | keys *keys.Router | 56 | keys *keys.Router | ||
| 56 | sess Guarded[*session] | 57 | sess Guarded[*session] | ||
M internal/usecase/daemon/render.go +117 -21
| @@ -332,14 +332,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 332 | 332 | return | |
| 333 | 333 | } | |
| 334 | 334 | reset = ac.shouldResetOutputState(reset) | |
| 335 | - | if reset || overlays.copyActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive { | |
| 335 | + | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | |
| 336 | + | if reset || overlayActive { | |
| 336 | 337 | ac.rend.Reset() | |
| 337 | 338 | ac.bars.Reset() | |
| 339 | + | ac.composed.invalidate() | |
| 338 | 340 | } | |
| 339 | 341 | if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive { | |
| 340 | 342 | ac.lastCursor.valid = false | |
| 341 | 343 | } | |
| 342 | - | frame, damage := composeClientFrameWithLayout(bars, tb, reset, layoutSnap, &ac.bars) | |
| 344 | + | var frame renderer.Frame | |
| 345 | + | var damage []renderer.Damage | |
| 346 | + | if overlayActive { | |
| 347 | + | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil) | |
| 348 | + | } else { | |
| 349 | + | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | |
| 350 | + | } | |
| 343 | 351 | if overlays.copyActive { | |
| 344 | 352 | frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars) | |
| 345 | 353 | } | |
| @@ -380,11 +388,6 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 380 | 388 | if err == nil { | |
| 381 | 389 | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | |
| 382 | 390 | } | |
| 383 | - | for _, pane := range tb.panesSnapshot() { | |
| 384 | - | pane.mu.Lock() | |
| 385 | - | pane.screen.ClearDamage() | |
| 386 | - | pane.mu.Unlock() | |
| 387 | - | } | |
| 388 | 391 | tb.mu.Unlock() | |
| 389 | 392 | ||
| 390 | 393 | var serr error | |
| @@ -500,6 +503,16 @@ func (c *barCache) Reset() { | |||
| 500 | 503 | c.bottom = nil | |
| 501 | 504 | } | |
| 502 | 505 | ||
| 506 | + | type composedFrameCache struct { | |
| 507 | + | frame renderer.Frame | |
| 508 | + | valid bool | |
| 509 | + | layoutSnap tabLayoutSnapshot | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | func (c *composedFrameCache) invalidate() { | |
| 513 | + | c.valid = false | |
| 514 | + | } | |
| 515 | + | ||
| 503 | 516 | func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) { | |
| 504 | 517 | return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...) | |
| 505 | 518 | } | |
| @@ -513,6 +526,18 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap | |||
| 513 | 526 | if len(caches) > 0 { | |
| 514 | 527 | cache = caches[0] | |
| 515 | 528 | } | |
| 529 | + | return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil) | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) { | |
| 533 | + | return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false) | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) { | |
| 537 | + | return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true) | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) { | |
| 516 | 541 | p := tb.focusedPane() | |
| 517 | 542 | styles := newThemeStyles(bars.theme) | |
| 518 | 543 | if p == nil { | |
| @@ -528,15 +553,34 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap | |||
| 528 | 553 | if tb.size.Valid() { | |
| 529 | 554 | width, screenRows = tb.size.Cols, tb.size.Rows | |
| 530 | 555 | } | |
| 531 | - | frame := renderer.NewFrame(width, screenRows+2) | |
| 556 | + | cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2 | |
| 557 | + | layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap)) | |
| 558 | + | var frame renderer.Frame | |
| 559 | + | if cacheValid { | |
| 560 | + | frame = composed.frame | |
| 561 | + | } else { | |
| 562 | + | frame = renderer.NewFrame(width, screenRows+2) | |
| 563 | + | if composed != nil { | |
| 564 | + | full = true | |
| 565 | + | } | |
| 566 | + | } | |
| 567 | + | if !layoutSame { | |
| 568 | + | full = true | |
| 569 | + | } | |
| 570 | + | contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows} | |
| 571 | + | if cacheValid && !layoutSame { | |
| 572 | + | clearFrameRect(frame, contentArea) | |
| 573 | + | } | |
| 532 | 574 | topBar := frame.Row(0) | |
| 533 | 575 | drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles) | |
| 534 | - | contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap) | |
| 535 | - | for y := range screenRows { | |
| 536 | - | copy(frame.Row(y+1), contentFrame.Row(y)) | |
| 537 | - | } | |
| 576 | + | contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage) | |
| 538 | 577 | bottomBar := frame.Row(screenRows + 1) | |
| 539 | 578 | drawStatusBarState(bottomBar, bars, styles) | |
| 579 | + | if composed != nil { | |
| 580 | + | composed.frame = frame | |
| 581 | + | composed.valid = true | |
| 582 | + | composed.layoutSnap = layoutSnap | |
| 583 | + | } | |
| 540 | 584 | if full { | |
| 541 | 585 | if cache != nil { | |
| 542 | 586 | cache.capture(topBar, bottomBar) | |
| @@ -562,20 +606,30 @@ func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.F | |||
| 562 | 606 | ||
| 563 | 607 | func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) { | |
| 564 | 608 | frame := renderer.NewFrame(area.Width, area.Height) | |
| 609 | + | damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false) | |
| 610 | + | return frame, damage | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage { | |
| 614 | + | return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false) | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool) []renderer.Damage { | |
| 618 | + | contentArea := domain.Rect{Width: area.Width, Height: area.Height} | |
| 565 | 619 | root := tb.tree.Root | |
| 566 | - | placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == area | |
| 620 | + | placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea | |
| 567 | 621 | if !ok { | |
| 568 | - | placements, ok = layout.Solve(root, area) | |
| 622 | + | placements, ok = layout.Solve(root, contentArea) | |
| 569 | 623 | } | |
| 570 | 624 | var fallback *pane | |
| 571 | 625 | if !ok { | |
| 572 | 626 | fallback = tb.focusedPane() | |
| 573 | 627 | if fallback == nil { | |
| 574 | - | return frame, nil | |
| 628 | + | return nil | |
| 575 | 629 | } | |
| 576 | - | placements = []layout.Placement{{ID: fallback.id, Content: area}} | |
| 630 | + | placements = []layout.Placement{{ID: fallback.id, Content: contentArea}} | |
| 577 | 631 | } | |
| 578 | - | if ok { | |
| 632 | + | if ok && !cacheValid { | |
| 579 | 633 | drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme)) | |
| 580 | 634 | } | |
| 581 | 635 | var damage []renderer.Damage | |
| @@ -588,20 +642,62 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l | |||
| 588 | 642 | continue | |
| 589 | 643 | } | |
| 590 | 644 | focused := tb.tree.Focus == pl.ID | |
| 645 | + | pl = offsetPlacement(pl, area.X, area.Y) | |
| 591 | 646 | if pl.TitleBar.Height > 0 { | |
| 592 | 647 | drawPaneTitleBar(frame, pl, p, focused, theme, "") | |
| 648 | + | if cacheValid { | |
| 649 | + | titleDamage := pl.TitleBar | |
| 650 | + | titleDamage.X -= area.X | |
| 651 | + | titleDamage.Y -= area.Y | |
| 652 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height}) | |
| 653 | + | } | |
| 593 | 654 | } | |
| 594 | 655 | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | |
| 656 | + | if consumeDamage { | |
| 657 | + | p.mu.Lock() | |
| 658 | + | p.screen.ClearDamage() | |
| 659 | + | p.mu.Unlock() | |
| 660 | + | } | |
| 595 | 661 | continue | |
| 596 | 662 | } | |
| 597 | 663 | p.mu.Lock() | |
| 598 | - | blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme) | |
| 599 | - | for _, d := range p.screen.Damage() { | |
| 600 | - | damage = append(damage, translatePaneDamage(d, pl.Content, area)...) | |
| 664 | + | paneDamage := p.screen.Damage() | |
| 665 | + | if !cacheValid || len(paneDamage) > 0 { | |
| 666 | + | blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme) | |
| 667 | + | } | |
| 668 | + | for _, d := range paneDamage { | |
| 669 | + | localContent := pl.Content | |
| 670 | + | localContent.X -= area.X | |
| 671 | + | localContent.Y -= area.Y | |
| 672 | + | damage = append(damage, translatePaneDamage(d, localContent, contentArea)...) | |
| 673 | + | } | |
| 674 | + | if consumeDamage { | |
| 675 | + | p.screen.ClearDamage() | |
| 601 | 676 | } | |
| 602 | 677 | p.mu.Unlock() | |
| 603 | 678 | } | |
| 604 | - | return frame, damage | |
| 679 | + | return damage | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool { | |
| 683 | + | return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | |
| 687 | + | pl.Content.X += dx | |
| 688 | + | pl.Content.Y += dy | |
| 689 | + | pl.TitleBar.X += dx | |
| 690 | + | pl.TitleBar.Y += dy | |
| 691 | + | return pl | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | func clearFrameRect(frame renderer.Frame, r domain.Rect) { | |
| 695 | + | blank := renderer.BlankCell() | |
| 696 | + | for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ { | |
| 697 | + | for x := r.X; x < r.X+r.Width && x < frame.Width; x++ { | |
| 698 | + | frame.Set(x, y, blank) | |
| 699 | + | } | |
| 700 | + | } | |
| 605 | 701 | } | |
| 606 | 702 | ||
| 607 | 703 | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) { | |
| @@ -332,14 +332,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 332 | return | 332 | return | ||
| 333 | } | 333 | } | ||
| 334 | reset = ac.shouldResetOutputState(reset) | 334 | reset = ac.shouldResetOutputState(reset) | ||
| 335 | - | if reset || overlays.copyActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive { | 335 | + | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive |
| 336 | + | if reset || overlayActive { | |||
| 336 | ac.rend.Reset() | 337 | ac.rend.Reset() | ||
| 337 | ac.bars.Reset() | 338 | ac.bars.Reset() | ||
| 339 | + | ac.composed.invalidate() | |||
| 338 | } | 340 | } | ||
| 339 | if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive { | 341 | if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive { | ||
| 340 | ac.lastCursor.valid = false | 342 | ac.lastCursor.valid = false | ||
| 341 | } | 343 | } | ||
| 342 | - | frame, damage := composeClientFrameWithLayout(bars, tb, reset, layoutSnap, &ac.bars) | 344 | + | var frame renderer.Frame |
| 345 | + | var damage []renderer.Damage | |||
| 346 | + | if overlayActive { | |||
| 347 | + | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil) | |||
| 348 | + | } else { | |||
| 349 | + | frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed) | |||
| 350 | + | } | |||
| 343 | if overlays.copyActive { | 351 | if overlays.copyActive { | ||
| 344 | frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars) | 352 | frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars) | ||
| 345 | } | 353 | } | ||
| @@ -380,11 +388,6 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 380 | if err == nil { | 388 | if err == nil { | ||
| 381 | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | 389 | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | ||
| 382 | } | 390 | } | ||
| 383 | - | for _, pane := range tb.panesSnapshot() { | |||
| 384 | - | pane.mu.Lock() | |||
| 385 | - | pane.screen.ClearDamage() | |||
| 386 | - | pane.mu.Unlock() | |||
| 387 | - | } | |||
| 388 | tb.mu.Unlock() | 391 | tb.mu.Unlock() | ||
| 389 | 392 | ||||
| 390 | var serr error | 393 | var serr error | ||
| @@ -500,6 +503,16 @@ func (c *barCache) Reset() { | |||||
| 500 | c.bottom = nil | 503 | c.bottom = nil | ||
| 501 | } | 504 | } | ||
| 502 | 505 | ||||
| 506 | + | type composedFrameCache struct { | |||
| 507 | + | frame renderer.Frame | |||
| 508 | + | valid bool | |||
| 509 | + | layoutSnap tabLayoutSnapshot | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | func (c *composedFrameCache) invalidate() { | |||
| 513 | + | c.valid = false | |||
| 514 | + | } | |||
| 515 | + | ||||
| 503 | func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) { | 516 | func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) { | ||
| 504 | return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...) | 517 | return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...) | ||
| 505 | } | 518 | } | ||
| @@ -513,6 +526,18 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap | |||||
| 513 | if len(caches) > 0 { | 526 | if len(caches) > 0 { | ||
| 514 | cache = caches[0] | 527 | cache = caches[0] | ||
| 515 | } | 528 | } | ||
| 529 | + | return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil) | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) { | |||
| 533 | + | return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false) | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) { | |||
| 537 | + | return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true) | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) { | |||
| 516 | p := tb.focusedPane() | 541 | p := tb.focusedPane() | ||
| 517 | styles := newThemeStyles(bars.theme) | 542 | styles := newThemeStyles(bars.theme) | ||
| 518 | if p == nil { | 543 | if p == nil { | ||
| @@ -528,15 +553,34 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap | |||||
| 528 | if tb.size.Valid() { | 553 | if tb.size.Valid() { | ||
| 529 | width, screenRows = tb.size.Cols, tb.size.Rows | 554 | width, screenRows = tb.size.Cols, tb.size.Rows | ||
| 530 | } | 555 | } | ||
| 531 | - | frame := renderer.NewFrame(width, screenRows+2) | 556 | + | cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2 |
| 557 | + | layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap)) | |||
| 558 | + | var frame renderer.Frame | |||
| 559 | + | if cacheValid { | |||
| 560 | + | frame = composed.frame | |||
| 561 | + | } else { | |||
| 562 | + | frame = renderer.NewFrame(width, screenRows+2) | |||
| 563 | + | if composed != nil { | |||
| 564 | + | full = true | |||
| 565 | + | } | |||
| 566 | + | } | |||
| 567 | + | if !layoutSame { | |||
| 568 | + | full = true | |||
| 569 | + | } | |||
| 570 | + | contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows} | |||
| 571 | + | if cacheValid && !layoutSame { | |||
| 572 | + | clearFrameRect(frame, contentArea) | |||
| 573 | + | } | |||
| 532 | topBar := frame.Row(0) | 574 | topBar := frame.Row(0) | ||
| 533 | drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles) | 575 | drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles) | ||
| 534 | - | contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap) | 576 | + | contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage) |
| 535 | - | for y := range screenRows { | |||
| 536 | - | copy(frame.Row(y+1), contentFrame.Row(y)) | |||
| 537 | - | } | |||
| 538 | bottomBar := frame.Row(screenRows + 1) | 577 | bottomBar := frame.Row(screenRows + 1) | ||
| 539 | drawStatusBarState(bottomBar, bars, styles) | 578 | drawStatusBarState(bottomBar, bars, styles) | ||
| 579 | + | if composed != nil { | |||
| 580 | + | composed.frame = frame | |||
| 581 | + | composed.valid = true | |||
| 582 | + | composed.layoutSnap = layoutSnap | |||
| 583 | + | } | |||
| 540 | if full { | 584 | if full { | ||
| 541 | if cache != nil { | 585 | if cache != nil { | ||
| 542 | cache.capture(topBar, bottomBar) | 586 | cache.capture(topBar, bottomBar) | ||
| @@ -562,20 +606,30 @@ func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.F | |||||
| 562 | 606 | ||||
| 563 | func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) { | 607 | func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) { | ||
| 564 | frame := renderer.NewFrame(area.Width, area.Height) | 608 | frame := renderer.NewFrame(area.Width, area.Height) | ||
| 609 | + | damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false) | |||
| 610 | + | return frame, damage | |||
| 611 | + | } | |||
| 612 | + | ||||
| 613 | + | func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage { | |||
| 614 | + | return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false) | |||
| 615 | + | } | |||
| 616 | + | ||||
| 617 | + | func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool) []renderer.Damage { | |||
| 618 | + | contentArea := domain.Rect{Width: area.Width, Height: area.Height} | |||
| 565 | root := tb.tree.Root | 619 | root := tb.tree.Root | ||
| 566 | - | placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == area | 620 | + | placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea |
| 567 | if !ok { | 621 | if !ok { | ||
| 568 | - | placements, ok = layout.Solve(root, area) | 622 | + | placements, ok = layout.Solve(root, contentArea) |
| 569 | } | 623 | } | ||
| 570 | var fallback *pane | 624 | var fallback *pane | ||
| 571 | if !ok { | 625 | if !ok { | ||
| 572 | fallback = tb.focusedPane() | 626 | fallback = tb.focusedPane() | ||
| 573 | if fallback == nil { | 627 | if fallback == nil { | ||
| 574 | - | return frame, nil | 628 | + | return nil |
| 575 | } | 629 | } | ||
| 576 | - | placements = []layout.Placement{{ID: fallback.id, Content: area}} | 630 | + | placements = []layout.Placement{{ID: fallback.id, Content: contentArea}} |
| 577 | } | 631 | } | ||
| 578 | - | if ok { | 632 | + | if ok && !cacheValid { |
| 579 | drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme)) | 633 | drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme)) | ||
| 580 | } | 634 | } | ||
| 581 | var damage []renderer.Damage | 635 | var damage []renderer.Damage | ||
| @@ -588,20 +642,62 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l | |||||
| 588 | continue | 642 | continue | ||
| 589 | } | 643 | } | ||
| 590 | focused := tb.tree.Focus == pl.ID | 644 | focused := tb.tree.Focus == pl.ID | ||
| 645 | + | pl = offsetPlacement(pl, area.X, area.Y) | |||
| 591 | if pl.TitleBar.Height > 0 { | 646 | if pl.TitleBar.Height > 0 { | ||
| 592 | drawPaneTitleBar(frame, pl, p, focused, theme, "") | 647 | drawPaneTitleBar(frame, pl, p, focused, theme, "") | ||
| 648 | + | if cacheValid { | |||
| 649 | + | titleDamage := pl.TitleBar | |||
| 650 | + | titleDamage.X -= area.X | |||
| 651 | + | titleDamage.Y -= area.Y | |||
| 652 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height}) | |||
| 653 | + | } | |||
| 593 | } | 654 | } | ||
| 594 | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | 655 | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | ||
| 656 | + | if consumeDamage { | |||
| 657 | + | p.mu.Lock() | |||
| 658 | + | p.screen.ClearDamage() | |||
| 659 | + | p.mu.Unlock() | |||
| 660 | + | } | |||
| 595 | continue | 661 | continue | ||
| 596 | } | 662 | } | ||
| 597 | p.mu.Lock() | 663 | p.mu.Lock() | ||
| 598 | - | blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme) | 664 | + | paneDamage := p.screen.Damage() |
| 599 | - | for _, d := range p.screen.Damage() { | 665 | + | if !cacheValid || len(paneDamage) > 0 { |
| 600 | - | damage = append(damage, translatePaneDamage(d, pl.Content, area)...) | 666 | + | blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme) |
| 667 | + | } | |||
| 668 | + | for _, d := range paneDamage { | |||
| 669 | + | localContent := pl.Content | |||
| 670 | + | localContent.X -= area.X | |||
| 671 | + | localContent.Y -= area.Y | |||
| 672 | + | damage = append(damage, translatePaneDamage(d, localContent, contentArea)...) | |||
| 673 | + | } | |||
| 674 | + | if consumeDamage { | |||
| 675 | + | p.screen.ClearDamage() | |||
| 601 | } | 676 | } | ||
| 602 | p.mu.Unlock() | 677 | p.mu.Unlock() | ||
| 603 | } | 678 | } | ||
| 604 | - | return frame, damage | 679 | + | return damage |
| 680 | + | } | |||
| 681 | + | ||||
| 682 | + | func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool { | |||
| 683 | + | return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus | |||
| 684 | + | } | |||
| 685 | + | ||||
| 686 | + | func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | |||
| 687 | + | pl.Content.X += dx | |||
| 688 | + | pl.Content.Y += dy | |||
| 689 | + | pl.TitleBar.X += dx | |||
| 690 | + | pl.TitleBar.Y += dy | |||
| 691 | + | return pl | |||
| 692 | + | } | |||
| 693 | + | ||||
| 694 | + | func clearFrameRect(frame renderer.Frame, r domain.Rect) { | |||
| 695 | + | blank := renderer.BlankCell() | |||
| 696 | + | for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ { | |||
| 697 | + | for x := r.X; x < r.X+r.Width && x < frame.Width; x++ { | |||
| 698 | + | frame.Set(x, y, blank) | |||
| 699 | + | } | |||
| 700 | + | } | |||
| 605 | } | 701 | } | ||
| 606 | 702 | ||||
| 607 | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) { | 703 | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) { | ||
M internal/usecase/daemon/render_test.go +209 -0
| @@ -26,6 +26,12 @@ import ( | |||
| 26 | 26 | ||
| 27 | 27 | // --- test doubles ----------------------------------------------------------- | |
| 28 | 28 | ||
| 29 | + | type discardTransport struct{} | |
| 30 | + | ||
| 31 | + | func (discardTransport) Send(ports.Frame) error { return nil } | |
| 32 | + | func (discardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 33 | + | func (discardTransport) Close() error { return nil } | |
| 34 | + | ||
| 29 | 35 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 30 | 36 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| 31 | 37 | ||
| @@ -430,6 +436,147 @@ func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *test | |||
| 430 | 436 | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1}) | |
| 431 | 437 | } | |
| 432 | 438 | ||
| 439 | + | func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) { | |
| 440 | + | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | |
| 441 | + | left := win.focusedPane() | |
| 442 | + | right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4}) | |
| 443 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |
| 444 | + | win.tree.Focus = right.id | |
| 445 | + | win.panes[right.id] = right | |
| 446 | + | left.screen.Write([]byte("L")) | |
| 447 | + | right.screen.Write([]byte("R")) | |
| 448 | + | var bars barCache | |
| 449 | + | var composed composedFrameCache | |
| 450 | + | sess := &session{id: "s", name: "work", tabs: []*tab{win}} | |
| 451 | + | composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, true, solveTabLayoutLocked(win), &bars, &composed) | |
| 452 | + | left.screen.ClearDamage() | |
| 453 | + | right.screen.ClearDamage() | |
| 454 | + | left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'}) | |
| 455 | + | right.screen.Write([]byte("x")) | |
| 456 | + | ||
| 457 | + | frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 458 | + | ||
| 459 | + | require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted") | |
| 460 | + | require.Equal(t, 'x', frame.At(22, 1).Rune) | |
| 461 | + | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 1, Width: 1, Height: 1, Count: 1}) | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) { | |
| 465 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 4}) | |
| 466 | + | p := win.focusedPane() | |
| 467 | + | p.screen.Write([]byte("old")) | |
| 468 | + | var bars barCache | |
| 469 | + | var composed composedFrameCache | |
| 470 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |
| 471 | + | ||
| 472 | + | _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 473 | + | require.NotEmpty(t, damage) | |
| 474 | + | require.Empty(t, p.screen.Damage(), "paint compose should consume damage it included in the frame") | |
| 475 | + | ||
| 476 | + | p.screen.Write([]byte("new")) | |
| 477 | + | require.NotEmpty(t, p.screen.Damage(), "damage produced after paint compose must remain for a later render") | |
| 478 | + | ||
| 479 | + | _, _ = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 480 | + | require.NotEmpty(t, p.screen.Damage(), "standalone compose helpers should remain non-consuming") | |
| 481 | + | } | |
| 482 | + | ||
| 483 | + | func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) { | |
| 484 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |
| 485 | + | p1 := win.focusedPane() | |
| 486 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3}) | |
| 487 | + | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id} | |
| 488 | + | win.tree.Focus = p2.id | |
| 489 | + | win.panes[p2.id] = p2 | |
| 490 | + | p1.screen.Write([]byte("hidden")) | |
| 491 | + | p2.screen.Write([]byte("shown")) | |
| 492 | + | var bars barCache | |
| 493 | + | var composed composedFrameCache | |
| 494 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |
| 495 | + | ||
| 496 | + | _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 497 | + | ||
| 498 | + | require.Empty(t, p1.screen.Damage(), "collapsed pane damage should not accumulate while paint renders stack title bars") | |
| 499 | + | require.Empty(t, p2.screen.Damage()) | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) { | |
| 503 | + | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | |
| 504 | + | left := win.focusedPane() | |
| 505 | + | right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4}) | |
| 506 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |
| 507 | + | win.panes[right.id] = right | |
| 508 | + | left.screen.Write([]byte("L")) | |
| 509 | + | right.screen.Write([]byte("R")) | |
| 510 | + | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |
| 511 | + | var bars barCache | |
| 512 | + | var composed composedFrameCache | |
| 513 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(), theme: theme} | |
| 514 | + | win.tree.Focus = left.id | |
| 515 | + | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | |
| 516 | + | left.screen.ClearDamage() | |
| 517 | + | right.screen.ClearDamage() | |
| 518 | + | leftWasFocused := composed.frame.At(0, 1).Style | |
| 519 | + | rightWasDimmed := composed.frame.At(21, 1).Style | |
| 520 | + | ||
| 521 | + | win.tree.Focus = right.id | |
| 522 | + | frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 523 | + | ||
| 524 | + | require.Equal(t, rightWasDimmed, frame.At(0, 1).Style, "previously focused pane should be re-blitted dimmed") | |
| 525 | + | require.Equal(t, leftWasFocused, frame.At(21, 1).Style, "newly focused pane should be re-blitted undimmed") | |
| 526 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) { | |
| 530 | + | win := newTab(nil, domain.Size{Cols: 41, Rows: 5}) | |
| 531 | + | left := win.focusedPane() | |
| 532 | + | right := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 5}) | |
| 533 | + | win.panes[right.id] = right | |
| 534 | + | left.screen.Write([]byte("L")) | |
| 535 | + | right.screen.Write([]byte("R")) | |
| 536 | + | var bars barCache | |
| 537 | + | var composed composedFrameCache | |
| 538 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |
| 539 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |
| 540 | + | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | |
| 541 | + | require.Equal(t, '│', composed.frame.At(20, 1).Rune) | |
| 542 | + | left.screen.ClearDamage() | |
| 543 | + | right.screen.ClearDamage() | |
| 544 | + | ||
| 545 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |
| 546 | + | frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 547 | + | ||
| 548 | + | require.NotEqual(t, '│', frame.At(20, 1).Rune, "old vertical divider must be cleared when layout changes") | |
| 549 | + | require.Equal(t, '─', frame.At(20, 3).Rune) | |
| 550 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 551 | + | } | |
| 552 | + | ||
| 553 | + | func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | |
| 554 | + | win := newTab(nil, domain.Size{Cols: 81, Rows: 24}) | |
| 555 | + | left := win.focusedPane() | |
| 556 | + | right := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 24}) | |
| 557 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |
| 558 | + | win.tree.Focus = right.id | |
| 559 | + | win.panes[right.id] = right | |
| 560 | + | left.screen.Write([]byte("left")) | |
| 561 | + | right.screen.Write([]byte("right")) | |
| 562 | + | sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0} | |
| 563 | + | ac := &attachedClient{tr: discardTransport{}, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 81, Rows: 26}} | |
| 564 | + | ac.initOverlays() | |
| 565 | + | sess.client = ac | |
| 566 | + | ac.setSession(sess) | |
| 567 | + | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 568 | + | d.paint(sess, ac, true) | |
| 569 | + | left.screen.ClearDamage() | |
| 570 | + | right.screen.ClearDamage() | |
| 571 | + | ||
| 572 | + | b.ReportAllocs() | |
| 573 | + | b.ResetTimer() | |
| 574 | + | for i := 0; i < b.N; i++ { | |
| 575 | + | right.screen.Write([]byte("x\b")) | |
| 576 | + | d.paint(sess, ac, false) | |
| 577 | + | } | |
| 578 | + | } | |
| 579 | + | ||
| 433 | 580 | func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |
| 434 | 581 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |
| 435 | 582 | p1 := win.focusedPane() | |
| @@ -454,6 +601,31 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |||
| 454 | 601 | require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome") | |
| 455 | 602 | } | |
| 456 | 603 | ||
| 604 | + | func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) { | |
| 605 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |
| 606 | + | p1 := win.focusedPane() | |
| 607 | + | p1.title = "one" | |
| 608 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3}) | |
| 609 | + | p2.title = "two" | |
| 610 | + | p2.screen.Write([]byte("T")) | |
| 611 | + | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id} | |
| 612 | + | win.tree.Focus = p2.id | |
| 613 | + | win.panes[p2.id] = p2 | |
| 614 | + | var bars barCache | |
| 615 | + | var composed composedFrameCache | |
| 616 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |
| 617 | + | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | |
| 618 | + | p1.screen.ClearDamage() | |
| 619 | + | p2.screen.ClearDamage() | |
| 620 | + | ||
| 621 | + | p1.title = "renamed" | |
| 622 | + | frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 623 | + | ||
| 624 | + | require.Equal(t, "renamed", rowText(frame.Row(1))[:7]) | |
| 625 | + | require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset") | |
| 626 | + | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}) | |
| 627 | + | } | |
| 628 | + | ||
| 457 | 629 | func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |
| 458 | 630 | tests := []struct { | |
| 459 | 631 | name string | |
| @@ -524,6 +696,43 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |||
| 524 | 696 | } | |
| 525 | 697 | } | |
| 526 | 698 | ||
| 699 | + | func TestOverlayPaintBypassesComposedCache(t *testing.T) { | |
| 700 | + | p, release := newBlockingPTY(t) | |
| 701 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 702 | + | defer release() | |
| 703 | + | pane := sess.tabs[0].focusedPane() | |
| 704 | + | pane.screen.Write([]byte("live")) | |
| 705 | + | ||
| 706 | + | d.paint(sess, ac, true) | |
| 707 | + | _ = mustOutputData(t, sends) | |
| 708 | + | require.True(t, ac.composed.valid) | |
| 709 | + | require.NotContains(t, frameText(ac.composed.frame), "Rename session") | |
| 710 | + | ||
| 711 | + | d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil }) | |
| 712 | + | shown := string(mustOutputData(t, sends)) | |
| 713 | + | require.Contains(t, shown, "Rename session") | |
| 714 | + | require.False(t, ac.composed.valid, "overlay paint must not store the modal-mutated frame in the composed cache") | |
| 715 | + | require.NotContains(t, frameText(ac.composed.frame), "Rename session") | |
| 716 | + | ||
| 717 | + | d.closePrompt(ac) | |
| 718 | + | d.paint(sess, ac, false) | |
| 719 | + | restored := string(mustOutputData(t, sends)) | |
| 720 | + | require.NotContains(t, restored, "Rename session") | |
| 721 | + | require.True(t, ac.composed.valid) | |
| 722 | + | cached := frameText(ac.composed.frame) | |
| 723 | + | require.Contains(t, cached, "live") | |
| 724 | + | require.NotContains(t, cached, "Rename session") | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | func frameText(frame renderer.Frame) string { | |
| 728 | + | var b strings.Builder | |
| 729 | + | for y := 0; y < frame.Height; y++ { | |
| 730 | + | b.WriteString(rowText(frame.Row(y))) | |
| 731 | + | b.WriteByte('\n') | |
| 732 | + | } | |
| 733 | + | return b.String() | |
| 734 | + | } | |
| 735 | + | ||
| 527 | 736 | func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) { | |
| 528 | 737 | tests := []struct { | |
| 529 | 738 | name string | |
| @@ -26,6 +26,12 @@ import ( | |||||
| 26 | 26 | ||||
| 27 | // --- test doubles ----------------------------------------------------------- | 27 | // --- test doubles ----------------------------------------------------------- | ||
| 28 | 28 | ||||
| 29 | + | type discardTransport struct{} | |||
| 30 | + | ||||
| 31 | + | func (discardTransport) Send(ports.Frame) error { return nil } | |||
| 32 | + | func (discardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |||
| 33 | + | func (discardTransport) Close() error { return nil } | |||
| 34 | + | ||||
| 29 | // stubClock returns timers whose channel never fires, so a scheduler under it | 35 | // stubClock returns timers whose channel never fires, so a scheduler under it | ||
| 30 | // blocks in its debounce loop until the session context is cancelled. Used by | 36 | // blocks in its debounce loop until the session context is cancelled. Used by | ||
| 31 | 37 | ||||
| @@ -430,6 +436,147 @@ func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *test | |||||
| 430 | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1}) | 436 | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1}) | ||
| 431 | } | 437 | } | ||
| 432 | 438 | ||||
| 439 | + | func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) { | |||
| 440 | + | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | |||
| 441 | + | left := win.focusedPane() | |||
| 442 | + | right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4}) | |||
| 443 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |||
| 444 | + | win.tree.Focus = right.id | |||
| 445 | + | win.panes[right.id] = right | |||
| 446 | + | left.screen.Write([]byte("L")) | |||
| 447 | + | right.screen.Write([]byte("R")) | |||
| 448 | + | var bars barCache | |||
| 449 | + | var composed composedFrameCache | |||
| 450 | + | sess := &session{id: "s", name: "work", tabs: []*tab{win}} | |||
| 451 | + | composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, true, solveTabLayoutLocked(win), &bars, &composed) | |||
| 452 | + | left.screen.ClearDamage() | |||
| 453 | + | right.screen.ClearDamage() | |||
| 454 | + | left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'}) | |||
| 455 | + | right.screen.Write([]byte("x")) | |||
| 456 | + | ||||
| 457 | + | frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, false, solveTabLayoutLocked(win), &bars, &composed) | |||
| 458 | + | ||||
| 459 | + | require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted") | |||
| 460 | + | require.Equal(t, 'x', frame.At(22, 1).Rune) | |||
| 461 | + | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 1, Width: 1, Height: 1, Count: 1}) | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) { | |||
| 465 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 4}) | |||
| 466 | + | p := win.focusedPane() | |||
| 467 | + | p.screen.Write([]byte("old")) | |||
| 468 | + | var bars barCache | |||
| 469 | + | var composed composedFrameCache | |||
| 470 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |||
| 471 | + | ||||
| 472 | + | _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |||
| 473 | + | require.NotEmpty(t, damage) | |||
| 474 | + | require.Empty(t, p.screen.Damage(), "paint compose should consume damage it included in the frame") | |||
| 475 | + | ||||
| 476 | + | p.screen.Write([]byte("new")) | |||
| 477 | + | require.NotEmpty(t, p.screen.Damage(), "damage produced after paint compose must remain for a later render") | |||
| 478 | + | ||||
| 479 | + | _, _ = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |||
| 480 | + | require.NotEmpty(t, p.screen.Damage(), "standalone compose helpers should remain non-consuming") | |||
| 481 | + | } | |||
| 482 | + | ||||
| 483 | + | func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) { | |||
| 484 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 485 | + | p1 := win.focusedPane() | |||
| 486 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3}) | |||
| 487 | + | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id} | |||
| 488 | + | win.tree.Focus = p2.id | |||
| 489 | + | win.panes[p2.id] = p2 | |||
| 490 | + | p1.screen.Write([]byte("hidden")) | |||
| 491 | + | p2.screen.Write([]byte("shown")) | |||
| 492 | + | var bars barCache | |||
| 493 | + | var composed composedFrameCache | |||
| 494 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |||
| 495 | + | ||||
| 496 | + | _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |||
| 497 | + | ||||
| 498 | + | require.Empty(t, p1.screen.Damage(), "collapsed pane damage should not accumulate while paint renders stack title bars") | |||
| 499 | + | require.Empty(t, p2.screen.Damage()) | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) { | |||
| 503 | + | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | |||
| 504 | + | left := win.focusedPane() | |||
| 505 | + | right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4}) | |||
| 506 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |||
| 507 | + | win.panes[right.id] = right | |||
| 508 | + | left.screen.Write([]byte("L")) | |||
| 509 | + | right.screen.Write([]byte("R")) | |||
| 510 | + | theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |||
| 511 | + | var bars barCache | |||
| 512 | + | var composed composedFrameCache | |||
| 513 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(), theme: theme} | |||
| 514 | + | win.tree.Focus = left.id | |||
| 515 | + | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | |||
| 516 | + | left.screen.ClearDamage() | |||
| 517 | + | right.screen.ClearDamage() | |||
| 518 | + | leftWasFocused := composed.frame.At(0, 1).Style | |||
| 519 | + | rightWasDimmed := composed.frame.At(21, 1).Style | |||
| 520 | + | ||||
| 521 | + | win.tree.Focus = right.id | |||
| 522 | + | frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |||
| 523 | + | ||||
| 524 | + | require.Equal(t, rightWasDimmed, frame.At(0, 1).Style, "previously focused pane should be re-blitted dimmed") | |||
| 525 | + | require.Equal(t, leftWasFocused, frame.At(21, 1).Style, "newly focused pane should be re-blitted undimmed") | |||
| 526 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) { | |||
| 530 | + | win := newTab(nil, domain.Size{Cols: 41, Rows: 5}) | |||
| 531 | + | left := win.focusedPane() | |||
| 532 | + | right := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 5}) | |||
| 533 | + | win.panes[right.id] = right | |||
| 534 | + | left.screen.Write([]byte("L")) | |||
| 535 | + | right.screen.Write([]byte("R")) | |||
| 536 | + | var bars barCache | |||
| 537 | + | var composed composedFrameCache | |||
| 538 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |||
| 539 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |||
| 540 | + | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | |||
| 541 | + | require.Equal(t, '│', composed.frame.At(20, 1).Rune) | |||
| 542 | + | left.screen.ClearDamage() | |||
| 543 | + | right.screen.ClearDamage() | |||
| 544 | + | ||||
| 545 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |||
| 546 | + | frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |||
| 547 | + | ||||
| 548 | + | require.NotEqual(t, '│', frame.At(20, 1).Rune, "old vertical divider must be cleared when layout changes") | |||
| 549 | + | require.Equal(t, '─', frame.At(20, 3).Rune) | |||
| 550 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | |||
| 554 | + | win := newTab(nil, domain.Size{Cols: 81, Rows: 24}) | |||
| 555 | + | left := win.focusedPane() | |||
| 556 | + | right := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 24}) | |||
| 557 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |||
| 558 | + | win.tree.Focus = right.id | |||
| 559 | + | win.panes[right.id] = right | |||
| 560 | + | left.screen.Write([]byte("left")) | |||
| 561 | + | right.screen.Write([]byte("right")) | |||
| 562 | + | sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0} | |||
| 563 | + | ac := &attachedClient{tr: discardTransport{}, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 81, Rows: 26}} | |||
| 564 | + | ac.initOverlays() | |||
| 565 | + | sess.client = ac | |||
| 566 | + | ac.setSession(sess) | |||
| 567 | + | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |||
| 568 | + | d.paint(sess, ac, true) | |||
| 569 | + | left.screen.ClearDamage() | |||
| 570 | + | right.screen.ClearDamage() | |||
| 571 | + | ||||
| 572 | + | b.ReportAllocs() | |||
| 573 | + | b.ResetTimer() | |||
| 574 | + | for i := 0; i < b.N; i++ { | |||
| 575 | + | right.screen.Write([]byte("x\b")) | |||
| 576 | + | d.paint(sess, ac, false) | |||
| 577 | + | } | |||
| 578 | + | } | |||
| 579 | + | ||||
| 433 | func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | 580 | func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | ||
| 434 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | 581 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | ||
| 435 | p1 := win.focusedPane() | 582 | p1 := win.focusedPane() | ||
| @@ -454,6 +601,31 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |||||
| 454 | require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome") | 601 | require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome") | ||
| 455 | } | 602 | } | ||
| 456 | 603 | ||||
| 604 | + | func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) { | |||
| 605 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 606 | + | p1 := win.focusedPane() | |||
| 607 | + | p1.title = "one" | |||
| 608 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3}) | |||
| 609 | + | p2.title = "two" | |||
| 610 | + | p2.screen.Write([]byte("T")) | |||
| 611 | + | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id} | |||
| 612 | + | win.tree.Focus = p2.id | |||
| 613 | + | win.panes[p2.id] = p2 | |||
| 614 | + | var bars barCache | |||
| 615 | + | var composed composedFrameCache | |||
| 616 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |||
| 617 | + | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | |||
| 618 | + | p1.screen.ClearDamage() | |||
| 619 | + | p2.screen.ClearDamage() | |||
| 620 | + | ||||
| 621 | + | p1.title = "renamed" | |||
| 622 | + | frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |||
| 623 | + | ||||
| 624 | + | require.Equal(t, "renamed", rowText(frame.Row(1))[:7]) | |||
| 625 | + | require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset") | |||
| 626 | + | require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}) | |||
| 627 | + | } | |||
| 628 | + | ||||
| 457 | func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | 629 | func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | ||
| 458 | tests := []struct { | 630 | tests := []struct { | ||
| 459 | name string | 631 | name string | ||
| @@ -524,6 +696,43 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |||||
| 524 | } | 696 | } | ||
| 525 | } | 697 | } | ||
| 526 | 698 | ||||
| 699 | + | func TestOverlayPaintBypassesComposedCache(t *testing.T) { | |||
| 700 | + | p, release := newBlockingPTY(t) | |||
| 701 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 702 | + | defer release() | |||
| 703 | + | pane := sess.tabs[0].focusedPane() | |||
| 704 | + | pane.screen.Write([]byte("live")) | |||
| 705 | + | ||||
| 706 | + | d.paint(sess, ac, true) | |||
| 707 | + | _ = mustOutputData(t, sends) | |||
| 708 | + | require.True(t, ac.composed.valid) | |||
| 709 | + | require.NotContains(t, frameText(ac.composed.frame), "Rename session") | |||
| 710 | + | ||||
| 711 | + | d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil }) | |||
| 712 | + | shown := string(mustOutputData(t, sends)) | |||
| 713 | + | require.Contains(t, shown, "Rename session") | |||
| 714 | + | require.False(t, ac.composed.valid, "overlay paint must not store the modal-mutated frame in the composed cache") | |||
| 715 | + | require.NotContains(t, frameText(ac.composed.frame), "Rename session") | |||
| 716 | + | ||||
| 717 | + | d.closePrompt(ac) | |||
| 718 | + | d.paint(sess, ac, false) | |||
| 719 | + | restored := string(mustOutputData(t, sends)) | |||
| 720 | + | require.NotContains(t, restored, "Rename session") | |||
| 721 | + | require.True(t, ac.composed.valid) | |||
| 722 | + | cached := frameText(ac.composed.frame) | |||
| 723 | + | require.Contains(t, cached, "live") | |||
| 724 | + | require.NotContains(t, cached, "Rename session") | |||
| 725 | + | } | |||
| 726 | + | ||||
| 727 | + | func frameText(frame renderer.Frame) string { | |||
| 728 | + | var b strings.Builder | |||
| 729 | + | for y := 0; y < frame.Height; y++ { | |||
| 730 | + | b.WriteString(rowText(frame.Row(y))) | |||
| 731 | + | b.WriteByte('\n') | |||
| 732 | + | } | |||
| 733 | + | return b.String() | |||
| 734 | + | } | |||
| 735 | + | ||||
| 527 | func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) { | 736 | func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) { | ||
| 528 | tests := []struct { | 737 | tests := []struct { | ||
| 529 | name string | 738 | name string | ||
M internal/usecase/daemon/session.go +4 -3
| @@ -660,15 +660,16 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool { | |||
| 660 | 660 | } | |
| 661 | 661 | ||
| 662 | 662 | func (d *Daemon) cwdSampler(ctx context.Context) { | |
| 663 | - | t := time.NewTicker(5 * time.Second) | |
| 663 | + | t := d.clock.NewTimer(5 * time.Second) | |
| 664 | 664 | defer t.Stop() | |
| 665 | 665 | for { | |
| 666 | 666 | select { | |
| 667 | 667 | case <-ctx.Done(): | |
| 668 | 668 | return | |
| 669 | - | case <-t.C: | |
| 670 | - | d.refreshNamedSessionCwds() | |
| 669 | + | case <-t.C(): | |
| 671 | 670 | } | |
| 671 | + | d.refreshNamedSessionCwds() | |
| 672 | + | t.Reset(5 * time.Second) | |
| 672 | 673 | } | |
| 673 | 674 | } | |
| 674 | 675 | ||
| @@ -660,15 +660,16 @@ func (d *Daemon) nameLiveOrStoppedLocked(name string) bool { | |||||
| 660 | } | 660 | } | ||
| 661 | 661 | ||||
| 662 | func (d *Daemon) cwdSampler(ctx context.Context) { | 662 | func (d *Daemon) cwdSampler(ctx context.Context) { | ||
| 663 | - | t := time.NewTicker(5 * time.Second) | 663 | + | t := d.clock.NewTimer(5 * time.Second) |
| 664 | defer t.Stop() | 664 | defer t.Stop() | ||
| 665 | for { | 665 | for { | ||
| 666 | select { | 666 | select { | ||
| 667 | case <-ctx.Done(): | 667 | case <-ctx.Done(): | ||
| 668 | return | 668 | return | ||
| 669 | - | case <-t.C: | 669 | + | case <-t.C(): |
| 670 | - | d.refreshNamedSessionCwds() | |||
| 671 | } | 670 | } | ||
| 671 | + | d.refreshNamedSessionCwds() | |||
| 672 | + | t.Reset(5 * time.Second) | |||
| 672 | } | 673 | } | ||
| 673 | } | 674 | } | ||
| 674 | 675 | ||||
M internal/usecase/daemon/status_test.go +47 -0
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | 14 | "github.com/bnema/vev/internal/ports" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 15 | 16 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 16 | 17 | "github.com/bnema/vev/pkg/renderer" | |
| 17 | 18 | ) | |
| @@ -21,6 +22,52 @@ import ( | |||
| 21 | 22 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 22 | 23 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| 23 | 24 | ||
| 25 | + | func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) { | |
| 26 | + | p, releasePTY := newBlockingPTY(t) | |
| 27 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 28 | + | defer releasePTY() | |
| 29 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeLight}) | |
| 30 | + | ||
| 31 | + | win := sess.activeTab() | |
| 32 | + | left := win.focusedPane() | |
| 33 | + | right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4}) | |
| 34 | + | win.mu.Lock() | |
| 35 | + | win.size = domain.Size{Cols: 41, Rows: 4} | |
| 36 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |
| 37 | + | win.tree.Focus = left.id | |
| 38 | + | win.panes[right.id] = right | |
| 39 | + | win.mu.Unlock() | |
| 40 | + | left.mu.Lock() | |
| 41 | + | left.screen.Resize(20, 4) | |
| 42 | + | left.screen.Write([]byte("L")) | |
| 43 | + | left.mu.Unlock() | |
| 44 | + | right.mu.Lock() | |
| 45 | + | right.screen.Resize(20, 4) | |
| 46 | + | right.screen.Write([]byte("R")) | |
| 47 | + | right.mu.Unlock() | |
| 48 | + | ||
| 49 | + | d.paint(sess, ac, true) | |
| 50 | + | ac.sendMu.Lock() | |
| 51 | + | require.True(t, ac.composed.valid) | |
| 52 | + | lightDimmedPane := ac.composed.frame.At(21, 1).Style | |
| 53 | + | lightDivider := ac.composed.frame.At(20, 1).Style | |
| 54 | + | ac.sendMu.Unlock() | |
| 55 | + | left.mu.Lock() | |
| 56 | + | left.screen.ClearDamage() | |
| 57 | + | left.mu.Unlock() | |
| 58 | + | right.mu.Lock() | |
| 59 | + | right.screen.ClearDamage() | |
| 60 | + | right.mu.Unlock() | |
| 61 | + | ||
| 62 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeDark}) | |
| 63 | + | ||
| 64 | + | ac.sendMu.Lock() | |
| 65 | + | defer ac.sendMu.Unlock() | |
| 66 | + | require.True(t, ac.composed.valid, "reset=false config repaint should rebuild the composed cache") | |
| 67 | + | require.NotEqual(t, lightDimmedPane, ac.composed.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply") | |
| 68 | + | require.NotEqual(t, lightDivider, ac.composed.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply") | |
| 69 | + | } | |
| 70 | + | ||
| 24 | 71 | func TestStatusCompositionGolden(t *testing.T) { | |
| 25 | 72 | p1, releasePTY1 := newBlockingPTY(t) | |
| 26 | 73 | p2, releasePTY2 := newBlockingPTY(t) | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 14 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 15 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 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 | "github.com/bnema/vev/pkg/renderer" | ||
| 17 | ) | 18 | ) | ||
| @@ -21,6 +22,52 @@ import ( | |||||
| 21 | // stubClock returns timers whose channel never fires, so a scheduler under it | 22 | // stubClock returns timers whose channel never fires, so a scheduler under it | ||
| 22 | // blocks in its debounce loop until the session context is cancelled. Used by | 23 | // blocks in its debounce loop until the session context is cancelled. Used by | ||
| 23 | 24 | ||||
| 25 | + | func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) { | |||
| 26 | + | p, releasePTY := newBlockingPTY(t) | |||
| 27 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 28 | + | defer releasePTY() | |||
| 29 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeLight}) | |||
| 30 | + | ||||
| 31 | + | win := sess.activeTab() | |||
| 32 | + | left := win.focusedPane() | |||
| 33 | + | right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4}) | |||
| 34 | + | win.mu.Lock() | |||
| 35 | + | win.size = domain.Size{Cols: 41, Rows: 4} | |||
| 36 | + | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |||
| 37 | + | win.tree.Focus = left.id | |||
| 38 | + | win.panes[right.id] = right | |||
| 39 | + | win.mu.Unlock() | |||
| 40 | + | left.mu.Lock() | |||
| 41 | + | left.screen.Resize(20, 4) | |||
| 42 | + | left.screen.Write([]byte("L")) | |||
| 43 | + | left.mu.Unlock() | |||
| 44 | + | right.mu.Lock() | |||
| 45 | + | right.screen.Resize(20, 4) | |||
| 46 | + | right.screen.Write([]byte("R")) | |||
| 47 | + | right.mu.Unlock() | |||
| 48 | + | ||||
| 49 | + | d.paint(sess, ac, true) | |||
| 50 | + | ac.sendMu.Lock() | |||
| 51 | + | require.True(t, ac.composed.valid) | |||
| 52 | + | lightDimmedPane := ac.composed.frame.At(21, 1).Style | |||
| 53 | + | lightDivider := ac.composed.frame.At(20, 1).Style | |||
| 54 | + | ac.sendMu.Unlock() | |||
| 55 | + | left.mu.Lock() | |||
| 56 | + | left.screen.ClearDamage() | |||
| 57 | + | left.mu.Unlock() | |||
| 58 | + | right.mu.Lock() | |||
| 59 | + | right.screen.ClearDamage() | |||
| 60 | + | right.mu.Unlock() | |||
| 61 | + | ||||
| 62 | + | d.ApplyConfig(domain.Config{Theme: domain.ThemeDark}) | |||
| 63 | + | ||||
| 64 | + | ac.sendMu.Lock() | |||
| 65 | + | defer ac.sendMu.Unlock() | |||
| 66 | + | require.True(t, ac.composed.valid, "reset=false config repaint should rebuild the composed cache") | |||
| 67 | + | require.NotEqual(t, lightDimmedPane, ac.composed.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply") | |||
| 68 | + | require.NotEqual(t, lightDivider, ac.composed.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 24 | func TestStatusCompositionGolden(t *testing.T) { | 71 | func TestStatusCompositionGolden(t *testing.T) { | ||
| 25 | p1, releasePTY1 := newBlockingPTY(t) | 72 | p1, releasePTY1 := newBlockingPTY(t) | ||
| 26 | p2, releasePTY2 := newBlockingPTY(t) | 73 | p2, releasePTY2 := newBlockingPTY(t) | ||
M internal/usecase/daemon/theme_resolve.go +6 -0
| @@ -3,6 +3,11 @@ package daemon | |||
| 3 | 3 | import "github.com/bnema/vev/internal/usecase/theme" | |
| 4 | 4 | ||
| 5 | 5 | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | |
| 6 | + | if ac != nil { | |
| 7 | + | ac.sendMu.Lock() | |
| 8 | + | defer ac.sendMu.Unlock() | |
| 9 | + | } | |
| 10 | + | ||
| 6 | 11 | sess.mu.Lock() | |
| 7 | 12 | if ac != nil { | |
| 8 | 13 | if sess.client != ac { | |
| @@ -18,6 +23,7 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme | |||
| 18 | 23 | ||
| 19 | 24 | if ac != nil { | |
| 20 | 25 | ac.setTheme(t) | |
| 26 | + | ac.composed.invalidate() | |
| 21 | 27 | } | |
| 22 | 28 | for _, tb := range tabs { | |
| 23 | 29 | tb.mu.Lock() | |
| @@ -3,6 +3,11 @@ package daemon | |||||
| 3 | import "github.com/bnema/vev/internal/usecase/theme" | 3 | import "github.com/bnema/vev/internal/usecase/theme" | ||
| 4 | 4 | ||||
| 5 | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | 5 | func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool { | ||
| 6 | + | if ac != nil { | |||
| 7 | + | ac.sendMu.Lock() | |||
| 8 | + | defer ac.sendMu.Unlock() | |||
| 9 | + | } | |||
| 10 | + | ||||
| 6 | sess.mu.Lock() | 11 | sess.mu.Lock() | ||
| 7 | if ac != nil { | 12 | if ac != nil { | ||
| 8 | if sess.client != ac { | 13 | if sess.client != ac { | ||
| @@ -18,6 +23,7 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme | |||||
| 18 | 23 | ||||
| 19 | if ac != nil { | 24 | if ac != nil { | ||
| 20 | ac.setTheme(t) | 25 | ac.setTheme(t) | ||
| 26 | + | ac.composed.invalidate() | |||
| 21 | } | 27 | } | ||
| 22 | for _, tb := range tabs { | 28 | for _, tb := range tabs { | ||
| 23 | tb.mu.Lock() | 29 | tb.mu.Lock() | ||
M pkg/vt/screen.go +104 -44
| @@ -81,8 +81,10 @@ type Screen struct { | |||
| 81 | 81 | defaultBG renderer.RGB | |
| 82 | 82 | defaultColorsKnown bool | |
| 83 | 83 | ||
| 84 | - | damage []renderer.Damage | |
| 85 | - | escapeBuf []byte | |
| 84 | + | damage []renderer.Damage | |
| 85 | + | escapeBuf []byte | |
| 86 | + | csiScratch []int | |
| 87 | + | sgrScratch []int | |
| 86 | 88 | ||
| 87 | 89 | scrollTop int | |
| 88 | 90 | scrollBottom int | |
| @@ -784,7 +786,7 @@ func consumeSTString(data []byte) (consumed int, partial bool) { | |||
| 784 | 786 | ||
| 785 | 787 | func (s *Screen) applyCSI(params string, cmd byte) { | |
| 786 | 788 | private := strings.HasPrefix(params, "?") | |
| 787 | - | parts := parseCSIInts(params) | |
| 789 | + | parts := s.parseCSIInts(params) | |
| 788 | 790 | switch cmd { | |
| 789 | 791 | case 'c': | |
| 790 | 792 | switch params { | |
| @@ -952,7 +954,7 @@ func (s *Screen) applySGR(params string) { | |||
| 952 | 954 | s.Style = renderer.DefaultStyle() | |
| 953 | 955 | return | |
| 954 | 956 | } | |
| 955 | - | parts := parseSGRInts(params) | |
| 957 | + | parts := s.parseSGRInts(params) | |
| 956 | 958 | for i := 0; i < len(parts); i++ { | |
| 957 | 959 | switch parts[i] { | |
| 958 | 960 | case 0: | |
| @@ -1309,66 +1311,124 @@ func (s *Screen) clampCursor() { | |||
| 1309 | 1311 | s.Col = clamp(s.Col, 0, s.Frame.Width-1) | |
| 1310 | 1312 | } | |
| 1311 | 1313 | ||
| 1314 | + | func (s *Screen) parseCSIInts(params string) []int { | |
| 1315 | + | s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params) | |
| 1316 | + | return s.csiScratch | |
| 1317 | + | } | |
| 1318 | + | ||
| 1312 | 1319 | func parseCSIInts(params string) []int { | |
| 1320 | + | return parseCSIIntsInto(nil, params) | |
| 1321 | + | } | |
| 1322 | + | ||
| 1323 | + | func parseCSIIntsInto(out []int, params string) []int { | |
| 1313 | 1324 | if params == "" { | |
| 1314 | - | return nil | |
| 1325 | + | return out | |
| 1315 | 1326 | } | |
| 1316 | 1327 | params = strings.TrimPrefix(params, "?") | |
| 1317 | 1328 | params = strings.TrimPrefix(params, ">") | |
| 1318 | - | parts := strings.Split(params, ";") | |
| 1319 | - | out := make([]int, 0, len(parts)) | |
| 1320 | - | for _, p := range parts { | |
| 1321 | - | out = append(out, parseCSIInt(p)) | |
| 1329 | + | start := 0 | |
| 1330 | + | for i := 0; i <= len(params); i++ { | |
| 1331 | + | if i == len(params) || params[i] == ';' { | |
| 1332 | + | out = append(out, parseCSIInt(params[start:i])) | |
| 1333 | + | start = i + 1 | |
| 1334 | + | } | |
| 1322 | 1335 | } | |
| 1323 | 1336 | return out | |
| 1324 | 1337 | } | |
| 1325 | 1338 | ||
| 1339 | + | func (s *Screen) parseSGRInts(params string) []int { | |
| 1340 | + | s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params) | |
| 1341 | + | return s.sgrScratch | |
| 1342 | + | } | |
| 1343 | + | ||
| 1326 | 1344 | func parseSGRInts(params string) []int { | |
| 1345 | + | return parseSGRIntsInto(nil, params) | |
| 1346 | + | } | |
| 1347 | + | ||
| 1348 | + | func parseSGRIntsInto(out []int, params string) []int { | |
| 1327 | 1349 | if params == "" { | |
| 1328 | - | return nil | |
| 1350 | + | return out | |
| 1329 | 1351 | } | |
| 1330 | - | groups := strings.Split(params, ";") | |
| 1331 | - | out := make([]int, 0, len(groups)) | |
| 1332 | - | for _, group := range groups { | |
| 1333 | - | if !strings.Contains(group, ":") { | |
| 1334 | - | out = append(out, parseCSIInt(group)) | |
| 1335 | - | continue | |
| 1352 | + | start := 0 | |
| 1353 | + | for i := 0; i <= len(params); i++ { | |
| 1354 | + | if i == len(params) || params[i] == ';' { | |
| 1355 | + | out = appendSGRGroup(out, params[start:i]) | |
| 1356 | + | start = i + 1 | |
| 1336 | 1357 | } | |
| 1337 | - | sub := strings.Split(group, ":") | |
| 1338 | - | code := parseCSIInt(sub[0]) | |
| 1339 | - | if code != 38 && code != 48 { | |
| 1340 | - | out = append(out, code) | |
| 1341 | - | continue | |
| 1358 | + | } | |
| 1359 | + | return out | |
| 1360 | + | } | |
| 1361 | + | ||
| 1362 | + | func appendSGRGroup(out []int, group string) []int { | |
| 1363 | + | colon := false | |
| 1364 | + | for i := 0; i < len(group); i++ { | |
| 1365 | + | if group[i] == ':' { | |
| 1366 | + | colon = true | |
| 1367 | + | break | |
| 1342 | 1368 | } | |
| 1343 | - | mode := 0 | |
| 1344 | - | if len(sub) > 1 { | |
| 1345 | - | mode = parseCSIInt(sub[1]) | |
| 1369 | + | } | |
| 1370 | + | if !colon { | |
| 1371 | + | return append(out, parseCSIInt(group)) | |
| 1372 | + | } | |
| 1373 | + | ||
| 1374 | + | parts := countSGRColonFields(group) | |
| 1375 | + | code := parseCSIInt(sgrColonField(group, 0)) | |
| 1376 | + | if code != 38 && code != 48 { | |
| 1377 | + | return append(out, code) | |
| 1378 | + | } | |
| 1379 | + | mode := 0 | |
| 1380 | + | if parts > 1 { | |
| 1381 | + | mode = parseCSIInt(sgrColonField(group, 1)) | |
| 1382 | + | } | |
| 1383 | + | switch mode { | |
| 1384 | + | case 5: | |
| 1385 | + | if parts < 3 { | |
| 1386 | + | return out | |
| 1346 | 1387 | } | |
| 1347 | - | switch mode { | |
| 1348 | - | case 5: | |
| 1349 | - | if len(sub) < 3 { | |
| 1350 | - | continue | |
| 1351 | - | } | |
| 1352 | - | out = append(out, code, mode, parseCSIInt(sub[2])) | |
| 1353 | - | case 2: | |
| 1354 | - | // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after | |
| 1355 | - | // the colorspace slot; code:mode:R:G:B omits that slot. | |
| 1356 | - | start := 2 | |
| 1357 | - | if len(sub) > 2 && (sub[2] == "" || len(sub) >= 6) { | |
| 1358 | - | start = 3 | |
| 1359 | - | } | |
| 1360 | - | if len(sub) < start+3 { | |
| 1361 | - | continue | |
| 1362 | - | } | |
| 1363 | - | out = append(out, code, mode) | |
| 1364 | - | for i := 0; i < 3; i++ { | |
| 1365 | - | out = append(out, parseCSIInt(sub[start+i])) | |
| 1366 | - | } | |
| 1388 | + | out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2))) | |
| 1389 | + | case 2: | |
| 1390 | + | // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after | |
| 1391 | + | // the colorspace slot; code:mode:R:G:B omits that slot. | |
| 1392 | + | start := 2 | |
| 1393 | + | if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) { | |
| 1394 | + | start = 3 | |
| 1395 | + | } | |
| 1396 | + | if parts < start+3 { | |
| 1397 | + | return out | |
| 1398 | + | } | |
| 1399 | + | out = append(out, code, mode) | |
| 1400 | + | for i := 0; i < 3; i++ { | |
| 1401 | + | out = append(out, parseCSIInt(sgrColonField(group, start+i))) | |
| 1367 | 1402 | } | |
| 1368 | 1403 | } | |
| 1369 | 1404 | return out | |
| 1370 | 1405 | } | |
| 1371 | 1406 | ||
| 1407 | + | func countSGRColonFields(group string) int { | |
| 1408 | + | count := 1 | |
| 1409 | + | for i := 0; i < len(group); i++ { | |
| 1410 | + | if group[i] == ':' { | |
| 1411 | + | count++ | |
| 1412 | + | } | |
| 1413 | + | } | |
| 1414 | + | return count | |
| 1415 | + | } | |
| 1416 | + | ||
| 1417 | + | func sgrColonField(group string, index int) string { | |
| 1418 | + | start := 0 | |
| 1419 | + | field := 0 | |
| 1420 | + | for i := 0; i <= len(group); i++ { | |
| 1421 | + | if i == len(group) || group[i] == ':' { | |
| 1422 | + | if field == index { | |
| 1423 | + | return group[start:i] | |
| 1424 | + | } | |
| 1425 | + | field++ | |
| 1426 | + | start = i + 1 | |
| 1427 | + | } | |
| 1428 | + | } | |
| 1429 | + | return "" | |
| 1430 | + | } | |
| 1431 | + | ||
| 1372 | 1432 | func parseCSIInt(param string) int { | |
| 1373 | 1433 | if param == "" { | |
| 1374 | 1434 | return 0 | |
| @@ -81,8 +81,10 @@ type Screen struct { | |||||
| 81 | defaultBG renderer.RGB | 81 | defaultBG renderer.RGB | ||
| 82 | defaultColorsKnown bool | 82 | defaultColorsKnown bool | ||
| 83 | 83 | ||||
| 84 | - | damage []renderer.Damage | 84 | + | damage []renderer.Damage |
| 85 | - | escapeBuf []byte | 85 | + | escapeBuf []byte |
| 86 | + | csiScratch []int | |||
| 87 | + | sgrScratch []int | |||
| 86 | 88 | ||||
| 87 | scrollTop int | 89 | scrollTop int | ||
| 88 | scrollBottom int | 90 | scrollBottom int | ||
| @@ -784,7 +786,7 @@ func consumeSTString(data []byte) (consumed int, partial bool) { | |||||
| 784 | 786 | ||||
| 785 | func (s *Screen) applyCSI(params string, cmd byte) { | 787 | func (s *Screen) applyCSI(params string, cmd byte) { | ||
| 786 | private := strings.HasPrefix(params, "?") | 788 | private := strings.HasPrefix(params, "?") | ||
| 787 | - | parts := parseCSIInts(params) | 789 | + | parts := s.parseCSIInts(params) |
| 788 | switch cmd { | 790 | switch cmd { | ||
| 789 | case 'c': | 791 | case 'c': | ||
| 790 | switch params { | 792 | switch params { | ||
| @@ -952,7 +954,7 @@ func (s *Screen) applySGR(params string) { | |||||
| 952 | s.Style = renderer.DefaultStyle() | 954 | s.Style = renderer.DefaultStyle() | ||
| 953 | return | 955 | return | ||
| 954 | } | 956 | } | ||
| 955 | - | parts := parseSGRInts(params) | 957 | + | parts := s.parseSGRInts(params) |
| 956 | for i := 0; i < len(parts); i++ { | 958 | for i := 0; i < len(parts); i++ { | ||
| 957 | switch parts[i] { | 959 | switch parts[i] { | ||
| 958 | case 0: | 960 | case 0: | ||
| @@ -1309,66 +1311,124 @@ func (s *Screen) clampCursor() { | |||||
| 1309 | s.Col = clamp(s.Col, 0, s.Frame.Width-1) | 1311 | s.Col = clamp(s.Col, 0, s.Frame.Width-1) | ||
| 1310 | } | 1312 | } | ||
| 1311 | 1313 | ||||
| 1314 | + | func (s *Screen) parseCSIInts(params string) []int { | |||
| 1315 | + | s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params) | |||
| 1316 | + | return s.csiScratch | |||
| 1317 | + | } | |||
| 1318 | + | ||||
| 1312 | func parseCSIInts(params string) []int { | 1319 | func parseCSIInts(params string) []int { | ||
| 1320 | + | return parseCSIIntsInto(nil, params) | |||
| 1321 | + | } | |||
| 1322 | + | ||||
| 1323 | + | func parseCSIIntsInto(out []int, params string) []int { | |||
| 1313 | if params == "" { | 1324 | if params == "" { | ||
| 1314 | - | return nil | 1325 | + | return out |
| 1315 | } | 1326 | } | ||
| 1316 | params = strings.TrimPrefix(params, "?") | 1327 | params = strings.TrimPrefix(params, "?") | ||
| 1317 | params = strings.TrimPrefix(params, ">") | 1328 | params = strings.TrimPrefix(params, ">") | ||
| 1318 | - | parts := strings.Split(params, ";") | 1329 | + | start := 0 |
| 1319 | - | out := make([]int, 0, len(parts)) | 1330 | + | for i := 0; i <= len(params); i++ { |
| 1320 | - | for _, p := range parts { | 1331 | + | if i == len(params) || params[i] == ';' { |
| 1321 | - | out = append(out, parseCSIInt(p)) | 1332 | + | out = append(out, parseCSIInt(params[start:i])) |
| 1333 | + | start = i + 1 | |||
| 1334 | + | } | |||
| 1322 | } | 1335 | } | ||
| 1323 | return out | 1336 | return out | ||
| 1324 | } | 1337 | } | ||
| 1325 | 1338 | ||||
| 1339 | + | func (s *Screen) parseSGRInts(params string) []int { | |||
| 1340 | + | s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params) | |||
| 1341 | + | return s.sgrScratch | |||
| 1342 | + | } | |||
| 1343 | + | ||||
| 1326 | func parseSGRInts(params string) []int { | 1344 | func parseSGRInts(params string) []int { | ||
| 1345 | + | return parseSGRIntsInto(nil, params) | |||
| 1346 | + | } | |||
| 1347 | + | ||||
| 1348 | + | func parseSGRIntsInto(out []int, params string) []int { | |||
| 1327 | if params == "" { | 1349 | if params == "" { | ||
| 1328 | - | return nil | 1350 | + | return out |
| 1329 | } | 1351 | } | ||
| 1330 | - | groups := strings.Split(params, ";") | 1352 | + | start := 0 |
| 1331 | - | out := make([]int, 0, len(groups)) | 1353 | + | for i := 0; i <= len(params); i++ { |
| 1332 | - | for _, group := range groups { | 1354 | + | if i == len(params) || params[i] == ';' { |
| 1333 | - | if !strings.Contains(group, ":") { | 1355 | + | out = appendSGRGroup(out, params[start:i]) |
| 1334 | - | out = append(out, parseCSIInt(group)) | 1356 | + | start = i + 1 |
| 1335 | - | continue | |||
| 1336 | } | 1357 | } | ||
| 1337 | - | sub := strings.Split(group, ":") | 1358 | + | } |
| 1338 | - | code := parseCSIInt(sub[0]) | 1359 | + | return out |
| 1339 | - | if code != 38 && code != 48 { | 1360 | + | } |
| 1340 | - | out = append(out, code) | 1361 | + | |
| 1341 | - | continue | 1362 | + | func appendSGRGroup(out []int, group string) []int { |
| 1363 | + | colon := false | |||
| 1364 | + | for i := 0; i < len(group); i++ { | |||
| 1365 | + | if group[i] == ':' { | |||
| 1366 | + | colon = true | |||
| 1367 | + | break | |||
| 1342 | } | 1368 | } | ||
| 1343 | - | mode := 0 | 1369 | + | } |
| 1344 | - | if len(sub) > 1 { | 1370 | + | if !colon { |
| 1345 | - | mode = parseCSIInt(sub[1]) | 1371 | + | return append(out, parseCSIInt(group)) |
| 1372 | + | } | |||
| 1373 | + | ||||
| 1374 | + | parts := countSGRColonFields(group) | |||
| 1375 | + | code := parseCSIInt(sgrColonField(group, 0)) | |||
| 1376 | + | if code != 38 && code != 48 { | |||
| 1377 | + | return append(out, code) | |||
| 1378 | + | } | |||
| 1379 | + | mode := 0 | |||
| 1380 | + | if parts > 1 { | |||
| 1381 | + | mode = parseCSIInt(sgrColonField(group, 1)) | |||
| 1382 | + | } | |||
| 1383 | + | switch mode { | |||
| 1384 | + | case 5: | |||
| 1385 | + | if parts < 3 { | |||
| 1386 | + | return out | |||
| 1346 | } | 1387 | } | ||
| 1347 | - | switch mode { | 1388 | + | out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2))) |
| 1348 | - | case 5: | 1389 | + | case 2: |
| 1349 | - | if len(sub) < 3 { | 1390 | + | // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after |
| 1350 | - | continue | 1391 | + | // the colorspace slot; code:mode:R:G:B omits that slot. |
| 1351 | - | } | 1392 | + | start := 2 |
| 1352 | - | out = append(out, code, mode, parseCSIInt(sub[2])) | 1393 | + | if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) { |
| 1353 | - | case 2: | 1394 | + | start = 3 |
| 1354 | - | // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after | 1395 | + | } |
| 1355 | - | // the colorspace slot; code:mode:R:G:B omits that slot. | 1396 | + | if parts < start+3 { |
| 1356 | - | start := 2 | 1397 | + | return out |
| 1357 | - | if len(sub) > 2 && (sub[2] == "" || len(sub) >= 6) { | 1398 | + | } |
| 1358 | - | start = 3 | 1399 | + | out = append(out, code, mode) |
| 1359 | - | } | 1400 | + | for i := 0; i < 3; i++ { |
| 1360 | - | if len(sub) < start+3 { | 1401 | + | out = append(out, parseCSIInt(sgrColonField(group, start+i))) |
| 1361 | - | continue | |||
| 1362 | - | } | |||
| 1363 | - | out = append(out, code, mode) | |||
| 1364 | - | for i := 0; i < 3; i++ { | |||
| 1365 | - | out = append(out, parseCSIInt(sub[start+i])) | |||
| 1366 | - | } | |||
| 1367 | } | 1402 | } | ||
| 1368 | } | 1403 | } | ||
| 1369 | return out | 1404 | return out | ||
| 1370 | } | 1405 | } | ||
| 1371 | 1406 | ||||
| 1407 | + | func countSGRColonFields(group string) int { | |||
| 1408 | + | count := 1 | |||
| 1409 | + | for i := 0; i < len(group); i++ { | |||
| 1410 | + | if group[i] == ':' { | |||
| 1411 | + | count++ | |||
| 1412 | + | } | |||
| 1413 | + | } | |||
| 1414 | + | return count | |||
| 1415 | + | } | |||
| 1416 | + | ||||
| 1417 | + | func sgrColonField(group string, index int) string { | |||
| 1418 | + | start := 0 | |||
| 1419 | + | field := 0 | |||
| 1420 | + | for i := 0; i <= len(group); i++ { | |||
| 1421 | + | if i == len(group) || group[i] == ':' { | |||
| 1422 | + | if field == index { | |||
| 1423 | + | return group[start:i] | |||
| 1424 | + | } | |||
| 1425 | + | field++ | |||
| 1426 | + | start = i + 1 | |||
| 1427 | + | } | |||
| 1428 | + | } | |||
| 1429 | + | return "" | |||
| 1430 | + | } | |||
| 1431 | + | ||||
| 1372 | func parseCSIInt(param string) int { | 1432 | func parseCSIInt(param string) int { | ||
| 1373 | if param == "" { | 1433 | if param == "" { | ||
| 1374 | return 0 | 1434 | return 0 | ||
M pkg/vt/screen_test.go +57 -0
| @@ -74,6 +74,63 @@ func lineText(s *Screen, y int) string { | |||
| 74 | 74 | return string(out) | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | + | func TestParseCSIInts(t *testing.T) { | |
| 78 | + | t.Parallel() | |
| 79 | + | ||
| 80 | + | tests := []struct { | |
| 81 | + | name string | |
| 82 | + | params string | |
| 83 | + | want []int | |
| 84 | + | }{ | |
| 85 | + | {name: "empty", params: "", want: nil}, | |
| 86 | + | {name: "single", params: "5", want: []int{5}}, | |
| 87 | + | {name: "multiple", params: "1;2;3", want: []int{1, 2, 3}}, | |
| 88 | + | {name: "leading empty token", params: ";5", want: []int{0, 5}}, | |
| 89 | + | {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}}, | |
| 90 | + | {name: "trailing empty token", params: "5;", want: []int{5, 0}}, | |
| 91 | + | {name: "question prefix", params: "?25", want: []int{25}}, | |
| 92 | + | {name: "greater prefix", params: ">0;1", want: []int{0, 1}}, | |
| 93 | + | {name: "prefix order", params: "?>7", want: []int{7}}, | |
| 94 | + | {name: "junk token", params: "12x;4", want: []int{0, 4}}, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | for _, tt := range tests { | |
| 98 | + | t.Run(tt.name, func(t *testing.T) { | |
| 99 | + | require.Equal(t, tt.want, parseCSIInts(tt.params)) | |
| 100 | + | }) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestParseSGRInts(t *testing.T) { | |
| 105 | + | t.Parallel() | |
| 106 | + | ||
| 107 | + | tests := []struct { | |
| 108 | + | name string | |
| 109 | + | params string | |
| 110 | + | want []int | |
| 111 | + | }{ | |
| 112 | + | {name: "empty", params: "", want: nil}, | |
| 113 | + | {name: "single", params: "1", want: []int{1}}, | |
| 114 | + | {name: "multiple", params: "1;31", want: []int{1, 31}}, | |
| 115 | + | {name: "leading empty token", params: ";5", want: []int{0, 5}}, | |
| 116 | + | {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}}, | |
| 117 | + | {name: "trailing empty token", params: "5;", want: []int{5, 0}}, | |
| 118 | + | {name: "indexed foreground colon", params: "38:5:196", want: []int{38, 5, 196}}, | |
| 119 | + | {name: "rgb foreground colon", params: "38:2:1:2:3", want: []int{38, 2, 1, 2, 3}}, | |
| 120 | + | {name: "indexed background colon", params: "48:5:10", want: []int{48, 5, 10}}, | |
| 121 | + | {name: "rgb foreground empty colorspace", params: "38:2::1:2:3", want: []int{38, 2, 1, 2, 3}}, | |
| 122 | + | {name: "rgb foreground explicit colorspace", params: "38:2:9:1:2:3", want: []int{38, 2, 1, 2, 3}}, | |
| 123 | + | {name: "non color colon group", params: "1:2:3", want: []int{1}}, | |
| 124 | + | {name: "junk token", params: "abc;31", want: []int{0, 31}}, | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | for _, tt := range tests { | |
| 128 | + | t.Run(tt.name, func(t *testing.T) { | |
| 129 | + | require.Equal(t, tt.want, parseSGRInts(tt.params)) | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 77 | 134 | func rowString(row []renderer.Cell) string { | |
| 78 | 135 | out := make([]rune, len(row)) | |
| 79 | 136 | for i, cell := range row { | |
| @@ -74,6 +74,63 @@ func lineText(s *Screen, y int) string { | |||||
| 74 | return string(out) | 74 | return string(out) | ||
| 75 | } | 75 | } | ||
| 76 | 76 | ||||
| 77 | + | func TestParseCSIInts(t *testing.T) { | |||
| 78 | + | t.Parallel() | |||
| 79 | + | ||||
| 80 | + | tests := []struct { | |||
| 81 | + | name string | |||
| 82 | + | params string | |||
| 83 | + | want []int | |||
| 84 | + | }{ | |||
| 85 | + | {name: "empty", params: "", want: nil}, | |||
| 86 | + | {name: "single", params: "5", want: []int{5}}, | |||
| 87 | + | {name: "multiple", params: "1;2;3", want: []int{1, 2, 3}}, | |||
| 88 | + | {name: "leading empty token", params: ";5", want: []int{0, 5}}, | |||
| 89 | + | {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}}, | |||
| 90 | + | {name: "trailing empty token", params: "5;", want: []int{5, 0}}, | |||
| 91 | + | {name: "question prefix", params: "?25", want: []int{25}}, | |||
| 92 | + | {name: "greater prefix", params: ">0;1", want: []int{0, 1}}, | |||
| 93 | + | {name: "prefix order", params: "?>7", want: []int{7}}, | |||
| 94 | + | {name: "junk token", params: "12x;4", want: []int{0, 4}}, | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | for _, tt := range tests { | |||
| 98 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 99 | + | require.Equal(t, tt.want, parseCSIInts(tt.params)) | |||
| 100 | + | }) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestParseSGRInts(t *testing.T) { | |||
| 105 | + | t.Parallel() | |||
| 106 | + | ||||
| 107 | + | tests := []struct { | |||
| 108 | + | name string | |||
| 109 | + | params string | |||
| 110 | + | want []int | |||
| 111 | + | }{ | |||
| 112 | + | {name: "empty", params: "", want: nil}, | |||
| 113 | + | {name: "single", params: "1", want: []int{1}}, | |||
| 114 | + | {name: "multiple", params: "1;31", want: []int{1, 31}}, | |||
| 115 | + | {name: "leading empty token", params: ";5", want: []int{0, 5}}, | |||
| 116 | + | {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}}, | |||
| 117 | + | {name: "trailing empty token", params: "5;", want: []int{5, 0}}, | |||
| 118 | + | {name: "indexed foreground colon", params: "38:5:196", want: []int{38, 5, 196}}, | |||
| 119 | + | {name: "rgb foreground colon", params: "38:2:1:2:3", want: []int{38, 2, 1, 2, 3}}, | |||
| 120 | + | {name: "indexed background colon", params: "48:5:10", want: []int{48, 5, 10}}, | |||
| 121 | + | {name: "rgb foreground empty colorspace", params: "38:2::1:2:3", want: []int{38, 2, 1, 2, 3}}, | |||
| 122 | + | {name: "rgb foreground explicit colorspace", params: "38:2:9:1:2:3", want: []int{38, 2, 1, 2, 3}}, | |||
| 123 | + | {name: "non color colon group", params: "1:2:3", want: []int{1}}, | |||
| 124 | + | {name: "junk token", params: "abc;31", want: []int{0, 31}}, | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | for _, tt := range tests { | |||
| 128 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 129 | + | require.Equal(t, tt.want, parseSGRInts(tt.params)) | |||
| 130 | + | }) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 77 | func rowString(row []renderer.Cell) string { | 134 | func rowString(row []renderer.Cell) string { | ||
| 78 | out := make([]rune, len(row)) | 135 | out := make([]rune, len(row)) | ||
| 79 | for i, cell := range row { | 136 | for i, cell := range row { | ||