detached a1d7783

feat(daemon): route floating copy mode

commit
a1d7783f84157edd71991e82f00ad7c2b8657c56
author
brice · · 2026-07-10 07:37 UTC
parent
540608f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +385 -79
M internal/usecase/daemon/copymode.go +43 -55
@@ -31,7 +31,6 @@ import (
3131 "github.com/bnema/vev/internal/ports"
3232 scopy "github.com/bnema/vev/internal/usecase/copy"
3333 "github.com/bnema/vev/internal/usecase/keys"
34- "github.com/bnema/vev/internal/usecase/layout"
3534 "github.com/bnema/vev/internal/usecase/mouse"
3635 "github.com/bnema/vev/internal/usecase/ui"
3736 "github.com/bnema/vev/internal/usecase/visualsearch"
@@ -40,22 +39,24 @@ import (
4039
4140 var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1}
4241
43-func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
44- rt := ac.overlays
45- tb := sess.activeTab()
46- if tb == nil {
47- return
42+func copyTargetPane(rt *overlayRuntime) *pane {
43+ if rt == nil {
44+ return nil
4845 }
46+ rt.copyMu.Lock()
47+ defer rt.copyMu.Unlock()
48+ return rt.copyPane
49+}
4950
50- tb.mu.Lock()
51- p := tb.focusedPane()
52- tb.mu.Unlock()
51+func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
52+ rt := ac.overlays
53+ p := copyTargetPane(rt)
5354 if p == nil {
5455 return
5556 }
5657 p.mu.Lock()
5758 rt.copyMu.Lock()
58- if rt.copyMode == nil {
59+ if rt.copyMode == nil || rt.copyPane != p {
5960 rt.copyMu.Unlock()
6061 p.mu.Unlock()
6162 return
@@ -86,7 +87,8 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
8687 return
8788 }
8889 tb.mu.Lock()
89- p := tb.focusedPane()
90+ p := tb.terminalTargetLocked()
91+ floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p
9092 tb.mu.Unlock()
9193 if p == nil {
9294 return
@@ -96,12 +98,21 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
9698 p.mu.Unlock()
9799 rt.copyMu.Lock()
98100 rt.copyMode = scopy.NewMode(snap)
101+ rt.copyPane = p
99102 rt.copySearch = nil
100103 rt.copySearchPending = nil
101104 rt.copyPressRowValid = false
102105 rt.copyDragging = false
103106 rt.normalMousePressValid = false
104107 rt.copyMu.Unlock()
108+ if floatingSource {
109+ tb.mu.Lock()
110+ stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p
111+ tb.mu.Unlock()
112+ if !stillVisible {
113+ rt.clearCopyModeForPane(p)
114+ }
115+ }
105116 d.paint(sess, ac, true)
106117 }
107118
@@ -110,14 +121,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
110121 if ev.Button != mouse.Left {
111122 return
112123 }
113- tb := sess.activeTab()
114- if tb == nil {
115- return
116- }
117-
118- tb.mu.Lock()
119- p := tb.focusedPane()
120- tb.mu.Unlock()
124+ p := copyTargetPane(rt)
121125 if p == nil {
122126 return
123127 }
@@ -133,7 +137,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
133137 return
134138 }
135139 rt.copyMu.Lock()
136- if rt.copyMode == nil {
140+ if rt.copyMode == nil || rt.copyPane != p {
137141 rt.copyMu.Unlock()
138142 p.mu.Unlock()
139143 return
@@ -175,20 +179,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
175179 if sess == nil {
176180 return
177181 }
178- tb := sess.activeTab()
179- if tb == nil {
180- return
181- }
182-
183- tb.mu.Lock()
184- p := tb.focusedPane()
185- tb.mu.Unlock()
182+ p := copyTargetPane(rt)
186183 if p == nil {
187184 return
188185 }
189186 p.mu.Lock()
190187 rt.copyMu.Lock()
191- if rt.copyMode == nil {
188+ if rt.copyMode == nil || rt.copyPane != p {
192189 rt.copyPending = nil
193190 d.stopCopyPendingTimerLocked(ac)
194191 rt.copyMu.Unlock()
@@ -420,36 +417,27 @@ func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame
420417 return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
421418 }
422419
423-func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
424- styles := newThemeStyles(bars.theme)
425- p := tb.focusedPane()
426- if p == nil {
427- return renderer.NewFrame(0, 0), nil
420+func composeCopyClientFrame(mode *scopy.Mode, p *pane, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
421+ if mode == nil || p == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
422+ return frame, nil
428423 }
424+ styles := newThemeStyles(bars.theme)
429425 p.mu.Lock()
430- paneFrame := p.screen.Frame
431- paneWidth, paneRows := paneFrame.Width, paneFrame.Height
432- snap := scopy.NewSnapshot(p.scrollback, paneFrame)
426+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
433427 p.mu.Unlock()
434428 copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
435- pl, ok := focusedPlacementLocked(tb)
436- if !ok {
437- pl = layout.Placement{Content: domain.Rect{Width: paneWidth, Height: paneRows}}
438- }
439- width, screenRows := paneWidth, paneRows
440- if len(tb.panes) > 1 && tb.size.Valid() {
441- width, screenRows = tb.size.Cols, tb.size.Rows
442- }
443- frame := renderer.NewFrame(width, screenRows+2)
444- drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, bars.topRight, styles)
445- base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme)
446- for y := range screenRows {
447- copy(frame.Row(y+1), base.Row(y))
448- }
449- for y := 0; y < pl.Content.Height && y < copyFrame.Height-1; y++ {
450- copy(frame.Row(pl.Content.Y + 1 + y)[pl.Content.X:pl.Content.X+min(pl.Content.Width, copyFrame.Width)], copyFrame.Row(y)[:min(pl.Content.Width, copyFrame.Width)])
429+ bodyRows := max(copyFrame.Height-1, 0)
430+ for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
431+ dstX := max(target.X, 0)
432+ srcX := max(-target.X, 0)
433+ width := min(target.Width-srcX, copyFrame.Width-srcX)
434+ width = min(width, frame.Width-dstX)
435+ if width > 0 && target.Y+y >= 0 {
436+ copy(frame.Row(target.Y + y)[dstX:dstX+width], copyFrame.Row(y)[srcX:srcX+width])
437+ }
451438 }
452- statusY := screenRows + 1
453- copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(width, copyFrame.Width)])
439+ statusY := frame.Height - 1
440+ clear(frame.Row(statusY))
441+ copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
454442 return frame, []renderer.Damage{renderer.FullRedraw()}
455443 }
@@ -31,7 +31,6 @@ import (
31 "github.com/bnema/vev/internal/ports"31 "github.com/bnema/vev/internal/ports"
32 scopy "github.com/bnema/vev/internal/usecase/copy"32 scopy "github.com/bnema/vev/internal/usecase/copy"
33 "github.com/bnema/vev/internal/usecase/keys"33 "github.com/bnema/vev/internal/usecase/keys"
34- "github.com/bnema/vev/internal/usecase/layout"
35 "github.com/bnema/vev/internal/usecase/mouse"34 "github.com/bnema/vev/internal/usecase/mouse"
36 "github.com/bnema/vev/internal/usecase/ui"35 "github.com/bnema/vev/internal/usecase/ui"
37 "github.com/bnema/vev/internal/usecase/visualsearch"36 "github.com/bnema/vev/internal/usecase/visualsearch"
@@ -40,22 +39,24 @@ import (
40 39
41 var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1}40 var copySearchModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title: " Search ", Anchor: ui.AnchorBottom, BottomMargin: 1}
42 41
43-func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {42+func copyTargetPane(rt *overlayRuntime) *pane {
44- rt := ac.overlays43+ if rt == nil {
45- tb := sess.activeTab()44+ return nil
46- if tb == nil {
47- return
48 }45 }
46+ rt.copyMu.Lock()
47+ defer rt.copyMu.Unlock()
48+ return rt.copyPane
49+}
49 50
50- tb.mu.Lock()51+func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
51- p := tb.focusedPane()52+ rt := ac.overlays
52- tb.mu.Unlock()53+ p := copyTargetPane(rt)
53 if p == nil {54 if p == nil {
54 return55 return
55 }56 }
56 p.mu.Lock()57 p.mu.Lock()
57 rt.copyMu.Lock()58 rt.copyMu.Lock()
58- if rt.copyMode == nil {59+ if rt.copyMode == nil || rt.copyPane != p {
59 rt.copyMu.Unlock()60 rt.copyMu.Unlock()
60 p.mu.Unlock()61 p.mu.Unlock()
61 return62 return
@@ -86,7 +87,8 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
86 return87 return
87 }88 }
88 tb.mu.Lock()89 tb.mu.Lock()
89- p := tb.focusedPane()90+ p := tb.terminalTargetLocked()
91+ floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p
90 tb.mu.Unlock()92 tb.mu.Unlock()
91 if p == nil {93 if p == nil {
92 return94 return
@@ -96,12 +98,21 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
96 p.mu.Unlock()98 p.mu.Unlock()
97 rt.copyMu.Lock()99 rt.copyMu.Lock()
98 rt.copyMode = scopy.NewMode(snap)100 rt.copyMode = scopy.NewMode(snap)
101+ rt.copyPane = p
99 rt.copySearch = nil102 rt.copySearch = nil
100 rt.copySearchPending = nil103 rt.copySearchPending = nil
101 rt.copyPressRowValid = false104 rt.copyPressRowValid = false
102 rt.copyDragging = false105 rt.copyDragging = false
103 rt.normalMousePressValid = false106 rt.normalMousePressValid = false
104 rt.copyMu.Unlock()107 rt.copyMu.Unlock()
108+ if floatingSource {
109+ tb.mu.Lock()
110+ stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p
111+ tb.mu.Unlock()
112+ if !stillVisible {
113+ rt.clearCopyModeForPane(p)
114+ }
115+ }
105 d.paint(sess, ac, true)116 d.paint(sess, ac, true)
106 }117 }
107 118
@@ -110,14 +121,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
110 if ev.Button != mouse.Left {121 if ev.Button != mouse.Left {
111 return122 return
112 }123 }
113- tb := sess.activeTab()124+ p := copyTargetPane(rt)
114- if tb == nil {
115- return
116- }
117-
118- tb.mu.Lock()
119- p := tb.focusedPane()
120- tb.mu.Unlock()
121 if p == nil {125 if p == nil {
122 return126 return
123 }127 }
@@ -133,7 +137,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
133 return137 return
134 }138 }
135 rt.copyMu.Lock()139 rt.copyMu.Lock()
136- if rt.copyMode == nil {140+ if rt.copyMode == nil || rt.copyPane != p {
137 rt.copyMu.Unlock()141 rt.copyMu.Unlock()
138 p.mu.Unlock()142 p.mu.Unlock()
139 return143 return
@@ -175,20 +179,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
175 if sess == nil {179 if sess == nil {
176 return180 return
177 }181 }
178- tb := sess.activeTab()182+ p := copyTargetPane(rt)
179- if tb == nil {
180- return
181- }
182-
183- tb.mu.Lock()
184- p := tb.focusedPane()
185- tb.mu.Unlock()
186 if p == nil {183 if p == nil {
187 return184 return
188 }185 }
189 p.mu.Lock()186 p.mu.Lock()
190 rt.copyMu.Lock()187 rt.copyMu.Lock()
191- if rt.copyMode == nil {188+ if rt.copyMode == nil || rt.copyPane != p {
192 rt.copyPending = nil189 rt.copyPending = nil
193 d.stopCopyPendingTimerLocked(ac)190 d.stopCopyPendingTimerLocked(ac)
194 rt.copyMu.Unlock()191 rt.copyMu.Unlock()
@@ -420,36 +417,27 @@ func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame
420 return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)417 return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
421 }418 }
422 419
423-func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {420+func composeCopyClientFrame(mode *scopy.Mode, p *pane, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
424- styles := newThemeStyles(bars.theme)421+ if mode == nil || p == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
425- p := tb.focusedPane()422+ return frame, nil
426- if p == nil {
427- return renderer.NewFrame(0, 0), nil
428 }423 }
424+ styles := newThemeStyles(bars.theme)
429 p.mu.Lock()425 p.mu.Lock()
430- paneFrame := p.screen.Frame426+ snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
431- paneWidth, paneRows := paneFrame.Width, paneFrame.Height
432- snap := scopy.NewSnapshot(p.scrollback, paneFrame)
433 p.mu.Unlock()427 p.mu.Unlock()
434 copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)428 copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
435- pl, ok := focusedPlacementLocked(tb)429+ bodyRows := max(copyFrame.Height-1, 0)
436- if !ok {430+ for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
437- pl = layout.Placement{Content: domain.Rect{Width: paneWidth, Height: paneRows}}431+ dstX := max(target.X, 0)
438- }432+ srcX := max(-target.X, 0)
439- width, screenRows := paneWidth, paneRows433+ width := min(target.Width-srcX, copyFrame.Width-srcX)
440- if len(tb.panes) > 1 && tb.size.Valid() {434+ width = min(width, frame.Width-dstX)
441- width, screenRows = tb.size.Cols, tb.size.Rows435+ if width > 0 && target.Y+y >= 0 {
442- }436+ copy(frame.Row(target.Y + y)[dstX:dstX+width], copyFrame.Row(y)[srcX:srcX+width])
443- frame := renderer.NewFrame(width, screenRows+2)437+ }
444- drawTopBarSnapshot(frame.Row(0), bars.status, bars.attentionFrame, bars.topRight, styles)
445- base, _ := composeTabFrame(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme)
446- for y := range screenRows {
447- copy(frame.Row(y+1), base.Row(y))
448- }
449- for y := 0; y < pl.Content.Height && y < copyFrame.Height-1; y++ {
450- copy(frame.Row(pl.Content.Y + 1 + y)[pl.Content.X:pl.Content.X+min(pl.Content.Width, copyFrame.Width)], copyFrame.Row(y)[:min(pl.Content.Width, copyFrame.Width)])
451 }438 }
452- statusY := screenRows + 1439+ statusY := frame.Height - 1
453- copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(width, copyFrame.Width)])440+ clear(frame.Row(statusY))
441+ copy(frame.Row(statusY), copyFrame.Row(copyFrame.Height - 1)[:min(frame.Width, copyFrame.Width)])
454 return frame, []renderer.Damage{renderer.FullRedraw()}442 return frame, []renderer.Damage{renderer.FullRedraw()}
455 }443 }
M internal/usecase/daemon/copymode_test.go +163 -9
@@ -17,6 +17,7 @@ import (
1717 "github.com/bnema/vev/internal/ports"
1818 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1919 scopy "github.com/bnema/vev/internal/usecase/copy"
20+ "github.com/bnema/vev/internal/usecase/layout"
2021 "github.com/bnema/vev/pkg/renderer"
2122 "github.com/bnema/vev/pkg/vt"
2223 )
@@ -107,6 +108,9 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
107108 mode := scopy.NewMode(snap)
108109 bars := barState{status: sess.statusSegments()}
109110
111+ base := renderer.NewFrame(80, 25)
112+ target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}
113+
110114 var wg sync.WaitGroup
111115 wg.Add(2)
112116 go func() {
@@ -120,9 +124,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
120124 go func() {
121125 defer wg.Done()
122126 for range 200 {
123- tb.mu.Lock()
124- _, _ = composeCopyClientFrame(mode, tb, bars)
125- tb.mu.Unlock()
127+ _, _ = composeCopyClientFrame(mode, pane, target, base, bars)
126128 }
127129 }()
128130 wg.Wait()
@@ -139,12 +141,15 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
139141 snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
140142 mode := scopy.NewMode(snap)
141143
142- frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})
143-
144- require.Equal(t, 12, frame.Width)
145- require.Equal(t, 5, frame.Height)
146- require.Equal(t, " 1 ", rowText(frame.Row(0)))
147- require.Contains(t, rowText(frame.Row(4)), "[SCROLL]")
144+ bars := barState{status: sess.statusSegments()}
145+ base, _ := composeClientFrameWithState(bars, tb, true)
146+ frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
147+
148+ require.Equal(t, 80, frame.Width)
149+ require.Equal(t, 25, frame.Height)
150+ require.Equal(t, " 1", strings.TrimRight(rowText(frame.Row(0)), " "))
151+ require.Contains(t, rowText(frame.Row(1)), "live")
152+ require.Contains(t, rowText(frame.Row(24)), "[SCROLL]")
148153 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
149154 }
150155
@@ -636,3 +641,152 @@ func TestCursorTailHidesInCopyMode(t *testing.T) {
636641 data := mustOutputData(t, sends)
637642 require.Contains(t, string(data), "\x1b[?25l")
638643 }
644+
645+func installFloatingCopyFixture(t *testing.T, d *Daemon, sess *session, size domain.Size) *pane {
646+ t.Helper()
647+ floatingPTY, releaseFloating := newBlockingPTY(t)
648+ t.Cleanup(releaseFloating)
649+ fp := newPane("floating", floatingPTY, size)
650+ installTestFloating(sess.activeTab(), fp, true)
651+ return fp
652+}
653+
654+func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
655+ cases := []struct {
656+ name string
657+ useFloating bool
658+ wantText string
659+ }{
660+ {name: "floating source", useFloating: true, wantText: "flt-old\nflt-live"},
661+ {name: "normal source", useFloating: false, wantText: "one-old\none-live"},
662+ }
663+ for _, tc := range cases {
664+ t.Run(tc.name, func(t *testing.T) {
665+ normalWrites := make(chan []byte, 1)
666+ normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
667+ defer releaseNormal()
668+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
669+ tb := sess.activeTab()
670+ second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
671+ tb.mu.Lock()
672+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
673+ tb.panes["pane-2"] = second
674+ tb.mu.Unlock()
675+ main := tb.focusedPane()
676+ var want *pane
677+ if tc.useFloating {
678+ want = installFloatingCopyFixture(t, d, sess, domain.Size{Cols: 20, Rows: 3})
679+ want.scrollback.Append(testRow("flt-old"))
680+ want.screen.Write([]byte("flt-live"))
681+ } else {
682+ want = main
683+ want.scrollback.Append(testRow("one-old"))
684+ want.screen.Write([]byte("one-live"))
685+ }
686+
687+ d.enterCopyMode(sess, ac)
688+ awaitFrame(t, sends, ports.MsgOutput)
689+ ac.overlays.copyMu.Lock()
690+ captured := ac.overlays.copyPane
691+ ac.overlays.copyMu.Unlock()
692+ require.Same(t, want, captured)
693+
694+ tb.mu.Lock()
695+ focusPlacementLocked(tb, "pane-2")
696+ tb.mu.Unlock()
697+
698+ d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'})
699+ out := awaitFrame(t, sends, ports.MsgOutput)
700+ msg, err := ports.UnmarshalOutput(out.Payload)
701+ require.NoError(t, err)
702+ wantOSC := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(tc.wantText)) + "\x07"
703+ require.Equal(t, wantOSC, string(msg.Data))
704+ require.Nil(t, ac.overlays.copyMode)
705+ select {
706+ case got := <-normalWrites:
707+ t.Fatalf("copy-mode input forwarded to normal PTY: %q", got)
708+ default:
709+ }
710+ })
711+ }
712+}
713+
714+func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
715+ normal, releaseNormal := newBlockingPTY(t)
716+ defer releaseNormal()
717+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
718+ fp := installFloatingCopyFixture(t, d, sess, domain.Size{Cols: 20, Rows: 3})
719+ for i := range 30 {
720+ fp.scrollback.Append(testRow(fmt.Sprintf("old-%02d", i)))
721+ }
722+ fp.screen.Write([]byte("live"))
723+ fp.mu.Lock()
724+ total := len(scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Rows)
725+ fp.mu.Unlock()
726+
727+ d.enterCopyMode(sess, ac)
728+ awaitFrame(t, sends, ports.MsgOutput)
729+ require.Equal(t, total-1, ac.overlays.copyMode.Cursor)
730+
731+ d.copyWheel(sess, ac, -3)
732+ awaitFrame(t, sends, ports.MsgOutput)
733+ require.Equal(t, total-4, ac.overlays.copyMode.Cursor)
734+
735+ d.copyWheel(sess, ac, 3)
736+ awaitFrame(t, sends, ports.MsgOutput)
737+ require.Nil(t, ac.overlays.copyMode, "wheel down reaching the captured bottom exits copy mode")
738+}
739+
740+func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
741+ normal, releaseNormal := newBlockingPTY(t)
742+ defer releaseNormal()
743+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
744+ fp := installFloatingCopyFixture(t, d, sess, domain.Size{Cols: 20, Rows: 3})
745+ for i := range 5 {
746+ fp.scrollback.Append(testRow(fmt.Sprintf("old-%d", i)))
747+ }
748+ fp.screen.Write([]byte("live"))
749+
750+ d.enterCopyMode(sess, ac)
751+ awaitFrame(t, sends, ports.MsgOutput)
752+ viewportTop := ac.overlays.copyMode.ViewportTop
753+ inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner
754+
755+ press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+2)
756+ d.handleInput(sess, ac, []byte(press))
757+ awaitFrame(t, sends, ports.MsgOutput)
758+ require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor)
759+
760+ motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+3)
761+ d.handleInput(sess, ac, []byte(motion))
762+ awaitFrame(t, sends, ports.MsgOutput)
763+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
764+ require.True(t, ok)
765+ require.Equal(t, viewportTop+1, lo)
766+ require.Equal(t, viewportTop+2, hi)
767+}
768+
769+func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
770+ normal, releaseNormal := newBlockingPTY(t)
771+ defer releaseNormal()
772+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
773+ tb := sess.activeTab()
774+ fp := installFloatingCopyFixture(t, d, sess, domain.Size{Cols: 20, Rows: 3})
775+ fp.screen.Write([]byte("flt-live"))
776+
777+ d.enterCopyMode(sess, ac)
778+ awaitFrame(t, sends, ports.MsgOutput)
779+ tb.mu.Lock()
780+ generation := tb.floating.generation
781+ tb.mu.Unlock()
782+
783+ d.reapFloating(sess, tb, fp, generation)
784+
785+ ac.overlays.copyMu.Lock()
786+ require.Nil(t, ac.overlays.copyMode)
787+ require.Nil(t, ac.overlays.copyPane)
788+ ac.overlays.copyMu.Unlock()
789+ data := mustOutputData(t, sends)
790+ require.NotContains(t, string(data), "[SCROLL]")
791+ require.NotContains(t, string(data), "flt-live")
792+}
@@ -17,6 +17,7 @@ import (
17 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 scopy "github.com/bnema/vev/internal/usecase/copy"19 scopy "github.com/bnema/vev/internal/usecase/copy"
20+ "github.com/bnema/vev/internal/usecase/layout"
20 "github.com/bnema/vev/pkg/renderer"21 "github.com/bnema/vev/pkg/renderer"
21 "github.com/bnema/vev/pkg/vt"22 "github.com/bnema/vev/pkg/vt"
22 )23 )
@@ -107,6 +108,9 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
107 mode := scopy.NewMode(snap)108 mode := scopy.NewMode(snap)
108 bars := barState{status: sess.statusSegments()}109 bars := barState{status: sess.statusSegments()}
109 110
111+ base := renderer.NewFrame(80, 25)
112+ target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}
113+
110 var wg sync.WaitGroup114 var wg sync.WaitGroup
111 wg.Add(2)115 wg.Add(2)
112 go func() {116 go func() {
@@ -120,9 +124,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
120 go func() {124 go func() {
121 defer wg.Done()125 defer wg.Done()
122 for range 200 {126 for range 200 {
123- tb.mu.Lock()127+ _, _ = composeCopyClientFrame(mode, pane, target, base, bars)
124- _, _ = composeCopyClientFrame(mode, tb, bars)
125- tb.mu.Unlock()
126 }128 }
127 }()129 }()
128 wg.Wait()130 wg.Wait()
@@ -139,12 +141,15 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
139 snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)141 snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
140 mode := scopy.NewMode(snap)142 mode := scopy.NewMode(snap)
141 143
142- frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})144+ bars := barState{status: sess.statusSegments()}
143-145+ base, _ := composeClientFrameWithState(bars, tb, true)
144- require.Equal(t, 12, frame.Width)146+ frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
145- require.Equal(t, 5, frame.Height)147+
146- require.Equal(t, " 1 ", rowText(frame.Row(0)))148+ require.Equal(t, 80, frame.Width)
147- require.Contains(t, rowText(frame.Row(4)), "[SCROLL]")149+ require.Equal(t, 25, frame.Height)
150+ require.Equal(t, " 1", strings.TrimRight(rowText(frame.Row(0)), " "))
151+ require.Contains(t, rowText(frame.Row(1)), "live")
152+ require.Contains(t, rowText(frame.Row(24)), "[SCROLL]")
148 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)153 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
149 }154 }
150 155
@@ -636,3 +641,152 @@ func TestCursorTailHidesInCopyMode(t *testing.T) {
636 data := mustOutputData(t, sends)641 data := mustOutputData(t, sends)
637 require.Contains(t, string(data), "\x1b[?25l")642 require.Contains(t, string(data), "\x1b[?25l")
638 }643 }
644+
645+func installFloatingCopyFixture(t *testing.T, d *Daemon, sess *session, size domain.Size) *pane {
646+ t.Helper()
647+ floatingPTY, releaseFloating := newBlockingPTY(t)
648+ t.Cleanup(releaseFloating)
649+ fp := newPane("floating", floatingPTY, size)
650+ installTestFloating(sess.activeTab(), fp, true)
651+ return fp
652+}
653+
654+func TestCopyModeCapturesSourceAndRetainsItAcrossFocusMove(t *testing.T) {
655+ cases := []struct {
656+ name string
657+ useFloating bool
658+ wantText string
659+ }{
660+ {name: "floating source", useFloating: true, wantText: "flt-old\nflt-live"},
661+ {name: "normal source", useFloating: false, wantText: "one-old\none-live"},
662+ }
663+ for _, tc := range cases {
664+ t.Run(tc.name, func(t *testing.T) {
665+ normalWrites := make(chan []byte, 1)
666+ normal, releaseNormal := newBlockingPTYWithWrites(t, normalWrites)
667+ defer releaseNormal()
668+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
669+ tb := sess.activeTab()
670+ second := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 5})
671+ tb.mu.Lock()
672+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
673+ tb.panes["pane-2"] = second
674+ tb.mu.Unlock()
675+ main := tb.focusedPane()
676+ var want *pane
677+ if tc.useFloating {
678+ want = installFloatingCopyFixture(t, d, sess, domain.Size{Cols: 20, Rows: 3})
679+ want.scrollback.Append(testRow("flt-old"))
680+ want.screen.Write([]byte("flt-live"))
681+ } else {
682+ want = main
683+ want.scrollback.Append(testRow("one-old"))
684+ want.screen.Write([]byte("one-live"))
685+ }
686+
687+ d.enterCopyMode(sess, ac)
688+ awaitFrame(t, sends, ports.MsgOutput)
689+ ac.overlays.copyMu.Lock()
690+ captured := ac.overlays.copyPane
691+ ac.overlays.copyMu.Unlock()
692+ require.Same(t, want, captured)
693+
694+ tb.mu.Lock()
695+ focusPlacementLocked(tb, "pane-2")
696+ tb.mu.Unlock()
697+
698+ d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'})
699+ out := awaitFrame(t, sends, ports.MsgOutput)
700+ msg, err := ports.UnmarshalOutput(out.Payload)
701+ require.NoError(t, err)
702+ wantOSC := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(tc.wantText)) + "\x07"
703+ require.Equal(t, wantOSC, string(msg.Data))
704+ require.Nil(t, ac.overlays.copyMode)
705+ select {
706+ case got := <-normalWrites:
707+ t.Fatalf("copy-mode input forwarded to normal PTY: %q", got)
708+ default:
709+ }
710+ })
711+ }
712+}
713+
714+func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
715+ normal, releaseNormal := newBlockingPTY(t)
716+ defer releaseNormal()
717+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
718+ fp := installFloatingCopyFixture(t, d, sess, domain.Size{Cols: 20, Rows: 3})
719+ for i := range 30 {
720+ fp.scrollback.Append(testRow(fmt.Sprintf("old-%02d", i)))
721+ }
722+ fp.screen.Write([]byte("live"))
723+ fp.mu.Lock()
724+ total := len(scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Rows)
725+ fp.mu.Unlock()
726+
727+ d.enterCopyMode(sess, ac)
728+ awaitFrame(t, sends, ports.MsgOutput)
729+ require.Equal(t, total-1, ac.overlays.copyMode.Cursor)
730+
731+ d.copyWheel(sess, ac, -3)
732+ awaitFrame(t, sends, ports.MsgOutput)
733+ require.Equal(t, total-4, ac.overlays.copyMode.Cursor)
734+
735+ d.copyWheel(sess, ac, 3)
736+ awaitFrame(t, sends, ports.MsgOutput)
737+ require.Nil(t, ac.overlays.copyMode, "wheel down reaching the captured bottom exits copy mode")
738+}
739+
740+func TestFloatingCopyModeMouseSelectsFloatingRows(t *testing.T) {
741+ normal, releaseNormal := newBlockingPTY(t)
742+ defer releaseNormal()
743+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
744+ fp := installFloatingCopyFixture(t, d, sess, domain.Size{Cols: 20, Rows: 3})
745+ for i := range 5 {
746+ fp.scrollback.Append(testRow(fmt.Sprintf("old-%d", i)))
747+ }
748+ fp.screen.Write([]byte("live"))
749+
750+ d.enterCopyMode(sess, ac)
751+ awaitFrame(t, sends, ports.MsgOutput)
752+ viewportTop := ac.overlays.copyMode.ViewportTop
753+ inner := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 23}, d.currentFloatingConfig()).Inner
754+
755+ press := fmt.Sprintf("\x1b[<0;%d;%dM", inner.X+3, inner.Y+2)
756+ d.handleInput(sess, ac, []byte(press))
757+ awaitFrame(t, sends, ports.MsgOutput)
758+ require.Equal(t, viewportTop+1, ac.overlays.copyMode.Cursor)
759+
760+ motion := fmt.Sprintf("\x1b[<32;%d;%dM", inner.X+3, inner.Y+3)
761+ d.handleInput(sess, ac, []byte(motion))
762+ awaitFrame(t, sends, ports.MsgOutput)
763+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
764+ require.True(t, ok)
765+ require.Equal(t, viewportTop+1, lo)
766+ require.Equal(t, viewportTop+2, hi)
767+}
768+
769+func TestFloatingExitClearsCopyModeBeforeRepaint(t *testing.T) {
770+ normal, releaseNormal := newBlockingPTY(t)
771+ defer releaseNormal()
772+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
773+ tb := sess.activeTab()
774+ fp := installFloatingCopyFixture(t, d, sess, domain.Size{Cols: 20, Rows: 3})
775+ fp.screen.Write([]byte("flt-live"))
776+
777+ d.enterCopyMode(sess, ac)
778+ awaitFrame(t, sends, ports.MsgOutput)
779+ tb.mu.Lock()
780+ generation := tb.floating.generation
781+ tb.mu.Unlock()
782+
783+ d.reapFloating(sess, tb, fp, generation)
784+
785+ ac.overlays.copyMu.Lock()
786+ require.Nil(t, ac.overlays.copyMode)
787+ require.Nil(t, ac.overlays.copyPane)
788+ ac.overlays.copyMu.Unlock()
789+ data := mustOutputData(t, sends)
790+ require.NotContains(t, string(data), "[SCROLL]")
791+ require.NotContains(t, string(data), "flt-live")
792+}
M internal/usecase/daemon/floating.go +6 -7
@@ -357,13 +357,12 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64
357357 return
358358 }
359359 closeFloatingPane(p)
360- if visible {
361- sess.mu.Lock()
362- ac := sess.client
363- sess.mu.Unlock()
364- if ac != nil {
365- d.paint(sess, ac, true)
366- }
360+ sess.mu.Lock()
361+ ac := sess.client
362+ sess.mu.Unlock()
363+ copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
364+ if ac != nil && (visible || copyCleared) {
365+ d.paint(sess, ac, true)
367366 }
368367 }
369368
@@ -357,13 +357,12 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64
357 return357 return
358 }358 }
359 closeFloatingPane(p)359 closeFloatingPane(p)
360- if visible {360+ sess.mu.Lock()
361- sess.mu.Lock()361+ ac := sess.client
362- ac := sess.client362+ sess.mu.Unlock()
363- sess.mu.Unlock()363+ copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
364- if ac != nil {364+ if ac != nil && (visible || copyCleared) {
365- d.paint(sess, ac, true)365+ d.paint(sess, ac, true)
366- }
367 }366 }
368 }367 }
369 368
M internal/usecase/daemon/input.go +21 -0
@@ -74,6 +74,27 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
7474 if rt.copySearchActive() {
7575 return
7676 }
77+ copyPane := copyTargetPane(rt)
78+ tb.mu.Lock()
79+ floatingPane, geometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
80+ floatingCopy := floatingVisible && copyPane == floatingPane
81+ tb.mu.Unlock()
82+ if floatingCopy {
83+ contentRow := ev.Row - 1
84+ if !pointInRect(ev.Col, contentRow, geometry.Inner) {
85+ return
86+ }
87+ ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y)
88+ switch ev.Button {
89+ case mouse.Left:
90+ d.copyMouse(sess, ac, ev)
91+ case mouse.WheelUp:
92+ d.copyWheel(sess, ac, -3)
93+ case mouse.WheelDown:
94+ d.copyWheel(sess, ac, 3)
95+ }
96+ return
97+ }
7798 if ev.Button == mouse.Left {
7899 contentRow := ev.Row - 1
79100 tb.mu.Lock()
@@ -74,6 +74,27 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
74 if rt.copySearchActive() {74 if rt.copySearchActive() {
75 return75 return
76 }76 }
77+ copyPane := copyTargetPane(rt)
78+ tb.mu.Lock()
79+ floatingPane, geometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
80+ floatingCopy := floatingVisible && copyPane == floatingPane
81+ tb.mu.Unlock()
82+ if floatingCopy {
83+ contentRow := ev.Row - 1
84+ if !pointInRect(ev.Col, contentRow, geometry.Inner) {
85+ return
86+ }
87+ ev = translateMouseEvent(ev, geometry.Inner.X, geometry.Inner.Y)
88+ switch ev.Button {
89+ case mouse.Left:
90+ d.copyMouse(sess, ac, ev)
91+ case mouse.WheelUp:
92+ d.copyWheel(sess, ac, -3)
93+ case mouse.WheelDown:
94+ d.copyWheel(sess, ac, 3)
95+ }
96+ return
97+ }
77 if ev.Button == mouse.Left {98 if ev.Button == mouse.Left {
78 contentRow := ev.Row - 199 contentRow := ev.Row - 1
79 tb.mu.Lock()100 tb.mu.Lock()
M internal/usecase/daemon/overlay_runtime.go +17 -0
@@ -30,6 +30,7 @@ type overlayRuntime struct {
3030
3131 copyMu sync.Mutex
3232 copyMode *scopy.Mode
33+ copyPane *pane
3334 copyPending []byte
3435 copyESC pendingByteTimer
3536 copySearch *visualsearch.Model
@@ -101,10 +102,24 @@ func (rt *overlayRuntime) copySearchActive() bool {
101102
102103 func (rt *overlayRuntime) clearCopyModeLocked() {
103104 rt.copyMode = nil
105+ rt.copyPane = nil
104106 rt.copySearch = nil
105107 rt.copySearchPending = nil
106108 }
107109
110+func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
111+ if rt == nil || p == nil {
112+ return false
113+ }
114+ rt.copyMu.Lock()
115+ defer rt.copyMu.Unlock()
116+ if rt.copyMode == nil || rt.copyPane != p {
117+ return false
118+ }
119+ rt.clearCopyModeLocked()
120+ return true
121+}
122+
108123 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
109124 if rt == nil || rt.ac == nil {
110125 return false
@@ -134,6 +149,7 @@ type overlayRenderSnapshot struct {
134149
135150 copyActive bool
136151 copyMode *scopy.Mode
152+ copyPane *pane
137153 copySearchModel *visualsearch.Model
138154 copyFeedback string
139155
@@ -164,6 +180,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
164180
165181 rt.copyMu.Lock()
166182 snap.copyActive = rt.copyMode != nil
183+ snap.copyPane = rt.copyPane
167184 if rt.copyMode != nil {
168185 copyModeValue := *rt.copyMode
169186 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
@@ -30,6 +30,7 @@ type overlayRuntime struct {
30 30
31 copyMu sync.Mutex31 copyMu sync.Mutex
32 copyMode *scopy.Mode32 copyMode *scopy.Mode
33+ copyPane *pane
33 copyPending []byte34 copyPending []byte
34 copyESC pendingByteTimer35 copyESC pendingByteTimer
35 copySearch *visualsearch.Model36 copySearch *visualsearch.Model
@@ -101,10 +102,24 @@ func (rt *overlayRuntime) copySearchActive() bool {
101 102
102 func (rt *overlayRuntime) clearCopyModeLocked() {103 func (rt *overlayRuntime) clearCopyModeLocked() {
103 rt.copyMode = nil104 rt.copyMode = nil
105+ rt.copyPane = nil
104 rt.copySearch = nil106 rt.copySearch = nil
105 rt.copySearchPending = nil107 rt.copySearchPending = nil
106 }108 }
107 109
110+func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
111+ if rt == nil || p == nil {
112+ return false
113+ }
114+ rt.copyMu.Lock()
115+ defer rt.copyMu.Unlock()
116+ if rt.copyMode == nil || rt.copyPane != p {
117+ return false
118+ }
119+ rt.clearCopyModeLocked()
120+ return true
121+}
122+
108 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {123 func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
109 if rt == nil || rt.ac == nil {124 if rt == nil || rt.ac == nil {
110 return false125 return false
@@ -134,6 +149,7 @@ type overlayRenderSnapshot struct {
134 149
135 copyActive bool150 copyActive bool
136 copyMode *scopy.Mode151 copyMode *scopy.Mode
152+ copyPane *pane
137 copySearchModel *visualsearch.Model153 copySearchModel *visualsearch.Model
138 copyFeedback string154 copyFeedback string
139 155
@@ -164,6 +180,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
164 180
165 rt.copyMu.Lock()181 rt.copyMu.Lock()
166 snap.copyActive = rt.copyMode != nil182 snap.copyActive = rt.copyMode != nil
183+ snap.copyPane = rt.copyPane
167 if rt.copyMode != nil {184 if rt.copyMode != nil {
168 copyModeValue := *rt.copyMode185 copyModeValue := *rt.copyMode
169 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)186 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
M internal/usecase/daemon/overlay_runtime_test.go +23 -0
@@ -5,6 +5,7 @@ import (
55
66 "github.com/stretchr/testify/require"
77
8+ "github.com/bnema/vev/internal/domain"
89 scopy "github.com/bnema/vev/internal/usecase/copy"
910 promptui "github.com/bnema/vev/internal/usecase/prompt"
1011 "github.com/bnema/vev/internal/usecase/visualsearch"
@@ -143,3 +144,25 @@ func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
143144
144145 require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))
145146 }
147+
148+func TestOverlayRuntimeSnapshotCarriesCopyPaneAndClearReleasesIt(t *testing.T) {
149+ ac := &attachedClient{}
150+ ac.initOverlays()
151+ p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2})
152+ snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("row")}, Width: 4, Height: 2}
153+
154+ ac.overlays.copyMu.Lock()
155+ ac.overlays.copyMode = scopy.NewMode(snap)
156+ ac.overlays.copyPane = p
157+ ac.overlays.copyMu.Unlock()
158+
159+ renderSnap := ac.overlays.SnapshotForRender()
160+ require.Same(t, p, renderSnap.copyPane)
161+ renderSnap.Unlock()
162+
163+ ac.overlays.copyMu.Lock()
164+ ac.overlays.clearCopyModeLocked()
165+ require.Nil(t, ac.overlays.copyPane)
166+ require.Nil(t, ac.overlays.copyMode)
167+ ac.overlays.copyMu.Unlock()
168+}
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8+ "github.com/bnema/vev/internal/domain"
8 scopy "github.com/bnema/vev/internal/usecase/copy"9 scopy "github.com/bnema/vev/internal/usecase/copy"
9 promptui "github.com/bnema/vev/internal/usecase/prompt"10 promptui "github.com/bnema/vev/internal/usecase/prompt"
10 "github.com/bnema/vev/internal/usecase/visualsearch"11 "github.com/bnema/vev/internal/usecase/visualsearch"
@@ -143,3 +144,25 @@ func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
143 144
144 require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))145 require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))
145 }146 }
147+
148+func TestOverlayRuntimeSnapshotCarriesCopyPaneAndClearReleasesIt(t *testing.T) {
149+ ac := &attachedClient{}
150+ ac.initOverlays()
151+ p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2})
152+ snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("row")}, Width: 4, Height: 2}
153+
154+ ac.overlays.copyMu.Lock()
155+ ac.overlays.copyMode = scopy.NewMode(snap)
156+ ac.overlays.copyPane = p
157+ ac.overlays.copyMu.Unlock()
158+
159+ renderSnap := ac.overlays.SnapshotForRender()
160+ require.Same(t, p, renderSnap.copyPane)
161+ renderSnap.Unlock()
162+
163+ ac.overlays.copyMu.Lock()
164+ ac.overlays.clearCopyModeLocked()
165+ require.Nil(t, ac.overlays.copyPane)
166+ require.Nil(t, ac.overlays.copyMode)
167+ ac.overlays.copyMu.Unlock()
168+}
M internal/usecase/daemon/render.go +37 -8
@@ -275,6 +275,30 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
275275 // paint draws the composed client frame (active tab plus status bar) and
276276 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
277277 // such as switch/create/close/rename/resize so the repaint is complete.
278+// copyTargetRectLocked maps a captured copy source into the already-composed
279+// client frame. The caller holds tb.mu, preserving the layout/floating
280+// snapshot while the rectangle is chosen.
281+func copyTargetRectLocked(tb *tab, layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, cfg domain.FloatingConfig) domain.Rect {
282+ if p == nil {
283+ return domain.Rect{}
284+ }
285+ if hasFloating && p == floating {
286+ return calculateFloatingGeometry(contentArea, cfg).Inner
287+ }
288+ for _, placement := range layoutSnap.placements {
289+ if placement.ID == p.id && !placement.Collapsed {
290+ r := placement.Content
291+ r.X += contentArea.X
292+ r.Y += contentArea.Y
293+ return r
294+ }
295+ }
296+ p.mu.Lock()
297+ width, height := p.screen.Frame.Width, p.screen.Frame.Height
298+ p.mu.Unlock()
299+ return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
300+}
301+
278302 func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {
279303 if !ok {
280304 return nil
@@ -367,19 +391,24 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
367391 } else {
368392 frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
369393 }
394+ contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
395+ if hasFloating {
396+ frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, d.currentFloatingConfig(), layoutSnap, bars.theme, &ac.composed, reset || overlayActive)
397+ }
370398 if overlays.copyActive {
371- frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
399+ copyPane := overlays.copyPane
400+ if copyPane == nil {
401+ copyPane = p
402+ }
403+ copyTarget := copyTargetRectLocked(tb, layoutSnap, contentArea, copyPane, floating, hasFloating, d.currentFloatingConfig())
404+ frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars)
372405 }
373- contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
374- // Palette dimming belongs below both the popup and the palette itself.
375- // The floating backdrop is canonical and deliberately only dims pane
376- // contents, leaving normal chrome and the popup crisp.
406+ // A palette above normal/copy content dims that composed content. When a
407+ // floating pane is present its own backdrop already dims normal pane
408+ // contents; applying the palette backdrop here would also dim the popup.
377409 if overlays.paletteActive && !hasFloating {
378410 (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)
379411 }
380- if hasFloating {
381- frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, d.currentFloatingConfig(), layoutSnap, bars.theme, &ac.composed, reset || overlayActive)
382- }
383412 if overlays.copySearchModel != nil {
384413 frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles)
385414 }
@@ -275,6 +275,30 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
275 // paint draws the composed client frame (active tab plus status bar) and275 // paint draws the composed client frame (active tab plus status bar) and
276 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations276 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
277 // such as switch/create/close/rename/resize so the repaint is complete.277 // such as switch/create/close/rename/resize so the repaint is complete.
278+// copyTargetRectLocked maps a captured copy source into the already-composed
279+// client frame. The caller holds tb.mu, preserving the layout/floating
280+// snapshot while the rectangle is chosen.
281+func copyTargetRectLocked(tb *tab, layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, cfg domain.FloatingConfig) domain.Rect {
282+ if p == nil {
283+ return domain.Rect{}
284+ }
285+ if hasFloating && p == floating {
286+ return calculateFloatingGeometry(contentArea, cfg).Inner
287+ }
288+ for _, placement := range layoutSnap.placements {
289+ if placement.ID == p.id && !placement.Collapsed {
290+ r := placement.Content
291+ r.X += contentArea.X
292+ r.Y += contentArea.Y
293+ return r
294+ }
295+ }
296+ p.mu.Lock()
297+ width, height := p.screen.Frame.Width, p.screen.Frame.Height
298+ p.mu.Unlock()
299+ return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
300+}
301+
278 func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {302 func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {
279 if !ok {303 if !ok {
280 return nil304 return nil
@@ -367,19 +391,24 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
367 } else {391 } else {
368 frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)392 frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
369 }393 }
394+ contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
395+ if hasFloating {
396+ frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, d.currentFloatingConfig(), layoutSnap, bars.theme, &ac.composed, reset || overlayActive)
397+ }
370 if overlays.copyActive {398 if overlays.copyActive {
371- frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)399+ copyPane := overlays.copyPane
400+ if copyPane == nil {
401+ copyPane = p
402+ }
403+ copyTarget := copyTargetRectLocked(tb, layoutSnap, contentArea, copyPane, floating, hasFloating, d.currentFloatingConfig())
404+ frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars)
372 }405 }
373- contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}406+ // A palette above normal/copy content dims that composed content. When a
374- // Palette dimming belongs below both the popup and the palette itself.407+ // floating pane is present its own backdrop already dims normal pane
375- // The floating backdrop is canonical and deliberately only dims pane408+ // contents; applying the palette backdrop here would also dim the popup.
376- // contents, leaving normal chrome and the popup crisp.
377 if overlays.paletteActive && !hasFloating {409 if overlays.paletteActive && !hasFloating {
378 (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)410 (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)
379 }411 }
380- if hasFloating {
381- frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, d.currentFloatingConfig(), layoutSnap, bars.theme, &ac.composed, reset || overlayActive)
382- }
383 if overlays.copySearchModel != nil {412 if overlays.copySearchModel != nil {
384 frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles)413 frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles)
385 }414 }
M internal/usecase/daemon/render_test.go +75 -0
@@ -1423,3 +1423,78 @@ func TestHistoryNavBottomBar(t *testing.T) {
14231423 require.True(t, row[docsCol].Style.Equal(styles.accent))
14241424 })
14251425 }
1426+
1427+func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1428+ base := renderer.NewFrame(20, 8)
1429+ for y := range base.Height {
1430+ for x := range base.Width {
1431+ base.Set(x, y, renderer.Cell{Rune: '#', Style: renderer.DefaultStyle()})
1432+ }
1433+ }
1434+ p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1435+ p.screen.Write([]byte("ab\r\ncd"))
1436+ mode := scopy.NewMode(scopy.NewSnapshot(p.scrollback, p.screen.Frame))
1437+ target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1438+
1439+ frame, damage := composeCopyClientFrame(mode, p, target, base, barState{})
1440+
1441+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1442+ require.Equal(t, "##ab ", rowText(frame.Row(3)))
1443+ require.Equal(t, "##cd ", rowText(frame.Row(4)))
1444+ require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(2)))
1445+ require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(5)))
1446+ require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(6)))
1447+ status := rowText(frame.Row(7))
1448+ require.Contains(t, status, "[SCROLL]")
1449+ require.NotContains(t, status, "#")
1450+}
1451+
1452+func TestCopyTargetRectLocked(t *testing.T) {
1453+ tb := newTab(nil, domain.Size{Cols: 80, Rows: 23})
1454+ cfg := domain.Defaults().Floating
1455+ contentArea := domain.Rect{Y: 1, Width: 80, Height: 23}
1456+ fp := newPane("floating", nil, domain.Size{Cols: 10, Rows: 4})
1457+ stray := newPane("stray", nil, domain.Size{Cols: 10, Rows: 4})
1458+ tb.mu.Lock()
1459+ defer tb.mu.Unlock()
1460+ layoutSnap := solveTabLayoutLocked(tb)
1461+ main := tb.focusedPane()
1462+
1463+ cases := []struct {
1464+ name string
1465+ pane *pane
1466+ floating *pane
1467+ hasFloating bool
1468+ want domain.Rect
1469+ }{
1470+ {name: "floating source targets popup inner", pane: fp, floating: fp, hasFloating: true, want: calculateFloatingGeometry(contentArea, cfg).Inner},
1471+ {name: "normal source targets solved placement", pane: main, want: domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}},
1472+ {name: "unplaced source falls back to pane frame", pane: stray, want: domain.Rect{X: 0, Y: 1, Width: 10, Height: 4}},
1473+ }
1474+ for _, tc := range cases {
1475+ t.Run(tc.name, func(t *testing.T) {
1476+ require.Equal(t, tc.want, copyTargetRectLocked(tb, layoutSnap, contentArea, tc.pane, tc.floating, tc.hasFloating, cfg))
1477+ })
1478+ }
1479+}
1480+
1481+func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1482+ normal, releaseNormal := newBlockingPTY(t)
1483+ floatingPTY, releaseFloating := newBlockingPTY(t)
1484+ defer releaseNormal()
1485+ defer releaseFloating()
1486+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
1487+ fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1488+ fp.scrollback.Append(testRow("flt-old"))
1489+ fp.screen.Write([]byte("flt-live"))
1490+ installTestFloating(sess.activeTab(), fp, true)
1491+
1492+ d.enterCopyMode(sess, ac)
1493+ data := mustOutputData(t, sends)
1494+ require.Contains(t, string(data), "[SCROLL]")
1495+ require.Contains(t, string(data), "\x1b[?25l")
1496+
1497+ d.handleInput(sess, ac, []byte("g"))
1498+ data = mustOutputData(t, sends)
1499+ require.Contains(t, string(data), "lt-old", "copy viewport of the captured floating pane must compose above the popup")
1500+}
@@ -1423,3 +1423,78 @@ func TestHistoryNavBottomBar(t *testing.T) {
1423 require.True(t, row[docsCol].Style.Equal(styles.accent))1423 require.True(t, row[docsCol].Style.Equal(styles.accent))
1424 })1424 })
1425 }1425 }
1426+
1427+func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1428+ base := renderer.NewFrame(20, 8)
1429+ for y := range base.Height {
1430+ for x := range base.Width {
1431+ base.Set(x, y, renderer.Cell{Rune: '#', Style: renderer.DefaultStyle()})
1432+ }
1433+ }
1434+ p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1435+ p.screen.Write([]byte("ab\r\ncd"))
1436+ mode := scopy.NewMode(scopy.NewSnapshot(p.scrollback, p.screen.Frame))
1437+ target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1438+
1439+ frame, damage := composeCopyClientFrame(mode, p, target, base, barState{})
1440+
1441+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1442+ require.Equal(t, "##ab ", rowText(frame.Row(3)))
1443+ require.Equal(t, "##cd ", rowText(frame.Row(4)))
1444+ require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(2)))
1445+ require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(5)))
1446+ require.Equal(t, strings.Repeat("#", 20), rowText(frame.Row(6)))
1447+ status := rowText(frame.Row(7))
1448+ require.Contains(t, status, "[SCROLL]")
1449+ require.NotContains(t, status, "#")
1450+}
1451+
1452+func TestCopyTargetRectLocked(t *testing.T) {
1453+ tb := newTab(nil, domain.Size{Cols: 80, Rows: 23})
1454+ cfg := domain.Defaults().Floating
1455+ contentArea := domain.Rect{Y: 1, Width: 80, Height: 23}
1456+ fp := newPane("floating", nil, domain.Size{Cols: 10, Rows: 4})
1457+ stray := newPane("stray", nil, domain.Size{Cols: 10, Rows: 4})
1458+ tb.mu.Lock()
1459+ defer tb.mu.Unlock()
1460+ layoutSnap := solveTabLayoutLocked(tb)
1461+ main := tb.focusedPane()
1462+
1463+ cases := []struct {
1464+ name string
1465+ pane *pane
1466+ floating *pane
1467+ hasFloating bool
1468+ want domain.Rect
1469+ }{
1470+ {name: "floating source targets popup inner", pane: fp, floating: fp, hasFloating: true, want: calculateFloatingGeometry(contentArea, cfg).Inner},
1471+ {name: "normal source targets solved placement", pane: main, want: domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}},
1472+ {name: "unplaced source falls back to pane frame", pane: stray, want: domain.Rect{X: 0, Y: 1, Width: 10, Height: 4}},
1473+ }
1474+ for _, tc := range cases {
1475+ t.Run(tc.name, func(t *testing.T) {
1476+ require.Equal(t, tc.want, copyTargetRectLocked(tb, layoutSnap, contentArea, tc.pane, tc.floating, tc.hasFloating, cfg))
1477+ })
1478+ }
1479+}
1480+
1481+func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1482+ normal, releaseNormal := newBlockingPTY(t)
1483+ floatingPTY, releaseFloating := newBlockingPTY(t)
1484+ defer releaseNormal()
1485+ defer releaseFloating()
1486+ d, sess, ac, sends := newManualSessionWithPTYs(t, normal)
1487+ fp := newPane("floating", floatingPTY, domain.Size{Cols: 20, Rows: 3})
1488+ fp.scrollback.Append(testRow("flt-old"))
1489+ fp.screen.Write([]byte("flt-live"))
1490+ installTestFloating(sess.activeTab(), fp, true)
1491+
1492+ d.enterCopyMode(sess, ac)
1493+ data := mustOutputData(t, sends)
1494+ require.Contains(t, string(data), "[SCROLL]")
1495+ require.Contains(t, string(data), "\x1b[?25l")
1496+
1497+ d.handleInput(sess, ac, []byte("g"))
1498+ data = mustOutputData(t, sends)
1499+ require.Contains(t, string(data), "lt-old", "copy viewport of the captured floating pane must compose above the popup")
1500+}

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