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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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. | |||
| 100 | 100 | The HTML renderer compares every row with its committed shadow; damage values are | |
| 101 | 101 | non-authoritative hints. `Prepare` permits one outstanding draw and requires an | |
| 102 | 102 | 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 | |
| 104 | 106 | replacement preserves wide-cell atomicity, and scroll damage uses a safe | |
| 105 | 107 | snapshot fallback. Rows are encoded as text runs: adjacent printable ASCII | |
| 106 | 108 | 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 are | 100 | The HTML renderer compares every row with its committed shadow; damage values are | ||
| 101 | non-authoritative hints. `Prepare` permits one outstanding draw and requires an | 101 | 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-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 | |||
| 104 | replacement preserves wide-cell atomicity, and scroll damage uses a safe | 106 | replacement preserves wide-cell atomicity, and scroll damage uses a safe | ||
| 105 | snapshot fallback. Rows are encoded as text runs: adjacent printable ASCII | 107 | 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 characters | 108 | 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 { | |||
| 35 | 35 | 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 | 69 | func propertyRect(r *rand.Rand, w, h int, kind DamageKind) Damage { | |
| 39 | 70 | d := Damage{Kind: kind, X: r.IntN(w), Y: r.IntN(h)} | |
| 40 | 71 | d.Width = 1 + r.IntN(w-d.X) | |
| @@ -99,7 +130,7 @@ func TestShadowMatchesCloneReference(t *testing.T) { | |||
| 99 | 130 | } | |
| 100 | 131 | } | |
| 101 | 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 | 135 | damage := propertyDamage(r, w, h) | |
| 105 | 136 | reset := r.IntN(15) == 0 | |
| @@ -132,7 +163,7 @@ func TestShadowMatchesCloneReference(t *testing.T) { | |||
| 132 | 163 | // Caller keeps mutating after Prepare, before Commit. | |
| 133 | 164 | snapshotMutation := r.IntN(3) == 0 | |
| 134 | 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 | 169 | switch k := r.IntN(10); { | |
| @@ -35,6 +35,37 @@ func propertyCell(r *rand.Rand) Cell { | |||||
| 35 | return c | 35 | 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) == 0 | 136 | 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) == 0 | 164 | 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 ( | |||
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | 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 | |
| 16 | 19 | cursor Cursor | |
| 17 | 20 | initialized bool | |
| 18 | 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 | 67 | if r.pending != nil { | |
| 65 | 68 | return nil, ErrPendingDraw | |
| 66 | 69 | } | |
| 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) | |
| 82 | 71 | if err != nil { | |
| 83 | 72 | return nil, err | |
| 84 | 73 | } | |
| 85 | - | encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes) | |
| 74 | + | update, encoded, err := r.prepareUpdate(frame, damage, reset, &cursor) | |
| 86 | 75 | if err != nil { | |
| 76 | + | r.spare = frame | |
| 87 | 77 | return nil, err | |
| 88 | 78 | } | |
| 89 | 79 | ||
| @@ -100,6 +90,27 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||
| 100 | 90 | 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 | 114 | // validateFrame checks limits and cell invariants. It returns a one-row | |
| 104 | 115 | // scratch buffer that the caller may reuse for the rest of this Prepare. | |
| 105 | 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 | 173 | return scratch, nil | |
| 163 | 174 | } | |
| 164 | 175 | ||
| 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) { | |
| 168 | 180 | if source == nil { | |
| 169 | 181 | return core.Frame{}, fmt.Errorf("html: nil cell source") | |
| 170 | 182 | } | |
| @@ -173,7 +185,7 @@ func materializeCellSource(source CellSource) (core.Frame, error) { | |||
| 173 | 185 | if err := frame.Validate(); err != nil { | |
| 174 | 186 | return core.Frame{}, fmt.Errorf("html: validate frame: %w", err) | |
| 175 | 187 | } | |
| 176 | - | return frame.Clone(), nil | |
| 188 | + | return r.copyFrame(frame), nil | |
| 177 | 189 | case *core.Frame: | |
| 178 | 190 | if frame == nil { | |
| 179 | 191 | return core.Frame{}, fmt.Errorf("html: nil cell source") | |
| @@ -181,7 +193,7 @@ func materializeCellSource(source CellSource) (core.Frame, error) { | |||
| 181 | 193 | if err := frame.Validate(); err != nil { | |
| 182 | 194 | return core.Frame{}, fmt.Errorf("html: validate frame: %w", err) | |
| 183 | 195 | } | |
| 184 | - | return frame.Clone(), nil | |
| 196 | + | return r.copyFrame(*frame), nil | |
| 185 | 197 | } | |
| 186 | 198 | width, height := source.Columns(), source.Rows() | |
| 187 | 199 | if width <= 0 || height <= 0 { | |
| @@ -196,6 +208,18 @@ func materializeCellSource(source CellSource) (core.Frame, error) { | |||
| 196 | 208 | 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 | 223 | // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column | |
| 200 | 224 | // produced after writing the last column with autowrap enabled onto the | |
| 201 | 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 | 269 | 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 | 272 | func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot bool, cursor Cursor) (Update, error) { | |
| 263 | 273 | update := Update{ | |
| 264 | 274 | SchemaVersion: UpdateSchemaVersion, | |
| @@ -287,15 +297,15 @@ func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot b | |||
| 287 | 297 | // Keep scratch for frames up to the common 240x80 size only, so one | |
| 288 | 298 | // huge snapshot does not pin its buffers for the renderer lifetime. | |
| 289 | 299 | 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 | 301 | r.ends, r.text, r.rowStarts = ends, text, rowStarts | |
| 292 | 302 | } | |
| 293 | 303 | }() | |
| 294 | 304 | 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) { | |
| 297 | 306 | continue | |
| 298 | 307 | } | |
| 308 | + | row := readRow(frame, y, scratch) | |
| 299 | 309 | if len(update.Rows) >= r.limits.MaxRowsPerUpdate { | |
| 300 | 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 | 494 | if tx.generation != r.generation || r.pending != tx { | |
| 485 | 495 | return ErrStaleDraw | |
| 486 | 496 | } | |
| 497 | + | r.spare = r.committed | |
| 487 | 498 | r.committed = tx.frame | |
| 488 | 499 | r.cursor = tx.cursor | |
| 489 | 500 | r.initialized = true | |
| @@ -505,6 +516,7 @@ func (p *PreparedDraw) Abort() error { | |||
| 505 | 516 | if tx.generation != r.generation || r.pending != tx { | |
| 506 | 517 | return ErrStaleDraw | |
| 507 | 518 | } | |
| 519 | + | r.spare = tx.frame | |
| 508 | 520 | r.pending = nil | |
| 509 | 521 | tx.state = transactionAborted | |
| 510 | 522 | return nil | |
| @@ -518,6 +530,7 @@ func (r *Renderer) Reset() { | |||
| 518 | 530 | r.generation++ | |
| 519 | 531 | r.pending = nil | |
| 520 | 532 | r.committed = core.Frame{} | |
| 533 | + | r.spare = core.Frame{} | |
| 521 | 534 | r.cursor = Cursor{} | |
| 522 | 535 | r.initialized = false | |
| 523 | 536 | } | |
| @@ -9,10 +9,13 @@ import ( | |||||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | type Renderer struct { | 11 | type Renderer struct { | ||
| 12 | - | limits Limits | 12 | + | limits Limits |
| 13 | - | generation uint64 | 13 | + | generation uint64 |
| 14 | - | pending *transaction | 14 | + | pending *transaction |
| 15 | - | committed core.Frame | 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 | |||
| 16 | cursor Cursor | 19 | cursor Cursor | ||
| 17 | initialized bool | 20 | 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, ErrPendingDraw | 68 | 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, err | 72 | 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, err | 77 | 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}, nil | 90 | 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-row | 114 | // 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, nil | 173 | return scratch, nil | ||
| 163 | } | 174 | } | ||
| 164 | 175 | ||||
| 165 | - | // materializeCellSource reads a source into an owned frame. A core.Frame | 176 | + | // 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(), nil | 188 | + | 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(), nil | 196 | + | 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, nil | 208 | 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 column | 223 | // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column | ||
| 200 | // produced after writing the last column with autowrap enabled onto the | 224 | // 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 row | 269 | 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 one | 297 | // 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 * 80 | 299 | 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, rowStarts | 301 | 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 | continue | 306 | 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 ErrStaleDraw | 495 | return ErrStaleDraw | ||
| 486 | } | 496 | } | ||
| 497 | + | r.spare = r.committed | |||
| 487 | r.committed = tx.frame | 498 | r.committed = tx.frame | ||
| 488 | r.cursor = tx.cursor | 499 | r.cursor = tx.cursor | ||
| 489 | r.initialized = true | 500 | 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 ErrStaleDraw | 517 | return ErrStaleDraw | ||
| 507 | } | 518 | } | ||
| 519 | + | r.spare = tx.frame | |||
| 508 | r.pending = nil | 520 | r.pending = nil | ||
| 509 | tx.state = transactionAborted | 521 | tx.state = transactionAborted | ||
| 510 | return nil | 522 | return nil | ||
| @@ -518,6 +530,7 @@ func (r *Renderer) Reset() { | |||||
| 518 | r.generation++ | 530 | r.generation++ | ||
| 519 | r.pending = nil | 531 | 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 = false | 535 | r.initialized = false | ||
| 523 | } | 536 | } | ||
M html/renderer_test.go +46 -11
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "math/rand" | |
| 7 | 7 | "os" | |
| 8 | 8 | "testing" | |
| 9 | + | "unicode/utf8" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/vev-vt/core" | |
| 11 | 12 | "github.com/stretchr/testify/require" | |
| @@ -215,20 +216,54 @@ func TestRendererRunsCoverEveryRowExactly(t *testing.T) { | |||
| 215 | 216 | require.NoError(t, err) | |
| 216 | 217 | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 217 | 218 | 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) | |
| 228 | 251 | 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 | ||
| @@ -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 := 0 | 220 | + | |
| 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.Width | 251 | 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 | ||||