Merge branch 'perf/alloc-pass' into perf/ansi-shadow
6 files · +830 -71
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 | + | } | |||
A html/encode.go +234 -0
| @@ -0,0 +1,234 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "reflect" | |
| 7 | + | "strconv" | |
| 8 | + | "unicode/utf8" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // appendUpdateJSON appends the canonical JSON encoding of update to dst. The | |
| 12 | + | // bytes are identical to encoding/json.Marshal(update) (including HTML | |
| 13 | + | // escaping of text), but it writes into one caller-sized buffer instead of | |
| 14 | + | // going through reflection and an intermediate copy. | |
| 15 | + | func appendUpdateJSON(dst []byte, update Update) ([]byte, error) { | |
| 16 | + | dst = append(dst, `{"schemaVersion":`...) | |
| 17 | + | dst = strconv.AppendUint(dst, uint64(update.SchemaVersion), 10) | |
| 18 | + | dst = append(dst, `,"width":`...) | |
| 19 | + | dst = strconv.AppendInt(dst, int64(update.Width), 10) | |
| 20 | + | dst = append(dst, `,"height":`...) | |
| 21 | + | dst = strconv.AppendInt(dst, int64(update.Height), 10) | |
| 22 | + | dst = append(dst, `,"snapshot":`...) | |
| 23 | + | dst = strconv.AppendBool(dst, update.Snapshot) | |
| 24 | + | dst = append(dst, `,"rows":`...) | |
| 25 | + | if update.Rows == nil { | |
| 26 | + | dst = append(dst, "null"...) | |
| 27 | + | } else { | |
| 28 | + | dst = append(dst, '[') | |
| 29 | + | for i := range update.Rows { | |
| 30 | + | if i > 0 { | |
| 31 | + | dst = append(dst, ',') | |
| 32 | + | } | |
| 33 | + | dst = appendRowJSON(dst, &update.Rows[i]) | |
| 34 | + | } | |
| 35 | + | dst = append(dst, ']') | |
| 36 | + | } | |
| 37 | + | dst = append(dst, `,"styles":`...) | |
| 38 | + | if update.Styles == nil { | |
| 39 | + | dst = append(dst, "null"...) | |
| 40 | + | } else { | |
| 41 | + | dst = append(dst, '[') | |
| 42 | + | for i := range update.Styles { | |
| 43 | + | if i > 0 { | |
| 44 | + | dst = append(dst, ',') | |
| 45 | + | } | |
| 46 | + | var err error | |
| 47 | + | if dst, err = appendStyleJSON(dst, &update.Styles[i]); err != nil { | |
| 48 | + | return nil, err | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | dst = append(dst, ']') | |
| 52 | + | } | |
| 53 | + | dst = append(dst, `,"cursor":{"row":`...) | |
| 54 | + | dst = strconv.AppendInt(dst, int64(update.Cursor.Row), 10) | |
| 55 | + | dst = append(dst, `,"column":`...) | |
| 56 | + | dst = strconv.AppendInt(dst, int64(update.Cursor.Column), 10) | |
| 57 | + | dst = append(dst, `,"visible":`...) | |
| 58 | + | dst = strconv.AppendBool(dst, update.Cursor.Visible) | |
| 59 | + | dst = append(dst, `,"style":`...) | |
| 60 | + | dst = strconv.AppendUint(dst, uint64(update.Cursor.Style), 10) | |
| 61 | + | dst = append(dst, `,"styleSet":`...) | |
| 62 | + | dst = strconv.AppendBool(dst, update.Cursor.StyleSet) | |
| 63 | + | dst = append(dst, "}}"...) | |
| 64 | + | return dst, nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func appendRowJSON(dst []byte, row *RowUpdate) []byte { | |
| 68 | + | dst = append(dst, `{"row":`...) | |
| 69 | + | dst = strconv.AppendInt(dst, int64(row.Row), 10) | |
| 70 | + | dst = append(dst, `,"cells":`...) | |
| 71 | + | if row.Cells == nil { | |
| 72 | + | return append(dst, "null}"...) | |
| 73 | + | } | |
| 74 | + | dst = append(dst, '[') | |
| 75 | + | for i := range row.Cells { | |
| 76 | + | cell := &row.Cells[i] | |
| 77 | + | if i > 0 { | |
| 78 | + | dst = append(dst, ',') | |
| 79 | + | } | |
| 80 | + | dst = append(dst, `{"column":`...) | |
| 81 | + | dst = strconv.AppendInt(dst, int64(cell.Column), 10) | |
| 82 | + | dst = append(dst, `,"width":`...) | |
| 83 | + | dst = strconv.AppendInt(dst, int64(cell.Width), 10) | |
| 84 | + | dst = append(dst, `,"text":`...) | |
| 85 | + | dst = appendJSONString(dst, cell.Text) | |
| 86 | + | dst = append(dst, `,"style":`...) | |
| 87 | + | dst = strconv.AppendInt(dst, int64(cell.Style), 10) | |
| 88 | + | dst = append(dst, '}') | |
| 89 | + | } | |
| 90 | + | return append(dst, "]}"...) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func appendStyleJSON(dst []byte, style *Style) ([]byte, error) { | |
| 94 | + | dst = append(dst, '{') | |
| 95 | + | first := true | |
| 96 | + | flag := func(name string, set bool) { | |
| 97 | + | if !set { | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | if !first { | |
| 101 | + | dst = append(dst, ',') | |
| 102 | + | } | |
| 103 | + | first = false | |
| 104 | + | dst = append(dst, '"') | |
| 105 | + | dst = append(dst, name...) | |
| 106 | + | dst = append(dst, `":true`...) | |
| 107 | + | } | |
| 108 | + | flag("bold", style.Bold) | |
| 109 | + | flag("italic", style.Italic) | |
| 110 | + | flag("inverse", style.Inverse) | |
| 111 | + | flag("dim", style.Dim) | |
| 112 | + | flag("blink", style.Blink) | |
| 113 | + | flag("strikethrough", style.Strikethrough) | |
| 114 | + | flag("underline", style.Underline) | |
| 115 | + | if style.UnderlineStyle != 0 { | |
| 116 | + | if !first { | |
| 117 | + | dst = append(dst, ',') | |
| 118 | + | } | |
| 119 | + | first = false | |
| 120 | + | dst = append(dst, `"underlineStyle":`...) | |
| 121 | + | dst = strconv.AppendUint(dst, uint64(style.UnderlineStyle), 10) | |
| 122 | + | } | |
| 123 | + | if !first { | |
| 124 | + | dst = append(dst, ',') | |
| 125 | + | } | |
| 126 | + | var err error | |
| 127 | + | dst = append(dst, `"foreground":`...) | |
| 128 | + | if dst, err = appendColorJSON(dst, style.Foreground); err != nil { | |
| 129 | + | return nil, err | |
| 130 | + | } | |
| 131 | + | dst = append(dst, `,"background":`...) | |
| 132 | + | if dst, err = appendColorJSON(dst, style.Background); err != nil { | |
| 133 | + | return nil, err | |
| 134 | + | } | |
| 135 | + | dst = append(dst, `,"underlineColor":`...) | |
| 136 | + | if dst, err = appendColorJSON(dst, style.UnderlineColor); err != nil { | |
| 137 | + | return nil, err | |
| 138 | + | } | |
| 139 | + | return append(dst, '}'), nil | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // appendColorJSON mirrors Color.MarshalJSON. | |
| 143 | + | func appendColorJSON(dst []byte, color Color) ([]byte, error) { | |
| 144 | + | switch color.Kind { | |
| 145 | + | case ColorDefault: | |
| 146 | + | return append(dst, `{"kind":0}`...), nil | |
| 147 | + | case ColorIndexed: | |
| 148 | + | dst = append(dst, `{"kind":1,"index":`...) | |
| 149 | + | dst = strconv.AppendUint(dst, uint64(color.Index), 10) | |
| 150 | + | return append(dst, '}'), nil | |
| 151 | + | case ColorRGB: | |
| 152 | + | dst = append(dst, `{"kind":2,"rgb":{"r":`...) | |
| 153 | + | dst = strconv.AppendUint(dst, uint64(color.RGB.R), 10) | |
| 154 | + | dst = append(dst, `,"g":`...) | |
| 155 | + | dst = strconv.AppendUint(dst, uint64(color.RGB.G), 10) | |
| 156 | + | dst = append(dst, `,"b":`...) | |
| 157 | + | dst = strconv.AppendUint(dst, uint64(color.RGB.B), 10) | |
| 158 | + | return append(dst, "}}"...), nil | |
| 159 | + | default: | |
| 160 | + | // Same error value encoding/json produced via Color.MarshalJSON. | |
| 161 | + | return nil, &json.MarshalerError{ | |
| 162 | + | Type: reflect.TypeFor[*Color](), | |
| 163 | + | Err: fmt.Errorf("html: invalid color kind %d", color.Kind), | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | const hexDigits = "0123456789abcdef" | |
| 169 | + | ||
| 170 | + | // appendJSONString appends s as a JSON string using encoding/json's default | |
| 171 | + | // escaping: quotes, backslashes, control characters, <, >, & and U+2028/2029 | |
| 172 | + | // are escaped, and each invalid UTF-8 byte becomes U+FFFD written as the | |
| 173 | + | // literal character, byte-identical to encoding/json on this toolchain. | |
| 174 | + | func appendJSONString(dst []byte, s string) []byte { | |
| 175 | + | dst = append(dst, '"') | |
| 176 | + | start := 0 | |
| 177 | + | for i := 0; i < len(s); { | |
| 178 | + | b := s[i] | |
| 179 | + | if b < utf8.RuneSelf { | |
| 180 | + | if b >= 0x20 && b != '"' && b != '\\' && b != '<' && b != '>' && b != '&' { | |
| 181 | + | i++ | |
| 182 | + | continue | |
| 183 | + | } | |
| 184 | + | dst = append(dst, s[start:i]...) | |
| 185 | + | switch b { | |
| 186 | + | case '"', '\\': | |
| 187 | + | dst = append(dst, '\\', b) | |
| 188 | + | case '\b': | |
| 189 | + | dst = append(dst, '\\', 'b') | |
| 190 | + | case '\f': | |
| 191 | + | dst = append(dst, '\\', 'f') | |
| 192 | + | case '\n': | |
| 193 | + | dst = append(dst, '\\', 'n') | |
| 194 | + | case '\r': | |
| 195 | + | dst = append(dst, '\\', 'r') | |
| 196 | + | case '\t': | |
| 197 | + | dst = append(dst, '\\', 't') | |
| 198 | + | default: | |
| 199 | + | dst = append(dst, '\\', 'u', '0', '0', hexDigits[b>>4], hexDigits[b&0xF]) | |
| 200 | + | } | |
| 201 | + | i++ | |
| 202 | + | start = i | |
| 203 | + | continue | |
| 204 | + | } | |
| 205 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |
| 206 | + | if r == utf8.RuneError && size == 1 { | |
| 207 | + | dst = append(dst, s[start:i]...) | |
| 208 | + | dst = append(dst, "\uFFFD"...) | |
| 209 | + | i += size | |
| 210 | + | start = i | |
| 211 | + | continue | |
| 212 | + | } | |
| 213 | + | if r == '\u2028' || r == '\u2029' { | |
| 214 | + | dst = append(dst, s[start:i]...) | |
| 215 | + | dst = append(dst, '\\', 'u', '2', '0', '2', hexDigits[r&0xF]) | |
| 216 | + | i += size | |
| 217 | + | start = i | |
| 218 | + | continue | |
| 219 | + | } | |
| 220 | + | i += size | |
| 221 | + | } | |
| 222 | + | dst = append(dst, s[start:]...) | |
| 223 | + | return append(dst, '"') | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // estimateUpdateJSONSize returns a capacity hint close to the encoded size so | |
| 227 | + | // the output buffer normally grows zero times. | |
| 228 | + | func estimateUpdateJSONSize(update Update) int { | |
| 229 | + | size := 256 + 160*len(update.Styles) | |
| 230 | + | for i := range update.Rows { | |
| 231 | + | size += 32 + 48*len(update.Rows[i].Cells) | |
| 232 | + | } | |
| 233 | + | return size | |
| 234 | + | } | |
| @@ -0,0 +1,234 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | "reflect" | |||
| 7 | + | "strconv" | |||
| 8 | + | "unicode/utf8" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // appendUpdateJSON appends the canonical JSON encoding of update to dst. The | |||
| 12 | + | // bytes are identical to encoding/json.Marshal(update) (including HTML | |||
| 13 | + | // escaping of text), but it writes into one caller-sized buffer instead of | |||
| 14 | + | // going through reflection and an intermediate copy. | |||
| 15 | + | func appendUpdateJSON(dst []byte, update Update) ([]byte, error) { | |||
| 16 | + | dst = append(dst, `{"schemaVersion":`...) | |||
| 17 | + | dst = strconv.AppendUint(dst, uint64(update.SchemaVersion), 10) | |||
| 18 | + | dst = append(dst, `,"width":`...) | |||
| 19 | + | dst = strconv.AppendInt(dst, int64(update.Width), 10) | |||
| 20 | + | dst = append(dst, `,"height":`...) | |||
| 21 | + | dst = strconv.AppendInt(dst, int64(update.Height), 10) | |||
| 22 | + | dst = append(dst, `,"snapshot":`...) | |||
| 23 | + | dst = strconv.AppendBool(dst, update.Snapshot) | |||
| 24 | + | dst = append(dst, `,"rows":`...) | |||
| 25 | + | if update.Rows == nil { | |||
| 26 | + | dst = append(dst, "null"...) | |||
| 27 | + | } else { | |||
| 28 | + | dst = append(dst, '[') | |||
| 29 | + | for i := range update.Rows { | |||
| 30 | + | if i > 0 { | |||
| 31 | + | dst = append(dst, ',') | |||
| 32 | + | } | |||
| 33 | + | dst = appendRowJSON(dst, &update.Rows[i]) | |||
| 34 | + | } | |||
| 35 | + | dst = append(dst, ']') | |||
| 36 | + | } | |||
| 37 | + | dst = append(dst, `,"styles":`...) | |||
| 38 | + | if update.Styles == nil { | |||
| 39 | + | dst = append(dst, "null"...) | |||
| 40 | + | } else { | |||
| 41 | + | dst = append(dst, '[') | |||
| 42 | + | for i := range update.Styles { | |||
| 43 | + | if i > 0 { | |||
| 44 | + | dst = append(dst, ',') | |||
| 45 | + | } | |||
| 46 | + | var err error | |||
| 47 | + | if dst, err = appendStyleJSON(dst, &update.Styles[i]); err != nil { | |||
| 48 | + | return nil, err | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | dst = append(dst, ']') | |||
| 52 | + | } | |||
| 53 | + | dst = append(dst, `,"cursor":{"row":`...) | |||
| 54 | + | dst = strconv.AppendInt(dst, int64(update.Cursor.Row), 10) | |||
| 55 | + | dst = append(dst, `,"column":`...) | |||
| 56 | + | dst = strconv.AppendInt(dst, int64(update.Cursor.Column), 10) | |||
| 57 | + | dst = append(dst, `,"visible":`...) | |||
| 58 | + | dst = strconv.AppendBool(dst, update.Cursor.Visible) | |||
| 59 | + | dst = append(dst, `,"style":`...) | |||
| 60 | + | dst = strconv.AppendUint(dst, uint64(update.Cursor.Style), 10) | |||
| 61 | + | dst = append(dst, `,"styleSet":`...) | |||
| 62 | + | dst = strconv.AppendBool(dst, update.Cursor.StyleSet) | |||
| 63 | + | dst = append(dst, "}}"...) | |||
| 64 | + | return dst, nil | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func appendRowJSON(dst []byte, row *RowUpdate) []byte { | |||
| 68 | + | dst = append(dst, `{"row":`...) | |||
| 69 | + | dst = strconv.AppendInt(dst, int64(row.Row), 10) | |||
| 70 | + | dst = append(dst, `,"cells":`...) | |||
| 71 | + | if row.Cells == nil { | |||
| 72 | + | return append(dst, "null}"...) | |||
| 73 | + | } | |||
| 74 | + | dst = append(dst, '[') | |||
| 75 | + | for i := range row.Cells { | |||
| 76 | + | cell := &row.Cells[i] | |||
| 77 | + | if i > 0 { | |||
| 78 | + | dst = append(dst, ',') | |||
| 79 | + | } | |||
| 80 | + | dst = append(dst, `{"column":`...) | |||
| 81 | + | dst = strconv.AppendInt(dst, int64(cell.Column), 10) | |||
| 82 | + | dst = append(dst, `,"width":`...) | |||
| 83 | + | dst = strconv.AppendInt(dst, int64(cell.Width), 10) | |||
| 84 | + | dst = append(dst, `,"text":`...) | |||
| 85 | + | dst = appendJSONString(dst, cell.Text) | |||
| 86 | + | dst = append(dst, `,"style":`...) | |||
| 87 | + | dst = strconv.AppendInt(dst, int64(cell.Style), 10) | |||
| 88 | + | dst = append(dst, '}') | |||
| 89 | + | } | |||
| 90 | + | return append(dst, "]}"...) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func appendStyleJSON(dst []byte, style *Style) ([]byte, error) { | |||
| 94 | + | dst = append(dst, '{') | |||
| 95 | + | first := true | |||
| 96 | + | flag := func(name string, set bool) { | |||
| 97 | + | if !set { | |||
| 98 | + | return | |||
| 99 | + | } | |||
| 100 | + | if !first { | |||
| 101 | + | dst = append(dst, ',') | |||
| 102 | + | } | |||
| 103 | + | first = false | |||
| 104 | + | dst = append(dst, '"') | |||
| 105 | + | dst = append(dst, name...) | |||
| 106 | + | dst = append(dst, `":true`...) | |||
| 107 | + | } | |||
| 108 | + | flag("bold", style.Bold) | |||
| 109 | + | flag("italic", style.Italic) | |||
| 110 | + | flag("inverse", style.Inverse) | |||
| 111 | + | flag("dim", style.Dim) | |||
| 112 | + | flag("blink", style.Blink) | |||
| 113 | + | flag("strikethrough", style.Strikethrough) | |||
| 114 | + | flag("underline", style.Underline) | |||
| 115 | + | if style.UnderlineStyle != 0 { | |||
| 116 | + | if !first { | |||
| 117 | + | dst = append(dst, ',') | |||
| 118 | + | } | |||
| 119 | + | first = false | |||
| 120 | + | dst = append(dst, `"underlineStyle":`...) | |||
| 121 | + | dst = strconv.AppendUint(dst, uint64(style.UnderlineStyle), 10) | |||
| 122 | + | } | |||
| 123 | + | if !first { | |||
| 124 | + | dst = append(dst, ',') | |||
| 125 | + | } | |||
| 126 | + | var err error | |||
| 127 | + | dst = append(dst, `"foreground":`...) | |||
| 128 | + | if dst, err = appendColorJSON(dst, style.Foreground); err != nil { | |||
| 129 | + | return nil, err | |||
| 130 | + | } | |||
| 131 | + | dst = append(dst, `,"background":`...) | |||
| 132 | + | if dst, err = appendColorJSON(dst, style.Background); err != nil { | |||
| 133 | + | return nil, err | |||
| 134 | + | } | |||
| 135 | + | dst = append(dst, `,"underlineColor":`...) | |||
| 136 | + | if dst, err = appendColorJSON(dst, style.UnderlineColor); err != nil { | |||
| 137 | + | return nil, err | |||
| 138 | + | } | |||
| 139 | + | return append(dst, '}'), nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // appendColorJSON mirrors Color.MarshalJSON. | |||
| 143 | + | func appendColorJSON(dst []byte, color Color) ([]byte, error) { | |||
| 144 | + | switch color.Kind { | |||
| 145 | + | case ColorDefault: | |||
| 146 | + | return append(dst, `{"kind":0}`...), nil | |||
| 147 | + | case ColorIndexed: | |||
| 148 | + | dst = append(dst, `{"kind":1,"index":`...) | |||
| 149 | + | dst = strconv.AppendUint(dst, uint64(color.Index), 10) | |||
| 150 | + | return append(dst, '}'), nil | |||
| 151 | + | case ColorRGB: | |||
| 152 | + | dst = append(dst, `{"kind":2,"rgb":{"r":`...) | |||
| 153 | + | dst = strconv.AppendUint(dst, uint64(color.RGB.R), 10) | |||
| 154 | + | dst = append(dst, `,"g":`...) | |||
| 155 | + | dst = strconv.AppendUint(dst, uint64(color.RGB.G), 10) | |||
| 156 | + | dst = append(dst, `,"b":`...) | |||
| 157 | + | dst = strconv.AppendUint(dst, uint64(color.RGB.B), 10) | |||
| 158 | + | return append(dst, "}}"...), nil | |||
| 159 | + | default: | |||
| 160 | + | // Same error value encoding/json produced via Color.MarshalJSON. | |||
| 161 | + | return nil, &json.MarshalerError{ | |||
| 162 | + | Type: reflect.TypeFor[*Color](), | |||
| 163 | + | Err: fmt.Errorf("html: invalid color kind %d", color.Kind), | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | const hexDigits = "0123456789abcdef" | |||
| 169 | + | ||||
| 170 | + | // appendJSONString appends s as a JSON string using encoding/json's default | |||
| 171 | + | // escaping: quotes, backslashes, control characters, <, >, & and U+2028/2029 | |||
| 172 | + | // are escaped, and each invalid UTF-8 byte becomes U+FFFD written as the | |||
| 173 | + | // literal character, byte-identical to encoding/json on this toolchain. | |||
| 174 | + | func appendJSONString(dst []byte, s string) []byte { | |||
| 175 | + | dst = append(dst, '"') | |||
| 176 | + | start := 0 | |||
| 177 | + | for i := 0; i < len(s); { | |||
| 178 | + | b := s[i] | |||
| 179 | + | if b < utf8.RuneSelf { | |||
| 180 | + | if b >= 0x20 && b != '"' && b != '\\' && b != '<' && b != '>' && b != '&' { | |||
| 181 | + | i++ | |||
| 182 | + | continue | |||
| 183 | + | } | |||
| 184 | + | dst = append(dst, s[start:i]...) | |||
| 185 | + | switch b { | |||
| 186 | + | case '"', '\\': | |||
| 187 | + | dst = append(dst, '\\', b) | |||
| 188 | + | case '\b': | |||
| 189 | + | dst = append(dst, '\\', 'b') | |||
| 190 | + | case '\f': | |||
| 191 | + | dst = append(dst, '\\', 'f') | |||
| 192 | + | case '\n': | |||
| 193 | + | dst = append(dst, '\\', 'n') | |||
| 194 | + | case '\r': | |||
| 195 | + | dst = append(dst, '\\', 'r') | |||
| 196 | + | case '\t': | |||
| 197 | + | dst = append(dst, '\\', 't') | |||
| 198 | + | default: | |||
| 199 | + | dst = append(dst, '\\', 'u', '0', '0', hexDigits[b>>4], hexDigits[b&0xF]) | |||
| 200 | + | } | |||
| 201 | + | i++ | |||
| 202 | + | start = i | |||
| 203 | + | continue | |||
| 204 | + | } | |||
| 205 | + | r, size := utf8.DecodeRuneInString(s[i:]) | |||
| 206 | + | if r == utf8.RuneError && size == 1 { | |||
| 207 | + | dst = append(dst, s[start:i]...) | |||
| 208 | + | dst = append(dst, "\uFFFD"...) | |||
| 209 | + | i += size | |||
| 210 | + | start = i | |||
| 211 | + | continue | |||
| 212 | + | } | |||
| 213 | + | if r == '\u2028' || r == '\u2029' { | |||
| 214 | + | dst = append(dst, s[start:i]...) | |||
| 215 | + | dst = append(dst, '\\', 'u', '2', '0', '2', hexDigits[r&0xF]) | |||
| 216 | + | i += size | |||
| 217 | + | start = i | |||
| 218 | + | continue | |||
| 219 | + | } | |||
| 220 | + | i += size | |||
| 221 | + | } | |||
| 222 | + | dst = append(dst, s[start:]...) | |||
| 223 | + | return append(dst, '"') | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | // estimateUpdateJSONSize returns a capacity hint close to the encoded size so | |||
| 227 | + | // the output buffer normally grows zero times. | |||
| 228 | + | func estimateUpdateJSONSize(update Update) int { | |||
| 229 | + | size := 256 + 160*len(update.Styles) | |||
| 230 | + | for i := range update.Rows { | |||
| 231 | + | size += 32 + 48*len(update.Rows[i].Cells) | |||
| 232 | + | } | |||
| 233 | + | return size | |||
| 234 | + | } | |||
A html/encode_test.go +215 -0
| @@ -0,0 +1,215 @@ | |||
| 1 | + | package html | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "math/rand" | |
| 6 | + | "reflect" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev-vt/core" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestAppendJSONStringMatchesEncodingJSON(t *testing.T) { | |
| 14 | + | cases := []string{ | |
| 15 | + | "", " ", "A", `"`, `\`, "<", ">", "&", "\x00", "\x1f", "\x7f", "\b\f\n\r\t", | |
| 16 | + | "\u2028", "\u2029", "界", "😀", "e\u0301", "a<b>&c\"d\\e\u2028f", | |
| 17 | + | } | |
| 18 | + | for r := rune(0); r < 0x300; r++ { | |
| 19 | + | cases = append(cases, string(r)) | |
| 20 | + | } | |
| 21 | + | for _, text := range cases { | |
| 22 | + | want, err := json.Marshal(text) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Invalid UTF-8 never reaches the encoder (cells are validated), but the | |
| 29 | + | // escaping must still match encoding/json byte for byte. | |
| 30 | + | func TestAppendJSONStringInvalidUTF8MatchesEncodingJSON(t *testing.T) { | |
| 31 | + | for _, text := range []string{"\xff", "a\xc3", "\xed\xa0\x80", "\xe2\x80", "ok\xf0\x9f"} { | |
| 32 | + | want, err := json.Marshal(text) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestAsciiTextIndexesEveryByte(t *testing.T) { | |
| 39 | + | require.Len(t, asciiText, 128) | |
| 40 | + | for i := 0; i < 128; i++ { | |
| 41 | + | require.Equal(t, byte(i), asciiText[i]) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func randomColor(rng *rand.Rand) Color { | |
| 46 | + | switch rng.Intn(3) { | |
| 47 | + | case 0: | |
| 48 | + | return Color{Kind: ColorDefault} | |
| 49 | + | case 1: | |
| 50 | + | return Color{Kind: ColorIndexed, Index: uint8(rng.Intn(256))} | |
| 51 | + | } | |
| 52 | + | return Color{Kind: ColorRGB, RGB: RGB{R: uint8(rng.Intn(256)), G: uint8(rng.Intn(256)), B: uint8(rng.Intn(256))}} | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestAppendUpdateJSONMatchesEncodingJSON(t *testing.T) { | |
| 56 | + | rng := rand.New(rand.NewSource(1)) | |
| 57 | + | texts := []string{" ", "A", "<", "&", `"`, `\`, "界", "😀", "\x01", "\u2028"} | |
| 58 | + | for range 300 { | |
| 59 | + | update := Update{ | |
| 60 | + | SchemaVersion: UpdateSchemaVersion, | |
| 61 | + | Width: rng.Intn(500), | |
| 62 | + | Height: rng.Intn(500), | |
| 63 | + | Snapshot: rng.Intn(2) == 0, | |
| 64 | + | Cursor: Cursor{ | |
| 65 | + | Row: rng.Intn(100), Column: rng.Intn(100), Visible: rng.Intn(2) == 0, | |
| 66 | + | Style: CursorStyle(rng.Intn(7)), StyleSet: rng.Intn(2) == 0, | |
| 67 | + | }, | |
| 68 | + | } | |
| 69 | + | if rng.Intn(5) != 0 { | |
| 70 | + | update.Rows = make([]RowUpdate, rng.Intn(4)) | |
| 71 | + | for i := range update.Rows { | |
| 72 | + | update.Rows[i].Row = rng.Intn(1000) | |
| 73 | + | if rng.Intn(5) != 0 { | |
| 74 | + | update.Rows[i].Cells = make([]CellUpdate, rng.Intn(5)) | |
| 75 | + | for j := range update.Rows[i].Cells { | |
| 76 | + | update.Rows[i].Cells[j] = CellUpdate{Column: rng.Intn(300), Width: 1 + rng.Intn(2), Text: texts[rng.Intn(len(texts))], Style: rng.Intn(10)} | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | if rng.Intn(5) != 0 { | |
| 82 | + | update.Styles = make([]Style, rng.Intn(4)) | |
| 83 | + | for i := range update.Styles { | |
| 84 | + | update.Styles[i] = Style{ | |
| 85 | + | Bold: rng.Intn(2) == 0, Italic: rng.Intn(2) == 0, Inverse: rng.Intn(2) == 0, | |
| 86 | + | Dim: rng.Intn(2) == 0, Blink: rng.Intn(2) == 0, Strikethrough: rng.Intn(2) == 0, | |
| 87 | + | Underline: rng.Intn(2) == 0, UnderlineStyle: core.UnderlineStyle(rng.Intn(6)), | |
| 88 | + | Foreground: randomColor(rng), Background: randomColor(rng), UnderlineColor: randomColor(rng), | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | want, err := json.Marshal(update) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | got, err := appendUpdateJSON(nil, update) | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | require.Equal(t, string(want), string(got)) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestAppendUpdateJSONRejectsInvalidColorKind(t *testing.T) { | |
| 101 | + | update := Update{Styles: []Style{{Foreground: Color{Kind: 9}}}} | |
| 102 | + | _, err := appendUpdateJSON(nil, update) | |
| 103 | + | _, want := json.Marshal(update) | |
| 104 | + | require.Error(t, want) | |
| 105 | + | require.EqualError(t, err, want.Error()) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) { | |
| 109 | + | frame := core.NewFrame(6, 2) | |
| 110 | + | style := core.DefaultStyle() | |
| 111 | + | style.Bold = true | |
| 112 | + | style.HasForegroundRGB, style.ForegroundRGB = true, core.RGB{R: 1, G: 2, B: 3} | |
| 113 | + | style.Background = 200 | |
| 114 | + | frame.Set(0, 0, core.Cell{Rune: '<', Style: style}) | |
| 115 | + | frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) | |
| 116 | + | frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) | |
| 117 | + | frame.Set(3, 0, core.Cell{Rune: '😀', Style: core.DefaultStyle()}) | |
| 118 | + | frame.Set(4, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) | |
| 119 | + | frame.Set(5, 0, core.Cell{Rune: '&', Style: core.DefaultStyle()}) | |
| 120 | + | frame.Set(0, 1, core.Cell{Rune: '"', Style: style}) | |
| 121 | + | ||
| 122 | + | renderer, err := New(Options{}) | |
| 123 | + | require.NoError(t, err) | |
| 124 | + | prepared, err := renderer.Prepare(frame, nil, true, Cursor{Column: 2, Visible: true, Style: 3, StyleSet: true}) | |
| 125 | + | require.NoError(t, err) | |
| 126 | + | want, err := json.Marshal(prepared.tx.update) | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | require.Equal(t, string(want), string(prepared.JSON())) | |
| 129 | + | require.Contains(t, string(prepared.JSON()), "😀") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // fillAll sets every field reachable from v to a distinctive non-zero value | |
| 133 | + | // (negative for signed integers) so a field added to the update types without | |
| 134 | + | // a matching encoder change shows up as a JSON mismatch. | |
| 135 | + | func fillAll(t *testing.T, v reflect.Value, colorKind ColorKind, counter *int) { | |
| 136 | + | t.Helper() | |
| 137 | + | *counter++ | |
| 138 | + | n := *counter | |
| 139 | + | if v.Type() == reflect.TypeOf(Color{}) { | |
| 140 | + | v.FieldByName("Kind").SetUint(uint64(colorKind)) | |
| 141 | + | v.FieldByName("Index").SetUint(uint64(100 + n%100)) | |
| 142 | + | fillAll(t, v.FieldByName("RGB"), colorKind, counter) | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | switch v.Kind() { | |
| 146 | + | case reflect.Bool: | |
| 147 | + | v.SetBool(true) | |
| 148 | + | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: | |
| 149 | + | v.SetInt(-int64(n) - 1) | |
| 150 | + | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: | |
| 151 | + | v.SetUint(uint64(1 + n%120)) | |
| 152 | + | case reflect.String: | |
| 153 | + | v.SetString("t<&>\"\\界\u2028" + string(rune('a'+n%26))) | |
| 154 | + | case reflect.Slice: | |
| 155 | + | v.Set(reflect.MakeSlice(v.Type(), 2, 2)) | |
| 156 | + | for i := range 2 { | |
| 157 | + | fillAll(t, v.Index(i), colorKind, counter) | |
| 158 | + | } | |
| 159 | + | case reflect.Struct: | |
| 160 | + | for i := range v.NumField() { | |
| 161 | + | require.True(t, v.Field(i).CanSet(), "%s.%s must be settable", v.Type(), v.Type().Field(i).Name) | |
| 162 | + | fillAll(t, v.Field(i), colorKind, counter) | |
| 163 | + | } | |
| 164 | + | default: | |
| 165 | + | t.Fatalf("fillAll: unsupported kind %s in %s", v.Kind(), v.Type()) | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func requireNoZeroFields(t *testing.T, v reflect.Value) { | |
| 170 | + | t.Helper() | |
| 171 | + | switch v.Kind() { | |
| 172 | + | case reflect.Struct: | |
| 173 | + | for i := range v.NumField() { | |
| 174 | + | requireNoZeroFields(t, v.Field(i)) | |
| 175 | + | } | |
| 176 | + | case reflect.Slice: | |
| 177 | + | require.NotZero(t, v.Len()) | |
| 178 | + | for i := range v.Len() { | |
| 179 | + | requireNoZeroFields(t, v.Index(i)) | |
| 180 | + | } | |
| 181 | + | default: | |
| 182 | + | require.False(t, v.IsZero(), "zero %s left in filled value", v.Type()) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // TestEncoderCoversEveryUpdateField fails when a field is added to (or removed | |
| 187 | + | // from) the update types without updating appendUpdateJSON and this test. | |
| 188 | + | func TestEncoderCoversEveryUpdateField(t *testing.T) { | |
| 189 | + | for typ, fields := range map[reflect.Type]int{ | |
| 190 | + | reflect.TypeOf(Update{}): 7, | |
| 191 | + | reflect.TypeOf(RowUpdate{}): 2, | |
| 192 | + | reflect.TypeOf(CellUpdate{}): 4, | |
| 193 | + | reflect.TypeOf(Style{}): 11, | |
| 194 | + | reflect.TypeOf(Cursor{}): 5, | |
| 195 | + | reflect.TypeOf(RGB{}): 3, | |
| 196 | + | reflect.TypeOf(Color{}): 3, | |
| 197 | + | } { | |
| 198 | + | require.Equal(t, fields, typ.NumField(), "%s field count changed: update appendUpdateJSON", typ) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | for _, kind := range []ColorKind{ColorDefault, ColorIndexed, ColorRGB} { | |
| 202 | + | var update Update | |
| 203 | + | counter := 0 | |
| 204 | + | fillAll(t, reflect.ValueOf(&update).Elem(), kind, &counter) | |
| 205 | + | update.Cursor.Style = 6 // keep within the documented DECSCUSR range | |
| 206 | + | if kind != ColorDefault { | |
| 207 | + | requireNoZeroFields(t, reflect.ValueOf(update)) | |
| 208 | + | } | |
| 209 | + | want, err := json.Marshal(update) | |
| 210 | + | require.NoError(t, err) | |
| 211 | + | got, err := appendUpdateJSON(nil, update) | |
| 212 | + | require.NoError(t, err) | |
| 213 | + | require.Equal(t, string(want), string(got), "color kind %d", kind) | |
| 214 | + | } | |
| 215 | + | } | |
| @@ -0,0 +1,215 @@ | |||||
| 1 | + | package html | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "math/rand" | |||
| 6 | + | "reflect" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev-vt/core" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestAppendJSONStringMatchesEncodingJSON(t *testing.T) { | |||
| 14 | + | cases := []string{ | |||
| 15 | + | "", " ", "A", `"`, `\`, "<", ">", "&", "\x00", "\x1f", "\x7f", "\b\f\n\r\t", | |||
| 16 | + | "\u2028", "\u2029", "界", "😀", "e\u0301", "a<b>&c\"d\\e\u2028f", | |||
| 17 | + | } | |||
| 18 | + | for r := rune(0); r < 0x300; r++ { | |||
| 19 | + | cases = append(cases, string(r)) | |||
| 20 | + | } | |||
| 21 | + | for _, text := range cases { | |||
| 22 | + | want, err := json.Marshal(text) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // Invalid UTF-8 never reaches the encoder (cells are validated), but the | |||
| 29 | + | // escaping must still match encoding/json byte for byte. | |||
| 30 | + | func TestAppendJSONStringInvalidUTF8MatchesEncodingJSON(t *testing.T) { | |||
| 31 | + | for _, text := range []string{"\xff", "a\xc3", "\xed\xa0\x80", "\xe2\x80", "ok\xf0\x9f"} { | |||
| 32 | + | want, err := json.Marshal(text) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | require.Equal(t, string(want), string(appendJSONString(nil, text)), "%q", text) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestAsciiTextIndexesEveryByte(t *testing.T) { | |||
| 39 | + | require.Len(t, asciiText, 128) | |||
| 40 | + | for i := 0; i < 128; i++ { | |||
| 41 | + | require.Equal(t, byte(i), asciiText[i]) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func randomColor(rng *rand.Rand) Color { | |||
| 46 | + | switch rng.Intn(3) { | |||
| 47 | + | case 0: | |||
| 48 | + | return Color{Kind: ColorDefault} | |||
| 49 | + | case 1: | |||
| 50 | + | return Color{Kind: ColorIndexed, Index: uint8(rng.Intn(256))} | |||
| 51 | + | } | |||
| 52 | + | return Color{Kind: ColorRGB, RGB: RGB{R: uint8(rng.Intn(256)), G: uint8(rng.Intn(256)), B: uint8(rng.Intn(256))}} | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestAppendUpdateJSONMatchesEncodingJSON(t *testing.T) { | |||
| 56 | + | rng := rand.New(rand.NewSource(1)) | |||
| 57 | + | texts := []string{" ", "A", "<", "&", `"`, `\`, "界", "😀", "\x01", "\u2028"} | |||
| 58 | + | for range 300 { | |||
| 59 | + | update := Update{ | |||
| 60 | + | SchemaVersion: UpdateSchemaVersion, | |||
| 61 | + | Width: rng.Intn(500), | |||
| 62 | + | Height: rng.Intn(500), | |||
| 63 | + | Snapshot: rng.Intn(2) == 0, | |||
| 64 | + | Cursor: Cursor{ | |||
| 65 | + | Row: rng.Intn(100), Column: rng.Intn(100), Visible: rng.Intn(2) == 0, | |||
| 66 | + | Style: CursorStyle(rng.Intn(7)), StyleSet: rng.Intn(2) == 0, | |||
| 67 | + | }, | |||
| 68 | + | } | |||
| 69 | + | if rng.Intn(5) != 0 { | |||
| 70 | + | update.Rows = make([]RowUpdate, rng.Intn(4)) | |||
| 71 | + | for i := range update.Rows { | |||
| 72 | + | update.Rows[i].Row = rng.Intn(1000) | |||
| 73 | + | if rng.Intn(5) != 0 { | |||
| 74 | + | update.Rows[i].Cells = make([]CellUpdate, rng.Intn(5)) | |||
| 75 | + | for j := range update.Rows[i].Cells { | |||
| 76 | + | update.Rows[i].Cells[j] = CellUpdate{Column: rng.Intn(300), Width: 1 + rng.Intn(2), Text: texts[rng.Intn(len(texts))], Style: rng.Intn(10)} | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | if rng.Intn(5) != 0 { | |||
| 82 | + | update.Styles = make([]Style, rng.Intn(4)) | |||
| 83 | + | for i := range update.Styles { | |||
| 84 | + | update.Styles[i] = Style{ | |||
| 85 | + | Bold: rng.Intn(2) == 0, Italic: rng.Intn(2) == 0, Inverse: rng.Intn(2) == 0, | |||
| 86 | + | Dim: rng.Intn(2) == 0, Blink: rng.Intn(2) == 0, Strikethrough: rng.Intn(2) == 0, | |||
| 87 | + | Underline: rng.Intn(2) == 0, UnderlineStyle: core.UnderlineStyle(rng.Intn(6)), | |||
| 88 | + | Foreground: randomColor(rng), Background: randomColor(rng), UnderlineColor: randomColor(rng), | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | want, err := json.Marshal(update) | |||
| 93 | + | require.NoError(t, err) | |||
| 94 | + | got, err := appendUpdateJSON(nil, update) | |||
| 95 | + | require.NoError(t, err) | |||
| 96 | + | require.Equal(t, string(want), string(got)) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestAppendUpdateJSONRejectsInvalidColorKind(t *testing.T) { | |||
| 101 | + | update := Update{Styles: []Style{{Foreground: Color{Kind: 9}}}} | |||
| 102 | + | _, err := appendUpdateJSON(nil, update) | |||
| 103 | + | _, want := json.Marshal(update) | |||
| 104 | + | require.Error(t, want) | |||
| 105 | + | require.EqualError(t, err, want.Error()) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestPreparedJSONMatchesEncodingJSONOfUpdate(t *testing.T) { | |||
| 109 | + | frame := core.NewFrame(6, 2) | |||
| 110 | + | style := core.DefaultStyle() | |||
| 111 | + | style.Bold = true | |||
| 112 | + | style.HasForegroundRGB, style.ForegroundRGB = true, core.RGB{R: 1, G: 2, B: 3} | |||
| 113 | + | style.Background = 200 | |||
| 114 | + | frame.Set(0, 0, core.Cell{Rune: '<', Style: style}) | |||
| 115 | + | frame.Set(1, 0, core.Cell{Rune: '界', Style: core.DefaultStyle()}) | |||
| 116 | + | frame.Set(2, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) | |||
| 117 | + | frame.Set(3, 0, core.Cell{Rune: '😀', Style: core.DefaultStyle()}) | |||
| 118 | + | frame.Set(4, 0, core.Cell{Continuation: true, Style: core.DefaultStyle()}) | |||
| 119 | + | frame.Set(5, 0, core.Cell{Rune: '&', Style: core.DefaultStyle()}) | |||
| 120 | + | frame.Set(0, 1, core.Cell{Rune: '"', Style: style}) | |||
| 121 | + | ||||
| 122 | + | renderer, err := New(Options{}) | |||
| 123 | + | require.NoError(t, err) | |||
| 124 | + | prepared, err := renderer.Prepare(frame, nil, true, Cursor{Column: 2, Visible: true, Style: 3, StyleSet: true}) | |||
| 125 | + | require.NoError(t, err) | |||
| 126 | + | want, err := json.Marshal(prepared.tx.update) | |||
| 127 | + | require.NoError(t, err) | |||
| 128 | + | require.Equal(t, string(want), string(prepared.JSON())) | |||
| 129 | + | require.Contains(t, string(prepared.JSON()), "😀") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // fillAll sets every field reachable from v to a distinctive non-zero value | |||
| 133 | + | // (negative for signed integers) so a field added to the update types without | |||
| 134 | + | // a matching encoder change shows up as a JSON mismatch. | |||
| 135 | + | func fillAll(t *testing.T, v reflect.Value, colorKind ColorKind, counter *int) { | |||
| 136 | + | t.Helper() | |||
| 137 | + | *counter++ | |||
| 138 | + | n := *counter | |||
| 139 | + | if v.Type() == reflect.TypeOf(Color{}) { | |||
| 140 | + | v.FieldByName("Kind").SetUint(uint64(colorKind)) | |||
| 141 | + | v.FieldByName("Index").SetUint(uint64(100 + n%100)) | |||
| 142 | + | fillAll(t, v.FieldByName("RGB"), colorKind, counter) | |||
| 143 | + | return | |||
| 144 | + | } | |||
| 145 | + | switch v.Kind() { | |||
| 146 | + | case reflect.Bool: | |||
| 147 | + | v.SetBool(true) | |||
| 148 | + | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: | |||
| 149 | + | v.SetInt(-int64(n) - 1) | |||
| 150 | + | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: | |||
| 151 | + | v.SetUint(uint64(1 + n%120)) | |||
| 152 | + | case reflect.String: | |||
| 153 | + | v.SetString("t<&>\"\\界\u2028" + string(rune('a'+n%26))) | |||
| 154 | + | case reflect.Slice: | |||
| 155 | + | v.Set(reflect.MakeSlice(v.Type(), 2, 2)) | |||
| 156 | + | for i := range 2 { | |||
| 157 | + | fillAll(t, v.Index(i), colorKind, counter) | |||
| 158 | + | } | |||
| 159 | + | case reflect.Struct: | |||
| 160 | + | for i := range v.NumField() { | |||
| 161 | + | require.True(t, v.Field(i).CanSet(), "%s.%s must be settable", v.Type(), v.Type().Field(i).Name) | |||
| 162 | + | fillAll(t, v.Field(i), colorKind, counter) | |||
| 163 | + | } | |||
| 164 | + | default: | |||
| 165 | + | t.Fatalf("fillAll: unsupported kind %s in %s", v.Kind(), v.Type()) | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func requireNoZeroFields(t *testing.T, v reflect.Value) { | |||
| 170 | + | t.Helper() | |||
| 171 | + | switch v.Kind() { | |||
| 172 | + | case reflect.Struct: | |||
| 173 | + | for i := range v.NumField() { | |||
| 174 | + | requireNoZeroFields(t, v.Field(i)) | |||
| 175 | + | } | |||
| 176 | + | case reflect.Slice: | |||
| 177 | + | require.NotZero(t, v.Len()) | |||
| 178 | + | for i := range v.Len() { | |||
| 179 | + | requireNoZeroFields(t, v.Index(i)) | |||
| 180 | + | } | |||
| 181 | + | default: | |||
| 182 | + | require.False(t, v.IsZero(), "zero %s left in filled value", v.Type()) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // TestEncoderCoversEveryUpdateField fails when a field is added to (or removed | |||
| 187 | + | // from) the update types without updating appendUpdateJSON and this test. | |||
| 188 | + | func TestEncoderCoversEveryUpdateField(t *testing.T) { | |||
| 189 | + | for typ, fields := range map[reflect.Type]int{ | |||
| 190 | + | reflect.TypeOf(Update{}): 7, | |||
| 191 | + | reflect.TypeOf(RowUpdate{}): 2, | |||
| 192 | + | reflect.TypeOf(CellUpdate{}): 4, | |||
| 193 | + | reflect.TypeOf(Style{}): 11, | |||
| 194 | + | reflect.TypeOf(Cursor{}): 5, | |||
| 195 | + | reflect.TypeOf(RGB{}): 3, | |||
| 196 | + | reflect.TypeOf(Color{}): 3, | |||
| 197 | + | } { | |||
| 198 | + | require.Equal(t, fields, typ.NumField(), "%s field count changed: update appendUpdateJSON", typ) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | for _, kind := range []ColorKind{ColorDefault, ColorIndexed, ColorRGB} { | |||
| 202 | + | var update Update | |||
| 203 | + | counter := 0 | |||
| 204 | + | fillAll(t, reflect.ValueOf(&update).Elem(), kind, &counter) | |||
| 205 | + | update.Cursor.Style = 6 // keep within the documented DECSCUSR range | |||
| 206 | + | if kind != ColorDefault { | |||
| 207 | + | requireNoZeroFields(t, reflect.ValueOf(update)) | |||
| 208 | + | } | |||
| 209 | + | want, err := json.Marshal(update) | |||
| 210 | + | require.NoError(t, err) | |||
| 211 | + | got, err := appendUpdateJSON(nil, update) | |||
| 212 | + | require.NoError(t, err) | |||
| 213 | + | require.Equal(t, string(want), string(got), "color kind %d", kind) | |||
| 214 | + | } | |||
| 215 | + | } | |||
M html/renderer.go +66 -40
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | package html | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "encoding/json" | |
| 5 | 4 | "fmt" | |
| 6 | 5 | "math" | |
| 7 | 6 | "unicode/utf8" | |
| @@ -65,7 +64,8 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||
| 65 | 64 | if err != nil { | |
| 66 | 65 | return nil, err | |
| 67 | 66 | } | |
| 68 | - | if err := r.validateFrame(frame); err != nil { | |
| 67 | + | scratch, err := r.validateFrame(frame) | |
| 68 | + | if err != nil { | |
| 69 | 69 | return nil, err | |
| 70 | 70 | } | |
| 71 | 71 | cursor = normalizeWrapPendingCursor(cursor, frame.Width) | |
| @@ -74,8 +74,7 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height) | |
| 77 | - | candidate := frame.Clone() | |
| 78 | - | update, err := r.buildUpdate(candidate, snapshot, cursor) | |
| 77 | + | update, err := r.buildUpdate(frame, scratch, snapshot, cursor) | |
| 79 | 78 | if err != nil { | |
| 80 | 79 | return nil, err | |
| 81 | 80 | } | |
| @@ -90,46 +89,49 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||
| 90 | 89 | state: transactionPending, | |
| 91 | 90 | update: update, | |
| 92 | 91 | json: encoded, | |
| 93 | - | frame: candidate, | |
| 92 | + | frame: frame, | |
| 94 | 93 | cursor: cursor, | |
| 95 | 94 | } | |
| 96 | 95 | r.pending = tx | |
| 97 | 96 | return &PreparedDraw{tx: tx}, nil | |
| 98 | 97 | } | |
| 99 | 98 | ||
| 100 | - | func (r *Renderer) validateFrame(frame core.Frame) error { | |
| 99 | + | // validateFrame checks limits and cell invariants. It returns a one-row | |
| 100 | + | // scratch buffer that the caller may reuse for the rest of this Prepare. | |
| 101 | + | func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) { | |
| 101 | 102 | if frame.Width <= 0 || frame.Height <= 0 { | |
| 102 | - | return fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height) | |
| 103 | + | return nil, fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height) | |
| 103 | 104 | } | |
| 104 | 105 | if frame.Width > math.MaxInt/frame.Height { | |
| 105 | - | return fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded) | |
| 106 | + | return nil, fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded) | |
| 106 | 107 | } | |
| 107 | 108 | cells := frame.Width * frame.Height | |
| 108 | 109 | if cells > r.limits.MaxCells { | |
| 109 | - | return fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells) | |
| 110 | + | return nil, fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells) | |
| 110 | 111 | } | |
| 111 | 112 | if frame.Height > r.limits.MaxRowsPerUpdate { | |
| 112 | - | return fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate) | |
| 113 | + | return nil, fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate) | |
| 113 | 114 | } | |
| 114 | 115 | if err := frame.Validate(); err != nil { | |
| 115 | - | return fmt.Errorf("html: validate frame: %w", err) | |
| 116 | + | return nil, fmt.Errorf("html: validate frame: %w", err) | |
| 116 | 117 | } | |
| 118 | + | scratch := make([]core.Cell, frame.Width) | |
| 117 | 119 | for y := range frame.Height { | |
| 118 | - | row := frame.Row(y) | |
| 120 | + | row := readRow(frame, y, scratch) | |
| 119 | 121 | for x := 0; x < frame.Width; x++ { | |
| 120 | 122 | cell := row[x] | |
| 121 | 123 | if err := validateCoreStyle(cell.Style); err != nil { | |
| 122 | - | return fmt.Errorf("html: cell (%d,%d): %w", x, y, err) | |
| 124 | + | return nil, fmt.Errorf("html: cell (%d,%d): %w", x, y, err) | |
| 123 | 125 | } | |
| 124 | 126 | if cell.Continuation { | |
| 125 | 127 | if cell.Rune != 0 { | |
| 126 | - | return fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y) | |
| 128 | + | return nil, fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y) | |
| 127 | 129 | } | |
| 128 | 130 | if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 { | |
| 129 | - | return fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y) | |
| 131 | + | return nil, fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y) | |
| 130 | 132 | } | |
| 131 | 133 | if !cell.Style.Equal(row[x-1].Style) { | |
| 132 | - | return fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y) | |
| 134 | + | return nil, fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y) | |
| 133 | 135 | } | |
| 134 | 136 | continue | |
| 135 | 137 | } | |
| @@ -137,23 +139,23 @@ func (r *Renderer) validateFrame(frame core.Frame) error { | |||
| 137 | 139 | continue | |
| 138 | 140 | } | |
| 139 | 141 | if !utf8.ValidRune(cell.Rune) { | |
| 140 | - | return fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y) | |
| 142 | + | return nil, fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y) | |
| 141 | 143 | } | |
| 142 | 144 | width := core.RuneWidth(cell.Rune) | |
| 143 | 145 | switch width { | |
| 144 | 146 | case 1: | |
| 145 | 147 | case 2: | |
| 146 | 148 | if x+1 >= frame.Width || !row[x+1].Continuation { | |
| 147 | - | return fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y) | |
| 149 | + | return nil, fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y) | |
| 148 | 150 | } | |
| 149 | 151 | case 0: | |
| 150 | - | return fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y) | |
| 152 | + | return nil, fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y) | |
| 151 | 153 | default: | |
| 152 | - | return fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width) | |
| 154 | + | return nil, fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width) | |
| 153 | 155 | } | |
| 154 | 156 | } | |
| 155 | 157 | } | |
| 156 | - | return nil | |
| 158 | + | return scratch, nil | |
| 157 | 159 | } | |
| 158 | 160 | ||
| 159 | 161 | // materializeCellSource reads a source into an owned frame. A core.Frame | |
| @@ -229,7 +231,31 @@ func damageRequiresSnapshot(damage []core.Damage, width, height int) bool { | |||
| 229 | 231 | return false | |
| 230 | 232 | } | |
| 231 | 233 | ||
| 232 | - | func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) (Update, error) { | |
| 234 | + | // readRow copies logical row y into buf and returns the filled prefix. It is | |
| 235 | + | // the allocation-free equivalent of core.Frame.Row. | |
| 236 | + | func readRow(frame core.Frame, y int, buf []core.Cell) []core.Cell { | |
| 237 | + | row := buf[:frame.Width] | |
| 238 | + | for x := range row { | |
| 239 | + | row[x] = frame.Cell(x, y) | |
| 240 | + | } | |
| 241 | + | return row | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // rowEqualsFrame reports whether row matches logical row y of frame, stopping | |
| 245 | + | // at the first difference without materializing the frame row. | |
| 246 | + | func rowEqualsFrame(row []core.Cell, frame core.Frame, y int) bool { | |
| 247 | + | if len(row) != frame.Width { | |
| 248 | + | return false | |
| 249 | + | } | |
| 250 | + | for x := range row { | |
| 251 | + | if !row[x].Equal(frame.Cell(x, y)) { | |
| 252 | + | return false | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | return true | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot bool, cursor Cursor) (Update, error) { | |
| 233 | 259 | update := Update{ | |
| 234 | 260 | SchemaVersion: UpdateSchemaVersion, | |
| 235 | 261 | Width: frame.Width, | |
| @@ -241,26 +267,27 @@ func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) ( | |||
| 241 | 267 | } | |
| 242 | 268 | styleIDs := make(map[Style]int) | |
| 243 | 269 | for y := range frame.Height { | |
| 244 | - | if !snapshot && rowsEqual(frame.Row(y), r.committed.Row(y)) { | |
| 270 | + | row := readRow(frame, y, scratch) | |
| 271 | + | if !snapshot && rowEqualsFrame(row, r.committed, y) { | |
| 245 | 272 | continue | |
| 246 | 273 | } | |
| 247 | 274 | if len(update.Rows) >= r.limits.MaxRowsPerUpdate { | |
| 248 | 275 | return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate) | |
| 249 | 276 | } | |
| 250 | - | row, err := encodeRow(y, frame.Row(y), &update.Styles, styleIDs, r.limits.MaxStyles) | |
| 277 | + | encoded, err := encodeRow(y, row, &update.Styles, styleIDs, r.limits.MaxStyles) | |
| 251 | 278 | if err != nil { | |
| 252 | 279 | return Update{}, err | |
| 253 | 280 | } | |
| 254 | - | update.Rows = append(update.Rows, row) | |
| 281 | + | update.Rows = append(update.Rows, encoded) | |
| 255 | 282 | } | |
| 256 | 283 | return update, nil | |
| 257 | 284 | } | |
| 258 | 285 | ||
| 259 | - | // encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on | |
| 286 | + | // encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on | |
| 260 | 287 | // the exact encoded size. The previous conservative estimate could reject | |
| 261 | 288 | // compact updates well below the configured limit. | |
| 262 | 289 | func encodeBoundedUpdate(update Update, limit int) ([]byte, error) { | |
| 263 | - | encoded, err := json.Marshal(update) | |
| 290 | + | encoded, err := appendUpdateJSON(make([]byte, 0, estimateUpdateJSONSize(update)), update) | |
| 264 | 291 | if err != nil { | |
| 265 | 292 | return nil, fmt.Errorf("html: encode update: %w", err) | |
| 266 | 293 | } | |
| @@ -270,17 +297,11 @@ func encodeBoundedUpdate(update Update, limit int) ([]byte, error) { | |||
| 270 | 297 | return encoded, nil | |
| 271 | 298 | } | |
| 272 | 299 | ||
| 273 | - | func rowsEqual(left, right []core.Cell) bool { | |
| 274 | - | if len(left) != len(right) { | |
| 275 | - | return false | |
| 276 | - | } | |
| 277 | - | for i := range left { | |
| 278 | - | if !left[i].Equal(right[i]) { | |
| 279 | - | return false | |
| 280 | - | } | |
| 281 | - | } | |
| 282 | - | return true | |
| 283 | - | } | |
| 300 | + | // asciiText holds every ASCII byte so single-byte cell text can be sliced out | |
| 301 | + | // of it instead of allocating a new string per cell. | |
| 302 | + | const asciiText = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f" + | |
| 303 | + | "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f" + | |
| 304 | + | " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\x7f" | |
| 284 | 305 | ||
| 285 | 306 | func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) { | |
| 286 | 307 | row := RowUpdate{Row: y, Cells: make([]CellUpdate, 0, len(cells))} | |
| @@ -300,10 +321,15 @@ func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int | |||
| 300 | 321 | *styles = append(*styles, style) | |
| 301 | 322 | } | |
| 302 | 323 | width := core.RuneWidth(cell.Rune) | |
| 303 | - | text := string(cell.Rune) | |
| 304 | - | if cell.Rune == 0 { | |
| 324 | + | var text string | |
| 325 | + | switch { | |
| 326 | + | case cell.Rune == 0: | |
| 305 | 327 | width = 1 | |
| 306 | 328 | text = " " | |
| 329 | + | case cell.Rune < utf8.RuneSelf: | |
| 330 | + | text = asciiText[cell.Rune : cell.Rune+1] // no allocation | |
| 331 | + | default: | |
| 332 | + | text = string(cell.Rune) | |
| 307 | 333 | } | |
| 308 | 334 | row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID}) | |
| 309 | 335 | } | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package html | 1 | package html | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "encoding/json" | |||
| 5 | "fmt" | 4 | "fmt" | ||
| 6 | "math" | 5 | "math" | ||
| 7 | "unicode/utf8" | 6 | "unicode/utf8" | ||
| @@ -65,7 +64,8 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||||
| 65 | if err != nil { | 64 | if err != nil { | ||
| 66 | return nil, err | 65 | return nil, err | ||
| 67 | } | 66 | } | ||
| 68 | - | if err := r.validateFrame(frame); err != nil { | 67 | + | scratch, err := r.validateFrame(frame) |
| 68 | + | if err != nil { | |||
| 69 | return nil, err | 69 | return nil, err | ||
| 70 | } | 70 | } | ||
| 71 | cursor = normalizeWrapPendingCursor(cursor, frame.Width) | 71 | cursor = normalizeWrapPendingCursor(cursor, frame.Width) | ||
| @@ -74,8 +74,7 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height) | 76 | snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height) | ||
| 77 | - | candidate := frame.Clone() | 77 | + | update, err := r.buildUpdate(frame, scratch, snapshot, cursor) |
| 78 | - | update, err := r.buildUpdate(candidate, snapshot, cursor) | |||
| 79 | if err != nil { | 78 | if err != nil { | ||
| 80 | return nil, err | 79 | return nil, err | ||
| 81 | } | 80 | } | ||
| @@ -90,46 +89,49 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, | |||||
| 90 | state: transactionPending, | 89 | state: transactionPending, | ||
| 91 | update: update, | 90 | update: update, | ||
| 92 | json: encoded, | 91 | json: encoded, | ||
| 93 | - | frame: candidate, | 92 | + | frame: frame, |
| 94 | cursor: cursor, | 93 | cursor: cursor, | ||
| 95 | } | 94 | } | ||
| 96 | r.pending = tx | 95 | r.pending = tx | ||
| 97 | return &PreparedDraw{tx: tx}, nil | 96 | return &PreparedDraw{tx: tx}, nil | ||
| 98 | } | 97 | } | ||
| 99 | 98 | ||||
| 100 | - | func (r *Renderer) validateFrame(frame core.Frame) error { | 99 | + | // validateFrame checks limits and cell invariants. It returns a one-row |
| 100 | + | // scratch buffer that the caller may reuse for the rest of this Prepare. | |||
| 101 | + | func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) { | |||
| 101 | if frame.Width <= 0 || frame.Height <= 0 { | 102 | if frame.Width <= 0 || frame.Height <= 0 { | ||
| 102 | - | return fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height) | 103 | + | return nil, fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height) |
| 103 | } | 104 | } | ||
| 104 | if frame.Width > math.MaxInt/frame.Height { | 105 | if frame.Width > math.MaxInt/frame.Height { | ||
| 105 | - | return fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded) | 106 | + | return nil, fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded) |
| 106 | } | 107 | } | ||
| 107 | cells := frame.Width * frame.Height | 108 | cells := frame.Width * frame.Height | ||
| 108 | if cells > r.limits.MaxCells { | 109 | if cells > r.limits.MaxCells { | ||
| 109 | - | return fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells) | 110 | + | return nil, fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells) |
| 110 | } | 111 | } | ||
| 111 | if frame.Height > r.limits.MaxRowsPerUpdate { | 112 | if frame.Height > r.limits.MaxRowsPerUpdate { | ||
| 112 | - | return fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate) | 113 | + | return nil, fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate) |
| 113 | } | 114 | } | ||
| 114 | if err := frame.Validate(); err != nil { | 115 | if err := frame.Validate(); err != nil { | ||
| 115 | - | return fmt.Errorf("html: validate frame: %w", err) | 116 | + | return nil, fmt.Errorf("html: validate frame: %w", err) |
| 116 | } | 117 | } | ||
| 118 | + | scratch := make([]core.Cell, frame.Width) | |||
| 117 | for y := range frame.Height { | 119 | for y := range frame.Height { | ||
| 118 | - | row := frame.Row(y) | 120 | + | row := readRow(frame, y, scratch) |
| 119 | for x := 0; x < frame.Width; x++ { | 121 | for x := 0; x < frame.Width; x++ { | ||
| 120 | cell := row[x] | 122 | cell := row[x] | ||
| 121 | if err := validateCoreStyle(cell.Style); err != nil { | 123 | if err := validateCoreStyle(cell.Style); err != nil { | ||
| 122 | - | return fmt.Errorf("html: cell (%d,%d): %w", x, y, err) | 124 | + | return nil, fmt.Errorf("html: cell (%d,%d): %w", x, y, err) |
| 123 | } | 125 | } | ||
| 124 | if cell.Continuation { | 126 | if cell.Continuation { | ||
| 125 | if cell.Rune != 0 { | 127 | if cell.Rune != 0 { | ||
| 126 | - | return fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y) | 128 | + | return nil, fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y) |
| 127 | } | 129 | } | ||
| 128 | if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 { | 130 | if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 { | ||
| 129 | - | return fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y) | 131 | + | return nil, fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y) |
| 130 | } | 132 | } | ||
| 131 | if !cell.Style.Equal(row[x-1].Style) { | 133 | if !cell.Style.Equal(row[x-1].Style) { | ||
| 132 | - | return fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y) | 134 | + | return nil, fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y) |
| 133 | } | 135 | } | ||
| 134 | continue | 136 | continue | ||
| 135 | } | 137 | } | ||
| @@ -137,23 +139,23 @@ func (r *Renderer) validateFrame(frame core.Frame) error { | |||||
| 137 | continue | 139 | continue | ||
| 138 | } | 140 | } | ||
| 139 | if !utf8.ValidRune(cell.Rune) { | 141 | if !utf8.ValidRune(cell.Rune) { | ||
| 140 | - | return fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y) | 142 | + | return nil, fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y) |
| 141 | } | 143 | } | ||
| 142 | width := core.RuneWidth(cell.Rune) | 144 | width := core.RuneWidth(cell.Rune) | ||
| 143 | switch width { | 145 | switch width { | ||
| 144 | case 1: | 146 | case 1: | ||
| 145 | case 2: | 147 | case 2: | ||
| 146 | if x+1 >= frame.Width || !row[x+1].Continuation { | 148 | if x+1 >= frame.Width || !row[x+1].Continuation { | ||
| 147 | - | return fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y) | 149 | + | return nil, fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y) |
| 148 | } | 150 | } | ||
| 149 | case 0: | 151 | case 0: | ||
| 150 | - | return fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y) | 152 | + | return nil, fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y) |
| 151 | default: | 153 | default: | ||
| 152 | - | return fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width) | 154 | + | return nil, fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width) |
| 153 | } | 155 | } | ||
| 154 | } | 156 | } | ||
| 155 | } | 157 | } | ||
| 156 | - | return nil | 158 | + | return scratch, nil |
| 157 | } | 159 | } | ||
| 158 | 160 | ||||
| 159 | // materializeCellSource reads a source into an owned frame. A core.Frame | 161 | // materializeCellSource reads a source into an owned frame. A core.Frame | ||
| @@ -229,7 +231,31 @@ func damageRequiresSnapshot(damage []core.Damage, width, height int) bool { | |||||
| 229 | return false | 231 | return false | ||
| 230 | } | 232 | } | ||
| 231 | 233 | ||||
| 232 | - | func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) (Update, error) { | 234 | + | // readRow copies logical row y into buf and returns the filled prefix. It is |
| 235 | + | // the allocation-free equivalent of core.Frame.Row. | |||
| 236 | + | func readRow(frame core.Frame, y int, buf []core.Cell) []core.Cell { | |||
| 237 | + | row := buf[:frame.Width] | |||
| 238 | + | for x := range row { | |||
| 239 | + | row[x] = frame.Cell(x, y) | |||
| 240 | + | } | |||
| 241 | + | return row | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // rowEqualsFrame reports whether row matches logical row y of frame, stopping | |||
| 245 | + | // at the first difference without materializing the frame row. | |||
| 246 | + | func rowEqualsFrame(row []core.Cell, frame core.Frame, y int) bool { | |||
| 247 | + | if len(row) != frame.Width { | |||
| 248 | + | return false | |||
| 249 | + | } | |||
| 250 | + | for x := range row { | |||
| 251 | + | if !row[x].Equal(frame.Cell(x, y)) { | |||
| 252 | + | return false | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | return true | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot bool, cursor Cursor) (Update, error) { | |||
| 233 | update := Update{ | 259 | update := Update{ | ||
| 234 | SchemaVersion: UpdateSchemaVersion, | 260 | SchemaVersion: UpdateSchemaVersion, | ||
| 235 | Width: frame.Width, | 261 | Width: frame.Width, | ||
| @@ -241,26 +267,27 @@ func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) ( | |||||
| 241 | } | 267 | } | ||
| 242 | styleIDs := make(map[Style]int) | 268 | styleIDs := make(map[Style]int) | ||
| 243 | for y := range frame.Height { | 269 | for y := range frame.Height { | ||
| 244 | - | if !snapshot && rowsEqual(frame.Row(y), r.committed.Row(y)) { | 270 | + | row := readRow(frame, y, scratch) |
| 271 | + | if !snapshot && rowEqualsFrame(row, r.committed, y) { | |||
| 245 | continue | 272 | continue | ||
| 246 | } | 273 | } | ||
| 247 | if len(update.Rows) >= r.limits.MaxRowsPerUpdate { | 274 | if len(update.Rows) >= r.limits.MaxRowsPerUpdate { | ||
| 248 | return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate) | 275 | return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate) | ||
| 249 | } | 276 | } | ||
| 250 | - | row, err := encodeRow(y, frame.Row(y), &update.Styles, styleIDs, r.limits.MaxStyles) | 277 | + | encoded, err := encodeRow(y, row, &update.Styles, styleIDs, r.limits.MaxStyles) |
| 251 | if err != nil { | 278 | if err != nil { | ||
| 252 | return Update{}, err | 279 | return Update{}, err | ||
| 253 | } | 280 | } | ||
| 254 | - | update.Rows = append(update.Rows, row) | 281 | + | update.Rows = append(update.Rows, encoded) |
| 255 | } | 282 | } | ||
| 256 | return update, nil | 283 | return update, nil | ||
| 257 | } | 284 | } | ||
| 258 | 285 | ||||
| 259 | - | // encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on | 286 | + | // encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on |
| 260 | // the exact encoded size. The previous conservative estimate could reject | 287 | // the exact encoded size. The previous conservative estimate could reject | ||
| 261 | // compact updates well below the configured limit. | 288 | // compact updates well below the configured limit. | ||
| 262 | func encodeBoundedUpdate(update Update, limit int) ([]byte, error) { | 289 | func encodeBoundedUpdate(update Update, limit int) ([]byte, error) { | ||
| 263 | - | encoded, err := json.Marshal(update) | 290 | + | encoded, err := appendUpdateJSON(make([]byte, 0, estimateUpdateJSONSize(update)), update) |
| 264 | if err != nil { | 291 | if err != nil { | ||
| 265 | return nil, fmt.Errorf("html: encode update: %w", err) | 292 | return nil, fmt.Errorf("html: encode update: %w", err) | ||
| 266 | } | 293 | } | ||
| @@ -270,17 +297,11 @@ func encodeBoundedUpdate(update Update, limit int) ([]byte, error) { | |||||
| 270 | return encoded, nil | 297 | return encoded, nil | ||
| 271 | } | 298 | } | ||
| 272 | 299 | ||||
| 273 | - | func rowsEqual(left, right []core.Cell) bool { | 300 | + | // asciiText holds every ASCII byte so single-byte cell text can be sliced out |
| 274 | - | if len(left) != len(right) { | 301 | + | // of it instead of allocating a new string per cell. |
| 275 | - | return false | 302 | + | const asciiText = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f" + |
| 276 | - | } | 303 | + | "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f" + |
| 277 | - | for i := range left { | 304 | + | " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\x7f" |
| 278 | - | if !left[i].Equal(right[i]) { | |||
| 279 | - | return false | |||
| 280 | - | } | |||
| 281 | - | } | |||
| 282 | - | return true | |||
| 283 | - | } | |||
| 284 | 305 | ||||
| 285 | func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) { | 306 | func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) { | ||
| 286 | row := RowUpdate{Row: y, Cells: make([]CellUpdate, 0, len(cells))} | 307 | row := RowUpdate{Row: y, Cells: make([]CellUpdate, 0, len(cells))} | ||
| @@ -300,10 +321,15 @@ func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int | |||||
| 300 | *styles = append(*styles, style) | 321 | *styles = append(*styles, style) | ||
| 301 | } | 322 | } | ||
| 302 | width := core.RuneWidth(cell.Rune) | 323 | width := core.RuneWidth(cell.Rune) | ||
| 303 | - | text := string(cell.Rune) | 324 | + | var text string |
| 304 | - | if cell.Rune == 0 { | 325 | + | switch { |
| 326 | + | case cell.Rune == 0: | |||
| 305 | width = 1 | 327 | width = 1 | ||
| 306 | text = " " | 328 | text = " " | ||
| 329 | + | case cell.Rune < utf8.RuneSelf: | |||
| 330 | + | text = asciiText[cell.Rune : cell.Rune+1] // no allocation | |||
| 331 | + | default: | |||
| 332 | + | text = string(cell.Rune) | |||
| 307 | } | 333 | } | ||
| 308 | row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID}) | 334 | row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID}) | ||
| 309 | } | 335 | } | ||
M html/renderer_test.go +81 -0
| @@ -228,3 +228,84 @@ func TestRendererInvalidatesCopiedDrawOnReset(t *testing.T) { | |||
| 228 | 228 | require.True(t, next.Update().Snapshot) | |
| 229 | 229 | require.NoError(t, next.Commit()) | |
| 230 | 230 | } | |
| 231 | + | ||
| 232 | + | func TestPreparedDrawIsIndependentOfLaterPrepares(t *testing.T) { | |
| 233 | + | frame := core.NewFrame(4, 3) | |
| 234 | + | frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) | |
| 235 | + | renderer, err := New(Options{}) | |
| 236 | + | require.NoError(t, err) | |
| 237 | + | ||
| 238 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 239 | + | require.NoError(t, err) | |
| 240 | + | firstUpdate, firstJSON := first.Update(), first.JSON() | |
| 241 | + | require.NoError(t, first.Commit()) | |
| 242 | + | ||
| 243 | + | // The caller mutates its frame in place; neither the earlier draw nor the | |
| 244 | + | // committed shadow may observe it. | |
| 245 | + | frame.Set(1, 1, core.Cell{Rune: 'B', Style: core.DefaultStyle()}) | |
| 246 | + | second, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 247 | + | require.NoError(t, err) | |
| 248 | + | require.Equal(t, []int{1}, rowIndexes(second.Update())) | |
| 249 | + | secondUpdate, secondJSON := second.Update(), second.JSON() | |
| 250 | + | require.NoError(t, second.Commit()) | |
| 251 | + | ||
| 252 | + | frame.Set(2, 2, core.Cell{Rune: 'C', Style: core.DefaultStyle()}) | |
| 253 | + | third, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 254 | + | require.NoError(t, err) | |
| 255 | + | require.Equal(t, []int{2}, rowIndexes(third.Update())) | |
| 256 | + | require.NoError(t, third.Commit()) | |
| 257 | + | ||
| 258 | + | require.Equal(t, firstUpdate, first.Update()) | |
| 259 | + | require.Equal(t, firstJSON, first.JSON()) | |
| 260 | + | require.Equal(t, secondUpdate, second.Update()) | |
| 261 | + | require.Equal(t, secondJSON, second.JSON()) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestAbortThenPrepareKeepsCommittedShadow(t *testing.T) { | |
| 265 | + | frame := core.NewFrame(3, 2) | |
| 266 | + | renderer, err := New(Options{}) | |
| 267 | + | require.NoError(t, err) | |
| 268 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 269 | + | require.NoError(t, err) | |
| 270 | + | require.NoError(t, first.Commit()) | |
| 271 | + | ||
| 272 | + | frame.Set(0, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()}) | |
| 273 | + | aborted, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 274 | + | require.NoError(t, err) | |
| 275 | + | abortedJSON := aborted.JSON() | |
| 276 | + | require.NoError(t, aborted.Abort()) | |
| 277 | + | ||
| 278 | + | retry, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 279 | + | require.NoError(t, err) | |
| 280 | + | require.False(t, retry.Update().Snapshot) | |
| 281 | + | require.Equal(t, []int{1}, rowIndexes(retry.Update())) | |
| 282 | + | require.Equal(t, abortedJSON, retry.JSON()) | |
| 283 | + | require.NoError(t, retry.Commit()) | |
| 284 | + | ||
| 285 | + | steady, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 286 | + | require.NoError(t, err) | |
| 287 | + | require.Empty(t, steady.Update().Rows) | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func TestPrepareDoesNotAliasCallerFrame(t *testing.T) { | |
| 291 | + | frame := core.NewFrame(2, 1) | |
| 292 | + | renderer, err := New(Options{}) | |
| 293 | + | require.NoError(t, err) | |
| 294 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 295 | + | require.NoError(t, err) | |
| 296 | + | require.NoError(t, prepared.Commit()) | |
| 297 | + | ||
| 298 | + | // Mutating the caller frame after Commit must be seen as a change. | |
| 299 | + | frame.Set(0, 0, core.Cell{Rune: 'Q', Style: core.DefaultStyle()}) | |
| 300 | + | next, err := renderer.Prepare(frame, nil, false, Cursor{}) | |
| 301 | + | require.NoError(t, err) | |
| 302 | + | require.Equal(t, []int{0}, rowIndexes(next.Update())) | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func rowIndexes(update Update) []int { | |
| 306 | + | rows := make([]int, 0, len(update.Rows)) | |
| 307 | + | for _, row := range update.Rows { | |
| 308 | + | rows = append(rows, row.Row) | |
| 309 | + | } | |
| 310 | + | return rows | |
| 311 | + | } | |
| @@ -228,3 +228,84 @@ func TestRendererInvalidatesCopiedDrawOnReset(t *testing.T) { | |||||
| 228 | require.True(t, next.Update().Snapshot) | 228 | require.True(t, next.Update().Snapshot) | ||
| 229 | require.NoError(t, next.Commit()) | 229 | require.NoError(t, next.Commit()) | ||
| 230 | } | 230 | } | ||
| 231 | + | ||||
| 232 | + | func TestPreparedDrawIsIndependentOfLaterPrepares(t *testing.T) { | |||
| 233 | + | frame := core.NewFrame(4, 3) | |||
| 234 | + | frame.Set(0, 0, core.Cell{Rune: 'A', Style: core.DefaultStyle()}) | |||
| 235 | + | renderer, err := New(Options{}) | |||
| 236 | + | require.NoError(t, err) | |||
| 237 | + | ||||
| 238 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 239 | + | require.NoError(t, err) | |||
| 240 | + | firstUpdate, firstJSON := first.Update(), first.JSON() | |||
| 241 | + | require.NoError(t, first.Commit()) | |||
| 242 | + | ||||
| 243 | + | // The caller mutates its frame in place; neither the earlier draw nor the | |||
| 244 | + | // committed shadow may observe it. | |||
| 245 | + | frame.Set(1, 1, core.Cell{Rune: 'B', Style: core.DefaultStyle()}) | |||
| 246 | + | second, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 247 | + | require.NoError(t, err) | |||
| 248 | + | require.Equal(t, []int{1}, rowIndexes(second.Update())) | |||
| 249 | + | secondUpdate, secondJSON := second.Update(), second.JSON() | |||
| 250 | + | require.NoError(t, second.Commit()) | |||
| 251 | + | ||||
| 252 | + | frame.Set(2, 2, core.Cell{Rune: 'C', Style: core.DefaultStyle()}) | |||
| 253 | + | third, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 254 | + | require.NoError(t, err) | |||
| 255 | + | require.Equal(t, []int{2}, rowIndexes(third.Update())) | |||
| 256 | + | require.NoError(t, third.Commit()) | |||
| 257 | + | ||||
| 258 | + | require.Equal(t, firstUpdate, first.Update()) | |||
| 259 | + | require.Equal(t, firstJSON, first.JSON()) | |||
| 260 | + | require.Equal(t, secondUpdate, second.Update()) | |||
| 261 | + | require.Equal(t, secondJSON, second.JSON()) | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestAbortThenPrepareKeepsCommittedShadow(t *testing.T) { | |||
| 265 | + | frame := core.NewFrame(3, 2) | |||
| 266 | + | renderer, err := New(Options{}) | |||
| 267 | + | require.NoError(t, err) | |||
| 268 | + | first, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 269 | + | require.NoError(t, err) | |||
| 270 | + | require.NoError(t, first.Commit()) | |||
| 271 | + | ||||
| 272 | + | frame.Set(0, 1, core.Cell{Rune: 'X', Style: core.DefaultStyle()}) | |||
| 273 | + | aborted, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 274 | + | require.NoError(t, err) | |||
| 275 | + | abortedJSON := aborted.JSON() | |||
| 276 | + | require.NoError(t, aborted.Abort()) | |||
| 277 | + | ||||
| 278 | + | retry, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 279 | + | require.NoError(t, err) | |||
| 280 | + | require.False(t, retry.Update().Snapshot) | |||
| 281 | + | require.Equal(t, []int{1}, rowIndexes(retry.Update())) | |||
| 282 | + | require.Equal(t, abortedJSON, retry.JSON()) | |||
| 283 | + | require.NoError(t, retry.Commit()) | |||
| 284 | + | ||||
| 285 | + | steady, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 286 | + | require.NoError(t, err) | |||
| 287 | + | require.Empty(t, steady.Update().Rows) | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func TestPrepareDoesNotAliasCallerFrame(t *testing.T) { | |||
| 291 | + | frame := core.NewFrame(2, 1) | |||
| 292 | + | renderer, err := New(Options{}) | |||
| 293 | + | require.NoError(t, err) | |||
| 294 | + | prepared, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 295 | + | require.NoError(t, err) | |||
| 296 | + | require.NoError(t, prepared.Commit()) | |||
| 297 | + | ||||
| 298 | + | // Mutating the caller frame after Commit must be seen as a change. | |||
| 299 | + | frame.Set(0, 0, core.Cell{Rune: 'Q', Style: core.DefaultStyle()}) | |||
| 300 | + | next, err := renderer.Prepare(frame, nil, false, Cursor{}) | |||
| 301 | + | require.NoError(t, err) | |||
| 302 | + | require.Equal(t, []int{0}, rowIndexes(next.Update())) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func rowIndexes(update Update) []int { | |||
| 306 | + | rows := make([]int, 0, len(update.Rows)) | |||
| 307 | + | for _, row := range update.Rows { | |||
| 308 | + | rows = append(rows, row.Row) | |||
| 309 | + | } | |||
| 310 | + | return rows | |||
| 311 | + | } | |||