detached 9491156

perf(core): cache last interned style and speed up row blanking

Skip canonicalization and map hashing when Set repeats the raw style, fill blanked rows by copy-doubling, and double style slice capacity (bounded by cell count) to cut growth allocations.
commit
94911565a4aac9cf6891c39caccb2fae859d0a0e
author
brice · · 2026-10-02 09:26 UTC
parent
dca8245
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +234 -31
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+}

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