detached 4bd8450

merge(perf): ansi renderer reusable shadow frames and row compare

commit
4bd8450347b4969e726ac677475e70f97e08eb5c
author
brice · · 2026-10-02 09:50 UTC
merge of
540cc0d + 64a7a45 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +1006 -46
M ansi/delta_plan.go +35 -20
@@ -1,5 +1,7 @@
11 package ansi
22
3+import "github.com/bnema/vev-vt/core"
4+
35 // Span identifies a changed horizontal range in a logical frame row.
46 type Span struct {
57 Y int
@@ -31,35 +33,45 @@ type DeltaCandidate struct {
3133 frame Frame
3234 }
3335
34-func newDeltaCandidate(frame CellSource, plan DeltaPlan) DeltaCandidate {
36+// newDeltaCandidate builds a candidate whose snapshot, when the plan has work to
37+// commit, is copied into scratch (reusing its storage) or into a fresh frame
38+// when scratch is nil.
39+func newDeltaCandidate(frame CellSource, plan DeltaPlan, scratch *Frame) DeltaCandidate {
3540 candidate := DeltaCandidate{Plan: plan}
3641 if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
37- candidate.frame = cloneCellSource(frame)
42+ candidate.frame = snapshotCellSource(scratch, frame)
3843 }
3944 return candidate
4045 }
4146
42-// PlanDelta prepares a non-mutating update from committed to frame.
47+// PlanDelta prepares a non-mutating update from committed to frame. The
48+// returned candidate owns an independent snapshot of frame.
4349 func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {
50+ return planDelta(frame, damage, committed, reset, nil)
51+}
52+
53+// planDelta implements PlanDelta. A non-nil scratch receives the candidate's
54+// snapshot in place of a fresh allocation; the caller then owns its lifetime.
55+func planDelta(frame CellSource, damage []Damage, committed Frame, reset bool, scratch *Frame) (DeltaCandidate, error) {
4456 if err := validateCellSource(frame); err != nil {
4557 return DeltaCandidate{}, err
4658 }
4759
4860 if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() {
49- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil
61+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil
5062 }
5163 if err := committed.Validate(); err != nil {
5264 return DeltaCandidate{}, err
5365 }
5466
5567 if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
56- return newDeltaCandidate(frame, planSingleDamage(frame, damage[0])), nil
68+ return newDeltaCandidate(frame, planSingleDamage(frame, damage[0]), scratch), nil
5769 }
5870
5971 if needsFull(damage) {
6072 dirty, full := buildDirtyLinePlan(frame, committed)
6173 if len(dirty) > 0 || full {
62- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}), nil
74+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}, scratch), nil
6375 }
6476 return DeltaCandidate{}, nil
6577 }
@@ -70,7 +82,7 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (
7082 plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown}
7183 skip = &scroll
7284 } else if hasScrollDamage(damage) {
73- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil
85+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil
7486 }
7587
7688 var spans []Span
@@ -81,16 +93,16 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (
8193 spans, full = buildDamagePlan(frame, damage, skip)
8294 }
8395 if full {
84- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil
96+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil
8597 }
8698 if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) {
8799 if plan.Scroll.Height != 0 {
88- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil
100+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil
89101 }
90- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}), nil
102+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}, scratch), nil
91103 }
92104 plan.Spans = spans
93- return newDeltaCandidate(frame, plan), nil
105+ return newDeltaCandidate(frame, plan, scratch), nil
94106 }
95107
96108 func planSingleDamage(frame CellSource, d Damage) DeltaPlan {
@@ -107,12 +119,17 @@ func planSingleDamage(frame CellSource, d Damage) DeltaPlan {
107119
108120 func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) {
109121 var spans []Span
122+ fast, fastOK := compactFrames(frame, committed)
110123 for y := range frame.Rows() {
111124 dirty := false
112- for x := range frame.Columns() {
113- if !frame.Cell(x, y).Equal(committed.Cell(x, y)) {
114- dirty = true
115- break
125+ if fastOK {
126+ dirty = !core.RowsEqual(fast.a, fast.b, y)
127+ } else {
128+ for x := range frame.Columns() {
129+ if !frame.Cell(x, y).Equal(committed.Cell(x, y)) {
130+ dirty = true
131+ break
132+ }
116133 }
117134 }
118135 if dirty {
@@ -198,7 +215,9 @@ func (c DeltaCandidate) Commit(dst *Frame) {
198215 return
199216 }
200217 if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height {
201- replaceFrame(dst, c.frame)
218+ // dst may be shared with the caller, so give it a fresh page rather
219+ // than overwriting storage other Frame values might still reference.
220+ dst.Replace(c.frame)
202221 return
203222 }
204223 if c.Plan.Scroll.Height != 0 {
@@ -215,7 +234,3 @@ func (c DeltaCandidate) Commit(dst *Frame) {
215234 }
216235 }
217236 }
218-
219-func replaceFrame(dst *Frame, src Frame) {
220- dst.Replace(src)
221-}
@@ -1,5 +1,7 @@
1 package ansi1 package ansi
2 2
3+import "github.com/bnema/vev-vt/core"
4+
3 // Span identifies a changed horizontal range in a logical frame row.5 // Span identifies a changed horizontal range in a logical frame row.
4 type Span struct {6 type Span struct {
5 Y int7 Y int
@@ -31,35 +33,45 @@ type DeltaCandidate struct {
31 frame Frame33 frame Frame
32 }34 }
33 35
34-func newDeltaCandidate(frame CellSource, plan DeltaPlan) DeltaCandidate {36+// newDeltaCandidate builds a candidate whose snapshot, when the plan has work to
37+// commit, is copied into scratch (reusing its storage) or into a fresh frame
38+// when scratch is nil.
39+func newDeltaCandidate(frame CellSource, plan DeltaPlan, scratch *Frame) DeltaCandidate {
35 candidate := DeltaCandidate{Plan: plan}40 candidate := DeltaCandidate{Plan: plan}
36 if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {41 if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
37- candidate.frame = cloneCellSource(frame)42+ candidate.frame = snapshotCellSource(scratch, frame)
38 }43 }
39 return candidate44 return candidate
40 }45 }
41 46
42-// PlanDelta prepares a non-mutating update from committed to frame.47+// PlanDelta prepares a non-mutating update from committed to frame. The
48+// returned candidate owns an independent snapshot of frame.
43 func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {49 func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (DeltaCandidate, error) {
50+ return planDelta(frame, damage, committed, reset, nil)
51+}
52+
53+// planDelta implements PlanDelta. A non-nil scratch receives the candidate's
54+// snapshot in place of a fresh allocation; the caller then owns its lifetime.
55+func planDelta(frame CellSource, damage []Damage, committed Frame, reset bool, scratch *Frame) (DeltaCandidate, error) {
44 if err := validateCellSource(frame); err != nil {56 if err := validateCellSource(frame); err != nil {
45 return DeltaCandidate{}, err57 return DeltaCandidate{}, err
46 }58 }
47 59
48 if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() {60 if reset || frame.Columns() != committed.Columns() || frame.Rows() != committed.Rows() {
49- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil61+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil
50 }62 }
51 if err := committed.Validate(); err != nil {63 if err := committed.Validate(); err != nil {
52 return DeltaCandidate{}, err64 return DeltaCandidate{}, err
53 }65 }
54 66
55 if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {67 if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
56- return newDeltaCandidate(frame, planSingleDamage(frame, damage[0])), nil68+ return newDeltaCandidate(frame, planSingleDamage(frame, damage[0]), scratch), nil
57 }69 }
58 70
59 if needsFull(damage) {71 if needsFull(damage) {
60 dirty, full := buildDirtyLinePlan(frame, committed)72 dirty, full := buildDirtyLinePlan(frame, committed)
61 if len(dirty) > 0 || full {73 if len(dirty) > 0 || full {
62- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}), nil74+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: dirty}, scratch), nil
63 }75 }
64 return DeltaCandidate{}, nil76 return DeltaCandidate{}, nil
65 }77 }
@@ -70,7 +82,7 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (
70 plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown}82 plan.Scroll = Scroll{Y: scroll.Y, Height: scroll.Height, Count: scroll.Count, Down: scroll.Kind == DamageScrollDown}
71 skip = &scroll83 skip = &scroll
72 } else if hasScrollDamage(damage) {84 } else if hasScrollDamage(damage) {
73- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil85+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil
74 }86 }
75 87
76 var spans []Span88 var spans []Span
@@ -81,16 +93,16 @@ func PlanDelta(frame CellSource, damage []Damage, committed Frame, reset bool) (
81 spans, full = buildDamagePlan(frame, damage, skip)93 spans, full = buildDamagePlan(frame, damage, skip)
82 }94 }
83 if full {95 if full {
84- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil96+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil
85 }97 }
86 if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) {98 if deltaCostsSnapshot(frame, spans, plan.Scroll.Height != 0) {
87 if plan.Scroll.Height != 0 {99 if plan.Scroll.Height != 0 {
88- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}), nil100+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true}, scratch), nil
89 }101 }
90- return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}), nil102+ return newDeltaCandidate(frame, DeltaPlan{Snapshot: true, Spans: spans}, scratch), nil
91 }103 }
92 plan.Spans = spans104 plan.Spans = spans
93- return newDeltaCandidate(frame, plan), nil105+ return newDeltaCandidate(frame, plan, scratch), nil
94 }106 }
95 107
96 func planSingleDamage(frame CellSource, d Damage) DeltaPlan {108 func planSingleDamage(frame CellSource, d Damage) DeltaPlan {
@@ -107,12 +119,17 @@ func planSingleDamage(frame CellSource, d Damage) DeltaPlan {
107 119
108 func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) {120 func buildDirtyLinePlan(frame, committed CellSource) ([]Span, bool) {
109 var spans []Span121 var spans []Span
122+ fast, fastOK := compactFrames(frame, committed)
110 for y := range frame.Rows() {123 for y := range frame.Rows() {
111 dirty := false124 dirty := false
112- for x := range frame.Columns() {125+ if fastOK {
113- if !frame.Cell(x, y).Equal(committed.Cell(x, y)) {126+ dirty = !core.RowsEqual(fast.a, fast.b, y)
114- dirty = true127+ } else {
115- break128+ for x := range frame.Columns() {
129+ if !frame.Cell(x, y).Equal(committed.Cell(x, y)) {
130+ dirty = true
131+ break
132+ }
116 }133 }
117 }134 }
118 if dirty {135 if dirty {
@@ -198,7 +215,9 @@ func (c DeltaCandidate) Commit(dst *Frame) {
198 return215 return
199 }216 }
200 if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height {217 if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height {
201- replaceFrame(dst, c.frame)218+ // dst may be shared with the caller, so give it a fresh page rather
219+ // than overwriting storage other Frame values might still reference.
220+ dst.Replace(c.frame)
202 return221 return
203 }222 }
204 if c.Plan.Scroll.Height != 0 {223 if c.Plan.Scroll.Height != 0 {
@@ -215,7 +234,3 @@ func (c DeltaCandidate) Commit(dst *Frame) {
215 }234 }
216 }235 }
217 }236 }
218-
219-func replaceFrame(dst *Frame, src Frame) {
220- dst.Replace(src)
221-}
M ansi/frame.go +58 -0
@@ -39,6 +39,64 @@ func validateCellSource(source CellSource) error {
3939 return nil
4040 }
4141
42+// snapshotCellSource returns an independent Frame holding source's cells. When
43+// scratch is non-nil the snapshot is written into scratch's existing storage
44+// (allocating only to grow or on first use) and the returned Frame shares that
45+// storage; the caller owns scratch and must not hand it to anyone else while
46+// the snapshot is live. A nil scratch allocates a fresh frame.
47+func snapshotCellSource(scratch *Frame, source CellSource) Frame {
48+ if scratch == nil {
49+ return cloneCellSource(source)
50+ }
51+ switch frame := source.(type) {
52+ case Frame:
53+ scratch.CopyFrom(frame)
54+ return *scratch
55+ case *Frame:
56+ scratch.CopyFrom(*frame)
57+ return *scratch
58+ }
59+ columns, rows := source.Columns(), source.Rows()
60+ if scratch.Width != columns || scratch.Height != rows || scratch.Validate() != nil {
61+ *scratch = NewFrame(columns, rows)
62+ }
63+ for y := range rows {
64+ for x := range columns {
65+ scratch.Set(x, y, source.Cell(x, y))
66+ }
67+ }
68+ return *scratch
69+}
70+
71+// framePair holds two compact frames compared through core.RowsEqual.
72+type framePair struct{ a, b Frame }
73+
74+// compactFrames reports whether both sources are core.Frame values (directly or
75+// by pointer), enabling stored-cell comparison without the CellSource interface.
76+func compactFrames(a, b CellSource) (framePair, bool) {
77+ fa, ok := asFrame(a)
78+ if !ok {
79+ return framePair{}, false
80+ }
81+ fb, ok := asFrame(b)
82+ if !ok {
83+ return framePair{}, false
84+ }
85+ return framePair{fa, fb}, true
86+}
87+
88+func asFrame(source CellSource) (Frame, bool) {
89+ switch frame := source.(type) {
90+ case Frame:
91+ return frame, true
92+ case *Frame:
93+ if frame != nil {
94+ return *frame, true
95+ }
96+ }
97+ return Frame{}, false
98+}
99+
42100 func cloneCellSource(source CellSource) Frame {
43101 switch frame := source.(type) {
44102 case Frame:
@@ -39,6 +39,64 @@ func validateCellSource(source CellSource) error {
39 return nil39 return nil
40 }40 }
41 41
42+// snapshotCellSource returns an independent Frame holding source's cells. When
43+// scratch is non-nil the snapshot is written into scratch's existing storage
44+// (allocating only to grow or on first use) and the returned Frame shares that
45+// storage; the caller owns scratch and must not hand it to anyone else while
46+// the snapshot is live. A nil scratch allocates a fresh frame.
47+func snapshotCellSource(scratch *Frame, source CellSource) Frame {
48+ if scratch == nil {
49+ return cloneCellSource(source)
50+ }
51+ switch frame := source.(type) {
52+ case Frame:
53+ scratch.CopyFrom(frame)
54+ return *scratch
55+ case *Frame:
56+ scratch.CopyFrom(*frame)
57+ return *scratch
58+ }
59+ columns, rows := source.Columns(), source.Rows()
60+ if scratch.Width != columns || scratch.Height != rows || scratch.Validate() != nil {
61+ *scratch = NewFrame(columns, rows)
62+ }
63+ for y := range rows {
64+ for x := range columns {
65+ scratch.Set(x, y, source.Cell(x, y))
66+ }
67+ }
68+ return *scratch
69+}
70+
71+// framePair holds two compact frames compared through core.RowsEqual.
72+type framePair struct{ a, b Frame }
73+
74+// compactFrames reports whether both sources are core.Frame values (directly or
75+// by pointer), enabling stored-cell comparison without the CellSource interface.
76+func compactFrames(a, b CellSource) (framePair, bool) {
77+ fa, ok := asFrame(a)
78+ if !ok {
79+ return framePair{}, false
80+ }
81+ fb, ok := asFrame(b)
82+ if !ok {
83+ return framePair{}, false
84+ }
85+ return framePair{fa, fb}, true
86+}
87+
88+func asFrame(source CellSource) (Frame, bool) {
89+ switch frame := source.(type) {
90+ case Frame:
91+ return frame, true
92+ case *Frame:
93+ if frame != nil {
94+ return *frame, true
95+ }
96+ }
97+ return Frame{}, false
98+}
99+
42 func cloneCellSource(source CellSource) Frame {100 func cloneCellSource(source CellSource) Frame {
43 switch frame := source.(type) {101 switch frame := source.(type) {
44 case Frame:102 case Frame:
M ansi/prepared_ownership_test.go +176 -0
@@ -1,6 +1,7 @@
11 package ansi
22
33 import (
4+ "runtime"
45 "testing"
56
67 "github.com/stretchr/testify/require"
@@ -23,3 +24,178 @@ func TestPreparedSnapshotCommitTransfersOnlyPrivatelyOwnedCells(t *testing.T) {
2324 require.Equal(t, 'b', r.committed.Cell(0, 0).Rune, "a retained draw copy cannot recommit stale cells")
2425 require.Equal(t, encoded, alias.Bytes())
2526 }
27+
28+func TestRendererNeverAliasesCallerFrame(t *testing.T) {
29+ r := New(Capabilities{})
30+ frame := NewFrame(6, 3)
31+ markFrame(&frame)
32+ _, err := r.Draw(frame, []Damage{FullRedraw()})
33+ require.NoError(t, err)
34+
35+ // Mutate the caller's frame in place after commit without drawing, then
36+ // restore it: the renderer's committed state must not have followed it.
37+ original := frame.Cell(2, 1)
38+ frame.Set(2, 1, Cell{Rune: 'Q', Style: Style{Bold: true, Foreground: 1, Background: -1}})
39+ require.Equal(t, original.Rune, r.committed.Cell(2, 1).Rune)
40+ frame.Set(2, 1, original)
41+ prepared, err := r.Prepare(frame, nil, false)
42+ require.NoError(t, err)
43+ require.Empty(t, prepared.Bytes(), "committed state followed caller mutation")
44+ prepared.Commit()
45+
46+ // Mutate after Prepare and before Commit: the commit keeps the prepared
47+ // content, so the next draw re-emits the difference.
48+ frame.Set(0, 0, Cell{Rune: 'P', Style: DefaultStyle()})
49+ prepared, err = r.Prepare(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
50+ require.NoError(t, err)
51+ frame.Set(0, 0, Cell{Rune: 'R', Style: DefaultStyle()})
52+ prepared.Commit()
53+ require.Equal(t, 'P', r.committed.Cell(0, 0).Rune)
54+ out, err := r.Draw(frame, nil)
55+ require.NoError(t, err)
56+ require.Contains(t, string(out), "R")
57+ require.Equal(t, 'R', r.committed.Cell(0, 0).Rune)
58+ require.NoError(t, r.committed.CheckInvariants())
59+}
60+
61+func TestDiscardedPreparedDrawLeavesCommittedStateUnchanged(t *testing.T) {
62+ r := New(Capabilities{})
63+ frame := NewFrame(5, 3)
64+ markFrame(&frame)
65+ _, err := r.Draw(frame, []Damage{FullRedraw()})
66+ require.NoError(t, err)
67+ committed := r.committed.Clone()
68+
69+ for _, damage := range [][]Damage{
70+ {FullRedraw()},
71+ {{Kind: DamageText, X: 0, Y: 1, Width: 5, Height: 1}},
72+ nil,
73+ } {
74+ frame.Set(1, 1, Cell{Rune: '#', Style: Style{Italic: true, Foreground: 2, Background: -1}})
75+ discarded, err := r.Prepare(frame, damage, false)
76+ require.NoError(t, err)
77+ require.NotEmpty(t, discarded.Bytes())
78+ for y := range committed.Height {
79+ require.Equal(t, committed.Row(y), r.committed.Row(y))
80+ }
81+ // Preparing again after a discard produces the same bytes.
82+ again, err := r.Prepare(frame, damage, false)
83+ require.NoError(t, err)
84+ require.Equal(t, discarded.Bytes(), again.Bytes())
85+ discarded.Commit() // superseded by a later Prepare: must not apply
86+ for y := range committed.Height {
87+ require.Equal(t, committed.Row(y), r.committed.Row(y))
88+ }
89+ again.Commit()
90+ require.Equal(t, '#', r.committed.Cell(1, 1).Rune)
91+ require.NoError(t, r.committed.CheckInvariants())
92+
93+ // Restore for the next scenario.
94+ frame.Set(1, 1, committed.Cell(1, 1))
95+ _, err = r.Draw(frame, nil)
96+ require.NoError(t, err)
97+ require.Equal(t, committed.Cell(1, 1).Rune, r.committed.Cell(1, 1).Rune)
98+ }
99+}
100+
101+func TestPreparedBytesSurviveLaterDraws(t *testing.T) {
102+ r := New(Capabilities{})
103+ frame := NewFrame(8, 2)
104+ markFrame(&frame)
105+ first, err := r.Prepare(frame, []Damage{FullRedraw()}, false)
106+ require.NoError(t, err)
107+ want := append([]byte(nil), first.Bytes()...)
108+ first.Commit()
109+ for i := range 5 {
110+ frame.Set(i, 0, Cell{Rune: rune('v' + i%3), Style: DefaultStyle()})
111+ _, err = r.Draw(frame, []Damage{{Kind: DamageText, X: i, Y: 0, Width: 1, Height: 1}})
112+ require.NoError(t, err)
113+ }
114+ require.Equal(t, want, first.Bytes())
115+}
116+
117+func TestRendererRepeatedDrawsMatchFreshRenderer(t *testing.T) {
118+ // A long mixed sequence on one renderer (buffer reuse, resizes, reset,
119+ // scroll) must keep emitting exactly what a renderer with an exact
120+ // reference committed frame would: full redraw after each step equals
121+ // the fresh-renderer snapshot, and no-damage diffs are empty.
122+ r := New(Capabilities{})
123+ frame := NewFrame(7, 4)
124+ markFrame(&frame)
125+ steps := []func(){
126+ func() { frame.Set(3, 2, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 4, Background: -1}}) },
127+ func() { frame.ScrollUp(0, 3, 1); frame.FillRow(3, 0, 7, Cell{Rune: 'n', Style: DefaultStyle()}) },
128+ func() { frame = NewFrame(9, 5); markFrame(&frame) },
129+ func() { frame.Set(8, 4, Cell{Rune: 'z', Style: DefaultStyle()}) },
130+ func() {
131+ frame = NewFrame(7, 4)
132+ markFrame(&frame)
133+ frame.Set(0, 0, Cell{Rune: 'k', Style: DefaultStyle()})
134+ },
135+ }
136+ for i, step := range steps {
137+ step()
138+ if i == 3 {
139+ r.Reset()
140+ }
141+ _, err := r.Draw(frame, nil)
142+ require.NoError(t, err)
143+ for y := range frame.Height {
144+ require.Equal(t, frame.Row(y), r.committed.Row(y), "step %d row %d", i, y)
145+ }
146+ out, err := r.Draw(frame, nil)
147+ require.NoError(t, err)
148+ require.Empty(t, out, "step %d", i)
149+ // Reset keeps equivalence with a brand new renderer.
150+ r2 := New(Capabilities{})
151+ want, err := r2.Draw(frame, nil)
152+ require.NoError(t, err)
153+ r.Reset()
154+ got, err := r.Draw(frame, nil)
155+ require.NoError(t, err)
156+ require.Equal(t, want, got, "step %d", i)
157+ }
158+}
159+
160+func TestPrepareCommitDoesNotAllocateFrameStorage(t *testing.T) {
161+ r := New(Capabilities{})
162+ frame := NewFrame(120, 40)
163+ markFrame(&frame)
164+ _, err := r.Draw(frame, []Damage{FullRedraw()})
165+ require.NoError(t, err)
166+ _, err = r.Draw(frame, []Damage{FullRedraw()})
167+ require.NoError(t, err)
168+
169+ // A whole frame is ~76 KB; steady-state draws must stay far below it.
170+ toggled := false
171+ damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}
172+ allocs := testing.AllocsPerRun(20, func() {
173+ toggled = !toggled
174+ ch := 'X'
175+ if toggled {
176+ ch = 'Y'
177+ }
178+ frame.Set(60, 20, Cell{Rune: ch, Style: DefaultStyle()})
179+ prepared, err := r.Prepare(frame, damage, false)
180+ if err != nil {
181+ t.Fatal(err)
182+ }
183+ prepared.Commit()
184+ })
185+ require.LessOrEqual(t, allocs, 8.0)
186+ var m1, m2 runtime.MemStats
187+ runtime.ReadMemStats(&m1)
188+ for range 50 {
189+ toggled = !toggled
190+ frame.Set(60, 20, Cell{Rune: 'W', Style: DefaultStyle()})
191+ prepared, err := r.Prepare(frame, damage, false)
192+ require.NoError(t, err)
193+ prepared.Commit()
194+ frame.Set(60, 20, Cell{Rune: 'V', Style: DefaultStyle()})
195+ prepared, err = r.Prepare(frame, damage, false)
196+ require.NoError(t, err)
197+ prepared.Commit()
198+ }
199+ runtime.ReadMemStats(&m2)
200+ require.Less(t, (m2.TotalAlloc-m1.TotalAlloc)/100, uint64(8*1024), "per-draw bytes allocated")
201+}
@@ -1,6 +1,7 @@
1 package ansi1 package ansi
2 2
3 import (3 import (
4+ "runtime"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
@@ -23,3 +24,178 @@ func TestPreparedSnapshotCommitTransfersOnlyPrivatelyOwnedCells(t *testing.T) {
23 require.Equal(t, 'b', r.committed.Cell(0, 0).Rune, "a retained draw copy cannot recommit stale cells")24 require.Equal(t, 'b', r.committed.Cell(0, 0).Rune, "a retained draw copy cannot recommit stale cells")
24 require.Equal(t, encoded, alias.Bytes())25 require.Equal(t, encoded, alias.Bytes())
25 }26 }
27+
28+func TestRendererNeverAliasesCallerFrame(t *testing.T) {
29+ r := New(Capabilities{})
30+ frame := NewFrame(6, 3)
31+ markFrame(&frame)
32+ _, err := r.Draw(frame, []Damage{FullRedraw()})
33+ require.NoError(t, err)
34+
35+ // Mutate the caller's frame in place after commit without drawing, then
36+ // restore it: the renderer's committed state must not have followed it.
37+ original := frame.Cell(2, 1)
38+ frame.Set(2, 1, Cell{Rune: 'Q', Style: Style{Bold: true, Foreground: 1, Background: -1}})
39+ require.Equal(t, original.Rune, r.committed.Cell(2, 1).Rune)
40+ frame.Set(2, 1, original)
41+ prepared, err := r.Prepare(frame, nil, false)
42+ require.NoError(t, err)
43+ require.Empty(t, prepared.Bytes(), "committed state followed caller mutation")
44+ prepared.Commit()
45+
46+ // Mutate after Prepare and before Commit: the commit keeps the prepared
47+ // content, so the next draw re-emits the difference.
48+ frame.Set(0, 0, Cell{Rune: 'P', Style: DefaultStyle()})
49+ prepared, err = r.Prepare(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, false)
50+ require.NoError(t, err)
51+ frame.Set(0, 0, Cell{Rune: 'R', Style: DefaultStyle()})
52+ prepared.Commit()
53+ require.Equal(t, 'P', r.committed.Cell(0, 0).Rune)
54+ out, err := r.Draw(frame, nil)
55+ require.NoError(t, err)
56+ require.Contains(t, string(out), "R")
57+ require.Equal(t, 'R', r.committed.Cell(0, 0).Rune)
58+ require.NoError(t, r.committed.CheckInvariants())
59+}
60+
61+func TestDiscardedPreparedDrawLeavesCommittedStateUnchanged(t *testing.T) {
62+ r := New(Capabilities{})
63+ frame := NewFrame(5, 3)
64+ markFrame(&frame)
65+ _, err := r.Draw(frame, []Damage{FullRedraw()})
66+ require.NoError(t, err)
67+ committed := r.committed.Clone()
68+
69+ for _, damage := range [][]Damage{
70+ {FullRedraw()},
71+ {{Kind: DamageText, X: 0, Y: 1, Width: 5, Height: 1}},
72+ nil,
73+ } {
74+ frame.Set(1, 1, Cell{Rune: '#', Style: Style{Italic: true, Foreground: 2, Background: -1}})
75+ discarded, err := r.Prepare(frame, damage, false)
76+ require.NoError(t, err)
77+ require.NotEmpty(t, discarded.Bytes())
78+ for y := range committed.Height {
79+ require.Equal(t, committed.Row(y), r.committed.Row(y))
80+ }
81+ // Preparing again after a discard produces the same bytes.
82+ again, err := r.Prepare(frame, damage, false)
83+ require.NoError(t, err)
84+ require.Equal(t, discarded.Bytes(), again.Bytes())
85+ discarded.Commit() // superseded by a later Prepare: must not apply
86+ for y := range committed.Height {
87+ require.Equal(t, committed.Row(y), r.committed.Row(y))
88+ }
89+ again.Commit()
90+ require.Equal(t, '#', r.committed.Cell(1, 1).Rune)
91+ require.NoError(t, r.committed.CheckInvariants())
92+
93+ // Restore for the next scenario.
94+ frame.Set(1, 1, committed.Cell(1, 1))
95+ _, err = r.Draw(frame, nil)
96+ require.NoError(t, err)
97+ require.Equal(t, committed.Cell(1, 1).Rune, r.committed.Cell(1, 1).Rune)
98+ }
99+}
100+
101+func TestPreparedBytesSurviveLaterDraws(t *testing.T) {
102+ r := New(Capabilities{})
103+ frame := NewFrame(8, 2)
104+ markFrame(&frame)
105+ first, err := r.Prepare(frame, []Damage{FullRedraw()}, false)
106+ require.NoError(t, err)
107+ want := append([]byte(nil), first.Bytes()...)
108+ first.Commit()
109+ for i := range 5 {
110+ frame.Set(i, 0, Cell{Rune: rune('v' + i%3), Style: DefaultStyle()})
111+ _, err = r.Draw(frame, []Damage{{Kind: DamageText, X: i, Y: 0, Width: 1, Height: 1}})
112+ require.NoError(t, err)
113+ }
114+ require.Equal(t, want, first.Bytes())
115+}
116+
117+func TestRendererRepeatedDrawsMatchFreshRenderer(t *testing.T) {
118+ // A long mixed sequence on one renderer (buffer reuse, resizes, reset,
119+ // scroll) must keep emitting exactly what a renderer with an exact
120+ // reference committed frame would: full redraw after each step equals
121+ // the fresh-renderer snapshot, and no-damage diffs are empty.
122+ r := New(Capabilities{})
123+ frame := NewFrame(7, 4)
124+ markFrame(&frame)
125+ steps := []func(){
126+ func() { frame.Set(3, 2, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 4, Background: -1}}) },
127+ func() { frame.ScrollUp(0, 3, 1); frame.FillRow(3, 0, 7, Cell{Rune: 'n', Style: DefaultStyle()}) },
128+ func() { frame = NewFrame(9, 5); markFrame(&frame) },
129+ func() { frame.Set(8, 4, Cell{Rune: 'z', Style: DefaultStyle()}) },
130+ func() {
131+ frame = NewFrame(7, 4)
132+ markFrame(&frame)
133+ frame.Set(0, 0, Cell{Rune: 'k', Style: DefaultStyle()})
134+ },
135+ }
136+ for i, step := range steps {
137+ step()
138+ if i == 3 {
139+ r.Reset()
140+ }
141+ _, err := r.Draw(frame, nil)
142+ require.NoError(t, err)
143+ for y := range frame.Height {
144+ require.Equal(t, frame.Row(y), r.committed.Row(y), "step %d row %d", i, y)
145+ }
146+ out, err := r.Draw(frame, nil)
147+ require.NoError(t, err)
148+ require.Empty(t, out, "step %d", i)
149+ // Reset keeps equivalence with a brand new renderer.
150+ r2 := New(Capabilities{})
151+ want, err := r2.Draw(frame, nil)
152+ require.NoError(t, err)
153+ r.Reset()
154+ got, err := r.Draw(frame, nil)
155+ require.NoError(t, err)
156+ require.Equal(t, want, got, "step %d", i)
157+ }
158+}
159+
160+func TestPrepareCommitDoesNotAllocateFrameStorage(t *testing.T) {
161+ r := New(Capabilities{})
162+ frame := NewFrame(120, 40)
163+ markFrame(&frame)
164+ _, err := r.Draw(frame, []Damage{FullRedraw()})
165+ require.NoError(t, err)
166+ _, err = r.Draw(frame, []Damage{FullRedraw()})
167+ require.NoError(t, err)
168+
169+ // A whole frame is ~76 KB; steady-state draws must stay far below it.
170+ toggled := false
171+ damage := []Damage{{Kind: DamageText, X: 60, Y: 20, Width: 1, Height: 1}}
172+ allocs := testing.AllocsPerRun(20, func() {
173+ toggled = !toggled
174+ ch := 'X'
175+ if toggled {
176+ ch = 'Y'
177+ }
178+ frame.Set(60, 20, Cell{Rune: ch, Style: DefaultStyle()})
179+ prepared, err := r.Prepare(frame, damage, false)
180+ if err != nil {
181+ t.Fatal(err)
182+ }
183+ prepared.Commit()
184+ })
185+ require.LessOrEqual(t, allocs, 8.0)
186+ var m1, m2 runtime.MemStats
187+ runtime.ReadMemStats(&m1)
188+ for range 50 {
189+ toggled = !toggled
190+ frame.Set(60, 20, Cell{Rune: 'W', Style: DefaultStyle()})
191+ prepared, err := r.Prepare(frame, damage, false)
192+ require.NoError(t, err)
193+ prepared.Commit()
194+ frame.Set(60, 20, Cell{Rune: 'V', Style: DefaultStyle()})
195+ prepared, err = r.Prepare(frame, damage, false)
196+ require.NoError(t, err)
197+ prepared.Commit()
198+ }
199+ runtime.ReadMemStats(&m2)
200+ require.Less(t, (m2.TotalAlloc-m1.TotalAlloc)/100, uint64(8*1024), "per-draw bytes allocated")
201+}
M ansi/renderer.go +46 -26
@@ -24,6 +24,12 @@ type Renderer struct {
2424 height int
2525 hasCommitted bool
2626 committed Frame
27+ // scratch receives the snapshot of the frame being prepared. It never
28+ // aliases committed or caller storage: a snapshot commit swaps the two so
29+ // both buffers are reused across draws without allocating.
30+ scratch Frame
31+ // generation identifies the latest prepared draw that owns scratch.
32+ generation uint64
2733 }
2834
2935 // PreparedDraw owns encoded output and its transactional delta until Commit.
@@ -32,6 +38,7 @@ type PreparedDraw struct {
3238 renderer *Renderer
3339 candidate DeltaCandidate
3440 data []byte
41+ generation uint64
3542 commitOnce *sync.Once
3643 }
3744
@@ -48,7 +55,7 @@ func (r *Renderer) Reset() {
4855 r.width = 0
4956 r.height = 0
5057 r.hasCommitted = false
51- r.committed = Frame{}
58+ // The frame buffers are kept for reuse; hasCommitted gates their content.
5259 }
5360
5461 // Bytes returns the prepared ANSI output. The returned bytes remain valid after
@@ -57,46 +64,70 @@ func (p PreparedDraw) Bytes() []byte { return p.data }
5764
5865 // Commit applies the prepared delta exactly once. Discarding it leaves the
5966 // renderer's committed state unchanged.
67+//
68+// Committing a draw that a later Prepare has superseded does not apply it:
69+// the renderer instead drops its committed state, so the next Prepare
70+// re-emits a full snapshot instead of diffing against a shadow that may no
71+// longer match the terminal. Draws without output are unaffected.
6072 func (p *PreparedDraw) Commit() {
6173 if p == nil || p.renderer == nil || p.commitOnce == nil {
6274 return
6375 }
6476 p.commitOnce.Do(func() {
65- committed := p.renderer.committedFrame()
66- if p.candidate.Plan.Snapshot {
67- // PreparedDraw privately owns this snapshot. Its shared Once makes
68- // transfer safe even when the caller retains copies of the draw.
69- committed = p.candidate.frame
77+ r := p.renderer
78+ plan := p.candidate.Plan
79+ if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
80+ return
81+ }
82+ if p.generation != r.generation {
83+ // A later Prepare reused the scratch frame this draw's snapshot
84+ // lives in, so it cannot be applied. Its bytes may nevertheless
85+ // have reached the terminal, which would then differ from the
86+ // committed shadow in unknown ways: forget the shadow so the next
87+ // Prepare emits a full snapshot.
88+ r.hasCommitted = false
89+ return
90+ }
91+ if plan.Snapshot || !r.hasCommitted {
92+ // The snapshot lives in the renderer's scratch buffer; promote it
93+ // and recycle the previous committed buffer as the next scratch.
94+ r.committed, r.scratch = p.candidate.frame, r.committed
7095 } else {
71- p.candidate.Commit(&committed)
96+ p.candidate.Commit(&r.committed)
7297 }
73- p.renderer.setCommittedFrame(committed)
98+ r.width = r.committed.Width
99+ r.height = r.committed.Height
100+ r.hasCommitted = true
74101 })
75102 }
76103
77104 // Prepare plans and encodes a transactional draw. The renderer advances only
78105 // when the returned draw is committed. Keep at most one prepared draw
79-// outstanding; commit or discard it before calling Prepare again.
106+// outstanding; commit or discard it before calling Prepare again. A draw
107+// superseded by a later Prepare is discarded and its Commit does nothing.
108+//
109+// The renderer copies frame into reusable private storage, so the caller may
110+// keep mutating frame between draws.
80111 func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) {
81112 var candidate DeltaCandidate
82113 var err error
83114 if err = validateCellSource(frame); err != nil {
84115 return PreparedDraw{}, err
85116 }
117+ // Any earlier prepared draw loses its claim on the scratch snapshot, even
118+ // if this Prepare fails after partially rewriting it.
119+ r.generation++
86120 columns, rows := frame.Columns(), frame.Rows()
87121 if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
88122 plan := planSingleDamage(frame, damage[0])
89- candidate = DeltaCandidate{Plan: plan}
90- if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
91- candidate.frame = cloneCellSource(frame)
92- }
123+ candidate = newDeltaCandidate(frame, plan, &r.scratch)
93124 } else {
94- candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted)
125+ candidate, err = planDelta(frame, damage, r.committed, reset || !r.hasCommitted, &r.scratch)
95126 }
96127 if err != nil {
97128 return PreparedDraw{}, err
98129 }
99- prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)}
130+ prepared := PreparedDraw{renderer: r, candidate: candidate, generation: r.generation, commitOnce: new(sync.Once)}
100131 plan := candidate.Plan
101132 if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
102133 return prepared, nil
@@ -149,17 +180,6 @@ func (r *Renderer) Draw(frame CellSource, damage []Damage) ([]byte, error) {
149180 return prepared.Bytes(), nil
150181 }
151182
152-func (r *Renderer) committedFrame() Frame {
153- return r.committed
154-}
155-
156-func (r *Renderer) setCommittedFrame(frame Frame) {
157- r.width = frame.Width
158- r.height = frame.Height
159- r.hasCommitted = true
160- r.committed = frame
161-}
162-
163183 // copyBytes copies the buffer contents into a fresh byte slice and is used
164184 // to return output that is independent of the pooled scratch buffer.
165185 func copyBytes(buf *bytes.Buffer) []byte {
@@ -24,6 +24,12 @@ type Renderer struct {
24 height int24 height int
25 hasCommitted bool25 hasCommitted bool
26 committed Frame26 committed Frame
27+ // scratch receives the snapshot of the frame being prepared. It never
28+ // aliases committed or caller storage: a snapshot commit swaps the two so
29+ // both buffers are reused across draws without allocating.
30+ scratch Frame
31+ // generation identifies the latest prepared draw that owns scratch.
32+ generation uint64
27 }33 }
28 34
29 // PreparedDraw owns encoded output and its transactional delta until Commit.35 // PreparedDraw owns encoded output and its transactional delta until Commit.
@@ -32,6 +38,7 @@ type PreparedDraw struct {
32 renderer *Renderer38 renderer *Renderer
33 candidate DeltaCandidate39 candidate DeltaCandidate
34 data []byte40 data []byte
41+ generation uint64
35 commitOnce *sync.Once42 commitOnce *sync.Once
36 }43 }
37 44
@@ -48,7 +55,7 @@ func (r *Renderer) Reset() {
48 r.width = 055 r.width = 0
49 r.height = 056 r.height = 0
50 r.hasCommitted = false57 r.hasCommitted = false
51- r.committed = Frame{}58+ // The frame buffers are kept for reuse; hasCommitted gates their content.
52 }59 }
53 60
54 // Bytes returns the prepared ANSI output. The returned bytes remain valid after61 // Bytes returns the prepared ANSI output. The returned bytes remain valid after
@@ -57,46 +64,70 @@ func (p PreparedDraw) Bytes() []byte { return p.data }
57 64
58 // Commit applies the prepared delta exactly once. Discarding it leaves the65 // Commit applies the prepared delta exactly once. Discarding it leaves the
59 // renderer's committed state unchanged.66 // renderer's committed state unchanged.
67+//
68+// Committing a draw that a later Prepare has superseded does not apply it:
69+// the renderer instead drops its committed state, so the next Prepare
70+// re-emits a full snapshot instead of diffing against a shadow that may no
71+// longer match the terminal. Draws without output are unaffected.
60 func (p *PreparedDraw) Commit() {72 func (p *PreparedDraw) Commit() {
61 if p == nil || p.renderer == nil || p.commitOnce == nil {73 if p == nil || p.renderer == nil || p.commitOnce == nil {
62 return74 return
63 }75 }
64 p.commitOnce.Do(func() {76 p.commitOnce.Do(func() {
65- committed := p.renderer.committedFrame()77+ r := p.renderer
66- if p.candidate.Plan.Snapshot {78+ plan := p.candidate.Plan
67- // PreparedDraw privately owns this snapshot. Its shared Once makes79+ if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
68- // transfer safe even when the caller retains copies of the draw.80+ return
69- committed = p.candidate.frame81+ }
82+ if p.generation != r.generation {
83+ // A later Prepare reused the scratch frame this draw's snapshot
84+ // lives in, so it cannot be applied. Its bytes may nevertheless
85+ // have reached the terminal, which would then differ from the
86+ // committed shadow in unknown ways: forget the shadow so the next
87+ // Prepare emits a full snapshot.
88+ r.hasCommitted = false
89+ return
90+ }
91+ if plan.Snapshot || !r.hasCommitted {
92+ // The snapshot lives in the renderer's scratch buffer; promote it
93+ // and recycle the previous committed buffer as the next scratch.
94+ r.committed, r.scratch = p.candidate.frame, r.committed
70 } else {95 } else {
71- p.candidate.Commit(&committed)96+ p.candidate.Commit(&r.committed)
72 }97 }
73- p.renderer.setCommittedFrame(committed)98+ r.width = r.committed.Width
99+ r.height = r.committed.Height
100+ r.hasCommitted = true
74 })101 })
75 }102 }
76 103
77 // Prepare plans and encodes a transactional draw. The renderer advances only104 // Prepare plans and encodes a transactional draw. The renderer advances only
78 // when the returned draw is committed. Keep at most one prepared draw105 // when the returned draw is committed. Keep at most one prepared draw
79-// outstanding; commit or discard it before calling Prepare again.106+// outstanding; commit or discard it before calling Prepare again. A draw
107+// superseded by a later Prepare is discarded and its Commit does nothing.
108+//
109+// The renderer copies frame into reusable private storage, so the caller may
110+// keep mutating frame between draws.
80 func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) {111 func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) {
81 var candidate DeltaCandidate112 var candidate DeltaCandidate
82 var err error113 var err error
83 if err = validateCellSource(frame); err != nil {114 if err = validateCellSource(frame); err != nil {
84 return PreparedDraw{}, err115 return PreparedDraw{}, err
85 }116 }
117+ // Any earlier prepared draw loses its claim on the scratch snapshot, even
118+ // if this Prepare fails after partially rewriting it.
119+ r.generation++
86 columns, rows := frame.Columns(), frame.Rows()120 columns, rows := frame.Columns(), frame.Rows()
87 if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {121 if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
88 plan := planSingleDamage(frame, damage[0])122 plan := planSingleDamage(frame, damage[0])
89- candidate = DeltaCandidate{Plan: plan}123+ candidate = newDeltaCandidate(frame, plan, &r.scratch)
90- if plan.Snapshot || plan.Scroll.Height != 0 || len(plan.Spans) != 0 {
91- candidate.frame = cloneCellSource(frame)
92- }
93 } else {124 } else {
94- candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted)125+ candidate, err = planDelta(frame, damage, r.committed, reset || !r.hasCommitted, &r.scratch)
95 }126 }
96 if err != nil {127 if err != nil {
97 return PreparedDraw{}, err128 return PreparedDraw{}, err
98 }129 }
99- prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)}130+ prepared := PreparedDraw{renderer: r, candidate: candidate, generation: r.generation, commitOnce: new(sync.Once)}
100 plan := candidate.Plan131 plan := candidate.Plan
101 if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {132 if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
102 return prepared, nil133 return prepared, nil
@@ -149,17 +180,6 @@ func (r *Renderer) Draw(frame CellSource, damage []Damage) ([]byte, error) {
149 return prepared.Bytes(), nil180 return prepared.Bytes(), nil
150 }181 }
151 182
152-func (r *Renderer) committedFrame() Frame {
153- return r.committed
154-}
155-
156-func (r *Renderer) setCommittedFrame(frame Frame) {
157- r.width = frame.Width
158- r.height = frame.Height
159- r.hasCommitted = true
160- r.committed = frame
161-}
162-
163 // copyBytes copies the buffer contents into a fresh byte slice and is used183 // copyBytes copies the buffer contents into a fresh byte slice and is used
164 // to return output that is independent of the pooled scratch buffer.184 // to return output that is independent of the pooled scratch buffer.
165 func copyBytes(buf *bytes.Buffer) []byte {185 func copyBytes(buf *bytes.Buffer) []byte {
M ansi/scroll.go +7 -0
@@ -3,6 +3,8 @@ package ansi
33 import (
44 "bytes"
55 "strconv"
6+
7+ "github.com/bnema/vev-vt/core"
68 )
79
810 func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) {
@@ -92,6 +94,11 @@ func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, com
9294 func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {
9395 start, end, offset := scrollRetainedRows(scroll)
9496 for y := start; y < end; y++ {
97+ // Scrolls are full width, so a retained row that matches whole needs no
98+ // per-cell damage lookups.
99+ if scroll.X == 0 && scroll.Width == frame.Width && core.RowsEqualAt(committed, y+offset, frame, y) {
100+ continue
101+ }
95102 for x := range scroll.Width {
96103 column := scroll.X + x
97104 committedCell, frameCell := committed.At(column, y+offset), frame.At(column, y)
@@ -3,6 +3,8 @@ package ansi
3 import (3 import (
4 "bytes"4 "bytes"
5 "strconv"5 "strconv"
6+
7+ "github.com/bnema/vev-vt/core"
6 )8 )
7 9
8 func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) {10 func findSafeScroll(frame CellSource, damage []Damage) (Damage, bool) {
@@ -92,6 +94,11 @@ func canApplyScrollAgainst(frame CellSource, scroll Damage, damage []Damage, com
92 func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {94 func canApplyDenseScrollAgainst(frame Frame, scroll Damage, damage []Damage, committed Frame) bool {
93 start, end, offset := scrollRetainedRows(scroll)95 start, end, offset := scrollRetainedRows(scroll)
94 for y := start; y < end; y++ {96 for y := start; y < end; y++ {
97+ // Scrolls are full width, so a retained row that matches whole needs no
98+ // per-cell damage lookups.
99+ if scroll.X == 0 && scroll.Width == frame.Width && core.RowsEqualAt(committed, y+offset, frame, y) {
100+ continue
101+ }
95 for x := range scroll.Width {102 for x := range scroll.Width {
96 column := scroll.X + x103 column := scroll.X + x
97 committedCell, frameCell := committed.At(column, y+offset), frame.At(column, y)104 committedCell, frameCell := committed.At(column, y+offset), frame.At(column, y)
M ansi/scroll_down_test.go +53 -0
@@ -77,3 +77,56 @@ func TestDownwardScrollMismatchFallsBack(t *testing.T) {
7777 }
7878 }
7979 }
80+
81+func TestStaleCommitForcesSnapshotAndReplaysCorrectly(t *testing.T) {
82+ for _, deliverB := range []bool{false, true} {
83+ frame := vt.NewFrame(10, 4)
84+ for y := range frame.Height {
85+ frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: rune('a' + y), Style: vt.DefaultStyle()})
86+ }
87+ r := ansi.New(ansi.Capabilities{})
88+ terminal := vt.NewScreen(frame.Width, frame.Height)
89+ initial, err := r.Draw(frame, nil)
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ terminal.Write(initial)
94+
95+ frame.Set(1, 1, vt.Cell{Rune: 'A', Style: vt.DefaultStyle()})
96+ a, err := r.Prepare(frame, []vt.Damage{{Kind: vt.DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ terminal.Write(a.Bytes())
101+ frame.Set(2, 2, vt.Cell{Rune: 'B', Style: vt.Style{Bold: true, Foreground: 2, Background: -1}})
102+ b, err := r.Prepare(frame, []vt.Damage{{Kind: vt.DamageText, X: 2, Y: 2, Width: 1, Height: 1}}, false)
103+ if err != nil {
104+ t.Fatal(err)
105+ }
106+ if deliverB {
107+ terminal.Write(b.Bytes())
108+ }
109+ a.Commit() // superseded by b: must drop the committed shadow
110+
111+ next, err := r.Prepare(frame, nil, false)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ if len(next.Bytes()) == 0 || !bytes.Contains(next.Bytes(), []byte("a")) || !bytes.Contains(next.Bytes(), []byte("d")) {
116+ t.Fatalf("deliverB=%v: next prepare is not a full snapshot: %q", deliverB, next.Bytes())
117+ }
118+ terminal.Write(next.Bytes())
119+ next.Commit()
120+ for y := range frame.Height {
121+ for x := range frame.Width {
122+ if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) {
123+ t.Fatalf("deliverB=%v: cell %d,%d = %+v, want %+v", deliverB, x, y, terminal.Cell(x, y), frame.Cell(x, y))
124+ }
125+ }
126+ }
127+ unchanged, err := r.Draw(frame, nil)
128+ if err != nil || len(unchanged) != 0 {
129+ t.Fatalf("committed shadow differs after snapshot: %q, %v", unchanged, err)
130+ }
131+ }
132+}
@@ -77,3 +77,56 @@ func TestDownwardScrollMismatchFallsBack(t *testing.T) {
77 }77 }
78 }78 }
79 }79 }
80+
81+func TestStaleCommitForcesSnapshotAndReplaysCorrectly(t *testing.T) {
82+ for _, deliverB := range []bool{false, true} {
83+ frame := vt.NewFrame(10, 4)
84+ for y := range frame.Height {
85+ frame.FillRow(y, 0, frame.Width, vt.Cell{Rune: rune('a' + y), Style: vt.DefaultStyle()})
86+ }
87+ r := ansi.New(ansi.Capabilities{})
88+ terminal := vt.NewScreen(frame.Width, frame.Height)
89+ initial, err := r.Draw(frame, nil)
90+ if err != nil {
91+ t.Fatal(err)
92+ }
93+ terminal.Write(initial)
94+
95+ frame.Set(1, 1, vt.Cell{Rune: 'A', Style: vt.DefaultStyle()})
96+ a, err := r.Prepare(frame, []vt.Damage{{Kind: vt.DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ terminal.Write(a.Bytes())
101+ frame.Set(2, 2, vt.Cell{Rune: 'B', Style: vt.Style{Bold: true, Foreground: 2, Background: -1}})
102+ b, err := r.Prepare(frame, []vt.Damage{{Kind: vt.DamageText, X: 2, Y: 2, Width: 1, Height: 1}}, false)
103+ if err != nil {
104+ t.Fatal(err)
105+ }
106+ if deliverB {
107+ terminal.Write(b.Bytes())
108+ }
109+ a.Commit() // superseded by b: must drop the committed shadow
110+
111+ next, err := r.Prepare(frame, nil, false)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ if len(next.Bytes()) == 0 || !bytes.Contains(next.Bytes(), []byte("a")) || !bytes.Contains(next.Bytes(), []byte("d")) {
116+ t.Fatalf("deliverB=%v: next prepare is not a full snapshot: %q", deliverB, next.Bytes())
117+ }
118+ terminal.Write(next.Bytes())
119+ next.Commit()
120+ for y := range frame.Height {
121+ for x := range frame.Width {
122+ if !frame.Cell(x, y).Equal(terminal.Cell(x, y)) {
123+ t.Fatalf("deliverB=%v: cell %d,%d = %+v, want %+v", deliverB, x, y, terminal.Cell(x, y), frame.Cell(x, y))
124+ }
125+ }
126+ }
127+ unchanged, err := r.Draw(frame, nil)
128+ if err != nil || len(unchanged) != 0 {
129+ t.Fatalf("committed shadow differs after snapshot: %q, %v", unchanged, err)
130+ }
131+ }
132+}
A ansi/shadow_helpers_test.go +16 -0
@@ -0,0 +1,16 @@
1+package ansi
2+
3+// committedFrame returns the renderer's committed frame (sharing its storage).
4+func (r *Renderer) committedFrame() Frame { return r.committed }
5+
6+// setCommittedFrame installs frame as the committed shadow.
7+func (r *Renderer) setCommittedFrame(frame Frame) {
8+ r.width = frame.Width
9+ r.height = frame.Height
10+ r.hasCommitted = true
11+ r.committed = frame
12+}
13+
14+// replaceFrame overwrites dst's own storage with a copy of src, reusing
15+// capacity.
16+func replaceFrame(dst *Frame, src Frame) { dst.CopyFrom(src) }
@@ -0,0 +1,16 @@
1+package ansi
2+
3+// committedFrame returns the renderer's committed frame (sharing its storage).
4+func (r *Renderer) committedFrame() Frame { return r.committed }
5+
6+// setCommittedFrame installs frame as the committed shadow.
7+func (r *Renderer) setCommittedFrame(frame Frame) {
8+ r.width = frame.Width
9+ r.height = frame.Height
10+ r.hasCommitted = true
11+ r.committed = frame
12+}
13+
14+// replaceFrame overwrites dst's own storage with a copy of src, reusing
15+// capacity.
16+func replaceFrame(dst *Frame, src Frame) { dst.CopyFrom(src) }
A ansi/shadow_property_test.go +200 -0
@@ -0,0 +1,200 @@
1+package ansi
2+
3+import (
4+ "fmt"
5+ "math/rand/v2"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/vev-vt/core"
10+)
11+
12+// genericSource hides the concrete core.Frame type so the renderer must use
13+// the generic CellSource paths.
14+type genericSource struct{ f Frame }
15+
16+func (g genericSource) Columns() int { return g.f.Columns() }
17+func (g genericSource) Rows() int { return g.f.Rows() }
18+func (g genericSource) Cell(x, y int) Cell { return g.f.Cell(x, y) }
19+
20+var propertyStyles = []Style{
21+ DefaultStyle(),
22+ {Bold: true, Foreground: 1, Background: -1},
23+ {Italic: true, Foreground: -1, Background: 4},
24+ {Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: core.RGB{R: 9, G: 8, B: 7}},
25+ {Inverse: true, Foreground: 2, Background: 3},
26+}
27+
28+func propertyCell(r *rand.Rand) Cell {
29+ c := Cell{Rune: rune('a' + r.IntN(6)), Style: propertyStyles[r.IntN(len(propertyStyles))]}
30+ if r.IntN(12) == 0 {
31+ if p, err := core.NewCellPayload("e\u0301", "https://example.test/"+string(rune('a'+r.IntN(3)))); err == nil {
32+ c.Payload = p
33+ }
34+ }
35+ return c
36+}
37+
38+func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage {
39+ d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)}
40+ d.Width = 1 + r.IntN(w-d.X)
41+ d.Height = 1 + r.IntN(h-d.Y)
42+ if kind == DamageScrollUp || kind == DamageScrollDown {
43+ d.X, d.Width = 0, w
44+ d.Count = 1 + r.IntN(max(1, d.Height))
45+ }
46+ return d
47+}
48+
49+func propertyDamage(r *rand.Rand, w, h int) []Damage {
50+ switch r.IntN(6) {
51+ case 0:
52+ return nil
53+ case 1:
54+ return []Damage{FullRedraw()}
55+ case 2:
56+ return []Damage{propertyRect(r, w, h, DamageText)}
57+ case 3:
58+ return []Damage{propertyRect(r, w, h, DamageClear)}
59+ case 4:
60+ return []Damage{propertyRect(r, w, h, DamageScrollUp), propertyRect(r, w, h, DamageText)}
61+ default:
62+ out := []Damage{propertyRect(r, w, h, DamageScrollDown)}
63+ for range r.IntN(4) {
64+ out = append(out, propertyRect(r, w, h, DamageText))
65+ }
66+ return out
67+ }
68+}
69+
70+// TestShadowMatchesCloneReference drives one renderer (reused buffers) and a
71+// reference built from the public clone-based PlanDelta/Commit pair through
72+// random edits, under-reported damage, scrolls, resets, resizes, discarded
73+// draws and stale commits. The committed shadows must always agree.
74+func TestShadowMatchesCloneReference(t *testing.T) {
75+ for seed := range uint64(40) {
76+ t.Run(fmt.Sprint(seed), func(t *testing.T) {
77+ r := rand.New(rand.NewPCG(seed, 0xa11ce))
78+ rend := New(Capabilities{})
79+ var ref Frame
80+ refHas := false
81+ w, h := 3+r.IntN(10), 2+r.IntN(8)
82+ frame := NewFrame(w, h)
83+ var outstanding *PreparedDraw // last prepared draw, for stale commits
84+
85+ for step := range 150 {
86+ // Mutate the caller's single frame in place.
87+ switch r.IntN(8) {
88+ case 0:
89+ w, h = 3+r.IntN(10), 2+r.IntN(8)
90+ frame = NewFrame(w, h)
91+ case 1:
92+ top := r.IntN(h)
93+ bottom := top + r.IntN(h-top)
94+ n := 1 + r.IntN(bottom-top+1)
95+ if r.IntN(2) == 0 {
96+ frame.ScrollUp(top, bottom, n)
97+ } else {
98+ frame.ScrollDown(top, bottom, n)
99+ }
100+ }
101+ for range r.IntN(12) {
102+ frame.Set(r.IntN(w), r.IntN(h), propertyCell(r))
103+ }
104+ damage := propertyDamage(r, w, h)
105+ reset := r.IntN(15) == 0
106+
107+ var source CellSource = frame
108+ switch r.IntN(4) {
109+ case 0:
110+ source = &frame
111+ case 1:
112+ source = genericSource{frame}
113+ }
114+
115+ // Reference plan against a clone-based committed shadow.
116+ refReset := reset || !refHas
117+ want, err := PlanDelta(source, damage, ref, refReset)
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ prepared, err := rend.Prepare(source, damage, reset)
122+ if err != nil {
123+ t.Fatal(err)
124+ }
125+ if !reflect.DeepEqual(prepared.candidate.Plan, want.Plan) {
126+ t.Fatalf("step %d: plan %+v, reference %+v", step, prepared.candidate.Plan, want.Plan)
127+ }
128+ if prepared.candidate.frame.Width != 0 && !framesEqual(prepared.candidate.frame, want.frame) {
129+ t.Fatalf("step %d: prepared snapshot differs from reference snapshot", step)
130+ }
131+
132+ // Caller keeps mutating after Prepare, before Commit.
133+ snapshotMutation := r.IntN(3) == 0
134+ if snapshotMutation {
135+ frame.Set(r.IntN(w), r.IntN(h), propertyCell(r))
136+ }
137+
138+ switch k := r.IntN(10); {
139+ case k < 6: // commit
140+ prepared.Commit()
141+ want.Commit(&ref)
142+ refHas = refHas || want.frame.Width != 0
143+ if want.frame.Width == 0 && !refHas {
144+ // A no-op candidate with no shadow cannot occur: reset snapshots.
145+ t.Fatalf("step %d: empty plan without committed state", step)
146+ }
147+ outstanding = nil
148+ case k < 8: // discard
149+ outstanding = &prepared
150+ default: // stale commit of the previous discarded draw
151+ if outstanding != nil {
152+ if hasWork(outstanding.candidate.Plan) {
153+ outstanding.Commit()
154+ refHas = false
155+ } else {
156+ outstanding.Commit()
157+ }
158+ outstanding = nil
159+ }
160+ }
161+ if r.IntN(25) == 0 {
162+ rend.Reset()
163+ refHas = false
164+ ref = Frame{}
165+ }
166+
167+ if rend.hasCommitted != refHas {
168+ t.Fatalf("step %d: hasCommitted=%v, reference %v", step, rend.hasCommitted, refHas)
169+ }
170+ if refHas {
171+ if !framesEqual(rend.committed, ref) {
172+ t.Fatalf("step %d: committed shadow differs from reference", step)
173+ }
174+ if err := rend.committed.CheckInvariants(); err != nil {
175+ t.Fatalf("step %d: %v", step, err)
176+ }
177+ if rend.width != ref.Width || rend.height != ref.Height {
178+ t.Fatalf("step %d: renderer size %dx%d, reference %dx%d", step, rend.width, rend.height, ref.Width, ref.Height)
179+ }
180+ }
181+ }
182+ })
183+ }
184+}
185+
186+func hasWork(p DeltaPlan) bool { return p.Snapshot || p.Scroll.Height != 0 || len(p.Spans) != 0 }
187+
188+func framesEqual(a, b Frame) bool {
189+ if a.Width != b.Width || a.Height != b.Height {
190+ return false
191+ }
192+ for y := range a.Height {
193+ for x := range a.Width {
194+ if !a.Cell(x, y).Equal(b.Cell(x, y)) {
195+ return false
196+ }
197+ }
198+ }
199+ return true
200+}
@@ -0,0 +1,200 @@
1+package ansi
2+
3+import (
4+ "fmt"
5+ "math/rand/v2"
6+ "reflect"
7+ "testing"
8+
9+ "github.com/bnema/vev-vt/core"
10+)
11+
12+// genericSource hides the concrete core.Frame type so the renderer must use
13+// the generic CellSource paths.
14+type genericSource struct{ f Frame }
15+
16+func (g genericSource) Columns() int { return g.f.Columns() }
17+func (g genericSource) Rows() int { return g.f.Rows() }
18+func (g genericSource) Cell(x, y int) Cell { return g.f.Cell(x, y) }
19+
20+var propertyStyles = []Style{
21+ DefaultStyle(),
22+ {Bold: true, Foreground: 1, Background: -1},
23+ {Italic: true, Foreground: -1, Background: 4},
24+ {Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: core.RGB{R: 9, G: 8, B: 7}},
25+ {Inverse: true, Foreground: 2, Background: 3},
26+}
27+
28+func propertyCell(r *rand.Rand) Cell {
29+ c := Cell{Rune: rune('a' + r.IntN(6)), Style: propertyStyles[r.IntN(len(propertyStyles))]}
30+ if r.IntN(12) == 0 {
31+ if p, err := core.NewCellPayload("e\u0301", "https://example.test/"+string(rune('a'+r.IntN(3)))); err == nil {
32+ c.Payload = p
33+ }
34+ }
35+ return c
36+}
37+
38+func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage {
39+ d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)}
40+ d.Width = 1 + r.IntN(w-d.X)
41+ d.Height = 1 + r.IntN(h-d.Y)
42+ if kind == DamageScrollUp || kind == DamageScrollDown {
43+ d.X, d.Width = 0, w
44+ d.Count = 1 + r.IntN(max(1, d.Height))
45+ }
46+ return d
47+}
48+
49+func propertyDamage(r *rand.Rand, w, h int) []Damage {
50+ switch r.IntN(6) {
51+ case 0:
52+ return nil
53+ case 1:
54+ return []Damage{FullRedraw()}
55+ case 2:
56+ return []Damage{propertyRect(r, w, h, DamageText)}
57+ case 3:
58+ return []Damage{propertyRect(r, w, h, DamageClear)}
59+ case 4:
60+ return []Damage{propertyRect(r, w, h, DamageScrollUp), propertyRect(r, w, h, DamageText)}
61+ default:
62+ out := []Damage{propertyRect(r, w, h, DamageScrollDown)}
63+ for range r.IntN(4) {
64+ out = append(out, propertyRect(r, w, h, DamageText))
65+ }
66+ return out
67+ }
68+}
69+
70+// TestShadowMatchesCloneReference drives one renderer (reused buffers) and a
71+// reference built from the public clone-based PlanDelta/Commit pair through
72+// random edits, under-reported damage, scrolls, resets, resizes, discarded
73+// draws and stale commits. The committed shadows must always agree.
74+func TestShadowMatchesCloneReference(t *testing.T) {
75+ for seed := range uint64(40) {
76+ t.Run(fmt.Sprint(seed), func(t *testing.T) {
77+ r := rand.New(rand.NewPCG(seed, 0xa11ce))
78+ rend := New(Capabilities{})
79+ var ref Frame
80+ refHas := false
81+ w, h := 3+r.IntN(10), 2+r.IntN(8)
82+ frame := NewFrame(w, h)
83+ var outstanding *PreparedDraw // last prepared draw, for stale commits
84+
85+ for step := range 150 {
86+ // Mutate the caller's single frame in place.
87+ switch r.IntN(8) {
88+ case 0:
89+ w, h = 3+r.IntN(10), 2+r.IntN(8)
90+ frame = NewFrame(w, h)
91+ case 1:
92+ top := r.IntN(h)
93+ bottom := top + r.IntN(h-top)
94+ n := 1 + r.IntN(bottom-top+1)
95+ if r.IntN(2) == 0 {
96+ frame.ScrollUp(top, bottom, n)
97+ } else {
98+ frame.ScrollDown(top, bottom, n)
99+ }
100+ }
101+ for range r.IntN(12) {
102+ frame.Set(r.IntN(w), r.IntN(h), propertyCell(r))
103+ }
104+ damage := propertyDamage(r, w, h)
105+ reset := r.IntN(15) == 0
106+
107+ var source CellSource = frame
108+ switch r.IntN(4) {
109+ case 0:
110+ source = &frame
111+ case 1:
112+ source = genericSource{frame}
113+ }
114+
115+ // Reference plan against a clone-based committed shadow.
116+ refReset := reset || !refHas
117+ want, err := PlanDelta(source, damage, ref, refReset)
118+ if err != nil {
119+ t.Fatal(err)
120+ }
121+ prepared, err := rend.Prepare(source, damage, reset)
122+ if err != nil {
123+ t.Fatal(err)
124+ }
125+ if !reflect.DeepEqual(prepared.candidate.Plan, want.Plan) {
126+ t.Fatalf("step %d: plan %+v, reference %+v", step, prepared.candidate.Plan, want.Plan)
127+ }
128+ if prepared.candidate.frame.Width != 0 && !framesEqual(prepared.candidate.frame, want.frame) {
129+ t.Fatalf("step %d: prepared snapshot differs from reference snapshot", step)
130+ }
131+
132+ // Caller keeps mutating after Prepare, before Commit.
133+ snapshotMutation := r.IntN(3) == 0
134+ if snapshotMutation {
135+ frame.Set(r.IntN(w), r.IntN(h), propertyCell(r))
136+ }
137+
138+ switch k := r.IntN(10); {
139+ case k < 6: // commit
140+ prepared.Commit()
141+ want.Commit(&ref)
142+ refHas = refHas || want.frame.Width != 0
143+ if want.frame.Width == 0 && !refHas {
144+ // A no-op candidate with no shadow cannot occur: reset snapshots.
145+ t.Fatalf("step %d: empty plan without committed state", step)
146+ }
147+ outstanding = nil
148+ case k < 8: // discard
149+ outstanding = &prepared
150+ default: // stale commit of the previous discarded draw
151+ if outstanding != nil {
152+ if hasWork(outstanding.candidate.Plan) {
153+ outstanding.Commit()
154+ refHas = false
155+ } else {
156+ outstanding.Commit()
157+ }
158+ outstanding = nil
159+ }
160+ }
161+ if r.IntN(25) == 0 {
162+ rend.Reset()
163+ refHas = false
164+ ref = Frame{}
165+ }
166+
167+ if rend.hasCommitted != refHas {
168+ t.Fatalf("step %d: hasCommitted=%v, reference %v", step, rend.hasCommitted, refHas)
169+ }
170+ if refHas {
171+ if !framesEqual(rend.committed, ref) {
172+ t.Fatalf("step %d: committed shadow differs from reference", step)
173+ }
174+ if err := rend.committed.CheckInvariants(); err != nil {
175+ t.Fatalf("step %d: %v", step, err)
176+ }
177+ if rend.width != ref.Width || rend.height != ref.Height {
178+ t.Fatalf("step %d: renderer size %dx%d, reference %dx%d", step, rend.width, rend.height, ref.Width, ref.Height)
179+ }
180+ }
181+ }
182+ })
183+ }
184+}
185+
186+func hasWork(p DeltaPlan) bool { return p.Snapshot || p.Scroll.Height != 0 || len(p.Spans) != 0 }
187+
188+func framesEqual(a, b Frame) bool {
189+ if a.Width != b.Width || a.Height != b.Height {
190+ return false
191+ }
192+ for y := range a.Height {
193+ for x := range a.Width {
194+ if !a.Cell(x, y).Equal(b.Cell(x, y)) {
195+ return false
196+ }
197+ }
198+ }
199+ return true
200+}
A core/frame_copy.go +116 -0
@@ -0,0 +1,116 @@
1+package core
2+
3+import "maps"
4+
5+// CopyFrom makes f an independent structural copy of src, like Replace, but
6+// reuses f's existing page storage (cells, row descriptors, style and payload
7+// tables and dictionaries) when f already owns a page. It therefore performs no
8+// allocation once f's page has grown to src's size.
9+//
10+// Page-local style and payload IDs and physical row rotation are copied
11+// verbatim, exactly as Clone does. Every Frame value sharing f's page observes
12+// the overwrite. An invalid or empty source leaves f unchanged. Copying a frame
13+// onto itself only normalizes the dimensions.
14+func (f *Frame) CopyFrom(src Frame) {
15+ if f == nil {
16+ return
17+ }
18+ if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 {
19+ return
20+ }
21+ if f.page == nil {
22+ *f = src.Clone()
23+ return
24+ }
25+ f.Width, f.Height = src.Width, src.Height
26+ dst, from := f.page, src.page
27+ if dst == from {
28+ return
29+ }
30+ dst.cells = append(dst.cells[:0], from.cells...)
31+ dst.rows = append(dst.rows[:0], from.rows...)
32+ dst.styles = append(dst.styles[:0], from.styles...)
33+ dst.freeStyles = append(dst.freeStyles[:0], from.freeStyles...)
34+ dst.styleCount = from.styleCount
35+ // The cache refers to a slot in the style table copied above, so copying it
36+ // keeps it valid. Leaving the destination's old cache would point at a slot
37+ // that may now hold a different style.
38+ dst.styleCache, dst.styleCacheID, dst.styleCacheOK = from.styleCache, from.styleCacheID, from.styleCacheOK
39+ if dst.styleIndex == nil {
40+ dst.styleIndex = make(map[Style]uint32, len(from.styleIndex))
41+ } else {
42+ clear(dst.styleIndex)
43+ }
44+ maps.Copy(dst.styleIndex, from.styleIndex)
45+ dst.payloads = append(dst.payloads[:0], from.payloads...)
46+ dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...)
47+ dst.payloadBytes = from.payloadBytes
48+ if len(from.payloadIndex) == 0 {
49+ clear(dst.payloadIndex)
50+ } else {
51+ if dst.payloadIndex == nil {
52+ dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex))
53+ } else {
54+ clear(dst.payloadIndex)
55+ }
56+ maps.Copy(dst.payloadIndex, from.payloadIndex)
57+ }
58+}
59+
60+// RowsEqual reports whether logical row y holds semantically equal cells in a
61+// and b (the same comparison as Cell.Equal for every column). It compares
62+// compact stored cells directly and resolves page-local style and payload IDs
63+// only when they are not both the default, so it does not allocate and does not
64+// require the two frames to share ID tables. Frames of different width, an
65+// out-of-range row, or inconsistent storage compare unequal.
66+func RowsEqual(a, b Frame, y int) bool {
67+ return RowsEqualAt(a, y, b, y)
68+}
69+
70+// RowsEqualAt is RowsEqual for logical row ya of a against logical row yb of b.
71+func RowsEqualAt(a Frame, ya int, b Frame, yb int) bool {
72+ if a.Width != b.Width || a.Width <= 0 || a.page == nil || b.page == nil ||
73+ ya < 0 || ya >= a.Height || yb < 0 || yb >= b.Height ||
74+ ya >= len(a.page.rows) || yb >= len(b.page.rows) {
75+ return false
76+ }
77+ width := a.Width
78+ offA, offB := int(a.page.rows[ya]), int(b.page.rows[yb])
79+ if offA+width > len(a.page.cells) || offB+width > len(b.page.cells) {
80+ return false
81+ }
82+ rowA := a.page.cells[offA : offA+width]
83+ rowB := b.page.cells[offB : offB+width]
84+
85+ // Remember the last style ID pair that compared equal so runs of one style
86+ // cost a single table lookup.
87+ var lastStyleA, lastStyleB uint32
88+ for x := range rowA {
89+ ca, cb := rowA[x], rowB[x]
90+ if ca.rune != cb.rune || ca.flags != cb.flags {
91+ return false
92+ }
93+ if ca.styleID != 0 || cb.styleID != 0 {
94+ if ca.styleID != lastStyleA || cb.styleID != lastStyleB {
95+ if !a.storedStylesEqual(b, ca.styleID, cb.styleID) {
96+ return false
97+ }
98+ lastStyleA, lastStyleB = ca.styleID, cb.styleID
99+ }
100+ }
101+ if ca.payloadID != 0 || cb.payloadID != 0 {
102+ if ca.payloadID == 0 || cb.payloadID == 0 || a.payload(ca.payloadID) != b.payload(cb.payloadID) {
103+ return false
104+ }
105+ }
106+ }
107+ return true
108+}
109+
110+func (f Frame) storedStylesEqual(other Frame, id, otherID uint32) bool {
111+ if int(id) >= len(f.page.styles) || int(otherID) >= len(other.page.styles) {
112+ return false
113+ }
114+ sa, sb := f.page.styles[id].style, other.page.styles[otherID].style
115+ return sa == sb || sa.Equal(sb)
116+}
@@ -0,0 +1,116 @@
1+package core
2+
3+import "maps"
4+
5+// CopyFrom makes f an independent structural copy of src, like Replace, but
6+// reuses f's existing page storage (cells, row descriptors, style and payload
7+// tables and dictionaries) when f already owns a page. It therefore performs no
8+// allocation once f's page has grown to src's size.
9+//
10+// Page-local style and payload IDs and physical row rotation are copied
11+// verbatim, exactly as Clone does. Every Frame value sharing f's page observes
12+// the overwrite. An invalid or empty source leaves f unchanged. Copying a frame
13+// onto itself only normalizes the dimensions.
14+func (f *Frame) CopyFrom(src Frame) {
15+ if f == nil {
16+ return
17+ }
18+ if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 {
19+ return
20+ }
21+ if f.page == nil {
22+ *f = src.Clone()
23+ return
24+ }
25+ f.Width, f.Height = src.Width, src.Height
26+ dst, from := f.page, src.page
27+ if dst == from {
28+ return
29+ }
30+ dst.cells = append(dst.cells[:0], from.cells...)
31+ dst.rows = append(dst.rows[:0], from.rows...)
32+ dst.styles = append(dst.styles[:0], from.styles...)
33+ dst.freeStyles = append(dst.freeStyles[:0], from.freeStyles...)
34+ dst.styleCount = from.styleCount
35+ // The cache refers to a slot in the style table copied above, so copying it
36+ // keeps it valid. Leaving the destination's old cache would point at a slot
37+ // that may now hold a different style.
38+ dst.styleCache, dst.styleCacheID, dst.styleCacheOK = from.styleCache, from.styleCacheID, from.styleCacheOK
39+ if dst.styleIndex == nil {
40+ dst.styleIndex = make(map[Style]uint32, len(from.styleIndex))
41+ } else {
42+ clear(dst.styleIndex)
43+ }
44+ maps.Copy(dst.styleIndex, from.styleIndex)
45+ dst.payloads = append(dst.payloads[:0], from.payloads...)
46+ dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...)
47+ dst.payloadBytes = from.payloadBytes
48+ if len(from.payloadIndex) == 0 {
49+ clear(dst.payloadIndex)
50+ } else {
51+ if dst.payloadIndex == nil {
52+ dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex))
53+ } else {
54+ clear(dst.payloadIndex)
55+ }
56+ maps.Copy(dst.payloadIndex, from.payloadIndex)
57+ }
58+}
59+
60+// RowsEqual reports whether logical row y holds semantically equal cells in a
61+// and b (the same comparison as Cell.Equal for every column). It compares
62+// compact stored cells directly and resolves page-local style and payload IDs
63+// only when they are not both the default, so it does not allocate and does not
64+// require the two frames to share ID tables. Frames of different width, an
65+// out-of-range row, or inconsistent storage compare unequal.
66+func RowsEqual(a, b Frame, y int) bool {
67+ return RowsEqualAt(a, y, b, y)
68+}
69+
70+// RowsEqualAt is RowsEqual for logical row ya of a against logical row yb of b.
71+func RowsEqualAt(a Frame, ya int, b Frame, yb int) bool {
72+ if a.Width != b.Width || a.Width <= 0 || a.page == nil || b.page == nil ||
73+ ya < 0 || ya >= a.Height || yb < 0 || yb >= b.Height ||
74+ ya >= len(a.page.rows) || yb >= len(b.page.rows) {
75+ return false
76+ }
77+ width := a.Width
78+ offA, offB := int(a.page.rows[ya]), int(b.page.rows[yb])
79+ if offA+width > len(a.page.cells) || offB+width > len(b.page.cells) {
80+ return false
81+ }
82+ rowA := a.page.cells[offA : offA+width]
83+ rowB := b.page.cells[offB : offB+width]
84+
85+ // Remember the last style ID pair that compared equal so runs of one style
86+ // cost a single table lookup.
87+ var lastStyleA, lastStyleB uint32
88+ for x := range rowA {
89+ ca, cb := rowA[x], rowB[x]
90+ if ca.rune != cb.rune || ca.flags != cb.flags {
91+ return false
92+ }
93+ if ca.styleID != 0 || cb.styleID != 0 {
94+ if ca.styleID != lastStyleA || cb.styleID != lastStyleB {
95+ if !a.storedStylesEqual(b, ca.styleID, cb.styleID) {
96+ return false
97+ }
98+ lastStyleA, lastStyleB = ca.styleID, cb.styleID
99+ }
100+ }
101+ if ca.payloadID != 0 || cb.payloadID != 0 {
102+ if ca.payloadID == 0 || cb.payloadID == 0 || a.payload(ca.payloadID) != b.payload(cb.payloadID) {
103+ return false
104+ }
105+ }
106+ }
107+ return true
108+}
109+
110+func (f Frame) storedStylesEqual(other Frame, id, otherID uint32) bool {
111+ if int(id) >= len(f.page.styles) || int(otherID) >= len(other.page.styles) {
112+ return false
113+ }
114+ sa, sb := f.page.styles[id].style, other.page.styles[otherID].style
115+ return sa == sb || sa.Equal(sb)
116+}
A core/frame_copy_test.go +299 -0
@@ -0,0 +1,299 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "reflect"
6+ "testing"
7+)
8+
9+// cellPageFieldCount is the number of cellPage fields CopyFrom handles. Adding
10+// a field to cellPage fails TestCopyFromHandlesEveryPageField until CopyFrom
11+// (and this count) are updated.
12+const cellPageFieldCount = 13
13+
14+func copyTestPayload(t *testing.T, grapheme, link string) CellPayload {
15+ t.Helper()
16+ p, err := NewCellPayload(grapheme, link)
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ return p
21+}
22+
23+func copyTestFrame(t *testing.T, width, height int, seed rune) Frame {
24+ t.Helper()
25+ f := NewFrame(width, height)
26+ styles := []Style{DefaultStyle(), {Bold: true, Foreground: 3, Background: -1}, {Italic: true, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}, Background: -1}}
27+ for y := range height {
28+ for x := range width {
29+ c := Cell{Rune: seed + rune((x+y)%7), Style: styles[(x+2*y)%len(styles)]}
30+ if (x+y)%5 == 0 {
31+ c.Payload = copyTestPayload(t, "e\u0301", "https://example.test/"+string(rune('a'+y%3)))
32+ }
33+ f.Set(x, y, c)
34+ }
35+ }
36+ if height > 2 {
37+ f.ScrollUp(0, height-1, 1)
38+ }
39+ return f
40+}
41+
42+func requireFramesEqual(t *testing.T, got, want Frame) {
43+ t.Helper()
44+ if got.Width != want.Width || got.Height != want.Height {
45+ t.Fatalf("size %dx%d, want %dx%d", got.Width, got.Height, want.Width, want.Height)
46+ }
47+ for y := range want.Height {
48+ if !reflect.DeepEqual(got.Row(y), want.Row(y)) {
49+ t.Fatalf("row %d = %+v, want %+v", y, got.Row(y), want.Row(y))
50+ }
51+ }
52+ if err := got.CheckInvariants(); err != nil {
53+ t.Fatal(err)
54+ }
55+}
56+
57+func TestCopyFromIsIndependentAndReusesStorage(t *testing.T) {
58+ src := copyTestFrame(t, 9, 5, 'a')
59+ var dst Frame
60+ dst.CopyFrom(src)
61+ requireFramesEqual(t, dst, src)
62+ if dst.page == src.page {
63+ t.Fatal("copy aliases source page")
64+ }
65+
66+ // Mutating the source must not affect the copy.
67+ before := dst.Clone()
68+ src.Set(0, 0, Cell{Rune: 'Z', Style: Style{Bold: true, Foreground: 9, Background: -1}})
69+ requireFramesEqual(t, dst, before)
70+
71+ // Copy over a different, larger-state destination: the page pointer and
72+ // backing arrays are reused and stale styles/payloads do not leak.
73+ other := copyTestFrame(t, 9, 5, 'k')
74+ other.Set(1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}})
75+ page := dst.page
76+ cells := &dst.page.cells[0]
77+ dst.CopyFrom(other)
78+ if dst.page != page || &dst.page.cells[0] != cells {
79+ t.Fatal("CopyFrom did not reuse destination storage")
80+ }
81+ requireFramesEqual(t, dst, other)
82+ if dst.LogicalBytes() != other.LogicalBytes() || dst.StyleCount() != other.StyleCount() {
83+ t.Fatalf("accounting mismatch: %d/%d vs %d/%d", dst.LogicalBytes(), dst.StyleCount(), other.LogicalBytes(), other.StyleCount())
84+ }
85+
86+ // The copy stays a fully functional, independent frame.
87+ dst.Set(2, 2, Cell{Rune: 'n', Style: DefaultStyle()})
88+ dst.ScrollDown(0, 4, 2)
89+ if err := dst.CheckInvariants(); err != nil {
90+ t.Fatal(err)
91+ }
92+ requireFramesEqual(t, other, withCell(copyTestFrame(t, 9, 5, 'k'), 1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}}))
93+}
94+
95+func withCell(f Frame, x, y int, c Cell) Frame { f.Set(x, y, c); return f }
96+
97+func TestCopyFromResizesAndIgnoresInvalidSource(t *testing.T) {
98+ dst := copyTestFrame(t, 4, 3, 'a')
99+ small := copyTestFrame(t, 2, 2, 'x')
100+ dst.CopyFrom(small)
101+ requireFramesEqual(t, dst, small)
102+ large := copyTestFrame(t, 12, 7, 'm')
103+ dst.CopyFrom(large)
104+ requireFramesEqual(t, dst, large)
105+
106+ before := dst.Clone()
107+ dst.CopyFrom(Frame{})
108+ dst.CopyFrom(Frame{Width: 3, Height: 3})
109+ requireFramesEqual(t, dst, before)
110+ dst.CopyFrom(dst)
111+ requireFramesEqual(t, dst, before)
112+ var nilFrame *Frame
113+ nilFrame.CopyFrom(large)
114+}
115+
116+func TestCopyFromDoesNotAllocateOnceSized(t *testing.T) {
117+ src := copyTestFrame(t, 40, 10, 'a')
118+ dst := src.Clone()
119+ dst.CopyFrom(src)
120+ if n := testing.AllocsPerRun(20, func() { dst.CopyFrom(src) }); n != 0 {
121+ t.Fatalf("CopyFrom allocated %v times", n)
122+ }
123+}
124+
125+func TestRowsEqual(t *testing.T) {
126+ a := copyTestFrame(t, 10, 4, 'a')
127+ b := a.Clone()
128+ for y := range a.Height {
129+ if !RowsEqual(a, b, y) {
130+ t.Fatalf("clone row %d differs", y)
131+ }
132+ }
133+
134+ // Equal content with different physical rotation and different ID tables.
135+ c := NewFrame(a.Width, a.Height)
136+ // Intern an unrelated style first so IDs differ from a's.
137+ c.Set(0, 0, Cell{Rune: 'x', Style: Style{Inverse: true}})
138+ for y := range a.Height {
139+ for x := range a.Width {
140+ c.Set(x, y, a.Cell(x, y))
141+ }
142+ }
143+ for y := range a.Height {
144+ if !RowsEqual(a, c, y) {
145+ t.Fatalf("semantically equal row %d reported different", y)
146+ }
147+ if got, want := RowsEqual(a, c, y), reflect.DeepEqual(a.Row(y), c.Row(y)); got != want {
148+ t.Fatalf("row %d: RowsEqual=%v DeepEqual=%v", y, got, want)
149+ }
150+ }
151+
152+ // Each kind of difference is detected on the right row only.
153+ diffs := []Cell{
154+ {Rune: 'Q', Style: a.Cell(3, 1).Style},
155+ {Rune: a.Cell(3, 1).Rune, Style: Style{Bold: true, Italic: true, Foreground: 5, Background: -1}},
156+ {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Payload: copyTestPayload(t, "z", "")},
157+ {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Continuation: true},
158+ }
159+ for i, d := range diffs {
160+ m := a.Clone()
161+ m.Set(3, 1, d)
162+ for y := range a.Height {
163+ want := y != 1
164+ if got := RowsEqual(a, m, y); got != want {
165+ t.Fatalf("diff %d row %d: got %v want %v", i, y, got, want)
166+ }
167+ }
168+ }
169+
170+ // Payload present on one side only, and differing payloads.
171+ m := a.Clone()
172+ px := -1
173+ for x := range a.Width {
174+ if a.Cell(x, 0).Payload != (CellPayload{}) {
175+ px = x
176+ break
177+ }
178+ }
179+ if px < 0 {
180+ t.Fatal("test frame row 0 has no payload")
181+ }
182+ m.Set(px, 0, Cell{Rune: a.Cell(px, 0).Rune, Style: a.Cell(px, 0).Style})
183+ if RowsEqual(a, m, 0) {
184+ t.Fatal("payload removal not detected")
185+ }
186+
187+ // Inactive RGB fields and other equivalent spellings compare equal.
188+ e1, e2 := NewFrame(2, 1), NewFrame(2, 1)
189+ s1 := Style{Bold: true, Foreground: 2, Background: -1}
190+ s2 := s1
191+ s2.ForegroundRGB = RGB{R: 9}
192+ e1.Set(0, 0, Cell{Rune: 'a', Style: s1})
193+ e2.Set(0, 0, Cell{Rune: 'a', Style: s2})
194+ if !RowsEqual(e1, e2, 0) {
195+ t.Fatal("equivalent styles reported different")
196+ }
197+
198+ // Mismatched shape or range is unequal and never panics.
199+ if RowsEqual(a, NewFrame(a.Width+1, a.Height), 0) || RowsEqual(a, b, -1) || RowsEqual(a, b, a.Height) || RowsEqual(Frame{}, Frame{}, 0) {
200+ t.Fatal("invalid comparison reported equal")
201+ }
202+}
203+
204+func TestRowsEqualDoesNotAllocate(t *testing.T) {
205+ a := copyTestFrame(t, 80, 4, 'a')
206+ b := a.Clone()
207+ if n := testing.AllocsPerRun(20, func() { RowsEqual(a, b, 2) }); n != 0 {
208+ t.Fatalf("RowsEqual allocated %v times", n)
209+ }
210+}
211+
212+func TestCopyFromHandlesEveryPageField(t *testing.T) {
213+ typ := reflect.TypeOf(cellPage{})
214+ if typ.NumField() != cellPageFieldCount {
215+ t.Fatalf("cellPage has %d fields, CopyFrom handles %d: update CopyFrom and cellPageFieldCount", typ.NumField(), cellPageFieldCount)
216+ }
217+
218+ // Build a source that populates every field, and a destination whose every
219+ // field holds different, stale state.
220+ src := copyTestFrame(t, 9, 5, 'a')
221+ src.Set(0, 0, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 7, Background: -1}})
222+ dst := copyTestFrame(t, 9, 5, 'k')
223+ dst.Set(4, 4, Cell{Rune: 'f', Style: Style{Italic: true, Foreground: 1, Background: -1}})
224+ dst.Set(4, 4, Cell{Rune: 'f', Style: DefaultStyle()}) // frees a style slot
225+ dst.CopyFrom(src)
226+
227+ sp, dp := reflect.ValueOf(*src.page), reflect.ValueOf(*dst.page)
228+ populated := 0
229+ for i := range typ.NumField() {
230+ name := typ.Field(i).Name
231+ a, b := sp.Field(i), dp.Field(i)
232+ if !a.IsZero() {
233+ populated++
234+ }
235+ if (a.Kind() == reflect.Slice || a.Kind() == reflect.Map) && a.Len() == 0 && b.Len() == 0 {
236+ continue // nil and empty are equivalent
237+ }
238+ // Printing works on unexported fields and treats nil and empty alike;
239+ // fmt sorts map keys, so the rendering is deterministic.
240+ if got, want := fmt.Sprintf("%#v", b), fmt.Sprintf("%#v", a); got != want {
241+ t.Errorf("cellPage.%s = %s, want %s", name, got, want)
242+ }
243+ }
244+ if populated < typ.NumField()-2 { // freeStyles/freePayloads may legitimately be empty
245+ t.Errorf("source populates only %d of %d page fields; strengthen the test", populated, typ.NumField())
246+ }
247+}
248+
249+func TestCopyFromDropsStaleStyleCache(t *testing.T) {
250+ styleA := Style{Bold: true, Foreground: 1, Background: -1}
251+ styleB := Style{Italic: true, Foreground: 2, Background: -1}
252+
253+ // dst: style A lives in slot k and is cached by the last Set.
254+ dst := NewFrame(4, 2)
255+ dst.Set(0, 0, Cell{Rune: 'a', Style: styleA})
256+ k := dst.page.styleCacheID
257+ if !dst.page.styleCacheOK || dst.page.styles[k].style != styleA {
258+ t.Fatalf("setup: cache = (%v,%d)", dst.page.styleCacheOK, k)
259+ }
260+
261+ // src: a different style occupies the same slot k, and no cache.
262+ src := NewFrame(4, 2)
263+ src.Set(0, 0, Cell{Rune: 'b', Style: styleB})
264+ src.page.styleCacheOK = false
265+ if src.page.styleIndex[styleB] != k {
266+ t.Fatalf("setup: style B in slot %d, want %d", src.page.styleIndex[styleB], k)
267+ }
268+
269+ dst.CopyFrom(src)
270+ if dst.page.styleCacheOK && dst.page.styles[dst.page.styleCacheID].style != dst.page.styleCache {
271+ t.Fatal("CopyFrom left a stale style cache")
272+ }
273+ cell := Cell{Rune: 'c', Style: styleA}
274+ dst.Set(1, 1, cell)
275+ if got := dst.Cell(1, 1); !got.Equal(cell) {
276+ t.Fatalf("cell = %+v, want %+v", got, cell)
277+ }
278+ if got := dst.Cell(0, 0); got.Style != styleB {
279+ t.Fatalf("copied cell style = %+v, want %+v", got.Style, styleB)
280+ }
281+ if err := dst.CheckInvariants(); err != nil {
282+ t.Fatal(err)
283+ }
284+}
285+
286+func TestRowsEqualAt(t *testing.T) {
287+ a := copyTestFrame(t, 10, 5, 'a')
288+ b := a.Clone()
289+ // b row y+1 shifted: compare a row y+1 against b row y after scrolling b.
290+ b.ScrollUp(0, 4, 1)
291+ for y := range 4 {
292+ if !RowsEqualAt(a, y+1, b, y) {
293+ t.Fatalf("row %d of a != row %d of scrolled b", y+1, y)
294+ }
295+ }
296+ if RowsEqualAt(a, 0, b, 0) || RowsEqualAt(a, 0, b, 5) || RowsEqualAt(a, -1, b, 0) {
297+ t.Fatal("unequal or out-of-range rows reported equal")
298+ }
299+}
@@ -0,0 +1,299 @@
1+package core
2+
3+import (
4+ "fmt"
5+ "reflect"
6+ "testing"
7+)
8+
9+// cellPageFieldCount is the number of cellPage fields CopyFrom handles. Adding
10+// a field to cellPage fails TestCopyFromHandlesEveryPageField until CopyFrom
11+// (and this count) are updated.
12+const cellPageFieldCount = 13
13+
14+func copyTestPayload(t *testing.T, grapheme, link string) CellPayload {
15+ t.Helper()
16+ p, err := NewCellPayload(grapheme, link)
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ return p
21+}
22+
23+func copyTestFrame(t *testing.T, width, height int, seed rune) Frame {
24+ t.Helper()
25+ f := NewFrame(width, height)
26+ styles := []Style{DefaultStyle(), {Bold: true, Foreground: 3, Background: -1}, {Italic: true, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}, Background: -1}}
27+ for y := range height {
28+ for x := range width {
29+ c := Cell{Rune: seed + rune((x+y)%7), Style: styles[(x+2*y)%len(styles)]}
30+ if (x+y)%5 == 0 {
31+ c.Payload = copyTestPayload(t, "e\u0301", "https://example.test/"+string(rune('a'+y%3)))
32+ }
33+ f.Set(x, y, c)
34+ }
35+ }
36+ if height > 2 {
37+ f.ScrollUp(0, height-1, 1)
38+ }
39+ return f
40+}
41+
42+func requireFramesEqual(t *testing.T, got, want Frame) {
43+ t.Helper()
44+ if got.Width != want.Width || got.Height != want.Height {
45+ t.Fatalf("size %dx%d, want %dx%d", got.Width, got.Height, want.Width, want.Height)
46+ }
47+ for y := range want.Height {
48+ if !reflect.DeepEqual(got.Row(y), want.Row(y)) {
49+ t.Fatalf("row %d = %+v, want %+v", y, got.Row(y), want.Row(y))
50+ }
51+ }
52+ if err := got.CheckInvariants(); err != nil {
53+ t.Fatal(err)
54+ }
55+}
56+
57+func TestCopyFromIsIndependentAndReusesStorage(t *testing.T) {
58+ src := copyTestFrame(t, 9, 5, 'a')
59+ var dst Frame
60+ dst.CopyFrom(src)
61+ requireFramesEqual(t, dst, src)
62+ if dst.page == src.page {
63+ t.Fatal("copy aliases source page")
64+ }
65+
66+ // Mutating the source must not affect the copy.
67+ before := dst.Clone()
68+ src.Set(0, 0, Cell{Rune: 'Z', Style: Style{Bold: true, Foreground: 9, Background: -1}})
69+ requireFramesEqual(t, dst, before)
70+
71+ // Copy over a different, larger-state destination: the page pointer and
72+ // backing arrays are reused and stale styles/payloads do not leak.
73+ other := copyTestFrame(t, 9, 5, 'k')
74+ other.Set(1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}})
75+ page := dst.page
76+ cells := &dst.page.cells[0]
77+ dst.CopyFrom(other)
78+ if dst.page != page || &dst.page.cells[0] != cells {
79+ t.Fatal("CopyFrom did not reuse destination storage")
80+ }
81+ requireFramesEqual(t, dst, other)
82+ if dst.LogicalBytes() != other.LogicalBytes() || dst.StyleCount() != other.StyleCount() {
83+ t.Fatalf("accounting mismatch: %d/%d vs %d/%d", dst.LogicalBytes(), dst.StyleCount(), other.LogicalBytes(), other.StyleCount())
84+ }
85+
86+ // The copy stays a fully functional, independent frame.
87+ dst.Set(2, 2, Cell{Rune: 'n', Style: DefaultStyle()})
88+ dst.ScrollDown(0, 4, 2)
89+ if err := dst.CheckInvariants(); err != nil {
90+ t.Fatal(err)
91+ }
92+ requireFramesEqual(t, other, withCell(copyTestFrame(t, 9, 5, 'k'), 1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}}))
93+}
94+
95+func withCell(f Frame, x, y int, c Cell) Frame { f.Set(x, y, c); return f }
96+
97+func TestCopyFromResizesAndIgnoresInvalidSource(t *testing.T) {
98+ dst := copyTestFrame(t, 4, 3, 'a')
99+ small := copyTestFrame(t, 2, 2, 'x')
100+ dst.CopyFrom(small)
101+ requireFramesEqual(t, dst, small)
102+ large := copyTestFrame(t, 12, 7, 'm')
103+ dst.CopyFrom(large)
104+ requireFramesEqual(t, dst, large)
105+
106+ before := dst.Clone()
107+ dst.CopyFrom(Frame{})
108+ dst.CopyFrom(Frame{Width: 3, Height: 3})
109+ requireFramesEqual(t, dst, before)
110+ dst.CopyFrom(dst)
111+ requireFramesEqual(t, dst, before)
112+ var nilFrame *Frame
113+ nilFrame.CopyFrom(large)
114+}
115+
116+func TestCopyFromDoesNotAllocateOnceSized(t *testing.T) {
117+ src := copyTestFrame(t, 40, 10, 'a')
118+ dst := src.Clone()
119+ dst.CopyFrom(src)
120+ if n := testing.AllocsPerRun(20, func() { dst.CopyFrom(src) }); n != 0 {
121+ t.Fatalf("CopyFrom allocated %v times", n)
122+ }
123+}
124+
125+func TestRowsEqual(t *testing.T) {
126+ a := copyTestFrame(t, 10, 4, 'a')
127+ b := a.Clone()
128+ for y := range a.Height {
129+ if !RowsEqual(a, b, y) {
130+ t.Fatalf("clone row %d differs", y)
131+ }
132+ }
133+
134+ // Equal content with different physical rotation and different ID tables.
135+ c := NewFrame(a.Width, a.Height)
136+ // Intern an unrelated style first so IDs differ from a's.
137+ c.Set(0, 0, Cell{Rune: 'x', Style: Style{Inverse: true}})
138+ for y := range a.Height {
139+ for x := range a.Width {
140+ c.Set(x, y, a.Cell(x, y))
141+ }
142+ }
143+ for y := range a.Height {
144+ if !RowsEqual(a, c, y) {
145+ t.Fatalf("semantically equal row %d reported different", y)
146+ }
147+ if got, want := RowsEqual(a, c, y), reflect.DeepEqual(a.Row(y), c.Row(y)); got != want {
148+ t.Fatalf("row %d: RowsEqual=%v DeepEqual=%v", y, got, want)
149+ }
150+ }
151+
152+ // Each kind of difference is detected on the right row only.
153+ diffs := []Cell{
154+ {Rune: 'Q', Style: a.Cell(3, 1).Style},
155+ {Rune: a.Cell(3, 1).Rune, Style: Style{Bold: true, Italic: true, Foreground: 5, Background: -1}},
156+ {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Payload: copyTestPayload(t, "z", "")},
157+ {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Continuation: true},
158+ }
159+ for i, d := range diffs {
160+ m := a.Clone()
161+ m.Set(3, 1, d)
162+ for y := range a.Height {
163+ want := y != 1
164+ if got := RowsEqual(a, m, y); got != want {
165+ t.Fatalf("diff %d row %d: got %v want %v", i, y, got, want)
166+ }
167+ }
168+ }
169+
170+ // Payload present on one side only, and differing payloads.
171+ m := a.Clone()
172+ px := -1
173+ for x := range a.Width {
174+ if a.Cell(x, 0).Payload != (CellPayload{}) {
175+ px = x
176+ break
177+ }
178+ }
179+ if px < 0 {
180+ t.Fatal("test frame row 0 has no payload")
181+ }
182+ m.Set(px, 0, Cell{Rune: a.Cell(px, 0).Rune, Style: a.Cell(px, 0).Style})
183+ if RowsEqual(a, m, 0) {
184+ t.Fatal("payload removal not detected")
185+ }
186+
187+ // Inactive RGB fields and other equivalent spellings compare equal.
188+ e1, e2 := NewFrame(2, 1), NewFrame(2, 1)
189+ s1 := Style{Bold: true, Foreground: 2, Background: -1}
190+ s2 := s1
191+ s2.ForegroundRGB = RGB{R: 9}
192+ e1.Set(0, 0, Cell{Rune: 'a', Style: s1})
193+ e2.Set(0, 0, Cell{Rune: 'a', Style: s2})
194+ if !RowsEqual(e1, e2, 0) {
195+ t.Fatal("equivalent styles reported different")
196+ }
197+
198+ // Mismatched shape or range is unequal and never panics.
199+ if RowsEqual(a, NewFrame(a.Width+1, a.Height), 0) || RowsEqual(a, b, -1) || RowsEqual(a, b, a.Height) || RowsEqual(Frame{}, Frame{}, 0) {
200+ t.Fatal("invalid comparison reported equal")
201+ }
202+}
203+
204+func TestRowsEqualDoesNotAllocate(t *testing.T) {
205+ a := copyTestFrame(t, 80, 4, 'a')
206+ b := a.Clone()
207+ if n := testing.AllocsPerRun(20, func() { RowsEqual(a, b, 2) }); n != 0 {
208+ t.Fatalf("RowsEqual allocated %v times", n)
209+ }
210+}
211+
212+func TestCopyFromHandlesEveryPageField(t *testing.T) {
213+ typ := reflect.TypeOf(cellPage{})
214+ if typ.NumField() != cellPageFieldCount {
215+ t.Fatalf("cellPage has %d fields, CopyFrom handles %d: update CopyFrom and cellPageFieldCount", typ.NumField(), cellPageFieldCount)
216+ }
217+
218+ // Build a source that populates every field, and a destination whose every
219+ // field holds different, stale state.
220+ src := copyTestFrame(t, 9, 5, 'a')
221+ src.Set(0, 0, Cell{Rune: 'a', Style: Style{Bold: true, Foreground: 7, Background: -1}})
222+ dst := copyTestFrame(t, 9, 5, 'k')
223+ dst.Set(4, 4, Cell{Rune: 'f', Style: Style{Italic: true, Foreground: 1, Background: -1}})
224+ dst.Set(4, 4, Cell{Rune: 'f', Style: DefaultStyle()}) // frees a style slot
225+ dst.CopyFrom(src)
226+
227+ sp, dp := reflect.ValueOf(*src.page), reflect.ValueOf(*dst.page)
228+ populated := 0
229+ for i := range typ.NumField() {
230+ name := typ.Field(i).Name
231+ a, b := sp.Field(i), dp.Field(i)
232+ if !a.IsZero() {
233+ populated++
234+ }
235+ if (a.Kind() == reflect.Slice || a.Kind() == reflect.Map) && a.Len() == 0 && b.Len() == 0 {
236+ continue // nil and empty are equivalent
237+ }
238+ // Printing works on unexported fields and treats nil and empty alike;
239+ // fmt sorts map keys, so the rendering is deterministic.
240+ if got, want := fmt.Sprintf("%#v", b), fmt.Sprintf("%#v", a); got != want {
241+ t.Errorf("cellPage.%s = %s, want %s", name, got, want)
242+ }
243+ }
244+ if populated < typ.NumField()-2 { // freeStyles/freePayloads may legitimately be empty
245+ t.Errorf("source populates only %d of %d page fields; strengthen the test", populated, typ.NumField())
246+ }
247+}
248+
249+func TestCopyFromDropsStaleStyleCache(t *testing.T) {
250+ styleA := Style{Bold: true, Foreground: 1, Background: -1}
251+ styleB := Style{Italic: true, Foreground: 2, Background: -1}
252+
253+ // dst: style A lives in slot k and is cached by the last Set.
254+ dst := NewFrame(4, 2)
255+ dst.Set(0, 0, Cell{Rune: 'a', Style: styleA})
256+ k := dst.page.styleCacheID
257+ if !dst.page.styleCacheOK || dst.page.styles[k].style != styleA {
258+ t.Fatalf("setup: cache = (%v,%d)", dst.page.styleCacheOK, k)
259+ }
260+
261+ // src: a different style occupies the same slot k, and no cache.
262+ src := NewFrame(4, 2)
263+ src.Set(0, 0, Cell{Rune: 'b', Style: styleB})
264+ src.page.styleCacheOK = false
265+ if src.page.styleIndex[styleB] != k {
266+ t.Fatalf("setup: style B in slot %d, want %d", src.page.styleIndex[styleB], k)
267+ }
268+
269+ dst.CopyFrom(src)
270+ if dst.page.styleCacheOK && dst.page.styles[dst.page.styleCacheID].style != dst.page.styleCache {
271+ t.Fatal("CopyFrom left a stale style cache")
272+ }
273+ cell := Cell{Rune: 'c', Style: styleA}
274+ dst.Set(1, 1, cell)
275+ if got := dst.Cell(1, 1); !got.Equal(cell) {
276+ t.Fatalf("cell = %+v, want %+v", got, cell)
277+ }
278+ if got := dst.Cell(0, 0); got.Style != styleB {
279+ t.Fatalf("copied cell style = %+v, want %+v", got.Style, styleB)
280+ }
281+ if err := dst.CheckInvariants(); err != nil {
282+ t.Fatal(err)
283+ }
284+}
285+
286+func TestRowsEqualAt(t *testing.T) {
287+ a := copyTestFrame(t, 10, 5, 'a')
288+ b := a.Clone()
289+ // b row y+1 shifted: compare a row y+1 against b row y after scrolling b.
290+ b.ScrollUp(0, 4, 1)
291+ for y := range 4 {
292+ if !RowsEqualAt(a, y+1, b, y) {
293+ t.Fatalf("row %d of a != row %d of scrolled b", y+1, y)
294+ }
295+ }
296+ if RowsEqualAt(a, 0, b, 0) || RowsEqualAt(a, 0, b, 5) || RowsEqualAt(a, -1, b, 0) {
297+ t.Fatal("unequal or out-of-range rows reported equal")
298+ }
299+}

Keyboard shortcuts

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

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

Branches 2

Tags 13

↑↓ moveenter openesc close