detached f91f9d4

fix(renderer): harden prepared draw commits

commit
f91f9d42f6cee783526583e8293e7dde8a194d50
author
brice · · 2026-08-02 11:45 UTC
parent
4b7cd9e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +296 -214
M pkg/renderer/delta_plan.go +6 -3
@@ -23,7 +23,7 @@ type DeltaPlan struct {
2323 Spans []Span
2424 }
2525
26-// DeltaCandidate holds a plan and borrows its source frame until Commit.
26+// DeltaCandidate holds a plan and owns a source frame snapshot until Commit.
2727 type DeltaCandidate struct {
2828 Plan DeltaPlan
2929 frame Frame
@@ -35,11 +35,14 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta
3535 return DeltaCandidate{}, err
3636 }
3737
38- candidate := DeltaCandidate{frame: frame}
38+ candidate := DeltaCandidate{frame: frame.Clone()}
3939 if reset || frame.Width != committed.Width || frame.Height != committed.Height {
4040 candidate.Plan.Snapshot = true
4141 return candidate, nil
4242 }
43+ if err := committed.Validate(); err != nil {
44+ return DeltaCandidate{}, err
45+ }
4346
4447 if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
4548 candidate.Plan = planSingleDamage(frame, damage[0])
@@ -186,7 +189,7 @@ func styleRunCount(cells []Cell) int {
186189 return runs
187190 }
188191
189-// Commit advances dst to the frame borrowed by the candidate.
192+// Commit advances dst to the frame snapshot owned by the candidate.
190193 func (c DeltaCandidate) Commit(dst *Frame) {
191194 if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height {
192195 replaceFrame(dst, c.frame)
@@ -23,7 +23,7 @@ type DeltaPlan struct {
23 Spans []Span23 Spans []Span
24 }24 }
25 25
26-// DeltaCandidate holds a plan and borrows its source frame until Commit.26+// DeltaCandidate holds a plan and owns a source frame snapshot until Commit.
27 type DeltaCandidate struct {27 type DeltaCandidate struct {
28 Plan DeltaPlan28 Plan DeltaPlan
29 frame Frame29 frame Frame
@@ -35,11 +35,14 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta
35 return DeltaCandidate{}, err35 return DeltaCandidate{}, err
36 }36 }
37 37
38- candidate := DeltaCandidate{frame: frame}38+ candidate := DeltaCandidate{frame: frame.Clone()}
39 if reset || frame.Width != committed.Width || frame.Height != committed.Height {39 if reset || frame.Width != committed.Width || frame.Height != committed.Height {
40 candidate.Plan.Snapshot = true40 candidate.Plan.Snapshot = true
41 return candidate, nil41 return candidate, nil
42 }42 }
43+ if err := committed.Validate(); err != nil {
44+ return DeltaCandidate{}, err
45+ }
43 46
44 if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {47 if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
45 candidate.Plan = planSingleDamage(frame, damage[0])48 candidate.Plan = planSingleDamage(frame, damage[0])
@@ -186,7 +189,7 @@ func styleRunCount(cells []Cell) int {
186 return runs189 return runs
187 }190 }
188 191
189-// Commit advances dst to the frame borrowed by the candidate.192+// Commit advances dst to the frame snapshot owned by the candidate.
190 func (c DeltaCandidate) Commit(dst *Frame) {193 func (c DeltaCandidate) Commit(dst *Frame) {
191 if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height {194 if c.Plan.Snapshot || dst.Width != c.frame.Width || dst.Height != c.frame.Height {
192 replaceFrame(dst, c.frame)195 replaceFrame(dst, c.frame)
M pkg/renderer/renderer.go +12 -11
@@ -27,10 +27,10 @@ type Renderer struct {
2727 // PreparedDraw owns encoded output and its transactional delta until Commit.
2828 // It is returned by value so ordinary prepared draws do not allocate.
2929 type PreparedDraw struct {
30- renderer *Renderer
31- candidate DeltaCandidate
32- data []byte
33- committed bool
30+ renderer *Renderer
31+ candidate DeltaCandidate
32+ data []byte
33+ commitOnce *sync.Once
3434 }
3535
3636 func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} }
@@ -49,13 +49,14 @@ func (p PreparedDraw) Bytes() []byte { return p.data }
4949 // Commit applies the prepared delta exactly once. Discarding it leaves the
5050 // renderer's committed state unchanged.
5151 func (p *PreparedDraw) Commit() {
52- if p == nil || p.renderer == nil || p.committed {
52+ if p == nil || p.renderer == nil || p.commitOnce == nil {
5353 return
5454 }
55- committed := p.renderer.committedFrame()
56- p.candidate.Commit(&committed)
57- p.renderer.setCommittedFrame(committed)
58- p.committed = true
55+ p.commitOnce.Do(func() {
56+ committed := p.renderer.committedFrame()
57+ p.candidate.Commit(&committed)
58+ p.renderer.setCommittedFrame(committed)
59+ })
5960 }
6061
6162 // Prepare plans and encodes a transactional draw. The renderer advances only
@@ -66,7 +67,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr
6667 var err error
6768 if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
6869 if err = frame.Validate(); err == nil {
69- candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame}
70+ candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame.Clone()}
7071 }
7172 } else {
7273 candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0)
@@ -74,7 +75,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr
7475 if err != nil {
7576 return PreparedDraw{}, err
7677 }
77- prepared := PreparedDraw{renderer: r, candidate: candidate}
78+ prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)}
7879 plan := candidate.Plan
7980 if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
8081 return prepared, nil
@@ -27,10 +27,10 @@ type Renderer struct {
27 // PreparedDraw owns encoded output and its transactional delta until Commit.27 // PreparedDraw owns encoded output and its transactional delta until Commit.
28 // It is returned by value so ordinary prepared draws do not allocate.28 // It is returned by value so ordinary prepared draws do not allocate.
29 type PreparedDraw struct {29 type PreparedDraw struct {
30- renderer *Renderer30+ renderer *Renderer
31- candidate DeltaCandidate31+ candidate DeltaCandidate
32- data []byte32+ data []byte
33- committed bool33+ commitOnce *sync.Once
34 }34 }
35 35
36 func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} }36 func New(caps Capabilities) *Renderer { return &Renderer{caps: caps} }
@@ -49,13 +49,14 @@ func (p PreparedDraw) Bytes() []byte { return p.data }
49 // Commit applies the prepared delta exactly once. Discarding it leaves the49 // Commit applies the prepared delta exactly once. Discarding it leaves the
50 // renderer's committed state unchanged.50 // renderer's committed state unchanged.
51 func (p *PreparedDraw) Commit() {51 func (p *PreparedDraw) Commit() {
52- if p == nil || p.renderer == nil || p.committed {52+ if p == nil || p.renderer == nil || p.commitOnce == nil {
53 return53 return
54 }54 }
55- committed := p.renderer.committedFrame()55+ p.commitOnce.Do(func() {
56- p.candidate.Commit(&committed)56+ committed := p.renderer.committedFrame()
57- p.renderer.setCommittedFrame(committed)57+ p.candidate.Commit(&committed)
58- p.committed = true58+ p.renderer.setCommittedFrame(committed)
59+ })
59 }60 }
60 61
61 // Prepare plans and encodes a transactional draw. The renderer advances only62 // Prepare plans and encodes a transactional draw. The renderer advances only
@@ -66,7 +67,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr
66 var err error67 var err error
67 if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {68 if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
68 if err = frame.Validate(); err == nil {69 if err = frame.Validate(); err == nil {
69- candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame}70+ candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame.Clone()}
70 }71 }
71 } else {72 } else {
72 candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0)73 candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0)
@@ -74,7 +75,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr
74 if err != nil {75 if err != nil {
75 return PreparedDraw{}, err76 return PreparedDraw{}, err
76 }77 }
77- prepared := PreparedDraw{renderer: r, candidate: candidate}78+ prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)}
78 plan := candidate.Plan79 plan := candidate.Plan
79 if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {80 if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
80 return prepared, nil81 return prepared, nil
M pkg/renderer/renderer_test.go +278 -200
@@ -1082,223 +1082,301 @@ func TestRendererPrepare(t *testing.T) {
10821082 fullFrame := testFrame("ABC", "DEF")
10831083 fullDamage := []Damage{FullRedraw()}
10841084
1085- t.Run("zero commit is safe", func(t *testing.T) {
1086- var prepared PreparedDraw
1087- prepared.Commit()
1088- })
1089-
1090- t.Run("discard and reprepare returns identical owned bytes", func(t *testing.T) {
1091- r := New(Capabilities{})
1092- first, err := r.Prepare(fullFrame, fullDamage, false)
1093- if err != nil {
1094- t.Fatal(err)
1095- }
1096- want := append([]byte(nil), first.Bytes()...)
1085+ tests := []struct {
1086+ name string
1087+ run func(*testing.T)
1088+ }{
1089+ {name: "zero commit is safe", run: func(t *testing.T) {
1090+ var prepared PreparedDraw
1091+ prepared.Commit()
1092+ }},
10971093
1098- second, err := r.Prepare(fullFrame, fullDamage, false)
1099- if err != nil {
1100- t.Fatal(err)
1101- }
1102- if !bytes.Equal(second.Bytes(), want) {
1103- t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want)
1104- }
1094+ {name: "discard and reprepare returns identical owned bytes", run: func(t *testing.T) {
1095+ r := New(Capabilities{})
1096+ first, err := r.Prepare(fullFrame, fullDamage, false)
1097+ if err != nil {
1098+ t.Fatal(err)
1099+ }
1100+ want := append([]byte(nil), first.Bytes()...)
11051101
1106- other := testFrame("12345678", "abcdefgh", "ABCDEFGH")
1107- if _, err := r.Prepare(other, fullDamage, false); err != nil {
1108- t.Fatal(err)
1109- }
1110- if !bytes.Equal(first.Bytes(), want) {
1111- t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want)
1112- }
1113- })
1102+ second, err := r.Prepare(fullFrame, fullDamage, false)
1103+ if err != nil {
1104+ t.Fatal(err)
1105+ }
1106+ if !bytes.Equal(second.Bytes(), want) {
1107+ t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want)
1108+ }
11141109
1115- t.Run("commit then no-op", func(t *testing.T) {
1116- r := New(Capabilities{})
1117- prepared, err := r.Prepare(fullFrame, fullDamage, false)
1118- if err != nil {
1119- t.Fatal(err)
1120- }
1121- prepared.Commit()
1110+ other := testFrame("12345678", "abcdefgh", "ABCDEFGH")
1111+ if _, err := r.Prepare(other, fullDamage, false); err != nil {
1112+ t.Fatal(err)
1113+ }
1114+ if !bytes.Equal(first.Bytes(), want) {
1115+ t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want)
1116+ }
1117+ }},
11221118
1123- next, err := r.Prepare(fullFrame, nil, false)
1124- if err != nil {
1125- t.Fatal(err)
1126- }
1127- if len(next.Bytes()) != 0 {
1128- t.Fatalf("unchanged frame emitted %q", next.Bytes())
1129- }
1130- })
1119+ {name: "commit then no-op", run: func(t *testing.T) {
1120+ r := New(Capabilities{})
1121+ prepared, err := r.Prepare(fullFrame, fullDamage, false)
1122+ if err != nil {
1123+ t.Fatal(err)
1124+ }
1125+ prepared.Commit()
11311126
1132- t.Run("reset preparation is transactional", func(t *testing.T) {
1133- r := New(Capabilities{})
1134- initial, err := r.Prepare(fullFrame, fullDamage, false)
1135- if err != nil {
1136- t.Fatal(err)
1137- }
1138- initial.Commit()
1127+ next, err := r.Prepare(fullFrame, nil, false)
1128+ if err != nil {
1129+ t.Fatal(err)
1130+ }
1131+ if len(next.Bytes()) != 0 {
1132+ t.Fatalf("unchanged frame emitted %q", next.Bytes())
1133+ }
1134+ }},
11391135
1140- resetFrame := testFrame("ABC", "XYZ")
1141- resetPrepared, err := r.Prepare(resetFrame, nil, true)
1142- if err != nil {
1143- t.Fatal(err)
1144- }
1145- want, err := New(Capabilities{}).Draw(resetFrame, nil)
1146- if err != nil {
1147- t.Fatal(err)
1148- }
1149- if !bytes.Equal(resetPrepared.Bytes(), want) {
1150- t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want)
1151- }
1136+ {name: "reset preparation is transactional", run: func(t *testing.T) {
1137+ r := New(Capabilities{})
1138+ initial, err := r.Prepare(fullFrame, fullDamage, false)
1139+ if err != nil {
1140+ t.Fatal(err)
1141+ }
1142+ initial.Commit()
11521143
1153- unchanged, err := r.Prepare(fullFrame, nil, false)
1154- if err != nil {
1155- t.Fatal(err)
1156- }
1157- if len(unchanged.Bytes()) != 0 {
1158- t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes())
1159- }
1144+ resetFrame := testFrame("ABC", "XYZ")
1145+ resetPrepared, err := r.Prepare(resetFrame, nil, true)
1146+ if err != nil {
1147+ t.Fatal(err)
1148+ }
1149+ want, err := New(Capabilities{}).Draw(resetFrame, nil)
1150+ if err != nil {
1151+ t.Fatal(err)
1152+ }
1153+ if !bytes.Equal(resetPrepared.Bytes(), want) {
1154+ t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want)
1155+ }
11601156
1161- resetPrepared, err = r.Prepare(resetFrame, nil, true)
1162- if err != nil {
1163- t.Fatal(err)
1164- }
1165- resetPrepared.Commit()
1166- afterCommit, err := r.Prepare(resetFrame, nil, false)
1167- if err != nil {
1168- t.Fatal(err)
1169- }
1170- if len(afterCommit.Bytes()) != 0 {
1171- t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes())
1172- }
1173- })
1157+ unchanged, err := r.Prepare(fullFrame, nil, false)
1158+ if err != nil {
1159+ t.Fatal(err)
1160+ }
1161+ if len(unchanged.Bytes()) != 0 {
1162+ t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes())
1163+ }
11741164
1175- t.Run("successful no-byte commit", func(t *testing.T) {
1176- r := New(Capabilities{})
1177- initial, err := r.Prepare(fullFrame, fullDamage, false)
1178- if err != nil {
1179- t.Fatal(err)
1180- }
1181- initial.Commit()
1165+ resetPrepared, err = r.Prepare(resetFrame, nil, true)
1166+ if err != nil {
1167+ t.Fatal(err)
1168+ }
1169+ resetPrepared.Commit()
1170+ afterCommit, err := r.Prepare(resetFrame, nil, false)
1171+ if err != nil {
1172+ t.Fatal(err)
1173+ }
1174+ if len(afterCommit.Bytes()) != 0 {
1175+ t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes())
1176+ }
1177+ }},
11821178
1183- noOp, err := r.Prepare(fullFrame, nil, false)
1184- if err != nil {
1185- t.Fatal(err)
1186- }
1187- if len(noOp.Bytes()) != 0 {
1188- t.Fatalf("no-op emitted %q", noOp.Bytes())
1189- }
1190- noOp.Commit()
1179+ {name: "successful no-byte commit", run: func(t *testing.T) {
1180+ r := New(Capabilities{})
1181+ initial, err := r.Prepare(fullFrame, fullDamage, false)
1182+ if err != nil {
1183+ t.Fatal(err)
1184+ }
1185+ initial.Commit()
11911186
1192- again, err := r.Prepare(fullFrame, nil, false)
1193- if err != nil {
1194- t.Fatal(err)
1195- }
1196- if len(again.Bytes()) != 0 {
1197- t.Fatalf("committed no-op changed renderer state: %q", again.Bytes())
1198- }
1199- })
1200-
1201- t.Run("double commit is idempotent", func(t *testing.T) {
1202- base := testFrame("0000", "1111", "2222", "3333")
1203- scrolled := testFrame("1111", "2222", "3333", "new!")
1204- damage := []Damage{
1205- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1206- {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1207- }
1208- r := New(Capabilities{})
1209- if _, err := r.Draw(base, fullDamage); err != nil {
1210- t.Fatal(err)
1211- }
1187+ noOp, err := r.Prepare(fullFrame, nil, false)
1188+ if err != nil {
1189+ t.Fatal(err)
1190+ }
1191+ if len(noOp.Bytes()) != 0 {
1192+ t.Fatalf("no-op emitted %q", noOp.Bytes())
1193+ }
1194+ noOp.Commit()
12121195
1213- prepared, err := r.Prepare(scrolled, damage, false)
1214- if err != nil {
1215- t.Fatal(err)
1216- }
1217- prepared.Commit()
1218- prepared.Commit()
1196+ again, err := r.Prepare(fullFrame, nil, false)
1197+ if err != nil {
1198+ t.Fatal(err)
1199+ }
1200+ if len(again.Bytes()) != 0 {
1201+ t.Fatalf("committed no-op changed renderer state: %q", again.Bytes())
1202+ }
1203+ }},
12191204
1220- next, err := r.Prepare(scrolled, nil, false)
1221- if err != nil {
1222- t.Fatal(err)
1223- }
1224- if len(next.Bytes()) != 0 {
1225- t.Fatalf("double commit advanced state twice: %q", next.Bytes())
1226- }
1227- })
1228-
1229- t.Run("matches concrete ANSI fixtures", func(t *testing.T) {
1230- tests := []struct {
1231- name string
1232- prepare func(t *testing.T) PreparedDraw
1233- want string
1234- }{
1235- {
1236- name: "full draw",
1237- prepare: func(t *testing.T) PreparedDraw {
1238- prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false)
1239- if err != nil {
1240- t.Fatal(err)
1241- }
1242- return prepared
1205+ {name: "double commit is idempotent", run: func(t *testing.T) {
1206+ base := testFrame("0000", "1111", "2222", "3333")
1207+ scrolled := testFrame("1111", "2222", "3333", "new!")
1208+ damage := []Damage{
1209+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1210+ {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1211+ }
1212+ r := New(Capabilities{})
1213+ if _, err := r.Draw(base, fullDamage); err != nil {
1214+ t.Fatal(err)
1215+ }
1216+
1217+ prepared, err := r.Prepare(scrolled, damage, false)
1218+ if err != nil {
1219+ t.Fatal(err)
1220+ }
1221+ prepared.Commit()
1222+ prepared.Commit()
1223+
1224+ next, err := r.Prepare(scrolled, nil, false)
1225+ if err != nil {
1226+ t.Fatal(err)
1227+ }
1228+ if len(next.Bytes()) != 0 {
1229+ t.Fatalf("double commit advanced state twice: %q", next.Bytes())
1230+ }
1231+ }},
1232+
1233+ {name: "copied prepared draw commits scroll once", run: func(t *testing.T) {
1234+ base := testFrame("0000", "1111", "2222", "3333")
1235+ scrolled := testFrame("1111", "2222", "3333", "new!")
1236+ damage := []Damage{
1237+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1238+ {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1239+ }
1240+ r := New(Capabilities{})
1241+ if _, err := r.Draw(base, fullDamage); err != nil {
1242+ t.Fatal(err)
1243+ }
1244+
1245+ prepared, err := r.Prepare(scrolled, damage, false)
1246+ if err != nil {
1247+ t.Fatal(err)
1248+ }
1249+ copied := prepared
1250+ prepared.Commit()
1251+ copied.Commit()
1252+
1253+ next, err := r.Prepare(scrolled, nil, false)
1254+ if err != nil {
1255+ t.Fatal(err)
1256+ }
1257+ if len(next.Bytes()) != 0 {
1258+ t.Fatalf("copied commit advanced state twice: %q", next.Bytes())
1259+ }
1260+ }},
1261+
1262+ {name: "commit uses immutable frame snapshot", run: func(t *testing.T) {
1263+ r := New(Capabilities{})
1264+ base := testFrame("ABC", "DEF")
1265+ if _, err := r.Draw(base, fullDamage); err != nil {
1266+ t.Fatal(err)
1267+ }
1268+
1269+ changed := base.Clone()
1270+ changed.Set(1, 1, Cell{Rune: 'X', Style: DefaultStyle()})
1271+ want := changed.Clone()
1272+ prepared, err := r.Prepare(changed, []Damage{{Kind: DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false)
1273+ if err != nil {
1274+ t.Fatal(err)
1275+ }
1276+ if !strings.Contains(string(prepared.Bytes()), "X") {
1277+ t.Fatalf("prepared output = %q, want changed cell", prepared.Bytes())
1278+ }
1279+
1280+ changed.Set(1, 1, Cell{Rune: 'Y', Style: DefaultStyle()})
1281+ prepared.Commit()
1282+
1283+ next, err := r.Prepare(want, nil, false)
1284+ if err != nil {
1285+ t.Fatal(err)
1286+ }
1287+ if len(next.Bytes()) != 0 {
1288+ t.Fatalf("snapshot did not preserve prepared frame: %q", next.Bytes())
1289+ }
1290+ }},
1291+
1292+ {name: "matches concrete ANSI fixtures", run: func(t *testing.T) {
1293+ tests := []struct {
1294+ name string
1295+ prepare func(t *testing.T) PreparedDraw
1296+ want string
1297+ }{
1298+ {
1299+ name: "full draw",
1300+ prepare: func(t *testing.T) PreparedDraw {
1301+ prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false)
1302+ if err != nil {
1303+ t.Fatal(err)
1304+ }
1305+ return prepared
1306+ },
1307+ want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m",
12431308 },
1244- want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m",
1245- },
1246- {
1247- name: "fragmented damage",
1248- prepare: func(t *testing.T) PreparedDraw {
1249- base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx")
1250- frame := base.Clone()
1251- frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1252- frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()})
1253- frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()})
1254- damage := []Damage{
1255- {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1},
1256- {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1},
1257- {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1},
1258- }
1259- r := New(Capabilities{})
1260- if _, err := r.Draw(base, fullDamage); err != nil {
1261- t.Fatal(err)
1262- }
1263- prepared, err := r.Prepare(frame, damage, false)
1264- if err != nil {
1265- t.Fatal(err)
1266- }
1267- return prepared
1309+ {
1310+ name: "fragmented damage",
1311+ prepare: func(t *testing.T) PreparedDraw {
1312+ base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx")
1313+ frame := base.Clone()
1314+ frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1315+ frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()})
1316+ frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()})
1317+ damage := []Damage{
1318+ {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1},
1319+ {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1},
1320+ {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1},
1321+ }
1322+ r := New(Capabilities{})
1323+ if _, err := r.Draw(base, fullDamage); err != nil {
1324+ t.Fatal(err)
1325+ }
1326+ prepared, err := r.Prepare(frame, damage, false)
1327+ if err != nil {
1328+ t.Fatal(err)
1329+ }
1330+ return prepared
1331+ },
1332+ want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m",
12681333 },
1269- want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m",
1270- },
1271- {
1272- name: "scroll",
1273- prepare: func(t *testing.T) PreparedDraw {
1274- base := testFrame("00000", "11111", "22222", "33333")
1275- frame := testFrame("11111", "22222", "33333", "new!!")
1276- damage := []Damage{
1277- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
1278- {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
1279- }
1280- r := New(Capabilities{})
1281- if _, err := r.Draw(base, fullDamage); err != nil {
1282- t.Fatal(err)
1283- }
1284- prepared, err := r.Prepare(frame, damage, false)
1285- if err != nil {
1286- t.Fatal(err)
1287- }
1288- return prepared
1334+ {
1335+ name: "scroll",
1336+ prepare: func(t *testing.T) PreparedDraw {
1337+ base := testFrame("00000", "11111", "22222", "33333")
1338+ frame := testFrame("11111", "22222", "33333", "new!!")
1339+ damage := []Damage{
1340+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
1341+ {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
1342+ }
1343+ r := New(Capabilities{})
1344+ if _, err := r.Draw(base, fullDamage); err != nil {
1345+ t.Fatal(err)
1346+ }
1347+ prepared, err := r.Prepare(frame, damage, false)
1348+ if err != nil {
1349+ t.Fatal(err)
1350+ }
1351+ return prepared
1352+ },
1353+ want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m",
12891354 },
1290- want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m",
1291- },
1292- }
1355+ }
12931356
1294- for _, tt := range tests {
1295- t.Run(tt.name, func(t *testing.T) {
1296- if got := string(tt.prepare(t).Bytes()); got != tt.want {
1297- t.Fatalf("prepared ANSI = %q, want %q", got, tt.want)
1298- }
1299- })
1300- }
1301- })
1357+ for _, tt := range tests {
1358+ t.Run(tt.name, func(t *testing.T) {
1359+ if got := string(tt.prepare(t).Bytes()); got != tt.want {
1360+ t.Fatalf("prepared ANSI = %q, want %q", got, tt.want)
1361+ }
1362+ })
1363+ }
1364+ }},
1365+ }
1366+
1367+ for _, tt := range tests {
1368+ t.Run(tt.name, tt.run)
1369+ }
1370+}
1371+
1372+func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {
1373+ frame := NewFrame(2, 1)
1374+ committed := NewFrame(2, 1)
1375+ committed.lineOffset = nil
1376+
1377+ if _, err := PlanDelta(frame, nil, committed, false); err == nil {
1378+ t.Fatal("PlanDelta accepted an invalid committed frame")
1379+ }
13021380 }
13031381
13041382 func TestWideCharDamageEmission(t *testing.T) {
@@ -1082,223 +1082,301 @@ func TestRendererPrepare(t *testing.T) {
1082 fullFrame := testFrame("ABC", "DEF")1082 fullFrame := testFrame("ABC", "DEF")
1083 fullDamage := []Damage{FullRedraw()}1083 fullDamage := []Damage{FullRedraw()}
1084 1084
1085- t.Run("zero commit is safe", func(t *testing.T) {1085+ tests := []struct {
1086- var prepared PreparedDraw1086+ name string
1087- prepared.Commit()1087+ run func(*testing.T)
1088- })1088+ }{
1089-1089+ {name: "zero commit is safe", run: func(t *testing.T) {
1090- t.Run("discard and reprepare returns identical owned bytes", func(t *testing.T) {1090+ var prepared PreparedDraw
1091- r := New(Capabilities{})1091+ prepared.Commit()
1092- first, err := r.Prepare(fullFrame, fullDamage, false)1092+ }},
1093- if err != nil {
1094- t.Fatal(err)
1095- }
1096- want := append([]byte(nil), first.Bytes()...)
1097 1093
1098- second, err := r.Prepare(fullFrame, fullDamage, false)1094+ {name: "discard and reprepare returns identical owned bytes", run: func(t *testing.T) {
1099- if err != nil {1095+ r := New(Capabilities{})
1100- t.Fatal(err)1096+ first, err := r.Prepare(fullFrame, fullDamage, false)
1101- }1097+ if err != nil {
1102- if !bytes.Equal(second.Bytes(), want) {1098+ t.Fatal(err)
1103- t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want)1099+ }
1104- }1100+ want := append([]byte(nil), first.Bytes()...)
1105 1101
1106- other := testFrame("12345678", "abcdefgh", "ABCDEFGH")1102+ second, err := r.Prepare(fullFrame, fullDamage, false)
1107- if _, err := r.Prepare(other, fullDamage, false); err != nil {1103+ if err != nil {
1108- t.Fatal(err)1104+ t.Fatal(err)
1109- }1105+ }
1110- if !bytes.Equal(first.Bytes(), want) {1106+ if !bytes.Equal(second.Bytes(), want) {
1111- t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want)1107+ t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want)
1112- }1108+ }
1113- })
1114 1109
1115- t.Run("commit then no-op", func(t *testing.T) {1110+ other := testFrame("12345678", "abcdefgh", "ABCDEFGH")
1116- r := New(Capabilities{})1111+ if _, err := r.Prepare(other, fullDamage, false); err != nil {
1117- prepared, err := r.Prepare(fullFrame, fullDamage, false)1112+ t.Fatal(err)
1118- if err != nil {1113+ }
1119- t.Fatal(err)1114+ if !bytes.Equal(first.Bytes(), want) {
1120- }1115+ t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want)
1121- prepared.Commit()1116+ }
1117+ }},
1122 1118
1123- next, err := r.Prepare(fullFrame, nil, false)1119+ {name: "commit then no-op", run: func(t *testing.T) {
1124- if err != nil {1120+ r := New(Capabilities{})
1125- t.Fatal(err)1121+ prepared, err := r.Prepare(fullFrame, fullDamage, false)
1126- }1122+ if err != nil {
1127- if len(next.Bytes()) != 0 {1123+ t.Fatal(err)
1128- t.Fatalf("unchanged frame emitted %q", next.Bytes())1124+ }
1129- }1125+ prepared.Commit()
1130- })
1131 1126
1132- t.Run("reset preparation is transactional", func(t *testing.T) {1127+ next, err := r.Prepare(fullFrame, nil, false)
1133- r := New(Capabilities{})1128+ if err != nil {
1134- initial, err := r.Prepare(fullFrame, fullDamage, false)1129+ t.Fatal(err)
1135- if err != nil {1130+ }
1136- t.Fatal(err)1131+ if len(next.Bytes()) != 0 {
1137- }1132+ t.Fatalf("unchanged frame emitted %q", next.Bytes())
1138- initial.Commit()1133+ }
1134+ }},
1139 1135
1140- resetFrame := testFrame("ABC", "XYZ")1136+ {name: "reset preparation is transactional", run: func(t *testing.T) {
1141- resetPrepared, err := r.Prepare(resetFrame, nil, true)1137+ r := New(Capabilities{})
1142- if err != nil {1138+ initial, err := r.Prepare(fullFrame, fullDamage, false)
1143- t.Fatal(err)1139+ if err != nil {
1144- }1140+ t.Fatal(err)
1145- want, err := New(Capabilities{}).Draw(resetFrame, nil)1141+ }
1146- if err != nil {1142+ initial.Commit()
1147- t.Fatal(err)
1148- }
1149- if !bytes.Equal(resetPrepared.Bytes(), want) {
1150- t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want)
1151- }
1152 1143
1153- unchanged, err := r.Prepare(fullFrame, nil, false)1144+ resetFrame := testFrame("ABC", "XYZ")
1154- if err != nil {1145+ resetPrepared, err := r.Prepare(resetFrame, nil, true)
1155- t.Fatal(err)1146+ if err != nil {
1156- }1147+ t.Fatal(err)
1157- if len(unchanged.Bytes()) != 0 {1148+ }
1158- t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes())1149+ want, err := New(Capabilities{}).Draw(resetFrame, nil)
1159- }1150+ if err != nil {
1151+ t.Fatal(err)
1152+ }
1153+ if !bytes.Equal(resetPrepared.Bytes(), want) {
1154+ t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want)
1155+ }
1160 1156
1161- resetPrepared, err = r.Prepare(resetFrame, nil, true)1157+ unchanged, err := r.Prepare(fullFrame, nil, false)
1162- if err != nil {1158+ if err != nil {
1163- t.Fatal(err)1159+ t.Fatal(err)
1164- }1160+ }
1165- resetPrepared.Commit()1161+ if len(unchanged.Bytes()) != 0 {
1166- afterCommit, err := r.Prepare(resetFrame, nil, false)1162+ t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes())
1167- if err != nil {1163+ }
1168- t.Fatal(err)
1169- }
1170- if len(afterCommit.Bytes()) != 0 {
1171- t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes())
1172- }
1173- })
1174 1164
1175- t.Run("successful no-byte commit", func(t *testing.T) {1165+ resetPrepared, err = r.Prepare(resetFrame, nil, true)
1176- r := New(Capabilities{})1166+ if err != nil {
1177- initial, err := r.Prepare(fullFrame, fullDamage, false)1167+ t.Fatal(err)
1178- if err != nil {1168+ }
1179- t.Fatal(err)1169+ resetPrepared.Commit()
1180- }1170+ afterCommit, err := r.Prepare(resetFrame, nil, false)
1181- initial.Commit()1171+ if err != nil {
1172+ t.Fatal(err)
1173+ }
1174+ if len(afterCommit.Bytes()) != 0 {
1175+ t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes())
1176+ }
1177+ }},
1182 1178
1183- noOp, err := r.Prepare(fullFrame, nil, false)1179+ {name: "successful no-byte commit", run: func(t *testing.T) {
1184- if err != nil {1180+ r := New(Capabilities{})
1185- t.Fatal(err)1181+ initial, err := r.Prepare(fullFrame, fullDamage, false)
1186- }1182+ if err != nil {
1187- if len(noOp.Bytes()) != 0 {1183+ t.Fatal(err)
1188- t.Fatalf("no-op emitted %q", noOp.Bytes())1184+ }
1189- }1185+ initial.Commit()
1190- noOp.Commit()
1191 1186
1192- again, err := r.Prepare(fullFrame, nil, false)1187+ noOp, err := r.Prepare(fullFrame, nil, false)
1193- if err != nil {1188+ if err != nil {
1194- t.Fatal(err)1189+ t.Fatal(err)
1195- }1190+ }
1196- if len(again.Bytes()) != 0 {1191+ if len(noOp.Bytes()) != 0 {
1197- t.Fatalf("committed no-op changed renderer state: %q", again.Bytes())1192+ t.Fatalf("no-op emitted %q", noOp.Bytes())
1198- }1193+ }
1199- })1194+ noOp.Commit()
1200-
1201- t.Run("double commit is idempotent", func(t *testing.T) {
1202- base := testFrame("0000", "1111", "2222", "3333")
1203- scrolled := testFrame("1111", "2222", "3333", "new!")
1204- damage := []Damage{
1205- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1206- {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1207- }
1208- r := New(Capabilities{})
1209- if _, err := r.Draw(base, fullDamage); err != nil {
1210- t.Fatal(err)
1211- }
1212 1195
1213- prepared, err := r.Prepare(scrolled, damage, false)1196+ again, err := r.Prepare(fullFrame, nil, false)
1214- if err != nil {1197+ if err != nil {
1215- t.Fatal(err)1198+ t.Fatal(err)
1216- }1199+ }
1217- prepared.Commit()1200+ if len(again.Bytes()) != 0 {
1218- prepared.Commit()1201+ t.Fatalf("committed no-op changed renderer state: %q", again.Bytes())
1202+ }
1203+ }},
1219 1204
1220- next, err := r.Prepare(scrolled, nil, false)1205+ {name: "double commit is idempotent", run: func(t *testing.T) {
1221- if err != nil {1206+ base := testFrame("0000", "1111", "2222", "3333")
1222- t.Fatal(err)1207+ scrolled := testFrame("1111", "2222", "3333", "new!")
1223- }1208+ damage := []Damage{
1224- if len(next.Bytes()) != 0 {1209+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1225- t.Fatalf("double commit advanced state twice: %q", next.Bytes())1210+ {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1226- }1211+ }
1227- })1212+ r := New(Capabilities{})
1228-1213+ if _, err := r.Draw(base, fullDamage); err != nil {
1229- t.Run("matches concrete ANSI fixtures", func(t *testing.T) {1214+ t.Fatal(err)
1230- tests := []struct {1215+ }
1231- name string1216+
1232- prepare func(t *testing.T) PreparedDraw1217+ prepared, err := r.Prepare(scrolled, damage, false)
1233- want string1218+ if err != nil {
1234- }{1219+ t.Fatal(err)
1235- {1220+ }
1236- name: "full draw",1221+ prepared.Commit()
1237- prepare: func(t *testing.T) PreparedDraw {1222+ prepared.Commit()
1238- prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false)1223+
1239- if err != nil {1224+ next, err := r.Prepare(scrolled, nil, false)
1240- t.Fatal(err)1225+ if err != nil {
1241- }1226+ t.Fatal(err)
1242- return prepared1227+ }
1228+ if len(next.Bytes()) != 0 {
1229+ t.Fatalf("double commit advanced state twice: %q", next.Bytes())
1230+ }
1231+ }},
1232+
1233+ {name: "copied prepared draw commits scroll once", run: func(t *testing.T) {
1234+ base := testFrame("0000", "1111", "2222", "3333")
1235+ scrolled := testFrame("1111", "2222", "3333", "new!")
1236+ damage := []Damage{
1237+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1238+ {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1239+ }
1240+ r := New(Capabilities{})
1241+ if _, err := r.Draw(base, fullDamage); err != nil {
1242+ t.Fatal(err)
1243+ }
1244+
1245+ prepared, err := r.Prepare(scrolled, damage, false)
1246+ if err != nil {
1247+ t.Fatal(err)
1248+ }
1249+ copied := prepared
1250+ prepared.Commit()
1251+ copied.Commit()
1252+
1253+ next, err := r.Prepare(scrolled, nil, false)
1254+ if err != nil {
1255+ t.Fatal(err)
1256+ }
1257+ if len(next.Bytes()) != 0 {
1258+ t.Fatalf("copied commit advanced state twice: %q", next.Bytes())
1259+ }
1260+ }},
1261+
1262+ {name: "commit uses immutable frame snapshot", run: func(t *testing.T) {
1263+ r := New(Capabilities{})
1264+ base := testFrame("ABC", "DEF")
1265+ if _, err := r.Draw(base, fullDamage); err != nil {
1266+ t.Fatal(err)
1267+ }
1268+
1269+ changed := base.Clone()
1270+ changed.Set(1, 1, Cell{Rune: 'X', Style: DefaultStyle()})
1271+ want := changed.Clone()
1272+ prepared, err := r.Prepare(changed, []Damage{{Kind: DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false)
1273+ if err != nil {
1274+ t.Fatal(err)
1275+ }
1276+ if !strings.Contains(string(prepared.Bytes()), "X") {
1277+ t.Fatalf("prepared output = %q, want changed cell", prepared.Bytes())
1278+ }
1279+
1280+ changed.Set(1, 1, Cell{Rune: 'Y', Style: DefaultStyle()})
1281+ prepared.Commit()
1282+
1283+ next, err := r.Prepare(want, nil, false)
1284+ if err != nil {
1285+ t.Fatal(err)
1286+ }
1287+ if len(next.Bytes()) != 0 {
1288+ t.Fatalf("snapshot did not preserve prepared frame: %q", next.Bytes())
1289+ }
1290+ }},
1291+
1292+ {name: "matches concrete ANSI fixtures", run: func(t *testing.T) {
1293+ tests := []struct {
1294+ name string
1295+ prepare func(t *testing.T) PreparedDraw
1296+ want string
1297+ }{
1298+ {
1299+ name: "full draw",
1300+ prepare: func(t *testing.T) PreparedDraw {
1301+ prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false)
1302+ if err != nil {
1303+ t.Fatal(err)
1304+ }
1305+ return prepared
1306+ },
1307+ want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m",
1243 },1308 },
1244- want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m",1309+ {
1245- },1310+ name: "fragmented damage",
1246- {1311+ prepare: func(t *testing.T) PreparedDraw {
1247- name: "fragmented damage",1312+ base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx")
1248- prepare: func(t *testing.T) PreparedDraw {1313+ frame := base.Clone()
1249- base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx")1314+ frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1250- frame := base.Clone()1315+ frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()})
1251- frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()})1316+ frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()})
1252- frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()})1317+ damage := []Damage{
1253- frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()})1318+ {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1},
1254- damage := []Damage{1319+ {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1},
1255- {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1},1320+ {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1},
1256- {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1},1321+ }
1257- {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1},1322+ r := New(Capabilities{})
1258- }1323+ if _, err := r.Draw(base, fullDamage); err != nil {
1259- r := New(Capabilities{})1324+ t.Fatal(err)
1260- if _, err := r.Draw(base, fullDamage); err != nil {1325+ }
1261- t.Fatal(err)1326+ prepared, err := r.Prepare(frame, damage, false)
1262- }1327+ if err != nil {
1263- prepared, err := r.Prepare(frame, damage, false)1328+ t.Fatal(err)
1264- if err != nil {1329+ }
1265- t.Fatal(err)1330+ return prepared
1266- }1331+ },
1267- return prepared1332+ want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m",
1268 },1333 },
1269- want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m",1334+ {
1270- },1335+ name: "scroll",
1271- {1336+ prepare: func(t *testing.T) PreparedDraw {
1272- name: "scroll",1337+ base := testFrame("00000", "11111", "22222", "33333")
1273- prepare: func(t *testing.T) PreparedDraw {1338+ frame := testFrame("11111", "22222", "33333", "new!!")
1274- base := testFrame("00000", "11111", "22222", "33333")1339+ damage := []Damage{
1275- frame := testFrame("11111", "22222", "33333", "new!!")1340+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
1276- damage := []Damage{1341+ {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
1277- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},1342+ }
1278- {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},1343+ r := New(Capabilities{})
1279- }1344+ if _, err := r.Draw(base, fullDamage); err != nil {
1280- r := New(Capabilities{})1345+ t.Fatal(err)
1281- if _, err := r.Draw(base, fullDamage); err != nil {1346+ }
1282- t.Fatal(err)1347+ prepared, err := r.Prepare(frame, damage, false)
1283- }1348+ if err != nil {
1284- prepared, err := r.Prepare(frame, damage, false)1349+ t.Fatal(err)
1285- if err != nil {1350+ }
1286- t.Fatal(err)1351+ return prepared
1287- }1352+ },
1288- return prepared1353+ want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m",
1289 },1354 },
1290- want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m",1355+ }
1291- },
1292- }
1293 1356
1294- for _, tt := range tests {1357+ for _, tt := range tests {
1295- t.Run(tt.name, func(t *testing.T) {1358+ t.Run(tt.name, func(t *testing.T) {
1296- if got := string(tt.prepare(t).Bytes()); got != tt.want {1359+ if got := string(tt.prepare(t).Bytes()); got != tt.want {
1297- t.Fatalf("prepared ANSI = %q, want %q", got, tt.want)1360+ t.Fatalf("prepared ANSI = %q, want %q", got, tt.want)
1298- }1361+ }
1299- })1362+ })
1300- }1363+ }
1301- })1364+ }},
1365+ }
1366+
1367+ for _, tt := range tests {
1368+ t.Run(tt.name, tt.run)
1369+ }
1370+}
1371+
1372+func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {
1373+ frame := NewFrame(2, 1)
1374+ committed := NewFrame(2, 1)
1375+ committed.lineOffset = nil
1376+
1377+ if _, err := PlanDelta(frame, nil, committed, false); err == nil {
1378+ t.Fatal("PlanDelta accepted an invalid committed frame")
1379+ }
1302 }1380 }
1303 1381
1304 func TestWideCharDamageEmission(t *testing.T) {1382 func TestWideCharDamageEmission(t *testing.T) {

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close