detached caaf0b1

Merge branch 'perf/alloc-pass' into perf/ansi-shadow

commit
caaf0b1d7c7797e1e259b04b2134d1d6c38bc908
author
brice · · 2026-10-02 09:45 UTC
merge of
42b4cc2 + b58d93f · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +830 -71
M core/frame.go +75 -30
@@ -42,12 +42,20 @@ type styleSlot struct {
4242 }
4343
4444 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
5159 payloads []payloadSlot
5260 payloadIndex map[CellPayload]uint32
5361 freePayloads []uint32
@@ -101,6 +109,9 @@ func (f Frame) Clone() Frame {
101109 styleIndex: maps.Clone(f.page.styleIndex),
102110 freeStyles: append([]uint32(nil), f.page.freeStyles...),
103111 styleCount: f.page.styleCount,
112+ styleCache: f.page.styleCache,
113+ styleCacheID: f.page.styleCacheID,
114+ styleCacheOK: f.page.styleCacheOK,
104115 payloads: append([]payloadSlot(nil), f.page.payloads...),
105116 payloadIndex: maps.Clone(f.page.payloadIndex),
106117 freePayloads: append([]uint32(nil), f.page.freePayloads...),
@@ -262,9 +273,10 @@ func (f Frame) Set(x, y int, cell Cell) {
262273 f.releasePayload(f.page.cells[index].payloadID)
263274 oldID := f.page.cells[index].styleID
264275 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 {
268280 styleID = f.internStyle(cell.Style)
269281 f.releaseStyle(oldID)
270282 }
@@ -344,44 +356,77 @@ func (f Frame) offset(x, y int) int {
344356 }
345357
346358 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 {
352366 f.page.styles[0].refs++
353367 f.releaseStyle(oldID)
354368 }
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])
356376 }
357377 }
358378
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++
370390 } 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++
373404 }
374- f.page.styleIndex[style] = id
375- f.page.styleCount++
405+ p.styleCache, p.styleCacheID, p.styleCacheOK = raw, id, true
376406 return id
377407 }
378408
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+
379421 func (f Frame) releaseStyle(id uint32) {
380422 slot := &f.page.styles[id]
381423 slot.refs--
382424 if id == 0 || slot.refs != 0 {
383425 return
384426 }
427+ if f.page.styleCacheID == id {
428+ f.page.styleCacheOK = false
429+ }
385430 delete(f.page.styleIndex, slot.style)
386431 *slot = styleSlot{}
387432 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 []storedCell45+ cells []storedCell
46- rows []uint3246+ rows []uint32
47- styles []styleSlot47+ styles []styleSlot
48- styleIndex map[Style]uint3248+ styleIndex map[Style]uint32
49- freeStyles []uint3249+ freeStyles []uint32
50- styleCount uint3250+ 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 []payloadSlot59 payloads []payloadSlot
52 payloadIndex map[CellPayload]uint3260 payloadIndex map[CellPayload]uint32
53 freePayloads []uint3261 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].styleID274 oldID := f.page.cells[index].styleID
264 styleID := oldID275 styleID := oldID
265- // Repainting a cell usually keeps its style. Compare the canonical value276+ // 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) + x360+ for i := range row {
349- f.releasePayload(f.page.cells[index].payloadID)361+ cell := &row[i]
350- oldID := f.page.cells[index].styleID362+ 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 id383+ p.styles[p.styleCacheID].refs++
364- }384+ return p.styleCacheID
365- var id uint32385+ }
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] = id405+ p.styleCache, p.styleCacheID, p.styleCacheOK = raw, id, true
375- f.page.styleCount++
376 return id406 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 return425 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 @@
11 package core
22
3-import "testing"
3+import (
4+ "reflect"
5+ "testing"
6+)
47
58 func TestSetStyleReuse(t *testing.T) {
69 for _, rotated := range []bool{false, true} {
@@ -50,3 +53,158 @@ func BenchmarkFrameRewriteSameStyle(b *testing.B) {
5053 }
5154 }
5255 }
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 core1 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 @@
11 package html
22
33 import (
4- "encoding/json"
54 "fmt"
65 "math"
76 "unicode/utf8"
@@ -65,7 +64,8 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool,
6564 if err != nil {
6665 return nil, err
6766 }
68- if err := r.validateFrame(frame); err != nil {
67+ scratch, err := r.validateFrame(frame)
68+ if err != nil {
6969 return nil, err
7070 }
7171 cursor = normalizeWrapPendingCursor(cursor, frame.Width)
@@ -74,8 +74,7 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool,
7474 }
7575
7676 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)
7978 if err != nil {
8079 return nil, err
8180 }
@@ -90,46 +89,49 @@ func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool,
9089 state: transactionPending,
9190 update: update,
9291 json: encoded,
93- frame: candidate,
92+ frame: frame,
9493 cursor: cursor,
9594 }
9695 r.pending = tx
9796 return &PreparedDraw{tx: tx}, nil
9897 }
9998
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) {
101102 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)
103104 }
104105 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)
106107 }
107108 cells := frame.Width * frame.Height
108109 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)
110111 }
111112 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)
113114 }
114115 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)
116117 }
118+ scratch := make([]core.Cell, frame.Width)
117119 for y := range frame.Height {
118- row := frame.Row(y)
120+ row := readRow(frame, y, scratch)
119121 for x := 0; x < frame.Width; x++ {
120122 cell := row[x]
121123 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)
123125 }
124126 if cell.Continuation {
125127 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)
127129 }
128130 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)
130132 }
131133 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)
133135 }
134136 continue
135137 }
@@ -137,23 +139,23 @@ func (r *Renderer) validateFrame(frame core.Frame) error {
137139 continue
138140 }
139141 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)
141143 }
142144 width := core.RuneWidth(cell.Rune)
143145 switch width {
144146 case 1:
145147 case 2:
146148 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)
148150 }
149151 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)
151153 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)
153155 }
154156 }
155157 }
156- return nil
158+ return scratch, nil
157159 }
158160
159161 // materializeCellSource reads a source into an owned frame. A core.Frame
@@ -229,7 +231,31 @@ func damageRequiresSnapshot(damage []core.Damage, width, height int) bool {
229231 return false
230232 }
231233
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) {
233259 update := Update{
234260 SchemaVersion: UpdateSchemaVersion,
235261 Width: frame.Width,
@@ -241,26 +267,27 @@ func (r *Renderer) buildUpdate(frame core.Frame, snapshot bool, cursor Cursor) (
241267 }
242268 styleIDs := make(map[Style]int)
243269 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) {
245272 continue
246273 }
247274 if len(update.Rows) >= r.limits.MaxRowsPerUpdate {
248275 return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate)
249276 }
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)
251278 if err != nil {
252279 return Update{}, err
253280 }
254- update.Rows = append(update.Rows, row)
281+ update.Rows = append(update.Rows, encoded)
255282 }
256283 return update, nil
257284 }
258285
259-// encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on
286+// encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on
260287 // the exact encoded size. The previous conservative estimate could reject
261288 // compact updates well below the configured limit.
262289 func encodeBoundedUpdate(update Update, limit int) ([]byte, error) {
263- encoded, err := json.Marshal(update)
290+ encoded, err := appendUpdateJSON(make([]byte, 0, estimateUpdateJSONSize(update)), update)
264291 if err != nil {
265292 return nil, fmt.Errorf("html: encode update: %w", err)
266293 }
@@ -270,17 +297,11 @@ func encodeBoundedUpdate(update Update, limit int) ([]byte, error) {
270297 return encoded, nil
271298 }
272299
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"
284305
285306 func encodeRow(y int, cells []core.Cell, styles *[]Style, styleIDs map[Style]int, maxStyles int) (RowUpdate, error) {
286307 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
300321 *styles = append(*styles, style)
301322 }
302323 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:
305327 width = 1
306328 text = " "
329+ case cell.Rune < utf8.RuneSelf:
330+ text = asciiText[cell.Rune : cell.Rune+1] // no allocation
331+ default:
332+ text = string(cell.Rune)
307333 }
308334 row.Cells = append(row.Cells, CellUpdate{Column: x, Width: width, Text: text, Style: styleID})
309335 }
@@ -1,7 +1,6 @@
1 package html1 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, err65 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, err69 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, err79 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 = tx95 r.pending = tx
97 return &PreparedDraw{tx: tx}, nil96 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.Height108 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 continue136 continue
135 }137 }
@@ -137,23 +139,23 @@ func (r *Renderer) validateFrame(frame core.Frame) error {
137 continue139 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 nil158+ return scratch, nil
157 }159 }
158 160
159 // materializeCellSource reads a source into an owned frame. A core.Frame161 // 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 false231 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 continue272 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{}, err279 return Update{}, err
253 }280 }
254- update.Rows = append(update.Rows, row)281+ update.Rows = append(update.Rows, encoded)
255 }282 }
256 return update, nil283 return update, nil
257 }284 }
258 285
259-// encodeBoundedUpdate marshals one update and enforces MaxGeneratedBytes on286+// encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on
260 // the exact encoded size. The previous conservative estimate could reject287 // 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, nil297 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 false302+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 = 1327 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) {
228228 require.True(t, next.Update().Snapshot)
229229 require.NoError(t, next.Commit())
230230 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close