Merge pull request #165 from bnema/refactor/renderer-delta-planner
refactor(renderer): add transactional delta planning
21 files · +1435 -308
M internal/ports/wire_test.go +4 -0
| @@ -625,6 +625,10 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) { | |||
| 625 | 625 | } | |
| 626 | 626 | ||
| 627 | 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 | 634 | 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 { | |||
| 18 | 18 | next uint64 | |
| 19 | 19 | acked uint64 | |
| 20 | 20 | maxOutstanding uint64 | |
| 21 | + | forceSnapshot bool | |
| 21 | 22 | } | |
| 22 | 23 | ||
| 23 | 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 | 35 | 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 | |
| 46 | 42 | } | |
| 47 | 43 | ||
| 48 | 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 | 47 | if err != nil { | |
| 51 | 48 | return nil, err | |
| 52 | 49 | } | |
| 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 | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 56 | 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 | 69 | if err := send(out); err != nil { | |
| 59 | - | p.stream.renderer.Reset() | |
| 60 | - | p.stream.next = p.next | |
| 70 | + | p.stream.forceSnapshot = true | |
| 61 | 71 | return err | |
| 62 | 72 | } | |
| 73 | + | p.commit() | |
| 74 | + | p.stream.next = p.next | |
| 63 | 75 | return nil | |
| 64 | 76 | } | |
| 65 | 77 | ||
| 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 | |
| 71 | 90 | } | |
| 72 | - | frame := frameOutputState(data, base, s.next, echoAck) | |
| 73 | - | return frame | |
| 74 | 91 | } | |
| 75 | 92 | ||
| 76 | 93 | func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame { | |
| @@ -87,6 +104,7 @@ func (s *outputStateStream) ack(state uint64) { | |||
| 87 | 104 | func (s *outputStateStream) rebase() { | |
| 88 | 105 | s.acked = s.next | |
| 89 | 106 | s.renderer.Reset() | |
| 107 | + | s.forceSnapshot = true | |
| 90 | 108 | } | |
| 91 | 109 | ||
| 92 | 110 | func (s *outputStateStream) atCapacity() bool { | |
| @@ -18,6 +18,7 @@ type outputStateStream struct { | |||||
| 18 | next uint64 | 18 | next uint64 | ||
| 19 | acked uint64 | 19 | acked uint64 | ||
| 20 | maxOutstanding uint64 | 20 | 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 *outputStateStream | 36 | + | stream *outputStateStream |
| 43 | - | next uint64 | 37 | + | draw renderer.PreparedDraw |
| 44 | - | data []byte | 38 | + | next uint64 |
| 45 | - | reset bool | 39 | + | 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, err | 48 | return nil, err | ||
| 52 | } | 49 | } | ||
| 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 | |||
| 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 err | 71 | return err | ||
| 62 | } | 72 | } | ||
| 73 | + | p.commit() | |||
| 74 | + | p.stream.next = p.next | |||
| 63 | return nil | 75 | 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 - 1 | 80 | + | return |
| 69 | - | if reset { | 81 | + | } |
| 70 | - | base = 0 | 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 | |||
| 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.next | 105 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/vev/internal/ports" | |
| @@ -34,6 +35,116 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) { | |||
| 34 | 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 | 148 | func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) { | |
| 38 | 149 | stream := newOutputStateStream() | |
| 39 | 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 | 191 | func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) { | |
| 72 | 192 | 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 { | |
| 75 | 205 | return ports.Frame{}, false, err | |
| 76 | 206 | } | |
| 77 | - | return stream.frame(data, reset, echoAck), true, nil | |
| 207 | + | return output, true, nil | |
| 78 | 208 | } | |
| 79 | 209 | ||
| 80 | 210 | // A resize is a state reset, but it must not turn subsequent idle or damaged | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | 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, err | 205 | return ports.Frame{}, false, err | ||
| 76 | } | 206 | } | ||
| 77 | - | return stream.frame(data, reset, echoAck), true, nil | 207 | + | return output, true, nil |
| 78 | } | 208 | } | ||
| 79 | 209 | ||||
| 80 | // A resize is a state reset, but it must not turn subsequent idle or damaged | 210 | // 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 { | |||
| 384 | 384 | } | |
| 385 | 385 | } | |
| 386 | 386 | ||
| 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 | |
| 389 | 397 | if !changed { | |
| 390 | - | return nil | |
| 398 | + | return candidate | |
| 391 | 399 | } | |
| 392 | - | prev := ac.lastCursor | |
| 393 | - | ac.lastCursor = desired | |
| 394 | - | ac.lastCursor.valid = true | |
| 395 | 400 | 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') | |
| 404 | 409 | 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"...) | |
| 408 | 413 | } | |
| 409 | - | b = append(b, "\x1b[?25h"...) | |
| 410 | - | return b | |
| 414 | + | candidate.data = append(candidate.data, "\x1b[?25h"...) | |
| 415 | + | return candidate | |
| 411 | 416 | } | |
| 412 | 417 | ||
| 413 | 418 | // commitDamageReceipts acknowledges only snapshots that reached the client. | |
| @@ -493,8 +498,9 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 493 | 498 | d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed") | |
| 494 | 499 | return true | |
| 495 | 500 | } | |
| 501 | + | cursor := ac.prepareCursorTail(composed.cursor, len(prepared.data) > 0) | |
| 496 | 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 | 504 | var sendTr ports.Transport | |
| 499 | 505 | var sendErr error | |
| 500 | 506 | // 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 | 524 | send = async.SendAsync | |
| 519 | 525 | } | |
| 520 | 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 | 538 | if interruptible { | |
| 523 | 539 | if sendErr != nil { | |
| 524 | 540 | marks.roleEffect.reportTransportFailure(sendTransport) | |
| @@ -530,14 +546,17 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 530 | 546 | } | |
| 531 | 547 | } | |
| 532 | 548 | if sendErr == nil { | |
| 549 | + | if len(data) == 0 { | |
| 550 | + | prepared.commitNoSend() | |
| 551 | + | } | |
| 533 | 552 | // Publish only after output preparation and transport emission both | |
| 534 | 553 | // succeed. A cross-session transition may publish concurrently, but its | |
| 535 | 554 | // mandatory first-paint rebase waits for sendMu and therefore follows this | |
| 536 | 555 | // completed output transaction. | |
| 556 | + | ac.lastCursor = cursor.next | |
| 537 | 557 | ac.pipelineScratch = ac.pipelineCache | |
| 538 | 558 | ac.pipelineCache = composed.cache | |
| 539 | - | } | |
| 540 | - | if sendErr == nil { | |
| 559 | + | ||
| 541 | 560 | // A successful no-byte emission also commits: its renderer shadow still | |
| 542 | 561 | // represents the captured frame. Lock panes only under sendMu and with no | |
| 543 | 562 | // 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.hasStyle | 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 | |||
| 389 | if !changed { | 397 | if !changed { | ||
| 390 | - | return nil | 398 | + | 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 []byte | 403 | + | } |
| 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 b | 415 | + | 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 true | 499 | 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.Transport | 504 | var sendTr ports.Transport | ||
| 499 | var sendErr error | 505 | var sendErr error | ||
| 500 | // Metadata precedes any output bytes and is published on its own when the | 506 | // 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.SendAsync | 524 | 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 both | 552 | // Publish only after output preparation and transport emission both | ||
| 534 | // succeed. A cross-session transition may publish concurrently, but its | 553 | // succeed. A cross-session transition may publish concurrently, but its | ||
| 535 | // mandatory first-paint rebase waits for sendMu and therefore follows this | 554 | // 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.pipelineCache | 557 | ac.pipelineScratch = ac.pipelineCache | ||
| 538 | ac.pipelineCache = composed.cache | 558 | ac.pipelineCache = composed.cache | ||
| 539 | - | } | 559 | + | |
| 540 | - | if sendErr == nil { | |||
| 541 | // A successful no-byte emission also commits: its renderer shadow still | 560 | // A successful no-byte emission also commits: its renderer shadow still | ||
| 542 | // represents the captured frame. Lock panes only under sendMu and with no | 561 | // 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 ( | |||
| 15 | 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 | 28 | func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 22 | 29 | func (cacheFailTransport) Close() error { return nil } | |
| 23 | 30 | ||
| @@ -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) { | |||
| 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 | 299 | func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) { | |
| 212 | 300 | paneFrame := renderer.NewFrame(12, 5) | |
| 213 | 301 | for y, text := range []string{"AAAAAAAAAAAA", "BBBBBBBBBBBB", "CCCCCCCCCCCC", "DDDDDDDDDDDD", "EEEEEEEEEEEE"} { | |
| @@ -240,11 +328,12 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) { | |||
| 240 | 328 | stream := newOutputStateStream() | |
| 241 | 329 | prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset) | |
| 242 | 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 | 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 | 333 | client := vt.NewScreen(composed.frame.Width, composed.frame.Height) | |
| 246 | 334 | client.Write(terminalBytes) | |
| 247 | 335 | require.Equal(t, frameRows(composed.frame), frameRows(client.Frame)) | |
| 336 | + | prepared.commitNoSend() | |
| 248 | 337 | again, err := stream.renderer.Draw(composed.frame, nil) | |
| 249 | 338 | require.NoError(t, err) | |
| 250 | 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 | 372 | stream := newOutputStateStream() | |
| 284 | 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 | 375 | require.NoError(t, err) | |
| 287 | 376 | client := vt.NewScreen(4, 5) | |
| 288 | 377 | client.Write(firstBytes) | |
| 289 | 378 | ||
| 290 | 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 | 381 | require.NoError(t, err) | |
| 293 | 382 | require.Equal(t, tt.wantBytes, string(secondBytes)) | |
| 294 | 383 | 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 ( | |||
| 16 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | + | "github.com/bnema/vev/pkg/renderer" | |
| 19 | 20 | "github.com/bnema/vev/pkg/vt" | |
| 20 | 21 | ) | |
| 21 | 22 | ||
| @@ -1396,10 +1397,22 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |||
| 1396 | 1397 | ||
| 1397 | 1398 | ac.sendMu.Lock() | |
| 1398 | 1399 | 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 | 1405 | reset := false | |
| 1400 | 1406 | 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 | + | })) | |
| 1403 | 1416 | ac.sendMu.Unlock() | |
| 1404 | 1417 | out, err := ports.UnmarshalOutput(f.Payload) | |
| 1405 | 1418 | require.NoError(t, err) | |
| @@ -1409,9 +1422,16 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |||
| 1409 | 1422 | ac.sendMu.Lock() | |
| 1410 | 1423 | reset = true | |
| 1411 | 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 | 1433 | ac.sendMu.Unlock() | |
| 1414 | - | fullOut, err := ports.UnmarshalOutput(full.Payload) | |
| 1434 | + | fullOut, err := ports.UnmarshalOutput(fullOutput.Payload) | |
| 1415 | 1435 | require.NoError(t, err) | |
| 1416 | 1436 | require.Equal(t, uint64(0), fullOut.BaseStateNum) | |
| 1417 | 1437 | 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 = 5 | 1399 | 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 := false | 1405 | 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 = true | 1423 | 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() { | |||
| 198 | 198 | // beginRoleEffect is the sole role-bound effect admission point. The gate is | |
| 199 | 199 | // held only long enough to reserve the capability; the effect itself must run | |
| 200 | 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 | 211 | func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) { | |
| 202 | 212 | if ac == nil || token.ac != ac { | |
| 203 | 213 | return nil, false | |
| @@ -198,6 +198,16 @@ func (t *roleEffectTicket) endTransportSend() { | |||||
| 198 | // beginRoleEffect is the sole role-bound effect admission point. The gate is | 198 | // 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 run | 199 | // 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, false | 213 | return nil, false | ||
M internal/usecase/daemon/snatched_cleanup.go +1 -1
| @@ -187,7 +187,7 @@ func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionRe | |||
| 187 | 187 | } | |
| 188 | 188 | if token := result.displaced; token.ac != nil && token.transport.transport != nil { | |
| 189 | 189 | blockedRender := result.displacedInterrupted | |
| 190 | - | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) { | |
| 190 | + | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) && token.ac.transportEffectActive() { | |
| 191 | 191 | blockedRender = !token.ac.sendMu.TryLock() | |
| 192 | 192 | if !blockedRender { | |
| 193 | 193 | 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.displacedInterrupted | 189 | 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 ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | 8 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 8 | 9 | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -119,17 +120,32 @@ func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapsho | |||
| 119 | 120 | if err != nil { | |
| 120 | 121 | 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 | 125 | if effect != nil && !effect.beginTransportSend(expected) { | |
| 124 | 126 | return errAttachmentTransition | |
| 125 | 127 | } | |
| 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 { | |
| 128 | 141 | if err != nil { | |
| 129 | 142 | effect.reportTransportFailure(expected) | |
| 130 | 143 | } | |
| 131 | 144 | effect.endTransportSend() | |
| 132 | 145 | } | |
| 146 | + | if err == nil { | |
| 147 | + | ac.lastCursor = cursor.next | |
| 148 | + | } | |
| 133 | 149 | return err | |
| 134 | 150 | }) | |
| 135 | 151 | 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 err | 121 | 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 errAttachmentTransition | 126 | 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 err | 149 | 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) { | |||
| 138 | 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 | 178 | func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) { | |
| 142 | 179 | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 143 | 180 | 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 | |||
| 33 | 33 | require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1}) | |
| 34 | 34 | requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints) | |
| 35 | 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 | 37 | require.NoError(t, err) | |
| 38 | 38 | fullRenderer := renderer.New(renderer.Capabilities{}) | |
| 39 | 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 | 127 | func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) { | |
| 128 | 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 | 130 | require.NoError(t, err) | |
| 131 | 131 | terminal.Write(data) | |
| 132 | 132 | 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) { | |||
| 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 | 19 | func BenchmarkRendererFullFrameDraw(b *testing.B) { | |
| 14 | 20 | frame := NewFrame(120, 40) | |
| 15 | 21 | markBenchmarkFrame(&frame) | |
| @@ -63,7 +69,128 @@ func BenchmarkRendererFragmentedDamage(b *testing.B) { | |||
| 63 | 69 | var outBytes int64 | |
| 64 | 70 | for b.Loop() { | |
| 65 | 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 | 194 | out, err := r.Draw(frame, damage) | |
| 68 | 195 | if err != nil { | |
| 69 | 196 | b.Fatal(err) | |
| @@ -100,7 +227,7 @@ func BenchmarkRendererScrollFastPath(b *testing.B) { | |||
| 100 | 227 | b.ReportAllocs() | |
| 101 | 228 | var outBytes int64 | |
| 102 | 229 | for b.Loop() { | |
| 103 | - | r.replaceShadow(frame) | |
| 230 | + | setBenchmarkShadow(r, frame) | |
| 104 | 231 | out, err := r.Draw(scrolled, damage) | |
| 105 | 232 | if err != nil { | |
| 106 | 233 | 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 int64 | 69 | 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 int64 | 228 | 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 ( | |||
| 7 | 7 | ||
| 8 | 8 | const maxPlannedDamageSpans = 4096 | |
| 9 | 9 | ||
| 10 | - | type damageSpan struct { | |
| 11 | - | y, x, width int | |
| 12 | - | } | |
| 13 | - | ||
| 14 | 10 | // buildDamagePlan creates the bounded, canonical view of text and clear damage | |
| 15 | 11 | // used for terminal emission. The source damage remains untouched because the | |
| 16 | 12 | // 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) | |
| 19 | 15 | for _, d := range damage { | |
| 20 | 16 | if skip != nil && sameDamage(d, *skip) { | |
| 21 | 17 | continue | |
| @@ -31,21 +27,24 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]damageSpan, | |||
| 31 | 27 | if len(spans) == maxPlannedDamageSpans { | |
| 32 | 28 | 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 { | |
| 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 { | |
| 40 | 39 | 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 | 44 | return mergeDamageSpans(spans), false | |
| 46 | 45 | } | |
| 47 | 46 | ||
| 48 | - | func mergeDamageSpans(spans []damageSpan) []damageSpan { | |
| 47 | + | func mergeDamageSpans(spans []Span) []Span { | |
| 49 | 48 | if len(spans) < 2 { | |
| 50 | 49 | return spans | |
| 51 | 50 | } | |
| @@ -59,14 +58,14 @@ func mergeDamageSpans(spans []damageSpan) []damageSpan { | |||
| 59 | 58 | last := &out[len(out)-1] | |
| 60 | 59 | // Both endpoints are bounded by frame.Width after clampRect, so these | |
| 61 | 60 | // 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 { | |
| 65 | 64 | out = append(out, span) | |
| 66 | 65 | continue | |
| 67 | 66 | } | |
| 68 | 67 | if spanEnd > lastEnd { | |
| 69 | - | last.width = spanEnd - last.x | |
| 68 | + | last.Width = spanEnd - last.X | |
| 70 | 69 | } | |
| 71 | 70 | } | |
| 72 | 71 | return out | |
| @@ -7,15 +7,11 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | const maxPlannedDamageSpans = 4096 | 8 | 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 damage | 10 | // buildDamagePlan creates the bounded, canonical view of text and clear damage | ||
| 15 | // used for terminal emission. The source damage remains untouched because the | 11 | // 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 | continue | 17 | 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, true | 28 | 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 c | 39 | 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), false | 44 | 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 spans | 49 | 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 these | 59 | // 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.width | 61 | + | lastEnd := last.X + last.Width |
| 63 | - | spanEnd := span.x + span.width | 62 | + | 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 | continue | 65 | continue | ||
| 67 | } | 66 | } | ||
| 68 | if spanEnd > lastEnd { | 67 | if spanEnd > lastEnd { | ||
| 69 | - | last.width = spanEnd - last.x | 68 | + | last.Width = spanEnd - last.X |
| 70 | } | 69 | } | ||
| 71 | } | 70 | } | ||
| 72 | return out | 71 | return out | ||
M pkg/renderer/damage_plan_test.go +28 -21
| @@ -11,7 +11,8 @@ func TestBuildDamagePlan(t *testing.T) { | |||
| 11 | 11 | name string | |
| 12 | 12 | frame Frame | |
| 13 | 13 | damage []Damage | |
| 14 | - | want []damageSpan | |
| 14 | + | skip *Damage | |
| 15 | + | want []Span | |
| 15 | 16 | full bool | |
| 16 | 17 | }{ | |
| 17 | 18 | { | |
| @@ -21,7 +22,7 @@ func TestBuildDamagePlan(t *testing.T) { | |||
| 21 | 22 | {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1}, | |
| 22 | 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 | 28 | name: "merges text and clear", | |
| @@ -30,7 +31,7 @@ func TestBuildDamagePlan(t *testing.T) { | |||
| 30 | 31 | {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1}, | |
| 31 | 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 | 37 | name: "orders multi-row spans", | |
| @@ -41,10 +42,10 @@ func TestBuildDamagePlan(t *testing.T) { | |||
| 41 | 42 | {Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 2}, | |
| 42 | 43 | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1}, | |
| 43 | 44 | }, | |
| 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}, | |
| 48 | 49 | }, | |
| 49 | 50 | }, | |
| 50 | 51 | { | |
| @@ -54,7 +55,7 @@ func TestBuildDamagePlan(t *testing.T) { | |||
| 54 | 55 | {Kind: DamageText, X: -2, Y: -1, Width: 5, Height: 3}, | |
| 55 | 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 | 61 | name: "does not mutate input", | |
| @@ -63,7 +64,7 @@ func TestBuildDamagePlan(t *testing.T) { | |||
| 63 | 64 | {Kind: DamageText, X: 5, Y: 1, Width: 2, Height: 1}, | |
| 64 | 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 | 70 | name: "exceeding budget requests full redraw", | |
| @@ -72,12 +73,29 @@ func TestBuildDamagePlan(t *testing.T) { | |||
| 72 | 73 | want: nil, | |
| 73 | 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 | 95 | for _, tt := range tests { | |
| 78 | 96 | t.Run(tt.name, func(t *testing.T) { | |
| 79 | 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 | 99 | if full != tt.full || !reflect.DeepEqual(got, tt.want) { | |
| 82 | 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 | 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 | - | } | |
| @@ -11,7 +11,8 @@ func TestBuildDamagePlan(t *testing.T) { | |||||
| 11 | name string | 11 | name string | ||
| 12 | frame Frame | 12 | frame Frame | ||
| 13 | damage []Damage | 13 | damage []Damage | ||
| 14 | - | want []damageSpan | 14 | + | skip *Damage |
| 15 | + | want []Span | |||
| 15 | full bool | 16 | 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) { | |||
| 264 | 264 | func TestWideRunCursorTrackingAndEL(t *testing.T) { | |
| 265 | 265 | t.Run("cursor advances two columns per wide rune", func(t *testing.T) { | |
| 266 | 266 | // 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. | |
| 270 | 269 | r := New(Capabilities{}) | |
| 271 | 270 | base := NewFrame(8, 1) | |
| 272 | 271 | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |
| @@ -286,8 +285,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) { | |||
| 286 | 285 | {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1}, | |
| 287 | 286 | } | |
| 288 | 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. | |
| 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" | |
| 291 | 290 | if got != want { | |
| 292 | 291 | t.Fatalf("output = %q, want %q", got, want) | |
| 293 | 292 | } | |
| @@ -295,9 +294,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) { | |||
| 295 | 294 | ||
| 296 | 295 | t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) { | |
| 297 | 296 | // 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. | |
| 301 | 299 | r := New(Capabilities{}) | |
| 302 | 300 | base := NewFrame(8, 1) | |
| 303 | 301 | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |
| @@ -314,9 +312,9 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) { | |||
| 314 | 312 | {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1}, | |
| 315 | 313 | } | |
| 316 | 314 | 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" | |
| 320 | 318 | if got != want { | |
| 321 | 319 | t.Fatalf("output = %q, want %q", got, want) | |
| 322 | 320 | } | |
| @@ -425,7 +423,7 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) { | |||
| 425 | 423 | if err != nil { | |
| 426 | 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 | 427 | t.Fatalf("output = %q, want %q", got, want) | |
| 430 | 428 | } | |
| 431 | 429 | } | |
| @@ -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 one | 266 | // 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 | 267 | + | // column later. The literal space in the expected output bridges the |
| 268 | - | // position that reflects the terminal advancing two columns for the | 268 | + | // 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 lies | 296 | // A damage rect that ends on a wide head whose continuation lies | ||
| 298 | - | // outside the rect makes the terminal advance one column further than | 297 | + | // outside the rect is followed by a literal space that bridges the |
| 299 | - | // the rect width accounts for. Tracking must be dropped: the next | 298 | + | // 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 CUF | 315 | + | // 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 { | |||
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | 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 | |
| 24 | 34 | } | |
| 25 | 35 | ||
| 26 | 36 | func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} } | |
| @@ -29,102 +39,98 @@ func (r *Renderer) Reset() { | |||
| 29 | 39 | r.width = 0 | |
| 30 | 40 | r.height = 0 | |
| 31 | 41 | r.shadow = nil | |
| 42 | + | r.lineOffset = nil | |
| 32 | 43 | } | |
| 33 | 44 | ||
| 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 } | |
| 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 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} | |
| 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 | 86 | buf.Reset() | |
| 55 | 87 | defer putBuffer(buf) | |
| 56 | - | ||
| 57 | 88 | if r.caps.SynchronizedOutput { | |
| 58 | 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 | 92 | 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 { | |
| 86 | 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 | - | ||
| 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}) | |
| 103 | 103 | } | |
| 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) | |
| 115 | 106 | } | |
| 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") | |
| 123 | 108 | } | |
| 124 | 109 | if r.caps.SynchronizedOutput { | |
| 125 | 110 | buf.WriteString(SyncEndCSI) | |
| 126 | 111 | } | |
| 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 | |
| 128 | 134 | } | |
| 129 | 135 | ||
| 130 | 136 | // copyBytes copies the buffer contents into a fresh byte slice and is used | |
| @@ -143,15 +149,6 @@ func putBuffer(buf *bytes.Buffer) { | |||
| 143 | 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 | 152 | func needsFull(damage []Damage) bool { | |
| 156 | 153 | if len(damage) == 0 { | |
| 157 | 154 | return false | |
| @@ -180,100 +177,12 @@ func (r *Renderer) writeFull(out *bytes.Buffer, frame Frame, st *drawState) { | |||
| 180 | 177 | out.WriteString("\x1b[0m") | |
| 181 | 178 | } | |
| 182 | 179 | ||
| 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) { | |
| 206 | 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 | 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 | 186 | func clampRect(frame Frame, x, y, width, height int) (int, int, int, int, bool) { | |
| 278 | 187 | x, width, okX := clampRange(x, width, frame.Width) | |
| 279 | 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 | 214 | available := limit - pos | |
| 306 | - | if size > available { | |
| 307 | - | size = available | |
| 308 | - | } | |
| 309 | - | return pos, size, true | |
| 215 | + | return pos, min(size, available), true | |
| 310 | 216 | } | |
| 311 | 217 | ||
| 312 | 218 | // 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 Capabilities | 20 | + | caps Capabilities |
| 21 | - | width int | 21 | + | width int |
| 22 | - | height int | 22 | + | height int |
| 23 | - | shadow []Cell | 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 | |||
| 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 = 0 | 39 | r.width = 0 | ||
| 30 | r.height = 0 | 40 | r.height = 0 | ||
| 31 | r.shadow = nil | 41 | 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, err | 47 | + | 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 no | 61 | + | // Prepare plans and encodes a transactional draw. The renderer advances only |
| 42 | - | // damage or a structural redraw request that can be satisfied from the | 62 | + | // 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, nil | 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) { |
| 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 initial | 101 | + | 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), nil | 104 | + | 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), nil | 107 | + | 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), 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 | |||
| 128 | } | 134 | } | ||
| 129 | 135 | ||||
| 130 | // copyBytes copies the buffer contents into a fresh byte slice and is used | 136 | // 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 false | 154 | 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. It | 180 | + | 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 - pos | 214 | 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.Fprintf | 218 | // 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) { | |||
| 190 | 190 | if err != nil { | |
| 191 | 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 | 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 | 1315 | func TestWideCharDamageEmission(t *testing.T) { | |
| 1082 | 1316 | // Populate the shadow with a blank frame, then place a wide char and draw | |
| 1083 | 1317 | // 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 draw | 1316 | // 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 must | 1317 | // 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) { | |||
| 42 | 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 | 46 | for y := scroll.Y; y < scroll.Y+scroll.Height-scroll.Count; y++ { | |
| 47 | 47 | frameRow := frame.Row(y) | |
| 48 | - | shadowStart := (y+scroll.Count)*r.width + scroll.X | |
| 48 | + | committedRow := committed.Row(y + scroll.Count) | |
| 49 | 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 | 53 | continue | |
| 52 | 54 | } | |
| 53 | - | if !damageCoversCell(damage, scroll.X+x, y) { | |
| 55 | + | if !damageCoversCell(damage, column, y) { | |
| 54 | 56 | return false | |
| 55 | 57 | } | |
| 56 | 58 | } | |
| @@ -58,14 +60,6 @@ func (r *Renderer) canApplyScroll(frame Frame, scroll Damage, damage []Damage) b | |||
| 58 | 60 | 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 | 63 | func damageCoversCell(damage []Damage, x, y int) bool { | |
| 70 | 64 | for _, d := range damage { | |
| 71 | 65 | 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.X | 48 | + | 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 | continue | 53 | continue | ||
| 52 | } | 54 | } | ||
| 53 | - | if !damageCoversCell(damage, scroll.X+x, y) { | 55 | + | if !damageCoversCell(damage, column, y) { |
| 54 | return false | 56 | return false | ||
| 55 | } | 57 | } | ||
| 56 | } | 58 | } | ||
| @@ -58,14 +60,6 @@ func (r *Renderer) canApplyScroll(frame Frame, scroll Damage, damage []Damage) b | |||||
| 58 | return true | 60 | 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 { | ||