feat(daemon): route floating copy mode
- commit
- a1d7783f84157edd71991e82f00ad7c2b8657c56
- author
- brice · · 2026-07-10 07:37 UTC
- parent
- 540608f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +385 -79
M internal/usecase/daemon/copymode.go +43 -55
| @@ -31,7 +31,6 @@ import ( | |||
| 31 | 31 | "github.com/bnema/vev/internal/ports" | |
| 32 | 32 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 33 | 33 | "github.com/bnema/vev/internal/usecase/keys" | |
| 34 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 35 | 34 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 36 | 35 | "github.com/bnema/vev/internal/usecase/ui" | |
| 37 | 36 | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| @@ -40,22 +39,24 @@ import ( | |||
| 40 | 39 | ||
| 41 | 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) { | |
| 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 | |
| 48 | 45 | } | |
| 46 | + | rt.copyMu.Lock() | |
| 47 | + | defer rt.copyMu.Unlock() | |
| 48 | + | return rt.copyPane | |
| 49 | + | } | |
| 49 | 50 | ||
| 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) | |
| 53 | 54 | if p == nil { | |
| 54 | 55 | return | |
| 55 | 56 | } | |
| 56 | 57 | p.mu.Lock() | |
| 57 | 58 | rt.copyMu.Lock() | |
| 58 | - | if rt.copyMode == nil { | |
| 59 | + | if rt.copyMode == nil || rt.copyPane != p { | |
| 59 | 60 | rt.copyMu.Unlock() | |
| 60 | 61 | p.mu.Unlock() | |
| 61 | 62 | return | |
| @@ -86,7 +87,8 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 86 | 87 | return | |
| 87 | 88 | } | |
| 88 | 89 | tb.mu.Lock() | |
| 89 | - | p := tb.focusedPane() | |
| 90 | + | p := tb.terminalTargetLocked() | |
| 91 | + | floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p | |
| 90 | 92 | tb.mu.Unlock() | |
| 91 | 93 | if p == nil { | |
| 92 | 94 | return | |
| @@ -96,12 +98,21 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 96 | 98 | p.mu.Unlock() | |
| 97 | 99 | rt.copyMu.Lock() | |
| 98 | 100 | rt.copyMode = scopy.NewMode(snap) | |
| 101 | + | rt.copyPane = p | |
| 99 | 102 | rt.copySearch = nil | |
| 100 | 103 | rt.copySearchPending = nil | |
| 101 | 104 | rt.copyPressRowValid = false | |
| 102 | 105 | rt.copyDragging = false | |
| 103 | 106 | rt.normalMousePressValid = false | |
| 104 | 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 | 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 | 121 | if ev.Button != mouse.Left { | |
| 111 | 122 | return | |
| 112 | 123 | } | |
| 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) | |
| 121 | 125 | if p == nil { | |
| 122 | 126 | return | |
| 123 | 127 | } | |
| @@ -133,7 +137,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||
| 133 | 137 | return | |
| 134 | 138 | } | |
| 135 | 139 | rt.copyMu.Lock() | |
| 136 | - | if rt.copyMode == nil { | |
| 140 | + | if rt.copyMode == nil || rt.copyPane != p { | |
| 137 | 141 | rt.copyMu.Unlock() | |
| 138 | 142 | p.mu.Unlock() | |
| 139 | 143 | return | |
| @@ -175,20 +179,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 175 | 179 | if sess == nil { | |
| 176 | 180 | return | |
| 177 | 181 | } | |
| 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) | |
| 186 | 183 | if p == nil { | |
| 187 | 184 | return | |
| 188 | 185 | } | |
| 189 | 186 | p.mu.Lock() | |
| 190 | 187 | rt.copyMu.Lock() | |
| 191 | - | if rt.copyMode == nil { | |
| 188 | + | if rt.copyMode == nil || rt.copyPane != p { | |
| 192 | 189 | rt.copyPending = nil | |
| 193 | 190 | d.stopCopyPendingTimerLocked(ac) | |
| 194 | 191 | rt.copyMu.Unlock() | |
| @@ -420,36 +417,27 @@ func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame | |||
| 420 | 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) { | |
| 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 | |
| 428 | 423 | } | |
| 424 | + | styles := newThemeStyles(bars.theme) | |
| 429 | 425 | 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) | |
| 433 | 427 | p.mu.Unlock() | |
| 434 | 428 | 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 | + | } | |
| 451 | 438 | } | |
| 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)]) | |
| 454 | 442 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 455 | 443 | } | |
| @@ -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.overlays | 43 | + | 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 | return | 55 | 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 | return | 62 | return | ||
| @@ -86,7 +87,8 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 86 | return | 87 | 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 | return | 94 | 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 = nil | 102 | rt.copySearch = nil | ||
| 100 | rt.copySearchPending = nil | 103 | rt.copySearchPending = nil | ||
| 101 | rt.copyPressRowValid = false | 104 | rt.copyPressRowValid = false | ||
| 102 | rt.copyDragging = false | 105 | rt.copyDragging = false | ||
| 103 | rt.normalMousePressValid = false | 106 | 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 | return | 122 | 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 | return | 126 | return | ||
| 123 | } | 127 | } | ||
| @@ -133,7 +137,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||||
| 133 | return | 137 | 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 | return | 143 | return | ||
| @@ -175,20 +179,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 175 | if sess == nil { | 179 | if sess == nil { | ||
| 176 | return | 180 | 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 | return | 184 | 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 = nil | 189 | 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.Frame | 426 | + | 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, paneRows | 433 | + | 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.Rows | 435 | + | 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 + 1 | 439 | + | 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 ( | |||
| 17 | 17 | "github.com/bnema/vev/internal/ports" | |
| 18 | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | 19 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 20 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 20 | 21 | "github.com/bnema/vev/pkg/renderer" | |
| 21 | 22 | "github.com/bnema/vev/pkg/vt" | |
| 22 | 23 | ) | |
| @@ -107,6 +108,9 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 107 | 108 | mode := scopy.NewMode(snap) | |
| 108 | 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 | 114 | var wg sync.WaitGroup | |
| 111 | 115 | wg.Add(2) | |
| 112 | 116 | go func() { | |
| @@ -120,9 +124,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 120 | 124 | go func() { | |
| 121 | 125 | defer wg.Done() | |
| 122 | 126 | for range 200 { | |
| 123 | - | tb.mu.Lock() | |
| 124 | - | _, _ = composeCopyClientFrame(mode, tb, bars) | |
| 125 | - | tb.mu.Unlock() | |
| 127 | + | _, _ = composeCopyClientFrame(mode, pane, target, base, bars) | |
| 126 | 128 | } | |
| 127 | 129 | }() | |
| 128 | 130 | wg.Wait() | |
| @@ -139,12 +141,15 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 139 | 141 | snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame) | |
| 140 | 142 | mode := scopy.NewMode(snap) | |
| 141 | 143 | ||
| 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]") | |
| 148 | 153 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 149 | 154 | } | |
| 150 | 155 | ||
| @@ -636,3 +641,152 @@ func TestCursorTailHidesInCopyMode(t *testing.T) { | |||
| 636 | 641 | data := mustOutputData(t, sends) | |
| 637 | 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 | + | } | |
| @@ -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.WaitGroup | 114 | 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 | |||
| 357 | 357 | return | |
| 358 | 358 | } | |
| 359 | 359 | 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) | |
| 367 | 366 | } | |
| 368 | 367 | } | |
| 369 | 368 | ||
| @@ -357,13 +357,12 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64 | |||||
| 357 | return | 357 | 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.client | 362 | + | 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) { | |||
| 74 | 74 | if rt.copySearchActive() { | |
| 75 | 75 | 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 | 98 | if ev.Button == mouse.Left { | |
| 78 | 99 | contentRow := ev.Row - 1 | |
| 79 | 100 | 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 | return | 75 | 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 - 1 | 99 | 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 { | |||
| 30 | 30 | ||
| 31 | 31 | copyMu sync.Mutex | |
| 32 | 32 | copyMode *scopy.Mode | |
| 33 | + | copyPane *pane | |
| 33 | 34 | copyPending []byte | |
| 34 | 35 | copyESC pendingByteTimer | |
| 35 | 36 | copySearch *visualsearch.Model | |
| @@ -101,10 +102,24 @@ func (rt *overlayRuntime) copySearchActive() bool { | |||
| 101 | 102 | ||
| 102 | 103 | func (rt *overlayRuntime) clearCopyModeLocked() { | |
| 103 | 104 | rt.copyMode = nil | |
| 105 | + | rt.copyPane = nil | |
| 104 | 106 | rt.copySearch = nil | |
| 105 | 107 | 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 | 123 | func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |
| 109 | 124 | if rt == nil || rt.ac == nil { | |
| 110 | 125 | return false | |
| @@ -134,6 +149,7 @@ type overlayRenderSnapshot struct { | |||
| 134 | 149 | ||
| 135 | 150 | copyActive bool | |
| 136 | 151 | copyMode *scopy.Mode | |
| 152 | + | copyPane *pane | |
| 137 | 153 | copySearchModel *visualsearch.Model | |
| 138 | 154 | copyFeedback string | |
| 139 | 155 | ||
| @@ -164,6 +180,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 164 | 180 | ||
| 165 | 181 | rt.copyMu.Lock() | |
| 166 | 182 | snap.copyActive = rt.copyMode != nil | |
| 183 | + | snap.copyPane = rt.copyPane | |
| 167 | 184 | if rt.copyMode != nil { | |
| 168 | 185 | copyModeValue := *rt.copyMode | |
| 169 | 186 | copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...) | |
| @@ -30,6 +30,7 @@ type overlayRuntime struct { | |||||
| 30 | 30 | ||||
| 31 | copyMu sync.Mutex | 31 | copyMu sync.Mutex | ||
| 32 | copyMode *scopy.Mode | 32 | copyMode *scopy.Mode | ||
| 33 | + | copyPane *pane | |||
| 33 | copyPending []byte | 34 | copyPending []byte | ||
| 34 | copyESC pendingByteTimer | 35 | copyESC pendingByteTimer | ||
| 35 | copySearch *visualsearch.Model | 36 | 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 = nil | 104 | rt.copyMode = nil | ||
| 105 | + | rt.copyPane = nil | |||
| 104 | rt.copySearch = nil | 106 | rt.copySearch = nil | ||
| 105 | rt.copySearchPending = nil | 107 | 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 false | 125 | return false | ||
| @@ -134,6 +149,7 @@ type overlayRenderSnapshot struct { | |||||
| 134 | 149 | ||||
| 135 | copyActive bool | 150 | copyActive bool | ||
| 136 | copyMode *scopy.Mode | 151 | copyMode *scopy.Mode | ||
| 152 | + | copyPane *pane | |||
| 137 | copySearchModel *visualsearch.Model | 153 | copySearchModel *visualsearch.Model | ||
| 138 | copyFeedback string | 154 | 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 != nil | 182 | snap.copyActive = rt.copyMode != nil | ||
| 183 | + | snap.copyPane = rt.copyPane | |||
| 167 | if rt.copyMode != nil { | 184 | if rt.copyMode != nil { | ||
| 168 | copyModeValue := *rt.copyMode | 185 | 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 ( | |||
| 5 | 5 | ||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | 10 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 10 | 11 | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| @@ -143,3 +144,25 @@ func TestOverlayRuntimeHandleInputInactive(t *testing.T) { | |||
| 143 | 144 | ||
| 144 | 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 | + | } | |
| @@ -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) { | |||
| 275 | 275 | // paint draws the composed client frame (active tab plus status bar) and | |
| 276 | 276 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | |
| 277 | 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 | 302 | func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID { | |
| 279 | 303 | if !ok { | |
| 280 | 304 | return nil | |
| @@ -367,19 +391,24 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 367 | 391 | } else { | |
| 368 | 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 | 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)} | |
| 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. | |
| 377 | 409 | if overlays.paletteActive && !hasFloating { | |
| 378 | 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 | 412 | if overlays.copySearchModel != nil { | |
| 384 | 413 | frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles) | |
| 385 | 414 | } | |
| @@ -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) and | 275 | // paint draws the composed client frame (active tab plus status bar) and | ||
| 276 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | 276 | // 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 nil | 304 | 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 pane | 408 | + | // 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) { | |||
| 1423 | 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 | + | } | |
| @@ -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 | + | } | |||