detached af961c3

fix(panes): address split pane review findings

commit
af961c3c3e5859ecb755040aedc2c1c8bf00dff8
author
brice · · 2026-07-04 07:07 UTC
parent
181ce78
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
27 files · +1148 -360
M internal/usecase/daemon/attention_test.go +27 -29
@@ -15,7 +15,6 @@ import (
1515 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1616 "github.com/bnema/vev/internal/usecase/keys"
1717 "github.com/bnema/vev/pkg/renderer"
18- "github.com/bnema/vev/pkg/vt"
1918 )
2019
2120 func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
@@ -68,7 +67,7 @@ func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
6867 sess.active = 0
6968
7069 d.sessWg.Add(1)
71- go d.ptyReader(sess, sess.tabs[1])
70+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
7271
7372 require.Eventually(t, func() bool {
7473 sess.mu.Lock()
@@ -104,7 +103,11 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
104103 acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
105104 sctxW, cancelW := context.WithCancel(d.serveCtx)
106105 t.Cleanup(cancelW)
107- tabW := &tab{screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxW, cancel: cancelW}
106+ tabW := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 23})
107+ tabW.ctx, tabW.cancel = sctxW, cancelW
108+ for _, pane := range tabW.panes {
109+ pane.ctx, pane.cancel = sctxW, cancelW
110+ }
108111 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}
109112 acW.setSession(sessW)
110113 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})
@@ -270,7 +273,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
270273 defer releases[0]()
271274 defer releases[1]()
272275 sess.active = 0
273- sess.tabs[0].screen.Write([]byte("MIDSCREENMARKER"))
276+ sess.tabs[0].focusedPane().screen.Write([]byte("MIDSCREENMARKER"))
274277 sess.mu.Lock()
275278 sess.tabs[1].attention = true
276279 sess.tabs[1].attentionAt = time.Unix(1, 0)
@@ -315,12 +318,12 @@ func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testi
315318 bars := d.barStateFor(sess, "")
316319 _, damage := composeClientFrameWithState(bars, tb, true, &cache)
317320 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
318- tb.screen.ClearDamage()
321+ tb.focusedPane().screen.ClearDamage()
319322
320323 bars.attentionFrame++
321324 _, damage = composeClientFrameWithState(bars, tb, false, &cache)
322325 require.NotEmpty(t, damage)
323- lastRow := tb.screen.Frame.Height + 1
326+ lastRow := tb.focusedPane().screen.Frame.Height + 1
324327 for _, dmg := range damage {
325328 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
326329 }
@@ -351,7 +354,11 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
351354 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
352355 sctxB, cancelB := context.WithCancel(d.serveCtx)
353356 t.Cleanup(cancelB)
354- tbB := &tab{pty: pB, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxB, cancel: cancelB}
357+ tbB := newTab(pB, domain.Size{Cols: 80, Rows: 23})
358+ tbB.ctx, tbB.cancel = sctxB, cancelB
359+ for _, pane := range tbB.panes {
360+ pane.ctx, pane.cancel = sctxB, cancelB
361+ }
355362 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}
356363 acB.setSession(sessB)
357364 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})
@@ -397,33 +404,24 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
397404 d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
398405 sctx2, cancel2 := context.WithCancel(d.serveCtx)
399406 defer cancel2()
407+ tab2a := newTab(p2, domain.Size{Cols: 80, Rows: 23})
408+ tab2b := newTab(p3, domain.Size{Cols: 80, Rows: 23})
409+ for _, tb := range []*tab{tab2a, tab2b} {
410+ tb.ctx, tb.cancel = sctx2, cancel2
411+ for _, pane := range tb.panes {
412+ pane.ctx, pane.cancel = sctx2, cancel2
413+ }
414+ }
415+ tab2a.attention = true
416+ tab2a.attentionAt = time.Unix(9, 0)
417+ tab2b.attention = true
418+ tab2b.attentionAt = time.Unix(5, 0)
400419 sess2 := &session{
401420 id: "other",
402421 name: "other",
403422 ctx: sctx2,
404423 cancel: cancel2,
405- tabs: []*tab{
406- {
407- pty: p2,
408- screen: vt.NewScreen(80, 23),
409- dirty: make(chan struct{}, 1),
410- size: domain.Size{Cols: 80, Rows: 23},
411- ctx: sctx2,
412- cancel: cancel2,
413- attention: true,
414- attentionAt: time.Unix(9, 0),
415- },
416- {
417- pty: p3,
418- screen: vt.NewScreen(80, 23),
419- dirty: make(chan struct{}, 1),
420- size: domain.Size{Cols: 80, Rows: 23},
421- ctx: sctx2,
422- cancel: cancel2,
423- attention: true,
424- attentionAt: time.Unix(5, 0),
425- },
426- },
424+ tabs: []*tab{tab2a, tab2b},
427425 }
428426 d.sessions[sess2.id] = sess2
429427
@@ -15,7 +15,6 @@ import (
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"15 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/usecase/keys"16 "github.com/bnema/vev/internal/usecase/keys"
17 "github.com/bnema/vev/pkg/renderer"17 "github.com/bnema/vev/pkg/renderer"
18- "github.com/bnema/vev/pkg/vt"
19 )18 )
20 19
21 func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {20 func TestNoteAttentionFlagsBackgroundTab(t *testing.T) {
@@ -68,7 +67,7 @@ func TestPTYReaderBellMarksBackgroundTab(t *testing.T) {
68 sess.active = 067 sess.active = 0
69 68
70 d.sessWg.Add(1)69 d.sessWg.Add(1)
71- go d.ptyReader(sess, sess.tabs[1])70+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
72 71
73 require.Eventually(t, func() bool {72 require.Eventually(t, func() bool {
74 sess.mu.Lock()73 sess.mu.Lock()
@@ -104,7 +103,11 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
104 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}}
105 sctxW, cancelW := context.WithCancel(d.serveCtx)104 sctxW, cancelW := context.WithCancel(d.serveCtx)
106 t.Cleanup(cancelW)105 t.Cleanup(cancelW)
107- tabW := &tab{screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxW, cancel: cancelW}106+ tabW := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 23})
107+ tabW.ctx, tabW.cancel = sctxW, cancelW
108+ for _, pane := range tabW.panes {
109+ pane.ctx, pane.cancel = sctxW, cancelW
110+ }
108 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}111 sessW := &session{id: "wedged", name: "wedged", ctx: sctxW, cancel: cancelW, tabs: []*tab{tabW}, client: acW}
109 acW.setSession(sessW)112 acW.setSession(sessW)
110 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})113 acW.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acW})
@@ -270,7 +273,7 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
270 defer releases[0]()273 defer releases[0]()
271 defer releases[1]()274 defer releases[1]()
272 sess.active = 0275 sess.active = 0
273- sess.tabs[0].screen.Write([]byte("MIDSCREENMARKER"))276+ sess.tabs[0].focusedPane().screen.Write([]byte("MIDSCREENMARKER"))
274 sess.mu.Lock()277 sess.mu.Lock()
275 sess.tabs[1].attention = true278 sess.tabs[1].attention = true
276 sess.tabs[1].attentionAt = time.Unix(1, 0)279 sess.tabs[1].attentionAt = time.Unix(1, 0)
@@ -315,12 +318,12 @@ func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testi
315 bars := d.barStateFor(sess, "")318 bars := d.barStateFor(sess, "")
316 _, damage := composeClientFrameWithState(bars, tb, true, &cache)319 _, damage := composeClientFrameWithState(bars, tb, true, &cache)
317 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)320 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
318- tb.screen.ClearDamage()321+ tb.focusedPane().screen.ClearDamage()
319 322
320 bars.attentionFrame++323 bars.attentionFrame++
321 _, damage = composeClientFrameWithState(bars, tb, false, &cache)324 _, damage = composeClientFrameWithState(bars, tb, false, &cache)
322 require.NotEmpty(t, damage)325 require.NotEmpty(t, damage)
323- lastRow := tb.screen.Frame.Height + 1326+ lastRow := tb.focusedPane().screen.Frame.Height + 1
324 for _, dmg := range damage {327 for _, dmg := range damage {
325 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)328 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
326 }329 }
@@ -351,7 +354,11 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
351 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}354 acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
352 sctxB, cancelB := context.WithCancel(d.serveCtx)355 sctxB, cancelB := context.WithCancel(d.serveCtx)
353 t.Cleanup(cancelB)356 t.Cleanup(cancelB)
354- tbB := &tab{pty: pB, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctxB, cancel: cancelB}357+ tbB := newTab(pB, domain.Size{Cols: 80, Rows: 23})
358+ tbB.ctx, tbB.cancel = sctxB, cancelB
359+ for _, pane := range tbB.panes {
360+ pane.ctx, pane.cancel = sctxB, cancelB
361+ }
355 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}362 sessB := &session{id: "sessB", name: "other", ctx: sctxB, cancel: cancelB, tabs: []*tab{tbB}, client: acB}
356 acB.setSession(sessB)363 acB.setSession(sessB)
357 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})364 acB.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: acB})
@@ -397,33 +404,24 @@ func TestJumpAttentionCrossesSessionsWhenNoLocalBells(t *testing.T) {
397 d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)404 d, sess1, ac, sends := newManualSessionWithPTYs(t, p1)
398 sctx2, cancel2 := context.WithCancel(d.serveCtx)405 sctx2, cancel2 := context.WithCancel(d.serveCtx)
399 defer cancel2()406 defer cancel2()
407+ tab2a := newTab(p2, domain.Size{Cols: 80, Rows: 23})
408+ tab2b := newTab(p3, domain.Size{Cols: 80, Rows: 23})
409+ for _, tb := range []*tab{tab2a, tab2b} {
410+ tb.ctx, tb.cancel = sctx2, cancel2
411+ for _, pane := range tb.panes {
412+ pane.ctx, pane.cancel = sctx2, cancel2
413+ }
414+ }
415+ tab2a.attention = true
416+ tab2a.attentionAt = time.Unix(9, 0)
417+ tab2b.attention = true
418+ tab2b.attentionAt = time.Unix(5, 0)
400 sess2 := &session{419 sess2 := &session{
401 id: "other",420 id: "other",
402 name: "other",421 name: "other",
403 ctx: sctx2,422 ctx: sctx2,
404 cancel: cancel2,423 cancel: cancel2,
405- tabs: []*tab{424+ tabs: []*tab{tab2a, tab2b},
406- {
407- pty: p2,
408- screen: vt.NewScreen(80, 23),
409- dirty: make(chan struct{}, 1),
410- size: domain.Size{Cols: 80, Rows: 23},
411- ctx: sctx2,
412- cancel: cancel2,
413- attention: true,
414- attentionAt: time.Unix(9, 0),
415- },
416- {
417- pty: p3,
418- screen: vt.NewScreen(80, 23),
419- dirty: make(chan struct{}, 1),
420- size: domain.Size{Cols: 80, Rows: 23},
421- ctx: sctx2,
422- cancel: cancel2,
423- attention: true,
424- attentionAt: time.Unix(5, 0),
425- },
426- },
427 }425 }
428 d.sessions[sess2.id] = sess2426 d.sessions[sess2.id] = sess2
429 427
M internal/usecase/daemon/client.go +1 -14
@@ -449,20 +449,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
449449 for _, tb := range tabs {
450450 tb.mu.Lock()
451451 tb.size = tbSize
452- if tb.tree == nil {
453- p := tb.focusedPane()
454- if p != nil {
455- if err := p.pty.Resize(tbSize); err != nil {
456- d.log.Warn("pty resize failed", "err", err, "session", sess.name)
457- }
458- p.mu.Lock()
459- p.screen.Resize(tbSize.Cols, tbSize.Rows)
460- p.rect = domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows}
461- p.mu.Unlock()
462- }
463- } else {
464- d.applyLayoutLocked(tb)
465- }
452+ d.applyLayoutLocked(tb)
466453 tb.mu.Unlock()
467454 }
468455 if ac != nil {
@@ -449,20 +449,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
449 for _, tb := range tabs {449 for _, tb := range tabs {
450 tb.mu.Lock()450 tb.mu.Lock()
451 tb.size = tbSize451 tb.size = tbSize
452- if tb.tree == nil {452+ d.applyLayoutLocked(tb)
453- p := tb.focusedPane()
454- if p != nil {
455- if err := p.pty.Resize(tbSize); err != nil {
456- d.log.Warn("pty resize failed", "err", err, "session", sess.name)
457- }
458- p.mu.Lock()
459- p.screen.Resize(tbSize.Cols, tbSize.Rows)
460- p.rect = domain.Rect{Width: tbSize.Cols, Height: tbSize.Rows}
461- p.mu.Unlock()
462- }
463- } else {
464- d.applyLayoutLocked(tb)
465- }
466 tb.mu.Unlock()453 tb.mu.Unlock()
467 }454 }
468 if ac != nil {455 if ac != nil {
M internal/usecase/daemon/copymode.go +11 -4
@@ -335,13 +335,20 @@ func isCopyEscapePrefix(data []byte) bool {
335335 func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
336336 styles := newThemeStyles(bars.theme)
337337 p := tb.focusedPane()
338+ if p == nil {
339+ return renderer.NewFrame(0, 0), nil
340+ }
341+ p.mu.Lock()
342+ paneFrame := p.screen.Frame
343+ paneWidth, paneRows := paneFrame.Width, paneFrame.Height
344+ snap := scopy.NewSnapshot(p.scrollback, paneFrame)
345+ p.mu.Unlock()
346+ copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
338347 pl, ok := focusedPlacementLocked(tb)
339348 if !ok {
340- pl = layout.Placement{Content: domain.Rect{Width: p.screen.Frame.Width, Height: p.screen.Frame.Height}}
349+ pl = layout.Placement{Content: domain.Rect{Width: paneWidth, Height: paneRows}}
341350 }
342- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
343- copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
344- width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
351+ width, screenRows := paneWidth, paneRows
345352 if len(tb.panes) > 1 && tb.size.Valid() {
346353 width, screenRows = tb.size.Cols, tb.size.Rows
347354 }
@@ -335,13 +335,20 @@ func isCopyEscapePrefix(data []byte) bool {
335 func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {335 func composeCopyClientFrame(mode *scopy.Mode, tb *tab, bars barState) (renderer.Frame, []renderer.Damage) {
336 styles := newThemeStyles(bars.theme)336 styles := newThemeStyles(bars.theme)
337 p := tb.focusedPane()337 p := tb.focusedPane()
338+ if p == nil {
339+ return renderer.NewFrame(0, 0), nil
340+ }
341+ p.mu.Lock()
342+ paneFrame := p.screen.Frame
343+ paneWidth, paneRows := paneFrame.Width, paneFrame.Height
344+ snap := scopy.NewSnapshot(p.scrollback, paneFrame)
345+ p.mu.Unlock()
346+ copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
338 pl, ok := focusedPlacementLocked(tb)347 pl, ok := focusedPlacementLocked(tb)
339 if !ok {348 if !ok {
340- pl = layout.Placement{Content: domain.Rect{Width: p.screen.Frame.Width, Height: p.screen.Frame.Height}}349+ pl = layout.Placement{Content: domain.Rect{Width: paneWidth, Height: paneRows}}
341 }350 }
342- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)351+ width, screenRows := paneWidth, paneRows
343- copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
344- width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
345 if len(tb.panes) > 1 && tb.size.Valid() {352 if len(tb.panes) > 1 && tb.size.Valid() {
346 width, screenRows = tb.size.Cols, tb.size.Rows353 width, screenRows = tb.size.Cols, tb.size.Rows
347 }354 }
M internal/usecase/daemon/copymode_test.go +60 -27
@@ -26,15 +26,48 @@ import (
2626 // stubClock returns timers whose channel never fires, so a scheduler under it
2727 // blocks in its debounce loop until the session context is cancelled. Used by
2828
29+func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
30+ p, release := newBlockingPTY(t)
31+ defer release()
32+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
33+ tb := sess.activeTab()
34+ pane := tb.focusedPane()
35+ pane.mu.Lock()
36+ snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)
37+ pane.mu.Unlock()
38+ mode := scopy.NewMode(snap)
39+ bars := barState{status: sess.statusSegments()}
40+
41+ var wg sync.WaitGroup
42+ wg.Add(2)
43+ go func() {
44+ defer wg.Done()
45+ for range 200 {
46+ pane.mu.Lock()
47+ pane.screen.Write([]byte("output\n"))
48+ pane.mu.Unlock()
49+ }
50+ }()
51+ go func() {
52+ defer wg.Done()
53+ for range 200 {
54+ tb.mu.Lock()
55+ _, _ = composeCopyClientFrame(mode, tb, bars)
56+ tb.mu.Unlock()
57+ }
58+ }()
59+ wg.Wait()
60+}
61+
2962 func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
3063 p, release := newBlockingPTY(t)
3164 _, sess, _, _ := newManualSessionWithPTYs(t, p)
3265 defer release()
3366 sess.name = "work"
3467 tb := sess.activeTab()
35- tb.screen = vt.NewScreen(12, 3)
36- tb.screen.Write([]byte("live"))
37- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
68+ tb.focusedPane().screen = vt.NewScreen(12, 3)
69+ tb.focusedPane().screen.Write([]byte("live"))
70+ snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
3871 mode := scopy.NewMode(snap)
3972
4073 frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})
@@ -50,8 +83,8 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
5083 writes := make(chan []byte, 1)
5184 p, _ := newBlockingPTYWithWrites(t, writes)
5285 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
53- sess.tabs[0].scrollback = scopy.NewScrollback(4)
54- sess.tabs[0].screen.Write([]byte("live"))
86+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
87+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
5588
5689 d.handleInput(sess, ac, []byte("\x1b "))
5790 awaitFrame(t, sends, ports.MsgOutput)
@@ -85,10 +118,10 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
85118 writes := make(chan []byte, 1)
86119 p, _ := newBlockingPTYWithWrites(t, writes)
87120 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
88- sess.tabs[0].scrollback = scopy.NewScrollback(4)
89- sess.tabs[0].scrollback.Append(testRow("old1 "))
90- sess.tabs[0].scrollback.Append(testRow("old2 "))
91- sess.tabs[0].screen.Write([]byte("live"))
121+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
122+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
123+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
124+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
92125
93126 d.enterCopyMode(sess, ac)
94127 awaitFrame(t, sends, ports.MsgOutput)
@@ -211,8 +244,8 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
211244 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
212245 p, _ := newBlockingPTY(t)
213246 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
214- sess.tabs[0].scrollback = scopy.NewScrollback(4)
215- sess.tabs[0].screen.Write([]byte("live"))
247+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
248+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
216249
217250 d.enterCopyMode(sess, ac)
218251 awaitFrame(t, sends, ports.MsgOutput)
@@ -245,10 +278,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
245278 t.Run(tc.name, func(t *testing.T) {
246279 p, _ := newBlockingPTY(t)
247280 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
248- sess.tabs[0].scrollback = scopy.NewScrollback(4)
249- sess.tabs[0].scrollback.Append(testRow("old1 "))
250- sess.tabs[0].scrollback.Append(testRow("old2 "))
251- sess.tabs[0].screen.Write([]byte("live"))
281+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
282+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
283+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
284+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
252285
253286 d.enterCopyMode(sess, ac)
254287 awaitFrame(t, sends, ports.MsgOutput)
@@ -265,10 +298,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
265298 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
266299 p, _ := newBlockingPTY(t)
267300 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
268- sess.tabs[0].scrollback = scopy.NewScrollback(1)
301+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(1)
269302 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
270- sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1)
271- copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine))
303+ sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
304+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
272305
273306 d.enterCopyMode(sess, ac)
274307 awaitFrame(t, sends, ports.MsgOutput)
@@ -290,8 +323,8 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
290323 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
291324 clk := &signalClock{timers: make(chan *signalTimer, 1)}
292325 d.clock = clk
293- sess.tabs[0].scrollback = scopy.NewScrollback(4)
294- sess.tabs[0].screen.Write([]byte("live"))
326+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
327+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
295328
296329 d.enterCopyMode(sess, ac)
297330 awaitFrame(t, sends, ports.MsgOutput)
@@ -313,8 +346,8 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
313346 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
314347 clk := &signalClock{timers: make(chan *signalTimer, 1)}
315348 d.clock = clk
316- sess.tabs[0].scrollback = scopy.NewScrollback(4)
317- sess.tabs[0].screen.Write([]byte("live"))
349+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
350+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
318351
319352 d.enterCopyMode(sess, ac)
320353 awaitFrame(t, sends, ports.MsgOutput)
@@ -348,8 +381,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
348381 t.Run(tc.name, func(t *testing.T) {
349382 p, _ := newBlockingPTY(t)
350383 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
351- sess.tabs[0].scrollback = scopy.NewScrollback(4)
352- sess.tabs[0].screen.Write([]byte("live"))
384+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
385+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
353386
354387 d.enterCopyMode(sess, ac)
355388 awaitFrame(t, sends, ports.MsgOutput)
@@ -374,8 +407,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
374407 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
375408 p, _ := newBlockingPTY(t)
376409 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
377- sess.tabs[0].scrollback = scopy.NewScrollback(4)
378- sess.tabs[0].screen.Write([]byte("live"))
410+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
411+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
379412
380413 done := make(chan struct{})
381414 var drain sync.WaitGroup
@@ -407,7 +440,7 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
407440 func TestCursorTailHidesInCopyMode(t *testing.T) {
408441 p, _ := newBlockingPTY(t)
409442 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
410- sess.tabs[0].screen.Write([]byte("live"))
443+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
411444
412445 d.enterCopyMode(sess, ac)
413446 data := mustOutputData(t, sends)
@@ -26,15 +26,48 @@ import (
26 // stubClock returns timers whose channel never fires, so a scheduler under it26 // stubClock returns timers whose channel never fires, so a scheduler under it
27 // blocks in its debounce loop until the session context is cancelled. Used by27 // blocks in its debounce loop until the session context is cancelled. Used by
28 28
29+func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
30+ p, release := newBlockingPTY(t)
31+ defer release()
32+ _, sess, _, _ := newManualSessionWithPTYs(t, p)
33+ tb := sess.activeTab()
34+ pane := tb.focusedPane()
35+ pane.mu.Lock()
36+ snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)
37+ pane.mu.Unlock()
38+ mode := scopy.NewMode(snap)
39+ bars := barState{status: sess.statusSegments()}
40+
41+ var wg sync.WaitGroup
42+ wg.Add(2)
43+ go func() {
44+ defer wg.Done()
45+ for range 200 {
46+ pane.mu.Lock()
47+ pane.screen.Write([]byte("output\n"))
48+ pane.mu.Unlock()
49+ }
50+ }()
51+ go func() {
52+ defer wg.Done()
53+ for range 200 {
54+ tb.mu.Lock()
55+ _, _ = composeCopyClientFrame(mode, tb, bars)
56+ tb.mu.Unlock()
57+ }
58+ }()
59+ wg.Wait()
60+}
61+
29 func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {62 func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
30 p, release := newBlockingPTY(t)63 p, release := newBlockingPTY(t)
31 _, sess, _, _ := newManualSessionWithPTYs(t, p)64 _, sess, _, _ := newManualSessionWithPTYs(t, p)
32 defer release()65 defer release()
33 sess.name = "work"66 sess.name = "work"
34 tb := sess.activeTab()67 tb := sess.activeTab()
35- tb.screen = vt.NewScreen(12, 3)68+ tb.focusedPane().screen = vt.NewScreen(12, 3)
36- tb.screen.Write([]byte("live"))69+ tb.focusedPane().screen.Write([]byte("live"))
37- snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)70+ snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
38 mode := scopy.NewMode(snap)71 mode := scopy.NewMode(snap)
39 72
40 frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})73 frame, damage := composeCopyClientFrame(mode, tb, barState{status: sess.statusSegments()})
@@ -50,8 +83,8 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) {
50 writes := make(chan []byte, 1)83 writes := make(chan []byte, 1)
51 p, _ := newBlockingPTYWithWrites(t, writes)84 p, _ := newBlockingPTYWithWrites(t, writes)
52 d, sess, ac, sends := newManualSessionWithPTYs(t, p)85 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
53- sess.tabs[0].scrollback = scopy.NewScrollback(4)86+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
54- sess.tabs[0].screen.Write([]byte("live"))87+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
55 88
56 d.handleInput(sess, ac, []byte("\x1b "))89 d.handleInput(sess, ac, []byte("\x1b "))
57 awaitFrame(t, sends, ports.MsgOutput)90 awaitFrame(t, sends, ports.MsgOutput)
@@ -85,10 +118,10 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
85 writes := make(chan []byte, 1)118 writes := make(chan []byte, 1)
86 p, _ := newBlockingPTYWithWrites(t, writes)119 p, _ := newBlockingPTYWithWrites(t, writes)
87 d, sess, ac, sends := newManualSessionWithPTYs(t, p)120 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
88- sess.tabs[0].scrollback = scopy.NewScrollback(4)121+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
89- sess.tabs[0].scrollback.Append(testRow("old1 "))122+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
90- sess.tabs[0].scrollback.Append(testRow("old2 "))123+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
91- sess.tabs[0].screen.Write([]byte("live"))124+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
92 125
93 d.enterCopyMode(sess, ac)126 d.enterCopyMode(sess, ac)
94 awaitFrame(t, sends, ports.MsgOutput)127 awaitFrame(t, sends, ports.MsgOutput)
@@ -211,8 +244,8 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
211 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {244 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
212 p, _ := newBlockingPTY(t)245 p, _ := newBlockingPTY(t)
213 d, sess, ac, sends := newManualSessionWithPTYs(t, p)246 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
214- sess.tabs[0].scrollback = scopy.NewScrollback(4)247+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
215- sess.tabs[0].screen.Write([]byte("live"))248+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
216 249
217 d.enterCopyMode(sess, ac)250 d.enterCopyMode(sess, ac)
218 awaitFrame(t, sends, ports.MsgOutput)251 awaitFrame(t, sends, ports.MsgOutput)
@@ -245,10 +278,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
245 t.Run(tc.name, func(t *testing.T) {278 t.Run(tc.name, func(t *testing.T) {
246 p, _ := newBlockingPTY(t)279 p, _ := newBlockingPTY(t)
247 d, sess, ac, sends := newManualSessionWithPTYs(t, p)280 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
248- sess.tabs[0].scrollback = scopy.NewScrollback(4)281+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
249- sess.tabs[0].scrollback.Append(testRow("old1 "))282+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old1 "))
250- sess.tabs[0].scrollback.Append(testRow("old2 "))283+ sess.tabs[0].focusedPane().scrollback.Append(testRow("old2 "))
251- sess.tabs[0].screen.Write([]byte("live"))284+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
252 285
253 d.enterCopyMode(sess, ac)286 d.enterCopyMode(sess, ac)
254 awaitFrame(t, sends, ports.MsgOutput)287 awaitFrame(t, sends, ports.MsgOutput)
@@ -265,10 +298,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
265 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {298 func TestCopyModeOversizedYankShowsTooLargeFeedback(t *testing.T) {
266 p, _ := newBlockingPTY(t)299 p, _ := newBlockingPTY(t)
267 d, sess, ac, sends := newManualSessionWithPTYs(t, p)300 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
268- sess.tabs[0].scrollback = scopy.NewScrollback(1)301+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(1)
269 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)302 longLine := strings.Repeat("x", scopy.OSC52MaxPayloadBytes+1)
270- sess.tabs[0].screen.Frame = renderer.NewFrame(len(longLine), 1)303+ sess.tabs[0].focusedPane().screen.Frame = renderer.NewFrame(len(longLine), 1)
271- copy(sess.tabs[0].screen.Frame.Row(0), testRow(longLine))304+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow(longLine))
272 305
273 d.enterCopyMode(sess, ac)306 d.enterCopyMode(sess, ac)
274 awaitFrame(t, sends, ports.MsgOutput)307 awaitFrame(t, sends, ports.MsgOutput)
@@ -290,8 +323,8 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
290 d, sess, ac, sends := newManualSessionWithPTYs(t, p)323 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
291 clk := &signalClock{timers: make(chan *signalTimer, 1)}324 clk := &signalClock{timers: make(chan *signalTimer, 1)}
292 d.clock = clk325 d.clock = clk
293- sess.tabs[0].scrollback = scopy.NewScrollback(4)326+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
294- sess.tabs[0].screen.Write([]byte("live"))327+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
295 328
296 d.enterCopyMode(sess, ac)329 d.enterCopyMode(sess, ac)
297 awaitFrame(t, sends, ports.MsgOutput)330 awaitFrame(t, sends, ports.MsgOutput)
@@ -313,8 +346,8 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
313 d, sess, ac, sends := newManualSessionWithPTYs(t, p)346 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
314 clk := &signalClock{timers: make(chan *signalTimer, 1)}347 clk := &signalClock{timers: make(chan *signalTimer, 1)}
315 d.clock = clk348 d.clock = clk
316- sess.tabs[0].scrollback = scopy.NewScrollback(4)349+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
317- sess.tabs[0].screen.Write([]byte("live"))350+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
318 351
319 d.enterCopyMode(sess, ac)352 d.enterCopyMode(sess, ac)
320 awaitFrame(t, sends, ports.MsgOutput)353 awaitFrame(t, sends, ports.MsgOutput)
@@ -348,8 +381,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
348 t.Run(tc.name, func(t *testing.T) {381 t.Run(tc.name, func(t *testing.T) {
349 p, _ := newBlockingPTY(t)382 p, _ := newBlockingPTY(t)
350 d, sess, ac, sends := newManualSessionWithPTYs(t, p)383 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
351- sess.tabs[0].scrollback = scopy.NewScrollback(4)384+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
352- sess.tabs[0].screen.Write([]byte("live"))385+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
353 386
354 d.enterCopyMode(sess, ac)387 d.enterCopyMode(sess, ac)
355 awaitFrame(t, sends, ports.MsgOutput)388 awaitFrame(t, sends, ports.MsgOutput)
@@ -374,8 +407,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
374 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {407 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
375 p, _ := newBlockingPTY(t)408 p, _ := newBlockingPTY(t)
376 d, sess, ac, sends := newManualSessionWithPTYs(t, p)409 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
377- sess.tabs[0].scrollback = scopy.NewScrollback(4)410+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(4)
378- sess.tabs[0].screen.Write([]byte("live"))411+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
379 412
380 done := make(chan struct{})413 done := make(chan struct{})
381 var drain sync.WaitGroup414 var drain sync.WaitGroup
@@ -407,7 +440,7 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
407 func TestCursorTailHidesInCopyMode(t *testing.T) {440 func TestCursorTailHidesInCopyMode(t *testing.T) {
408 p, _ := newBlockingPTY(t)441 p, _ := newBlockingPTY(t)
409 d, sess, ac, sends := newManualSessionWithPTYs(t, p)442 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
410- sess.tabs[0].screen.Write([]byte("live"))443+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
411 444
412 d.enterCopyMode(sess, ac)445 d.enterCopyMode(sess, ac)
413 data := mustOutputData(t, sends)446 data := mustOutputData(t, sends)
M internal/usecase/daemon/input.go +79 -19
@@ -74,6 +74,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
7474
7575 if ac.copyModeActive() {
7676 if ev.Button == mouse.Left {
77+ contentRow := ev.Row - 1
7778 tb.mu.Lock()
7879 p := tb.focusedPane()
7980 var pl layout.Placement
@@ -82,11 +83,26 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
8283 if p != nil {
8384 pl, ok = focusedPlacementLocked(tb)
8485 }
85- tb.mu.Unlock()
86- if ok && multi {
87- if !pointInRect(ev.Col, ev.Row-1, pl.Content) {
86+ if ok && multi && !pointInRect(ev.Col, contentRow, pl.Content) && ev.Type == mouse.Press {
87+ if target, hit := hitTestPlacementLocked(tb, ev.Col, contentRow); hit {
88+ oldFocus := tb.tree.Focus
89+ focusPlacementLocked(tb, target.ID)
90+ d.applyLayoutLocked(tb)
91+ tb.mu.Unlock()
92+ d.exitCopyMode(ac)
93+ if target.ID != oldFocus {
94+ d.refreshPaneTitleOnFocus(sess, target.ID)
95+ }
96+ d.paint(sess, ac, true)
8897 return
8998 }
99+ }
100+ tb.mu.Unlock()
101+ if ok && multi {
102+ clampedCol := clampInt(ev.Col, pl.Content.X, pl.Content.X+pl.Content.Width-1)
103+ clampedRow := clampInt(contentRow, pl.Content.Y, pl.Content.Y+pl.Content.Height-1)
104+ ev.Col = clampedCol
105+ ev.Row = clampedRow
90106 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
91107 }
92108 }
@@ -104,42 +120,56 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
104120 tb.mu.Lock()
105121 contentRow := ev.Row - 1
106122 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
107- if hit && pl.Collapsed && pointInRect(ev.Col, contentRow, pl.TitleBar) {
108- oldFocus := tb.tree.Focus
123+ multi := len(tb.panes) > 1
124+ focusedID := layout.PaneID("")
125+ if tb.tree != nil {
126+ focusedID = tb.tree.Focus
127+ }
128+ if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
129+ if !isMouseFocusPress(ev) {
130+ tb.mu.Unlock()
131+ return
132+ }
133+ oldFocus := focusedID
109134 focusPlacementLocked(tb, pl.ID)
110135 d.applyLayoutLocked(tb)
111136 tb.mu.Unlock()
112137 if pl.ID != oldFocus {
113138 d.exitCopyMode(ac)
114- d.refreshPaneTitle(sess, pl.ID)
139+ d.refreshPaneTitleOnFocus(sess, pl.ID)
115140 }
116141 d.paint(sess, ac, true)
117142 return
118143 }
119144 var p *pane
120145 translated := false
146+ hoveredFocused := true
121147 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
122- oldFocus := tb.tree.Focus
123- focusPlacementLocked(tb, pl.ID)
124- d.applyLayoutLocked(tb)
125- p = tb.panes[pl.ID]
126- if len(tb.panes) == 1 {
127- p = tb.focusedPane()
148+ oldFocus := focusedID
149+ if isMouseFocusPress(ev) {
150+ focusPlacementLocked(tb, pl.ID)
151+ d.applyLayoutLocked(tb)
128152 }
153+ p = tb.panes[pl.ID]
154+ hoveredFocused = pl.ID == oldFocus
129155 tb.mu.Unlock()
130156 if p == nil {
131157 return
132158 }
133- if pl.ID != oldFocus {
159+ if isMouseFocusPress(ev) && pl.ID != oldFocus {
134160 d.exitCopyMode(ac)
135- d.refreshPaneTitle(sess, pl.ID)
161+ d.refreshPaneTitleOnFocus(sess, pl.ID)
136162 d.paint(sess, ac, true)
137163 }
138- if len(tb.panes) > 1 {
164+ if multi {
139165 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
140166 translated = true
141167 }
142168 } else {
169+ if multi {
170+ tb.mu.Unlock()
171+ return
172+ }
143173 p = tb.focusedPane()
144174 tb.mu.Unlock()
145175 if p == nil {
@@ -161,9 +191,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
161191 return
162192 }
163193 if translated {
164- daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw)
194+ d.writeToPane(sess, p, ev.Raw)
165195 } else {
166- daemonKeyHandler{d: d, ac: ac}.Forward(sgrRowOffset(ev.Raw, -1))
196+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1))
167197 }
168198 return
169199 }
@@ -218,15 +248,45 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
218248 }
219249 case mouse.WheelUp:
220250 if altScreen {
221- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
251+ d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))
252+ return
253+ }
254+ if !hoveredFocused {
222255 return
223256 }
224257 d.enterCopyMode(sess, ac)
225258 d.copyWheel(sess, ac, -3)
226259 case mouse.WheelDown:
227260 if altScreen {
228- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
261+ d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))
262+ }
263+ }
264+}
265+
266+func isMouseFocusPress(ev mouse.Event) bool {
267+ return ev.Type == mouse.Press && (ev.Button == mouse.Left || ev.Button == mouse.Middle || ev.Button == mouse.Right)
268+}
269+
270+func clampInt(v, lo, hi int) int {
271+ if v < lo {
272+ return lo
273+ }
274+ if v > hi {
275+ return hi
276+ }
277+ return v
278+}
279+
280+func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
281+ if p == nil {
282+ return
283+ }
284+ if _, err := p.pty.Write(data); err != nil {
285+ name := ""
286+ if sess != nil {
287+ name = sess.name
229288 }
289+ d.log.Error("pty write failed", "err", err, "session", name)
230290 }
231291 }
232292
@@ -74,6 +74,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
74 74
75 if ac.copyModeActive() {75 if ac.copyModeActive() {
76 if ev.Button == mouse.Left {76 if ev.Button == mouse.Left {
77+ contentRow := ev.Row - 1
77 tb.mu.Lock()78 tb.mu.Lock()
78 p := tb.focusedPane()79 p := tb.focusedPane()
79 var pl layout.Placement80 var pl layout.Placement
@@ -82,11 +83,26 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
82 if p != nil {83 if p != nil {
83 pl, ok = focusedPlacementLocked(tb)84 pl, ok = focusedPlacementLocked(tb)
84 }85 }
85- tb.mu.Unlock()86+ if ok && multi && !pointInRect(ev.Col, contentRow, pl.Content) && ev.Type == mouse.Press {
86- if ok && multi {87+ if target, hit := hitTestPlacementLocked(tb, ev.Col, contentRow); hit {
87- if !pointInRect(ev.Col, ev.Row-1, pl.Content) {88+ oldFocus := tb.tree.Focus
89+ focusPlacementLocked(tb, target.ID)
90+ d.applyLayoutLocked(tb)
91+ tb.mu.Unlock()
92+ d.exitCopyMode(ac)
93+ if target.ID != oldFocus {
94+ d.refreshPaneTitleOnFocus(sess, target.ID)
95+ }
96+ d.paint(sess, ac, true)
88 return97 return
89 }98 }
99+ }
100+ tb.mu.Unlock()
101+ if ok && multi {
102+ clampedCol := clampInt(ev.Col, pl.Content.X, pl.Content.X+pl.Content.Width-1)
103+ clampedRow := clampInt(contentRow, pl.Content.Y, pl.Content.Y+pl.Content.Height-1)
104+ ev.Col = clampedCol
105+ ev.Row = clampedRow
90 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)106 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
91 }107 }
92 }108 }
@@ -104,42 +120,56 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
104 tb.mu.Lock()120 tb.mu.Lock()
105 contentRow := ev.Row - 1121 contentRow := ev.Row - 1
106 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)122 pl, hit := hitTestPlacementLocked(tb, ev.Col, contentRow)
107- if hit && pl.Collapsed && pointInRect(ev.Col, contentRow, pl.TitleBar) {123+ multi := len(tb.panes) > 1
108- oldFocus := tb.tree.Focus124+ focusedID := layout.PaneID("")
125+ if tb.tree != nil {
126+ focusedID = tb.tree.Focus
127+ }
128+ if hit && pointInRect(ev.Col, contentRow, pl.TitleBar) {
129+ if !isMouseFocusPress(ev) {
130+ tb.mu.Unlock()
131+ return
132+ }
133+ oldFocus := focusedID
109 focusPlacementLocked(tb, pl.ID)134 focusPlacementLocked(tb, pl.ID)
110 d.applyLayoutLocked(tb)135 d.applyLayoutLocked(tb)
111 tb.mu.Unlock()136 tb.mu.Unlock()
112 if pl.ID != oldFocus {137 if pl.ID != oldFocus {
113 d.exitCopyMode(ac)138 d.exitCopyMode(ac)
114- d.refreshPaneTitle(sess, pl.ID)139+ d.refreshPaneTitleOnFocus(sess, pl.ID)
115 }140 }
116 d.paint(sess, ac, true)141 d.paint(sess, ac, true)
117 return142 return
118 }143 }
119 var p *pane144 var p *pane
120 translated := false145 translated := false
146+ hoveredFocused := true
121 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {147 if hit && !pl.Collapsed && pointInRect(ev.Col, contentRow, pl.Content) {
122- oldFocus := tb.tree.Focus148+ oldFocus := focusedID
123- focusPlacementLocked(tb, pl.ID)149+ if isMouseFocusPress(ev) {
124- d.applyLayoutLocked(tb)150+ focusPlacementLocked(tb, pl.ID)
125- p = tb.panes[pl.ID]151+ d.applyLayoutLocked(tb)
126- if len(tb.panes) == 1 {
127- p = tb.focusedPane()
128 }152 }
153+ p = tb.panes[pl.ID]
154+ hoveredFocused = pl.ID == oldFocus
129 tb.mu.Unlock()155 tb.mu.Unlock()
130 if p == nil {156 if p == nil {
131 return157 return
132 }158 }
133- if pl.ID != oldFocus {159+ if isMouseFocusPress(ev) && pl.ID != oldFocus {
134 d.exitCopyMode(ac)160 d.exitCopyMode(ac)
135- d.refreshPaneTitle(sess, pl.ID)161+ d.refreshPaneTitleOnFocus(sess, pl.ID)
136 d.paint(sess, ac, true)162 d.paint(sess, ac, true)
137 }163 }
138- if len(tb.panes) > 1 {164+ if multi {
139 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)165 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
140 translated = true166 translated = true
141 }167 }
142 } else {168 } else {
169+ if multi {
170+ tb.mu.Unlock()
171+ return
172+ }
143 p = tb.focusedPane()173 p = tb.focusedPane()
144 tb.mu.Unlock()174 tb.mu.Unlock()
145 if p == nil {175 if p == nil {
@@ -161,9 +191,9 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
161 return191 return
162 }192 }
163 if translated {193 if translated {
164- daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw)194+ d.writeToPane(sess, p, ev.Raw)
165 } else {195 } else {
166- daemonKeyHandler{d: d, ac: ac}.Forward(sgrRowOffset(ev.Raw, -1))196+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1))
167 }197 }
168 return198 return
169 }199 }
@@ -218,15 +248,45 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
218 }248 }
219 case mouse.WheelUp:249 case mouse.WheelUp:
220 if altScreen {250 if altScreen {
221- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))251+ d.writeToPane(sess, p, []byte("\x1b[A\x1b[A\x1b[A"))
252+ return
253+ }
254+ if !hoveredFocused {
222 return255 return
223 }256 }
224 d.enterCopyMode(sess, ac)257 d.enterCopyMode(sess, ac)
225 d.copyWheel(sess, ac, -3)258 d.copyWheel(sess, ac, -3)
226 case mouse.WheelDown:259 case mouse.WheelDown:
227 if altScreen {260 if altScreen {
228- daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))261+ d.writeToPane(sess, p, []byte("\x1b[B\x1b[B\x1b[B"))
262+ }
263+ }
264+}
265+
266+func isMouseFocusPress(ev mouse.Event) bool {
267+ return ev.Type == mouse.Press && (ev.Button == mouse.Left || ev.Button == mouse.Middle || ev.Button == mouse.Right)
268+}
269+
270+func clampInt(v, lo, hi int) int {
271+ if v < lo {
272+ return lo
273+ }
274+ if v > hi {
275+ return hi
276+ }
277+ return v
278+}
279+
280+func (d *Daemon) writeToPane(sess *session, p *pane, data []byte) {
281+ if p == nil {
282+ return
283+ }
284+ if _, err := p.pty.Write(data); err != nil {
285+ name := ""
286+ if sess != nil {
287+ name = sess.name
229 }288 }
289+ d.log.Error("pty write failed", "err", err, "session", name)
230 }290 }
231 }291 }
232 292
M internal/usecase/daemon/input_test.go +114 -17
@@ -7,6 +7,7 @@ import (
77 "testing"
88 "time"
99
10+ "github.com/stretchr/testify/mock"
1011 "github.com/stretchr/testify/require"
1112
1213 "github.com/bnema/vev/internal/domain"
@@ -171,7 +172,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
171172 p, _ := newBlockingPTYWithWrites(t, writes)
172173 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
173174 win := sess.tabs[0]
174- win.screen.Write([]byte("live"))
175+ win.focusedPane().screen.Write([]byte("live"))
175176
176177 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
177178 data := mustOutputData(t, sends)
@@ -198,7 +199,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
198199 writes := make(chan []byte, 2)
199200 p, _ := newBlockingPTYWithWrites(t, writes)
200201 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
201- sess.tabs[0].screen.Write([]byte("\x1b[?1049h"))
202+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1049h"))
202203
203204 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
204205 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
@@ -248,7 +249,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
248249 }
249250 require.Nil(t, ac.copyMode, "press alone must not enter visual mode")
250251
251- sess.tabs[0].screen.Write([]byte("\x1b[?1000h"))
252+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1000h"))
252253 raw := []byte("\x1b[<0;2;3M")
253254 d.handleInput(sess, ac, raw)
254255 select {
@@ -257,7 +258,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
257258 default:
258259 }
259260
260- sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
261+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h"))
261262 d.handleInput(sess, ac, raw)
262263 require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)
263264
@@ -276,7 +277,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
276277 default:
277278 }
278279
279- sess.tabs[0].screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
280+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
280281 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
281282 require.Nil(t, ac.copyMode, "press alone must still not enter visual mode")
282283 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
@@ -291,9 +292,9 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
291292 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
292293 p, _ := newBlockingPTY(t)
293294 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
294- copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha"))
295- copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo"))
296- copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie"))
295+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
296+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
297+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
297298
298299 d.enterCopyMode(sess, ac)
299300 mustOutputData(t, sends)
@@ -330,7 +331,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
330331 func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
331332 p, _ := newBlockingPTY(t)
332333 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
333- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")
334+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
334335
335336 // Press on a content row establishes a (soon to be stale) anchor.
336337 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
@@ -359,10 +360,10 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
359360 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
360361 p, _ := newBlockingPTY(t)
361362 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
362- copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha"))
363- copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo"))
364- copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie"))
365- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")
363+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
364+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
365+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
366+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
366367
367368 d.enterCopyMode(sess, ac)
368369 mustOutputData(t, sends)
@@ -399,8 +400,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
399400 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
400401 p, _ := newBlockingPTY(t)
401402 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
402- sess.tabs[0].scrollback = scopy.NewScrollback(50)
403- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")
403+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50)
404+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
404405
405406 // Press on a content row while scrollback is empty: the anchor is
406407 // content-stable at pressTop(0)+pressRow(0) = row 0.
@@ -409,7 +410,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
409410 // Simulate 5 lines evicted into scrollback between the Press and the
410411 // first Motion (e.g. the child kept producing output).
411412 for range 5 {
412- sess.tabs[0].scrollback.Append(testRow("evicted"))
413+ sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted"))
413414 }
414415
415416 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).
@@ -429,7 +430,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
429430 writes := make(chan []byte, 2)
430431 p, _ := newBlockingPTYWithWrites(t, writes)
431432 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
432- sess.tabs[0].screen.Write([]byte("live"))
433+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
433434
434435 d.handleInput(sess, ac, []byte("\x1b[<64;"))
435436 d.handleInput(sess, ac, []byte("1;1Mq"))
@@ -444,6 +445,102 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
444445 }
445446 }
446447
448+func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing.T) {
449+ p1 := portsmocks.NewMockPTY(t)
450+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
451+ p2 := portsmocks.NewMockPTY(t)
452+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
453+ p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
454+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
455+ d.procComm = nil
456+ tb := sess.activeTab()
457+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
458+ p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
459+ tb.mu.Lock()
460+ tb.size = domain.Size{Cols: 41, Rows: 5}
461+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
462+ tb.tree.Focus = "pane-1"
463+ tb.panes["pane-2"] = p2pane
464+ tb.mu.Unlock()
465+
466+ d.handleInput(sess, ac, []byte("\x1b[<64;22;2M"))
467+
468+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
469+}
470+
471+func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
472+ cases := []struct {
473+ name string
474+ raw []byte
475+ root *layout.Node
476+ size domain.Size
477+ }{
478+ {
479+ name: "divider",
480+ raw: []byte("\x1b[<0;21;2M"),
481+ root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}},
482+ size: domain.Size{Cols: 41, Rows: 5},
483+ },
484+ {
485+ name: "expanded title bar",
486+ raw: []byte("\x1b[<0;1;1M"),
487+ root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"},
488+ size: domain.Size{Cols: 20, Rows: 5},
489+ },
490+ }
491+ for _, tc := range cases {
492+ t.Run(tc.name, func(t *testing.T) {
493+ p1 := portsmocks.NewMockPTY(t)
494+ p1.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
495+ p2 := portsmocks.NewMockPTY(t)
496+ p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
497+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
498+ d.procComm = nil
499+ tb := sess.activeTab()
500+ tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
501+ tb.mu.Lock()
502+ tb.size = tc.size
503+ tb.tree.Root = tc.root
504+ tb.tree.Focus = "pane-1"
505+ tb.panes["pane-2"] = newPane("pane-2", p2, tc.size)
506+ tb.mu.Unlock()
507+
508+ d.handleInput(sess, ac, tc.raw)
509+
510+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
511+ })
512+ }
513+}
514+
515+func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
516+ p1 := portsmocks.NewMockPTY(t)
517+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
518+ p2 := portsmocks.NewMockPTY(t)
519+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
520+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
521+ d.procComm = nil
522+ tb := sess.activeTab()
523+ for i := range 10 {
524+ copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i))))
525+ }
526+ tb.mu.Lock()
527+ tb.size = domain.Size{Cols: 41, Rows: 10}
528+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
529+ tb.tree.Focus = "pane-1"
530+ tb.panes["pane-2"] = newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 10})
531+ tb.mu.Unlock()
532+ d.enterCopyMode(sess, ac)
533+ mustOutputData(t, sends)
534+
535+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))
536+ mustOutputData(t, sends)
537+
538+ lo, hi, ok := ac.copyMode.SelectedBounds()
539+ require.True(t, ok)
540+ require.Equal(t, 0, lo)
541+ require.Equal(t, 9, hi)
542+}
543+
447544 func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
448545 p1 := portsmocks.NewMockPTY(t)
449546 p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
@@ -7,6 +7,7 @@ import (
7 "testing"7 "testing"
8 "time"8 "time"
9 9
10+ "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
11 12
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
@@ -171,7 +172,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
171 p, _ := newBlockingPTYWithWrites(t, writes)172 p, _ := newBlockingPTYWithWrites(t, writes)
172 d, sess, ac, sends := newManualSessionWithPTYs(t, p)173 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
173 win := sess.tabs[0]174 win := sess.tabs[0]
174- win.screen.Write([]byte("live"))175+ win.focusedPane().screen.Write([]byte("live"))
175 176
176 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))177 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
177 data := mustOutputData(t, sends)178 data := mustOutputData(t, sends)
@@ -198,7 +199,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
198 writes := make(chan []byte, 2)199 writes := make(chan []byte, 2)
199 p, _ := newBlockingPTYWithWrites(t, writes)200 p, _ := newBlockingPTYWithWrites(t, writes)
200 d, sess, ac, _ := newManualSessionWithPTYs(t, p)201 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
201- sess.tabs[0].screen.Write([]byte("\x1b[?1049h"))202+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1049h"))
202 203
203 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))204 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
204 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))205 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
@@ -248,7 +249,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
248 }249 }
249 require.Nil(t, ac.copyMode, "press alone must not enter visual mode")250 require.Nil(t, ac.copyMode, "press alone must not enter visual mode")
250 251
251- sess.tabs[0].screen.Write([]byte("\x1b[?1000h"))252+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1000h"))
252 raw := []byte("\x1b[<0;2;3M")253 raw := []byte("\x1b[<0;2;3M")
253 d.handleInput(sess, ac, raw)254 d.handleInput(sess, ac, raw)
254 select {255 select {
@@ -257,7 +258,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
257 default:258 default:
258 }259 }
259 260
260- sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))261+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006h"))
261 d.handleInput(sess, ac, raw)262 d.handleInput(sess, ac, raw)
262 require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)263 require.Equal(t, []byte("\x1b[<0;2;2M"), <-writes)
263 264
@@ -276,7 +277,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
276 default:277 default:
277 }278 }
278 279
279- sess.tabs[0].screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))280+ sess.tabs[0].focusedPane().screen.Write([]byte("\x1b[?1006l\x1b[?1000l"))
280 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))281 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
281 require.Nil(t, ac.copyMode, "press alone must still not enter visual mode")282 require.Nil(t, ac.copyMode, "press alone must still not enter visual mode")
282 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))283 d.handleInput(sess, ac, []byte("\x1b[<32;1;3M"))
@@ -291,9 +292,9 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
291 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {292 func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
292 p, _ := newBlockingPTY(t)293 p, _ := newBlockingPTY(t)
293 d, sess, ac, sends := newManualSessionWithPTYs(t, p)294 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
294- copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha"))295+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
295- copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo"))296+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
296- copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie"))297+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
297 298
298 d.enterCopyMode(sess, ac)299 d.enterCopyMode(sess, ac)
299 mustOutputData(t, sends)300 mustOutputData(t, sends)
@@ -330,7 +331,7 @@ func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) {
330 func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {331 func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
331 p, _ := newBlockingPTY(t)332 p, _ := newBlockingPTY(t)
332 d, sess, ac, _ := newManualSessionWithPTYs(t, p)333 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
333- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")334+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
334 335
335 // Press on a content row establishes a (soon to be stale) anchor.336 // Press on a content row establishes a (soon to be stale) anchor.
336 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))337 d.handleInput(sess, ac, []byte("\x1b[<0;1;1M"))
@@ -359,10 +360,10 @@ func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) {
359 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {360 func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
360 p, _ := newBlockingPTY(t)361 p, _ := newBlockingPTY(t)
361 d, sess, ac, sends := newManualSessionWithPTYs(t, p)362 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
362- copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha"))363+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(0), testRow("alpha"))
363- copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo"))364+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(1), testRow("bravo"))
364- copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie"))365+ copy(sess.tabs[0].focusedPane().screen.Frame.Row(2), testRow("charlie"))
365- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")366+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
366 367
367 d.enterCopyMode(sess, ac)368 d.enterCopyMode(sess, ac)
368 mustOutputData(t, sends)369 mustOutputData(t, sends)
@@ -399,8 +400,8 @@ func TestCopyModeStatusRowPressClearsDragState(t *testing.T) {
399 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {400 func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
400 p, _ := newBlockingPTY(t)401 p, _ := newBlockingPTY(t)
401 d, sess, ac, _ := newManualSessionWithPTYs(t, p)402 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
402- sess.tabs[0].scrollback = scopy.NewScrollback(50)403+ sess.tabs[0].focusedPane().scrollback = scopy.NewScrollback(50)
403- require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24")404+ require.Equal(t, 23, sess.tabs[0].focusedPane().screen.Frame.Height, "fixture assumption: status row is wire row 24")
404 405
405 // Press on a content row while scrollback is empty: the anchor is406 // Press on a content row while scrollback is empty: the anchor is
406 // content-stable at pressTop(0)+pressRow(0) = row 0.407 // content-stable at pressTop(0)+pressRow(0) = row 0.
@@ -409,7 +410,7 @@ func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) {
409 // Simulate 5 lines evicted into scrollback between the Press and the410 // Simulate 5 lines evicted into scrollback between the Press and the
410 // first Motion (e.g. the child kept producing output).411 // first Motion (e.g. the child kept producing output).
411 for range 5 {412 for range 5 {
412- sess.tabs[0].scrollback.Append(testRow("evicted"))413+ sess.tabs[0].focusedPane().scrollback.Append(testRow("evicted"))
413 }414 }
414 415
415 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).416 // Motion lands on wire row 3 (0-based row 2 of the *current* screen).
@@ -429,7 +430,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
429 writes := make(chan []byte, 2)430 writes := make(chan []byte, 2)
430 p, _ := newBlockingPTYWithWrites(t, writes)431 p, _ := newBlockingPTYWithWrites(t, writes)
431 d, sess, ac, sends := newManualSessionWithPTYs(t, p)432 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
432- sess.tabs[0].screen.Write([]byte("live"))433+ sess.tabs[0].focusedPane().screen.Write([]byte("live"))
433 434
434 d.handleInput(sess, ac, []byte("\x1b[<64;"))435 d.handleInput(sess, ac, []byte("\x1b[<64;"))
435 d.handleInput(sess, ac, []byte("1;1Mq"))436 d.handleInput(sess, ac, []byte("1;1Mq"))
@@ -444,6 +445,102 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
444 }445 }
445 }446 }
446 447
448+func TestMouseWheelOverUnfocusedPaneDoesNotFocusAndForwardsChildMouse(t *testing.T) {
449+ p1 := portsmocks.NewMockPTY(t)
450+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
451+ p2 := portsmocks.NewMockPTY(t)
452+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
453+ p2.EXPECT().Write([]byte("\x1b[<64;1;1M")).Return(len("\x1b[<64;1;1M"), nil).Once()
454+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
455+ d.procComm = nil
456+ tb := sess.activeTab()
457+ p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5})
458+ p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
459+ tb.mu.Lock()
460+ tb.size = domain.Size{Cols: 41, Rows: 5}
461+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
462+ tb.tree.Focus = "pane-1"
463+ tb.panes["pane-2"] = p2pane
464+ tb.mu.Unlock()
465+
466+ d.handleInput(sess, ac, []byte("\x1b[<64;22;2M"))
467+
468+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
469+}
470+
471+func TestMouseDividerAndTitleBarDoNotForwardBogusCoordinates(t *testing.T) {
472+ cases := []struct {
473+ name string
474+ raw []byte
475+ root *layout.Node
476+ size domain.Size
477+ }{
478+ {
479+ name: "divider",
480+ raw: []byte("\x1b[<0;21;2M"),
481+ root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}},
482+ size: domain.Size{Cols: 41, Rows: 5},
483+ },
484+ {
485+ name: "expanded title bar",
486+ raw: []byte("\x1b[<0;1;1M"),
487+ root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-1"},
488+ size: domain.Size{Cols: 20, Rows: 5},
489+ },
490+ }
491+ for _, tc := range cases {
492+ t.Run(tc.name, func(t *testing.T) {
493+ p1 := portsmocks.NewMockPTY(t)
494+ p1.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
495+ p2 := portsmocks.NewMockPTY(t)
496+ p2.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
497+ d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
498+ d.procComm = nil
499+ tb := sess.activeTab()
500+ tb.focusedPane().screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
501+ tb.mu.Lock()
502+ tb.size = tc.size
503+ tb.tree.Root = tc.root
504+ tb.tree.Focus = "pane-1"
505+ tb.panes["pane-2"] = newPane("pane-2", p2, tc.size)
506+ tb.mu.Unlock()
507+
508+ d.handleInput(sess, ac, tc.raw)
509+
510+ require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus)
511+ })
512+ }
513+}
514+
515+func TestCopyModeDragOutsideSplitPaneClampsToPaneContent(t *testing.T) {
516+ p1 := portsmocks.NewMockPTY(t)
517+ p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
518+ p2 := portsmocks.NewMockPTY(t)
519+ p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Maybe()
520+ d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
521+ d.procComm = nil
522+ tb := sess.activeTab()
523+ for i := range 10 {
524+ copy(tb.focusedPane().screen.Frame.Row(i), testRow(string(rune('a'+i))))
525+ }
526+ tb.mu.Lock()
527+ tb.size = domain.Size{Cols: 41, Rows: 10}
528+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}
529+ tb.tree.Focus = "pane-1"
530+ tb.panes["pane-2"] = newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 10})
531+ tb.mu.Unlock()
532+ d.enterCopyMode(sess, ac)
533+ mustOutputData(t, sends)
534+
535+ d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;12M"))
536+ mustOutputData(t, sends)
537+
538+ lo, hi, ok := ac.copyMode.SelectedBounds()
539+ require.True(t, ok)
540+ require.Equal(t, 0, lo)
541+ require.Equal(t, 9, hi)
542+}
543+
447 func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {544 func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) {
448 p1 := portsmocks.NewMockPTY(t)545 p1 := portsmocks.NewMockPTY(t)
449 p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()546 p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe()
M internal/usecase/daemon/pane.go +3 -0
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "context"
55 "sync"
6+ "time"
67
78 "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/ports"
@@ -26,6 +27,8 @@ type pane struct {
2627 syncGen uint64
2728 rect domain.Rect
2829 title string
30+ titleAt time.Time
31+ titleValid bool
2932 ctx context.Context
3033 cancel context.CancelFunc
3134 }
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6+ "time"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
@@ -26,6 +27,8 @@ type pane struct {
26 syncGen uint6427 syncGen uint64
27 rect domain.Rect28 rect domain.Rect
28 title string29 title string
30+ titleAt time.Time
31+ titleValid bool
29 ctx context.Context32 ctx context.Context
30 cancel context.CancelFunc33 cancel context.CancelFunc
31 }34 }
M internal/usecase/daemon/pane_actions.go +5 -24
@@ -42,7 +42,6 @@ func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Directi
4242 if !ok {
4343 _ = tb.tree.Close(newID)
4444 tb.tree.Focus = oldFocus
45- tb.nextPaneID--
4645 tb.mu.Unlock()
4746 return layout.ErrTooSmall
4847 }
@@ -54,7 +53,6 @@ func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Directi
5453 tb.mu.Lock()
5554 _ = tb.tree.Close(newID)
5655 tb.tree.Focus = oldFocus
57- tb.nextPaneID--
5856 tb.mu.Unlock()
5957 return err
6058 }
@@ -65,7 +63,7 @@ func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Directi
6563 p.rect = newRect
6664
6765 tb.mu.Lock()
68- if _, ok := tb.panes[newID]; ok || !layoutContains(tb.tree.Root, newID) {
66+ if _, ok := tb.panes[newID]; ok || !layout.ContainsLeaf(tb.tree.Root, newID) {
6967 tb.mu.Unlock()
7068 cancel()
7169 _ = pty.Close()
@@ -135,21 +133,6 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re
135133
136134 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
137135
138-func layoutContains(n *layout.Node, id layout.PaneID) bool {
139- if n == nil {
140- return false
141- }
142- if n.Kind == layout.Leaf {
143- return n.Leaf == id
144- }
145- for _, child := range n.Children {
146- if layoutContains(child, id) {
147- return true
148- }
149- }
150- return false
151-}
152-
153136 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
154137 if d.ptys == nil {
155138 if ac != nil {
@@ -183,7 +166,6 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
183166 if !ok {
184167 _ = tb.tree.Close(newID)
185168 tb.tree.Focus = oldFocus
186- tb.nextPaneID--
187169 tb.mu.Unlock()
188170 return layout.ErrTooSmall
189171 }
@@ -195,7 +177,6 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
195177 tb.mu.Lock()
196178 _ = tb.tree.Close(newID)
197179 tb.tree.Focus = oldFocus
198- tb.nextPaneID--
199180 tb.mu.Unlock()
200181 return err
201182 }
@@ -206,7 +187,7 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
206187 p.rect = newRect
207188
208189 tb.mu.Lock()
209- if _, ok := tb.panes[newID]; ok || !layoutContains(tb.tree.Root, newID) {
190+ if _, ok := tb.panes[newID]; ok || !layout.ContainsLeaf(tb.tree.Root, newID) {
210191 tb.mu.Unlock()
211192 cancel()
212193 _ = pty.Close()
@@ -281,11 +262,11 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
281262 }
282263 tb.mu.Lock()
283264 p := tb.panes[id]
284- if p == nil || tb.tree == nil || !layoutContains(tb.tree.Root, id) {
265+ if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {
285266 tb.mu.Unlock()
286267 return nil
287268 }
288- if len(tb.panes) <= 1 {
269+ if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
289270 tb.mu.Unlock()
290271 d.closeTab(sess, tb, repaint)
291272 return nil
@@ -339,7 +320,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
339320 pl, ok := focusedPlacementLocked(tb)
340321 tb.mu.Unlock()
341322 if ok && pl.TitleBar.Height > 0 {
342- d.refreshPaneTitle(sess, newFocus)
323+ d.refreshPaneTitleOnFocus(sess, newFocus)
343324 }
344325 }
345326 d.paint(sess, ac, true)
@@ -42,7 +42,6 @@ func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Directi
42 if !ok {42 if !ok {
43 _ = tb.tree.Close(newID)43 _ = tb.tree.Close(newID)
44 tb.tree.Focus = oldFocus44 tb.tree.Focus = oldFocus
45- tb.nextPaneID--
46 tb.mu.Unlock()45 tb.mu.Unlock()
47 return layout.ErrTooSmall46 return layout.ErrTooSmall
48 }47 }
@@ -54,7 +53,6 @@ func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Directi
54 tb.mu.Lock()53 tb.mu.Lock()
55 _ = tb.tree.Close(newID)54 _ = tb.tree.Close(newID)
56 tb.tree.Focus = oldFocus55 tb.tree.Focus = oldFocus
57- tb.nextPaneID--
58 tb.mu.Unlock()56 tb.mu.Unlock()
59 return err57 return err
60 }58 }
@@ -65,7 +63,7 @@ func (d *Daemon) splitPane(sess *session, ac *attachedClient, dir layout.Directi
65 p.rect = newRect63 p.rect = newRect
66 64
67 tb.mu.Lock()65 tb.mu.Lock()
68- if _, ok := tb.panes[newID]; ok || !layoutContains(tb.tree.Root, newID) {66+ if _, ok := tb.panes[newID]; ok || !layout.ContainsLeaf(tb.tree.Root, newID) {
69 tb.mu.Unlock()67 tb.mu.Unlock()
70 cancel()68 cancel()
71 _ = pty.Close()69 _ = pty.Close()
@@ -135,21 +133,6 @@ func placementContent(placements []layout.Placement, id layout.PaneID) domain.Re
135 133
136 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }134 func rectSize(r domain.Rect) domain.Size { return domain.Size{Cols: r.Width, Rows: r.Height} }
137 135
138-func layoutContains(n *layout.Node, id layout.PaneID) bool {
139- if n == nil {
140- return false
141- }
142- if n.Kind == layout.Leaf {
143- return n.Leaf == id
144- }
145- for _, child := range n.Children {
146- if layoutContains(child, id) {
147- return true
148- }
149- }
150- return false
151-}
152-
153 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {136 func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
154 if d.ptys == nil {137 if d.ptys == nil {
155 if ac != nil {138 if ac != nil {
@@ -183,7 +166,6 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
183 if !ok {166 if !ok {
184 _ = tb.tree.Close(newID)167 _ = tb.tree.Close(newID)
185 tb.tree.Focus = oldFocus168 tb.tree.Focus = oldFocus
186- tb.nextPaneID--
187 tb.mu.Unlock()169 tb.mu.Unlock()
188 return layout.ErrTooSmall170 return layout.ErrTooSmall
189 }171 }
@@ -195,7 +177,6 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
195 tb.mu.Lock()177 tb.mu.Lock()
196 _ = tb.tree.Close(newID)178 _ = tb.tree.Close(newID)
197 tb.tree.Focus = oldFocus179 tb.tree.Focus = oldFocus
198- tb.nextPaneID--
199 tb.mu.Unlock()180 tb.mu.Unlock()
200 return err181 return err
201 }182 }
@@ -206,7 +187,7 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
206 p.rect = newRect187 p.rect = newRect
207 188
208 tb.mu.Lock()189 tb.mu.Lock()
209- if _, ok := tb.panes[newID]; ok || !layoutContains(tb.tree.Root, newID) {190+ if _, ok := tb.panes[newID]; ok || !layout.ContainsLeaf(tb.tree.Root, newID) {
210 tb.mu.Unlock()191 tb.mu.Unlock()
211 cancel()192 cancel()
212 _ = pty.Close()193 _ = pty.Close()
@@ -281,11 +262,11 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
281 }262 }
282 tb.mu.Lock()263 tb.mu.Lock()
283 p := tb.panes[id]264 p := tb.panes[id]
284- if p == nil || tb.tree == nil || !layoutContains(tb.tree.Root, id) {265+ if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {
285 tb.mu.Unlock()266 tb.mu.Unlock()
286 return nil267 return nil
287 }268 }
288- if len(tb.panes) <= 1 {269+ if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
289 tb.mu.Unlock()270 tb.mu.Unlock()
290 d.closeTab(sess, tb, repaint)271 d.closeTab(sess, tb, repaint)
291 return nil272 return nil
@@ -339,7 +320,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
339 pl, ok := focusedPlacementLocked(tb)320 pl, ok := focusedPlacementLocked(tb)
340 tb.mu.Unlock()321 tb.mu.Unlock()
341 if ok && pl.TitleBar.Height > 0 {322 if ok && pl.TitleBar.Height > 0 {
342- d.refreshPaneTitle(sess, newFocus)323+ d.refreshPaneTitleOnFocus(sess, newFocus)
343 }324 }
344 }325 }
345 d.paint(sess, ac, true)326 d.paint(sess, ac, true)
M internal/usecase/daemon/pane_actions_test.go +42 -1
@@ -72,7 +72,7 @@ func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
7272 defer tb.mu.Unlock()
7373 require.Equal(t, beforeRoot, tb.tree.Root.Leaf)
7474 require.Equal(t, beforeFocus, tb.tree.Focus)
75- require.Equal(t, beforeNext, tb.nextPaneID)
75+ require.Equal(t, beforeNext+1, tb.nextPaneID)
7676 require.Len(t, tb.panes, beforePaneCount)
7777 require.Nil(t, tb.panes["pane-2"])
7878 }
@@ -173,6 +173,19 @@ func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) {
173173 require.NotContains(t, tb.panes, layout.PaneID("pane-2"))
174174 }
175175
176+func TestClosePaneKeepsTabOpenWhenPendingSpawnLeafExists(t *testing.T) {
177+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
178+ tb := sess.activeTab()
179+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
180+ oldPTY.EXPECT().Close().Return(nil).Once()
181+
182+ require.NoError(t, d.closePane(sess, tb, "pane-1", nil, false))
183+
184+ require.Len(t, sess.tabs, 1)
185+ require.Empty(t, tb.panes)
186+ require.True(t, layout.ContainsLeaf(tb.tree.Root, "pane-2"))
187+}
188+
176189 func TestCloseFocusedLastPaneDelegatesToCloseTab(t *testing.T) {
177190 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
178191 otherPTY := portsmocks.NewMockPTY(t)
@@ -243,6 +256,34 @@ func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *ports
243256 return d, sess, oldPTY, factory
244257 }
245258
259+func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
260+ writes := make(chan []byte, 1)
261+ d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
262+ newPTY, releaseNew := newBlockingPTYWithWrites(t, writes)
263+ defer releaseNew()
264+ oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
265+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
266+ factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
267+
268+ require.NoError(t, d.splitPane(sess, nil, layout.Right))
269+ tb := sess.activeTab()
270+ tb.mu.Lock()
271+ tb.tree.Focus = "pane-1"
272+ tb.mu.Unlock()
273+ oldPTY.EXPECT().Close().Return(nil).Once()
274+
275+ require.NoError(t, d.closePane(sess, tb, "pane-1", nil, false))
276+ require.Equal(t, layout.PaneID("pane-2"), tb.focusedPane().id)
277+
278+ ac := &attachedClient{}
279+ ac.setSession(sess)
280+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
281+ require.Equal(t, []byte("Z"), <-writes)
282+ tb.focusedPane().screen.Write([]byte("survivor"))
283+ frame, _ := composeClientFrame(sess, tb, false, "")
284+ require.Contains(t, frameRowString(frame, 1), "survivor")
285+}
286+
246287 func blockingRead(t *testing.T) func([]byte) (int, error) {
247288 t.Helper()
248289 ch := make(chan struct{})
@@ -72,7 +72,7 @@ func TestSplitPaneOpenErrorRollsBackTreeAndPaneMap(t *testing.T) {
72 defer tb.mu.Unlock()72 defer tb.mu.Unlock()
73 require.Equal(t, beforeRoot, tb.tree.Root.Leaf)73 require.Equal(t, beforeRoot, tb.tree.Root.Leaf)
74 require.Equal(t, beforeFocus, tb.tree.Focus)74 require.Equal(t, beforeFocus, tb.tree.Focus)
75- require.Equal(t, beforeNext, tb.nextPaneID)75+ require.Equal(t, beforeNext+1, tb.nextPaneID)
76 require.Len(t, tb.panes, beforePaneCount)76 require.Len(t, tb.panes, beforePaneCount)
77 require.Nil(t, tb.panes["pane-2"])77 require.Nil(t, tb.panes["pane-2"])
78 }78 }
@@ -173,6 +173,19 @@ func TestReapPaneSharesClosePathAndIsIdempotent(t *testing.T) {
173 require.NotContains(t, tb.panes, layout.PaneID("pane-2"))173 require.NotContains(t, tb.panes, layout.PaneID("pane-2"))
174 }174 }
175 175
176+func TestClosePaneKeepsTabOpenWhenPendingSpawnLeafExists(t *testing.T) {
177+ d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
178+ tb := sess.activeTab()
179+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
180+ oldPTY.EXPECT().Close().Return(nil).Once()
181+
182+ require.NoError(t, d.closePane(sess, tb, "pane-1", nil, false))
183+
184+ require.Len(t, sess.tabs, 1)
185+ require.Empty(t, tb.panes)
186+ require.True(t, layout.ContainsLeaf(tb.tree.Root, "pane-2"))
187+}
188+
176 func TestCloseFocusedLastPaneDelegatesToCloseTab(t *testing.T) {189 func TestCloseFocusedLastPaneDelegatesToCloseTab(t *testing.T) {
177 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})190 d, sess, oldPTY, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
178 otherPTY := portsmocks.NewMockPTY(t)191 otherPTY := portsmocks.NewMockPTY(t)
@@ -243,6 +256,34 @@ func newSplitTestDaemon(t *testing.T, sz domain.Size) (*Daemon, *session, *ports
243 return d, sess, oldPTY, factory256 return d, sess, oldPTY, factory
244 }257 }
245 258
259+func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
260+ writes := make(chan []byte, 1)
261+ d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
262+ newPTY, releaseNew := newBlockingPTYWithWrites(t, writes)
263+ defer releaseNew()
264+ oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 10}).Return(nil).Once()
265+ newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
266+ factory.EXPECT().Open("/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 10}).Return(newPTY, nil).Once()
267+
268+ require.NoError(t, d.splitPane(sess, nil, layout.Right))
269+ tb := sess.activeTab()
270+ tb.mu.Lock()
271+ tb.tree.Focus = "pane-1"
272+ tb.mu.Unlock()
273+ oldPTY.EXPECT().Close().Return(nil).Once()
274+
275+ require.NoError(t, d.closePane(sess, tb, "pane-1", nil, false))
276+ require.Equal(t, layout.PaneID("pane-2"), tb.focusedPane().id)
277+
278+ ac := &attachedClient{}
279+ ac.setSession(sess)
280+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
281+ require.Equal(t, []byte("Z"), <-writes)
282+ tb.focusedPane().screen.Write([]byte("survivor"))
283+ frame, _ := composeClientFrame(sess, tb, false, "")
284+ require.Contains(t, frameRowString(frame, 1), "survivor")
285+}
286+
246 func blockingRead(t *testing.T) func([]byte) (int, error) {287 func blockingRead(t *testing.T) func([]byte) (int, error) {
247 t.Helper()288 t.Helper()
248 ch := make(chan struct{})289 ch := make(chan struct{})
M internal/usecase/daemon/pane_titles.go +25 -10
@@ -3,11 +3,22 @@ package daemon
33 import (
44 "path/filepath"
55 "strings"
6+ "time"
67
78 "github.com/bnema/vev/internal/usecase/layout"
89 )
910
11+const paneTitleCacheTTL = time.Second
12+
1013 func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
14+ return d.refreshPaneTitleCached(sess, id, false)
15+}
16+
17+func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID) string {
18+ return d.refreshPaneTitleCached(sess, id, true)
19+}
20+
21+func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string {
1122 fallback := filepath.Base(d.shell)
1223 if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {
1324 fallback = d.shell
@@ -25,9 +36,20 @@ func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
2536 if p == nil {
2637 return fallback
2738 }
39+ now := d.clock.Now()
40+ p.mu.Lock()
41+ if !force && p.titleValid && now.Sub(p.titleAt) < paneTitleCacheTTL {
42+ title := p.title
43+ p.mu.Unlock()
44+ if title == "" {
45+ return fallback
46+ }
47+ return title
48+ }
49+ p.mu.Unlock()
2850 title := fallback
2951 if d.procComm != nil && p.pty != nil {
30- if pgid, err := foregroundPgid(p.pty); err == nil && pgid > 0 {
52+ if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {
3153 if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {
3254 title = strings.TrimSpace(comm)
3355 }
@@ -35,15 +57,8 @@ func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
3557 }
3658 p.mu.Lock()
3759 p.title = title
60+ p.titleAt = now
61+ p.titleValid = true
3862 p.mu.Unlock()
3963 return title
4064 }
41-
42-func foregroundPgid(pty interface{ ForegroundPgid() (int, error) }) (pgid int, err error) {
43- defer func() {
44- if recover() != nil {
45- pgid = 0
46- }
47- }()
48- return pty.ForegroundPgid()
49-}
@@ -3,11 +3,22 @@ package daemon
3 import (3 import (
4 "path/filepath"4 "path/filepath"
5 "strings"5 "strings"
6+ "time"
6 7
7 "github.com/bnema/vev/internal/usecase/layout"8 "github.com/bnema/vev/internal/usecase/layout"
8 )9 )
9 10
11+const paneTitleCacheTTL = time.Second
12+
10 func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {13 func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
14+ return d.refreshPaneTitleCached(sess, id, false)
15+}
16+
17+func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID) string {
18+ return d.refreshPaneTitleCached(sess, id, true)
19+}
20+
21+func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool) string {
11 fallback := filepath.Base(d.shell)22 fallback := filepath.Base(d.shell)
12 if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {23 if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {
13 fallback = d.shell24 fallback = d.shell
@@ -25,9 +36,20 @@ func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
25 if p == nil {36 if p == nil {
26 return fallback37 return fallback
27 }38 }
39+ now := d.clock.Now()
40+ p.mu.Lock()
41+ if !force && p.titleValid && now.Sub(p.titleAt) < paneTitleCacheTTL {
42+ title := p.title
43+ p.mu.Unlock()
44+ if title == "" {
45+ return fallback
46+ }
47+ return title
48+ }
49+ p.mu.Unlock()
28 title := fallback50 title := fallback
29 if d.procComm != nil && p.pty != nil {51 if d.procComm != nil && p.pty != nil {
30- if pgid, err := foregroundPgid(p.pty); err == nil && pgid > 0 {52+ if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {
31 if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {53 if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" {
32 title = strings.TrimSpace(comm)54 title = strings.TrimSpace(comm)
33 }55 }
@@ -35,15 +57,8 @@ func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID) string {
35 }57 }
36 p.mu.Lock()58 p.mu.Lock()
37 p.title = title59 p.title = title
60+ p.titleAt = now
61+ p.titleValid = true
38 p.mu.Unlock()62 p.mu.Unlock()
39 return title63 return title
40 }64 }
41-
42-func foregroundPgid(pty interface{ ForegroundPgid() (int, error) }) (pgid int, err error) {
43- defer func() {
44- if recover() != nil {
45- pgid = 0
46- }
47- }()
48- return pty.ForegroundPgid()
49-}
M internal/usecase/daemon/pane_titles_test.go +25 -0
@@ -2,7 +2,9 @@ package daemon
22
33 import (
44 "errors"
5+ "sync/atomic"
56 "testing"
7+ "time"
68
79 "github.com/stretchr/testify/require"
810
@@ -26,6 +28,29 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
2628 require.Equal(t, "vim", sess.activeTab().focusedPane().title)
2729 }
2830
31+func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
32+ pty := portsmocks.NewMockPTY(t)
33+ pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()
34+ _, sess, _, _ := newManualSessionWithPTYs(t, pty)
35+ clk := portsmocks.NewMockClock(t)
36+ now := time.Unix(10, 0)
37+ clk.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe()
38+ d := newTestDaemon(t, nil, clk)
39+ var calls atomic.Int32
40+ d.procComm = func(pid int) (string, error) {
41+ require.Equal(t, 1234, pid)
42+ calls.Add(1)
43+ return "vim\n", nil
44+ }
45+
46+ require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1"))
47+ now = now.Add(paneTitleCacheTTL / 2)
48+ require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1"))
49+ require.Equal(t, int32(1), calls.Load(), "paint refresh inside TTL should use cached title")
50+ require.Equal(t, "vim", d.refreshPaneTitleOnFocus(sess, "pane-1"))
51+ require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL")
52+}
53+
2954 func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) {
3055 pty := portsmocks.NewMockPTY(t)
3156 pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Once()
@@ -2,7 +2,9 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "sync/atomic"
5 "testing"6 "testing"
7+ "time"
6 8
7 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
8 10
@@ -26,6 +28,29 @@ func TestRefreshPaneTitleUsesForegroundProcessComm(t *testing.T) {
26 require.Equal(t, "vim", sess.activeTab().focusedPane().title)28 require.Equal(t, "vim", sess.activeTab().focusedPane().title)
27 }29 }
28 30
31+func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
32+ pty := portsmocks.NewMockPTY(t)
33+ pty.EXPECT().ForegroundPgid().Return(1234, nil).Twice()
34+ _, sess, _, _ := newManualSessionWithPTYs(t, pty)
35+ clk := portsmocks.NewMockClock(t)
36+ now := time.Unix(10, 0)
37+ clk.EXPECT().Now().RunAndReturn(func() time.Time { return now }).Maybe()
38+ d := newTestDaemon(t, nil, clk)
39+ var calls atomic.Int32
40+ d.procComm = func(pid int) (string, error) {
41+ require.Equal(t, 1234, pid)
42+ calls.Add(1)
43+ return "vim\n", nil
44+ }
45+
46+ require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1"))
47+ now = now.Add(paneTitleCacheTTL / 2)
48+ require.Equal(t, "vim", d.refreshPaneTitle(sess, "pane-1"))
49+ require.Equal(t, int32(1), calls.Load(), "paint refresh inside TTL should use cached title")
50+ require.Equal(t, "vim", d.refreshPaneTitleOnFocus(sess, "pane-1"))
51+ require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL")
52+}
53+
29 func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) {54 func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) {
30 pty := portsmocks.NewMockPTY(t)55 pty := portsmocks.NewMockPTY(t)
31 pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Once()56 pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Once()
M internal/usecase/daemon/picker_test.go +18 -10
@@ -12,11 +12,19 @@ import (
1212 "github.com/bnema/vev/internal/ports"
1313 "github.com/bnema/vev/internal/usecase/keys"
1414 "github.com/bnema/vev/pkg/renderer"
15- "github.com/bnema/vev/pkg/vt"
1615 )
1716
1817 // --- test doubles -----------------------------------------------------------
1918
19+func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
20+ tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
21+ tb.ctx, tb.cancel = ctx, cancel
22+ for _, pane := range tb.panes {
23+ pane.ctx, pane.cancel = ctx, cancel
24+ }
25+ return tb
26+}
27+
2028 // stubClock returns timers whose channel never fires, so a scheduler under it
2129 // blocks in its debounce loop until the session context is cancelled. Used by
2230
@@ -144,8 +152,8 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
144152 sctx2, cancel2 := context.WithCancel(d.serveCtx)
145153 defer cancel1()
146154 defer cancel2()
147- sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
148- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
155+ sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
156+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
149157 ac1.setSession(sess1)
150158 ac2.setSession(sess2)
151159 ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
@@ -185,9 +193,9 @@ func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
185193
186194 previewTab := sess.tabs[1]
187195 previewTab.mu.Lock()
188- previewTab.screen.Write([]byte("inactive-preview-live"))
196+ previewTab.focusedPane().screen.Write([]byte("inactive-preview-live"))
189197 previewTab.mu.Unlock()
190- d.render(sess, previewTab)
198+ d.render(sess, previewTab, previewTab.focusedPane())
191199
192200 previewOut := awaitFrame(t, sends, ports.MsgOutput)
193201 previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
@@ -209,8 +217,8 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
209217 sctx2, cancel2 := context.WithCancel(d.serveCtx)
210218 defer cancel1()
211219 defer cancel2()
212- sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
213- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
220+ sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
221+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
214222 ac1.setSession(sess1)
215223 ac2.setSession(sess2)
216224 ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
@@ -225,9 +233,9 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
225233
226234 previewTab := sess2.tabs[0]
227235 previewTab.mu.Lock()
228- previewTab.screen.Write([]byte("cross-session-preview-live"))
236+ previewTab.focusedPane().screen.Write([]byte("cross-session-preview-live"))
229237 previewTab.mu.Unlock()
230- d.render(sess2, previewTab)
238+ d.render(sess2, previewTab, previewTab.focusedPane())
231239
232240 previewOut := awaitFrame(t, sends1, ports.MsgOutput)
233241 previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
@@ -277,7 +285,7 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
277285 p.screen.Write([]byte("render-race"))
278286 p.mu.Unlock()
279287 }
280- d.render(sess, tb)
288+ d.render(sess, tb, tb.focusedPane())
281289 })
282290 }
283291 wg.Wait()
@@ -12,11 +12,19 @@ import (
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/internal/usecase/keys"13 "github.com/bnema/vev/internal/usecase/keys"
14 "github.com/bnema/vev/pkg/renderer"14 "github.com/bnema/vev/pkg/renderer"
15- "github.com/bnema/vev/pkg/vt"
16 )15 )
17 16
18 // --- test doubles -----------------------------------------------------------17 // --- test doubles -----------------------------------------------------------
19 18
19+func newTestTabWithContext(p ports.PTY, ctx context.Context, cancel context.CancelFunc) *tab {
20+ tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
21+ tb.ctx, tb.cancel = ctx, cancel
22+ for _, pane := range tb.panes {
23+ pane.ctx, pane.cancel = ctx, cancel
24+ }
25+ return tb
26+}
27+
20 // stubClock returns timers whose channel never fires, so a scheduler under it28 // stubClock returns timers whose channel never fires, so a scheduler under it
21 // blocks in its debounce loop until the session context is cancelled. Used by29 // blocks in its debounce loop until the session context is cancelled. Used by
22 30
@@ -144,8 +152,8 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
144 sctx2, cancel2 := context.WithCancel(d.serveCtx)152 sctx2, cancel2 := context.WithCancel(d.serveCtx)
145 defer cancel1()153 defer cancel1()
146 defer cancel2()154 defer cancel2()
147- sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}155+ sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
148- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}156+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
149 ac1.setSession(sess1)157 ac1.setSession(sess1)
150 ac2.setSession(sess2)158 ac2.setSession(sess2)
151 ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})159 ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
@@ -185,9 +193,9 @@ func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
185 193
186 previewTab := sess.tabs[1]194 previewTab := sess.tabs[1]
187 previewTab.mu.Lock()195 previewTab.mu.Lock()
188- previewTab.screen.Write([]byte("inactive-preview-live"))196+ previewTab.focusedPane().screen.Write([]byte("inactive-preview-live"))
189 previewTab.mu.Unlock()197 previewTab.mu.Unlock()
190- d.render(sess, previewTab)198+ d.render(sess, previewTab, previewTab.focusedPane())
191 199
192 previewOut := awaitFrame(t, sends, ports.MsgOutput)200 previewOut := awaitFrame(t, sends, ports.MsgOutput)
193 previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)201 previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
@@ -209,8 +217,8 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
209 sctx2, cancel2 := context.WithCancel(d.serveCtx)217 sctx2, cancel2 := context.WithCancel(d.serveCtx)
210 defer cancel1()218 defer cancel1()
211 defer cancel2()219 defer cancel2()
212- sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}220+ sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{newTestTabWithContext(p1, sctx1, cancel1)}, client: ac1}
213- sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}221+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{newTestTabWithContext(p2, sctx2, cancel2)}, client: ac2}
214 ac1.setSession(sess1)222 ac1.setSession(sess1)
215 ac2.setSession(sess2)223 ac2.setSession(sess2)
216 ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})224 ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
@@ -225,9 +233,9 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
225 233
226 previewTab := sess2.tabs[0]234 previewTab := sess2.tabs[0]
227 previewTab.mu.Lock()235 previewTab.mu.Lock()
228- previewTab.screen.Write([]byte("cross-session-preview-live"))236+ previewTab.focusedPane().screen.Write([]byte("cross-session-preview-live"))
229 previewTab.mu.Unlock()237 previewTab.mu.Unlock()
230- d.render(sess2, previewTab)238+ d.render(sess2, previewTab, previewTab.focusedPane())
231 239
232 previewOut := awaitFrame(t, sends1, ports.MsgOutput)240 previewOut := awaitFrame(t, sends1, ports.MsgOutput)
233 previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)241 previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
@@ -277,7 +285,7 @@ func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
277 p.screen.Write([]byte("render-race"))285 p.screen.Write([]byte("render-race"))
278 p.mu.Unlock()286 p.mu.Unlock()
279 }287 }
280- d.render(sess, tb)288+ d.render(sess, tb, tb.focusedPane())
281 })289 })
282 }290 }
283 wg.Wait()291 wg.Wait()
M internal/usecase/daemon/prompt_test.go +1 -3
@@ -4,9 +4,7 @@ import (
44 "context"
55 "testing"
66
7- "github.com/bnema/vev/internal/domain"
87 "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/pkg/vt"
108 "github.com/stretchr/testify/require"
119 )
1210
@@ -53,7 +51,7 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
5351 defer release2()
5452 ctx, cancel := context.WithCancel(context.Background())
5553 defer cancel()
56- d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: ctx, cancel: cancel}}}
54+ d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}
5755
5856 d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
5957 awaitFrame(t, sends, ports.MsgOutput)
@@ -4,9 +4,7 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7- "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/pkg/vt"
10 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
11 )9 )
12 10
@@ -53,7 +51,7 @@ func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
53 defer release2()51 defer release2()
54 ctx, cancel := context.WithCancel(context.Background())52 ctx, cancel := context.WithCancel(context.Background())
55 defer cancel()53 defer cancel()
56- d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: ctx, cancel: cancel}}}54+ d.sessions["other"] = &session{id: "other", name: "taken", ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}
57 55
58 d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })56 d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
59 awaitFrame(t, sends, ports.MsgOutput)57 awaitFrame(t, sends, ports.MsgOutput)
M internal/usecase/daemon/render.go +32 -28
@@ -45,14 +45,8 @@ func signal(ch chan struct{}) {
4545 }
4646 }
4747
48-func (d *Daemon) ptyReader(sess *session, tb *tab, panes ...*pane) {
48+func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
4949 defer d.sessWg.Done()
50- var p *pane
51- if len(panes) > 0 {
52- p = panes[0]
53- } else {
54- p = tb.focusedPane()
55- }
5650 if p == nil {
5751 return
5852 }
@@ -112,14 +106,8 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, panes ...*pane) {
112106 // scheduler debounces dirty signals. The first dirty opens a short tab;
113107 // sustained floods progressively widen that tab, while isolated updates
114108 // return to the minimum delay for interactive latency.
115-func (d *Daemon) scheduler(sess *session, tb *tab, panes ...*pane) {
109+func (d *Daemon) scheduler(sess *session, tb *tab, p *pane) {
116110 defer d.sessWg.Done()
117- var p *pane
118- if len(panes) > 0 {
119- p = panes[0]
120- } else {
121- p = tb.focusedPane()
122- }
123111 if p == nil {
124112 return
125113 }
@@ -133,7 +121,7 @@ outer:
133121 case <-paneDone(p):
134122 return
135123 case <-p.flush:
136- d.render(sess, tb)
124+ d.render(sess, tb, p)
137125 lastRender = d.clock.Now()
138126 continue
139127 case <-p.dirty:
@@ -160,7 +148,7 @@ outer:
160148 default:
161149 }
162150 }
163- d.render(sess, tb)
151+ d.render(sess, tb, p)
164152 lastRender = d.clock.Now()
165153 continue outer
166154 case <-p.dirty:
@@ -170,7 +158,7 @@ outer:
170158 }
171159 }
172160 delay = nextDebounceDelay(delay, coalesced)
173- d.render(sess, tb)
161+ d.render(sess, tb, p)
174162 lastRender = d.clock.Now()
175163 }
176164 }
@@ -214,9 +202,8 @@ func syncUpdateEndIn(data []byte) bool {
214202
215203 // render paints the current client, or (when detached) just clears accumulated
216204 // damage so it never grows unbounded while headless.
217-func (d *Daemon) render(sess *session, tb *tab) {
205+func (d *Daemon) render(sess *session, tb *tab, p *pane) {
218206 tb.mu.Lock()
219- p := tb.focusedPane()
220207 previewer := tb.previewClient
221208 tb.mu.Unlock()
222209 if p == nil {
@@ -470,7 +457,16 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b
470457 }
471458 p := tb.focusedPane()
472459 styles := newThemeStyles(bars.theme)
460+ if p == nil {
461+ width, screenRows := tb.size.Cols, tb.size.Rows
462+ if width <= 0 || screenRows <= 0 {
463+ return renderer.NewFrame(0, 0), nil
464+ }
465+ return renderer.NewFrame(width, screenRows+2), nil
466+ }
467+ p.mu.Lock()
473468 width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
469+ p.mu.Unlock()
474470 if tb.size.Valid() {
475471 width, screenRows = tb.size.Cols, tb.size.Rows
476472 }
@@ -504,10 +500,7 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b
504500
505501 func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
506502 frame := renderer.NewFrame(area.Width, area.Height)
507- var root *layout.Node
508- if tb.tree != nil {
509- root = tb.tree.Root
510- }
503+ root := tb.tree.Root
511504 placements, ok := layout.Solve(root, area)
512505 var fallback *pane
513506 if !ok {
@@ -521,16 +514,13 @@ func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.F
521514 var damage []renderer.Damage
522515 for _, pl := range placements {
523516 p := tb.panes[pl.ID]
524- if len(tb.panes) == 1 && tb.tree != nil && tb.tree.Focus == pl.ID {
525- p = tb.focusedPane()
526- }
527517 if p == nil && fallback != nil && pl.ID == fallback.id {
528518 p = fallback
529519 }
530520 if p == nil {
531521 continue
532522 }
533- focused := tb.tree == nil || tb.tree.Focus == pl.ID
523+ focused := tb.tree.Focus == pl.ID
534524 if pl.TitleBar.Height > 0 {
535525 drawPaneTitleBar(frame, pl, p, focused, theme, "")
536526 }
@@ -540,7 +530,7 @@ func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.F
540530 p.mu.Lock()
541531 blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
542532 for _, d := range p.screen.Damage() {
543- damage = append(damage, translateDamage([]renderer.Damage{d}, pl.Content.X, pl.Content.Y)...)
533+ damage = append(damage, translatePaneDamage(d, pl.Content, area)...)
544534 }
545535 p.mu.Unlock()
546536 }
@@ -572,7 +562,9 @@ func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focuse
572562 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
573563 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
574564 }
565+ p.mu.Lock()
575566 title := p.title
567+ p.mu.Unlock()
576568 if title == "" {
577569 title = fallback
578570 }
@@ -668,3 +660,15 @@ func translateDamage(in []renderer.Damage, dx, dy int) []renderer.Damage {
668660 }
669661 return out
670662 }
663+
664+func translatePaneDamage(d renderer.Damage, content domain.Rect, area domain.Rect) []renderer.Damage {
665+ if d.Kind == renderer.DamageFullRedraw {
666+ return []renderer.Damage{d}
667+ }
668+ if d.Kind == renderer.DamageScrollUp && (content.X != 0 || content.Width != area.Width) {
669+ return []renderer.Damage{{Kind: renderer.DamageText, X: content.X, Y: content.Y, Width: content.Width, Height: content.Height}}
670+ }
671+ d.X += content.X
672+ d.Y += content.Y
673+ return []renderer.Damage{d}
674+}
@@ -45,14 +45,8 @@ func signal(ch chan struct{}) {
45 }45 }
46 }46 }
47 47
48-func (d *Daemon) ptyReader(sess *session, tb *tab, panes ...*pane) {48+func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
49 defer d.sessWg.Done()49 defer d.sessWg.Done()
50- var p *pane
51- if len(panes) > 0 {
52- p = panes[0]
53- } else {
54- p = tb.focusedPane()
55- }
56 if p == nil {50 if p == nil {
57 return51 return
58 }52 }
@@ -112,14 +106,8 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, panes ...*pane) {
112 // scheduler debounces dirty signals. The first dirty opens a short tab;106 // scheduler debounces dirty signals. The first dirty opens a short tab;
113 // sustained floods progressively widen that tab, while isolated updates107 // sustained floods progressively widen that tab, while isolated updates
114 // return to the minimum delay for interactive latency.108 // return to the minimum delay for interactive latency.
115-func (d *Daemon) scheduler(sess *session, tb *tab, panes ...*pane) {109+func (d *Daemon) scheduler(sess *session, tb *tab, p *pane) {
116 defer d.sessWg.Done()110 defer d.sessWg.Done()
117- var p *pane
118- if len(panes) > 0 {
119- p = panes[0]
120- } else {
121- p = tb.focusedPane()
122- }
123 if p == nil {111 if p == nil {
124 return112 return
125 }113 }
@@ -133,7 +121,7 @@ outer:
133 case <-paneDone(p):121 case <-paneDone(p):
134 return122 return
135 case <-p.flush:123 case <-p.flush:
136- d.render(sess, tb)124+ d.render(sess, tb, p)
137 lastRender = d.clock.Now()125 lastRender = d.clock.Now()
138 continue126 continue
139 case <-p.dirty:127 case <-p.dirty:
@@ -160,7 +148,7 @@ outer:
160 default:148 default:
161 }149 }
162 }150 }
163- d.render(sess, tb)151+ d.render(sess, tb, p)
164 lastRender = d.clock.Now()152 lastRender = d.clock.Now()
165 continue outer153 continue outer
166 case <-p.dirty:154 case <-p.dirty:
@@ -170,7 +158,7 @@ outer:
170 }158 }
171 }159 }
172 delay = nextDebounceDelay(delay, coalesced)160 delay = nextDebounceDelay(delay, coalesced)
173- d.render(sess, tb)161+ d.render(sess, tb, p)
174 lastRender = d.clock.Now()162 lastRender = d.clock.Now()
175 }163 }
176 }164 }
@@ -214,9 +202,8 @@ func syncUpdateEndIn(data []byte) bool {
214 202
215 // render paints the current client, or (when detached) just clears accumulated203 // render paints the current client, or (when detached) just clears accumulated
216 // damage so it never grows unbounded while headless.204 // damage so it never grows unbounded while headless.
217-func (d *Daemon) render(sess *session, tb *tab) {205+func (d *Daemon) render(sess *session, tb *tab, p *pane) {
218 tb.mu.Lock()206 tb.mu.Lock()
219- p := tb.focusedPane()
220 previewer := tb.previewClient207 previewer := tb.previewClient
221 tb.mu.Unlock()208 tb.mu.Unlock()
222 if p == nil {209 if p == nil {
@@ -470,7 +457,16 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b
470 }457 }
471 p := tb.focusedPane()458 p := tb.focusedPane()
472 styles := newThemeStyles(bars.theme)459 styles := newThemeStyles(bars.theme)
460+ if p == nil {
461+ width, screenRows := tb.size.Cols, tb.size.Rows
462+ if width <= 0 || screenRows <= 0 {
463+ return renderer.NewFrame(0, 0), nil
464+ }
465+ return renderer.NewFrame(width, screenRows+2), nil
466+ }
467+ p.mu.Lock()
473 width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height468 width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
469+ p.mu.Unlock()
474 if tb.size.Valid() {470 if tb.size.Valid() {
475 width, screenRows = tb.size.Cols, tb.size.Rows471 width, screenRows = tb.size.Cols, tb.size.Rows
476 }472 }
@@ -504,10 +500,7 @@ func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*b
504 500
505 func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {501 func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
506 frame := renderer.NewFrame(area.Width, area.Height)502 frame := renderer.NewFrame(area.Width, area.Height)
507- var root *layout.Node503+ root := tb.tree.Root
508- if tb.tree != nil {
509- root = tb.tree.Root
510- }
511 placements, ok := layout.Solve(root, area)504 placements, ok := layout.Solve(root, area)
512 var fallback *pane505 var fallback *pane
513 if !ok {506 if !ok {
@@ -521,16 +514,13 @@ func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.F
521 var damage []renderer.Damage514 var damage []renderer.Damage
522 for _, pl := range placements {515 for _, pl := range placements {
523 p := tb.panes[pl.ID]516 p := tb.panes[pl.ID]
524- if len(tb.panes) == 1 && tb.tree != nil && tb.tree.Focus == pl.ID {
525- p = tb.focusedPane()
526- }
527 if p == nil && fallback != nil && pl.ID == fallback.id {517 if p == nil && fallback != nil && pl.ID == fallback.id {
528 p = fallback518 p = fallback
529 }519 }
530 if p == nil {520 if p == nil {
531 continue521 continue
532 }522 }
533- focused := tb.tree == nil || tb.tree.Focus == pl.ID523+ focused := tb.tree.Focus == pl.ID
534 if pl.TitleBar.Height > 0 {524 if pl.TitleBar.Height > 0 {
535 drawPaneTitleBar(frame, pl, p, focused, theme, "")525 drawPaneTitleBar(frame, pl, p, focused, theme, "")
536 }526 }
@@ -540,7 +530,7 @@ func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.F
540 p.mu.Lock()530 p.mu.Lock()
541 blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)531 blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
542 for _, d := range p.screen.Damage() {532 for _, d := range p.screen.Damage() {
543- damage = append(damage, translateDamage([]renderer.Damage{d}, pl.Content.X, pl.Content.Y)...)533+ damage = append(damage, translatePaneDamage(d, pl.Content, area)...)
544 }534 }
545 p.mu.Unlock()535 p.mu.Unlock()
546 }536 }
@@ -572,7 +562,9 @@ func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focuse
572 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {562 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
573 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})563 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
574 }564 }
565+ p.mu.Lock()
575 title := p.title566 title := p.title
567+ p.mu.Unlock()
576 if title == "" {568 if title == "" {
577 title = fallback569 title = fallback
578 }570 }
@@ -668,3 +660,15 @@ func translateDamage(in []renderer.Damage, dx, dy int) []renderer.Damage {
668 }660 }
669 return out661 return out
670 }662 }
663+
664+func translatePaneDamage(d renderer.Damage, content domain.Rect, area domain.Rect) []renderer.Damage {
665+ if d.Kind == renderer.DamageFullRedraw {
666+ return []renderer.Damage{d}
667+ }
668+ if d.Kind == renderer.DamageScrollUp && (content.X != 0 || content.Width != area.Width) {
669+ return []renderer.Damage{{Kind: renderer.DamageText, X: content.X, Y: content.Y, Width: content.Width, Height: content.Height}}
670+ }
671+ d.X += content.X
672+ d.Y += content.Y
673+ return []renderer.Damage{d}
674+}
M internal/usecase/daemon/render_test.go +110 -63
@@ -20,7 +20,6 @@ import (
2020 "github.com/bnema/vev/internal/usecase/layout"
2121 themeui "github.com/bnema/vev/internal/usecase/theme"
2222 "github.com/bnema/vev/pkg/renderer"
23- "github.com/bnema/vev/pkg/vt"
2423 )
2524
2625 // --- test doubles -----------------------------------------------------------
@@ -34,7 +33,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
3433 errBoom := errors.New("boom")
3534 p := portsmocks.NewMockPTY(t)
3635 p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
37- win := &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}
36+ win := newTab(p, domain.Size{Cols: 80, Rows: 23})
3837 sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
3938 ac := &attachedClient{}
4039 ac.setSession(sess)
@@ -57,8 +56,8 @@ func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
5756 defer releasePTY2()
5857
5958 d.sessWg.Add(1)
60- go d.scheduler(sess, sess.tabs[1])
61- sess.tabs[1].dirty <- struct{}{}
59+ go d.scheduler(sess, sess.tabs[1], sess.tabs[1].focusedPane())
60+ sess.tabs[1].focusedPane().dirty <- struct{}{}
6261 <-clk.called
6362
6463 d.handleInput(sess, ac, []byte("\x1b2"))
@@ -83,9 +82,9 @@ func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) {
8382 defer releasePTY2()
8483
8584 d.sessWg.Add(2)
86- go d.scheduler(sess, sess.tabs[1])
87- go d.ptyReader(sess, sess.tabs[1])
88- sess.tabs[1].dirty <- struct{}{}
85+ go d.scheduler(sess, sess.tabs[1], sess.tabs[1].focusedPane())
86+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
87+ sess.tabs[1].focusedPane().dirty <- struct{}{}
8988 <-clk.called
9089 releasePTY2()
9190
@@ -117,10 +116,10 @@ func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
117116 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
118117 defer releasePTY2()
119118 sess.active = 0
120- sess.tabs[1].screen.Write([]byte("remaining"))
119+ sess.tabs[1].focusedPane().screen.Write([]byte("remaining"))
121120
122121 d.sessWg.Add(1)
123- go d.ptyReader(sess, sess.tabs[0])
122+ go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
124123 releasePTY1()
125124
126125 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
@@ -143,10 +142,10 @@ func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
143142 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
144143 defer releasePTY1()
145144 sess.active = 0
146- sess.tabs[0].screen.Write([]byte("active"))
145+ sess.tabs[0].focusedPane().screen.Write([]byte("active"))
147146
148147 d.sessWg.Add(1)
149- go d.ptyReader(sess, sess.tabs[1])
148+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
150149 releasePTY2()
151150
152151 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
@@ -167,7 +166,7 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
167166 d, sess, _, sends, releases := newManualTabSession(t, 1)
168167
169168 d.sessWg.Add(1)
170- go d.ptyReader(sess, sess.tabs[0])
169+ go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
171170 releases[0]()
172171
173172 require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)
@@ -213,26 +212,26 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
213212
214213 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
215214 sctx, cancel := context.WithCancel(context.Background())
216- win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
215+ win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 5})
217216 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
218217 sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
219218 ac.setSession(sess)
220219
221220 win.mu.Lock()
222- win.screen.Write([]byte("hi"))
221+ win.focusedPane().screen.Write([]byte("hi"))
223222 win.mu.Unlock()
224223
225224 d.sessWg.Add(1)
226- go d.scheduler(sess, win)
225+ go d.scheduler(sess, win, win.focusedPane())
227226
228227 // First dirty opens the debounce window.
229- win.dirty <- struct{}{}
228+ win.focusedPane().dirty <- struct{}{}
230229 <-newTimerCalled
231230
232231 // A burst of further dirties inside the window must be absorbed.
233232 for range 5 {
234233 select {
235- case win.dirty <- struct{}{}:
234+ case win.focusedPane().dirty <- struct{}{}:
236235 default:
237236 }
238237 }
@@ -268,9 +267,9 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
268267
269268 win := newTab(p, domain.Size{Cols: 4, Rows: 4})
270269 for y, text := range []string{"0000", "1111", "2222", "3333"} {
271- copy(win.screen.Frame.Row(y), testRow(text))
270+ copy(win.focusedPane().screen.Frame.Row(y), testRow(text))
272271 }
273- win.screen.Row = 3
272+ win.focusedPane().screen.Row = 3
274273
275274 tr := portsmocks.NewMockTransport(t)
276275 tr.EXPECT().Close().Return(nil).Maybe()
@@ -285,16 +284,16 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
285284 // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1,
286285 // so a client height of 4 (not 3) is what yields the same 2-row tab.
287286 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
288- require.Equal(t, "2222", frameRowString(win.screen.Frame, 0))
289- require.Equal(t, "3333", frameRowString(win.screen.Frame, 1))
290- require.Equal(t, 2, win.scrollback.Len())
291- require.Equal(t, "0000", cellsString(win.scrollback.Row(0)))
292- require.Equal(t, "1111", cellsString(win.scrollback.Row(1)))
287+ require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
288+ require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
289+ require.Equal(t, 2, win.focusedPane().scrollback.Len())
290+ require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.Row(0)))
291+ require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.Row(1)))
293292
294293 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
295- require.Equal(t, "2222 ", frameRowString(win.screen.Frame, 0))
296- require.Equal(t, "3333 ", frameRowString(win.screen.Frame, 1))
297- require.Equal(t, 2, win.scrollback.Len())
294+ require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
295+ require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))
296+ require.Equal(t, 2, win.focusedPane().scrollback.Len())
298297 }
299298
300299 func frameRowString(f renderer.Frame, y int) string {
@@ -336,6 +335,54 @@ func TestTranslateDamageShiftsXYAndPreservesFullRedraw(t *testing.T) {
336335 }, damage)
337336 }
338337
338+func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
339+ tests := []struct {
340+ name string
341+ content domain.Rect
342+ area domain.Rect
343+ in renderer.Damage
344+ want []renderer.Damage
345+ }{
346+ {
347+ name: "half width scroll becomes pane text damage",
348+ content: domain.Rect{X: 21, Y: 0, Width: 20, Height: 4},
349+ area: domain.Rect{Width: 41, Height: 4},
350+ in: renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 20, Height: 4, Count: 1},
351+ want: []renderer.Damage{{Kind: renderer.DamageText, X: 21, Y: 0, Width: 20, Height: 4}},
352+ },
353+ {
354+ name: "full width scroll keeps translated scroll damage",
355+ content: domain.Rect{X: 0, Y: 5, Width: 80, Height: 10},
356+ area: domain.Rect{Width: 80, Height: 24},
357+ in: renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 80, Height: 10, Count: 1},
358+ want: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 5, Width: 80, Height: 10, Count: 1}},
359+ },
360+ }
361+ for _, tt := range tests {
362+ t.Run(tt.name, func(t *testing.T) {
363+ require.Equal(t, tt.want, translatePaneDamage(tt.in, tt.content, tt.area))
364+ })
365+ }
366+}
367+
368+func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) {
369+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
370+ focused := tb.focusedPane()
371+ emitting := newPane("pane-2", nil, domain.Size{Cols: 10, Rows: 3})
372+ tb.panes[emitting.id] = emitting
373+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(focused.id), layout.NewLeaf(emitting.id)}}
374+ tb.tree.Focus = focused.id
375+ sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0}
376+ d := newTestDaemon(t, nil, stubClock{})
377+
378+ focused.screen.Write([]byte("a"))
379+ emitting.screen.Write([]byte("b"))
380+ d.render(sess, tb, emitting)
381+
382+ require.NotEmpty(t, focused.screen.Damage(), "focused pane damage should be untouched by detached render for another pane")
383+ require.Empty(t, emitting.screen.Damage(), "emitting pane damage should be drained")
384+}
385+
339386 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
340387 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
341388 left := win.focusedPane()
@@ -393,15 +440,15 @@ func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {
393440
394441 _, damage := composeClientFrame(sess, win, true, "", &cache)
395442 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
396- win.screen.ClearDamage()
397- win.screen.Write([]byte("x"))
443+ win.focusedPane().screen.ClearDamage()
444+ win.focusedPane().screen.Write([]byte("x"))
398445
399446 _, damage = composeClientFrame(sess, win, false, "", &cache)
400447
401448 require.NotEmpty(t, damage)
402449 for _, d := range damage {
403450 require.NotEqual(t, 0, d.Y, "unchanged top bar should not be damaged")
404- require.NotEqual(t, win.screen.Frame.Height+1, d.Y, "unchanged bottom bar should not be damaged")
451+ require.NotEqual(t, win.focusedPane().screen.Frame.Height+1, d.Y, "unchanged bottom bar should not be damaged")
405452 }
406453 }
407454
@@ -409,12 +456,12 @@ func TestComposeClientFrameBarCacheDamagesChangedBottomOnly(t *testing.T) {
409456 sess, win := newBarCacheTestSession()
410457 var cache barCache
411458 composeClientFrame(sess, win, true, "", &cache)
412- win.screen.ClearDamage()
459+ win.focusedPane().screen.ClearDamage()
413460
414461 sess.name = "renamed"
415462 _, damage := composeClientFrame(sess, win, false, "", &cache)
416463
417- require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: win.screen.Frame.Height + 1, Width: win.screen.Frame.Width, Height: 1}}, damage)
464+ require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: win.focusedPane().screen.Frame.Height + 1, Width: win.focusedPane().screen.Frame.Width, Height: 1}}, damage)
418465 }
419466
420467 func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {
@@ -425,14 +472,14 @@ func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {
425472
426473 _, damage := composeClientFrame(sess, win, true, "", &cache)
427474 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
428- win.screen.ClearDamage()
475+ win.focusedPane().screen.ClearDamage()
429476
430477 _, damage = composeClientFrame(sess, win, false, "", &cache)
431478 require.Empty(t, damage)
432479 }
433480
434481 func newBarCacheTestSession() (*session, *tab) {
435- win := &tab{screen: vt.NewScreen(20, 3), size: domain.Size{Cols: 20, Rows: 3}}
482+ win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 3})
436483 sess := &session{id: "s", name: "work", tabs: []*tab{win}}
437484 return sess, win
438485 }
@@ -442,13 +489,13 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
442489
443490 p := portsmocks.NewMockPTY(t)
444491 var screenWidthAtResize int
445- win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
492+ win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24})
446493 p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 28}).RunAndReturn(func(sz domain.Size) error {
447494 // The screen must not yet be resized when the PTY is: proves order.
448- screenWidthAtResize = win.screen.Frame.Width
495+ screenWidthAtResize = win.focusedPane().screen.Frame.Width
449496 return nil
450497 }).Once()
451- win.pty = p
498+ win.focusedPane().pty = p
452499
453500 var gotOutput atomic.Bool
454501 tr := portsmocks.NewMockTransport(t)
@@ -468,8 +515,8 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
468515 d.resize(sess, ac, newSize)
469516
470517 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
471- require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")
472- require.Equal(t, 28, win.screen.Frame.Height, "screen reserves top and bottom chrome rows")
518+ require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")
519+ require.Equal(t, 28, win.focusedPane().screen.Frame.Height, "screen reserves top and bottom chrome rows")
473520 require.True(t, gotOutput.Load(), "resize forces a full redraw output")
474521 }
475522
@@ -484,7 +531,7 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
484531 tr.EXPECT().Close().Return(nil).Maybe()
485532
486533 d := newTestDaemon(t, nil, stubClock{})
487- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
534+ win := newTab(p, domain.Size{Cols: 20, Rows: 5})
488535 sctx, cancel := context.WithCancel(context.Background())
489536 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
490537 sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
@@ -492,7 +539,7 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
492539 d.sessions[sess.id] = sess
493540
494541 win.mu.Lock()
495- win.screen.Write([]byte("x"))
542+ win.focusedPane().screen.Write([]byte("x"))
496543 win.mu.Unlock()
497544
498545 d.paint(sess, ac, true) // send fails -> detach -> ephemeral killed
@@ -590,13 +637,13 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
590637
591638 tr, sends := newCapturingTransport(t)
592639 sctx, cancel := context.WithCancel(context.Background())
593- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
640+ win := newTestTabWithContext(p, sctx, cancel)
594641 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
595642 sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
596643 ac.setSession(sess)
597644 d.sessions[sess.id] = sess
598645 d.sessWg.Add(1)
599- d.ptyReader(sess, win)
646+ d.ptyReader(sess, win, win.focusedPane())
600647
601648 select {
602649 case got := <-writes:
@@ -626,19 +673,19 @@ func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testin
626673 p.EXPECT().Close().Return(nil).Maybe()
627674
628675 sctx, cancel := context.WithCancel(context.Background())
629- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
676+ win := newTestTabWithContext(p, sctx, cancel)
630677 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
631678 d.sessions[sess.id] = sess
632679 d.sessWg.Add(1)
633- d.ptyReader(sess, win)
680+ d.ptyReader(sess, win, win.focusedPane())
634681
635682 select {
636- case <-win.dirty:
683+ case <-win.focusedPane().dirty:
637684 t.Fatal("same-read synchronized update end should request flush, not dirty")
638685 default:
639686 }
640687 select {
641- case <-win.flush:
688+ case <-win.focusedPane().flush:
642689 case <-time.After(time.Second):
643690 t.Fatal("same-read synchronized update end did not request immediate flush")
644691 }
@@ -659,19 +706,19 @@ func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.
659706 p.EXPECT().Close().Return(nil).Maybe()
660707
661708 sctx, cancel := context.WithCancel(context.Background())
662- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
709+ win := newTestTabWithContext(p, sctx, cancel)
663710 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
664711 d.sessions[sess.id] = sess
665712 d.sessWg.Add(1)
666- d.ptyReader(sess, win)
713+ d.ptyReader(sess, win, win.focusedPane())
667714
668715 select {
669- case <-win.dirty:
716+ case <-win.focusedPane().dirty:
670717 t.Fatal("dirty signaled while synchronized update was active")
671718 default:
672719 }
673720 select {
674- case <-win.flush:
721+ case <-win.focusedPane().flush:
675722 case <-time.After(time.Second):
676723 t.Fatal("sync end did not request immediate flush")
677724 }
@@ -695,22 +742,22 @@ func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
695742
696743 tr, sends := newCapturingTransport(t)
697744 sctx, cancel := context.WithCancel(context.Background())
698- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
745+ win := newTestTabWithContext(p, sctx, cancel)
699746 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
700747 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
701748 ac.setSession(sess)
702749 d.sessions[sess.id] = sess
703750 d.sessWg.Add(2)
704- go d.scheduler(sess, win)
705- go d.ptyReader(sess, win)
751+ go d.scheduler(sess, win, win.focusedPane())
752+ go d.ptyReader(sess, win, win.focusedPane())
706753
707754 timer := <-clk.timers
708755 timer.ch <- time.Now()
709756 awaitFrame(t, sends, ports.MsgOutput)
710757
711758 win.mu.Lock()
712- active := win.screen.SyncUpdateActive()
713- got := screenLineText(win.screen, 0)
759+ active := win.focusedPane().screen.SyncUpdateActive()
760+ got := screenLineText(win.focusedPane().screen, 0)
714761 win.mu.Unlock()
715762 require.False(t, active)
716763 require.Contains(t, got, "abandoned")
@@ -739,14 +786,14 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
739786
740787 tr, sends := newCapturingTransport(t)
741788 sctx, cancel := context.WithCancel(context.Background())
742- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
789+ win := newTestTabWithContext(p, sctx, cancel)
743790 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
744791 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
745792 ac.setSession(sess)
746793 d.sessions[sess.id] = sess
747794 d.sessWg.Add(2)
748- go d.scheduler(sess, win)
749- go d.ptyReader(sess, win)
795+ go d.scheduler(sess, win, win.focusedPane())
796+ go d.ptyReader(sess, win, win.focusedPane())
750797
751798 timer := <-clk.timers
752799 awaitFrame(t, sends, ports.MsgOutput)
@@ -760,7 +807,7 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
760807 case <-time.After(50 * time.Millisecond):
761808 }
762809 win.mu.Lock()
763- active := win.screen.SyncUpdateActive()
810+ active := win.focusedPane().screen.SyncUpdateActive()
764811 win.mu.Unlock()
765812 require.False(t, active)
766813
@@ -773,20 +820,20 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
773820 p, _ := newBlockingPTY(t)
774821 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
775822 win := sess.tabs[0]
776- win.screen.Write([]byte("A"))
823+ win.focusedPane().screen.Write([]byte("A"))
777824
778825 d.paint(sess, ac, true)
779826 data := mustOutputData(t, sends)
780827 require.Contains(t, string(data), "\x1b[5 q")
781828 require.Contains(t, string(data), "\x1b[?25h")
782829
783- win.screen.Write([]byte("\x1b[2;3H"))
830+ win.focusedPane().screen.Write([]byte("\x1b[2;3H"))
784831 d.paint(sess, ac, false)
785832 data = mustOutputData(t, sends)
786833 require.Contains(t, string(data), "\x1b[3;3H")
787834 require.Contains(t, string(data), "\x1b[?25h")
788835
789- win.screen.Write([]byte("\x1b[?25l"))
836+ win.focusedPane().screen.Write([]byte("\x1b[?25l"))
790837 d.paint(sess, ac, false)
791838 data = mustOutputData(t, sends)
792839 require.Contains(t, string(data), "\x1b[?25l")
@@ -20,7 +20,6 @@ import (
20 "github.com/bnema/vev/internal/usecase/layout"20 "github.com/bnema/vev/internal/usecase/layout"
21 themeui "github.com/bnema/vev/internal/usecase/theme"21 themeui "github.com/bnema/vev/internal/usecase/theme"
22 "github.com/bnema/vev/pkg/renderer"22 "github.com/bnema/vev/pkg/renderer"
23- "github.com/bnema/vev/pkg/vt"
24 )23 )
25 24
26 // --- test doubles -----------------------------------------------------------25 // --- test doubles -----------------------------------------------------------
@@ -34,7 +33,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
34 errBoom := errors.New("boom")33 errBoom := errors.New("boom")
35 p := portsmocks.NewMockPTY(t)34 p := portsmocks.NewMockPTY(t)
36 p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()35 p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
37- win := &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}36+ win := newTab(p, domain.Size{Cols: 80, Rows: 23})
38 sess := &session{id: "manual", name: "work", tabs: []*tab{win}}37 sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
39 ac := &attachedClient{}38 ac := &attachedClient{}
40 ac.setSession(sess)39 ac.setSession(sess)
@@ -57,8 +56,8 @@ func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
57 defer releasePTY2()56 defer releasePTY2()
58 57
59 d.sessWg.Add(1)58 d.sessWg.Add(1)
60- go d.scheduler(sess, sess.tabs[1])59+ go d.scheduler(sess, sess.tabs[1], sess.tabs[1].focusedPane())
61- sess.tabs[1].dirty <- struct{}{}60+ sess.tabs[1].focusedPane().dirty <- struct{}{}
62 <-clk.called61 <-clk.called
63 62
64 d.handleInput(sess, ac, []byte("\x1b2"))63 d.handleInput(sess, ac, []byte("\x1b2"))
@@ -83,9 +82,9 @@ func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) {
83 defer releasePTY2()82 defer releasePTY2()
84 83
85 d.sessWg.Add(2)84 d.sessWg.Add(2)
86- go d.scheduler(sess, sess.tabs[1])85+ go d.scheduler(sess, sess.tabs[1], sess.tabs[1].focusedPane())
87- go d.ptyReader(sess, sess.tabs[1])86+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
88- sess.tabs[1].dirty <- struct{}{}87+ sess.tabs[1].focusedPane().dirty <- struct{}{}
89 <-clk.called88 <-clk.called
90 releasePTY2()89 releasePTY2()
91 90
@@ -117,10 +116,10 @@ func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
117 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)116 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
118 defer releasePTY2()117 defer releasePTY2()
119 sess.active = 0118 sess.active = 0
120- sess.tabs[1].screen.Write([]byte("remaining"))119+ sess.tabs[1].focusedPane().screen.Write([]byte("remaining"))
121 120
122 d.sessWg.Add(1)121 d.sessWg.Add(1)
123- go d.ptyReader(sess, sess.tabs[0])122+ go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
124 releasePTY1()123 releasePTY1()
125 124
126 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)125 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
@@ -143,10 +142,10 @@ func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
143 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)142 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
144 defer releasePTY1()143 defer releasePTY1()
145 sess.active = 0144 sess.active = 0
146- sess.tabs[0].screen.Write([]byte("active"))145+ sess.tabs[0].focusedPane().screen.Write([]byte("active"))
147 146
148 d.sessWg.Add(1)147 d.sessWg.Add(1)
149- go d.ptyReader(sess, sess.tabs[1])148+ go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane())
150 releasePTY2()149 releasePTY2()
151 150
152 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)151 require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
@@ -167,7 +166,7 @@ func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
167 d, sess, _, sends, releases := newManualTabSession(t, 1)166 d, sess, _, sends, releases := newManualTabSession(t, 1)
168 167
169 d.sessWg.Add(1)168 d.sessWg.Add(1)
170- go d.ptyReader(sess, sess.tabs[0])169+ go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane())
171 releases[0]()170 releases[0]()
172 171
173 require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)172 require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)
@@ -213,26 +212,26 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
213 212
214 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))213 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
215 sctx, cancel := context.WithCancel(context.Background())214 sctx, cancel := context.WithCancel(context.Background())
216- win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}215+ win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 5})
217 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}216 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
218 sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}217 sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
219 ac.setSession(sess)218 ac.setSession(sess)
220 219
221 win.mu.Lock()220 win.mu.Lock()
222- win.screen.Write([]byte("hi"))221+ win.focusedPane().screen.Write([]byte("hi"))
223 win.mu.Unlock()222 win.mu.Unlock()
224 223
225 d.sessWg.Add(1)224 d.sessWg.Add(1)
226- go d.scheduler(sess, win)225+ go d.scheduler(sess, win, win.focusedPane())
227 226
228 // First dirty opens the debounce window.227 // First dirty opens the debounce window.
229- win.dirty <- struct{}{}228+ win.focusedPane().dirty <- struct{}{}
230 <-newTimerCalled229 <-newTimerCalled
231 230
232 // A burst of further dirties inside the window must be absorbed.231 // A burst of further dirties inside the window must be absorbed.
233 for range 5 {232 for range 5 {
234 select {233 select {
235- case win.dirty <- struct{}{}:234+ case win.focusedPane().dirty <- struct{}{}:
236 default:235 default:
237 }236 }
238 }237 }
@@ -268,9 +267,9 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
268 267
269 win := newTab(p, domain.Size{Cols: 4, Rows: 4})268 win := newTab(p, domain.Size{Cols: 4, Rows: 4})
270 for y, text := range []string{"0000", "1111", "2222", "3333"} {269 for y, text := range []string{"0000", "1111", "2222", "3333"} {
271- copy(win.screen.Frame.Row(y), testRow(text))270+ copy(win.focusedPane().screen.Frame.Row(y), testRow(text))
272 }271 }
273- win.screen.Row = 3272+ win.focusedPane().screen.Row = 3
274 273
275 tr := portsmocks.NewMockTransport(t)274 tr := portsmocks.NewMockTransport(t)
276 tr.EXPECT().Close().Return(nil).Maybe()275 tr.EXPECT().Close().Return(nil).Maybe()
@@ -285,16 +284,16 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
285 // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1,284 // layout: tabSize reserves 2 chrome rows (top + bottom bar) here, not 1,
286 // so a client height of 4 (not 3) is what yields the same 2-row tab.285 // so a client height of 4 (not 3) is what yields the same 2-row tab.
287 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})286 d.resize(sess, ac, domain.Size{Cols: 4, Rows: 4})
288- require.Equal(t, "2222", frameRowString(win.screen.Frame, 0))287+ require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
289- require.Equal(t, "3333", frameRowString(win.screen.Frame, 1))288+ require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
290- require.Equal(t, 2, win.scrollback.Len())289+ require.Equal(t, 2, win.focusedPane().scrollback.Len())
291- require.Equal(t, "0000", cellsString(win.scrollback.Row(0)))290+ require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.Row(0)))
292- require.Equal(t, "1111", cellsString(win.scrollback.Row(1)))291+ require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.Row(1)))
293 292
294 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})293 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
295- require.Equal(t, "2222 ", frameRowString(win.screen.Frame, 0))294+ require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
296- require.Equal(t, "3333 ", frameRowString(win.screen.Frame, 1))295+ require.Equal(t, "3333 ", frameRowString(win.focusedPane().screen.Frame, 1))
297- require.Equal(t, 2, win.scrollback.Len())296+ require.Equal(t, 2, win.focusedPane().scrollback.Len())
298 }297 }
299 298
300 func frameRowString(f renderer.Frame, y int) string {299 func frameRowString(f renderer.Frame, y int) string {
@@ -336,6 +335,54 @@ func TestTranslateDamageShiftsXYAndPreservesFullRedraw(t *testing.T) {
336 }, damage)335 }, damage)
337 }336 }
338 337
338+func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
339+ tests := []struct {
340+ name string
341+ content domain.Rect
342+ area domain.Rect
343+ in renderer.Damage
344+ want []renderer.Damage
345+ }{
346+ {
347+ name: "half width scroll becomes pane text damage",
348+ content: domain.Rect{X: 21, Y: 0, Width: 20, Height: 4},
349+ area: domain.Rect{Width: 41, Height: 4},
350+ in: renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 20, Height: 4, Count: 1},
351+ want: []renderer.Damage{{Kind: renderer.DamageText, X: 21, Y: 0, Width: 20, Height: 4}},
352+ },
353+ {
354+ name: "full width scroll keeps translated scroll damage",
355+ content: domain.Rect{X: 0, Y: 5, Width: 80, Height: 10},
356+ area: domain.Rect{Width: 80, Height: 24},
357+ in: renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 80, Height: 10, Count: 1},
358+ want: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 5, Width: 80, Height: 10, Count: 1}},
359+ },
360+ }
361+ for _, tt := range tests {
362+ t.Run(tt.name, func(t *testing.T) {
363+ require.Equal(t, tt.want, translatePaneDamage(tt.in, tt.content, tt.area))
364+ })
365+ }
366+}
367+
368+func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) {
369+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
370+ focused := tb.focusedPane()
371+ emitting := newPane("pane-2", nil, domain.Size{Cols: 10, Rows: 3})
372+ tb.panes[emitting.id] = emitting
373+ tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(focused.id), layout.NewLeaf(emitting.id)}}
374+ tb.tree.Focus = focused.id
375+ sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0}
376+ d := newTestDaemon(t, nil, stubClock{})
377+
378+ focused.screen.Write([]byte("a"))
379+ emitting.screen.Write([]byte("b"))
380+ d.render(sess, tb, emitting)
381+
382+ require.NotEmpty(t, focused.screen.Damage(), "focused pane damage should be untouched by detached render for another pane")
383+ require.Empty(t, emitting.screen.Damage(), "emitting pane damage should be drained")
384+}
385+
339 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {386 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
340 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})387 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
341 left := win.focusedPane()388 left := win.focusedPane()
@@ -393,15 +440,15 @@ func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {
393 440
394 _, damage := composeClientFrame(sess, win, true, "", &cache)441 _, damage := composeClientFrame(sess, win, true, "", &cache)
395 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)442 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
396- win.screen.ClearDamage()443+ win.focusedPane().screen.ClearDamage()
397- win.screen.Write([]byte("x"))444+ win.focusedPane().screen.Write([]byte("x"))
398 445
399 _, damage = composeClientFrame(sess, win, false, "", &cache)446 _, damage = composeClientFrame(sess, win, false, "", &cache)
400 447
401 require.NotEmpty(t, damage)448 require.NotEmpty(t, damage)
402 for _, d := range damage {449 for _, d := range damage {
403 require.NotEqual(t, 0, d.Y, "unchanged top bar should not be damaged")450 require.NotEqual(t, 0, d.Y, "unchanged top bar should not be damaged")
404- require.NotEqual(t, win.screen.Frame.Height+1, d.Y, "unchanged bottom bar should not be damaged")451+ require.NotEqual(t, win.focusedPane().screen.Frame.Height+1, d.Y, "unchanged bottom bar should not be damaged")
405 }452 }
406 }453 }
407 454
@@ -409,12 +456,12 @@ func TestComposeClientFrameBarCacheDamagesChangedBottomOnly(t *testing.T) {
409 sess, win := newBarCacheTestSession()456 sess, win := newBarCacheTestSession()
410 var cache barCache457 var cache barCache
411 composeClientFrame(sess, win, true, "", &cache)458 composeClientFrame(sess, win, true, "", &cache)
412- win.screen.ClearDamage()459+ win.focusedPane().screen.ClearDamage()
413 460
414 sess.name = "renamed"461 sess.name = "renamed"
415 _, damage := composeClientFrame(sess, win, false, "", &cache)462 _, damage := composeClientFrame(sess, win, false, "", &cache)
416 463
417- require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: win.screen.Frame.Height + 1, Width: win.screen.Frame.Width, Height: 1}}, damage)464+ require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: win.focusedPane().screen.Frame.Height + 1, Width: win.focusedPane().screen.Frame.Width, Height: 1}}, damage)
418 }465 }
419 466
420 func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {467 func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {
@@ -425,14 +472,14 @@ func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {
425 472
426 _, damage := composeClientFrame(sess, win, true, "", &cache)473 _, damage := composeClientFrame(sess, win, true, "", &cache)
427 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)474 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
428- win.screen.ClearDamage()475+ win.focusedPane().screen.ClearDamage()
429 476
430 _, damage = composeClientFrame(sess, win, false, "", &cache)477 _, damage = composeClientFrame(sess, win, false, "", &cache)
431 require.Empty(t, damage)478 require.Empty(t, damage)
432 }479 }
433 480
434 func newBarCacheTestSession() (*session, *tab) {481 func newBarCacheTestSession() (*session, *tab) {
435- win := &tab{screen: vt.NewScreen(20, 3), size: domain.Size{Cols: 20, Rows: 3}}482+ win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 3})
436 sess := &session{id: "s", name: "work", tabs: []*tab{win}}483 sess := &session{id: "s", name: "work", tabs: []*tab{win}}
437 return sess, win484 return sess, win
438 }485 }
@@ -442,13 +489,13 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
442 489
443 p := portsmocks.NewMockPTY(t)490 p := portsmocks.NewMockPTY(t)
444 var screenWidthAtResize int491 var screenWidthAtResize int
445- win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}492+ win := newTab(newScriptPTY(nil), domain.Size{Cols: 80, Rows: 24})
446 p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 28}).RunAndReturn(func(sz domain.Size) error {493 p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 28}).RunAndReturn(func(sz domain.Size) error {
447 // The screen must not yet be resized when the PTY is: proves order.494 // The screen must not yet be resized when the PTY is: proves order.
448- screenWidthAtResize = win.screen.Frame.Width495+ screenWidthAtResize = win.focusedPane().screen.Frame.Width
449 return nil496 return nil
450 }).Once()497 }).Once()
451- win.pty = p498+ win.focusedPane().pty = p
452 499
453 var gotOutput atomic.Bool500 var gotOutput atomic.Bool
454 tr := portsmocks.NewMockTransport(t)501 tr := portsmocks.NewMockTransport(t)
@@ -468,8 +515,8 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
468 d.resize(sess, ac, newSize)515 d.resize(sess, ac, newSize)
469 516
470 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")517 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
471- require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")518+ require.Equal(t, 100, win.focusedPane().screen.Frame.Width, "screen resized after pty")
472- require.Equal(t, 28, win.screen.Frame.Height, "screen reserves top and bottom chrome rows")519+ require.Equal(t, 28, win.focusedPane().screen.Frame.Height, "screen reserves top and bottom chrome rows")
473 require.True(t, gotOutput.Load(), "resize forces a full redraw output")520 require.True(t, gotOutput.Load(), "resize forces a full redraw output")
474 }521 }
475 522
@@ -484,7 +531,7 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
484 tr.EXPECT().Close().Return(nil).Maybe()531 tr.EXPECT().Close().Return(nil).Maybe()
485 532
486 d := newTestDaemon(t, nil, stubClock{})533 d := newTestDaemon(t, nil, stubClock{})
487- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}534+ win := newTab(p, domain.Size{Cols: 20, Rows: 5})
488 sctx, cancel := context.WithCancel(context.Background())535 sctx, cancel := context.WithCancel(context.Background())
489 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}536 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
490 sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}537 sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
@@ -492,7 +539,7 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
492 d.sessions[sess.id] = sess539 d.sessions[sess.id] = sess
493 540
494 win.mu.Lock()541 win.mu.Lock()
495- win.screen.Write([]byte("x"))542+ win.focusedPane().screen.Write([]byte("x"))
496 win.mu.Unlock()543 win.mu.Unlock()
497 544
498 d.paint(sess, ac, true) // send fails -> detach -> ephemeral killed545 d.paint(sess, ac, true) // send fails -> detach -> ephemeral killed
@@ -590,13 +637,13 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
590 637
591 tr, sends := newCapturingTransport(t)638 tr, sends := newCapturingTransport(t)
592 sctx, cancel := context.WithCancel(context.Background())639 sctx, cancel := context.WithCancel(context.Background())
593- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}640+ win := newTestTabWithContext(p, sctx, cancel)
594 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}641 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
595 sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}642 sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
596 ac.setSession(sess)643 ac.setSession(sess)
597 d.sessions[sess.id] = sess644 d.sessions[sess.id] = sess
598 d.sessWg.Add(1)645 d.sessWg.Add(1)
599- d.ptyReader(sess, win)646+ d.ptyReader(sess, win, win.focusedPane())
600 647
601 select {648 select {
602 case got := <-writes:649 case got := <-writes:
@@ -626,19 +673,19 @@ func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testin
626 p.EXPECT().Close().Return(nil).Maybe()673 p.EXPECT().Close().Return(nil).Maybe()
627 674
628 sctx, cancel := context.WithCancel(context.Background())675 sctx, cancel := context.WithCancel(context.Background())
629- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}676+ win := newTestTabWithContext(p, sctx, cancel)
630 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}677 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
631 d.sessions[sess.id] = sess678 d.sessions[sess.id] = sess
632 d.sessWg.Add(1)679 d.sessWg.Add(1)
633- d.ptyReader(sess, win)680+ d.ptyReader(sess, win, win.focusedPane())
634 681
635 select {682 select {
636- case <-win.dirty:683+ case <-win.focusedPane().dirty:
637 t.Fatal("same-read synchronized update end should request flush, not dirty")684 t.Fatal("same-read synchronized update end should request flush, not dirty")
638 default:685 default:
639 }686 }
640 select {687 select {
641- case <-win.flush:688+ case <-win.focusedPane().flush:
642 case <-time.After(time.Second):689 case <-time.After(time.Second):
643 t.Fatal("same-read synchronized update end did not request immediate flush")690 t.Fatal("same-read synchronized update end did not request immediate flush")
644 }691 }
@@ -659,19 +706,19 @@ func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.
659 p.EXPECT().Close().Return(nil).Maybe()706 p.EXPECT().Close().Return(nil).Maybe()
660 707
661 sctx, cancel := context.WithCancel(context.Background())708 sctx, cancel := context.WithCancel(context.Background())
662- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}709+ win := newTestTabWithContext(p, sctx, cancel)
663 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}710 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
664 d.sessions[sess.id] = sess711 d.sessions[sess.id] = sess
665 d.sessWg.Add(1)712 d.sessWg.Add(1)
666- d.ptyReader(sess, win)713+ d.ptyReader(sess, win, win.focusedPane())
667 714
668 select {715 select {
669- case <-win.dirty:716+ case <-win.focusedPane().dirty:
670 t.Fatal("dirty signaled while synchronized update was active")717 t.Fatal("dirty signaled while synchronized update was active")
671 default:718 default:
672 }719 }
673 select {720 select {
674- case <-win.flush:721+ case <-win.focusedPane().flush:
675 case <-time.After(time.Second):722 case <-time.After(time.Second):
676 t.Fatal("sync end did not request immediate flush")723 t.Fatal("sync end did not request immediate flush")
677 }724 }
@@ -695,22 +742,22 @@ func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
695 742
696 tr, sends := newCapturingTransport(t)743 tr, sends := newCapturingTransport(t)
697 sctx, cancel := context.WithCancel(context.Background())744 sctx, cancel := context.WithCancel(context.Background())
698- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}745+ win := newTestTabWithContext(p, sctx, cancel)
699 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}746 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
700 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}747 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
701 ac.setSession(sess)748 ac.setSession(sess)
702 d.sessions[sess.id] = sess749 d.sessions[sess.id] = sess
703 d.sessWg.Add(2)750 d.sessWg.Add(2)
704- go d.scheduler(sess, win)751+ go d.scheduler(sess, win, win.focusedPane())
705- go d.ptyReader(sess, win)752+ go d.ptyReader(sess, win, win.focusedPane())
706 753
707 timer := <-clk.timers754 timer := <-clk.timers
708 timer.ch <- time.Now()755 timer.ch <- time.Now()
709 awaitFrame(t, sends, ports.MsgOutput)756 awaitFrame(t, sends, ports.MsgOutput)
710 757
711 win.mu.Lock()758 win.mu.Lock()
712- active := win.screen.SyncUpdateActive()759+ active := win.focusedPane().screen.SyncUpdateActive()
713- got := screenLineText(win.screen, 0)760+ got := screenLineText(win.focusedPane().screen, 0)
714 win.mu.Unlock()761 win.mu.Unlock()
715 require.False(t, active)762 require.False(t, active)
716 require.Contains(t, got, "abandoned")763 require.Contains(t, got, "abandoned")
@@ -739,14 +786,14 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
739 786
740 tr, sends := newCapturingTransport(t)787 tr, sends := newCapturingTransport(t)
741 sctx, cancel := context.WithCancel(context.Background())788 sctx, cancel := context.WithCancel(context.Background())
742- win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}789+ win := newTestTabWithContext(p, sctx, cancel)
743 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}790 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
744 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}791 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
745 ac.setSession(sess)792 ac.setSession(sess)
746 d.sessions[sess.id] = sess793 d.sessions[sess.id] = sess
747 d.sessWg.Add(2)794 d.sessWg.Add(2)
748- go d.scheduler(sess, win)795+ go d.scheduler(sess, win, win.focusedPane())
749- go d.ptyReader(sess, win)796+ go d.ptyReader(sess, win, win.focusedPane())
750 797
751 timer := <-clk.timers798 timer := <-clk.timers
752 awaitFrame(t, sends, ports.MsgOutput)799 awaitFrame(t, sends, ports.MsgOutput)
@@ -760,7 +807,7 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
760 case <-time.After(50 * time.Millisecond):807 case <-time.After(50 * time.Millisecond):
761 }808 }
762 win.mu.Lock()809 win.mu.Lock()
763- active := win.screen.SyncUpdateActive()810+ active := win.focusedPane().screen.SyncUpdateActive()
764 win.mu.Unlock()811 win.mu.Unlock()
765 require.False(t, active)812 require.False(t, active)
766 813
@@ -773,20 +820,20 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
773 p, _ := newBlockingPTY(t)820 p, _ := newBlockingPTY(t)
774 d, sess, ac, sends := newManualSessionWithPTYs(t, p)821 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
775 win := sess.tabs[0]822 win := sess.tabs[0]
776- win.screen.Write([]byte("A"))823+ win.focusedPane().screen.Write([]byte("A"))
777 824
778 d.paint(sess, ac, true)825 d.paint(sess, ac, true)
779 data := mustOutputData(t, sends)826 data := mustOutputData(t, sends)
780 require.Contains(t, string(data), "\x1b[5 q")827 require.Contains(t, string(data), "\x1b[5 q")
781 require.Contains(t, string(data), "\x1b[?25h")828 require.Contains(t, string(data), "\x1b[?25h")
782 829
783- win.screen.Write([]byte("\x1b[2;3H"))830+ win.focusedPane().screen.Write([]byte("\x1b[2;3H"))
784 d.paint(sess, ac, false)831 d.paint(sess, ac, false)
785 data = mustOutputData(t, sends)832 data = mustOutputData(t, sends)
786 require.Contains(t, string(data), "\x1b[3;3H")833 require.Contains(t, string(data), "\x1b[3;3H")
787 require.Contains(t, string(data), "\x1b[?25h")834 require.Contains(t, string(data), "\x1b[?25h")
788 835
789- win.screen.Write([]byte("\x1b[?25l"))836+ win.focusedPane().screen.Write([]byte("\x1b[?25l"))
790 d.paint(sess, ac, false)837 d.paint(sess, ac, false)
791 data = mustOutputData(t, sends)838 data = mustOutputData(t, sends)
792 require.Contains(t, string(data), "\x1b[?25l")839 require.Contains(t, string(data), "\x1b[?25l")
M internal/usecase/daemon/scrollback_test.go +6 -6
@@ -24,19 +24,19 @@ func TestNewTabInitializesScrollback(t *testing.T) {
2424 for _, tt := range tests {
2525 t.Run(tt.name, func(t *testing.T) {
2626 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
27- if win.scrollback == nil {
27+ if win.focusedPane().scrollback == nil {
2828 t.Fatal("scrollback is nil")
2929 }
30- if win.scrollback.Cap() != defaultScrollbackRows {
31- t.Fatalf("scrollback cap = %d, want %d", win.scrollback.Cap(), defaultScrollbackRows)
30+ if win.focusedPane().scrollback.Cap() != defaultScrollbackRows {
31+ t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows)
3232 }
3333
34- win.screen.Write(tt.write)
34+ win.focusedPane().screen.Write(tt.write)
3535
36- if got := win.scrollback.Len(); got != tt.wantLen {
36+ if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {
3737 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
3838 }
39- row := win.scrollback.Row(0)
39+ row := win.focusedPane().scrollback.Row(0)
4040 runes := make([]rune, len(row))
4141 for i, c := range row {
4242 runes[i] = c.Rune
@@ -24,19 +24,19 @@ func TestNewTabInitializesScrollback(t *testing.T) {
24 for _, tt := range tests {24 for _, tt := range tests {
25 t.Run(tt.name, func(t *testing.T) {25 t.Run(tt.name, func(t *testing.T) {
26 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})26 win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
27- if win.scrollback == nil {27+ if win.focusedPane().scrollback == nil {
28 t.Fatal("scrollback is nil")28 t.Fatal("scrollback is nil")
29 }29 }
30- if win.scrollback.Cap() != defaultScrollbackRows {30+ if win.focusedPane().scrollback.Cap() != defaultScrollbackRows {
31- t.Fatalf("scrollback cap = %d, want %d", win.scrollback.Cap(), defaultScrollbackRows)31+ t.Fatalf("scrollback cap = %d, want %d", win.focusedPane().scrollback.Cap(), defaultScrollbackRows)
32 }32 }
33 33
34- win.screen.Write(tt.write)34+ win.focusedPane().screen.Write(tt.write)
35 35
36- if got := win.scrollback.Len(); got != tt.wantLen {36+ if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {
37 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)37 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
38 }38 }
39- row := win.scrollback.Row(0)39+ row := win.focusedPane().scrollback.Row(0)
40 runes := make([]rune, len(row))40 runes := make([]rune, len(row))
41 for i, c := range row {41 for i, c := range row {
42 runes[i] = c.Rune42 runes[i] = c.Rune
M internal/usecase/daemon/session.go +3 -37
@@ -36,9 +36,7 @@ import (
3636 "github.com/bnema/vev/internal/domain"
3737 "github.com/bnema/vev/internal/persist"
3838 "github.com/bnema/vev/internal/ports"
39- scopy "github.com/bnema/vev/internal/usecase/copy"
4039 "github.com/bnema/vev/internal/usecase/layout"
41- "github.com/bnema/vev/pkg/vt"
4240 )
4341
4442 type session struct {
@@ -57,18 +55,8 @@ type session struct {
5755 createdAt int64
5856 }
5957
60-// tab is a pane layout container. In the single-leaf case it preserves the old
61-// one-PTY tab behavior; pane owns PTY/screen/scrollback/render scheduling state.
58+// tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
6259 type tab struct {
63- // Compatibility aliases for the single focused pane. Production call sites use
64- // focusedPane/all-pane loops; tests still inspect the single-leaf state directly.
65- pty ports.PTY
66- screen *vt.Screen
67- scrollback *scopy.Scrollback
68- dirty chan struct{}
69- flush chan struct{}
70- syncGen uint64
71-
7260 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
7361
7462 tree *layout.Tree
@@ -220,11 +208,6 @@ func newTab(pty ports.PTY, sz domain.Size) *tab {
220208 id := layout.PaneID("pane-1")
221209 p := newPane(id, pty, sz)
222210 return &tab{
223- pty: p.pty,
224- screen: p.screen,
225- scrollback: p.scrollback,
226- dirty: p.dirty,
227- flush: p.flush,
228211 tree: layout.NewTree(id),
229212 panes: map[layout.PaneID]*pane{id: p},
230213 nextPaneID: 2,
@@ -233,33 +216,16 @@ func newTab(pty ports.PTY, sz domain.Size) *tab {
233216 }
234217
235218 func (tb *tab) focusedPane() *pane {
236- if tb == nil {
219+ if tb == nil || tb.tree == nil || tb.panes == nil {
237220 return nil
238221 }
239- if tb.tree != nil && tb.panes != nil {
240- p := tb.panes[tb.tree.Focus]
241- if p == nil {
242- return nil
243- }
244- if len(tb.panes) == 1 && ((tb.pty != nil && tb.pty != p.pty) || (tb.screen != nil && tb.screen != p.screen) || (tb.scrollback != nil && tb.scrollback != p.scrollback) || (tb.dirty != nil && tb.dirty != p.dirty) || (tb.flush != nil && tb.flush != p.flush)) {
245- return &pane{id: p.id, pty: tb.pty, screen: tb.screen, scrollback: tb.scrollback, dirty: tb.dirty, flush: tb.flush, syncGen: tb.syncGen, rect: p.rect, ctx: tb.ctx, cancel: tb.cancel}
246- }
247- return p
248- }
249- if tb.pty != nil || tb.screen != nil || tb.scrollback != nil {
250- return &pane{pty: tb.pty, screen: tb.screen, scrollback: tb.scrollback, dirty: tb.dirty, flush: tb.flush, syncGen: tb.syncGen, ctx: tb.ctx, cancel: tb.cancel}
251- }
252- return nil
222+ return tb.panes[tb.tree.Focus]
253223 }
254224
255225 func (tb *tab) panesSnapshot() []*pane {
256226 if tb == nil {
257227 return nil
258228 }
259- p0 := tb.focusedPane()
260- if len(tb.panes) == 0 && p0 != nil {
261- return []*pane{p0}
262- }
263229 out := make([]*pane, 0, len(tb.panes))
264230 for _, p := range tb.panes {
265231 out = append(out, p)
@@ -36,9 +36,7 @@ import (
36 "github.com/bnema/vev/internal/domain"36 "github.com/bnema/vev/internal/domain"
37 "github.com/bnema/vev/internal/persist"37 "github.com/bnema/vev/internal/persist"
38 "github.com/bnema/vev/internal/ports"38 "github.com/bnema/vev/internal/ports"
39- scopy "github.com/bnema/vev/internal/usecase/copy"
40 "github.com/bnema/vev/internal/usecase/layout"39 "github.com/bnema/vev/internal/usecase/layout"
41- "github.com/bnema/vev/pkg/vt"
42 )40 )
43 41
44 type session struct {42 type session struct {
@@ -57,18 +55,8 @@ type session struct {
57 createdAt int6455 createdAt int64
58 }56 }
59 57
60-// tab is a pane layout container. In the single-leaf case it preserves the old58+// tab is a pane layout container; pane owns PTY/screen/scrollback/render scheduling state.
61-// one-PTY tab behavior; pane owns PTY/screen/scrollback/render scheduling state.
62 type tab struct {59 type tab struct {
63- // Compatibility aliases for the single focused pane. Production call sites use
64- // focusedPane/all-pane loops; tests still inspect the single-leaf state directly.
65- pty ports.PTY
66- screen *vt.Screen
67- scrollback *scopy.Scrollback
68- dirty chan struct{}
69- flush chan struct{}
70- syncGen uint64
71-
72 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership60 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
73 61
74 tree *layout.Tree62 tree *layout.Tree
@@ -220,11 +208,6 @@ func newTab(pty ports.PTY, sz domain.Size) *tab {
220 id := layout.PaneID("pane-1")208 id := layout.PaneID("pane-1")
221 p := newPane(id, pty, sz)209 p := newPane(id, pty, sz)
222 return &tab{210 return &tab{
223- pty: p.pty,
224- screen: p.screen,
225- scrollback: p.scrollback,
226- dirty: p.dirty,
227- flush: p.flush,
228 tree: layout.NewTree(id),211 tree: layout.NewTree(id),
229 panes: map[layout.PaneID]*pane{id: p},212 panes: map[layout.PaneID]*pane{id: p},
230 nextPaneID: 2,213 nextPaneID: 2,
@@ -233,33 +216,16 @@ func newTab(pty ports.PTY, sz domain.Size) *tab {
233 }216 }
234 217
235 func (tb *tab) focusedPane() *pane {218 func (tb *tab) focusedPane() *pane {
236- if tb == nil {219+ if tb == nil || tb.tree == nil || tb.panes == nil {
237 return nil220 return nil
238 }221 }
239- if tb.tree != nil && tb.panes != nil {222+ return tb.panes[tb.tree.Focus]
240- p := tb.panes[tb.tree.Focus]
241- if p == nil {
242- return nil
243- }
244- if len(tb.panes) == 1 && ((tb.pty != nil && tb.pty != p.pty) || (tb.screen != nil && tb.screen != p.screen) || (tb.scrollback != nil && tb.scrollback != p.scrollback) || (tb.dirty != nil && tb.dirty != p.dirty) || (tb.flush != nil && tb.flush != p.flush)) {
245- return &pane{id: p.id, pty: tb.pty, screen: tb.screen, scrollback: tb.scrollback, dirty: tb.dirty, flush: tb.flush, syncGen: tb.syncGen, rect: p.rect, ctx: tb.ctx, cancel: tb.cancel}
246- }
247- return p
248- }
249- if tb.pty != nil || tb.screen != nil || tb.scrollback != nil {
250- return &pane{pty: tb.pty, screen: tb.screen, scrollback: tb.scrollback, dirty: tb.dirty, flush: tb.flush, syncGen: tb.syncGen, ctx: tb.ctx, cancel: tb.cancel}
251- }
252- return nil
253 }223 }
254 224
255 func (tb *tab) panesSnapshot() []*pane {225 func (tb *tab) panesSnapshot() []*pane {
256 if tb == nil {226 if tb == nil {
257 return nil227 return nil
258 }228 }
259- p0 := tb.focusedPane()
260- if len(tb.panes) == 0 && p0 != nil {
261- return []*pane{p0}
262- }
263 out := make([]*pane, 0, len(tb.panes))229 out := make([]*pane, 0, len(tb.panes))
264 for _, p := range tb.panes {230 for _, p := range tb.panes {
265 out = append(out, p)231 out = append(out, p)
M internal/usecase/daemon/status_test.go +20 -21
@@ -12,7 +12,6 @@ import (
1212 "github.com/bnema/vev/internal/ports"
1313 themeui "github.com/bnema/vev/internal/usecase/theme"
1414 "github.com/bnema/vev/pkg/renderer"
15- "github.com/bnema/vev/pkg/vt"
1615 )
1716
1817 // --- test doubles -----------------------------------------------------------
@@ -30,9 +29,9 @@ func TestStatusCompositionGolden(t *testing.T) {
3029 sess.name = "work"
3130
3231 win := sess.activeTab()
33- win.screen = vt.NewScreen(12, 2)
32+ win.focusedPane().screen.Resize(12, 2)
3433 win.size = domain.Size{Cols: 12, Rows: 2}
35- win.screen.Write([]byte("hello"))
34+ win.focusedPane().screen.Write([]byte("hello"))
3635
3736 frame, damage := composeClientFrame(sess, win, true, "")
3837
@@ -54,7 +53,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
5453 _, sess, ac, _ := newManualSessionWithPTYs(t, p)
5554 defer release()
5655 win := sess.activeTab()
57- win.screen = vt.NewScreen(12, 2)
56+ win.focusedPane().screen.Resize(12, 2)
5857 win.size = domain.Size{Cols: 12, Rows: 2}
5958 ac.setTheme(themeui.Theme{
6059 Foreground: renderer.RGB{R: 220, G: 220, B: 220},
@@ -86,8 +85,8 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) {
8685 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground)
8786 for _, tb := range sess.tabs {
8887 var got []byte
89- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
90- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
88+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
89+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
9190 require.True(t, strings.Contains(string(got), "rgb:0101/0202/0303"), string(got))
9291 require.True(t, strings.Contains(string(got), "rgb:0404/0505/0606"), string(got))
9392 }
@@ -107,8 +106,8 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
107106
108107 var got []byte
109108 tb := sess.activeTab()
110- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
111- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
109+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
110+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
112111 require.Empty(t, got)
113112 }
114113
@@ -124,15 +123,15 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
124123 // Sanity: the tab answers OSC 10/11 while ac is attached.
125124 tb := sess.activeTab()
126125 var before []byte
127- tb.screen.OnResponse = func(b []byte) { before = append(before, b...) }
128- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
126+ tb.focusedPane().screen.OnResponse = func(b []byte) { before = append(before, b...) }
127+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
129128 require.NotEmpty(t, before)
130129
131130 d.clientGone(sess, ac, true)
132131
133132 var got []byte
134- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
135- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
133+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
134+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
136135 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
137136 }
138137
@@ -164,8 +163,8 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
164163
165164 tb := sess.activeTab()
166165 var got []byte
167- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
168- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
166+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
167+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
169168 require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset")
170169 }
171170
@@ -182,8 +181,8 @@ func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
182181
183182 tb := sess.activeTab()
184183 var got []byte
185- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
186- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
184+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
185+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
187186 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
188187 }
189188
@@ -201,8 +200,8 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
201200
202201 var got []byte
203202 tb := sess.activeTab()
204- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }
205- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
203+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
204+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
206205 require.Empty(t, got)
207206 }
208207
@@ -213,7 +212,7 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
213212 sess.name = "0"
214213 sess.ephemeral = true
215214 win := sess.activeTab()
216- win.screen = vt.NewScreen(12, 2)
215+ win.focusedPane().screen.Resize(12, 2)
217216 win.size = domain.Size{Cols: 12, Rows: 2}
218217
219218 frame, _ := composeClientFrame(sess, win, true, "")
@@ -228,7 +227,7 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
228227 defer releasePTY()
229228 sess.name = "work"
230229 win := sess.activeTab()
231- win.screen = vt.NewScreen(30, 2)
230+ win.focusedPane().screen.Resize(30, 2)
232231 win.size = domain.Size{Cols: 30, Rows: 2}
233232
234233 frame, _ := composeClientFrame(sess, win, true, "ok")
@@ -265,7 +264,7 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
265264 defer releasePTY1()
266265 defer releasePTY2()
267266 win := sess.activeTab()
268- win.screen = vt.NewScreen(18, 2)
267+ win.focusedPane().screen.Resize(18, 2)
269268 win.size = domain.Size{Cols: 18, Rows: 2}
270269 sess.mu.Lock()
271270 sess.tabs[1].attention = true
@@ -12,7 +12,6 @@ import (
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13 themeui "github.com/bnema/vev/internal/usecase/theme"13 themeui "github.com/bnema/vev/internal/usecase/theme"
14 "github.com/bnema/vev/pkg/renderer"14 "github.com/bnema/vev/pkg/renderer"
15- "github.com/bnema/vev/pkg/vt"
16 )15 )
17 16
18 // --- test doubles -----------------------------------------------------------17 // --- test doubles -----------------------------------------------------------
@@ -30,9 +29,9 @@ func TestStatusCompositionGolden(t *testing.T) {
30 sess.name = "work"29 sess.name = "work"
31 30
32 win := sess.activeTab()31 win := sess.activeTab()
33- win.screen = vt.NewScreen(12, 2)32+ win.focusedPane().screen.Resize(12, 2)
34 win.size = domain.Size{Cols: 12, Rows: 2}33 win.size = domain.Size{Cols: 12, Rows: 2}
35- win.screen.Write([]byte("hello"))34+ win.focusedPane().screen.Write([]byte("hello"))
36 35
37 frame, damage := composeClientFrame(sess, win, true, "")36 frame, damage := composeClientFrame(sess, win, true, "")
38 37
@@ -54,7 +53,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
54 _, sess, ac, _ := newManualSessionWithPTYs(t, p)53 _, sess, ac, _ := newManualSessionWithPTYs(t, p)
55 defer release()54 defer release()
56 win := sess.activeTab()55 win := sess.activeTab()
57- win.screen = vt.NewScreen(12, 2)56+ win.focusedPane().screen.Resize(12, 2)
58 win.size = domain.Size{Cols: 12, Rows: 2}57 win.size = domain.Size{Cols: 12, Rows: 2}
59 ac.setTheme(themeui.Theme{58 ac.setTheme(themeui.Theme{
60 Foreground: renderer.RGB{R: 220, G: 220, B: 220},59 Foreground: renderer.RGB{R: 220, G: 220, B: 220},
@@ -86,8 +85,8 @@ func TestStatusApplyThemeStoresClientAndPropagatesScreens(t *testing.T) {
86 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground)85 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, ac.getTheme().Foreground)
87 for _, tb := range sess.tabs {86 for _, tb := range sess.tabs {
88 var got []byte87 var got []byte
89- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }88+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
90- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))89+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
91 require.True(t, strings.Contains(string(got), "rgb:0101/0202/0303"), string(got))90 require.True(t, strings.Contains(string(got), "rgb:0101/0202/0303"), string(got))
92 require.True(t, strings.Contains(string(got), "rgb:0404/0505/0606"), string(got))91 require.True(t, strings.Contains(string(got), "rgb:0404/0505/0606"), string(got))
93 }92 }
@@ -107,8 +106,8 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
107 106
108 var got []byte107 var got []byte
109 tb := sess.activeTab()108 tb := sess.activeTab()
110- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }109+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
111- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))110+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
112 require.Empty(t, got)111 require.Empty(t, got)
113 }112 }
114 113
@@ -124,15 +123,15 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
124 // Sanity: the tab answers OSC 10/11 while ac is attached.123 // Sanity: the tab answers OSC 10/11 while ac is attached.
125 tb := sess.activeTab()124 tb := sess.activeTab()
126 var before []byte125 var before []byte
127- tb.screen.OnResponse = func(b []byte) { before = append(before, b...) }126+ tb.focusedPane().screen.OnResponse = func(b []byte) { before = append(before, b...) }
128- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))127+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
129 require.NotEmpty(t, before)128 require.NotEmpty(t, before)
130 129
131 d.clientGone(sess, ac, true)130 d.clientGone(sess, ac, true)
132 131
133 var got []byte132 var got []byte
134- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }133+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
135- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))134+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
136 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")135 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
137 }136 }
138 137
@@ -164,8 +163,8 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
164 163
165 tb := sess.activeTab()164 tb := sess.activeTab()
166 var got []byte165 var got []byte
167- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }166+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
168- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))167+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
169 require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset")168 require.NotEmpty(t, got, "a newer client's screen default colors must survive a stale detach's reset")
170 }169 }
171 170
@@ -182,8 +181,8 @@ func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
182 181
183 tb := sess.activeTab()182 tb := sess.activeTab()
184 var got []byte183 var got []byte
185- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }184+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
186- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))185+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
187 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")186 require.Empty(t, got, "OSC 10/11 queries must be swallowed once the client that reported these colors is gone")
188 }187 }
189 188
@@ -201,8 +200,8 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
201 200
202 var got []byte201 var got []byte
203 tb := sess.activeTab()202 tb := sess.activeTab()
204- tb.screen.OnResponse = func(b []byte) { got = append(got, b...) }203+ tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
205- tb.screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))204+ tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
206 require.Empty(t, got)205 require.Empty(t, got)
207 }206 }
208 207
@@ -213,7 +212,7 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
213 sess.name = "0"212 sess.name = "0"
214 sess.ephemeral = true213 sess.ephemeral = true
215 win := sess.activeTab()214 win := sess.activeTab()
216- win.screen = vt.NewScreen(12, 2)215+ win.focusedPane().screen.Resize(12, 2)
217 win.size = domain.Size{Cols: 12, Rows: 2}216 win.size = domain.Size{Cols: 12, Rows: 2}
218 217
219 frame, _ := composeClientFrame(sess, win, true, "")218 frame, _ := composeClientFrame(sess, win, true, "")
@@ -228,7 +227,7 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
228 defer releasePTY()227 defer releasePTY()
229 sess.name = "work"228 sess.name = "work"
230 win := sess.activeTab()229 win := sess.activeTab()
231- win.screen = vt.NewScreen(30, 2)230+ win.focusedPane().screen.Resize(30, 2)
232 win.size = domain.Size{Cols: 30, Rows: 2}231 win.size = domain.Size{Cols: 30, Rows: 2}
233 232
234 frame, _ := composeClientFrame(sess, win, true, "ok")233 frame, _ := composeClientFrame(sess, win, true, "ok")
@@ -265,7 +264,7 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
265 defer releasePTY1()264 defer releasePTY1()
266 defer releasePTY2()265 defer releasePTY2()
267 win := sess.activeTab()266 win := sess.activeTab()
268- win.screen = vt.NewScreen(18, 2)267+ win.focusedPane().screen.Resize(18, 2)
269 win.size = domain.Size{Cols: 18, Rows: 2}268 win.size = domain.Size{Cols: 18, Rows: 2}
270 sess.mu.Lock()269 sess.mu.Lock()
271 sess.tabs[1].attention = true270 sess.tabs[1].attention = true
M internal/usecase/keys/router.go +3 -5
@@ -224,16 +224,14 @@ func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
224224
225225 func altArrowCSI(data []byte) (Action, int, bool, bool) {
226226 const seqLen = len("[1;3A")
227- if len(data) > seqLen {
228- return 0, 0, false, false
229- }
230227 if len(data) < seqLen {
231228 return 0, 0, false, hasAltArrowCSIPrefix(data)
232229 }
233- if data[0] != '[' || data[1] != '1' || data[2] != ';' || (data[3] != '3' && data[3] != '9') {
230+ seq := data[:seqLen]
231+ if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') {
234232 return 0, 0, false, false
235233 }
236- switch data[4] {
234+ switch seq[4] {
237235 case 'A':
238236 return ActionFocusPaneUp, seqLen, true, false
239237 case 'B':
@@ -224,16 +224,14 @@ func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
224 224
225 func altArrowCSI(data []byte) (Action, int, bool, bool) {225 func altArrowCSI(data []byte) (Action, int, bool, bool) {
226 const seqLen = len("[1;3A")226 const seqLen = len("[1;3A")
227- if len(data) > seqLen {
228- return 0, 0, false, false
229- }
230 if len(data) < seqLen {227 if len(data) < seqLen {
231 return 0, 0, false, hasAltArrowCSIPrefix(data)228 return 0, 0, false, hasAltArrowCSIPrefix(data)
232 }229 }
233- if data[0] != '[' || data[1] != '1' || data[2] != ';' || (data[3] != '3' && data[3] != '9') {230+ seq := data[:seqLen]
231+ if seq[0] != '[' || seq[1] != '1' || seq[2] != ';' || (seq[3] != '3' && seq[3] != '9') {
234 return 0, 0, false, false232 return 0, 0, false, false
235 }233 }
236- switch data[4] {234+ switch seq[4] {
237 case 'A':235 case 'A':
238 return ActionFocusPaneUp, seqLen, true, false236 return ActionFocusPaneUp, seqLen, true, false
239 case 'B':237 case 'B':
M internal/usecase/keys/router_test.go +44 -0
@@ -301,6 +301,50 @@ func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) {
301301 }
302302 }
303303
304+func TestRouterAltArrowCSIPrefixRouting(t *testing.T) {
305+ cases := []struct {
306+ name string
307+ reads [][]byte
308+ wantActions []Action
309+ wantForwards [][]byte
310+ }{
311+ {
312+ name: "two alt arrows in one read",
313+ reads: [][]byte{{ESC, '[', '1', ';', '3', 'A', ESC, '[', '1', ';', '3', 'A'}},
314+ wantActions: []Action{ActionFocusPaneUp, ActionFocusPaneUp},
315+ },
316+ {
317+ name: "alt arrow followed by normal byte",
318+ reads: [][]byte{{ESC, '[', '1', ';', '3', 'A', 'x'}},
319+ wantActions: []Action{ActionFocusPaneUp},
320+ wantForwards: [][]byte{[]byte("x")},
321+ },
322+ {
323+ name: "bare arrow passthrough",
324+ reads: [][]byte{{ESC, '[', 'A'}},
325+ wantForwards: [][]byte{{ESC, '[', 'A'}},
326+ },
327+ {
328+ name: "retained escape alt arrow across reads",
329+ reads: [][]byte{{ESC}, []byte("[1;3A")},
330+ wantActions: []Action{ActionFocusPaneUp},
331+ },
332+ }
333+
334+ for _, tc := range cases {
335+ t.Run(tc.name, func(t *testing.T) {
336+ clk := &fakeClock{}
337+ h := &captureHandler{}
338+ r := NewRouter(clk, h)
339+ for _, read := range tc.reads {
340+ r.Route(read)
341+ }
342+ require.Equal(t, tc.wantActions, h.actions)
343+ require.Equal(t, tc.wantForwards, h.forwards)
344+ })
345+ }
346+}
347+
304348 func TestRouterPassesThroughBareArrows(t *testing.T) {
305349 for _, in := range [][]byte{
306350 {ESC, '[', 'A'},
@@ -301,6 +301,50 @@ func TestRouterAltArrowCSIPartialsAcrossReads(t *testing.T) {
301 }301 }
302 }302 }
303 303
304+func TestRouterAltArrowCSIPrefixRouting(t *testing.T) {
305+ cases := []struct {
306+ name string
307+ reads [][]byte
308+ wantActions []Action
309+ wantForwards [][]byte
310+ }{
311+ {
312+ name: "two alt arrows in one read",
313+ reads: [][]byte{{ESC, '[', '1', ';', '3', 'A', ESC, '[', '1', ';', '3', 'A'}},
314+ wantActions: []Action{ActionFocusPaneUp, ActionFocusPaneUp},
315+ },
316+ {
317+ name: "alt arrow followed by normal byte",
318+ reads: [][]byte{{ESC, '[', '1', ';', '3', 'A', 'x'}},
319+ wantActions: []Action{ActionFocusPaneUp},
320+ wantForwards: [][]byte{[]byte("x")},
321+ },
322+ {
323+ name: "bare arrow passthrough",
324+ reads: [][]byte{{ESC, '[', 'A'}},
325+ wantForwards: [][]byte{{ESC, '[', 'A'}},
326+ },
327+ {
328+ name: "retained escape alt arrow across reads",
329+ reads: [][]byte{{ESC}, []byte("[1;3A")},
330+ wantActions: []Action{ActionFocusPaneUp},
331+ },
332+ }
333+
334+ for _, tc := range cases {
335+ t.Run(tc.name, func(t *testing.T) {
336+ clk := &fakeClock{}
337+ h := &captureHandler{}
338+ r := NewRouter(clk, h)
339+ for _, read := range tc.reads {
340+ r.Route(read)
341+ }
342+ require.Equal(t, tc.wantActions, h.actions)
343+ require.Equal(t, tc.wantForwards, h.forwards)
344+ })
345+ }
346+}
347+
304 func TestRouterPassesThroughBareArrows(t *testing.T) {348 func TestRouterPassesThroughBareArrows(t *testing.T) {
305 for _, in := range [][]byte{349 for _, in := range [][]byte{
306 {ESC, '[', 'A'},350 {ESC, '[', 'A'},
M internal/usecase/layout/geometry.go +1 -1
@@ -93,7 +93,7 @@ func solveStack(n *Node, r domain.Rect, out *[]Placement) bool {
9393 }
9494 }
9595 if idx < 0 {
96- idx = 0
96+ return false
9797 }
9898 contentRows := r.Height - count
9999 for i, child := range n.Children {
@@ -93,7 +93,7 @@ func solveStack(n *Node, r domain.Rect, out *[]Placement) bool {
93 }93 }
94 }94 }
95 if idx < 0 {95 if idx < 0 {
96- idx = 096+ return false
97 }97 }
98 contentRows := r.Height - count98 contentRows := r.Height - count
99 for i, child := range n.Children {99 for i, child := range n.Children {
M internal/usecase/layout/layout_test.go +178 -0
@@ -136,6 +136,102 @@ func TestTreeSurgeryShapes(t *testing.T) {
136136 })
137137 }
138138
139+func TestToggleStackNestedShapes(t *testing.T) {
140+ t.Parallel()
141+ area := domain.Rect{Width: 62, Height: 5}
142+ tests := []struct {
143+ name string
144+ root *Node
145+ target PaneID
146+ wantKind Kind
147+ wantDir SplitDir
148+ wantErr error
149+ wantLeaves []PaneID
150+ }{
151+ {
152+ name: "nested stack toggles directly containing stack",
153+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
154+ NewLeaf("a"),
155+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "b"},
156+ }},
157+ target: "b",
158+ wantKind: Split,
159+ wantDir: Vertical,
160+ wantLeaves: []PaneID{"b", "c"},
161+ },
162+ {
163+ name: "nested split toggles directly containing two-leaf split",
164+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
165+ NewLeaf("a"),
166+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c")}},
167+ }},
168+ target: "b",
169+ wantKind: Stack,
170+ wantLeaves: []PaneID{"b", "c"},
171+ },
172+ {
173+ name: "nested non toggleable split reports not toggleable",
174+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
175+ NewLeaf("a"),
176+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c"), NewLeaf("d")}},
177+ }},
178+ target: "b",
179+ wantErr: ErrNotToggleable,
180+ },
181+ {
182+ name: "three leaf split reports not toggleable not too small",
183+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
184+ NewLeaf("a"), NewLeaf("b"), NewLeaf("c"),
185+ }},
186+ target: "b",
187+ wantErr: ErrNotToggleable,
188+ },
189+ }
190+ for _, tt := range tests {
191+ t.Run(tt.name, func(t *testing.T) {
192+ tr := &Tree{Root: tt.root, Focus: tt.target}
193+ err := tr.ToggleStack(tt.target, area)
194+ if tt.wantErr != nil {
195+ require.ErrorIs(t, err, tt.wantErr)
196+ require.NotErrorIs(t, err, ErrTooSmall)
197+ return
198+ }
199+ require.NoError(t, err)
200+ container := tr.Root.Children[1]
201+ require.Equal(t, tt.wantKind, container.Kind)
202+ if tt.wantKind == Split {
203+ require.Equal(t, tt.wantDir, container.Dir)
204+ }
205+ require.Equal(t, tt.wantLeaves, LeafIDs(container))
206+ })
207+ }
208+}
209+
210+func TestCloseRefocusPrefersSibling(t *testing.T) {
211+ t.Parallel()
212+ tr := &Tree{Focus: "c", Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
213+ NewLeaf("a"),
214+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c")}},
215+ NewLeaf("d"),
216+ }}}
217+ require.NoError(t, tr.Close("c"))
218+ require.Equal(t, PaneID("b"), tr.Focus)
219+}
220+
221+func TestSolveRejectsStackWithMissingExpanded(t *testing.T) {
222+ t.Parallel()
223+ _, ok := Solve(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "missing"}, domain.Rect{Width: 20, Height: 4})
224+ require.False(t, ok)
225+}
226+
227+func TestExportedLeafHelpers(t *testing.T) {
228+ t.Parallel()
229+ root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("a"), NewLeaf("b")}}
230+ require.True(t, ContainsLeaf(root, "b"))
231+ require.False(t, ContainsLeaf(root, "c"))
232+ require.Equal(t, []PaneID{"a", "b"}, LeafIDs(root))
233+}
234+
139235 func TestFocusDir(t *testing.T) {
140236 t.Parallel()
141237 area := domain.Rect{Width: 62, Height: 5}
@@ -168,6 +264,7 @@ func TestFocusDir(t *testing.T) {
168264 require.Equal(t, PaneID("c"), tr.Root.Expanded)
169265 require.NoError(t, tr.FocusDir(Up, domain.Rect{Width: 20, Height: 4}))
170266 require.Equal(t, PaneID("b"), tr.Focus)
267+ require.Equal(t, PaneID("b"), tr.Root.Expanded)
171268 })
172269
173270 t.Run("no pane in direction", func(t *testing.T) {
@@ -177,6 +274,74 @@ func TestFocusDir(t *testing.T) {
177274 })
178275 }
179276
277+func TestFocusDirEnteringStackExpandsMember(t *testing.T) {
278+ t.Parallel()
279+ tests := []struct {
280+ name string
281+ root *Node
282+ area domain.Rect
283+ focus PaneID
284+ dir Direction
285+ want PaneID
286+ }{
287+ {
288+ name: "from left",
289+ area: domain.Rect{Width: 62, Height: 8},
290+ focus: "a",
291+ dir: Right,
292+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
293+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("x"), NewLeaf("a")}},
294+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c"), NewLeaf("d"), NewLeaf("e")}, Expanded: "b"},
295+ }},
296+ want: "c",
297+ },
298+ {
299+ name: "from right",
300+ area: domain.Rect{Width: 62, Height: 8},
301+ focus: "a",
302+ dir: Left,
303+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
304+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c"), NewLeaf("d"), NewLeaf("e")}, Expanded: "b"},
305+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("x"), NewLeaf("a")}},
306+ }},
307+ want: "c",
308+ },
309+ {
310+ name: "from above",
311+ area: domain.Rect{Width: 41, Height: 7},
312+ focus: "a",
313+ dir: Down,
314+ root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{
315+ NewLeaf("a"),
316+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "c"},
317+ }},
318+ want: "b",
319+ },
320+ {
321+ name: "from below",
322+ area: domain.Rect{Width: 41, Height: 7},
323+ focus: "a",
324+ dir: Up,
325+ root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{
326+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "b"},
327+ NewLeaf("a"),
328+ }},
329+ want: "c",
330+ },
331+ }
332+ for _, tt := range tests {
333+ t.Run(tt.name, func(t *testing.T) {
334+ tr := &Tree{Root: tt.root, Focus: tt.focus}
335+ var stack *Node
336+ findStack(tr.Root, &stack)
337+ require.NotEqual(t, tt.want, stack.Expanded)
338+ require.NoError(t, tr.FocusDir(tt.dir, tt.area))
339+ require.Equal(t, tt.want, tr.Focus)
340+ require.Equal(t, tt.want, stack.Expanded)
341+ })
342+ }
343+}
344+
180345 func contents(ps []Placement) map[PaneID]domain.Rect {
181346 out := make(map[PaneID]domain.Rect, len(ps))
182347 for _, p := range ps {
@@ -184,3 +349,16 @@ func contents(ps []Placement) map[PaneID]domain.Rect {
184349 }
185350 return out
186351 }
352+
353+func findStack(n *Node, out **Node) {
354+ if n == nil || *out != nil {
355+ return
356+ }
357+ if n.Kind == Stack {
358+ *out = n
359+ return
360+ }
361+ for _, child := range n.Children {
362+ findStack(child, out)
363+ }
364+}
@@ -136,6 +136,102 @@ func TestTreeSurgeryShapes(t *testing.T) {
136 })136 })
137 }137 }
138 138
139+func TestToggleStackNestedShapes(t *testing.T) {
140+ t.Parallel()
141+ area := domain.Rect{Width: 62, Height: 5}
142+ tests := []struct {
143+ name string
144+ root *Node
145+ target PaneID
146+ wantKind Kind
147+ wantDir SplitDir
148+ wantErr error
149+ wantLeaves []PaneID
150+ }{
151+ {
152+ name: "nested stack toggles directly containing stack",
153+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
154+ NewLeaf("a"),
155+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "b"},
156+ }},
157+ target: "b",
158+ wantKind: Split,
159+ wantDir: Vertical,
160+ wantLeaves: []PaneID{"b", "c"},
161+ },
162+ {
163+ name: "nested split toggles directly containing two-leaf split",
164+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
165+ NewLeaf("a"),
166+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c")}},
167+ }},
168+ target: "b",
169+ wantKind: Stack,
170+ wantLeaves: []PaneID{"b", "c"},
171+ },
172+ {
173+ name: "nested non toggleable split reports not toggleable",
174+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
175+ NewLeaf("a"),
176+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c"), NewLeaf("d")}},
177+ }},
178+ target: "b",
179+ wantErr: ErrNotToggleable,
180+ },
181+ {
182+ name: "three leaf split reports not toggleable not too small",
183+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
184+ NewLeaf("a"), NewLeaf("b"), NewLeaf("c"),
185+ }},
186+ target: "b",
187+ wantErr: ErrNotToggleable,
188+ },
189+ }
190+ for _, tt := range tests {
191+ t.Run(tt.name, func(t *testing.T) {
192+ tr := &Tree{Root: tt.root, Focus: tt.target}
193+ err := tr.ToggleStack(tt.target, area)
194+ if tt.wantErr != nil {
195+ require.ErrorIs(t, err, tt.wantErr)
196+ require.NotErrorIs(t, err, ErrTooSmall)
197+ return
198+ }
199+ require.NoError(t, err)
200+ container := tr.Root.Children[1]
201+ require.Equal(t, tt.wantKind, container.Kind)
202+ if tt.wantKind == Split {
203+ require.Equal(t, tt.wantDir, container.Dir)
204+ }
205+ require.Equal(t, tt.wantLeaves, LeafIDs(container))
206+ })
207+ }
208+}
209+
210+func TestCloseRefocusPrefersSibling(t *testing.T) {
211+ t.Parallel()
212+ tr := &Tree{Focus: "c", Root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
213+ NewLeaf("a"),
214+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("b"), NewLeaf("c")}},
215+ NewLeaf("d"),
216+ }}}
217+ require.NoError(t, tr.Close("c"))
218+ require.Equal(t, PaneID("b"), tr.Focus)
219+}
220+
221+func TestSolveRejectsStackWithMissingExpanded(t *testing.T) {
222+ t.Parallel()
223+ _, ok := Solve(&Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b")}, Expanded: "missing"}, domain.Rect{Width: 20, Height: 4})
224+ require.False(t, ok)
225+}
226+
227+func TestExportedLeafHelpers(t *testing.T) {
228+ t.Parallel()
229+ root := &Node{Kind: Split, Dir: Horizontal, Children: []*Node{NewLeaf("a"), NewLeaf("b")}}
230+ require.True(t, ContainsLeaf(root, "b"))
231+ require.False(t, ContainsLeaf(root, "c"))
232+ require.Equal(t, []PaneID{"a", "b"}, LeafIDs(root))
233+}
234+
139 func TestFocusDir(t *testing.T) {235 func TestFocusDir(t *testing.T) {
140 t.Parallel()236 t.Parallel()
141 area := domain.Rect{Width: 62, Height: 5}237 area := domain.Rect{Width: 62, Height: 5}
@@ -168,6 +264,7 @@ func TestFocusDir(t *testing.T) {
168 require.Equal(t, PaneID("c"), tr.Root.Expanded)264 require.Equal(t, PaneID("c"), tr.Root.Expanded)
169 require.NoError(t, tr.FocusDir(Up, domain.Rect{Width: 20, Height: 4}))265 require.NoError(t, tr.FocusDir(Up, domain.Rect{Width: 20, Height: 4}))
170 require.Equal(t, PaneID("b"), tr.Focus)266 require.Equal(t, PaneID("b"), tr.Focus)
267+ require.Equal(t, PaneID("b"), tr.Root.Expanded)
171 })268 })
172 269
173 t.Run("no pane in direction", func(t *testing.T) {270 t.Run("no pane in direction", func(t *testing.T) {
@@ -177,6 +274,74 @@ func TestFocusDir(t *testing.T) {
177 })274 })
178 }275 }
179 276
277+func TestFocusDirEnteringStackExpandsMember(t *testing.T) {
278+ t.Parallel()
279+ tests := []struct {
280+ name string
281+ root *Node
282+ area domain.Rect
283+ focus PaneID
284+ dir Direction
285+ want PaneID
286+ }{
287+ {
288+ name: "from left",
289+ area: domain.Rect{Width: 62, Height: 8},
290+ focus: "a",
291+ dir: Right,
292+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
293+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("x"), NewLeaf("a")}},
294+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c"), NewLeaf("d"), NewLeaf("e")}, Expanded: "b"},
295+ }},
296+ want: "c",
297+ },
298+ {
299+ name: "from right",
300+ area: domain.Rect{Width: 62, Height: 8},
301+ focus: "a",
302+ dir: Left,
303+ root: &Node{Kind: Split, Dir: Horizontal, Children: []*Node{
304+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c"), NewLeaf("d"), NewLeaf("e")}, Expanded: "b"},
305+ {Kind: Split, Dir: Vertical, Children: []*Node{NewLeaf("x"), NewLeaf("a")}},
306+ }},
307+ want: "c",
308+ },
309+ {
310+ name: "from above",
311+ area: domain.Rect{Width: 41, Height: 7},
312+ focus: "a",
313+ dir: Down,
314+ root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{
315+ NewLeaf("a"),
316+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "c"},
317+ }},
318+ want: "b",
319+ },
320+ {
321+ name: "from below",
322+ area: domain.Rect{Width: 41, Height: 7},
323+ focus: "a",
324+ dir: Up,
325+ root: &Node{Kind: Split, Dir: Vertical, Children: []*Node{
326+ {Kind: Stack, Children: []*Node{NewLeaf("b"), NewLeaf("c")}, Expanded: "b"},
327+ NewLeaf("a"),
328+ }},
329+ want: "c",
330+ },
331+ }
332+ for _, tt := range tests {
333+ t.Run(tt.name, func(t *testing.T) {
334+ tr := &Tree{Root: tt.root, Focus: tt.focus}
335+ var stack *Node
336+ findStack(tr.Root, &stack)
337+ require.NotEqual(t, tt.want, stack.Expanded)
338+ require.NoError(t, tr.FocusDir(tt.dir, tt.area))
339+ require.Equal(t, tt.want, tr.Focus)
340+ require.Equal(t, tt.want, stack.Expanded)
341+ })
342+ }
343+}
344+
180 func contents(ps []Placement) map[PaneID]domain.Rect {345 func contents(ps []Placement) map[PaneID]domain.Rect {
181 out := make(map[PaneID]domain.Rect, len(ps))346 out := make(map[PaneID]domain.Rect, len(ps))
182 for _, p := range ps {347 for _, p := range ps {
@@ -184,3 +349,16 @@ func contents(ps []Placement) map[PaneID]domain.Rect {
184 }349 }
185 return out350 return out
186 }351 }
352+
353+func findStack(n *Node, out **Node) {
354+ if n == nil || *out != nil {
355+ return
356+ }
357+ if n.Kind == Stack {
358+ *out = n
359+ return
360+ }
361+ for _, child := range n.Children {
362+ findStack(child, out)
363+ }
364+}
M internal/usecase/layout/ops.go +132 -16
@@ -8,9 +8,10 @@ import (
88 )
99
1010 var (
11- ErrNotFound = errors.New("pane not found")
12- ErrTooSmall = errors.New("layout too small")
13- ErrNoPane = errors.New("no pane in direction")
11+ ErrNotFound = errors.New("pane not found")
12+ ErrTooSmall = errors.New("layout too small")
13+ ErrNoPane = errors.New("no pane in direction")
14+ ErrNotToggleable = errors.New("layout node is not toggleable")
1415 )
1516
1617 // Split adds newID next to target. The after flag controls whether the new pane
@@ -108,8 +109,12 @@ func stackNew(n *Node, target, newID PaneID) bool {
108109 // a two-child split, it converts that split to a stack.
109110 func (t *Tree) ToggleStack(target PaneID, area domain.Rect) error {
110111 candidate := t.clone()
111- if !toggleStack(candidate.Root, target) {
112+ result := toggleStack(candidate.Root, target)
113+ switch result {
114+ case toggleNotFound:
112115 return ErrNotFound
116+ case toggleNotToggleable:
117+ return ErrNotToggleable
113118 }
114119 if _, ok := Solve(candidate.Root, area); !ok {
115120 return ErrTooSmall
@@ -118,24 +123,63 @@ func (t *Tree) ToggleStack(target PaneID, area domain.Rect) error {
118123 return nil
119124 }
120125
121-func toggleStack(n *Node, target PaneID) bool {
122- if n.Kind == Stack && containsLeaf(n, target) {
126+type toggleResult int
127+
128+const (
129+ toggleNotFound toggleResult = iota
130+ toggleToggled
131+ toggleNotToggleable
132+)
133+
134+func toggleStack(n *Node, target PaneID) toggleResult {
135+ if n == nil {
136+ return toggleNotFound
137+ }
138+ for _, child := range n.Children {
139+ if child.Kind == Leaf || !containsLeaf(child, target) {
140+ continue
141+ }
142+ result := toggleStack(child, target)
143+ if result != toggleNotFound {
144+ return result
145+ }
146+ }
147+ if !containsLeaf(n, target) {
148+ return toggleNotFound
149+ }
150+ if n.Kind == Stack {
151+ if stackDirectLeafIndex(n, target) < 0 {
152+ return toggleNotToggleable
153+ }
123154 n.Kind = Split
124155 n.Dir = Vertical
125156 n.Expanded = ""
126- return true
157+ return toggleToggled
127158 }
128- if n.Kind == Split && len(n.Children) == 2 && containsLeaf(n, target) {
159+ if n.Kind == Split && len(n.Children) == 2 && splitDirectLeavesContain(n, target) {
129160 n.Kind = Stack
130161 n.Expanded = target
131- return true
162+ return toggleToggled
132163 }
164+ return toggleNotToggleable
165+}
166+
167+func stackDirectLeafIndex(n *Node, target PaneID) int {
168+ for i, child := range n.Children {
169+ if child.Kind == Leaf && child.Leaf == target {
170+ return i
171+ }
172+ }
173+ return -1
174+}
175+
176+func splitDirectLeavesContain(n *Node, target PaneID) bool {
133177 for _, child := range n.Children {
134- if toggleStack(child, target) {
135- return true
178+ if child.Kind != Leaf {
179+ return false
136180 }
137181 }
138- return false
182+ return n.Children[0].Leaf == target || n.Children[1].Leaf == target
139183 }
140184
141185 // Close removes target and dissolves single-child containers.
@@ -148,18 +192,24 @@ func (t *Tree) Close(target PaneID) error {
148192 t.Focus = ""
149193 return nil
150194 }
195+ refocus := closeRefocusCandidate(t.Root, target)
151196 root, removed := closeNode(t.Root, target)
152197 if !removed {
153198 return ErrNotFound
154199 }
155200 t.Root = root
156201 if t.Focus == target {
157- ids := leafIDs(t.Root)
158- if len(ids) > 0 {
159- t.Focus = ids[0]
202+ if refocus != "" && containsLeaf(t.Root, refocus) {
203+ t.Focus = refocus
160204 } else {
161- t.Focus = ""
205+ ids := leafIDs(t.Root)
206+ if len(ids) > 0 {
207+ t.Focus = ids[0]
208+ } else {
209+ t.Focus = ""
210+ }
162211 }
212+ setExpanded(t.Root, t.Focus)
163213 }
164214 return nil
165215 }
@@ -198,6 +248,40 @@ func closeNode(n *Node, target PaneID) (*Node, bool) {
198248 return n, true
199249 }
200250
251+func closeRefocusCandidate(n *Node, target PaneID) PaneID {
252+ if n == nil || n.Kind == Leaf {
253+ return ""
254+ }
255+ for i, child := range n.Children {
256+ if !containsLeaf(child, target) {
257+ continue
258+ }
259+ if child.Kind == Leaf && child.Leaf == target {
260+ if i > 0 {
261+ return lastLeaf(n.Children[i-1])
262+ }
263+ if i+1 < len(n.Children) {
264+ return firstLeaf(n.Children[i+1])
265+ }
266+ return ""
267+ }
268+ if id := closeRefocusCandidate(child, target); id != "" {
269+ return id
270+ }
271+ if i > 0 {
272+ return lastLeaf(n.Children[i-1])
273+ }
274+ if i+1 < len(n.Children) {
275+ return firstLeaf(n.Children[i+1])
276+ }
277+ return ""
278+ }
279+ return ""
280+}
281+
282+// ContainsLeaf reports whether n contains a leaf with id.
283+func ContainsLeaf(n *Node, id PaneID) bool { return containsLeaf(n, id) }
284+
201285 func containsLeaf(n *Node, id PaneID) bool {
202286 if n == nil {
203287 return false
@@ -225,6 +309,37 @@ func firstLeaf(n *Node) PaneID {
225309 return ""
226310 }
227311
312+func lastLeaf(n *Node) PaneID {
313+ if n.Kind == Leaf {
314+ return n.Leaf
315+ }
316+ for i := len(n.Children) - 1; i >= 0; i-- {
317+ if id := lastLeaf(n.Children[i]); id != "" {
318+ return id
319+ }
320+ }
321+ return ""
322+}
323+
324+func setExpanded(n *Node, id PaneID) bool {
325+ if n == nil {
326+ return false
327+ }
328+ if n.Kind == Stack && stackDirectLeafIndex(n, id) >= 0 {
329+ n.Expanded = id
330+ return true
331+ }
332+ for _, child := range n.Children {
333+ if setExpanded(child, id) {
334+ return true
335+ }
336+ }
337+ return false
338+}
339+
340+// LeafIDs returns the leaf ids under n in layout order.
341+func LeafIDs(n *Node) []PaneID { return leafIDs(n) }
342+
228343 func leafIDs(n *Node) []PaneID {
229344 if n == nil {
230345 return nil
@@ -291,6 +406,7 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
291406 return candidates[i].id < candidates[j].id
292407 })
293408 t.Focus = candidates[0].id
409+ setExpanded(t.Root, t.Focus)
294410 return nil
295411 }
296412
@@ -8,9 +8,10 @@ import (
8 )8 )
9 9
10 var (10 var (
11- ErrNotFound = errors.New("pane not found")11+ ErrNotFound = errors.New("pane not found")
12- ErrTooSmall = errors.New("layout too small")12+ ErrTooSmall = errors.New("layout too small")
13- ErrNoPane = errors.New("no pane in direction")13+ ErrNoPane = errors.New("no pane in direction")
14+ ErrNotToggleable = errors.New("layout node is not toggleable")
14 )15 )
15 16
16 // Split adds newID next to target. The after flag controls whether the new pane17 // Split adds newID next to target. The after flag controls whether the new pane
@@ -108,8 +109,12 @@ func stackNew(n *Node, target, newID PaneID) bool {
108 // a two-child split, it converts that split to a stack.109 // a two-child split, it converts that split to a stack.
109 func (t *Tree) ToggleStack(target PaneID, area domain.Rect) error {110 func (t *Tree) ToggleStack(target PaneID, area domain.Rect) error {
110 candidate := t.clone()111 candidate := t.clone()
111- if !toggleStack(candidate.Root, target) {112+ result := toggleStack(candidate.Root, target)
113+ switch result {
114+ case toggleNotFound:
112 return ErrNotFound115 return ErrNotFound
116+ case toggleNotToggleable:
117+ return ErrNotToggleable
113 }118 }
114 if _, ok := Solve(candidate.Root, area); !ok {119 if _, ok := Solve(candidate.Root, area); !ok {
115 return ErrTooSmall120 return ErrTooSmall
@@ -118,24 +123,63 @@ func (t *Tree) ToggleStack(target PaneID, area domain.Rect) error {
118 return nil123 return nil
119 }124 }
120 125
121-func toggleStack(n *Node, target PaneID) bool {126+type toggleResult int
122- if n.Kind == Stack && containsLeaf(n, target) {127+
128+const (
129+ toggleNotFound toggleResult = iota
130+ toggleToggled
131+ toggleNotToggleable
132+)
133+
134+func toggleStack(n *Node, target PaneID) toggleResult {
135+ if n == nil {
136+ return toggleNotFound
137+ }
138+ for _, child := range n.Children {
139+ if child.Kind == Leaf || !containsLeaf(child, target) {
140+ continue
141+ }
142+ result := toggleStack(child, target)
143+ if result != toggleNotFound {
144+ return result
145+ }
146+ }
147+ if !containsLeaf(n, target) {
148+ return toggleNotFound
149+ }
150+ if n.Kind == Stack {
151+ if stackDirectLeafIndex(n, target) < 0 {
152+ return toggleNotToggleable
153+ }
123 n.Kind = Split154 n.Kind = Split
124 n.Dir = Vertical155 n.Dir = Vertical
125 n.Expanded = ""156 n.Expanded = ""
126- return true157+ return toggleToggled
127 }158 }
128- if n.Kind == Split && len(n.Children) == 2 && containsLeaf(n, target) {159+ if n.Kind == Split && len(n.Children) == 2 && splitDirectLeavesContain(n, target) {
129 n.Kind = Stack160 n.Kind = Stack
130 n.Expanded = target161 n.Expanded = target
131- return true162+ return toggleToggled
132 }163 }
164+ return toggleNotToggleable
165+}
166+
167+func stackDirectLeafIndex(n *Node, target PaneID) int {
168+ for i, child := range n.Children {
169+ if child.Kind == Leaf && child.Leaf == target {
170+ return i
171+ }
172+ }
173+ return -1
174+}
175+
176+func splitDirectLeavesContain(n *Node, target PaneID) bool {
133 for _, child := range n.Children {177 for _, child := range n.Children {
134- if toggleStack(child, target) {178+ if child.Kind != Leaf {
135- return true179+ return false
136 }180 }
137 }181 }
138- return false182+ return n.Children[0].Leaf == target || n.Children[1].Leaf == target
139 }183 }
140 184
141 // Close removes target and dissolves single-child containers.185 // Close removes target and dissolves single-child containers.
@@ -148,18 +192,24 @@ func (t *Tree) Close(target PaneID) error {
148 t.Focus = ""192 t.Focus = ""
149 return nil193 return nil
150 }194 }
195+ refocus := closeRefocusCandidate(t.Root, target)
151 root, removed := closeNode(t.Root, target)196 root, removed := closeNode(t.Root, target)
152 if !removed {197 if !removed {
153 return ErrNotFound198 return ErrNotFound
154 }199 }
155 t.Root = root200 t.Root = root
156 if t.Focus == target {201 if t.Focus == target {
157- ids := leafIDs(t.Root)202+ if refocus != "" && containsLeaf(t.Root, refocus) {
158- if len(ids) > 0 {203+ t.Focus = refocus
159- t.Focus = ids[0]
160 } else {204 } else {
161- t.Focus = ""205+ ids := leafIDs(t.Root)
206+ if len(ids) > 0 {
207+ t.Focus = ids[0]
208+ } else {
209+ t.Focus = ""
210+ }
162 }211 }
212+ setExpanded(t.Root, t.Focus)
163 }213 }
164 return nil214 return nil
165 }215 }
@@ -198,6 +248,40 @@ func closeNode(n *Node, target PaneID) (*Node, bool) {
198 return n, true248 return n, true
199 }249 }
200 250
251+func closeRefocusCandidate(n *Node, target PaneID) PaneID {
252+ if n == nil || n.Kind == Leaf {
253+ return ""
254+ }
255+ for i, child := range n.Children {
256+ if !containsLeaf(child, target) {
257+ continue
258+ }
259+ if child.Kind == Leaf && child.Leaf == target {
260+ if i > 0 {
261+ return lastLeaf(n.Children[i-1])
262+ }
263+ if i+1 < len(n.Children) {
264+ return firstLeaf(n.Children[i+1])
265+ }
266+ return ""
267+ }
268+ if id := closeRefocusCandidate(child, target); id != "" {
269+ return id
270+ }
271+ if i > 0 {
272+ return lastLeaf(n.Children[i-1])
273+ }
274+ if i+1 < len(n.Children) {
275+ return firstLeaf(n.Children[i+1])
276+ }
277+ return ""
278+ }
279+ return ""
280+}
281+
282+// ContainsLeaf reports whether n contains a leaf with id.
283+func ContainsLeaf(n *Node, id PaneID) bool { return containsLeaf(n, id) }
284+
201 func containsLeaf(n *Node, id PaneID) bool {285 func containsLeaf(n *Node, id PaneID) bool {
202 if n == nil {286 if n == nil {
203 return false287 return false
@@ -225,6 +309,37 @@ func firstLeaf(n *Node) PaneID {
225 return ""309 return ""
226 }310 }
227 311
312+func lastLeaf(n *Node) PaneID {
313+ if n.Kind == Leaf {
314+ return n.Leaf
315+ }
316+ for i := len(n.Children) - 1; i >= 0; i-- {
317+ if id := lastLeaf(n.Children[i]); id != "" {
318+ return id
319+ }
320+ }
321+ return ""
322+}
323+
324+func setExpanded(n *Node, id PaneID) bool {
325+ if n == nil {
326+ return false
327+ }
328+ if n.Kind == Stack && stackDirectLeafIndex(n, id) >= 0 {
329+ n.Expanded = id
330+ return true
331+ }
332+ for _, child := range n.Children {
333+ if setExpanded(child, id) {
334+ return true
335+ }
336+ }
337+ return false
338+}
339+
340+// LeafIDs returns the leaf ids under n in layout order.
341+func LeafIDs(n *Node) []PaneID { return leafIDs(n) }
342+
228 func leafIDs(n *Node) []PaneID {343 func leafIDs(n *Node) []PaneID {
229 if n == nil {344 if n == nil {
230 return nil345 return nil
@@ -291,6 +406,7 @@ func (t *Tree) FocusDir(dir Direction, area domain.Rect) error {
291 return candidates[i].id < candidates[j].id406 return candidates[i].id < candidates[j].id
292 })407 })
293 t.Focus = candidates[0].id408 t.Focus = candidates[0].id
409+ setExpanded(t.Root, t.Focus)
294 return nil410 return nil
295 }411 }
296 412
M internal/usecase/theme/theme.go +32 -0
@@ -258,15 +258,47 @@ func DimStyle(style renderer.Style, t Theme) renderer.Style {
258258 out.HasForegroundRGB = true
259259 if style.HasForegroundRGB {
260260 out.ForegroundRGB = Blend(style.ForegroundRGB, t.Background, 0.35)
261+ } else if style.Foreground >= 0 {
262+ out.ForegroundRGB = Blend(xterm256Color(style.Foreground), t.Background, 0.35)
261263 } else {
262264 out.ForegroundRGB = Blend(t.Foreground, t.Background, 0.35)
263265 }
266+ out.HasBackgroundRGB = true
264267 if style.HasBackgroundRGB {
265268 out.BackgroundRGB = Blend(style.BackgroundRGB, t.Background, 0.35)
269+ } else if style.Background >= 0 {
270+ out.BackgroundRGB = Blend(xterm256Color(style.Background), t.Background, 0.35)
271+ } else {
272+ out.BackgroundRGB = Blend(t.Background, t.Background, 0.35)
266273 }
267274 return out
268275 }
269276
277+func xterm256Color(index int) renderer.RGB {
278+ if index < 0 {
279+ return renderer.RGB{}
280+ }
281+ base := [...]renderer.RGB{
282+ {R: 0x00, G: 0x00, B: 0x00}, {R: 0x80, G: 0x00, B: 0x00}, {R: 0x00, G: 0x80, B: 0x00}, {R: 0x80, G: 0x80, B: 0x00},
283+ {R: 0x00, G: 0x00, B: 0x80}, {R: 0x80, G: 0x00, B: 0x80}, {R: 0x00, G: 0x80, B: 0x80}, {R: 0xc0, G: 0xc0, B: 0xc0},
284+ {R: 0x80, G: 0x80, B: 0x80}, {R: 0xff, G: 0x00, B: 0x00}, {R: 0x00, G: 0xff, B: 0x00}, {R: 0xff, G: 0xff, B: 0x00},
285+ {R: 0x00, G: 0x00, B: 0xff}, {R: 0xff, G: 0x00, B: 0xff}, {R: 0x00, G: 0xff, B: 0xff}, {R: 0xff, G: 0xff, B: 0xff},
286+ }
287+ if index < len(base) {
288+ return base[index]
289+ }
290+ if index < 232 {
291+ index -= 16
292+ levels := [...]uint8{0, 95, 135, 175, 215, 255}
293+ return renderer.RGB{R: levels[index/36], G: levels[(index/6)%6], B: levels[index%6]}
294+ }
295+ if index < 256 {
296+ level := uint8(8 + (index-232)*10)
297+ return renderer.RGB{R: level, G: level, B: level}
298+ }
299+ return base[index%len(base)]
300+}
301+
270302 func SelectionStyle(t Theme) renderer.Style {
271303 if !usable(t) {
272304 return inverseStyle()
@@ -258,15 +258,47 @@ func DimStyle(style renderer.Style, t Theme) renderer.Style {
258 out.HasForegroundRGB = true258 out.HasForegroundRGB = true
259 if style.HasForegroundRGB {259 if style.HasForegroundRGB {
260 out.ForegroundRGB = Blend(style.ForegroundRGB, t.Background, 0.35)260 out.ForegroundRGB = Blend(style.ForegroundRGB, t.Background, 0.35)
261+ } else if style.Foreground >= 0 {
262+ out.ForegroundRGB = Blend(xterm256Color(style.Foreground), t.Background, 0.35)
261 } else {263 } else {
262 out.ForegroundRGB = Blend(t.Foreground, t.Background, 0.35)264 out.ForegroundRGB = Blend(t.Foreground, t.Background, 0.35)
263 }265 }
266+ out.HasBackgroundRGB = true
264 if style.HasBackgroundRGB {267 if style.HasBackgroundRGB {
265 out.BackgroundRGB = Blend(style.BackgroundRGB, t.Background, 0.35)268 out.BackgroundRGB = Blend(style.BackgroundRGB, t.Background, 0.35)
269+ } else if style.Background >= 0 {
270+ out.BackgroundRGB = Blend(xterm256Color(style.Background), t.Background, 0.35)
271+ } else {
272+ out.BackgroundRGB = Blend(t.Background, t.Background, 0.35)
266 }273 }
267 return out274 return out
268 }275 }
269 276
277+func xterm256Color(index int) renderer.RGB {
278+ if index < 0 {
279+ return renderer.RGB{}
280+ }
281+ base := [...]renderer.RGB{
282+ {R: 0x00, G: 0x00, B: 0x00}, {R: 0x80, G: 0x00, B: 0x00}, {R: 0x00, G: 0x80, B: 0x00}, {R: 0x80, G: 0x80, B: 0x00},
283+ {R: 0x00, G: 0x00, B: 0x80}, {R: 0x80, G: 0x00, B: 0x80}, {R: 0x00, G: 0x80, B: 0x80}, {R: 0xc0, G: 0xc0, B: 0xc0},
284+ {R: 0x80, G: 0x80, B: 0x80}, {R: 0xff, G: 0x00, B: 0x00}, {R: 0x00, G: 0xff, B: 0x00}, {R: 0xff, G: 0xff, B: 0x00},
285+ {R: 0x00, G: 0x00, B: 0xff}, {R: 0xff, G: 0x00, B: 0xff}, {R: 0x00, G: 0xff, B: 0xff}, {R: 0xff, G: 0xff, B: 0xff},
286+ }
287+ if index < len(base) {
288+ return base[index]
289+ }
290+ if index < 232 {
291+ index -= 16
292+ levels := [...]uint8{0, 95, 135, 175, 215, 255}
293+ return renderer.RGB{R: levels[index/36], G: levels[(index/6)%6], B: levels[index%6]}
294+ }
295+ if index < 256 {
296+ level := uint8(8 + (index-232)*10)
297+ return renderer.RGB{R: level, G: level, B: level}
298+ }
299+ return base[index%len(base)]
300+}
301+
270 func SelectionStyle(t Theme) renderer.Style {302 func SelectionStyle(t Theme) renderer.Style {
271 if !usable(t) {303 if !usable(t) {
272 return inverseStyle()304 return inverseStyle()
M internal/usecase/theme/theme_test.go +86 -0
@@ -197,6 +197,92 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
197197 }
198198 }
199199
200+func TestDimStyle(t *testing.T) {
201+ theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
202+ tests := []struct {
203+ name string
204+ in renderer.Style
205+ want renderer.Style
206+ }{
207+ {
208+ name: "default foreground and background dim to theme colors",
209+ in: renderer.DefaultStyle(),
210+ want: renderer.Style{
211+ Foreground: -1,
212+ Background: -1,
213+ HasForegroundRGB: true,
214+ ForegroundRGB: Blend(theme.Foreground, theme.Background, 0.35),
215+ HasBackgroundRGB: true,
216+ BackgroundRGB: theme.Background,
217+ },
218+ },
219+ {
220+ name: "indexed foreground is mapped before dimming",
221+ in: renderer.Style{Foreground: 196, Background: -1},
222+ want: renderer.Style{
223+ Foreground: 196,
224+ Background: -1,
225+ HasForegroundRGB: true,
226+ ForegroundRGB: Blend(renderer.RGB{R: 255, G: 0, B: 0}, theme.Background, 0.35),
227+ HasBackgroundRGB: true,
228+ BackgroundRGB: theme.Background,
229+ },
230+ },
231+ {
232+ name: "indexed foregrounds remain distinct",
233+ in: renderer.Style{Foreground: 46, Background: -1},
234+ want: renderer.Style{
235+ Foreground: 46,
236+ Background: -1,
237+ HasForegroundRGB: true,
238+ ForegroundRGB: Blend(renderer.RGB{R: 0, G: 255, B: 0}, theme.Background, 0.35),
239+ HasBackgroundRGB: true,
240+ BackgroundRGB: theme.Background,
241+ },
242+ },
243+ {
244+ name: "indexed background is mapped before dimming",
245+ in: renderer.Style{Foreground: -1, Background: 21},
246+ want: renderer.Style{
247+ Foreground: -1,
248+ Background: 21,
249+ HasForegroundRGB: true,
250+ ForegroundRGB: Blend(theme.Foreground, theme.Background, 0.35),
251+ HasBackgroundRGB: true,
252+ BackgroundRGB: Blend(renderer.RGB{R: 0, G: 0, B: 255}, theme.Background, 0.35),
253+ },
254+ },
255+ {
256+ name: "rgb foreground and background are dimmed",
257+ in: renderer.Style{
258+ Foreground: -1,
259+ Background: -1,
260+ HasForegroundRGB: true,
261+ ForegroundRGB: renderer.RGB{R: 100, G: 50, B: 25},
262+ HasBackgroundRGB: true,
263+ BackgroundRGB: renderer.RGB{R: 20, G: 100, B: 180},
264+ },
265+ want: renderer.Style{
266+ Foreground: -1,
267+ Background: -1,
268+ HasForegroundRGB: true,
269+ ForegroundRGB: Blend(renderer.RGB{R: 100, G: 50, B: 25}, theme.Background, 0.35),
270+ HasBackgroundRGB: true,
271+ BackgroundRGB: Blend(renderer.RGB{R: 20, G: 100, B: 180}, theme.Background, 0.35),
272+ },
273+ },
274+ }
275+
276+ for _, tt := range tests {
277+ t.Run(tt.name, func(t *testing.T) {
278+ got := DimStyle(tt.in, theme)
279+ if !got.Equal(tt.want) {
280+ t.Fatalf("DimStyle()=%+v want %+v", got, tt.want)
281+ }
282+ })
283+ }
284+}
285+
200286 func TestStyleHelpersFallbackAndThemed(t *testing.T) {
201287 unknown := Theme{}
202288 if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {
@@ -197,6 +197,92 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
197 }197 }
198 }198 }
199 199
200+func TestDimStyle(t *testing.T) {
201+ theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
202+ tests := []struct {
203+ name string
204+ in renderer.Style
205+ want renderer.Style
206+ }{
207+ {
208+ name: "default foreground and background dim to theme colors",
209+ in: renderer.DefaultStyle(),
210+ want: renderer.Style{
211+ Foreground: -1,
212+ Background: -1,
213+ HasForegroundRGB: true,
214+ ForegroundRGB: Blend(theme.Foreground, theme.Background, 0.35),
215+ HasBackgroundRGB: true,
216+ BackgroundRGB: theme.Background,
217+ },
218+ },
219+ {
220+ name: "indexed foreground is mapped before dimming",
221+ in: renderer.Style{Foreground: 196, Background: -1},
222+ want: renderer.Style{
223+ Foreground: 196,
224+ Background: -1,
225+ HasForegroundRGB: true,
226+ ForegroundRGB: Blend(renderer.RGB{R: 255, G: 0, B: 0}, theme.Background, 0.35),
227+ HasBackgroundRGB: true,
228+ BackgroundRGB: theme.Background,
229+ },
230+ },
231+ {
232+ name: "indexed foregrounds remain distinct",
233+ in: renderer.Style{Foreground: 46, Background: -1},
234+ want: renderer.Style{
235+ Foreground: 46,
236+ Background: -1,
237+ HasForegroundRGB: true,
238+ ForegroundRGB: Blend(renderer.RGB{R: 0, G: 255, B: 0}, theme.Background, 0.35),
239+ HasBackgroundRGB: true,
240+ BackgroundRGB: theme.Background,
241+ },
242+ },
243+ {
244+ name: "indexed background is mapped before dimming",
245+ in: renderer.Style{Foreground: -1, Background: 21},
246+ want: renderer.Style{
247+ Foreground: -1,
248+ Background: 21,
249+ HasForegroundRGB: true,
250+ ForegroundRGB: Blend(theme.Foreground, theme.Background, 0.35),
251+ HasBackgroundRGB: true,
252+ BackgroundRGB: Blend(renderer.RGB{R: 0, G: 0, B: 255}, theme.Background, 0.35),
253+ },
254+ },
255+ {
256+ name: "rgb foreground and background are dimmed",
257+ in: renderer.Style{
258+ Foreground: -1,
259+ Background: -1,
260+ HasForegroundRGB: true,
261+ ForegroundRGB: renderer.RGB{R: 100, G: 50, B: 25},
262+ HasBackgroundRGB: true,
263+ BackgroundRGB: renderer.RGB{R: 20, G: 100, B: 180},
264+ },
265+ want: renderer.Style{
266+ Foreground: -1,
267+ Background: -1,
268+ HasForegroundRGB: true,
269+ ForegroundRGB: Blend(renderer.RGB{R: 100, G: 50, B: 25}, theme.Background, 0.35),
270+ HasBackgroundRGB: true,
271+ BackgroundRGB: Blend(renderer.RGB{R: 20, G: 100, B: 180}, theme.Background, 0.35),
272+ },
273+ },
274+ }
275+
276+ for _, tt := range tests {
277+ t.Run(tt.name, func(t *testing.T) {
278+ got := DimStyle(tt.in, theme)
279+ if !got.Equal(tt.want) {
280+ t.Fatalf("DimStyle()=%+v want %+v", got, tt.want)
281+ }
282+ })
283+ }
284+}
285+
200 func TestStyleHelpersFallbackAndThemed(t *testing.T) {286 func TestStyleHelpersFallbackAndThemed(t *testing.T) {
201 unknown := Theme{}287 unknown := Theme{}
202 if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {288 if got := StatusBarStyle(unknown); !got.Equal(renderer.DefaultStyle()) {
M pkg/renderer/renderer.go +19 -3
@@ -36,11 +36,18 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
3636 return nil, err
3737 }
3838
39- // Unchanged frame no-op: same dimensions, shadow populated, no damage, no dirty lines.
40- if r.width == frame.Width && r.height == frame.Height && len(r.shadow) == len(frame.Cells) {
39+ knownSameDimensions := r.width == frame.Width && r.height == frame.Height && len(r.shadow) == len(frame.Cells)
40+
41+ // Unchanged frame no-op: same dimensions, shadow populated, and either no
42+ // damage or a structural redraw request that can be satisfied from the
43+ // known terminal shadow.
44+ if knownSameDimensions {
4145 if len(damage) == 0 && !r.hasDirtyLines(frame) {
4246 return nil, nil
4347 }
48+ if needsFull(damage) && !r.hasDirtyLines(frame) {
49+ return nil, nil
50+ }
4451 }
4552
4653 buf := bufferPool.Get().(*bytes.Buffer)
@@ -55,7 +62,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
5562 // tracking and the SGR pen persist across all rects within one Draw.
5663 st := newDrawState()
5764
58- if r.width != frame.Width || r.height != frame.Height || len(r.shadow) != len(frame.Cells) || needsFull(damage) {
65+ if !knownSameDimensions {
5966 r.writeFull(buf, frame, &st)
6067 r.replaceShadow(frame)
6168 if r.caps.SynchronizedOutput {
@@ -64,6 +71,15 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
6471 return copyBytes(buf), nil
6572 }
6673
74+ if needsFull(damage) {
75+ r.writeDamage(buf, frame, nil, nil, &st)
76+ r.replaceShadow(frame)
77+ if r.caps.SynchronizedOutput {
78+ buf.WriteString(SyncEndCSI)
79+ }
80+ return copyBytes(buf), nil
81+ }
82+
6783 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
6884 emitScrollUp(buf, scroll)
6985 // emitScrollUp resets the SGR pen to default (matching st's initial
@@ -36,11 +36,18 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
36 return nil, err36 return nil, err
37 }37 }
38 38
39- // Unchanged frame no-op: same dimensions, shadow populated, no damage, no dirty lines.39+ knownSameDimensions := r.width == frame.Width && r.height == frame.Height && len(r.shadow) == len(frame.Cells)
40- if r.width == frame.Width && r.height == frame.Height && len(r.shadow) == len(frame.Cells) {40+
41+ // Unchanged frame no-op: same dimensions, shadow populated, and either no
42+ // damage or a structural redraw request that can be satisfied from the
43+ // known terminal shadow.
44+ if knownSameDimensions {
41 if len(damage) == 0 && !r.hasDirtyLines(frame) {45 if len(damage) == 0 && !r.hasDirtyLines(frame) {
42 return nil, nil46 return nil, nil
43 }47 }
48+ if needsFull(damage) && !r.hasDirtyLines(frame) {
49+ return nil, nil
50+ }
44 }51 }
45 52
46 buf := bufferPool.Get().(*bytes.Buffer)53 buf := bufferPool.Get().(*bytes.Buffer)
@@ -55,7 +62,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
55 // tracking and the SGR pen persist across all rects within one Draw.62 // tracking and the SGR pen persist across all rects within one Draw.
56 st := newDrawState()63 st := newDrawState()
57 64
58- if r.width != frame.Width || r.height != frame.Height || len(r.shadow) != len(frame.Cells) || needsFull(damage) {65+ if !knownSameDimensions {
59 r.writeFull(buf, frame, &st)66 r.writeFull(buf, frame, &st)
60 r.replaceShadow(frame)67 r.replaceShadow(frame)
61 if r.caps.SynchronizedOutput {68 if r.caps.SynchronizedOutput {
@@ -64,6 +71,15 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
64 return copyBytes(buf), nil71 return copyBytes(buf), nil
65 }72 }
66 73
74+ if needsFull(damage) {
75+ r.writeDamage(buf, frame, nil, nil, &st)
76+ r.replaceShadow(frame)
77+ if r.caps.SynchronizedOutput {
78+ buf.WriteString(SyncEndCSI)
79+ }
80+ return copyBytes(buf), nil
81+ }
82+
67 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {83 if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
68 emitScrollUp(buf, scroll)84 emitScrollUp(buf, scroll)
69 // emitScrollUp resets the SGR pen to default (matching st's initial85 // emitScrollUp resets the SGR pen to default (matching st's initial
M pkg/renderer/renderer_test.go +71 -22
@@ -552,32 +552,81 @@ func TestReset(t *testing.T) {
552552 func TestFullRedraw(t *testing.T) {
553553 tests := []struct {
554554 name string
555+ run func(t *testing.T)
555556 }{
556- {name: "forces full output even with an unchanged frame"},
557- }
557+ {
558+ name: "known same-size full redraw diffs against shadow",
559+ run: func(t *testing.T) {
560+ r := New(Capabilities{})
561+ frame := NewFrame(3, 2)
562+ markFrame(&frame)
558563
559- for _, tt := range tests {
560- t.Run(tt.name, func(t *testing.T) {
561- r := New(Capabilities{})
562- frame := NewFrame(3, 2)
563- markFrame(&frame)
564+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
565+ t.Fatal(err)
566+ }
564567
565- // Populate.
566- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
567- if err != nil {
568- t.Fatal(err)
569- }
568+ changed := frame
569+ changed.Set(0, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
570+ out, err := r.Draw(changed, []Damage{FullRedraw()})
571+ if err != nil {
572+ t.Fatal(err)
573+ }
574+ got := string(out)
575+ if strings.Contains(got, "ABC") {
576+ t.Fatalf("unchanged row was re-emitted on dimension-stable FullRedraw: %q", got)
577+ }
578+ if !strings.Contains(got, "ZEF") {
579+ t.Fatalf("changed row was not emitted on dimension-stable FullRedraw: %q", got)
580+ }
581+ outputEndsWith(t, out, "\x1b[0m")
582+ },
583+ },
584+ {
585+ name: "unchanged known same-size full redraw is a no-op",
586+ run: func(t *testing.T) {
587+ r := New(Capabilities{})
588+ frame := NewFrame(3, 2)
589+ markFrame(&frame)
570590
571- // FullRedraw forces full output even with same frame.
572- out, err := r.Draw(frame, []Damage{FullRedraw()})
573- if err != nil {
574- t.Fatal(err)
575- }
576- if len(out) == 0 {
577- t.Fatal("expected output for FullRedraw")
578- }
579- outputEndsWith(t, out, "\x1b[0m")
580- })
591+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
592+ t.Fatal(err)
593+ }
594+ out, err := r.Draw(frame, []Damage{FullRedraw()})
595+ if err != nil {
596+ t.Fatal(err)
597+ }
598+ if len(out) != 0 {
599+ t.Fatalf("unchanged dimension-stable FullRedraw emitted output: %q", string(out))
600+ }
601+ },
602+ },
603+ {
604+ name: "reset full redraw emits complete frame",
605+ run: func(t *testing.T) {
606+ r := New(Capabilities{})
607+ frame := NewFrame(3, 2)
608+ markFrame(&frame)
609+
610+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
611+ t.Fatal(err)
612+ }
613+ r.Reset()
614+
615+ out, err := r.Draw(frame, []Damage{FullRedraw()})
616+ if err != nil {
617+ t.Fatal(err)
618+ }
619+ got := string(out)
620+ if !strings.Contains(got, "ABC") || !strings.Contains(got, "DEF") {
621+ t.Fatalf("reset FullRedraw did not emit complete frame: %q", got)
622+ }
623+ outputEndsWith(t, out, "\x1b[0m")
624+ },
625+ },
626+ }
627+
628+ for _, tt := range tests {
629+ t.Run(tt.name, tt.run)
581630 }
582631 }
583632
@@ -552,32 +552,81 @@ func TestReset(t *testing.T) {
552 func TestFullRedraw(t *testing.T) {552 func TestFullRedraw(t *testing.T) {
553 tests := []struct {553 tests := []struct {
554 name string554 name string
555+ run func(t *testing.T)
555 }{556 }{
556- {name: "forces full output even with an unchanged frame"},557+ {
557- }558+ name: "known same-size full redraw diffs against shadow",
559+ run: func(t *testing.T) {
560+ r := New(Capabilities{})
561+ frame := NewFrame(3, 2)
562+ markFrame(&frame)
558 563
559- for _, tt := range tests {564+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
560- t.Run(tt.name, func(t *testing.T) {565+ t.Fatal(err)
561- r := New(Capabilities{})566+ }
562- frame := NewFrame(3, 2)
563- markFrame(&frame)
564 567
565- // Populate.568+ changed := frame
566- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})569+ changed.Set(0, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
567- if err != nil {570+ out, err := r.Draw(changed, []Damage{FullRedraw()})
568- t.Fatal(err)571+ if err != nil {
569- }572+ t.Fatal(err)
573+ }
574+ got := string(out)
575+ if strings.Contains(got, "ABC") {
576+ t.Fatalf("unchanged row was re-emitted on dimension-stable FullRedraw: %q", got)
577+ }
578+ if !strings.Contains(got, "ZEF") {
579+ t.Fatalf("changed row was not emitted on dimension-stable FullRedraw: %q", got)
580+ }
581+ outputEndsWith(t, out, "\x1b[0m")
582+ },
583+ },
584+ {
585+ name: "unchanged known same-size full redraw is a no-op",
586+ run: func(t *testing.T) {
587+ r := New(Capabilities{})
588+ frame := NewFrame(3, 2)
589+ markFrame(&frame)
570 590
571- // FullRedraw forces full output even with same frame.591+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
572- out, err := r.Draw(frame, []Damage{FullRedraw()})592+ t.Fatal(err)
573- if err != nil {593+ }
574- t.Fatal(err)594+ out, err := r.Draw(frame, []Damage{FullRedraw()})
575- }595+ if err != nil {
576- if len(out) == 0 {596+ t.Fatal(err)
577- t.Fatal("expected output for FullRedraw")597+ }
578- }598+ if len(out) != 0 {
579- outputEndsWith(t, out, "\x1b[0m")599+ t.Fatalf("unchanged dimension-stable FullRedraw emitted output: %q", string(out))
580- })600+ }
601+ },
602+ },
603+ {
604+ name: "reset full redraw emits complete frame",
605+ run: func(t *testing.T) {
606+ r := New(Capabilities{})
607+ frame := NewFrame(3, 2)
608+ markFrame(&frame)
609+
610+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
611+ t.Fatal(err)
612+ }
613+ r.Reset()
614+
615+ out, err := r.Draw(frame, []Damage{FullRedraw()})
616+ if err != nil {
617+ t.Fatal(err)
618+ }
619+ got := string(out)
620+ if !strings.Contains(got, "ABC") || !strings.Contains(got, "DEF") {
621+ t.Fatalf("reset FullRedraw did not emit complete frame: %q", got)
622+ }
623+ outputEndsWith(t, out, "\x1b[0m")
624+ },
625+ },
626+ }
627+
628+ for _, tt := range tests {
629+ t.Run(tt.name, tt.run)
581 }630 }
582 }631 }
583 632

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