fix(html): retain 240x80 encoder scratch and clear stale payload slots
The row-start scratch is sized for up to 80 rows up front, so frames taller than 64 rows no longer drop the encoder scratch on every update. CopyFrom clears the reused payload tail so graphemes and hyperlinks from a larger earlier frame are not kept alive. The size probe is excluded from selection, and copyFrame drops a branch that could not run. A property test drives one renderer through edits with payloads and equivalent styles, aborts, resets, stale commits and failed prepares, and checks each update against a cell-by-cell diff of the committed shadow.
- commit
- c6dd596575459bd57c9074a0ca96d6391b5eed5d
- author
- brice · · 2026-10-02 16:37 UTC
- parent
- f49c70a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +132 -7
M core/frame_copy.go +6 -0
| @@ -45,7 +45,13 @@ func (f *Frame) CopyFrom(src Frame) { | |||
| 45 | 45 | clear(dst.styleIndex) | |
| 46 | 46 | } | |
| 47 | 47 | maps.Copy(dst.styleIndex, from.styleIndex) | |
| 48 | + | // Payloads hold strings: clear the reused tail so stale graphemes and | |
| 49 | + | // hyperlinks are not kept alive by the backing array. | |
| 50 | + | oldPayloads := len(dst.payloads) | |
| 48 | 51 | dst.payloads = append(dst.payloads[:0], from.payloads...) | |
| 52 | + | if oldPayloads > len(dst.payloads) { | |
| 53 | + | clear(dst.payloads[len(dst.payloads):oldPayloads]) | |
| 54 | + | } | |
| 49 | 55 | dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...) | |
| 50 | 56 | dst.payloadBytes = from.payloadBytes | |
| 51 | 57 | if dst.payloadIndex == nil && len(from.payloadIndex) > 0 { | |
| @@ -45,7 +45,13 @@ func (f *Frame) CopyFrom(src Frame) { | |||||
| 45 | clear(dst.styleIndex) | 45 | clear(dst.styleIndex) | ||
| 46 | } | 46 | } | ||
| 47 | maps.Copy(dst.styleIndex, from.styleIndex) | 47 | maps.Copy(dst.styleIndex, from.styleIndex) | ||
| 48 | + | // Payloads hold strings: clear the reused tail so stale graphemes and | |||
| 49 | + | // hyperlinks are not kept alive by the backing array. | |||
| 50 | + | oldPayloads := len(dst.payloads) | |||
| 48 | dst.payloads = append(dst.payloads[:0], from.payloads...) | 51 | dst.payloads = append(dst.payloads[:0], from.payloads...) | ||
| 52 | + | if oldPayloads > len(dst.payloads) { | |||
| 53 | + | clear(dst.payloads[len(dst.payloads):oldPayloads]) | |||
| 54 | + | } | |||
| 49 | dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...) | 55 | dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...) | ||
| 50 | dst.payloadBytes = from.payloadBytes | 56 | dst.payloadBytes = from.payloadBytes | ||
| 51 | if dst.payloadIndex == nil && len(from.payloadIndex) > 0 { | 57 | if dst.payloadIndex == nil && len(from.payloadIndex) > 0 { | ||
M core/frame_copy_test.go +18 -0
| @@ -136,6 +136,24 @@ func TestCopyFromReleasesStorageWhenSourceShrinks(t *testing.T) { | |||
| 136 | 136 | } | |
| 137 | 137 | } | |
| 138 | 138 | ||
| 139 | + | func TestCopyFromClearsStalePayloadSlots(t *testing.T) { | |
| 140 | + | src := copyTestFrame(t, 8, 2, 'a') | |
| 141 | + | dst := NewFrame(8, 2) | |
| 142 | + | for x := range 8 { | |
| 143 | + | dst.Set(x, 0, Cell{Rune: 'x', Payload: copyTestPayload(t, "", "https://example.test/"+string(rune('a'+x)))}) | |
| 144 | + | } | |
| 145 | + | old := len(dst.page.payloads) | |
| 146 | + | dst.CopyFrom(src) | |
| 147 | + | if len(dst.page.payloads) >= old { | |
| 148 | + | t.Fatalf("test needs a source with fewer payload slots: %d >= %d", len(dst.page.payloads), old) | |
| 149 | + | } | |
| 150 | + | for i, slot := range dst.page.payloads[len(dst.page.payloads):old] { | |
| 151 | + | if slot != (payloadSlot{}) { | |
| 152 | + | t.Fatalf("stale payload slot %d still holds %+v", len(dst.page.payloads)+i, slot.value) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 139 | 157 | func TestRowsEqualAt(t *testing.T) { | |
| 140 | 158 | a := copyTestFrame(t, 10, 4, 'a') | |
| 141 | 159 | b := a.Clone() | |
| @@ -136,6 +136,24 @@ func TestCopyFromReleasesStorageWhenSourceShrinks(t *testing.T) { | |||||
| 136 | } | 136 | } | ||
| 137 | } | 137 | } | ||
| 138 | 138 | ||||
| 139 | + | func TestCopyFromClearsStalePayloadSlots(t *testing.T) { | |||
| 140 | + | src := copyTestFrame(t, 8, 2, 'a') | |||
| 141 | + | dst := NewFrame(8, 2) | |||
| 142 | + | for x := range 8 { | |||
| 143 | + | dst.Set(x, 0, Cell{Rune: 'x', Payload: copyTestPayload(t, "", "https://example.test/"+string(rune('a'+x)))}) | |||
| 144 | + | } | |||
| 145 | + | old := len(dst.page.payloads) | |||
| 146 | + | dst.CopyFrom(src) | |||
| 147 | + | if len(dst.page.payloads) >= old { | |||
| 148 | + | t.Fatalf("test needs a source with fewer payload slots: %d >= %d", len(dst.page.payloads), old) | |||
| 149 | + | } | |||
| 150 | + | for i, slot := range dst.page.payloads[len(dst.page.payloads):old] { | |||
| 151 | + | if slot != (payloadSlot{}) { | |||
| 152 | + | t.Fatalf("stale payload slot %d still holds %+v", len(dst.page.payloads)+i, slot.value) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 139 | func TestRowsEqualAt(t *testing.T) { | 157 | func TestRowsEqualAt(t *testing.T) { | ||
| 140 | a := copyTestFrame(t, 10, 4, 'a') | 158 | a := copyTestFrame(t, 10, 4, 'a') | ||
| 141 | b := a.Clone() | 159 | b := a.Clone() | ||
M html/renderer.go +10 -7
| @@ -209,17 +209,18 @@ func (r *Renderer) materializeCellSource(source CellSource) (core.Frame, error) | |||
| 209 | 209 | } | |
| 210 | 210 | ||
| 211 | 211 | // copyFrame copies a validated frame into the spare page, which the returned | |
| 212 | - | // frame then owns. | |
| 212 | + | // frame then owns. CopyFrom always succeeds here: src passed Validate. | |
| 213 | 213 | func (r *Renderer) copyFrame(src core.Frame) core.Frame { | |
| 214 | 214 | frame := r.spare | |
| 215 | 215 | r.spare = core.Frame{} | |
| 216 | 216 | frame.CopyFrom(src) | |
| 217 | - | if frame.Width != src.Width || frame.Height != src.Height { | |
| 218 | - | return src.Clone() | |
| 219 | - | } | |
| 220 | 217 | return frame | |
| 221 | 218 | } | |
| 222 | 219 | ||
| 220 | + | // maxRetainedRows bounds the row count whose encoder scratch the renderer | |
| 221 | + | // keeps between updates. | |
| 222 | + | const maxRetainedRows = 80 | |
| 223 | + | ||
| 223 | 224 | // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column | |
| 224 | 225 | // produced after writing the last column with autowrap enabled onto the | |
| 225 | 226 | // last visible column. It leaves every in-bounds cursor untouched. | |
| @@ -291,13 +292,15 @@ func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot b | |||
| 291 | 292 | if cap(ends) < frame.Width { | |
| 292 | 293 | ends = make([]int, 0, frame.Width) | |
| 293 | 294 | text = make([]byte, 0, frame.Width) | |
| 294 | - | rowStarts = make([]int, 0, 4) | |
| 295 | + | } | |
| 296 | + | if cap(rowStarts) < min(frame.Height, maxRetainedRows) { | |
| 297 | + | rowStarts = make([]int, 0, min(frame.Height, maxRetainedRows)) | |
| 295 | 298 | } | |
| 296 | 299 | defer func() { | |
| 297 | 300 | // Keep scratch for frames up to the common 240x80 size only, so one | |
| 298 | 301 | // huge snapshot does not pin its buffers for the renderer lifetime. | |
| 299 | - | const maxRetainedCells = 240 * 80 | |
| 300 | - | if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= 80 { | |
| 302 | + | const maxRetainedCells = 240 * maxRetainedRows | |
| 303 | + | if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= maxRetainedRows { | |
| 301 | 304 | r.ends, r.text, r.rowStarts = ends, text, rowStarts | |
| 302 | 305 | } | |
| 303 | 306 | }() | |
| @@ -209,17 +209,18 @@ func (r *Renderer) materializeCellSource(source CellSource) (core.Frame, error) | |||||
| 209 | } | 209 | } | ||
| 210 | 210 | ||||
| 211 | // copyFrame copies a validated frame into the spare page, which the returned | 211 | // copyFrame copies a validated frame into the spare page, which the returned | ||
| 212 | - | // frame then owns. | 212 | + | // frame then owns. CopyFrom always succeeds here: src passed Validate. |
| 213 | func (r *Renderer) copyFrame(src core.Frame) core.Frame { | 213 | func (r *Renderer) copyFrame(src core.Frame) core.Frame { | ||
| 214 | frame := r.spare | 214 | frame := r.spare | ||
| 215 | r.spare = core.Frame{} | 215 | r.spare = core.Frame{} | ||
| 216 | frame.CopyFrom(src) | 216 | frame.CopyFrom(src) | ||
| 217 | - | if frame.Width != src.Width || frame.Height != src.Height { | |||
| 218 | - | return src.Clone() | |||
| 219 | - | } | |||
| 220 | return frame | 217 | return frame | ||
| 221 | } | 218 | } | ||
| 222 | 219 | ||||
| 220 | + | // maxRetainedRows bounds the row count whose encoder scratch the renderer | |||
| 221 | + | // keeps between updates. | |||
| 222 | + | const maxRetainedRows = 80 | |||
| 223 | + | ||||
| 223 | // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column | 224 | // normalizeWrapPendingCursor maps the deferred-wrap one-past-end column | ||
| 224 | // produced after writing the last column with autowrap enabled onto the | 225 | // produced after writing the last column with autowrap enabled onto the | ||
| 225 | // last visible column. It leaves every in-bounds cursor untouched. | 226 | // last visible column. It leaves every in-bounds cursor untouched. | ||
| @@ -291,13 +292,15 @@ func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot b | |||||
| 291 | if cap(ends) < frame.Width { | 292 | if cap(ends) < frame.Width { | ||
| 292 | ends = make([]int, 0, frame.Width) | 293 | ends = make([]int, 0, frame.Width) | ||
| 293 | text = make([]byte, 0, frame.Width) | 294 | text = make([]byte, 0, frame.Width) | ||
| 294 | - | rowStarts = make([]int, 0, 4) | 295 | + | } |
| 296 | + | if cap(rowStarts) < min(frame.Height, maxRetainedRows) { | |||
| 297 | + | rowStarts = make([]int, 0, min(frame.Height, maxRetainedRows)) | |||
| 295 | } | 298 | } | ||
| 296 | defer func() { | 299 | defer func() { | ||
| 297 | // Keep scratch for frames up to the common 240x80 size only, so one | 300 | // Keep scratch for frames up to the common 240x80 size only, so one | ||
| 298 | // huge snapshot does not pin its buffers for the renderer lifetime. | 301 | // huge snapshot does not pin its buffers for the renderer lifetime. | ||
| 299 | - | const maxRetainedCells = 240 * 80 | 302 | + | const maxRetainedCells = 240 * maxRetainedRows |
| 300 | - | if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= 80 { | 303 | + | if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= maxRetainedRows { |
| 301 | r.ends, r.text, r.rowStarts = ends, text, rowStarts | 304 | r.ends, r.text, r.rowStarts = ends, text, rowStarts | ||
| 302 | } | 305 | } | ||
| 303 | }() | 306 | }() | ||
M html/renderer_test.go +97 -0
| @@ -435,6 +435,103 @@ func TestAbortThenPrepareKeepsCommittedShadow(t *testing.T) { | |||
| 435 | 435 | require.Empty(t, steady.Update().Rows) | |
| 436 | 436 | } | |
| 437 | 437 | ||
| 438 | + | func TestRendererKeepsEncoderScratchAt240x80(t *testing.T) { | |
| 439 | + | frames := [2]core.Frame{core.NewFrame(240, 80), core.NewFrame(240, 80)} | |
| 440 | + | for y := range 80 { | |
| 441 | + | for x := range 240 { | |
| 442 | + | frames[0].Set(x, y, core.Cell{Rune: 'a'}) | |
| 443 | + | frames[1].Set(x, y, core.Cell{Rune: 'b'}) | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | renderer, err := New(Options{}) | |
| 447 | + | require.NoError(t, err) | |
| 448 | + | for step := range 2 { | |
| 449 | + | prepared, err := renderer.Prepare(frames[step], nil, false, Cursor{}) | |
| 450 | + | require.NoError(t, err) | |
| 451 | + | require.NoError(t, prepared.Commit()) | |
| 452 | + | } | |
| 453 | + | require.GreaterOrEqual(t, cap(renderer.rowStarts), 80, "row-start scratch was not retained") | |
| 454 | + | require.GreaterOrEqual(t, cap(renderer.ends), 240, "run-end scratch was not retained") | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | // TestRendererMatchesFreshRendererAcrossTransactions drives one renderer | |
| 458 | + | // (reused spare pages and scratch) through random edits with payloads and | |
| 459 | + | // equivalent styles, aborts, resets, stale commits and failed prepares. Most | |
| 460 | + | // steps commit and keep editing, so the shadow and spare pages alternate. | |
| 461 | + | // Every update must list exactly the rows that differ from a cloned committed | |
| 462 | + | // shadow (compared cell by cell with Cell.Equal) and encode the source frame. | |
| 463 | + | func TestRendererMatchesFreshRendererAcrossTransactions(t *testing.T) { | |
| 464 | + | rng := rand.New(rand.NewSource(7)) | |
| 465 | + | link := func(i int) core.CellPayload { | |
| 466 | + | payload, err := core.NewCellPayload("", "https://example.test/"+string(rune('a'+i))) | |
| 467 | + | require.NoError(t, err) | |
| 468 | + | return payload | |
| 469 | + | } | |
| 470 | + | bold := core.DefaultStyle() | |
| 471 | + | bold.Bold = true | |
| 472 | + | equivalent := bold | |
| 473 | + | equivalent.ForegroundRGB = core.RGB{R: 1} // inactive RGB: same style | |
| 474 | + | for seed := range 30 { | |
| 475 | + | width, height := 2+rng.Intn(10), 1+rng.Intn(5) | |
| 476 | + | frame := core.NewFrame(width, height) | |
| 477 | + | renderer, err := New(Options{}) | |
| 478 | + | require.NoError(t, err) | |
| 479 | + | var committed core.Frame | |
| 480 | + | var stale *PreparedDraw | |
| 481 | + | for step := range 60 { | |
| 482 | + | for range rng.Intn(4) { | |
| 483 | + | cell := core.Cell{Rune: rune('a' + rng.Intn(4))} | |
| 484 | + | switch rng.Intn(4) { | |
| 485 | + | case 0: | |
| 486 | + | cell.Style = bold | |
| 487 | + | case 1: | |
| 488 | + | cell.Style = equivalent | |
| 489 | + | case 2: | |
| 490 | + | cell.Payload = link(rng.Intn(3)) | |
| 491 | + | } | |
| 492 | + | frame.Set(rng.Intn(width), rng.Intn(height), cell) | |
| 493 | + | } | |
| 494 | + | if stale != nil && rng.Intn(3) == 0 { | |
| 495 | + | require.ErrorIs(t, stale.Commit(), ErrStaleDraw) | |
| 496 | + | stale = nil | |
| 497 | + | } | |
| 498 | + | if rng.Intn(10) == 0 { | |
| 499 | + | // A failed prepare must not lose or corrupt the spare page. | |
| 500 | + | _, err := renderer.Prepare(frame, nil, false, Cursor{Row: height}) | |
| 501 | + | require.Error(t, err) | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | want := []int{} | |
| 505 | + | for y := range height { | |
| 506 | + | for x := range width { | |
| 507 | + | if committed.Width == 0 || !frame.Cell(x, y).Equal(committed.Cell(x, y)) { | |
| 508 | + | want = append(want, y) | |
| 509 | + | break | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | } | |
| 513 | + | got, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 514 | + | require.NoError(t, err, "seed %d step %d", seed, step) | |
| 515 | + | update := got.Update() | |
| 516 | + | require.Equal(t, committed.Width == 0, update.Snapshot, "seed %d step %d", seed, step) | |
| 517 | + | require.Equal(t, want, rowIndexes(update), "seed %d step %d", seed, step) | |
| 518 | + | requireRunsMatchFrame(t, update, frame, step) | |
| 519 | + | ||
| 520 | + | switch k := rng.Intn(20); { | |
| 521 | + | case k == 0: | |
| 522 | + | renderer.Reset() | |
| 523 | + | committed = core.Frame{} | |
| 524 | + | stale = got | |
| 525 | + | case k < 4: | |
| 526 | + | require.NoError(t, got.Abort()) | |
| 527 | + | default: | |
| 528 | + | require.NoError(t, got.Commit()) | |
| 529 | + | committed = frame.Clone() | |
| 530 | + | } | |
| 531 | + | } | |
| 532 | + | } | |
| 533 | + | } | |
| 534 | + | ||
| 438 | 535 | func rowIndexes(update Update) []int { | |
| 439 | 536 | rows := make([]int, 0, len(update.Rows)) | |
| 440 | 537 | for _, row := range update.Rows { | |
| @@ -435,6 +435,103 @@ func TestAbortThenPrepareKeepsCommittedShadow(t *testing.T) { | |||||
| 435 | require.Empty(t, steady.Update().Rows) | 435 | require.Empty(t, steady.Update().Rows) | ||
| 436 | } | 436 | } | ||
| 437 | 437 | ||||
| 438 | + | func TestRendererKeepsEncoderScratchAt240x80(t *testing.T) { | |||
| 439 | + | frames := [2]core.Frame{core.NewFrame(240, 80), core.NewFrame(240, 80)} | |||
| 440 | + | for y := range 80 { | |||
| 441 | + | for x := range 240 { | |||
| 442 | + | frames[0].Set(x, y, core.Cell{Rune: 'a'}) | |||
| 443 | + | frames[1].Set(x, y, core.Cell{Rune: 'b'}) | |||
| 444 | + | } | |||
| 445 | + | } | |||
| 446 | + | renderer, err := New(Options{}) | |||
| 447 | + | require.NoError(t, err) | |||
| 448 | + | for step := range 2 { | |||
| 449 | + | prepared, err := renderer.Prepare(frames[step], nil, false, Cursor{}) | |||
| 450 | + | require.NoError(t, err) | |||
| 451 | + | require.NoError(t, prepared.Commit()) | |||
| 452 | + | } | |||
| 453 | + | require.GreaterOrEqual(t, cap(renderer.rowStarts), 80, "row-start scratch was not retained") | |||
| 454 | + | require.GreaterOrEqual(t, cap(renderer.ends), 240, "run-end scratch was not retained") | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | // TestRendererMatchesFreshRendererAcrossTransactions drives one renderer | |||
| 458 | + | // (reused spare pages and scratch) through random edits with payloads and | |||
| 459 | + | // equivalent styles, aborts, resets, stale commits and failed prepares. Most | |||
| 460 | + | // steps commit and keep editing, so the shadow and spare pages alternate. | |||
| 461 | + | // Every update must list exactly the rows that differ from a cloned committed | |||
| 462 | + | // shadow (compared cell by cell with Cell.Equal) and encode the source frame. | |||
| 463 | + | func TestRendererMatchesFreshRendererAcrossTransactions(t *testing.T) { | |||
| 464 | + | rng := rand.New(rand.NewSource(7)) | |||
| 465 | + | link := func(i int) core.CellPayload { | |||
| 466 | + | payload, err := core.NewCellPayload("", "https://example.test/"+string(rune('a'+i))) | |||
| 467 | + | require.NoError(t, err) | |||
| 468 | + | return payload | |||
| 469 | + | } | |||
| 470 | + | bold := core.DefaultStyle() | |||
| 471 | + | bold.Bold = true | |||
| 472 | + | equivalent := bold | |||
| 473 | + | equivalent.ForegroundRGB = core.RGB{R: 1} // inactive RGB: same style | |||
| 474 | + | for seed := range 30 { | |||
| 475 | + | width, height := 2+rng.Intn(10), 1+rng.Intn(5) | |||
| 476 | + | frame := core.NewFrame(width, height) | |||
| 477 | + | renderer, err := New(Options{}) | |||
| 478 | + | require.NoError(t, err) | |||
| 479 | + | var committed core.Frame | |||
| 480 | + | var stale *PreparedDraw | |||
| 481 | + | for step := range 60 { | |||
| 482 | + | for range rng.Intn(4) { | |||
| 483 | + | cell := core.Cell{Rune: rune('a' + rng.Intn(4))} | |||
| 484 | + | switch rng.Intn(4) { | |||
| 485 | + | case 0: | |||
| 486 | + | cell.Style = bold | |||
| 487 | + | case 1: | |||
| 488 | + | cell.Style = equivalent | |||
| 489 | + | case 2: | |||
| 490 | + | cell.Payload = link(rng.Intn(3)) | |||
| 491 | + | } | |||
| 492 | + | frame.Set(rng.Intn(width), rng.Intn(height), cell) | |||
| 493 | + | } | |||
| 494 | + | if stale != nil && rng.Intn(3) == 0 { | |||
| 495 | + | require.ErrorIs(t, stale.Commit(), ErrStaleDraw) | |||
| 496 | + | stale = nil | |||
| 497 | + | } | |||
| 498 | + | if rng.Intn(10) == 0 { | |||
| 499 | + | // A failed prepare must not lose or corrupt the spare page. | |||
| 500 | + | _, err := renderer.Prepare(frame, nil, false, Cursor{Row: height}) | |||
| 501 | + | require.Error(t, err) | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | want := []int{} | |||
| 505 | + | for y := range height { | |||
| 506 | + | for x := range width { | |||
| 507 | + | if committed.Width == 0 || !frame.Cell(x, y).Equal(committed.Cell(x, y)) { | |||
| 508 | + | want = append(want, y) | |||
| 509 | + | break | |||
| 510 | + | } | |||
| 511 | + | } | |||
| 512 | + | } | |||
| 513 | + | got, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 514 | + | require.NoError(t, err, "seed %d step %d", seed, step) | |||
| 515 | + | update := got.Update() | |||
| 516 | + | require.Equal(t, committed.Width == 0, update.Snapshot, "seed %d step %d", seed, step) | |||
| 517 | + | require.Equal(t, want, rowIndexes(update), "seed %d step %d", seed, step) | |||
| 518 | + | requireRunsMatchFrame(t, update, frame, step) | |||
| 519 | + | ||||
| 520 | + | switch k := rng.Intn(20); { | |||
| 521 | + | case k == 0: | |||
| 522 | + | renderer.Reset() | |||
| 523 | + | committed = core.Frame{} | |||
| 524 | + | stale = got | |||
| 525 | + | case k < 4: | |||
| 526 | + | require.NoError(t, got.Abort()) | |||
| 527 | + | default: | |||
| 528 | + | require.NoError(t, got.Commit()) | |||
| 529 | + | committed = frame.Clone() | |||
| 530 | + | } | |||
| 531 | + | } | |||
| 532 | + | } | |||
| 533 | + | } | |||
| 534 | + | ||||
| 438 | func rowIndexes(update Update) []int { | 535 | func rowIndexes(update Update) []int { | ||
| 439 | rows := make([]int, 0, len(update.Rows)) | 536 | rows := make([]int, 0, len(update.Rows)) | ||
| 440 | for _, row := range update.Rows { | 537 | for _, row := range update.Rows { | ||
M html/terminal.css +1 -0
| @@ -76,6 +76,7 @@ | |||
| 76 | 76 | white-space: pre; | |
| 77 | 77 | visibility: hidden; | |
| 78 | 78 | pointer-events: none; | |
| 79 | + | user-select: none; | |
| 79 | 80 | } | |
| 80 | 81 | ||
| 81 | 82 | .vev-terminal__cell--bold { font-weight: 700; } | |
| @@ -76,6 +76,7 @@ | |||||
| 76 | white-space: pre; | 76 | white-space: pre; | ||
| 77 | visibility: hidden; | 77 | visibility: hidden; | ||
| 78 | pointer-events: none; | 78 | pointer-events: none; | ||
| 79 | + | user-select: none; | |||
| 79 | } | 80 | } | ||
| 80 | 81 | ||||
| 81 | .vev-terminal__cell--bold { font-weight: 700; } | 82 | .vev-terminal__cell--bold { font-weight: 700; } | ||