perf(scrollback): reuse viewport rows and ease mouse scrolling (#255)
* perf(scrollback): reuse viewport rows and ease mouse scrolling * fix(scrollback): address review findings and pin scroll validation fix * chore(deps): upgrade vev-vt to v0.5.1
- commit
- f1fbf32301f763d19a4cb3e666d928374cbc7074
- author
- bnema · · 2026-09-05 13:30 UTC
- committer
- GitHub · · 2026-09-05 13:30 UTC
- parent
- cb489d5
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
21 files · +740 -32
M docs/configuration.md +17 -0
| @@ -172,15 +172,32 @@ Overflow applies only to keyboard focus actions; mouse navigation does not overf | |||
| 172 | 172 | ||
| 173 | 173 | Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy. | |
| 174 | 174 | ||
| 175 | + | The mouse wheel moves the viewport immediately in either direction, independently | |
| 176 | + | of keyboard cursor navigation. Scrolling has a short, decelerating animation: | |
| 177 | + | the first row responds immediately and the remaining movement is paced at 16 ms | |
| 178 | + | intervals. Reversing direction cancels the old tail. Keyboard input and mouse | |
| 179 | + | selection stop the animation; selection scrolling remains immediate. Returning | |
| 180 | + | to the bottom exits copy mode and shows live output. | |
| 181 | + | ||
| 175 | 182 | Mouse drag selects a text range. Double-click selects the word under the pointer; dragging after a double-click extends by complete words. | |
| 176 | 183 | ||
| 177 | 184 | ```ini | |
| 178 | 185 | # Unicode whitespace always separates words. | |
| 179 | 186 | # The default is " -_@". | |
| 180 | 187 | copy.word-separators = " -_@" | |
| 188 | + | # Disable animated wheel scrolling (default: off). | |
| 189 | + | copy.reduce-motion = off | |
| 181 | 190 | ``` | |
| 182 | 191 | ||
| 183 | 192 | Set `copy.word-separators = ""` to use only Unicode whitespace as a separator. | |
| 193 | + | Set `copy.reduce-motion = on` for immediate three-row wheel steps. The setting | |
| 194 | + | applies to subsequent wheel input after configuration reload. Applications that | |
| 195 | + | capture the mouse, and alternate-screen wheel-to-arrow forwarding, are unchanged. | |
| 196 | + | ||
| 197 | + | Motion is row-based: vev cannot animate fractional pixels inside the outer | |
| 198 | + | terminal. The easing preserves requested scroll distance rather than adding | |
| 199 | + | extra momentum, and drains any remaining tail on the first animation tick at | |
| 200 | + | least 120 ms after the last wheel input. | |
| 184 | 201 | ||
| 185 | 202 | ## Responsive overlays | |
| 186 | 203 | ||
| @@ -172,15 +172,32 @@ Overflow applies only to keyboard focus actions; mouse navigation does not overf | |||||
| 172 | 172 | ||||
| 173 | Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy. | 173 | Scroll up with the mouse to enter copy mode. Use `h`, `j`, `k`, and `l` to move, `w`, `b`, and `e` for word motions, `v` or Space to start line selection, and `y` or Enter to copy. | ||
| 174 | 174 | ||||
| 175 | + | The mouse wheel moves the viewport immediately in either direction, independently | |||
| 176 | + | of keyboard cursor navigation. Scrolling has a short, decelerating animation: | |||
| 177 | + | the first row responds immediately and the remaining movement is paced at 16 ms | |||
| 178 | + | intervals. Reversing direction cancels the old tail. Keyboard input and mouse | |||
| 179 | + | selection stop the animation; selection scrolling remains immediate. Returning | |||
| 180 | + | to the bottom exits copy mode and shows live output. | |||
| 181 | + | ||||
| 175 | Mouse drag selects a text range. Double-click selects the word under the pointer; dragging after a double-click extends by complete words. | 182 | Mouse drag selects a text range. Double-click selects the word under the pointer; dragging after a double-click extends by complete words. | ||
| 176 | 183 | ||||
| 177 | ```ini | 184 | ```ini | ||
| 178 | # Unicode whitespace always separates words. | 185 | # Unicode whitespace always separates words. | ||
| 179 | # The default is " -_@". | 186 | # The default is " -_@". | ||
| 180 | copy.word-separators = " -_@" | 187 | copy.word-separators = " -_@" | ||
| 188 | + | # Disable animated wheel scrolling (default: off). | |||
| 189 | + | copy.reduce-motion = off | |||
| 181 | ``` | 190 | ``` | ||
| 182 | 191 | ||||
| 183 | Set `copy.word-separators = ""` to use only Unicode whitespace as a separator. | 192 | Set `copy.word-separators = ""` to use only Unicode whitespace as a separator. | ||
| 193 | + | Set `copy.reduce-motion = on` for immediate three-row wheel steps. The setting | |||
| 194 | + | applies to subsequent wheel input after configuration reload. Applications that | |||
| 195 | + | capture the mouse, and alternate-screen wheel-to-arrow forwarding, are unchanged. | |||
| 196 | + | ||||
| 197 | + | Motion is row-based: vev cannot animate fractional pixels inside the outer | |||
| 198 | + | terminal. The easing preserves requested scroll distance rather than adding | |||
| 199 | + | extra momentum, and drains any remaining tail on the first animation tick at | |||
| 200 | + | least 120 ms after the last wheel input. | |||
| 184 | 201 | ||||
| 185 | ## Responsive overlays | 202 | ## Responsive overlays | ||
| 186 | 203 | ||||
M docs/performance.md +61 -1
| @@ -113,6 +113,66 @@ latency: | |||
| 113 | 113 | ||
| 114 | 114 | ```sh | |
| 115 | 115 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | |
| 116 | - | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem | |
| 116 | + | go test github.com/bnema/vev-vt/ansi github.com/bnema/vev-vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem | |
| 117 | 117 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeCapturedFloatingFrameCached$' -benchmem | |
| 118 | 118 | ``` | |
| 119 | + | ||
| 120 | + | ## Mouse scroll rendering | |
| 121 | + | ||
| 122 | + | Wheel input moves the immutable copy viewport, rather than walking the keyboard | |
| 123 | + | cursor to an edge before scrolling. Ordinary copy paints preserve the output | |
| 124 | + | epoch. For unchanged full-width viewports, composition rotates retained compact | |
| 125 | + | rows and paints only exposed rows and cursor highlights. The ANSI renderer checks | |
| 126 | + | the scroll against its own committed shadow before using a scroll region in | |
| 127 | + | either direction. Selection, search, narrow panes, floating panes, notices, and | |
| 128 | + | geometry/theme changes keep their conservative composition paths. | |
| 129 | + | ||
| 130 | + | The unadorned live-pane cache stays separate from the displayed copy viewport. | |
| 131 | + | Neither committed frame is mutated while preparing a candidate; failed output | |
| 132 | + | cannot publish its viewport metadata. Copy exit refreshes live content. | |
| 133 | + | ||
| 134 | + | Mouse animation is tested separately with injected clocks: first response, | |
| 135 | + | 16 ms pacing, burst accumulation, deceleration, reversal, cancellation, and the | |
| 136 | + | 120 ms tail deadline. The rendering benchmark deliberately bypasses those timers | |
| 137 | + | to measure work rather than sleep time: | |
| 138 | + | ||
| 139 | + | ```sh | |
| 140 | + | go test ./internal/usecase/daemon -run '^$' \ | |
| 141 | + | -bench '^BenchmarkDaemonHistoryCopyScroll$' -benchmem -count=3 | |
| 142 | + | go test ./internal/usecase/daemon ./internal/usecase/copy -race \ | |
| 143 | + | -run 'TestCopyScrollAnimation|TestCopyWheel|TestScrollRows|TestRenderRowsRange' | |
| 144 | + | ``` | |
| 145 | + | ||
| 146 | + | ### Local comparison (2026-09-05) | |
| 147 | + | ||
| 148 | + | AMD Ryzen 5 7535HS, Linux amd64, Go 1.27.1; three repetitions, median time. | |
| 149 | + | Baseline: vev `cb489d5b` with vev-vt v0.5.0. Each operation is two wheel movements | |
| 150 | + | with 10,000 retained rows; setup and copy entry are outside the timed loop. | |
| 151 | + | ||
| 152 | + | | Viewport | Before, ms/pair | After, ms/pair | Last ANSI bytes/wheel, before → after | | |
| 153 | + | | --- | ---: | ---: | ---: | | |
| 154 | + | | 120×40 | 1.306 | 0.654 | 4,998 → 713 | | |
| 155 | + | | 182×53 | 2.531 | 1.159 | 9,873 → 1,023 | | |
| 156 | + | | 240×70 | 4.329 | 1.850 | 17,088 → 1,313 | | |
| 157 | + | ||
| 158 | + | Important limits of this comparison: | |
| 159 | + | ||
| 160 | + | - The old alternating-wheel loop mostly moved a cursor within a stationary | |
| 161 | + | viewport after its first scroll. The new loop actually moves the viewport on | |
| 162 | + | every wheel event. These are the same input events, not equivalent old/new | |
| 163 | + | visual behavior. | |
| 164 | + | - Allocated bytes remain roughly unchanged: about 0.66/1.16/1.95 MB per pair. | |
| 165 | + | Transactional compact-frame copies still dominate allocations; the speedup | |
| 166 | + | is not evidence of eliminating GC pressure. | |
| 167 | + | - This repetitive fixture compresses snapshots very well. Encoded output payloads | |
| 168 | + | **increase** from 574/751/896 to 762/1,072/1,362 bytes per wheel because ordinary | |
| 169 | + | incremental output is uncompressed. Fewer ANSI bytes means less terminal work, | |
| 170 | + | not automatically fewer network bytes. Animation also adds intermediate frames. | |
| 171 | + | - These are in-process measurements, not terminal presentation latency or a | |
| 172 | + | substitute for the canonical remote matrix. Subjective motion and remote | |
| 173 | + | latency still need real-terminal validation. | |
| 174 | + | ||
| 175 | + | Ghostty was inspected as a design reference at local commit `c81f0b268`, especially | |
| 176 | + | `src/renderer/generic.zig` and `src/renderer/Thread.zig`: retain viewport state, | |
| 177 | + | track dirty work, and separate terminal capture from rendering work. vev keeps | |
| 178 | + | its server-side ANSI architecture; no GPU renderer or Ghostty code is imported. | |
| @@ -113,6 +113,66 @@ latency: | |||||
| 113 | 113 | ||||
| 114 | ```sh | 114 | ```sh | ||
| 115 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | 115 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem | ||
| 116 | - | go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem | 116 | + | go test github.com/bnema/vev-vt/ansi github.com/bnema/vev-vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem |
| 117 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeCapturedFloatingFrameCached$' -benchmem | 117 | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeCapturedFloatingFrameCached$' -benchmem | ||
| 118 | ``` | 118 | ``` | ||
| 119 | + | ||||
| 120 | + | ## Mouse scroll rendering | |||
| 121 | + | ||||
| 122 | + | Wheel input moves the immutable copy viewport, rather than walking the keyboard | |||
| 123 | + | cursor to an edge before scrolling. Ordinary copy paints preserve the output | |||
| 124 | + | epoch. For unchanged full-width viewports, composition rotates retained compact | |||
| 125 | + | rows and paints only exposed rows and cursor highlights. The ANSI renderer checks | |||
| 126 | + | the scroll against its own committed shadow before using a scroll region in | |||
| 127 | + | either direction. Selection, search, narrow panes, floating panes, notices, and | |||
| 128 | + | geometry/theme changes keep their conservative composition paths. | |||
| 129 | + | ||||
| 130 | + | The unadorned live-pane cache stays separate from the displayed copy viewport. | |||
| 131 | + | Neither committed frame is mutated while preparing a candidate; failed output | |||
| 132 | + | cannot publish its viewport metadata. Copy exit refreshes live content. | |||
| 133 | + | ||||
| 134 | + | Mouse animation is tested separately with injected clocks: first response, | |||
| 135 | + | 16 ms pacing, burst accumulation, deceleration, reversal, cancellation, and the | |||
| 136 | + | 120 ms tail deadline. The rendering benchmark deliberately bypasses those timers | |||
| 137 | + | to measure work rather than sleep time: | |||
| 138 | + | ||||
| 139 | + | ```sh | |||
| 140 | + | go test ./internal/usecase/daemon -run '^$' \ | |||
| 141 | + | -bench '^BenchmarkDaemonHistoryCopyScroll$' -benchmem -count=3 | |||
| 142 | + | go test ./internal/usecase/daemon ./internal/usecase/copy -race \ | |||
| 143 | + | -run 'TestCopyScrollAnimation|TestCopyWheel|TestScrollRows|TestRenderRowsRange' | |||
| 144 | + | ``` | |||
| 145 | + | ||||
| 146 | + | ### Local comparison (2026-09-05) | |||
| 147 | + | ||||
| 148 | + | AMD Ryzen 5 7535HS, Linux amd64, Go 1.27.1; three repetitions, median time. | |||
| 149 | + | Baseline: vev `cb489d5b` with vev-vt v0.5.0. Each operation is two wheel movements | |||
| 150 | + | with 10,000 retained rows; setup and copy entry are outside the timed loop. | |||
| 151 | + | ||||
| 152 | + | | Viewport | Before, ms/pair | After, ms/pair | Last ANSI bytes/wheel, before → after | | |||
| 153 | + | | --- | ---: | ---: | ---: | | |||
| 154 | + | | 120×40 | 1.306 | 0.654 | 4,998 → 713 | | |||
| 155 | + | | 182×53 | 2.531 | 1.159 | 9,873 → 1,023 | | |||
| 156 | + | | 240×70 | 4.329 | 1.850 | 17,088 → 1,313 | | |||
| 157 | + | ||||
| 158 | + | Important limits of this comparison: | |||
| 159 | + | ||||
| 160 | + | - The old alternating-wheel loop mostly moved a cursor within a stationary | |||
| 161 | + | viewport after its first scroll. The new loop actually moves the viewport on | |||
| 162 | + | every wheel event. These are the same input events, not equivalent old/new | |||
| 163 | + | visual behavior. | |||
| 164 | + | - Allocated bytes remain roughly unchanged: about 0.66/1.16/1.95 MB per pair. | |||
| 165 | + | Transactional compact-frame copies still dominate allocations; the speedup | |||
| 166 | + | is not evidence of eliminating GC pressure. | |||
| 167 | + | - This repetitive fixture compresses snapshots very well. Encoded output payloads | |||
| 168 | + | **increase** from 574/751/896 to 762/1,072/1,362 bytes per wheel because ordinary | |||
| 169 | + | incremental output is uncompressed. Fewer ANSI bytes means less terminal work, | |||
| 170 | + | not automatically fewer network bytes. Animation also adds intermediate frames. | |||
| 171 | + | - These are in-process measurements, not terminal presentation latency or a | |||
| 172 | + | substitute for the canonical remote matrix. Subjective motion and remote | |||
| 173 | + | latency still need real-terminal validation. | |||
| 174 | + | ||||
| 175 | + | Ghostty was inspected as a design reference at local commit `c81f0b268`, especially | |||
| 176 | + | `src/renderer/generic.zig` and `src/renderer/Thread.zig`: retain viewport state, | |||
| 177 | + | track dirty work, and separate terminal capture from rendering work. vev keeps | |||
| 178 | + | its server-side ANSI architecture; no GPU renderer or Ghostty code is imported. | |||
M go.mod +1 -1
| @@ -3,7 +3,7 @@ module github.com/bnema/vev | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/vev-vt v0.5.0 | |
| 6 | + | github.com/bnema/vev-vt v0.5.1 | |
| 7 | 7 | github.com/stretchr/testify v1.11.1 | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| @@ -3,7 +3,7 @@ module github.com/bnema/vev | |||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/vev-vt v0.5.0 | 6 | + | github.com/bnema/vev-vt v0.5.1 |
| 7 | github.com/stretchr/testify v1.11.1 | 7 | github.com/stretchr/testify v1.11.1 | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
M go.sum +2 -2 generated file: collapsed
M internal/adapters/config/config.go +8 -0
| @@ -152,6 +152,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 152 | 152 | continue | |
| 153 | 153 | } | |
| 154 | 154 | cfg.Copy.WordSeparators = separators | |
| 155 | + | case key == "copy.reduce-motion": | |
| 156 | + | warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo) | |
| 157 | + | on, ok := parseOnOff(value) | |
| 158 | + | if !ok { | |
| 159 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.reduce-motion %q", value)}) | |
| 160 | + | continue | |
| 161 | + | } | |
| 162 | + | cfg.Copy.ReduceMotion = on | |
| 155 | 163 | case key == "floating.command": | |
| 156 | 164 | warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) | |
| 157 | 165 | command, ok := parseBarCommand(value) | |
| @@ -152,6 +152,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 152 | continue | 152 | continue | ||
| 153 | } | 153 | } | ||
| 154 | cfg.Copy.WordSeparators = separators | 154 | cfg.Copy.WordSeparators = separators | ||
| 155 | + | case key == "copy.reduce-motion": | |||
| 156 | + | warnings = warnDuplicateKey(warnings, seenCopyKeys, key, lineNo) | |||
| 157 | + | on, ok := parseOnOff(value) | |||
| 158 | + | if !ok { | |||
| 159 | + | warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid copy.reduce-motion %q", value)}) | |||
| 160 | + | continue | |||
| 161 | + | } | |||
| 162 | + | cfg.Copy.ReduceMotion = on | |||
| 155 | case key == "floating.command": | 163 | case key == "floating.command": | ||
| 156 | warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) | 164 | warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) | ||
| 157 | command, ok := parseBarCommand(value) | 165 | command, ok := parseBarCommand(value) | ||
A internal/adapters/config/copy_motion_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestParseCopyReduceMotion(t *testing.T) { | |
| 11 | + | for _, tc := range []struct { | |
| 12 | + | name string | |
| 13 | + | input string | |
| 14 | + | want bool | |
| 15 | + | warnings int | |
| 16 | + | }{ | |
| 17 | + | {"default", "", false, 0}, | |
| 18 | + | {"enabled", "copy.reduce-motion = on", true, 0}, | |
| 19 | + | {"disabled", "copy.reduce-motion = off", false, 0}, | |
| 20 | + | {"invalid", "copy.reduce-motion = maybe", false, 1}, | |
| 21 | + | {"duplicate", "copy.reduce-motion = on\ncopy.reduce-motion = off", false, 1}, | |
| 22 | + | } { | |
| 23 | + | t.Run(tc.name, func(t *testing.T) { | |
| 24 | + | cfg, warnings, err := Parse(strings.NewReader(tc.input)) | |
| 25 | + | require.NoError(t, err) | |
| 26 | + | require.Equal(t, tc.want, cfg.Copy.ReduceMotion) | |
| 27 | + | require.Len(t, warnings, tc.warnings) | |
| 28 | + | }) | |
| 29 | + | } | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestParseCopyReduceMotion(t *testing.T) { | |||
| 11 | + | for _, tc := range []struct { | |||
| 12 | + | name string | |||
| 13 | + | input string | |||
| 14 | + | want bool | |||
| 15 | + | warnings int | |||
| 16 | + | }{ | |||
| 17 | + | {"default", "", false, 0}, | |||
| 18 | + | {"enabled", "copy.reduce-motion = on", true, 0}, | |||
| 19 | + | {"disabled", "copy.reduce-motion = off", false, 0}, | |||
| 20 | + | {"invalid", "copy.reduce-motion = maybe", false, 1}, | |||
| 21 | + | {"duplicate", "copy.reduce-motion = on\ncopy.reduce-motion = off", false, 1}, | |||
| 22 | + | } { | |||
| 23 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 24 | + | cfg, warnings, err := Parse(strings.NewReader(tc.input)) | |||
| 25 | + | require.NoError(t, err) | |||
| 26 | + | require.Equal(t, tc.want, cfg.Copy.ReduceMotion) | |||
| 27 | + | require.Len(t, warnings, tc.warnings) | |||
| 28 | + | }) | |||
| 29 | + | } | |||
| 30 | + | } | |||
M internal/domain/config.go +2 -1
| @@ -50,9 +50,10 @@ type BarConfig struct { | |||
| 50 | 50 | Interval time.Duration | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | - | // CopyConfig contains copy-mode text selection settings. | |
| 53 | + | // CopyConfig contains copy-mode selection and mouse motion settings. | |
| 54 | 54 | type CopyConfig struct { | |
| 55 | 55 | WordSeparators string | |
| 56 | + | ReduceMotion bool | |
| 56 | 57 | } | |
| 57 | 58 | ||
| 58 | 59 | // PaletteConfig contains palette placement settings. A false AnchorSet uses the | |
| @@ -50,9 +50,10 @@ type BarConfig struct { | |||||
| 50 | Interval time.Duration | 50 | Interval time.Duration | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | - | // CopyConfig contains copy-mode text selection settings. | 53 | + | // CopyConfig contains copy-mode selection and mouse motion settings. |
| 54 | type CopyConfig struct { | 54 | type CopyConfig struct { | ||
| 55 | WordSeparators string | 55 | WordSeparators string | ||
| 56 | + | ReduceMotion bool | |||
| 56 | } | 57 | } | ||
| 57 | 58 | ||||
| 58 | // PaletteConfig contains palette placement settings. A false AnchorSet uses the | 59 | // PaletteConfig contains palette placement settings. A false AnchorSet uses the | ||
M internal/usecase/copy/mode.go +29 -1
| @@ -322,6 +322,25 @@ func (m *Mode) MoveRows(delta int) bool { | |||
| 322 | 322 | } | |
| 323 | 323 | return changed | |
| 324 | 324 | } | |
| 325 | + | ||
| 326 | + | // ScrollRows moves the viewport and cursor together for mouse-wheel input. | |
| 327 | + | // Unlike keyboard navigation, reversing direction scrolls immediately rather | |
| 328 | + | // than first walking the cursor to the opposite edge of the viewport. | |
| 329 | + | func (m *Mode) ScrollRows(delta int) bool { | |
| 330 | + | if m == nil || m.document == nil || m.document.Len() == 0 || delta == 0 { | |
| 331 | + | return false | |
| 332 | + | } | |
| 333 | + | d := m.document | |
| 334 | + | oldTop := m.ViewportTop | |
| 335 | + | maxTop := max(d.Len()-max(d.Height(), 1), 0) | |
| 336 | + | top := oldTop + min(max(delta, -oldTop), maxTop-oldTop) | |
| 337 | + | row := m.navigator.Pos.Row | |
| 338 | + | row += min(max(delta, -row), d.Len()-1-row) | |
| 339 | + | changed := m.move(func(d *Document) bool { return m.navigator.moveVertical(d, row) }, false) | |
| 340 | + | m.ViewportTop = top | |
| 341 | + | return changed || oldTop != top | |
| 342 | + | } | |
| 343 | + | ||
| 325 | 344 | func (m *Mode) Page(pages int) bool { | |
| 326 | 345 | if m == nil || m.document == nil { | |
| 327 | 346 | return false | |
| @@ -539,6 +558,15 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||
| 539 | 558 | // The callback borrows a reusable semantic row only until it returns. Rendering | |
| 540 | 559 | // directly into a compositor avoids building and then decoding a compact frame. | |
| 541 | 560 | func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.Style) { | |
| 561 | + | if m == nil || m.document == nil { | |
| 562 | + | return | |
| 563 | + | } | |
| 564 | + | m.RenderRowsRange(0, m.document.Height(), paint, styles...) | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | // RenderRowsRange paints viewport rows in [start, end), then the status row. | |
| 568 | + | // Row coordinates remain viewport-relative and the callback borrows each row. | |
| 569 | + | func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell), styles ...renderer.Style) { | |
| 542 | 570 | if m == nil || m.document == nil { | |
| 543 | 571 | return | |
| 544 | 572 | } | |
| @@ -547,7 +575,7 @@ func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.S | |||
| 547 | 575 | selection, hasSelection := optionalStyle(styles, 1) | |
| 548 | 576 | cursor, cursorValid := d.Normalize(m.navigator.Pos) | |
| 549 | 577 | row := make([]renderer.Cell, d.Width()) | |
| 550 | - | for y := range d.Height() { | |
| 578 | + | for y := max(start, 0); y < min(end, d.Height()); y++ { | |
| 551 | 579 | src := m.ViewportTop + y | |
| 552 | 580 | for x := range row { | |
| 553 | 581 | row[x] = renderer.BlankCell() | |
| @@ -322,6 +322,25 @@ func (m *Mode) MoveRows(delta int) bool { | |||||
| 322 | } | 322 | } | ||
| 323 | return changed | 323 | return changed | ||
| 324 | } | 324 | } | ||
| 325 | + | ||||
| 326 | + | // ScrollRows moves the viewport and cursor together for mouse-wheel input. | |||
| 327 | + | // Unlike keyboard navigation, reversing direction scrolls immediately rather | |||
| 328 | + | // than first walking the cursor to the opposite edge of the viewport. | |||
| 329 | + | func (m *Mode) ScrollRows(delta int) bool { | |||
| 330 | + | if m == nil || m.document == nil || m.document.Len() == 0 || delta == 0 { | |||
| 331 | + | return false | |||
| 332 | + | } | |||
| 333 | + | d := m.document | |||
| 334 | + | oldTop := m.ViewportTop | |||
| 335 | + | maxTop := max(d.Len()-max(d.Height(), 1), 0) | |||
| 336 | + | top := oldTop + min(max(delta, -oldTop), maxTop-oldTop) | |||
| 337 | + | row := m.navigator.Pos.Row | |||
| 338 | + | row += min(max(delta, -row), d.Len()-1-row) | |||
| 339 | + | changed := m.move(func(d *Document) bool { return m.navigator.moveVertical(d, row) }, false) | |||
| 340 | + | m.ViewportTop = top | |||
| 341 | + | return changed || oldTop != top | |||
| 342 | + | } | |||
| 343 | + | ||||
| 325 | func (m *Mode) Page(pages int) bool { | 344 | func (m *Mode) Page(pages int) bool { | ||
| 326 | if m == nil || m.document == nil { | 345 | if m == nil || m.document == nil { | ||
| 327 | return false | 346 | return false | ||
| @@ -539,6 +558,15 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame { | |||||
| 539 | // The callback borrows a reusable semantic row only until it returns. Rendering | 558 | // The callback borrows a reusable semantic row only until it returns. Rendering | ||
| 540 | // directly into a compositor avoids building and then decoding a compact frame. | 559 | // directly into a compositor avoids building and then decoding a compact frame. | ||
| 541 | func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.Style) { | 560 | func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.Style) { | ||
| 561 | + | if m == nil || m.document == nil { | |||
| 562 | + | return | |||
| 563 | + | } | |||
| 564 | + | m.RenderRowsRange(0, m.document.Height(), paint, styles...) | |||
| 565 | + | } | |||
| 566 | + | ||||
| 567 | + | // RenderRowsRange paints viewport rows in [start, end), then the status row. | |||
| 568 | + | // Row coordinates remain viewport-relative and the callback borrows each row. | |||
| 569 | + | func (m *Mode) RenderRowsRange(start, end int, paint func(int, []renderer.Cell), styles ...renderer.Style) { | |||
| 542 | if m == nil || m.document == nil { | 570 | if m == nil || m.document == nil { | ||
| 543 | return | 571 | return | ||
| 544 | } | 572 | } | ||
| @@ -547,7 +575,7 @@ func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.S | |||||
| 547 | selection, hasSelection := optionalStyle(styles, 1) | 575 | selection, hasSelection := optionalStyle(styles, 1) | ||
| 548 | cursor, cursorValid := d.Normalize(m.navigator.Pos) | 576 | cursor, cursorValid := d.Normalize(m.navigator.Pos) | ||
| 549 | row := make([]renderer.Cell, d.Width()) | 577 | row := make([]renderer.Cell, d.Width()) | ||
| 550 | - | for y := range d.Height() { | 578 | + | for y := max(start, 0); y < min(end, d.Height()); y++ { |
| 551 | src := m.ViewportTop + y | 579 | src := m.ViewportTop + y | ||
| 552 | for x := range row { | 580 | for x := range row { | ||
| 553 | row[x] = renderer.BlankCell() | 581 | row[x] = renderer.BlankCell() | ||
A internal/usecase/copy/render_rows_range_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestRenderRowsRangeMatchesFullRender(t *testing.T) { | |
| 12 | + | rows := [][]renderer.Cell{ | |
| 13 | + | {{Rune: 'a'}, {Rune: 'b'}, {Rune: 'c'}}, | |
| 14 | + | {{Rune: 'd'}, {Rune: 'e'}}, | |
| 15 | + | {{Rune: 'f'}}, | |
| 16 | + | } | |
| 17 | + | mode := NewMode(NewDocument(NewSnapshotFromRows(rows, 3, 5), "")) | |
| 18 | + | mode.Search("a") | |
| 19 | + | mode.StartCharacterSelection(Pos{}) | |
| 20 | + | mode.ExtendCharacterSelection(Pos{Row: 1, Col: 1}) | |
| 21 | + | full := mode.Render() | |
| 22 | + | for _, bounds := range [][2]int{{0, 5}, {1, 2}, {-3, 99}, {4, 2}, {9, 10}} { | |
| 23 | + | t.Run(fmt.Sprint(bounds), func(t *testing.T) { | |
| 24 | + | var painted []int | |
| 25 | + | mode.RenderRowsRange(bounds[0], bounds[1], func(y int, row []renderer.Cell) { | |
| 26 | + | painted = append(painted, y) | |
| 27 | + | require.Equal(t, full.Row(y), row) | |
| 28 | + | }) | |
| 29 | + | var want []int | |
| 30 | + | for y := max(bounds[0], 0); y < min(bounds[1], 5); y++ { | |
| 31 | + | want = append(want, y) | |
| 32 | + | } | |
| 33 | + | want = append(want, 5) | |
| 34 | + | require.Equal(t, want, painted) | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestRenderRowsRangeMatchesFullRender(t *testing.T) { | |||
| 12 | + | rows := [][]renderer.Cell{ | |||
| 13 | + | {{Rune: 'a'}, {Rune: 'b'}, {Rune: 'c'}}, | |||
| 14 | + | {{Rune: 'd'}, {Rune: 'e'}}, | |||
| 15 | + | {{Rune: 'f'}}, | |||
| 16 | + | } | |||
| 17 | + | mode := NewMode(NewDocument(NewSnapshotFromRows(rows, 3, 5), "")) | |||
| 18 | + | mode.Search("a") | |||
| 19 | + | mode.StartCharacterSelection(Pos{}) | |||
| 20 | + | mode.ExtendCharacterSelection(Pos{Row: 1, Col: 1}) | |||
| 21 | + | full := mode.Render() | |||
| 22 | + | for _, bounds := range [][2]int{{0, 5}, {1, 2}, {-3, 99}, {4, 2}, {9, 10}} { | |||
| 23 | + | t.Run(fmt.Sprint(bounds), func(t *testing.T) { | |||
| 24 | + | var painted []int | |||
| 25 | + | mode.RenderRowsRange(bounds[0], bounds[1], func(y int, row []renderer.Cell) { | |||
| 26 | + | painted = append(painted, y) | |||
| 27 | + | require.Equal(t, full.Row(y), row) | |||
| 28 | + | }) | |||
| 29 | + | var want []int | |||
| 30 | + | for y := max(bounds[0], 0); y < min(bounds[1], 5); y++ { | |||
| 31 | + | want = append(want, y) | |||
| 32 | + | } | |||
| 33 | + | want = append(want, 5) | |||
| 34 | + | require.Equal(t, want, painted) | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||
A internal/usecase/copy/scroll_rows_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestScrollRowsMovesViewportImmediately(t *testing.T) { | |
| 12 | + | rows := make([][]renderer.Cell, 100) | |
| 13 | + | for i := range rows { | |
| 14 | + | rows[i] = []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}} | |
| 15 | + | } | |
| 16 | + | for _, tc := range []struct { | |
| 17 | + | name string | |
| 18 | + | deltas []int | |
| 19 | + | want []int | |
| 20 | + | }{ | |
| 21 | + | {"reverse", []int{-3, 3, -3, 3}, []int{87, 90, 87, 90}}, | |
| 22 | + | {"clamp", []int{math.MinInt, -1, math.MaxInt}, []int{0, 0, 90}}, | |
| 23 | + | } { | |
| 24 | + | t.Run(tc.name, func(t *testing.T) { | |
| 25 | + | mode := NewMode(NewDocument(NewSnapshotFromRows(rows, 1, 10), "")) | |
| 26 | + | for i, delta := range tc.deltas { | |
| 27 | + | mode.ScrollRows(delta) | |
| 28 | + | require.Equal(t, tc.want[i], mode.ViewportTop) | |
| 29 | + | require.GreaterOrEqual(t, mode.Cursor().Row, mode.ViewportTop) | |
| 30 | + | require.Less(t, mode.Cursor().Row, mode.ViewportTop+10) | |
| 31 | + | } | |
| 32 | + | require.True(t, mode.AtBottom()) | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | renderer "github.com/bnema/vev-vt/ansi" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestScrollRowsMovesViewportImmediately(t *testing.T) { | |||
| 12 | + | rows := make([][]renderer.Cell, 100) | |||
| 13 | + | for i := range rows { | |||
| 14 | + | rows[i] = []renderer.Cell{{Rune: 'x', Style: renderer.DefaultStyle()}} | |||
| 15 | + | } | |||
| 16 | + | for _, tc := range []struct { | |||
| 17 | + | name string | |||
| 18 | + | deltas []int | |||
| 19 | + | want []int | |||
| 20 | + | }{ | |||
| 21 | + | {"reverse", []int{-3, 3, -3, 3}, []int{87, 90, 87, 90}}, | |||
| 22 | + | {"clamp", []int{math.MinInt, -1, math.MaxInt}, []int{0, 0, 90}}, | |||
| 23 | + | } { | |||
| 24 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 25 | + | mode := NewMode(NewDocument(NewSnapshotFromRows(rows, 1, 10), "")) | |||
| 26 | + | for i, delta := range tc.deltas { | |||
| 27 | + | mode.ScrollRows(delta) | |||
| 28 | + | require.Equal(t, tc.want[i], mode.ViewportTop) | |||
| 29 | + | require.GreaterOrEqual(t, mode.Cursor().Row, mode.ViewportTop) | |||
| 30 | + | require.Less(t, mode.Cursor().Row, mode.ViewportTop+10) | |||
| 31 | + | } | |||
| 32 | + | require.True(t, mode.AtBottom()) | |||
| 33 | + | }) | |||
| 34 | + | } | |||
| 35 | + | } | |||
M internal/usecase/daemon/copy_mouse_input.go +5 -2
| @@ -28,10 +28,13 @@ func (d *Daemon) handleCopyMouse(sess *session, ac *attachedClient, tb *tab, ev | |||
| 28 | 28 | } | |
| 29 | 29 | switch ev.Button { | |
| 30 | 30 | case mouse.WheelUp: | |
| 31 | - | d.copyWheel(sess, ac, -3) | |
| 31 | + | d.smoothCopyWheel(sess, ac, -3) | |
| 32 | 32 | case mouse.WheelDown: | |
| 33 | - | d.copyWheel(sess, ac, 3) | |
| 33 | + | d.smoothCopyWheel(sess, ac, 3) | |
| 34 | 34 | case mouse.Left: | |
| 35 | + | rt.copyMu.Lock() | |
| 36 | + | rt.stopCopyScrollLocked() | |
| 37 | + | rt.copyMu.Unlock() | |
| 35 | 38 | d.handleActiveCopyMouse(sess, ac, tb, ev) | |
| 36 | 39 | } | |
| 37 | 40 | return true | |
| @@ -28,10 +28,13 @@ func (d *Daemon) handleCopyMouse(sess *session, ac *attachedClient, tb *tab, ev | |||||
| 28 | } | 28 | } | ||
| 29 | switch ev.Button { | 29 | switch ev.Button { | ||
| 30 | case mouse.WheelUp: | 30 | case mouse.WheelUp: | ||
| 31 | - | d.copyWheel(sess, ac, -3) | 31 | + | d.smoothCopyWheel(sess, ac, -3) |
| 32 | case mouse.WheelDown: | 32 | case mouse.WheelDown: | ||
| 33 | - | d.copyWheel(sess, ac, 3) | 33 | + | d.smoothCopyWheel(sess, ac, 3) |
| 34 | case mouse.Left: | 34 | case mouse.Left: | ||
| 35 | + | rt.copyMu.Lock() | |||
| 36 | + | rt.stopCopyScrollLocked() | |||
| 37 | + | rt.copyMu.Unlock() | |||
| 35 | d.handleActiveCopyMouse(sess, ac, tb, ev) | 38 | d.handleActiveCopyMouse(sess, ac, tb, ev) | ||
| 36 | } | 39 | } | ||
| 37 | return true | 40 | return true | ||
A internal/usecase/daemon/copy_scroll_animation.go +120 -0
| @@ -0,0 +1,120 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | copyScrollFrame = 16 * time.Millisecond | |
| 11 | + | copyScrollTail = 120 * time.Millisecond | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // copyMu owns the animation and its single cancellable overlay-input timer. | |
| 15 | + | // Easing distributes requested rows over frames; it never invents extra travel. | |
| 16 | + | type copyScrollAnimation struct { | |
| 17 | + | remaining int | |
| 18 | + | lastInput time.Time | |
| 19 | + | timer pendingByteTimer | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (rt *overlayRuntime) stopCopyScrollLocked() { | |
| 23 | + | rt.copyScroll.timer.stop() | |
| 24 | + | rt.copyScroll = copyScrollAnimation{} | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (d *Daemon) smoothCopyWheel(sess *session, ac *attachedClient, delta int) { | |
| 28 | + | if delta == 0 { | |
| 29 | + | return | |
| 30 | + | } | |
| 31 | + | rt := ac.overlays | |
| 32 | + | if d.clock == nil || d.currentCopyConfig().ReduceMotion { | |
| 33 | + | rt.copyMu.Lock() | |
| 34 | + | rt.stopCopyScrollLocked() | |
| 35 | + | rt.copyMu.Unlock() | |
| 36 | + | d.copyWheel(sess, ac, delta) | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | rt.copyMu.Lock() | |
| 40 | + | if rt.copyMode == nil || rt.copyDocument == nil || rt.copyPane == nil { | |
| 41 | + | rt.copyMu.Unlock() | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | // Selection stays exact and immediate. Reversal cancels the old tail so | |
| 45 | + | // the next frame always follows the user's latest direction. | |
| 46 | + | if rt.copyMode.Selection().Enabled || rt.copyDocument.Len() <= rt.copyDocument.Height() { | |
| 47 | + | rt.stopCopyScrollLocked() | |
| 48 | + | rt.copyMu.Unlock() | |
| 49 | + | d.copyWheel(sess, ac, delta) | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | if rt.copyScroll.remaining != 0 && (rt.copyScroll.remaining < 0) != (delta < 0) { | |
| 53 | + | rt.stopCopyScrollLocked() | |
| 54 | + | } | |
| 55 | + | motion := &rt.copyScroll | |
| 56 | + | limit := rt.copyDocument.Len() | |
| 57 | + | motion.remaining += min(max(delta, -limit-motion.remaining), limit-motion.remaining) | |
| 58 | + | motion.lastInput = d.clock.Now() | |
| 59 | + | changed, exit := false, false | |
| 60 | + | if motion.timer.timer == nil { | |
| 61 | + | changed, exit = d.advanceCopyScrollLocked(sess, ac) | |
| 62 | + | } | |
| 63 | + | rt.copyMu.Unlock() | |
| 64 | + | if changed || exit { | |
| 65 | + | d.invalidateRender(sess, ac, exit, "copy-scroll-animation") | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (bool, bool) { | |
| 70 | + | rt := ac.overlays | |
| 71 | + | motion := &rt.copyScroll | |
| 72 | + | remaining := motion.remaining | |
| 73 | + | if remaining == 0 { | |
| 74 | + | return false, false | |
| 75 | + | } | |
| 76 | + | magnitude := remaining | |
| 77 | + | if magnitude < 0 { | |
| 78 | + | magnitude = -magnitude | |
| 79 | + | } | |
| 80 | + | step := (magnitude + 3) / 4 | |
| 81 | + | if d.clock.Now().Sub(motion.lastInput) >= copyScrollTail { | |
| 82 | + | step = magnitude | |
| 83 | + | } | |
| 84 | + | if remaining < 0 { | |
| 85 | + | step = -step | |
| 86 | + | } | |
| 87 | + | motion.remaining -= step | |
| 88 | + | changed, exit := rt.moveCopyWheelLocked(step) | |
| 89 | + | if !changed || exit { | |
| 90 | + | rt.stopCopyScrollLocked() | |
| 91 | + | return changed, exit | |
| 92 | + | } | |
| 93 | + | if motion.remaining != 0 { | |
| 94 | + | mode := rt.copyMode | |
| 95 | + | motion.timer.retain(d.clock, copyScrollFrame, func(timer ports.Timer) { | |
| 96 | + | // Check presentation ownership before entering the copy lock. A | |
| 97 | + | // callback never locks another overlay or a session under copyMu. | |
| 98 | + | visible := ac.currentSession() == sess && !rt.promptActive() && !rt.paletteActive() && | |
| 99 | + | !rt.pickerActive() && !rt.noticesActive() && !rt.resizeModeActive() | |
| 100 | + | rt.copyMu.Lock() | |
| 101 | + | if rt.copyScroll.timer.timer != timer { | |
| 102 | + | rt.copyMu.Unlock() | |
| 103 | + | return | |
| 104 | + | } | |
| 105 | + | rt.copyScroll.timer.timer = nil | |
| 106 | + | rt.copyScroll.timer.done = nil | |
| 107 | + | if !visible || rt.copyMode != mode || rt.copySearch != nil { | |
| 108 | + | rt.stopCopyScrollLocked() | |
| 109 | + | rt.copyMu.Unlock() | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | changed, exit := d.advanceCopyScrollLocked(sess, ac) | |
| 113 | + | rt.copyMu.Unlock() | |
| 114 | + | if changed || exit { | |
| 115 | + | d.invalidateRender(sess, ac, exit, "copy-scroll-animation") | |
| 116 | + | } | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | return changed, exit | |
| 120 | + | } | |
| @@ -0,0 +1,120 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const ( | |||
| 10 | + | copyScrollFrame = 16 * time.Millisecond | |||
| 11 | + | copyScrollTail = 120 * time.Millisecond | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // copyMu owns the animation and its single cancellable overlay-input timer. | |||
| 15 | + | // Easing distributes requested rows over frames; it never invents extra travel. | |||
| 16 | + | type copyScrollAnimation struct { | |||
| 17 | + | remaining int | |||
| 18 | + | lastInput time.Time | |||
| 19 | + | timer pendingByteTimer | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (rt *overlayRuntime) stopCopyScrollLocked() { | |||
| 23 | + | rt.copyScroll.timer.stop() | |||
| 24 | + | rt.copyScroll = copyScrollAnimation{} | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (d *Daemon) smoothCopyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 28 | + | if delta == 0 { | |||
| 29 | + | return | |||
| 30 | + | } | |||
| 31 | + | rt := ac.overlays | |||
| 32 | + | if d.clock == nil || d.currentCopyConfig().ReduceMotion { | |||
| 33 | + | rt.copyMu.Lock() | |||
| 34 | + | rt.stopCopyScrollLocked() | |||
| 35 | + | rt.copyMu.Unlock() | |||
| 36 | + | d.copyWheel(sess, ac, delta) | |||
| 37 | + | return | |||
| 38 | + | } | |||
| 39 | + | rt.copyMu.Lock() | |||
| 40 | + | if rt.copyMode == nil || rt.copyDocument == nil || rt.copyPane == nil { | |||
| 41 | + | rt.copyMu.Unlock() | |||
| 42 | + | return | |||
| 43 | + | } | |||
| 44 | + | // Selection stays exact and immediate. Reversal cancels the old tail so | |||
| 45 | + | // the next frame always follows the user's latest direction. | |||
| 46 | + | if rt.copyMode.Selection().Enabled || rt.copyDocument.Len() <= rt.copyDocument.Height() { | |||
| 47 | + | rt.stopCopyScrollLocked() | |||
| 48 | + | rt.copyMu.Unlock() | |||
| 49 | + | d.copyWheel(sess, ac, delta) | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 52 | + | if rt.copyScroll.remaining != 0 && (rt.copyScroll.remaining < 0) != (delta < 0) { | |||
| 53 | + | rt.stopCopyScrollLocked() | |||
| 54 | + | } | |||
| 55 | + | motion := &rt.copyScroll | |||
| 56 | + | limit := rt.copyDocument.Len() | |||
| 57 | + | motion.remaining += min(max(delta, -limit-motion.remaining), limit-motion.remaining) | |||
| 58 | + | motion.lastInput = d.clock.Now() | |||
| 59 | + | changed, exit := false, false | |||
| 60 | + | if motion.timer.timer == nil { | |||
| 61 | + | changed, exit = d.advanceCopyScrollLocked(sess, ac) | |||
| 62 | + | } | |||
| 63 | + | rt.copyMu.Unlock() | |||
| 64 | + | if changed || exit { | |||
| 65 | + | d.invalidateRender(sess, ac, exit, "copy-scroll-animation") | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func (d *Daemon) advanceCopyScrollLocked(sess *session, ac *attachedClient) (bool, bool) { | |||
| 70 | + | rt := ac.overlays | |||
| 71 | + | motion := &rt.copyScroll | |||
| 72 | + | remaining := motion.remaining | |||
| 73 | + | if remaining == 0 { | |||
| 74 | + | return false, false | |||
| 75 | + | } | |||
| 76 | + | magnitude := remaining | |||
| 77 | + | if magnitude < 0 { | |||
| 78 | + | magnitude = -magnitude | |||
| 79 | + | } | |||
| 80 | + | step := (magnitude + 3) / 4 | |||
| 81 | + | if d.clock.Now().Sub(motion.lastInput) >= copyScrollTail { | |||
| 82 | + | step = magnitude | |||
| 83 | + | } | |||
| 84 | + | if remaining < 0 { | |||
| 85 | + | step = -step | |||
| 86 | + | } | |||
| 87 | + | motion.remaining -= step | |||
| 88 | + | changed, exit := rt.moveCopyWheelLocked(step) | |||
| 89 | + | if !changed || exit { | |||
| 90 | + | rt.stopCopyScrollLocked() | |||
| 91 | + | return changed, exit | |||
| 92 | + | } | |||
| 93 | + | if motion.remaining != 0 { | |||
| 94 | + | mode := rt.copyMode | |||
| 95 | + | motion.timer.retain(d.clock, copyScrollFrame, func(timer ports.Timer) { | |||
| 96 | + | // Check presentation ownership before entering the copy lock. A | |||
| 97 | + | // callback never locks another overlay or a session under copyMu. | |||
| 98 | + | visible := ac.currentSession() == sess && !rt.promptActive() && !rt.paletteActive() && | |||
| 99 | + | !rt.pickerActive() && !rt.noticesActive() && !rt.resizeModeActive() | |||
| 100 | + | rt.copyMu.Lock() | |||
| 101 | + | if rt.copyScroll.timer.timer != timer { | |||
| 102 | + | rt.copyMu.Unlock() | |||
| 103 | + | return | |||
| 104 | + | } | |||
| 105 | + | rt.copyScroll.timer.timer = nil | |||
| 106 | + | rt.copyScroll.timer.done = nil | |||
| 107 | + | if !visible || rt.copyMode != mode || rt.copySearch != nil { | |||
| 108 | + | rt.stopCopyScrollLocked() | |||
| 109 | + | rt.copyMu.Unlock() | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | changed, exit := d.advanceCopyScrollLocked(sess, ac) | |||
| 113 | + | rt.copyMu.Unlock() | |||
| 114 | + | if changed || exit { | |||
| 115 | + | d.invalidateRender(sess, ac, exit, "copy-scroll-animation") | |||
| 116 | + | } | |||
| 117 | + | }) | |||
| 118 | + | } | |||
| 119 | + | return changed, exit | |||
| 120 | + | } | |||
A internal/usecase/daemon/copy_scroll_animation_test.go +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestCopyScrollAnimationEasesAndPreservesDistance(t *testing.T) { | |
| 12 | + | for _, burst := range []int{1, 6} { | |
| 13 | + | t.Run(map[int]string{1: "notch", 6: "burst"}[burst], func(t *testing.T) { | |
| 14 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200}) | |
| 15 | + | f.d.enterCopyMode(f.sess, f.ac) | |
| 16 | + | f.d.copyWheel(f.sess, f.ac, -50) | |
| 17 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 18 | + | clock := newCoordinatorMockClock(t, 16) | |
| 19 | + | f.d.clock = clock.clock | |
| 20 | + | emitted := make(chan struct{}, 16) | |
| 21 | + | f.ac.renderStages.emit = func() { emitted <- struct{}{} } | |
| 22 | + | await := func() { | |
| 23 | + | select { | |
| 24 | + | case <-emitted: | |
| 25 | + | case <-time.After(time.Second): | |
| 26 | + | t.Fatal("scroll frame not emitted") | |
| 27 | + | } | |
| 28 | + | // Wait for publication and capture the ACK coordinates under | |
| 29 | + | // the same lock that protects the in-flight output transaction. | |
| 30 | + | f.ac.sendMu.Lock() | |
| 31 | + | epoch, state := f.ac.output.currentEpoch(), f.ac.output.next | |
| 32 | + | f.ac.sendMu.Unlock() | |
| 33 | + | f.ac.ackOutputState(epoch, state) | |
| 34 | + | } | |
| 35 | + | rt := f.ac.overlays | |
| 36 | + | start := rt.copyMode.ViewportTop | |
| 37 | + | for range burst { | |
| 38 | + | f.d.smoothCopyWheel(f.sess, f.ac, -3) | |
| 39 | + | } | |
| 40 | + | await() | |
| 41 | + | require.Equal(t, start-1, rt.copyMode.ViewportTop, "first row responds immediately; bursts wait for the next frame") | |
| 42 | + | for frame := 0; ; frame++ { | |
| 43 | + | rt.copyMu.Lock() | |
| 44 | + | pending := rt.copyScroll.remaining | |
| 45 | + | rt.copyMu.Unlock() | |
| 46 | + | if pending == 0 { | |
| 47 | + | break | |
| 48 | + | } | |
| 49 | + | require.Less(t, frame, 16, "animation must settle") | |
| 50 | + | timer := awaitCoordinatorScheduledTimer(t, clock) | |
| 51 | + | require.Equal(t, copyScrollFrame, timer.duration) | |
| 52 | + | timer.ch <- time.Time{} | |
| 53 | + | await() | |
| 54 | + | } | |
| 55 | + | require.Equal(t, start-3*burst, rt.copyMode.ViewportTop) | |
| 56 | + | }) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestCopyScrollAnimationReversalAndCancellation(t *testing.T) { | |
| 61 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200}) | |
| 62 | + | f.d.enterCopyMode(f.sess, f.ac) | |
| 63 | + | f.d.copyWheel(f.sess, f.ac, -50) | |
| 64 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 65 | + | clock := newCoordinatorMockClock(t, 8) | |
| 66 | + | f.d.clock = clock.clock | |
| 67 | + | rt := f.ac.overlays | |
| 68 | + | start := rt.copyMode.ViewportTop | |
| 69 | + | f.d.smoothCopyWheel(f.sess, f.ac, -3) | |
| 70 | + | old := awaitCoordinatorScheduledTimer(t, clock) | |
| 71 | + | f.d.smoothCopyWheel(f.sess, f.ac, 3) | |
| 72 | + | require.Equal(t, start, rt.copyMode.ViewportTop, "reversal responds without finishing the old tail") | |
| 73 | + | select { | |
| 74 | + | case <-old.stopped: | |
| 75 | + | default: | |
| 76 | + | t.Fatal("old direction timer not canceled") | |
| 77 | + | } | |
| 78 | + | current := awaitCoordinatorScheduledTimer(t, clock) | |
| 79 | + | rt.copyMu.Lock() | |
| 80 | + | rt.invalidateCopyPointerLocked(true) | |
| 81 | + | require.Zero(t, rt.copyScroll.remaining) | |
| 82 | + | require.Nil(t, rt.copyScroll.timer.timer) | |
| 83 | + | rt.copyMu.Unlock() | |
| 84 | + | select { | |
| 85 | + | case <-current.stopped: | |
| 86 | + | default: | |
| 87 | + | t.Fatal("pointer lifecycle must stop inertia") | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestCopyScrollAnimationTailDeadline(t *testing.T) { | |
| 92 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200}) | |
| 93 | + | f.d.enterCopyMode(f.sess, f.ac) | |
| 94 | + | clock := newCoordinatorMockClock(t, 8) | |
| 95 | + | f.d.clock = clock.clock | |
| 96 | + | rt := f.ac.overlays | |
| 97 | + | rt.copyMu.Lock() | |
| 98 | + | start := rt.copyMode.ViewportTop | |
| 99 | + | rt.copyScroll.remaining = -60 | |
| 100 | + | rt.copyScroll.lastInput = time.Time{}.Add(-copyScrollTail) | |
| 101 | + | changed, exit := f.d.advanceCopyScrollLocked(f.sess, f.ac) | |
| 102 | + | require.True(t, changed) | |
| 103 | + | require.False(t, exit) | |
| 104 | + | require.Equal(t, start-60, rt.copyMode.ViewportTop) | |
| 105 | + | require.Zero(t, rt.copyScroll.remaining) | |
| 106 | + | require.Nil(t, rt.copyScroll.timer.timer) | |
| 107 | + | rt.copyMu.Unlock() | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestCopyScrollReducedMotionAndKeyboardCancellation(t *testing.T) { | |
| 111 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200}) | |
| 112 | + | f.d.enterCopyMode(f.sess, f.ac) | |
| 113 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 114 | + | clock := newCoordinatorMockClock(t, 8) | |
| 115 | + | f.d.clock = clock.clock | |
| 116 | + | f.d.copyConfig.Store(&domain.CopyConfig{ReduceMotion: true}) | |
| 117 | + | rt := f.ac.overlays | |
| 118 | + | start := rt.copyMode.ViewportTop | |
| 119 | + | f.d.smoothCopyWheel(f.sess, f.ac, -3) | |
| 120 | + | require.Equal(t, start-3, rt.copyMode.ViewportTop) | |
| 121 | + | require.Empty(t, clock.timers, "reduced motion must not arm an animation") | |
| 122 | + | f.d.copyConfig.Store(&domain.CopyConfig{}) | |
| 123 | + | f.d.smoothCopyWheel(f.sess, f.ac, -3) | |
| 124 | + | timer := awaitCoordinatorScheduledTimer(t, clock) | |
| 125 | + | require.True(t, rt.HandleInput(f.d, []byte("k"))) | |
| 126 | + | rt.copyMu.Lock() | |
| 127 | + | require.Zero(t, rt.copyScroll.remaining) | |
| 128 | + | require.Nil(t, rt.copyScroll.timer.timer) | |
| 129 | + | rt.copyMu.Unlock() | |
| 130 | + | select { | |
| 131 | + | case <-timer.stopped: | |
| 132 | + | default: | |
| 133 | + | t.Fatal("keyboard navigation must cancel inertia") | |
| 134 | + | } | |
| 135 | + | } | |
| @@ -0,0 +1,135 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestCopyScrollAnimationEasesAndPreservesDistance(t *testing.T) { | |||
| 12 | + | for _, burst := range []int{1, 6} { | |||
| 13 | + | t.Run(map[int]string{1: "notch", 6: "burst"}[burst], func(t *testing.T) { | |||
| 14 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200}) | |||
| 15 | + | f.d.enterCopyMode(f.sess, f.ac) | |||
| 16 | + | f.d.copyWheel(f.sess, f.ac, -50) | |||
| 17 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |||
| 18 | + | clock := newCoordinatorMockClock(t, 16) | |||
| 19 | + | f.d.clock = clock.clock | |||
| 20 | + | emitted := make(chan struct{}, 16) | |||
| 21 | + | f.ac.renderStages.emit = func() { emitted <- struct{}{} } | |||
| 22 | + | await := func() { | |||
| 23 | + | select { | |||
| 24 | + | case <-emitted: | |||
| 25 | + | case <-time.After(time.Second): | |||
| 26 | + | t.Fatal("scroll frame not emitted") | |||
| 27 | + | } | |||
| 28 | + | // Wait for publication and capture the ACK coordinates under | |||
| 29 | + | // the same lock that protects the in-flight output transaction. | |||
| 30 | + | f.ac.sendMu.Lock() | |||
| 31 | + | epoch, state := f.ac.output.currentEpoch(), f.ac.output.next | |||
| 32 | + | f.ac.sendMu.Unlock() | |||
| 33 | + | f.ac.ackOutputState(epoch, state) | |||
| 34 | + | } | |||
| 35 | + | rt := f.ac.overlays | |||
| 36 | + | start := rt.copyMode.ViewportTop | |||
| 37 | + | for range burst { | |||
| 38 | + | f.d.smoothCopyWheel(f.sess, f.ac, -3) | |||
| 39 | + | } | |||
| 40 | + | await() | |||
| 41 | + | require.Equal(t, start-1, rt.copyMode.ViewportTop, "first row responds immediately; bursts wait for the next frame") | |||
| 42 | + | for frame := 0; ; frame++ { | |||
| 43 | + | rt.copyMu.Lock() | |||
| 44 | + | pending := rt.copyScroll.remaining | |||
| 45 | + | rt.copyMu.Unlock() | |||
| 46 | + | if pending == 0 { | |||
| 47 | + | break | |||
| 48 | + | } | |||
| 49 | + | require.Less(t, frame, 16, "animation must settle") | |||
| 50 | + | timer := awaitCoordinatorScheduledTimer(t, clock) | |||
| 51 | + | require.Equal(t, copyScrollFrame, timer.duration) | |||
| 52 | + | timer.ch <- time.Time{} | |||
| 53 | + | await() | |||
| 54 | + | } | |||
| 55 | + | require.Equal(t, start-3*burst, rt.copyMode.ViewportTop) | |||
| 56 | + | }) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestCopyScrollAnimationReversalAndCancellation(t *testing.T) { | |||
| 61 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200}) | |||
| 62 | + | f.d.enterCopyMode(f.sess, f.ac) | |||
| 63 | + | f.d.copyWheel(f.sess, f.ac, -50) | |||
| 64 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |||
| 65 | + | clock := newCoordinatorMockClock(t, 8) | |||
| 66 | + | f.d.clock = clock.clock | |||
| 67 | + | rt := f.ac.overlays | |||
| 68 | + | start := rt.copyMode.ViewportTop | |||
| 69 | + | f.d.smoothCopyWheel(f.sess, f.ac, -3) | |||
| 70 | + | old := awaitCoordinatorScheduledTimer(t, clock) | |||
| 71 | + | f.d.smoothCopyWheel(f.sess, f.ac, 3) | |||
| 72 | + | require.Equal(t, start, rt.copyMode.ViewportTop, "reversal responds without finishing the old tail") | |||
| 73 | + | select { | |||
| 74 | + | case <-old.stopped: | |||
| 75 | + | default: | |||
| 76 | + | t.Fatal("old direction timer not canceled") | |||
| 77 | + | } | |||
| 78 | + | current := awaitCoordinatorScheduledTimer(t, clock) | |||
| 79 | + | rt.copyMu.Lock() | |||
| 80 | + | rt.invalidateCopyPointerLocked(true) | |||
| 81 | + | require.Zero(t, rt.copyScroll.remaining) | |||
| 82 | + | require.Nil(t, rt.copyScroll.timer.timer) | |||
| 83 | + | rt.copyMu.Unlock() | |||
| 84 | + | select { | |||
| 85 | + | case <-current.stopped: | |||
| 86 | + | default: | |||
| 87 | + | t.Fatal("pointer lifecycle must stop inertia") | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestCopyScrollAnimationTailDeadline(t *testing.T) { | |||
| 92 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200}) | |||
| 93 | + | f.d.enterCopyMode(f.sess, f.ac) | |||
| 94 | + | clock := newCoordinatorMockClock(t, 8) | |||
| 95 | + | f.d.clock = clock.clock | |||
| 96 | + | rt := f.ac.overlays | |||
| 97 | + | rt.copyMu.Lock() | |||
| 98 | + | start := rt.copyMode.ViewportTop | |||
| 99 | + | rt.copyScroll.remaining = -60 | |||
| 100 | + | rt.copyScroll.lastInput = time.Time{}.Add(-copyScrollTail) | |||
| 101 | + | changed, exit := f.d.advanceCopyScrollLocked(f.sess, f.ac) | |||
| 102 | + | require.True(t, changed) | |||
| 103 | + | require.False(t, exit) | |||
| 104 | + | require.Equal(t, start-60, rt.copyMode.ViewportTop) | |||
| 105 | + | require.Zero(t, rt.copyScroll.remaining) | |||
| 106 | + | require.Nil(t, rt.copyScroll.timer.timer) | |||
| 107 | + | rt.copyMu.Unlock() | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func TestCopyScrollReducedMotionAndKeyboardCancellation(t *testing.T) { | |||
| 111 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200}) | |||
| 112 | + | f.d.enterCopyMode(f.sess, f.ac) | |||
| 113 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |||
| 114 | + | clock := newCoordinatorMockClock(t, 8) | |||
| 115 | + | f.d.clock = clock.clock | |||
| 116 | + | f.d.copyConfig.Store(&domain.CopyConfig{ReduceMotion: true}) | |||
| 117 | + | rt := f.ac.overlays | |||
| 118 | + | start := rt.copyMode.ViewportTop | |||
| 119 | + | f.d.smoothCopyWheel(f.sess, f.ac, -3) | |||
| 120 | + | require.Equal(t, start-3, rt.copyMode.ViewportTop) | |||
| 121 | + | require.Empty(t, clock.timers, "reduced motion must not arm an animation") | |||
| 122 | + | f.d.copyConfig.Store(&domain.CopyConfig{}) | |||
| 123 | + | f.d.smoothCopyWheel(f.sess, f.ac, -3) | |||
| 124 | + | timer := awaitCoordinatorScheduledTimer(t, clock) | |||
| 125 | + | require.True(t, rt.HandleInput(f.d, []byte("k"))) | |||
| 126 | + | rt.copyMu.Lock() | |||
| 127 | + | require.Zero(t, rt.copyScroll.remaining) | |||
| 128 | + | require.Nil(t, rt.copyScroll.timer.timer) | |||
| 129 | + | rt.copyMu.Unlock() | |||
| 130 | + | select { | |||
| 131 | + | case <-timer.stopped: | |||
| 132 | + | default: | |||
| 133 | + | t.Fatal("keyboard navigation must cancel inertia") | |||
| 134 | + | } | |||
| 135 | + | } | |||
A internal/usecase/daemon/copy_scroll_delta.go +98 -0
| @@ -0,0 +1,98 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | renderer "github.com/bnema/vev-vt" | |
| 5 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // Only successfully emitted composition caches carry this metadata forward. | |
| 10 | + | // It describes the terminal shadow, not the unadorned live-pane cache. | |
| 11 | + | type copyViewportState struct { | |
| 12 | + | document *scopy.Document | |
| 13 | + | target domain.Rect | |
| 14 | + | top int | |
| 15 | + | cursor scopy.Pos | |
| 16 | + | frame renderer.Frame | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func captureCopyViewport(state capturedRenderState, content domain.Rect) copyViewportState { | |
| 20 | + | mode := state.overlays.copyMode | |
| 21 | + | if mode == nil || mode.Document() == nil || mode.Selection().Enabled || mode.SearchQuery != "" { | |
| 22 | + | return copyViewportState{} | |
| 23 | + | } | |
| 24 | + | return copyViewportState{document: mode.Document(), target: capturedCopyTarget(state, content), top: mode.ViewportTop, cursor: mode.Cursor()} | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // A scroll hint is safe only for a whole-width, unchanged document viewport. | |
| 28 | + | // The ANSI renderer independently verifies retained cells against its committed | |
| 29 | + | // shadow and falls back to a snapshot if search or other adornments changed. | |
| 30 | + | func copyViewportDamage(previous, next copyViewportState, frame renderer.Frame) []renderer.Damage { | |
| 31 | + | target := next.target | |
| 32 | + | if next.document == nil || previous.document != next.document || previous.target != target || | |
| 33 | + | target.X != 0 || target.Width != frame.Width || target.Y < 0 || target.Height <= 0 || | |
| 34 | + | target.Y >= frame.Height || target.Height >= frame.Height-target.Y || | |
| 35 | + | target.Height != next.document.Height() || target.Width != next.document.Width() { | |
| 36 | + | return nil | |
| 37 | + | } | |
| 38 | + | delta := next.top - previous.top | |
| 39 | + | if delta == 0 || delta <= -target.Height || delta >= target.Height { | |
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | kind, count, exposedY := renderer.DamageScrollUp, delta, target.Y+target.Height-delta | |
| 43 | + | if delta < 0 { | |
| 44 | + | kind, count, exposedY = renderer.DamageScrollDown, -delta, target.Y | |
| 45 | + | } | |
| 46 | + | damage := []renderer.Damage{ | |
| 47 | + | {Kind: kind, Y: target.Y, Width: target.Width, Height: target.Height, Count: count}, | |
| 48 | + | {Kind: renderer.DamageText, Y: exposedY, Width: frame.Width, Height: count}, | |
| 49 | + | } | |
| 50 | + | // Refresh the old cursor after shifting it, and paint the new cursor. The | |
| 51 | + | // highlighted cursor need not occupy the same screen row after navigation. | |
| 52 | + | for _, cursor := range []scopy.Pos{previous.cursor, next.cursor} { | |
| 53 | + | y := cursor.Row - next.top | |
| 54 | + | if y >= 0 && y < target.Height { | |
| 55 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, Y: target.Y + y, Width: frame.Width, Height: 1}) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | // Preserve all chrome and other panes above/below the scroll region, even | |
| 59 | + | // when live PTY output or status updates arrive together with the wheel. | |
| 60 | + | if target.Y > 0 { | |
| 61 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, Width: frame.Width, Height: target.Y}) | |
| 62 | + | } | |
| 63 | + | bottom := target.Y + target.Height | |
| 64 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, Y: bottom, Width: frame.Width, Height: frame.Height - bottom}) | |
| 65 | + | return damage | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Reuse retained compact rows instead of decoding and interning the whole | |
| 69 | + | // immutable viewport. Both committed frames remain untouched until send succeeds. | |
| 70 | + | func composeScrolledCopyViewport(state capturedRenderState, base renderer.Frame, previous, next copyViewportState, damage []renderer.Damage) renderer.Frame { | |
| 71 | + | frame := previous.frame.Clone() | |
| 72 | + | target := next.target | |
| 73 | + | scroll := damage[0] | |
| 74 | + | if scroll.Kind == renderer.DamageScrollDown { | |
| 75 | + | frame.ScrollDown(target.Y, target.Y+target.Height-1, scroll.Count) | |
| 76 | + | } else { | |
| 77 | + | frame.ScrollUp(target.Y, target.Y+target.Height-1, scroll.Count) | |
| 78 | + | } | |
| 79 | + | for y := range frame.Height { | |
| 80 | + | if y < target.Y || y >= target.Y+target.Height { | |
| 81 | + | frame.WriteRow(y, 0, base.Row(y)) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | paint := func(y int, row []renderer.Cell) { | |
| 85 | + | if y == next.document.Height() { | |
| 86 | + | frame.WriteRow(frame.Height-1, 0, row) | |
| 87 | + | } else { | |
| 88 | + | frame.WriteRow(target.Y+y, 0, row) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | for _, d := range damage[1:] { | |
| 92 | + | start, end := max(d.Y-target.Y, 0), min(d.Y+d.Height-target.Y, target.Height) | |
| 93 | + | if start < end { | |
| 94 | + | state.overlays.copyMode.RenderRowsRange(start, end, paint, state.styles.CopyStatus, state.styles.Selection) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | return frame | |
| 98 | + | } | |
| @@ -0,0 +1,98 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | renderer "github.com/bnema/vev-vt" | |||
| 5 | + | "github.com/bnema/vev/internal/domain" | |||
| 6 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // Only successfully emitted composition caches carry this metadata forward. | |||
| 10 | + | // It describes the terminal shadow, not the unadorned live-pane cache. | |||
| 11 | + | type copyViewportState struct { | |||
| 12 | + | document *scopy.Document | |||
| 13 | + | target domain.Rect | |||
| 14 | + | top int | |||
| 15 | + | cursor scopy.Pos | |||
| 16 | + | frame renderer.Frame | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func captureCopyViewport(state capturedRenderState, content domain.Rect) copyViewportState { | |||
| 20 | + | mode := state.overlays.copyMode | |||
| 21 | + | if mode == nil || mode.Document() == nil || mode.Selection().Enabled || mode.SearchQuery != "" { | |||
| 22 | + | return copyViewportState{} | |||
| 23 | + | } | |||
| 24 | + | return copyViewportState{document: mode.Document(), target: capturedCopyTarget(state, content), top: mode.ViewportTop, cursor: mode.Cursor()} | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // A scroll hint is safe only for a whole-width, unchanged document viewport. | |||
| 28 | + | // The ANSI renderer independently verifies retained cells against its committed | |||
| 29 | + | // shadow and falls back to a snapshot if search or other adornments changed. | |||
| 30 | + | func copyViewportDamage(previous, next copyViewportState, frame renderer.Frame) []renderer.Damage { | |||
| 31 | + | target := next.target | |||
| 32 | + | if next.document == nil || previous.document != next.document || previous.target != target || | |||
| 33 | + | target.X != 0 || target.Width != frame.Width || target.Y < 0 || target.Height <= 0 || | |||
| 34 | + | target.Y >= frame.Height || target.Height >= frame.Height-target.Y || | |||
| 35 | + | target.Height != next.document.Height() || target.Width != next.document.Width() { | |||
| 36 | + | return nil | |||
| 37 | + | } | |||
| 38 | + | delta := next.top - previous.top | |||
| 39 | + | if delta == 0 || delta <= -target.Height || delta >= target.Height { | |||
| 40 | + | return nil | |||
| 41 | + | } | |||
| 42 | + | kind, count, exposedY := renderer.DamageScrollUp, delta, target.Y+target.Height-delta | |||
| 43 | + | if delta < 0 { | |||
| 44 | + | kind, count, exposedY = renderer.DamageScrollDown, -delta, target.Y | |||
| 45 | + | } | |||
| 46 | + | damage := []renderer.Damage{ | |||
| 47 | + | {Kind: kind, Y: target.Y, Width: target.Width, Height: target.Height, Count: count}, | |||
| 48 | + | {Kind: renderer.DamageText, Y: exposedY, Width: frame.Width, Height: count}, | |||
| 49 | + | } | |||
| 50 | + | // Refresh the old cursor after shifting it, and paint the new cursor. The | |||
| 51 | + | // highlighted cursor need not occupy the same screen row after navigation. | |||
| 52 | + | for _, cursor := range []scopy.Pos{previous.cursor, next.cursor} { | |||
| 53 | + | y := cursor.Row - next.top | |||
| 54 | + | if y >= 0 && y < target.Height { | |||
| 55 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, Y: target.Y + y, Width: frame.Width, Height: 1}) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | // Preserve all chrome and other panes above/below the scroll region, even | |||
| 59 | + | // when live PTY output or status updates arrive together with the wheel. | |||
| 60 | + | if target.Y > 0 { | |||
| 61 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, Width: frame.Width, Height: target.Y}) | |||
| 62 | + | } | |||
| 63 | + | bottom := target.Y + target.Height | |||
| 64 | + | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, Y: bottom, Width: frame.Width, Height: frame.Height - bottom}) | |||
| 65 | + | return damage | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Reuse retained compact rows instead of decoding and interning the whole | |||
| 69 | + | // immutable viewport. Both committed frames remain untouched until send succeeds. | |||
| 70 | + | func composeScrolledCopyViewport(state capturedRenderState, base renderer.Frame, previous, next copyViewportState, damage []renderer.Damage) renderer.Frame { | |||
| 71 | + | frame := previous.frame.Clone() | |||
| 72 | + | target := next.target | |||
| 73 | + | scroll := damage[0] | |||
| 74 | + | if scroll.Kind == renderer.DamageScrollDown { | |||
| 75 | + | frame.ScrollDown(target.Y, target.Y+target.Height-1, scroll.Count) | |||
| 76 | + | } else { | |||
| 77 | + | frame.ScrollUp(target.Y, target.Y+target.Height-1, scroll.Count) | |||
| 78 | + | } | |||
| 79 | + | for y := range frame.Height { | |||
| 80 | + | if y < target.Y || y >= target.Y+target.Height { | |||
| 81 | + | frame.WriteRow(y, 0, base.Row(y)) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | paint := func(y int, row []renderer.Cell) { | |||
| 85 | + | if y == next.document.Height() { | |||
| 86 | + | frame.WriteRow(frame.Height-1, 0, row) | |||
| 87 | + | } else { | |||
| 88 | + | frame.WriteRow(target.Y+y, 0, row) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | for _, d := range damage[1:] { | |||
| 92 | + | start, end := max(d.Y-target.Y, 0), min(d.Y+d.Height-target.Y, target.Height) | |||
| 93 | + | if start < end { | |||
| 94 | + | state.overlays.copyMode.RenderRowsRange(start, end, paint, state.styles.CopyStatus, state.styles.Selection) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | return frame | |||
| 98 | + | } | |||
A internal/usecase/daemon/copy_scroll_delta_test.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestCopyWheelPreservesOutputEpoch(t *testing.T) { | |
| 11 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 10000}) | |
| 12 | + | f.d.enterCopyMode(f.sess, f.ac) | |
| 13 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 14 | + | epoch := f.ac.output.currentEpoch() | |
| 15 | + | for _, delta := range []int{-30, -3, 3} { | |
| 16 | + | f.d.copyWheel(f.sess, f.ac, delta) | |
| 17 | + | require.Equal(t, epoch, f.ac.output.currentEpoch(), "ordinary scrolling must not reset the output dependency chain") | |
| 18 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 19 | + | } | |
| 20 | + | viewport := f.ac.pipelineCache.copyViewport | |
| 21 | + | require.NotNil(t, viewport.document) | |
| 22 | + | require.Equal(t, viewport.document.Height(), viewport.target.Height) | |
| 23 | + | require.Equal(t, viewport.document.Width(), viewport.target.Width) | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestCopyWheelPreservesOutputEpoch(t *testing.T) { | |||
| 11 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 10000}) | |||
| 12 | + | f.d.enterCopyMode(f.sess, f.ac) | |||
| 13 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |||
| 14 | + | epoch := f.ac.output.currentEpoch() | |||
| 15 | + | for _, delta := range []int{-30, -3, 3} { | |||
| 16 | + | f.d.copyWheel(f.sess, f.ac, delta) | |||
| 17 | + | require.Equal(t, epoch, f.ac.output.currentEpoch(), "ordinary scrolling must not reset the output dependency chain") | |||
| 18 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |||
| 19 | + | } | |||
| 20 | + | viewport := f.ac.pipelineCache.copyViewport | |||
| 21 | + | require.NotNil(t, viewport.document) | |||
| 22 | + | require.Equal(t, viewport.document.Height(), viewport.target.Height) | |||
| 23 | + | require.Equal(t, viewport.document.Width(), viewport.target.Width) | |||
| 24 | + | } | |||
M internal/usecase/daemon/copy_scroll_performance_test.go +7 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | renderer "github.com/bnema/vev-vt" | |
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 8 | 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| 10 | 11 | ) | |
| @@ -46,12 +47,18 @@ func BenchmarkDaemonHistoryCopyScroll(b *testing.B) { | |||
| 46 | 47 | f.d.copyWheel(f.sess, f.ac, -5000) | |
| 47 | 48 | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 48 | 49 | b.ReportAllocs() | |
| 50 | + | f.output.reset() | |
| 49 | 51 | for b.Loop() { | |
| 50 | 52 | f.d.copyWheel(f.sess, f.ac, -3) | |
| 51 | 53 | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 52 | 54 | f.d.copyWheel(f.sess, f.ac, 3) | |
| 53 | 55 | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 54 | 56 | } | |
| 57 | + | metrics := f.output.metrics() | |
| 58 | + | b.ReportMetric(float64(metrics.payloadBytes)/float64(2*b.N), "wire-B/wheel") | |
| 59 | + | output, err := wire.UnmarshalOutput(f.output.lastPayload()) | |
| 60 | + | require.NoError(b, err) | |
| 61 | + | b.ReportMetric(float64(len(output.Data)), "last-ANSI-B/wheel") | |
| 55 | 62 | }) | |
| 56 | 63 | } | |
| 57 | 64 | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | renderer "github.com/bnema/vev-vt" | 6 | renderer "github.com/bnema/vev-vt" | ||
| 7 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 8 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 8 | scopy "github.com/bnema/vev/internal/usecase/copy" | 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 9 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 11 | ) | ||
| @@ -46,12 +47,18 @@ func BenchmarkDaemonHistoryCopyScroll(b *testing.B) { | |||||
| 46 | f.d.copyWheel(f.sess, f.ac, -5000) | 47 | f.d.copyWheel(f.sess, f.ac, -5000) | ||
| 47 | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | 48 | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | ||
| 48 | b.ReportAllocs() | 49 | b.ReportAllocs() | ||
| 50 | + | f.output.reset() | |||
| 49 | for b.Loop() { | 51 | for b.Loop() { | ||
| 50 | f.d.copyWheel(f.sess, f.ac, -3) | 52 | f.d.copyWheel(f.sess, f.ac, -3) | ||
| 51 | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | 53 | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | ||
| 52 | f.d.copyWheel(f.sess, f.ac, 3) | 54 | f.d.copyWheel(f.sess, f.ac, 3) | ||
| 53 | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | 55 | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | ||
| 54 | } | 56 | } | ||
| 57 | + | metrics := f.output.metrics() | |||
| 58 | + | b.ReportMetric(float64(metrics.payloadBytes)/float64(2*b.N), "wire-B/wheel") | |||
| 59 | + | output, err := wire.UnmarshalOutput(f.output.lastPayload()) | |||
| 60 | + | require.NoError(b, err) | |||
| 61 | + | b.ReportMetric(float64(len(output.Data)), "last-ANSI-B/wheel") | |||
| 55 | }) | 62 | }) | ||
| 56 | } | 63 | } | ||
| 57 | } | 64 | } | ||
A internal/usecase/daemon/copy_scroll_replay_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | vt "github.com/bnema/vev-vt" | |
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/protocol/wire" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestCopyWheelReplayMatchesFullComposition(t *testing.T) { | |
| 15 | + | for _, panes := range []int{1, 2, 4} { | |
| 16 | + | t.Run(fmt.Sprint(panes), func(t *testing.T) { | |
| 17 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: panes, historyRows: 200}) | |
| 18 | + | f.d.enterCopyMode(f.sess, f.ac) | |
| 19 | + | base := f.ac.pipelineCache.frame | |
| 20 | + | terminal := vt.NewScreen(base.Width, base.Height) | |
| 21 | + | replay := func() []byte { | |
| 22 | + | output, err := wire.UnmarshalOutput(f.output.lastPayload()) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | terminal.Write(output.Data) | |
| 25 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 26 | + | return output.Data | |
| 27 | + | } | |
| 28 | + | replay() | |
| 29 | + | for i, delta := range []int{-30, -3, 3, -1, 1, -60, 3, 1} { | |
| 30 | + | previous := f.ac.pipelineCache.copyViewport.frame | |
| 31 | + | var before vt.Frame | |
| 32 | + | if previous.Width > 0 { | |
| 33 | + | before = captureTestFrame(previous) | |
| 34 | + | } | |
| 35 | + | // Concurrent live output must update the unadorned base without | |
| 36 | + | // contaminating retained immutable copy rows. | |
| 37 | + | f.activePane.mu.Lock() | |
| 38 | + | f.activePane.screen.Write([]byte(fmt.Sprintf("\x1b[1;1HLIVE-%d", i))) | |
| 39 | + | f.activePane.mu.Unlock() | |
| 40 | + | f.d.copyWheel(f.sess, f.ac, delta) | |
| 41 | + | data := replay() | |
| 42 | + | if before.Width > 0 { | |
| 43 | + | require.Equal(t, before, captureTestFrame(previous)) | |
| 44 | + | } | |
| 45 | + | cache := f.ac.pipelineCache | |
| 46 | + | mode := f.ac.overlays.copyMode | |
| 47 | + | require.NotNil(t, mode) | |
| 48 | + | target := cache.copyViewport.target | |
| 49 | + | require.NotNil(t, cache.copyViewport.document) | |
| 50 | + | want, _ := composeCopyClientFrame(mode, target, cache.frame.Clone(), resolveStyles(nil)) | |
| 51 | + | require.Equal(t, captureTestFrame(want), captureTestFrame(cache.copyViewport.frame), "cached composition, step %d", i) | |
| 52 | + | for y := range want.Height { | |
| 53 | + | for x := range want.Width { | |
| 54 | + | require.True(t, want.Cell(x, y).Equal(terminal.Cell(x, y)), "replay step %d cell %d,%d", i, x, y) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | require.NoError(t, cache.copyViewport.frame.CheckInvariants()) | |
| 58 | + | if panes == 1 && i == 1 { | |
| 59 | + | require.Contains(t, string(data), "\x1b[3T") | |
| 60 | + | } | |
| 61 | + | if panes == 1 && i == 2 { | |
| 62 | + | require.Contains(t, string(data), "\x1b[3S") | |
| 63 | + | } | |
| 64 | + | if target.Width != want.Width { | |
| 65 | + | require.False(t, strings.Contains(string(data), "\x1b[3T")) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | vt "github.com/bnema/vev-vt" | |||
| 9 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | + | "github.com/bnema/vev/internal/protocol/wire" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestCopyWheelReplayMatchesFullComposition(t *testing.T) { | |||
| 15 | + | for _, panes := range []int{1, 2, 4} { | |||
| 16 | + | t.Run(fmt.Sprint(panes), func(t *testing.T) { | |||
| 17 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: panes, historyRows: 200}) | |||
| 18 | + | f.d.enterCopyMode(f.sess, f.ac) | |||
| 19 | + | base := f.ac.pipelineCache.frame | |||
| 20 | + | terminal := vt.NewScreen(base.Width, base.Height) | |||
| 21 | + | replay := func() []byte { | |||
| 22 | + | output, err := wire.UnmarshalOutput(f.output.lastPayload()) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | terminal.Write(output.Data) | |||
| 25 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |||
| 26 | + | return output.Data | |||
| 27 | + | } | |||
| 28 | + | replay() | |||
| 29 | + | for i, delta := range []int{-30, -3, 3, -1, 1, -60, 3, 1} { | |||
| 30 | + | previous := f.ac.pipelineCache.copyViewport.frame | |||
| 31 | + | var before vt.Frame | |||
| 32 | + | if previous.Width > 0 { | |||
| 33 | + | before = captureTestFrame(previous) | |||
| 34 | + | } | |||
| 35 | + | // Concurrent live output must update the unadorned base without | |||
| 36 | + | // contaminating retained immutable copy rows. | |||
| 37 | + | f.activePane.mu.Lock() | |||
| 38 | + | f.activePane.screen.Write([]byte(fmt.Sprintf("\x1b[1;1HLIVE-%d", i))) | |||
| 39 | + | f.activePane.mu.Unlock() | |||
| 40 | + | f.d.copyWheel(f.sess, f.ac, delta) | |||
| 41 | + | data := replay() | |||
| 42 | + | if before.Width > 0 { | |||
| 43 | + | require.Equal(t, before, captureTestFrame(previous)) | |||
| 44 | + | } | |||
| 45 | + | cache := f.ac.pipelineCache | |||
| 46 | + | mode := f.ac.overlays.copyMode | |||
| 47 | + | require.NotNil(t, mode) | |||
| 48 | + | target := cache.copyViewport.target | |||
| 49 | + | require.NotNil(t, cache.copyViewport.document) | |||
| 50 | + | want, _ := composeCopyClientFrame(mode, target, cache.frame.Clone(), resolveStyles(nil)) | |||
| 51 | + | require.Equal(t, captureTestFrame(want), captureTestFrame(cache.copyViewport.frame), "cached composition, step %d", i) | |||
| 52 | + | for y := range want.Height { | |||
| 53 | + | for x := range want.Width { | |||
| 54 | + | require.True(t, want.Cell(x, y).Equal(terminal.Cell(x, y)), "replay step %d cell %d,%d", i, x, y) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | require.NoError(t, cache.copyViewport.frame.CheckInvariants()) | |||
| 58 | + | if panes == 1 && i == 1 { | |||
| 59 | + | require.Contains(t, string(data), "\x1b[3T") | |||
| 60 | + | } | |||
| 61 | + | if panes == 1 && i == 2 { | |||
| 62 | + | require.Contains(t, string(data), "\x1b[3S") | |||
| 63 | + | } | |||
| 64 | + | if target.Width != want.Width { | |||
| 65 | + | require.False(t, strings.Contains(string(data), "\x1b[3T")) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | }) | |||
| 69 | + | } | |||
| 70 | + | } | |||
M internal/usecase/daemon/copymode.go +16 -14
| @@ -44,27 +44,29 @@ func copyTargetPane(rt *overlayRuntime) *pane { | |||
| 44 | 44 | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |
| 45 | 45 | rt := ac.overlays | |
| 46 | 46 | rt.copyMu.Lock() | |
| 47 | - | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |
| 48 | - | rt.copyMu.Unlock() | |
| 49 | - | return | |
| 50 | - | } | |
| 51 | - | if delta > 0 && rt.copyMode.AtBottom() { | |
| 52 | - | rt.clearCopyModeLocked() | |
| 53 | - | rt.copyMu.Unlock() | |
| 54 | - | d.invalidateRender(sess, ac, true, "copymode.go") | |
| 47 | + | changed, exit := rt.moveCopyWheelLocked(delta) | |
| 48 | + | rt.copyMu.Unlock() | |
| 49 | + | if !changed && !exit { | |
| 55 | 50 | return | |
| 56 | 51 | } | |
| 57 | - | rt.copyMode.MoveRows(delta) | |
| 58 | - | exit := delta > 0 && rt.copyMode.AtBottom() | |
| 59 | - | if exit { | |
| 60 | - | rt.clearCopyModeLocked() | |
| 61 | - | } | |
| 62 | - | rt.copyMu.Unlock() | |
| 63 | 52 | // Moving within the immutable copy document does not invalidate live pane | |
| 64 | 53 | // captures. The compositor still redraws the overlay; leaving it resets live | |
| 65 | 54 | // state so attachment-local snapshots cannot survive the return to the PTY. | |
| 66 | 55 | d.invalidateRender(sess, ac, exit, "copymode.go") | |
| 67 | 56 | } | |
| 57 | + | ||
| 58 | + | func (rt *overlayRuntime) moveCopyWheelLocked(delta int) (changed, exit bool) { | |
| 59 | + | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |
| 60 | + | return false, false | |
| 61 | + | } | |
| 62 | + | changed = rt.copyMode.ScrollRows(delta) | |
| 63 | + | exit = delta > 0 && rt.copyMode.AtBottom() | |
| 64 | + | if exit { | |
| 65 | + | rt.clearCopyModeLocked() | |
| 66 | + | } | |
| 67 | + | return changed, exit | |
| 68 | + | } | |
| 69 | + | ||
| 68 | 70 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| 69 | 71 | tb := sess.tabForAttachment(ac) | |
| 70 | 72 | if tb == nil { | |
| @@ -44,27 +44,29 @@ func copyTargetPane(rt *overlayRuntime) *pane { | |||||
| 44 | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | 44 | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | ||
| 45 | rt := ac.overlays | 45 | rt := ac.overlays | ||
| 46 | rt.copyMu.Lock() | 46 | rt.copyMu.Lock() | ||
| 47 | - | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | 47 | + | changed, exit := rt.moveCopyWheelLocked(delta) |
| 48 | - | rt.copyMu.Unlock() | 48 | + | rt.copyMu.Unlock() |
| 49 | - | return | 49 | + | if !changed && !exit { |
| 50 | - | } | |||
| 51 | - | if delta > 0 && rt.copyMode.AtBottom() { | |||
| 52 | - | rt.clearCopyModeLocked() | |||
| 53 | - | rt.copyMu.Unlock() | |||
| 54 | - | d.invalidateRender(sess, ac, true, "copymode.go") | |||
| 55 | return | 50 | return | ||
| 56 | } | 51 | } | ||
| 57 | - | rt.copyMode.MoveRows(delta) | |||
| 58 | - | exit := delta > 0 && rt.copyMode.AtBottom() | |||
| 59 | - | if exit { | |||
| 60 | - | rt.clearCopyModeLocked() | |||
| 61 | - | } | |||
| 62 | - | rt.copyMu.Unlock() | |||
| 63 | // Moving within the immutable copy document does not invalidate live pane | 52 | // Moving within the immutable copy document does not invalidate live pane | ||
| 64 | // captures. The compositor still redraws the overlay; leaving it resets live | 53 | // captures. The compositor still redraws the overlay; leaving it resets live | ||
| 65 | // state so attachment-local snapshots cannot survive the return to the PTY. | 54 | // state so attachment-local snapshots cannot survive the return to the PTY. | ||
| 66 | d.invalidateRender(sess, ac, exit, "copymode.go") | 55 | d.invalidateRender(sess, ac, exit, "copymode.go") | ||
| 67 | } | 56 | } | ||
| 57 | + | ||||
| 58 | + | func (rt *overlayRuntime) moveCopyWheelLocked(delta int) (changed, exit bool) { | |||
| 59 | + | if rt.copyMode == nil || rt.copyPane == nil || rt.copyDocument == nil { | |||
| 60 | + | return false, false | |||
| 61 | + | } | |||
| 62 | + | changed = rt.copyMode.ScrollRows(delta) | |||
| 63 | + | exit = delta > 0 && rt.copyMode.AtBottom() | |||
| 64 | + | if exit { | |||
| 65 | + | rt.clearCopyModeLocked() | |||
| 66 | + | } | |||
| 67 | + | return changed, exit | |||
| 68 | + | } | |||
| 69 | + | ||||
| 68 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | 70 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | ||
| 69 | tb := sess.tabForAttachment(ac) | 71 | tb := sess.tabForAttachment(ac) | ||
| 70 | if tb == nil { | 72 | if tb == nil { | ||
M internal/usecase/daemon/input.go +1 -1
| @@ -286,7 +286,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 286 | 286 | return | |
| 287 | 287 | } | |
| 288 | 288 | d.enterCopyMode(sess, ac) | |
| 289 | - | d.copyWheel(sess, ac, -3) | |
| 289 | + | d.smoothCopyWheel(sess, ac, -3) | |
| 290 | 290 | case mouse.WheelDown: | |
| 291 | 291 | if altScreen { | |
| 292 | 292 | _ = sess.runMutation(func() error { | |
| @@ -286,7 +286,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 286 | return | 286 | return | ||
| 287 | } | 287 | } | ||
| 288 | d.enterCopyMode(sess, ac) | 288 | d.enterCopyMode(sess, ac) | ||
| 289 | - | d.copyWheel(sess, ac, -3) | 289 | + | d.smoothCopyWheel(sess, ac, -3) |
| 290 | case mouse.WheelDown: | 290 | case mouse.WheelDown: | ||
| 291 | if altScreen { | 291 | if altScreen { | ||
| 292 | _ = sess.runMutation(func() error { | 292 | _ = sess.runMutation(func() error { | ||
M internal/usecase/daemon/overlay_runtime.go +5 -0
| @@ -71,6 +71,7 @@ type overlayRuntime struct { | |||
| 71 | 71 | copyPane *pane | |
| 72 | 72 | copyPending []byte | |
| 73 | 73 | copyESC pendingByteTimer | |
| 74 | + | copyScroll copyScrollAnimation | |
| 74 | 75 | copySearch *visualsearch.Model | |
| 75 | 76 | copySearchPending []byte | |
| 76 | 77 | statusFeedback string | |
| @@ -199,6 +200,7 @@ func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) { | |||
| 199 | 200 | } | |
| 200 | 201 | ||
| 201 | 202 | func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) { | |
| 203 | + | rt.stopCopyScrollLocked() | |
| 202 | 204 | rt.copyPointerEpoch++ | |
| 203 | 205 | rt.copyPointer = copyPointerState{} | |
| 204 | 206 | if clearClick { | |
| @@ -255,6 +257,9 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attach | |||
| 255 | 257 | return false | |
| 256 | 258 | } | |
| 257 | 259 | ac := rt.ac | |
| 260 | + | rt.copyMu.Lock() | |
| 261 | + | rt.stopCopyScrollLocked() | |
| 262 | + | rt.copyMu.Unlock() | |
| 258 | 263 | var effect *attachmentEffect | |
| 259 | 264 | if len(effects) != 0 { | |
| 260 | 265 | effect = effects[0] | |
| @@ -71,6 +71,7 @@ type overlayRuntime struct { | |||||
| 71 | copyPane *pane | 71 | copyPane *pane | ||
| 72 | copyPending []byte | 72 | copyPending []byte | ||
| 73 | copyESC pendingByteTimer | 73 | copyESC pendingByteTimer | ||
| 74 | + | copyScroll copyScrollAnimation | |||
| 74 | copySearch *visualsearch.Model | 75 | copySearch *visualsearch.Model | ||
| 75 | copySearchPending []byte | 76 | copySearchPending []byte | ||
| 76 | statusFeedback string | 77 | statusFeedback string | ||
| @@ -199,6 +200,7 @@ func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) { | |||||
| 199 | } | 200 | } | ||
| 200 | 201 | ||||
| 201 | func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) { | 202 | func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) { | ||
| 203 | + | rt.stopCopyScrollLocked() | |||
| 202 | rt.copyPointerEpoch++ | 204 | rt.copyPointerEpoch++ | ||
| 203 | rt.copyPointer = copyPointerState{} | 205 | rt.copyPointer = copyPointerState{} | ||
| 204 | if clearClick { | 206 | if clearClick { | ||
| @@ -255,6 +257,9 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attach | |||||
| 255 | return false | 257 | return false | ||
| 256 | } | 258 | } | ||
| 257 | ac := rt.ac | 259 | ac := rt.ac | ||
| 260 | + | rt.copyMu.Lock() | |||
| 261 | + | rt.stopCopyScrollLocked() | |||
| 262 | + | rt.copyMu.Unlock() | |||
| 258 | var effect *attachmentEffect | 263 | var effect *attachmentEffect | ||
| 259 | if len(effects) != 0 { | 264 | if len(effects) != 0 { | ||
| 260 | effect = effects[0] | 265 | effect = effects[0] | ||
M internal/usecase/daemon/render_pipeline.go +37 -9
| @@ -48,6 +48,7 @@ type composeCacheInput struct { | |||
| 48 | 48 | toastFootprints []domain.Rect | |
| 49 | 49 | theme themeui.Theme | |
| 50 | 50 | styleGeneration uint64 | |
| 51 | + | copyViewport copyViewportState | |
| 51 | 52 | } | |
| 52 | 53 | ||
| 53 | 54 | type composedRenderFrame struct { | |
| @@ -173,11 +174,27 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 173 | 174 | }) | |
| 174 | 175 | damage = floatingDamage | |
| 175 | 176 | } | |
| 177 | + | copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive && | |
| 178 | + | !state.overlays.copySearchActive && !state.overlays.pickerActive && | |
| 179 | + | !state.overlays.paletteActive && !state.overlays.promptActive && | |
| 180 | + | !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible | |
| 181 | + | var copyViewport copyViewportState | |
| 182 | + | var scrollDamage []renderer.Damage | |
| 183 | + | if copyOnly && !toastsVisible { | |
| 184 | + | copyViewport = captureCopyViewport(state, content) | |
| 185 | + | if !full { | |
| 186 | + | scrollDamage = copyViewportDamage(in.copyViewport, copyViewport, frame) | |
| 187 | + | } | |
| 188 | + | } | |
| 176 | 189 | if overlaysActive { | |
| 177 | - | if !toastsVisible && !state.floating.visible { | |
| 178 | - | frame = baseFrame.Clone() | |
| 190 | + | if scrollDamage != nil && in.copyViewport.frame.Width == width && in.copyViewport.frame.Height == frameHeight { | |
| 191 | + | frame = composeScrolledCopyViewport(state, baseFrame, in.copyViewport, copyViewport, scrollDamage) | |
| 192 | + | } else { | |
| 193 | + | if !toastsVisible && !state.floating.visible { | |
| 194 | + | frame = baseFrame.Clone() | |
| 195 | + | } | |
| 196 | + | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |
| 179 | 197 | } | |
| 180 | - | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |
| 181 | 198 | frame, damage = composeCapturedOverlays(state, frame, damage) | |
| 182 | 199 | } | |
| 183 | 200 | for x := range width { | |
| @@ -208,13 +225,18 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 208 | 225 | // A plain copy viewport is painted on a clone, never into baseFrame. Keep | |
| 209 | 226 | // that unadorned base reusable between wheel events. Modal/floating paths | |
| 210 | 227 | // retain their conservative invalidation, and copy exit requests a reset. | |
| 211 | - | copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive && | |
| 212 | - | !state.overlays.copySearchActive && !state.overlays.pickerActive && | |
| 213 | - | !state.overlays.paletteActive && !state.overlays.promptActive && | |
| 214 | - | !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible | |
| 215 | 228 | outCache := composeCacheInput{valid: !overlaysActive || copyOnly, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | |
| 216 | 229 | outCache.bars = barCache{top: topBar, bottom: bottomBar} | |
| 217 | - | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | |
| 230 | + | if copyViewport.document != nil { | |
| 231 | + | copyViewport.frame = frame | |
| 232 | + | outCache.copyViewport = copyViewport | |
| 233 | + | } | |
| 234 | + | if scrollDamage != nil { | |
| 235 | + | damage = scrollDamage | |
| 236 | + | } | |
| 237 | + | // A copy viewport is ordinary terminal content, not a new output epoch. | |
| 238 | + | // Keep conservative resets for other overlays and explicit resynchronization. | |
| 239 | + | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || (overlaysActive && !copyOnly)} | |
| 218 | 240 | } | |
| 219 | 241 | ||
| 220 | 242 | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | |
| @@ -312,6 +334,12 @@ func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, da | |||
| 312 | 334 | if !o.copyActive { | |
| 313 | 335 | return frame, damage | |
| 314 | 336 | } | |
| 337 | + | target := capturedCopyTarget(state, content) | |
| 338 | + | return composeCopyClientFrame(o.copyMode, target, frame, state.styles) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func capturedCopyTarget(state capturedRenderState, content domain.Rect) domain.Rect { | |
| 342 | + | o := state.overlays | |
| 315 | 343 | target := domain.Rect{} | |
| 316 | 344 | if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { | |
| 317 | 345 | target = state.floating.geometry.translate(content.X, content.Y).Inner | |
| @@ -329,7 +357,7 @@ func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, da | |||
| 329 | 357 | } | |
| 330 | 358 | } | |
| 331 | 359 | } | |
| 332 | - | return composeCopyClientFrame(o.copyMode, target, frame, state.styles) | |
| 360 | + | return target | |
| 333 | 361 | } | |
| 334 | 362 | ||
| 335 | 363 | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) { | |
| @@ -48,6 +48,7 @@ type composeCacheInput struct { | |||||
| 48 | toastFootprints []domain.Rect | 48 | toastFootprints []domain.Rect | ||
| 49 | theme themeui.Theme | 49 | theme themeui.Theme | ||
| 50 | styleGeneration uint64 | 50 | styleGeneration uint64 | ||
| 51 | + | copyViewport copyViewportState | |||
| 51 | } | 52 | } | ||
| 52 | 53 | ||||
| 53 | type composedRenderFrame struct { | 54 | type composedRenderFrame struct { | ||
| @@ -173,11 +174,27 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 173 | }) | 174 | }) | ||
| 174 | damage = floatingDamage | 175 | damage = floatingDamage | ||
| 175 | } | 176 | } | ||
| 177 | + | copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive && | |||
| 178 | + | !state.overlays.copySearchActive && !state.overlays.pickerActive && | |||
| 179 | + | !state.overlays.paletteActive && !state.overlays.promptActive && | |||
| 180 | + | !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible | |||
| 181 | + | var copyViewport copyViewportState | |||
| 182 | + | var scrollDamage []renderer.Damage | |||
| 183 | + | if copyOnly && !toastsVisible { | |||
| 184 | + | copyViewport = captureCopyViewport(state, content) | |||
| 185 | + | if !full { | |||
| 186 | + | scrollDamage = copyViewportDamage(in.copyViewport, copyViewport, frame) | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 176 | if overlaysActive { | 189 | if overlaysActive { | ||
| 177 | - | if !toastsVisible && !state.floating.visible { | 190 | + | if scrollDamage != nil && in.copyViewport.frame.Width == width && in.copyViewport.frame.Height == frameHeight { |
| 178 | - | frame = baseFrame.Clone() | 191 | + | frame = composeScrolledCopyViewport(state, baseFrame, in.copyViewport, copyViewport, scrollDamage) |
| 192 | + | } else { | |||
| 193 | + | if !toastsVisible && !state.floating.visible { | |||
| 194 | + | frame = baseFrame.Clone() | |||
| 195 | + | } | |||
| 196 | + | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |||
| 179 | } | 197 | } | ||
| 180 | - | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |||
| 181 | frame, damage = composeCapturedOverlays(state, frame, damage) | 198 | frame, damage = composeCapturedOverlays(state, frame, damage) | ||
| 182 | } | 199 | } | ||
| 183 | for x := range width { | 200 | for x := range width { | ||
| @@ -208,13 +225,18 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 208 | // A plain copy viewport is painted on a clone, never into baseFrame. Keep | 225 | // A plain copy viewport is painted on a clone, never into baseFrame. Keep | ||
| 209 | // that unadorned base reusable between wheel events. Modal/floating paths | 226 | // that unadorned base reusable between wheel events. Modal/floating paths | ||
| 210 | // retain their conservative invalidation, and copy exit requests a reset. | 227 | // retain their conservative invalidation, and copy exit requests a reset. | ||
| 211 | - | copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive && | |||
| 212 | - | !state.overlays.copySearchActive && !state.overlays.pickerActive && | |||
| 213 | - | !state.overlays.paletteActive && !state.overlays.promptActive && | |||
| 214 | - | !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible | |||
| 215 | outCache := composeCacheInput{valid: !overlaysActive || copyOnly, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | 228 | outCache := composeCacheInput{valid: !overlaysActive || copyOnly, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | ||
| 216 | outCache.bars = barCache{top: topBar, bottom: bottomBar} | 229 | outCache.bars = barCache{top: topBar, bottom: bottomBar} | ||
| 217 | - | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | 230 | + | if copyViewport.document != nil { |
| 231 | + | copyViewport.frame = frame | |||
| 232 | + | outCache.copyViewport = copyViewport | |||
| 233 | + | } | |||
| 234 | + | if scrollDamage != nil { | |||
| 235 | + | damage = scrollDamage | |||
| 236 | + | } | |||
| 237 | + | // A copy viewport is ordinary terminal content, not a new output epoch. | |||
| 238 | + | // Keep conservative resets for other overlays and explicit resynchronization. | |||
| 239 | + | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || (overlaysActive && !copyOnly)} | |||
| 218 | } | 240 | } | ||
| 219 | 241 | ||||
| 220 | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | 242 | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | ||
| @@ -312,6 +334,12 @@ func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, da | |||||
| 312 | if !o.copyActive { | 334 | if !o.copyActive { | ||
| 313 | return frame, damage | 335 | return frame, damage | ||
| 314 | } | 336 | } | ||
| 337 | + | target := capturedCopyTarget(state, content) | |||
| 338 | + | return composeCopyClientFrame(o.copyMode, target, frame, state.styles) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | func capturedCopyTarget(state capturedRenderState, content domain.Rect) domain.Rect { | |||
| 342 | + | o := state.overlays | |||
| 315 | target := domain.Rect{} | 343 | target := domain.Rect{} | ||
| 316 | if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { | 344 | if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { | ||
| 317 | target = state.floating.geometry.translate(content.X, content.Y).Inner | 345 | target = state.floating.geometry.translate(content.X, content.Y).Inner | ||
| @@ -329,7 +357,7 @@ func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, da | |||||
| 329 | } | 357 | } | ||
| 330 | } | 358 | } | ||
| 331 | } | 359 | } | ||
| 332 | - | return composeCopyClientFrame(o.copyMode, target, frame, state.styles) | 360 | + | return target |
| 333 | } | 361 | } | ||
| 334 | 362 | ||||
| 335 | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) { | 363 | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) { | ||