perf(core): cache last interned style and speed up row blanking
Skip canonicalization and map hashing when Set repeats the raw style, fill blanked rows by copy-doubling, and double style slice capacity (bounded by cell count) to cut growth allocations.
- commit
- 94911565a4aac9cf6891c39caccb2fae859d0a0e
- author
- brice · · 2026-10-02 09:26 UTC
- parent
- dca8245
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +234 -31
M core/frame.go +75 -30
| @@ -42,12 +42,20 @@ type styleSlot struct { | |||
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | type cellPage struct { | |
| 45 | - | cells []storedCell | |
| 46 | - | rows []uint32 | |
| 47 | - | styles []styleSlot | |
| 48 | - | styleIndex map[Style]uint32 | |
| 49 | - | freeStyles []uint32 | |
| 50 | - | styleCount uint32 | |
| 45 | + | cells []storedCell | |
| 46 | + | rows []uint32 | |
| 47 | + | styles []styleSlot | |
| 48 | + | styleIndex map[Style]uint32 | |
| 49 | + | freeStyles []uint32 | |
| 50 | + | styleCount uint32 | |
| 51 | + | // styleCache remembers the most recent raw Style passed to internStyle and | |
| 52 | + | // the live slot it resolved to. It is a pure accelerator: it is cleared | |
| 53 | + | // whenever that slot is freed, and a fresh page starts with it invalid. | |
| 54 | + | // Clone copies it along with the style slots it refers to, so a clone stays | |
| 55 | + | // reflect.DeepEqual to its source. | |
| 56 | + | styleCache Style | |
| 57 | + | styleCacheID uint32 | |
| 58 | + | styleCacheOK bool | |
| 51 | 59 | payloads []payloadSlot | |
| 52 | 60 | payloadIndex map[CellPayload]uint32 | |
| 53 | 61 | freePayloads []uint32 | |
| @@ -101,6 +109,9 @@ func (f Frame) Clone() Frame { | |||
| 101 | 109 | styleIndex: maps.Clone(f.page.styleIndex), | |
| 102 | 110 | freeStyles: append([]uint32(nil), f.page.freeStyles...), | |
| 103 | 111 | styleCount: f.page.styleCount, | |
| 112 | + | styleCache: f.page.styleCache, | |
| 113 | + | styleCacheID: f.page.styleCacheID, | |
| 114 | + | styleCacheOK: f.page.styleCacheOK, | |
| 104 | 115 | payloads: append([]payloadSlot(nil), f.page.payloads...), | |
| 105 | 116 | payloadIndex: maps.Clone(f.page.payloadIndex), | |
| 106 | 117 | freePayloads: append([]uint32(nil), f.page.freePayloads...), | |
| @@ -262,9 +273,10 @@ func (f Frame) Set(x, y int, cell Cell) { | |||
| 262 | 273 | f.releasePayload(f.page.cells[index].payloadID) | |
| 263 | 274 | oldID := f.page.cells[index].styleID | |
| 264 | 275 | styleID := oldID | |
| 265 | - | // Repainting a cell usually keeps its style. Compare the canonical value | |
| 266 | - | // before hashing it and changing references in the page-local dictionary. | |
| 267 | - | if f.page.styles[oldID].style != cell.Style.Canonical() { | |
| 276 | + | // Repainting a cell usually keeps its style. Stored styles are canonical, | |
| 277 | + | // so a raw match proves the canonical form matches too; this skips | |
| 278 | + | // canonicalization and the dictionary for the common case. | |
| 279 | + | if f.page.styles[oldID].style != cell.Style { | |
| 268 | 280 | styleID = f.internStyle(cell.Style) | |
| 269 | 281 | f.releaseStyle(oldID) | |
| 270 | 282 | } | |
| @@ -344,44 +356,77 @@ func (f Frame) offset(x, y int) int { | |||
| 344 | 356 | } | |
| 345 | 357 | ||
| 346 | 358 | func (f Frame) blankPhysicalRow(offset uint32) { | |
| 347 | - | for x := range f.Width { | |
| 348 | - | index := int(offset) + x | |
| 349 | - | f.releasePayload(f.page.cells[index].payloadID) | |
| 350 | - | oldID := f.page.cells[index].styleID | |
| 351 | - | if oldID != 0 { | |
| 359 | + | row := f.page.cells[offset : int(offset)+f.Width] | |
| 360 | + | for i := range row { | |
| 361 | + | cell := &row[i] | |
| 362 | + | if cell.payloadID != 0 { | |
| 363 | + | f.releasePayload(cell.payloadID) | |
| 364 | + | } | |
| 365 | + | if oldID := cell.styleID; oldID != 0 { | |
| 352 | 366 | f.page.styles[0].refs++ | |
| 353 | 367 | f.releaseStyle(oldID) | |
| 354 | 368 | } | |
| 355 | - | f.page.cells[index] = storedCell{rune: ' '} | |
| 369 | + | } | |
| 370 | + | if len(row) == 0 { | |
| 371 | + | return | |
| 372 | + | } | |
| 373 | + | row[0] = storedCell{rune: ' '} | |
| 374 | + | for filled := 1; filled < len(row); filled *= 2 { | |
| 375 | + | copy(row[filled:], row[:filled]) | |
| 356 | 376 | } | |
| 357 | 377 | } | |
| 358 | 378 | ||
| 359 | - | func (f Frame) internStyle(style Style) uint32 { | |
| 360 | - | style = style.Canonical() | |
| 361 | - | if id, ok := f.page.styleIndex[style]; ok { | |
| 362 | - | f.page.styles[id].refs++ | |
| 363 | - | return id | |
| 364 | - | } | |
| 365 | - | var id uint32 | |
| 366 | - | if n := len(f.page.freeStyles); n != 0 { | |
| 367 | - | id = f.page.freeStyles[n-1] | |
| 368 | - | f.page.freeStyles = f.page.freeStyles[:n-1] | |
| 369 | - | f.page.styles[id] = styleSlot{style: style, refs: 1, used: true} | |
| 379 | + | // internStyle returns the page-local ID for style and acquires one reference. | |
| 380 | + | func (f Frame) internStyle(raw Style) uint32 { | |
| 381 | + | p := f.page | |
| 382 | + | if p.styleCacheOK && raw == p.styleCache { | |
| 383 | + | p.styles[p.styleCacheID].refs++ | |
| 384 | + | return p.styleCacheID | |
| 385 | + | } | |
| 386 | + | style := raw.Canonical() | |
| 387 | + | id, ok := p.styleIndex[style] | |
| 388 | + | if ok { | |
| 389 | + | p.styles[id].refs++ | |
| 370 | 390 | } else { | |
| 371 | - | id = uint32(len(f.page.styles)) | |
| 372 | - | f.page.styles = append(f.page.styles, styleSlot{style: style, refs: 1, used: true}) | |
| 391 | + | if n := len(p.freeStyles); n != 0 { | |
| 392 | + | id = p.freeStyles[n-1] | |
| 393 | + | p.freeStyles = p.freeStyles[:n-1] | |
| 394 | + | p.styles[id] = styleSlot{style: style, refs: 1, used: true} | |
| 395 | + | } else { | |
| 396 | + | id = uint32(len(p.styles)) | |
| 397 | + | if len(p.styles) == cap(p.styles) { | |
| 398 | + | p.styles = f.growStyles() | |
| 399 | + | } | |
| 400 | + | p.styles = append(p.styles, styleSlot{style: style, refs: 1, used: true}) | |
| 401 | + | } | |
| 402 | + | p.styleIndex[style] = id | |
| 403 | + | p.styleCount++ | |
| 373 | 404 | } | |
| 374 | - | f.page.styleIndex[style] = id | |
| 375 | - | f.page.styleCount++ | |
| 405 | + | p.styleCache, p.styleCacheID, p.styleCacheOK = raw, id, true | |
| 376 | 406 | return id | |
| 377 | 407 | } | |
| 378 | 408 | ||
| 409 | + | // growStyles doubles style capacity instead of Go's gentler large-slice growth, | |
| 410 | + | // roughly halving total bytes allocated by high-cardinality frames. Capacity | |
| 411 | + | // never exceeds the one-style-per-cell bound, so small frames stay small. | |
| 412 | + | func (f Frame) growStyles() []styleSlot { | |
| 413 | + | old := f.page.styles | |
| 414 | + | limit := len(f.page.cells) + 1 | |
| 415 | + | newCap := min(max(2*cap(old), 4), max(limit, len(old)+1)) | |
| 416 | + | grown := make([]styleSlot, len(old), newCap) | |
| 417 | + | copy(grown, old) | |
| 418 | + | return grown | |
| 419 | + | } | |
| 420 | + | ||
| 379 | 421 | func (f Frame) releaseStyle(id uint32) { | |
| 380 | 422 | slot := &f.page.styles[id] | |
| 381 | 423 | slot.refs-- | |
| 382 | 424 | if id == 0 || slot.refs != 0 { | |
| 383 | 425 | return | |
| 384 | 426 | } | |
| 427 | + | if f.page.styleCacheID == id { | |
| 428 | + | f.page.styleCacheOK = false | |
| 429 | + | } | |
| 385 | 430 | delete(f.page.styleIndex, slot.style) | |
| 386 | 431 | *slot = styleSlot{} | |
| 387 | 432 | f.page.freeStyles = append(f.page.freeStyles, id) | |
| @@ -42,12 +42,20 @@ type styleSlot struct { | |||||
| 42 | } | 42 | } | ||
| 43 | 43 | ||||
| 44 | type cellPage struct { | 44 | type cellPage struct { | ||
| 45 | - | cells []storedCell | 45 | + | cells []storedCell |
| 46 | - | rows []uint32 | 46 | + | rows []uint32 |
| 47 | - | styles []styleSlot | 47 | + | styles []styleSlot |
| 48 | - | styleIndex map[Style]uint32 | 48 | + | styleIndex map[Style]uint32 |
| 49 | - | freeStyles []uint32 | 49 | + | freeStyles []uint32 |
| 50 | - | styleCount uint32 | 50 | + | styleCount uint32 |
| 51 | + | // styleCache remembers the most recent raw Style passed to internStyle and | |||
| 52 | + | // the live slot it resolved to. It is a pure accelerator: it is cleared | |||
| 53 | + | // whenever that slot is freed, and a fresh page starts with it invalid. | |||
| 54 | + | // Clone copies it along with the style slots it refers to, so a clone stays | |||
| 55 | + | // reflect.DeepEqual to its source. | |||
| 56 | + | styleCache Style | |||
| 57 | + | styleCacheID uint32 | |||
| 58 | + | styleCacheOK bool | |||
| 51 | payloads []payloadSlot | 59 | payloads []payloadSlot | ||
| 52 | payloadIndex map[CellPayload]uint32 | 60 | payloadIndex map[CellPayload]uint32 | ||
| 53 | freePayloads []uint32 | 61 | freePayloads []uint32 | ||
| @@ -101,6 +109,9 @@ func (f Frame) Clone() Frame { | |||||
| 101 | styleIndex: maps.Clone(f.page.styleIndex), | 109 | styleIndex: maps.Clone(f.page.styleIndex), | ||
| 102 | freeStyles: append([]uint32(nil), f.page.freeStyles...), | 110 | freeStyles: append([]uint32(nil), f.page.freeStyles...), | ||
| 103 | styleCount: f.page.styleCount, | 111 | styleCount: f.page.styleCount, | ||
| 112 | + | styleCache: f.page.styleCache, | |||
| 113 | + | styleCacheID: f.page.styleCacheID, | |||
| 114 | + | styleCacheOK: f.page.styleCacheOK, | |||
| 104 | payloads: append([]payloadSlot(nil), f.page.payloads...), | 115 | payloads: append([]payloadSlot(nil), f.page.payloads...), | ||
| 105 | payloadIndex: maps.Clone(f.page.payloadIndex), | 116 | payloadIndex: maps.Clone(f.page.payloadIndex), | ||
| 106 | freePayloads: append([]uint32(nil), f.page.freePayloads...), | 117 | freePayloads: append([]uint32(nil), f.page.freePayloads...), | ||
| @@ -262,9 +273,10 @@ func (f Frame) Set(x, y int, cell Cell) { | |||||
| 262 | f.releasePayload(f.page.cells[index].payloadID) | 273 | f.releasePayload(f.page.cells[index].payloadID) | ||
| 263 | oldID := f.page.cells[index].styleID | 274 | oldID := f.page.cells[index].styleID | ||
| 264 | styleID := oldID | 275 | styleID := oldID | ||
| 265 | - | // Repainting a cell usually keeps its style. Compare the canonical value | 276 | + | // Repainting a cell usually keeps its style. Stored styles are canonical, |
| 266 | - | // before hashing it and changing references in the page-local dictionary. | 277 | + | // so a raw match proves the canonical form matches too; this skips |
| 267 | - | if f.page.styles[oldID].style != cell.Style.Canonical() { | 278 | + | // canonicalization and the dictionary for the common case. |
| 279 | + | if f.page.styles[oldID].style != cell.Style { | |||
| 268 | styleID = f.internStyle(cell.Style) | 280 | styleID = f.internStyle(cell.Style) | ||
| 269 | f.releaseStyle(oldID) | 281 | f.releaseStyle(oldID) | ||
| 270 | } | 282 | } | ||
| @@ -344,44 +356,77 @@ func (f Frame) offset(x, y int) int { | |||||
| 344 | } | 356 | } | ||
| 345 | 357 | ||||
| 346 | func (f Frame) blankPhysicalRow(offset uint32) { | 358 | func (f Frame) blankPhysicalRow(offset uint32) { | ||
| 347 | - | for x := range f.Width { | 359 | + | row := f.page.cells[offset : int(offset)+f.Width] |
| 348 | - | index := int(offset) + x | 360 | + | for i := range row { |
| 349 | - | f.releasePayload(f.page.cells[index].payloadID) | 361 | + | cell := &row[i] |
| 350 | - | oldID := f.page.cells[index].styleID | 362 | + | if cell.payloadID != 0 { |
| 351 | - | if oldID != 0 { | 363 | + | f.releasePayload(cell.payloadID) |
| 364 | + | } | |||
| 365 | + | if oldID := cell.styleID; oldID != 0 { | |||
| 352 | f.page.styles[0].refs++ | 366 | f.page.styles[0].refs++ | ||
| 353 | f.releaseStyle(oldID) | 367 | f.releaseStyle(oldID) | ||
| 354 | } | 368 | } | ||
| 355 | - | f.page.cells[index] = storedCell{rune: ' '} | 369 | + | } |
| 370 | + | if len(row) == 0 { | |||
| 371 | + | return | |||
| 372 | + | } | |||
| 373 | + | row[0] = storedCell{rune: ' '} | |||
| 374 | + | for filled := 1; filled < len(row); filled *= 2 { | |||
| 375 | + | copy(row[filled:], row[:filled]) | |||
| 356 | } | 376 | } | ||
| 357 | } | 377 | } | ||
| 358 | 378 | ||||
| 359 | - | func (f Frame) internStyle(style Style) uint32 { | 379 | + | // internStyle returns the page-local ID for style and acquires one reference. |
| 360 | - | style = style.Canonical() | 380 | + | func (f Frame) internStyle(raw Style) uint32 { |
| 361 | - | if id, ok := f.page.styleIndex[style]; ok { | 381 | + | p := f.page |
| 362 | - | f.page.styles[id].refs++ | 382 | + | if p.styleCacheOK && raw == p.styleCache { |
| 363 | - | return id | 383 | + | p.styles[p.styleCacheID].refs++ |
| 364 | - | } | 384 | + | return p.styleCacheID |
| 365 | - | var id uint32 | 385 | + | } |
| 366 | - | if n := len(f.page.freeStyles); n != 0 { | 386 | + | style := raw.Canonical() |
| 367 | - | id = f.page.freeStyles[n-1] | 387 | + | id, ok := p.styleIndex[style] |
| 368 | - | f.page.freeStyles = f.page.freeStyles[:n-1] | 388 | + | if ok { |
| 369 | - | f.page.styles[id] = styleSlot{style: style, refs: 1, used: true} | 389 | + | p.styles[id].refs++ |
| 370 | } else { | 390 | } else { | ||
| 371 | - | id = uint32(len(f.page.styles)) | 391 | + | if n := len(p.freeStyles); n != 0 { |
| 372 | - | f.page.styles = append(f.page.styles, styleSlot{style: style, refs: 1, used: true}) | 392 | + | id = p.freeStyles[n-1] |
| 393 | + | p.freeStyles = p.freeStyles[:n-1] | |||
| 394 | + | p.styles[id] = styleSlot{style: style, refs: 1, used: true} | |||
| 395 | + | } else { | |||
| 396 | + | id = uint32(len(p.styles)) | |||
| 397 | + | if len(p.styles) == cap(p.styles) { | |||
| 398 | + | p.styles = f.growStyles() | |||
| 399 | + | } | |||
| 400 | + | p.styles = append(p.styles, styleSlot{style: style, refs: 1, used: true}) | |||
| 401 | + | } | |||
| 402 | + | p.styleIndex[style] = id | |||
| 403 | + | p.styleCount++ | |||
| 373 | } | 404 | } | ||
| 374 | - | f.page.styleIndex[style] = id | 405 | + | p.styleCache, p.styleCacheID, p.styleCacheOK = raw, id, true |
| 375 | - | f.page.styleCount++ | |||
| 376 | return id | 406 | return id | ||
| 377 | } | 407 | } | ||
| 378 | 408 | ||||
| 409 | + | // growStyles doubles style capacity instead of Go's gentler large-slice growth, | |||
| 410 | + | // roughly halving total bytes allocated by high-cardinality frames. Capacity | |||
| 411 | + | // never exceeds the one-style-per-cell bound, so small frames stay small. | |||
| 412 | + | func (f Frame) growStyles() []styleSlot { | |||
| 413 | + | old := f.page.styles | |||
| 414 | + | limit := len(f.page.cells) + 1 | |||
| 415 | + | newCap := min(max(2*cap(old), 4), max(limit, len(old)+1)) | |||
| 416 | + | grown := make([]styleSlot, len(old), newCap) | |||
| 417 | + | copy(grown, old) | |||
| 418 | + | return grown | |||
| 419 | + | } | |||
| 420 | + | ||||
| 379 | func (f Frame) releaseStyle(id uint32) { | 421 | func (f Frame) releaseStyle(id uint32) { | ||
| 380 | slot := &f.page.styles[id] | 422 | slot := &f.page.styles[id] | ||
| 381 | slot.refs-- | 423 | slot.refs-- | ||
| 382 | if id == 0 || slot.refs != 0 { | 424 | if id == 0 || slot.refs != 0 { | ||
| 383 | return | 425 | return | ||
| 384 | } | 426 | } | ||
| 427 | + | if f.page.styleCacheID == id { | |||
| 428 | + | f.page.styleCacheOK = false | |||
| 429 | + | } | |||
| 385 | delete(f.page.styleIndex, slot.style) | 430 | delete(f.page.styleIndex, slot.style) | ||
| 386 | *slot = styleSlot{} | 431 | *slot = styleSlot{} | ||
| 387 | f.page.freeStyles = append(f.page.freeStyles, id) | 432 | f.page.freeStyles = append(f.page.freeStyles, id) | ||
M core/frame_style_reuse_test.go +159 -1
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | func TestSetStyleReuse(t *testing.T) { | |
| 6 | 9 | for _, rotated := range []bool{false, true} { | |
| @@ -50,3 +53,158 @@ func BenchmarkFrameRewriteSameStyle(b *testing.B) { | |||
| 50 | 53 | } | |
| 51 | 54 | } | |
| 52 | 55 | } | |
| 56 | + | ||
| 57 | + | func TestStyleCacheSurvivesSlotFreeAndReuse(t *testing.T) { | |
| 58 | + | a := Style{Foreground: 1} | |
| 59 | + | b := Style{Foreground: 2, Bold: true} | |
| 60 | + | f := NewFrame(4, 2) | |
| 61 | + | check := func(step string) { | |
| 62 | + | t.Helper() | |
| 63 | + | if err := f.CheckInvariants(); err != nil { | |
| 64 | + | t.Fatalf("%s: %v", step, err) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | f.Set(0, 0, Cell{Rune: 'a', Style: a}) | |
| 69 | + | check("set a") | |
| 70 | + | idA := f.page.cells[f.offset(0, 0)].styleID | |
| 71 | + | if !f.page.styleCacheOK || f.page.styleCacheID != idA { | |
| 72 | + | t.Fatalf("cache = (%v,%d), want live slot %d", f.page.styleCacheOK, f.page.styleCacheID, idA) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Overwrite the only A cell so its slot is freed; the cache must not keep it. | |
| 76 | + | f.Set(0, 0, BlankCell()) | |
| 77 | + | check("free a") | |
| 78 | + | if f.page.styleCacheOK && !f.page.styles[f.page.styleCacheID].used { | |
| 79 | + | t.Fatal("cache points at a freed style slot") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Reuse the freed slot for B, then request A again: it must not alias B. | |
| 83 | + | f.Set(1, 0, Cell{Rune: 'b', Style: b}) | |
| 84 | + | check("reuse slot for b") | |
| 85 | + | if got := f.page.cells[f.offset(1, 0)].styleID; got != idA { | |
| 86 | + | t.Fatalf("expected freed slot %d to be reused, got %d", idA, got) | |
| 87 | + | } | |
| 88 | + | f.Set(2, 0, Cell{Rune: 'a', Style: a}) | |
| 89 | + | check("set a again") | |
| 90 | + | if got := f.Cell(2, 0).Style; got != a { | |
| 91 | + | t.Fatalf("style after reuse = %+v, want %+v", got, a) | |
| 92 | + | } | |
| 93 | + | if got := f.Cell(1, 0).Style; got != b { | |
| 94 | + | t.Fatalf("style b = %+v, want %+v", got, b) | |
| 95 | + | } | |
| 96 | + | if f.StyleCount() != 3 { | |
| 97 | + | t.Fatalf("StyleCount = %d, want 3", f.StyleCount()) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Repeating the cached raw style must bump references exactly once per cell. | |
| 101 | + | f.Set(3, 0, Cell{Rune: 'a', Style: a}) | |
| 102 | + | f.Set(0, 1, Cell{Rune: 'a', Style: a}) | |
| 103 | + | check("repeat a") | |
| 104 | + | f.Set(2, 0, BlankCell()) | |
| 105 | + | f.Set(3, 0, BlankCell()) | |
| 106 | + | f.Set(0, 1, BlankCell()) | |
| 107 | + | check("release a") | |
| 108 | + | if f.StyleCount() != 2 { | |
| 109 | + | t.Fatalf("StyleCount = %d, want 2", f.StyleCount()) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestStyleCacheNonCanonicalInput(t *testing.T) { | |
| 114 | + | f := NewFrame(3, 1) | |
| 115 | + | rgbWithStaleIndex := Style{Foreground: 7, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1}, Background: -1} | |
| 116 | + | canonical := rgbWithStaleIndex.Canonical() | |
| 117 | + | f.Set(0, 0, Cell{Rune: 'x', Style: rgbWithStaleIndex}) | |
| 118 | + | f.Set(1, 0, Cell{Rune: 'y', Style: canonical}) | |
| 119 | + | f.Set(2, 0, Cell{Rune: 'z', Style: rgbWithStaleIndex}) | |
| 120 | + | if err := f.CheckInvariants(); err != nil { | |
| 121 | + | t.Fatal(err) | |
| 122 | + | } | |
| 123 | + | if f.StyleCount() != 2 { | |
| 124 | + | t.Fatalf("StyleCount = %d, want 2", f.StyleCount()) | |
| 125 | + | } | |
| 126 | + | for x := range 3 { | |
| 127 | + | if got := f.Cell(x, 0).Style; got != canonical { | |
| 128 | + | t.Fatalf("cell %d style = %+v, want %+v", x, got, canonical) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | // Rewriting with the non-canonical form must keep reference counts exact. | |
| 132 | + | f.Set(0, 0, Cell{Rune: 'x', Style: rgbWithStaleIndex}) | |
| 133 | + | if err := f.CheckInvariants(); err != nil { | |
| 134 | + | t.Fatal(err) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func TestStyleCacheCloneAndReplaceIndependence(t *testing.T) { | |
| 139 | + | a := Style{Foreground: 1} | |
| 140 | + | b := Style{Foreground: 2} | |
| 141 | + | src := NewFrame(2, 1) | |
| 142 | + | src.Set(0, 0, Cell{Rune: 'a', Style: a}) | |
| 143 | + | clone := src.Clone() | |
| 144 | + | if !reflect.DeepEqual(src, clone) { | |
| 145 | + | t.Fatal("clone is not DeepEqual to its source") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Free A in the source and reuse its slot for B; the clone keeps A. | |
| 149 | + | src.Set(0, 0, BlankCell()) | |
| 150 | + | src.Set(1, 0, Cell{Rune: 'b', Style: b}) | |
| 151 | + | clone.Set(1, 0, Cell{Rune: 'a', Style: a}) | |
| 152 | + | for name, f := range map[string]Frame{"src": src, "clone": clone} { | |
| 153 | + | if err := f.CheckInvariants(); err != nil { | |
| 154 | + | t.Fatalf("%s: %v", name, err) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | if got := clone.Cell(0, 0).Style; got != a { | |
| 158 | + | t.Fatalf("clone cell 0 = %+v, want %+v", got, a) | |
| 159 | + | } | |
| 160 | + | if got := clone.Cell(1, 0).Style; got != a { | |
| 161 | + | t.Fatalf("clone cell 1 = %+v, want %+v", got, a) | |
| 162 | + | } | |
| 163 | + | if got := src.Cell(1, 0).Style; got != b { | |
| 164 | + | t.Fatalf("src cell 1 = %+v, want %+v", got, b) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | replaced := NewFrame(2, 1) | |
| 168 | + | replaced.Set(0, 0, Cell{Rune: 'q', Style: b}) | |
| 169 | + | replaced.Replace(clone) | |
| 170 | + | replaced.Set(0, 0, Cell{Rune: 'q', Style: b}) | |
| 171 | + | replaced.Set(1, 0, Cell{Rune: 'q', Style: b}) | |
| 172 | + | if err := replaced.CheckInvariants(); err != nil { | |
| 173 | + | t.Fatal(err) | |
| 174 | + | } | |
| 175 | + | if err := clone.CheckInvariants(); err != nil || clone.Cell(0, 0).Style != a { | |
| 176 | + | t.Fatalf("clone changed by replaced frame: %v", err) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestBlankPhysicalRowReleasesStylesAndPayloads(t *testing.T) { | |
| 181 | + | const width = 7 | |
| 182 | + | f := NewFrame(width, 3) | |
| 183 | + | payload, err := NewCellPayload("é", "") | |
| 184 | + | if err != nil { | |
| 185 | + | t.Fatal(err) | |
| 186 | + | } | |
| 187 | + | for x := range width { | |
| 188 | + | f.Set(x, 0, Cell{Rune: 'x', Style: Style{Foreground: x % 3}, Payload: payload}) | |
| 189 | + | f.Set(x, 1, Cell{Rune: 'y', Style: Style{Foreground: x % 3}}) | |
| 190 | + | } | |
| 191 | + | f.ScrollUp(0, 2, 1) | |
| 192 | + | if err := f.CheckInvariants(); err != nil { | |
| 193 | + | t.Fatal(err) | |
| 194 | + | } | |
| 195 | + | for x := range width { | |
| 196 | + | if got := f.Cell(x, 2); got != (Cell{Rune: ' ', Style: DefaultStyle()}) { | |
| 197 | + | t.Fatalf("blank row cell %d = %+v", x, got) | |
| 198 | + | } | |
| 199 | + | if got := f.Cell(x, 0); got.Rune != 'y' || !got.Payload.Empty() { | |
| 200 | + | t.Fatalf("shifted row cell %d = %+v", x, got) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | f.ScrollDown(0, 2, 3) | |
| 204 | + | if err := f.CheckInvariants(); err != nil { | |
| 205 | + | t.Fatal(err) | |
| 206 | + | } | |
| 207 | + | if f.StyleCount() != 1 { | |
| 208 | + | t.Fatalf("StyleCount = %d, want 1", f.StyleCount()) | |
| 209 | + | } | |
| 210 | + | } | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 3 | + | import ( |
| 4 | + | "reflect" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | func TestSetStyleReuse(t *testing.T) { | 8 | func TestSetStyleReuse(t *testing.T) { | ||
| 6 | for _, rotated := range []bool{false, true} { | 9 | for _, rotated := range []bool{false, true} { | ||
| @@ -50,3 +53,158 @@ func BenchmarkFrameRewriteSameStyle(b *testing.B) { | |||||
| 50 | } | 53 | } | ||
| 51 | } | 54 | } | ||
| 52 | } | 55 | } | ||
| 56 | + | ||||
| 57 | + | func TestStyleCacheSurvivesSlotFreeAndReuse(t *testing.T) { | |||
| 58 | + | a := Style{Foreground: 1} | |||
| 59 | + | b := Style{Foreground: 2, Bold: true} | |||
| 60 | + | f := NewFrame(4, 2) | |||
| 61 | + | check := func(step string) { | |||
| 62 | + | t.Helper() | |||
| 63 | + | if err := f.CheckInvariants(); err != nil { | |||
| 64 | + | t.Fatalf("%s: %v", step, err) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | f.Set(0, 0, Cell{Rune: 'a', Style: a}) | |||
| 69 | + | check("set a") | |||
| 70 | + | idA := f.page.cells[f.offset(0, 0)].styleID | |||
| 71 | + | if !f.page.styleCacheOK || f.page.styleCacheID != idA { | |||
| 72 | + | t.Fatalf("cache = (%v,%d), want live slot %d", f.page.styleCacheOK, f.page.styleCacheID, idA) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Overwrite the only A cell so its slot is freed; the cache must not keep it. | |||
| 76 | + | f.Set(0, 0, BlankCell()) | |||
| 77 | + | check("free a") | |||
| 78 | + | if f.page.styleCacheOK && !f.page.styles[f.page.styleCacheID].used { | |||
| 79 | + | t.Fatal("cache points at a freed style slot") | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Reuse the freed slot for B, then request A again: it must not alias B. | |||
| 83 | + | f.Set(1, 0, Cell{Rune: 'b', Style: b}) | |||
| 84 | + | check("reuse slot for b") | |||
| 85 | + | if got := f.page.cells[f.offset(1, 0)].styleID; got != idA { | |||
| 86 | + | t.Fatalf("expected freed slot %d to be reused, got %d", idA, got) | |||
| 87 | + | } | |||
| 88 | + | f.Set(2, 0, Cell{Rune: 'a', Style: a}) | |||
| 89 | + | check("set a again") | |||
| 90 | + | if got := f.Cell(2, 0).Style; got != a { | |||
| 91 | + | t.Fatalf("style after reuse = %+v, want %+v", got, a) | |||
| 92 | + | } | |||
| 93 | + | if got := f.Cell(1, 0).Style; got != b { | |||
| 94 | + | t.Fatalf("style b = %+v, want %+v", got, b) | |||
| 95 | + | } | |||
| 96 | + | if f.StyleCount() != 3 { | |||
| 97 | + | t.Fatalf("StyleCount = %d, want 3", f.StyleCount()) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Repeating the cached raw style must bump references exactly once per cell. | |||
| 101 | + | f.Set(3, 0, Cell{Rune: 'a', Style: a}) | |||
| 102 | + | f.Set(0, 1, Cell{Rune: 'a', Style: a}) | |||
| 103 | + | check("repeat a") | |||
| 104 | + | f.Set(2, 0, BlankCell()) | |||
| 105 | + | f.Set(3, 0, BlankCell()) | |||
| 106 | + | f.Set(0, 1, BlankCell()) | |||
| 107 | + | check("release a") | |||
| 108 | + | if f.StyleCount() != 2 { | |||
| 109 | + | t.Fatalf("StyleCount = %d, want 2", f.StyleCount()) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestStyleCacheNonCanonicalInput(t *testing.T) { | |||
| 114 | + | f := NewFrame(3, 1) | |||
| 115 | + | rgbWithStaleIndex := Style{Foreground: 7, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1}, Background: -1} | |||
| 116 | + | canonical := rgbWithStaleIndex.Canonical() | |||
| 117 | + | f.Set(0, 0, Cell{Rune: 'x', Style: rgbWithStaleIndex}) | |||
| 118 | + | f.Set(1, 0, Cell{Rune: 'y', Style: canonical}) | |||
| 119 | + | f.Set(2, 0, Cell{Rune: 'z', Style: rgbWithStaleIndex}) | |||
| 120 | + | if err := f.CheckInvariants(); err != nil { | |||
| 121 | + | t.Fatal(err) | |||
| 122 | + | } | |||
| 123 | + | if f.StyleCount() != 2 { | |||
| 124 | + | t.Fatalf("StyleCount = %d, want 2", f.StyleCount()) | |||
| 125 | + | } | |||
| 126 | + | for x := range 3 { | |||
| 127 | + | if got := f.Cell(x, 0).Style; got != canonical { | |||
| 128 | + | t.Fatalf("cell %d style = %+v, want %+v", x, got, canonical) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | // Rewriting with the non-canonical form must keep reference counts exact. | |||
| 132 | + | f.Set(0, 0, Cell{Rune: 'x', Style: rgbWithStaleIndex}) | |||
| 133 | + | if err := f.CheckInvariants(); err != nil { | |||
| 134 | + | t.Fatal(err) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func TestStyleCacheCloneAndReplaceIndependence(t *testing.T) { | |||
| 139 | + | a := Style{Foreground: 1} | |||
| 140 | + | b := Style{Foreground: 2} | |||
| 141 | + | src := NewFrame(2, 1) | |||
| 142 | + | src.Set(0, 0, Cell{Rune: 'a', Style: a}) | |||
| 143 | + | clone := src.Clone() | |||
| 144 | + | if !reflect.DeepEqual(src, clone) { | |||
| 145 | + | t.Fatal("clone is not DeepEqual to its source") | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // Free A in the source and reuse its slot for B; the clone keeps A. | |||
| 149 | + | src.Set(0, 0, BlankCell()) | |||
| 150 | + | src.Set(1, 0, Cell{Rune: 'b', Style: b}) | |||
| 151 | + | clone.Set(1, 0, Cell{Rune: 'a', Style: a}) | |||
| 152 | + | for name, f := range map[string]Frame{"src": src, "clone": clone} { | |||
| 153 | + | if err := f.CheckInvariants(); err != nil { | |||
| 154 | + | t.Fatalf("%s: %v", name, err) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | if got := clone.Cell(0, 0).Style; got != a { | |||
| 158 | + | t.Fatalf("clone cell 0 = %+v, want %+v", got, a) | |||
| 159 | + | } | |||
| 160 | + | if got := clone.Cell(1, 0).Style; got != a { | |||
| 161 | + | t.Fatalf("clone cell 1 = %+v, want %+v", got, a) | |||
| 162 | + | } | |||
| 163 | + | if got := src.Cell(1, 0).Style; got != b { | |||
| 164 | + | t.Fatalf("src cell 1 = %+v, want %+v", got, b) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | replaced := NewFrame(2, 1) | |||
| 168 | + | replaced.Set(0, 0, Cell{Rune: 'q', Style: b}) | |||
| 169 | + | replaced.Replace(clone) | |||
| 170 | + | replaced.Set(0, 0, Cell{Rune: 'q', Style: b}) | |||
| 171 | + | replaced.Set(1, 0, Cell{Rune: 'q', Style: b}) | |||
| 172 | + | if err := replaced.CheckInvariants(); err != nil { | |||
| 173 | + | t.Fatal(err) | |||
| 174 | + | } | |||
| 175 | + | if err := clone.CheckInvariants(); err != nil || clone.Cell(0, 0).Style != a { | |||
| 176 | + | t.Fatalf("clone changed by replaced frame: %v", err) | |||
| 177 | + | } | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestBlankPhysicalRowReleasesStylesAndPayloads(t *testing.T) { | |||
| 181 | + | const width = 7 | |||
| 182 | + | f := NewFrame(width, 3) | |||
| 183 | + | payload, err := NewCellPayload("é", "") | |||
| 184 | + | if err != nil { | |||
| 185 | + | t.Fatal(err) | |||
| 186 | + | } | |||
| 187 | + | for x := range width { | |||
| 188 | + | f.Set(x, 0, Cell{Rune: 'x', Style: Style{Foreground: x % 3}, Payload: payload}) | |||
| 189 | + | f.Set(x, 1, Cell{Rune: 'y', Style: Style{Foreground: x % 3}}) | |||
| 190 | + | } | |||
| 191 | + | f.ScrollUp(0, 2, 1) | |||
| 192 | + | if err := f.CheckInvariants(); err != nil { | |||
| 193 | + | t.Fatal(err) | |||
| 194 | + | } | |||
| 195 | + | for x := range width { | |||
| 196 | + | if got := f.Cell(x, 2); got != (Cell{Rune: ' ', Style: DefaultStyle()}) { | |||
| 197 | + | t.Fatalf("blank row cell %d = %+v", x, got) | |||
| 198 | + | } | |||
| 199 | + | if got := f.Cell(x, 0); got.Rune != 'y' || !got.Payload.Empty() { | |||
| 200 | + | t.Fatalf("shifted row cell %d = %+v", x, got) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | f.ScrollDown(0, 2, 3) | |||
| 204 | + | if err := f.CheckInvariants(); err != nil { | |||
| 205 | + | t.Fatal(err) | |||
| 206 | + | } | |||
| 207 | + | if f.StyleCount() != 1 { | |||
| 208 | + | t.Fatalf("StyleCount = %d, want 1", f.StyleCount()) | |||
| 209 | + | } | |||
| 210 | + | } | |||