Merge pull request #75 from bnema/perf/render-pipeline
perf(daemon): single-owner render pipeline
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) { | |||
| 309 | 309 | sess.mu.Lock() | |
| 310 | 310 | ac := sess.client | |
| 311 | 311 | 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") | |
| 315 | 316 | } | |
| @@ -309,7 +309,8 @@ func (d *Daemon) pokeSessionRender(sess *session) { | |||||
| 309 | sess.mu.Lock() | 309 | sess.mu.Lock() | ||
| 310 | ac := sess.client | 310 | 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) { | |||
| 277 | 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 | 292 | func TestBarScriptRefreshIsPerSession(t *testing.T) { | |
| 281 | 293 | r := &fakeBarRunner{outs: []string{"top-a", "bottom-a", "top-b", "bottom-b"}} | |
| 282 | 294 | 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 ( | |||
| 14 | 14 | "github.com/bnema/vev/internal/domain" | |
| 15 | 15 | "github.com/bnema/vev/internal/ports" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/keys" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | 18 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 18 | 19 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 19 | 20 | ) | |
| @@ -35,11 +36,16 @@ type attachedClient struct { | |||
| 35 | 36 | // exact coordinator incarnation. paint rechecks it under sendMu. | |
| 36 | 37 | coordinatorReadyEpoch atomic.Uint64 | |
| 37 | 38 | 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 | |
| 43 | 49 | size domain.Size | |
| 44 | 50 | keys *keys.Router | |
| 45 | 51 | sess Guarded[*session] | |
| @@ -48,6 +54,7 @@ type attachedClient struct { | |||
| 48 | 54 | theme themeui.Theme | |
| 49 | 55 | clientTheme themeui.Theme | |
| 50 | 56 | lastCursor cursorOut | |
| 57 | + | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | |
| 51 | 58 | // previousSession is guarded independently. It is retained through temporary | |
| 52 | 59 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 53 | 60 | 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.Uint64 | 37 | coordinatorReadyEpoch atomic.Uint64 | ||
| 37 | echoAck atomic.Uint64 | 38 | echoAck atomic.Uint64 | ||
| 38 | - | bars barCache // only touched while sendMu is held | 39 | + | // pipelineCache is the last successfully emitted composition. pipelineScratch |
| 39 | - | composed composedFrameCache // only touched while sendMu is held | 40 | + | // is its attachment-owned alternate buffer; both are only touched under |
| 40 | - | resizePaint pendingByteTimer // guarded by sendMu | 41 | + | // sendMu and must never share mutable backing storage. |
| 41 | - | resizePaintGeneration uint64 // guarded by sendMu | 42 | + | pipelineCache composeCacheInput |
| 42 | - | resizePaintPending bool // guarded by sendMu | 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 | |||
| 43 | size domain.Size | 49 | size domain.Size | ||
| 44 | keys *keys.Router | 50 | keys *keys.Router | ||
| 45 | sess Guarded[*session] | 51 | sess Guarded[*session] | ||
| @@ -48,6 +54,7 @@ type attachedClient struct { | |||||
| 48 | theme themeui.Theme | 54 | theme themeui.Theme | ||
| 49 | clientTheme themeui.Theme | 55 | clientTheme themeui.Theme | ||
| 50 | lastCursor cursorOut | 56 | lastCursor cursorOut | ||
| 57 | + | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | |||
| 51 | // previousSession is guarded independently. It is retained through temporary | 58 | // 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 { | |||
| 402 | 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 | 405 | func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) { | |
| 411 | 406 | if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 { | |
| 412 | 407 | 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, nil | 407 | return frame, nil | ||
M internal/usecase/daemon/floating_render.go +12 -46
| @@ -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 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) | |
| 116 | 94 | damage := append([]renderer.Damage(nil), baseDamage...) | |
| 117 | - | for _, d := range screenDamage { | |
| 95 | + | for _, d := range floating.pane.damage { | |
| 118 | 96 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | |
| 119 | 97 | } | |
| 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) | |
| 135 | 101 | if full || popupChanged { | |
| 136 | - | return frame, []renderer.Damage{renderer.FullRedraw()}, geometry | |
| 102 | + | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 137 | 103 | } | |
| 138 | 104 | if titleChanged && geometry.Bounds.Height >= 3 { | |
| 139 | 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, geometry | |
| 107 | + | return frame, damage | |
| 142 | 108 | } | |
| 143 | 109 | ||
| 144 | 110 | 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 composed | 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 | - | // destination. In particular it never writes the VT screen frame: that frame | 89 | + | 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.Frame | 92 | + | 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()}, geometry | 102 | + | 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, geometry | 107 | + | 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 { | |||
| 64 | 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 | 67 | func layoutFingerprint(root *layout.Node) string { | |
| 72 | 68 | var b strings.Builder | |
| 73 | 69 | 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.Builder | 68 | 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) { | |||
| 49 | 49 | fixture.paintLive() | |
| 50 | 50 | metrics := fixture.metrics() | |
| 51 | 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 | 55 | require.Positive(t, metrics.outputBytes) | |
| 53 | 56 | require.Positive(t, metrics.outputPayloadBytes) | |
| 54 | 57 | require.Equal(t, uint64(2), metrics.coordinatorInvalidations) | |
| @@ -68,6 +71,27 @@ func TestPerformanceFixtureCounters(t *testing.T) { | |||
| 68 | 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 | 95 | func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) { | |
| 72 | 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 | 230 | b.ReportMetric(float64(metrics.coordinatorInvalidations)/perOperation, "coordinatorinvalidations/op") | |
| 207 | 231 | b.ReportMetric(float64(metrics.coordinatorWakes)/perOperation, "coordinatorwakes/op") | |
| 208 | 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 | 240 | b.ReportMetric(coordinatorCoalescingRatio(metrics), "coordinatorcoalescingratio") | |
| 210 | 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 | 267 | func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) { | |
| 214 | 268 | fixture := newPerformanceFixture(t, performanceConfig{}) | |
| 215 | 269 | ||
| @@ -243,6 +297,9 @@ type performanceMetrics struct { | |||
| 243 | 297 | coordinatorInvalidations uint64 | |
| 244 | 298 | coordinatorWakes uint64 | |
| 245 | 299 | coordinatorCoalesced uint64 | |
| 300 | + | renderCaptures uint64 | |
| 301 | + | renderCompositions uint64 | |
| 302 | + | renderEmissions uint64 | |
| 246 | 303 | } | |
| 247 | 304 | ||
| 248 | 305 | type performanceFixture struct { | |
| @@ -259,6 +316,9 @@ type performanceFixture struct { | |||
| 259 | 316 | resizes int | |
| 260 | 317 | resizedSize domain.Size | |
| 261 | 318 | coordinatorBaseline renderCoordinatorBurstMetricsSnapshot | |
| 319 | + | renderCaptures uint64 | |
| 320 | + | renderCompositions uint64 | |
| 321 | + | renderEmissions uint64 | |
| 262 | 322 | } | |
| 263 | 323 | ||
| 264 | 324 | func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture { | |
| @@ -303,6 +363,11 @@ func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceF | |||
| 303 | 363 | // The fixture uses the production coordinator. stubClock's inert timer is | |
| 304 | 364 | // completed synchronously by the coordinator, keeping benchmark operations | |
| 305 | 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 | 371 | d.attachCoordinator(sess, nil, ac, true) | |
| 307 | 372 | ||
| 308 | 373 | // Prime the real renderer shadow before measurements. Subsequent paints use | |
| @@ -415,6 +480,9 @@ func (f *performanceFixture) resized() bool { | |||
| 415 | 480 | func (f *performanceFixture) resetMetrics() { | |
| 416 | 481 | f.output.reset() | |
| 417 | 482 | f.snaps.reset() | |
| 483 | + | f.renderCaptures = 0 | |
| 484 | + | f.renderCompositions = 0 | |
| 485 | + | f.renderEmissions = 0 | |
| 418 | 486 | if rc := f.sess.renderCoordinator(); rc != nil { | |
| 419 | 487 | f.coordinatorBaseline = rc.burstMetricsSnapshot() | |
| 420 | 488 | } | |
| @@ -426,6 +494,7 @@ func (f *performanceFixture) metrics() performanceMetrics { | |||
| 426 | 494 | metrics := performanceMetrics{ | |
| 427 | 495 | outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes, | |
| 428 | 496 | snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes, | |
| 497 | + | renderCaptures: f.renderCaptures, renderCompositions: f.renderCompositions, renderEmissions: f.renderEmissions, | |
| 429 | 498 | } | |
| 430 | 499 | if rc := f.sess.renderCoordinator(); rc != nil { | |
| 431 | 500 | 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 uint64 | 297 | coordinatorInvalidations uint64 | ||
| 244 | coordinatorWakes uint64 | 298 | coordinatorWakes uint64 | ||
| 245 | coordinatorCoalesced uint64 | 299 | 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 int | 316 | resizes int | ||
| 260 | resizedSize domain.Size | 317 | resizedSize domain.Size | ||
| 261 | coordinatorBaseline renderCoordinatorBurstMetricsSnapshot | 318 | 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 is | 363 | // The fixture uses the production coordinator. stubClock's inert timer is | ||
| 304 | // completed synchronously by the coordinator, keeping benchmark operations | 364 | // 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 use | 373 | // 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 ( | |||
| 9 | 9 | "github.com/bnema/vev/internal/usecase/keys" | |
| 10 | 10 | "github.com/bnema/vev/internal/usecase/layout" | |
| 11 | 11 | "github.com/bnema/vev/internal/usecase/picker" | |
| 12 | - | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | 12 | "github.com/bnema/vev/internal/usecase/ui" | |
| 14 | 13 | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 14 | ) | |
| @@ -497,49 +496,59 @@ func (d *Daemon) refreshPicker(ac *attachedClient) { | |||
| 497 | 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 | 499 | func snapshotPickerPreview(tb *tab) picker.Preview { | |
| 516 | 500 | if tb == nil { | |
| 517 | 501 | return picker.Preview{} | |
| 518 | 502 | } | |
| 519 | 503 | 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) | |
| 522 | 540 | } | |
| 523 | 541 | ||
| 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) | |
| 533 | 546 | } | |
| 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)...) | |
| 540 | 550 | } | |
| 541 | - | frame, _ := composeTabFrame(tb, area, themeui.Theme{}) | |
| 542 | - | return pickerPreviewFromFrame(frame) | |
| 551 | + | return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)} | |
| 543 | 552 | } | |
| 544 | 553 | ||
| 545 | 554 | 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 ( | |||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | 7 | promptui "github.com/bnema/vev/internal/usecase/prompt" | |
| 8 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | 11 | func promptModalFor(title string) ui.Modal { | |
| @@ -95,8 +94,3 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) { | |||
| 95 | 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 | - | } | |
| @@ -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 | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "bytes" | |
| 6 | - | "errors" | |
| 7 | - | "strconv" | |
| 8 | 6 | ||
| 9 | 7 | "github.com/bnema/vev/internal/domain" | |
| 10 | 8 | "github.com/bnema/vev/internal/ports" | |
| 11 | 9 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | - | "github.com/bnema/vev/internal/usecase/ui" | |
| 14 | 11 | "github.com/bnema/vev/pkg/renderer" | |
| 15 | - | "github.com/bnema/vev/pkg/vt" | |
| 16 | 12 | ) | |
| 17 | 13 | ||
| 18 | 14 | // 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 | 57 | 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 | 60 | // tabIsPickerPreview reports whether any attached client currently composes | |
| 79 | 61 | // tb as a picker preview. It snapshots daemon ownership before taking overlay | |
| 80 | 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 | 142 | p.mu.Unlock() | |
| 161 | - | renderable := d.clearNonRenderablePaneDamage(sess, tb, p) | |
| 143 | + | renderable := d.paneRenderable(sess, tb, p) | |
| 162 | 144 | markSnapshotDirty(sess) | |
| 163 | 145 | select { | |
| 164 | 146 | case <-attentionCh: | |
| @@ -217,43 +199,6 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||
| 217 | 199 | // paint draws the composed client frame (active tab plus status bar) and | |
| 218 | 200 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | |
| 219 | 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 | 202 | func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) { | |
| 258 | 203 | ac.resizePaint.stop() | |
| 259 | 204 | ac.resizePaintGeneration++ | |
| @@ -306,6 +251,13 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 306 | 251 | ac.sendMu.Unlock() | |
| 307 | 252 | 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 | 261 | if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) { | |
| 310 | 262 | ac.sendMu.Unlock() | |
| 311 | 263 | return | |
| @@ -339,11 +291,16 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 339 | 291 | attentionVisible := pulseVisible(bars.attentionFrame) | |
| 340 | 292 | repaintAttachedClients = sess.ackAttention(tb, attentionVisible) | |
| 341 | 293 | ||
| 342 | - | styles := newThemeStyles(ac.getTheme()) | |
| 343 | 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 | 298 | 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 | |
| 347 | 304 | floating := tb.floating.pane | |
| 348 | 305 | hasFloating := tb.floating.state == floatingVisible && floating != nil | |
| 349 | 306 | tb.mu.Unlock() | |
| @@ -354,189 +311,31 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 354 | 311 | d.refreshPaneDisplayTitle(sess, floating, false) | |
| 355 | 312 | } | |
| 356 | 313 | ||
| 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, | |
| 431 | 318 | } | |
| 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 { | |
| 448 | 321 | ac.sendMu.Unlock() | |
| 449 | 322 | return | |
| 450 | 323 | } | |
| 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() | |
| 478 | 326 | } | |
| 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) | |
| 485 | 332 | } | |
| 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() | |
| 488 | 337 | } | |
| 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) | |
| 540 | 339 | } | |
| 541 | 340 | ||
| 542 | 341 | type themeStyles struct { | |
| @@ -602,211 +401,6 @@ func (c *barCache) Reset() { | |||
| 602 | 401 | 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 | 404 | func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | |
| 811 | 405 | pl.Content.X += dx | |
| 812 | 406 | pl.Content.Y += dy | |
| @@ -815,15 +409,6 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | |||
| 815 | 409 | 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 | 412 | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) { | |
| 828 | 413 | rows := min(r.Height, src.Height) | |
| 829 | 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 | 426 | func drawDividers(frame renderer.Frame, n *layout.Node, r domain.Rect, style renderer.Style) { | |
| 861 | 427 | if n == nil || n.Kind != layout.Split || len(n.Children) <= 1 { | |
| 862 | 428 | 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 one | 14 | // 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 false | 57 | 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 composes | 60 | // tabIsPickerPreview reports whether any attached client currently composes | ||
| 79 | // tb as a picker preview. It snapshots daemon ownership before taking overlay | 61 | // 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) and | 199 | // paint draws the composed client frame (active tab plus status bar) and | ||
| 218 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | 200 | // 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 | return | 252 | 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 | return | 263 | 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.pane | 304 | floating := tb.floating.pane | ||
| 348 | hasFloating := tb.floating.state == floatingVisible && floating != nil | 305 | 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 := p | 320 | + | 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 | return | 322 | return | ||
| 450 | } | 323 | } | ||
| 451 | - | prepared, err := ac.output.prepare(frame, damage, reset || overlayActive) | 324 | + | if ac.renderStages.capture != nil { |
| 452 | - | var data []byte | 325 | + | 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 = nil | 401 | 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 += dx | 405 | pl.Content.X += dx | ||
| 812 | pl.Content.Y += dy | 406 | pl.Content.Y += dy | ||
| @@ -815,15 +409,6 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | |||||
| 815 | return pl | 409 | 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 | return | 428 | 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 | |||
| 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 | 378 | newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) { | |
| 376 | 379 | t.Helper() | |
| 377 | 380 | p, release := newBlockingPTY(t) | |
| @@ -381,7 +384,7 @@ func TestClearNonRenderablePaneDamage(t *testing.T) { | |||
| 381 | 384 | 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 | 388 | for _, tt := range []struct { | |
| 386 | 389 | name string | |
| 387 | 390 | setup func(*Daemon, *session, *tab, *pane) | |
| @@ -407,8 +410,8 @@ func TestClearNonRenderablePaneDamage(t *testing.T) { | |||
| 407 | 410 | d, sess, tb, p, sends := newFixture(t) | |
| 408 | 411 | tt.setup(d, sess, tb, p) | |
| 409 | 412 | 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") | |
| 412 | 415 | select { | |
| 413 | 416 | case frame := <-sends: | |
| 414 | 417 | t.Fatalf("non-renderable output must not compose or send: %#v", frame) | |
| @@ -421,7 +424,7 @@ func TestClearNonRenderablePaneDamage(t *testing.T) { | |||
| 421 | 424 | t.Run("retains active and picker-preview pane damage", func(t *testing.T) { | |
| 422 | 425 | d, sess, tb, p, _ := newFixture(t) | |
| 423 | 426 | p.screen.Write([]byte("active")) | |
| 424 | - | d.clearNonRenderablePaneDamage(sess, tb, p) | |
| 427 | + | _ = d.paneRenderable(sess, tb, p) | |
| 425 | 428 | require.NotEmpty(t, p.screen.Damage(), "active pane damage belongs to coordinator composition") | |
| 426 | 429 | p.screen.ClearDamage() | |
| 427 | 430 | ||
| @@ -433,7 +436,7 @@ func TestClearNonRenderablePaneDamage(t *testing.T) { | |||
| 433 | 436 | viewer.overlays.pickerMu.Unlock() | |
| 434 | 437 | d.sessions["viewer"] = &session{id: "viewer", client: viewer} | |
| 435 | 438 | p.screen.Write([]byte("preview")) | |
| 436 | - | d.clearNonRenderablePaneDamage(sess, tb, p) | |
| 439 | + | _ = d.paneRenderable(sess, tb, p) | |
| 437 | 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 | 953 | d.paint(sess, ac, true) | |
| 951 | 954 | _ = 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") | |
| 954 | 957 | ||
| 955 | 958 | d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil }) | |
| 956 | 959 | shown := string(mustOutputData(t, sends)) | |
| 957 | 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") | |
| 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") | |
| 960 | 963 | ||
| 961 | 964 | d.closePrompt(ac) | |
| 962 | 965 | d.paint(sess, ac, false) | |
| 963 | 966 | restored := string(mustOutputData(t, sends)) | |
| 964 | 967 | 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) | |
| 967 | 970 | require.Contains(t, cached, "live") | |
| 968 | 971 | require.NotContains(t, cached, "Rename session") | |
| 969 | 972 | } | |
| @@ -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(), sends | 384 | 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 string | 389 | 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) { | |||
| 55 | 55 | <-wakeDone | |
| 56 | 56 | require.Empty(t, newTransport.Sends(), "a stale pre-resume wake must not emit Output before Welcome") | |
| 57 | 57 | ac.sendMu.Lock() | |
| 58 | - | staleWakeComposed := ac.composed.valid | |
| 58 | + | staleWakeComposed := ac.pipelineCache.valid | |
| 59 | 59 | ac.sendMu.Unlock() | |
| 60 | 60 | require.False(t, staleWakeComposed, "a stale wake must not mutate the resumed attachment shadow") | |
| 61 | 61 | ||
| @@ -55,7 +55,7 @@ func TestParkedResumeRejectsDispatchedWakeFromPriorAttachment(t *testing.T) { | |||||
| 55 | <-wakeDone | 55 | <-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.valid | 58 | + | 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) { | |||
| 52 | 52 | ||
| 53 | 53 | d.paint(sess, ac, true) | |
| 54 | 54 | 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 | |
| 58 | 58 | ac.sendMu.Unlock() | |
| 59 | 59 | left.mu.Lock() | |
| 60 | 60 | left.screen.ClearDamage() | |
| @@ -67,9 +67,9 @@ func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) { | |||
| 67 | 67 | ||
| 68 | 68 | ac.sendMu.Lock() | |
| 69 | 69 | 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") | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | 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).Style | 56 | + | lightDimmedPane := ac.pipelineCache.frame.At(21, 1).Style |
| 57 | - | lightDivider := ac.composed.frame.At(20, 1).Style | 57 | + | 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 | |||
| 28 | 28 | ||
| 29 | 29 | if ac != nil { | |
| 30 | 30 | ac.setTheme(t) | |
| 31 | - | ac.composed.invalidate() | |
| 32 | 31 | } | |
| 33 | 32 | for _, tb := range tabs { | |
| 34 | 33 | 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 { | |||
| 35 | 35 | // so keep this value in sync if that payload cap changes. | |
| 36 | 36 | 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 | 42 | const ( | |
| 39 | 43 | // ColorSchemeReportDark is the DEC 2031 dark-scheme report. | |
| 40 | 44 | ColorSchemeReportDark = "\x1b[?997;1n" | |
| @@ -86,10 +90,12 @@ type Screen struct { | |||
| 86 | 90 | defaultColorsKnown bool | |
| 87 | 91 | terminalTitle string | |
| 88 | 92 | ||
| 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 | |
| 93 | 99 | ||
| 94 | 100 | scrollTop int | |
| 95 | 101 | scrollBottom int | |
| @@ -112,10 +118,11 @@ type Screen struct { | |||
| 112 | 118 | ||
| 113 | 119 | func NewScreen(width, height int) *Screen { | |
| 114 | 120 | 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, | |
| 119 | 126 | } | |
| 120 | 127 | s.resetScrollRegion() | |
| 121 | 128 | return s | |
| @@ -162,10 +169,36 @@ func (s *Screen) Resize(width, height int) { | |||
| 162 | 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 | 178 | // Damage returns the current damage list. The caller must not modify the | |
| 166 | 179 | // returned slice; ClearDamage must be called after the damage is consumed. | |
| 167 | 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 | 203 | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | |
| 171 | 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 | 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 | 644 | // Replace FullRedraw with the first concrete damage item. | |
| 603 | 645 | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | |
| 604 | 646 | s.damage[0] = d | |
| @@ -616,6 +658,7 @@ func (s *Screen) record(d renderer.Damage) { | |||
| 616 | 658 | } | |
| 617 | 659 | ||
| 618 | 660 | func (s *Screen) fullRedraw() { | |
| 661 | + | s.damageGeneration++ | |
| 619 | 662 | s.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 620 | 663 | } | |
| 621 | 664 | ||
| @@ -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 * 1024 | 36 | 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 bool | 90 | defaultColorsKnown bool | ||
| 87 | terminalTitle string | 91 | terminalTitle string | ||
| 88 | 92 | ||||
| 89 | - | damage []renderer.Damage | 93 | + | damage []renderer.Damage |
| 90 | - | escapeBuf []byte | 94 | + | damageGeneration uint64 |
| 91 | - | csiScratch []int | 95 | + | damageSaturated bool |
| 92 | - | sgrScratch []int | 96 | + | escapeBuf []byte |
| 97 | + | csiScratch []int | |||
| 98 | + | sgrScratch []int | |||
| 93 | 99 | ||||
| 94 | scrollTop int | 100 | scrollTop int | ||
| 95 | scrollBottom int | 101 | 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 s | 128 | 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 the | 178 | // 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] = d | 646 | 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) { | |||
| 2055 | 2055 | // 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 | 2076 | func TestClearDamage(t *testing.T) { | |
| 2059 | 2077 | tests := []struct { | |
| 2060 | 2078 | name string | |
| @@ -3284,3 +3302,36 @@ func TestScreenAltScreenActiveAccessor(t *testing.T) { | |||
| 3284 | 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 | + | } | |
| @@ -2055,6 +2055,24 @@ func TestResize(t *testing.T) { | |||||
| 2055 | // ClearDamage | 2055 | // 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 string | 2078 | 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 | + | } | |||