detached f1fbf32

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 UnverifiedB5690EEEBB952194 The 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
172172
173173 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.
174174
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+
175182 Mouse drag selects a text range. Double-click selects the word under the pointer; dragging after a double-click extends by complete words.
176183
177184 ```ini
178185 # Unicode whitespace always separates words.
179186 # The default is " -_@".
180187 copy.word-separators = " -_@"
188+# Disable animated wheel scrolling (default: off).
189+copy.reduce-motion = off
181190 ```
182191
183192 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.
184201
185202 ## Responsive overlays
186203
@@ -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 ```ini184 ```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 overlays202 ## Responsive overlays
186 203
M docs/performance.md +61 -1
@@ -113,6 +113,66 @@ latency:
113113
114114 ```sh
115115 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
117117 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeCapturedFloatingFrameCached$' -benchmem
118118 ```
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 ```sh114 ```sh
115 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem115 go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
116-go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem116+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$' -benchmem117 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
33 go 1.27
44
55 require (
6- github.com/bnema/vev-vt v0.5.0
6+ github.com/bnema/vev-vt v0.5.1
77 github.com/stretchr/testify v1.11.1
88 )
99
@@ -3,7 +3,7 @@ module github.com/bnema/vev
3 go 1.273 go 1.27
4 4
5 require (5 require (
6- github.com/bnema/vev-vt v0.5.06+ github.com/bnema/vev-vt v0.5.1
7 github.com/stretchr/testify v1.11.17 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) {
152152 continue
153153 }
154154 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
155163 case key == "floating.command":
156164 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
157165 command, ok := parseBarCommand(value)
@@ -152,6 +152,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
152 continue152 continue
153 }153 }
154 cfg.Copy.WordSeparators = separators154 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 {
5050 Interval time.Duration
5151 }
5252
53-// CopyConfig contains copy-mode text selection settings.
53+// CopyConfig contains copy-mode selection and mouse motion settings.
5454 type CopyConfig struct {
5555 WordSeparators string
56+ ReduceMotion bool
5657 }
5758
5859 // PaletteConfig contains palette placement settings. A false AnchorSet uses the
@@ -50,9 +50,10 @@ type BarConfig struct {
50 Interval time.Duration50 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 string55 WordSeparators string
56+ ReduceMotion bool
56 }57 }
57 58
58 // PaletteConfig contains palette placement settings. A false AnchorSet uses the59 // 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 {
322322 }
323323 return changed
324324 }
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+
325344 func (m *Mode) Page(pages int) bool {
326345 if m == nil || m.document == nil {
327346 return false
@@ -539,6 +558,15 @@ func (m *Mode) Render(styles ...renderer.Style) renderer.Frame {
539558 // The callback borrows a reusable semantic row only until it returns. Rendering
540559 // directly into a compositor avoids building and then decoding a compact frame.
541560 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) {
542570 if m == nil || m.document == nil {
543571 return
544572 }
@@ -547,7 +575,7 @@ func (m *Mode) RenderRows(paint func(int, []renderer.Cell), styles ...renderer.S
547575 selection, hasSelection := optionalStyle(styles, 1)
548576 cursor, cursorValid := d.Normalize(m.navigator.Pos)
549577 row := make([]renderer.Cell, d.Width())
550- for y := range d.Height() {
578+ for y := max(start, 0); y < min(end, d.Height()); y++ {
551579 src := m.ViewportTop + y
552580 for x := range row {
553581 row[x] = renderer.BlankCell()
@@ -322,6 +322,25 @@ func (m *Mode) MoveRows(delta int) bool {
322 }322 }
323 return changed323 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 false346 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. Rendering558 // 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 return571 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 + y579 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
2828 }
2929 switch ev.Button {
3030 case mouse.WheelUp:
31- d.copyWheel(sess, ac, -3)
31+ d.smoothCopyWheel(sess, ac, -3)
3232 case mouse.WheelDown:
33- d.copyWheel(sess, ac, 3)
33+ d.smoothCopyWheel(sess, ac, 3)
3434 case mouse.Left:
35+ rt.copyMu.Lock()
36+ rt.stopCopyScrollLocked()
37+ rt.copyMu.Unlock()
3538 d.handleActiveCopyMouse(sess, ac, tb, ev)
3639 }
3740 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 true40 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 (
55
66 renderer "github.com/bnema/vev-vt"
77 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/protocol/wire"
89 scopy "github.com/bnema/vev/internal/usecase/copy"
910 "github.com/stretchr/testify/require"
1011 )
@@ -46,12 +47,18 @@ func BenchmarkDaemonHistoryCopyScroll(b *testing.B) {
4647 f.d.copyWheel(f.sess, f.ac, -5000)
4748 f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
4849 b.ReportAllocs()
50+ f.output.reset()
4951 for b.Loop() {
5052 f.d.copyWheel(f.sess, f.ac, -3)
5153 f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
5254 f.d.copyWheel(f.sess, f.ac, 3)
5355 f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
5456 }
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")
5562 })
5663 }
5764 }
@@ -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 {
4444 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
4545 rt := ac.overlays
4646 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 {
5550 return
5651 }
57- rt.copyMode.MoveRows(delta)
58- exit := delta > 0 && rt.copyMode.AtBottom()
59- if exit {
60- rt.clearCopyModeLocked()
61- }
62- rt.copyMu.Unlock()
6352 // Moving within the immutable copy document does not invalidate live pane
6453 // captures. The compositor still redraws the overlay; leaving it resets live
6554 // state so attachment-local snapshots cannot survive the return to the PTY.
6655 d.invalidateRender(sess, ac, exit, "copymode.go")
6756 }
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+
6870 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
6971 tb := sess.tabForAttachment(ac)
7072 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.overlays45 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- return49+ 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 return50 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 pane52 // Moving within the immutable copy document does not invalidate live pane
64 // captures. The compositor still redraws the overlay; leaving it resets live53 // 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,
286286 return
287287 }
288288 d.enterCopyMode(sess, ac)
289- d.copyWheel(sess, ac, -3)
289+ d.smoothCopyWheel(sess, ac, -3)
290290 case mouse.WheelDown:
291291 if altScreen {
292292 _ = sess.runMutation(func() error {
@@ -286,7 +286,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
286 return286 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 {
7171 copyPane *pane
7272 copyPending []byte
7373 copyESC pendingByteTimer
74+ copyScroll copyScrollAnimation
7475 copySearch *visualsearch.Model
7576 copySearchPending []byte
7677 statusFeedback string
@@ -199,6 +200,7 @@ func (rt *overlayRuntime) beginCopyPointerLocked(pointer copyPointerState) {
199200 }
200201
201202 func (rt *overlayRuntime) invalidateCopyPointerLocked(clearClick bool) {
203+ rt.stopCopyScrollLocked()
202204 rt.copyPointerEpoch++
203205 rt.copyPointer = copyPointerState{}
204206 if clearClick {
@@ -255,6 +257,9 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte, effects ...*attach
255257 return false
256258 }
257259 ac := rt.ac
260+ rt.copyMu.Lock()
261+ rt.stopCopyScrollLocked()
262+ rt.copyMu.Unlock()
258263 var effect *attachmentEffect
259264 if len(effects) != 0 {
260265 effect = effects[0]
@@ -71,6 +71,7 @@ type overlayRuntime struct {
71 copyPane *pane71 copyPane *pane
72 copyPending []byte72 copyPending []byte
73 copyESC pendingByteTimer73 copyESC pendingByteTimer
74+ copyScroll copyScrollAnimation
74 copySearch *visualsearch.Model75 copySearch *visualsearch.Model
75 copySearchPending []byte76 copySearchPending []byte
76 statusFeedback string77 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 false257 return false
256 }258 }
257 ac := rt.ac259 ac := rt.ac
260+ rt.copyMu.Lock()
261+ rt.stopCopyScrollLocked()
262+ rt.copyMu.Unlock()
258 var effect *attachmentEffect263 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 {
4848 toastFootprints []domain.Rect
4949 theme themeui.Theme
5050 styleGeneration uint64
51+ copyViewport copyViewportState
5152 }
5253
5354 type composedRenderFrame struct {
@@ -173,11 +174,27 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
173174 })
174175 damage = floatingDamage
175176 }
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+ }
176189 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)
179197 }
180- frame, damage = composeCapturedCopyMode(state, frame, damage, content)
181198 frame, damage = composeCapturedOverlays(state, frame, damage)
182199 }
183200 for x := range width {
@@ -208,13 +225,18 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
208225 // A plain copy viewport is painted on a clone, never into baseFrame. Keep
209226 // that unadorned base reusable between wheel events. Modal/floating paths
210227 // 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
215228 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}
216229 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)}
218240 }
219241
220242 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
312334 if !o.copyActive {
313335 return frame, damage
314336 }
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
315343 target := domain.Rect{}
316344 if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {
317345 target = state.floating.geometry.translate(content.X, content.Y).Inner
@@ -329,7 +357,7 @@ func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, da
329357 }
330358 }
331359 }
332- return composeCopyClientFrame(o.copyMode, target, frame, state.styles)
360+ return target
333361 }
334362
335363 func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) {
@@ -48,6 +48,7 @@ type composeCacheInput struct {
48 toastFootprints []domain.Rect48 toastFootprints []domain.Rect
49 theme themeui.Theme49 theme themeui.Theme
50 styleGeneration uint6450 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 = floatingDamage175 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. Keep225 // A plain copy viewport is painted on a clone, never into baseFrame. Keep
209 // that unadorned base reusable between wheel events. Modal/floating paths226 // 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, damage335 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).Inner345 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) {

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close