detached 216dc2e

Merge pull request #75 from bnema/perf/render-pipeline

perf(daemon): single-owner render pipeline
commit
216dc2ed0638278b81e1b4096ed92a109fe9c44e
author
bnema · · 2026-07-12 09:12 UTC
committer
GitHub · · 2026-07-12 09:12 UTC
merge of
6bc1da2 + 22dd029 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
27 files · +2059 -601
A internal/adapters/dgram/transport_replay_test.go +41 -0
@@ -0,0 +1,41 @@
1+package dgram
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// This must stay byte-for-byte equal to the carriage transcript in the stream
11+// transport adapters. Datagram framing is not a rendering operation.
12+func replayTranscript() []ports.Frame {
13+ return []ports.Frame{
14+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
15+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
16+ }
17+}
18+
19+func TestTransportReplay(t *testing.T) {
20+ link := newSimulatedLink(fixedClock{}, packetPolicy{})
21+ leftPC, rightPC := newPairWithCapacity(link, 32)
22+ left, err := NewTransport(leftPC, testAddr("b"), key(), 1, 2)
23+ require.NoError(t, err)
24+ right, err := NewTransport(rightPC, testAddr("a"), key(), 2, 1)
25+ require.NoError(t, err)
26+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
27+
28+ for _, want := range replayTranscript() {
29+ require.NoError(t, left.Send(want))
30+ link.flush(rightPC)
31+ got, err := right.Recv()
32+ require.NoError(t, err)
33+ require.Equal(t, want.Type, got.Type)
34+ require.Equal(t, want.Payload, got.Payload)
35+ out, err := ports.UnmarshalOutput(got.Payload)
36+ require.NoError(t, err)
37+ wantOut, err := ports.UnmarshalOutput(want.Payload)
38+ require.NoError(t, err)
39+ require.Equal(t, wantOut, out)
40+ }
41+}
@@ -0,0 +1,41 @@
1+package dgram
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/ports"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// This must stay byte-for-byte equal to the carriage transcript in the stream
11+// transport adapters. Datagram framing is not a rendering operation.
12+func replayTranscript() []ports.Frame {
13+ return []ports.Frame{
14+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
15+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
16+ }
17+}
18+
19+func TestTransportReplay(t *testing.T) {
20+ link := newSimulatedLink(fixedClock{}, packetPolicy{})
21+ leftPC, rightPC := newPairWithCapacity(link, 32)
22+ left, err := NewTransport(leftPC, testAddr("b"), key(), 1, 2)
23+ require.NoError(t, err)
24+ right, err := NewTransport(rightPC, testAddr("a"), key(), 2, 1)
25+ require.NoError(t, err)
26+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
27+
28+ for _, want := range replayTranscript() {
29+ require.NoError(t, left.Send(want))
30+ link.flush(rightPC)
31+ got, err := right.Recv()
32+ require.NoError(t, err)
33+ require.Equal(t, want.Type, got.Type)
34+ require.Equal(t, want.Payload, got.Payload)
35+ out, err := ports.UnmarshalOutput(got.Payload)
36+ require.NoError(t, err)
37+ wantOut, err := ports.UnmarshalOutput(want.Payload)
38+ require.NoError(t, err)
39+ require.Equal(t, wantOut, out)
40+ }
41+}
A internal/adapters/ipc/transport_replay_test.go +48 -0
@@ -0,0 +1,48 @@
1+package ipc
2+
3+import (
4+ "net"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// replayTranscript is deliberately transport-agnostic: adapters only carry
13+// these already-composed output frames and must not interpret them.
14+func replayTranscript() []ports.Frame {
15+ return []ports.Frame{
16+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
17+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
18+ }
19+}
20+
21+func TestTransportReplay(t *testing.T) {
22+ left, right := net.Pipe()
23+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
24+ sender, receiver := NewTransport(left), NewTransport(right)
25+ frames := replayTranscript()
26+
27+ var wg sync.WaitGroup
28+ wg.Go(func() {
29+ for _, frame := range frames {
30+ if err := sender.Send(frame); err != nil {
31+ t.Errorf("Send: %v", err)
32+ return
33+ }
34+ }
35+ })
36+ for _, want := range frames {
37+ got, err := receiver.Recv()
38+ require.NoError(t, err)
39+ require.Equal(t, want.Type, got.Type)
40+ require.Equal(t, want.Payload, got.Payload)
41+ out, err := ports.UnmarshalOutput(got.Payload)
42+ require.NoError(t, err)
43+ wantOut, err := ports.UnmarshalOutput(want.Payload)
44+ require.NoError(t, err)
45+ require.Equal(t, wantOut, out)
46+ }
47+ wg.Wait()
48+}
@@ -0,0 +1,48 @@
1+package ipc
2+
3+import (
4+ "net"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// replayTranscript is deliberately transport-agnostic: adapters only carry
13+// these already-composed output frames and must not interpret them.
14+func replayTranscript() []ports.Frame {
15+ return []ports.Frame{
16+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
17+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
18+ }
19+}
20+
21+func TestTransportReplay(t *testing.T) {
22+ left, right := net.Pipe()
23+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
24+ sender, receiver := NewTransport(left), NewTransport(right)
25+ frames := replayTranscript()
26+
27+ var wg sync.WaitGroup
28+ wg.Go(func() {
29+ for _, frame := range frames {
30+ if err := sender.Send(frame); err != nil {
31+ t.Errorf("Send: %v", err)
32+ return
33+ }
34+ }
35+ })
36+ for _, want := range frames {
37+ got, err := receiver.Recv()
38+ require.NoError(t, err)
39+ require.Equal(t, want.Type, got.Type)
40+ require.Equal(t, want.Payload, got.Payload)
41+ out, err := ports.UnmarshalOutput(got.Payload)
42+ require.NoError(t, err)
43+ wantOut, err := ports.UnmarshalOutput(want.Payload)
44+ require.NoError(t, err)
45+ require.Equal(t, wantOut, out)
46+ }
47+ wg.Wait()
48+}
A internal/adapters/sshstdio/transport_replay_test.go +50 -0
@@ -0,0 +1,50 @@
1+package sshstdio
2+
3+import (
4+ "io"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// This must stay byte-for-byte equal to the carriage transcript in the other
13+// transport adapters. It is composed before it reaches stdio.
14+func replayTranscript() []ports.Frame {
15+ return []ports.Frame{
16+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
17+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
18+ }
19+}
20+
21+func TestTransportReplay(t *testing.T) {
22+ clientRead, serverWrite := io.Pipe()
23+ serverRead, clientWrite := io.Pipe()
24+ sender := NewTransport(clientRead, clientWrite, func() error { return clientWrite.Close() })
25+ receiver := NewTransport(serverRead, serverWrite, func() error { return serverRead.Close() })
26+ t.Cleanup(func() { _ = sender.Close(); _ = receiver.Close() })
27+ frames := replayTranscript()
28+
29+ var wg sync.WaitGroup
30+ wg.Go(func() {
31+ for _, frame := range frames {
32+ if err := sender.Send(frame); err != nil {
33+ t.Errorf("Send: %v", err)
34+ return
35+ }
36+ }
37+ })
38+ for _, want := range frames {
39+ got, err := receiver.Recv()
40+ require.NoError(t, err)
41+ require.Equal(t, want.Type, got.Type)
42+ require.Equal(t, want.Payload, got.Payload)
43+ out, err := ports.UnmarshalOutput(got.Payload)
44+ require.NoError(t, err)
45+ wantOut, err := ports.UnmarshalOutput(want.Payload)
46+ require.NoError(t, err)
47+ require.Equal(t, wantOut, out)
48+ }
49+ wg.Wait()
50+}
@@ -0,0 +1,50 @@
1+package sshstdio
2+
3+import (
4+ "io"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// This must stay byte-for-byte equal to the carriage transcript in the other
13+// transport adapters. It is composed before it reaches stdio.
14+func replayTranscript() []ports.Frame {
15+ return []ports.Frame{
16+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
17+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
18+ }
19+}
20+
21+func TestTransportReplay(t *testing.T) {
22+ clientRead, serverWrite := io.Pipe()
23+ serverRead, clientWrite := io.Pipe()
24+ sender := NewTransport(clientRead, clientWrite, func() error { return clientWrite.Close() })
25+ receiver := NewTransport(serverRead, serverWrite, func() error { return serverRead.Close() })
26+ t.Cleanup(func() { _ = sender.Close(); _ = receiver.Close() })
27+ frames := replayTranscript()
28+
29+ var wg sync.WaitGroup
30+ wg.Go(func() {
31+ for _, frame := range frames {
32+ if err := sender.Send(frame); err != nil {
33+ t.Errorf("Send: %v", err)
34+ return
35+ }
36+ }
37+ })
38+ for _, want := range frames {
39+ got, err := receiver.Recv()
40+ require.NoError(t, err)
41+ require.Equal(t, want.Type, got.Type)
42+ require.Equal(t, want.Payload, got.Payload)
43+ out, err := ports.UnmarshalOutput(got.Payload)
44+ require.NoError(t, err)
45+ wantOut, err := ports.UnmarshalOutput(want.Payload)
46+ require.NoError(t, err)
47+ require.Equal(t, wantOut, out)
48+ }
49+ wg.Wait()
50+}
M internal/usecase/daemon/bar_refresh.go +4 -3
@@ -309,7 +309,8 @@ func (d *Daemon) pokeSessionRender(sess *session) {
309309 sess.mu.Lock()
310310 ac := sess.client
311311 sess.mu.Unlock()
312- if ac != nil {
313- d.invalidateRender(sess, ac, false, "bar_refresh.go")
314- }
312+ // A headless session can still supply a picker preview to an attachment
313+ // owned by another session. Its coordinator fans this wake out to those
314+ // preview subscribers even though no primary attachment is bound.
315+ d.invalidateRender(sess, ac, false, "bar_refresh.go")
315316 }
@@ -309,7 +309,8 @@ func (d *Daemon) pokeSessionRender(sess *session) {
309 sess.mu.Lock()309 sess.mu.Lock()
310 ac := sess.client310 ac := sess.client
311 sess.mu.Unlock()311 sess.mu.Unlock()
312- if ac != nil {312+ // A headless session can still supply a picker preview to an attachment
313- d.invalidateRender(sess, ac, false, "bar_refresh.go")313+ // owned by another session. Its coordinator fans this wake out to those
314- }314+ // preview subscribers even though no primary attachment is bound.
315+ d.invalidateRender(sess, ac, false, "bar_refresh.go")
315 }316 }
M internal/usecase/daemon/bar_refresh_test.go +12 -0
@@ -277,6 +277,18 @@ func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
277277 require.Empty(t, state.bottomRight)
278278 }
279279
280+func TestPokeSessionRenderWakesHeadlessPreviewCoordinator(t *testing.T) {
281+ d := newBarRefreshTestDaemon(nil, time.Second)
282+ sess := newBarRefreshTestSession()
283+ var invalidations int
284+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(renderInvalidation) { invalidations++ }})
285+ sess.installRenderCoordinator(rc)
286+
287+ d.pokeSessionRender(sess)
288+
289+ require.Equal(t, 1, invalidations)
290+}
291+
280292 func TestBarScriptRefreshIsPerSession(t *testing.T) {
281293 r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
282294 d := newBarRefreshTestDaemon(r, time.Second)
@@ -277,6 +277,18 @@ func TestBarScriptRunIgnoresStaleConfigVersion(t *testing.T) {
277 require.Empty(t, state.bottomRight)277 require.Empty(t, state.bottomRight)
278 }278 }
279 279
280+func TestPokeSessionRenderWakesHeadlessPreviewCoordinator(t *testing.T) {
281+ d := newBarRefreshTestDaemon(nil, time.Second)
282+ sess := newBarRefreshTestSession()
283+ var invalidations int
284+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(renderInvalidation) { invalidations++ }})
285+ sess.installRenderCoordinator(rc)
286+
287+ d.pokeSessionRender(sess)
288+
289+ require.Equal(t, 1, invalidations)
290+}
291+
280 func TestBarScriptRefreshIsPerSession(t *testing.T) {292 func TestBarScriptRefreshIsPerSession(t *testing.T) {
281 r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}293 r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}}
282 d := newBarRefreshTestDaemon(r, time.Second)294 d := newBarRefreshTestDaemon(r, time.Second)
M internal/usecase/daemon/client.go +12 -5
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/vev/internal/domain"
1515 "github.com/bnema/vev/internal/ports"
1616 "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/internal/usecase/layout"
1718 "github.com/bnema/vev/internal/usecase/mouse"
1819 themeui "github.com/bnema/vev/internal/usecase/theme"
1920 )
@@ -35,11 +36,16 @@ type attachedClient struct {
3536 // exact coordinator incarnation. paint rechecks it under sendMu.
3637 coordinatorReadyEpoch atomic.Uint64
3738 echoAck atomic.Uint64
38- bars barCache // only touched while sendMu is held
39- composed composedFrameCache // only touched while sendMu is held
40- resizePaint pendingByteTimer // guarded by sendMu
41- resizePaintGeneration uint64 // guarded by sendMu
42- resizePaintPending bool // guarded by sendMu
39+ // pipelineCache is the last successfully emitted composition. pipelineScratch
40+ // is its attachment-owned alternate buffer; both are only touched under
41+ // sendMu and must never share mutable backing storage.
42+ pipelineCache composeCacheInput
43+ pipelineScratch composeCacheInput
44+ renderScratch renderCaptureScratch // only touched while sendMu is held
45+ captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
46+ resizePaint pendingByteTimer // guarded by sendMu
47+ resizePaintGeneration uint64 // guarded by sendMu
48+ resizePaintPending bool // guarded by sendMu
4349 size domain.Size
4450 keys *keys.Router
4551 sess Guarded[*session]
@@ -48,6 +54,7 @@ type attachedClient struct {
4854 theme themeui.Theme
4955 clientTheme themeui.Theme
5056 lastCursor cursorOut
57+ renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
5158 // previousSession is guarded independently. It is retained through temporary
5259 // setSession(nil) hand-offs and cleared only on terminal teardown.
5360 previousSession Guarded[*session]
@@ -14,6 +14,7 @@ import (
14 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/usecase/keys"16 "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/internal/usecase/layout"
17 "github.com/bnema/vev/internal/usecase/mouse"18 "github.com/bnema/vev/internal/usecase/mouse"
18 themeui "github.com/bnema/vev/internal/usecase/theme"19 themeui "github.com/bnema/vev/internal/usecase/theme"
19 )20 )
@@ -35,11 +36,16 @@ type attachedClient struct {
35 // exact coordinator incarnation. paint rechecks it under sendMu.36 // exact coordinator incarnation. paint rechecks it under sendMu.
36 coordinatorReadyEpoch atomic.Uint6437 coordinatorReadyEpoch atomic.Uint64
37 echoAck atomic.Uint6438 echoAck atomic.Uint64
38- bars barCache // only touched while sendMu is held39+ // pipelineCache is the last successfully emitted composition. pipelineScratch
39- composed composedFrameCache // only touched while sendMu is held40+ // is its attachment-owned alternate buffer; both are only touched under
40- resizePaint pendingByteTimer // guarded by sendMu41+ // sendMu and must never share mutable backing storage.
41- resizePaintGeneration uint64 // guarded by sendMu42+ pipelineCache composeCacheInput
42- resizePaintPending bool // guarded by sendMu43+ pipelineScratch composeCacheInput
44+ renderScratch renderCaptureScratch // only touched while sendMu is held
45+ captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held
46+ resizePaint pendingByteTimer // guarded by sendMu
47+ resizePaintGeneration uint64 // guarded by sendMu
48+ resizePaintPending bool // guarded by sendMu
43 size domain.Size49 size domain.Size
44 keys *keys.Router50 keys *keys.Router
45 sess Guarded[*session]51 sess Guarded[*session]
@@ -48,6 +54,7 @@ type attachedClient struct {
48 theme themeui.Theme54 theme themeui.Theme
49 clientTheme themeui.Theme55 clientTheme themeui.Theme
50 lastCursor cursorOut56 lastCursor cursorOut
57+ renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held
51 // previousSession is guarded independently. It is retained through temporary58 // previousSession is guarded independently. It is retained through temporary
52 // setSession(nil) hand-offs and cleared only on terminal teardown.59 // setSession(nil) hand-offs and cleared only on terminal teardown.
53 previousSession Guarded[*session]60 previousSession Guarded[*session]
M internal/usecase/daemon/copymode.go +0 -5
@@ -402,11 +402,6 @@ func isCopyEscapePrefix(data []byte) bool {
402402 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
403403 }
404404
405-func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
406- styleSet := resolveThemeStyles(styles)
407- return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
408-}
409-
410405 func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
411406 if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
412407 return frame, nil
@@ -402,11 +402,6 @@ func isCopyEscapePrefix(data []byte) bool {
402 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')402 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
403 }403 }
404 404
405-func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
406- styleSet := resolveThemeStyles(styles)
407- return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
408-}
409-
410 func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {405 func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
411 if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {406 if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
412 return frame, nil407 return frame, nil
M internal/usecase/daemon/floating_render.go +12 -46
@@ -85,60 +85,26 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
8585 }
8686 }
8787
88-// composeFloatingFrame applies the popup to a copy of the normal composed
89-// destination. In particular it never writes the VT screen frame: that frame
90-// is shared with the PTY reader and is the source for future renders.
91-func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) {
92- var frame renderer.Frame
93- if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {
94- frame = cache.floatingFrame
95- for y := 0; y < base.Height; y++ {
96- copy(frame.Row(y), base.Row(y))
97- }
98- } else {
99- frame = base.Clone()
100- if cache != nil {
101- cache.floatingFrame = frame
102- }
103- }
104- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
105- // The PTY reader mutates both the frame and its damage under p.mu. Keep
106- // every read of them, including the blit, in the same critical section.
107- // In particular, Frame is not safe to retain as an alias after unlocking.
108- p.mu.Lock()
109- // Committed popup geometry is content-relative; translate it exactly once
110- // while holding the same lock that protects the committed state and frame.
111- geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
112- title := p.displayTitleLocked()
113- titleGeneration := p.title.generation
114- screenDamage := p.screen.Damage()
115- blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme)
88+func composeCapturedFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, floating capturedFloatingRenderState, content domain.Rect, layoutSnap capturedTabLayout, theme themeui.Theme, cache composeCacheInput, full bool) (renderer.Frame, []renderer.Damage) {
89+ frame := base.Clone()
90+ legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
91+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)
92+ geometry := floating.geometry.translate(content.X, content.Y)
93+ blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme)
11694 damage := append([]renderer.Damage(nil), baseDamage...)
117- for _, d := range screenDamage {
95+ for _, d := range floating.pane.damage {
11896 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
11997 }
120- p.screen.ClearDamage()
121- p.mu.Unlock()
122-
123- // The committed geometry, rather than the requested config geometry, is
124- // what was actually rendered. Track it so movement (including a same-size
125- // resize) cannot reuse a cached popup at its former position.
126- popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
127- titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
128- drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
129- if cache != nil {
130- cache.floating = p
131- cache.floatingGeneration = generation
132- cache.floatingGeometry = geometry
133- cache.floatingTitleGeneration = titleGeneration
134- }
98+ popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry
99+ titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
100+ drawFloatingBorder(frame, geometry.Bounds, floating.title, newThemeStyles(theme).border)
135101 if full || popupChanged {
136- return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
102+ return frame, []renderer.Damage{renderer.FullRedraw()}
137103 }
138104 if titleChanged && geometry.Bounds.Height >= 3 {
139105 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
140106 }
141- return frame, damage, geometry
107+ return frame, damage
142108 }
143109
144110 func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
@@ -85,60 +85,26 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
85 }85 }
86 }86 }
87 87
88-// composeFloatingFrame applies the popup to a copy of the normal composed88+func composeCapturedFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, floating capturedFloatingRenderState, content domain.Rect, layoutSnap capturedTabLayout, theme themeui.Theme, cache composeCacheInput, full bool) (renderer.Frame, []renderer.Damage) {
89-// destination. In particular it never writes the VT screen frame: that frame89+ frame := base.Clone()
90-// is shared with the PTY reader and is the source for future renders.90+ legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
91-func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) {91+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)
92- var frame renderer.Frame92+ geometry := floating.geometry.translate(content.X, content.Y)
93- if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {93+ blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme)
94- frame = cache.floatingFrame
95- for y := 0; y < base.Height; y++ {
96- copy(frame.Row(y), base.Row(y))
97- }
98- } else {
99- frame = base.Clone()
100- if cache != nil {
101- cache.floatingFrame = frame
102- }
103- }
104- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
105- // The PTY reader mutates both the frame and its damage under p.mu. Keep
106- // every read of them, including the blit, in the same critical section.
107- // In particular, Frame is not safe to retain as an alias after unlocking.
108- p.mu.Lock()
109- // Committed popup geometry is content-relative; translate it exactly once
110- // while holding the same lock that protects the committed state and frame.
111- geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
112- title := p.displayTitleLocked()
113- titleGeneration := p.title.generation
114- screenDamage := p.screen.Damage()
115- blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme)
116 damage := append([]renderer.Damage(nil), baseDamage...)94 damage := append([]renderer.Damage(nil), baseDamage...)
117- for _, d := range screenDamage {95+ for _, d := range floating.pane.damage {
118 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)96 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
119 }97 }
120- p.screen.ClearDamage()98+ popupChanged := !cache.valid || cache.floatingGeneration != floating.generation || cache.floatingGeometry != geometry
121- p.mu.Unlock()99+ titleChanged := popupChanged || cache.floatingTitleGeneration != floating.titleGeneration
122-100+ drawFloatingBorder(frame, geometry.Bounds, floating.title, newThemeStyles(theme).border)
123- // The committed geometry, rather than the requested config geometry, is
124- // what was actually rendered. Track it so movement (including a same-size
125- // resize) cannot reuse a cached popup at its former position.
126- popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
127- titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
128- drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
129- if cache != nil {
130- cache.floating = p
131- cache.floatingGeneration = generation
132- cache.floatingGeometry = geometry
133- cache.floatingTitleGeneration = titleGeneration
134- }
135 if full || popupChanged {101 if full || popupChanged {
136- return frame, []renderer.Damage{renderer.FullRedraw()}, geometry102+ return frame, []renderer.Damage{renderer.FullRedraw()}
137 }103 }
138 if titleChanged && geometry.Bounds.Height >= 3 {104 if titleChanged && geometry.Bounds.Height >= 3 {
139 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})105 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
140 }106 }
141- return frame, damage, geometry107+ return frame, damage
142 }108 }
143 109
144 func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {110 func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
A internal/usecase/daemon/floating_render_legacy_test.go +72 -0
@@ -0,0 +1,72 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// composeFloatingFrame applies the popup to a copy of the normal composed
10+// destination. In particular it never writes the VT screen frame: that frame
11+// is shared with the PTY reader and is the source for future renders.
12+func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) {
13+ var frame renderer.Frame
14+ if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {
15+ frame = cache.floatingFrame
16+ for y := 0; y < base.Height; y++ {
17+ copy(frame.Row(y), base.Row(y))
18+ }
19+ } else {
20+ frame = base.Clone()
21+ if cache != nil {
22+ cache.floatingFrame = frame
23+ }
24+ }
25+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
26+ // The PTY reader mutates both the frame and its damage under p.mu. Keep
27+ // every read of them, including the blit, in the same critical section.
28+ // In particular, Frame is not safe to retain as an alias after unlocking.
29+ p.mu.Lock()
30+ // Committed popup geometry is content-relative; translate it exactly once
31+ // while holding the same lock that protects the committed state and frame.
32+ geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
33+ previousCapture := capturedPaneRenderState{}
34+ if cache != nil {
35+ previousCapture = cache.floatingCaptured
36+ }
37+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture)
38+ // This retained legacy test helper models its historical synchronous
39+ // consume path; production commits receipts in emitFrame instead.
40+ p.screen.AcknowledgeDamage(captured.damageGeneration)
41+ if cache != nil {
42+ cache.floatingCaptured = captured
43+ }
44+ p.mu.Unlock()
45+ title := captured.title
46+ titleGeneration := captured.titleGeneration
47+ blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme)
48+ damage := append([]renderer.Damage(nil), baseDamage...)
49+ for _, d := range captured.damage {
50+ damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
51+ }
52+
53+ // The committed geometry, rather than the requested config geometry, is
54+ // what was actually rendered. Track it so movement (including a same-size
55+ // resize) cannot reuse a cached popup at its former position.
56+ popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
57+ titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
58+ drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
59+ if cache != nil {
60+ cache.floating = p
61+ cache.floatingGeneration = generation
62+ cache.floatingGeometry = geometry
63+ cache.floatingTitleGeneration = titleGeneration
64+ }
65+ if full || popupChanged {
66+ return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
67+ }
68+ if titleChanged && geometry.Bounds.Height >= 3 {
69+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
70+ }
71+ return frame, damage, geometry
72+}
@@ -0,0 +1,72 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ themeui "github.com/bnema/vev/internal/usecase/theme"
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+// composeFloatingFrame applies the popup to a copy of the normal composed
10+// destination. In particular it never writes the VT screen frame: that frame
11+// is shared with the PTY reader and is the source for future renders.
12+func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) {
13+ var frame renderer.Frame
14+ if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {
15+ frame = cache.floatingFrame
16+ for y := 0; y < base.Height; y++ {
17+ copy(frame.Row(y), base.Row(y))
18+ }
19+ } else {
20+ frame = base.Clone()
21+ if cache != nil {
22+ cache.floatingFrame = frame
23+ }
24+ }
25+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
26+ // The PTY reader mutates both the frame and its damage under p.mu. Keep
27+ // every read of them, including the blit, in the same critical section.
28+ // In particular, Frame is not safe to retain as an alias after unlocking.
29+ p.mu.Lock()
30+ // Committed popup geometry is content-relative; translate it exactly once
31+ // while holding the same lock that protects the committed state and frame.
32+ geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
33+ previousCapture := capturedPaneRenderState{}
34+ if cache != nil {
35+ previousCapture = cache.floatingCaptured
36+ }
37+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture)
38+ // This retained legacy test helper models its historical synchronous
39+ // consume path; production commits receipts in emitFrame instead.
40+ p.screen.AcknowledgeDamage(captured.damageGeneration)
41+ if cache != nil {
42+ cache.floatingCaptured = captured
43+ }
44+ p.mu.Unlock()
45+ title := captured.title
46+ titleGeneration := captured.titleGeneration
47+ blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme)
48+ damage := append([]renderer.Damage(nil), baseDamage...)
49+ for _, d := range captured.damage {
50+ damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
51+ }
52+
53+ // The committed geometry, rather than the requested config geometry, is
54+ // what was actually rendered. Track it so movement (including a same-size
55+ // resize) cannot reuse a cached popup at its former position.
56+ popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
57+ titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
58+ drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
59+ if cache != nil {
60+ cache.floating = p
61+ cache.floatingGeneration = generation
62+ cache.floatingGeometry = geometry
63+ cache.floatingTitleGeneration = titleGeneration
64+ }
65+ if full || popupChanged {
66+ return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
67+ }
68+ if titleChanged && geometry.Bounds.Height >= 3 {
69+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
70+ }
71+ return frame, damage, geometry
72+}
M internal/usecase/daemon/mouse_panes.go +0 -4
@@ -64,10 +64,6 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
6464 return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}
6565 }
6666
67-func (snap tabLayoutSnapshot) matchesLocked(tb *tab) bool {
68- return snap.ok && tb != nil && tb.tree != nil && tb.tree.Root == snap.root && tb.tree.Focus == snap.focus && tb.size.Cols == snap.area.Width && tb.size.Rows == snap.area.Height && snap.fingerprint == layoutFingerprint(tb.tree.Root)
69-}
70-
7167 func layoutFingerprint(root *layout.Node) string {
7268 var b strings.Builder
7369 writeLayoutFingerprint(&b, root)
@@ -64,10 +64,6 @@ func solveTabLayoutLocked(tb *tab) tabLayoutSnapshot {
64 return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}64 return tabLayoutSnapshot{root: tb.tree.Root, fingerprint: layoutFingerprint(tb.tree.Root), area: area, focus: tb.tree.Focus, placements: placements, ok: ok}
65 }65 }
66 66
67-func (snap tabLayoutSnapshot) matchesLocked(tb *tab) bool {
68- return snap.ok && tb != nil && tb.tree != nil && tb.tree.Root == snap.root && tb.tree.Focus == snap.focus && tb.size.Cols == snap.area.Width && tb.size.Rows == snap.area.Height && snap.fingerprint == layoutFingerprint(tb.tree.Root)
69-}
70-
71 func layoutFingerprint(root *layout.Node) string {67 func layoutFingerprint(root *layout.Node) string {
72 var b strings.Builder68 var b strings.Builder
73 writeLayoutFingerprint(&b, root)69 writeLayoutFingerprint(&b, root)
M internal/usecase/daemon/performance_benchmark_test.go +69 -0
@@ -49,6 +49,9 @@ func TestPerformanceFixtureCounters(t *testing.T) {
4949 fixture.paintLive()
5050 metrics := fixture.metrics()
5151 require.Equal(t, uint64(2), metrics.outputFrames)
52+ require.Equal(t, uint64(2), metrics.renderCaptures, "capture is one immutable state snapshot per live render request")
53+ require.Equal(t, uint64(2), metrics.renderCompositions, "composition is one frame built from each captured state")
54+ require.Equal(t, uint64(2), metrics.renderEmissions, "emission is one accepted output frame")
5255 require.Positive(t, metrics.outputBytes)
5356 require.Positive(t, metrics.outputPayloadBytes)
5457 require.Equal(t, uint64(2), metrics.coordinatorInvalidations)
@@ -68,6 +71,27 @@ func TestPerformanceFixtureCounters(t *testing.T) {
6871 require.True(t, fixture.resized())
6972 }
7073
74+func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
75+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
76+ d.paint(sess, ac, true)
77+ <-sends
78+ var captures, compositions, emissions int
79+ ac.renderStages = renderStageHooks{
80+ capture: func() { captures++ },
81+ compose: func() { compositions++ },
82+ emit: func() { emissions++ },
83+ }
84+ ac.replaceTransport(cacheFailTransport{})
85+ p := sess.tabs[0].focusedPane()
86+ p.mu.Lock()
87+ p.screen.Write([]byte("hook"))
88+ p.mu.Unlock()
89+ d.paint(sess, ac, false)
90+ require.Equal(t, 1, captures, "a completed capture counts even when its emission fails")
91+ require.Equal(t, 1, compositions, "a completed composition counts even when its emission fails")
92+ require.Zero(t, emissions, "emit counts only prepare and transport success")
93+}
94+
7195 func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) {
7296 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}})
7397
@@ -206,10 +230,40 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
206230 b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op")
207231 b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op")
208232 b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op")
233+ // Split counters have precise fixture definitions: capture is an immutable
234+ // state snapshot requested for a live paint; composition builds the frame
235+ // from that capture; emission accepts its output frame. Keep bytes/frames
236+ // above as transport observables rather than folding them into these rates.
237+ b.ReportMetric(float64(metrics.renderCaptures)/perOperation, "rendercaptures/op")
238+ b.ReportMetric(float64(metrics.renderCompositions)/perOperation, "rendercompositions/op")
239+ b.ReportMetric(float64(metrics.renderEmissions)/perOperation, "renderemissions/op")
209240 b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio")
210241 b.ReportMetric(float64(historyRows), "historyrows/pane")
211242 }
212243
244+// Root-cause hypothesis (verified by this guard before the fix): S2 allocates
245+// one visible VT-frame copy during capture plus a new composed frame and its
246+// base-frame clone for every live paint. Those client-sized copies, rather
247+// than history, account for the ~1 MiB/op regression in the pinned benchmark.
248+func TestLivePaintAllocationBudget(t *testing.T) {
249+ for _, tt := range []struct {
250+ name string
251+ panes int
252+ }{
253+ {name: "1tab-1pane", panes: 1},
254+ {name: "1tab-4panes", panes: 4},
255+ } {
256+ t.Run(tt.name, func(t *testing.T) {
257+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})
258+ allocs := testing.AllocsPerRun(20, func() {
259+ fixture.paintLive()
260+ fixture.ac.ackOutputState(fixture.ac.output.next)
261+ })
262+ require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints")
263+ })
264+ }
265+}
266+
213267 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {
214268 fixture := newPerformanceFixture(t, performanceConfig{})
215269
@@ -243,6 +297,9 @@ type performanceMetrics struct {
243297 coordinatorInvalidations uint64
244298 coordinatorWakes uint64
245299 coordinatorCoalesced uint64
300+ renderCaptures uint64
301+ renderCompositions uint64
302+ renderEmissions uint64
246303 }
247304
248305 type performanceFixture struct {
@@ -259,6 +316,9 @@ type performanceFixture struct {
259316 resizes int
260317 resizedSize domain.Size
261318 coordinatorBaseline renderCoordinatorBurstMetricsSnapshot
319+ renderCaptures uint64
320+ renderCompositions uint64
321+ renderEmissions uint64
262322 }
263323
264324 func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {
@@ -303,6 +363,11 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
303363 // The fixture uses the production coordinator. stubClock's inert timer is
304364 // completed synchronously by the coordinator, keeping benchmark operations
305365 // deterministic while retaining their real invalidation path.
366+ ac.renderStages = renderStageHooks{
367+ capture: func() { fixture.renderCaptures++ },
368+ compose: func() { fixture.renderCompositions++ },
369+ emit: func() { fixture.renderEmissions++ },
370+ }
306371 d.attachCoordinator(sess, nil, ac, true)
307372
308373 // Prime the real renderer shadow before measurements. Subsequent paints use
@@ -415,6 +480,9 @@ func (f *performanceFixture) resized() bool {
415480 func (f *performanceFixture) resetMetrics() {
416481 f.output.reset()
417482 f.snaps.reset()
483+ f.renderCaptures = 0
484+ f.renderCompositions = 0
485+ f.renderEmissions = 0
418486 if rc := f.sess.renderCoordinator(); rc != nil {
419487 f.coordinatorBaseline = rc.burstMetricsSnapshot()
420488 }
@@ -426,6 +494,7 @@ func (f *performanceFixture) metrics() performanceMetrics {
426494 metrics := performanceMetrics{
427495 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
428496 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
497+ renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,
429498 }
430499 if rc := f.sess.renderCoordinator(); rc != nil {
431500 coordinator := rc.burstMetricsSnapshot()
@@ -49,6 +49,9 @@ func TestPerformanceFixtureCounters(t *testing.T) {
49 fixture.paintLive()49 fixture.paintLive()
50 metrics := fixture.metrics()50 metrics := fixture.metrics()
51 require.Equal(t, uint64(2), metrics.outputFrames)51 require.Equal(t, uint64(2), metrics.outputFrames)
52+ require.Equal(t, uint64(2), metrics.renderCaptures, "capture is one immutable state snapshot per live render request")
53+ require.Equal(t, uint64(2), metrics.renderCompositions, "composition is one frame built from each captured state")
54+ require.Equal(t, uint64(2), metrics.renderEmissions, "emission is one accepted output frame")
52 require.Positive(t, metrics.outputBytes)55 require.Positive(t, metrics.outputBytes)
53 require.Positive(t, metrics.outputPayloadBytes)56 require.Positive(t, metrics.outputPayloadBytes)
54 require.Equal(t, uint64(2), metrics.coordinatorInvalidations)57 require.Equal(t, uint64(2), metrics.coordinatorInvalidations)
@@ -68,6 +71,27 @@ func TestPerformanceFixtureCounters(t *testing.T) {
68 require.True(t, fixture.resized())71 require.True(t, fixture.resized())
69 }72 }
70 73
74+func TestRenderStageHooksCountProductionBoundariesOnFailedSend(t *testing.T) {
75+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
76+ d.paint(sess, ac, true)
77+ <-sends
78+ var captures, compositions, emissions int
79+ ac.renderStages = renderStageHooks{
80+ capture: func() { captures++ },
81+ compose: func() { compositions++ },
82+ emit: func() { emissions++ },
83+ }
84+ ac.replaceTransport(cacheFailTransport{})
85+ p := sess.tabs[0].focusedPane()
86+ p.mu.Lock()
87+ p.screen.Write([]byte("hook"))
88+ p.mu.Unlock()
89+ d.paint(sess, ac, false)
90+ require.Equal(t, 1, captures, "a completed capture counts even when its emission fails")
91+ require.Equal(t, 1, compositions, "a completed composition counts even when its emission fails")
92+ require.Zero(t, emissions, "emit counts only prepare and transport success")
93+}
94+
71 func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) {95 func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) {
72 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}})96 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}})
73 97
@@ -206,10 +230,40 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
206 b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op")230 b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op")
207 b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op")231 b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op")
208 b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op")232 b.ReportMetric(float64(metrics.coordinatorCoalesced)/perOperation, "coordinatorcoalesced/op")
233+ // Split counters have precise fixture definitions: capture is an immutable
234+ // state snapshot requested for a live paint; composition builds the frame
235+ // from that capture; emission accepts its output frame. Keep bytes/frames
236+ // above as transport observables rather than folding them into these rates.
237+ b.ReportMetric(float64(metrics.renderCaptures)/perOperation, "rendercaptures/op")
238+ b.ReportMetric(float64(metrics.renderCompositions)/perOperation, "rendercompositions/op")
239+ b.ReportMetric(float64(metrics.renderEmissions)/perOperation, "renderemissions/op")
209 b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio")240 b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio")
210 b.ReportMetric(float64(historyRows), "historyrows/pane")241 b.ReportMetric(float64(historyRows), "historyrows/pane")
211 }242 }
212 243
244+// Root-cause hypothesis (verified by this guard before the fix): S2 allocates
245+// one visible VT-frame copy during capture plus a new composed frame and its
246+// base-frame clone for every live paint. Those client-sized copies, rather
247+// than history, account for the ~1 MiB/op regression in the pinned benchmark.
248+func TestLivePaintAllocationBudget(t *testing.T) {
249+ for _, tt := range []struct {
250+ name string
251+ panes int
252+ }{
253+ {name: "1tab-1pane", panes: 1},
254+ {name: "1tab-4panes", panes: 4},
255+ } {
256+ t.Run(tt.name, func(t *testing.T) {
257+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})
258+ allocs := testing.AllocsPerRun(20, func() {
259+ fixture.paintLive()
260+ fixture.ac.ackOutputState(fixture.ac.output.next)
261+ })
262+ require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints")
263+ })
264+ }
265+}
266+
213 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {267 func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {
214 fixture := newPerformanceFixture(t, performanceConfig{})268 fixture := newPerformanceFixture(t, performanceConfig{})
215 269
@@ -243,6 +297,9 @@ type performanceMetrics struct {
243 coordinatorInvalidations uint64297 coordinatorInvalidations uint64
244 coordinatorWakes uint64298 coordinatorWakes uint64
245 coordinatorCoalesced uint64299 coordinatorCoalesced uint64
300+ renderCaptures uint64
301+ renderCompositions uint64
302+ renderEmissions uint64
246 }303 }
247 304
248 type performanceFixture struct {305 type performanceFixture struct {
@@ -259,6 +316,9 @@ type performanceFixture struct {
259 resizes int316 resizes int
260 resizedSize domain.Size317 resizedSize domain.Size
261 coordinatorBaseline renderCoordinatorBurstMetricsSnapshot318 coordinatorBaseline renderCoordinatorBurstMetricsSnapshot
319+ renderCaptures uint64
320+ renderCompositions uint64
321+ renderEmissions uint64
262 }322 }
263 323
264 func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {324 func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {
@@ -303,6 +363,11 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF
303 // The fixture uses the production coordinator. stubClock's inert timer is363 // The fixture uses the production coordinator. stubClock's inert timer is
304 // completed synchronously by the coordinator, keeping benchmark operations364 // completed synchronously by the coordinator, keeping benchmark operations
305 // deterministic while retaining their real invalidation path.365 // deterministic while retaining their real invalidation path.
366+ ac.renderStages = renderStageHooks{
367+ capture: func() { fixture.renderCaptures++ },
368+ compose: func() { fixture.renderCompositions++ },
369+ emit: func() { fixture.renderEmissions++ },
370+ }
306 d.attachCoordinator(sess, nil, ac, true)371 d.attachCoordinator(sess, nil, ac, true)
307 372
308 // Prime the real renderer shadow before measurements. Subsequent paints use373 // Prime the real renderer shadow before measurements. Subsequent paints use
@@ -415,6 +480,9 @@ func (f *performanceFixture) resized() bool {
415 func (f *performanceFixture) resetMetrics() {480 func (f *performanceFixture) resetMetrics() {
416 f.output.reset()481 f.output.reset()
417 f.snaps.reset()482 f.snaps.reset()
483+ f.renderCaptures = 0
484+ f.renderCompositions = 0
485+ f.renderEmissions = 0
418 if rc := f.sess.renderCoordinator(); rc != nil {486 if rc := f.sess.renderCoordinator(); rc != nil {
419 f.coordinatorBaseline = rc.burstMetricsSnapshot()487 f.coordinatorBaseline = rc.burstMetricsSnapshot()
420 }488 }
@@ -426,6 +494,7 @@ func (f *performanceFixture) metrics() performanceMetrics {
426 metrics := performanceMetrics{494 metrics := performanceMetrics{
427 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,495 outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
428 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,496 snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
497+ renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions,
429 }498 }
430 if rc := f.sess.renderCoordinator(); rc != nil {499 if rc := f.sess.renderCoordinator(); rc != nil {
431 coordinator := rc.burstMetricsSnapshot()500 coordinator := rc.burstMetricsSnapshot()
M internal/usecase/daemon/picker.go +44 -35
@@ -9,7 +9,6 @@ import (
99 "github.com/bnema/vev/internal/usecase/keys"
1010 "github.com/bnema/vev/internal/usecase/layout"
1111 "github.com/bnema/vev/internal/usecase/picker"
12- themeui "github.com/bnema/vev/internal/usecase/theme"
1312 "github.com/bnema/vev/internal/usecase/ui"
1413 "github.com/bnema/vev/pkg/renderer"
1514 )
@@ -497,49 +496,59 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
497496 d.registerPreviewForSelection(ac)
498497 }
499498
500-func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
501- styleSet := resolveThemeStyles(styles)
502- renderStyles := picker.RenderStyles{
503- Selection: styleSet.selection,
504- SelectionName: styleSet.pickerSelectionName,
505- SelectionMuted: styleSet.pickerSelectionMuted,
506- Name: styleSet.pickerName,
507- Detail: styleSet.paletteDesc,
508- Base: renderer.DefaultStyle(),
509- }
510- return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
511- return model.Render(size, preview, renderStyles)
512- })
513-}
514-
515499 func snapshotPickerPreview(tb *tab) picker.Preview {
516500 if tb == nil {
517501 return picker.Preview{}
518502 }
519503 tb.mu.Lock()
520- defer tb.mu.Unlock()
521- return pickerPreviewFromLockedTab(tb)
504+ layoutSnap := solveTabLayoutLocked(tb)
505+ if !layoutSnap.ok {
506+ p := tb.focusedPane()
507+ if p == nil {
508+ tb.mu.Unlock()
509+ return picker.Preview{}
510+ }
511+ p.mu.Lock()
512+ preview := pickerPreviewFromLockedPane(p)
513+ p.mu.Unlock()
514+ tb.mu.Unlock()
515+ return preview
516+ }
517+ state := capturedRenderState{layout: capturedTabLayout{
518+ root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus,
519+ placements: append([]layout.Placement(nil), layoutSnap.placements...),
520+ fingerprint: layoutSnap.fingerprint, valid: true,
521+ }}
522+ for _, placement := range layoutSnap.placements {
523+ p := tb.panes[placement.ID]
524+ if p == nil {
525+ continue
526+ }
527+ visible := placement.Content
528+ if placement.Collapsed {
529+ visible = domain.Rect{}
530+ }
531+ p.mu.Lock()
532+ captured := capturePaneRenderStateLocked(p, visible, damageCapturePreview)
533+ p.mu.Unlock()
534+ captured.placement = placement
535+ captured.focused = placement.ID == layoutSnap.focus
536+ state.panes = append(state.panes, captured)
537+ }
538+ tb.mu.Unlock()
539+ return pickerPreviewFromCapturedRender(state)
522540 }
523541
524-func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
525- p := tb.focusedPane()
526- if p == nil {
527- return picker.Preview{}
528- }
529- if tb.tree == nil || tb.tree.Root == nil || tb.tree.Root.Kind == layout.Leaf {
530- p.mu.Lock()
531- defer p.mu.Unlock()
532- return pickerPreviewFromLockedPane(p)
542+func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
543+ composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame
544+ if composed.Height < 2 {
545+ return pickerPreviewFromFrame(composed)
533546 }
534-
535- area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
536- if area.Width <= 0 || area.Height <= 0 {
537- p.mu.Lock()
538- defer p.mu.Unlock()
539- return pickerPreviewFromLockedPane(p)
547+ rows := make([][]renderer.Cell, composed.Height-2)
548+ for y := range rows {
549+ rows[y] = append([]renderer.Cell(nil), composed.Row(y+1)...)
540550 }
541- frame, _ := composeTabFrame(tb, area, themeui.Theme{})
542- return pickerPreviewFromFrame(frame)
551+ return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)}
543552 }
544553
545554 func pickerPreviewFromLockedPane(p *pane) picker.Preview {
@@ -9,7 +9,6 @@ import (
9 "github.com/bnema/vev/internal/usecase/keys"9 "github.com/bnema/vev/internal/usecase/keys"
10 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
11 "github.com/bnema/vev/internal/usecase/picker"11 "github.com/bnema/vev/internal/usecase/picker"
12- themeui "github.com/bnema/vev/internal/usecase/theme"
13 "github.com/bnema/vev/internal/usecase/ui"12 "github.com/bnema/vev/internal/usecase/ui"
14 "github.com/bnema/vev/pkg/renderer"13 "github.com/bnema/vev/pkg/renderer"
15 )14 )
@@ -497,49 +496,59 @@ func (d *Daemon) refreshPicker(ac *attachedClient) {
497 d.registerPreviewForSelection(ac)496 d.registerPreviewForSelection(ac)
498 }497 }
499 498
500-func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
501- styleSet := resolveThemeStyles(styles)
502- renderStyles := picker.RenderStyles{
503- Selection: styleSet.selection,
504- SelectionName: styleSet.pickerSelectionName,
505- SelectionMuted: styleSet.pickerSelectionMuted,
506- Name: styleSet.pickerName,
507- Detail: styleSet.paletteDesc,
508- Base: renderer.DefaultStyle(),
509- }
510- return composeModalClientFrame(base, pickerModal, styleSet, styleSet.selection, func(size domain.Size, _ ...renderer.Style) renderer.Frame {
511- return model.Render(size, preview, renderStyles)
512- })
513-}
514-
515 func snapshotPickerPreview(tb *tab) picker.Preview {499 func snapshotPickerPreview(tb *tab) picker.Preview {
516 if tb == nil {500 if tb == nil {
517 return picker.Preview{}501 return picker.Preview{}
518 }502 }
519 tb.mu.Lock()503 tb.mu.Lock()
520- defer tb.mu.Unlock()504+ layoutSnap := solveTabLayoutLocked(tb)
521- return pickerPreviewFromLockedTab(tb)505+ if !layoutSnap.ok {
506+ p := tb.focusedPane()
507+ if p == nil {
508+ tb.mu.Unlock()
509+ return picker.Preview{}
510+ }
511+ p.mu.Lock()
512+ preview := pickerPreviewFromLockedPane(p)
513+ p.mu.Unlock()
514+ tb.mu.Unlock()
515+ return preview
516+ }
517+ state := capturedRenderState{layout: capturedTabLayout{
518+ root: tb.tree.Clone().Root, area: layoutSnap.area, focus: layoutSnap.focus,
519+ placements: append([]layout.Placement(nil), layoutSnap.placements...),
520+ fingerprint: layoutSnap.fingerprint, valid: true,
521+ }}
522+ for _, placement := range layoutSnap.placements {
523+ p := tb.panes[placement.ID]
524+ if p == nil {
525+ continue
526+ }
527+ visible := placement.Content
528+ if placement.Collapsed {
529+ visible = domain.Rect{}
530+ }
531+ p.mu.Lock()
532+ captured := capturePaneRenderStateLocked(p, visible, damageCapturePreview)
533+ p.mu.Unlock()
534+ captured.placement = placement
535+ captured.focused = placement.ID == layoutSnap.focus
536+ state.panes = append(state.panes, captured)
537+ }
538+ tb.mu.Unlock()
539+ return pickerPreviewFromCapturedRender(state)
522 }540 }
523 541
524-func pickerPreviewFromLockedTab(tb *tab) picker.Preview {542+func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
525- p := tb.focusedPane()543+ composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame
526- if p == nil {544+ if composed.Height < 2 {
527- return picker.Preview{}545+ return pickerPreviewFromFrame(composed)
528- }
529- if tb.tree == nil || tb.tree.Root == nil || tb.tree.Root.Kind == layout.Leaf {
530- p.mu.Lock()
531- defer p.mu.Unlock()
532- return pickerPreviewFromLockedPane(p)
533 }546 }
534-547+ rows := make([][]renderer.Cell, composed.Height-2)
535- area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}548+ for y := range rows {
536- if area.Width <= 0 || area.Height <= 0 {549+ rows[y] = append([]renderer.Cell(nil), composed.Row(y+1)...)
537- p.mu.Lock()
538- defer p.mu.Unlock()
539- return pickerPreviewFromLockedPane(p)
540 }550 }
541- frame, _ := composeTabFrame(tb, area, themeui.Theme{})551+ return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)}
542- return pickerPreviewFromFrame(frame)
543 }552 }
544 553
545 func pickerPreviewFromLockedPane(p *pane) picker.Preview {554 func pickerPreviewFromLockedPane(p *pane) picker.Preview {
M internal/usecase/daemon/prompt.go +0 -6
@@ -6,7 +6,6 @@ import (
66 "github.com/bnema/vev/internal/domain"
77 promptui "github.com/bnema/vev/internal/usecase/prompt"
88 "github.com/bnema/vev/internal/usecase/ui"
9- "github.com/bnema/vev/pkg/renderer"
109 )
1110
1211 func promptModalFor(title string) ui.Modal {
@@ -95,8 +94,3 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
9594 d.invalidateRender(sess, ac, true, "prompt.go")
9695 }
9796 }
98-
99-func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
100- styleSet := resolveThemeStyles(styles)
101- return composeModalClientFrame(base, promptModalFor(model.Title()), styleSet, styleSet.accent, model.Render)
102-}
@@ -6,7 +6,6 @@ import (
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 promptui "github.com/bnema/vev/internal/usecase/prompt"7 promptui "github.com/bnema/vev/internal/usecase/prompt"
8 "github.com/bnema/vev/internal/usecase/ui"8 "github.com/bnema/vev/internal/usecase/ui"
9- "github.com/bnema/vev/pkg/renderer"
10 )9 )
11 10
12 func promptModalFor(title string) ui.Modal {11 func promptModalFor(title string) ui.Modal {
@@ -95,8 +94,3 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
95 d.invalidateRender(sess, ac, true, "prompt.go")94 d.invalidateRender(sess, ac, true, "prompt.go")
96 }95 }
97 }96 }
98-
99-func composePromptClientFrame(model *promptui.Model, base renderer.Frame, styles ...themeStyles) (renderer.Frame, []renderer.Damage) {
100- styleSet := resolveThemeStyles(styles)
101- return composeModalClientFrame(base, promptModalFor(model.Title()), styleSet, styleSet.accent, model.Render)
102-}
M internal/usecase/daemon/render.go +34 -468
@@ -3,16 +3,12 @@ package daemon
33
44 import (
55 "bytes"
6- "errors"
7- "strconv"
86
97 "github.com/bnema/vev/internal/domain"
108 "github.com/bnema/vev/internal/ports"
119 "github.com/bnema/vev/internal/usecase/layout"
1210 themeui "github.com/bnema/vev/internal/usecase/theme"
13- "github.com/bnema/vev/internal/usecase/ui"
1411 "github.com/bnema/vev/pkg/renderer"
15- "github.com/bnema/vev/pkg/vt"
1612 )
1713
1814 // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one
@@ -61,20 +57,6 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
6157 return false
6258 }
6359
64-// clearNonRenderablePaneDamage is the single transitional S1 owner for VT
65-// damage that cannot reach a composition target. S2 replaces this visibility
66-// decision with pipeline ownership; do not paint or mutate renderer shadows
67-// here.
68-func (d *Daemon) clearNonRenderablePaneDamage(sess *session, tb *tab, p *pane) bool {
69- renderable := d.paneRenderable(sess, tb, p)
70- if !renderable && p != nil {
71- p.mu.Lock()
72- p.screen.ClearDamage()
73- p.mu.Unlock()
74- }
75- return renderable
76-}
77-
7860 // tabIsPickerPreview reports whether any attached client currently composes
7961 // tb as a picker preview. It snapshots daemon ownership before taking overlay
8062 // locks, preserving the Daemon -> session -> tab -> pane lock order.
@@ -158,7 +140,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
158140 }
159141 }
160142 p.mu.Unlock()
161- renderable := d.clearNonRenderablePaneDamage(sess, tb, p)
143+ renderable := d.paneRenderable(sess, tb, p)
162144 markSnapshotDirty(sess)
163145 select {
164146 case <-attentionCh:
@@ -217,43 +199,6 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
217199 // paint draws the composed client frame (active tab plus status bar) and
218200 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
219201 // such as switch/create/close/rename/resize so the repaint is complete.
220-// copyTargetRectLocked maps a captured copy source into the already-composed
221-// client frame. The caller holds tb.mu, preserving the layout/floating
222-// snapshot while the rectangle is chosen.
223-func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect {
224- if p == nil {
225- return domain.Rect{}
226- }
227- if hasFloating && p == floating {
228- return floatingFrameGeometry.Inner
229- }
230- for _, placement := range layoutSnap.placements {
231- if placement.ID == p.id && !placement.Collapsed {
232- r := placement.Content
233- r.X += contentArea.X
234- r.Y += contentArea.Y
235- return r
236- }
237- }
238- p.mu.Lock()
239- width, height := p.screen.Frame.Width, p.screen.Frame.Height
240- p.mu.Unlock()
241- return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
242-}
243-
244-func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {
245- if !ok {
246- return nil
247- }
248- ids := make([]layout.PaneID, 0, len(placements))
249- for _, pl := range placements {
250- if pl.TitleBar.Height > 0 {
251- ids = append(ids, pl.ID)
252- }
253- }
254- return ids
255-}
256-
257202 func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {
258203 ac.resizePaint.stop()
259204 ac.resizePaintGeneration++
@@ -306,6 +251,13 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
306251 ac.sendMu.Unlock()
307252 return
308253 }
254+ // Capacity is checked before any destructive capture. The coordinator is
255+ // the normal gate, but this ownership check also protects direct resize and
256+ // test paint paths from consuming damage that cannot be emitted.
257+ if ac.output != nil && ac.output.atCapacity() {
258+ ac.sendMu.Unlock()
259+ return
260+ }
309261 if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {
310262 ac.sendMu.Unlock()
311263 return
@@ -339,11 +291,16 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
339291 attentionVisible := pulseVisible(bars.attentionFrame)
340292 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
341293
342- styles := newThemeStyles(ac.getTheme())
343294 paletteCfg := d.currentPaletteConfig()
295+ floatingCfg := d.currentFloatingConfig()
296+ // Title refresh may inspect process state, so it remains before the capture
297+ // boundary and outside tab/pane ownership locks.
344298 tb.mu.Lock()
345- layoutSnap := solveTabLayoutLocked(tb)
346- titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)
299+ titleIDs := ac.renderScratch.titleIDs[:0]
300+ if tb.tree != nil {
301+ titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
302+ }
303+ ac.renderScratch.titleIDs = titleIDs
347304 floating := tb.floating.pane
348305 hasFloating := tb.floating.state == floatingVisible && floating != nil
349306 tb.mu.Unlock()
@@ -354,189 +311,31 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
354311 d.refreshPaneDisplayTitle(sess, floating, false)
355312 }
356313
357- tb.mu.Lock()
358- if !layoutSnap.matchesLocked(tb) {
359- layoutSnap = solveTabLayoutLocked(tb)
360- }
361- p := tb.focusedPane()
362- if p == nil {
363- tb.mu.Unlock()
364- ac.sendMu.Unlock()
365- return
366- }
367- // Recheck the slot after refreshing its title outside the tab lock. One
368- // paint uses one immutable floating config snapshot across composition,
369- // copy targeting, and cursor placement.
370- floating = tb.floating.pane
371- hasFloating = tb.floating.state == floatingVisible && floating != nil
372- floatingCfg := d.currentFloatingConfig()
373- overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
374- if reset || overlayActive {
375- ac.bars.Reset()
376- ac.composed.invalidate()
377- }
378- if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
379- ac.lastCursor.valid = false
380- }
381- var frame renderer.Frame
382- var damage []renderer.Damage
383- if overlayActive {
384- frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil)
385- } else {
386- frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
387- }
388- contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
389- floatingFrameGeometry := floatingGeometry{}
390- if hasFloating {
391- desiredFloatingGeometry := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, floatingCfg)
392- frame, damage, floatingFrameGeometry = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, desiredFloatingGeometry, layoutSnap, bars.theme, &ac.composed, reset || overlayActive)
393- }
394- if overlays.copyActive {
395- copyPane := overlays.copyPane
396- if copyPane == nil {
397- copyPane = p
398- }
399- copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry)
400- frame, damage = composeCopyClientFrame(overlays.copyMode, overlays.copySnapshot, copyTarget, frame, bars)
401- }
402- // A palette above normal/copy content dims that composed content. When a
403- // floating pane is present its own backdrop already dims normal pane
404- // contents; applying the palette backdrop here would also dim the popup.
405- if overlays.paletteActive && !hasFloating {
406- (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)
407- }
408- if overlays.copySearchModel != nil {
409- frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles)
410- }
411- if overlays.pickerActive {
412- if overlays.previewTab == tb {
413- if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf {
414- previewFrame, _ := composeTabFrameWithLayout(tb, layoutSnap.area, themeui.Theme{}, layoutSnap)
415- preview = pickerPreviewFromFrame(previewFrame)
416- } else {
417- preview = pickerPreviewFromLockedTab(tb)
418- }
419- }
420- frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)
421- }
422- if overlays.paletteActive {
423- guidance := ""
424- if overlays.paletteHints != nil {
425- guidance = overlays.paletteHints.Feedback
426- }
427- frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, guidance, styles)
428- }
429- if overlays.promptActive {
430- frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)
314+ capturedOverlays := capturedOverlayRenderState{
315+ copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
316+ pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
317+ copyFeedback: overlays.copyFeedback,
431318 }
432- overlays.Unlock()
433- cursorPane := p
434- cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
435- cursorContent.X += contentArea.X
436- cursorContent.Y += contentArea.Y
437- if hasFloating {
438- cursorPane = floating
439- cursorContent, cursorVisible = floatingFrameGeometry.Inner, floatingFrameGeometry.Inner.Width > 0 && floatingFrameGeometry.Inner.Height > 0
440- }
441- cursorPane.mu.Lock()
442- desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
443- cursorPane.mu.Unlock()
444- ac.sess.mu.Lock()
445- if ac.sess.v != sess {
446- ac.sess.mu.Unlock()
447- tb.mu.Unlock()
319+ state, ok := captureRenderState(sess, ac, bars, capturedOverlays, preview, floatingCfg, reset, damageCaptureConsume)
320+ if !ok {
448321 ac.sendMu.Unlock()
449322 return
450323 }
451- prepared, err := ac.output.prepare(frame, damage, reset || overlayActive)
452- var data []byte
453- if err == nil {
454- data = prepared.data
455- }
456- var cursorTail []byte
457- if err == nil {
458- cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
459- }
460- tb.mu.Unlock()
461-
462- var serr error
463- var sendTr ports.Transport
464- if err == nil {
465- data = append(data, cursorTail...)
466- if len(data) > 0 {
467- sendTr = ac.transport()
468- if sendTr == nil {
469- serr = errors.New("client transport is nil")
470- } else {
471- send := sendTr.Send
472- if async, ok := sendTr.(ports.AsyncTransport); ok {
473- send = async.SendAsync
474- }
475- serr = prepared.send(data, ac.echoAck.Load(), send)
476- }
477- }
324+ if ac.renderStages.capture != nil {
325+ ac.renderStages.capture()
478326 }
479- ac.sess.mu.Unlock()
480- ac.sendMu.Unlock()
481-
482- if err != nil {
483- d.log.Error("render draw failed", "err", err, "session", sess.name)
484- return
327+ if overlays.pickerActive && overlays.previewTab == tb {
328+ previewState := *state
329+ previewState.overlays = capturedOverlayRenderState{}
330+ previewState.reset = true
331+ state.preview = pickerPreviewFromCapturedRender(previewState)
485332 }
486- if serr != nil {
487- d.detachOnSendError(sess, ac, sendTr)
333+ captureOverlayLayers(state, overlays, paletteCfg)
334+ composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
335+ if ac.renderStages.compose != nil {
336+ ac.renderStages.compose()
488337 }
489-}
490-
491-func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) {
492- if !layoutSnap.ok {
493- return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0
494- }
495- for _, pl := range layoutSnap.placements {
496- if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
497- return pl.Content, true
498- }
499- }
500- return domain.Rect{}, false
501-}
502-
503-// desiredCursorOut computes the terminal cursor state that should be shown to
504-// the client for the current pane placement and overlay mode.
505-func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut {
506- if hide || !s.CursorVisible() {
507- return cursorOut{hidden: true}
508- }
509- style, ok := s.CursorStyle()
510- if !ok {
511- style = 1
512- }
513- return cursorOut{row: content.Y + s.CursorRow(), col: content.X + s.CursorCol(), style: style, hasStyle: true}
514-}
515-
516-func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
517- changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
518- if !changed {
519- return nil
520- }
521- prev := ac.lastCursor
522- ac.lastCursor = desired
523- ac.lastCursor.valid = true
524- if desired.hidden {
525- return []byte("\x1b[?25l")
526- }
527- var b []byte
528- b = append(b, "\x1b["...)
529- b = strconv.AppendInt(b, int64(desired.row+1), 10)
530- b = append(b, ';')
531- b = strconv.AppendInt(b, int64(desired.col+1), 10)
532- b = append(b, 'H')
533- if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
534- b = append(b, "\x1b["...)
535- b = strconv.AppendInt(b, int64(desired.style), 10)
536- b = append(b, " q"...)
537- }
538- b = append(b, "\x1b[?25h"...)
539- return b
338+ d.emitFrame(sess, ac, state, composed)
540339 }
541340
542341 type themeStyles struct {
@@ -602,211 +401,6 @@ func (c *barCache) Reset() {
602401 c.bottom = nil
603402 }
604403
605-type composedFrameCache struct {
606- frame renderer.Frame
607- valid bool
608- layoutSnap tabLayoutSnapshot
609- titleGenerations map[layout.PaneID]uint64
610- floating *pane
611- floatingFrame renderer.Frame
612- floatingGeneration uint64
613- floatingGeometry floatingGeometry
614- floatingTitleGeneration uint64
615-}
616-
617-func (c *composedFrameCache) invalidate() {
618- c.valid = false
619- c.floating = nil
620- c.floatingGeneration = 0
621- c.floatingGeometry = floatingGeometry{}
622- c.floatingTitleGeneration = 0
623-}
624-
625-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
626- return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
627-}
628-
629-func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
630- return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
631-}
632-
633-func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
634- var cache *barCache
635- if len(caches) > 0 {
636- cache = caches[0]
637- }
638- return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
639-}
640-
641-func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
642- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
643-}
644-
645-func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
646- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
647-}
648-
649-func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
650- p := tb.focusedPane()
651- styles := newThemeStyles(bars.theme)
652- if p == nil {
653- width, screenRows := tb.size.Cols, tb.size.Rows
654- if width <= 0 || screenRows <= 0 {
655- return renderer.NewFrame(0, 0), nil
656- }
657- return renderer.NewFrame(width, screenRows+2), nil
658- }
659- p.mu.Lock()
660- width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
661- p.mu.Unlock()
662- if tb.size.Valid() {
663- width, screenRows = tb.size.Cols, tb.size.Rows
664- }
665- cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
666- layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
667- var frame renderer.Frame
668- if cacheValid {
669- frame = composed.frame
670- } else {
671- frame = renderer.NewFrame(width, screenRows+2)
672- if composed != nil {
673- full = true
674- }
675- }
676- if !layoutSame {
677- full = true
678- }
679- contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
680- if cacheValid && !layoutSame {
681- clearFrameRect(frame, contentArea)
682- }
683- topBar := frame.Row(0)
684- drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
685- var titleGenerations map[layout.PaneID]uint64
686- if composed != nil {
687- if composed.titleGenerations == nil {
688- composed.titleGenerations = make(map[layout.PaneID]uint64)
689- }
690- titleGenerations = composed.titleGenerations
691- }
692- contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
693- bottomBar := frame.Row(screenRows + 1)
694- drawStatusBarState(bottomBar, bars, styles)
695- if composed != nil {
696- composed.frame = frame
697- composed.valid = true
698- composed.layoutSnap = layoutSnap
699- }
700- if full {
701- if cache != nil {
702- cache.capture(topBar, bottomBar)
703- }
704- return frame, []renderer.Damage{renderer.FullRedraw()}
705- }
706- damage := translateDamage(contentDamage, 0, 1)
707- if cache == nil || !sameCells(cache.top, topBar) {
708- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
709- }
710- if cache == nil || !sameCells(cache.bottom, bottomBar) {
711- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
712- }
713- if cache != nil {
714- cache.capture(topBar, bottomBar)
715- }
716- return frame, damage
717-}
718-
719-func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
720- return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
721-}
722-
723-func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
724- frame := renderer.NewFrame(area.Width, area.Height)
725- damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
726- return frame, damage
727-}
728-
729-func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
730- return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
731-}
732-
733-func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
734- contentArea := domain.Rect{Width: area.Width, Height: area.Height}
735- root := tb.tree.Root
736- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
737- if !ok {
738- placements, ok = layout.Solve(root, contentArea)
739- }
740- var fallback *pane
741- if !ok {
742- fallback = tb.focusedPane()
743- if fallback == nil {
744- return nil
745- }
746- placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
747- }
748- // A valid cache has the same layout, so its title IDs remain valid. Reset
749- // the existing cache only when rebuilding after layout or frame churn.
750- if titleGenerations != nil && !cacheValid {
751- clear(titleGenerations)
752- }
753- if ok && !cacheValid {
754- drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
755- }
756- var damage []renderer.Damage
757- for _, pl := range placements {
758- p := tb.panes[pl.ID]
759- if p == nil && fallback != nil && pl.ID == fallback.id {
760- p = fallback
761- }
762- if p == nil {
763- continue
764- }
765- focused := tb.tree.Focus == pl.ID
766- pl = offsetPlacement(pl, area.X, area.Y)
767- if pl.TitleBar.Height > 0 {
768- generation := drawPaneTitleBar(frame, pl, p, focused, theme)
769- if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
770- titleDamage := pl.TitleBar
771- titleDamage.X -= area.X
772- titleDamage.Y -= area.Y
773- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
774- }
775- if titleGenerations != nil {
776- titleGenerations[pl.ID] = generation
777- }
778- }
779- if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
780- if consumeDamage {
781- p.mu.Lock()
782- p.screen.ClearDamage()
783- p.mu.Unlock()
784- }
785- continue
786- }
787- p.mu.Lock()
788- paneDamage := p.screen.Damage()
789- if !cacheValid || len(paneDamage) > 0 {
790- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
791- }
792- for _, d := range paneDamage {
793- localContent := pl.Content
794- localContent.X -= area.X
795- localContent.Y -= area.Y
796- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
797- }
798- if consumeDamage {
799- p.screen.ClearDamage()
800- }
801- p.mu.Unlock()
802- }
803- return damage
804-}
805-
806-func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
807- return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
808-}
809-
810404 func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
811405 pl.Content.X += dx
812406 pl.Content.Y += dy
@@ -815,15 +409,6 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
815409 return pl
816410 }
817411
818-func clearFrameRect(frame renderer.Frame, r domain.Rect) {
819- blank := renderer.BlankCell()
820- for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
821- for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
822- frame.Set(x, y, blank)
823- }
824- }
825-}
826-
827412 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
828413 rows := min(r.Height, src.Height)
829414 cols := min(r.Width, src.Width)
@@ -838,25 +423,6 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
838423 }
839424 }
840425
841-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
842- styles := newThemeStyles(theme)
843- style := styles.border
844- if focused {
845- style = styles.statusBar
846- } else {
847- style = themeui.DimStyle(style, theme)
848- }
849- for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
850- frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
851- }
852- p.mu.Lock()
853- title := p.displayTitleLocked()
854- generation := p.title.generation
855- p.mu.Unlock()
856- ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
857- return generation
858-}
859-
860426 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {
861427 if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 {
862428 return
@@ -3,16 +3,12 @@ package daemon
3 3
4 import (4 import (
5 "bytes"5 "bytes"
6- "errors"
7- "strconv"
8 6
9 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
12 themeui "github.com/bnema/vev/internal/usecase/theme"10 themeui "github.com/bnema/vev/internal/usecase/theme"
13- "github.com/bnema/vev/internal/usecase/ui"
14 "github.com/bnema/vev/pkg/renderer"11 "github.com/bnema/vev/pkg/renderer"
15- "github.com/bnema/vev/pkg/vt"
16 )12 )
17 13
18 // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one14 // completedSynchronizedUpdate reports a full DEC 2026 batch contained in one
@@ -61,20 +57,6 @@ func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
61 return false57 return false
62 }58 }
63 59
64-// clearNonRenderablePaneDamage is the single transitional S1 owner for VT
65-// damage that cannot reach a composition target. S2 replaces this visibility
66-// decision with pipeline ownership; do not paint or mutate renderer shadows
67-// here.
68-func (d *Daemon) clearNonRenderablePaneDamage(sess *session, tb *tab, p *pane) bool {
69- renderable := d.paneRenderable(sess, tb, p)
70- if !renderable && p != nil {
71- p.mu.Lock()
72- p.screen.ClearDamage()
73- p.mu.Unlock()
74- }
75- return renderable
76-}
77-
78 // tabIsPickerPreview reports whether any attached client currently composes60 // tabIsPickerPreview reports whether any attached client currently composes
79 // tb as a picker preview. It snapshots daemon ownership before taking overlay61 // tb as a picker preview. It snapshots daemon ownership before taking overlay
80 // locks, preserving the Daemon -> session -> tab -> pane lock order.62 // locks, preserving the Daemon -> session -> tab -> pane lock order.
@@ -158,7 +140,7 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
158 }140 }
159 }141 }
160 p.mu.Unlock()142 p.mu.Unlock()
161- renderable := d.clearNonRenderablePaneDamage(sess, tb, p)143+ renderable := d.paneRenderable(sess, tb, p)
162 markSnapshotDirty(sess)144 markSnapshotDirty(sess)
163 select {145 select {
164 case <-attentionCh:146 case <-attentionCh:
@@ -217,43 +199,6 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
217 // paint draws the composed client frame (active tab plus status bar) and199 // paint draws the composed client frame (active tab plus status bar) and
218 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations200 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
219 // such as switch/create/close/rename/resize so the repaint is complete.201 // such as switch/create/close/rename/resize so the repaint is complete.
220-// copyTargetRectLocked maps a captured copy source into the already-composed
221-// client frame. The caller holds tb.mu, preserving the layout/floating
222-// snapshot while the rectangle is chosen.
223-func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect {
224- if p == nil {
225- return domain.Rect{}
226- }
227- if hasFloating && p == floating {
228- return floatingFrameGeometry.Inner
229- }
230- for _, placement := range layoutSnap.placements {
231- if placement.ID == p.id && !placement.Collapsed {
232- r := placement.Content
233- r.X += contentArea.X
234- r.Y += contentArea.Y
235- return r
236- }
237- }
238- p.mu.Lock()
239- width, height := p.screen.Frame.Width, p.screen.Frame.Height
240- p.mu.Unlock()
241- return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
242-}
243-
244-func titleBarPaneIDs(placements []layout.Placement, ok bool) []layout.PaneID {
245- if !ok {
246- return nil
247- }
248- ids := make([]layout.PaneID, 0, len(placements))
249- for _, pl := range placements {
250- if pl.TitleBar.Height > 0 {
251- ids = append(ids, pl.ID)
252- }
253- }
254- return ids
255-}
256-
257 func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {202 func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {
258 ac.resizePaint.stop()203 ac.resizePaint.stop()
259 ac.resizePaintGeneration++204 ac.resizePaintGeneration++
@@ -306,6 +251,13 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
306 ac.sendMu.Unlock()251 ac.sendMu.Unlock()
307 return252 return
308 }253 }
254+ // Capacity is checked before any destructive capture. The coordinator is
255+ // the normal gate, but this ownership check also protects direct resize and
256+ // test paint paths from consuming damage that cannot be emitted.
257+ if ac.output != nil && ac.output.atCapacity() {
258+ ac.sendMu.Unlock()
259+ return
260+ }
309 if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {261 if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) {
310 ac.sendMu.Unlock()262 ac.sendMu.Unlock()
311 return263 return
@@ -339,11 +291,16 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
339 attentionVisible := pulseVisible(bars.attentionFrame)291 attentionVisible := pulseVisible(bars.attentionFrame)
340 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)292 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
341 293
342- styles := newThemeStyles(ac.getTheme())
343 paletteCfg := d.currentPaletteConfig()294 paletteCfg := d.currentPaletteConfig()
295+ floatingCfg := d.currentFloatingConfig()
296+ // Title refresh may inspect process state, so it remains before the capture
297+ // boundary and outside tab/pane ownership locks.
344 tb.mu.Lock()298 tb.mu.Lock()
345- layoutSnap := solveTabLayoutLocked(tb)299+ titleIDs := ac.renderScratch.titleIDs[:0]
346- titleIDs := titleBarPaneIDs(layoutSnap.placements, layoutSnap.ok)300+ if tb.tree != nil {
301+ titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
302+ }
303+ ac.renderScratch.titleIDs = titleIDs
347 floating := tb.floating.pane304 floating := tb.floating.pane
348 hasFloating := tb.floating.state == floatingVisible && floating != nil305 hasFloating := tb.floating.state == floatingVisible && floating != nil
349 tb.mu.Unlock()306 tb.mu.Unlock()
@@ -354,189 +311,31 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
354 d.refreshPaneDisplayTitle(sess, floating, false)311 d.refreshPaneDisplayTitle(sess, floating, false)
355 }312 }
356 313
357- tb.mu.Lock()314+ capturedOverlays := capturedOverlayRenderState{
358- if !layoutSnap.matchesLocked(tb) {315+ copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
359- layoutSnap = solveTabLayoutLocked(tb)316+ pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
360- }317+ copyFeedback: overlays.copyFeedback,
361- p := tb.focusedPane()
362- if p == nil {
363- tb.mu.Unlock()
364- ac.sendMu.Unlock()
365- return
366- }
367- // Recheck the slot after refreshing its title outside the tab lock. One
368- // paint uses one immutable floating config snapshot across composition,
369- // copy targeting, and cursor placement.
370- floating = tb.floating.pane
371- hasFloating = tb.floating.state == floatingVisible && floating != nil
372- floatingCfg := d.currentFloatingConfig()
373- overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
374- if reset || overlayActive {
375- ac.bars.Reset()
376- ac.composed.invalidate()
377- }
378- if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
379- ac.lastCursor.valid = false
380- }
381- var frame renderer.Frame
382- var damage []renderer.Damage
383- if overlayActive {
384- frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil)
385- } else {
386- frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
387- }
388- contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)}
389- floatingFrameGeometry := floatingGeometry{}
390- if hasFloating {
391- desiredFloatingGeometry := calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, floatingCfg)
392- frame, damage, floatingFrameGeometry = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, desiredFloatingGeometry, layoutSnap, bars.theme, &ac.composed, reset || overlayActive)
393- }
394- if overlays.copyActive {
395- copyPane := overlays.copyPane
396- if copyPane == nil {
397- copyPane = p
398- }
399- copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry)
400- frame, damage = composeCopyClientFrame(overlays.copyMode, overlays.copySnapshot, copyTarget, frame, bars)
401- }
402- // A palette above normal/copy content dims that composed content. When a
403- // floating pane is present its own backdrop already dims normal pane
404- // contents; applying the palette backdrop here would also dim the popup.
405- if overlays.paletteActive && !hasFloating {
406- (overlayBackdrop{DimPaneContents: true}).apply(frame, contentArea, layoutSnap, bars.theme)
407- }
408- if overlays.copySearchModel != nil {
409- frame, damage = composeCopySearchClientFrame(overlays.copySearchModel, frame, styles)
410- }
411- if overlays.pickerActive {
412- if overlays.previewTab == tb {
413- if layoutSnap.ok && tb.tree != nil && tb.tree.Root != nil && tb.tree.Root.Kind != layout.Leaf {
414- previewFrame, _ := composeTabFrameWithLayout(tb, layoutSnap.area, themeui.Theme{}, layoutSnap)
415- preview = pickerPreviewFromFrame(previewFrame)
416- } else {
417- preview = pickerPreviewFromLockedTab(tb)
418- }
419- }
420- frame, damage = composePickerClientFrame(overlays.pickerModel, preview, frame, styles)
421- }
422- if overlays.paletteActive {
423- guidance := ""
424- if overlays.paletteHints != nil {
425- guidance = overlays.paletteHints.Feedback
426- }
427- frame, damage = composePaletteClientFrame(overlays.paletteModel, frame, paletteCfg, guidance, styles)
428- }
429- if overlays.promptActive {
430- frame, damage = composePromptClientFrame(overlays.promptModel, frame, styles)
431 }318 }
432- overlays.Unlock()319+ state, ok := captureRenderState(sess, ac, bars, capturedOverlays, preview, floatingCfg, reset, damageCaptureConsume)
433- cursorPane := p320+ if !ok {
434- cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id)
435- cursorContent.X += contentArea.X
436- cursorContent.Y += contentArea.Y
437- if hasFloating {
438- cursorPane = floating
439- cursorContent, cursorVisible = floatingFrameGeometry.Inner, floatingFrameGeometry.Inner.Width > 0 && floatingFrameGeometry.Inner.Height > 0
440- }
441- cursorPane.mu.Lock()
442- desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive)
443- cursorPane.mu.Unlock()
444- ac.sess.mu.Lock()
445- if ac.sess.v != sess {
446- ac.sess.mu.Unlock()
447- tb.mu.Unlock()
448 ac.sendMu.Unlock()321 ac.sendMu.Unlock()
449 return322 return
450 }323 }
451- prepared, err := ac.output.prepare(frame, damage, reset || overlayActive)324+ if ac.renderStages.capture != nil {
452- var data []byte325+ ac.renderStages.capture()
453- if err == nil {
454- data = prepared.data
455- }
456- var cursorTail []byte
457- if err == nil {
458- cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
459- }
460- tb.mu.Unlock()
461-
462- var serr error
463- var sendTr ports.Transport
464- if err == nil {
465- data = append(data, cursorTail...)
466- if len(data) > 0 {
467- sendTr = ac.transport()
468- if sendTr == nil {
469- serr = errors.New("client transport is nil")
470- } else {
471- send := sendTr.Send
472- if async, ok := sendTr.(ports.AsyncTransport); ok {
473- send = async.SendAsync
474- }
475- serr = prepared.send(data, ac.echoAck.Load(), send)
476- }
477- }
478 }326 }
479- ac.sess.mu.Unlock()327+ if overlays.pickerActive && overlays.previewTab == tb {
480- ac.sendMu.Unlock()328+ previewState := *state
481-329+ previewState.overlays = capturedOverlayRenderState{}
482- if err != nil {330+ previewState.reset = true
483- d.log.Error("render draw failed", "err", err, "session", sess.name)331+ state.preview = pickerPreviewFromCapturedRender(previewState)
484- return
485 }332 }
486- if serr != nil {333+ captureOverlayLayers(state, overlays, paletteCfg)
487- d.detachOnSendError(sess, ac, sendTr)334+ composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
335+ if ac.renderStages.compose != nil {
336+ ac.renderStages.compose()
488 }337 }
489-}338+ d.emitFrame(sess, ac, state, composed)
490-
491-func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) {
492- if !layoutSnap.ok {
493- return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0
494- }
495- for _, pl := range layoutSnap.placements {
496- if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 {
497- return pl.Content, true
498- }
499- }
500- return domain.Rect{}, false
501-}
502-
503-// desiredCursorOut computes the terminal cursor state that should be shown to
504-// the client for the current pane placement and overlay mode.
505-func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut {
506- if hide || !s.CursorVisible() {
507- return cursorOut{hidden: true}
508- }
509- style, ok := s.CursorStyle()
510- if !ok {
511- style = 1
512- }
513- return cursorOut{row: content.Y + s.CursorRow(), col: content.X + s.CursorCol(), style: style, hasStyle: true}
514-}
515-
516-func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
517- changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
518- if !changed {
519- return nil
520- }
521- prev := ac.lastCursor
522- ac.lastCursor = desired
523- ac.lastCursor.valid = true
524- if desired.hidden {
525- return []byte("\x1b[?25l")
526- }
527- var b []byte
528- b = append(b, "\x1b["...)
529- b = strconv.AppendInt(b, int64(desired.row+1), 10)
530- b = append(b, ';')
531- b = strconv.AppendInt(b, int64(desired.col+1), 10)
532- b = append(b, 'H')
533- if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
534- b = append(b, "\x1b["...)
535- b = strconv.AppendInt(b, int64(desired.style), 10)
536- b = append(b, " q"...)
537- }
538- b = append(b, "\x1b[?25h"...)
539- return b
540 }339 }
541 340
542 type themeStyles struct {341 type themeStyles struct {
@@ -602,211 +401,6 @@ func (c *barCache) Reset() {
602 c.bottom = nil401 c.bottom = nil
603 }402 }
604 403
605-type composedFrameCache struct {
606- frame renderer.Frame
607- valid bool
608- layoutSnap tabLayoutSnapshot
609- titleGenerations map[layout.PaneID]uint64
610- floating *pane
611- floatingFrame renderer.Frame
612- floatingGeneration uint64
613- floatingGeometry floatingGeometry
614- floatingTitleGeneration uint64
615-}
616-
617-func (c *composedFrameCache) invalidate() {
618- c.valid = false
619- c.floating = nil
620- c.floatingGeneration = 0
621- c.floatingGeometry = floatingGeometry{}
622- c.floatingTitleGeneration = 0
623-}
624-
625-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
626- return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
627-}
628-
629-func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
630- return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
631-}
632-
633-func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
634- var cache *barCache
635- if len(caches) > 0 {
636- cache = caches[0]
637- }
638- return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
639-}
640-
641-func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
642- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
643-}
644-
645-func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
646- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
647-}
648-
649-func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
650- p := tb.focusedPane()
651- styles := newThemeStyles(bars.theme)
652- if p == nil {
653- width, screenRows := tb.size.Cols, tb.size.Rows
654- if width <= 0 || screenRows <= 0 {
655- return renderer.NewFrame(0, 0), nil
656- }
657- return renderer.NewFrame(width, screenRows+2), nil
658- }
659- p.mu.Lock()
660- width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
661- p.mu.Unlock()
662- if tb.size.Valid() {
663- width, screenRows = tb.size.Cols, tb.size.Rows
664- }
665- cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
666- layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
667- var frame renderer.Frame
668- if cacheValid {
669- frame = composed.frame
670- } else {
671- frame = renderer.NewFrame(width, screenRows+2)
672- if composed != nil {
673- full = true
674- }
675- }
676- if !layoutSame {
677- full = true
678- }
679- contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
680- if cacheValid && !layoutSame {
681- clearFrameRect(frame, contentArea)
682- }
683- topBar := frame.Row(0)
684- drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
685- var titleGenerations map[layout.PaneID]uint64
686- if composed != nil {
687- if composed.titleGenerations == nil {
688- composed.titleGenerations = make(map[layout.PaneID]uint64)
689- }
690- titleGenerations = composed.titleGenerations
691- }
692- contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
693- bottomBar := frame.Row(screenRows + 1)
694- drawStatusBarState(bottomBar, bars, styles)
695- if composed != nil {
696- composed.frame = frame
697- composed.valid = true
698- composed.layoutSnap = layoutSnap
699- }
700- if full {
701- if cache != nil {
702- cache.capture(topBar, bottomBar)
703- }
704- return frame, []renderer.Damage{renderer.FullRedraw()}
705- }
706- damage := translateDamage(contentDamage, 0, 1)
707- if cache == nil || !sameCells(cache.top, topBar) {
708- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
709- }
710- if cache == nil || !sameCells(cache.bottom, bottomBar) {
711- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
712- }
713- if cache != nil {
714- cache.capture(topBar, bottomBar)
715- }
716- return frame, damage
717-}
718-
719-func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
720- return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
721-}
722-
723-func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
724- frame := renderer.NewFrame(area.Width, area.Height)
725- damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
726- return frame, damage
727-}
728-
729-func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
730- return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
731-}
732-
733-func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
734- contentArea := domain.Rect{Width: area.Width, Height: area.Height}
735- root := tb.tree.Root
736- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
737- if !ok {
738- placements, ok = layout.Solve(root, contentArea)
739- }
740- var fallback *pane
741- if !ok {
742- fallback = tb.focusedPane()
743- if fallback == nil {
744- return nil
745- }
746- placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
747- }
748- // A valid cache has the same layout, so its title IDs remain valid. Reset
749- // the existing cache only when rebuilding after layout or frame churn.
750- if titleGenerations != nil && !cacheValid {
751- clear(titleGenerations)
752- }
753- if ok && !cacheValid {
754- drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
755- }
756- var damage []renderer.Damage
757- for _, pl := range placements {
758- p := tb.panes[pl.ID]
759- if p == nil && fallback != nil && pl.ID == fallback.id {
760- p = fallback
761- }
762- if p == nil {
763- continue
764- }
765- focused := tb.tree.Focus == pl.ID
766- pl = offsetPlacement(pl, area.X, area.Y)
767- if pl.TitleBar.Height > 0 {
768- generation := drawPaneTitleBar(frame, pl, p, focused, theme)
769- if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
770- titleDamage := pl.TitleBar
771- titleDamage.X -= area.X
772- titleDamage.Y -= area.Y
773- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
774- }
775- if titleGenerations != nil {
776- titleGenerations[pl.ID] = generation
777- }
778- }
779- if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
780- if consumeDamage {
781- p.mu.Lock()
782- p.screen.ClearDamage()
783- p.mu.Unlock()
784- }
785- continue
786- }
787- p.mu.Lock()
788- paneDamage := p.screen.Damage()
789- if !cacheValid || len(paneDamage) > 0 {
790- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
791- }
792- for _, d := range paneDamage {
793- localContent := pl.Content
794- localContent.X -= area.X
795- localContent.Y -= area.Y
796- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
797- }
798- if consumeDamage {
799- p.screen.ClearDamage()
800- }
801- p.mu.Unlock()
802- }
803- return damage
804-}
805-
806-func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
807- return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
808-}
809-
810 func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {404 func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
811 pl.Content.X += dx405 pl.Content.X += dx
812 pl.Content.Y += dy406 pl.Content.Y += dy
@@ -815,15 +409,6 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
815 return pl409 return pl
816 }410 }
817 411
818-func clearFrameRect(frame renderer.Frame, r domain.Rect) {
819- blank := renderer.BlankCell()
820- for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
821- for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
822- frame.Set(x, y, blank)
823- }
824- }
825-}
826-
827 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {412 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
828 rows := min(r.Height, src.Height)413 rows := min(r.Height, src.Height)
829 cols := min(r.Width, src.Width)414 cols := min(r.Width, src.Width)
@@ -838,25 +423,6 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
838 }423 }
839 }424 }
840 425
841-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
842- styles := newThemeStyles(theme)
843- style := styles.border
844- if focused {
845- style = styles.statusBar
846- } else {
847- style = themeui.DimStyle(style, theme)
848- }
849- for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
850- frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
851- }
852- p.mu.Lock()
853- title := p.displayTitleLocked()
854- generation := p.title.generation
855- p.mu.Unlock()
856- ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
857- return generation
858-}
859-
860 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {426 func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) {
861 if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 {427 if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 {
862 return428 return
A internal/usecase/daemon/render_capture.go +335 -0
@@ -0,0 +1,335 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ scopy "github.com/bnema/vev/internal/usecase/copy"
6+ "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/internal/usecase/picker"
8+ themeui "github.com/bnema/vev/internal/usecase/theme"
9+ "github.com/bnema/vev/internal/usecase/ui"
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+// damageCaptureMode makes damage ownership explicit. Primary active-session
14+// capture is destructive; picker preview observes the same VT state without
15+// acknowledging it.
16+type damageCaptureMode uint8
17+
18+const (
19+ damageCapturePreview damageCaptureMode = iota
20+ damageCaptureConsume
21+)
22+
23+// capturedRenderState is the immutable hand-off from authoritative daemon
24+// state to composition. Every field is a value or an owned bounded copy; the
25+// composer must not retain or consult session, tab, pane, or overlay owners.
26+// renderCaptureScratch owns bounded snapshots for one attachment. The complete
27+// capture/compose/emit transaction holds that attachment's sendMu, so these
28+// buffers cannot be observed or reused before emission completes.
29+type renderCaptureScratch struct {
30+ state capturedRenderState
31+ panes []capturedPaneRenderState
32+ placements []layout.Placement
33+ statusTabs []statusTab
34+ mru []recentSession
35+ ranked []rankedRecent
36+ titleIDs []layout.PaneID
37+ treeNodes []layout.Node
38+ treeUsed int
39+ receipts []damageReceipt
40+}
41+
42+type damageReceipt struct {
43+ pane *pane
44+ generation uint64
45+}
46+
47+type capturedRenderState struct {
48+ attachment *attachedClient // identity only; never dereferenced by composition
49+ attachmentEpoch uint64
50+ reset bool
51+ layout capturedTabLayout
52+ panes []capturedPaneRenderState
53+ floating capturedFloatingRenderState
54+ bars barState
55+ theme themeui.Theme
56+ overlays capturedOverlayRenderState
57+ preview picker.Preview
58+ cursor capturedCursorInputs
59+ tabGeneration uint64
60+ floatingGeneration uint64
61+ receipts []damageReceipt // private capture receipts; compose must not inspect these
62+}
63+
64+type capturedTabLayout struct {
65+ root *layout.Node
66+ area domain.Rect
67+ focus layout.PaneID
68+ placements []layout.Placement
69+ fingerprint string
70+ valid bool
71+}
72+
73+type capturedPaneRenderState struct {
74+ id layout.PaneID
75+ frame renderer.Frame
76+ rawDamage []renderer.Damage
77+ damage []renderer.Damage
78+ damageGeneration uint64
79+ title string
80+ titleGeneration uint64
81+ placement layout.Placement
82+ focused bool
83+}
84+
85+type capturedFloatingRenderState struct {
86+ visible bool
87+ pane capturedPaneRenderState
88+ geometry floatingGeometry
89+ title string
90+ generation uint64
91+ titleGeneration uint64
92+}
93+
94+type capturedOverlayRenderState struct {
95+ copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
96+ copyMode *scopy.Mode
97+ copySnapshot *scopy.Snapshot
98+ copyPaneID layout.PaneID
99+ copyFeedback string
100+ paletteGuidance string
101+ copySearch, picker, palette, prompt capturedModal
102+}
103+
104+type capturedModal struct {
105+ modal ui.Modal
106+ inner renderer.Frame
107+}
108+
109+func (o capturedOverlayRenderState) active() bool {
110+ return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive
111+}
112+
113+type capturedCursorInputs struct {
114+ row, col, style int
115+ hasStyle, visible, renderable, hiddenByOverlay bool
116+ content domain.Rect
117+}
118+
119+// capturePaneRenderStateLocked copies only the visible rectangle required by
120+// composition. It never copies scrollback/history and never lets the mutable
121+// VT frame's Cells or row-offset slices escape pane.mu.
122+func capturePaneRenderStateLocked(p *pane, visible domain.Rect, mode damageCaptureMode) capturedPaneRenderState {
123+ return capturePaneRenderStateLockedInto(p, visible, mode, capturedPaneRenderState{})
124+}
125+
126+func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCaptureMode, out capturedPaneRenderState) capturedPaneRenderState {
127+ out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation
128+ damage := p.screen.CaptureDamage()
129+ out.damageGeneration = damage.Generation
130+ out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
131+
132+ width := min(max(visible.Width, 0), p.screen.Frame.Width)
133+ height := min(max(visible.Height, 0), p.screen.Frame.Height)
134+ needsFrame := len(damage.Damage) > 0
135+ if out.frame.Width != width || out.frame.Height != height {
136+ out.frame = renderer.NewFrame(width, height)
137+ needsFrame = true
138+ }
139+ // A retained attachment snapshot is already an immutable copy of this
140+ // pane. With no VT damage, retain it rather than copying inactive panes.
141+ if needsFrame {
142+ for y := range height {
143+ copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])
144+ }
145+ }
146+
147+ if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
148+ out.damage = []renderer.Damage{renderer.FullRedraw()}
149+ } else {
150+ out.damage = append(out.damage[:0], damage.Damage...)
151+ }
152+ // Consumption is transactional: capture only snapshots damage. emitFrame
153+ // acknowledges this generation after preparation and transport success.
154+ return out
155+}
156+
157+func uncertainDamage(damage []renderer.Damage, width, height int) bool {
158+ for _, d := range damage {
159+ if d.Kind == renderer.DamageFullRedraw {
160+ if d != renderer.FullRedraw() {
161+ return true
162+ }
163+ continue
164+ }
165+ if d.Kind < renderer.DamageText || d.Kind > renderer.DamageScrollUp || d.X < 0 || d.Y < 0 || d.Width <= 0 || d.Height <= 0 || d.X+d.Width > width || d.Y+d.Height > height {
166+ return true
167+ }
168+ if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) {
169+ return true
170+ }
171+ }
172+ return false
173+}
174+
175+// captureRenderState is the ownership boundary for a render transaction.
176+// Callers hold attachment sendMu; this function then follows session -> tab ->
177+// pane lock order. ACK-blocked primary capture returns before touching VT
178+// damage. Preview mode is always non-destructive.
179+func captureRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, floatingCfg domain.FloatingConfig, reset bool, mode damageCaptureMode) (*capturedRenderState, bool) {
180+ if sess == nil || ac == nil || (mode == damageCaptureConsume && ac.output != nil && ac.output.atCapacity()) {
181+ return nil, false
182+ }
183+ sess.mu.Lock()
184+ if sess.client != ac || sess.active < 0 || sess.active >= len(sess.tabs) {
185+ sess.mu.Unlock()
186+ return nil, false
187+ }
188+ tb := sess.tabs[sess.active]
189+ epoch := ac.coordinatorEpoch.Load()
190+ sess.mu.Unlock()
191+
192+ scratch := &ac.renderScratch
193+ scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
194+ bars.status.tabs = scratch.statusTabs
195+ scratch.mru = append(scratch.mru[:0], bars.mru...)
196+ bars.mru = scratch.mru
197+ scratch.ranked = append(scratch.ranked[:0], bars.rankedRecent...)
198+ bars.rankedRecent = scratch.ranked
199+ if len(preview.Rows) > 0 {
200+ preview = clonePickerPreview(preview)
201+ } else {
202+ preview = picker.Preview{Width: preview.Width, Height: preview.Height}
203+ }
204+
205+ tb.mu.Lock()
206+ defer tb.mu.Unlock()
207+ layoutSnap := solveTabLayoutLocked(tb)
208+ scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
209+ scratch.treeUsed = 0
210+ var root *layout.Node
211+ if tb.tree != nil {
212+ root = cloneLayoutNodeIntoScratch(tb.tree.Root, scratch)
213+ }
214+ state := &scratch.state
215+ *state = capturedRenderState{
216+ attachment: ac, attachmentEpoch: epoch, reset: reset, bars: bars, theme: bars.theme,
217+ overlays: overlays, preview: preview,
218+ layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
219+ floatingGeneration: tb.floating.generation,
220+ receipts: scratch.receipts[:0],
221+ }
222+ state.panes = scratch.panes[:0]
223+ state.tabGeneration = uint64(len(layoutSnap.fingerprint))
224+ for _, placement := range layoutSnap.placements {
225+ p := tb.panes[placement.ID]
226+ if p == nil {
227+ continue
228+ }
229+ visible := placement.Content
230+ if placement.Collapsed {
231+ visible = domain.Rect{}
232+ }
233+ p.mu.Lock()
234+ if ac.captureFrames == nil {
235+ ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
236+ }
237+ captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id])
238+ if mode == damageCaptureConsume {
239+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
240+ }
241+ captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
242+ if captured.focused {
243+ state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
244+ }
245+ p.mu.Unlock()
246+ translated := captured.rawDamage[:0]
247+ for _, damage := range captured.damage {
248+ translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
249+ }
250+ captured.rawDamage, captured.damage = captured.damage, translated
251+ ac.captureFrames[captured.id] = captured
252+ state.panes = append(state.panes, captured)
253+ }
254+ scratch.panes = state.panes
255+ if tb.floating.state == floatingVisible && tb.floating.pane != nil {
256+ p := tb.floating.pane
257+ p.mu.Lock()
258+ geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
259+ if ac.captureFrames == nil {
260+ ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
261+ }
262+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id])
263+ if mode == damageCaptureConsume {
264+ seen := false
265+ for _, receipt := range state.receipts {
266+ if receipt.pane == p {
267+ seen = true
268+ break
269+ }
270+ }
271+ if !seen {
272+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
273+ }
274+ }
275+ ac.captureFrames[captured.id] = captured
276+ state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
277+ state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
278+ p.mu.Unlock()
279+ }
280+ scratch.receipts = state.receipts
281+ return state, true
282+}
283+
284+func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node {
285+ if src == nil {
286+ return nil
287+ }
288+ if scratch.treeUsed == len(scratch.treeNodes) {
289+ scratch.treeNodes = append(scratch.treeNodes, layout.Node{})
290+ }
291+ index := scratch.treeUsed
292+ scratch.treeUsed++
293+ dst := &scratch.treeNodes[index]
294+ children := dst.Children[:0]
295+ *dst = *src
296+ dst.Children = children
297+ for _, child := range src.Children {
298+ dst.Children = append(dst.Children, cloneLayoutNodeIntoScratch(child, scratch))
299+ }
300+ return dst
301+}
302+
303+func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
304+ style, hasStyle := p.screen.CursorStyle()
305+ hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
306+ return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content}
307+}
308+
309+func clonePickerPreview(in picker.Preview) picker.Preview {
310+ out := picker.Preview{Width: in.Width, Height: in.Height, Rows: make([][]renderer.Cell, len(in.Rows))}
311+ for i := range in.Rows {
312+ out.Rows[i] = append([]renderer.Cell(nil), in.Rows[i]...)
313+ }
314+ return out
315+}
316+
317+// appendStackPaneIDs returns exactly the panes whose solved placement can own a
318+// stack title bar. The caller holds tab.mu, and dst is attachment scratch.
319+func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID {
320+ if node == nil {
321+ return dst
322+ }
323+ if node.Kind == layout.Stack {
324+ for _, child := range node.Children {
325+ if child != nil && child.Kind == layout.Leaf {
326+ dst = append(dst, child.Leaf)
327+ }
328+ }
329+ return dst
330+ }
331+ for _, child := range node.Children {
332+ dst = appendStackPaneIDs(dst, child)
333+ }
334+ return dst
335+}
@@ -0,0 +1,335 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ scopy "github.com/bnema/vev/internal/usecase/copy"
6+ "github.com/bnema/vev/internal/usecase/layout"
7+ "github.com/bnema/vev/internal/usecase/picker"
8+ themeui "github.com/bnema/vev/internal/usecase/theme"
9+ "github.com/bnema/vev/internal/usecase/ui"
10+ "github.com/bnema/vev/pkg/renderer"
11+)
12+
13+// damageCaptureMode makes damage ownership explicit. Primary active-session
14+// capture is destructive; picker preview observes the same VT state without
15+// acknowledging it.
16+type damageCaptureMode uint8
17+
18+const (
19+ damageCapturePreview damageCaptureMode = iota
20+ damageCaptureConsume
21+)
22+
23+// capturedRenderState is the immutable hand-off from authoritative daemon
24+// state to composition. Every field is a value or an owned bounded copy; the
25+// composer must not retain or consult session, tab, pane, or overlay owners.
26+// renderCaptureScratch owns bounded snapshots for one attachment. The complete
27+// capture/compose/emit transaction holds that attachment's sendMu, so these
28+// buffers cannot be observed or reused before emission completes.
29+type renderCaptureScratch struct {
30+ state capturedRenderState
31+ panes []capturedPaneRenderState
32+ placements []layout.Placement
33+ statusTabs []statusTab
34+ mru []recentSession
35+ ranked []rankedRecent
36+ titleIDs []layout.PaneID
37+ treeNodes []layout.Node
38+ treeUsed int
39+ receipts []damageReceipt
40+}
41+
42+type damageReceipt struct {
43+ pane *pane
44+ generation uint64
45+}
46+
47+type capturedRenderState struct {
48+ attachment *attachedClient // identity only; never dereferenced by composition
49+ attachmentEpoch uint64
50+ reset bool
51+ layout capturedTabLayout
52+ panes []capturedPaneRenderState
53+ floating capturedFloatingRenderState
54+ bars barState
55+ theme themeui.Theme
56+ overlays capturedOverlayRenderState
57+ preview picker.Preview
58+ cursor capturedCursorInputs
59+ tabGeneration uint64
60+ floatingGeneration uint64
61+ receipts []damageReceipt // private capture receipts; compose must not inspect these
62+}
63+
64+type capturedTabLayout struct {
65+ root *layout.Node
66+ area domain.Rect
67+ focus layout.PaneID
68+ placements []layout.Placement
69+ fingerprint string
70+ valid bool
71+}
72+
73+type capturedPaneRenderState struct {
74+ id layout.PaneID
75+ frame renderer.Frame
76+ rawDamage []renderer.Damage
77+ damage []renderer.Damage
78+ damageGeneration uint64
79+ title string
80+ titleGeneration uint64
81+ placement layout.Placement
82+ focused bool
83+}
84+
85+type capturedFloatingRenderState struct {
86+ visible bool
87+ pane capturedPaneRenderState
88+ geometry floatingGeometry
89+ title string
90+ generation uint64
91+ titleGeneration uint64
92+}
93+
94+type capturedOverlayRenderState struct {
95+ copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
96+ copyMode *scopy.Mode
97+ copySnapshot *scopy.Snapshot
98+ copyPaneID layout.PaneID
99+ copyFeedback string
100+ paletteGuidance string
101+ copySearch, picker, palette, prompt capturedModal
102+}
103+
104+type capturedModal struct {
105+ modal ui.Modal
106+ inner renderer.Frame
107+}
108+
109+func (o capturedOverlayRenderState) active() bool {
110+ return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive
111+}
112+
113+type capturedCursorInputs struct {
114+ row, col, style int
115+ hasStyle, visible, renderable, hiddenByOverlay bool
116+ content domain.Rect
117+}
118+
119+// capturePaneRenderStateLocked copies only the visible rectangle required by
120+// composition. It never copies scrollback/history and never lets the mutable
121+// VT frame's Cells or row-offset slices escape pane.mu.
122+func capturePaneRenderStateLocked(p *pane, visible domain.Rect, mode damageCaptureMode) capturedPaneRenderState {
123+ return capturePaneRenderStateLockedInto(p, visible, mode, capturedPaneRenderState{})
124+}
125+
126+func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, _ damageCaptureMode, out capturedPaneRenderState) capturedPaneRenderState {
127+ out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation
128+ damage := p.screen.CaptureDamage()
129+ out.damageGeneration = damage.Generation
130+ out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
131+
132+ width := min(max(visible.Width, 0), p.screen.Frame.Width)
133+ height := min(max(visible.Height, 0), p.screen.Frame.Height)
134+ needsFrame := len(damage.Damage) > 0
135+ if out.frame.Width != width || out.frame.Height != height {
136+ out.frame = renderer.NewFrame(width, height)
137+ needsFrame = true
138+ }
139+ // A retained attachment snapshot is already an immutable copy of this
140+ // pane. With no VT damage, retain it rather than copying inactive panes.
141+ if needsFrame {
142+ for y := range height {
143+ copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])
144+ }
145+ }
146+
147+ if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
148+ out.damage = []renderer.Damage{renderer.FullRedraw()}
149+ } else {
150+ out.damage = append(out.damage[:0], damage.Damage...)
151+ }
152+ // Consumption is transactional: capture only snapshots damage. emitFrame
153+ // acknowledges this generation after preparation and transport success.
154+ return out
155+}
156+
157+func uncertainDamage(damage []renderer.Damage, width, height int) bool {
158+ for _, d := range damage {
159+ if d.Kind == renderer.DamageFullRedraw {
160+ if d != renderer.FullRedraw() {
161+ return true
162+ }
163+ continue
164+ }
165+ if d.Kind < renderer.DamageText || d.Kind > renderer.DamageScrollUp || d.X < 0 || d.Y < 0 || d.Width <= 0 || d.Height <= 0 || d.X+d.Width > width || d.Y+d.Height > height {
166+ return true
167+ }
168+ if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) {
169+ return true
170+ }
171+ }
172+ return false
173+}
174+
175+// captureRenderState is the ownership boundary for a render transaction.
176+// Callers hold attachment sendMu; this function then follows session -> tab ->
177+// pane lock order. ACK-blocked primary capture returns before touching VT
178+// damage. Preview mode is always non-destructive.
179+func captureRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, floatingCfg domain.FloatingConfig, reset bool, mode damageCaptureMode) (*capturedRenderState, bool) {
180+ if sess == nil || ac == nil || (mode == damageCaptureConsume && ac.output != nil && ac.output.atCapacity()) {
181+ return nil, false
182+ }
183+ sess.mu.Lock()
184+ if sess.client != ac || sess.active < 0 || sess.active >= len(sess.tabs) {
185+ sess.mu.Unlock()
186+ return nil, false
187+ }
188+ tb := sess.tabs[sess.active]
189+ epoch := ac.coordinatorEpoch.Load()
190+ sess.mu.Unlock()
191+
192+ scratch := &ac.renderScratch
193+ scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
194+ bars.status.tabs = scratch.statusTabs
195+ scratch.mru = append(scratch.mru[:0], bars.mru...)
196+ bars.mru = scratch.mru
197+ scratch.ranked = append(scratch.ranked[:0], bars.rankedRecent...)
198+ bars.rankedRecent = scratch.ranked
199+ if len(preview.Rows) > 0 {
200+ preview = clonePickerPreview(preview)
201+ } else {
202+ preview = picker.Preview{Width: preview.Width, Height: preview.Height}
203+ }
204+
205+ tb.mu.Lock()
206+ defer tb.mu.Unlock()
207+ layoutSnap := solveTabLayoutLocked(tb)
208+ scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
209+ scratch.treeUsed = 0
210+ var root *layout.Node
211+ if tb.tree != nil {
212+ root = cloneLayoutNodeIntoScratch(tb.tree.Root, scratch)
213+ }
214+ state := &scratch.state
215+ *state = capturedRenderState{
216+ attachment: ac, attachmentEpoch: epoch, reset: reset, bars: bars, theme: bars.theme,
217+ overlays: overlays, preview: preview,
218+ layout: capturedTabLayout{root: root, area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
219+ floatingGeneration: tb.floating.generation,
220+ receipts: scratch.receipts[:0],
221+ }
222+ state.panes = scratch.panes[:0]
223+ state.tabGeneration = uint64(len(layoutSnap.fingerprint))
224+ for _, placement := range layoutSnap.placements {
225+ p := tb.panes[placement.ID]
226+ if p == nil {
227+ continue
228+ }
229+ visible := placement.Content
230+ if placement.Collapsed {
231+ visible = domain.Rect{}
232+ }
233+ p.mu.Lock()
234+ if ac.captureFrames == nil {
235+ ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
236+ }
237+ captured := capturePaneRenderStateLockedInto(p, visible, mode, ac.captureFrames[p.id])
238+ if mode == damageCaptureConsume {
239+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
240+ }
241+ captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
242+ if captured.focused {
243+ state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
244+ }
245+ p.mu.Unlock()
246+ translated := captured.rawDamage[:0]
247+ for _, damage := range captured.damage {
248+ translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
249+ }
250+ captured.rawDamage, captured.damage = captured.damage, translated
251+ ac.captureFrames[captured.id] = captured
252+ state.panes = append(state.panes, captured)
253+ }
254+ scratch.panes = state.panes
255+ if tb.floating.state == floatingVisible && tb.floating.pane != nil {
256+ p := tb.floating.pane
257+ p.mu.Lock()
258+ geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
259+ if ac.captureFrames == nil {
260+ ac.captureFrames = make(map[layout.PaneID]capturedPaneRenderState)
261+ }
262+ captured := capturePaneRenderStateLockedInto(p, geometry.Inner, mode, ac.captureFrames[p.id])
263+ if mode == damageCaptureConsume {
264+ seen := false
265+ for _, receipt := range state.receipts {
266+ if receipt.pane == p {
267+ seen = true
268+ break
269+ }
270+ }
271+ if !seen {
272+ state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
273+ }
274+ }
275+ ac.captureFrames[captured.id] = captured
276+ state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
277+ state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
278+ p.mu.Unlock()
279+ }
280+ scratch.receipts = state.receipts
281+ return state, true
282+}
283+
284+func cloneLayoutNodeIntoScratch(src *layout.Node, scratch *renderCaptureScratch) *layout.Node {
285+ if src == nil {
286+ return nil
287+ }
288+ if scratch.treeUsed == len(scratch.treeNodes) {
289+ scratch.treeNodes = append(scratch.treeNodes, layout.Node{})
290+ }
291+ index := scratch.treeUsed
292+ scratch.treeUsed++
293+ dst := &scratch.treeNodes[index]
294+ children := dst.Children[:0]
295+ *dst = *src
296+ dst.Children = children
297+ for _, child := range src.Children {
298+ dst.Children = append(dst.Children, cloneLayoutNodeIntoScratch(child, scratch))
299+ }
300+ return dst
301+}
302+
303+func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
304+ style, hasStyle := p.screen.CursorStyle()
305+ hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
306+ return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content}
307+}
308+
309+func clonePickerPreview(in picker.Preview) picker.Preview {
310+ out := picker.Preview{Width: in.Width, Height: in.Height, Rows: make([][]renderer.Cell, len(in.Rows))}
311+ for i := range in.Rows {
312+ out.Rows[i] = append([]renderer.Cell(nil), in.Rows[i]...)
313+ }
314+ return out
315+}
316+
317+// appendStackPaneIDs returns exactly the panes whose solved placement can own a
318+// stack title bar. The caller holds tab.mu, and dst is attachment scratch.
319+func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID {
320+ if node == nil {
321+ return dst
322+ }
323+ if node.Kind == layout.Stack {
324+ for _, child := range node.Children {
325+ if child != nil && child.Kind == layout.Leaf {
326+ dst = append(dst, child.Leaf)
327+ }
328+ }
329+ return dst
330+ }
331+ for _, child := range node.Children {
332+ dst = appendStackPaneIDs(dst, child)
333+ }
334+ return dst
335+}
A internal/usecase/daemon/render_damage_receipt_test.go +102 -0
@@ -0,0 +1,102 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+ "github.com/bnema/vev/pkg/renderer"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// These are deliberately end-to-end pipeline tests: the pending damage comes
14+// from a real pane VT Write, not a hand-built FullRedraw.
15+func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
16+ for _, tt := range []struct {
17+ name string
18+ fail func(*composedRenderFrame, *attachedClient)
19+ restore func(*session, *attachedClient, ports.Transport)
20+ }{
21+ {
22+ name: "prepare failure",
23+ fail: func(c *composedRenderFrame, _ *attachedClient) {
24+ // An invalid frame makes the real output.prepare reject after capture.
25+ c.frame = renderer.Frame{Width: 1}
26+ },
27+ restore: func(_ *session, _ *attachedClient, _ ports.Transport) {},
28+ },
29+ {
30+ name: "send failure",
31+ fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
32+ restore: func(sess *session, ac *attachedClient, healthy ports.Transport) {
33+ sess.mu.Lock()
34+ sess.client = ac
35+ sess.mu.Unlock()
36+ ac.setSession(sess)
37+ ac.replaceTransport(healthy)
38+ },
39+ },
40+ } {
41+ t.Run(tt.name, func(t *testing.T) {
42+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
43+ healthy := ac.transport()
44+ p := sess.tabs[0].focusedPane()
45+
46+ // Prime both the renderer shadow and the committed capture cache.
47+ d.paint(sess, ac, true)
48+ <-sends
49+ p.mu.Lock()
50+ p.screen.Write([]byte("\x1b[1;1Hchanged"))
51+ p.mu.Unlock()
52+
53+ state, composed := captureComposeForReceiptTest(t, sess, ac)
54+ tt.fail(&composed, ac)
55+ require.True(t, d.emitFrame(sess, ac, state, composed))
56+ p.mu.Lock()
57+ require.NotEmpty(t, p.screen.Damage(), "failed emission must retain real VT damage")
58+ p.mu.Unlock()
59+
60+ tt.restore(sess, ac, healthy)
61+ state, composed = captureComposeForReceiptTest(t, sess, ac)
62+ require.True(t, d.emitFrame(sess, ac, state, composed))
63+ frame := <-sends
64+ out, err := ports.UnmarshalOutput(frame.Payload)
65+ require.NoError(t, err)
66+ require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes")
67+ p.mu.Lock()
68+ require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt")
69+ require.Equal(t, 'c', p.screen.Frame.At(0, 0).Rune, "retry preserves the changed VT shadow")
70+ p.mu.Unlock()
71+ })
72+ }
73+}
74+
75+func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
76+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
77+ d.paint(sess, ac, true)
78+ <-sends
79+ p := sess.tabs[0].focusedPane()
80+ p.mu.Lock()
81+ p.screen.Write([]byte("\x1b[1;1Hfirst"))
82+ p.mu.Unlock()
83+ state, composed := captureComposeForReceiptTest(t, sess, ac)
84+ p.mu.Lock()
85+ p.screen.Write([]byte("\x1b[2;1Hsecond"))
86+ p.mu.Unlock()
87+ require.True(t, d.emitFrame(sess, ac, state, composed))
88+ <-sends
89+ p.mu.Lock()
90+ defer p.mu.Unlock()
91+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, p.screen.Damage(), "a stale receipt conservatively preserves intervening VT output")
92+}
93+
94+func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
95+ t.Helper()
96+ ac.sendMu.Lock() // emitFrame releases the transaction lock.
97+ state, ok := captureRenderState(sess, ac, barState{}, capturedOverlayRenderState{}, pickerPreviewEmpty(), domain.FloatingConfig{}, false, damageCaptureConsume)
98+ require.True(t, ok)
99+ return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
100+}
101+
102+func pickerPreviewEmpty() picker.Preview { return picker.Preview{} }
@@ -0,0 +1,102 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/picker"
9+ "github.com/bnema/vev/pkg/renderer"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// These are deliberately end-to-end pipeline tests: the pending damage comes
14+// from a real pane VT Write, not a hand-built FullRedraw.
15+func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
16+ for _, tt := range []struct {
17+ name string
18+ fail func(*composedRenderFrame, *attachedClient)
19+ restore func(*session, *attachedClient, ports.Transport)
20+ }{
21+ {
22+ name: "prepare failure",
23+ fail: func(c *composedRenderFrame, _ *attachedClient) {
24+ // An invalid frame makes the real output.prepare reject after capture.
25+ c.frame = renderer.Frame{Width: 1}
26+ },
27+ restore: func(_ *session, _ *attachedClient, _ ports.Transport) {},
28+ },
29+ {
30+ name: "send failure",
31+ fail: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
32+ restore: func(sess *session, ac *attachedClient, healthy ports.Transport) {
33+ sess.mu.Lock()
34+ sess.client = ac
35+ sess.mu.Unlock()
36+ ac.setSession(sess)
37+ ac.replaceTransport(healthy)
38+ },
39+ },
40+ } {
41+ t.Run(tt.name, func(t *testing.T) {
42+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
43+ healthy := ac.transport()
44+ p := sess.tabs[0].focusedPane()
45+
46+ // Prime both the renderer shadow and the committed capture cache.
47+ d.paint(sess, ac, true)
48+ <-sends
49+ p.mu.Lock()
50+ p.screen.Write([]byte("\x1b[1;1Hchanged"))
51+ p.mu.Unlock()
52+
53+ state, composed := captureComposeForReceiptTest(t, sess, ac)
54+ tt.fail(&composed, ac)
55+ require.True(t, d.emitFrame(sess, ac, state, composed))
56+ p.mu.Lock()
57+ require.NotEmpty(t, p.screen.Damage(), "failed emission must retain real VT damage")
58+ p.mu.Unlock()
59+
60+ tt.restore(sess, ac, healthy)
61+ state, composed = captureComposeForReceiptTest(t, sess, ac)
62+ require.True(t, d.emitFrame(sess, ac, state, composed))
63+ frame := <-sends
64+ out, err := ports.UnmarshalOutput(frame.Payload)
65+ require.NoError(t, err)
66+ require.Contains(t, string(out.Data), "changed", "retry must emit the retained terminal bytes")
67+ p.mu.Lock()
68+ require.Empty(t, p.screen.Damage(), "only the successful retry commits its receipt")
69+ require.Equal(t, 'c', p.screen.Frame.At(0, 0).Rune, "retry preserves the changed VT shadow")
70+ p.mu.Unlock()
71+ })
72+ }
73+}
74+
75+func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
76+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
77+ d.paint(sess, ac, true)
78+ <-sends
79+ p := sess.tabs[0].focusedPane()
80+ p.mu.Lock()
81+ p.screen.Write([]byte("\x1b[1;1Hfirst"))
82+ p.mu.Unlock()
83+ state, composed := captureComposeForReceiptTest(t, sess, ac)
84+ p.mu.Lock()
85+ p.screen.Write([]byte("\x1b[2;1Hsecond"))
86+ p.mu.Unlock()
87+ require.True(t, d.emitFrame(sess, ac, state, composed))
88+ <-sends
89+ p.mu.Lock()
90+ defer p.mu.Unlock()
91+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, p.screen.Damage(), "a stale receipt conservatively preserves intervening VT output")
92+}
93+
94+func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
95+ t.Helper()
96+ ac.sendMu.Lock() // emitFrame releases the transaction lock.
97+ state, ok := captureRenderState(sess, ac, barState{}, capturedOverlayRenderState{}, pickerPreviewEmpty(), domain.FloatingConfig{}, false, damageCaptureConsume)
98+ require.True(t, ok)
99+ return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
100+}
101+
102+func pickerPreviewEmpty() picker.Preview { return picker.Preview{} }
A internal/usecase/daemon/render_legacy_test.go +283 -0
@@ -0,0 +1,283 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+ themeui "github.com/bnema/vev/internal/usecase/theme"
7+ "github.com/bnema/vev/internal/usecase/ui"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+// Legacy composition adapters are test-only. Production rendering is owned by
12+// captureRenderState -> composeFrame -> emitFrame.
13+// copyTargetRectLocked maps a captured copy source into the already-composed
14+// client frame. The caller holds tb.mu, preserving the layout/floating
15+// snapshot while the rectangle is chosen.
16+func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect {
17+ if p == nil {
18+ return domain.Rect{}
19+ }
20+ if hasFloating && p == floating {
21+ return floatingFrameGeometry.Inner
22+ }
23+ for _, placement := range layoutSnap.placements {
24+ if placement.ID == p.id && !placement.Collapsed {
25+ r := placement.Content
26+ r.X += contentArea.X
27+ r.Y += contentArea.Y
28+ return r
29+ }
30+ }
31+ p.mu.Lock()
32+ width, height := p.screen.Frame.Width, p.screen.Frame.Height
33+ p.mu.Unlock()
34+ return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
35+}
36+
37+type composedFrameCache struct {
38+ frame renderer.Frame
39+ valid bool
40+ layoutSnap tabLayoutSnapshot
41+ titleGenerations map[layout.PaneID]uint64
42+ floating *pane
43+ floatingFrame renderer.Frame
44+ floatingGeneration uint64
45+ floatingGeometry floatingGeometry
46+ floatingTitleGeneration uint64
47+ floatingCaptured capturedPaneRenderState
48+}
49+
50+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
51+ return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
52+}
53+
54+func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
55+ return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
56+}
57+
58+func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
59+ var cache *barCache
60+ if len(caches) > 0 {
61+ cache = caches[0]
62+ }
63+ return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
64+}
65+
66+func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
67+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
68+}
69+
70+func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
71+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
72+}
73+
74+func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
75+ p := tb.focusedPane()
76+ styles := newThemeStyles(bars.theme)
77+ if p == nil {
78+ width, screenRows := tb.size.Cols, tb.size.Rows
79+ if width <= 0 || screenRows <= 0 {
80+ return renderer.NewFrame(0, 0), nil
81+ }
82+ return renderer.NewFrame(width, screenRows+2), nil
83+ }
84+ p.mu.Lock()
85+ width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
86+ p.mu.Unlock()
87+ if tb.size.Valid() {
88+ width, screenRows = tb.size.Cols, tb.size.Rows
89+ }
90+ cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
91+ layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
92+ var frame renderer.Frame
93+ if cacheValid {
94+ frame = composed.frame
95+ } else {
96+ frame = renderer.NewFrame(width, screenRows+2)
97+ if composed != nil {
98+ full = true
99+ }
100+ }
101+ if !layoutSame {
102+ full = true
103+ }
104+ contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
105+ if cacheValid && !layoutSame {
106+ clearFrameRect(frame, contentArea)
107+ }
108+ topBar := frame.Row(0)
109+ drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
110+ var titleGenerations map[layout.PaneID]uint64
111+ if composed != nil {
112+ if composed.titleGenerations == nil {
113+ composed.titleGenerations = make(map[layout.PaneID]uint64)
114+ }
115+ titleGenerations = composed.titleGenerations
116+ }
117+ contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
118+ bottomBar := frame.Row(screenRows + 1)
119+ drawStatusBarState(bottomBar, bars, styles)
120+ if composed != nil {
121+ composed.frame = frame
122+ composed.valid = true
123+ composed.layoutSnap = layoutSnap
124+ }
125+ if full {
126+ if cache != nil {
127+ cache.capture(topBar, bottomBar)
128+ }
129+ return frame, []renderer.Damage{renderer.FullRedraw()}
130+ }
131+ damage := translateDamage(contentDamage, 0, 1)
132+ if cache == nil || !sameCells(cache.top, topBar) {
133+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
134+ }
135+ if cache == nil || !sameCells(cache.bottom, bottomBar) {
136+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
137+ }
138+ if cache != nil {
139+ cache.capture(topBar, bottomBar)
140+ }
141+ return frame, damage
142+}
143+
144+func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
145+ return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
146+}
147+
148+func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
149+ frame := renderer.NewFrame(area.Width, area.Height)
150+ damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
151+ return frame, damage
152+}
153+
154+func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
155+ return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
156+}
157+
158+func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
159+ contentArea := domain.Rect{Width: area.Width, Height: area.Height}
160+ root := tb.tree.Root
161+ placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
162+ if !ok {
163+ placements, ok = layout.Solve(root, contentArea)
164+ }
165+ var fallback *pane
166+ if !ok {
167+ fallback = tb.focusedPane()
168+ if fallback == nil {
169+ return nil
170+ }
171+ placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
172+ }
173+ // A valid cache has the same layout, so its title IDs remain valid. Reset
174+ // the existing cache only when rebuilding after layout or frame churn.
175+ if titleGenerations != nil && !cacheValid {
176+ clear(titleGenerations)
177+ }
178+ if ok && !cacheValid {
179+ drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
180+ }
181+ var damage []renderer.Damage
182+ for _, pl := range placements {
183+ p := tb.panes[pl.ID]
184+ if p == nil && fallback != nil && pl.ID == fallback.id {
185+ p = fallback
186+ }
187+ if p == nil {
188+ continue
189+ }
190+ focused := tb.tree.Focus == pl.ID
191+ pl = offsetPlacement(pl, area.X, area.Y)
192+ if pl.TitleBar.Height > 0 {
193+ generation := drawPaneTitleBar(frame, pl, p, focused, theme)
194+ if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
195+ titleDamage := pl.TitleBar
196+ titleDamage.X -= area.X
197+ titleDamage.Y -= area.Y
198+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
199+ }
200+ if titleGenerations != nil {
201+ titleGenerations[pl.ID] = generation
202+ }
203+ }
204+ mode := damageCapturePreview
205+ if consumeDamage {
206+ mode = damageCaptureConsume
207+ }
208+ if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
209+ if consumeDamage {
210+ p.mu.Lock()
211+ captured := capturePaneRenderStateLocked(p, domain.Rect{}, mode)
212+ p.screen.AcknowledgeDamage(captured.damageGeneration)
213+ p.mu.Unlock()
214+ }
215+ continue
216+ }
217+ var paneFrame renderer.Frame
218+ var paneDamage []renderer.Damage
219+ if consumeDamage {
220+ p.mu.Lock()
221+ captured := capturePaneRenderStateLocked(p, pl.Content, mode)
222+ p.screen.AcknowledgeDamage(captured.damageGeneration)
223+ p.mu.Unlock()
224+ paneFrame, paneDamage = captured.frame, captured.damage
225+ } else {
226+ p.mu.Lock()
227+ paneDamage = p.screen.Damage()
228+ if !cacheValid || len(paneDamage) > 0 {
229+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
230+ }
231+ for _, d := range paneDamage {
232+ localContent := pl.Content
233+ localContent.X -= area.X
234+ localContent.Y -= area.Y
235+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
236+ }
237+ p.mu.Unlock()
238+ continue
239+ }
240+ if !cacheValid || len(paneDamage) > 0 {
241+ blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
242+ }
243+ for _, d := range paneDamage {
244+ localContent := pl.Content
245+ localContent.X -= area.X
246+ localContent.Y -= area.Y
247+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
248+ }
249+ }
250+ return damage
251+}
252+
253+func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
254+ return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
255+}
256+
257+func clearFrameRect(frame renderer.Frame, r domain.Rect) {
258+ blank := renderer.BlankCell()
259+ for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
260+ for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
261+ frame.Set(x, y, blank)
262+ }
263+ }
264+}
265+
266+func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
267+ styles := newThemeStyles(theme)
268+ style := styles.border
269+ if focused {
270+ style = styles.statusBar
271+ } else {
272+ style = themeui.DimStyle(style, theme)
273+ }
274+ for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
275+ frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
276+ }
277+ p.mu.Lock()
278+ title := p.displayTitleLocked()
279+ generation := p.title.generation
280+ p.mu.Unlock()
281+ ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
282+ return generation
283+}
@@ -0,0 +1,283 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+ themeui "github.com/bnema/vev/internal/usecase/theme"
7+ "github.com/bnema/vev/internal/usecase/ui"
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+// Legacy composition adapters are test-only. Production rendering is owned by
12+// captureRenderState -> composeFrame -> emitFrame.
13+// copyTargetRectLocked maps a captured copy source into the already-composed
14+// client frame. The caller holds tb.mu, preserving the layout/floating
15+// snapshot while the rectangle is chosen.
16+func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect {
17+ if p == nil {
18+ return domain.Rect{}
19+ }
20+ if hasFloating && p == floating {
21+ return floatingFrameGeometry.Inner
22+ }
23+ for _, placement := range layoutSnap.placements {
24+ if placement.ID == p.id && !placement.Collapsed {
25+ r := placement.Content
26+ r.X += contentArea.X
27+ r.Y += contentArea.Y
28+ return r
29+ }
30+ }
31+ p.mu.Lock()
32+ width, height := p.screen.Frame.Width, p.screen.Frame.Height
33+ p.mu.Unlock()
34+ return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
35+}
36+
37+type composedFrameCache struct {
38+ frame renderer.Frame
39+ valid bool
40+ layoutSnap tabLayoutSnapshot
41+ titleGenerations map[layout.PaneID]uint64
42+ floating *pane
43+ floatingFrame renderer.Frame
44+ floatingGeneration uint64
45+ floatingGeometry floatingGeometry
46+ floatingTitleGeneration uint64
47+ floatingCaptured capturedPaneRenderState
48+}
49+
50+func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
51+ return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
52+}
53+
54+func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
55+ return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
56+}
57+
58+func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
59+ var cache *barCache
60+ if len(caches) > 0 {
61+ cache = caches[0]
62+ }
63+ return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
64+}
65+
66+func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
67+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
68+}
69+
70+func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
71+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
72+}
73+
74+func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
75+ p := tb.focusedPane()
76+ styles := newThemeStyles(bars.theme)
77+ if p == nil {
78+ width, screenRows := tb.size.Cols, tb.size.Rows
79+ if width <= 0 || screenRows <= 0 {
80+ return renderer.NewFrame(0, 0), nil
81+ }
82+ return renderer.NewFrame(width, screenRows+2), nil
83+ }
84+ p.mu.Lock()
85+ width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
86+ p.mu.Unlock()
87+ if tb.size.Valid() {
88+ width, screenRows = tb.size.Cols, tb.size.Rows
89+ }
90+ cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
91+ layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
92+ var frame renderer.Frame
93+ if cacheValid {
94+ frame = composed.frame
95+ } else {
96+ frame = renderer.NewFrame(width, screenRows+2)
97+ if composed != nil {
98+ full = true
99+ }
100+ }
101+ if !layoutSame {
102+ full = true
103+ }
104+ contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
105+ if cacheValid && !layoutSame {
106+ clearFrameRect(frame, contentArea)
107+ }
108+ topBar := frame.Row(0)
109+ drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
110+ var titleGenerations map[layout.PaneID]uint64
111+ if composed != nil {
112+ if composed.titleGenerations == nil {
113+ composed.titleGenerations = make(map[layout.PaneID]uint64)
114+ }
115+ titleGenerations = composed.titleGenerations
116+ }
117+ contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
118+ bottomBar := frame.Row(screenRows + 1)
119+ drawStatusBarState(bottomBar, bars, styles)
120+ if composed != nil {
121+ composed.frame = frame
122+ composed.valid = true
123+ composed.layoutSnap = layoutSnap
124+ }
125+ if full {
126+ if cache != nil {
127+ cache.capture(topBar, bottomBar)
128+ }
129+ return frame, []renderer.Damage{renderer.FullRedraw()}
130+ }
131+ damage := translateDamage(contentDamage, 0, 1)
132+ if cache == nil || !sameCells(cache.top, topBar) {
133+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
134+ }
135+ if cache == nil || !sameCells(cache.bottom, bottomBar) {
136+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
137+ }
138+ if cache != nil {
139+ cache.capture(topBar, bottomBar)
140+ }
141+ return frame, damage
142+}
143+
144+func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
145+ return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
146+}
147+
148+func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
149+ frame := renderer.NewFrame(area.Width, area.Height)
150+ damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
151+ return frame, damage
152+}
153+
154+func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
155+ return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
156+}
157+
158+func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
159+ contentArea := domain.Rect{Width: area.Width, Height: area.Height}
160+ root := tb.tree.Root
161+ placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
162+ if !ok {
163+ placements, ok = layout.Solve(root, contentArea)
164+ }
165+ var fallback *pane
166+ if !ok {
167+ fallback = tb.focusedPane()
168+ if fallback == nil {
169+ return nil
170+ }
171+ placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
172+ }
173+ // A valid cache has the same layout, so its title IDs remain valid. Reset
174+ // the existing cache only when rebuilding after layout or frame churn.
175+ if titleGenerations != nil && !cacheValid {
176+ clear(titleGenerations)
177+ }
178+ if ok && !cacheValid {
179+ drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
180+ }
181+ var damage []renderer.Damage
182+ for _, pl := range placements {
183+ p := tb.panes[pl.ID]
184+ if p == nil && fallback != nil && pl.ID == fallback.id {
185+ p = fallback
186+ }
187+ if p == nil {
188+ continue
189+ }
190+ focused := tb.tree.Focus == pl.ID
191+ pl = offsetPlacement(pl, area.X, area.Y)
192+ if pl.TitleBar.Height > 0 {
193+ generation := drawPaneTitleBar(frame, pl, p, focused, theme)
194+ if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
195+ titleDamage := pl.TitleBar
196+ titleDamage.X -= area.X
197+ titleDamage.Y -= area.Y
198+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
199+ }
200+ if titleGenerations != nil {
201+ titleGenerations[pl.ID] = generation
202+ }
203+ }
204+ mode := damageCapturePreview
205+ if consumeDamage {
206+ mode = damageCaptureConsume
207+ }
208+ if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
209+ if consumeDamage {
210+ p.mu.Lock()
211+ captured := capturePaneRenderStateLocked(p, domain.Rect{}, mode)
212+ p.screen.AcknowledgeDamage(captured.damageGeneration)
213+ p.mu.Unlock()
214+ }
215+ continue
216+ }
217+ var paneFrame renderer.Frame
218+ var paneDamage []renderer.Damage
219+ if consumeDamage {
220+ p.mu.Lock()
221+ captured := capturePaneRenderStateLocked(p, pl.Content, mode)
222+ p.screen.AcknowledgeDamage(captured.damageGeneration)
223+ p.mu.Unlock()
224+ paneFrame, paneDamage = captured.frame, captured.damage
225+ } else {
226+ p.mu.Lock()
227+ paneDamage = p.screen.Damage()
228+ if !cacheValid || len(paneDamage) > 0 {
229+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
230+ }
231+ for _, d := range paneDamage {
232+ localContent := pl.Content
233+ localContent.X -= area.X
234+ localContent.Y -= area.Y
235+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
236+ }
237+ p.mu.Unlock()
238+ continue
239+ }
240+ if !cacheValid || len(paneDamage) > 0 {
241+ blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
242+ }
243+ for _, d := range paneDamage {
244+ localContent := pl.Content
245+ localContent.X -= area.X
246+ localContent.Y -= area.Y
247+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
248+ }
249+ }
250+ return damage
251+}
252+
253+func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
254+ return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
255+}
256+
257+func clearFrameRect(frame renderer.Frame, r domain.Rect) {
258+ blank := renderer.BlankCell()
259+ for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
260+ for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
261+ frame.Set(x, y, blank)
262+ }
263+ }
264+}
265+
266+func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
267+ styles := newThemeStyles(theme)
268+ style := styles.border
269+ if focused {
270+ style = styles.statusBar
271+ } else {
272+ style = themeui.DimStyle(style, theme)
273+ }
274+ for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
275+ frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
276+ }
277+ p.mu.Lock()
278+ title := p.displayTitleLocked()
279+ generation := p.title.generation
280+ p.mu.Unlock()
281+ ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
282+ return generation
283+}
A internal/usecase/daemon/render_pipeline.go +354 -0
@@ -0,0 +1,354 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "strconv"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/usecase/palette"
11+ "github.com/bnema/vev/internal/usecase/picker"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
13+ "github.com/bnema/vev/internal/usecase/ui"
14+ "github.com/bnema/vev/pkg/renderer"
15+)
16+
17+// composeCacheInput is an attachment-local value snapshot. composeFrame never
18+// retains or accesses the attachment that owns it.
19+// renderStageHooks are optional observability hooks. They are invoked only at
20+// completed production boundaries: capture and compose after their result is
21+// available, and emit after prepare plus transport success (including a
22+// successful no-byte emission). Failed attempts count for capture/compose but
23+// never for emit.
24+type renderStageHooks struct {
25+ capture func()
26+ compose func()
27+ emit func()
28+}
29+
30+type composeCacheInput struct {
31+ valid bool
32+ frame renderer.Frame
33+ layoutFingerprint string
34+ titleGenerations map[layout.PaneID]uint64
35+ damage []renderer.Damage
36+ floatingVisible bool
37+ floatingGeneration uint64
38+ floatingGeometry floatingGeometry
39+ floatingTitleGeneration uint64
40+ bars barCache
41+ theme themeui.Theme
42+}
43+
44+type composedRenderFrame struct {
45+ frame renderer.Frame
46+ damage []renderer.Damage
47+ cursor cursorOut
48+ cache composeCacheInput
49+ reset bool
50+}
51+
52+// composeFrame is pure with respect to daemon ownership: it consumes only the
53+// capture, the last committed cache, and an independent attachment-owned
54+// scratch cache. It returns a replacement cache without mutating committed.
55+func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
56+ scratch := composeCacheInput{}
57+ if len(scratchIn) > 0 {
58+ scratch = scratchIn[0]
59+ }
60+ width, rows := state.layout.area.Width, state.layout.area.Height
61+ if width <= 0 || rows < 0 {
62+ return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
63+ }
64+ // Compose into the alternate attachment-owned buffer. The committed frame is
65+ // copied before incremental drawing so prepare/send failures cannot change it.
66+ canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == rows+2
67+ frame := scratch.frame
68+ if !canReuseFrame {
69+ frame = renderer.NewFrame(width, rows+2)
70+ }
71+ if in.frame.Width == width && in.frame.Height == rows+2 {
72+ for y := 0; y < frame.Height; y++ {
73+ copy(frame.Row(y), in.frame.Row(y))
74+ }
75+ }
76+ styles := newThemeStyles(state.theme)
77+ drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
78+ drawStatusBarState(frame.Row(rows+1), state.bars, styles)
79+ content := domain.Rect{Y: 1, Width: width, Height: rows}
80+ if state.layout.valid && state.layout.root != nil {
81+ drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme))
82+ }
83+
84+ full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible
85+ titles := scratch.titleGenerations
86+ if titles == nil {
87+ titles = make(map[layout.PaneID]uint64, len(state.panes))
88+ }
89+ damage := scratch.damage[:0]
90+ for _, pane := range state.panes {
91+ pl := offsetPlacement(pane.placement, 0, 1)
92+ if pl.TitleBar.Height > 0 {
93+ drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, state.theme)
94+ if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
95+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
96+ }
97+ titles[pane.id] = pane.titleGeneration
98+ }
99+ if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
100+ blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme)
101+ }
102+ for _, d := range pane.damage {
103+ if d.Kind != renderer.DamageFullRedraw {
104+ d.Y++
105+ }
106+ damage = append(damage, d)
107+ }
108+ }
109+ // Keep the committed cache unadorned: floating composition clones this
110+ // base, so closing or moving a popup cannot retain dimmed/bordered cells.
111+ baseFrame := frame
112+ if state.floating.visible {
113+ var floatingDamage []renderer.Damage
114+ frame, floatingDamage = composeCapturedFloatingFrame(baseFrame, damage, state.floating, content, state.layout, state.theme, in, full || state.overlays.active())
115+ damage = floatingDamage
116+ }
117+ if !full {
118+ if !sameCells(in.bars.top, frame.Row(0)) {
119+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
120+ }
121+ if !sameCells(in.bars.bottom, frame.Row(rows+1)) {
122+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
123+ }
124+ }
125+ if state.overlays.active() {
126+ // Without a floating frame, overlay composition would otherwise mutate
127+ // the base cache in place. Floating composition already owns a clone.
128+ if !state.floating.visible {
129+ baseFrame = frame.Clone()
130+ }
131+ frame, damage = composeCapturedOverlays(state, frame, damage, content)
132+ }
133+ if full || state.overlays.active() {
134+ damage = []renderer.Damage{renderer.FullRedraw()}
135+ }
136+ cursorInputs := state.cursor
137+ cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
138+ cursor := desiredCapturedCursor(cursorInputs)
139+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
140+ outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
141+ return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
142+}
143+
144+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, theme themeui.Theme) {
145+ styles := newThemeStyles(theme)
146+ style := styles.border
147+ if focused {
148+ style = styles.statusBar
149+ } else {
150+ style = themeui.DimStyle(style, theme)
151+ }
152+ for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
153+ frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
154+ }
155+ ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
156+}
157+
158+func desiredCapturedCursor(c capturedCursorInputs) cursorOut {
159+ if !c.renderable || c.hiddenByOverlay || !c.visible {
160+ return cursorOut{hidden: true}
161+ }
162+ style := c.style
163+ if !c.hasStyle {
164+ style = 1
165+ }
166+ return cursorOut{row: c.content.Y + 1 + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
167+}
168+
169+func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
170+ if state == nil || snap == nil {
171+ return
172+ }
173+ o := &state.overlays
174+ o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive
175+ o.copyMode, o.copySnapshot = snap.copyMode, snap.copySnapshot
176+ if snap.copyPane != nil {
177+ o.copyPaneID = snap.copyPane.id
178+ }
179+ styles := newThemeStyles(state.theme)
180+ size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
181+ if snap.copySearchModel != nil {
182+ o.copySearch.modal = copySearchModal
183+ o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.selection)
184+ }
185+ if snap.pickerActive && snap.pickerModel != nil {
186+ o.picker.modal = pickerModal
187+ renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle()}
188+ o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
189+ }
190+ if snap.paletteActive && snap.paletteModel != nil {
191+ o.palette.modal = paletteModalFor(size, paletteCfg)
192+ guidance := ""
193+ if snap.paletteHints != nil {
194+ guidance = snap.paletteHints.Feedback
195+ }
196+ o.paletteGuidance = guidance
197+ o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance})
198+ }
199+ if snap.promptActive && snap.promptModel != nil {
200+ o.prompt.modal = promptModalFor(snap.promptModel.Title())
201+ o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.accent)
202+ }
203+ state.cursor.hiddenByOverlay = o.active()
204+}
205+
206+func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
207+ o := state.overlays
208+ if o.copyActive {
209+ target := domain.Rect{}
210+ if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {
211+ target = state.floating.geometry.translate(content.X, content.Y).Inner
212+ }
213+ if target.Width == 0 {
214+ id := o.copyPaneID
215+ if id == "" {
216+ id = state.layout.focus
217+ }
218+ for _, p := range state.panes {
219+ if p.id == id && !p.placement.Collapsed {
220+ target = p.placement.Content
221+ target.Y += content.Y
222+ break
223+ }
224+ }
225+ }
226+ frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars)
227+ }
228+ legacy := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
229+ if o.paletteActive && !state.floating.visible {
230+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacy, state.theme)
231+ }
232+ for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} {
233+ if modal.inner.Width == 0 && modal.inner.Height == 0 {
234+ continue
235+ }
236+ inner := modal.modal.Composite(frame, newThemeStyles(state.theme).border)
237+ for y := range min(inner.Height, modal.inner.Height) {
238+ copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])
239+ }
240+ damage = []renderer.Damage{renderer.FullRedraw()}
241+ }
242+ return frame, damage
243+}
244+
245+func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
246+ changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
247+ if !changed {
248+ return nil
249+ }
250+ prev := ac.lastCursor
251+ ac.lastCursor = desired
252+ ac.lastCursor.valid = true
253+ if desired.hidden {
254+ return []byte("\x1b[?25l")
255+ }
256+ var b []byte
257+ b = append(b, "\x1b["...)
258+ b = strconv.AppendInt(b, int64(desired.row+1), 10)
259+ b = append(b, ';')
260+ b = strconv.AppendInt(b, int64(desired.col+1), 10)
261+ b = append(b, 'H')
262+ if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
263+ b = append(b, "\x1b["...)
264+ b = strconv.AppendInt(b, int64(desired.style), 10)
265+ b = append(b, " q"...)
266+ }
267+ b = append(b, "\x1b[?25h"...)
268+ return b
269+}
270+
271+// commitDamageReceipts acknowledges only snapshots that reached the client.
272+// sendMu serializes attachment transactions; panes are locked individually so
273+// no pane lock is held while another is acquired. A stale generation is
274+// intentionally acknowledged too: Screen conservatively leaves FullRedraw for
275+// the next transaction.
276+func commitDamageReceipts(receipts []damageReceipt) {
277+ for _, receipt := range receipts {
278+ if receipt.pane == nil {
279+ continue
280+ }
281+ receipt.pane.mu.Lock()
282+ receipt.pane.screen.AcknowledgeDamage(receipt.generation)
283+ receipt.pane.mu.Unlock()
284+ }
285+}
286+
287+// emitFrame is the sole side-effecting half of the pipeline. The caller holds
288+// sendMu for the complete capture/compose/emit transaction.
289+func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame) bool {
290+ if sess == nil || ac == nil || state == nil {
291+ if ac != nil {
292+ ac.sendMu.Unlock()
293+ }
294+ return false
295+ }
296+ sess.mu.Lock()
297+ owned := sess.client == ac
298+ sess.mu.Unlock()
299+ if !owned || state.attachment != ac || state.attachmentEpoch != ac.coordinatorEpoch.Load() || ac.currentSession() != sess {
300+ ac.sendMu.Unlock()
301+ return false
302+ }
303+ ac.sess.mu.Lock()
304+ if ac.sess.v != sess {
305+ ac.sess.mu.Unlock()
306+ ac.sendMu.Unlock()
307+ return false
308+ }
309+ prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset)
310+ if err != nil {
311+ ac.sess.mu.Unlock()
312+ ac.sendMu.Unlock()
313+ d.log.Error("render draw failed", "err", err, "session", sess.name)
314+ return true
315+ }
316+ data := append([]byte(nil), prepared.data...)
317+ data = append(data, ac.encodeCursorTail(composed.cursor, len(data) > 0)...)
318+ var sendTr ports.Transport
319+ var sendErr error
320+ if len(data) > 0 {
321+ sendTr = ac.transport()
322+ if sendTr == nil {
323+ sendErr = errors.New("client transport is nil")
324+ } else {
325+ send := sendTr.Send
326+ if async, ok := sendTr.(ports.AsyncTransport); ok {
327+ send = async.SendAsync
328+ }
329+ sendErr = prepared.send(data, ac.echoAck.Load(), send)
330+ }
331+ }
332+ if sendErr == nil {
333+ // Publish only after output preparation and transport emission both
334+ // succeed. Receipt acknowledgement follows after releasing the attachment
335+ // session guard, while sendMu still owns this transaction.
336+ ac.pipelineScratch = ac.pipelineCache
337+ ac.pipelineCache = composed.cache
338+ }
339+ ac.sess.mu.Unlock()
340+ if sendErr == nil {
341+ // A successful no-byte emission also commits: its renderer shadow still
342+ // represents the captured frame. Lock panes only under sendMu and with no
343+ // session guard held.
344+ commitDamageReceipts(state.receipts)
345+ if ac.renderStages.emit != nil {
346+ ac.renderStages.emit()
347+ }
348+ }
349+ ac.sendMu.Unlock()
350+ if sendErr != nil {
351+ d.detachOnSendError(sess, ac, sendTr)
352+ }
353+ return true
354+}
@@ -0,0 +1,354 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "strconv"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/internal/usecase/layout"
10+ "github.com/bnema/vev/internal/usecase/palette"
11+ "github.com/bnema/vev/internal/usecase/picker"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
13+ "github.com/bnema/vev/internal/usecase/ui"
14+ "github.com/bnema/vev/pkg/renderer"
15+)
16+
17+// composeCacheInput is an attachment-local value snapshot. composeFrame never
18+// retains or accesses the attachment that owns it.
19+// renderStageHooks are optional observability hooks. They are invoked only at
20+// completed production boundaries: capture and compose after their result is
21+// available, and emit after prepare plus transport success (including a
22+// successful no-byte emission). Failed attempts count for capture/compose but
23+// never for emit.
24+type renderStageHooks struct {
25+ capture func()
26+ compose func()
27+ emit func()
28+}
29+
30+type composeCacheInput struct {
31+ valid bool
32+ frame renderer.Frame
33+ layoutFingerprint string
34+ titleGenerations map[layout.PaneID]uint64
35+ damage []renderer.Damage
36+ floatingVisible bool
37+ floatingGeneration uint64
38+ floatingGeometry floatingGeometry
39+ floatingTitleGeneration uint64
40+ bars barCache
41+ theme themeui.Theme
42+}
43+
44+type composedRenderFrame struct {
45+ frame renderer.Frame
46+ damage []renderer.Damage
47+ cursor cursorOut
48+ cache composeCacheInput
49+ reset bool
50+}
51+
52+// composeFrame is pure with respect to daemon ownership: it consumes only the
53+// capture, the last committed cache, and an independent attachment-owned
54+// scratch cache. It returns a replacement cache without mutating committed.
55+func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
56+ scratch := composeCacheInput{}
57+ if len(scratchIn) > 0 {
58+ scratch = scratchIn[0]
59+ }
60+ width, rows := state.layout.area.Width, state.layout.area.Height
61+ if width <= 0 || rows < 0 {
62+ return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
63+ }
64+ // Compose into the alternate attachment-owned buffer. The committed frame is
65+ // copied before incremental drawing so prepare/send failures cannot change it.
66+ canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == rows+2
67+ frame := scratch.frame
68+ if !canReuseFrame {
69+ frame = renderer.NewFrame(width, rows+2)
70+ }
71+ if in.frame.Width == width && in.frame.Height == rows+2 {
72+ for y := 0; y < frame.Height; y++ {
73+ copy(frame.Row(y), in.frame.Row(y))
74+ }
75+ }
76+ styles := newThemeStyles(state.theme)
77+ drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
78+ drawStatusBarState(frame.Row(rows+1), state.bars, styles)
79+ content := domain.Rect{Y: 1, Width: width, Height: rows}
80+ if state.layout.valid && state.layout.root != nil {
81+ drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme))
82+ }
83+
84+ full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible
85+ titles := scratch.titleGenerations
86+ if titles == nil {
87+ titles = make(map[layout.PaneID]uint64, len(state.panes))
88+ }
89+ damage := scratch.damage[:0]
90+ for _, pane := range state.panes {
91+ pl := offsetPlacement(pane.placement, 0, 1)
92+ if pl.TitleBar.Height > 0 {
93+ drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, state.theme)
94+ if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
95+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
96+ }
97+ titles[pane.id] = pane.titleGeneration
98+ }
99+ if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
100+ blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme)
101+ }
102+ for _, d := range pane.damage {
103+ if d.Kind != renderer.DamageFullRedraw {
104+ d.Y++
105+ }
106+ damage = append(damage, d)
107+ }
108+ }
109+ // Keep the committed cache unadorned: floating composition clones this
110+ // base, so closing or moving a popup cannot retain dimmed/bordered cells.
111+ baseFrame := frame
112+ if state.floating.visible {
113+ var floatingDamage []renderer.Damage
114+ frame, floatingDamage = composeCapturedFloatingFrame(baseFrame, damage, state.floating, content, state.layout, state.theme, in, full || state.overlays.active())
115+ damage = floatingDamage
116+ }
117+ if !full {
118+ if !sameCells(in.bars.top, frame.Row(0)) {
119+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
120+ }
121+ if !sameCells(in.bars.bottom, frame.Row(rows+1)) {
122+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
123+ }
124+ }
125+ if state.overlays.active() {
126+ // Without a floating frame, overlay composition would otherwise mutate
127+ // the base cache in place. Floating composition already owns a clone.
128+ if !state.floating.visible {
129+ baseFrame = frame.Clone()
130+ }
131+ frame, damage = composeCapturedOverlays(state, frame, damage, content)
132+ }
133+ if full || state.overlays.active() {
134+ damage = []renderer.Damage{renderer.FullRedraw()}
135+ }
136+ cursorInputs := state.cursor
137+ cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || state.overlays.active()
138+ cursor := desiredCapturedCursor(cursorInputs)
139+ outCache := composeCacheInput{valid: !state.overlays.active(), frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, titleGenerations: titles, damage: damage, floatingVisible: state.floating.visible, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
140+ outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
141+ return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
142+}
143+
144+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, theme themeui.Theme) {
145+ styles := newThemeStyles(theme)
146+ style := styles.border
147+ if focused {
148+ style = styles.statusBar
149+ } else {
150+ style = themeui.DimStyle(style, theme)
151+ }
152+ for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
153+ frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
154+ }
155+ ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
156+}
157+
158+func desiredCapturedCursor(c capturedCursorInputs) cursorOut {
159+ if !c.renderable || c.hiddenByOverlay || !c.visible {
160+ return cursorOut{hidden: true}
161+ }
162+ style := c.style
163+ if !c.hasStyle {
164+ style = 1
165+ }
166+ return cursorOut{row: c.content.Y + 1 + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
167+}
168+
169+func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
170+ if state == nil || snap == nil {
171+ return
172+ }
173+ o := &state.overlays
174+ o.copyActive, o.copySearchActive, o.pickerActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.pickerActive, snap.paletteActive, snap.promptActive
175+ o.copyMode, o.copySnapshot = snap.copyMode, snap.copySnapshot
176+ if snap.copyPane != nil {
177+ o.copyPaneID = snap.copyPane.id
178+ }
179+ styles := newThemeStyles(state.theme)
180+ size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
181+ if snap.copySearchModel != nil {
182+ o.copySearch.modal = copySearchModal
183+ o.copySearch.inner = snap.copySearchModel.Render(rectSize(copySearchModal.Inner(size)), styles.selection)
184+ }
185+ if snap.pickerActive && snap.pickerModel != nil {
186+ o.picker.modal = pickerModal
187+ renderStyles := picker.RenderStyles{Selection: styles.selection, SelectionName: styles.pickerSelectionName, SelectionMuted: styles.pickerSelectionMuted, Name: styles.pickerName, Detail: styles.paletteDesc, Base: renderer.DefaultStyle()}
188+ o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)
189+ }
190+ if snap.paletteActive && snap.paletteModel != nil {
191+ o.palette.modal = paletteModalFor(size, paletteCfg)
192+ guidance := ""
193+ if snap.paletteHints != nil {
194+ guidance = snap.paletteHints.Feedback
195+ }
196+ o.paletteGuidance = guidance
197+ o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Selection: styles.selection, Description: styles.paletteDesc}, Guidance: guidance})
198+ }
199+ if snap.promptActive && snap.promptModel != nil {
200+ o.prompt.modal = promptModalFor(snap.promptModel.Title())
201+ o.prompt.inner = snap.promptModel.Render(rectSize(o.prompt.modal.Inner(size)), styles.accent)
202+ }
203+ state.cursor.hiddenByOverlay = o.active()
204+}
205+
206+func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
207+ o := state.overlays
208+ if o.copyActive {
209+ target := domain.Rect{}
210+ if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {
211+ target = state.floating.geometry.translate(content.X, content.Y).Inner
212+ }
213+ if target.Width == 0 {
214+ id := o.copyPaneID
215+ if id == "" {
216+ id = state.layout.focus
217+ }
218+ for _, p := range state.panes {
219+ if p.id == id && !p.placement.Collapsed {
220+ target = p.placement.Content
221+ target.Y += content.Y
222+ break
223+ }
224+ }
225+ }
226+ frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars)
227+ }
228+ legacy := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
229+ if o.paletteActive && !state.floating.visible {
230+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacy, state.theme)
231+ }
232+ for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} {
233+ if modal.inner.Width == 0 && modal.inner.Height == 0 {
234+ continue
235+ }
236+ inner := modal.modal.Composite(frame, newThemeStyles(state.theme).border)
237+ for y := range min(inner.Height, modal.inner.Height) {
238+ copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])
239+ }
240+ damage = []renderer.Damage{renderer.FullRedraw()}
241+ }
242+ return frame, damage
243+}
244+
245+func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
246+ changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle
247+ if !changed {
248+ return nil
249+ }
250+ prev := ac.lastCursor
251+ ac.lastCursor = desired
252+ ac.lastCursor.valid = true
253+ if desired.hidden {
254+ return []byte("\x1b[?25l")
255+ }
256+ var b []byte
257+ b = append(b, "\x1b["...)
258+ b = strconv.AppendInt(b, int64(desired.row+1), 10)
259+ b = append(b, ';')
260+ b = strconv.AppendInt(b, int64(desired.col+1), 10)
261+ b = append(b, 'H')
262+ if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
263+ b = append(b, "\x1b["...)
264+ b = strconv.AppendInt(b, int64(desired.style), 10)
265+ b = append(b, " q"...)
266+ }
267+ b = append(b, "\x1b[?25h"...)
268+ return b
269+}
270+
271+// commitDamageReceipts acknowledges only snapshots that reached the client.
272+// sendMu serializes attachment transactions; panes are locked individually so
273+// no pane lock is held while another is acquired. A stale generation is
274+// intentionally acknowledged too: Screen conservatively leaves FullRedraw for
275+// the next transaction.
276+func commitDamageReceipts(receipts []damageReceipt) {
277+ for _, receipt := range receipts {
278+ if receipt.pane == nil {
279+ continue
280+ }
281+ receipt.pane.mu.Lock()
282+ receipt.pane.screen.AcknowledgeDamage(receipt.generation)
283+ receipt.pane.mu.Unlock()
284+ }
285+}
286+
287+// emitFrame is the sole side-effecting half of the pipeline. The caller holds
288+// sendMu for the complete capture/compose/emit transaction.
289+func (d *Daemon) emitFrame(sess *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame) bool {
290+ if sess == nil || ac == nil || state == nil {
291+ if ac != nil {
292+ ac.sendMu.Unlock()
293+ }
294+ return false
295+ }
296+ sess.mu.Lock()
297+ owned := sess.client == ac
298+ sess.mu.Unlock()
299+ if !owned || state.attachment != ac || state.attachmentEpoch != ac.coordinatorEpoch.Load() || ac.currentSession() != sess {
300+ ac.sendMu.Unlock()
301+ return false
302+ }
303+ ac.sess.mu.Lock()
304+ if ac.sess.v != sess {
305+ ac.sess.mu.Unlock()
306+ ac.sendMu.Unlock()
307+ return false
308+ }
309+ prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset)
310+ if err != nil {
311+ ac.sess.mu.Unlock()
312+ ac.sendMu.Unlock()
313+ d.log.Error("render draw failed", "err", err, "session", sess.name)
314+ return true
315+ }
316+ data := append([]byte(nil), prepared.data...)
317+ data = append(data, ac.encodeCursorTail(composed.cursor, len(data) > 0)...)
318+ var sendTr ports.Transport
319+ var sendErr error
320+ if len(data) > 0 {
321+ sendTr = ac.transport()
322+ if sendTr == nil {
323+ sendErr = errors.New("client transport is nil")
324+ } else {
325+ send := sendTr.Send
326+ if async, ok := sendTr.(ports.AsyncTransport); ok {
327+ send = async.SendAsync
328+ }
329+ sendErr = prepared.send(data, ac.echoAck.Load(), send)
330+ }
331+ }
332+ if sendErr == nil {
333+ // Publish only after output preparation and transport emission both
334+ // succeed. Receipt acknowledgement follows after releasing the attachment
335+ // session guard, while sendMu still owns this transaction.
336+ ac.pipelineScratch = ac.pipelineCache
337+ ac.pipelineCache = composed.cache
338+ }
339+ ac.sess.mu.Unlock()
340+ if sendErr == nil {
341+ // A successful no-byte emission also commits: its renderer shadow still
342+ // represents the captured frame. Lock panes only under sendMu and with no
343+ // session guard held.
344+ commitDamageReceipts(state.receipts)
345+ if ac.renderStages.emit != nil {
346+ ac.renderStages.emit()
347+ }
348+ }
349+ ac.sendMu.Unlock()
350+ if sendErr != nil {
351+ d.detachOnSendError(sess, ac, sendTr)
352+ }
353+ return true
354+}
A internal/usecase/daemon/render_pipeline_cache_test.go +116 -0
@@ -0,0 +1,116 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/bnema/vev/pkg/renderer"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type cacheFailTransport struct{}
16+
17+func (cacheFailTransport) Send(ports.Frame) error { return errors.New("send failed") }
18+func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
19+func (cacheFailTransport) Close() error { return nil }
20+
21+func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
22+ for _, failure := range []struct {
23+ name string
24+ apply func(*composedRenderFrame, *attachedClient)
25+ }{
26+ {
27+ name: "prepare",
28+ apply: func(composed *composedRenderFrame, _ *attachedClient) {
29+ // Frame validation fails in output.prepare after composition.
30+ composed.frame = renderer.Frame{Width: 1}
31+ },
32+ },
33+ {
34+ name: "send",
35+ apply: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
36+ },
37+ } {
38+ t.Run(failure.name, func(t *testing.T) {
39+ d, sess, ac, sends := newManualSessionWithPTYs(t)
40+ healthy := ac.transport()
41+ committed := composeFrame(cacheState("committed", 1), composeCacheInput{})
42+ ac.pipelineCache = committed.cache
43+ before := cloneComposeCache(ac.pipelineCache)
44+ pending := composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
45+ failure.apply(&pending, ac)
46+
47+ state := cacheState("next", 2)
48+ state.attachment = ac
49+ ac.sendMu.Lock()
50+ require.True(t, d.emitFrame(sess, ac, &state, pending))
51+ require.Equal(t, before, cloneComposeCache(ac.pipelineCache), "failed emission must not publish any composed cache backing storage")
52+
53+ // A send error detaches the failed link. Re-own this test attachment with
54+ // its original healthy transport, then retry the pending state.
55+ if failure.name == "send" {
56+ sess.mu.Lock()
57+ sess.client = ac
58+ sess.mu.Unlock()
59+ ac.setSession(sess)
60+ ac.replaceTransport(healthy)
61+ }
62+ pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
63+ state = cacheState("next", 2)
64+ state.attachment = ac
65+ ac.sendMu.Lock()
66+ require.True(t, d.emitFrame(sess, ac, &state, pending))
67+ require.Equal(t, pending.cache, ac.pipelineCache)
68+ frame := <-sends
69+ output, err := ports.UnmarshalOutput(frame.Payload)
70+ require.NoError(t, err)
71+ require.Contains(t, string(output.Data), "next", "the retry must emit state retained after the failed emission")
72+ })
73+ }
74+}
75+
76+func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) {
77+ visible := cacheState("base", 1)
78+ committed := composeFrame(visible, composeCacheInput{})
79+
80+ hidden := visible
81+ hidden.floating = capturedFloatingRenderState{}
82+ hidden.panes[0].damage = nil
83+ result := composeFrame(hidden, committed.cache, committed.cache)
84+
85+ require.Equal(t, 's', result.frame.Row(1)[2].Rune)
86+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage)
87+}
88+
89+func cacheState(title string, generation uint64) capturedRenderState {
90+ pane := renderer.NewFrame(6, 1)
91+ for x, r := range title[:min(len(title), 6)] {
92+ pane.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
93+ }
94+ floating := renderer.NewFrame(2, 1)
95+ floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()})
96+ return capturedRenderState{
97+ reset: false,
98+ layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
99+ panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
100+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},
101+ bars: barState{topRight: title, bottomRight: title},
102+ }
103+}
104+
105+func cloneComposeCache(in composeCacheInput) composeCacheInput {
106+ out := in
107+ out.frame = in.frame.Clone()
108+ out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
109+ for id, generation := range in.titleGenerations {
110+ out.titleGenerations[id] = generation
111+ }
112+ out.damage = append([]renderer.Damage(nil), in.damage...)
113+ out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
114+ out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
115+ return out
116+}
@@ -0,0 +1,116 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "io"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/usecase/layout"
11+ "github.com/bnema/vev/pkg/renderer"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type cacheFailTransport struct{}
16+
17+func (cacheFailTransport) Send(ports.Frame) error { return errors.New("send failed") }
18+func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
19+func (cacheFailTransport) Close() error { return nil }
20+
21+func TestPipelineCachePublishesOnlyAfterEmission(t *testing.T) {
22+ for _, failure := range []struct {
23+ name string
24+ apply func(*composedRenderFrame, *attachedClient)
25+ }{
26+ {
27+ name: "prepare",
28+ apply: func(composed *composedRenderFrame, _ *attachedClient) {
29+ // Frame validation fails in output.prepare after composition.
30+ composed.frame = renderer.Frame{Width: 1}
31+ },
32+ },
33+ {
34+ name: "send",
35+ apply: func(_ *composedRenderFrame, ac *attachedClient) { ac.replaceTransport(cacheFailTransport{}) },
36+ },
37+ } {
38+ t.Run(failure.name, func(t *testing.T) {
39+ d, sess, ac, sends := newManualSessionWithPTYs(t)
40+ healthy := ac.transport()
41+ committed := composeFrame(cacheState("committed", 1), composeCacheInput{})
42+ ac.pipelineCache = committed.cache
43+ before := cloneComposeCache(ac.pipelineCache)
44+ pending := composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
45+ failure.apply(&pending, ac)
46+
47+ state := cacheState("next", 2)
48+ state.attachment = ac
49+ ac.sendMu.Lock()
50+ require.True(t, d.emitFrame(sess, ac, &state, pending))
51+ require.Equal(t, before, cloneComposeCache(ac.pipelineCache), "failed emission must not publish any composed cache backing storage")
52+
53+ // A send error detaches the failed link. Re-own this test attachment with
54+ // its original healthy transport, then retry the pending state.
55+ if failure.name == "send" {
56+ sess.mu.Lock()
57+ sess.client = ac
58+ sess.mu.Unlock()
59+ ac.setSession(sess)
60+ ac.replaceTransport(healthy)
61+ }
62+ pending = composeFrame(cacheState("next", 2), ac.pipelineCache, ac.pipelineScratch)
63+ state = cacheState("next", 2)
64+ state.attachment = ac
65+ ac.sendMu.Lock()
66+ require.True(t, d.emitFrame(sess, ac, &state, pending))
67+ require.Equal(t, pending.cache, ac.pipelineCache)
68+ frame := <-sends
69+ output, err := ports.UnmarshalOutput(frame.Payload)
70+ require.NoError(t, err)
71+ require.Contains(t, string(output.Data), "next", "the retry must emit state retained after the failed emission")
72+ })
73+ }
74+}
75+
76+func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) {
77+ visible := cacheState("base", 1)
78+ committed := composeFrame(visible, composeCacheInput{})
79+
80+ hidden := visible
81+ hidden.floating = capturedFloatingRenderState{}
82+ hidden.panes[0].damage = nil
83+ result := composeFrame(hidden, committed.cache, committed.cache)
84+
85+ require.Equal(t, 's', result.frame.Row(1)[2].Rune)
86+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage)
87+}
88+
89+func cacheState(title string, generation uint64) capturedRenderState {
90+ pane := renderer.NewFrame(6, 1)
91+ for x, r := range title[:min(len(title), 6)] {
92+ pane.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
93+ }
94+ floating := renderer.NewFrame(2, 1)
95+ floating.Set(0, 0, renderer.Cell{Rune: 'F', Style: renderer.DefaultStyle()})
96+ return capturedRenderState{
97+ reset: false,
98+ layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
99+ panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
100+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},
101+ bars: barState{topRight: title, bottomRight: title},
102+ }
103+}
104+
105+func cloneComposeCache(in composeCacheInput) composeCacheInput {
106+ out := in
107+ out.frame = in.frame.Clone()
108+ out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
109+ for id, generation := range in.titleGenerations {
110+ out.titleGenerations[id] = generation
111+ }
112+ out.damage = append([]renderer.Damage(nil), in.damage...)
113+ out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
114+ out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
115+ return out
116+}
A internal/usecase/daemon/render_pipeline_test.go +286 -0
@@ -0,0 +1,286 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/usecase/layout"
13+ "github.com/bnema/vev/internal/usecase/ui"
14+ "github.com/bnema/vev/pkg/renderer"
15+ "github.com/bnema/vev/pkg/vt"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+// scriptedReplayTransport is intentionally a ports.Transport only: replay
20+// assertions must not depend on any adapter's framing implementation.
21+type scriptedReplayTransport struct {
22+ t *testing.T
23+ frames []ports.Frame
24+ next int
25+}
26+
27+func (s *scriptedReplayTransport) Send(got ports.Frame) error {
28+ s.t.Helper()
29+ if s.next >= len(s.frames) {
30+ s.t.Errorf("unexpected frame %#v", got)
31+ return nil
32+ }
33+ want := s.frames[s.next]
34+ s.next++
35+ require.Equal(s.t, want.Type, got.Type)
36+ require.Equal(s.t, want.Payload, got.Payload)
37+ return nil
38+}
39+func (*scriptedReplayTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
40+func (*scriptedReplayTransport) Close() error { return nil }
41+
42+func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
43+ frames := []ports.Frame{
44+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
45+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
46+ }
47+ transport := &scriptedReplayTransport{t: t, frames: frames}
48+ terminal := vt.NewScreen(8, 3)
49+ var transcript []byte
50+ for _, frame := range frames {
51+ require.NoError(t, transport.Send(frame))
52+ output, err := ports.UnmarshalOutput(frame.Payload)
53+ require.NoError(t, err)
54+ require.Equal(t, frame.Payload, ports.MarshalOutput(output), "output payload must remain byte exact")
55+ transcript = append(transcript, output.Data...)
56+ terminal.Write(output.Data)
57+ }
58+ require.Equal(t, len(frames), transport.next)
59+ require.Equal(t, "\x1b[2J\x1b[Hone\r\ntwo\x1b[2;1HTWO", string(transcript))
60+ require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow")
61+}
62+
63+func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) {
64+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
65+ p.screen.ClearDamage()
66+ p.screen.Write([]byte("old"))
67+
68+ p.mu.Lock()
69+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 3, Height: 1}, damageCaptureConsume)
70+ p.mu.Unlock()
71+
72+ require.Equal(t, 3, captured.frame.Width)
73+ require.Equal(t, 1, captured.frame.Height)
74+ require.NotEmpty(t, p.screen.Damage(), "capture is only a transactional snapshot")
75+ p.mu.Lock()
76+ p.screen.Write([]byte("new"))
77+ p.mu.Unlock()
78+ require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame")
79+}
80+
81+func TestCapturePaneRenderStatePreviewIsNonDestructive(t *testing.T) {
82+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
83+ p.screen.ClearDamage()
84+ p.screen.Write([]byte("preview"))
85+
86+ p.mu.Lock()
87+ _ = capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCapturePreview)
88+ p.mu.Unlock()
89+
90+ require.NotEmpty(t, p.screen.Damage())
91+}
92+
93+func TestCapturePaneRenderStateMalformedDamageFallsBackToFullRedraw(t *testing.T) {
94+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
95+ p.screen.ClearDamage()
96+ p.screen.Write([]byte("x"))
97+ p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1}
98+
99+ p.mu.Lock()
100+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCaptureConsume)
101+ p.mu.Unlock()
102+
103+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage)
104+}
105+
106+func TestCapturePaneRenderStateCollapsedConsumesBoundedly(t *testing.T) {
107+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
108+ p.screen.ClearDamage()
109+ p.screen.Write([]byte("hidden"))
110+
111+ p.mu.Lock()
112+ captured := capturePaneRenderStateLocked(p, domain.Rect{}, damageCaptureConsume)
113+ p.mu.Unlock()
114+
115+ require.Zero(t, captured.frame.Width)
116+ require.Zero(t, captured.frame.Height)
117+ require.NotEmpty(t, p.screen.Damage(), "capture keeps hidden-pane damage pending until emission commits")
118+}
119+
120+func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
121+ pty, release := newBlockingPTY(t)
122+ t.Cleanup(release)
123+ d, sess, _, sends := newManualSessionWithPTYs(t, pty)
124+ ac := sess.client
125+ ac.sendMu.Lock()
126+ ac.output.next = ac.output.maxOutstanding
127+ ac.output.acked = 0
128+ ac.sendMu.Unlock()
129+ p := sess.tabs[0].focusedPane()
130+ p.mu.Lock()
131+ p.screen.ClearDamage()
132+ p.screen.Write([]byte("blocked"))
133+ p.mu.Unlock()
134+
135+ d.paint(sess, ac, false)
136+
137+ p.mu.Lock()
138+ require.NotEmpty(t, p.screen.Damage())
139+ p.mu.Unlock()
140+ select {
141+ case frame := <-sends:
142+ t.Fatalf("ACK-blocked paint sent %#v", frame)
143+ default:
144+ }
145+}
146+
147+func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) {
148+ state := &capturedRenderState{
149+ layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, valid: true},
150+ panes: []capturedPaneRenderState{{
151+ id: "p", frame: func() renderer.Frame {
152+ f := renderer.NewFrame(4, 1)
153+ for i, r := range "old " {
154+ f.Set(i, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
155+ }
156+ return f
157+ }(), placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, focused: true,
158+ damage: []renderer.Damage{renderer.FullRedraw()},
159+ }},
160+ bars: barState{},
161+ cursor: capturedCursorInputs{visible: true, renderable: true, content: domain.Rect{Width: 4, Height: 1}},
162+ }
163+ cache := composeCacheInput{}
164+ out := composeFrame(*state, cache)
165+ state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
166+ require.Equal(t, "old ", rowText(out.frame.Row(1)))
167+ require.False(t, out.cursor.hidden)
168+}
169+
170+func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
171+ for _, name := range []string{"render.go", "floating_render.go", "picker.go"} {
172+ data, err := os.ReadFile(filepath.Join(".", name))
173+ require.NoError(t, err)
174+ source := string(data)
175+ for _, forbidden := range []string{
176+ "func composeClientFrame", "func composeTabFrame", "func composeFloatingFrame",
177+ } {
178+ require.NotContains(t, source, forbidden, "%s retains duplicate production composition path", name)
179+ }
180+ }
181+ data, err := os.ReadFile("render_pipeline.go")
182+ require.NoError(t, err)
183+ body := string(data)
184+ start := strings.Index(body, "func composeFrame(")
185+ require.NotEqual(t, -1, start)
186+ end := strings.Index(body[start:], "\nfunc ")
187+ if end > 0 {
188+ body = body[start : start+end]
189+ } else {
190+ body = body[start:]
191+ }
192+ for _, forbidden := range []string{".Lock()", ".Unlock()", ".Send(", ".prepare("} {
193+ require.NotContains(t, body, forbidden, "composeFrame must remain ownership- and transport-free")
194+ }
195+}
196+
197+func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
198+ paneFrame := renderer.NewFrame(12, 5)
199+ for y, text := range []string{"AAAAAAAAAAAA", "BBBBBBBBBBBB", "CCCCCCCCCCCC", "DDDDDDDDDDDD", "EEEEEEEEEEEE"} {
200+ for x, r := range text {
201+ paneFrame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
202+ }
203+ }
204+ floatingFrame := renderer.NewFrame(4, 1)
205+ for x, r := range "FLOAT"[:4] {
206+ floatingFrame.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
207+ }
208+ modalInner := renderer.NewFrame(6, 1)
209+ for x, r := range "PROMPT" {
210+ modalInner.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
211+ }
212+ state := capturedRenderState{
213+ reset: true,
214+ layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},
215+ panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
216+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
217+ bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
218+ overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},
219+ cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
220+ }
221+ composed := composeFrame(state, composeCacheInput{})
222+ require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))
223+ require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
224+
225+ stream := newOutputStateStream()
226+ prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
227+ require.NoError(t, err)
228+ terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)
229+ require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBB┌Prompt┐BB\x1b[4;1HCC│PROMPT│CC\x1b[5;1HDD└──────┘DD\x1b[6;1HEEEEEEEEEEEE\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
230+ client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
231+ client.Write(terminalBytes)
232+ require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
233+ again, err := stream.renderer.Draw(composed.frame, nil)
234+ require.NoError(t, err)
235+ require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
236+}
237+
238+func frameRows(frame renderer.Frame) []string {
239+ rows := make([]string, frame.Height)
240+ for y := range rows {
241+ rows[y] = rowText(frame.Row(y))
242+ }
243+ return rows
244+}
245+
246+func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
247+ for _, tt := range []struct {
248+ name string
249+ placement layout.Placement
250+ initial []string
251+ scrolled []string
252+ damage []renderer.Damage
253+ wantBytes string
254+ }{
255+ {name: "safe full width", placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 3}}, initial: []string{"AAAA", "BBBB", "CCCC"}, scrolled: []string{"BBBB", "CCCC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, {Kind: renderer.DamageText, X: 0, Y: 2, Width: 4, Height: 1}}, wantBytes: "\x1b[0m\x1b[2;4r\x1b[4;1H\n\x1b[r\x1b[4;1HN\x1b[K\x1b[0m"},
256+ {name: "unsafe partial width", placement: layout.Placement{ID: "p", Content: domain.Rect{X: 2, Width: 2, Height: 3}}, initial: []string{"AA", "BB", "CC"}, scrolled: []string{"BB", "CC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageText, X: 2, Y: 0, Width: 2, Height: 3}}, wantBytes: "\x1b[2;3HBB\x1b[3;3HCC\x1b[4;3HN \x1b[0m"},
257+ } {
258+ t.Run(tt.name, func(t *testing.T) {
259+ makeState := func(rows []string, damage []renderer.Damage, reset bool) capturedRenderState {
260+ f := renderer.NewFrame(tt.placement.Content.Width, tt.placement.Content.Height)
261+ for y, text := range rows {
262+ for x, r := range text {
263+ f.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
264+ }
265+ }
266+ return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 3}, valid: true, focus: "p", placements: []layout.Placement{tt.placement}}, panes: []capturedPaneRenderState{{id: "p", frame: f, placement: tt.placement, focused: true, damage: damage}}, cursor: capturedCursorInputs{visible: false}}
267+ }
268+ stream := newOutputStateStream()
269+ first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{})
270+ firstBytes, err := stream.render(first.frame, first.damage, first.reset)
271+ require.NoError(t, err)
272+ client := vt.NewScreen(4, 5)
273+ client.Write(firstBytes)
274+
275+ second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache)
276+ secondBytes, err := stream.render(second.frame, second.damage, second.reset)
277+ require.NoError(t, err)
278+ require.Equal(t, tt.wantBytes, string(secondBytes))
279+ client.Write(secondBytes)
280+ require.Equal(t, frameRows(second.frame), frameRows(client.Frame))
281+ again, err := stream.renderer.Draw(second.frame, nil)
282+ require.NoError(t, err)
283+ require.Empty(t, again, "renderer shadow must equal replayed terminal state")
284+ })
285+ }
286+}
@@ -0,0 +1,286 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/usecase/layout"
13+ "github.com/bnema/vev/internal/usecase/ui"
14+ "github.com/bnema/vev/pkg/renderer"
15+ "github.com/bnema/vev/pkg/vt"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+// scriptedReplayTransport is intentionally a ports.Transport only: replay
20+// assertions must not depend on any adapter's framing implementation.
21+type scriptedReplayTransport struct {
22+ t *testing.T
23+ frames []ports.Frame
24+ next int
25+}
26+
27+func (s *scriptedReplayTransport) Send(got ports.Frame) error {
28+ s.t.Helper()
29+ if s.next >= len(s.frames) {
30+ s.t.Errorf("unexpected frame %#v", got)
31+ return nil
32+ }
33+ want := s.frames[s.next]
34+ s.next++
35+ require.Equal(s.t, want.Type, got.Type)
36+ require.Equal(s.t, want.Payload, got.Payload)
37+ return nil
38+}
39+func (*scriptedReplayTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
40+func (*scriptedReplayTransport) Close() error { return nil }
41+
42+func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
43+ frames := []ports.Frame{
44+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
45+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
46+ }
47+ transport := &scriptedReplayTransport{t: t, frames: frames}
48+ terminal := vt.NewScreen(8, 3)
49+ var transcript []byte
50+ for _, frame := range frames {
51+ require.NoError(t, transport.Send(frame))
52+ output, err := ports.UnmarshalOutput(frame.Payload)
53+ require.NoError(t, err)
54+ require.Equal(t, frame.Payload, ports.MarshalOutput(output), "output payload must remain byte exact")
55+ transcript = append(transcript, output.Data...)
56+ terminal.Write(output.Data)
57+ }
58+ require.Equal(t, len(frames), transport.next)
59+ require.Equal(t, "\x1b[2J\x1b[Hone\r\ntwo\x1b[2;1HTWO", string(transcript))
60+ require.Equal(t, []string{"one ", "TWO ", " "}, frameRows(terminal.Frame), "terminal replay is the final renderer shadow")
61+}
62+
63+func TestCapturePaneRenderStateOwnsVisibleFrameWithoutConsumingDamage(t *testing.T) {
64+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
65+ p.screen.ClearDamage()
66+ p.screen.Write([]byte("old"))
67+
68+ p.mu.Lock()
69+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 3, Height: 1}, damageCaptureConsume)
70+ p.mu.Unlock()
71+
72+ require.Equal(t, 3, captured.frame.Width)
73+ require.Equal(t, 1, captured.frame.Height)
74+ require.NotEmpty(t, p.screen.Damage(), "capture is only a transactional snapshot")
75+ p.mu.Lock()
76+ p.screen.Write([]byte("new"))
77+ p.mu.Unlock()
78+ require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame")
79+}
80+
81+func TestCapturePaneRenderStatePreviewIsNonDestructive(t *testing.T) {
82+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
83+ p.screen.ClearDamage()
84+ p.screen.Write([]byte("preview"))
85+
86+ p.mu.Lock()
87+ _ = capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCapturePreview)
88+ p.mu.Unlock()
89+
90+ require.NotEmpty(t, p.screen.Damage())
91+}
92+
93+func TestCapturePaneRenderStateMalformedDamageFallsBackToFullRedraw(t *testing.T) {
94+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
95+ p.screen.ClearDamage()
96+ p.screen.Write([]byte("x"))
97+ p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1}
98+
99+ p.mu.Lock()
100+ captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCaptureConsume)
101+ p.mu.Unlock()
102+
103+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage)
104+}
105+
106+func TestCapturePaneRenderStateCollapsedConsumesBoundedly(t *testing.T) {
107+ p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2})
108+ p.screen.ClearDamage()
109+ p.screen.Write([]byte("hidden"))
110+
111+ p.mu.Lock()
112+ captured := capturePaneRenderStateLocked(p, domain.Rect{}, damageCaptureConsume)
113+ p.mu.Unlock()
114+
115+ require.Zero(t, captured.frame.Width)
116+ require.Zero(t, captured.frame.Height)
117+ require.NotEmpty(t, p.screen.Damage(), "capture keeps hidden-pane damage pending until emission commits")
118+}
119+
120+func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
121+ pty, release := newBlockingPTY(t)
122+ t.Cleanup(release)
123+ d, sess, _, sends := newManualSessionWithPTYs(t, pty)
124+ ac := sess.client
125+ ac.sendMu.Lock()
126+ ac.output.next = ac.output.maxOutstanding
127+ ac.output.acked = 0
128+ ac.sendMu.Unlock()
129+ p := sess.tabs[0].focusedPane()
130+ p.mu.Lock()
131+ p.screen.ClearDamage()
132+ p.screen.Write([]byte("blocked"))
133+ p.mu.Unlock()
134+
135+ d.paint(sess, ac, false)
136+
137+ p.mu.Lock()
138+ require.NotEmpty(t, p.screen.Damage())
139+ p.mu.Unlock()
140+ select {
141+ case frame := <-sends:
142+ t.Fatalf("ACK-blocked paint sent %#v", frame)
143+ default:
144+ }
145+}
146+
147+func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) {
148+ state := &capturedRenderState{
149+ layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 1}, valid: true},
150+ panes: []capturedPaneRenderState{{
151+ id: "p", frame: func() renderer.Frame {
152+ f := renderer.NewFrame(4, 1)
153+ for i, r := range "old " {
154+ f.Set(i, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
155+ }
156+ return f
157+ }(), placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 1}}, focused: true,
158+ damage: []renderer.Damage{renderer.FullRedraw()},
159+ }},
160+ bars: barState{},
161+ cursor: capturedCursorInputs{visible: true, renderable: true, content: domain.Rect{Width: 4, Height: 1}},
162+ }
163+ cache := composeCacheInput{}
164+ out := composeFrame(*state, cache)
165+ state.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
166+ require.Equal(t, "old ", rowText(out.frame.Row(1)))
167+ require.False(t, out.cursor.hidden)
168+}
169+
170+func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
171+ for _, name := range []string{"render.go", "floating_render.go", "picker.go"} {
172+ data, err := os.ReadFile(filepath.Join(".", name))
173+ require.NoError(t, err)
174+ source := string(data)
175+ for _, forbidden := range []string{
176+ "func composeClientFrame", "func composeTabFrame", "func composeFloatingFrame",
177+ } {
178+ require.NotContains(t, source, forbidden, "%s retains duplicate production composition path", name)
179+ }
180+ }
181+ data, err := os.ReadFile("render_pipeline.go")
182+ require.NoError(t, err)
183+ body := string(data)
184+ start := strings.Index(body, "func composeFrame(")
185+ require.NotEqual(t, -1, start)
186+ end := strings.Index(body[start:], "\nfunc ")
187+ if end > 0 {
188+ body = body[start : start+end]
189+ } else {
190+ body = body[start:]
191+ }
192+ for _, forbidden := range []string{".Lock()", ".Unlock()", ".Send(", ".prepare("} {
193+ require.NotContains(t, body, forbidden, "composeFrame must remain ownership- and transport-free")
194+ }
195+}
196+
197+func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
198+ paneFrame := renderer.NewFrame(12, 5)
199+ for y, text := range []string{"AAAAAAAAAAAA", "BBBBBBBBBBBB", "CCCCCCCCCCCC", "DDDDDDDDDDDD", "EEEEEEEEEEEE"} {
200+ for x, r := range text {
201+ paneFrame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
202+ }
203+ }
204+ floatingFrame := renderer.NewFrame(4, 1)
205+ for x, r := range "FLOAT"[:4] {
206+ floatingFrame.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
207+ }
208+ modalInner := renderer.NewFrame(6, 1)
209+ for x, r := range "PROMPT" {
210+ modalInner.Set(x, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
211+ }
212+ state := capturedRenderState{
213+ reset: true,
214+ layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},
215+ panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
216+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
217+ bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
218+ overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},
219+ cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
220+ }
221+ composed := composeFrame(state, composeCacheInput{})
222+ require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))
223+ require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
224+
225+ stream := newOutputStateStream()
226+ prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
227+ require.NoError(t, err)
228+ terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)
229+ require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBB┌Prompt┐BB\x1b[4;1HCC│PROMPT│CC\x1b[5;1HDD└──────┘DD\x1b[6;1HEEEEEEEEEEEE\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
230+ client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
231+ client.Write(terminalBytes)
232+ require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
233+ again, err := stream.renderer.Draw(composed.frame, nil)
234+ require.NoError(t, err)
235+ require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
236+}
237+
238+func frameRows(frame renderer.Frame) []string {
239+ rows := make([]string, frame.Height)
240+ for y := range rows {
241+ rows[y] = rowText(frame.Row(y))
242+ }
243+ return rows
244+}
245+
246+func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
247+ for _, tt := range []struct {
248+ name string
249+ placement layout.Placement
250+ initial []string
251+ scrolled []string
252+ damage []renderer.Damage
253+ wantBytes string
254+ }{
255+ {name: "safe full width", placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 4, Height: 3}}, initial: []string{"AAAA", "BBBB", "CCCC"}, scrolled: []string{"BBBB", "CCCC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, {Kind: renderer.DamageText, X: 0, Y: 2, Width: 4, Height: 1}}, wantBytes: "\x1b[0m\x1b[2;4r\x1b[4;1H\n\x1b[r\x1b[4;1HN\x1b[K\x1b[0m"},
256+ {name: "unsafe partial width", placement: layout.Placement{ID: "p", Content: domain.Rect{X: 2, Width: 2, Height: 3}}, initial: []string{"AA", "BB", "CC"}, scrolled: []string{"BB", "CC", "N "}, damage: []renderer.Damage{{Kind: renderer.DamageText, X: 2, Y: 0, Width: 2, Height: 3}}, wantBytes: "\x1b[2;3HBB\x1b[3;3HCC\x1b[4;3HN \x1b[0m"},
257+ } {
258+ t.Run(tt.name, func(t *testing.T) {
259+ makeState := func(rows []string, damage []renderer.Damage, reset bool) capturedRenderState {
260+ f := renderer.NewFrame(tt.placement.Content.Width, tt.placement.Content.Height)
261+ for y, text := range rows {
262+ for x, r := range text {
263+ f.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
264+ }
265+ }
266+ return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 4, Height: 3}, valid: true, focus: "p", placements: []layout.Placement{tt.placement}}, panes: []capturedPaneRenderState{{id: "p", frame: f, placement: tt.placement, focused: true, damage: damage}}, cursor: capturedCursorInputs{visible: false}}
267+ }
268+ stream := newOutputStateStream()
269+ first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{})
270+ firstBytes, err := stream.render(first.frame, first.damage, first.reset)
271+ require.NoError(t, err)
272+ client := vt.NewScreen(4, 5)
273+ client.Write(firstBytes)
274+
275+ second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache)
276+ secondBytes, err := stream.render(second.frame, second.damage, second.reset)
277+ require.NoError(t, err)
278+ require.Equal(t, tt.wantBytes, string(secondBytes))
279+ client.Write(secondBytes)
280+ require.Equal(t, frameRows(second.frame), frameRows(client.Frame))
281+ again, err := stream.renderer.Draw(second.frame, nil)
282+ require.NoError(t, err)
283+ require.Empty(t, again, "renderer shadow must equal replayed terminal state")
284+ })
285+ }
286+}
M internal/usecase/daemon/render_test.go +15 -12
@@ -371,7 +371,10 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
371371 })
372372 }
373373
374-func TestClearNonRenderablePaneDamage(t *testing.T) {
374+// S2 keeps damage pending until the owning render capture consumes it. In
375+// particular, a pane becoming invisible must not let a PTY reader erase data
376+// that a later attachment or picker preview needs to render.
377+func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
375378 newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) {
376379 t.Helper()
377380 p, release := newBlockingPTY(t)
@@ -381,7 +384,7 @@ func TestClearNonRenderablePaneDamage(t *testing.T) {
381384 return d, sess, tb, tb.focusedPane(), sends
382385 }
383386
384- t.Run("clears headless, inactive, collapsed, and hidden panes without output", func(t *testing.T) {
387+ t.Run("headless, inactive, collapsed, and hidden panes retain damage without output", func(t *testing.T) {
385388 for _, tt := range []struct {
386389 name string
387390 setup func(*Daemon, *session, *tab, *pane)
@@ -407,8 +410,8 @@ func TestClearNonRenderablePaneDamage(t *testing.T) {
407410 d, sess, tb, p, sends := newFixture(t)
408411 tt.setup(d, sess, tb, p)
409412 p.screen.Write([]byte("damage"))
410- d.clearNonRenderablePaneDamage(sess, tb, p)
411- require.Empty(t, p.screen.Damage())
413+ _ = d.paneRenderable(sess, tb, p)
414+ require.NotEmpty(t, p.screen.Damage(), "only render capture may consume VT damage")
412415 select {
413416 case frame := <-sends:
414417 t.Fatalf("non-renderable output must not compose or send: %#v", frame)
@@ -421,7 +424,7 @@ func TestClearNonRenderablePaneDamage(t *testing.T) {
421424 t.Run("retains active and picker-preview pane damage", func(t *testing.T) {
422425 d, sess, tb, p, _ := newFixture(t)
423426 p.screen.Write([]byte("active"))
424- d.clearNonRenderablePaneDamage(sess, tb, p)
427+ _ = d.paneRenderable(sess, tb, p)
425428 require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition")
426429 p.screen.ClearDamage()
427430
@@ -433,7 +436,7 @@ func TestClearNonRenderablePaneDamage(t *testing.T) {
433436 viewer.overlays.pickerMu.Unlock()
434437 d.sessions["viewer"] = &session{id: "viewer", client: viewer}
435438 p.screen.Write([]byte("preview"))
436- d.clearNonRenderablePaneDamage(sess, tb, p)
439+ _ = d.paneRenderable(sess, tb, p)
437440 require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")
438441 })
439442 }
@@ -949,21 +952,21 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) {
949952
950953 d.paint(sess, ac, true)
951954 _ = mustOutputData(t, sends)
952- require.True(t, ac.composed.valid)
953- require.NotContains(t, frameText(ac.composed.frame), "Rename session")
955+ require.True(t, ac.pipelineCache.valid)
956+ require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
954957
955958 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
956959 shown := string(mustOutputData(t, sends))
957960 require.Contains(t, shown, "Rename session")
958- require.False(t, ac.composed.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
959- require.NotContains(t, frameText(ac.composed.frame), "Rename session")
961+ require.False(t, ac.pipelineCache.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
962+ require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
960963
961964 d.closePrompt(ac)
962965 d.paint(sess, ac, false)
963966 restored := string(mustOutputData(t, sends))
964967 require.NotContains(t, restored, "Rename session")
965- require.True(t, ac.composed.valid)
966- cached := frameText(ac.composed.frame)
968+ require.True(t, ac.pipelineCache.valid)
969+ cached := frameText(ac.pipelineCache.frame)
967970 require.Contains(t, cached, "live")
968971 require.NotContains(t, cached, "Rename session")
969972 }
@@ -371,7 +371,10 @@ func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *t
371 })371 })
372 }372 }
373 373
374-func TestClearNonRenderablePaneDamage(t *testing.T) {374+// S2 keeps damage pending until the owning render capture consumes it. In
375+// particular, a pane becoming invisible must not let a PTY reader erase data
376+// that a later attachment or picker preview needs to render.
377+func TestNonRenderablePaneDamageRemainsPendingForCapture(t *testing.T) {
375 newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) {378 newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) {
376 t.Helper()379 t.Helper()
377 p, release := newBlockingPTY(t)380 p, release := newBlockingPTY(t)
@@ -381,7 +384,7 @@ func TestClearNonRenderablePaneDamage(t *testing.T) {
381 return d, sess, tb, tb.focusedPane(), sends384 return d, sess, tb, tb.focusedPane(), sends
382 }385 }
383 386
384- t.Run("clears headless, inactive, collapsed, and hidden panes without output", func(t *testing.T) {387+ t.Run("headless, inactive, collapsed, and hidden panes retain damage without output", func(t *testing.T) {
385 for _, tt := range []struct {388 for _, tt := range []struct {
386 name string389 name string
387 setup func(*Daemon, *session, *tab, *pane)390 setup func(*Daemon, *session, *tab, *pane)
@@ -407,8 +410,8 @@ func TestClearNonRenderablePaneDamage(t *testing.T) {
407 d, sess, tb, p, sends := newFixture(t)410 d, sess, tb, p, sends := newFixture(t)
408 tt.setup(d, sess, tb, p)411 tt.setup(d, sess, tb, p)
409 p.screen.Write([]byte("damage"))412 p.screen.Write([]byte("damage"))
410- d.clearNonRenderablePaneDamage(sess, tb, p)413+ _ = d.paneRenderable(sess, tb, p)
411- require.Empty(t, p.screen.Damage())414+ require.NotEmpty(t, p.screen.Damage(), "only render capture may consume VT damage")
412 select {415 select {
413 case frame := <-sends:416 case frame := <-sends:
414 t.Fatalf("non-renderable output must not compose or send: %#v", frame)417 t.Fatalf("non-renderable output must not compose or send: %#v", frame)
@@ -421,7 +424,7 @@ func TestClearNonRenderablePaneDamage(t *testing.T) {
421 t.Run("retains active and picker-preview pane damage", func(t *testing.T) {424 t.Run("retains active and picker-preview pane damage", func(t *testing.T) {
422 d, sess, tb, p, _ := newFixture(t)425 d, sess, tb, p, _ := newFixture(t)
423 p.screen.Write([]byte("active"))426 p.screen.Write([]byte("active"))
424- d.clearNonRenderablePaneDamage(sess, tb, p)427+ _ = d.paneRenderable(sess, tb, p)
425 require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition")428 require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition")
426 p.screen.ClearDamage()429 p.screen.ClearDamage()
427 430
@@ -433,7 +436,7 @@ func TestClearNonRenderablePaneDamage(t *testing.T) {
433 viewer.overlays.pickerMu.Unlock()436 viewer.overlays.pickerMu.Unlock()
434 d.sessions["viewer"] = &session{id: "viewer", client: viewer}437 d.sessions["viewer"] = &session{id: "viewer", client: viewer}
435 p.screen.Write([]byte("preview"))438 p.screen.Write([]byte("preview"))
436- d.clearNonRenderablePaneDamage(sess, tb, p)439+ _ = d.paneRenderable(sess, tb, p)
437 require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")440 require.NotEmpty(t, p.screen.Damage(), "picker preview damage must remain for coordinator composition")
438 })441 })
439 }442 }
@@ -949,21 +952,21 @@ func TestOverlayPaintBypassesComposedCache(t *testing.T) {
949 952
950 d.paint(sess, ac, true)953 d.paint(sess, ac, true)
951 _ = mustOutputData(t, sends)954 _ = mustOutputData(t, sends)
952- require.True(t, ac.composed.valid)955+ require.True(t, ac.pipelineCache.valid)
953- require.NotContains(t, frameText(ac.composed.frame), "Rename session")956+ require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
954 957
955 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })958 d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
956 shown := string(mustOutputData(t, sends))959 shown := string(mustOutputData(t, sends))
957 require.Contains(t, shown, "Rename session")960 require.Contains(t, shown, "Rename session")
958- require.False(t, ac.composed.valid, "overlay paint must not store the modal-mutated frame in the composed cache")961+ require.False(t, ac.pipelineCache.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
959- require.NotContains(t, frameText(ac.composed.frame), "Rename session")962+ require.NotContains(t, frameText(ac.pipelineCache.frame), "Rename session")
960 963
961 d.closePrompt(ac)964 d.closePrompt(ac)
962 d.paint(sess, ac, false)965 d.paint(sess, ac, false)
963 restored := string(mustOutputData(t, sends))966 restored := string(mustOutputData(t, sends))
964 require.NotContains(t, restored, "Rename session")967 require.NotContains(t, restored, "Rename session")
965- require.True(t, ac.composed.valid)968+ require.True(t, ac.pipelineCache.valid)
966- cached := frameText(ac.composed.frame)969+ cached := frameText(ac.pipelineCache.frame)
967 require.Contains(t, cached, "live")970 require.Contains(t, cached, "live")
968 require.NotContains(t, cached, "Rename session")971 require.NotContains(t, cached, "Rename session")
969 }972 }
M internal/usecase/daemon/resume_wake_incarnation_test.go +1 -1
@@ -55,7 +55,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
5555 <-wakeDone
5656 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")
5757 ac.sendMu.Lock()
58- staleWakeComposed := ac.composed.valid
58+ staleWakeComposed := ac.pipelineCache.valid
5959 ac.sendMu.Unlock()
6060 require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")
6161
@@ -55,7 +55,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) {
55 <-wakeDone55 <-wakeDone
56 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")56 require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome")
57 ac.sendMu.Lock()57 ac.sendMu.Lock()
58- staleWakeComposed := ac.composed.valid58+ staleWakeComposed := ac.pipelineCache.valid
59 ac.sendMu.Unlock()59 ac.sendMu.Unlock()
60 require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")60 require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow")
61 61
M internal/usecase/daemon/status_test.go +6 -6
@@ -52,9 +52,9 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
5252
5353 d.paint(sess, ac, true)
5454 ac.sendMu.Lock()
55- require.True(t, ac.composed.valid)
56- lightDimmedPane := ac.composed.frame.At(21, 1).Style
57- lightDivider := ac.composed.frame.At(20, 1).Style
55+ require.True(t, ac.pipelineCache.valid)
56+ lightDimmedPane := ac.pipelineCache.frame.At(21, 1).Style
57+ lightDivider := ac.pipelineCache.frame.At(20, 1).Style
5858 ac.sendMu.Unlock()
5959 left.mu.Lock()
6060 left.screen.ClearDamage()
@@ -67,9 +67,9 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
6767
6868 ac.sendMu.Lock()
6969 defer ac.sendMu.Unlock()
70- require.True(t, ac.composed.valid, "reset=false config repaint should rebuild the composed cache")
71- require.NotEqual(t, lightDimmedPane, ac.composed.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")
72- require.NotEqual(t, lightDivider, ac.composed.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
70+ require.True(t, ac.pipelineCache.valid, "reset=false config repaint should rebuild the composed cache")
71+ require.NotEqual(t, lightDimmedPane, ac.pipelineCache.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")
72+ require.NotEqual(t, lightDivider, ac.pipelineCache.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
7373 }
7474
7575 func TestStatusCompositionGolden(t *testing.T) {
@@ -52,9 +52,9 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
52 52
53 d.paint(sess, ac, true)53 d.paint(sess, ac, true)
54 ac.sendMu.Lock()54 ac.sendMu.Lock()
55- require.True(t, ac.composed.valid)55+ require.True(t, ac.pipelineCache.valid)
56- lightDimmedPane := ac.composed.frame.At(21, 1).Style56+ lightDimmedPane := ac.pipelineCache.frame.At(21, 1).Style
57- lightDivider := ac.composed.frame.At(20, 1).Style57+ lightDivider := ac.pipelineCache.frame.At(20, 1).Style
58 ac.sendMu.Unlock()58 ac.sendMu.Unlock()
59 left.mu.Lock()59 left.mu.Lock()
60 left.screen.ClearDamage()60 left.screen.ClearDamage()
@@ -67,9 +67,9 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
67 67
68 ac.sendMu.Lock()68 ac.sendMu.Lock()
69 defer ac.sendMu.Unlock()69 defer ac.sendMu.Unlock()
70- require.True(t, ac.composed.valid, "reset=false config repaint should rebuild the composed cache")70+ require.True(t, ac.pipelineCache.valid, "reset=false config repaint should rebuild the composed cache")
71- require.NotEqual(t, lightDimmedPane, ac.composed.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")71+ require.NotEqual(t, lightDimmedPane, ac.pipelineCache.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")
72- require.NotEqual(t, lightDivider, ac.composed.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")72+ require.NotEqual(t, lightDivider, ac.pipelineCache.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
73 }73 }
74 74
75 func TestStatusCompositionGolden(t *testing.T) {75 func TestStatusCompositionGolden(t *testing.T) {
M internal/usecase/daemon/theme_resolve.go +0 -1
@@ -28,7 +28,6 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
2828
2929 if ac != nil {
3030 ac.setTheme(t)
31- ac.composed.invalidate()
3231 }
3332 for _, tb := range tabs {
3433 tb.mu.Lock()
@@ -28,7 +28,6 @@ func (d *Daemon) applyHostThemeLocked(sess *session, ac *attachedClient, t theme
28 28
29 if ac != nil {29 if ac != nil {
30 ac.setTheme(t)30 ac.setTheme(t)
31- ac.composed.invalidate()
32 }31 }
33 for _, tb := range tabs {32 for _, tb := range tabs {
34 tb.mu.Lock()33 tb.mu.Lock()
M pkg/vt/screen.go +52 -9
@@ -35,6 +35,10 @@ type screenState struct {
3535 // so keep this value in sync if that payload cap changes.
3636 const maxEscapeBufferLen = 128 * 1024
3737
38+// maxPendingDamage bounds metadata retained while no render transaction can
39+// acknowledge a screen. Saturation falls back to one exact full redraw.
40+const maxPendingDamage = 1024
41+
3842 const (
3943 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
4044 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -86,10 +90,12 @@ type Screen struct {
8690 defaultColorsKnown bool
8791 terminalTitle string
8892
89- damage []renderer.Damage
90- escapeBuf []byte
91- csiScratch []int
92- sgrScratch []int
93+ damage []renderer.Damage
94+ damageGeneration uint64
95+ damageSaturated bool
96+ escapeBuf []byte
97+ csiScratch []int
98+ sgrScratch []int
9399
94100 scrollTop int
95101 scrollBottom int
@@ -112,10 +118,11 @@ type Screen struct {
112118
113119 func NewScreen(width, height int) *Screen {
114120 s := &Screen{
115- Frame: renderer.NewFrame(width, height),
116- Style: renderer.DefaultStyle(),
117- damage: []renderer.Damage{renderer.FullRedraw()},
118- cursorVisible: true,
121+ Frame: renderer.NewFrame(width, height),
122+ Style: renderer.DefaultStyle(),
123+ damage: []renderer.Damage{renderer.FullRedraw()},
124+ damageGeneration: 1,
125+ cursorVisible: true,
119126 }
120127 s.resetScrollRegion()
121128 return s
@@ -162,10 +169,36 @@ func (s *Screen) Resize(width, height int) {
162169 s.fullRedraw()
163170 }
164171
172+// DamageCapture is an immutable copy of pending damage at one screen generation.
173+type DamageCapture struct {
174+ Damage []renderer.Damage
175+ Generation uint64
176+}
177+
165178 // Damage returns the current damage list. The caller must not modify the
166179 // returned slice; ClearDamage must be called after the damage is consumed.
167180 func (s *Screen) Damage() []renderer.Damage { return s.damage }
168-func (s *Screen) ClearDamage() { s.damage = s.damage[:0] }
181+func (s *Screen) ClearDamage() {
182+ s.damage = s.damage[:0]
183+ s.damageSaturated = false
184+}
185+
186+// CaptureDamage snapshots pending damage without consuming it.
187+func (s *Screen) CaptureDamage() DamageCapture {
188+ return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration}
189+}
190+
191+// AcknowledgeDamage consumes a capture only if no screen mutation occurred
192+// since it was taken. A stale acknowledgement conservatively requests a full
193+// redraw, ensuring intervening writes remain visible to the next capture.
194+func (s *Screen) AcknowledgeDamage(generation uint64) bool {
195+ if generation != s.damageGeneration {
196+ s.fullRedraw()
197+ return false
198+ }
199+ s.ClearDamage()
200+ return true
201+}
169202
170203 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
171204 // is currently enabled by the child process.
@@ -599,6 +632,15 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
599632 }
600633
601634 func (s *Screen) record(d renderer.Damage) {
635+ s.damageGeneration++
636+ if s.damageSaturated {
637+ return
638+ }
639+ if len(s.damage) >= maxPendingDamage {
640+ s.damage = []renderer.Damage{renderer.FullRedraw()}
641+ s.damageSaturated = true
642+ return
643+ }
602644 // Replace FullRedraw with the first concrete damage item.
603645 if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
604646 s.damage[0] = d
@@ -616,6 +658,7 @@ func (s *Screen) record(d renderer.Damage) {
616658 }
617659
618660 func (s *Screen) fullRedraw() {
661+ s.damageGeneration++
619662 s.damage = []renderer.Damage{renderer.FullRedraw()}
620663 }
621664
@@ -35,6 +35,10 @@ type screenState struct {
35 // so keep this value in sync if that payload cap changes.35 // so keep this value in sync if that payload cap changes.
36 const maxEscapeBufferLen = 128 * 102436 const maxEscapeBufferLen = 128 * 1024
37 37
38+// maxPendingDamage bounds metadata retained while no render transaction can
39+// acknowledge a screen. Saturation falls back to one exact full redraw.
40+const maxPendingDamage = 1024
41+
38 const (42 const (
39 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.43 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
40 ColorSchemeReportDark = "\x1b[?997;1n"44 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -86,10 +90,12 @@ type Screen struct {
86 defaultColorsKnown bool90 defaultColorsKnown bool
87 terminalTitle string91 terminalTitle string
88 92
89- damage []renderer.Damage93+ damage []renderer.Damage
90- escapeBuf []byte94+ damageGeneration uint64
91- csiScratch []int95+ damageSaturated bool
92- sgrScratch []int96+ escapeBuf []byte
97+ csiScratch []int
98+ sgrScratch []int
93 99
94 scrollTop int100 scrollTop int
95 scrollBottom int101 scrollBottom int
@@ -112,10 +118,11 @@ type Screen struct {
112 118
113 func NewScreen(width, height int) *Screen {119 func NewScreen(width, height int) *Screen {
114 s := &Screen{120 s := &Screen{
115- Frame: renderer.NewFrame(width, height),121+ Frame: renderer.NewFrame(width, height),
116- Style: renderer.DefaultStyle(),122+ Style: renderer.DefaultStyle(),
117- damage: []renderer.Damage{renderer.FullRedraw()},123+ damage: []renderer.Damage{renderer.FullRedraw()},
118- cursorVisible: true,124+ damageGeneration: 1,
125+ cursorVisible: true,
119 }126 }
120 s.resetScrollRegion()127 s.resetScrollRegion()
121 return s128 return s
@@ -162,10 +169,36 @@ func (s *Screen) Resize(width, height int) {
162 s.fullRedraw()169 s.fullRedraw()
163 }170 }
164 171
172+// DamageCapture is an immutable copy of pending damage at one screen generation.
173+type DamageCapture struct {
174+ Damage []renderer.Damage
175+ Generation uint64
176+}
177+
165 // Damage returns the current damage list. The caller must not modify the178 // Damage returns the current damage list. The caller must not modify the
166 // returned slice; ClearDamage must be called after the damage is consumed.179 // returned slice; ClearDamage must be called after the damage is consumed.
167 func (s *Screen) Damage() []renderer.Damage { return s.damage }180 func (s *Screen) Damage() []renderer.Damage { return s.damage }
168-func (s *Screen) ClearDamage() { s.damage = s.damage[:0] }181+func (s *Screen) ClearDamage() {
182+ s.damage = s.damage[:0]
183+ s.damageSaturated = false
184+}
185+
186+// CaptureDamage snapshots pending damage without consuming it.
187+func (s *Screen) CaptureDamage() DamageCapture {
188+ return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration}
189+}
190+
191+// AcknowledgeDamage consumes a capture only if no screen mutation occurred
192+// since it was taken. A stale acknowledgement conservatively requests a full
193+// redraw, ensuring intervening writes remain visible to the next capture.
194+func (s *Screen) AcknowledgeDamage(generation uint64) bool {
195+ if generation != s.damageGeneration {
196+ s.fullRedraw()
197+ return false
198+ }
199+ s.ClearDamage()
200+ return true
201+}
169 202
170 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)203 // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
171 // is currently enabled by the child process.204 // is currently enabled by the child process.
@@ -599,6 +632,15 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
599 }632 }
600 633
601 func (s *Screen) record(d renderer.Damage) {634 func (s *Screen) record(d renderer.Damage) {
635+ s.damageGeneration++
636+ if s.damageSaturated {
637+ return
638+ }
639+ if len(s.damage) >= maxPendingDamage {
640+ s.damage = []renderer.Damage{renderer.FullRedraw()}
641+ s.damageSaturated = true
642+ return
643+ }
602 // Replace FullRedraw with the first concrete damage item.644 // Replace FullRedraw with the first concrete damage item.
603 if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {645 if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
604 s.damage[0] = d646 s.damage[0] = d
@@ -616,6 +658,7 @@ func (s *Screen) record(d renderer.Damage) {
616 }658 }
617 659
618 func (s *Screen) fullRedraw() {660 func (s *Screen) fullRedraw() {
661+ s.damageGeneration++
619 s.damage = []renderer.Damage{renderer.FullRedraw()}662 s.damage = []renderer.Damage{renderer.FullRedraw()}
620 }663 }
621 664
M pkg/vt/screen_test.go +51 -0
@@ -2055,6 +2055,24 @@ func TestResize(t *testing.T) {
20552055 // ClearDamage
20562056 // ---------------------------------------------------------------------------
20572057
2058+func TestDamageSaturationBoundsPendingRecords(t *testing.T) {
2059+ s := NewScreen(2, 2)
2060+ s.ClearDamage()
2061+
2062+ for i := range 1_100 {
2063+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: i % 2, Width: 1, Height: 1, Count: 1})
2064+ }
2065+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
2066+
2067+ // Saturation is sticky until the render owner acknowledges or clears it.
2068+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
2069+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
2070+
2071+ s.ClearDamage()
2072+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
2073+ require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, s.Damage())
2074+}
2075+
20582076 func TestClearDamage(t *testing.T) {
20592077 tests := []struct {
20602078 name string
@@ -3284,3 +3302,36 @@ func TestScreenAltScreenActiveAccessor(t *testing.T) {
32843302 t.Fatal("alt screen should be inactive after exit")
32853303 }
32863304 }
3305+
3306+func TestDamageCaptureAcknowledgementPreservesConcurrentWrite(t *testing.T) {
3307+ s := NewScreen(8, 2)
3308+ s.ClearDamage()
3309+ s.Write([]byte("a"))
3310+
3311+ captured := s.CaptureDamage()
3312+ require.NotEmpty(t, captured.Damage)
3313+ s.Write([]byte("b"))
3314+
3315+ require.False(t, s.AcknowledgeDamage(captured.Generation))
3316+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage(), "stale acknowledgement must retain later writes conservatively")
3317+}
3318+
3319+func TestDamageCaptureAcknowledgementClearsMatchingGeneration(t *testing.T) {
3320+ s := NewScreen(8, 2)
3321+ s.ClearDamage()
3322+ s.Write([]byte("a"))
3323+
3324+ captured := s.CaptureDamage()
3325+ require.True(t, s.AcknowledgeDamage(captured.Generation))
3326+ require.Empty(t, s.Damage())
3327+}
3328+
3329+func TestDamageCaptureOwnsDamageSlice(t *testing.T) {
3330+ s := NewScreen(8, 2)
3331+ s.ClearDamage()
3332+ s.Write([]byte("a"))
3333+
3334+ captured := s.CaptureDamage()
3335+ s.ClearDamage()
3336+ require.NotEmpty(t, captured.Damage, "captured damage must not alias mutable screen storage")
3337+}
@@ -2055,6 +2055,24 @@ func TestResize(t *testing.T) {
2055 // ClearDamage2055 // ClearDamage
2056 // ---------------------------------------------------------------------------2056 // ---------------------------------------------------------------------------
2057 2057
2058+func TestDamageSaturationBoundsPendingRecords(t *testing.T) {
2059+ s := NewScreen(2, 2)
2060+ s.ClearDamage()
2061+
2062+ for i := range 1_100 {
2063+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: i % 2, Width: 1, Height: 1, Count: 1})
2064+ }
2065+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
2066+
2067+ // Saturation is sticky until the render owner acknowledges or clears it.
2068+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
2069+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
2070+
2071+ s.ClearDamage()
2072+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
2073+ require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, s.Damage())
2074+}
2075+
2058 func TestClearDamage(t *testing.T) {2076 func TestClearDamage(t *testing.T) {
2059 tests := []struct {2077 tests := []struct {
2060 name string2078 name string
@@ -3284,3 +3302,36 @@ func TestScreenAltScreenActiveAccessor(t *testing.T) {
3284 t.Fatal("alt screen should be inactive after exit")3302 t.Fatal("alt screen should be inactive after exit")
3285 }3303 }
3286 }3304 }
3305+
3306+func TestDamageCaptureAcknowledgementPreservesConcurrentWrite(t *testing.T) {
3307+ s := NewScreen(8, 2)
3308+ s.ClearDamage()
3309+ s.Write([]byte("a"))
3310+
3311+ captured := s.CaptureDamage()
3312+ require.NotEmpty(t, captured.Damage)
3313+ s.Write([]byte("b"))
3314+
3315+ require.False(t, s.AcknowledgeDamage(captured.Generation))
3316+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage(), "stale acknowledgement must retain later writes conservatively")
3317+}
3318+
3319+func TestDamageCaptureAcknowledgementClearsMatchingGeneration(t *testing.T) {
3320+ s := NewScreen(8, 2)
3321+ s.ClearDamage()
3322+ s.Write([]byte("a"))
3323+
3324+ captured := s.CaptureDamage()
3325+ require.True(t, s.AcknowledgeDamage(captured.Generation))
3326+ require.Empty(t, s.Damage())
3327+}
3328+
3329+func TestDamageCaptureOwnsDamageSlice(t *testing.T) {
3330+ s := NewScreen(8, 2)
3331+ s.ClearDamage()
3332+ s.Write([]byte("a"))
3333+
3334+ captured := s.CaptureDamage()
3335+ s.ClearDamage()
3336+ require.NotEmpty(t, captured.Damage, "captured damage must not alias mutable screen storage")
3337+}
A transport_replay_integration_test.go +60 -0
@@ -0,0 +1,60 @@
1+package main_test
2+
3+import (
4+ "net"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/adapters/ipc"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/pkg/renderer"
11+ "github.com/bnema/vev/pkg/vt"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// TestTransportReplayIntegration stays at the legal application boundary: it
16+// observes only typed output frames and terminal bytes, never daemon internals.
17+func TestTransportReplayIntegration(t *testing.T) {
18+ frames := []ports.Frame{
19+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
20+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
21+ }
22+ left, right := net.Pipe()
23+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
24+ sender, receiver := ipc.NewTransport(left), ipc.NewTransport(right)
25+ var wg sync.WaitGroup
26+ wg.Go(func() {
27+ for _, frame := range frames {
28+ if err := sender.Send(frame); err != nil {
29+ t.Errorf("Send: %v", err)
30+ return
31+ }
32+ }
33+ })
34+
35+ terminal := vt.NewScreen(8, 3)
36+ var bytes []byte
37+ for _, want := range frames {
38+ got, err := receiver.Recv()
39+ require.NoError(t, err)
40+ require.Equal(t, want, got)
41+ output, err := ports.UnmarshalOutput(got.Payload)
42+ require.NoError(t, err)
43+ bytes = append(bytes, output.Data...)
44+ terminal.Write(output.Data)
45+ }
46+ wg.Wait()
47+ require.Equal(t, "\x1b[2J\x1b[Hone\r\ntwo\x1b[2;1HTWO", string(bytes))
48+ require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal.Frame))
49+}
50+
51+func integrationFrameRows(frame renderer.Frame) []string {
52+ rows := make([]string, frame.Height)
53+ for y := range rows {
54+ cells := frame.Row(y)
55+ for _, cell := range cells {
56+ rows[y] += string(cell.Rune)
57+ }
58+ }
59+ return rows
60+}
@@ -0,0 +1,60 @@
1+package main_test
2+
3+import (
4+ "net"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/adapters/ipc"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/pkg/renderer"
11+ "github.com/bnema/vev/pkg/vt"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// TestTransportReplayIntegration stays at the legal application boundary: it
16+// observes only typed output frames and terminal bytes, never daemon internals.
17+func TestTransportReplayIntegration(t *testing.T) {
18+ frames := []ports.Frame{
19+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
20+ {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
21+ }
22+ left, right := net.Pipe()
23+ t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
24+ sender, receiver := ipc.NewTransport(left), ipc.NewTransport(right)
25+ var wg sync.WaitGroup
26+ wg.Go(func() {
27+ for _, frame := range frames {
28+ if err := sender.Send(frame); err != nil {
29+ t.Errorf("Send: %v", err)
30+ return
31+ }
32+ }
33+ })
34+
35+ terminal := vt.NewScreen(8, 3)
36+ var bytes []byte
37+ for _, want := range frames {
38+ got, err := receiver.Recv()
39+ require.NoError(t, err)
40+ require.Equal(t, want, got)
41+ output, err := ports.UnmarshalOutput(got.Payload)
42+ require.NoError(t, err)
43+ bytes = append(bytes, output.Data...)
44+ terminal.Write(output.Data)
45+ }
46+ wg.Wait()
47+ require.Equal(t, "\x1b[2J\x1b[Hone\r\ntwo\x1b[2;1HTWO", string(bytes))
48+ require.Equal(t, []string{"one ", "TWO ", " "}, integrationFrameRows(terminal.Frame))
49+}
50+
51+func integrationFrameRows(frame renderer.Frame) []string {
52+ rows := make([]string, frame.Height)
53+ for y := range rows {
54+ cells := frame.Row(y)
55+ for _, cell := range cells {
56+ rows[y] += string(cell.Rune)
57+ }
58+ }
59+ return rows
60+}

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