detached ae77a31

refactor(daemon): deepen overlay runtime

commit
ae77a3124679c4ad936efed3268c1e21518a55b8
author
brice · · 2026-07-04 18:08 UTC
committer
brice · · 2026-07-05 04:22 UTC
parent
604f123
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
24 files · +1015 -519
M internal/usecase/daemon/attention_test.go +2 -0
@@ -101,6 +101,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
101101 }).Maybe()
102102 trW.EXPECT().Close().Return(nil).Maybe()
103103 acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
104+ acW.initOverlays()
104105 sctxW, cancelW := context.WithCancel(d.serveCtx)
105106 t.Cleanup(cancelW)
106107 tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
@@ -348,6 +349,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
348349
349350 trB, sendsB := newCapturingTransport(t)
350351 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
352+ acB.initOverlays()
351353 sctxB, cancelB := context.WithCancel(d.serveCtx)
352354 t.Cleanup(cancelB)
353355 tbB := newTestTabWithContext(pB, sctxB, cancelB)
@@ -101,6 +101,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
101 }).Maybe()101 }).Maybe()
102 trW.EXPECT().Close().Return(nil).Maybe()102 trW.EXPECT().Close().Return(nil).Maybe()
103 acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}103 acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
104+ acW.initOverlays()
104 sctxW, cancelW := context.WithCancel(d.serveCtx)105 sctxW, cancelW := context.WithCancel(d.serveCtx)
105 t.Cleanup(cancelW)106 t.Cleanup(cancelW)
106 tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)107 tabW := newTestTabWithContext(newScriptPTY(nil), sctxW, cancelW)
@@ -348,6 +349,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
348 349
349 trB, sendsB := newCapturingTransport(t)350 trB, sendsB := newCapturingTransport(t)
350 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}351 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
352+ acB.initOverlays()
351 sctxB, cancelB := context.WithCancel(d.serveCtx)353 sctxB, cancelB := context.WithCancel(d.serveCtx)
352 t.Cleanup(cancelB)354 t.Cleanup(cancelB)
353 tbB := newTestTabWithContext(pB, sctxB, cancelB)355 tbB := newTestTabWithContext(pB, sctxB, cancelB)
M internal/usecase/daemon/client.go +26 -69
@@ -32,58 +32,32 @@ import (
3232
3333 "github.com/bnema/vev/internal/domain"
3434 "github.com/bnema/vev/internal/ports"
35- scopy "github.com/bnema/vev/internal/usecase/copy"
3635 "github.com/bnema/vev/internal/usecase/keys"
3736 "github.com/bnema/vev/internal/usecase/mouse"
38- "github.com/bnema/vev/internal/usecase/palette"
39- "github.com/bnema/vev/internal/usecase/picker"
40- promptui "github.com/bnema/vev/internal/usecase/prompt"
4137 themeui "github.com/bnema/vev/internal/usecase/theme"
4238 "github.com/bnema/vev/pkg/renderer"
4339 )
4440
4541 type attachedClient struct {
46- tr ports.Transport
47- rend *renderer.Renderer
48- clientID [16]byte
49- resumeCapable bool
50- resumeToken uint64
51- parked bool
52- nextStateNum uint64
53- echoAck atomic.Uint64
54- bars barCache // only touched while sendMu is held
55- size domain.Size
56- keys *keys.Router
57- sess Guarded[*session]
58- copyMu sync.Mutex
59- copyMode *scopy.Mode
60- copyPending []byte
61- copyESC pendingByteTimer
62- copyFeedback string
63- copyPressRow int
64- copyPressRowValid bool
65- copyDragging bool
66- normalMousePressRow int
67- normalMousePressTop int
68- normalMousePressValid bool
69- pickerMu sync.Mutex
70- picker *picker.Model
71- pickerPreview *tab
72- pickerPending []byte
73- pickerESC pendingByteTimer
74- paletteMu sync.Mutex
75- palette *palette.Model
76- palettePending []byte
77- promptMu sync.Mutex
78- prompt *promptui.Model
79- promptSubmit func(string) error
80- promptPending []byte
81- mouseScan mouse.Scanner
82- themeMu sync.Mutex
83- theme themeui.Theme
84- lastCursor cursorOut
85- linkMu sync.Mutex
86- sendMu sync.Mutex
42+ tr ports.Transport
43+ rend *renderer.Renderer
44+ overlays *overlayRuntime
45+ clientID [16]byte
46+ resumeCapable bool
47+ resumeToken uint64
48+ parked bool
49+ nextStateNum uint64
50+ echoAck atomic.Uint64
51+ bars barCache // only touched while sendMu is held
52+ size domain.Size
53+ keys *keys.Router
54+ sess Guarded[*session]
55+ mouseScan mouse.Scanner
56+ themeMu sync.Mutex
57+ theme themeui.Theme
58+ lastCursor cursorOut
59+ linkMu sync.Mutex
60+ sendMu sync.Mutex
8761 }
8862
8963 type cursorOut struct {
@@ -124,6 +98,12 @@ func (p *pendingByteTimer) stop() {
12498 }
12599 }
126100
101+func (ac *attachedClient) initOverlays() {
102+ if ac.overlays == nil {
103+ ac.overlays = newOverlayRuntime(ac)
104+ }
105+}
106+
127107 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
128108
129109 func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
@@ -140,30 +120,6 @@ func (ac *attachedClient) setTheme(t themeui.Theme) {
140120 ac.themeMu.Unlock()
141121 }
142122
143-func (ac *attachedClient) copyModeActive() bool {
144- ac.copyMu.Lock()
145- defer ac.copyMu.Unlock()
146- return ac.copyMode != nil
147-}
148-
149-func (ac *attachedClient) pickerActive() bool {
150- ac.pickerMu.Lock()
151- defer ac.pickerMu.Unlock()
152- return ac.picker != nil
153-}
154-
155-func (ac *attachedClient) paletteActive() bool {
156- ac.paletteMu.Lock()
157- defer ac.paletteMu.Unlock()
158- return ac.palette != nil
159-}
160-
161-func (ac *attachedClient) promptActive() bool {
162- ac.promptMu.Lock()
163- defer ac.promptMu.Unlock()
164- return ac.prompt != nil
165-}
166-
167123 func (ac *attachedClient) transport() ports.Transport {
168124 ac.linkMu.Lock()
169125 defer ac.linkMu.Unlock()
@@ -362,6 +318,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
362318 resumeCapable: opts.resumeCapable,
363319 resumeToken: resumeToken,
364320 }
321+ ac.initOverlays()
365322 ac.setSession(sess)
366323 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
367324 sess.mu.Lock()
@@ -32,58 +32,32 @@ import (
32 32
33 "github.com/bnema/vev/internal/domain"33 "github.com/bnema/vev/internal/domain"
34 "github.com/bnema/vev/internal/ports"34 "github.com/bnema/vev/internal/ports"
35- scopy "github.com/bnema/vev/internal/usecase/copy"
36 "github.com/bnema/vev/internal/usecase/keys"35 "github.com/bnema/vev/internal/usecase/keys"
37 "github.com/bnema/vev/internal/usecase/mouse"36 "github.com/bnema/vev/internal/usecase/mouse"
38- "github.com/bnema/vev/internal/usecase/palette"
39- "github.com/bnema/vev/internal/usecase/picker"
40- promptui "github.com/bnema/vev/internal/usecase/prompt"
41 themeui "github.com/bnema/vev/internal/usecase/theme"37 themeui "github.com/bnema/vev/internal/usecase/theme"
42 "github.com/bnema/vev/pkg/renderer"38 "github.com/bnema/vev/pkg/renderer"
43 )39 )
44 40
45 type attachedClient struct {41 type attachedClient struct {
46- tr ports.Transport42+ tr ports.Transport
47- rend *renderer.Renderer43+ rend *renderer.Renderer
48- clientID [16]byte44+ overlays *overlayRuntime
49- resumeCapable bool45+ clientID [16]byte
50- resumeToken uint6446+ resumeCapable bool
51- parked bool47+ resumeToken uint64
52- nextStateNum uint6448+ parked bool
53- echoAck atomic.Uint6449+ nextStateNum uint64
54- bars barCache // only touched while sendMu is held50+ echoAck atomic.Uint64
55- size domain.Size51+ bars barCache // only touched while sendMu is held
56- keys *keys.Router52+ size domain.Size
57- sess Guarded[*session]53+ keys *keys.Router
58- copyMu sync.Mutex54+ sess Guarded[*session]
59- copyMode *scopy.Mode55+ mouseScan mouse.Scanner
60- copyPending []byte56+ themeMu sync.Mutex
61- copyESC pendingByteTimer57+ theme themeui.Theme
62- copyFeedback string58+ lastCursor cursorOut
63- copyPressRow int59+ linkMu sync.Mutex
64- copyPressRowValid bool60+ sendMu sync.Mutex
65- copyDragging bool
66- normalMousePressRow int
67- normalMousePressTop int
68- normalMousePressValid bool
69- pickerMu sync.Mutex
70- picker *picker.Model
71- pickerPreview *tab
72- pickerPending []byte
73- pickerESC pendingByteTimer
74- paletteMu sync.Mutex
75- palette *palette.Model
76- palettePending []byte
77- promptMu sync.Mutex
78- prompt *promptui.Model
79- promptSubmit func(string) error
80- promptPending []byte
81- mouseScan mouse.Scanner
82- themeMu sync.Mutex
83- theme themeui.Theme
84- lastCursor cursorOut
85- linkMu sync.Mutex
86- sendMu sync.Mutex
87 }61 }
88 62
89 type cursorOut struct {63 type cursorOut struct {
@@ -124,6 +98,12 @@ func (p *pendingByteTimer) stop() {
124 }98 }
125 }99 }
126 100
101+func (ac *attachedClient) initOverlays() {
102+ if ac.overlays == nil {
103+ ac.overlays = newOverlayRuntime(ac)
104+ }
105+}
106+
127 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }107 func (ac *attachedClient) currentSession() *session { return ac.sess.Get() }
128 108
129 func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }109 func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
@@ -140,30 +120,6 @@ func (ac *attachedClient) setTheme(t themeui.Theme) {
140 ac.themeMu.Unlock()120 ac.themeMu.Unlock()
141 }121 }
142 122
143-func (ac *attachedClient) copyModeActive() bool {
144- ac.copyMu.Lock()
145- defer ac.copyMu.Unlock()
146- return ac.copyMode != nil
147-}
148-
149-func (ac *attachedClient) pickerActive() bool {
150- ac.pickerMu.Lock()
151- defer ac.pickerMu.Unlock()
152- return ac.picker != nil
153-}
154-
155-func (ac *attachedClient) paletteActive() bool {
156- ac.paletteMu.Lock()
157- defer ac.paletteMu.Unlock()
158- return ac.palette != nil
159-}
160-
161-func (ac *attachedClient) promptActive() bool {
162- ac.promptMu.Lock()
163- defer ac.promptMu.Unlock()
164- return ac.prompt != nil
165-}
166-
167 func (ac *attachedClient) transport() ports.Transport {123 func (ac *attachedClient) transport() ports.Transport {
168 ac.linkMu.Lock()124 ac.linkMu.Lock()
169 defer ac.linkMu.Unlock()125 defer ac.linkMu.Unlock()
@@ -362,6 +318,7 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
362 resumeCapable: opts.resumeCapable,318 resumeCapable: opts.resumeCapable,
363 resumeToken: resumeToken,319 resumeToken: resumeToken,
364 }320 }
321+ ac.initOverlays()
365 ac.setSession(sess)322 ac.setSession(sess)
366 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})323 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
367 sess.mu.Lock()324 sess.mu.Lock()
M internal/usecase/daemon/clipboard_test.go +4 -0
@@ -191,6 +191,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
191191 defer cancel()
192192 win := newTestTabWithContext(p, sctx, cancel)
193193 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
194+ ac.initOverlays()
194195 sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
195196 ac.setSession(sess)
196197 d.sessions[sess.id] = sess
@@ -215,6 +216,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
215216 defer cancel()
216217 win := newTestTabWithContext(p, sctx, cancel)
217218 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
219+ ac.initOverlays()
218220 sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
219221 ac.setSession(sess)
220222 d.sessions[sess.id] = sess
@@ -238,6 +240,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
238240 defer cancel()
239241 win := newTestTabWithContext(p, sctx, cancel)
240242 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
243+ ac.initOverlays()
241244 sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
242245 ac.setSession(sess)
243246 d.sessions[sess.id] = sess
@@ -272,6 +275,7 @@ func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
272275 d := newTestDaemon(t, nil, stubClock{})
273276 tr := newBlockingClipboardTransport()
274277 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
278+ ac.initOverlays()
275279 sctx, cancel := context.WithCancel(context.Background())
276280 defer cancel()
277281 sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}
@@ -191,6 +191,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
191 defer cancel()191 defer cancel()
192 win := newTestTabWithContext(p, sctx, cancel)192 win := newTestTabWithContext(p, sctx, cancel)
193 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}193 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
194+ ac.initOverlays()
194 sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}195 sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
195 ac.setSession(sess)196 ac.setSession(sess)
196 d.sessions[sess.id] = sess197 d.sessions[sess.id] = sess
@@ -215,6 +216,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
215 defer cancel()216 defer cancel()
216 win := newTestTabWithContext(p, sctx, cancel)217 win := newTestTabWithContext(p, sctx, cancel)
217 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}218 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
219+ ac.initOverlays()
218 sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}220 sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
219 ac.setSession(sess)221 ac.setSession(sess)
220 d.sessions[sess.id] = sess222 d.sessions[sess.id] = sess
@@ -238,6 +240,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
238 defer cancel()240 defer cancel()
239 win := newTestTabWithContext(p, sctx, cancel)241 win := newTestTabWithContext(p, sctx, cancel)
240 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}242 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
243+ ac.initOverlays()
241 sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}244 sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
242 ac.setSession(sess)245 ac.setSession(sess)
243 d.sessions[sess.id] = sess246 d.sessions[sess.id] = sess
@@ -272,6 +275,7 @@ func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
272 d := newTestDaemon(t, nil, stubClock{})275 d := newTestDaemon(t, nil, stubClock{})
273 tr := newBlockingClipboardTransport()276 tr := newBlockingClipboardTransport()
274 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}277 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
278+ ac.initOverlays()
275 sctx, cancel := context.WithCancel(context.Background())279 sctx, cancel := context.WithCancel(context.Background())
276 defer cancel()280 defer cancel()
277 sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}281 sess := &session{id: "clip-order", name: "clip-order", ctx: sctx, cancel: cancel, client: ac}
M internal/usecase/daemon/copymode.go +76 -70
@@ -37,6 +37,7 @@ import (
3737 )
3838
3939 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
40+ rt := ac.overlays
4041 tb := sess.activeTab()
4142 if tb == nil {
4243 return
@@ -49,32 +50,33 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
4950 return
5051 }
5152 p.mu.Lock()
52- ac.copyMu.Lock()
53- if ac.copyMode == nil {
54- ac.copyMu.Unlock()
53+ rt.copyMu.Lock()
54+ if rt.copyMode == nil {
55+ rt.copyMu.Unlock()
5556 p.mu.Unlock()
5657 return
5758 }
5859 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
59- if delta > 0 && ac.copyMode.AtBottom(snap) {
60- ac.copyMode = nil
61- ac.copyMu.Unlock()
60+ if delta > 0 && rt.copyMode.AtBottom(snap) {
61+ rt.copyMode = nil
62+ rt.copyMu.Unlock()
6263 p.mu.Unlock()
6364 d.paint(sess, ac, true)
6465 return
6566 }
66- ac.copyMode.Move(snap, delta)
67- exit := delta > 0 && ac.copyMode.AtBottom(snap)
67+ rt.copyMode.Move(snap, delta)
68+ exit := delta > 0 && rt.copyMode.AtBottom(snap)
6869 if exit {
69- ac.copyMode = nil
70+ rt.copyMode = nil
7071 }
71- ac.copyMu.Unlock()
72+ rt.copyMu.Unlock()
7273 p.mu.Unlock()
7374
7475 d.paint(sess, ac, true)
7576 }
7677
7778 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
79+ rt := ac.overlays
7880 tb := sess.activeTab()
7981 if tb == nil {
8082 return
@@ -88,16 +90,17 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
8890 p.mu.Lock()
8991 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
9092 p.mu.Unlock()
91- ac.copyMu.Lock()
92- ac.copyMode = scopy.NewMode(snap)
93- ac.copyPressRowValid = false
94- ac.copyDragging = false
95- ac.normalMousePressValid = false
96- ac.copyMu.Unlock()
93+ rt.copyMu.Lock()
94+ rt.copyMode = scopy.NewMode(snap)
95+ rt.copyPressRowValid = false
96+ rt.copyDragging = false
97+ rt.normalMousePressValid = false
98+ rt.copyMu.Unlock()
9799 d.paint(sess, ac, true)
98100 }
99101
100102 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
103+ rt := ac.overlays
101104 if ev.Button != mouse.Left {
102105 return
103106 }
@@ -115,44 +118,44 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
115118 p.mu.Lock()
116119 if ev.Row >= p.screen.Frame.Height {
117120 if ev.Type == mouse.Press {
118- ac.copyMu.Lock()
119- ac.copyPressRowValid = false
120- ac.copyDragging = false
121- ac.copyMu.Unlock()
121+ rt.copyMu.Lock()
122+ rt.copyPressRowValid = false
123+ rt.copyDragging = false
124+ rt.copyMu.Unlock()
122125 }
123126 p.mu.Unlock()
124127 return
125128 }
126- ac.copyMu.Lock()
127- if ac.copyMode == nil {
128- ac.copyMu.Unlock()
129+ rt.copyMu.Lock()
130+ if rt.copyMode == nil {
131+ rt.copyMu.Unlock()
129132 p.mu.Unlock()
130133 return
131134 }
132135 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
133- absRow := ac.copyMode.ViewportTop + ev.Row
136+ absRow := rt.copyMode.ViewportTop + ev.Row
134137 changed := false
135138 switch ev.Type {
136139 case mouse.Press:
137- ac.copyMode.SetCursor(snap, absRow)
138- ac.copyPressRow = ac.copyMode.Cursor
139- ac.copyPressRowValid = true
140- ac.copyDragging = false
140+ rt.copyMode.SetCursor(snap, absRow)
141+ rt.copyPressRow = rt.copyMode.Cursor
142+ rt.copyPressRowValid = true
143+ rt.copyDragging = false
141144 changed = true
142145 case mouse.Motion:
143- if !ac.copyPressRowValid {
146+ if !rt.copyPressRowValid {
144147 break
145148 }
146- if !ac.copyDragging {
147- ac.copyMode.StartSelectionAt(snap, ac.copyPressRow)
148- ac.copyDragging = true
149+ if !rt.copyDragging {
150+ rt.copyMode.StartSelectionAt(snap, rt.copyPressRow)
151+ rt.copyDragging = true
149152 }
150- ac.copyMode.ExtendTo(snap, absRow)
153+ rt.copyMode.ExtendTo(snap, absRow)
151154 changed = true
152155 case mouse.Release:
153156 // Button release intentionally has no visual effect.
154157 }
155- ac.copyMu.Unlock()
158+ rt.copyMu.Unlock()
156159 p.mu.Unlock()
157160
158161 if changed {
@@ -161,6 +164,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
161164 }
162165
163166 func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
167+ rt := ac.overlays
164168 sess := ac.currentSession()
165169 if sess == nil {
166170 return
@@ -177,21 +181,21 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
177181 return
178182 }
179183 p.mu.Lock()
180- ac.copyMu.Lock()
181- if ac.copyMode == nil {
182- ac.copyPending = nil
184+ rt.copyMu.Lock()
185+ if rt.copyMode == nil {
186+ rt.copyPending = nil
183187 d.stopCopyPendingTimerLocked(ac)
184- ac.copyMu.Unlock()
188+ rt.copyMu.Unlock()
185189 p.mu.Unlock()
186190 return
187191 }
188- if len(ac.copyPending) > 0 {
192+ if len(rt.copyPending) > 0 {
189193 d.stopCopyPendingTimerLocked(ac)
190- combined := make([]byte, 0, len(ac.copyPending)+len(data))
191- combined = append(combined, ac.copyPending...)
194+ combined := make([]byte, 0, len(rt.copyPending)+len(data))
195+ combined = append(combined, rt.copyPending...)
192196 combined = append(combined, data...)
193197 data = combined
194- ac.copyPending = nil
198+ rt.copyPending = nil
195199 }
196200 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
197201 changed := false
@@ -200,19 +204,19 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
200204 for i := 0; i < len(data); i++ {
201205 switch data[i] {
202206 case 'j':
203- ac.copyMode.Move(snap, 1)
207+ rt.copyMode.Move(snap, 1)
204208 changed = true
205209 case 'k':
206- ac.copyMode.Move(snap, -1)
210+ rt.copyMode.Move(snap, -1)
207211 changed = true
208212 case 'g':
209- ac.copyMode.Top(snap)
213+ rt.copyMode.Top(snap)
210214 changed = true
211215 case 'G':
212- ac.copyMode.Bottom(snap)
216+ rt.copyMode.Bottom(snap)
213217 changed = true
214218 case ' ', 'v':
215- ac.copyMode.ToggleSelection()
219+ rt.copyMode.ToggleSelection()
216220 changed = true
217221 case '\r', '\n', 'y':
218222 copyOut = true
@@ -220,7 +224,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
220224 case 'q', 0x03, 0x1b:
221225 if data[i] == 0x1b {
222226 tail := data[i:]
223- consumed, ok := routeCopyEscape(ac.copyMode, snap, tail)
227+ consumed, ok := routeCopyEscape(rt.copyMode, snap, tail)
224228 if ok {
225229 i += consumed - 1
226230 changed = true
@@ -231,7 +235,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
231235 break
232236 }
233237 if isCopyEscapePrefix(tail) {
234- ac.copyPending = append(ac.copyPending[:0], tail...)
238+ rt.copyPending = append(rt.copyPending[:0], tail...)
235239 break
236240 }
237241 }
@@ -240,13 +244,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
240244 }
241245 text := ""
242246 if copyOut {
243- text = ac.copyMode.SelectedText(snap)
247+ text = rt.copyMode.SelectedText(snap)
244248 }
245249 if exit {
246250 d.stopCopyPendingTimerLocked(ac)
247- ac.copyMode = nil
251+ rt.copyMode = nil
248252 }
249- ac.copyMu.Unlock()
253+ rt.copyMu.Unlock()
250254 p.mu.Unlock()
251255
252256 if copyOut && text != "" {
@@ -258,13 +262,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
258262 return
259263 }
260264 }
261- ac.copyMu.Lock()
265+ rt.copyMu.Lock()
262266 if len(chunks) > 0 {
263- ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
267+ rt.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
264268 } else {
265- ac.copyFeedback = "selection too large to copy"
269+ rt.copyFeedback = "selection too large to copy"
266270 }
267- ac.copyMu.Unlock()
271+ rt.copyMu.Unlock()
268272 }
269273 if exit {
270274 d.paint(sess, ac, true)
@@ -276,23 +280,24 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
276280 }
277281
278282 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
279- mode := ac.copyMode
280- ac.copyPending = append(ac.copyPending[:0], keys.ESC)
281- ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
282- ac.copyMu.Lock()
283- if ac.copyESC.timer != timer {
284- ac.copyMu.Unlock()
283+ rt := ac.overlays
284+ mode := rt.copyMode
285+ rt.copyPending = append(rt.copyPending[:0], keys.ESC)
286+ rt.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
287+ rt.copyMu.Lock()
288+ if rt.copyESC.timer != timer {
289+ rt.copyMu.Unlock()
285290 return
286291 }
287- ac.copyPending = nil
288- ac.copyESC.timer = nil
289- ac.copyESC.done = nil
290- if ac.copyMode != mode || ac.copyMode == nil {
291- ac.copyMu.Unlock()
292+ rt.copyPending = nil
293+ rt.copyESC.timer = nil
294+ rt.copyESC.done = nil
295+ if rt.copyMode != mode || rt.copyMode == nil {
296+ rt.copyMu.Unlock()
292297 return
293298 }
294- ac.copyMode = nil
295- ac.copyMu.Unlock()
299+ rt.copyMode = nil
300+ rt.copyMu.Unlock()
296301
297302 if sess := ac.currentSession(); sess != nil {
298303 d.paint(sess, ac, true)
@@ -301,7 +306,8 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
301306 }
302307
303308 func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
304- ac.copyESC.stop()
309+ rt := ac.overlays
310+ rt.copyESC.stop()
305311 }
306312
307313 func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
@@ -37,6 +37,7 @@ import (
37 )37 )
38 38
39 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {39 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
40+ rt := ac.overlays
40 tb := sess.activeTab()41 tb := sess.activeTab()
41 if tb == nil {42 if tb == nil {
42 return43 return
@@ -49,32 +50,33 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
49 return50 return
50 }51 }
51 p.mu.Lock()52 p.mu.Lock()
52- ac.copyMu.Lock()53+ rt.copyMu.Lock()
53- if ac.copyMode == nil {54+ if rt.copyMode == nil {
54- ac.copyMu.Unlock()55+ rt.copyMu.Unlock()
55 p.mu.Unlock()56 p.mu.Unlock()
56 return57 return
57 }58 }
58 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)59 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
59- if delta > 0 && ac.copyMode.AtBottom(snap) {60+ if delta > 0 && rt.copyMode.AtBottom(snap) {
60- ac.copyMode = nil61+ rt.copyMode = nil
61- ac.copyMu.Unlock()62+ rt.copyMu.Unlock()
62 p.mu.Unlock()63 p.mu.Unlock()
63 d.paint(sess, ac, true)64 d.paint(sess, ac, true)
64 return65 return
65 }66 }
66- ac.copyMode.Move(snap, delta)67+ rt.copyMode.Move(snap, delta)
67- exit := delta > 0 && ac.copyMode.AtBottom(snap)68+ exit := delta > 0 && rt.copyMode.AtBottom(snap)
68 if exit {69 if exit {
69- ac.copyMode = nil70+ rt.copyMode = nil
70 }71 }
71- ac.copyMu.Unlock()72+ rt.copyMu.Unlock()
72 p.mu.Unlock()73 p.mu.Unlock()
73 74
74 d.paint(sess, ac, true)75 d.paint(sess, ac, true)
75 }76 }
76 77
77 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {78 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
79+ rt := ac.overlays
78 tb := sess.activeTab()80 tb := sess.activeTab()
79 if tb == nil {81 if tb == nil {
80 return82 return
@@ -88,16 +90,17 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
88 p.mu.Lock()90 p.mu.Lock()
89 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)91 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
90 p.mu.Unlock()92 p.mu.Unlock()
91- ac.copyMu.Lock()93+ rt.copyMu.Lock()
92- ac.copyMode = scopy.NewMode(snap)94+ rt.copyMode = scopy.NewMode(snap)
93- ac.copyPressRowValid = false95+ rt.copyPressRowValid = false
94- ac.copyDragging = false96+ rt.copyDragging = false
95- ac.normalMousePressValid = false97+ rt.normalMousePressValid = false
96- ac.copyMu.Unlock()98+ rt.copyMu.Unlock()
97 d.paint(sess, ac, true)99 d.paint(sess, ac, true)
98 }100 }
99 101
100 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {102 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
103+ rt := ac.overlays
101 if ev.Button != mouse.Left {104 if ev.Button != mouse.Left {
102 return105 return
103 }106 }
@@ -115,44 +118,44 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
115 p.mu.Lock()118 p.mu.Lock()
116 if ev.Row >= p.screen.Frame.Height {119 if ev.Row >= p.screen.Frame.Height {
117 if ev.Type == mouse.Press {120 if ev.Type == mouse.Press {
118- ac.copyMu.Lock()121+ rt.copyMu.Lock()
119- ac.copyPressRowValid = false122+ rt.copyPressRowValid = false
120- ac.copyDragging = false123+ rt.copyDragging = false
121- ac.copyMu.Unlock()124+ rt.copyMu.Unlock()
122 }125 }
123 p.mu.Unlock()126 p.mu.Unlock()
124 return127 return
125 }128 }
126- ac.copyMu.Lock()129+ rt.copyMu.Lock()
127- if ac.copyMode == nil {130+ if rt.copyMode == nil {
128- ac.copyMu.Unlock()131+ rt.copyMu.Unlock()
129 p.mu.Unlock()132 p.mu.Unlock()
130 return133 return
131 }134 }
132 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)135 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
133- absRow := ac.copyMode.ViewportTop + ev.Row136+ absRow := rt.copyMode.ViewportTop + ev.Row
134 changed := false137 changed := false
135 switch ev.Type {138 switch ev.Type {
136 case mouse.Press:139 case mouse.Press:
137- ac.copyMode.SetCursor(snap, absRow)140+ rt.copyMode.SetCursor(snap, absRow)
138- ac.copyPressRow = ac.copyMode.Cursor141+ rt.copyPressRow = rt.copyMode.Cursor
139- ac.copyPressRowValid = true142+ rt.copyPressRowValid = true
140- ac.copyDragging = false143+ rt.copyDragging = false
141 changed = true144 changed = true
142 case mouse.Motion:145 case mouse.Motion:
143- if !ac.copyPressRowValid {146+ if !rt.copyPressRowValid {
144 break147 break
145 }148 }
146- if !ac.copyDragging {149+ if !rt.copyDragging {
147- ac.copyMode.StartSelectionAt(snap, ac.copyPressRow)150+ rt.copyMode.StartSelectionAt(snap, rt.copyPressRow)
148- ac.copyDragging = true151+ rt.copyDragging = true
149 }152 }
150- ac.copyMode.ExtendTo(snap, absRow)153+ rt.copyMode.ExtendTo(snap, absRow)
151 changed = true154 changed = true
152 case mouse.Release:155 case mouse.Release:
153 // Button release intentionally has no visual effect.156 // Button release intentionally has no visual effect.
154 }157 }
155- ac.copyMu.Unlock()158+ rt.copyMu.Unlock()
156 p.mu.Unlock()159 p.mu.Unlock()
157 160
158 if changed {161 if changed {
@@ -161,6 +164,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
161 }164 }
162 165
163 func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {166 func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
167+ rt := ac.overlays
164 sess := ac.currentSession()168 sess := ac.currentSession()
165 if sess == nil {169 if sess == nil {
166 return170 return
@@ -177,21 +181,21 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
177 return181 return
178 }182 }
179 p.mu.Lock()183 p.mu.Lock()
180- ac.copyMu.Lock()184+ rt.copyMu.Lock()
181- if ac.copyMode == nil {185+ if rt.copyMode == nil {
182- ac.copyPending = nil186+ rt.copyPending = nil
183 d.stopCopyPendingTimerLocked(ac)187 d.stopCopyPendingTimerLocked(ac)
184- ac.copyMu.Unlock()188+ rt.copyMu.Unlock()
185 p.mu.Unlock()189 p.mu.Unlock()
186 return190 return
187 }191 }
188- if len(ac.copyPending) > 0 {192+ if len(rt.copyPending) > 0 {
189 d.stopCopyPendingTimerLocked(ac)193 d.stopCopyPendingTimerLocked(ac)
190- combined := make([]byte, 0, len(ac.copyPending)+len(data))194+ combined := make([]byte, 0, len(rt.copyPending)+len(data))
191- combined = append(combined, ac.copyPending...)195+ combined = append(combined, rt.copyPending...)
192 combined = append(combined, data...)196 combined = append(combined, data...)
193 data = combined197 data = combined
194- ac.copyPending = nil198+ rt.copyPending = nil
195 }199 }
196 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)200 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
197 changed := false201 changed := false
@@ -200,19 +204,19 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
200 for i := 0; i < len(data); i++ {204 for i := 0; i < len(data); i++ {
201 switch data[i] {205 switch data[i] {
202 case 'j':206 case 'j':
203- ac.copyMode.Move(snap, 1)207+ rt.copyMode.Move(snap, 1)
204 changed = true208 changed = true
205 case 'k':209 case 'k':
206- ac.copyMode.Move(snap, -1)210+ rt.copyMode.Move(snap, -1)
207 changed = true211 changed = true
208 case 'g':212 case 'g':
209- ac.copyMode.Top(snap)213+ rt.copyMode.Top(snap)
210 changed = true214 changed = true
211 case 'G':215 case 'G':
212- ac.copyMode.Bottom(snap)216+ rt.copyMode.Bottom(snap)
213 changed = true217 changed = true
214 case ' ', 'v':218 case ' ', 'v':
215- ac.copyMode.ToggleSelection()219+ rt.copyMode.ToggleSelection()
216 changed = true220 changed = true
217 case '\r', '\n', 'y':221 case '\r', '\n', 'y':
218 copyOut = true222 copyOut = true
@@ -220,7 +224,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
220 case 'q', 0x03, 0x1b:224 case 'q', 0x03, 0x1b:
221 if data[i] == 0x1b {225 if data[i] == 0x1b {
222 tail := data[i:]226 tail := data[i:]
223- consumed, ok := routeCopyEscape(ac.copyMode, snap, tail)227+ consumed, ok := routeCopyEscape(rt.copyMode, snap, tail)
224 if ok {228 if ok {
225 i += consumed - 1229 i += consumed - 1
226 changed = true230 changed = true
@@ -231,7 +235,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
231 break235 break
232 }236 }
233 if isCopyEscapePrefix(tail) {237 if isCopyEscapePrefix(tail) {
234- ac.copyPending = append(ac.copyPending[:0], tail...)238+ rt.copyPending = append(rt.copyPending[:0], tail...)
235 break239 break
236 }240 }
237 }241 }
@@ -240,13 +244,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
240 }244 }
241 text := ""245 text := ""
242 if copyOut {246 if copyOut {
243- text = ac.copyMode.SelectedText(snap)247+ text = rt.copyMode.SelectedText(snap)
244 }248 }
245 if exit {249 if exit {
246 d.stopCopyPendingTimerLocked(ac)250 d.stopCopyPendingTimerLocked(ac)
247- ac.copyMode = nil251+ rt.copyMode = nil
248 }252 }
249- ac.copyMu.Unlock()253+ rt.copyMu.Unlock()
250 p.mu.Unlock()254 p.mu.Unlock()
251 255
252 if copyOut && text != "" {256 if copyOut && text != "" {
@@ -258,13 +262,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
258 return262 return
259 }263 }
260 }264 }
261- ac.copyMu.Lock()265+ rt.copyMu.Lock()
262 if len(chunks) > 0 {266 if len(chunks) > 0 {
263- ac.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"267+ rt.copyFeedback = "copied " + strconv.Itoa(len([]rune(text))) + " chars to clipboard"
264 } else {268 } else {
265- ac.copyFeedback = "selection too large to copy"269+ rt.copyFeedback = "selection too large to copy"
266 }270 }
267- ac.copyMu.Unlock()271+ rt.copyMu.Unlock()
268 }272 }
269 if exit {273 if exit {
270 d.paint(sess, ac, true)274 d.paint(sess, ac, true)
@@ -276,23 +280,24 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
276 }280 }
277 281
278 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {282 func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
279- mode := ac.copyMode283+ rt := ac.overlays
280- ac.copyPending = append(ac.copyPending[:0], keys.ESC)284+ mode := rt.copyMode
281- ac.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {285+ rt.copyPending = append(rt.copyPending[:0], keys.ESC)
282- ac.copyMu.Lock()286+ rt.copyESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
283- if ac.copyESC.timer != timer {287+ rt.copyMu.Lock()
284- ac.copyMu.Unlock()288+ if rt.copyESC.timer != timer {
289+ rt.copyMu.Unlock()
285 return290 return
286 }291 }
287- ac.copyPending = nil292+ rt.copyPending = nil
288- ac.copyESC.timer = nil293+ rt.copyESC.timer = nil
289- ac.copyESC.done = nil294+ rt.copyESC.done = nil
290- if ac.copyMode != mode || ac.copyMode == nil {295+ if rt.copyMode != mode || rt.copyMode == nil {
291- ac.copyMu.Unlock()296+ rt.copyMu.Unlock()
292 return297 return
293 }298 }
294- ac.copyMode = nil299+ rt.copyMode = nil
295- ac.copyMu.Unlock()300+ rt.copyMu.Unlock()
296 301
297 if sess := ac.currentSession(); sess != nil {302 if sess := ac.currentSession(); sess != nil {
298 d.paint(sess, ac, true)303 d.paint(sess, ac, true)
@@ -301,7 +306,8 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
301 }306 }
302 307
303 func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {308 func (d *Daemon) stopCopyPendingTimerLocked(ac *attachedClient) {
304- ac.copyESC.stop()309+ rt := ac.overlays
310+ rt.copyESC.stop()
305 }311 }
306 312
307 func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {313 func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
M internal/usecase/daemon/copymode_test.go +21 -20
@@ -26,6 +26,7 @@ import (
2626 func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) {
2727 d := newTestDaemon(t, nil, stubClock{})
2828 ac := &attachedClient{}
29+ ac.initOverlays()
2930 replacement := &closeTrackingTransport{}
3031 sendErr := errors.New("send failed")
3132 failed := &swapErrorTransport{ac: ac, replacement: replacement, err: sendErr}
@@ -119,7 +120,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
119120 awaitFrame(t, sends, ports.MsgOutput)
120121 d.handleInput(sess, ac, []byte("VIS\r"))
121122
122- if ac.copyMode == nil {
123+ if ac.overlays.copyMode == nil {
123124 t.Fatal("scrollback mode not entered")
124125 }
125126 select {
@@ -167,7 +168,7 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
167168 if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {
168169 t.Fatalf("OSC52 = %q, want %q", got, want)
169170 }
170- if ac.copyMode != nil {
171+ if ac.overlays.copyMode != nil {
171172 t.Fatal("copy mode still active after yank")
172173 }
173174 live := awaitFrame(t, sends, ports.MsgOutput)
@@ -280,7 +281,7 @@ func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
280281 awaitFrame(t, sends, ports.MsgOutput)
281282 d.handleInput(sess, ac, []byte("q"))
282283
283- if ac.copyMode != nil {
284+ if ac.overlays.copyMode != nil {
284285 t.Fatal("copy mode still active after q")
285286 }
286287 out := awaitFrame(t, sends, ports.MsgOutput)
@@ -318,8 +319,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
318319 d.handleInput(sess, ac, input)
319320 }
320321
321- require.NotNil(t, ac.copyMode)
322- require.Equal(t, tc.wantCursor, ac.copyMode.Cursor)
322+ require.NotNil(t, ac.overlays.copyMode)
323+ require.Equal(t, tc.wantCursor, ac.overlays.copyMode.Cursor)
323324 })
324325 }
325326 }
@@ -359,14 +360,14 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
359360 awaitFrame(t, sends, ports.MsgOutput)
360361 d.handleInput(sess, ac, []byte("\x1b"))
361362 timer := <-clk.timers
362- ac.copyMu.Lock()
363- require.NotNil(t, ac.copyMode)
364- ac.copyMu.Unlock()
363+ ac.overlays.copyMu.Lock()
364+ require.NotNil(t, ac.overlays.copyMode)
365+ ac.overlays.copyMu.Unlock()
365366 timer.ch <- time.Now()
366367 require.Eventually(t, func() bool {
367- ac.copyMu.Lock()
368- defer ac.copyMu.Unlock()
369- return ac.copyMode == nil
368+ ac.overlays.copyMu.Lock()
369+ defer ac.overlays.copyMu.Unlock()
370+ return ac.overlays.copyMode == nil
370371 }, time.Second, 5*time.Millisecond)
371372 }
372373
@@ -387,15 +388,15 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
387388
388389 timer.ch <- time.Now()
389390 require.Never(t, func() bool {
390- ac.copyMu.Lock()
391- defer ac.copyMu.Unlock()
392- return ac.copyMode == nil
391+ ac.overlays.copyMu.Lock()
392+ defer ac.overlays.copyMu.Unlock()
393+ return ac.overlays.copyMode == nil
393394 }, 50*time.Millisecond, 5*time.Millisecond)
394- ac.copyMu.Lock()
395- require.Nil(t, ac.copyESC.timer)
396- require.Nil(t, ac.copyESC.done)
397- require.Empty(t, ac.copyPending)
398- ac.copyMu.Unlock()
395+ ac.overlays.copyMu.Lock()
396+ require.Nil(t, ac.overlays.copyESC.timer)
397+ require.Nil(t, ac.overlays.copyESC.done)
398+ require.Empty(t, ac.overlays.copyPending)
399+ ac.overlays.copyMu.Unlock()
399400 }
400401
401402 func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
@@ -417,7 +418,7 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
417418 awaitFrame(t, sends, ports.MsgOutput)
418419 d.handleInput(sess, ac, tc.input)
419420
420- if ac.copyMode != nil {
421+ if ac.overlays.copyMode != nil {
421422 t.Fatal("copy mode still active after empty yank")
422423 }
423424 out := awaitFrame(t, sends, ports.MsgOutput)
@@ -26,6 +26,7 @@ import (
26 func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) {26 func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) {
27 d := newTestDaemon(t, nil, stubClock{})27 d := newTestDaemon(t, nil, stubClock{})
28 ac := &attachedClient{}28 ac := &attachedClient{}
29+ ac.initOverlays()
29 replacement := &closeTrackingTransport{}30 replacement := &closeTrackingTransport{}
30 sendErr := errors.New("send failed")31 sendErr := errors.New("send failed")
31 failed := &swapErrorTransport{ac: ac, replacement: replacement, err: sendErr}32 failed := &swapErrorTransport{ac: ac, replacement: replacement, err: sendErr}
@@ -119,7 +120,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
119 awaitFrame(t, sends, ports.MsgOutput)120 awaitFrame(t, sends, ports.MsgOutput)
120 d.handleInput(sess, ac, []byte("VIS\r"))121 d.handleInput(sess, ac, []byte("VIS\r"))
121 122
122- if ac.copyMode == nil {123+ if ac.overlays.copyMode == nil {
123 t.Fatal("scrollback mode not entered")124 t.Fatal("scrollback mode not entered")
124 }125 }
125 select {126 select {
@@ -167,7 +168,7 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
167 if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {168 if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {
168 t.Fatalf("OSC52 = %q, want %q", got, want)169 t.Fatalf("OSC52 = %q, want %q", got, want)
169 }170 }
170- if ac.copyMode != nil {171+ if ac.overlays.copyMode != nil {
171 t.Fatal("copy mode still active after yank")172 t.Fatal("copy mode still active after yank")
172 }173 }
173 live := awaitFrame(t, sends, ports.MsgOutput)174 live := awaitFrame(t, sends, ports.MsgOutput)
@@ -280,7 +281,7 @@ func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
280 awaitFrame(t, sends, ports.MsgOutput)281 awaitFrame(t, sends, ports.MsgOutput)
281 d.handleInput(sess, ac, []byte("q"))282 d.handleInput(sess, ac, []byte("q"))
282 283
283- if ac.copyMode != nil {284+ if ac.overlays.copyMode != nil {
284 t.Fatal("copy mode still active after q")285 t.Fatal("copy mode still active after q")
285 }286 }
286 out := awaitFrame(t, sends, ports.MsgOutput)287 out := awaitFrame(t, sends, ports.MsgOutput)
@@ -318,8 +319,8 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
318 d.handleInput(sess, ac, input)319 d.handleInput(sess, ac, input)
319 }320 }
320 321
321- require.NotNil(t, ac.copyMode)322+ require.NotNil(t, ac.overlays.copyMode)
322- require.Equal(t, tc.wantCursor, ac.copyMode.Cursor)323+ require.Equal(t, tc.wantCursor, ac.overlays.copyMode.Cursor)
323 })324 })
324 }325 }
325 }326 }
@@ -359,14 +360,14 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
359 awaitFrame(t, sends, ports.MsgOutput)360 awaitFrame(t, sends, ports.MsgOutput)
360 d.handleInput(sess, ac, []byte("\x1b"))361 d.handleInput(sess, ac, []byte("\x1b"))
361 timer := <-clk.timers362 timer := <-clk.timers
362- ac.copyMu.Lock()363+ ac.overlays.copyMu.Lock()
363- require.NotNil(t, ac.copyMode)364+ require.NotNil(t, ac.overlays.copyMode)
364- ac.copyMu.Unlock()365+ ac.overlays.copyMu.Unlock()
365 timer.ch <- time.Now()366 timer.ch <- time.Now()
366 require.Eventually(t, func() bool {367 require.Eventually(t, func() bool {
367- ac.copyMu.Lock()368+ ac.overlays.copyMu.Lock()
368- defer ac.copyMu.Unlock()369+ defer ac.overlays.copyMu.Unlock()
369- return ac.copyMode == nil370+ return ac.overlays.copyMode == nil
370 }, time.Second, 5*time.Millisecond)371 }, time.Second, 5*time.Millisecond)
371 }372 }
372 373
@@ -387,15 +388,15 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
387 388
388 timer.ch <- time.Now()389 timer.ch <- time.Now()
389 require.Never(t, func() bool {390 require.Never(t, func() bool {
390- ac.copyMu.Lock()391+ ac.overlays.copyMu.Lock()
391- defer ac.copyMu.Unlock()392+ defer ac.overlays.copyMu.Unlock()
392- return ac.copyMode == nil393+ return ac.overlays.copyMode == nil
393 }, 50*time.Millisecond, 5*time.Millisecond)394 }, 50*time.Millisecond, 5*time.Millisecond)
394- ac.copyMu.Lock()395+ ac.overlays.copyMu.Lock()
395- require.Nil(t, ac.copyESC.timer)396+ require.Nil(t, ac.overlays.copyESC.timer)
396- require.Nil(t, ac.copyESC.done)397+ require.Nil(t, ac.overlays.copyESC.done)
397- require.Empty(t, ac.copyPending)398+ require.Empty(t, ac.overlays.copyPending)
398- ac.copyMu.Unlock()399+ ac.overlays.copyMu.Unlock()
399 }400 }
400 401
401 func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {402 func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
@@ -417,7 +418,7 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
417 awaitFrame(t, sends, ports.MsgOutput)418 awaitFrame(t, sends, ports.MsgOutput)
418 d.handleInput(sess, ac, tc.input)419 d.handleInput(sess, ac, tc.input)
419 420
420- if ac.copyMode != nil {421+ if ac.overlays.copyMode != nil {
421 t.Fatal("copy mode still active after empty yank")422 t.Fatal("copy mode still active after empty yank")
422 }423 }
423 out := awaitFrame(t, sends, ports.MsgOutput)424 out := awaitFrame(t, sends, ports.MsgOutput)
M internal/usecase/daemon/daemon_test.go +1 -0
@@ -239,6 +239,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
239239 d := newTestDaemon(t, nil, stubClock{})
240240 tr, sends := newCapturingTransport(t)
241241 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
242+ ac.initOverlays()
242243 sctx, cancel := context.WithCancel(d.serveCtx)
243244 tabs := make([]*tab, 0, len(ptys))
244245 for _, p := range ptys {
@@ -239,6 +239,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
239 d := newTestDaemon(t, nil, stubClock{})239 d := newTestDaemon(t, nil, stubClock{})
240 tr, sends := newCapturingTransport(t)240 tr, sends := newCapturingTransport(t)
241 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}241 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
242+ ac.initOverlays()
242 sctx, cancel := context.WithCancel(d.serveCtx)243 sctx, cancel := context.WithCancel(d.serveCtx)
243 tabs := make([]*tab, 0, len(ptys))244 tabs := make([]*tab, 0, len(ptys))
244 for _, p := range ptys {245 for _, p := range ptys {
M internal/usecase/daemon/input.go +29 -39
@@ -43,23 +43,11 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
4343 }
4444
4545 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
46+ ac.initOverlays()
4647 ac.mouseScan.Scan(data,
4748 func(ev mouse.Event) { d.handleMouse(ac, ev) },
4849 func(b []byte) {
49- if ac.promptActive() {
50- d.handlePromptInput(ac, b)
51- return
52- }
53- if ac.paletteActive() {
54- d.handlePaletteInput(ac, b)
55- return
56- }
57- if ac.pickerActive() {
58- d.handlePickerInput(ac, b)
59- return
60- }
61- if ac.copyModeActive() {
62- d.handleCopyInput(ac, b)
50+ if ac.overlays.HandleInput(d, b) {
6351 return
6452 }
6553 ac.keys.Route(b)
@@ -68,7 +56,9 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
6856 }
6957
7058 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
71- if ac.promptActive() || ac.paletteActive() || ac.pickerActive() {
59+ ac.initOverlays()
60+ rt := ac.overlays
61+ if rt.promptActive() || rt.paletteActive() || rt.pickerActive() {
7262 return
7363 }
7464 sess := ac.currentSession()
@@ -80,7 +70,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
8070 return
8171 }
8272
83- if ac.copyModeActive() {
73+ if rt.copyActive() {
8474 if ev.Button == mouse.Left {
8575 contentRow := ev.Row - 1
8676 tb.mu.Lock()
@@ -211,25 +201,25 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
211201 switch ev.Type {
212202 case mouse.Press:
213203 if altScreen || ev.Row >= childRows {
214- ac.copyMu.Lock()
215- ac.normalMousePressValid = false
216- ac.copyMu.Unlock()
204+ rt.copyMu.Lock()
205+ rt.normalMousePressValid = false
206+ rt.copyMu.Unlock()
217207 return
218208 }
219- ac.copyMu.Lock()
220- ac.normalMousePressRow = ev.Row
221- ac.normalMousePressTop = scrollbackRows
222- ac.normalMousePressValid = true
223- ac.copyMu.Unlock()
209+ rt.copyMu.Lock()
210+ rt.normalMousePressRow = ev.Row
211+ rt.normalMousePressTop = scrollbackRows
212+ rt.normalMousePressValid = true
213+ rt.copyMu.Unlock()
224214 case mouse.Motion:
225215 if altScreen || ev.Row >= childRows {
226216 return
227217 }
228- ac.copyMu.Lock()
229- pressValid := ac.normalMousePressValid
230- pressRow := ac.normalMousePressRow
231- pressTop := ac.normalMousePressTop
232- ac.copyMu.Unlock()
218+ rt.copyMu.Lock()
219+ pressValid := rt.normalMousePressValid
220+ pressRow := rt.normalMousePressRow
221+ pressTop := rt.normalMousePressTop
222+ rt.copyMu.Unlock()
233223 if !pressValid {
234224 return
235225 }
@@ -238,21 +228,21 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
238228 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
239229 p.mu.Unlock()
240230
241- ac.copyMu.Lock()
231+ rt.copyMu.Lock()
242232 mode := scopy.NewMode(snap)
243233 mode.StartSelectionAt(snap, pressTop+pressRow)
244234 mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)
245- ac.copyMode = mode
246- ac.copyPressRow = pressTop + pressRow
247- ac.copyPressRowValid = true
248- ac.copyDragging = true
249- ac.normalMousePressValid = false
250- ac.copyMu.Unlock()
235+ rt.copyMode = mode
236+ rt.copyPressRow = pressTop + pressRow
237+ rt.copyPressRowValid = true
238+ rt.copyDragging = true
239+ rt.normalMousePressValid = false
240+ rt.copyMu.Unlock()
251241 d.paint(sess, ac, true)
252242 case mouse.Release:
253- ac.copyMu.Lock()
254- ac.normalMousePressValid = false
255- ac.copyMu.Unlock()
243+ rt.copyMu.Lock()
244+ rt.normalMousePressValid = false
245+ rt.copyMu.Unlock()
256246 }
257247 case mouse.WheelUp:
258248 if altScreen {
@@ -43,23 +43,11 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
43 }43 }
44 44
45 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {45 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
46+ ac.initOverlays()
46 ac.mouseScan.Scan(data,47 ac.mouseScan.Scan(data,
47 func(ev mouse.Event) { d.handleMouse(ac, ev) },48 func(ev mouse.Event) { d.handleMouse(ac, ev) },
48 func(b []byte) {49 func(b []byte) {
49- if ac.promptActive() {50+ if ac.overlays.HandleInput(d, b) {
50- d.handlePromptInput(ac, b)
51- return
52- }
53- if ac.paletteActive() {
54- d.handlePaletteInput(ac, b)
55- return
56- }
57- if ac.pickerActive() {
58- d.handlePickerInput(ac, b)
59- return
60- }
61- if ac.copyModeActive() {
62- d.handleCopyInput(ac, b)
63 return51 return
64 }52 }
65 ac.keys.Route(b)53 ac.keys.Route(b)
@@ -68,7 +56,9 @@ func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
68 }56 }
69 57
70 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {58 func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
71- if ac.promptActive() || ac.paletteActive() || ac.pickerActive() {59+ ac.initOverlays()
60+ rt := ac.overlays
61+ if rt.promptActive() || rt.paletteActive() || rt.pickerActive() {
72 return62 return
73 }63 }
74 sess := ac.currentSession()64 sess := ac.currentSession()
@@ -80,7 +70,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
80 return70 return
81 }71 }
82 72
83- if ac.copyModeActive() {73+ if rt.copyActive() {
84 if ev.Button == mouse.Left {74 if ev.Button == mouse.Left {
85 contentRow := ev.Row - 175 contentRow := ev.Row - 1
86 tb.mu.Lock()76 tb.mu.Lock()
@@ -211,25 +201,25 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
211 switch ev.Type {201 switch ev.Type {
212 case mouse.Press:202 case mouse.Press:
213 if altScreen || ev.Row >= childRows {203 if altScreen || ev.Row >= childRows {
214- ac.copyMu.Lock()204+ rt.copyMu.Lock()
215- ac.normalMousePressValid = false205+ rt.normalMousePressValid = false
216- ac.copyMu.Unlock()206+ rt.copyMu.Unlock()
217 return207 return
218 }208 }
219- ac.copyMu.Lock()209+ rt.copyMu.Lock()
220- ac.normalMousePressRow = ev.Row210+ rt.normalMousePressRow = ev.Row
221- ac.normalMousePressTop = scrollbackRows211+ rt.normalMousePressTop = scrollbackRows
222- ac.normalMousePressValid = true212+ rt.normalMousePressValid = true
223- ac.copyMu.Unlock()213+ rt.copyMu.Unlock()
224 case mouse.Motion:214 case mouse.Motion:
225 if altScreen || ev.Row >= childRows {215 if altScreen || ev.Row >= childRows {
226 return216 return
227 }217 }
228- ac.copyMu.Lock()218+ rt.copyMu.Lock()
229- pressValid := ac.normalMousePressValid219+ pressValid := rt.normalMousePressValid
230- pressRow := ac.normalMousePressRow220+ pressRow := rt.normalMousePressRow
231- pressTop := ac.normalMousePressTop221+ pressTop := rt.normalMousePressTop
232- ac.copyMu.Unlock()222+ rt.copyMu.Unlock()
233 if !pressValid {223 if !pressValid {
234 return224 return
235 }225 }
@@ -238,21 +228,21 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
238 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)228 snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
239 p.mu.Unlock()229 p.mu.Unlock()
240 230
241- ac.copyMu.Lock()231+ rt.copyMu.Lock()
242 mode := scopy.NewMode(snap)232 mode := scopy.NewMode(snap)
243 mode.StartSelectionAt(snap, pressTop+pressRow)233 mode.StartSelectionAt(snap, pressTop+pressRow)
244 mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)234 mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)
245- ac.copyMode = mode235+ rt.copyMode = mode
246- ac.copyPressRow = pressTop + pressRow236+ rt.copyPressRow = pressTop + pressRow
247- ac.copyPressRowValid = true237+ rt.copyPressRowValid = true
248- ac.copyDragging = true238+ rt.copyDragging = true
249- ac.normalMousePressValid = false239+ rt.normalMousePressValid = false
250- ac.copyMu.Unlock()240+ rt.copyMu.Unlock()
251 d.paint(sess, ac, true)241 d.paint(sess, ac, true)
252 case mouse.Release:242 case mouse.Release:
253- ac.copyMu.Lock()243+ rt.copyMu.Lock()
254- ac.normalMousePressValid = false244+ rt.normalMousePressValid = false
255- ac.copyMu.Unlock()245+ rt.copyMu.Unlock()
256 }246 }
257 case mouse.WheelUp:247 case mouse.WheelUp:
258 if altScreen {248 if altScreen {
M internal/usecase/daemon/input_test.go +135 -24
@@ -94,6 +94,117 @@ func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
9494 releasePTY()
9595 }
9696
97+func TestOverlayInputPrecedence(t *testing.T) {
98+ cases := []struct {
99+ name string
100+ setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame)
101+ input []byte
102+ check func(t *testing.T, ac *attachedClient, writes chan []byte)
103+ }{
104+ {
105+ name: "prompt before palette picker copy and normal",
106+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
107+ t.Helper()
108+ d.enterCopyMode(sess, ac)
109+ d.enterPicker(sess, ac)
110+ d.enterPalette(sess, ac)
111+ d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
112+ },
113+ input: []byte("x"),
114+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
115+ t.Helper()
116+ require.Equal(t, "x", ac.overlays.prompt.Value())
117+ require.Equal(t, "", ac.overlays.palette.Query())
118+ require.True(t, ac.overlays.pickerActive())
119+ require.True(t, ac.overlays.copyActive())
120+ requireNoPTYWrite(t, writes)
121+ },
122+ },
123+ {
124+ name: "palette before picker copy and normal",
125+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
126+ t.Helper()
127+ d.enterCopyMode(sess, ac)
128+ d.enterPicker(sess, ac)
129+ d.enterPalette(sess, ac)
130+ },
131+ input: []byte("x"),
132+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
133+ t.Helper()
134+ require.Equal(t, "x", ac.overlays.palette.Query())
135+ require.True(t, ac.overlays.pickerActive())
136+ require.True(t, ac.overlays.copyActive())
137+ requireNoPTYWrite(t, writes)
138+ },
139+ },
140+ {
141+ name: "picker before copy and normal",
142+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
143+ t.Helper()
144+ d.enterCopyMode(sess, ac)
145+ d.enterPicker(sess, ac)
146+ },
147+ input: []byte("j"),
148+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
149+ t.Helper()
150+ target, ok := ac.overlays.picker.Selected()
151+ require.True(t, ok)
152+ require.Equal(t, 1, target.TabIndex)
153+ require.True(t, ac.overlays.copyActive())
154+ requireNoPTYWrite(t, writes)
155+ },
156+ },
157+ {
158+ name: "copy before normal",
159+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
160+ t.Helper()
161+ d.enterCopyMode(sess, ac)
162+ },
163+ input: []byte("q"),
164+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
165+ t.Helper()
166+ require.False(t, ac.overlays.copyActive())
167+ requireNoPTYWrite(t, writes)
168+ },
169+ },
170+ {
171+ name: "normal keys when no overlay is active",
172+ input: []byte("Z"),
173+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
174+ t.Helper()
175+ require.Equal(t, []byte("Z"), <-writes)
176+ },
177+ },
178+ }
179+
180+ for _, tc := range cases {
181+ t.Run(tc.name, func(t *testing.T) {
182+ writes := make(chan []byte, 1)
183+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
184+ p2, releasePTY2 := newBlockingPTY(t)
185+ d, sess, ac, sends := newManualSessionWithPTYs(t, p, p2)
186+ defer releasePTY()
187+ defer releasePTY2()
188+ if tc.setup != nil {
189+ tc.setup(t, d, sess, ac, sends)
190+ }
191+
192+ d.handleInput(sess, ac, tc.input)
193+
194+ tc.check(t, ac, writes)
195+ })
196+ }
197+}
198+
199+func requireNoPTYWrite(t *testing.T, writes chan []byte) {
200+ t.Helper()
201+ select {
202+ case got := <-writes:
203+ t.Fatalf("input forwarded to PTY: %q", got)
204+ default:
205+ }
206+}
207+
97208 func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
98209 cases := []struct {
99210 name string
@@ -172,12 +283,12 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
172283 awaitFrame(t, sends, ports.MsgOutput)
173284 d.handleInput(sess, ac, []byte("RNS\r"))
174285 awaitFrame(t, sends, ports.MsgOutput)
175- require.True(t, ac.promptActive())
286+ require.True(t, ac.overlays.promptActive())
176287
177288 d.handleInput(sess, ac, []byte("\r"))
178289 awaitFrame(t, sends, ports.MsgOutput)
179290
180- require.False(t, ac.promptActive())
291+ require.False(t, ac.overlays.promptActive())
181292 require.False(t, sess.ephemeral)
182293 require.Equal(t, "0", sess.name)
183294 }
@@ -191,18 +302,18 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
191302
192303 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
193304 data := mustOutputData(t, sends)
194- require.NotNil(t, ac.copyMode)
195- require.Equal(t, 19, ac.copyMode.Cursor)
305+ require.NotNil(t, ac.overlays.copyMode)
306+ require.Equal(t, 19, ac.overlays.copyMode.Cursor)
196307 require.Contains(t, string(data), "[VISUAL]")
197308
198309 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
199310 mustOutputData(t, sends)
200- require.Nil(t, ac.copyMode)
311+ require.Nil(t, ac.overlays.copyMode)
201312
202313 d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
203314 mustOutputData(t, sends)
204315 mustOutputData(t, sends)
205- require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered")
316+ require.Nil(t, ac.overlays.copyMode, "q after wheel in same input must be routed after copy mode is entered")
206317 select {
207318 case got := <-writes:
208319 t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
@@ -221,7 +332,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
221332
222333 require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)
223334 require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)
224- require.Nil(t, ac.copyMode)
335+ require.Nil(t, ac.overlays.copyMode)
225336 }
226337
227338 func TestSGRRowOffset(t *testing.T) {
@@ -262,7 +373,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
262373 t.Fatalf("press without child mouse mode forwarded: %q", got)
263374 default:
264375 }
265- require.Nil(t, ac.copyMode, "press alone must not enter visual mode")
376+ require.Nil(t, ac.overlays.copyMode, "press alone must not enter visual mode")
266377
267378 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1000h"))
268379 raw := []byte("\x1b[<0;2;3M")
@@ -294,11 +405,11 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
294405
295406 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
296407 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
297- require.Nil(t, ac.copyMode, "press alone must still not enter visual mode")
408+ require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode")
298409 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
299410 mustOutputData(t, sends)
300- require.NotNil(t, ac.copyMode)
301- lo, hi, ok := ac.copyMode.SelectedBounds()
411+ require.NotNil(t, ac.overlays.copyMode)
412+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
302413 require.True(t, ok)
303414 require.Equal(t, 0, lo)
304415 require.Equal(t, 2, hi)
@@ -315,8 +426,8 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
315426 mustOutputData(t, sends)
316427 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
317428 mustOutputData(t, sends)
318- require.NotNil(t, ac.copyMode)
319- lo, hi, ok := ac.copyMode.SelectedBounds()
429+ require.NotNil(t, ac.overlays.copyMode)
430+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
320431 require.True(t, ok)
321432 require.Equal(t, 0, lo)
322433 require.Equal(t, 1, hi)
@@ -330,7 +441,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
330441 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))
331442 require.NoError(t, err)
332443 require.Equal(t, "alpha\nbravo", string(decoded))
333- require.Nil(t, ac.copyMode)
444+ require.Nil(t, ac.overlays.copyMode)
334445
335446 exitPaint := string(mustOutputData(t, sends))
336447 require.NotContains(t, exitPaint, "[SELECT]")
@@ -350,7 +461,7 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
350461
351462 // Press on a content row establishes a (soon to be stale) anchor.
352463 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
353- require.Nil(t, ac.copyMode)
464+ require.Nil(t, ac.overlays.copyMode)
354465
355466 // Release lands on the status row: must still clear the press state,
356467 // regardless of the row it landed on.
@@ -363,7 +474,7 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
363474 // Motion onto a content row must not resurrect a stale anchor.
364475 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
365476
366- require.Nil(t, ac.copyMode, "stale press state must not resurrect a selection")
477+ require.Nil(t, ac.overlays.copyMode, "stale press state must not resurrect a selection")
367478 }
368479
369480 // TestCopyModeStatusRowPressClearsDragState covers the copy-mode counterpart
@@ -386,8 +497,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
386497 // Full drag: press row0, motion to row1 -> selection [0,1].
387498 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
388499 mustOutputData(t, sends)
389- require.NotNil(t, ac.copyMode)
390- lo, hi, ok := ac.copyMode.SelectedBounds()
500+ require.NotNil(t, ac.overlays.copyMode)
501+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
391502 require.True(t, ok)
392503 require.Equal(t, 0, lo)
393504 require.Equal(t, 1, hi)
@@ -400,8 +511,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
400511 // have cleared copyPressRowValid/copyDragging.
401512 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
402513
403- require.NotNil(t, ac.copyMode)
404- lo, hi, ok = ac.copyMode.SelectedBounds()
514+ require.NotNil(t, ac.overlays.copyMode)
515+ lo, hi, ok = ac.overlays.copyMode.SelectedBounds()
405516 require.True(t, ok)
406517 require.Equal(t, 0, lo, "anchor must not have moved")
407518 require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op")
@@ -434,8 +545,8 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
434545 // pressTop(0)+ev.Row(2) = 2.
435546 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
436547
437- require.NotNil(t, ac.copyMode)
438- lo, hi, ok := ac.copyMode.SelectedBounds()
548+ require.NotNil(t, ac.overlays.copyMode)
549+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
439550 require.True(t, ok)
440551 require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")
441552 require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset")
@@ -452,7 +563,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
452563
453564 mustOutputData(t, sends)
454565 mustOutputData(t, sends)
455- require.Nil(t, ac.copyMode)
566+ require.Nil(t, ac.overlays.copyMode)
456567 select {
457568 case got := <-writes:
458569 t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
@@ -550,7 +661,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
550661 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))
551662 mustOutputData(t, sends)
552663
553- lo, hi, ok := ac.copyMode.SelectedBounds()
664+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
554665 require.True(t, ok)
555666 require.Equal(t, 0, lo)
556667 require.Equal(t, 9, hi)
@@ -94,6 +94,117 @@ func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
94 releasePTY()94 releasePTY()
95 }95 }
96 96
97+func TestOverlayInputPrecedence(t *testing.T) {
98+ cases := []struct {
99+ name string
100+ setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame)
101+ input []byte
102+ check func(t *testing.T, ac *attachedClient, writes chan []byte)
103+ }{
104+ {
105+ name: "prompt before palette picker copy and normal",
106+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
107+ t.Helper()
108+ d.enterCopyMode(sess, ac)
109+ d.enterPicker(sess, ac)
110+ d.enterPalette(sess, ac)
111+ d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
112+ },
113+ input: []byte("x"),
114+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
115+ t.Helper()
116+ require.Equal(t, "x", ac.overlays.prompt.Value())
117+ require.Equal(t, "", ac.overlays.palette.Query())
118+ require.True(t, ac.overlays.pickerActive())
119+ require.True(t, ac.overlays.copyActive())
120+ requireNoPTYWrite(t, writes)
121+ },
122+ },
123+ {
124+ name: "palette before picker copy and normal",
125+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
126+ t.Helper()
127+ d.enterCopyMode(sess, ac)
128+ d.enterPicker(sess, ac)
129+ d.enterPalette(sess, ac)
130+ },
131+ input: []byte("x"),
132+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
133+ t.Helper()
134+ require.Equal(t, "x", ac.overlays.palette.Query())
135+ require.True(t, ac.overlays.pickerActive())
136+ require.True(t, ac.overlays.copyActive())
137+ requireNoPTYWrite(t, writes)
138+ },
139+ },
140+ {
141+ name: "picker before copy and normal",
142+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
143+ t.Helper()
144+ d.enterCopyMode(sess, ac)
145+ d.enterPicker(sess, ac)
146+ },
147+ input: []byte("j"),
148+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
149+ t.Helper()
150+ target, ok := ac.overlays.picker.Selected()
151+ require.True(t, ok)
152+ require.Equal(t, 1, target.TabIndex)
153+ require.True(t, ac.overlays.copyActive())
154+ requireNoPTYWrite(t, writes)
155+ },
156+ },
157+ {
158+ name: "copy before normal",
159+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient, sends chan ports.Frame) {
160+ t.Helper()
161+ d.enterCopyMode(sess, ac)
162+ },
163+ input: []byte("q"),
164+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
165+ t.Helper()
166+ require.False(t, ac.overlays.copyActive())
167+ requireNoPTYWrite(t, writes)
168+ },
169+ },
170+ {
171+ name: "normal keys when no overlay is active",
172+ input: []byte("Z"),
173+ check: func(t *testing.T, ac *attachedClient, writes chan []byte) {
174+ t.Helper()
175+ require.Equal(t, []byte("Z"), <-writes)
176+ },
177+ },
178+ }
179+
180+ for _, tc := range cases {
181+ t.Run(tc.name, func(t *testing.T) {
182+ writes := make(chan []byte, 1)
183+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
184+ p2, releasePTY2 := newBlockingPTY(t)
185+ d, sess, ac, sends := newManualSessionWithPTYs(t, p, p2)
186+ defer releasePTY()
187+ defer releasePTY2()
188+ if tc.setup != nil {
189+ tc.setup(t, d, sess, ac, sends)
190+ }
191+
192+ d.handleInput(sess, ac, tc.input)
193+
194+ tc.check(t, ac, writes)
195+ })
196+ }
197+}
198+
199+func requireNoPTYWrite(t *testing.T, writes chan []byte) {
200+ t.Helper()
201+ select {
202+ case got := <-writes:
203+ t.Fatalf("input forwarded to PTY: %q", got)
204+ default:
205+ }
206+}
207+
97 func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {208 func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
98 cases := []struct {209 cases := []struct {
99 name string210 name string
@@ -172,12 +283,12 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
172 awaitFrame(t, sends, ports.MsgOutput)283 awaitFrame(t, sends, ports.MsgOutput)
173 d.handleInput(sess, ac, []byte("RNS\r"))284 d.handleInput(sess, ac, []byte("RNS\r"))
174 awaitFrame(t, sends, ports.MsgOutput)285 awaitFrame(t, sends, ports.MsgOutput)
175- require.True(t, ac.promptActive())286+ require.True(t, ac.overlays.promptActive())
176 287
177 d.handleInput(sess, ac, []byte("\r"))288 d.handleInput(sess, ac, []byte("\r"))
178 awaitFrame(t, sends, ports.MsgOutput)289 awaitFrame(t, sends, ports.MsgOutput)
179 290
180- require.False(t, ac.promptActive())291+ require.False(t, ac.overlays.promptActive())
181 require.False(t, sess.ephemeral)292 require.False(t, sess.ephemeral)
182 require.Equal(t, "0", sess.name)293 require.Equal(t, "0", sess.name)
183 }294 }
@@ -191,18 +302,18 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
191 302
192 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))303 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
193 data := mustOutputData(t, sends)304 data := mustOutputData(t, sends)
194- require.NotNil(t, ac.copyMode)305+ require.NotNil(t, ac.overlays.copyMode)
195- require.Equal(t, 19, ac.copyMode.Cursor)306+ require.Equal(t, 19, ac.overlays.copyMode.Cursor)
196 require.Contains(t, string(data), "[VISUAL]")307 require.Contains(t, string(data), "[VISUAL]")
197 308
198 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))309 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
199 mustOutputData(t, sends)310 mustOutputData(t, sends)
200- require.Nil(t, ac.copyMode)311+ require.Nil(t, ac.overlays.copyMode)
201 312
202 d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))313 d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq"))
203 mustOutputData(t, sends)314 mustOutputData(t, sends)
204 mustOutputData(t, sends)315 mustOutputData(t, sends)
205- require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered")316+ require.Nil(t, ac.overlays.copyMode, "q after wheel in same input must be routed after copy mode is entered")
206 select {317 select {
207 case got := <-writes:318 case got := <-writes:
208 t.Fatalf("mouse/copy input forwarded to PTY: %q", got)319 t.Fatalf("mouse/copy input forwarded to PTY: %q", got)
@@ -221,7 +332,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
221 332
222 require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)333 require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes)
223 require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)334 require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes)
224- require.Nil(t, ac.copyMode)335+ require.Nil(t, ac.overlays.copyMode)
225 }336 }
226 337
227 func TestSGRRowOffset(t *testing.T) {338 func TestSGRRowOffset(t *testing.T) {
@@ -262,7 +373,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
262 t.Fatalf("press without child mouse mode forwarded: %q", got)373 t.Fatalf("press without child mouse mode forwarded: %q", got)
263 default:374 default:
264 }375 }
265- require.Nil(t, ac.copyMode, "press alone must not enter visual mode")376+ require.Nil(t, ac.overlays.copyMode, "press alone must not enter visual mode")
266 377
267 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1000h"))378 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1000h"))
268 raw := []byte("\x1b[<0;2;3M")379 raw := []byte("\x1b[<0;2;3M")
@@ -294,11 +405,11 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
294 405
295 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))406 sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
296 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))407 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
297- require.Nil(t, ac.copyMode, "press alone must still not enter visual mode")408+ require.Nil(t, ac.overlays.copyMode, "press alone must still not enter visual mode")
298 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))409 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
299 mustOutputData(t, sends)410 mustOutputData(t, sends)
300- require.NotNil(t, ac.copyMode)411+ require.NotNil(t, ac.overlays.copyMode)
301- lo, hi, ok := ac.copyMode.SelectedBounds()412+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
302 require.True(t, ok)413 require.True(t, ok)
303 require.Equal(t, 0, lo)414 require.Equal(t, 0, lo)
304 require.Equal(t, 2, hi)415 require.Equal(t, 2, hi)
@@ -315,8 +426,8 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
315 mustOutputData(t, sends)426 mustOutputData(t, sends)
316 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))427 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
317 mustOutputData(t, sends)428 mustOutputData(t, sends)
318- require.NotNil(t, ac.copyMode)429+ require.NotNil(t, ac.overlays.copyMode)
319- lo, hi, ok := ac.copyMode.SelectedBounds()430+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
320 require.True(t, ok)431 require.True(t, ok)
321 require.Equal(t, 0, lo)432 require.Equal(t, 0, lo)
322 require.Equal(t, 1, hi)433 require.Equal(t, 1, hi)
@@ -330,7 +441,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
330 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))441 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a"))
331 require.NoError(t, err)442 require.NoError(t, err)
332 require.Equal(t, "alpha\nbravo", string(decoded))443 require.Equal(t, "alpha\nbravo", string(decoded))
333- require.Nil(t, ac.copyMode)444+ require.Nil(t, ac.overlays.copyMode)
334 445
335 exitPaint := string(mustOutputData(t, sends))446 exitPaint := string(mustOutputData(t, sends))
336 require.NotContains(t, exitPaint, "[SELECT]")447 require.NotContains(t, exitPaint, "[SELECT]")
@@ -350,7 +461,7 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
350 461
351 // Press on a content row establishes a (soon to be stale) anchor.462 // Press on a content row establishes a (soon to be stale) anchor.
352 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))463 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
353- require.Nil(t, ac.copyMode)464+ require.Nil(t, ac.overlays.copyMode)
354 465
355 // Release lands on the status row: must still clear the press state,466 // Release lands on the status row: must still clear the press state,
356 // regardless of the row it landed on.467 // regardless of the row it landed on.
@@ -363,7 +474,7 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
363 // Motion onto a content row must not resurrect a stale anchor.474 // Motion onto a content row must not resurrect a stale anchor.
364 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))475 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
365 476
366- require.Nil(t, ac.copyMode, "stale press state must not resurrect a selection")477+ require.Nil(t, ac.overlays.copyMode, "stale press state must not resurrect a selection")
367 }478 }
368 479
369 // TestCopyModeStatusRowPressClearsDragState covers the copy-mode counterpart480 // TestCopyModeStatusRowPressClearsDragState covers the copy-mode counterpart
@@ -386,8 +497,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
386 // Full drag: press row0, motion to row1 -> selection [0,1].497 // Full drag: press row0, motion to row1 -> selection [0,1].
387 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))498 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M"))
388 mustOutputData(t, sends)499 mustOutputData(t, sends)
389- require.NotNil(t, ac.copyMode)500+ require.NotNil(t, ac.overlays.copyMode)
390- lo, hi, ok := ac.copyMode.SelectedBounds()501+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
391 require.True(t, ok)502 require.True(t, ok)
392 require.Equal(t, 0, lo)503 require.Equal(t, 0, lo)
393 require.Equal(t, 1, hi)504 require.Equal(t, 1, hi)
@@ -400,8 +511,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
400 // have cleared copyPressRowValid/copyDragging.511 // have cleared copyPressRowValid/copyDragging.
401 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))512 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
402 513
403- require.NotNil(t, ac.copyMode)514+ require.NotNil(t, ac.overlays.copyMode)
404- lo, hi, ok = ac.copyMode.SelectedBounds()515+ lo, hi, ok = ac.overlays.copyMode.SelectedBounds()
405 require.True(t, ok)516 require.True(t, ok)
406 require.Equal(t, 0, lo, "anchor must not have moved")517 require.Equal(t, 0, lo, "anchor must not have moved")
407 require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op")518 require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op")
@@ -434,8 +545,8 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
434 // pressTop(0)+ev.Row(2) = 2.545 // pressTop(0)+ev.Row(2) = 2.
435 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))546 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
436 547
437- require.NotNil(t, ac.copyMode)548+ require.NotNil(t, ac.overlays.copyMode)
438- lo, hi, ok := ac.copyMode.SelectedBounds()549+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
439 require.True(t, ok)550 require.True(t, ok)
440 require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")551 require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time")
441 require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset")552 require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset")
@@ -452,7 +563,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
452 563
453 mustOutputData(t, sends)564 mustOutputData(t, sends)
454 mustOutputData(t, sends)565 mustOutputData(t, sends)
455- require.Nil(t, ac.copyMode)566+ require.Nil(t, ac.overlays.copyMode)
456 select {567 select {
457 case got := <-writes:568 case got := <-writes:
458 t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)569 t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got)
@@ -550,7 +661,7 @@ func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
550 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))661 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))
551 mustOutputData(t, sends)662 mustOutputData(t, sends)
552 663
553- lo, hi, ok := ac.copyMode.SelectedBounds()664+ lo, hi, ok := ac.overlays.copyMode.SelectedBounds()
554 require.True(t, ok)665 require.True(t, ok)
555 require.Equal(t, 0, lo)666 require.Equal(t, 0, lo)
556 require.Equal(t, 9, hi)667 require.Equal(t, 9, hi)
M internal/usecase/daemon/mouse_panes.go +7 -6
@@ -142,10 +142,11 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) {
142142 if ac == nil {
143143 return
144144 }
145- ac.copyMu.Lock()
146- ac.copyMode = nil
147- ac.copyPressRowValid = false
148- ac.copyDragging = false
149- ac.normalMousePressValid = false
150- ac.copyMu.Unlock()
145+ rt := ac.overlays
146+ rt.copyMu.Lock()
147+ rt.copyMode = nil
148+ rt.copyPressRowValid = false
149+ rt.copyDragging = false
150+ rt.normalMousePressValid = false
151+ rt.copyMu.Unlock()
151152 }
@@ -142,10 +142,11 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) {
142 if ac == nil {142 if ac == nil {
143 return143 return
144 }144 }
145- ac.copyMu.Lock()145+ rt := ac.overlays
146- ac.copyMode = nil146+ rt.copyMu.Lock()
147- ac.copyPressRowValid = false147+ rt.copyMode = nil
148- ac.copyDragging = false148+ rt.copyPressRowValid = false
149- ac.normalMousePressValid = false149+ rt.copyDragging = false
150- ac.copyMu.Unlock()150+ rt.normalMousePressValid = false
151+ rt.copyMu.Unlock()
151 }152 }
M internal/usecase/daemon/nil_pty_test.go +1 -0
@@ -58,6 +58,7 @@ func TestNilPTYInputPathsDoNotPanic(t *testing.T) {
5858 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
5959 sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
6060 ac := &attachedClient{}
61+ ac.initOverlays()
6162 ac.setSession(sess)
6263
6364 require.NotPanics(t, func() { d.writeToPane(sess, tb.focusedPane(), []byte("x")) })
@@ -58,6 +58,7 @@ func TestNilPTYInputPathsDoNotPanic(t *testing.T) {
58 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})58 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
59 sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel, tabs: []*tab{tb}}59 sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel, tabs: []*tab{tb}}
60 ac := &attachedClient{}60 ac := &attachedClient{}
61+ ac.initOverlays()
61 ac.setSession(sess)62 ac.setSession(sess)
62 63
63 require.NotPanics(t, func() { d.writeToPane(sess, tb.focusedPane(), []byte("x")) })64 require.NotPanics(t, func() { d.writeToPane(sess, tb.focusedPane(), []byte("x")) })
A internal/usecase/daemon/overlay_runtime.go +204 -0
@@ -0,0 +1,204 @@
1+package daemon
2+
3+import (
4+ "sync"
5+
6+ scopy "github.com/bnema/vev/internal/usecase/copy"
7+ "github.com/bnema/vev/internal/usecase/palette"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+ promptui "github.com/bnema/vev/internal/usecase/prompt"
10+)
11+
12+type overlayRuntime struct {
13+ ac *attachedClient
14+
15+ pickerMu sync.Mutex
16+ picker *picker.Model
17+ pickerPreview *tab
18+ pickerPending []byte
19+ pickerESC pendingByteTimer
20+
21+ paletteMu sync.Mutex
22+ palette *palette.Model
23+ palettePending []byte
24+
25+ promptMu sync.Mutex
26+ prompt *promptui.Model
27+ promptSubmit func(string) error
28+ promptPending []byte
29+
30+ copyMu sync.Mutex
31+ copyMode *scopy.Mode
32+ copyPending []byte
33+ copyESC pendingByteTimer
34+ copyFeedback string
35+ copyPressRow int
36+ copyPressRowValid bool
37+ copyDragging bool
38+ normalMousePressRow int
39+ normalMousePressTop int
40+ normalMousePressValid bool
41+}
42+
43+func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
44+ return &overlayRuntime{ac: ac}
45+}
46+
47+func (rt *overlayRuntime) Active() bool {
48+ if rt == nil || rt.ac == nil {
49+ return false
50+ }
51+ return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.copyActive()
52+}
53+
54+func (rt *overlayRuntime) promptActive() bool {
55+ if rt == nil {
56+ return false
57+ }
58+ rt.promptMu.Lock()
59+ defer rt.promptMu.Unlock()
60+ return rt.prompt != nil
61+}
62+
63+func (rt *overlayRuntime) paletteActive() bool {
64+ if rt == nil {
65+ return false
66+ }
67+ rt.paletteMu.Lock()
68+ defer rt.paletteMu.Unlock()
69+ return rt.palette != nil
70+}
71+
72+func (rt *overlayRuntime) pickerActive() bool {
73+ if rt == nil {
74+ return false
75+ }
76+ rt.pickerMu.Lock()
77+ defer rt.pickerMu.Unlock()
78+ return rt.picker != nil
79+}
80+
81+func (rt *overlayRuntime) copyActive() bool {
82+ if rt == nil {
83+ return false
84+ }
85+ rt.copyMu.Lock()
86+ defer rt.copyMu.Unlock()
87+ return rt.copyMode != nil
88+}
89+
90+func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
91+ if rt == nil || rt.ac == nil {
92+ return false
93+ }
94+ ac := rt.ac
95+ if rt.promptActive() {
96+ d.handlePromptInput(ac, data)
97+ return true
98+ }
99+ if rt.paletteActive() {
100+ d.handlePaletteInput(ac, data)
101+ return true
102+ }
103+ if rt.pickerActive() {
104+ d.handlePickerInput(ac, data)
105+ return true
106+ }
107+ if rt.copyActive() {
108+ d.handleCopyInput(ac, data)
109+ return true
110+ }
111+ return false
112+}
113+
114+type overlayRenderSnapshot struct {
115+ rt *overlayRuntime
116+
117+ copyActive bool
118+ copyMode *scopy.Mode
119+ copyFeedback string
120+
121+ pickerActive bool
122+ pickerModel *picker.Model
123+ previewTab *tab
124+
125+ paletteActive bool
126+ paletteModel *palette.Model
127+ paletteLocked bool
128+
129+ promptActive bool
130+ promptModel *promptui.Model
131+ promptLocked bool
132+}
133+
134+// SnapshotForRender captures the overlay state needed by paint.
135+//
136+// The snapshot owns any prompt or palette locks it had to keep held so the
137+// returned model pointers stay stable during composition. Callers must release
138+// those locks with overlayRenderSnapshot.Unlock, usually with defer immediately
139+// after acquisition and before any path that may re-enter rendering.
140+func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
141+ snap := &overlayRenderSnapshot{rt: rt}
142+ if rt == nil {
143+ return snap
144+ }
145+
146+ rt.copyMu.Lock()
147+ snap.copyActive = rt.copyMode != nil
148+ if rt.copyMode != nil {
149+ copyModeValue := *rt.copyMode
150+ snap.copyMode = &copyModeValue
151+ }
152+ snap.copyFeedback = rt.copyFeedback
153+ if snap.copyFeedback != "" && !snap.copyActive {
154+ rt.copyFeedback = ""
155+ }
156+ rt.copyMu.Unlock()
157+
158+ rt.pickerMu.Lock()
159+ snap.pickerActive = rt.picker != nil
160+ snap.pickerModel = rt.picker.Clone()
161+ snap.previewTab = rt.pickerPreview
162+ rt.pickerMu.Unlock()
163+
164+ rt.paletteMu.Lock()
165+ snap.paletteModel = rt.palette
166+ snap.paletteActive = snap.paletteModel != nil
167+ if snap.paletteActive {
168+ snap.paletteLocked = true
169+ } else {
170+ rt.paletteMu.Unlock()
171+ }
172+
173+ rt.promptMu.Lock()
174+ snap.promptModel = rt.prompt
175+ snap.promptActive = snap.promptModel != nil
176+ if snap.promptActive {
177+ snap.promptLocked = true
178+ } else {
179+ rt.promptMu.Unlock()
180+ }
181+
182+ return snap
183+}
184+
185+func (rt *overlayRuntime) UnlockRenderSnapshot(snap *overlayRenderSnapshot) {
186+ if snap == nil {
187+ return
188+ }
189+ snap.Unlock()
190+}
191+
192+func (snap *overlayRenderSnapshot) Unlock() {
193+ if snap == nil || snap.rt == nil {
194+ return
195+ }
196+ if snap.promptLocked {
197+ snap.rt.promptMu.Unlock()
198+ snap.promptLocked = false
199+ }
200+ if snap.paletteLocked {
201+ snap.rt.paletteMu.Unlock()
202+ snap.paletteLocked = false
203+ }
204+}
@@ -0,0 +1,204 @@
1+package daemon
2+
3+import (
4+ "sync"
5+
6+ scopy "github.com/bnema/vev/internal/usecase/copy"
7+ "github.com/bnema/vev/internal/usecase/palette"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+ promptui "github.com/bnema/vev/internal/usecase/prompt"
10+)
11+
12+type overlayRuntime struct {
13+ ac *attachedClient
14+
15+ pickerMu sync.Mutex
16+ picker *picker.Model
17+ pickerPreview *tab
18+ pickerPending []byte
19+ pickerESC pendingByteTimer
20+
21+ paletteMu sync.Mutex
22+ palette *palette.Model
23+ palettePending []byte
24+
25+ promptMu sync.Mutex
26+ prompt *promptui.Model
27+ promptSubmit func(string) error
28+ promptPending []byte
29+
30+ copyMu sync.Mutex
31+ copyMode *scopy.Mode
32+ copyPending []byte
33+ copyESC pendingByteTimer
34+ copyFeedback string
35+ copyPressRow int
36+ copyPressRowValid bool
37+ copyDragging bool
38+ normalMousePressRow int
39+ normalMousePressTop int
40+ normalMousePressValid bool
41+}
42+
43+func newOverlayRuntime(ac *attachedClient) *overlayRuntime {
44+ return &overlayRuntime{ac: ac}
45+}
46+
47+func (rt *overlayRuntime) Active() bool {
48+ if rt == nil || rt.ac == nil {
49+ return false
50+ }
51+ return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.copyActive()
52+}
53+
54+func (rt *overlayRuntime) promptActive() bool {
55+ if rt == nil {
56+ return false
57+ }
58+ rt.promptMu.Lock()
59+ defer rt.promptMu.Unlock()
60+ return rt.prompt != nil
61+}
62+
63+func (rt *overlayRuntime) paletteActive() bool {
64+ if rt == nil {
65+ return false
66+ }
67+ rt.paletteMu.Lock()
68+ defer rt.paletteMu.Unlock()
69+ return rt.palette != nil
70+}
71+
72+func (rt *overlayRuntime) pickerActive() bool {
73+ if rt == nil {
74+ return false
75+ }
76+ rt.pickerMu.Lock()
77+ defer rt.pickerMu.Unlock()
78+ return rt.picker != nil
79+}
80+
81+func (rt *overlayRuntime) copyActive() bool {
82+ if rt == nil {
83+ return false
84+ }
85+ rt.copyMu.Lock()
86+ defer rt.copyMu.Unlock()
87+ return rt.copyMode != nil
88+}
89+
90+func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool {
91+ if rt == nil || rt.ac == nil {
92+ return false
93+ }
94+ ac := rt.ac
95+ if rt.promptActive() {
96+ d.handlePromptInput(ac, data)
97+ return true
98+ }
99+ if rt.paletteActive() {
100+ d.handlePaletteInput(ac, data)
101+ return true
102+ }
103+ if rt.pickerActive() {
104+ d.handlePickerInput(ac, data)
105+ return true
106+ }
107+ if rt.copyActive() {
108+ d.handleCopyInput(ac, data)
109+ return true
110+ }
111+ return false
112+}
113+
114+type overlayRenderSnapshot struct {
115+ rt *overlayRuntime
116+
117+ copyActive bool
118+ copyMode *scopy.Mode
119+ copyFeedback string
120+
121+ pickerActive bool
122+ pickerModel *picker.Model
123+ previewTab *tab
124+
125+ paletteActive bool
126+ paletteModel *palette.Model
127+ paletteLocked bool
128+
129+ promptActive bool
130+ promptModel *promptui.Model
131+ promptLocked bool
132+}
133+
134+// SnapshotForRender captures the overlay state needed by paint.
135+//
136+// The snapshot owns any prompt or palette locks it had to keep held so the
137+// returned model pointers stay stable during composition. Callers must release
138+// those locks with overlayRenderSnapshot.Unlock, usually with defer immediately
139+// after acquisition and before any path that may re-enter rendering.
140+func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
141+ snap := &overlayRenderSnapshot{rt: rt}
142+ if rt == nil {
143+ return snap
144+ }
145+
146+ rt.copyMu.Lock()
147+ snap.copyActive = rt.copyMode != nil
148+ if rt.copyMode != nil {
149+ copyModeValue := *rt.copyMode
150+ snap.copyMode = &copyModeValue
151+ }
152+ snap.copyFeedback = rt.copyFeedback
153+ if snap.copyFeedback != "" && !snap.copyActive {
154+ rt.copyFeedback = ""
155+ }
156+ rt.copyMu.Unlock()
157+
158+ rt.pickerMu.Lock()
159+ snap.pickerActive = rt.picker != nil
160+ snap.pickerModel = rt.picker.Clone()
161+ snap.previewTab = rt.pickerPreview
162+ rt.pickerMu.Unlock()
163+
164+ rt.paletteMu.Lock()
165+ snap.paletteModel = rt.palette
166+ snap.paletteActive = snap.paletteModel != nil
167+ if snap.paletteActive {
168+ snap.paletteLocked = true
169+ } else {
170+ rt.paletteMu.Unlock()
171+ }
172+
173+ rt.promptMu.Lock()
174+ snap.promptModel = rt.prompt
175+ snap.promptActive = snap.promptModel != nil
176+ if snap.promptActive {
177+ snap.promptLocked = true
178+ } else {
179+ rt.promptMu.Unlock()
180+ }
181+
182+ return snap
183+}
184+
185+func (rt *overlayRuntime) UnlockRenderSnapshot(snap *overlayRenderSnapshot) {
186+ if snap == nil {
187+ return
188+ }
189+ snap.Unlock()
190+}
191+
192+func (snap *overlayRenderSnapshot) Unlock() {
193+ if snap == nil || snap.rt == nil {
194+ return
195+ }
196+ if snap.promptLocked {
197+ snap.rt.promptMu.Unlock()
198+ snap.promptLocked = false
199+ }
200+ if snap.paletteLocked {
201+ snap.rt.paletteMu.Unlock()
202+ snap.paletteLocked = false
203+ }
204+}
A internal/usecase/daemon/overlay_runtime_test.go +113 -0
@@ -0,0 +1,113 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ promptui "github.com/bnema/vev/internal/usecase/prompt"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestOverlayRuntimeActive(t *testing.T) {
11+ ac := &attachedClient{}
12+ ac.initOverlays()
13+ require.False(t, ac.overlays.Active())
14+
15+ ac.overlays.promptMu.Lock()
16+ ac.overlays.prompt = promptui.New(" Test ", "")
17+ ac.overlays.promptMu.Unlock()
18+ require.True(t, ac.overlays.Active())
19+}
20+
21+func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
22+ cases := []struct {
23+ name string
24+ setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
25+ input []byte
26+ check func(t *testing.T, ac *attachedClient)
27+ }{
28+ {
29+ name: "prompt before palette picker copy",
30+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
31+ t.Helper()
32+ d.enterCopyMode(sess, ac)
33+ d.enterPicker(sess, ac)
34+ d.enterPalette(sess, ac)
35+ d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
36+ },
37+ input: []byte("x"),
38+ check: func(t *testing.T, ac *attachedClient) {
39+ t.Helper()
40+ require.Equal(t, "x", ac.overlays.prompt.Value())
41+ require.Equal(t, "", ac.overlays.palette.Query())
42+ require.True(t, ac.overlays.pickerActive())
43+ require.True(t, ac.overlays.copyActive())
44+ },
45+ },
46+ {
47+ name: "palette before picker copy",
48+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
49+ t.Helper()
50+ d.enterCopyMode(sess, ac)
51+ d.enterPicker(sess, ac)
52+ d.enterPalette(sess, ac)
53+ },
54+ input: []byte("x"),
55+ check: func(t *testing.T, ac *attachedClient) {
56+ t.Helper()
57+ require.Equal(t, "x", ac.overlays.palette.Query())
58+ require.True(t, ac.overlays.pickerActive())
59+ require.True(t, ac.overlays.copyActive())
60+ },
61+ },
62+ {
63+ name: "picker before copy",
64+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
65+ t.Helper()
66+ d.enterCopyMode(sess, ac)
67+ d.enterPicker(sess, ac)
68+ },
69+ input: []byte("j"),
70+ check: func(t *testing.T, ac *attachedClient) {
71+ t.Helper()
72+ target, ok := ac.overlays.picker.Selected()
73+ require.True(t, ok)
74+ require.Equal(t, 1, target.TabIndex)
75+ require.True(t, ac.overlays.copyActive())
76+ },
77+ },
78+ {
79+ name: "copy",
80+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
81+ t.Helper()
82+ d.enterCopyMode(sess, ac)
83+ },
84+ input: []byte("q"),
85+ check: func(t *testing.T, ac *attachedClient) {
86+ t.Helper()
87+ require.False(t, ac.overlays.copyActive())
88+ },
89+ },
90+ }
91+
92+ for _, tc := range cases {
93+ t.Run(tc.name, func(t *testing.T) {
94+ p1, releasePTY1 := newBlockingPTY(t)
95+ p2, releasePTY2 := newBlockingPTY(t)
96+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
97+ defer releasePTY1()
98+ defer releasePTY2()
99+ tc.setup(t, d, sess, ac)
100+
101+ require.True(t, ac.overlays.HandleInput(d, tc.input))
102+
103+ tc.check(t, ac)
104+ })
105+ }
106+}
107+
108+func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
109+ ac := &attachedClient{}
110+ ac.initOverlays()
111+
112+ require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))
113+}
@@ -0,0 +1,113 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ promptui "github.com/bnema/vev/internal/usecase/prompt"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestOverlayRuntimeActive(t *testing.T) {
11+ ac := &attachedClient{}
12+ ac.initOverlays()
13+ require.False(t, ac.overlays.Active())
14+
15+ ac.overlays.promptMu.Lock()
16+ ac.overlays.prompt = promptui.New(" Test ", "")
17+ ac.overlays.promptMu.Unlock()
18+ require.True(t, ac.overlays.Active())
19+}
20+
21+func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
22+ cases := []struct {
23+ name string
24+ setup func(t *testing.T, d *Daemon, sess *session, ac *attachedClient)
25+ input []byte
26+ check func(t *testing.T, ac *attachedClient)
27+ }{
28+ {
29+ name: "prompt before palette picker copy",
30+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
31+ t.Helper()
32+ d.enterCopyMode(sess, ac)
33+ d.enterPicker(sess, ac)
34+ d.enterPalette(sess, ac)
35+ d.enterPrompt(sess, ac, "Rename", "", func(string) error { return nil })
36+ },
37+ input: []byte("x"),
38+ check: func(t *testing.T, ac *attachedClient) {
39+ t.Helper()
40+ require.Equal(t, "x", ac.overlays.prompt.Value())
41+ require.Equal(t, "", ac.overlays.palette.Query())
42+ require.True(t, ac.overlays.pickerActive())
43+ require.True(t, ac.overlays.copyActive())
44+ },
45+ },
46+ {
47+ name: "palette before picker copy",
48+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
49+ t.Helper()
50+ d.enterCopyMode(sess, ac)
51+ d.enterPicker(sess, ac)
52+ d.enterPalette(sess, ac)
53+ },
54+ input: []byte("x"),
55+ check: func(t *testing.T, ac *attachedClient) {
56+ t.Helper()
57+ require.Equal(t, "x", ac.overlays.palette.Query())
58+ require.True(t, ac.overlays.pickerActive())
59+ require.True(t, ac.overlays.copyActive())
60+ },
61+ },
62+ {
63+ name: "picker before copy",
64+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
65+ t.Helper()
66+ d.enterCopyMode(sess, ac)
67+ d.enterPicker(sess, ac)
68+ },
69+ input: []byte("j"),
70+ check: func(t *testing.T, ac *attachedClient) {
71+ t.Helper()
72+ target, ok := ac.overlays.picker.Selected()
73+ require.True(t, ok)
74+ require.Equal(t, 1, target.TabIndex)
75+ require.True(t, ac.overlays.copyActive())
76+ },
77+ },
78+ {
79+ name: "copy",
80+ setup: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
81+ t.Helper()
82+ d.enterCopyMode(sess, ac)
83+ },
84+ input: []byte("q"),
85+ check: func(t *testing.T, ac *attachedClient) {
86+ t.Helper()
87+ require.False(t, ac.overlays.copyActive())
88+ },
89+ },
90+ }
91+
92+ for _, tc := range cases {
93+ t.Run(tc.name, func(t *testing.T) {
94+ p1, releasePTY1 := newBlockingPTY(t)
95+ p2, releasePTY2 := newBlockingPTY(t)
96+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1, p2)
97+ defer releasePTY1()
98+ defer releasePTY2()
99+ tc.setup(t, d, sess, ac)
100+
101+ require.True(t, ac.overlays.HandleInput(d, tc.input))
102+
103+ tc.check(t, ac)
104+ })
105+ }
106+}
107+
108+func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
109+ ac := &attachedClient{}
110+ ac.initOverlays()
111+
112+ require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))
113+}
M internal/usecase/daemon/overlay_test.go +96 -89
@@ -6,96 +6,103 @@ import (
66 "github.com/stretchr/testify/require"
77 )
88
9-func TestRouteOverlayBytesHandlesTextAndSplitUTF8(t *testing.T) {
10- var pending []byte
11- var got []rune
12- ev := overlayEvents{rune: func(r rune) { got = append(got, r) }}
13-
14- routeOverlayBytes([]byte{'w', 0xc3}, &pending, ev)
15- require.Equal(t, []rune{'w'}, got)
16- require.Equal(t, []byte{0xc3}, pending)
17-
18- routeOverlayBytes([]byte{0xb8, 'k'}, &pending, ev)
19- require.Equal(t, []rune{'w', 'ø', 'k'}, got)
20- require.Empty(t, pending)
21-}
22-
23-func TestRouteOverlayBytesHandlesSplitArrowsAndCtrlNavigation(t *testing.T) {
24- var pending []byte
25- var up, down int
26- ev := overlayEvents{
27- up: func() { up++ },
28- down: func() { down++ },
29- }
30-
31- routeOverlayBytes([]byte("\x1b["), &pending, ev)
32- require.Equal(t, []byte("\x1b["), pending)
33- routeOverlayBytes([]byte("B"), &pending, ev)
34- require.Equal(t, 1, down)
35- require.Empty(t, pending)
36-
37- routeOverlayBytes([]byte("\x1b[A\x0e\x10"), &pending, ev)
38- require.Equal(t, 2, up)
39- require.Equal(t, 2, down)
40-}
41-
42-func TestRouteOverlayBytesHandlesSS3Arrows(t *testing.T) {
43- var pending []byte
44- var up, down int
45- ev := overlayEvents{
46- up: func() { up++ },
47- down: func() { down++ },
9+func TestRouteOverlayBytes(t *testing.T) {
10+ t.Parallel()
11+
12+ tests := []struct {
13+ name string
14+ chunks [][]byte
15+ wantRunes []rune
16+ wantPending []byte
17+ wantBackspace int
18+ wantEnter int
19+ wantCancel int
20+ wantUp int
21+ wantDown int
22+ }{
23+ {
24+ name: "text and split utf8",
25+ chunks: [][]byte{{'w', 0xc3}, {0xb8, 'k'}},
26+ wantRunes: []rune{'w', 'ø', 'k'},
27+ wantPending: nil,
28+ },
29+ {
30+ name: "incomplete utf8 remains pending",
31+ chunks: [][]byte{{'w', 0xc3}},
32+ wantRunes: []rune{'w'},
33+ wantPending: []byte{0xc3},
34+ },
35+ {
36+ name: "incomplete csi resumes as arrow",
37+ chunks: [][]byte{[]byte("\x1b["), []byte("B")},
38+ wantDown: 1,
39+ },
40+ {
41+ name: "incomplete csi remains pending",
42+ chunks: [][]byte{[]byte("\x1b[")},
43+ wantPending: []byte("\x1b["),
44+ },
45+ {
46+ name: "incomplete ss3 resumes as arrow",
47+ chunks: [][]byte{[]byte("\x1bO"), []byte("A\x1bOB")},
48+ wantUp: 1,
49+ wantDown: 1,
50+ },
51+ {
52+ name: "incomplete ss3 remains pending",
53+ chunks: [][]byte{[]byte("\x1bO")},
54+ wantPending: []byte("\x1bO"),
55+ },
56+ {
57+ name: "arrows and ctrl navigation",
58+ chunks: [][]byte{[]byte("\x1b[A\x1b[B\x0e\x10")},
59+ wantUp: 2,
60+ wantDown: 2,
61+ },
62+ {
63+ name: "unhandled escapes are consumed",
64+ chunks: [][]byte{[]byte("a\x1b[D\x1b[3~b\x1bOQ")},
65+ wantRunes: []rune{'a', 'b'},
66+ },
67+ {
68+ name: "submit cancel and editing",
69+ chunks: [][]byte{{0x7f, 0x08, '\r', '\n', 0x1b, 0x03}},
70+ wantBackspace: 2,
71+ wantEnter: 2,
72+ wantCancel: 2,
73+ },
74+ {
75+ name: "controls and invalid utf8 ignored",
76+ chunks: [][]byte{{'a', 0x01, 0xff, 'b'}},
77+ wantRunes: []rune{'a', 'b'},
78+ },
4879 }
4980
50- routeOverlayBytes([]byte("\x1bO"), &pending, ev)
51- require.Equal(t, []byte("\x1bO"), pending)
52- routeOverlayBytes([]byte("A\x1bOB"), &pending, ev)
53-
54- require.Equal(t, 1, up)
55- require.Equal(t, 1, down)
56- require.Empty(t, pending)
57-}
58-
59-func TestRouteOverlayBytesConsumesUnhandledEscapeSequences(t *testing.T) {
60- var pending []byte
61- var got []rune
62- var cancel int
63- ev := overlayEvents{
64- rune: func(r rune) { got = append(got, r) },
65- cancel: func() { cancel++ },
81+ for _, tt := range tests {
82+ t.Run(tt.name, func(t *testing.T) {
83+ var pending []byte
84+ var gotRunes []rune
85+ var gotBackspace, gotEnter, gotCancel, gotUp, gotDown int
86+ ev := overlayEvents{
87+ rune: func(r rune) { gotRunes = append(gotRunes, r) },
88+ backspace: func() { gotBackspace++ },
89+ enter: func() { gotEnter++ },
90+ cancel: func() { gotCancel++ },
91+ up: func() { gotUp++ },
92+ down: func() { gotDown++ },
93+ }
94+
95+ for _, chunk := range tt.chunks {
96+ routeOverlayBytes(chunk, &pending, ev)
97+ }
98+
99+ require.Equal(t, tt.wantRunes, gotRunes)
100+ require.Equal(t, tt.wantPending, pending)
101+ require.Equal(t, tt.wantBackspace, gotBackspace)
102+ require.Equal(t, tt.wantEnter, gotEnter)
103+ require.Equal(t, tt.wantCancel, gotCancel)
104+ require.Equal(t, tt.wantUp, gotUp)
105+ require.Equal(t, tt.wantDown, gotDown)
106+ })
66107 }
67-
68- routeOverlayBytes([]byte("a\x1b[D\x1b[3~b\x1bOQ"), &pending, ev)
69-
70- require.Equal(t, []rune{'a', 'b'}, got)
71- require.Equal(t, 0, cancel)
72- require.Empty(t, pending)
73-}
74-
75-func TestRouteOverlayBytesHandlesEditingSubmitAndCancel(t *testing.T) {
76- var pending []byte
77- var backspace, enter, cancel int
78- ev := overlayEvents{
79- backspace: func() { backspace++ },
80- enter: func() { enter++ },
81- cancel: func() { cancel++ },
82- }
83-
84- routeOverlayBytes([]byte{0x7f, 0x08, '\r', '\n', 0x1b, 0x03}, &pending, ev)
85-
86- require.Equal(t, 2, backspace)
87- require.Equal(t, 2, enter)
88- require.Equal(t, 2, cancel)
89- require.Empty(t, pending)
90-}
91-
92-func TestRouteOverlayBytesIgnoresControlsAndInvalidUTF8(t *testing.T) {
93- var pending []byte
94- var got []rune
95- ev := overlayEvents{rune: func(r rune) { got = append(got, r) }}
96-
97- routeOverlayBytes([]byte{'a', 0x01, 0xff, 'b'}, &pending, ev)
98-
99- require.Equal(t, []rune{'a', 'b'}, got)
100- require.Empty(t, pending)
101108 }
@@ -6,96 +6,103 @@ import (
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 )7 )
8 8
9-func TestRouteOverlayBytesHandlesTextAndSplitUTF8(t *testing.T) {9+func TestRouteOverlayBytes(t *testing.T) {
10- var pending []byte10+ t.Parallel()
11- var got []rune11+
12- ev := overlayEvents{rune: func(r rune) { got = append(got, r) }}12+ tests := []struct {
13-13+ name string
14- routeOverlayBytes([]byte{'w', 0xc3}, &pending, ev)14+ chunks [][]byte
15- require.Equal(t, []rune{'w'}, got)15+ wantRunes []rune
16- require.Equal(t, []byte{0xc3}, pending)16+ wantPending []byte
17-17+ wantBackspace int
18- routeOverlayBytes([]byte{0xb8, 'k'}, &pending, ev)18+ wantEnter int
19- require.Equal(t, []rune{'w', 'ø', 'k'}, got)19+ wantCancel int
20- require.Empty(t, pending)20+ wantUp int
21-}21+ wantDown int
22-22+ }{
23-func TestRouteOverlayBytesHandlesSplitArrowsAndCtrlNavigation(t *testing.T) {23+ {
24- var pending []byte24+ name: "text and split utf8",
25- var up, down int25+ chunks: [][]byte{{'w', 0xc3}, {0xb8, 'k'}},
26- ev := overlayEvents{26+ wantRunes: []rune{'w', 'ø', 'k'},
27- up: func() { up++ },27+ wantPending: nil,
28- down: func() { down++ },28+ },
29- }29+ {
30-30+ name: "incomplete utf8 remains pending",
31- routeOverlayBytes([]byte("\x1b["), &pending, ev)31+ chunks: [][]byte{{'w', 0xc3}},
32- require.Equal(t, []byte("\x1b["), pending)32+ wantRunes: []rune{'w'},
33- routeOverlayBytes([]byte("B"), &pending, ev)33+ wantPending: []byte{0xc3},
34- require.Equal(t, 1, down)34+ },
35- require.Empty(t, pending)35+ {
36-36+ name: "incomplete csi resumes as arrow",
37- routeOverlayBytes([]byte("\x1b[A\x0e\x10"), &pending, ev)37+ chunks: [][]byte{[]byte("\x1b["), []byte("B")},
38- require.Equal(t, 2, up)38+ wantDown: 1,
39- require.Equal(t, 2, down)39+ },
40-}40+ {
41-41+ name: "incomplete csi remains pending",
42-func TestRouteOverlayBytesHandlesSS3Arrows(t *testing.T) {42+ chunks: [][]byte{[]byte("\x1b[")},
43- var pending []byte43+ wantPending: []byte("\x1b["),
44- var up, down int44+ },
45- ev := overlayEvents{45+ {
46- up: func() { up++ },46+ name: "incomplete ss3 resumes as arrow",
47- down: func() { down++ },47+ chunks: [][]byte{[]byte("\x1bO"), []byte("A\x1bOB")},
48+ wantUp: 1,
49+ wantDown: 1,
50+ },
51+ {
52+ name: "incomplete ss3 remains pending",
53+ chunks: [][]byte{[]byte("\x1bO")},
54+ wantPending: []byte("\x1bO"),
55+ },
56+ {
57+ name: "arrows and ctrl navigation",
58+ chunks: [][]byte{[]byte("\x1b[A\x1b[B\x0e\x10")},
59+ wantUp: 2,
60+ wantDown: 2,
61+ },
62+ {
63+ name: "unhandled escapes are consumed",
64+ chunks: [][]byte{[]byte("a\x1b[D\x1b[3~b\x1bOQ")},
65+ wantRunes: []rune{'a', 'b'},
66+ },
67+ {
68+ name: "submit cancel and editing",
69+ chunks: [][]byte{{0x7f, 0x08, '\r', '\n', 0x1b, 0x03}},
70+ wantBackspace: 2,
71+ wantEnter: 2,
72+ wantCancel: 2,
73+ },
74+ {
75+ name: "controls and invalid utf8 ignored",
76+ chunks: [][]byte{{'a', 0x01, 0xff, 'b'}},
77+ wantRunes: []rune{'a', 'b'},
78+ },
48 }79 }
49 80
50- routeOverlayBytes([]byte("\x1bO"), &pending, ev)81+ for _, tt := range tests {
51- require.Equal(t, []byte("\x1bO"), pending)82+ t.Run(tt.name, func(t *testing.T) {
52- routeOverlayBytes([]byte("A\x1bOB"), &pending, ev)83+ var pending []byte
53-84+ var gotRunes []rune
54- require.Equal(t, 1, up)85+ var gotBackspace, gotEnter, gotCancel, gotUp, gotDown int
55- require.Equal(t, 1, down)86+ ev := overlayEvents{
56- require.Empty(t, pending)87+ rune: func(r rune) { gotRunes = append(gotRunes, r) },
57-}88+ backspace: func() { gotBackspace++ },
58-89+ enter: func() { gotEnter++ },
59-func TestRouteOverlayBytesConsumesUnhandledEscapeSequences(t *testing.T) {90+ cancel: func() { gotCancel++ },
60- var pending []byte91+ up: func() { gotUp++ },
61- var got []rune92+ down: func() { gotDown++ },
62- var cancel int93+ }
63- ev := overlayEvents{94+
64- rune: func(r rune) { got = append(got, r) },95+ for _, chunk := range tt.chunks {
65- cancel: func() { cancel++ },96+ routeOverlayBytes(chunk, &pending, ev)
97+ }
98+
99+ require.Equal(t, tt.wantRunes, gotRunes)
100+ require.Equal(t, tt.wantPending, pending)
101+ require.Equal(t, tt.wantBackspace, gotBackspace)
102+ require.Equal(t, tt.wantEnter, gotEnter)
103+ require.Equal(t, tt.wantCancel, gotCancel)
104+ require.Equal(t, tt.wantUp, gotUp)
105+ require.Equal(t, tt.wantDown, gotDown)
106+ })
66 }107 }
67-
68- routeOverlayBytes([]byte("a\x1b[D\x1b[3~b\x1bOQ"), &pending, ev)
69-
70- require.Equal(t, []rune{'a', 'b'}, got)
71- require.Equal(t, 0, cancel)
72- require.Empty(t, pending)
73-}
74-
75-func TestRouteOverlayBytesHandlesEditingSubmitAndCancel(t *testing.T) {
76- var pending []byte
77- var backspace, enter, cancel int
78- ev := overlayEvents{
79- backspace: func() { backspace++ },
80- enter: func() { enter++ },
81- cancel: func() { cancel++ },
82- }
83-
84- routeOverlayBytes([]byte{0x7f, 0x08, '\r', '\n', 0x1b, 0x03}, &pending, ev)
85-
86- require.Equal(t, 2, backspace)
87- require.Equal(t, 2, enter)
88- require.Equal(t, 2, cancel)
89- require.Empty(t, pending)
90-}
91-
92-func TestRouteOverlayBytesIgnoresControlsAndInvalidUTF8(t *testing.T) {
93- var pending []byte
94- var got []rune
95- ev := overlayEvents{rune: func(r rune) { got = append(got, r) }}
96-
97- routeOverlayBytes([]byte{'a', 0x01, 0xff, 'b'}, &pending, ev)
98-
99- require.Equal(t, []rune{'a', 'b'}, got)
100- require.Empty(t, pending)
101 }108 }
M internal/usecase/daemon/palette.go +21 -21
@@ -13,18 +13,18 @@ var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title:
1313
1414 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
1515 d.closePalette(ac)
16- ac.paletteMu.Lock()
17- ac.palette = palette.New(d.paletteCommands())
18- ac.palettePending = nil
19- ac.paletteMu.Unlock()
16+ ac.overlays.paletteMu.Lock()
17+ ac.overlays.palette = palette.New(d.paletteCommands())
18+ ac.overlays.palettePending = nil
19+ ac.overlays.paletteMu.Unlock()
2020 d.paint(sess, ac, true)
2121 }
2222
2323 func (d *Daemon) closePalette(ac *attachedClient) {
24- ac.paletteMu.Lock()
25- ac.palette = nil
26- ac.palettePending = nil
27- ac.paletteMu.Unlock()
24+ ac.overlays.paletteMu.Lock()
25+ ac.overlays.palette = nil
26+ ac.overlays.palettePending = nil
27+ ac.overlays.paletteMu.Unlock()
2828 }
2929
3030 func (d *Daemon) paletteCommands() []command.Command {
@@ -75,10 +75,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
7575 return
7676 }
7777
78- ac.paletteMu.Lock()
79- if ac.palette == nil {
80- ac.palettePending = nil
81- ac.paletteMu.Unlock()
78+ ac.overlays.paletteMu.Lock()
79+ if ac.overlays.palette == nil {
80+ ac.overlays.palettePending = nil
81+ ac.overlays.paletteMu.Unlock()
8282 return
8383 }
8484 changed := false
@@ -87,17 +87,17 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
8787 var cmd command.Command
8888 var ok bool
8989
90- routeOverlayBytes(data, &ac.palettePending, overlayEvents{
90+ routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
9191 rune: func(r rune) {
92- ac.palette.Insert(r)
92+ ac.overlays.palette.Insert(r)
9393 changed = true
9494 },
9595 backspace: func() {
96- ac.palette.Backspace()
96+ ac.overlays.palette.Backspace()
9797 changed = true
9898 },
9999 enter: func() {
100- cmd, ok = ac.palette.Selected()
100+ cmd, ok = ac.overlays.palette.Selected()
101101 if ok {
102102 run = true
103103 exit = true
@@ -105,19 +105,19 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
105105 },
106106 cancel: func() { exit = true },
107107 up: func() {
108- ac.palette.Up()
108+ ac.overlays.palette.Up()
109109 changed = true
110110 },
111111 down: func() {
112- ac.palette.Down()
112+ ac.overlays.palette.Down()
113113 changed = true
114114 },
115115 })
116116 if exit {
117- ac.palette = nil
118- ac.palettePending = nil
117+ ac.overlays.palette = nil
118+ ac.overlays.palettePending = nil
119119 }
120- ac.paletteMu.Unlock()
120+ ac.overlays.paletteMu.Unlock()
121121
122122 if run && ok {
123123 if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {
@@ -13,18 +13,18 @@ var paletteModal = ui.Modal{WidthPct: 100, MinWidth: 32, FixedHeight: 11, Title:
13 13
14 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {14 func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
15 d.closePalette(ac)15 d.closePalette(ac)
16- ac.paletteMu.Lock()16+ ac.overlays.paletteMu.Lock()
17- ac.palette = palette.New(d.paletteCommands())17+ ac.overlays.palette = palette.New(d.paletteCommands())
18- ac.palettePending = nil18+ ac.overlays.palettePending = nil
19- ac.paletteMu.Unlock()19+ ac.overlays.paletteMu.Unlock()
20 d.paint(sess, ac, true)20 d.paint(sess, ac, true)
21 }21 }
22 22
23 func (d *Daemon) closePalette(ac *attachedClient) {23 func (d *Daemon) closePalette(ac *attachedClient) {
24- ac.paletteMu.Lock()24+ ac.overlays.paletteMu.Lock()
25- ac.palette = nil25+ ac.overlays.palette = nil
26- ac.palettePending = nil26+ ac.overlays.palettePending = nil
27- ac.paletteMu.Unlock()27+ ac.overlays.paletteMu.Unlock()
28 }28 }
29 29
30 func (d *Daemon) paletteCommands() []command.Command {30 func (d *Daemon) paletteCommands() []command.Command {
@@ -75,10 +75,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
75 return75 return
76 }76 }
77 77
78- ac.paletteMu.Lock()78+ ac.overlays.paletteMu.Lock()
79- if ac.palette == nil {79+ if ac.overlays.palette == nil {
80- ac.palettePending = nil80+ ac.overlays.palettePending = nil
81- ac.paletteMu.Unlock()81+ ac.overlays.paletteMu.Unlock()
82 return82 return
83 }83 }
84 changed := false84 changed := false
@@ -87,17 +87,17 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
87 var cmd command.Command87 var cmd command.Command
88 var ok bool88 var ok bool
89 89
90- routeOverlayBytes(data, &ac.palettePending, overlayEvents{90+ routeOverlayBytes(data, &ac.overlays.palettePending, overlayEvents{
91 rune: func(r rune) {91 rune: func(r rune) {
92- ac.palette.Insert(r)92+ ac.overlays.palette.Insert(r)
93 changed = true93 changed = true
94 },94 },
95 backspace: func() {95 backspace: func() {
96- ac.palette.Backspace()96+ ac.overlays.palette.Backspace()
97 changed = true97 changed = true
98 },98 },
99 enter: func() {99 enter: func() {
100- cmd, ok = ac.palette.Selected()100+ cmd, ok = ac.overlays.palette.Selected()
101 if ok {101 if ok {
102 run = true102 run = true
103 exit = true103 exit = true
@@ -105,19 +105,19 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
105 },105 },
106 cancel: func() { exit = true },106 cancel: func() { exit = true },
107 up: func() {107 up: func() {
108- ac.palette.Up()108+ ac.overlays.palette.Up()
109 changed = true109 changed = true
110 },110 },
111 down: func() {111 down: func() {
112- ac.palette.Down()112+ ac.overlays.palette.Down()
113 changed = true113 changed = true
114 },114 },
115 })115 })
116 if exit {116 if exit {
117- ac.palette = nil117+ ac.overlays.palette = nil
118- ac.palettePending = nil118+ ac.overlays.palettePending = nil
119 }119 }
120- ac.paletteMu.Unlock()120+ ac.overlays.paletteMu.Unlock()
121 121
122 if run && ok {122 if run && ok {
123 if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {123 if err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}); err != nil {
M internal/usecase/daemon/palette_test.go +24 -26
@@ -22,21 +22,21 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
2222 defer release2()
2323
2424 d.handleInput(sess, ac, []byte("\x1b "))
25- require.True(t, ac.paletteActive())
25+ require.True(t, ac.overlays.paletteActive())
2626 out := awaitFrame(t, sends, ports.MsgOutput)
2727 msg, err := ports.UnmarshalOutput(out.Payload)
2828 require.NoError(t, err)
2929 require.Contains(t, string(msg.Data), "Commands")
3030
3131 d.handleInput(sess, ac, []byte("NXT\r"))
32- require.False(t, ac.paletteActive())
32+ require.False(t, ac.overlays.paletteActive())
3333 require.Equal(t, 1, activeTabIndex(sess))
3434 awaitFrame(t, sends, ports.MsgOutput)
3535
3636 d.handleInput(sess, ac, []byte("\x1b "))
3737 awaitFrame(t, sends, ports.MsgOutput)
3838 d.handleInput(sess, ac, []byte("\x1b"))
39- require.False(t, ac.paletteActive())
39+ require.False(t, ac.overlays.paletteActive())
4040 awaitFrame(t, sends, ports.MsgOutput)
4141 }
4242
@@ -56,8 +56,8 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
5656 promptFrame := awaitFrame(t, sends, ports.MsgOutput)
5757 promptOutput, err := ports.UnmarshalOutput(promptFrame.Payload)
5858 require.NoError(t, err)
59- require.False(t, ac.paletteActive())
60- require.True(t, ac.promptActive())
59+ require.False(t, ac.overlays.paletteActive())
60+ require.True(t, ac.overlays.promptActive())
6161 require.Contains(t, string(promptOutput.Data), "Create session")
6262
6363 d.handleInput(sess, ac, []byte("scratch\r"))
@@ -68,7 +68,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
6868 finalRepaint := awaitFrame(t, sends, ports.MsgOutput)
6969 finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)
7070 require.NoError(t, err)
71- require.False(t, ac.promptActive())
71+ require.False(t, ac.overlays.promptActive())
7272 require.Equal(t, 2, sessionCount(d))
7373 require.Nil(t, sess.client)
7474 newSess := ac.currentSession()
@@ -89,12 +89,12 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
8989 d.handleInput(sess, ac, []byte("\x1b "))
9090 awaitFrame(t, sends, ports.MsgOutput)
9191 d.handleInput(sess, ac, []byte("NXT\r"))
92- require.False(t, ac.paletteActive())
92+ require.False(t, ac.overlays.paletteActive())
9393 awaitFrame(t, sends, ports.MsgOutput)
9494
9595 d.handleInput(sess, ac, []byte("\x1b "))
9696 awaitFrame(t, sends, ports.MsgOutput)
97- cmd, ok := ac.palette.Selected()
97+ cmd, ok := ac.overlays.palette.Selected()
9898 require.True(t, ok)
9999 require.Equal(t, "NXT", cmd.Code)
100100 }
@@ -152,7 +152,7 @@ func TestPaletteCommandNoopRepaintsAfterClose(t *testing.T) {
152152 require.Contains(t, string(paletteOutput.Data), "Commands")
153153
154154 d.handleInput(sess, ac, []byte("NXT\r"))
155- require.False(t, ac.paletteActive())
155+ require.False(t, ac.overlays.paletteActive())
156156 repaint := awaitFrame(t, sends, ports.MsgOutput)
157157 repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)
158158 require.NoError(t, err)
@@ -169,7 +169,7 @@ func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
169169 d.handleInput(sess, ac, []byte("\x1b "))
170170 awaitFrame(t, sends, ports.MsgOutput)
171171 d.handleInput(sess, ac, []byte("CNT\r"))
172- require.False(t, ac.paletteActive())
172+ require.False(t, ac.overlays.paletteActive())
173173 repaint := awaitFrame(t, sends, ports.MsgOutput)
174174 repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)
175175 require.NoError(t, err)
@@ -184,15 +184,15 @@ func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) {
184184 d.handleInput(sess, ac, []byte("\x1b "))
185185 awaitFrame(t, sends, ports.MsgOutput)
186186 d.handleInput(sess, ac, []byte("zzzz\r"))
187- require.True(t, ac.paletteActive())
187+ require.True(t, ac.overlays.paletteActive())
188188 awaitFrame(t, sends, ports.MsgOutput)
189189
190190 d.handlePaletteInput(ac, []byte{0x1b, '['})
191- require.True(t, ac.paletteActive())
192- require.Equal(t, []byte{0x1b, '['}, ac.palettePending)
191+ require.True(t, ac.overlays.paletteActive())
192+ require.Equal(t, []byte{0x1b, '['}, ac.overlays.palettePending)
193193 d.handlePaletteInput(ac, []byte{'A'})
194- require.True(t, ac.paletteActive())
195- require.Empty(t, ac.palettePending)
194+ require.True(t, ac.overlays.paletteActive())
195+ require.Empty(t, ac.overlays.palettePending)
196196 awaitFrame(t, sends, ports.MsgOutput)
197197 }
198198
@@ -205,13 +205,13 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
205205
206206 d.handlePaletteInput(ac, []byte{0x0e})
207207 awaitFrame(t, sends, ports.MsgOutput)
208- cmd, ok := ac.palette.Selected()
208+ cmd, ok := ac.overlays.palette.Selected()
209209 require.True(t, ok)
210210 require.Equal(t, "CNS", cmd.Code)
211211
212212 d.handlePaletteInput(ac, []byte{0x10})
213213 awaitFrame(t, sends, ports.MsgOutput)
214- cmd, ok = ac.palette.Selected()
214+ cmd, ok = ac.overlays.palette.Selected()
215215 require.True(t, ok)
216216 require.Equal(t, "CNT", cmd.Code)
217217 }
@@ -234,10 +234,10 @@ func TestPaletteUTF8PendingCompletesFilter(t *testing.T) {
234234 d.handleInput(sess, ac, []byte("\x1b "))
235235 awaitFrame(t, sends, ports.MsgOutput)
236236 d.handlePaletteInput(ac, []byte{0xc3})
237- require.Equal(t, []byte{0xc3}, ac.palettePending)
237+ require.Equal(t, []byte{0xc3}, ac.overlays.palettePending)
238238 d.handlePaletteInput(ac, []byte{0xa9})
239- require.Empty(t, ac.palettePending)
240- require.Equal(t, "é", ac.palette.Query())
239+ require.Empty(t, ac.overlays.palettePending)
240+ require.Equal(t, "é", ac.overlays.palette.Query())
241241 }
242242
243243 func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
@@ -296,17 +296,15 @@ func TestPaletteExecMethods(t *testing.T) {
296296 require.Equal(t, 0, activeTabIndex(sess))
297297 sess.ephemeral = true
298298 require.NoError(t, exec.RenameSession())
299- require.True(t, ac.promptActive())
299+ require.True(t, ac.overlays.promptActive())
300300 d.closePrompt(ac)
301301 require.True(t, sess.ephemeral)
302302 require.NoError(t, exec.OpenSessionPicker())
303- require.True(t, ac.pickerActive())
303+ require.True(t, ac.overlays.pickerActive())
304304 d.closePicker(ac)
305305 require.NoError(t, exec.EnterVisualMode())
306- require.True(t, ac.copyModeActive())
307- ac.copyMu.Lock()
308- ac.copyMode = nil
309- ac.copyMu.Unlock()
306+ require.True(t, ac.overlays.copyActive())
307+ d.exitCopyMode(ac)
310308 require.NoError(t, exec.SplitRight())
311309 require.NoError(t, exec.SplitLeft())
312310 require.NoError(t, exec.SplitUp())
@@ -22,21 +22,21 @@ func TestPaletteOpenTypeEnterRunAndEscClose(t *testing.T) {
22 defer release2()22 defer release2()
23 23
24 d.handleInput(sess, ac, []byte("\x1b "))24 d.handleInput(sess, ac, []byte("\x1b "))
25- require.True(t, ac.paletteActive())25+ require.True(t, ac.overlays.paletteActive())
26 out := awaitFrame(t, sends, ports.MsgOutput)26 out := awaitFrame(t, sends, ports.MsgOutput)
27 msg, err := ports.UnmarshalOutput(out.Payload)27 msg, err := ports.UnmarshalOutput(out.Payload)
28 require.NoError(t, err)28 require.NoError(t, err)
29 require.Contains(t, string(msg.Data), "Commands")29 require.Contains(t, string(msg.Data), "Commands")
30 30
31 d.handleInput(sess, ac, []byte("NXT\r"))31 d.handleInput(sess, ac, []byte("NXT\r"))
32- require.False(t, ac.paletteActive())32+ require.False(t, ac.overlays.paletteActive())
33 require.Equal(t, 1, activeTabIndex(sess))33 require.Equal(t, 1, activeTabIndex(sess))
34 awaitFrame(t, sends, ports.MsgOutput)34 awaitFrame(t, sends, ports.MsgOutput)
35 35
36 d.handleInput(sess, ac, []byte("\x1b "))36 d.handleInput(sess, ac, []byte("\x1b "))
37 awaitFrame(t, sends, ports.MsgOutput)37 awaitFrame(t, sends, ports.MsgOutput)
38 d.handleInput(sess, ac, []byte("\x1b"))38 d.handleInput(sess, ac, []byte("\x1b"))
39- require.False(t, ac.paletteActive())39+ require.False(t, ac.overlays.paletteActive())
40 awaitFrame(t, sends, ports.MsgOutput)40 awaitFrame(t, sends, ports.MsgOutput)
41 }41 }
42 42
@@ -56,8 +56,8 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
56 promptFrame := awaitFrame(t, sends, ports.MsgOutput)56 promptFrame := awaitFrame(t, sends, ports.MsgOutput)
57 promptOutput, err := ports.UnmarshalOutput(promptFrame.Payload)57 promptOutput, err := ports.UnmarshalOutput(promptFrame.Payload)
58 require.NoError(t, err)58 require.NoError(t, err)
59- require.False(t, ac.paletteActive())59+ require.False(t, ac.overlays.paletteActive())
60- require.True(t, ac.promptActive())60+ require.True(t, ac.overlays.promptActive())
61 require.Contains(t, string(promptOutput.Data), "Create session")61 require.Contains(t, string(promptOutput.Data), "Create session")
62 62
63 d.handleInput(sess, ac, []byte("scratch\r"))63 d.handleInput(sess, ac, []byte("scratch\r"))
@@ -68,7 +68,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
68 finalRepaint := awaitFrame(t, sends, ports.MsgOutput)68 finalRepaint := awaitFrame(t, sends, ports.MsgOutput)
69 finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)69 finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)
70 require.NoError(t, err)70 require.NoError(t, err)
71- require.False(t, ac.promptActive())71+ require.False(t, ac.overlays.promptActive())
72 require.Equal(t, 2, sessionCount(d))72 require.Equal(t, 2, sessionCount(d))
73 require.Nil(t, sess.client)73 require.Nil(t, sess.client)
74 newSess := ac.currentSession()74 newSess := ac.currentSession()
@@ -89,12 +89,12 @@ func TestPaletteReopensWithSuccessfulCommandFirst(t *testing.T) {
89 d.handleInput(sess, ac, []byte("\x1b "))89 d.handleInput(sess, ac, []byte("\x1b "))
90 awaitFrame(t, sends, ports.MsgOutput)90 awaitFrame(t, sends, ports.MsgOutput)
91 d.handleInput(sess, ac, []byte("NXT\r"))91 d.handleInput(sess, ac, []byte("NXT\r"))
92- require.False(t, ac.paletteActive())92+ require.False(t, ac.overlays.paletteActive())
93 awaitFrame(t, sends, ports.MsgOutput)93 awaitFrame(t, sends, ports.MsgOutput)
94 94
95 d.handleInput(sess, ac, []byte("\x1b "))95 d.handleInput(sess, ac, []byte("\x1b "))
96 awaitFrame(t, sends, ports.MsgOutput)96 awaitFrame(t, sends, ports.MsgOutput)
97- cmd, ok := ac.palette.Selected()97+ cmd, ok := ac.overlays.palette.Selected()
98 require.True(t, ok)98 require.True(t, ok)
99 require.Equal(t, "NXT", cmd.Code)99 require.Equal(t, "NXT", cmd.Code)
100 }100 }
@@ -152,7 +152,7 @@ func TestPaletteCommandNoopRepaintsAfterClose(t *testing.T) {
152 require.Contains(t, string(paletteOutput.Data), "Commands")152 require.Contains(t, string(paletteOutput.Data), "Commands")
153 153
154 d.handleInput(sess, ac, []byte("NXT\r"))154 d.handleInput(sess, ac, []byte("NXT\r"))
155- require.False(t, ac.paletteActive())155+ require.False(t, ac.overlays.paletteActive())
156 repaint := awaitFrame(t, sends, ports.MsgOutput)156 repaint := awaitFrame(t, sends, ports.MsgOutput)
157 repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)157 repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)
158 require.NoError(t, err)158 require.NoError(t, err)
@@ -169,7 +169,7 @@ func TestPaletteCreateTabErrorRepaintsAfterClose(t *testing.T) {
169 d.handleInput(sess, ac, []byte("\x1b "))169 d.handleInput(sess, ac, []byte("\x1b "))
170 awaitFrame(t, sends, ports.MsgOutput)170 awaitFrame(t, sends, ports.MsgOutput)
171 d.handleInput(sess, ac, []byte("CNT\r"))171 d.handleInput(sess, ac, []byte("CNT\r"))
172- require.False(t, ac.paletteActive())172+ require.False(t, ac.overlays.paletteActive())
173 repaint := awaitFrame(t, sends, ports.MsgOutput)173 repaint := awaitFrame(t, sends, ports.MsgOutput)
174 repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)174 repaintOutput, err := ports.UnmarshalOutput(repaint.Payload)
175 require.NoError(t, err)175 require.NoError(t, err)
@@ -184,15 +184,15 @@ func TestPaletteEnterNoMatchKeepsOpenAndEscapeSplit(t *testing.T) {
184 d.handleInput(sess, ac, []byte("\x1b "))184 d.handleInput(sess, ac, []byte("\x1b "))
185 awaitFrame(t, sends, ports.MsgOutput)185 awaitFrame(t, sends, ports.MsgOutput)
186 d.handleInput(sess, ac, []byte("zzzz\r"))186 d.handleInput(sess, ac, []byte("zzzz\r"))
187- require.True(t, ac.paletteActive())187+ require.True(t, ac.overlays.paletteActive())
188 awaitFrame(t, sends, ports.MsgOutput)188 awaitFrame(t, sends, ports.MsgOutput)
189 189
190 d.handlePaletteInput(ac, []byte{0x1b, '['})190 d.handlePaletteInput(ac, []byte{0x1b, '['})
191- require.True(t, ac.paletteActive())191+ require.True(t, ac.overlays.paletteActive())
192- require.Equal(t, []byte{0x1b, '['}, ac.palettePending)192+ require.Equal(t, []byte{0x1b, '['}, ac.overlays.palettePending)
193 d.handlePaletteInput(ac, []byte{'A'})193 d.handlePaletteInput(ac, []byte{'A'})
194- require.True(t, ac.paletteActive())194+ require.True(t, ac.overlays.paletteActive())
195- require.Empty(t, ac.palettePending)195+ require.Empty(t, ac.overlays.palettePending)
196 awaitFrame(t, sends, ports.MsgOutput)196 awaitFrame(t, sends, ports.MsgOutput)
197 }197 }
198 198
@@ -205,13 +205,13 @@ func TestPaletteCtrlNAndCtrlPNavigate(t *testing.T) {
205 205
206 d.handlePaletteInput(ac, []byte{0x0e})206 d.handlePaletteInput(ac, []byte{0x0e})
207 awaitFrame(t, sends, ports.MsgOutput)207 awaitFrame(t, sends, ports.MsgOutput)
208- cmd, ok := ac.palette.Selected()208+ cmd, ok := ac.overlays.palette.Selected()
209 require.True(t, ok)209 require.True(t, ok)
210 require.Equal(t, "CNS", cmd.Code)210 require.Equal(t, "CNS", cmd.Code)
211 211
212 d.handlePaletteInput(ac, []byte{0x10})212 d.handlePaletteInput(ac, []byte{0x10})
213 awaitFrame(t, sends, ports.MsgOutput)213 awaitFrame(t, sends, ports.MsgOutput)
214- cmd, ok = ac.palette.Selected()214+ cmd, ok = ac.overlays.palette.Selected()
215 require.True(t, ok)215 require.True(t, ok)
216 require.Equal(t, "CNT", cmd.Code)216 require.Equal(t, "CNT", cmd.Code)
217 }217 }
@@ -234,10 +234,10 @@ func TestPaletteUTF8PendingCompletesFilter(t *testing.T) {
234 d.handleInput(sess, ac, []byte("\x1b "))234 d.handleInput(sess, ac, []byte("\x1b "))
235 awaitFrame(t, sends, ports.MsgOutput)235 awaitFrame(t, sends, ports.MsgOutput)
236 d.handlePaletteInput(ac, []byte{0xc3})236 d.handlePaletteInput(ac, []byte{0xc3})
237- require.Equal(t, []byte{0xc3}, ac.palettePending)237+ require.Equal(t, []byte{0xc3}, ac.overlays.palettePending)
238 d.handlePaletteInput(ac, []byte{0xa9})238 d.handlePaletteInput(ac, []byte{0xa9})
239- require.Empty(t, ac.palettePending)239+ require.Empty(t, ac.overlays.palettePending)
240- require.Equal(t, "é", ac.palette.Query())240+ require.Equal(t, "é", ac.overlays.palette.Query())
241 }241 }
242 242
243 func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {243 func TestPaletteRenderAndInputCanRunConcurrently(t *testing.T) {
@@ -296,17 +296,15 @@ func TestPaletteExecMethods(t *testing.T) {
296 require.Equal(t, 0, activeTabIndex(sess))296 require.Equal(t, 0, activeTabIndex(sess))
297 sess.ephemeral = true297 sess.ephemeral = true
298 require.NoError(t, exec.RenameSession())298 require.NoError(t, exec.RenameSession())
299- require.True(t, ac.promptActive())299+ require.True(t, ac.overlays.promptActive())
300 d.closePrompt(ac)300 d.closePrompt(ac)
301 require.True(t, sess.ephemeral)301 require.True(t, sess.ephemeral)
302 require.NoError(t, exec.OpenSessionPicker())302 require.NoError(t, exec.OpenSessionPicker())
303- require.True(t, ac.pickerActive())303+ require.True(t, ac.overlays.pickerActive())
304 d.closePicker(ac)304 d.closePicker(ac)
305 require.NoError(t, exec.EnterVisualMode())305 require.NoError(t, exec.EnterVisualMode())
306- require.True(t, ac.copyModeActive())306+ require.True(t, ac.overlays.copyActive())
307- ac.copyMu.Lock()307+ d.exitCopyMode(ac)
308- ac.copyMode = nil
309- ac.copyMu.Unlock()
310 require.NoError(t, exec.SplitRight())308 require.NoError(t, exec.SplitRight())
311 require.NoError(t, exec.SplitLeft())309 require.NoError(t, exec.SplitLeft())
312 require.NoError(t, exec.SplitUp())310 require.NoError(t, exec.SplitUp())
M internal/usecase/daemon/pane_actions_test.go +5 -2
@@ -150,12 +150,14 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
150150 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
151151 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
152152 tr, _ := newCapturingTransport(t)
153- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 41, Rows: 12}, copyMode: &scopy.Mode{}}
153+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 41, Rows: 12}}
154+ ac.initOverlays()
155+ ac.overlays.copyMode = &scopy.Mode{}
154156
155157 require.NoError(t, d.focusDir(sess, ac, layout.Right))
156158
157159 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
158- require.Nil(t, ac.copyMode)
160+ require.Nil(t, ac.overlays.copyMode)
159161 oldPTY.AssertNotCalled(t, "Resize", mock.Anything)
160162 newPTY.AssertNotCalled(t, "Resize", mock.Anything)
161163 }
@@ -301,6 +303,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
301303 require.Equal(t, layout.PaneID("pane-2"), tb.focusedPane().id)
302304
303305 ac := &attachedClient{}
306+ ac.initOverlays()
304307 ac.setSession(sess)
305308 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
306309 require.Equal(t, []byte("Z"), <-writes)
@@ -150,12 +150,14 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
150 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}150 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
151 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}151 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
152 tr, _ := newCapturingTransport(t)152 tr, _ := newCapturingTransport(t)
153- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 41, Rows: 12}, copyMode: &scopy.Mode{}}153+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 41, Rows: 12}}
154+ ac.initOverlays()
155+ ac.overlays.copyMode = &scopy.Mode{}
154 156
155 require.NoError(t, d.focusDir(sess, ac, layout.Right))157 require.NoError(t, d.focusDir(sess, ac, layout.Right))
156 158
157 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)159 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
158- require.Nil(t, ac.copyMode)160+ require.Nil(t, ac.overlays.copyMode)
159 oldPTY.AssertNotCalled(t, "Resize", mock.Anything)161 oldPTY.AssertNotCalled(t, "Resize", mock.Anything)
160 newPTY.AssertNotCalled(t, "Resize", mock.Anything)162 newPTY.AssertNotCalled(t, "Resize", mock.Anything)
161 }163 }
@@ -301,6 +303,7 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
301 require.Equal(t, layout.PaneID("pane-2"), tb.focusedPane().id)303 require.Equal(t, layout.PaneID("pane-2"), tb.focusedPane().id)
302 304
303 ac := &attachedClient{}305 ac := &attachedClient{}
306+ ac.initOverlays()
304 ac.setSession(sess)307 ac.setSession(sess)
305 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))308 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
306 require.Equal(t, []byte("Z"), <-writes)309 require.Equal(t, []byte("Z"), <-writes)
M internal/usecase/daemon/picker.go +60 -60
@@ -43,11 +43,11 @@ var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight:
4343
4444 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
4545 views, curTab := d.pickerViews(sess)
46- ac.pickerMu.Lock()
47- ac.picker = picker.New(views, sess.id, curTab)
48- ac.pickerPending = nil
49- ac.pickerPreview = nil
50- ac.pickerMu.Unlock()
46+ ac.overlays.pickerMu.Lock()
47+ ac.overlays.picker = picker.New(views, sess.id, curTab)
48+ ac.overlays.pickerPending = nil
49+ ac.overlays.pickerPreview = nil
50+ ac.overlays.pickerMu.Unlock()
5151 d.registerPreviewForSelection(ac)
5252 d.paint(sess, ac, true)
5353 }
@@ -108,20 +108,20 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
108108 if sess == nil {
109109 return
110110 }
111- ac.pickerMu.Lock()
112- if ac.picker == nil {
113- ac.pickerPending = nil
111+ ac.overlays.pickerMu.Lock()
112+ if ac.overlays.picker == nil {
113+ ac.overlays.pickerPending = nil
114114 d.stopPickerPendingTimerLocked(ac)
115- ac.pickerMu.Unlock()
115+ ac.overlays.pickerMu.Unlock()
116116 return
117117 }
118- if len(ac.pickerPending) > 0 {
118+ if len(ac.overlays.pickerPending) > 0 {
119119 d.stopPickerPendingTimerLocked(ac)
120- combined := make([]byte, 0, len(ac.pickerPending)+len(data))
121- combined = append(combined, ac.pickerPending...)
120+ combined := make([]byte, 0, len(ac.overlays.pickerPending)+len(data))
121+ combined = append(combined, ac.overlays.pickerPending...)
122122 combined = append(combined, data...)
123123 data = combined
124- ac.pickerPending = nil
124+ ac.overlays.pickerPending = nil
125125 }
126126 changed := false
127127 exit := false
@@ -129,8 +129,8 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
129129 for i := 0; i < len(data); i++ {
130130 switch data[i] {
131131 case 'x':
132- target, ok := ac.picker.Selected()
133- ac.pickerMu.Unlock()
132+ target, ok := ac.overlays.picker.Selected()
133+ ac.overlays.pickerMu.Unlock()
134134 if ok {
135135 d.killPickerTarget(target)
136136 }
@@ -138,10 +138,10 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
138138 d.paint(sess, ac, true)
139139 return
140140 case 'j':
141- ac.picker.Down()
141+ ac.overlays.picker.Down()
142142 changed = true
143143 case 'k':
144- ac.picker.Up()
144+ ac.overlays.picker.Up()
145145 changed = true
146146 case '\r', '\n':
147147 switchTarget = true
@@ -149,7 +149,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
149149 case 'q', 0x03, 0x1b:
150150 if data[i] == 0x1b {
151151 tail := data[i:]
152- consumed, ok := routePickerEscape(ac.picker, tail)
152+ consumed, ok := routePickerEscape(ac.overlays.picker, tail)
153153 if ok {
154154 i += consumed - 1
155155 changed = true
@@ -160,7 +160,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
160160 break
161161 }
162162 if isPickerEscapePrefix(tail) {
163- ac.pickerPending = append(ac.pickerPending[:0], tail...)
163+ ac.overlays.pickerPending = append(ac.overlays.pickerPending[:0], tail...)
164164 break
165165 }
166166 }
@@ -170,9 +170,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
170170 var target picker.Target
171171 var ok bool
172172 if switchTarget {
173- target, ok = ac.picker.Selected()
173+ target, ok = ac.overlays.picker.Selected()
174174 }
175- ac.pickerMu.Unlock()
175+ ac.overlays.pickerMu.Unlock()
176176
177177 if changed {
178178 d.registerPreviewForSelection(ac)
@@ -190,18 +190,18 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
190190 }
191191
192192 func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
193- ac.pickerPending = append(ac.pickerPending[:0], keys.ESC)
194- ac.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
195- ac.pickerMu.Lock()
196- if ac.pickerESC.timer != timer || len(ac.pickerPending) != 1 || ac.pickerPending[0] != keys.ESC || ac.picker == nil {
197- ac.pickerMu.Unlock()
193+ ac.overlays.pickerPending = append(ac.overlays.pickerPending[:0], keys.ESC)
194+ ac.overlays.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
195+ ac.overlays.pickerMu.Lock()
196+ if ac.overlays.pickerESC.timer != timer || len(ac.overlays.pickerPending) != 1 || ac.overlays.pickerPending[0] != keys.ESC || ac.overlays.picker == nil {
197+ ac.overlays.pickerMu.Unlock()
198198 return
199199 }
200- ac.pickerPending = nil
201- ac.pickerESC.timer = nil
202- ac.pickerESC.done = nil
203- ac.picker = nil
204- ac.pickerMu.Unlock()
200+ ac.overlays.pickerPending = nil
201+ ac.overlays.pickerESC.timer = nil
202+ ac.overlays.pickerESC.done = nil
203+ ac.overlays.picker = nil
204+ ac.overlays.pickerMu.Unlock()
205205
206206 d.unregisterPreview(ac)
207207 if sess := ac.currentSession(); sess != nil {
@@ -211,7 +211,7 @@ func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
211211 }
212212
213213 func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
214- ac.pickerESC.stop()
214+ ac.overlays.pickerESC.stop()
215215 }
216216
217217 func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
@@ -233,26 +233,26 @@ func isPickerEscapePrefix(data []byte) bool {
233233 }
234234
235235 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
236- ac.pickerMu.Lock()
236+ ac.overlays.pickerMu.Lock()
237237 var target picker.Target
238238 var ok bool
239- if ac.picker != nil {
240- target, ok = ac.picker.Selected()
239+ if ac.overlays.picker != nil {
240+ target, ok = ac.overlays.picker.Selected()
241241 }
242- ac.pickerMu.Unlock()
242+ ac.overlays.pickerMu.Unlock()
243243
244244 var next *tab
245245 if ok {
246246 next = d.tabByTarget(target)
247247 }
248248
249- ac.pickerMu.Lock()
250- old := ac.pickerPreview
251- if ac.picker == nil {
249+ ac.overlays.pickerMu.Lock()
250+ old := ac.overlays.pickerPreview
251+ if ac.overlays.picker == nil {
252252 next = nil
253253 }
254- ac.pickerPreview = next
255- ac.pickerMu.Unlock()
254+ ac.overlays.pickerPreview = next
255+ ac.overlays.pickerMu.Unlock()
256256
257257 if old != nil && old != next {
258258 old.mu.Lock()
@@ -266,9 +266,9 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
266266 next.previewClient = ac
267267 next.mu.Unlock()
268268
269- ac.pickerMu.Lock()
270- keep := ac.pickerPreview == next
271- ac.pickerMu.Unlock()
269+ ac.overlays.pickerMu.Lock()
270+ keep := ac.overlays.pickerPreview == next
271+ ac.overlays.pickerMu.Unlock()
272272 if !keep {
273273 next.mu.Lock()
274274 if next.previewClient == ac {
@@ -280,10 +280,10 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
280280 }
281281
282282 func (d *Daemon) unregisterPreview(ac *attachedClient) {
283- ac.pickerMu.Lock()
284- old := ac.pickerPreview
285- ac.pickerPreview = nil
286- ac.pickerMu.Unlock()
283+ ac.overlays.pickerMu.Lock()
284+ old := ac.overlays.pickerPreview
285+ ac.overlays.pickerPreview = nil
286+ ac.overlays.pickerMu.Unlock()
287287
288288 if old != nil {
289289 old.mu.Lock()
@@ -302,12 +302,12 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
302302 return
303303 }
304304 cleared := false
305- previewer.pickerMu.Lock()
306- if previewer.pickerPreview == tb {
307- previewer.pickerPreview = nil
305+ previewer.overlays.pickerMu.Lock()
306+ if previewer.overlays.pickerPreview == tb {
307+ previewer.overlays.pickerPreview = nil
308308 cleared = true
309309 }
310- previewer.pickerMu.Unlock()
310+ previewer.overlays.pickerMu.Unlock()
311311 tb.mu.Lock()
312312 if tb.previewClient == previewer {
313313 tb.previewClient = nil
@@ -321,11 +321,11 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
321321 }
322322
323323 func (d *Daemon) closePicker(ac *attachedClient) {
324- ac.pickerMu.Lock()
325- ac.picker = nil
326- ac.pickerPending = nil
324+ ac.overlays.pickerMu.Lock()
325+ ac.overlays.picker = nil
326+ ac.overlays.pickerPending = nil
327327 d.stopPickerPendingTimerLocked(ac)
328- ac.pickerMu.Unlock()
328+ ac.overlays.pickerMu.Unlock()
329329 d.unregisterPreview(ac)
330330 }
331331
@@ -471,11 +471,11 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
471471 return
472472 }
473473 views, curTab := d.pickerViews(sess)
474- ac.pickerMu.Lock()
475- if ac.picker != nil {
476- ac.picker = picker.New(views, sess.id, curTab)
474+ ac.overlays.pickerMu.Lock()
475+ if ac.overlays.picker != nil {
476+ ac.overlays.picker = picker.New(views, sess.id, curTab)
477477 }
478- ac.pickerMu.Unlock()
478+ ac.overlays.pickerMu.Unlock()
479479 d.registerPreviewForSelection(ac)
480480 }
481481
@@ -43,11 +43,11 @@ var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight:
43 43
44 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {44 func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
45 views, curTab := d.pickerViews(sess)45 views, curTab := d.pickerViews(sess)
46- ac.pickerMu.Lock()46+ ac.overlays.pickerMu.Lock()
47- ac.picker = picker.New(views, sess.id, curTab)47+ ac.overlays.picker = picker.New(views, sess.id, curTab)
48- ac.pickerPending = nil48+ ac.overlays.pickerPending = nil
49- ac.pickerPreview = nil49+ ac.overlays.pickerPreview = nil
50- ac.pickerMu.Unlock()50+ ac.overlays.pickerMu.Unlock()
51 d.registerPreviewForSelection(ac)51 d.registerPreviewForSelection(ac)
52 d.paint(sess, ac, true)52 d.paint(sess, ac, true)
53 }53 }
@@ -108,20 +108,20 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
108 if sess == nil {108 if sess == nil {
109 return109 return
110 }110 }
111- ac.pickerMu.Lock()111+ ac.overlays.pickerMu.Lock()
112- if ac.picker == nil {112+ if ac.overlays.picker == nil {
113- ac.pickerPending = nil113+ ac.overlays.pickerPending = nil
114 d.stopPickerPendingTimerLocked(ac)114 d.stopPickerPendingTimerLocked(ac)
115- ac.pickerMu.Unlock()115+ ac.overlays.pickerMu.Unlock()
116 return116 return
117 }117 }
118- if len(ac.pickerPending) > 0 {118+ if len(ac.overlays.pickerPending) > 0 {
119 d.stopPickerPendingTimerLocked(ac)119 d.stopPickerPendingTimerLocked(ac)
120- combined := make([]byte, 0, len(ac.pickerPending)+len(data))120+ combined := make([]byte, 0, len(ac.overlays.pickerPending)+len(data))
121- combined = append(combined, ac.pickerPending...)121+ combined = append(combined, ac.overlays.pickerPending...)
122 combined = append(combined, data...)122 combined = append(combined, data...)
123 data = combined123 data = combined
124- ac.pickerPending = nil124+ ac.overlays.pickerPending = nil
125 }125 }
126 changed := false126 changed := false
127 exit := false127 exit := false
@@ -129,8 +129,8 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
129 for i := 0; i < len(data); i++ {129 for i := 0; i < len(data); i++ {
130 switch data[i] {130 switch data[i] {
131 case 'x':131 case 'x':
132- target, ok := ac.picker.Selected()132+ target, ok := ac.overlays.picker.Selected()
133- ac.pickerMu.Unlock()133+ ac.overlays.pickerMu.Unlock()
134 if ok {134 if ok {
135 d.killPickerTarget(target)135 d.killPickerTarget(target)
136 }136 }
@@ -138,10 +138,10 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
138 d.paint(sess, ac, true)138 d.paint(sess, ac, true)
139 return139 return
140 case 'j':140 case 'j':
141- ac.picker.Down()141+ ac.overlays.picker.Down()
142 changed = true142 changed = true
143 case 'k':143 case 'k':
144- ac.picker.Up()144+ ac.overlays.picker.Up()
145 changed = true145 changed = true
146 case '\r', '\n':146 case '\r', '\n':
147 switchTarget = true147 switchTarget = true
@@ -149,7 +149,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
149 case 'q', 0x03, 0x1b:149 case 'q', 0x03, 0x1b:
150 if data[i] == 0x1b {150 if data[i] == 0x1b {
151 tail := data[i:]151 tail := data[i:]
152- consumed, ok := routePickerEscape(ac.picker, tail)152+ consumed, ok := routePickerEscape(ac.overlays.picker, tail)
153 if ok {153 if ok {
154 i += consumed - 1154 i += consumed - 1
155 changed = true155 changed = true
@@ -160,7 +160,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
160 break160 break
161 }161 }
162 if isPickerEscapePrefix(tail) {162 if isPickerEscapePrefix(tail) {
163- ac.pickerPending = append(ac.pickerPending[:0], tail...)163+ ac.overlays.pickerPending = append(ac.overlays.pickerPending[:0], tail...)
164 break164 break
165 }165 }
166 }166 }
@@ -170,9 +170,9 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
170 var target picker.Target170 var target picker.Target
171 var ok bool171 var ok bool
172 if switchTarget {172 if switchTarget {
173- target, ok = ac.picker.Selected()173+ target, ok = ac.overlays.picker.Selected()
174 }174 }
175- ac.pickerMu.Unlock()175+ ac.overlays.pickerMu.Unlock()
176 176
177 if changed {177 if changed {
178 d.registerPreviewForSelection(ac)178 d.registerPreviewForSelection(ac)
@@ -190,18 +190,18 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
190 }190 }
191 191
192 func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {192 func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
193- ac.pickerPending = append(ac.pickerPending[:0], keys.ESC)193+ ac.overlays.pickerPending = append(ac.overlays.pickerPending[:0], keys.ESC)
194- ac.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {194+ ac.overlays.pickerESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) {
195- ac.pickerMu.Lock()195+ ac.overlays.pickerMu.Lock()
196- if ac.pickerESC.timer != timer || len(ac.pickerPending) != 1 || ac.pickerPending[0] != keys.ESC || ac.picker == nil {196+ if ac.overlays.pickerESC.timer != timer || len(ac.overlays.pickerPending) != 1 || ac.overlays.pickerPending[0] != keys.ESC || ac.overlays.picker == nil {
197- ac.pickerMu.Unlock()197+ ac.overlays.pickerMu.Unlock()
198 return198 return
199 }199 }
200- ac.pickerPending = nil200+ ac.overlays.pickerPending = nil
201- ac.pickerESC.timer = nil201+ ac.overlays.pickerESC.timer = nil
202- ac.pickerESC.done = nil202+ ac.overlays.pickerESC.done = nil
203- ac.picker = nil203+ ac.overlays.picker = nil
204- ac.pickerMu.Unlock()204+ ac.overlays.pickerMu.Unlock()
205 205
206 d.unregisterPreview(ac)206 d.unregisterPreview(ac)
207 if sess := ac.currentSession(); sess != nil {207 if sess := ac.currentSession(); sess != nil {
@@ -211,7 +211,7 @@ func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
211 }211 }
212 212
213 func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {213 func (d *Daemon) stopPickerPendingTimerLocked(ac *attachedClient) {
214- ac.pickerESC.stop()214+ ac.overlays.pickerESC.stop()
215 }215 }
216 216
217 func routePickerEscape(m *picker.Model, data []byte) (int, bool) {217 func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
@@ -233,26 +233,26 @@ func isPickerEscapePrefix(data []byte) bool {
233 }233 }
234 234
235 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {235 func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
236- ac.pickerMu.Lock()236+ ac.overlays.pickerMu.Lock()
237 var target picker.Target237 var target picker.Target
238 var ok bool238 var ok bool
239- if ac.picker != nil {239+ if ac.overlays.picker != nil {
240- target, ok = ac.picker.Selected()240+ target, ok = ac.overlays.picker.Selected()
241 }241 }
242- ac.pickerMu.Unlock()242+ ac.overlays.pickerMu.Unlock()
243 243
244 var next *tab244 var next *tab
245 if ok {245 if ok {
246 next = d.tabByTarget(target)246 next = d.tabByTarget(target)
247 }247 }
248 248
249- ac.pickerMu.Lock()249+ ac.overlays.pickerMu.Lock()
250- old := ac.pickerPreview250+ old := ac.overlays.pickerPreview
251- if ac.picker == nil {251+ if ac.overlays.picker == nil {
252 next = nil252 next = nil
253 }253 }
254- ac.pickerPreview = next254+ ac.overlays.pickerPreview = next
255- ac.pickerMu.Unlock()255+ ac.overlays.pickerMu.Unlock()
256 256
257 if old != nil && old != next {257 if old != nil && old != next {
258 old.mu.Lock()258 old.mu.Lock()
@@ -266,9 +266,9 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
266 next.previewClient = ac266 next.previewClient = ac
267 next.mu.Unlock()267 next.mu.Unlock()
268 268
269- ac.pickerMu.Lock()269+ ac.overlays.pickerMu.Lock()
270- keep := ac.pickerPreview == next270+ keep := ac.overlays.pickerPreview == next
271- ac.pickerMu.Unlock()271+ ac.overlays.pickerMu.Unlock()
272 if !keep {272 if !keep {
273 next.mu.Lock()273 next.mu.Lock()
274 if next.previewClient == ac {274 if next.previewClient == ac {
@@ -280,10 +280,10 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
280 }280 }
281 281
282 func (d *Daemon) unregisterPreview(ac *attachedClient) {282 func (d *Daemon) unregisterPreview(ac *attachedClient) {
283- ac.pickerMu.Lock()283+ ac.overlays.pickerMu.Lock()
284- old := ac.pickerPreview284+ old := ac.overlays.pickerPreview
285- ac.pickerPreview = nil285+ ac.overlays.pickerPreview = nil
286- ac.pickerMu.Unlock()286+ ac.overlays.pickerMu.Unlock()
287 287
288 if old != nil {288 if old != nil {
289 old.mu.Lock()289 old.mu.Lock()
@@ -302,12 +302,12 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
302 return302 return
303 }303 }
304 cleared := false304 cleared := false
305- previewer.pickerMu.Lock()305+ previewer.overlays.pickerMu.Lock()
306- if previewer.pickerPreview == tb {306+ if previewer.overlays.pickerPreview == tb {
307- previewer.pickerPreview = nil307+ previewer.overlays.pickerPreview = nil
308 cleared = true308 cleared = true
309 }309 }
310- previewer.pickerMu.Unlock()310+ previewer.overlays.pickerMu.Unlock()
311 tb.mu.Lock()311 tb.mu.Lock()
312 if tb.previewClient == previewer {312 if tb.previewClient == previewer {
313 tb.previewClient = nil313 tb.previewClient = nil
@@ -321,11 +321,11 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
321 }321 }
322 322
323 func (d *Daemon) closePicker(ac *attachedClient) {323 func (d *Daemon) closePicker(ac *attachedClient) {
324- ac.pickerMu.Lock()324+ ac.overlays.pickerMu.Lock()
325- ac.picker = nil325+ ac.overlays.picker = nil
326- ac.pickerPending = nil326+ ac.overlays.pickerPending = nil
327 d.stopPickerPendingTimerLocked(ac)327 d.stopPickerPendingTimerLocked(ac)
328- ac.pickerMu.Unlock()328+ ac.overlays.pickerMu.Unlock()
329 d.unregisterPreview(ac)329 d.unregisterPreview(ac)
330 }330 }
331 331
@@ -471,11 +471,11 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
471 return471 return
472 }472 }
473 views, curTab := d.pickerViews(sess)473 views, curTab := d.pickerViews(sess)
474- ac.pickerMu.Lock()474+ ac.overlays.pickerMu.Lock()
475- if ac.picker != nil {475+ if ac.overlays.picker != nil {
476- ac.picker = picker.New(views, sess.id, curTab)476+ ac.overlays.picker = picker.New(views, sess.id, curTab)
477 }477 }
478- ac.pickerMu.Unlock()478+ ac.overlays.pickerMu.Unlock()
479 d.registerPreviewForSelection(ac)479 d.registerPreviewForSelection(ac)
480 }480 }
481 481
M internal/usecase/daemon/picker_test.go +10 -6
@@ -37,7 +37,7 @@ func TestAltTForwardsToPTY(t *testing.T) {
3737
3838 d.handleInput(sess, ac, []byte("\x1bt"))
3939
40- require.False(t, ac.pickerActive())
40+ require.False(t, ac.overlays.pickerActive())
4141 require.Equal(t, []byte("\x1bt"), <-writes)
4242 }
4343
@@ -86,9 +86,9 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
8686 d.clock = clk
8787 d.handleInput(sess, ac, []byte("\x1b"))
8888 timer := <-clk.timers
89- require.True(t, ac.pickerActive())
89+ require.True(t, ac.overlays.pickerActive())
9090 timer.ch <- time.Now()
91- require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
91+ require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
9292 awaitFrame(t, sends, ports.MsgOutput)
9393 }
9494
@@ -117,7 +117,7 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
117117 for _, input := range tc.input {
118118 d.handleInput(sess, ac, input)
119119 }
120- require.True(t, ac.pickerActive())
120+ require.True(t, ac.overlays.pickerActive())
121121 d.handleInput(sess, ac, []byte("\r"))
122122 require.Equal(t, tc.wantActive, activeTabIndex(sess))
123123 })
@@ -134,9 +134,9 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
134134 d.clock = clk
135135 d.handleInput(sess, ac, []byte("\x1b"))
136136 timer := <-clk.timers
137- require.True(t, ac.pickerActive())
137+ require.True(t, ac.overlays.pickerActive())
138138 timer.ch <- time.Now()
139- require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)
139+ require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
140140 }
141141
142142 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
@@ -148,7 +148,9 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
148148 tr1, sends1 := newCapturingTransport(t)
149149 tr2, sends2 := newCapturingTransport(t)
150150 ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
151+ ac1.initOverlays()
151152 ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
153+ ac2.initOverlays()
152154 sctx1, cancel1 := context.WithCancel(d.serveCtx)
153155 sctx2, cancel2 := context.WithCancel(d.serveCtx)
154156 defer cancel1()
@@ -258,7 +260,9 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
258260 tr1, sends1 := newCapturingTransport(t)
259261 tr2, _ := newCapturingTransport(t)
260262 ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
263+ ac1.initOverlays()
261264 ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
265+ ac2.initOverlays()
262266 sctx1, cancel1 := context.WithCancel(d.serveCtx)
263267 sctx2, cancel2 := context.WithCancel(d.serveCtx)
264268 defer cancel1()
@@ -37,7 +37,7 @@ func TestAltTForwardsToPTY(t *testing.T) {
37 37
38 d.handleInput(sess, ac, []byte("\x1bt"))38 d.handleInput(sess, ac, []byte("\x1bt"))
39 39
40- require.False(t, ac.pickerActive())40+ require.False(t, ac.overlays.pickerActive())
41 require.Equal(t, []byte("\x1bt"), <-writes)41 require.Equal(t, []byte("\x1bt"), <-writes)
42 }42 }
43 43
@@ -86,9 +86,9 @@ func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
86 d.clock = clk86 d.clock = clk
87 d.handleInput(sess, ac, []byte("\x1b"))87 d.handleInput(sess, ac, []byte("\x1b"))
88 timer := <-clk.timers88 timer := <-clk.timers
89- require.True(t, ac.pickerActive())89+ require.True(t, ac.overlays.pickerActive())
90 timer.ch <- time.Now()90 timer.ch <- time.Now()
91- require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)91+ require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
92 awaitFrame(t, sends, ports.MsgOutput)92 awaitFrame(t, sends, ports.MsgOutput)
93 }93 }
94 94
@@ -117,7 +117,7 @@ func TestPickerSplitArrowNavigatesWithoutExiting(t *testing.T) {
117 for _, input := range tc.input {117 for _, input := range tc.input {
118 d.handleInput(sess, ac, input)118 d.handleInput(sess, ac, input)
119 }119 }
120- require.True(t, ac.pickerActive())120+ require.True(t, ac.overlays.pickerActive())
121 d.handleInput(sess, ac, []byte("\r"))121 d.handleInput(sess, ac, []byte("\r"))
122 require.Equal(t, tc.wantActive, activeTabIndex(sess))122 require.Equal(t, tc.wantActive, activeTabIndex(sess))
123 })123 })
@@ -134,9 +134,9 @@ func TestPickerLoneEscapeExitsAfterDelay(t *testing.T) {
134 d.clock = clk134 d.clock = clk
135 d.handleInput(sess, ac, []byte("\x1b"))135 d.handleInput(sess, ac, []byte("\x1b"))
136 timer := <-clk.timers136 timer := <-clk.timers
137- require.True(t, ac.pickerActive())137+ require.True(t, ac.overlays.pickerActive())
138 timer.ch <- time.Now()138 timer.ch <- time.Now()
139- require.Eventually(t, func() bool { return !ac.pickerActive() }, time.Second, 5*time.Millisecond)139+ require.Eventually(t, func() bool { return !ac.overlays.pickerActive() }, time.Second, 5*time.Millisecond)
140 }140 }
141 141
142 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {142 func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
@@ -148,7 +148,9 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
148 tr1, sends1 := newCapturingTransport(t)148 tr1, sends1 := newCapturingTransport(t)
149 tr2, sends2 := newCapturingTransport(t)149 tr2, sends2 := newCapturingTransport(t)
150 ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}150 ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
151+ ac1.initOverlays()
151 ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}152 ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
153+ ac2.initOverlays()
152 sctx1, cancel1 := context.WithCancel(d.serveCtx)154 sctx1, cancel1 := context.WithCancel(d.serveCtx)
153 sctx2, cancel2 := context.WithCancel(d.serveCtx)155 sctx2, cancel2 := context.WithCancel(d.serveCtx)
154 defer cancel1()156 defer cancel1()
@@ -258,7 +260,9 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
258 tr1, sends1 := newCapturingTransport(t)260 tr1, sends1 := newCapturingTransport(t)
259 tr2, _ := newCapturingTransport(t)261 tr2, _ := newCapturingTransport(t)
260 ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}262 ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
263+ ac1.initOverlays()
261 ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}264 ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
265+ ac2.initOverlays()
262 sctx1, cancel1 := context.WithCancel(d.serveCtx)266 sctx1, cancel1 := context.WithCancel(d.serveCtx)
263 sctx2, cancel2 := context.WithCancel(d.serveCtx)267 sctx2, cancel2 := context.WithCancel(d.serveCtx)
264 defer cancel1()268 defer cancel1()
M internal/usecase/daemon/prompt.go +26 -24
@@ -15,20 +15,20 @@ func promptModalFor(title string) ui.Modal {
1515
1616 func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) {
1717 d.closePrompt(ac)
18- ac.promptMu.Lock()
19- ac.prompt = promptui.New(title, initial)
20- ac.promptSubmit = submit
21- ac.promptPending = nil
22- ac.promptMu.Unlock()
18+ ac.overlays.promptMu.Lock()
19+ ac.overlays.prompt = promptui.New(title, initial)
20+ ac.overlays.promptSubmit = submit
21+ ac.overlays.promptPending = nil
22+ ac.overlays.promptMu.Unlock()
2323 d.paint(sess, ac, true)
2424 }
2525
2626 func (d *Daemon) closePrompt(ac *attachedClient) {
27- ac.promptMu.Lock()
28- ac.prompt = nil
29- ac.promptSubmit = nil
30- ac.promptPending = nil
31- ac.promptMu.Unlock()
27+ ac.overlays.promptMu.Lock()
28+ ac.overlays.prompt = nil
29+ ac.overlays.promptSubmit = nil
30+ ac.overlays.promptPending = nil
31+ ac.overlays.promptMu.Unlock()
3232 }
3333
3434 func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
@@ -37,44 +37,46 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
3737 return
3838 }
3939
40- ac.promptMu.Lock()
41- if ac.prompt == nil {
42- ac.promptPending = nil
43- ac.promptMu.Unlock()
40+ ac.overlays.promptMu.Lock()
41+ if ac.overlays.prompt == nil {
42+ ac.overlays.promptPending = nil
43+ ac.overlays.promptMu.Unlock()
4444 return
4545 }
4646
4747 changed := false
4848 exit := false
4949 var submit func(string) error
50+ var submittedPrompt *promptui.Model
5051 var value string
5152
52- routeOverlayBytes(data, &ac.promptPending, overlayEvents{
53+ routeOverlayBytes(data, &ac.overlays.promptPending, overlayEvents{
5354 rune: func(r rune) {
54- ac.prompt.Insert(r)
55+ ac.overlays.prompt.Insert(r)
5556 changed = true
5657 },
5758 backspace: func() {
58- ac.prompt.Backspace()
59+ ac.overlays.prompt.Backspace()
5960 changed = true
6061 },
6162 enter: func() {
62- submit = ac.promptSubmit
63- value = strings.TrimSpace(ac.prompt.Value())
63+ submit = ac.overlays.promptSubmit
64+ submittedPrompt = ac.overlays.prompt
65+ value = strings.TrimSpace(ac.overlays.prompt.Value())
6466 },
6567 cancel: func() { exit = true },
6668 up: func() {},
6769 down: func() {},
6870 })
69- ac.promptMu.Unlock()
71+ ac.overlays.promptMu.Unlock()
7072
7173 if submit != nil {
7274 if err := submit(value); err != nil {
73- ac.promptMu.Lock()
74- if ac.prompt != nil {
75- ac.prompt.SetError(err.Error())
75+ ac.overlays.promptMu.Lock()
76+ if ac.overlays.prompt == submittedPrompt {
77+ ac.overlays.prompt.SetError(err.Error())
7678 }
77- ac.promptMu.Unlock()
79+ ac.overlays.promptMu.Unlock()
7880 d.paint(sess, ac, true)
7981 return
8082 }
@@ -15,20 +15,20 @@ func promptModalFor(title string) ui.Modal {
15 15
16 func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) {16 func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial string, submit func(string) error) {
17 d.closePrompt(ac)17 d.closePrompt(ac)
18- ac.promptMu.Lock()18+ ac.overlays.promptMu.Lock()
19- ac.prompt = promptui.New(title, initial)19+ ac.overlays.prompt = promptui.New(title, initial)
20- ac.promptSubmit = submit20+ ac.overlays.promptSubmit = submit
21- ac.promptPending = nil21+ ac.overlays.promptPending = nil
22- ac.promptMu.Unlock()22+ ac.overlays.promptMu.Unlock()
23 d.paint(sess, ac, true)23 d.paint(sess, ac, true)
24 }24 }
25 25
26 func (d *Daemon) closePrompt(ac *attachedClient) {26 func (d *Daemon) closePrompt(ac *attachedClient) {
27- ac.promptMu.Lock()27+ ac.overlays.promptMu.Lock()
28- ac.prompt = nil28+ ac.overlays.prompt = nil
29- ac.promptSubmit = nil29+ ac.overlays.promptSubmit = nil
30- ac.promptPending = nil30+ ac.overlays.promptPending = nil
31- ac.promptMu.Unlock()31+ ac.overlays.promptMu.Unlock()
32 }32 }
33 33
34 func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {34 func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
@@ -37,44 +37,46 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
37 return37 return
38 }38 }
39 39
40- ac.promptMu.Lock()40+ ac.overlays.promptMu.Lock()
41- if ac.prompt == nil {41+ if ac.overlays.prompt == nil {
42- ac.promptPending = nil42+ ac.overlays.promptPending = nil
43- ac.promptMu.Unlock()43+ ac.overlays.promptMu.Unlock()
44 return44 return
45 }45 }
46 46
47 changed := false47 changed := false
48 exit := false48 exit := false
49 var submit func(string) error49 var submit func(string) error
50+ var submittedPrompt *promptui.Model
50 var value string51 var value string
51 52
52- routeOverlayBytes(data, &ac.promptPending, overlayEvents{53+ routeOverlayBytes(data, &ac.overlays.promptPending, overlayEvents{
53 rune: func(r rune) {54 rune: func(r rune) {
54- ac.prompt.Insert(r)55+ ac.overlays.prompt.Insert(r)
55 changed = true56 changed = true
56 },57 },
57 backspace: func() {58 backspace: func() {
58- ac.prompt.Backspace()59+ ac.overlays.prompt.Backspace()
59 changed = true60 changed = true
60 },61 },
61 enter: func() {62 enter: func() {
62- submit = ac.promptSubmit63+ submit = ac.overlays.promptSubmit
63- value = strings.TrimSpace(ac.prompt.Value())64+ submittedPrompt = ac.overlays.prompt
65+ value = strings.TrimSpace(ac.overlays.prompt.Value())
64 },66 },
65 cancel: func() { exit = true },67 cancel: func() { exit = true },
66 up: func() {},68 up: func() {},
67 down: func() {},69 down: func() {},
68 })70 })
69- ac.promptMu.Unlock()71+ ac.overlays.promptMu.Unlock()
70 72
71 if submit != nil {73 if submit != nil {
72 if err := submit(value); err != nil {74 if err := submit(value); err != nil {
73- ac.promptMu.Lock()75+ ac.overlays.promptMu.Lock()
74- if ac.prompt != nil {76+ if ac.overlays.prompt == submittedPrompt {
75- ac.prompt.SetError(err.Error())77+ ac.overlays.prompt.SetError(err.Error())
76 }78 }
77- ac.promptMu.Unlock()79+ ac.overlays.promptMu.Unlock()
78 d.paint(sess, ac, true)80 d.paint(sess, ac, true)
79 return81 return
80 }82 }
M internal/usecase/daemon/prompt_test.go +6 -6
@@ -19,7 +19,7 @@ func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
1919 require.NoError(t, err)
2020 require.Contains(t, string(msg.Data), "Rename session")
2121 require.Contains(t, string(msg.Data), "> 0")
22- require.True(t, ac.promptActive())
22+ require.True(t, ac.overlays.promptActive())
2323 }
2424
2525 func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
@@ -34,7 +34,7 @@ func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
3434 d.handlePromptInput(ac, []byte("work\r"))
3535 repaint := awaitFrame(t, sends, ports.MsgOutput)
3636
37- require.False(t, ac.promptActive())
37+ require.False(t, ac.overlays.promptActive())
3838 require.Equal(t, "0work", sess.name)
3939 require.False(t, sess.ephemeral)
4040 out, err := ports.UnmarshalOutput(repaint.Payload)
@@ -58,7 +58,7 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
5858 d.handlePromptInput(ac, []byte("taken\r"))
5959 repaint := awaitFrame(t, sends, ports.MsgOutput)
6060
61- require.True(t, ac.promptActive())
61+ require.True(t, ac.overlays.promptActive())
6262 require.NotEqual(t, "taken", sess.name)
6363 out, err := ports.UnmarshalOutput(repaint.Payload)
6464 require.NoError(t, err)
@@ -76,7 +76,7 @@ func TestPromptEscapeCancelsWithoutRename(t *testing.T) {
7676 d.handlePromptInput(ac, []byte("new\x1b"))
7777 awaitFrame(t, sends, ports.MsgOutput)
7878
79- require.False(t, ac.promptActive())
79+ require.False(t, ac.overlays.promptActive())
8080 require.Equal(t, oldName, sess.name)
8181 }
8282
@@ -92,8 +92,8 @@ func TestPaletteRNSOpensRenamePrompt(t *testing.T) {
9292 d.handlePaletteInput(ac, []byte("RNS\r"))
9393 out := awaitFrame(t, sends, ports.MsgOutput)
9494
95- require.False(t, ac.paletteActive())
96- require.True(t, ac.promptActive())
95+ require.False(t, ac.overlays.paletteActive())
96+ require.True(t, ac.overlays.promptActive())
9797 msg, err := ports.UnmarshalOutput(out.Payload)
9898 require.NoError(t, err)
9999 require.Contains(t, string(msg.Data), "Rename session")
@@ -19,7 +19,7 @@ func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
19 require.NoError(t, err)19 require.NoError(t, err)
20 require.Contains(t, string(msg.Data), "Rename session")20 require.Contains(t, string(msg.Data), "Rename session")
21 require.Contains(t, string(msg.Data), "> 0")21 require.Contains(t, string(msg.Data), "> 0")
22- require.True(t, ac.promptActive())22+ require.True(t, ac.overlays.promptActive())
23 }23 }
24 24
25 func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {25 func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
@@ -34,7 +34,7 @@ func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
34 d.handlePromptInput(ac, []byte("work\r"))34 d.handlePromptInput(ac, []byte("work\r"))
35 repaint := awaitFrame(t, sends, ports.MsgOutput)35 repaint := awaitFrame(t, sends, ports.MsgOutput)
36 36
37- require.False(t, ac.promptActive())37+ require.False(t, ac.overlays.promptActive())
38 require.Equal(t, "0work", sess.name)38 require.Equal(t, "0work", sess.name)
39 require.False(t, sess.ephemeral)39 require.False(t, sess.ephemeral)
40 out, err := ports.UnmarshalOutput(repaint.Payload)40 out, err := ports.UnmarshalOutput(repaint.Payload)
@@ -58,7 +58,7 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
58 d.handlePromptInput(ac, []byte("taken\r"))58 d.handlePromptInput(ac, []byte("taken\r"))
59 repaint := awaitFrame(t, sends, ports.MsgOutput)59 repaint := awaitFrame(t, sends, ports.MsgOutput)
60 60
61- require.True(t, ac.promptActive())61+ require.True(t, ac.overlays.promptActive())
62 require.NotEqual(t, "taken", sess.name)62 require.NotEqual(t, "taken", sess.name)
63 out, err := ports.UnmarshalOutput(repaint.Payload)63 out, err := ports.UnmarshalOutput(repaint.Payload)
64 require.NoError(t, err)64 require.NoError(t, err)
@@ -76,7 +76,7 @@ func TestPromptEscapeCancelsWithoutRename(t *testing.T) {
76 d.handlePromptInput(ac, []byte("new\x1b"))76 d.handlePromptInput(ac, []byte("new\x1b"))
77 awaitFrame(t, sends, ports.MsgOutput)77 awaitFrame(t, sends, ports.MsgOutput)
78 78
79- require.False(t, ac.promptActive())79+ require.False(t, ac.overlays.promptActive())
80 require.Equal(t, oldName, sess.name)80 require.Equal(t, oldName, sess.name)
81 }81 }
82 82
@@ -92,8 +92,8 @@ func TestPaletteRNSOpensRenamePrompt(t *testing.T) {
92 d.handlePaletteInput(ac, []byte("RNS\r"))92 d.handlePaletteInput(ac, []byte("RNS\r"))
93 out := awaitFrame(t, sends, ports.MsgOutput)93 out := awaitFrame(t, sends, ports.MsgOutput)
94 94
95- require.False(t, ac.paletteActive())95+ require.False(t, ac.overlays.paletteActive())
96- require.True(t, ac.promptActive())96+ require.True(t, ac.overlays.promptActive())
97 msg, err := ports.UnmarshalOutput(out.Payload)97 msg, err := ports.UnmarshalOutput(out.Payload)
98 require.NoError(t, err)98 require.NoError(t, err)
99 require.Contains(t, string(msg.Data), "Rename session")99 require.Contains(t, string(msg.Data), "Rename session")
M internal/usecase/daemon/render.go +26 -56
@@ -32,7 +32,6 @@ import (
3232
3333 "github.com/bnema/vev/internal/domain"
3434 "github.com/bnema/vev/internal/ports"
35- scopy "github.com/bnema/vev/internal/usecase/copy"
3635 "github.com/bnema/vev/internal/usecase/layout"
3736 themeui "github.com/bnema/vev/internal/usecase/theme"
3837 "github.com/bnema/vev/internal/usecase/ui"
@@ -276,44 +275,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
276275 return
277276 }
278277
278+ ac.initOverlays()
279279 ac.sendMu.Lock()
280- ac.copyMu.Lock()
281- copyActive := ac.copyMode != nil
282- var copyMode *scopy.Mode
283- if ac.copyMode != nil {
284- copyModeValue := *ac.copyMode
285- copyMode = &copyModeValue
286- }
287- copyFeedback := ac.copyFeedback
288- if copyFeedback != "" && !copyActive {
289- ac.copyFeedback = ""
290- }
291- ac.copyMu.Unlock()
292- ac.pickerMu.Lock()
293- pickerActive := ac.picker != nil
294- pickerModel := ac.picker.Clone()
295- previewTab := ac.pickerPreview
296- ac.pickerMu.Unlock()
280+ overlays := ac.overlays.SnapshotForRender()
281+ repaintAttachedClients := false
282+ defer func() {
283+ if repaintAttachedClients {
284+ d.repaintAllAttachedClients()
285+ }
286+ }()
287+ defer overlays.Unlock()
297288 preview := snapshotPickerPreview(nil)
298- if previewTab != tb {
299- preview = snapshotPickerPreview(previewTab)
300- }
301- ac.paletteMu.Lock()
302- paletteModel := ac.palette
303- paletteActive := paletteModel != nil
304- if !paletteActive {
305- ac.paletteMu.Unlock()
306- }
307- ac.promptMu.Lock()
308- promptModel := ac.prompt
309- promptActive := promptModel != nil
310- if !promptActive {
311- ac.promptMu.Unlock()
289+ if overlays.previewTab != tb {
290+ preview = snapshotPickerPreview(overlays.previewTab)
312291 }
313- if sess.ackAttention(tb) {
314- defer d.repaintAllAttachedClients()
315- }
316- bars := d.barStateFor(sess, copyFeedback)
292+ repaintAttachedClients = sess.ackAttention(tb)
293+ bars := d.barStateFor(sess, overlays.copyFeedback)
317294 bars.theme = ac.getTheme()
318295
319296 styles := newThemeStyles(ac.getTheme())
@@ -332,28 +309,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
332309 p := tb.focusedPane()
333310 if p == nil {
334311 tb.mu.Unlock()
335- if paletteActive {
336- ac.paletteMu.Unlock()
337- }
338- if promptActive {
339- ac.promptMu.Unlock()
340- }
341312 ac.sendMu.Unlock()
342313 return
343314 }
344- if reset || copyActive || pickerActive || paletteActive || promptActive {
315+ if reset || overlays.copyActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
345316 ac.rend.Reset()
346317 ac.bars.Reset()
347318 }
348- if reset || pickerActive || paletteActive || promptActive {
319+ if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
349320 ac.lastCursor.valid = false
350321 }
351322 frame, damage := composeClientFrameWithLayout(bars, tb, reset, layoutSnap, &ac.bars)
352- if copyActive {
353- frame, damage = composeCopyClientFrame(copyMode, tb, bars)
323+ if overlays.copyActive {
324+ frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
354325 }
355- if pickerActive {
356- if previewTab == tb {
326+ if overlays.pickerActive {
327+ if overlays.previewTab == tb {
357328 if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf {
358329 previewFrame, _ := composeTabFrameWithLayout(tb, layoutSnap.area, themeui.Theme{}, layoutSnap)
359330 preview = pickerPreviewFromFrame(previewFrame)
@@ -361,19 +332,18 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
361332 preview = pickerPreviewFromLockedTab(tb)
362333 }
363334 }
364- frame, damage = composePickerClientFrame(pickerModel, preview, frame, styles)
335+ frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)
365336 }
366- if paletteActive {
367- frame, damage = composePaletteClientFrame(paletteModel, frame, styles)
368- ac.paletteMu.Unlock()
337+ if overlays.paletteActive {
338+ frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, styles)
369339 }
370- if promptActive {
371- frame, damage = composePromptClientFrame(promptModel, frame, styles)
372- ac.promptMu.Unlock()
340+ if overlays.promptActive {
341+ frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)
373342 }
343+ overlays.Unlock()
374344 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
375345 p.mu.Lock()
376- desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || copyActive || pickerActive || paletteActive || promptActive)
346+ desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
377347 p.mu.Unlock()
378348 data, err := ac.rend.Draw(frame, damage)
379349 var cursorTail []byte
@@ -32,7 +32,6 @@ import (
32 32
33 "github.com/bnema/vev/internal/domain"33 "github.com/bnema/vev/internal/domain"
34 "github.com/bnema/vev/internal/ports"34 "github.com/bnema/vev/internal/ports"
35- scopy "github.com/bnema/vev/internal/usecase/copy"
36 "github.com/bnema/vev/internal/usecase/layout"35 "github.com/bnema/vev/internal/usecase/layout"
37 themeui "github.com/bnema/vev/internal/usecase/theme"36 themeui "github.com/bnema/vev/internal/usecase/theme"
38 "github.com/bnema/vev/internal/usecase/ui"37 "github.com/bnema/vev/internal/usecase/ui"
@@ -276,44 +275,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
276 return275 return
277 }276 }
278 277
278+ ac.initOverlays()
279 ac.sendMu.Lock()279 ac.sendMu.Lock()
280- ac.copyMu.Lock()280+ overlays := ac.overlays.SnapshotForRender()
281- copyActive := ac.copyMode != nil281+ repaintAttachedClients := false
282- var copyMode *scopy.Mode282+ defer func() {
283- if ac.copyMode != nil {283+ if repaintAttachedClients {
284- copyModeValue := *ac.copyMode284+ d.repaintAllAttachedClients()
285- copyMode = &copyModeValue285+ }
286- }286+ }()
287- copyFeedback := ac.copyFeedback287+ defer overlays.Unlock()
288- if copyFeedback != "" && !copyActive {
289- ac.copyFeedback = ""
290- }
291- ac.copyMu.Unlock()
292- ac.pickerMu.Lock()
293- pickerActive := ac.picker != nil
294- pickerModel := ac.picker.Clone()
295- previewTab := ac.pickerPreview
296- ac.pickerMu.Unlock()
297 preview := snapshotPickerPreview(nil)288 preview := snapshotPickerPreview(nil)
298- if previewTab != tb {289+ if overlays.previewTab != tb {
299- preview = snapshotPickerPreview(previewTab)290+ preview = snapshotPickerPreview(overlays.previewTab)
300- }
301- ac.paletteMu.Lock()
302- paletteModel := ac.palette
303- paletteActive := paletteModel != nil
304- if !paletteActive {
305- ac.paletteMu.Unlock()
306- }
307- ac.promptMu.Lock()
308- promptModel := ac.prompt
309- promptActive := promptModel != nil
310- if !promptActive {
311- ac.promptMu.Unlock()
312 }291 }
313- if sess.ackAttention(tb) {292+ repaintAttachedClients = sess.ackAttention(tb)
314- defer d.repaintAllAttachedClients()293+ bars := d.barStateFor(sess, overlays.copyFeedback)
315- }
316- bars := d.barStateFor(sess, copyFeedback)
317 bars.theme = ac.getTheme()294 bars.theme = ac.getTheme()
318 295
319 styles := newThemeStyles(ac.getTheme())296 styles := newThemeStyles(ac.getTheme())
@@ -332,28 +309,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
332 p := tb.focusedPane()309 p := tb.focusedPane()
333 if p == nil {310 if p == nil {
334 tb.mu.Unlock()311 tb.mu.Unlock()
335- if paletteActive {
336- ac.paletteMu.Unlock()
337- }
338- if promptActive {
339- ac.promptMu.Unlock()
340- }
341 ac.sendMu.Unlock()312 ac.sendMu.Unlock()
342 return313 return
343 }314 }
344- if reset || copyActive || pickerActive || paletteActive || promptActive {315+ if reset || overlays.copyActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
345 ac.rend.Reset()316 ac.rend.Reset()
346 ac.bars.Reset()317 ac.bars.Reset()
347 }318 }
348- if reset || pickerActive || paletteActive || promptActive {319+ if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
349 ac.lastCursor.valid = false320 ac.lastCursor.valid = false
350 }321 }
351 frame, damage := composeClientFrameWithLayout(bars, tb, reset, layoutSnap, &ac.bars)322 frame, damage := composeClientFrameWithLayout(bars, tb, reset, layoutSnap, &ac.bars)
352- if copyActive {323+ if overlays.copyActive {
353- frame, damage = composeCopyClientFrame(copyMode, tb, bars)324+ frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
354 }325 }
355- if pickerActive {326+ if overlays.pickerActive {
356- if previewTab == tb {327+ if overlays.previewTab == tb {
357 if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf {328 if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf {
358 previewFrame, _ := composeTabFrameWithLayout(tb, layoutSnap.area, themeui.Theme{}, layoutSnap)329 previewFrame, _ := composeTabFrameWithLayout(tb, layoutSnap.area, themeui.Theme{}, layoutSnap)
359 preview = pickerPreviewFromFrame(previewFrame)330 preview = pickerPreviewFromFrame(previewFrame)
@@ -361,19 +332,18 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
361 preview = pickerPreviewFromLockedTab(tb)332 preview = pickerPreviewFromLockedTab(tb)
362 }333 }
363 }334 }
364- frame, damage = composePickerClientFrame(pickerModel, preview, frame, styles)335+ frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)
365 }336 }
366- if paletteActive {337+ if overlays.paletteActive {
367- frame, damage = composePaletteClientFrame(paletteModel, frame, styles)338+ frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, styles)
368- ac.paletteMu.Unlock()
369 }339 }
370- if promptActive {340+ if overlays.promptActive {
371- frame, damage = composePromptClientFrame(promptModel, frame, styles)341+ frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)
372- ac.promptMu.Unlock()
373 }342 }
343+ overlays.Unlock()
374 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)344 cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
375 p.mu.Lock()345 p.mu.Lock()
376- desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || copyActive || pickerActive || paletteActive || promptActive)346+ desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
377 p.mu.Unlock()347 p.mu.Unlock()
378 data, err := ac.rend.Draw(frame, damage)348 data, err := ac.rend.Draw(frame, damage)
379 var cursorTail []byte349 var cursorTail []byte
M internal/usecase/daemon/render_test.go +120 -0
@@ -18,6 +18,7 @@ import (
1818 "github.com/bnema/vev/internal/domain"
1919 "github.com/bnema/vev/internal/ports"
2020 portsmocks "github.com/bnema/vev/internal/ports/mocks"
21+ scopy "github.com/bnema/vev/internal/usecase/copy"
2122 "github.com/bnema/vev/internal/usecase/layout"
2223 themeui "github.com/bnema/vev/internal/usecase/theme"
2324 "github.com/bnema/vev/pkg/renderer"
@@ -37,6 +38,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
3738 win := newTab(p, domain.Size{Cols: 80, Rows: 23})
3839 sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
3940 ac := &attachedClient{}
41+ ac.initOverlays()
4042 ac.setSession(sess)
4143
4244 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))
@@ -215,6 +217,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
215217 sctx, cancel := context.WithCancel(context.Background())
216218 win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 5})
217219 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
220+ ac.initOverlays()
218221 sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
219222 ac.setSession(sess)
220223
@@ -278,6 +281,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
278281
279282 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
280283 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
284+ ac.initOverlays()
281285 sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
282286 ac.setSession(sess)
283287
@@ -388,6 +392,7 @@ func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) {
388392 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
389393 sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0}
390394 ac := &attachedClient{rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 10, Rows: 4}}
395+ ac.initOverlays()
391396 sess.client = ac
392397 ac.setSession(sess)
393398 d := newTestDaemon(t, nil, stubClock{})
@@ -449,6 +454,115 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
449454 require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")
450455 }
451456
457+func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
458+ tests := []struct {
459+ name string
460+ open func(*Daemon, *session, *attachedClient)
461+ close func(*Daemon, *session, *attachedClient)
462+ visible string
463+ notVisible string
464+ wantRestored string
465+ prepareScreen func(*session)
466+ }{
467+ {
468+ name: "prompt",
469+ open: func(d *Daemon, sess *session, ac *attachedClient) {
470+ d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
471+ },
472+ close: func(d *Daemon, _ *session, ac *attachedClient) { d.handlePromptInput(ac, []byte("\x1b")) },
473+ visible: "Rename session",
474+ notVisible: "Rename session",
475+ },
476+ {
477+ name: "palette",
478+ open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPalette(sess, ac) },
479+ close: func(d *Daemon, _ *session, ac *attachedClient) { d.handlePaletteInput(ac, []byte("\x1b")) },
480+ visible: "Commands",
481+ notVisible: "Commands",
482+ },
483+ {
484+ name: "picker",
485+ open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPicker(sess, ac) },
486+ close: func(d *Daemon, sess *session, ac *attachedClient) { d.closePicker(ac); d.paint(sess, ac, true) },
487+ visible: "Sessions",
488+ notVisible: "Sessions",
489+ },
490+ {
491+ name: "copy mode",
492+ open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },
493+ close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) },
494+ prepareScreen: func(sess *session) {
495+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
496+ },
497+ visible: "[VISUAL]",
498+ notVisible: "[VISUAL]",
499+ wantRestored: "live",
500+ },
501+ }
502+ for _, tt := range tests {
503+ t.Run(tt.name, func(t *testing.T) {
504+ p, release := newBlockingPTY(t)
505+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
506+ defer release()
507+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
508+ if tt.prepareScreen != nil {
509+ tt.prepareScreen(sess)
510+ }
511+ if tt.wantRestored == "" {
512+ tt.wantRestored = "live"
513+ }
514+
515+ tt.open(d, sess, ac)
516+ shown := string(mustOutputData(t, sends))
517+ require.Contains(t, shown, tt.visible)
518+
519+ tt.close(d, sess, ac)
520+ restored := string(mustOutputData(t, sends))
521+ require.Contains(t, restored, tt.wantRestored)
522+ require.NotContains(t, restored, tt.notVisible)
523+ })
524+ }
525+}
526+
527+func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {
528+ tests := []struct {
529+ name string
530+ open func(*Daemon, *session, *attachedClient)
531+ try func(*overlayRuntime) bool
532+ give func(*overlayRuntime)
533+ }{
534+ {
535+ name: "prompt",
536+ open: func(d *Daemon, sess *session, ac *attachedClient) {
537+ d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
538+ },
539+ try: func(rt *overlayRuntime) bool { return rt.promptMu.TryLock() },
540+ give: func(rt *overlayRuntime) { rt.promptMu.Unlock() },
541+ },
542+ {
543+ name: "palette",
544+ open: func(d *Daemon, sess *session, ac *attachedClient) {
545+ d.enterPalette(sess, ac)
546+ },
547+ try: func(rt *overlayRuntime) bool { return rt.paletteMu.TryLock() },
548+ give: func(rt *overlayRuntime) { rt.paletteMu.Unlock() },
549+ },
550+ }
551+ for _, tt := range tests {
552+ t.Run(tt.name, func(t *testing.T) {
553+ p, release := newBlockingPTY(t)
554+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
555+ defer release()
556+ sess.tabs[0].tree.Focus = layout.PaneID("missing")
557+
558+ tt.open(d, sess, ac)
559+
560+ require.True(t, tt.try(ac.overlays), "overlay render lock should be released after paint returns early")
561+ tt.give(ac.overlays)
562+ })
563+ }
564+}
565+
452566 func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {
453567 sess, win := newBarCacheTestSession()
454568 var cache barCache
@@ -524,6 +638,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
524638
525639 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
526640 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
641+ ac.initOverlays()
527642 sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
528643 ac.setSession(sess)
529644
@@ -549,6 +664,7 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
549664 win := newTab(p, domain.Size{Cols: 20, Rows: 5})
550665 sctx, cancel := context.WithCancel(context.Background())
551666 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
667+ ac.initOverlays()
552668 sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
553669 ac.setSession(sess)
554670 d.sessions[sess.id] = sess
@@ -594,6 +710,7 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
594710 p := win.focusedPane()
595711 p.ctx, p.cancel = sctx, cancel
596712 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
713+ ac.initOverlays()
597714 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
598715 ac.setSession(sess)
599716
@@ -654,6 +771,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
654771 sctx, cancel := context.WithCancel(context.Background())
655772 win := newTestTabWithContext(p, sctx, cancel)
656773 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
774+ ac.initOverlays()
657775 sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
658776 ac.setSession(sess)
659777 d.sessions[sess.id] = sess
@@ -759,6 +877,7 @@ func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
759877 sctx, cancel := context.WithCancel(context.Background())
760878 win := newTestTabWithContext(p, sctx, cancel)
761879 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
880+ ac.initOverlays()
762881 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
763882 ac.setSession(sess)
764883 d.sessions[sess.id] = sess
@@ -803,6 +922,7 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
803922 sctx, cancel := context.WithCancel(context.Background())
804923 win := newTestTabWithContext(p, sctx, cancel)
805924 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
925+ ac.initOverlays()
806926 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
807927 ac.setSession(sess)
808928 d.sessions[sess.id] = sess
@@ -18,6 +18,7 @@ import (
18 "github.com/bnema/vev/internal/domain"18 "github.com/bnema/vev/internal/domain"
19 "github.com/bnema/vev/internal/ports"19 "github.com/bnema/vev/internal/ports"
20 portsmocks "github.com/bnema/vev/internal/ports/mocks"20 portsmocks "github.com/bnema/vev/internal/ports/mocks"
21+ scopy "github.com/bnema/vev/internal/usecase/copy"
21 "github.com/bnema/vev/internal/usecase/layout"22 "github.com/bnema/vev/internal/usecase/layout"
22 themeui "github.com/bnema/vev/internal/usecase/theme"23 themeui "github.com/bnema/vev/internal/usecase/theme"
23 "github.com/bnema/vev/pkg/renderer"24 "github.com/bnema/vev/pkg/renderer"
@@ -37,6 +38,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
37 win := newTab(p, domain.Size{Cols: 80, Rows: 23})38 win := newTab(p, domain.Size{Cols: 80, Rows: 23})
38 sess := &session{id: "manual", name: "work", tabs: []*tab{win}}39 sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
39 ac := &attachedClient{}40 ac := &attachedClient{}
41+ ac.initOverlays()
40 ac.setSession(sess)42 ac.setSession(sess)
41 43
42 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))44 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))
@@ -215,6 +217,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
215 sctx, cancel := context.WithCancel(context.Background())217 sctx, cancel := context.WithCancel(context.Background())
216 win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 5})218 win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 5})
217 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}219 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
220+ ac.initOverlays()
218 sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}221 sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
219 ac.setSession(sess)222 ac.setSession(sess)
220 223
@@ -278,6 +281,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
278 281
279 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))282 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
280 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}283 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
284+ ac.initOverlays()
281 sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}285 sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
282 ac.setSession(sess)286 ac.setSession(sess)
283 287
@@ -388,6 +392,7 @@ func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) {
388 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})392 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
389 sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0}393 sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0}
390 ac := &attachedClient{rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 10, Rows: 4}}394 ac := &attachedClient{rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 10, Rows: 4}}
395+ ac.initOverlays()
391 sess.client = ac396 sess.client = ac
392 ac.setSession(sess)397 ac.setSession(sess)
393 d := newTestDaemon(t, nil, stubClock{})398 d := newTestDaemon(t, nil, stubClock{})
@@ -449,6 +454,115 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
449 require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")454 require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")
450 }455 }
451 456
457+func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
458+ tests := []struct {
459+ name string
460+ open func(*Daemon, *session, *attachedClient)
461+ close func(*Daemon, *session, *attachedClient)
462+ visible string
463+ notVisible string
464+ wantRestored string
465+ prepareScreen func(*session)
466+ }{
467+ {
468+ name: "prompt",
469+ open: func(d *Daemon, sess *session, ac *attachedClient) {
470+ d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
471+ },
472+ close: func(d *Daemon, _ *session, ac *attachedClient) { d.handlePromptInput(ac, []byte("\x1b")) },
473+ visible: "Rename session",
474+ notVisible: "Rename session",
475+ },
476+ {
477+ name: "palette",
478+ open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPalette(sess, ac) },
479+ close: func(d *Daemon, _ *session, ac *attachedClient) { d.handlePaletteInput(ac, []byte("\x1b")) },
480+ visible: "Commands",
481+ notVisible: "Commands",
482+ },
483+ {
484+ name: "picker",
485+ open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterPicker(sess, ac) },
486+ close: func(d *Daemon, sess *session, ac *attachedClient) { d.closePicker(ac); d.paint(sess, ac, true) },
487+ visible: "Sessions",
488+ notVisible: "Sessions",
489+ },
490+ {
491+ name: "copy mode",
492+ open: func(d *Daemon, sess *session, ac *attachedClient) { d.enterCopyMode(sess, ac) },
493+ close: func(d *Daemon, _ *session, ac *attachedClient) { d.handleCopyInput(ac, []byte("q")) },
494+ prepareScreen: func(sess *session) {
495+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
496+ },
497+ visible: "[VISUAL]",
498+ notVisible: "[VISUAL]",
499+ wantRestored: "live",
500+ },
501+ }
502+ for _, tt := range tests {
503+ t.Run(tt.name, func(t *testing.T) {
504+ p, release := newBlockingPTY(t)
505+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
506+ defer release()
507+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
508+ if tt.prepareScreen != nil {
509+ tt.prepareScreen(sess)
510+ }
511+ if tt.wantRestored == "" {
512+ tt.wantRestored = "live"
513+ }
514+
515+ tt.open(d, sess, ac)
516+ shown := string(mustOutputData(t, sends))
517+ require.Contains(t, shown, tt.visible)
518+
519+ tt.close(d, sess, ac)
520+ restored := string(mustOutputData(t, sends))
521+ require.Contains(t, restored, tt.wantRestored)
522+ require.NotContains(t, restored, tt.notVisible)
523+ })
524+ }
525+}
526+
527+func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {
528+ tests := []struct {
529+ name string
530+ open func(*Daemon, *session, *attachedClient)
531+ try func(*overlayRuntime) bool
532+ give func(*overlayRuntime)
533+ }{
534+ {
535+ name: "prompt",
536+ open: func(d *Daemon, sess *session, ac *attachedClient) {
537+ d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
538+ },
539+ try: func(rt *overlayRuntime) bool { return rt.promptMu.TryLock() },
540+ give: func(rt *overlayRuntime) { rt.promptMu.Unlock() },
541+ },
542+ {
543+ name: "palette",
544+ open: func(d *Daemon, sess *session, ac *attachedClient) {
545+ d.enterPalette(sess, ac)
546+ },
547+ try: func(rt *overlayRuntime) bool { return rt.paletteMu.TryLock() },
548+ give: func(rt *overlayRuntime) { rt.paletteMu.Unlock() },
549+ },
550+ }
551+ for _, tt := range tests {
552+ t.Run(tt.name, func(t *testing.T) {
553+ p, release := newBlockingPTY(t)
554+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
555+ defer release()
556+ sess.tabs[0].tree.Focus = layout.PaneID("missing")
557+
558+ tt.open(d, sess, ac)
559+
560+ require.True(t, tt.try(ac.overlays), "overlay render lock should be released after paint returns early")
561+ tt.give(ac.overlays)
562+ })
563+ }
564+}
565+
452 func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {566 func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {
453 sess, win := newBarCacheTestSession()567 sess, win := newBarCacheTestSession()
454 var cache barCache568 var cache barCache
@@ -524,6 +638,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
524 638
525 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))639 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
526 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}640 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
641+ ac.initOverlays()
527 sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}642 sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
528 ac.setSession(sess)643 ac.setSession(sess)
529 644
@@ -549,6 +664,7 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
549 win := newTab(p, domain.Size{Cols: 20, Rows: 5})664 win := newTab(p, domain.Size{Cols: 20, Rows: 5})
550 sctx, cancel := context.WithCancel(context.Background())665 sctx, cancel := context.WithCancel(context.Background())
551 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}666 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
667+ ac.initOverlays()
552 sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}668 sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
553 ac.setSession(sess)669 ac.setSession(sess)
554 d.sessions[sess.id] = sess670 d.sessions[sess.id] = sess
@@ -594,6 +710,7 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
594 p := win.focusedPane()710 p := win.focusedPane()
595 p.ctx, p.cancel = sctx, cancel711 p.ctx, p.cancel = sctx, cancel
596 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}712 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
713+ ac.initOverlays()
597 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}714 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
598 ac.setSession(sess)715 ac.setSession(sess)
599 716
@@ -654,6 +771,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
654 sctx, cancel := context.WithCancel(context.Background())771 sctx, cancel := context.WithCancel(context.Background())
655 win := newTestTabWithContext(p, sctx, cancel)772 win := newTestTabWithContext(p, sctx, cancel)
656 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}773 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
774+ ac.initOverlays()
657 sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}775 sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
658 ac.setSession(sess)776 ac.setSession(sess)
659 d.sessions[sess.id] = sess777 d.sessions[sess.id] = sess
@@ -759,6 +877,7 @@ func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
759 sctx, cancel := context.WithCancel(context.Background())877 sctx, cancel := context.WithCancel(context.Background())
760 win := newTestTabWithContext(p, sctx, cancel)878 win := newTestTabWithContext(p, sctx, cancel)
761 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}879 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
880+ ac.initOverlays()
762 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}881 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
763 ac.setSession(sess)882 ac.setSession(sess)
764 d.sessions[sess.id] = sess883 d.sessions[sess.id] = sess
@@ -803,6 +922,7 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
803 sctx, cancel := context.WithCancel(context.Background())922 sctx, cancel := context.WithCancel(context.Background())
804 win := newTestTabWithContext(p, sctx, cancel)923 win := newTestTabWithContext(p, sctx, cancel)
805 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}924 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
925+ ac.initOverlays()
806 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}926 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
807 ac.setSession(sess)927 ac.setSession(sess)
808 d.sessions[sess.id] = sess928 d.sessions[sess.id] = sess
M internal/usecase/daemon/resume_test.go +1 -0
@@ -278,6 +278,7 @@ func TestOutputStateNumberingIsSharedAndMonotone(t *testing.T) {
278278 d := newTestDaemon(t, nil, stubClock{})
279279 tr := &closeTrackingTransport{}
280280 ac := &attachedClient{tr: tr}
281+ ac.initOverlays()
281282
282283 require.NoError(t, d.boundedSendOutputErr(ac, []byte("copy")))
283284 require.NoError(t, d.boundedSendOutputErr(ac, []byte("more")))
@@ -278,6 +278,7 @@ func TestOutputStateNumberingIsSharedAndMonotone(t *testing.T) {
278 d := newTestDaemon(t, nil, stubClock{})278 d := newTestDaemon(t, nil, stubClock{})
279 tr := &closeTrackingTransport{}279 tr := &closeTrackingTransport{}
280 ac := &attachedClient{tr: tr}280 ac := &attachedClient{tr: tr}
281+ ac.initOverlays()
281 282
282 require.NoError(t, d.boundedSendOutputErr(ac, []byte("copy")))283 require.NoError(t, d.boundedSendOutputErr(ac, []byte("copy")))
283 require.NoError(t, d.boundedSendOutputErr(ac, []byte("more")))284 require.NoError(t, d.boundedSendOutputErr(ac, []byte("more")))
M internal/usecase/palette/model.go +1 -1
@@ -132,7 +132,7 @@ func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) rende
132132 codeWidth = len([]rune(match.Command.Code))
133133 }
134134 }
135- for y := 0; y < visible; y++ {
135+ for y := range visible {
136136 idx := m.scroll + y
137137 if idx >= len(m.matches) {
138138 break
@@ -132,7 +132,7 @@ func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) rende
132 codeWidth = len([]rune(match.Command.Code))132 codeWidth = len([]rune(match.Command.Code))
133 }133 }
134 }134 }
135- for y := 0; y < visible; y++ {135+ for y := range visible {
136 idx := m.scroll + y136 idx := m.scroll + y
137 if idx >= len(m.matches) {137 if idx >= len(m.matches) {
138 break138 break

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