detached 42b4cc2

perf(ansi): reuse renderer-owned frame buffers instead of cloning per Prepare

Prepare used to Clone the whole source frame (cells, rows, styles, cloned
style map, payloads) on every draw that had work to commit, which was ~99% of
allocated bytes. The renderer now owns two private frames: committed and
scratch. Prepare copies the source into scratch with Frame.CopyFrom (reusing
capacity; never aliasing caller storage, so callers may keep mutating one
Frame in place). Commit either swaps scratch and committed (snapshot plans) or
applies the span/scroll delta from scratch into committed. This double buffer
is the simplest design that keeps the transactional contract: discarding a
PreparedDraw leaves committed untouched because only scratch is written, and
PreparedDraw.Bytes never references frame storage. A generation counter makes
a draw superseded by a later Prepare a no-op on Commit, since its snapshot has
been overwritten (at most one prepared draw may be outstanding). Reset keeps
the buffers and only clears hasCommitted.

PlanDelta remains public with identical semantics: it still returns a
candidate owning a fresh snapshot (internal planDelta takes the scratch).

buildDirtyLinePlan uses core.RowsEqual when both sides are core.Frame or
*core.Frame, falling back to the generic Cell().Equal path for other
CellSource implementations. Output bytes are unchanged.

Adds ownership tests: caller mutation after commit / between Prepare and
Commit, discard then re-Prepare, retained Bytes after later draws, mixed
resize/reset/scroll sequences, and a steady-state allocation bound.
commit
42b4cc247d46731c49c60cf320f025514cf6af11
author
brice · · 2026-10-02 09:27 UTC
parent
d71ada9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +308 -32
M ansi/delta_plan.go +38 -17
@@ -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 {
@@ -216,6 +235,8 @@ func (c DeltaCandidate) Commit(dst *Frame) {
216235 }
217236 }
218237
238+// replaceFrame overwrites dst's own storage with a copy of src, reusing
239+// capacity. It is for frames the renderer owns exclusively.
219240 func replaceFrame(dst *Frame, src Frame) {
220- dst.Replace(src)
241+ dst.CopyFrom(src)
221242 }
@@ -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 {
@@ -216,6 +235,8 @@ func (c DeltaCandidate) Commit(dst *Frame) {
216 }235 }
217 }236 }
218 237
238+// replaceFrame overwrites dst's own storage with a copy of src, reusing
239+// capacity. It is for frames the renderer owns exclusively.
219 func replaceFrame(dst *Frame, src Frame) {240 func replaceFrame(dst *Frame, src Frame) {
220- dst.Replace(src)241+ dst.CopyFrom(src)
221 }242 }
M ansi/frame.go +57 -0
@@ -39,6 +39,63 @@ 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+// compactFrames reports whether both sources are core.Frame values (directly or
72+// by pointer), enabling stored-cell comparison without the CellSource interface.
73+type framePair struct{ a, b Frame }
74+
75+func compactFrames(a, b CellSource) (framePair, bool) {
76+ fa, ok := asFrame(a)
77+ if !ok {
78+ return framePair{}, false
79+ }
80+ fb, ok := asFrame(b)
81+ if !ok {
82+ return framePair{}, false
83+ }
84+ return framePair{fa, fb}, true
85+}
86+
87+func asFrame(source CellSource) (Frame, bool) {
88+ switch frame := source.(type) {
89+ case Frame:
90+ return frame, true
91+ case *Frame:
92+ if frame != nil {
93+ return *frame, true
94+ }
95+ }
96+ return Frame{}, false
97+}
98+
4299 func cloneCellSource(source CellSource) Frame {
43100 switch frame := source.(type) {
44101 case Frame:
@@ -39,6 +39,63 @@ 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+// compactFrames reports whether both sources are core.Frame values (directly or
72+// by pointer), enabling stored-cell comparison without the CellSource interface.
73+type framePair struct{ a, b Frame }
74+
75+func compactFrames(a, b CellSource) (framePair, bool) {
76+ fa, ok := asFrame(a)
77+ if !ok {
78+ return framePair{}, false
79+ }
80+ fb, ok := asFrame(b)
81+ if !ok {
82+ return framePair{}, false
83+ }
84+ return framePair{fa, fb}, true
85+}
86+
87+func asFrame(source CellSource) (Frame, bool) {
88+ switch frame := source.(type) {
89+ case Frame:
90+ return frame, true
91+ case *Frame:
92+ if frame != nil {
93+ return *frame, true
94+ }
95+ }
96+ return Frame{}, false
97+}
98+
42 func cloneCellSource(source CellSource) Frame {99 func cloneCellSource(source CellSource) Frame {
43 switch frame := source.(type) {100 switch frame := source.(type) {
44 case Frame:101 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 +37 -15
@@ -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
@@ -62,41 +69,56 @@ func (p *PreparedDraw) Commit() {
6269 return
6370 }
6471 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
72+ r := p.renderer
73+ plan := p.candidate.Plan
74+ if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
75+ return
76+ }
77+ if p.generation != r.generation {
78+ // A later Prepare reused the scratch frame this draw's snapshot
79+ // lives in, so it was discarded.
80+ return
81+ }
82+ if plan.Snapshot || !r.hasCommitted {
83+ // The snapshot lives in the renderer's scratch buffer; promote it
84+ // and recycle the previous committed buffer as the next scratch.
85+ r.committed, r.scratch = p.candidate.frame, r.committed
7086 } else {
71- p.candidate.Commit(&committed)
87+ p.candidate.Commit(&r.committed)
7288 }
73- p.renderer.setCommittedFrame(committed)
89+ r.width = r.committed.Width
90+ r.height = r.committed.Height
91+ r.hasCommitted = true
7492 })
7593 }
7694
7795 // Prepare plans and encodes a transactional draw. The renderer advances only
7896 // when the returned draw is committed. Keep at most one prepared draw
79-// outstanding; commit or discard it before calling Prepare again.
97+// outstanding; commit or discard it before calling Prepare again. A draw
98+// superseded by a later Prepare is discarded and its Commit does nothing.
99+//
100+// The renderer copies frame into reusable private storage, so the caller may
101+// keep mutating frame between draws.
80102 func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) {
81103 var candidate DeltaCandidate
82104 var err error
83105 if err = validateCellSource(frame); err != nil {
84106 return PreparedDraw{}, err
85107 }
108+ // Any earlier prepared draw loses its claim on the scratch snapshot, even
109+ // if this Prepare fails after partially rewriting it.
110+ r.generation++
86111 columns, rows := frame.Columns(), frame.Rows()
87112 if !reset && r.hasCommitted && r.width == columns && r.height == rows && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
88113 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- }
114+ candidate = newDeltaCandidate(frame, plan, &r.scratch)
93115 } else {
94- candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted)
116+ candidate, err = planDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted, &r.scratch)
95117 }
96118 if err != nil {
97119 return PreparedDraw{}, err
98120 }
99- prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)}
121+ prepared := PreparedDraw{renderer: r, candidate: candidate, generation: r.generation, commitOnce: new(sync.Once)}
100122 plan := candidate.Plan
101123 if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
102124 return prepared, nil
@@ -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
@@ -62,41 +69,56 @@ func (p *PreparedDraw) Commit() {
62 return69 return
63 }70 }
64 p.commitOnce.Do(func() {71 p.commitOnce.Do(func() {
65- committed := p.renderer.committedFrame()72+ r := p.renderer
66- if p.candidate.Plan.Snapshot {73+ plan := p.candidate.Plan
67- // PreparedDraw privately owns this snapshot. Its shared Once makes74+ if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
68- // transfer safe even when the caller retains copies of the draw.75+ return
69- committed = p.candidate.frame76+ }
77+ if p.generation != r.generation {
78+ // A later Prepare reused the scratch frame this draw's snapshot
79+ // lives in, so it was discarded.
80+ return
81+ }
82+ if plan.Snapshot || !r.hasCommitted {
83+ // The snapshot lives in the renderer's scratch buffer; promote it
84+ // and recycle the previous committed buffer as the next scratch.
85+ r.committed, r.scratch = p.candidate.frame, r.committed
70 } else {86 } else {
71- p.candidate.Commit(&committed)87+ p.candidate.Commit(&r.committed)
72 }88 }
73- p.renderer.setCommittedFrame(committed)89+ r.width = r.committed.Width
90+ r.height = r.committed.Height
91+ r.hasCommitted = true
74 })92 })
75 }93 }
76 94
77 // Prepare plans and encodes a transactional draw. The renderer advances only95 // Prepare plans and encodes a transactional draw. The renderer advances only
78 // when the returned draw is committed. Keep at most one prepared draw96 // when the returned draw is committed. Keep at most one prepared draw
79-// outstanding; commit or discard it before calling Prepare again.97+// outstanding; commit or discard it before calling Prepare again. A draw
98+// superseded by a later Prepare is discarded and its Commit does nothing.
99+//
100+// The renderer copies frame into reusable private storage, so the caller may
101+// keep mutating frame between draws.
80 func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) {102 func (r *Renderer) Prepare(frame CellSource, damage []Damage, reset bool) (PreparedDraw, error) {
81 var candidate DeltaCandidate103 var candidate DeltaCandidate
82 var err error104 var err error
83 if err = validateCellSource(frame); err != nil {105 if err = validateCellSource(frame); err != nil {
84 return PreparedDraw{}, err106 return PreparedDraw{}, err
85 }107 }
108+ // Any earlier prepared draw loses its claim on the scratch snapshot, even
109+ // if this Prepare fails after partially rewriting it.
110+ r.generation++
86 columns, rows := frame.Columns(), frame.Rows()111 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) {112 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])113 plan := planSingleDamage(frame, damage[0])
89- candidate = DeltaCandidate{Plan: plan}114+ 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 {115 } else {
94- candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted)116+ candidate, err = planDelta(frame, damage, r.committedFrame(), reset || !r.hasCommitted, &r.scratch)
95 }117 }
96 if err != nil {118 if err != nil {
97 return PreparedDraw{}, err119 return PreparedDraw{}, err
98 }120 }
99- prepared := PreparedDraw{renderer: r, candidate: candidate, commitOnce: new(sync.Once)}121+ prepared := PreparedDraw{renderer: r, candidate: candidate, generation: r.generation, commitOnce: new(sync.Once)}
100 plan := candidate.Plan122 plan := candidate.Plan
101 if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {123 if !plan.Snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 {
102 return prepared, nil124 return prepared, nil

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