detached 9174bfd

perf(html): skip unchanged rows before reading them and reuse frame pages

Incremental Prepare compares each row with the committed shadow through
core.RowsEqualAt and reads only changed rows. Prepare copies core.Frame
sources into a spare page that Commit and Abort hand back, instead of cloning
every draw. One-row updates at 80x24 drop from 111us/47.8KB to 51us/14KB, and
from 1.10ms/351KB to 0.49ms/39KB at 240x80.

The run property test now expands every entry back into cells and checks text,
style and width against the frame, also on incremental updates. The ANSI shadow
property test writes wide pairs. README states the schema version shared by
updates and events, and the retained row-start scratch is bounded too.
commit
9174bfd37013655e2197f2be59e5c2bfd1eadd0a
author
brice · · 2026-10-02 16:12 UTC
parent
cc11117
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +137 -56
M README.md +3 -1
@@ -100,7 +100,9 @@ optional compression during idle time.
100100 The HTML renderer compares every row with its committed shadow; damage values are
101101 non-authoritative hints. `Prepare` permits one outstanding draw and requires an
102102 explicit `Commit` or `Abort`. `Reset` invalidates retained prepared draws.
103-Updates are immutable, schema-versioned JSON-compatible values. Complete-row
103+Updates are immutable JSON-compatible values at schema version 2; browser
104+events share that version, so a host must serve the matching `terminal.js`.
105+Complete-row
104106 replacement preserves wide-cell atomicity, and scroll damage uses a safe
105107 snapshot fallback. Rows are encoded as text runs: adjacent printable ASCII
106108 cells with one style form a single entry, while wide and non-ASCII characters
@@ -100,7 +100,9 @@ optional compression during idle time.
100 The HTML renderer compares every row with its committed shadow; damage values are100 The HTML renderer compares every row with its committed shadow; damage values are
101 non-authoritative hints. `Prepare` permits one outstanding draw and requires an101 non-authoritative hints. `Prepare` permits one outstanding draw and requires an
102 explicit `Commit` or `Abort`. `Reset` invalidates retained prepared draws.102 explicit `Commit` or `Abort`. `Reset` invalidates retained prepared draws.
103-Updates are immutable, schema-versioned JSON-compatible values. Complete-row103+Updates are immutable JSON-compatible values at schema version 2; browser
104+events share that version, so a host must serve the matching `terminal.js`.
105+Complete-row
104 replacement preserves wide-cell atomicity, and scroll damage uses a safe106 replacement preserves wide-cell atomicity, and scroll damage uses a safe
105 snapshot fallback. Rows are encoded as text runs: adjacent printable ASCII107 snapshot fallback. Rows are encoded as text runs: adjacent printable ASCII
106 cells with one style form a single entry, while wide and non-ASCII characters108 cells with one style form a single entry, while wide and non-ASCII characters
M ansi/shadow_property_test.go +33 -2
@@ -35,6 +35,37 @@ func propertyCell(r *rand.Rand) Cell {
3535 return c
3636 }
3737
38+// propertyWrite writes one random cell at (x, y). Some writes are a wide
39+// pair, so span widening runs against the reused shadow buffers. A write that
40+// lands on half of an existing pair blanks the other half to keep the frame
41+// valid.
42+func propertyWrite(r *rand.Rand, frame *Frame, w, h int) {
43+ x, y := r.IntN(w), r.IntN(h)
44+ clearPair := func(x int) {
45+ if x < 0 || x >= w {
46+ return
47+ }
48+ cell := frame.Cell(x, y)
49+ if cell.Continuation && x > 0 {
50+ frame.Set(x-1, y, Cell{Rune: ' '})
51+ frame.Set(x, y, Cell{Rune: ' '})
52+ } else if core.RuneWidth(cell.Rune) == 2 && x+1 < w {
53+ frame.Set(x, y, Cell{Rune: ' '})
54+ frame.Set(x+1, y, Cell{Rune: ' '})
55+ }
56+ }
57+ if r.IntN(4) == 0 && x+1 < w {
58+ clearPair(x)
59+ clearPair(x + 1)
60+ style := propertyStyles[r.IntN(len(propertyStyles))]
61+ frame.Set(x, y, Cell{Rune: '界', Style: style})
62+ frame.Set(x+1, y, Cell{Continuation: true, Style: style})
63+ return
64+ }
65+ clearPair(x)
66+ frame.Set(x, y, propertyCell(r))
67+}
68+
3869 func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage {
3970 d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)}
4071 d.Width = 1 + r.IntN(w-d.X)
@@ -99,7 +130,7 @@ func TestShadowMatchesCloneReference(t *testing.T) {
99130 }
100131 }
101132 for range r.IntN(12) {
102- frame.Set(r.IntN(w), r.IntN(h), propertyCell(r))
133+ propertyWrite(r, &frame, w, h)
103134 }
104135 damage := propertyDamage(r, w, h)
105136 reset := r.IntN(15) == 0
@@ -132,7 +163,7 @@ func TestShadowMatchesCloneReference(t *testing.T) {
132163 // Caller keeps mutating after Prepare, before Commit.
133164 snapshotMutation := r.IntN(3) == 0
134165 if snapshotMutation {
135- frame.Set(r.IntN(w), r.IntN(h), propertyCell(r))
166+ propertyWrite(r, &frame, w, h)
136167 }
137168
138169 switch k := r.IntN(10); {
@@ -35,6 +35,37 @@ func propertyCell(r *rand.Rand) Cell {
35 return c35 return c
36 }36 }
37 37
38+// propertyWrite writes one random cell at (x, y). Some writes are a wide
39+// pair, so span widening runs against the reused shadow buffers. A write that
40+// lands on half of an existing pair blanks the other half to keep the frame
41+// valid.
42+func propertyWrite(r *rand.Rand, frame *Frame, w, h int) {
43+ x, y := r.IntN(w), r.IntN(h)
44+ clearPair := func(x int) {
45+ if x < 0 || x >= w {
46+ return
47+ }
48+ cell := frame.Cell(x, y)
49+ if cell.Continuation && x > 0 {
50+ frame.Set(x-1, y, Cell{Rune: ' '})
51+ frame.Set(x, y, Cell{Rune: ' '})
52+ } else if core.RuneWidth(cell.Rune) == 2 && x+1 < w {
53+ frame.Set(x, y, Cell{Rune: ' '})
54+ frame.Set(x+1, y, Cell{Rune: ' '})
55+ }
56+ }
57+ if r.IntN(4) == 0 && x+1 < w {
58+ clearPair(x)
59+ clearPair(x + 1)
60+ style := propertyStyles[r.IntN(len(propertyStyles))]
61+ frame.Set(x, y, Cell{Rune: '界', Style: style})
62+ frame.Set(x+1, y, Cell{Continuation: true, Style: style})
63+ return
64+ }
65+ clearPair(x)
66+ frame.Set(x, y, propertyCell(r))
67+}
68+
38 func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage {69 func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage {
39 d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)}70 d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)}
40 d.Width = 1 + r.IntN(w-d.X)71 d.Width = 1 + r.IntN(w-d.X)
@@ -99,7 +130,7 @@ func TestShadowMatchesCloneReference(t *testing.T) {
99 }130 }
100 }131 }
101 for range r.IntN(12) {132 for range r.IntN(12) {
102- frame.Set(r.IntN(w), r.IntN(h), propertyCell(r))133+ propertyWrite(r, &frame, w, h)
103 }134 }
104 damage := propertyDamage(r, w, h)135 damage := propertyDamage(r, w, h)
105 reset := r.IntN(15) == 0136 reset := r.IntN(15) == 0
@@ -132,7 +163,7 @@ func TestShadowMatchesCloneReference(t *testing.T) {
132 // Caller keeps mutating after Prepare, before Commit.163 // Caller keeps mutating after Prepare, before Commit.
133 snapshotMutation := r.IntN(3) == 0164 snapshotMutation := r.IntN(3) == 0
134 if snapshotMutation {165 if snapshotMutation {
135- frame.Set(r.IntN(w), r.IntN(h), propertyCell(r))166+ propertyWrite(r, &frame, w, h)
136 }167 }
137 168
138 switch k := r.IntN(10); {169 switch k := r.IntN(10); {
M html/renderer.go +55 -42
@@ -9,10 +9,13 @@ import (
99 )
1010
1111 type Renderer struct {
12- limits Limits
13- generation uint64
14- pending *transaction
15- committed core.Frame
12+ limits Limits
13+ generation uint64
14+ pending *transaction
15+ committed core.Frame
16+ // spare is a frame page no update or transaction references. Prepare
17+ // copies a core.Frame source into it; Commit and Abort hand a page back.
18+ spare core.Frame
1619 cursor Cursor
1720 initialized bool
1821 // Encoder scratch reused across updates; never referenced by an Update.
@@ -64,26 +67,13 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool,
6467 if r.pending != nil {
6568 return nil, ErrPendingDraw
6669 }
67- frame, err := materializeCellSource(source)
68- if err != nil {
69- return nil, err
70- }
71- scratch, err := r.validateFrame(frame)
72- if err != nil {
73- return nil, err
74- }
75- cursor = normalizeWrapPendingCursor(cursor, frame.Width)
76- if err := validateCursor(cursor, frame.Width, frame.Height); err != nil {
77- return nil, err
78- }
79-
80- snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height)
81- update, err := r.buildUpdate(frame, scratch, snapshot, cursor)
70+ frame, err := r.materializeCellSource(source)
8271 if err != nil {
8372 return nil, err
8473 }
85- encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes)
74+ update, encoded, err := r.prepareUpdate(frame, damage, reset, &cursor)
8675 if err != nil {
76+ r.spare = frame
8777 return nil, err
8878 }
8979
@@ -100,6 +90,27 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool,
10090 return &PreparedDraw{tx: tx}, nil
10191 }
10292
93+func (r *Renderer) prepareUpdate(frame core.Frame, damage []core.Damage, reset bool, cursor *Cursor) (Update, []byte, error) {
94+ scratch, err := r.validateFrame(frame)
95+ if err != nil {
96+ return Update{}, nil, err
97+ }
98+ *cursor = normalizeWrapPendingCursor(*cursor, frame.Width)
99+ if err := validateCursor(*cursor, frame.Width, frame.Height); err != nil {
100+ return Update{}, nil, err
101+ }
102+ snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height)
103+ update, err := r.buildUpdate(frame, scratch, snapshot, *cursor)
104+ if err != nil {
105+ return Update{}, nil, err
106+ }
107+ encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes)
108+ if err != nil {
109+ return Update{}, nil, err
110+ }
111+ return update, encoded, nil
112+}
113+
103114 // validateFrame checks limits and cell invariants. It returns a one-row
104115 // scratch buffer that the caller may reuse for the rest of this Prepare.
105116 func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) {
@@ -162,9 +173,10 @@ func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) {
162173 return scratch, nil
163174 }
164175
165-// materializeCellSource reads a source into an owned frame. A core.Frame
166-// takes the fast clone path; any other source is copied cell by cell.
167-func materializeCellSource(source CellSource) (core.Frame, error) {
176+// materializeCellSource reads a source into an owned frame. A core.Frame is
177+// copied into the renderer's spare page; any other source is copied cell by
178+// cell.
179+func (r *Renderer) materializeCellSource(source CellSource) (core.Frame, error) {
168180 if source == nil {
169181 return core.Frame{}, fmt.Errorf("html: nil cell source")
170182 }
@@ -173,7 +185,7 @@ func materializeCellSource(source CellSource) (core.Frame, error) {
173185 if err := frame.Validate(); err != nil {
174186 return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
175187 }
176- return frame.Clone(), nil
188+ return r.copyFrame(frame), nil
177189 case *core.Frame:
178190 if frame == nil {
179191 return core.Frame{}, fmt.Errorf("html: nil cell source")
@@ -181,7 +193,7 @@ func materializeCellSource(source CellSource) (core.Frame, error) {
181193 if err := frame.Validate(); err != nil {
182194 return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
183195 }
184- return frame.Clone(), nil
196+ return r.copyFrame(*frame), nil
185197 }
186198 width, height := source.Columns(), source.Rows()
187199 if width <= 0 || height <= 0 {
@@ -196,6 +208,18 @@ func materializeCellSource(source CellSource) (core.Frame, error) {
196208 return clone, nil
197209 }
198210
211+// copyFrame copies a validated frame into the spare page, which the returned
212+// frame then owns.
213+func (r *Renderer) copyFrame(src core.Frame) core.Frame {
214+ frame := r.spare
215+ r.spare = core.Frame{}
216+ frame.CopyFrom(src)
217+ if frame.Width != src.Width || frame.Height != src.Height {
218+ return src.Clone()
219+ }
220+ return frame
221+}
222+
199223 // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column
200224 // produced after writing the last column with autowrap enabled onto the
201225 // last visible column. It leaves every in-bounds cursor untouched.
@@ -245,20 +269,6 @@ func readRow(frame core.Frame, y int, buf []core.Cell) []core.Cell {
245269 return row
246270 }
247271
248-// rowEqualsFrame reports whether row matches logical row y of frame, stopping
249-// at the first difference without materializing the frame row.
250-func rowEqualsFrame(row []core.Cell, frame core.Frame, y int) bool {
251- if len(row) != frame.Width {
252- return false
253- }
254- for x := range row {
255- if !row[x].Equal(frame.Cell(x, y)) {
256- return false
257- }
258- }
259- return true
260-}
261-
262272 func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot bool, cursor Cursor) (Update, error) {
263273 update := Update{
264274 SchemaVersion: UpdateSchemaVersion,
@@ -287,15 +297,15 @@ func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot b
287297 // Keep scratch for frames up to the common 240x80 size only, so one
288298 // huge snapshot does not pin its buffers for the renderer lifetime.
289299 const maxRetainedCells = 240 * 80
290- if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells {
300+ if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= 80 {
291301 r.ends, r.text, r.rowStarts = ends, text, rowStarts
292302 }
293303 }()
294304 for y := range frame.Height {
295- row := readRow(frame, y, scratch)
296- if !snapshot && rowEqualsFrame(row, r.committed, y) {
305+ if !snapshot && core.RowsEqualAt(frame, y, r.committed, y) {
297306 continue
298307 }
308+ row := readRow(frame, y, scratch)
299309 if len(update.Rows) >= r.limits.MaxRowsPerUpdate {
300310 return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate)
301311 }
@@ -484,6 +494,7 @@ func (p *PreparedDraw) Commit() error {
484494 if tx.generation != r.generation || r.pending != tx {
485495 return ErrStaleDraw
486496 }
497+ r.spare = r.committed
487498 r.committed = tx.frame
488499 r.cursor = tx.cursor
489500 r.initialized = true
@@ -505,6 +516,7 @@ func (p *PreparedDraw) Abort() error {
505516 if tx.generation != r.generation || r.pending != tx {
506517 return ErrStaleDraw
507518 }
519+ r.spare = tx.frame
508520 r.pending = nil
509521 tx.state = transactionAborted
510522 return nil
@@ -518,6 +530,7 @@ func (r *Renderer) Reset() {
518530 r.generation++
519531 r.pending = nil
520532 r.committed = core.Frame{}
533+ r.spare = core.Frame{}
521534 r.cursor = Cursor{}
522535 r.initialized = false
523536 }
@@ -9,10 +9,13 @@ import (
9 )9 )
10 10
11 type Renderer struct {11 type Renderer struct {
12- limits Limits12+ limits Limits
13- generation uint6413+ generation uint64
14- pending *transaction14+ pending *transaction
15- committed core.Frame15+ committed core.Frame
16+ // spare is a frame page no update or transaction references. Prepare
17+ // copies a core.Frame source into it; Commit and Abort hand a page back.
18+ spare core.Frame
16 cursor Cursor19 cursor Cursor
17 initialized bool20 initialized bool
18 // Encoder scratch reused across updates; never referenced by an Update.21 // Encoder scratch reused across updates; never referenced by an Update.
@@ -64,26 +67,13 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool,
64 if r.pending != nil {67 if r.pending != nil {
65 return nil, ErrPendingDraw68 return nil, ErrPendingDraw
66 }69 }
67- frame, err := materializeCellSource(source)70+ frame, err := r.materializeCellSource(source)
68- if err != nil {
69- return nil, err
70- }
71- scratch, err := r.validateFrame(frame)
72- if err != nil {
73- return nil, err
74- }
75- cursor = normalizeWrapPendingCursor(cursor, frame.Width)
76- if err := validateCursor(cursor, frame.Width, frame.Height); err != nil {
77- return nil, err
78- }
79-
80- snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height)
81- update, err := r.buildUpdate(frame, scratch, snapshot, cursor)
82 if err != nil {71 if err != nil {
83 return nil, err72 return nil, err
84 }73 }
85- encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes)74+ update, encoded, err := r.prepareUpdate(frame, damage, reset, &cursor)
86 if err != nil {75 if err != nil {
76+ r.spare = frame
87 return nil, err77 return nil, err
88 }78 }
89 79
@@ -100,6 +90,27 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool,
100 return &PreparedDraw{tx: tx}, nil90 return &PreparedDraw{tx: tx}, nil
101 }91 }
102 92
93+func (r *Renderer) prepareUpdate(frame core.Frame, damage []core.Damage, reset bool, cursor *Cursor) (Update, []byte, error) {
94+ scratch, err := r.validateFrame(frame)
95+ if err != nil {
96+ return Update{}, nil, err
97+ }
98+ *cursor = normalizeWrapPendingCursor(*cursor, frame.Width)
99+ if err := validateCursor(*cursor, frame.Width, frame.Height); err != nil {
100+ return Update{}, nil, err
101+ }
102+ snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height)
103+ update, err := r.buildUpdate(frame, scratch, snapshot, *cursor)
104+ if err != nil {
105+ return Update{}, nil, err
106+ }
107+ encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes)
108+ if err != nil {
109+ return Update{}, nil, err
110+ }
111+ return update, encoded, nil
112+}
113+
103 // validateFrame checks limits and cell invariants. It returns a one-row114 // validateFrame checks limits and cell invariants. It returns a one-row
104 // scratch buffer that the caller may reuse for the rest of this Prepare.115 // scratch buffer that the caller may reuse for the rest of this Prepare.
105 func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) {116 func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) {
@@ -162,9 +173,10 @@ func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) {
162 return scratch, nil173 return scratch, nil
163 }174 }
164 175
165-// materializeCellSource reads a source into an owned frame. A core.Frame176+// materializeCellSource reads a source into an owned frame. A core.Frame is
166-// takes the fast clone path; any other source is copied cell by cell.177+// copied into the renderer's spare page; any other source is copied cell by
167-func materializeCellSource(source CellSource) (core.Frame, error) {178+// cell.
179+func (r *Renderer) materializeCellSource(source CellSource) (core.Frame, error) {
168 if source == nil {180 if source == nil {
169 return core.Frame{}, fmt.Errorf("html: nil cell source")181 return core.Frame{}, fmt.Errorf("html: nil cell source")
170 }182 }
@@ -173,7 +185,7 @@ func materializeCellSource(source CellSource) (core.Frame, error) {
173 if err := frame.Validate(); err != nil {185 if err := frame.Validate(); err != nil {
174 return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)186 return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
175 }187 }
176- return frame.Clone(), nil188+ return r.copyFrame(frame), nil
177 case *core.Frame:189 case *core.Frame:
178 if frame == nil {190 if frame == nil {
179 return core.Frame{}, fmt.Errorf("html: nil cell source")191 return core.Frame{}, fmt.Errorf("html: nil cell source")
@@ -181,7 +193,7 @@ func materializeCellSource(source CellSource) (core.Frame, error) {
181 if err := frame.Validate(); err != nil {193 if err := frame.Validate(); err != nil {
182 return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)194 return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
183 }195 }
184- return frame.Clone(), nil196+ return r.copyFrame(*frame), nil
185 }197 }
186 width, height := source.Columns(), source.Rows()198 width, height := source.Columns(), source.Rows()
187 if width <= 0 || height <= 0 {199 if width <= 0 || height <= 0 {
@@ -196,6 +208,18 @@ func materializeCellSource(source CellSource) (core.Frame, error) {
196 return clone, nil208 return clone, nil
197 }209 }
198 210
211+// copyFrame copies a validated frame into the spare page, which the returned
212+// frame then owns.
213+func (r *Renderer) copyFrame(src core.Frame) core.Frame {
214+ frame := r.spare
215+ r.spare = core.Frame{}
216+ frame.CopyFrom(src)
217+ if frame.Width != src.Width || frame.Height != src.Height {
218+ return src.Clone()
219+ }
220+ return frame
221+}
222+
199 // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column223 // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column
200 // produced after writing the last column with autowrap enabled onto the224 // produced after writing the last column with autowrap enabled onto the
201 // last visible column. It leaves every in-bounds cursor untouched.225 // last visible column. It leaves every in-bounds cursor untouched.
@@ -245,20 +269,6 @@ func readRow(frame core.Frame, y int, buf []core.Cell) []core.Cell {
245 return row269 return row
246 }270 }
247 271
248-// rowEqualsFrame reports whether row matches logical row y of frame, stopping
249-// at the first difference without materializing the frame row.
250-func rowEqualsFrame(row []core.Cell, frame core.Frame, y int) bool {
251- if len(row) != frame.Width {
252- return false
253- }
254- for x := range row {
255- if !row[x].Equal(frame.Cell(x, y)) {
256- return false
257- }
258- }
259- return true
260-}
261-
262 func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot bool, cursor Cursor) (Update, error) {272 func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot bool, cursor Cursor) (Update, error) {
263 update := Update{273 update := Update{
264 SchemaVersion: UpdateSchemaVersion,274 SchemaVersion: UpdateSchemaVersion,
@@ -287,15 +297,15 @@ func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot b
287 // Keep scratch for frames up to the common 240x80 size only, so one297 // Keep scratch for frames up to the common 240x80 size only, so one
288 // huge snapshot does not pin its buffers for the renderer lifetime.298 // huge snapshot does not pin its buffers for the renderer lifetime.
289 const maxRetainedCells = 240 * 80299 const maxRetainedCells = 240 * 80
290- if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells {300+ if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= 80 {
291 r.ends, r.text, r.rowStarts = ends, text, rowStarts301 r.ends, r.text, r.rowStarts = ends, text, rowStarts
292 }302 }
293 }()303 }()
294 for y := range frame.Height {304 for y := range frame.Height {
295- row := readRow(frame, y, scratch)305+ if !snapshot && core.RowsEqualAt(frame, y, r.committed, y) {
296- if !snapshot && rowEqualsFrame(row, r.committed, y) {
297 continue306 continue
298 }307 }
308+ row := readRow(frame, y, scratch)
299 if len(update.Rows) >= r.limits.MaxRowsPerUpdate {309 if len(update.Rows) >= r.limits.MaxRowsPerUpdate {
300 return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate)310 return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate)
301 }311 }
@@ -484,6 +494,7 @@ func (p *PreparedDraw) Commit() error {
484 if tx.generation != r.generation || r.pending != tx {494 if tx.generation != r.generation || r.pending != tx {
485 return ErrStaleDraw495 return ErrStaleDraw
486 }496 }
497+ r.spare = r.committed
487 r.committed = tx.frame498 r.committed = tx.frame
488 r.cursor = tx.cursor499 r.cursor = tx.cursor
489 r.initialized = true500 r.initialized = true
@@ -505,6 +516,7 @@ func (p *PreparedDraw) Abort() error {
505 if tx.generation != r.generation || r.pending != tx {516 if tx.generation != r.generation || r.pending != tx {
506 return ErrStaleDraw517 return ErrStaleDraw
507 }518 }
519+ r.spare = tx.frame
508 r.pending = nil520 r.pending = nil
509 tx.state = transactionAborted521 tx.state = transactionAborted
510 return nil522 return nil
@@ -518,6 +530,7 @@ func (r *Renderer) Reset() {
518 r.generation++530 r.generation++
519 r.pending = nil531 r.pending = nil
520 r.committed = core.Frame{}532 r.committed = core.Frame{}
533+ r.spare = core.Frame{}
521 r.cursor = Cursor{}534 r.cursor = Cursor{}
522 r.initialized = false535 r.initialized = false
523 }536 }
M html/renderer_test.go +46 -11
@@ -6,6 +6,7 @@ import (
66 "math/rand"
77 "os"
88 "testing"
9+ "unicode/utf8"
910
1011 "github.com/bnema/vev-vt/core"
1112 "github.com/stretchr/testify/require"
@@ -215,20 +216,54 @@ func TestRendererRunsCoverEveryRowExactly(t *testing.T) {
215216 require.NoError(t, err)
216217 prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
217218 require.NoError(t, err, "iteration %d", iteration)
218- for _, row := range prepared.Update().Rows {
219- column := 0
220- for _, cell := range row.Cells {
221- require.Equal(t, column, cell.Column, "iteration %d row %d", iteration, row.Row)
222- if cell.Width > 2 || len(cell.Text) > 1 && cell.Text[0] < 0x80 {
223- require.Len(t, cell.Text, cell.Width)
224- for i := range len(cell.Text) {
225- require.True(t, cell.Text[i] >= 0x20 && cell.Text[i] < 0x7f, "iteration %d text %q", iteration, cell.Text)
226- }
227- }
219+ requireRunsMatchFrame(t, prepared.Update(), frame, iteration)
220+
221+ // An incremental update after one changed cell decodes the same way,
222+ // through the renderer's reused frame page.
223+ require.NoError(t, prepared.Commit())
224+ y := rng.Intn(height)
225+ frame.Set(0, y, core.Cell{Rune: 'Z', Style: styles[1]})
226+ if width > 1 && frame.Cell(1, y).Continuation {
227+ frame.Set(1, y, core.Cell{Rune: ' '})
228+ }
229+ prepared, err = renderer.Prepare(frame, nil, false, Cursor{})
230+ require.NoError(t, err, "iteration %d", iteration)
231+ update := prepared.Update()
232+ require.False(t, update.Snapshot)
233+ require.Len(t, update.Rows, 1)
234+ requireRunsMatchFrame(t, update, frame, iteration)
235+ }
236+}
237+
238+// requireRunsMatchFrame expands every entry back into cells and compares
239+// each column's text, style and width with the source frame.
240+func requireRunsMatchFrame(t *testing.T, update Update, frame core.Frame, iteration int) {
241+ t.Helper()
242+ for _, row := range update.Rows {
243+ column := 0
244+ for _, cell := range row.Cells {
245+ require.Equal(t, column, cell.Column, "iteration %d row %d", iteration, row.Row)
246+ source := frame.Cell(column, row.Row)
247+ require.Equal(t, styleFromCore(source.Style), update.Styles[cell.Style], "iteration %d (%d,%d)", iteration, column, row.Row)
248+ if r, _ := utf8.DecodeRuneInString(cell.Text); r >= 0x80 {
249+ require.Equal(t, string(source.Rune), cell.Text, "iteration %d (%d,%d)", iteration, column, row.Row)
250+ require.Equal(t, core.RuneWidth(source.Rune), cell.Width, "iteration %d (%d,%d)", iteration, column, row.Row)
228251 column += cell.Width
252+ continue
253+ }
254+ require.Len(t, cell.Text, cell.Width, "iteration %d text %q", iteration, cell.Text)
255+ for i := range len(cell.Text) {
256+ source := frame.Cell(column+i, row.Row)
257+ want := source.Rune
258+ if want == 0 {
259+ want = ' '
260+ }
261+ require.Equal(t, string(want), cell.Text[i:i+1], "iteration %d (%d,%d)", iteration, column+i, row.Row)
262+ require.Equal(t, styleFromCore(source.Style), update.Styles[cell.Style], "iteration %d (%d,%d)", iteration, column+i, row.Row)
229263 }
230- require.Equal(t, width, column, "iteration %d row %d", iteration, row.Row)
264+ column += cell.Width
231265 }
266+ require.Equal(t, frame.Width, column, "iteration %d row %d", iteration, row.Row)
232267 }
233268 }
234269
@@ -6,6 +6,7 @@ import (
6 "math/rand"6 "math/rand"
7 "os"7 "os"
8 "testing"8 "testing"
9+ "unicode/utf8"
9 10
10 "github.com/bnema/vev-vt/core"11 "github.com/bnema/vev-vt/core"
11 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
@@ -215,20 +216,54 @@ func TestRendererRunsCoverEveryRowExactly(t *testing.T) {
215 require.NoError(t, err)216 require.NoError(t, err)
216 prepared, err := renderer.Prepare(frame, nil, false, Cursor{})217 prepared, err := renderer.Prepare(frame, nil, false, Cursor{})
217 require.NoError(t, err, "iteration %d", iteration)218 require.NoError(t, err, "iteration %d", iteration)
218- for _, row := range prepared.Update().Rows {219+ requireRunsMatchFrame(t, prepared.Update(), frame, iteration)
219- column := 0220+
220- for _, cell := range row.Cells {221+ // An incremental update after one changed cell decodes the same way,
221- require.Equal(t, column, cell.Column, "iteration %d row %d", iteration, row.Row)222+ // through the renderer's reused frame page.
222- if cell.Width > 2 || len(cell.Text) > 1 && cell.Text[0] < 0x80 {223+ require.NoError(t, prepared.Commit())
223- require.Len(t, cell.Text, cell.Width)224+ y := rng.Intn(height)
224- for i := range len(cell.Text) {225+ frame.Set(0, y, core.Cell{Rune: 'Z', Style: styles[1]})
225- require.True(t, cell.Text[i] >= 0x20 && cell.Text[i] < 0x7f, "iteration %d text %q", iteration, cell.Text)226+ if width > 1 && frame.Cell(1, y).Continuation {
226- }227+ frame.Set(1, y, core.Cell{Rune: ' '})
227- }228+ }
229+ prepared, err = renderer.Prepare(frame, nil, false, Cursor{})
230+ require.NoError(t, err, "iteration %d", iteration)
231+ update := prepared.Update()
232+ require.False(t, update.Snapshot)
233+ require.Len(t, update.Rows, 1)
234+ requireRunsMatchFrame(t, update, frame, iteration)
235+ }
236+}
237+
238+// requireRunsMatchFrame expands every entry back into cells and compares
239+// each column's text, style and width with the source frame.
240+func requireRunsMatchFrame(t *testing.T, update Update, frame core.Frame, iteration int) {
241+ t.Helper()
242+ for _, row := range update.Rows {
243+ column := 0
244+ for _, cell := range row.Cells {
245+ require.Equal(t, column, cell.Column, "iteration %d row %d", iteration, row.Row)
246+ source := frame.Cell(column, row.Row)
247+ require.Equal(t, styleFromCore(source.Style), update.Styles[cell.Style], "iteration %d (%d,%d)", iteration, column, row.Row)
248+ if r, _ := utf8.DecodeRuneInString(cell.Text); r >= 0x80 {
249+ require.Equal(t, string(source.Rune), cell.Text, "iteration %d (%d,%d)", iteration, column, row.Row)
250+ require.Equal(t, core.RuneWidth(source.Rune), cell.Width, "iteration %d (%d,%d)", iteration, column, row.Row)
228 column += cell.Width251 column += cell.Width
252+ continue
253+ }
254+ require.Len(t, cell.Text, cell.Width, "iteration %d text %q", iteration, cell.Text)
255+ for i := range len(cell.Text) {
256+ source := frame.Cell(column+i, row.Row)
257+ want := source.Rune
258+ if want == 0 {
259+ want = ' '
260+ }
261+ require.Equal(t, string(want), cell.Text[i:i+1], "iteration %d (%d,%d)", iteration, column+i, row.Row)
262+ require.Equal(t, styleFromCore(source.Style), update.Styles[cell.Style], "iteration %d (%d,%d)", iteration, column+i, row.Row)
229 }263 }
230- require.Equal(t, width, column, "iteration %d row %d", iteration, row.Row)264+ column += cell.Width
231 }265 }
266+ require.Equal(t, frame.Width, column, "iteration %d row %d", iteration, row.Row)
232 }267 }
233 }268 }
234 269

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