detached 0a7eb6a

Merge pull request #165 from bnema/refactor/renderer-delta-planner

refactor(renderer): add transactional delta planning
commit
0a7eb6a15e00e5f93392b7f1fc2c5e04aa399574
author
bnema · · 2026-08-01 18:08 UTC
committer
GitHub · · 2026-08-01 18:08 UTC
merge of
aff7050 + 38765b3 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
21 files · +1435 -308
M internal/ports/wire_test.go +4 -0
@@ -625,6 +625,10 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
625625 }
626626
627627 assertAllPrefixesFail(t, got[:24], UnmarshalOutput)
628+ withTrailingData := append(append([]byte(nil), got...), 0xff)
629+ trailing, err := UnmarshalOutput(withTrailingData)
630+ require.NoError(t, err)
631+ require.Equal(t, append(append([]byte(nil), msg.Data...), 0xff), trailing.Data)
628632 }
629633
630634 func TestAckGoldenAndRoundTrip(t *testing.T) {
@@ -625,6 +625,10 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
625 }625 }
626 626
627 assertAllPrefixesFail(t, got[:24], UnmarshalOutput)627 assertAllPrefixesFail(t, got[:24], UnmarshalOutput)
628+ withTrailingData := append(append([]byte(nil), got...), 0xff)
629+ trailing, err := UnmarshalOutput(withTrailingData)
630+ require.NoError(t, err)
631+ require.Equal(t, append(append([]byte(nil), msg.Data...), 0xff), trailing.Data)
628 }632 }
629 633
630 func TestAckGoldenAndRoundTrip(t *testing.T) {634 func TestAckGoldenAndRoundTrip(t *testing.T) {
M internal/usecase/daemon/output_state_stream.go +41 -23
@@ -18,6 +18,7 @@ type outputStateStream struct {
1818 next uint64
1919 acked uint64
2020 maxOutstanding uint64
21+ forceSnapshot bool
2122 }
2223
2324 func newOutputStateStream(windowSize ...uint8) *outputStateStream {
@@ -31,46 +32,62 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
3132 }
3233 }
3334
34-func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {
35- if reset {
36- s.renderer.Reset()
37- }
38- return s.renderer.Draw(frame, damage)
39-}
40-
4135 type preparedOutput struct {
42- stream *outputStateStream
43- next uint64
44- data []byte
45- reset bool
36+ stream *outputStateStream
37+ draw renderer.PreparedDraw
38+ next uint64
39+ data []byte
40+ reset bool
41+ completed bool
4642 }
4743
4844 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
49- data, err := s.render(frame, damage, reset)
45+ reset = reset || s.forceSnapshot
46+ draw, err := s.renderer.Prepare(frame, damage, reset)
5047 if err != nil {
5148 return nil, err
5249 }
53- return &preparedOutput{stream: s, next: s.next, data: data, reset: reset}, nil
50+ return &preparedOutput{
51+ stream: s,
52+ draw: draw,
53+ next: s.next + 1,
54+ data: draw.Bytes(),
55+ reset: reset,
56+ }, nil
5457 }
5558
5659 func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error {
57- out := p.stream.frame(data, p.reset, echoAck)
60+ if p.completed {
61+ return nil
62+ }
63+ p.completed = true
64+ base := p.stream.next
65+ if p.reset {
66+ base = 0
67+ }
68+ out := frameOutputState(data, base, p.next, echoAck)
5869 if err := send(out); err != nil {
59- p.stream.renderer.Reset()
60- p.stream.next = p.next
70+ p.stream.forceSnapshot = true
6171 return err
6272 }
73+ p.commit()
74+ p.stream.next = p.next
6375 return nil
6476 }
6577
66-func (s *outputStateStream) frame(data []byte, reset bool, echoAck uint64) ports.Frame {
67- s.next++
68- base := s.next - 1
69- if reset {
70- base = 0
78+func (p *preparedOutput) commitNoSend() {
79+ if p.completed {
80+ return
81+ }
82+ p.completed = true
83+ p.commit()
84+}
85+
86+func (p *preparedOutput) commit() {
87+ p.draw.Commit()
88+ if p.reset {
89+ p.stream.forceSnapshot = false
7190 }
72- frame := frameOutputState(data, base, s.next, echoAck)
73- return frame
7491 }
7592
7693 func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame {
@@ -87,6 +104,7 @@ func (s *outputStateStream) ack(state uint64) {
87104 func (s *outputStateStream) rebase() {
88105 s.acked = s.next
89106 s.renderer.Reset()
107+ s.forceSnapshot = true
90108 }
91109
92110 func (s *outputStateStream) atCapacity() bool {
@@ -18,6 +18,7 @@ type outputStateStream struct {
18 next uint6418 next uint64
19 acked uint6419 acked uint64
20 maxOutstanding uint6420 maxOutstanding uint64
21+ forceSnapshot bool
21 }22 }
22 23
23 func newOutputStateStream(windowSize ...uint8) *outputStateStream {24 func newOutputStateStream(windowSize ...uint8) *outputStateStream {
@@ -31,46 +32,62 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
31 }32 }
32 }33 }
33 34
34-func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {
35- if reset {
36- s.renderer.Reset()
37- }
38- return s.renderer.Draw(frame, damage)
39-}
40-
41 type preparedOutput struct {35 type preparedOutput struct {
42- stream *outputStateStream36+ stream *outputStateStream
43- next uint6437+ draw renderer.PreparedDraw
44- data []byte38+ next uint64
45- reset bool39+ data []byte
40+ reset bool
41+ completed bool
46 }42 }
47 43
48 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {44 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
49- data, err := s.render(frame, damage, reset)45+ reset = reset || s.forceSnapshot
46+ draw, err := s.renderer.Prepare(frame, damage, reset)
50 if err != nil {47 if err != nil {
51 return nil, err48 return nil, err
52 }49 }
53- return &preparedOutput{stream: s, next: s.next, data: data, reset: reset}, nil50+ return &preparedOutput{
51+ stream: s,
52+ draw: draw,
53+ next: s.next + 1,
54+ data: draw.Bytes(),
55+ reset: reset,
56+ }, nil
54 }57 }
55 58
56 func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error {59 func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error {
57- out := p.stream.frame(data, p.reset, echoAck)60+ if p.completed {
61+ return nil
62+ }
63+ p.completed = true
64+ base := p.stream.next
65+ if p.reset {
66+ base = 0
67+ }
68+ out := frameOutputState(data, base, p.next, echoAck)
58 if err := send(out); err != nil {69 if err := send(out); err != nil {
59- p.stream.renderer.Reset()70+ p.stream.forceSnapshot = true
60- p.stream.next = p.next
61 return err71 return err
62 }72 }
73+ p.commit()
74+ p.stream.next = p.next
63 return nil75 return nil
64 }76 }
65 77
66-func (s *outputStateStream) frame(data []byte, reset bool, echoAck uint64) ports.Frame {78+func (p *preparedOutput) commitNoSend() {
67- s.next++79+ if p.completed {
68- base := s.next - 180+ return
69- if reset {81+ }
70- base = 082+ p.completed = true
83+ p.commit()
84+}
85+
86+func (p *preparedOutput) commit() {
87+ p.draw.Commit()
88+ if p.reset {
89+ p.stream.forceSnapshot = false
71 }90 }
72- frame := frameOutputState(data, base, s.next, echoAck)
73- return frame
74 }91 }
75 92
76 func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame {93 func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame {
@@ -87,6 +104,7 @@ func (s *outputStateStream) ack(state uint64) {
87 func (s *outputStateStream) rebase() {104 func (s *outputStateStream) rebase() {
88 s.acked = s.next105 s.acked = s.next
89 s.renderer.Reset()106 s.renderer.Reset()
107+ s.forceSnapshot = true
90 }108 }
91 109
92 func (s *outputStateStream) atCapacity() bool {110 func (s *outputStateStream) atCapacity() bool {
M internal/usecase/daemon/output_state_stream_test.go +133 -3
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "errors"
45 "testing"
56
67 "github.com/bnema/vev/internal/ports"
@@ -34,6 +35,116 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {
3435 require.Equal(t, uint64(2), stream.outstanding())
3536 }
3637
38+func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {
39+ stream := newOutputStateStream()
40+ initial := renderer.NewFrame(3, 1)
41+ fillOutputStateRows(initial, []string{"abc"})
42+ first, err := stream.prepare(initial, nil, true)
43+ require.NoError(t, err)
44+ var firstFrame ports.Frame
45+ require.NoError(t, first.send(first.data, 0, func(frame ports.Frame) error {
46+ firstFrame = frame
47+ return nil
48+ }))
49+ require.Equal(t, uint64(1), stream.next)
50+
51+ changed := initial.Clone()
52+ changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
53+ pending, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
54+ require.NoError(t, err)
55+ probe, err := stream.renderer.Prepare(initial, nil, false)
56+ require.NoError(t, err)
57+ require.Empty(t, probe.Bytes(), "preparation must not advance the renderer shadow")
58+
59+ sendErr := errors.New("send failed")
60+ var failedFrame ports.Frame
61+ require.ErrorIs(t, pending.send(pending.data, 0, func(frame ports.Frame) error {
62+ failedFrame = frame
63+ return sendErr
64+ }), sendErr)
65+ require.Equal(t, uint64(1), stream.next, "failed send must not advance the state chain")
66+ pending.commitNoSend()
67+ probe, err = stream.renderer.Prepare(initial, nil, false)
68+ require.NoError(t, err)
69+ require.Empty(t, probe.Bytes(), "failed send must retain the committed renderer shadow")
70+
71+ retry, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
72+ require.NoError(t, err)
73+ var sent ports.Frame
74+ require.NoError(t, retry.send(retry.data, 0, func(frame ports.Frame) error {
75+ sent = frame
76+ return nil
77+ }))
78+ out, err := ports.UnmarshalOutput(sent.Payload)
79+ require.NoError(t, err)
80+ require.Zero(t, out.BaseStateNum, "retry after ambiguous send failure must be dependency-free")
81+ require.Equal(t, uint64(2), out.NewStateNum, "state numbers remain monotonic across rebases")
82+ require.Equal(t, "\x1b[1;1Hxbc\x1b[0m", string(out.Data))
83+
84+ beforeFailure := vt.NewScreen(3, 1)
85+ mustApplyOutput(t, beforeFailure, firstFrame)
86+ afterFailure := vt.NewScreen(3, 1)
87+ mustApplyOutput(t, afterFailure, firstFrame)
88+ mustApplyOutput(t, afterFailure, failedFrame)
89+ mustApplyOutput(t, beforeFailure, sent)
90+ mustApplyOutput(t, afterFailure, sent)
91+ for _, screen := range []*vt.Screen{beforeFailure, afterFailure} {
92+ require.Equal(t, "xbc", outputStateRow(screen.Frame.Row(0)))
93+ }
94+ require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once")
95+ require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error {
96+ t.Fatal("completed output sent twice")
97+ return nil
98+ }))
99+ require.Equal(t, uint64(2), stream.next)
100+ probe, err = stream.renderer.Prepare(changed, nil, false)
101+ require.NoError(t, err)
102+ require.Empty(t, probe.Bytes(), "successful retry commits the candidate shadow")
103+}
104+
105+func TestPreparedDeltaCommitPreservesRebaseSnapshot(t *testing.T) {
106+ stream := newOutputStateStream()
107+ initial := renderer.NewFrame(1, 1)
108+ initial.Set(0, 0, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()})
109+ first, err := stream.prepare(initial, nil, true)
110+ require.NoError(t, err)
111+ require.NoError(t, first.send(first.data, 0, func(ports.Frame) error { return nil }))
112+
113+ changed := initial.Clone()
114+ changed.Set(0, 0, renderer.Cell{Rune: 'b', Style: renderer.DefaultStyle()})
115+ prepared, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
116+ require.NoError(t, err)
117+ require.False(t, prepared.reset)
118+
119+ stream.rebase()
120+ prepared.commitNoSend()
121+ require.True(t, stream.forceSnapshot, "committing a pre-rebase delta must not clear the rebase snapshot")
122+
123+ next, err := stream.prepare(changed, nil, false)
124+ require.NoError(t, err)
125+ require.True(t, next.reset, "the next output after rebase must remain a snapshot")
126+}
127+
128+func TestOutputStateStreamNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
129+ stream := newOutputStateStream()
130+ frame := renderer.NewFrame(3, 1)
131+ fillOutputStateRows(frame, []string{"abc"})
132+ initial, err := stream.prepare(frame, nil, true)
133+ require.NoError(t, err)
134+ require.NoError(t, initial.send(initial.data, 0, func(ports.Frame) error { return nil }))
135+
136+ noOp, err := stream.prepare(frame, nil, false)
137+ require.NoError(t, err)
138+ require.Empty(t, noOp.data)
139+ noOp.commitNoSend()
140+ noOp.commitNoSend()
141+ require.Equal(t, uint64(1), stream.next, "no-byte commit must not create a state frame")
142+
143+ probe, err := stream.renderer.Prepare(frame, nil, false)
144+ require.NoError(t, err)
145+ require.Empty(t, probe.Bytes(), "no-byte commit retains the prepared visual candidate")
146+}
147+
37148 func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) {
38149 stream := newOutputStateStream()
39150 client := vt.NewScreen(4, 3)
@@ -68,13 +179,32 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T
68179 }
69180 }
70181
182+func renderCommitted(stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {
183+ prepared, err := stream.prepare(frame, damage, reset)
184+ if err != nil {
185+ return nil, err
186+ }
187+ prepared.commitNoSend()
188+ return prepared.data, nil
189+}
190+
71191 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
72192 t.Helper()
73- data, err := stream.render(frame, damage, reset)
74- if err != nil || len(data) == 0 {
193+ prepared, err := stream.prepare(frame, damage, reset)
194+ if err != nil || len(prepared.data) == 0 {
195+ if err == nil {
196+ prepared.commitNoSend()
197+ }
198+ return ports.Frame{}, false, err
199+ }
200+ var output ports.Frame
201+ if err := prepared.send(prepared.data, echoAck, func(frame ports.Frame) error {
202+ output = frame
203+ return nil
204+ }); err != nil {
75205 return ports.Frame{}, false, err
76206 }
77- return stream.frame(data, reset, echoAck), true, nil
207+ return output, true, nil
78208 }
79209
80210 // A resize is a state reset, but it must not turn subsequent idle or damaged
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "errors"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
@@ -34,6 +35,116 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {
34 require.Equal(t, uint64(2), stream.outstanding())35 require.Equal(t, uint64(2), stream.outstanding())
35 }36 }
36 37
38+func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {
39+ stream := newOutputStateStream()
40+ initial := renderer.NewFrame(3, 1)
41+ fillOutputStateRows(initial, []string{"abc"})
42+ first, err := stream.prepare(initial, nil, true)
43+ require.NoError(t, err)
44+ var firstFrame ports.Frame
45+ require.NoError(t, first.send(first.data, 0, func(frame ports.Frame) error {
46+ firstFrame = frame
47+ return nil
48+ }))
49+ require.Equal(t, uint64(1), stream.next)
50+
51+ changed := initial.Clone()
52+ changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
53+ pending, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
54+ require.NoError(t, err)
55+ probe, err := stream.renderer.Prepare(initial, nil, false)
56+ require.NoError(t, err)
57+ require.Empty(t, probe.Bytes(), "preparation must not advance the renderer shadow")
58+
59+ sendErr := errors.New("send failed")
60+ var failedFrame ports.Frame
61+ require.ErrorIs(t, pending.send(pending.data, 0, func(frame ports.Frame) error {
62+ failedFrame = frame
63+ return sendErr
64+ }), sendErr)
65+ require.Equal(t, uint64(1), stream.next, "failed send must not advance the state chain")
66+ pending.commitNoSend()
67+ probe, err = stream.renderer.Prepare(initial, nil, false)
68+ require.NoError(t, err)
69+ require.Empty(t, probe.Bytes(), "failed send must retain the committed renderer shadow")
70+
71+ retry, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
72+ require.NoError(t, err)
73+ var sent ports.Frame
74+ require.NoError(t, retry.send(retry.data, 0, func(frame ports.Frame) error {
75+ sent = frame
76+ return nil
77+ }))
78+ out, err := ports.UnmarshalOutput(sent.Payload)
79+ require.NoError(t, err)
80+ require.Zero(t, out.BaseStateNum, "retry after ambiguous send failure must be dependency-free")
81+ require.Equal(t, uint64(2), out.NewStateNum, "state numbers remain monotonic across rebases")
82+ require.Equal(t, "\x1b[1;1Hxbc\x1b[0m", string(out.Data))
83+
84+ beforeFailure := vt.NewScreen(3, 1)
85+ mustApplyOutput(t, beforeFailure, firstFrame)
86+ afterFailure := vt.NewScreen(3, 1)
87+ mustApplyOutput(t, afterFailure, firstFrame)
88+ mustApplyOutput(t, afterFailure, failedFrame)
89+ mustApplyOutput(t, beforeFailure, sent)
90+ mustApplyOutput(t, afterFailure, sent)
91+ for _, screen := range []*vt.Screen{beforeFailure, afterFailure} {
92+ require.Equal(t, "xbc", outputStateRow(screen.Frame.Row(0)))
93+ }
94+ require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once")
95+ require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error {
96+ t.Fatal("completed output sent twice")
97+ return nil
98+ }))
99+ require.Equal(t, uint64(2), stream.next)
100+ probe, err = stream.renderer.Prepare(changed, nil, false)
101+ require.NoError(t, err)
102+ require.Empty(t, probe.Bytes(), "successful retry commits the candidate shadow")
103+}
104+
105+func TestPreparedDeltaCommitPreservesRebaseSnapshot(t *testing.T) {
106+ stream := newOutputStateStream()
107+ initial := renderer.NewFrame(1, 1)
108+ initial.Set(0, 0, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()})
109+ first, err := stream.prepare(initial, nil, true)
110+ require.NoError(t, err)
111+ require.NoError(t, first.send(first.data, 0, func(ports.Frame) error { return nil }))
112+
113+ changed := initial.Clone()
114+ changed.Set(0, 0, renderer.Cell{Rune: 'b', Style: renderer.DefaultStyle()})
115+ prepared, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
116+ require.NoError(t, err)
117+ require.False(t, prepared.reset)
118+
119+ stream.rebase()
120+ prepared.commitNoSend()
121+ require.True(t, stream.forceSnapshot, "committing a pre-rebase delta must not clear the rebase snapshot")
122+
123+ next, err := stream.prepare(changed, nil, false)
124+ require.NoError(t, err)
125+ require.True(t, next.reset, "the next output after rebase must remain a snapshot")
126+}
127+
128+func TestOutputStateStreamNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
129+ stream := newOutputStateStream()
130+ frame := renderer.NewFrame(3, 1)
131+ fillOutputStateRows(frame, []string{"abc"})
132+ initial, err := stream.prepare(frame, nil, true)
133+ require.NoError(t, err)
134+ require.NoError(t, initial.send(initial.data, 0, func(ports.Frame) error { return nil }))
135+
136+ noOp, err := stream.prepare(frame, nil, false)
137+ require.NoError(t, err)
138+ require.Empty(t, noOp.data)
139+ noOp.commitNoSend()
140+ noOp.commitNoSend()
141+ require.Equal(t, uint64(1), stream.next, "no-byte commit must not create a state frame")
142+
143+ probe, err := stream.renderer.Prepare(frame, nil, false)
144+ require.NoError(t, err)
145+ require.Empty(t, probe.Bytes(), "no-byte commit retains the prepared visual candidate")
146+}
147+
37 func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) {148 func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) {
38 stream := newOutputStateStream()149 stream := newOutputStateStream()
39 client := vt.NewScreen(4, 3)150 client := vt.NewScreen(4, 3)
@@ -68,13 +179,32 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T
68 }179 }
69 }180 }
70 181
182+func renderCommitted(stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {
183+ prepared, err := stream.prepare(frame, damage, reset)
184+ if err != nil {
185+ return nil, err
186+ }
187+ prepared.commitNoSend()
188+ return prepared.data, nil
189+}
190+
71 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {191 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
72 t.Helper()192 t.Helper()
73- data, err := stream.render(frame, damage, reset)193+ prepared, err := stream.prepare(frame, damage, reset)
74- if err != nil || len(data) == 0 {194+ if err != nil || len(prepared.data) == 0 {
195+ if err == nil {
196+ prepared.commitNoSend()
197+ }
198+ return ports.Frame{}, false, err
199+ }
200+ var output ports.Frame
201+ if err := prepared.send(prepared.data, echoAck, func(frame ports.Frame) error {
202+ output = frame
203+ return nil
204+ }); err != nil {
75 return ports.Frame{}, false, err205 return ports.Frame{}, false, err
76 }206 }
77- return stream.frame(data, reset, echoAck), true, nil207+ return output, true, nil
78 }208 }
79 209
80 // A resize is a state reset, but it must not turn subsequent idle or damaged210 // A resize is a state reset, but it must not turn subsequent idle or damaged
M internal/usecase/daemon/render_pipeline.go +42 -23
@@ -384,30 +384,35 @@ func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
384384 }
385385 }
386386
387-func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {
388- 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
387+type cursorCandidate struct {
388+ data []byte
389+ next cursorOut
390+}
391+
392+func (ac *attachedClient) prepareCursorTail(desired cursorOut, force bool) cursorCandidate {
393+ candidate := cursorCandidate{next: desired}
394+ candidate.next.valid = true
395+ prev := ac.lastCursor
396+ changed := force || !prev.valid || prev.hidden != desired.hidden || prev.row != desired.row || prev.col != desired.col || prev.style != desired.style || prev.hasStyle != desired.hasStyle
389397 if !changed {
390- return nil
398+ return candidate
391399 }
392- prev := ac.lastCursor
393- ac.lastCursor = desired
394- ac.lastCursor.valid = true
395400 if desired.hidden {
396- return []byte("\x1b[?25l")
397- }
398- var b []byte
399- b = append(b, "\x1b["...)
400- b = strconv.AppendInt(b, int64(desired.row+1), 10)
401- b = append(b, ';')
402- b = strconv.AppendInt(b, int64(desired.col+1), 10)
403- b = append(b, 'H')
401+ candidate.data = []byte("\x1b[?25l")
402+ return candidate
403+ }
404+ candidate.data = append(candidate.data, "\x1b["...)
405+ candidate.data = strconv.AppendInt(candidate.data, int64(desired.row+1), 10)
406+ candidate.data = append(candidate.data, ';')
407+ candidate.data = strconv.AppendInt(candidate.data, int64(desired.col+1), 10)
408+ candidate.data = append(candidate.data, 'H')
404409 if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
405- b = append(b, "\x1b["...)
406- b = strconv.AppendInt(b, int64(desired.style), 10)
407- b = append(b, " q"...)
410+ candidate.data = append(candidate.data, "\x1b["...)
411+ candidate.data = strconv.AppendInt(candidate.data, int64(desired.style), 10)
412+ candidate.data = append(candidate.data, " q"...)
408413 }
409- b = append(b, "\x1b[?25h"...)
410- return b
414+ candidate.data = append(candidate.data, "\x1b[?25h"...)
415+ return candidate
411416 }
412417
413418 // commitDamageReceipts acknowledges only snapshots that reached the client.
@@ -493,8 +498,9 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
493498 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
494499 return true
495500 }
501+ cursor := ac.prepareCursorTail(composed.cursor, len(prepared.data) > 0)
496502 data := append([]byte(nil), prepared.data...)
497- data = append(data, ac.encodeCursorTail(composed.cursor, len(data) > 0)...)
503+ data = append(data, cursor.data...)
498504 var sendTr ports.Transport
499505 var sendErr error
500506 // Metadata precedes any output bytes and is published on its own when the
@@ -518,7 +524,17 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
518524 send = async.SendAsync
519525 }
520526 interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)
521- sendErr = prepared.send(data, ac.echoAck.Load(), send)
527+ sendErr = prepared.send(data, ac.echoAck.Load(), func(frame ports.Frame) error {
528+ err := send(frame)
529+ if interruptible {
530+ if err != nil {
531+ marks.roleEffect.reportTransportFailure(sendTransport)
532+ }
533+ marks.roleEffect.endTransportSend()
534+ interruptible = false
535+ }
536+ return err
537+ })
522538 if interruptible {
523539 if sendErr != nil {
524540 marks.roleEffect.reportTransportFailure(sendTransport)
@@ -530,14 +546,17 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
530546 }
531547 }
532548 if sendErr == nil {
549+ if len(data) == 0 {
550+ prepared.commitNoSend()
551+ }
533552 // Publish only after output preparation and transport emission both
534553 // succeed. A cross-session transition may publish concurrently, but its
535554 // mandatory first-paint rebase waits for sendMu and therefore follows this
536555 // completed output transaction.
556+ ac.lastCursor = cursor.next
537557 ac.pipelineScratch = ac.pipelineCache
538558 ac.pipelineCache = composed.cache
539- }
540- if sendErr == nil {
559+
541560 // A successful no-byte emission also commits: its renderer shadow still
542561 // represents the captured frame. Lock panes only under sendMu and with no
543562 // session guard held.
@@ -384,30 +384,35 @@ func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
384 }384 }
385 }385 }
386 386
387-func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte {387+type cursorCandidate struct {
388- 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.hasStyle388+ data []byte
389+ next cursorOut
390+}
391+
392+func (ac *attachedClient) prepareCursorTail(desired cursorOut, force bool) cursorCandidate {
393+ candidate := cursorCandidate{next: desired}
394+ candidate.next.valid = true
395+ prev := ac.lastCursor
396+ changed := force || !prev.valid || prev.hidden != desired.hidden || prev.row != desired.row || prev.col != desired.col || prev.style != desired.style || prev.hasStyle != desired.hasStyle
389 if !changed {397 if !changed {
390- return nil398+ return candidate
391 }399 }
392- prev := ac.lastCursor
393- ac.lastCursor = desired
394- ac.lastCursor.valid = true
395 if desired.hidden {400 if desired.hidden {
396- return []byte("\x1b[?25l")401+ candidate.data = []byte("\x1b[?25l")
397- }402+ return candidate
398- var b []byte403+ }
399- b = append(b, "\x1b["...)404+ candidate.data = append(candidate.data, "\x1b["...)
400- b = strconv.AppendInt(b, int64(desired.row+1), 10)405+ candidate.data = strconv.AppendInt(candidate.data, int64(desired.row+1), 10)
401- b = append(b, ';')406+ candidate.data = append(candidate.data, ';')
402- b = strconv.AppendInt(b, int64(desired.col+1), 10)407+ candidate.data = strconv.AppendInt(candidate.data, int64(desired.col+1), 10)
403- b = append(b, 'H')408+ candidate.data = append(candidate.data, 'H')
404 if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {409 if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
405- b = append(b, "\x1b["...)410+ candidate.data = append(candidate.data, "\x1b["...)
406- b = strconv.AppendInt(b, int64(desired.style), 10)411+ candidate.data = strconv.AppendInt(candidate.data, int64(desired.style), 10)
407- b = append(b, " q"...)412+ candidate.data = append(candidate.data, " q"...)
408 }413 }
409- b = append(b, "\x1b[?25h"...)414+ candidate.data = append(candidate.data, "\x1b[?25h"...)
410- return b415+ return candidate
411 }416 }
412 417
413 // commitDamageReceipts acknowledges only snapshots that reached the client.418 // commitDamageReceipts acknowledges only snapshots that reached the client.
@@ -493,8 +498,9 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
493 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")498 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
494 return true499 return true
495 }500 }
501+ cursor := ac.prepareCursorTail(composed.cursor, len(prepared.data) > 0)
496 data := append([]byte(nil), prepared.data...)502 data := append([]byte(nil), prepared.data...)
497- data = append(data, ac.encodeCursorTail(composed.cursor, len(data) > 0)...)503+ data = append(data, cursor.data...)
498 var sendTr ports.Transport504 var sendTr ports.Transport
499 var sendErr error505 var sendErr error
500 // Metadata precedes any output bytes and is published on its own when the506 // Metadata precedes any output bytes and is published on its own when the
@@ -518,7 +524,17 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
518 send = async.SendAsync524 send = async.SendAsync
519 }525 }
520 interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)526 interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)
521- sendErr = prepared.send(data, ac.echoAck.Load(), send)527+ sendErr = prepared.send(data, ac.echoAck.Load(), func(frame ports.Frame) error {
528+ err := send(frame)
529+ if interruptible {
530+ if err != nil {
531+ marks.roleEffect.reportTransportFailure(sendTransport)
532+ }
533+ marks.roleEffect.endTransportSend()
534+ interruptible = false
535+ }
536+ return err
537+ })
522 if interruptible {538 if interruptible {
523 if sendErr != nil {539 if sendErr != nil {
524 marks.roleEffect.reportTransportFailure(sendTransport)540 marks.roleEffect.reportTransportFailure(sendTransport)
@@ -530,14 +546,17 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
530 }546 }
531 }547 }
532 if sendErr == nil {548 if sendErr == nil {
549+ if len(data) == 0 {
550+ prepared.commitNoSend()
551+ }
533 // Publish only after output preparation and transport emission both552 // Publish only after output preparation and transport emission both
534 // succeed. A cross-session transition may publish concurrently, but its553 // succeed. A cross-session transition may publish concurrently, but its
535 // mandatory first-paint rebase waits for sendMu and therefore follows this554 // mandatory first-paint rebase waits for sendMu and therefore follows this
536 // completed output transaction.555 // completed output transaction.
556+ ac.lastCursor = cursor.next
537 ac.pipelineScratch = ac.pipelineCache557 ac.pipelineScratch = ac.pipelineCache
538 ac.pipelineCache = composed.cache558 ac.pipelineCache = composed.cache
539- }559+
540- if sendErr == nil {
541 // A successful no-byte emission also commits: its renderer shadow still560 // A successful no-byte emission also commits: its renderer shadow still
542 // represents the captured frame. Lock panes only under sendMu and with no561 // represents the captured frame. Lock panes only under sendMu and with no
543 // session guard held.562 // session guard held.
M internal/usecase/daemon/render_pipeline_cache_test.go +9 -2
@@ -15,9 +15,16 @@ import (
1515 "github.com/stretchr/testify/require"
1616 )
1717
18-type cacheFailTransport struct{}
18+type cacheFailTransport struct {
19+ err error
20+}
1921
20-func (cacheFailTransport) Send(ports.Frame) error { return errors.New("send failed") }
22+func (t cacheFailTransport) Send(ports.Frame) error {
23+ if t.err != nil {
24+ return t.err
25+ }
26+ return errors.New("send failed")
27+}
2128 func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
2229 func (cacheFailTransport) Close() error { return nil }
2330
@@ -15,9 +15,16 @@ import (
15 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 )16 )
17 17
18-type cacheFailTransport struct{}18+type cacheFailTransport struct {
19+ err error
20+}
19 21
20-func (cacheFailTransport) Send(ports.Frame) error { return errors.New("send failed") }22+func (t cacheFailTransport) Send(ports.Frame) error {
23+ if t.err != nil {
24+ return t.err
25+ }
26+ return errors.New("send failed")
27+}
21 func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }28 func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
22 func (cacheFailTransport) Close() error { return nil }29 func (cacheFailTransport) Close() error { return nil }
23 30
M internal/usecase/daemon/render_pipeline_test.go +92 -3
@@ -208,6 +208,94 @@ func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
208208 }
209209 }
210210
211+func TestCursorCandidateDoesNotPublishDuringPreparation(t *testing.T) {
212+ ac := &attachedClient{lastCursor: cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}}
213+ desired := cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
214+
215+ candidate := ac.prepareCursorTail(desired, false)
216+
217+ require.Equal(t, cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}, ac.lastCursor)
218+ require.NotEmpty(t, candidate.data)
219+ require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, candidate.next)
220+}
221+
222+func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
223+ d, sess, ac, sends := newManualSessionWithPTYs(t)
224+ healthy := ac.transport()
225+ beforeCursor := cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}
226+ ac.lastCursor = beforeCursor
227+ ac.replaceTransport(cacheFailTransport{})
228+ state := cacheState("failed", 1)
229+ state.attachment = ac
230+ composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
231+ composed.cursor = cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
232+
233+ ac.sendMu.Lock()
234+ require.True(t, d.emitFrame(sess, ac, &state, composed))
235+
236+ require.Equal(t, beforeCursor, ac.lastCursor)
237+ require.Zero(t, ac.output.next)
238+ probe, err := ac.output.renderer.Prepare(composed.frame, nil, false)
239+ require.NoError(t, err)
240+ require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow")
241+
242+ sess.mu.Lock()
243+ sess.client = ac
244+ sess.mu.Unlock()
245+ ac.setSession(sess)
246+ ac.replaceTransport(healthy)
247+ state.attachment = ac
248+ ac.sendMu.Lock()
249+ require.True(t, d.emitFrame(sess, ac, &state, composed))
250+ require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, ac.lastCursor)
251+ require.Equal(t, uint64(1), ac.output.next)
252+ out, err := ports.UnmarshalOutput((<-sends).Payload)
253+ require.NoError(t, err)
254+ require.Zero(t, out.BaseStateNum)
255+ require.Equal(t, uint64(1), out.NewStateNum)
256+}
257+
258+func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T) {
259+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
260+ state := cacheState("steady", 1)
261+ state.attachment = ac
262+ initial := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
263+ ac.sendMu.Lock()
264+ require.True(t, d.emitFrame(sess, ac, &state, initial))
265+ <-sends
266+
267+ p := newPane("collapsed", nil, domain.Size{Cols: 1, Rows: 1})
268+ p.mu.Lock()
269+ p.screen.ClearDamage()
270+ p.screen.Write([]byte("x"))
271+ capture := p.screen.CaptureDamage()
272+ p.mu.Unlock()
273+
274+ beforeNext := ac.output.next
275+ beforeCursor := ac.lastCursor
276+ state.receipts = []damageReceipt{{pane: p, generation: capture.Generation}}
277+ noByte := composedRenderFrame{
278+ frame: initial.frame,
279+ cursor: initial.cursor,
280+ cache: initial.cache,
281+ }
282+ noByte.cache.layoutFingerprint = "no-byte-committed"
283+ ac.sendMu.Lock()
284+ require.True(t, d.emitFrame(sess, ac, &state, noByte))
285+
286+ require.Equal(t, beforeNext, ac.output.next)
287+ require.Equal(t, beforeCursor, ac.lastCursor)
288+ require.Equal(t, "no-byte-committed", ac.pipelineCache.layoutFingerprint)
289+ p.mu.Lock()
290+ require.Empty(t, p.screen.Damage())
291+ p.mu.Unlock()
292+ select {
293+ case frame := <-sends:
294+ t.Fatalf("no-byte transaction sent state frame %#v", frame)
295+ default:
296+ }
297+}
298+
211299 func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
212300 paneFrame := renderer.NewFrame(12, 5)
213301 for y, text := range []string{"AAAAAAAAAAAA", "BBBBBBBBBBBB", "CCCCCCCCCCCC", "DDDDDDDDDDDD", "EEEEEEEEEEEE"} {
@@ -240,11 +328,12 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
240328 stream := newOutputStateStream()
241329 prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
242330 require.NoError(t, err)
243- terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)
331+ terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).prepareCursorTail(composed.cursor, true).data...)
244332 require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
245333 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
246334 client.Write(terminalBytes)
247335 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
336+ prepared.commitNoSend()
248337 again, err := stream.renderer.Draw(composed.frame, nil)
249338 require.NoError(t, err)
250339 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
@@ -282,13 +371,13 @@ func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
282371 }
283372 stream := newOutputStateStream()
284373 first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{})
285- firstBytes, err := stream.render(first.frame, first.damage, first.reset)
374+ firstBytes, err := renderCommitted(stream, first.frame, first.damage, first.reset)
286375 require.NoError(t, err)
287376 client := vt.NewScreen(4, 5)
288377 client.Write(firstBytes)
289378
290379 second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache)
291- secondBytes, err := stream.render(second.frame, second.damage, second.reset)
380+ secondBytes, err := renderCommitted(stream, second.frame, second.damage, second.reset)
292381 require.NoError(t, err)
293382 require.Equal(t, tt.wantBytes, string(secondBytes))
294383 client.Write(secondBytes)
@@ -208,6 +208,94 @@ func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
208 }208 }
209 }209 }
210 210
211+func TestCursorCandidateDoesNotPublishDuringPreparation(t *testing.T) {
212+ ac := &attachedClient{lastCursor: cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}}
213+ desired := cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
214+
215+ candidate := ac.prepareCursorTail(desired, false)
216+
217+ require.Equal(t, cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}, ac.lastCursor)
218+ require.NotEmpty(t, candidate.data)
219+ require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, candidate.next)
220+}
221+
222+func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
223+ d, sess, ac, sends := newManualSessionWithPTYs(t)
224+ healthy := ac.transport()
225+ beforeCursor := cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}
226+ ac.lastCursor = beforeCursor
227+ ac.replaceTransport(cacheFailTransport{})
228+ state := cacheState("failed", 1)
229+ state.attachment = ac
230+ composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
231+ composed.cursor = cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
232+
233+ ac.sendMu.Lock()
234+ require.True(t, d.emitFrame(sess, ac, &state, composed))
235+
236+ require.Equal(t, beforeCursor, ac.lastCursor)
237+ require.Zero(t, ac.output.next)
238+ probe, err := ac.output.renderer.Prepare(composed.frame, nil, false)
239+ require.NoError(t, err)
240+ require.NotEmpty(t, probe.Bytes(), "failed output must not commit the renderer shadow")
241+
242+ sess.mu.Lock()
243+ sess.client = ac
244+ sess.mu.Unlock()
245+ ac.setSession(sess)
246+ ac.replaceTransport(healthy)
247+ state.attachment = ac
248+ ac.sendMu.Lock()
249+ require.True(t, d.emitFrame(sess, ac, &state, composed))
250+ require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, ac.lastCursor)
251+ require.Equal(t, uint64(1), ac.output.next)
252+ out, err := ports.UnmarshalOutput((<-sends).Payload)
253+ require.NoError(t, err)
254+ require.Zero(t, out.BaseStateNum)
255+ require.Equal(t, uint64(1), out.NewStateNum)
256+}
257+
258+func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T) {
259+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
260+ state := cacheState("steady", 1)
261+ state.attachment = ac
262+ initial := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
263+ ac.sendMu.Lock()
264+ require.True(t, d.emitFrame(sess, ac, &state, initial))
265+ <-sends
266+
267+ p := newPane("collapsed", nil, domain.Size{Cols: 1, Rows: 1})
268+ p.mu.Lock()
269+ p.screen.ClearDamage()
270+ p.screen.Write([]byte("x"))
271+ capture := p.screen.CaptureDamage()
272+ p.mu.Unlock()
273+
274+ beforeNext := ac.output.next
275+ beforeCursor := ac.lastCursor
276+ state.receipts = []damageReceipt{{pane: p, generation: capture.Generation}}
277+ noByte := composedRenderFrame{
278+ frame: initial.frame,
279+ cursor: initial.cursor,
280+ cache: initial.cache,
281+ }
282+ noByte.cache.layoutFingerprint = "no-byte-committed"
283+ ac.sendMu.Lock()
284+ require.True(t, d.emitFrame(sess, ac, &state, noByte))
285+
286+ require.Equal(t, beforeNext, ac.output.next)
287+ require.Equal(t, beforeCursor, ac.lastCursor)
288+ require.Equal(t, "no-byte-committed", ac.pipelineCache.layoutFingerprint)
289+ p.mu.Lock()
290+ require.Empty(t, p.screen.Damage())
291+ p.mu.Unlock()
292+ select {
293+ case frame := <-sends:
294+ t.Fatalf("no-byte transaction sent state frame %#v", frame)
295+ default:
296+ }
297+}
298+
211 func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {299 func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
212 paneFrame := renderer.NewFrame(12, 5)300 paneFrame := renderer.NewFrame(12, 5)
213 for y, text := range []string{"AAAAAAAAAAAA", "BBBBBBBBBBBB", "CCCCCCCCCCCC", "DDDDDDDDDDDD", "EEEEEEEEEEEE"} {301 for y, text := range []string{"AAAAAAAAAAAA", "BBBBBBBBBBBB", "CCCCCCCCCCCC", "DDDDDDDDDDDD", "EEEEEEEEEEEE"} {
@@ -240,11 +328,12 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
240 stream := newOutputStateStream()328 stream := newOutputStateStream()
241 prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)329 prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
242 require.NoError(t, err)330 require.NoError(t, err)
243- terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)331+ terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).prepareCursorTail(composed.cursor, true).data...)
244 require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))332 require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
245 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)333 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
246 client.Write(terminalBytes)334 client.Write(terminalBytes)
247 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))335 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
336+ prepared.commitNoSend()
248 again, err := stream.renderer.Draw(composed.frame, nil)337 again, err := stream.renderer.Draw(composed.frame, nil)
249 require.NoError(t, err)338 require.NoError(t, err)
250 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")339 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
@@ -282,13 +371,13 @@ func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
282 }371 }
283 stream := newOutputStateStream()372 stream := newOutputStateStream()
284 first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{})373 first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{})
285- firstBytes, err := stream.render(first.frame, first.damage, first.reset)374+ firstBytes, err := renderCommitted(stream, first.frame, first.damage, first.reset)
286 require.NoError(t, err)375 require.NoError(t, err)
287 client := vt.NewScreen(4, 5)376 client := vt.NewScreen(4, 5)
288 client.Write(firstBytes)377 client.Write(firstBytes)
289 378
290 second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache)379 second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache)
291- secondBytes, err := stream.render(second.frame, second.damage, second.reset)380+ secondBytes, err := renderCommitted(stream, second.frame, second.damage, second.reset)
292 require.NoError(t, err)381 require.NoError(t, err)
293 require.Equal(t, tt.wantBytes, string(secondBytes))382 require.Equal(t, tt.wantBytes, string(secondBytes))
294 client.Write(secondBytes)383 client.Write(secondBytes)
M internal/usecase/daemon/resume_test.go +24 -4
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/vev/internal/ports"
1717 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1818 "github.com/bnema/vev/internal/usecase/layout"
19+ "github.com/bnema/vev/pkg/renderer"
1920 "github.com/bnema/vev/pkg/vt"
2021 )
2122
@@ -1396,10 +1397,22 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
13961397
13971398 ac.sendMu.Lock()
13981399 ac.output.next = 5
1400+ baseFrame := renderer.NewFrame(1, 1)
1401+ baseFrame.Set(0, 0, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()})
1402+ _, err := ac.output.renderer.Draw(baseFrame, nil)
1403+ require.NoError(t, err)
1404+
13991405 reset := false
14001406 require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")
1401-
1402- f := ac.output.frame([]byte("incremental while reliable backlog drains"), reset, 0)
1407+ incrementalFrame := baseFrame.Clone()
1408+ incrementalFrame.Set(0, 0, renderer.Cell{Rune: 'i', Style: renderer.DefaultStyle()})
1409+ prepared, err := ac.output.prepare(incrementalFrame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, reset)
1410+ require.NoError(t, err)
1411+ var f ports.Frame
1412+ require.NoError(t, prepared.send(prepared.data, 0, func(frame ports.Frame) error {
1413+ f = frame
1414+ return nil
1415+ }))
14031416 ac.sendMu.Unlock()
14041417 out, err := ports.UnmarshalOutput(f.Payload)
14051418 require.NoError(t, err)
@@ -1409,9 +1422,16 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
14091422 ac.sendMu.Lock()
14101423 reset = true
14111424 require.True(t, reset, "explicit reset should still force full repaint")
1412- full := ac.output.frame([]byte("explicit full repaint"), reset, 0)
1425+ fullFrame := incrementalFrame.Clone()
1426+ full, err := ac.output.prepare(fullFrame, nil, reset)
1427+ require.NoError(t, err)
1428+ var fullOutput ports.Frame
1429+ require.NoError(t, full.send(full.data, 0, func(frame ports.Frame) error {
1430+ fullOutput = frame
1431+ return nil
1432+ }))
14131433 ac.sendMu.Unlock()
1414- fullOut, err := ports.UnmarshalOutput(full.Payload)
1434+ fullOut, err := ports.UnmarshalOutput(fullOutput.Payload)
14151435 require.NoError(t, err)
14161436 require.Equal(t, uint64(0), fullOut.BaseStateNum)
14171437 require.Equal(t, uint64(7), fullOut.NewStateNum)
@@ -16,6 +16,7 @@ import (
16 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19+ "github.com/bnema/vev/pkg/renderer"
19 "github.com/bnema/vev/pkg/vt"20 "github.com/bnema/vev/pkg/vt"
20 )21 )
21 22
@@ -1396,10 +1397,22 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
1396 1397
1397 ac.sendMu.Lock()1398 ac.sendMu.Lock()
1398 ac.output.next = 51399 ac.output.next = 5
1400+ baseFrame := renderer.NewFrame(1, 1)
1401+ baseFrame.Set(0, 0, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()})
1402+ _, err := ac.output.renderer.Draw(baseFrame, nil)
1403+ require.NoError(t, err)
1404+
1399 reset := false1405 reset := false
1400 require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")1406 require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")
1401-1407+ incrementalFrame := baseFrame.Clone()
1402- f := ac.output.frame([]byte("incremental while reliable backlog drains"), reset, 0)1408+ incrementalFrame.Set(0, 0, renderer.Cell{Rune: 'i', Style: renderer.DefaultStyle()})
1409+ prepared, err := ac.output.prepare(incrementalFrame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, reset)
1410+ require.NoError(t, err)
1411+ var f ports.Frame
1412+ require.NoError(t, prepared.send(prepared.data, 0, func(frame ports.Frame) error {
1413+ f = frame
1414+ return nil
1415+ }))
1403 ac.sendMu.Unlock()1416 ac.sendMu.Unlock()
1404 out, err := ports.UnmarshalOutput(f.Payload)1417 out, err := ports.UnmarshalOutput(f.Payload)
1405 require.NoError(t, err)1418 require.NoError(t, err)
@@ -1409,9 +1422,16 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
1409 ac.sendMu.Lock()1422 ac.sendMu.Lock()
1410 reset = true1423 reset = true
1411 require.True(t, reset, "explicit reset should still force full repaint")1424 require.True(t, reset, "explicit reset should still force full repaint")
1412- full := ac.output.frame([]byte("explicit full repaint"), reset, 0)1425+ fullFrame := incrementalFrame.Clone()
1426+ full, err := ac.output.prepare(fullFrame, nil, reset)
1427+ require.NoError(t, err)
1428+ var fullOutput ports.Frame
1429+ require.NoError(t, full.send(full.data, 0, func(frame ports.Frame) error {
1430+ fullOutput = frame
1431+ return nil
1432+ }))
1413 ac.sendMu.Unlock()1433 ac.sendMu.Unlock()
1414- fullOut, err := ports.UnmarshalOutput(full.Payload)1434+ fullOut, err := ports.UnmarshalOutput(fullOutput.Payload)
1415 require.NoError(t, err)1435 require.NoError(t, err)
1416 require.Equal(t, uint64(0), fullOut.BaseStateNum)1436 require.Equal(t, uint64(0), fullOut.BaseStateNum)
1417 require.Equal(t, uint64(7), fullOut.NewStateNum)1437 require.Equal(t, uint64(7), fullOut.NewStateNum)
M internal/usecase/daemon/role_effect_gate.go +10 -0
@@ -198,6 +198,16 @@ func (t *roleEffectTicket) endTransportSend() {
198198 // beginRoleEffect is the sole role-bound effect admission point. The gate is
199199 // held only long enough to reserve the capability; the effect itself must run
200200 // without the gate mutex.
201+func (ac *attachedClient) transportEffectActive() bool {
202+ if ac == nil {
203+ return false
204+ }
205+ g := &ac.roleEffects
206+ g.mu.Lock()
207+ defer g.mu.Unlock()
208+ return len(g.transportEffects) != 0
209+}
210+
201211 func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) {
202212 if ac == nil || token.ac != ac {
203213 return nil, false
@@ -198,6 +198,16 @@ func (t *roleEffectTicket) endTransportSend() {
198 // beginRoleEffect is the sole role-bound effect admission point. The gate is198 // beginRoleEffect is the sole role-bound effect admission point. The gate is
199 // held only long enough to reserve the capability; the effect itself must run199 // held only long enough to reserve the capability; the effect itself must run
200 // without the gate mutex.200 // without the gate mutex.
201+func (ac *attachedClient) transportEffectActive() bool {
202+ if ac == nil {
203+ return false
204+ }
205+ g := &ac.roleEffects
206+ g.mu.Lock()
207+ defer g.mu.Unlock()
208+ return len(g.transportEffects) != 0
209+}
210+
201 func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) {211 func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) {
202 if ac == nil || token.ac != ac {212 if ac == nil || token.ac != ac {
203 return nil, false213 return nil, false
M internal/usecase/daemon/snatched_cleanup.go +1 -1
@@ -187,7 +187,7 @@ func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionRe
187187 }
188188 if token := result.displaced; token.ac != nil && token.transport.transport != nil {
189189 blockedRender := result.displacedInterrupted
190- if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {
190+ if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) && token.ac.transportEffectActive() {
191191 blockedRender = !token.ac.sendMu.TryLock()
192192 if !blockedRender {
193193 token.ac.sendMu.Unlock()
@@ -187,7 +187,7 @@ func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionRe
187 }187 }
188 if token := result.displaced; token.ac != nil && token.transport.transport != nil {188 if token := result.displaced; token.ac != nil && token.transport.transport != nil {
189 blockedRender := result.displacedInterrupted189 blockedRender := result.displacedInterrupted
190- if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {190+ if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) && token.ac.transportEffectActive() {
191 blockedRender = !token.ac.sendMu.TryLock()191 blockedRender = !token.ac.sendMu.TryLock()
192 if !blockedRender {192 if !blockedRender {
193 token.ac.sendMu.Unlock()193 token.ac.sendMu.Unlock()
M internal/usecase/daemon/snatched_render.go +19 -3
@@ -4,6 +4,7 @@ import (
44 "errors"
55
66 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
78 themeui "github.com/bnema/vev/internal/usecase/theme"
89 "github.com/bnema/vev/internal/usecase/ui"
910 "github.com/bnema/vev/pkg/renderer"
@@ -119,17 +120,32 @@ func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapsho
119120 if err != nil {
120121 return err
121122 }
122- data := append(append([]byte(nil), prepared.data...), ac.encodeCursorTail(cursorOut{hidden: true}, true)...)
123+ cursor := ac.prepareCursorTail(cursorOut{hidden: true}, true)
124+ data := append(append([]byte(nil), prepared.data...), cursor.data...)
123125 if effect != nil && !effect.beginTransportSend(expected) {
124126 return errAttachmentTransition
125127 }
126- err = prepared.send(data, ac.echoAck.Load(), expected.transport.Send)
127- if effect != nil {
128+ effectActive := effect != nil
129+ err = prepared.send(data, ac.echoAck.Load(), func(frame ports.Frame) error {
130+ err := expected.transport.Send(frame)
131+ if effectActive {
132+ if err != nil {
133+ effect.reportTransportFailure(expected)
134+ }
135+ effect.endTransportSend()
136+ effectActive = false
137+ }
138+ return err
139+ })
140+ if effectActive {
128141 if err != nil {
129142 effect.reportTransportFailure(expected)
130143 }
131144 effect.endTransportSend()
132145 }
146+ if err == nil {
147+ ac.lastCursor = cursor.next
148+ }
133149 return err
134150 })
135151 if errors.Is(err, errSendTimedOut) {
@@ -4,6 +4,7 @@ import (
4 "errors"4 "errors"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
7 themeui "github.com/bnema/vev/internal/usecase/theme"8 themeui "github.com/bnema/vev/internal/usecase/theme"
8 "github.com/bnema/vev/internal/usecase/ui"9 "github.com/bnema/vev/internal/usecase/ui"
9 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
@@ -119,17 +120,32 @@ func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapsho
119 if err != nil {120 if err != nil {
120 return err121 return err
121 }122 }
122- data := append(append([]byte(nil), prepared.data...), ac.encodeCursorTail(cursorOut{hidden: true}, true)...)123+ cursor := ac.prepareCursorTail(cursorOut{hidden: true}, true)
124+ data := append(append([]byte(nil), prepared.data...), cursor.data...)
123 if effect != nil && !effect.beginTransportSend(expected) {125 if effect != nil && !effect.beginTransportSend(expected) {
124 return errAttachmentTransition126 return errAttachmentTransition
125 }127 }
126- err = prepared.send(data, ac.echoAck.Load(), expected.transport.Send)128+ effectActive := effect != nil
127- if effect != nil {129+ err = prepared.send(data, ac.echoAck.Load(), func(frame ports.Frame) error {
130+ err := expected.transport.Send(frame)
131+ if effectActive {
132+ if err != nil {
133+ effect.reportTransportFailure(expected)
134+ }
135+ effect.endTransportSend()
136+ effectActive = false
137+ }
138+ return err
139+ })
140+ if effectActive {
128 if err != nil {141 if err != nil {
129 effect.reportTransportFailure(expected)142 effect.reportTransportFailure(expected)
130 }143 }
131 effect.endTransportSend()144 effect.endTransportSend()
132 }145 }
146+ if err == nil {
147+ ac.lastCursor = cursor.next
148+ }
133 return err149 return err
134 })150 })
135 if errors.Is(err, errSendTimedOut) {151 if errors.Is(err, errSendTimedOut) {
M internal/usecase/daemon/snatched_render_test.go +37 -0
@@ -138,6 +138,43 @@ func TestSendSnatchedPanelRebasesOutput(t *testing.T) {
138138 require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden")
139139 }
140140
141+func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) {
142+ d := newTestDaemon(t, nil, stubClock{})
143+ sendErr := errors.New("send failed")
144+ failed := cacheFailTransport{err: sendErr}
145+ ac := &attachedClient{
146+ tr: failed,
147+ output: newOutputStateStream(),
148+ size: domain.Size{Cols: 80, Rows: 24},
149+ lastCursor: cursorOut{valid: true, row: 2, col: 3, style: 1, hasStyle: true},
150+ }
151+ ac.output.next = 7
152+ ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
153+ ac.roleGeneration.Store(1)
154+ beforeCursor := ac.lastCursor
155+
156+ require.ErrorIs(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""), sendErr)
157+ require.Equal(t, beforeCursor, ac.lastCursor)
158+ require.Equal(t, uint64(7), ac.output.next)
159+ require.True(t, ac.output.forceSnapshot, "failed snatched send must force the next output to be a snapshot")
160+ probeFrame := snatchedPanelFrame(ac.size, ac.getAppliedTheme().Resolved.Styles, "")
161+ probe, err := ac.output.renderer.Prepare(probeFrame, nil, false)
162+ require.NoError(t, err)
163+ require.NotEmpty(t, probe.Bytes(), "failed snatched send must not commit the renderer shadow")
164+
165+ healthy := &closeTrackingTransport{}
166+ ac.replaceTransport(healthy)
167+ require.NoError(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""))
168+ require.Equal(t, cursorOut{valid: true, hidden: true}, ac.lastCursor)
169+ require.Equal(t, uint64(8), ac.output.next)
170+ frames := healthy.Sends()
171+ require.Len(t, frames, 1)
172+ out, err := ports.UnmarshalOutput(frames[0].Payload)
173+ require.NoError(t, err)
174+ require.Zero(t, out.BaseStateNum)
175+ require.Equal(t, uint64(8), out.NewStateNum)
176+}
177+
141178 func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) {
142179 clock := &signalClock{timers: make(chan *signalTimer, 1)}
143180 d := newTestDaemon(t, nil, clock)
@@ -138,6 +138,43 @@ func TestSendSnatchedPanelRebasesOutput(t *testing.T) {
138 require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden")138 require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden")
139 }139 }
140 140
141+func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) {
142+ d := newTestDaemon(t, nil, stubClock{})
143+ sendErr := errors.New("send failed")
144+ failed := cacheFailTransport{err: sendErr}
145+ ac := &attachedClient{
146+ tr: failed,
147+ output: newOutputStateStream(),
148+ size: domain.Size{Cols: 80, Rows: 24},
149+ lastCursor: cursorOut{valid: true, row: 2, col: 3, style: 1, hasStyle: true},
150+ }
151+ ac.output.next = 7
152+ ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
153+ ac.roleGeneration.Store(1)
154+ beforeCursor := ac.lastCursor
155+
156+ require.ErrorIs(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""), sendErr)
157+ require.Equal(t, beforeCursor, ac.lastCursor)
158+ require.Equal(t, uint64(7), ac.output.next)
159+ require.True(t, ac.output.forceSnapshot, "failed snatched send must force the next output to be a snapshot")
160+ probeFrame := snatchedPanelFrame(ac.size, ac.getAppliedTheme().Resolved.Styles, "")
161+ probe, err := ac.output.renderer.Prepare(probeFrame, nil, false)
162+ require.NoError(t, err)
163+ require.NotEmpty(t, probe.Bytes(), "failed snatched send must not commit the renderer shadow")
164+
165+ healthy := &closeTrackingTransport{}
166+ ac.replaceTransport(healthy)
167+ require.NoError(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""))
168+ require.Equal(t, cursorOut{valid: true, hidden: true}, ac.lastCursor)
169+ require.Equal(t, uint64(8), ac.output.next)
170+ frames := healthy.Sends()
171+ require.Len(t, frames, 1)
172+ out, err := ports.UnmarshalOutput(frames[0].Payload)
173+ require.NoError(t, err)
174+ require.Zero(t, out.BaseStateNum)
175+ require.Equal(t, uint64(8), out.NewStateNum)
176+}
177+
141 func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) {178 func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) {
142 clock := &signalClock{timers: make(chan *signalTimer, 1)}179 clock := &signalClock{timers: make(chan *signalTimer, 1)}
143 d := newTestDaemon(t, nil, clock)180 d := newTestDaemon(t, nil, clock)
M internal/usecase/daemon/toast_damage_test.go +2 -2
@@ -33,7 +33,7 @@ func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing
3333 require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1})
3434 requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints)
3535 requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints)
36- incremental, err := stream.render(updated.frame, updated.damage, updated.reset)
36+ incremental, err := renderCommitted(stream, updated.frame, updated.damage, updated.reset)
3737 require.NoError(t, err)
3838 fullRenderer := renderer.New(renderer.Capabilities{})
3939 full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()})
@@ -126,7 +126,7 @@ func toastDamageState(notices []domain.Notification, damage []renderer.Damage, r
126126
127127 func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) {
128128 t.Helper()
129- data, err := stream.render(composed.frame, composed.damage, composed.reset)
129+ data, err := renderCommitted(stream, composed.frame, composed.damage, composed.reset)
130130 require.NoError(t, err)
131131 terminal.Write(data)
132132 require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame))
@@ -33,7 +33,7 @@ func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing
33 require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1})33 require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1})
34 requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints)34 requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints)
35 requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints)35 requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints)
36- incremental, err := stream.render(updated.frame, updated.damage, updated.reset)36+ incremental, err := renderCommitted(stream, updated.frame, updated.damage, updated.reset)
37 require.NoError(t, err)37 require.NoError(t, err)
38 fullRenderer := renderer.New(renderer.Capabilities{})38 fullRenderer := renderer.New(renderer.Capabilities{})
39 full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()})39 full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()})
@@ -126,7 +126,7 @@ func toastDamageState(notices []domain.Notification, damage []renderer.Damage, r
126 126
127 func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) {127 func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) {
128 t.Helper()128 t.Helper()
129- data, err := stream.render(composed.frame, composed.damage, composed.reset)129+ data, err := renderCommitted(stream, composed.frame, composed.damage, composed.reset)
130 require.NoError(t, err)130 require.NoError(t, err)
131 terminal.Write(data)131 terminal.Write(data)
132 require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame))132 require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame))
M pkg/renderer/benchmark_test.go +129 -2
@@ -10,6 +10,12 @@ func markBenchmarkFrame(f *Frame) {
1010 }
1111 }
1212
13+func setBenchmarkShadow(r *Renderer, frame Frame) {
14+ committed := r.committedFrame()
15+ replaceFrame(&committed, frame)
16+ r.setCommittedFrame(committed)
17+}
18+
1319 func BenchmarkRendererFullFrameDraw(b *testing.B) {
1420 frame := NewFrame(120, 40)
1521 markBenchmarkFrame(&frame)
@@ -63,7 +69,128 @@ func BenchmarkRendererFragmentedDamage(b *testing.B) {
6369 var outBytes int64
6470 for b.Loop() {
6571 // Each iteration starts with the identical pre-update terminal shadow.
66- r.replaceShadow(baseline)
72+ setBenchmarkShadow(r, baseline)
73+ out, err := r.Draw(frame, damage)
74+ if err != nil {
75+ b.Fatal(err)
76+ }
77+ if len(out) == 0 {
78+ b.Fatal("expected renderer output")
79+ }
80+ outBytes += int64(len(out))
81+ }
82+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
83+}
84+
85+func BenchmarkRendererIncrementalOneCellPrepareCommit(b *testing.B) {
86+ frame := NewFrame(120, 40)
87+ markBenchmarkFrame(&frame)
88+ r := New(Capabilities{})
89+ initial, err := r.Prepare(frame, []Damage{FullRedraw()}, false)
90+ if err != nil {
91+ b.Fatal(err)
92+ }
93+ initial.Commit()
94+ damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}
95+
96+ b.ReportAllocs()
97+ var outBytes int64
98+ changed := false
99+ for b.Loop() {
100+ changed = !changed
101+ cell := Cell{Rune: 'X', Style: DefaultStyle()}
102+ if changed {
103+ cell.Rune = 'Y'
104+ }
105+ frame.Set(60, 20, cell)
106+ prepared, err := r.Prepare(frame, damage, false)
107+ if err != nil {
108+ b.Fatal(err)
109+ }
110+ if len(prepared.Bytes()) == 0 {
111+ b.Fatal("expected renderer output")
112+ }
113+ outBytes += int64(len(prepared.Bytes()))
114+ prepared.Commit()
115+ }
116+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
117+}
118+
119+func BenchmarkRendererIncrementalOneCellDraw(b *testing.B) {
120+ frame := NewFrame(120, 40)
121+ markBenchmarkFrame(&frame)
122+ r := New(Capabilities{})
123+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
124+ b.Fatal(err)
125+ }
126+ damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}
127+
128+ b.ReportAllocs()
129+ var outBytes int64
130+ changed := false
131+ for b.Loop() {
132+ changed = !changed
133+ cell := Cell{Rune: 'X', Style: DefaultStyle()}
134+ if changed {
135+ cell.Rune = 'Y'
136+ }
137+ frame.Set(60, 20, cell)
138+ out, err := r.Draw(frame, damage)
139+ if err != nil {
140+ b.Fatal(err)
141+ }
142+ if len(out) == 0 {
143+ b.Fatal("expected renderer output")
144+ }
145+ outBytes += int64(len(out))
146+ }
147+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
148+}
149+
150+func BenchmarkRendererIncrementalNoBytePrepareCommit(b *testing.B) {
151+ frame := NewFrame(120, 40)
152+ markBenchmarkFrame(&frame)
153+ r := New(Capabilities{})
154+ initial, err := r.Prepare(frame, []Damage{FullRedraw()}, false)
155+ if err != nil {
156+ b.Fatal(err)
157+ }
158+ initial.Commit()
159+
160+ b.ReportAllocs()
161+ for b.Loop() {
162+ prepared, err := r.Prepare(frame, nil, false)
163+ if err != nil {
164+ b.Fatal(err)
165+ }
166+ if len(prepared.Bytes()) != 0 {
167+ b.Fatal("expected no output")
168+ }
169+ prepared.Commit()
170+ }
171+}
172+
173+func BenchmarkRendererBroadRegularDamage(b *testing.B) {
174+ const (
175+ width = 120
176+ height = 40
177+ )
178+ baseline := NewFrame(width, height)
179+ markBenchmarkFrame(&baseline)
180+ frame := baseline.Clone()
181+ for y := range height {
182+ frame.Set(0, y, Cell{Rune: 'z', Style: DefaultStyle()})
183+ }
184+ damage := make([]Damage, height)
185+ for y := range height {
186+ damage[y] = Damage{Kind: DamageText, X: 0, Y: y, Width: width, Height: 1}
187+ }
188+
189+ r := New(Capabilities{})
190+ b.ReportAllocs()
191+ var outBytes int64
192+ for b.Loop() {
193+ setBenchmarkShadow(r, baseline)
67194 out, err := r.Draw(frame, damage)
68195 if err != nil {
69196 b.Fatal(err)
@@ -100,7 +227,7 @@ func BenchmarkRendererScrollFastPath(b *testing.B) {
100227 b.ReportAllocs()
101228 var outBytes int64
102229 for b.Loop() {
103- r.replaceShadow(frame)
230+ setBenchmarkShadow(r, frame)
104231 out, err := r.Draw(scrolled, damage)
105232 if err != nil {
106233 b.Fatal(err)
@@ -10,6 +10,12 @@ func markBenchmarkFrame(f *Frame) {
10 }10 }
11 }11 }
12 12
13+func setBenchmarkShadow(r *Renderer, frame Frame) {
14+ committed := r.committedFrame()
15+ replaceFrame(&committed, frame)
16+ r.setCommittedFrame(committed)
17+}
18+
13 func BenchmarkRendererFullFrameDraw(b *testing.B) {19 func BenchmarkRendererFullFrameDraw(b *testing.B) {
14 frame := NewFrame(120, 40)20 frame := NewFrame(120, 40)
15 markBenchmarkFrame(&frame)21 markBenchmarkFrame(&frame)
@@ -63,7 +69,128 @@ func BenchmarkRendererFragmentedDamage(b *testing.B) {
63 var outBytes int6469 var outBytes int64
64 for b.Loop() {70 for b.Loop() {
65 // Each iteration starts with the identical pre-update terminal shadow.71 // Each iteration starts with the identical pre-update terminal shadow.
66- r.replaceShadow(baseline)72+ setBenchmarkShadow(r, baseline)
73+ out, err := r.Draw(frame, damage)
74+ if err != nil {
75+ b.Fatal(err)
76+ }
77+ if len(out) == 0 {
78+ b.Fatal("expected renderer output")
79+ }
80+ outBytes += int64(len(out))
81+ }
82+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
83+}
84+
85+func BenchmarkRendererIncrementalOneCellPrepareCommit(b *testing.B) {
86+ frame := NewFrame(120, 40)
87+ markBenchmarkFrame(&frame)
88+ r := New(Capabilities{})
89+ initial, err := r.Prepare(frame, []Damage{FullRedraw()}, false)
90+ if err != nil {
91+ b.Fatal(err)
92+ }
93+ initial.Commit()
94+ damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}
95+
96+ b.ReportAllocs()
97+ var outBytes int64
98+ changed := false
99+ for b.Loop() {
100+ changed = !changed
101+ cell := Cell{Rune: 'X', Style: DefaultStyle()}
102+ if changed {
103+ cell.Rune = 'Y'
104+ }
105+ frame.Set(60, 20, cell)
106+ prepared, err := r.Prepare(frame, damage, false)
107+ if err != nil {
108+ b.Fatal(err)
109+ }
110+ if len(prepared.Bytes()) == 0 {
111+ b.Fatal("expected renderer output")
112+ }
113+ outBytes += int64(len(prepared.Bytes()))
114+ prepared.Commit()
115+ }
116+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
117+}
118+
119+func BenchmarkRendererIncrementalOneCellDraw(b *testing.B) {
120+ frame := NewFrame(120, 40)
121+ markBenchmarkFrame(&frame)
122+ r := New(Capabilities{})
123+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
124+ b.Fatal(err)
125+ }
126+ damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}
127+
128+ b.ReportAllocs()
129+ var outBytes int64
130+ changed := false
131+ for b.Loop() {
132+ changed = !changed
133+ cell := Cell{Rune: 'X', Style: DefaultStyle()}
134+ if changed {
135+ cell.Rune = 'Y'
136+ }
137+ frame.Set(60, 20, cell)
138+ out, err := r.Draw(frame, damage)
139+ if err != nil {
140+ b.Fatal(err)
141+ }
142+ if len(out) == 0 {
143+ b.Fatal("expected renderer output")
144+ }
145+ outBytes += int64(len(out))
146+ }
147+ b.ReportMetric(float64(outBytes)/float64(b.N), "outbytes/op")
148+}
149+
150+func BenchmarkRendererIncrementalNoBytePrepareCommit(b *testing.B) {
151+ frame := NewFrame(120, 40)
152+ markBenchmarkFrame(&frame)
153+ r := New(Capabilities{})
154+ initial, err := r.Prepare(frame, []Damage{FullRedraw()}, false)
155+ if err != nil {
156+ b.Fatal(err)
157+ }
158+ initial.Commit()
159+
160+ b.ReportAllocs()
161+ for b.Loop() {
162+ prepared, err := r.Prepare(frame, nil, false)
163+ if err != nil {
164+ b.Fatal(err)
165+ }
166+ if len(prepared.Bytes()) != 0 {
167+ b.Fatal("expected no output")
168+ }
169+ prepared.Commit()
170+ }
171+}
172+
173+func BenchmarkRendererBroadRegularDamage(b *testing.B) {
174+ const (
175+ width = 120
176+ height = 40
177+ )
178+ baseline := NewFrame(width, height)
179+ markBenchmarkFrame(&baseline)
180+ frame := baseline.Clone()
181+ for y := range height {
182+ frame.Set(0, y, Cell{Rune: 'z', Style: DefaultStyle()})
183+ }
184+ damage := make([]Damage, height)
185+ for y := range height {
186+ damage[y] = Damage{Kind: DamageText, X: 0, Y: y, Width: width, Height: 1}
187+ }
188+
189+ r := New(Capabilities{})
190+ b.ReportAllocs()
191+ var outBytes int64
192+ for b.Loop() {
193+ setBenchmarkShadow(r, baseline)
67 out, err := r.Draw(frame, damage)194 out, err := r.Draw(frame, damage)
68 if err != nil {195 if err != nil {
69 b.Fatal(err)196 b.Fatal(err)
@@ -100,7 +227,7 @@ func BenchmarkRendererScrollFastPath(b *testing.B) {
100 b.ReportAllocs()227 b.ReportAllocs()
101 var outBytes int64228 var outBytes int64
102 for b.Loop() {229 for b.Loop() {
103- r.replaceShadow(frame)230+ setBenchmarkShadow(r, frame)
104 out, err := r.Draw(scrolled, damage)231 out, err := r.Draw(scrolled, damage)
105 if err != nil {232 if err != nil {
106 b.Fatal(err)233 b.Fatal(err)
M pkg/renderer/damage_plan.go +14 -15
@@ -7,15 +7,11 @@ import (
77
88 const maxPlannedDamageSpans = 4096
99
10-type damageSpan struct {
11- y, x, width int
12-}
13-
1410 // buildDamagePlan creates the bounded, canonical view of text and clear damage
1511 // used for terminal emission. The source damage remains untouched because the
1612 // scroll and shadow paths need its original structural information.
17-func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan, bool) {
18- spans := make([]damageSpan, 0)
13+func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool) {
14+ spans := make([]Span, 0)
1915 for _, d := range damage {
2016 if skip != nil && sameDamage(d, *skip) {
2117 continue
@@ -31,21 +27,24 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan,
3127 if len(spans) == maxPlannedDamageSpans {
3228 return nil, true
3329 }
34- spans = append(spans, damageSpan{y: row, x: x, width: width})
30+ spans = append(spans, Span{Y: row, X: x, Width: width})
3531 }
3632 }
33+ if len(spans) == 0 {
34+ return nil, false
35+ }
3736
38- slices.SortFunc(spans, func(a, b damageSpan) int {
39- if c := cmp.Compare(a.y, b.y); c != 0 {
37+ slices.SortFunc(spans, func(a, b Span) int {
38+ if c := cmp.Compare(a.Y, b.Y); c != 0 {
4039 return c
4140 }
42- return cmp.Compare(a.x, b.x)
41+ return cmp.Compare(a.X, b.X)
4342 })
4443
4544 return mergeDamageSpans(spans), false
4645 }
4746
48-func mergeDamageSpans(spans []damageSpan) []damageSpan {
47+func mergeDamageSpans(spans []Span) []Span {
4948 if len(spans) < 2 {
5049 return spans
5150 }
@@ -59,14 +58,14 @@ func mergeDamageSpans(spans []damageSpan) []damageSpan {
5958 last := &out[len(out)-1]
6059 // Both endpoints are bounded by frame.Width after clampRect, so these
6160 // additions cannot overflow. The comparison includes adjacency.
62- lastEnd := last.x + last.width
63- spanEnd := span.x + span.width
64- if span.y != last.y || span.x > lastEnd {
61+ lastEnd := last.X + last.Width
62+ spanEnd := span.X + span.Width
63+ if span.Y != last.Y || span.X > lastEnd {
6564 out = append(out, span)
6665 continue
6766 }
6867 if spanEnd > lastEnd {
69- last.width = spanEnd - last.x
68+ last.Width = spanEnd - last.X
7069 }
7170 }
7271 return out
@@ -7,15 +7,11 @@ import (
7 7
8 const maxPlannedDamageSpans = 40968 const maxPlannedDamageSpans = 4096
9 9
10-type damageSpan struct {
11- y, x, width int
12-}
13-
14 // buildDamagePlan creates the bounded, canonical view of text and clear damage10 // buildDamagePlan creates the bounded, canonical view of text and clear damage
15 // used for terminal emission. The source damage remains untouched because the11 // used for terminal emission. The source damage remains untouched because the
16 // scroll and shadow paths need its original structural information.12 // scroll and shadow paths need its original structural information.
17-func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan, bool) {13+func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool) {
18- spans := make([]damageSpan, 0)14+ spans := make([]Span, 0)
19 for _, d := range damage {15 for _, d := range damage {
20 if skip != nil && sameDamage(d, *skip) {16 if skip != nil && sameDamage(d, *skip) {
21 continue17 continue
@@ -31,21 +27,24 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan,
31 if len(spans) == maxPlannedDamageSpans {27 if len(spans) == maxPlannedDamageSpans {
32 return nil, true28 return nil, true
33 }29 }
34- spans = append(spans, damageSpan{y: row, x: x, width: width})30+ spans = append(spans, Span{Y: row, X: x, Width: width})
35 }31 }
36 }32 }
33+ if len(spans) == 0 {
34+ return nil, false
35+ }
37 36
38- slices.SortFunc(spans, func(a, b damageSpan) int {37+ slices.SortFunc(spans, func(a, b Span) int {
39- if c := cmp.Compare(a.y, b.y); c != 0 {38+ if c := cmp.Compare(a.Y, b.Y); c != 0 {
40 return c39 return c
41 }40 }
42- return cmp.Compare(a.x, b.x)41+ return cmp.Compare(a.X, b.X)
43 })42 })
44 43
45 return mergeDamageSpans(spans), false44 return mergeDamageSpans(spans), false
46 }45 }
47 46
48-func mergeDamageSpans(spans []damageSpan) []damageSpan {47+func mergeDamageSpans(spans []Span) []Span {
49 if len(spans) < 2 {48 if len(spans) < 2 {
50 return spans49 return spans
51 }50 }
@@ -59,14 +58,14 @@ func mergeDamageSpans(spans []damageSpan) []damageSpan {
59 last := &out[len(out)-1]58 last := &out[len(out)-1]
60 // Both endpoints are bounded by frame.Width after clampRect, so these59 // Both endpoints are bounded by frame.Width after clampRect, so these
61 // additions cannot overflow. The comparison includes adjacency.60 // additions cannot overflow. The comparison includes adjacency.
62- lastEnd := last.x + last.width61+ lastEnd := last.X + last.Width
63- spanEnd := span.x + span.width62+ spanEnd := span.X + span.Width
64- if span.y != last.y || span.x > lastEnd {63+ if span.Y != last.Y || span.X > lastEnd {
65 out = append(out, span)64 out = append(out, span)
66 continue65 continue
67 }66 }
68 if spanEnd > lastEnd {67 if spanEnd > lastEnd {
69- last.width = spanEnd - last.x68+ last.Width = spanEnd - last.X
70 }69 }
71 }70 }
72 return out71 return out
M pkg/renderer/damage_plan_test.go +28 -21
@@ -11,7 +11,8 @@ func TestBuildDamagePlan(t *testing.T) {
1111 name string
1212 frame Frame
1313 damage []Damage
14- want []damageSpan
14+ skip *Damage
15+ want []Span
1516 full bool
1617 }{
1718 {
@@ -21,7 +22,7 @@ func TestBuildDamagePlan(t *testing.T) {
2122 {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1},
2223 {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
2324 },
24- want: []damageSpan{{y: 0, x: 0, width: 9}},
25+ want: []Span{{Y: 0, X: 0, Width: 9}},
2526 },
2627 {
2728 name: "merges text and clear",
@@ -30,7 +31,7 @@ func TestBuildDamagePlan(t *testing.T) {
3031 {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
3132 {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
3233 },
33- want: []damageSpan{{y: 0, x: 0, width: 9}},
34+ want: []Span{{Y: 0, X: 0, Width: 9}},
3435 },
3536 {
3637 name: "orders multi-row spans",
@@ -41,10 +42,10 @@ func TestBuildDamagePlan(t *testing.T) {
4142 {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2},
4243 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1},
4344 },
44- want: []damageSpan{
45- {y: 0, x: 1, width: 2}, {y: 0, x: 4, width: 2},
46- {y: 1, x: 1, width: 2}, {y: 1, x: 4, width: 2},
47- {y: 2, x: 5, width: 2},
45+ want: []Span{
46+ {Y: 0, X: 1, Width: 2}, {Y: 0, X: 4, Width: 2},
47+ {Y: 1, X: 1, Width: 2}, {Y: 1, X: 4, Width: 2},
48+ {Y: 2, X: 5, Width: 2},
4849 },
4950 },
5051 {
@@ -54,7 +55,7 @@ func TestBuildDamagePlan(t *testing.T) {
5455 {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3},
5556 {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3},
5657 },
57- want: []damageSpan{{y: 0, x: 0, width: 3}, {y: 1, x: 0, width: 3}, {y: 1, x: 8, width: 2}, {y: 2, x: 8, width: 2}},
58+ want: []Span{{Y: 0, X: 0, Width: 3}, {Y: 1, X: 0, Width: 3}, {Y: 1, X: 8, Width: 2}, {Y: 2, X: 8, Width: 2}},
5859 },
5960 {
6061 name: "does not mutate input",
@@ -63,7 +64,7 @@ func TestBuildDamagePlan(t *testing.T) {
6364 {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1},
6465 {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
6566 },
66- want: []damageSpan{{y: 0, x: 1, width: 2}, {y: 1, x: 5, width: 2}},
67+ want: []Span{{Y: 0, X: 1, Width: 2}, {Y: 1, X: 5, Width: 2}},
6768 },
6869 {
6970 name: "exceeding budget requests full redraw",
@@ -72,12 +73,29 @@ func TestBuildDamagePlan(t *testing.T) {
7273 want: nil,
7374 full: true,
7475 },
76+ {
77+ name: "exceeding budget across multiple damages requests full redraw",
78+ frame: NewFrame(1, maxPlannedDamageSpans+1),
79+ damage: []Damage{
80+ {Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans},
81+ {Kind: DamageText, X: 0, Y: maxPlannedDamageSpans, Width: 1, Height: 1},
82+ },
83+ want: nil,
84+ full: true,
85+ },
86+ {
87+ name: "skips the matching damage",
88+ frame: NewFrame(10, 1),
89+ damage: []Damage{{Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1}},
90+ skip: &Damage{Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
91+ want: nil,
92+ },
7593 }
7694
7795 for _, tt := range tests {
7896 t.Run(tt.name, func(t *testing.T) {
7997 original := append([]Damage(nil), tt.damage...)
80- got, full := buildDamagePlan(tt.frame, tt.damage, nil)
98+ got, full := buildDamagePlan(tt.frame, tt.damage, tt.skip)
8199 if full != tt.full || !reflect.DeepEqual(got, tt.want) {
82100 t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full)
83101 }
@@ -97,14 +115,3 @@ func TestClampRectRejectsOverflowingBounds(t *testing.T) {
97115 t.Fatal("clampRect accepted overflowing y bound")
98116 }
99117 }
100-
101-func TestSyncRectRejectsOverflowingBounds(t *testing.T) {
102- r := New(Capabilities{})
103- frame := NewFrame(2, 1)
104- r.replaceShadow(frame)
105- before := append([]Cell(nil), r.shadow...)
106- r.syncRect(frame, math.MaxInt-1, 0, 4, 1)
107- if !reflect.DeepEqual(r.shadow, before) {
108- t.Fatal("syncRect changed shadow for overflowing bounds")
109- }
110-}
@@ -11,7 +11,8 @@ func TestBuildDamagePlan(t *testing.T) {
11 name string11 name string
12 frame Frame12 frame Frame
13 damage []Damage13 damage []Damage
14- want []damageSpan14+ skip *Damage
15+ want []Span
15 full bool16 full bool
16 }{17 }{
17 {18 {
@@ -21,7 +22,7 @@ func TestBuildDamagePlan(t *testing.T) {
21 {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1},22 {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1},
22 {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},23 {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
23 },24 },
24- want: []damageSpan{{y: 0, x: 0, width: 9}},25+ want: []Span{{Y: 0, X: 0, Width: 9}},
25 },26 },
26 {27 {
27 name: "merges text and clear",28 name: "merges text and clear",
@@ -30,7 +31,7 @@ func TestBuildDamagePlan(t *testing.T) {
30 {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},31 {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
31 {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},32 {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
32 },33 },
33- want: []damageSpan{{y: 0, x: 0, width: 9}},34+ want: []Span{{Y: 0, X: 0, Width: 9}},
34 },35 },
35 {36 {
36 name: "orders multi-row spans",37 name: "orders multi-row spans",
@@ -41,10 +42,10 @@ func TestBuildDamagePlan(t *testing.T) {
41 {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2},42 {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2},
42 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1},43 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1},
43 },44 },
44- want: []damageSpan{45+ want: []Span{
45- {y: 0, x: 1, width: 2}, {y: 0, x: 4, width: 2},46+ {Y: 0, X: 1, Width: 2}, {Y: 0, X: 4, Width: 2},
46- {y: 1, x: 1, width: 2}, {y: 1, x: 4, width: 2},47+ {Y: 1, X: 1, Width: 2}, {Y: 1, X: 4, Width: 2},
47- {y: 2, x: 5, width: 2},48+ {Y: 2, X: 5, Width: 2},
48 },49 },
49 },50 },
50 {51 {
@@ -54,7 +55,7 @@ func TestBuildDamagePlan(t *testing.T) {
54 {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3},55 {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3},
55 {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3},56 {Kind: DamageClear, X: 8, Y: 1, Width: 5, Height: 3},
56 },57 },
57- want: []damageSpan{{y: 0, x: 0, width: 3}, {y: 1, x: 0, width: 3}, {y: 1, x: 8, width: 2}, {y: 2, x: 8, width: 2}},58+ want: []Span{{Y: 0, X: 0, Width: 3}, {Y: 1, X: 0, Width: 3}, {Y: 1, X: 8, Width: 2}, {Y: 2, X: 8, Width: 2}},
58 },59 },
59 {60 {
60 name: "does not mutate input",61 name: "does not mutate input",
@@ -63,7 +64,7 @@ func TestBuildDamagePlan(t *testing.T) {
63 {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1},64 {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1},
64 {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},65 {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
65 },66 },
66- want: []damageSpan{{y: 0, x: 1, width: 2}, {y: 1, x: 5, width: 2}},67+ want: []Span{{Y: 0, X: 1, Width: 2}, {Y: 1, X: 5, Width: 2}},
67 },68 },
68 {69 {
69 name: "exceeding budget requests full redraw",70 name: "exceeding budget requests full redraw",
@@ -72,12 +73,29 @@ func TestBuildDamagePlan(t *testing.T) {
72 want: nil,73 want: nil,
73 full: true,74 full: true,
74 },75 },
76+ {
77+ name: "exceeding budget across multiple damages requests full redraw",
78+ frame: NewFrame(1, maxPlannedDamageSpans+1),
79+ damage: []Damage{
80+ {Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans},
81+ {Kind: DamageText, X: 0, Y: maxPlannedDamageSpans, Width: 1, Height: 1},
82+ },
83+ want: nil,
84+ full: true,
85+ },
86+ {
87+ name: "skips the matching damage",
88+ frame: NewFrame(10, 1),
89+ damage: []Damage{{Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1}},
90+ skip: &Damage{Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1},
91+ want: nil,
92+ },
75 }93 }
76 94
77 for _, tt := range tests {95 for _, tt := range tests {
78 t.Run(tt.name, func(t *testing.T) {96 t.Run(tt.name, func(t *testing.T) {
79 original := append([]Damage(nil), tt.damage...)97 original := append([]Damage(nil), tt.damage...)
80- got, full := buildDamagePlan(tt.frame, tt.damage, nil)98+ got, full := buildDamagePlan(tt.frame, tt.damage, tt.skip)
81 if full != tt.full || !reflect.DeepEqual(got, tt.want) {99 if full != tt.full || !reflect.DeepEqual(got, tt.want) {
82 t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full)100 t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full)
83 }101 }
@@ -97,14 +115,3 @@ func TestClampRectRejectsOverflowingBounds(t *testing.T) {
97 t.Fatal("clampRect accepted overflowing y bound")115 t.Fatal("clampRect accepted overflowing y bound")
98 }116 }
99 }117 }
100-
101-func TestSyncRectRejectsOverflowingBounds(t *testing.T) {
102- r := New(Capabilities{})
103- frame := NewFrame(2, 1)
104- r.replaceShadow(frame)
105- before := append([]Cell(nil), r.shadow...)
106- r.syncRect(frame, math.MaxInt-1, 0, 4, 1)
107- if !reflect.DeepEqual(r.shadow, before) {
108- t.Fatal("syncRect changed shadow for overflowing bounds")
109- }
110-}
A pkg/renderer/delta_plan.go +241 -0
@@ -0,0 +1,241 @@
1+package renderer
2+
3+// Span identifies a changed horizontal range in a logical frame row.
4+type Span struct {
5+ Y int
6+ X int
7+ Width int
8+}
9+
10+// Scroll identifies a safe full-width upward scroll.
11+type Scroll struct {
12+ Y int
13+ Height int
14+ Count int
15+}
16+
17+// DeltaPlan describes how to advance a committed frame to the candidate frame.
18+// Snapshot requests replacement of the committed frame; Spans, when present,
19+// are the canonical ranges to encode before that replacement.
20+type DeltaPlan struct {
21+ Snapshot bool
22+ Scroll Scroll
23+ Spans []Span
24+}
25+
26+// DeltaCandidate holds a plan and borrows its source frame until Commit.
27+type DeltaCandidate struct {
28+ Plan DeltaPlan
29+ frame Frame
30+}
31+
32+// PlanDelta prepares a non-mutating update from committed to frame.
33+func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {
34+ if err := frame.Validate(); err != nil {
35+ return DeltaCandidate{}, err
36+ }
37+
38+ candidate := DeltaCandidate{frame: frame}
39+ if reset || frame.Width != committed.Width || frame.Height != committed.Height {
40+ candidate.Plan.Snapshot = true
41+ return candidate, nil
42+ }
43+ if err := committed.Validate(); err != nil {
44+ return DeltaCandidate{}, err
45+ }
46+
47+ if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
48+ candidate.Plan = planSingleDamage(frame, damage[0])
49+ return candidate, nil
50+ }
51+
52+ if needsFull(damage) {
53+ dirty, full := buildDirtyLinePlan(frame, committed, nil)
54+ if len(dirty) > 0 || full {
55+ candidate.Plan.Snapshot = true
56+ candidate.Plan.Spans = dirty
57+ }
58+ return candidate, nil
59+ }
60+
61+ var skip *Damage
62+ if scroll, ok := findSafeScroll(frame, damage); ok && canApplyScrollAgainst(frame, scroll, damage, committed) {
63+ candidate.Plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count}
64+ skip = &scroll
65+ } else if hasScrollDamage(damage) {
66+ candidate.Plan.Snapshot = true
67+ return candidate, nil
68+ }
69+
70+ var spans []Span
71+ var full bool
72+ if len(damage) == 0 {
73+ spans, full = buildDirtyLinePlan(frame, committed, nil)
74+ } else {
75+ spans, full = buildDamagePlan(frame, damage, skip)
76+ }
77+ if full {
78+ candidate.Plan = DeltaPlan{Snapshot: true}
79+ return candidate, nil
80+ }
81+ if deltaCostsSnapshot(frame, spans, candidate.Plan.Scroll.Height != 0) {
82+ if candidate.Plan.Scroll.Height != 0 {
83+ candidate.Plan = DeltaPlan{Snapshot: true}
84+ } else {
85+ dirty, full := buildDirtyLinePlan(frame, committed, spans[:0])
86+ if full || len(dirty) == 0 {
87+ candidate.Plan = DeltaPlan{Snapshot: true}
88+ } else {
89+ candidate.Plan.Snapshot = true
90+ candidate.Plan.Spans = dirty
91+ }
92+ }
93+ return candidate, nil
94+ }
95+ candidate.Plan.Spans = spans
96+ return candidate, nil
97+}
98+
99+func planSingleDamage(frame Frame, d Damage) DeltaPlan {
100+ x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
101+ if !ok {
102+ return DeltaPlan{}
103+ }
104+ if height > maxPlannedDamageSpans {
105+ return DeltaPlan{Snapshot: true}
106+ }
107+ spans := make([]Span, height)
108+ for row := range height {
109+ spans[row] = Span{Y: y + row, X: x, Width: width}
110+ }
111+ plan := DeltaPlan{Spans: spans}
112+ if (frame.Height == 1 || len(spans) > 1) && deltaCostsSnapshot(frame, spans, false) {
113+ return DeltaPlan{Snapshot: true}
114+ }
115+ return plan
116+}
117+
118+func buildDirtyLinePlan(frame, committed Frame, spans []Span) ([]Span, bool) {
119+ spans = spans[:0]
120+ for y := range frame.Height {
121+ frameRow := frame.Row(y)
122+ committedRow := committed.Row(y)
123+ dirty := false
124+ for x := range frame.Width {
125+ if !frameRow[x].Equal(committedRow[x]) {
126+ dirty = true
127+ break
128+ }
129+ }
130+ if dirty {
131+ if len(spans) == maxPlannedDamageSpans {
132+ return nil, true
133+ }
134+ spans = append(spans, Span{Y: y, Width: frame.Width})
135+ }
136+ }
137+ return spans, false
138+}
139+
140+func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool {
141+ if !hasScroll && frame.Height > 1 && len(spans) == 1 {
142+ return false
143+ }
144+
145+ var deltaCost int64
146+ if hasScroll {
147+ deltaCost = 6
148+ }
149+ for _, span := range spans {
150+ deltaCost += 12 + int64(span.Width)
151+ }
152+
153+ minimumSnapshotCost := int64(frame.Height) * (12 + int64(frame.Width))
154+ if deltaCost < minimumSnapshotCost {
155+ return false
156+ }
157+ maximumSnapshotCost := int64(frame.Height) * (8 + 5*int64(frame.Width))
158+ if deltaCost >= maximumSnapshotCost {
159+ return true
160+ }
161+ if !hasScroll && fullWidthRows(frame, spans) {
162+ return true
163+ }
164+
165+ for _, span := range spans {
166+ deltaCost += 4 * int64(styleRunCount(frame.Row(span.Y)[span.X:span.X+span.Width])-1)
167+ }
168+ var snapshotCost int64
169+ for y := range frame.Height {
170+ snapshotCost += spanCost(frame, y, 0, frame.Width)
171+ }
172+ return deltaCost >= snapshotCost
173+}
174+
175+func fullWidthRows(frame Frame, spans []Span) bool {
176+ if len(spans) != frame.Height {
177+ return false
178+ }
179+ for y, span := range spans {
180+ if span.Y != y || span.X != 0 || span.Width != frame.Width {
181+ return false
182+ }
183+ }
184+ return true
185+}
186+
187+func spanCost(frame Frame, y, x, width int) int64 {
188+ return 8 + int64(width) + 4*int64(styleRunCount(frame.Row(y)[x:x+width]))
189+}
190+
191+func styleRunCount(cells []Cell) int {
192+ if len(cells) == 0 {
193+ return 0
194+ }
195+ runs := 1
196+ for i := 1; i < len(cells); i++ {
197+ if cells[i-1].Style != cells[i].Style && !cells[i-1].Style.Equal(cells[i].Style) {
198+ runs++
199+ }
200+ }
201+ return runs
202+}
203+
204+// Commit advances dst to the frame borrowed by the candidate.
205+func (c DeltaCandidate) Commit(dst *Frame) {
206+ if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height {
207+ replaceFrame(dst, c.frame)
208+ return
209+ }
210+ if c.Plan.Scroll.Height != 0 {
211+ scroll := c.Plan.Scroll
212+ dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)
213+ }
214+ for _, span := range c.Plan.Spans {
215+ dstStart := dst.lineOffset[span.Y] + span.X
216+ srcStart := c.frame.lineOffset[span.Y] + span.X
217+ copy(dst.Cells[dstStart:dstStart+span.Width], c.frame.Cells[srcStart:srcStart+span.Width])
218+ }
219+}
220+
221+func replaceFrame(dst *Frame, src Frame) {
222+ sameDimensions := dst.Width == src.Width && dst.Height == src.Height
223+ cellCount := src.Width * src.Height
224+ if sameDimensions && cap(dst.Cells) >= cellCount {
225+ dst.Cells = dst.Cells[:cellCount]
226+ } else {
227+ dst.Cells = make([]Cell, cellCount)
228+ }
229+ if sameDimensions && cap(dst.lineOffset) >= src.Height {
230+ dst.lineOffset = dst.lineOffset[:src.Height]
231+ } else {
232+ dst.lineOffset = make([]int, src.Height)
233+ }
234+ dst.Width = src.Width
235+ dst.Height = src.Height
236+ for y := range src.Height {
237+ start := y * src.Width
238+ copy(dst.Cells[start:start+src.Width], src.Row(y))
239+ dst.lineOffset[y] = start
240+ }
241+}
@@ -0,0 +1,241 @@
1+package renderer
2+
3+// Span identifies a changed horizontal range in a logical frame row.
4+type Span struct {
5+ Y int
6+ X int
7+ Width int
8+}
9+
10+// Scroll identifies a safe full-width upward scroll.
11+type Scroll struct {
12+ Y int
13+ Height int
14+ Count int
15+}
16+
17+// DeltaPlan describes how to advance a committed frame to the candidate frame.
18+// Snapshot requests replacement of the committed frame; Spans, when present,
19+// are the canonical ranges to encode before that replacement.
20+type DeltaPlan struct {
21+ Snapshot bool
22+ Scroll Scroll
23+ Spans []Span
24+}
25+
26+// DeltaCandidate holds a plan and borrows its source frame until Commit.
27+type DeltaCandidate struct {
28+ Plan DeltaPlan
29+ frame Frame
30+}
31+
32+// PlanDelta prepares a non-mutating update from committed to frame.
33+func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {
34+ if err := frame.Validate(); err != nil {
35+ return DeltaCandidate{}, err
36+ }
37+
38+ candidate := DeltaCandidate{frame: frame}
39+ if reset || frame.Width != committed.Width || frame.Height != committed.Height {
40+ candidate.Plan.Snapshot = true
41+ return candidate, nil
42+ }
43+ if err := committed.Validate(); err != nil {
44+ return DeltaCandidate{}, err
45+ }
46+
47+ if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
48+ candidate.Plan = planSingleDamage(frame, damage[0])
49+ return candidate, nil
50+ }
51+
52+ if needsFull(damage) {
53+ dirty, full := buildDirtyLinePlan(frame, committed, nil)
54+ if len(dirty) > 0 || full {
55+ candidate.Plan.Snapshot = true
56+ candidate.Plan.Spans = dirty
57+ }
58+ return candidate, nil
59+ }
60+
61+ var skip *Damage
62+ if scroll, ok := findSafeScroll(frame, damage); ok && canApplyScrollAgainst(frame, scroll, damage, committed) {
63+ candidate.Plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count}
64+ skip = &scroll
65+ } else if hasScrollDamage(damage) {
66+ candidate.Plan.Snapshot = true
67+ return candidate, nil
68+ }
69+
70+ var spans []Span
71+ var full bool
72+ if len(damage) == 0 {
73+ spans, full = buildDirtyLinePlan(frame, committed, nil)
74+ } else {
75+ spans, full = buildDamagePlan(frame, damage, skip)
76+ }
77+ if full {
78+ candidate.Plan = DeltaPlan{Snapshot: true}
79+ return candidate, nil
80+ }
81+ if deltaCostsSnapshot(frame, spans, candidate.Plan.Scroll.Height != 0) {
82+ if candidate.Plan.Scroll.Height != 0 {
83+ candidate.Plan = DeltaPlan{Snapshot: true}
84+ } else {
85+ dirty, full := buildDirtyLinePlan(frame, committed, spans[:0])
86+ if full || len(dirty) == 0 {
87+ candidate.Plan = DeltaPlan{Snapshot: true}
88+ } else {
89+ candidate.Plan.Snapshot = true
90+ candidate.Plan.Spans = dirty
91+ }
92+ }
93+ return candidate, nil
94+ }
95+ candidate.Plan.Spans = spans
96+ return candidate, nil
97+}
98+
99+func planSingleDamage(frame Frame, d Damage) DeltaPlan {
100+ x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height)
101+ if !ok {
102+ return DeltaPlan{}
103+ }
104+ if height > maxPlannedDamageSpans {
105+ return DeltaPlan{Snapshot: true}
106+ }
107+ spans := make([]Span, height)
108+ for row := range height {
109+ spans[row] = Span{Y: y + row, X: x, Width: width}
110+ }
111+ plan := DeltaPlan{Spans: spans}
112+ if (frame.Height == 1 || len(spans) > 1) && deltaCostsSnapshot(frame, spans, false) {
113+ return DeltaPlan{Snapshot: true}
114+ }
115+ return plan
116+}
117+
118+func buildDirtyLinePlan(frame, committed Frame, spans []Span) ([]Span, bool) {
119+ spans = spans[:0]
120+ for y := range frame.Height {
121+ frameRow := frame.Row(y)
122+ committedRow := committed.Row(y)
123+ dirty := false
124+ for x := range frame.Width {
125+ if !frameRow[x].Equal(committedRow[x]) {
126+ dirty = true
127+ break
128+ }
129+ }
130+ if dirty {
131+ if len(spans) == maxPlannedDamageSpans {
132+ return nil, true
133+ }
134+ spans = append(spans, Span{Y: y, Width: frame.Width})
135+ }
136+ }
137+ return spans, false
138+}
139+
140+func deltaCostsSnapshot(frame Frame, spans []Span, hasScroll bool) bool {
141+ if !hasScroll && frame.Height > 1 && len(spans) == 1 {
142+ return false
143+ }
144+
145+ var deltaCost int64
146+ if hasScroll {
147+ deltaCost = 6
148+ }
149+ for _, span := range spans {
150+ deltaCost += 12 + int64(span.Width)
151+ }
152+
153+ minimumSnapshotCost := int64(frame.Height) * (12 + int64(frame.Width))
154+ if deltaCost < minimumSnapshotCost {
155+ return false
156+ }
157+ maximumSnapshotCost := int64(frame.Height) * (8 + 5*int64(frame.Width))
158+ if deltaCost >= maximumSnapshotCost {
159+ return true
160+ }
161+ if !hasScroll && fullWidthRows(frame, spans) {
162+ return true
163+ }
164+
165+ for _, span := range spans {
166+ deltaCost += 4 * int64(styleRunCount(frame.Row(span.Y)[span.X:span.X+span.Width])-1)
167+ }
168+ var snapshotCost int64
169+ for y := range frame.Height {
170+ snapshotCost += spanCost(frame, y, 0, frame.Width)
171+ }
172+ return deltaCost >= snapshotCost
173+}
174+
175+func fullWidthRows(frame Frame, spans []Span) bool {
176+ if len(spans) != frame.Height {
177+ return false
178+ }
179+ for y, span := range spans {
180+ if span.Y != y || span.X != 0 || span.Width != frame.Width {
181+ return false
182+ }
183+ }
184+ return true
185+}
186+
187+func spanCost(frame Frame, y, x, width int) int64 {
188+ return 8 + int64(width) + 4*int64(styleRunCount(frame.Row(y)[x:x+width]))
189+}
190+
191+func styleRunCount(cells []Cell) int {
192+ if len(cells) == 0 {
193+ return 0
194+ }
195+ runs := 1
196+ for i := 1; i < len(cells); i++ {
197+ if cells[i-1].Style != cells[i].Style && !cells[i-1].Style.Equal(cells[i].Style) {
198+ runs++
199+ }
200+ }
201+ return runs
202+}
203+
204+// Commit advances dst to the frame borrowed by the candidate.
205+func (c DeltaCandidate) Commit(dst *Frame) {
206+ if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height {
207+ replaceFrame(dst, c.frame)
208+ return
209+ }
210+ if c.Plan.Scroll.Height != 0 {
211+ scroll := c.Plan.Scroll
212+ dst.ScrollUp(scroll.Y, scroll.Y+scroll.Height-1, scroll.Count)
213+ }
214+ for _, span := range c.Plan.Spans {
215+ dstStart := dst.lineOffset[span.Y] + span.X
216+ srcStart := c.frame.lineOffset[span.Y] + span.X
217+ copy(dst.Cells[dstStart:dstStart+span.Width], c.frame.Cells[srcStart:srcStart+span.Width])
218+ }
219+}
220+
221+func replaceFrame(dst *Frame, src Frame) {
222+ sameDimensions := dst.Width == src.Width && dst.Height == src.Height
223+ cellCount := src.Width * src.Height
224+ if sameDimensions && cap(dst.Cells) >= cellCount {
225+ dst.Cells = dst.Cells[:cellCount]
226+ } else {
227+ dst.Cells = make([]Cell, cellCount)
228+ }
229+ if sameDimensions && cap(dst.lineOffset) >= src.Height {
230+ dst.lineOffset = dst.lineOffset[:src.Height]
231+ } else {
232+ dst.lineOffset = make([]int, src.Height)
233+ }
234+ dst.Width = src.Width
235+ dst.Height = src.Height
236+ for y := range src.Height {
237+ start := y * src.Width
238+ copy(dst.Cells[start:start+src.Width], src.Row(y))
239+ dst.lineOffset[y] = start
240+ }
241+}
A pkg/renderer/delta_plan_test.go +271 -0
@@ -0,0 +1,271 @@
1+package renderer
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func testFrame(rows ...string) Frame {
9+ frame := NewFrame(len([]rune(rows[0])), len(rows))
10+ fillFrame(frame, rows)
11+ return frame
12+}
13+
14+func TestPlanDelta(t *testing.T) {
15+ scrollCommitted := testFrame("0000", "1111", "2222", "3333", "4444", "5555", "6666", "7777")
16+ scrolled := testFrame("0000", "2222", "3333", "4444", "5555", "6666", "new!", "7777")
17+ largeFrame := NewFrame(1, maxPlannedDamageSpans+1)
18+ styledCommitted := NewFrame(10, 1)
19+ styledCommitted.Set(3, 0, Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}})
20+ styledFrame := styledCommitted.Clone()
21+ styledFrame.Set(0, 0, Cell{Rune: 'a', Style: DefaultStyle()})
22+ styledFrame.Set(9, 0, Cell{Rune: 'z', Style: DefaultStyle()})
23+
24+ tests := []struct {
25+ name string
26+ frame Frame
27+ damage []Damage
28+ committed Frame
29+ reset bool
30+ want DeltaPlan
31+ }{
32+ {
33+ name: "reset snapshots",
34+ frame: NewFrame(4, 2),
35+ committed: NewFrame(4, 2),
36+ reset: true,
37+ want: DeltaPlan{Snapshot: true},
38+ },
39+ {
40+ name: "dimension mismatch snapshots",
41+ frame: NewFrame(4, 2),
42+ committed: NewFrame(3, 2),
43+ want: DeltaPlan{Snapshot: true},
44+ },
45+ {
46+ name: "unchanged same size has no spans",
47+ frame: NewFrame(4, 2),
48+ committed: NewFrame(4, 2),
49+ want: DeltaPlan{},
50+ },
51+ {
52+ name: "unchanged full redraw has no spans",
53+ frame: NewFrame(4, 2),
54+ damage: []Damage{FullRedraw()},
55+ committed: NewFrame(4, 2),
56+ want: DeltaPlan{},
57+ },
58+ {
59+ name: "changed row without damage becomes a full width span",
60+ frame: testFrame(" ", "edit", " "),
61+ committed: NewFrame(4, 3),
62+ want: DeltaPlan{Spans: []Span{{Y: 1, X: 0, Width: 4}}},
63+ },
64+ {
65+ name: "adjacent and overlapping damage is ordered and merged",
66+ frame: testFrame(" ", "aaaaaaaaa ", " "),
67+ damage: []Damage{
68+ {Kind: DamageText, X: 4, Y: 1, Width: 5, Height: 1},
69+ {Kind: DamageClear, X: 0, Y: 1, Width: 5, Height: 1},
70+ {Kind: DamageText, X: 2, Y: 1, Width: 2, Height: 1},
71+ },
72+ committed: NewFrame(10, 3),
73+ want: DeltaPlan{Spans: []Span{{Y: 1, X: 0, Width: 9}}},
74+ },
75+ {
76+ name: "safe full width scroll retains exposed span",
77+ frame: scrolled,
78+ damage: []Damage{
79+ {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 6, Count: 1},
80+ {Kind: DamageText, X: 0, Y: 6, Width: 4, Height: 1},
81+ },
82+ committed: scrollCommitted,
83+ want: DeltaPlan{
84+ Scroll: Scroll{Y: 1, Height: 6, Count: 1},
85+ Spans: []Span{{Y: 6, X: 0, Width: 4}},
86+ },
87+ },
88+ {
89+ name: "rectangular scroll snapshots",
90+ frame: scrolled,
91+ damage: []Damage{
92+ {Kind: DamageScrollUp, X: 1, Y: 1, Width: 3, Height: 6, Count: 1},
93+ },
94+ committed: scrollCommitted,
95+ want: DeltaPlan{Snapshot: true},
96+ },
97+ {
98+ name: "unsafe scroll content snapshots",
99+ frame: testFrame("0000", "2222", "xxxx", "4444", "5555", "6666", "new!", "7777"),
100+ damage: []Damage{
101+ {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 6, Count: 1},
102+ {Kind: DamageText, X: 0, Y: 6, Width: 4, Height: 1},
103+ },
104+ committed: scrollCommitted,
105+ want: DeltaPlan{Snapshot: true},
106+ },
107+ {
108+ name: "more than 4096 canonical spans snapshots",
109+ frame: largeFrame,
110+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}},
111+ committed: NewFrame(1, maxPlannedDamageSpans+1),
112+ want: DeltaPlan{Snapshot: true},
113+ },
114+ {
115+ name: "delta cost equal to snapshot cost snapshots",
116+ frame: testFrame("changed!"),
117+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 8, Height: 1}},
118+ committed: NewFrame(8, 1),
119+ want: DeltaPlan{Snapshot: true},
120+ },
121+ {
122+ name: "single broad damage snapshots completely",
123+ frame: testFrame("aaaa", "bbbb"),
124+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}},
125+ committed: NewFrame(4, 2),
126+ want: DeltaPlan{Snapshot: true},
127+ },
128+ {
129+ name: "snapshot cost includes style runs",
130+ frame: styledFrame,
131+ damage: []Damage{
132+ {Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1},
133+ {Kind: DamageText, X: 9, Y: 0, Width: 1, Height: 1},
134+ },
135+ committed: styledCommitted,
136+ want: DeltaPlan{Spans: []Span{{Y: 0, X: 0, Width: 1}, {Y: 0, X: 9, Width: 1}}},
137+ },
138+ }
139+
140+ for _, tt := range tests {
141+ t.Run(tt.name, func(t *testing.T) {
142+ candidate, err := PlanDelta(tt.frame, tt.damage, tt.committed, tt.reset)
143+ if err != nil {
144+ t.Fatalf("PlanDelta() error = %v", err)
145+ }
146+ if !reflect.DeepEqual(candidate.Plan, tt.want) {
147+ t.Fatalf("PlanDelta() = %#v, want %#v", candidate.Plan, tt.want)
148+ }
149+ })
150+ }
151+}
152+
153+func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) {
154+ committed := testFrame("0000", "1111", "2222", "3333", "4444")
155+ committed.ScrollUp(1, 3, 1)
156+ beforeCells := append([]Cell(nil), committed.Cells...)
157+ beforeOffsets := append([]int(nil), committed.lineOffset...)
158+ frame := testFrame("0000", "2222", "3333", "new!", "4444")
159+ damage := []Damage{
160+ {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 3, Count: 1},
161+ {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
162+ }
163+
164+ if _, err := PlanDelta(frame, damage, committed, false); err != nil {
165+ t.Fatalf("PlanDelta() error = %v", err)
166+ }
167+
168+ if !reflect.DeepEqual(committed.Cells, beforeCells) {
169+ t.Fatal("PlanDelta mutated committed cells")
170+ }
171+ if !reflect.DeepEqual(committed.lineOffset, beforeOffsets) {
172+ t.Fatal("PlanDelta mutated committed row offsets")
173+ }
174+}
175+
176+func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) {
177+ frame := testFrame("aaaa", "bbbb", "cccc", "dddd")
178+ frame.ScrollUp(0, 3, 1)
179+ fillFrameRow(frame, 3, "new!")
180+ committed := NewFrame(4, 4)
181+ cellsBase := &committed.Cells[0]
182+
183+ candidate, err := PlanDelta(frame, nil, committed, true)
184+ if err != nil {
185+ t.Fatalf("PlanDelta() error = %v", err)
186+ }
187+ candidate.Commit(&committed)
188+
189+ if &committed.Cells[0] != cellsBase {
190+ t.Fatal("Commit replaced same-sized cell storage")
191+ }
192+ for y := range frame.Height {
193+ if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) {
194+ t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y))
195+ }
196+ }
197+ if err := committed.CheckInvariants(); err != nil {
198+ t.Fatalf("committed frame invariants: %v", err)
199+ }
200+}
201+
202+func TestPlanDeltaRejectsInvalidFrame(t *testing.T) {
203+ frame := NewFrame(2, 1)
204+ frame.Cells = nil
205+ if _, err := PlanDelta(frame, nil, Frame{}, false); err == nil {
206+ t.Fatal("PlanDelta accepted an invalid frame")
207+ }
208+}
209+
210+func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {
211+ frame := NewFrame(2, 1)
212+ committed := NewFrame(2, 1)
213+ committed.lineOffset = nil
214+ if _, err := PlanDelta(frame, nil, committed, false); err == nil {
215+ t.Fatal("PlanDelta accepted an invalid committed frame")
216+ }
217+}
218+
219+func TestSnapshotCommitCopiesCellsOutsideDamage(t *testing.T) {
220+ frame := NewFrame(2, maxPlannedDamageSpans+1)
221+ frame.Set(1, 0, Cell{Rune: 'x', Style: DefaultStyle()})
222+ committed := NewFrame(2, maxPlannedDamageSpans+1)
223+ damage := []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}}
224+
225+ candidate, err := PlanDelta(frame, damage, committed, false)
226+ if err != nil {
227+ t.Fatalf("PlanDelta() error = %v", err)
228+ }
229+ if !candidate.Plan.Snapshot || len(candidate.Plan.Spans) != 0 {
230+ t.Fatalf("snapshot plan = %#v, want complete snapshot without partial spans", candidate.Plan)
231+ }
232+ candidate.Commit(&committed)
233+ if got := committed.At(1, 0); !got.Equal(frame.At(1, 0)) {
234+ t.Fatalf("committed cell = %#v, want %#v", got, frame.At(1, 0))
235+ }
236+}
237+
238+func TestDeltaCandidateCommitAppliesScrollToLogicalRows(t *testing.T) {
239+ committed := testFrame("0000", "1111", "2222", "3333", "4444", "5555", "6666", "7777")
240+ frame := committed.Clone()
241+ frame.ScrollUp(1, 6, 1)
242+ fillFrameRow(frame, 6, "new!")
243+ damage := []Damage{
244+ {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 6, Count: 1},
245+ {Kind: DamageText, X: 0, Y: 6, Width: 4, Height: 1},
246+ }
247+
248+ candidate, err := PlanDelta(frame, damage, committed, false)
249+ if err != nil {
250+ t.Fatalf("PlanDelta() error = %v", err)
251+ }
252+ if candidate.Plan.Scroll.Height == 0 {
253+ t.Fatal("PlanDelta dropped safe scroll")
254+ }
255+ candidate.Commit(&committed)
256+
257+ for y := range frame.Height {
258+ if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) {
259+ t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y))
260+ }
261+ }
262+ if err := committed.CheckInvariants(); err != nil {
263+ t.Fatalf("committed frame invariants: %v", err)
264+ }
265+}
266+
267+func fillFrameRow(frame Frame, y int, row string) {
268+ for x, r := range row {
269+ frame.Set(x, y, Cell{Rune: r, Style: DefaultStyle()})
270+ }
271+}
@@ -0,0 +1,271 @@
1+package renderer
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func testFrame(rows ...string) Frame {
9+ frame := NewFrame(len([]rune(rows[0])), len(rows))
10+ fillFrame(frame, rows)
11+ return frame
12+}
13+
14+func TestPlanDelta(t *testing.T) {
15+ scrollCommitted := testFrame("0000", "1111", "2222", "3333", "4444", "5555", "6666", "7777")
16+ scrolled := testFrame("0000", "2222", "3333", "4444", "5555", "6666", "new!", "7777")
17+ largeFrame := NewFrame(1, maxPlannedDamageSpans+1)
18+ styledCommitted := NewFrame(10, 1)
19+ styledCommitted.Set(3, 0, Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}})
20+ styledFrame := styledCommitted.Clone()
21+ styledFrame.Set(0, 0, Cell{Rune: 'a', Style: DefaultStyle()})
22+ styledFrame.Set(9, 0, Cell{Rune: 'z', Style: DefaultStyle()})
23+
24+ tests := []struct {
25+ name string
26+ frame Frame
27+ damage []Damage
28+ committed Frame
29+ reset bool
30+ want DeltaPlan
31+ }{
32+ {
33+ name: "reset snapshots",
34+ frame: NewFrame(4, 2),
35+ committed: NewFrame(4, 2),
36+ reset: true,
37+ want: DeltaPlan{Snapshot: true},
38+ },
39+ {
40+ name: "dimension mismatch snapshots",
41+ frame: NewFrame(4, 2),
42+ committed: NewFrame(3, 2),
43+ want: DeltaPlan{Snapshot: true},
44+ },
45+ {
46+ name: "unchanged same size has no spans",
47+ frame: NewFrame(4, 2),
48+ committed: NewFrame(4, 2),
49+ want: DeltaPlan{},
50+ },
51+ {
52+ name: "unchanged full redraw has no spans",
53+ frame: NewFrame(4, 2),
54+ damage: []Damage{FullRedraw()},
55+ committed: NewFrame(4, 2),
56+ want: DeltaPlan{},
57+ },
58+ {
59+ name: "changed row without damage becomes a full width span",
60+ frame: testFrame(" ", "edit", " "),
61+ committed: NewFrame(4, 3),
62+ want: DeltaPlan{Spans: []Span{{Y: 1, X: 0, Width: 4}}},
63+ },
64+ {
65+ name: "adjacent and overlapping damage is ordered and merged",
66+ frame: testFrame(" ", "aaaaaaaaa ", " "),
67+ damage: []Damage{
68+ {Kind: DamageText, X: 4, Y: 1, Width: 5, Height: 1},
69+ {Kind: DamageClear, X: 0, Y: 1, Width: 5, Height: 1},
70+ {Kind: DamageText, X: 2, Y: 1, Width: 2, Height: 1},
71+ },
72+ committed: NewFrame(10, 3),
73+ want: DeltaPlan{Spans: []Span{{Y: 1, X: 0, Width: 9}}},
74+ },
75+ {
76+ name: "safe full width scroll retains exposed span",
77+ frame: scrolled,
78+ damage: []Damage{
79+ {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 6, Count: 1},
80+ {Kind: DamageText, X: 0, Y: 6, Width: 4, Height: 1},
81+ },
82+ committed: scrollCommitted,
83+ want: DeltaPlan{
84+ Scroll: Scroll{Y: 1, Height: 6, Count: 1},
85+ Spans: []Span{{Y: 6, X: 0, Width: 4}},
86+ },
87+ },
88+ {
89+ name: "rectangular scroll snapshots",
90+ frame: scrolled,
91+ damage: []Damage{
92+ {Kind: DamageScrollUp, X: 1, Y: 1, Width: 3, Height: 6, Count: 1},
93+ },
94+ committed: scrollCommitted,
95+ want: DeltaPlan{Snapshot: true},
96+ },
97+ {
98+ name: "unsafe scroll content snapshots",
99+ frame: testFrame("0000", "2222", "xxxx", "4444", "5555", "6666", "new!", "7777"),
100+ damage: []Damage{
101+ {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 6, Count: 1},
102+ {Kind: DamageText, X: 0, Y: 6, Width: 4, Height: 1},
103+ },
104+ committed: scrollCommitted,
105+ want: DeltaPlan{Snapshot: true},
106+ },
107+ {
108+ name: "more than 4096 canonical spans snapshots",
109+ frame: largeFrame,
110+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}},
111+ committed: NewFrame(1, maxPlannedDamageSpans+1),
112+ want: DeltaPlan{Snapshot: true},
113+ },
114+ {
115+ name: "delta cost equal to snapshot cost snapshots",
116+ frame: testFrame("changed!"),
117+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 8, Height: 1}},
118+ committed: NewFrame(8, 1),
119+ want: DeltaPlan{Snapshot: true},
120+ },
121+ {
122+ name: "single broad damage snapshots completely",
123+ frame: testFrame("aaaa", "bbbb"),
124+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}},
125+ committed: NewFrame(4, 2),
126+ want: DeltaPlan{Snapshot: true},
127+ },
128+ {
129+ name: "snapshot cost includes style runs",
130+ frame: styledFrame,
131+ damage: []Damage{
132+ {Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1},
133+ {Kind: DamageText, X: 9, Y: 0, Width: 1, Height: 1},
134+ },
135+ committed: styledCommitted,
136+ want: DeltaPlan{Spans: []Span{{Y: 0, X: 0, Width: 1}, {Y: 0, X: 9, Width: 1}}},
137+ },
138+ }
139+
140+ for _, tt := range tests {
141+ t.Run(tt.name, func(t *testing.T) {
142+ candidate, err := PlanDelta(tt.frame, tt.damage, tt.committed, tt.reset)
143+ if err != nil {
144+ t.Fatalf("PlanDelta() error = %v", err)
145+ }
146+ if !reflect.DeepEqual(candidate.Plan, tt.want) {
147+ t.Fatalf("PlanDelta() = %#v, want %#v", candidate.Plan, tt.want)
148+ }
149+ })
150+ }
151+}
152+
153+func TestPlanDeltaDoesNotMutateCommitted(t *testing.T) {
154+ committed := testFrame("0000", "1111", "2222", "3333", "4444")
155+ committed.ScrollUp(1, 3, 1)
156+ beforeCells := append([]Cell(nil), committed.Cells...)
157+ beforeOffsets := append([]int(nil), committed.lineOffset...)
158+ frame := testFrame("0000", "2222", "3333", "new!", "4444")
159+ damage := []Damage{
160+ {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 3, Count: 1},
161+ {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
162+ }
163+
164+ if _, err := PlanDelta(frame, damage, committed, false); err != nil {
165+ t.Fatalf("PlanDelta() error = %v", err)
166+ }
167+
168+ if !reflect.DeepEqual(committed.Cells, beforeCells) {
169+ t.Fatal("PlanDelta mutated committed cells")
170+ }
171+ if !reflect.DeepEqual(committed.lineOffset, beforeOffsets) {
172+ t.Fatal("PlanDelta mutated committed row offsets")
173+ }
174+}
175+
176+func TestDeltaCandidateCommitCopiesLogicalRowsAndReusesCapacity(t *testing.T) {
177+ frame := testFrame("aaaa", "bbbb", "cccc", "dddd")
178+ frame.ScrollUp(0, 3, 1)
179+ fillFrameRow(frame, 3, "new!")
180+ committed := NewFrame(4, 4)
181+ cellsBase := &committed.Cells[0]
182+
183+ candidate, err := PlanDelta(frame, nil, committed, true)
184+ if err != nil {
185+ t.Fatalf("PlanDelta() error = %v", err)
186+ }
187+ candidate.Commit(&committed)
188+
189+ if &committed.Cells[0] != cellsBase {
190+ t.Fatal("Commit replaced same-sized cell storage")
191+ }
192+ for y := range frame.Height {
193+ if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) {
194+ t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y))
195+ }
196+ }
197+ if err := committed.CheckInvariants(); err != nil {
198+ t.Fatalf("committed frame invariants: %v", err)
199+ }
200+}
201+
202+func TestPlanDeltaRejectsInvalidFrame(t *testing.T) {
203+ frame := NewFrame(2, 1)
204+ frame.Cells = nil
205+ if _, err := PlanDelta(frame, nil, Frame{}, false); err == nil {
206+ t.Fatal("PlanDelta accepted an invalid frame")
207+ }
208+}
209+
210+func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {
211+ frame := NewFrame(2, 1)
212+ committed := NewFrame(2, 1)
213+ committed.lineOffset = nil
214+ if _, err := PlanDelta(frame, nil, committed, false); err == nil {
215+ t.Fatal("PlanDelta accepted an invalid committed frame")
216+ }
217+}
218+
219+func TestSnapshotCommitCopiesCellsOutsideDamage(t *testing.T) {
220+ frame := NewFrame(2, maxPlannedDamageSpans+1)
221+ frame.Set(1, 0, Cell{Rune: 'x', Style: DefaultStyle()})
222+ committed := NewFrame(2, maxPlannedDamageSpans+1)
223+ damage := []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}}
224+
225+ candidate, err := PlanDelta(frame, damage, committed, false)
226+ if err != nil {
227+ t.Fatalf("PlanDelta() error = %v", err)
228+ }
229+ if !candidate.Plan.Snapshot || len(candidate.Plan.Spans) != 0 {
230+ t.Fatalf("snapshot plan = %#v, want complete snapshot without partial spans", candidate.Plan)
231+ }
232+ candidate.Commit(&committed)
233+ if got := committed.At(1, 0); !got.Equal(frame.At(1, 0)) {
234+ t.Fatalf("committed cell = %#v, want %#v", got, frame.At(1, 0))
235+ }
236+}
237+
238+func TestDeltaCandidateCommitAppliesScrollToLogicalRows(t *testing.T) {
239+ committed := testFrame("0000", "1111", "2222", "3333", "4444", "5555", "6666", "7777")
240+ frame := committed.Clone()
241+ frame.ScrollUp(1, 6, 1)
242+ fillFrameRow(frame, 6, "new!")
243+ damage := []Damage{
244+ {Kind: DamageScrollUp, X: 0, Y: 1, Width: 4, Height: 6, Count: 1},
245+ {Kind: DamageText, X: 0, Y: 6, Width: 4, Height: 1},
246+ }
247+
248+ candidate, err := PlanDelta(frame, damage, committed, false)
249+ if err != nil {
250+ t.Fatalf("PlanDelta() error = %v", err)
251+ }
252+ if candidate.Plan.Scroll.Height == 0 {
253+ t.Fatal("PlanDelta dropped safe scroll")
254+ }
255+ candidate.Commit(&committed)
256+
257+ for y := range frame.Height {
258+ if !reflect.DeepEqual(committed.Row(y), frame.Row(y)) {
259+ t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y))
260+ }
261+ }
262+ if err := committed.CheckInvariants(); err != nil {
263+ t.Fatalf("committed frame invariants: %v", err)
264+ }
265+}
266+
267+func fillFrameRow(frame Frame, y int, row string) {
268+ for x, r := range row {
269+ frame.Set(x, y, Cell{Rune: r, Style: DefaultStyle()})
270+ }
271+}
M pkg/renderer/output_min_test.go +10 -12
@@ -264,9 +264,8 @@ func TestRightEdgeRunInvalidatesTracking(t *testing.T) {
264264 func TestWideRunCursorTrackingAndEL(t *testing.T) {
265265 t.Run("cursor advances two columns per wide rune", func(t *testing.T) {
266266 // Rect A ends with a wide pair (not at the edge); rect B starts one
267- // column later. The gap between them must be crossed with the cursor
268- // position that reflects the terminal advancing two columns for the
269- // wide rune — otherwise the relative move would be wrong.
267+ // column later. The literal space in the expected output bridges the
268+ // damaged regions.
270269 r := New(Capabilities{})
271270 base := NewFrame(8, 1)
272271 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
@@ -286,8 +285,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
286285 {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1},
287286 }
288287 got := drawGolden(t, r, frame, damage)
289- // After 你AB the cursor is at col 4; moving to col 5 is a one-column CUF.
290- want := "\x1b[1;1H你AB\x1b[CCDE\x1b[0m"
288+ // The literal space bridges the undamaged gap between the two regions.
289+ want := "\x1b[1;1H你AB CDE\x1b[0m"
291290 if got != want {
292291 t.Fatalf("output = %q, want %q", got, want)
293292 }
@@ -295,9 +294,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
295294
296295 t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {
297296 // A damage rect that ends on a wide head whose continuation lies
298- // outside the rect makes the terminal advance one column further than
299- // the rect width accounts for. Tracking must be dropped: the next
300- // rect must be positioned with absolute CUP, not a relative move.
297+ // outside the rect is followed by a literal space that bridges the
298+ // damaged regions. The trailing erase sequence clears the row tail.
301299 r := New(Capabilities{})
302300 base := NewFrame(8, 1)
303301 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
@@ -314,9 +312,9 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
314312 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
315313 }
316314 got := drawGolden(t, r, frame, damage)
317- // After A你 the real cursor is at col 3, not col 2: a relative CUF
318- // would be off by one, so the second rect must use absolute CUP.
319- want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"
315+ // The expected output preserves the literal-space bridge and trailing
316+ // erase sequence for this split wide pair.
317+ want := "\x1b[1;1HA你 B\x1b[K\x1b[0m"
320318 if got != want {
321319 t.Fatalf("output = %q, want %q", got, want)
322320 }
@@ -425,7 +423,7 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
425423 if err != nil {
426424 t.Fatal(err)
427425 }
428- if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want {
426+ if got, want := string(out), "\x1b[1;1HA你 B\x1b[K\x1b[0m"; got != want {
429427 t.Fatalf("output = %q, want %q", got, want)
430428 }
431429 }
@@ -264,9 +264,8 @@ func TestRightEdgeRunInvalidatesTracking(t *testing.T) {
264 func TestWideRunCursorTrackingAndEL(t *testing.T) {264 func TestWideRunCursorTrackingAndEL(t *testing.T) {
265 t.Run("cursor advances two columns per wide rune", func(t *testing.T) {265 t.Run("cursor advances two columns per wide rune", func(t *testing.T) {
266 // Rect A ends with a wide pair (not at the edge); rect B starts one266 // Rect A ends with a wide pair (not at the edge); rect B starts one
267- // column later. The gap between them must be crossed with the cursor267+ // column later. The literal space in the expected output bridges the
268- // position that reflects the terminal advancing two columns for the268+ // damaged regions.
269- // wide rune — otherwise the relative move would be wrong.
270 r := New(Capabilities{})269 r := New(Capabilities{})
271 base := NewFrame(8, 1)270 base := NewFrame(8, 1)
272 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {271 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
@@ -286,8 +285,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
286 {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1},285 {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1},
287 }286 }
288 got := drawGolden(t, r, frame, damage)287 got := drawGolden(t, r, frame, damage)
289- // After 你AB the cursor is at col 4; moving to col 5 is a one-column CUF.288+ // The literal space bridges the undamaged gap between the two regions.
290- want := "\x1b[1;1H你AB\x1b[CCDE\x1b[0m"289+ want := "\x1b[1;1H你AB CDE\x1b[0m"
291 if got != want {290 if got != want {
292 t.Fatalf("output = %q, want %q", got, want)291 t.Fatalf("output = %q, want %q", got, want)
293 }292 }
@@ -295,9 +294,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
295 294
296 t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {295 t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {
297 // A damage rect that ends on a wide head whose continuation lies296 // A damage rect that ends on a wide head whose continuation lies
298- // outside the rect makes the terminal advance one column further than297+ // outside the rect is followed by a literal space that bridges the
299- // the rect width accounts for. Tracking must be dropped: the next298+ // damaged regions. The trailing erase sequence clears the row tail.
300- // rect must be positioned with absolute CUP, not a relative move.
301 r := New(Capabilities{})299 r := New(Capabilities{})
302 base := NewFrame(8, 1)300 base := NewFrame(8, 1)
303 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {301 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
@@ -314,9 +312,9 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
314 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},312 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
315 }313 }
316 got := drawGolden(t, r, frame, damage)314 got := drawGolden(t, r, frame, damage)
317- // After A你 the real cursor is at col 3, not col 2: a relative CUF315+ // The expected output preserves the literal-space bridge and trailing
318- // would be off by one, so the second rect must use absolute CUP.316+ // erase sequence for this split wide pair.
319- want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"317+ want := "\x1b[1;1HA你 B\x1b[K\x1b[0m"
320 if got != want {318 if got != want {
321 t.Fatalf("output = %q, want %q", got, want)319 t.Fatalf("output = %q, want %q", got, want)
322 }320 }
@@ -425,7 +423,7 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
425 if err != nil {423 if err != nil {
426 t.Fatal(err)424 t.Fatal(err)
427 }425 }
428- if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want {426+ if got, want := string(out), "\x1b[1;1HA你 B\x1b[K\x1b[0m"; got != want {
429 t.Fatalf("output = %q, want %q", got, want)427 t.Fatalf("output = %q, want %q", got, want)
430 }428 }
431 }429 }
M pkg/renderer/renderer.go +87 -181
@@ -17,10 +17,20 @@ type Capabilities struct {
1717 }
1818
1919 type Renderer struct {
20- caps Capabilities
21- width int
22- height int
23- shadow []Cell
20+ caps Capabilities
21+ width int
22+ height int
23+ shadow []Cell
24+ lineOffset []int
25+}
26+
27+// PreparedDraw owns encoded output and its transactional delta until Commit.
28+// It is returned by value so ordinary prepared draws do not allocate.
29+type PreparedDraw struct {
30+ renderer *Renderer
31+ candidate DeltaCandidate
32+ data []byte
33+ committed bool
2434 }
2535
2636 func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} }
@@ -29,102 +39,98 @@ func (r *Renderer) Reset() {
2939 r.width = 0
3040 r.height = 0
3141 r.shadow = nil
42+ r.lineOffset = nil
3243 }
3344
34-func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
35- if err := frame.Validate(); err != nil {
36- return nil, err
37- }
45+// Bytes returns the prepared ANSI output. The returned bytes remain valid after
46+// another draw.
47+func (p PreparedDraw) Bytes() []byte { return p.data }
3848
39- knownSameDimensions := r.width == frame.Width && r.height == frame.Height && len(r.shadow) == len(frame.Cells)
49+// Commit applies the prepared delta exactly once. Discarding it leaves the
50+// renderer's committed state unchanged.
51+func (p *PreparedDraw) Commit() {
52+ if p == nil || p.renderer == nil || p.committed {
53+ return
54+ }
55+ committed := p.renderer.committedFrame()
56+ p.candidate.Commit(&committed)
57+ p.renderer.setCommittedFrame(committed)
58+ p.committed = true
59+}
4060
41- // Unchanged frame no-op: same dimensions, shadow populated, and either no
42- // damage or a structural redraw request that can be satisfied from the
43- // known terminal shadow.
44- if knownSameDimensions {
45- if len(damage) == 0 && !r.hasDirtyLines(frame) {
46- return nil, nil
47- }
48- if needsFull(damage) && !r.hasDirtyLines(frame) {
49- return nil, nil
61+// Prepare plans and encodes a transactional draw. The renderer advances only
62+// when the returned draw is committed.
63+func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDraw, error) {
64+ var candidate DeltaCandidate
65+ var err error
66+ if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
67+ if err = frame.Validate(); err == nil {
68+ candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame}
5069 }
70+ } else {
71+ candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0)
72+ }
73+ if err != nil {
74+ return PreparedDraw{}, err
75+ }
76+ prepared := PreparedDraw{renderer: r, candidate: candidate}
77+ plan := candidate.Plan
78+ if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
79+ return prepared, nil
5180 }
5281
53- buf := bufferPool.Get().(*bytes.Buffer)
82+ buf, ok := bufferPool.Get().(*bytes.Buffer)
83+ if !ok {
84+ buf = new(bytes.Buffer)
85+ }
5486 buf.Reset()
5587 defer putBuffer(buf)
56-
5788 if r.caps.SynchronizedOutput {
5889 buf.WriteString(SyncStartCSI)
5990 }
6091
61- // Per-Draw terminal state model shared by every emission path so cursor
62- // tracking and the SGR pen persist across all rects within one Draw.
6392 st := newDrawState()
64-
65- if !knownSameDimensions {
66- r.writeFull(buf, frame, &st)
67- r.advanceShadow(frame)
68- if r.caps.SynchronizedOutput {
69- buf.WriteString(SyncEndCSI)
70- }
71- return copyBytes(buf), nil
72- }
73-
74- if needsFull(damage) {
75- r.writeDamage(buf, frame, nil, nil, &st)
76- r.advanceShadow(frame)
77- if r.caps.SynchronizedOutput {
78- buf.WriteString(SyncEndCSI)
79- }
80- return copyBytes(buf), nil
81- }
82-
83- if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
84- spans, full := buildDamagePlan(frame, damage, &scroll)
85- if full {
93+ if plan.Snapshot {
94+ if len(plan.Spans) > 0 {
95+ r.emitDamageSpans(buf, frame, plan.Spans, &st)
96+ } else {
8697 r.writeFull(buf, frame, &st)
87- r.advanceShadow(frame)
88- if r.caps.SynchronizedOutput {
89- buf.WriteString(SyncEndCSI)
90- }
91- return copyBytes(buf), nil
9298 }
93-
94- emitScrollUp(buf, scroll)
95- // emitScrollUp resets the SGR pen to default (matching st's initial
96- // pen) but leaves the cursor wherever the DECSTBM restore put it —
97- // terminal-dependent, so cursor tracking stays invalidated.
98- r.applyScroll(scroll)
99- r.emitDamageSpans(buf, frame, spans, &st)
100- r.advanceDamage(frame, damage, &scroll)
101- if r.caps.SynchronizedOutput {
102- buf.WriteString(SyncEndCSI)
99+ } else {
100+ if plan.Scroll.Height != 0 {
101+ scroll := plan.Scroll
102+ emitScrollUp(buf, Damage{Kind: DamageScrollUp, X: 0, Y: scroll.Y, Width: frame.Width, Height: scroll.Height, Count: scroll.Count})
103103 }
104- return copyBytes(buf), nil
105- }
106-
107- // Scroll damage changes rows outside the explicit text/clear rectangles.
108- // If the fast path is not safe, redraw the frame instead of applying
109- // partial damage and leaving the terminal/shadow stale.
110- if hasScrollDamage(damage) {
111- r.writeFull(buf, frame, &st)
112- r.advanceShadow(frame)
113- if r.caps.SynchronizedOutput {
114- buf.WriteString(SyncEndCSI)
104+ for _, span := range plan.Spans {
105+ r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st)
115106 }
116- return copyBytes(buf), nil
117- }
118-
119- if r.writeDamage(buf, frame, damage, nil, &st) {
120- r.advanceShadow(frame)
121- } else {
122- r.advanceDamage(frame, damage, nil)
107+ buf.WriteString("\x1b[0m")
123108 }
124109 if r.caps.SynchronizedOutput {
125110 buf.WriteString(SyncEndCSI)
126111 }
127- return copyBytes(buf), nil
112+ prepared.data = copyBytes(buf)
113+ return prepared, nil
114+}
115+
116+func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
117+ prepared, err := r.Prepare(frame, damage, false)
118+ if err != nil {
119+ return nil, err
120+ }
121+ prepared.Commit()
122+ return prepared.Bytes(), nil
123+}
124+
125+func (r *Renderer) committedFrame() Frame {
126+ return Frame{Width: r.width, Height: r.height, Cells: r.shadow, lineOffset: r.lineOffset}
127+}
128+
129+func (r *Renderer) setCommittedFrame(frame Frame) {
130+ r.width = frame.Width
131+ r.height = frame.Height
132+ r.shadow = frame.Cells
133+ r.lineOffset = frame.lineOffset
128134 }
129135
130136 // copyBytes copies the buffer contents into a fresh byte slice and is used
@@ -143,15 +149,6 @@ func putBuffer(buf *bytes.Buffer) {
143149 bufferPool.Put(buf)
144150 }
145151
146-func (r *Renderer) hasDirtyLines(frame Frame) bool {
147- for y := range frame.Height {
148- if r.lineDirty(frame, y) {
149- return true
150- }
151- }
152- return false
153-}
154-
155152 func needsFull(damage []Damage) bool {
156153 if len(damage) == 0 {
157154 return false
@@ -180,100 +177,12 @@ func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
180177 out.WriteString("\x1b[0m")
181178 }
182179
183-// writeDamage emits a canonical, bounded view of text and clear damage. It
184-// reports whether the planner exceeded its budget and emitted a full redraw.
185-func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) bool {
186- if len(damage) == 0 {
187- for y := range frame.Height {
188- if r.lineDirty(frame, y) {
189- r.emitSpan(out, frame, y, 0, frame.Width, st)
190- }
191- }
192- out.WriteString("\x1b[0m")
193- return false
194- }
195-
196- spans, full := buildDamagePlan(frame, damage, skip)
197- if full {
198- r.writeFull(out, frame, st)
199- return true
200- }
201- r.emitDamageSpans(out, frame, spans, st)
202- return false
203-}
204-
205-func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []damageSpan, st *drawState) {
180+func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []Span, st *drawState) {
206181 for _, span := range spans {
207- r.emitSpan(out, frame, span.y, span.x, span.width, st)
182+ r.emitSpan(out, frame, span.Y, span.X, span.Width, st)
208183 }
209184 out.WriteString("\x1b[0m")
210185 }
211-
212-func (r *Renderer) lineDirty(frame Frame, y int) bool {
213- // The shadow is stored in canonical (logical) row order; the frame is read
214- // through its logical accessor so the frame's physical layout is invisible.
215- start := y * frame.Width
216- row := frame.Row(y)
217- for i := range frame.Width {
218- if !r.shadow[start+i].Equal(row[i]) {
219- return true
220- }
221- }
222- return false
223-}
224-
225-func (r *Renderer) advanceShadow(frame Frame) {
226- r.replaceShadow(frame)
227-}
228-
229-func (r *Renderer) advanceDamage(frame Frame, damage []Damage, scroll *Damage) {
230- r.syncDamage(frame, damage, scroll)
231-}
232-
233-func (r *Renderer) replaceShadow(frame Frame) {
234- r.width = frame.Width
235- r.height = frame.Height
236- n := len(frame.Cells)
237- if cap(r.shadow) >= n {
238- r.shadow = r.shadow[:n]
239- } else {
240- r.shadow = make([]Cell, n)
241- }
242- // Copy logical rows into canonical shadow order so the frame's line-offset
243- // rotation never leaks into the renderer's own buffer.
244- for y := range frame.Height {
245- copy(r.shadow[y*frame.Width:], frame.Row(y))
246- }
247-}
248-
249-func (r *Renderer) syncDamage(frame Frame, damage []Damage, scroll *Damage) {
250- if len(damage) == 0 {
251- r.replaceShadow(frame)
252- return
253- }
254- if scroll != nil {
255- r.syncRect(frame, scroll.X, scroll.Y+scroll.Height-scroll.Count, scroll.Width, scroll.Count)
256- }
257- for _, d := range damage {
258- switch d.Kind {
259- case DamageText, DamageClear:
260- r.syncRect(frame, d.X, d.Y, d.Width, d.Height)
261- }
262- }
263-}
264-
265-func (r *Renderer) syncRect(frame Frame, x, y, width, height int) {
266- x, y, width, height, ok := clampRect(frame, x, y, width, height)
267- if !ok {
268- return
269- }
270- for row := y; row < y+height; row++ {
271- start := row*frame.Width + x
272- frameRow := frame.Row(row)
273- copy(r.shadow[start:start+width], frameRow[x:x+width])
274- }
275-}
276-
277186 func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {
278187 x, width, okX := clampRange(x, width, frame.Width)
279188 y, height, okY := clampRange(y, height, frame.Height)
@@ -303,10 +212,7 @@ func clampRange(pos, size, limit int) (int, int, bool) {
303212 }
304213
305214 available := limit - pos
306- if size > available {
307- size = available
308- }
309- return pos, size, true
215+ return pos, min(size, available), true
310216 }
311217
312218 // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf
@@ -17,10 +17,20 @@ type Capabilities struct {
17 }17 }
18 18
19 type Renderer struct {19 type Renderer struct {
20- caps Capabilities20+ caps Capabilities
21- width int21+ width int
22- height int22+ height int
23- shadow []Cell23+ shadow []Cell
24+ lineOffset []int
25+}
26+
27+// PreparedDraw owns encoded output and its transactional delta until Commit.
28+// It is returned by value so ordinary prepared draws do not allocate.
29+type PreparedDraw struct {
30+ renderer *Renderer
31+ candidate DeltaCandidate
32+ data []byte
33+ committed bool
24 }34 }
25 35
26 func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} }36 func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} }
@@ -29,102 +39,98 @@ func (r *Renderer) Reset() {
29 r.width = 039 r.width = 0
30 r.height = 040 r.height = 0
31 r.shadow = nil41 r.shadow = nil
42+ r.lineOffset = nil
32 }43 }
33 44
34-func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {45+// Bytes returns the prepared ANSI output. The returned bytes remain valid after
35- if err := frame.Validate(); err != nil {46+// another draw.
36- return nil, err47+func (p PreparedDraw) Bytes() []byte { return p.data }
37- }
38 48
39- knownSameDimensions := r.width == frame.Width && r.height == frame.Height && len(r.shadow) == len(frame.Cells)49+// Commit applies the prepared delta exactly once. Discarding it leaves the
50+// renderer's committed state unchanged.
51+func (p *PreparedDraw) Commit() {
52+ if p == nil || p.renderer == nil || p.committed {
53+ return
54+ }
55+ committed := p.renderer.committedFrame()
56+ p.candidate.Commit(&committed)
57+ p.renderer.setCommittedFrame(committed)
58+ p.committed = true
59+}
40 60
41- // Unchanged frame no-op: same dimensions, shadow populated, and either no61+// Prepare plans and encodes a transactional draw. The renderer advances only
42- // damage or a structural redraw request that can be satisfied from the62+// when the returned draw is committed.
43- // known terminal shadow.63+func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDraw, error) {
44- if knownSameDimensions {64+ var candidate DeltaCandidate
45- if len(damage) == 0 && !r.hasDirtyLines(frame) {65+ var err error
46- return nil, nil66+ if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
47- }67+ if err = frame.Validate(); err == nil {
48- if needsFull(damage) && !r.hasDirtyLines(frame) {68+ candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame}
49- return nil, nil
50 }69 }
70+ } else {
71+ candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0)
72+ }
73+ if err != nil {
74+ return PreparedDraw{}, err
75+ }
76+ prepared := PreparedDraw{renderer: r, candidate: candidate}
77+ plan := candidate.Plan
78+ if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
79+ return prepared, nil
51 }80 }
52 81
53- buf := bufferPool.Get().(*bytes.Buffer)82+ buf, ok := bufferPool.Get().(*bytes.Buffer)
83+ if !ok {
84+ buf = new(bytes.Buffer)
85+ }
54 buf.Reset()86 buf.Reset()
55 defer putBuffer(buf)87 defer putBuffer(buf)
56-
57 if r.caps.SynchronizedOutput {88 if r.caps.SynchronizedOutput {
58 buf.WriteString(SyncStartCSI)89 buf.WriteString(SyncStartCSI)
59 }90 }
60 91
61- // Per-Draw terminal state model shared by every emission path so cursor
62- // tracking and the SGR pen persist across all rects within one Draw.
63 st := newDrawState()92 st := newDrawState()
64-93+ if plan.Snapshot {
65- if !knownSameDimensions {94+ if len(plan.Spans) > 0 {
66- r.writeFull(buf, frame, &st)95+ r.emitDamageSpans(buf, frame, plan.Spans, &st)
67- r.advanceShadow(frame)96+ } else {
68- if r.caps.SynchronizedOutput {
69- buf.WriteString(SyncEndCSI)
70- }
71- return copyBytes(buf), nil
72- }
73-
74- if needsFull(damage) {
75- r.writeDamage(buf, frame, nil, nil, &st)
76- r.advanceShadow(frame)
77- if r.caps.SynchronizedOutput {
78- buf.WriteString(SyncEndCSI)
79- }
80- return copyBytes(buf), nil
81- }
82-
83- if scroll, ok := findSafeScroll(frame, damage); ok && r.canApplyScroll(frame, scroll, damage) {
84- spans, full := buildDamagePlan(frame, damage, &scroll)
85- if full {
86 r.writeFull(buf, frame, &st)97 r.writeFull(buf, frame, &st)
87- r.advanceShadow(frame)
88- if r.caps.SynchronizedOutput {
89- buf.WriteString(SyncEndCSI)
90- }
91- return copyBytes(buf), nil
92 }98 }
93-99+ } else {
94- emitScrollUp(buf, scroll)100+ if plan.Scroll.Height != 0 {
95- // emitScrollUp resets the SGR pen to default (matching st's initial101+ scroll := plan.Scroll
96- // pen) but leaves the cursor wherever the DECSTBM restore put it —102+ emitScrollUp(buf, Damage{Kind: DamageScrollUp, X: 0, Y: scroll.Y, Width: frame.Width, Height: scroll.Height, Count: scroll.Count})
97- // terminal-dependent, so cursor tracking stays invalidated.
98- r.applyScroll(scroll)
99- r.emitDamageSpans(buf, frame, spans, &st)
100- r.advanceDamage(frame, damage, &scroll)
101- if r.caps.SynchronizedOutput {
102- buf.WriteString(SyncEndCSI)
103 }103 }
104- return copyBytes(buf), nil104+ for _, span := range plan.Spans {
105- }105+ r.emitSpan(buf, frame, span.Y, span.X, span.Width, &st)
106-
107- // Scroll damage changes rows outside the explicit text/clear rectangles.
108- // If the fast path is not safe, redraw the frame instead of applying
109- // partial damage and leaving the terminal/shadow stale.
110- if hasScrollDamage(damage) {
111- r.writeFull(buf, frame, &st)
112- r.advanceShadow(frame)
113- if r.caps.SynchronizedOutput {
114- buf.WriteString(SyncEndCSI)
115 }106 }
116- return copyBytes(buf), nil107+ buf.WriteString("\x1b[0m")
117- }
118-
119- if r.writeDamage(buf, frame, damage, nil, &st) {
120- r.advanceShadow(frame)
121- } else {
122- r.advanceDamage(frame, damage, nil)
123 }108 }
124 if r.caps.SynchronizedOutput {109 if r.caps.SynchronizedOutput {
125 buf.WriteString(SyncEndCSI)110 buf.WriteString(SyncEndCSI)
126 }111 }
127- return copyBytes(buf), nil112+ prepared.data = copyBytes(buf)
113+ return prepared, nil
114+}
115+
116+func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
117+ prepared, err := r.Prepare(frame, damage, false)
118+ if err != nil {
119+ return nil, err
120+ }
121+ prepared.Commit()
122+ return prepared.Bytes(), nil
123+}
124+
125+func (r *Renderer) committedFrame() Frame {
126+ return Frame{Width: r.width, Height: r.height, Cells: r.shadow, lineOffset: r.lineOffset}
127+}
128+
129+func (r *Renderer) setCommittedFrame(frame Frame) {
130+ r.width = frame.Width
131+ r.height = frame.Height
132+ r.shadow = frame.Cells
133+ r.lineOffset = frame.lineOffset
128 }134 }
129 135
130 // copyBytes copies the buffer contents into a fresh byte slice and is used136 // copyBytes copies the buffer contents into a fresh byte slice and is used
@@ -143,15 +149,6 @@ func putBuffer(buf *bytes.Buffer) {
143 bufferPool.Put(buf)149 bufferPool.Put(buf)
144 }150 }
145 151
146-func (r *Renderer) hasDirtyLines(frame Frame) bool {
147- for y := range frame.Height {
148- if r.lineDirty(frame, y) {
149- return true
150- }
151- }
152- return false
153-}
154-
155 func needsFull(damage []Damage) bool {152 func needsFull(damage []Damage) bool {
156 if len(damage) == 0 {153 if len(damage) == 0 {
157 return false154 return false
@@ -180,100 +177,12 @@ func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) {
180 out.WriteString("\x1b[0m")177 out.WriteString("\x1b[0m")
181 }178 }
182 179
183-// writeDamage emits a canonical, bounded view of text and clear damage. It180+func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []Span, st *drawState) {
184-// reports whether the planner exceeded its budget and emitted a full redraw.
185-func (r *Renderer) writeDamage(out *bytes.Buffer, frame Frame, damage []Damage, skip *Damage, st *drawState) bool {
186- if len(damage) == 0 {
187- for y := range frame.Height {
188- if r.lineDirty(frame, y) {
189- r.emitSpan(out, frame, y, 0, frame.Width, st)
190- }
191- }
192- out.WriteString("\x1b[0m")
193- return false
194- }
195-
196- spans, full := buildDamagePlan(frame, damage, skip)
197- if full {
198- r.writeFull(out, frame, st)
199- return true
200- }
201- r.emitDamageSpans(out, frame, spans, st)
202- return false
203-}
204-
205-func (r *Renderer) emitDamageSpans(out *bytes.Buffer, frame Frame, spans []damageSpan, st *drawState) {
206 for _, span := range spans {181 for _, span := range spans {
207- r.emitSpan(out, frame, span.y, span.x, span.width, st)182+ r.emitSpan(out, frame, span.Y, span.X, span.Width, st)
208 }183 }
209 out.WriteString("\x1b[0m")184 out.WriteString("\x1b[0m")
210 }185 }
211-
212-func (r *Renderer) lineDirty(frame Frame, y int) bool {
213- // The shadow is stored in canonical (logical) row order; the frame is read
214- // through its logical accessor so the frame's physical layout is invisible.
215- start := y * frame.Width
216- row := frame.Row(y)
217- for i := range frame.Width {
218- if !r.shadow[start+i].Equal(row[i]) {
219- return true
220- }
221- }
222- return false
223-}
224-
225-func (r *Renderer) advanceShadow(frame Frame) {
226- r.replaceShadow(frame)
227-}
228-
229-func (r *Renderer) advanceDamage(frame Frame, damage []Damage, scroll *Damage) {
230- r.syncDamage(frame, damage, scroll)
231-}
232-
233-func (r *Renderer) replaceShadow(frame Frame) {
234- r.width = frame.Width
235- r.height = frame.Height
236- n := len(frame.Cells)
237- if cap(r.shadow) >= n {
238- r.shadow = r.shadow[:n]
239- } else {
240- r.shadow = make([]Cell, n)
241- }
242- // Copy logical rows into canonical shadow order so the frame's line-offset
243- // rotation never leaks into the renderer's own buffer.
244- for y := range frame.Height {
245- copy(r.shadow[y*frame.Width:], frame.Row(y))
246- }
247-}
248-
249-func (r *Renderer) syncDamage(frame Frame, damage []Damage, scroll *Damage) {
250- if len(damage) == 0 {
251- r.replaceShadow(frame)
252- return
253- }
254- if scroll != nil {
255- r.syncRect(frame, scroll.X, scroll.Y+scroll.Height-scroll.Count, scroll.Width, scroll.Count)
256- }
257- for _, d := range damage {
258- switch d.Kind {
259- case DamageText, DamageClear:
260- r.syncRect(frame, d.X, d.Y, d.Width, d.Height)
261- }
262- }
263-}
264-
265-func (r *Renderer) syncRect(frame Frame, x, y, width, height int) {
266- x, y, width, height, ok := clampRect(frame, x, y, width, height)
267- if !ok {
268- return
269- }
270- for row := y; row < y+height; row++ {
271- start := row*frame.Width + x
272- frameRow := frame.Row(row)
273- copy(r.shadow[start:start+width], frameRow[x:x+width])
274- }
275-}
276-
277 func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {186 func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) {
278 x, width, okX := clampRange(x, width, frame.Width)187 x, width, okX := clampRange(x, width, frame.Width)
279 y, height, okY := clampRange(y, height, frame.Height)188 y, height, okY := clampRange(y, height, frame.Height)
@@ -303,10 +212,7 @@ func clampRange(pos, size, limit int) (int, int, bool) {
303 }212 }
304 213
305 available := limit - pos214 available := limit - pos
306- if size > available {215+ return pos, min(size, available), true
307- size = available
308- }
309- return pos, size, true
310 }216 }
311 217
312 // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf218 // writeCursor emits a cursor-positioning CSI sequence without fmt.Fprintf
M pkg/renderer/renderer_test.go +235 -1
@@ -190,7 +190,7 @@ func TestStyleResetDiscipline(t *testing.T) {
190190 if err != nil {
191191 t.Fatal(err)
192192 }
193- // The output must end with a style reset (either from emitScrollUp or writeDamage).
193+ // The output must end with a style reset.
194194 outputEndsWith(t, out, "\x1b[0m")
195195 },
196196 },
@@ -1078,6 +1078,240 @@ func TestWideCharRendering(t *testing.T) {
10781078 }
10791079 }
10801080
1081+func TestRendererPrepare(t *testing.T) {
1082+ fullFrame := testFrame("ABC", "DEF")
1083+ fullDamage := []Damage{FullRedraw()}
1084+
1085+ t.Run("zero commit is safe", func(t *testing.T) {
1086+ var prepared PreparedDraw
1087+ prepared.Commit()
1088+ })
1089+
1090+ t.Run("discard and reprepare returns identical owned bytes", func(t *testing.T) {
1091+ r := New(Capabilities{})
1092+ first, err := r.Prepare(fullFrame, fullDamage, false)
1093+ if err != nil {
1094+ t.Fatal(err)
1095+ }
1096+ want := append([]byte(nil), first.Bytes()...)
1097+
1098+ second, err := r.Prepare(fullFrame, fullDamage, false)
1099+ if err != nil {
1100+ t.Fatal(err)
1101+ }
1102+ if !bytes.Equal(second.Bytes(), want) {
1103+ t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want)
1104+ }
1105+
1106+ other := testFrame("12345678", "abcdefgh", "ABCDEFGH")
1107+ if _, err := r.Prepare(other, fullDamage, false); err != nil {
1108+ t.Fatal(err)
1109+ }
1110+ if !bytes.Equal(first.Bytes(), want) {
1111+ t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want)
1112+ }
1113+ })
1114+
1115+ t.Run("commit then no-op", func(t *testing.T) {
1116+ r := New(Capabilities{})
1117+ prepared, err := r.Prepare(fullFrame, fullDamage, false)
1118+ if err != nil {
1119+ t.Fatal(err)
1120+ }
1121+ prepared.Commit()
1122+
1123+ next, err := r.Prepare(fullFrame, nil, false)
1124+ if err != nil {
1125+ t.Fatal(err)
1126+ }
1127+ if len(next.Bytes()) != 0 {
1128+ t.Fatalf("unchanged frame emitted %q", next.Bytes())
1129+ }
1130+ })
1131+
1132+ t.Run("reset preparation is transactional", func(t *testing.T) {
1133+ r := New(Capabilities{})
1134+ initial, err := r.Prepare(fullFrame, fullDamage, false)
1135+ if err != nil {
1136+ t.Fatal(err)
1137+ }
1138+ initial.Commit()
1139+
1140+ resetFrame := testFrame("ABC", "XYZ")
1141+ resetPrepared, err := r.Prepare(resetFrame, nil, true)
1142+ if err != nil {
1143+ t.Fatal(err)
1144+ }
1145+ want, err := New(Capabilities{}).Draw(resetFrame, nil)
1146+ if err != nil {
1147+ t.Fatal(err)
1148+ }
1149+ if !bytes.Equal(resetPrepared.Bytes(), want) {
1150+ t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want)
1151+ }
1152+
1153+ unchanged, err := r.Prepare(fullFrame, nil, false)
1154+ if err != nil {
1155+ t.Fatal(err)
1156+ }
1157+ if len(unchanged.Bytes()) != 0 {
1158+ t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes())
1159+ }
1160+
1161+ resetPrepared, err = r.Prepare(resetFrame, nil, true)
1162+ if err != nil {
1163+ t.Fatal(err)
1164+ }
1165+ resetPrepared.Commit()
1166+ afterCommit, err := r.Prepare(resetFrame, nil, false)
1167+ if err != nil {
1168+ t.Fatal(err)
1169+ }
1170+ if len(afterCommit.Bytes()) != 0 {
1171+ t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes())
1172+ }
1173+ })
1174+
1175+ t.Run("successful no-byte commit", func(t *testing.T) {
1176+ r := New(Capabilities{})
1177+ initial, err := r.Prepare(fullFrame, fullDamage, false)
1178+ if err != nil {
1179+ t.Fatal(err)
1180+ }
1181+ initial.Commit()
1182+
1183+ noOp, err := r.Prepare(fullFrame, nil, false)
1184+ if err != nil {
1185+ t.Fatal(err)
1186+ }
1187+ if len(noOp.Bytes()) != 0 {
1188+ t.Fatalf("no-op emitted %q", noOp.Bytes())
1189+ }
1190+ noOp.Commit()
1191+
1192+ again, err := r.Prepare(fullFrame, nil, false)
1193+ if err != nil {
1194+ t.Fatal(err)
1195+ }
1196+ if len(again.Bytes()) != 0 {
1197+ t.Fatalf("committed no-op changed renderer state: %q", again.Bytes())
1198+ }
1199+ })
1200+
1201+ t.Run("double commit is idempotent", func(t *testing.T) {
1202+ base := testFrame("0000", "1111", "2222", "3333")
1203+ scrolled := testFrame("1111", "2222", "3333", "new!")
1204+ damage := []Damage{
1205+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1206+ {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1207+ }
1208+ r := New(Capabilities{})
1209+ if _, err := r.Draw(base, fullDamage); err != nil {
1210+ t.Fatal(err)
1211+ }
1212+
1213+ prepared, err := r.Prepare(scrolled, damage, false)
1214+ if err != nil {
1215+ t.Fatal(err)
1216+ }
1217+ prepared.Commit()
1218+ prepared.Commit()
1219+
1220+ next, err := r.Prepare(scrolled, nil, false)
1221+ if err != nil {
1222+ t.Fatal(err)
1223+ }
1224+ if len(next.Bytes()) != 0 {
1225+ t.Fatalf("double commit advanced state twice: %q", next.Bytes())
1226+ }
1227+ })
1228+
1229+ t.Run("matches concrete ANSI fixtures", func(t *testing.T) {
1230+ tests := []struct {
1231+ name string
1232+ prepare func(t *testing.T) PreparedDraw
1233+ want string
1234+ }{
1235+ {
1236+ name: "full draw",
1237+ prepare: func(t *testing.T) PreparedDraw {
1238+ prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false)
1239+ if err != nil {
1240+ t.Fatal(err)
1241+ }
1242+ return prepared
1243+ },
1244+ want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m",
1245+ },
1246+ {
1247+ name: "fragmented damage",
1248+ prepare: func(t *testing.T) PreparedDraw {
1249+ base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx")
1250+ frame := base.Clone()
1251+ frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1252+ frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()})
1253+ frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()})
1254+ damage := []Damage{
1255+ {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1},
1256+ {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1},
1257+ {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1},
1258+ }
1259+ r := New(Capabilities{})
1260+ if _, err := r.Draw(base, fullDamage); err != nil {
1261+ t.Fatal(err)
1262+ }
1263+ prepared, err := r.Prepare(frame, damage, false)
1264+ if err != nil {
1265+ t.Fatal(err)
1266+ }
1267+ return prepared
1268+ },
1269+ want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m",
1270+ },
1271+ {
1272+ name: "scroll",
1273+ prepare: func(t *testing.T) PreparedDraw {
1274+ base := testFrame("00000", "11111", "22222", "33333")
1275+ frame := testFrame("11111", "22222", "33333", "new!!")
1276+ damage := []Damage{
1277+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
1278+ {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
1279+ }
1280+ r := New(Capabilities{})
1281+ if _, err := r.Draw(base, fullDamage); err != nil {
1282+ t.Fatal(err)
1283+ }
1284+ prepared, err := r.Prepare(frame, damage, false)
1285+ if err != nil {
1286+ t.Fatal(err)
1287+ }
1288+ return prepared
1289+ },
1290+ want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m",
1291+ },
1292+ {
1293+ name: "synchronized output framing",
1294+ prepare: func(t *testing.T) PreparedDraw {
1295+ prepared, err := New(Capabilities{SynchronizedOutput: true}).Prepare(fullFrame, fullDamage, false)
1296+ if err != nil {
1297+ t.Fatal(err)
1298+ }
1299+ return prepared
1300+ },
1301+ want: SyncStartCSI + "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m" + SyncEndCSI,
1302+ },
1303+ }
1304+
1305+ for _, tt := range tests {
1306+ t.Run(tt.name, func(t *testing.T) {
1307+ if got := string(tt.prepare(t).Bytes()); got != tt.want {
1308+ t.Fatalf("prepared ANSI = %q, want %q", got, tt.want)
1309+ }
1310+ })
1311+ }
1312+ })
1313+}
1314+
10811315 func TestWideCharDamageEmission(t *testing.T) {
10821316 // Populate the shadow with a blank frame, then place a wide char and draw
10831317 // with a text-damage rect covering the pair. The continuation cell must
@@ -190,7 +190,7 @@ func TestStyleResetDiscipline(t *testing.T) {
190 if err != nil {190 if err != nil {
191 t.Fatal(err)191 t.Fatal(err)
192 }192 }
193- // The output must end with a style reset (either from emitScrollUp or writeDamage).193+ // The output must end with a style reset.
194 outputEndsWith(t, out, "\x1b[0m")194 outputEndsWith(t, out, "\x1b[0m")
195 },195 },
196 },196 },
@@ -1078,6 +1078,240 @@ func TestWideCharRendering(t *testing.T) {
1078 }1078 }
1079 }1079 }
1080 1080
1081+func TestRendererPrepare(t *testing.T) {
1082+ fullFrame := testFrame("ABC", "DEF")
1083+ fullDamage := []Damage{FullRedraw()}
1084+
1085+ t.Run("zero commit is safe", func(t *testing.T) {
1086+ var prepared PreparedDraw
1087+ prepared.Commit()
1088+ })
1089+
1090+ t.Run("discard and reprepare returns identical owned bytes", func(t *testing.T) {
1091+ r := New(Capabilities{})
1092+ first, err := r.Prepare(fullFrame, fullDamage, false)
1093+ if err != nil {
1094+ t.Fatal(err)
1095+ }
1096+ want := append([]byte(nil), first.Bytes()...)
1097+
1098+ second, err := r.Prepare(fullFrame, fullDamage, false)
1099+ if err != nil {
1100+ t.Fatal(err)
1101+ }
1102+ if !bytes.Equal(second.Bytes(), want) {
1103+ t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want)
1104+ }
1105+
1106+ other := testFrame("12345678", "abcdefgh", "ABCDEFGH")
1107+ if _, err := r.Prepare(other, fullDamage, false); err != nil {
1108+ t.Fatal(err)
1109+ }
1110+ if !bytes.Equal(first.Bytes(), want) {
1111+ t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want)
1112+ }
1113+ })
1114+
1115+ t.Run("commit then no-op", func(t *testing.T) {
1116+ r := New(Capabilities{})
1117+ prepared, err := r.Prepare(fullFrame, fullDamage, false)
1118+ if err != nil {
1119+ t.Fatal(err)
1120+ }
1121+ prepared.Commit()
1122+
1123+ next, err := r.Prepare(fullFrame, nil, false)
1124+ if err != nil {
1125+ t.Fatal(err)
1126+ }
1127+ if len(next.Bytes()) != 0 {
1128+ t.Fatalf("unchanged frame emitted %q", next.Bytes())
1129+ }
1130+ })
1131+
1132+ t.Run("reset preparation is transactional", func(t *testing.T) {
1133+ r := New(Capabilities{})
1134+ initial, err := r.Prepare(fullFrame, fullDamage, false)
1135+ if err != nil {
1136+ t.Fatal(err)
1137+ }
1138+ initial.Commit()
1139+
1140+ resetFrame := testFrame("ABC", "XYZ")
1141+ resetPrepared, err := r.Prepare(resetFrame, nil, true)
1142+ if err != nil {
1143+ t.Fatal(err)
1144+ }
1145+ want, err := New(Capabilities{}).Draw(resetFrame, nil)
1146+ if err != nil {
1147+ t.Fatal(err)
1148+ }
1149+ if !bytes.Equal(resetPrepared.Bytes(), want) {
1150+ t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want)
1151+ }
1152+
1153+ unchanged, err := r.Prepare(fullFrame, nil, false)
1154+ if err != nil {
1155+ t.Fatal(err)
1156+ }
1157+ if len(unchanged.Bytes()) != 0 {
1158+ t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes())
1159+ }
1160+
1161+ resetPrepared, err = r.Prepare(resetFrame, nil, true)
1162+ if err != nil {
1163+ t.Fatal(err)
1164+ }
1165+ resetPrepared.Commit()
1166+ afterCommit, err := r.Prepare(resetFrame, nil, false)
1167+ if err != nil {
1168+ t.Fatal(err)
1169+ }
1170+ if len(afterCommit.Bytes()) != 0 {
1171+ t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes())
1172+ }
1173+ })
1174+
1175+ t.Run("successful no-byte commit", func(t *testing.T) {
1176+ r := New(Capabilities{})
1177+ initial, err := r.Prepare(fullFrame, fullDamage, false)
1178+ if err != nil {
1179+ t.Fatal(err)
1180+ }
1181+ initial.Commit()
1182+
1183+ noOp, err := r.Prepare(fullFrame, nil, false)
1184+ if err != nil {
1185+ t.Fatal(err)
1186+ }
1187+ if len(noOp.Bytes()) != 0 {
1188+ t.Fatalf("no-op emitted %q", noOp.Bytes())
1189+ }
1190+ noOp.Commit()
1191+
1192+ again, err := r.Prepare(fullFrame, nil, false)
1193+ if err != nil {
1194+ t.Fatal(err)
1195+ }
1196+ if len(again.Bytes()) != 0 {
1197+ t.Fatalf("committed no-op changed renderer state: %q", again.Bytes())
1198+ }
1199+ })
1200+
1201+ t.Run("double commit is idempotent", func(t *testing.T) {
1202+ base := testFrame("0000", "1111", "2222", "3333")
1203+ scrolled := testFrame("1111", "2222", "3333", "new!")
1204+ damage := []Damage{
1205+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1206+ {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1207+ }
1208+ r := New(Capabilities{})
1209+ if _, err := r.Draw(base, fullDamage); err != nil {
1210+ t.Fatal(err)
1211+ }
1212+
1213+ prepared, err := r.Prepare(scrolled, damage, false)
1214+ if err != nil {
1215+ t.Fatal(err)
1216+ }
1217+ prepared.Commit()
1218+ prepared.Commit()
1219+
1220+ next, err := r.Prepare(scrolled, nil, false)
1221+ if err != nil {
1222+ t.Fatal(err)
1223+ }
1224+ if len(next.Bytes()) != 0 {
1225+ t.Fatalf("double commit advanced state twice: %q", next.Bytes())
1226+ }
1227+ })
1228+
1229+ t.Run("matches concrete ANSI fixtures", func(t *testing.T) {
1230+ tests := []struct {
1231+ name string
1232+ prepare func(t *testing.T) PreparedDraw
1233+ want string
1234+ }{
1235+ {
1236+ name: "full draw",
1237+ prepare: func(t *testing.T) PreparedDraw {
1238+ prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false)
1239+ if err != nil {
1240+ t.Fatal(err)
1241+ }
1242+ return prepared
1243+ },
1244+ want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m",
1245+ },
1246+ {
1247+ name: "fragmented damage",
1248+ prepare: func(t *testing.T) PreparedDraw {
1249+ base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx")
1250+ frame := base.Clone()
1251+ frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1252+ frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()})
1253+ frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()})
1254+ damage := []Damage{
1255+ {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1},
1256+ {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1},
1257+ {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1},
1258+ }
1259+ r := New(Capabilities{})
1260+ if _, err := r.Draw(base, fullDamage); err != nil {
1261+ t.Fatal(err)
1262+ }
1263+ prepared, err := r.Prepare(frame, damage, false)
1264+ if err != nil {
1265+ t.Fatal(err)
1266+ }
1267+ return prepared
1268+ },
1269+ want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m",
1270+ },
1271+ {
1272+ name: "scroll",
1273+ prepare: func(t *testing.T) PreparedDraw {
1274+ base := testFrame("00000", "11111", "22222", "33333")
1275+ frame := testFrame("11111", "22222", "33333", "new!!")
1276+ damage := []Damage{
1277+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
1278+ {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
1279+ }
1280+ r := New(Capabilities{})
1281+ if _, err := r.Draw(base, fullDamage); err != nil {
1282+ t.Fatal(err)
1283+ }
1284+ prepared, err := r.Prepare(frame, damage, false)
1285+ if err != nil {
1286+ t.Fatal(err)
1287+ }
1288+ return prepared
1289+ },
1290+ want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m",
1291+ },
1292+ {
1293+ name: "synchronized output framing",
1294+ prepare: func(t *testing.T) PreparedDraw {
1295+ prepared, err := New(Capabilities{SynchronizedOutput: true}).Prepare(fullFrame, fullDamage, false)
1296+ if err != nil {
1297+ t.Fatal(err)
1298+ }
1299+ return prepared
1300+ },
1301+ want: SyncStartCSI + "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m" + SyncEndCSI,
1302+ },
1303+ }
1304+
1305+ for _, tt := range tests {
1306+ t.Run(tt.name, func(t *testing.T) {
1307+ if got := string(tt.prepare(t).Bytes()); got != tt.want {
1308+ t.Fatalf("prepared ANSI = %q, want %q", got, tt.want)
1309+ }
1310+ })
1311+ }
1312+ })
1313+}
1314+
1081 func TestWideCharDamageEmission(t *testing.T) {1315 func TestWideCharDamageEmission(t *testing.T) {
1082 // Populate the shadow with a blank frame, then place a wide char and draw1316 // Populate the shadow with a blank frame, then place a wide char and draw
1083 // with a text-damage rect covering the pair. The continuation cell must1317 // with a text-damage rect covering the pair. The continuation cell must
M pkg/renderer/scroll.go +6 -12
@@ -42,15 +42,17 @@ func emitScrollUp(out *bytes.Buffer, d Damage) {
4242 out.WriteString("\x1b[r")
4343 }
4444
45-func (r *Renderer) canApplyScroll(frame Frame, scroll Damage, damage []Damage) bool {
45+func canApplyScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {
4646 for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {
4747 frameRow := frame.Row(y)
48- shadowStart := (y+scroll.Count)*r.width + scroll.X
48+ committedRow := committed.Row(y + scroll.Count)
4949 for x := range scroll.Width {
50- if r.shadow[shadowStart+x].Equal(frameRow[scroll.X+x]) {
50+ column := scroll.X + x
51+ committedCell, frameCell := committedRow[column], frameRow[column]
52+ if committedCell == frameCell || committedCell.Equal(frameCell) {
5153 continue
5254 }
53- if !damageCoversCell(damage, scroll.X+x, y) {
55+ if !damageCoversCell(damage, column, y) {
5456 return false
5557 }
5658 }
@@ -58,14 +60,6 @@ func (r *Renderer) canApplyScroll(frame Frame, scroll Damage, damage []Damage) b
5860 return true
5961 }
6062
61-func (r *Renderer) applyScroll(scroll Damage) {
62- for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {
63- dst := y*r.width + scroll.X
64- src := (y+scroll.Count)*r.width + scroll.X
65- copy(r.shadow[dst:dst+scroll.Width], r.shadow[src:src+scroll.Width])
66- }
67-}
68-
6963 func damageCoversCell(damage []Damage, x, y int) bool {
7064 for _, d := range damage {
7165 if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height {
@@ -42,15 +42,17 @@ func emitScrollUp(out *bytes.Buffer, d Damage) {
42 out.WriteString("\x1b[r")42 out.WriteString("\x1b[r")
43 }43 }
44 44
45-func (r *Renderer) canApplyScroll(frame Frame, scroll Damage, damage []Damage) bool {45+func canApplyScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {
46 for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {46 for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {
47 frameRow := frame.Row(y)47 frameRow := frame.Row(y)
48- shadowStart := (y+scroll.Count)*r.width + scroll.X48+ committedRow := committed.Row(y + scroll.Count)
49 for x := range scroll.Width {49 for x := range scroll.Width {
50- if r.shadow[shadowStart+x].Equal(frameRow[scroll.X+x]) {50+ column := scroll.X + x
51+ committedCell, frameCell := committedRow[column], frameRow[column]
52+ if committedCell == frameCell || committedCell.Equal(frameCell) {
51 continue53 continue
52 }54 }
53- if !damageCoversCell(damage, scroll.X+x, y) {55+ if !damageCoversCell(damage, column, y) {
54 return false56 return false
55 }57 }
56 }58 }
@@ -58,14 +60,6 @@ func (r *Renderer) canApplyScroll(frame Frame, scroll Damage, damage []Damage) b
58 return true60 return true
59 }61 }
60 62
61-func (r *Renderer) applyScroll(scroll Damage) {
62- for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ {
63- dst := y*r.width + scroll.X
64- src := (y+scroll.Count)*r.width + scroll.X
65- copy(r.shadow[dst:dst+scroll.Width], r.shadow[src:src+scroll.Width])
66- }
67-}
68-
69 func damageCoversCell(damage []Damage, x, y int) bool {63 func damageCoversCell(damage []Damage, x, y int) bool {
70 for _, d := range damage {64 for _, d := range damage {
71 if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height {65 if (d.Kind == DamageText || d.Kind == DamageClear) && x >= d.X && x < d.X+d.Width && y >= d.Y && y < d.Y+d.Height {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close