detached d71ada9

feat(core): add Frame.CopyFrom and RowsEqual helpers

CopyFrom copies a frame's compact page (cells, row descriptors, style and
payload tables, dictionaries) into the destination's existing storage, so
repeated copies allocate nothing once sized. IDs and row rotation are copied
verbatim, like Clone, so page-local style/payload IDs stay consistent.

RowsEqual compares one logical row of two frames directly on stored cells,
resolving page-local style/payload IDs semantically only when needed, so
frames with different ID tables or row rotation still compare correctly.
commit
d71ada98394f9c2064e79bf992f47c282f7948f3
author
brice · · 2026-10-02 09:27 UTC
parent
dca8245
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +311 -0
A core/frame_copy.go +107 -0
@@ -0,0 +1,107 @@
1+package core
2+
3+import "maps"
4+
5+// CopyFrom makes f an independent structural copy of src, like Replace, but
6+// reuses f's existing page storage (cells, row descriptors, style and payload
7+// tables and dictionaries) when f already owns a page. It therefore performs no
8+// allocation once f's page has grown to src's size.
9+//
10+// Page-local style and payload IDs and physical row rotation are copied
11+// verbatim, exactly as Clone does. Every Frame value sharing f's page observes
12+// the overwrite. An invalid or empty source leaves f unchanged. Copying a frame
13+// onto itself only normalizes the dimensions.
14+func (f *Frame) CopyFrom(src Frame) {
15+ if f == nil {
16+ return
17+ }
18+ if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 {
19+ return
20+ }
21+ if f.page == nil {
22+ *f = src.Clone()
23+ return
24+ }
25+ f.Width, f.Height = src.Width, src.Height
26+ dst, from := f.page, src.page
27+ if dst == from {
28+ return
29+ }
30+ dst.cells = append(dst.cells[:0], from.cells...)
31+ dst.rows = append(dst.rows[:0], from.rows...)
32+ dst.styles = append(dst.styles[:0], from.styles...)
33+ dst.freeStyles = append(dst.freeStyles[:0], from.freeStyles...)
34+ dst.styleCount = from.styleCount
35+ if dst.styleIndex == nil {
36+ dst.styleIndex = make(map[Style]uint32, len(from.styleIndex))
37+ } else {
38+ clear(dst.styleIndex)
39+ }
40+ maps.Copy(dst.styleIndex, from.styleIndex)
41+ dst.payloads = append(dst.payloads[:0], from.payloads...)
42+ dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...)
43+ dst.payloadBytes = from.payloadBytes
44+ if len(from.payloadIndex) == 0 {
45+ clear(dst.payloadIndex)
46+ } else {
47+ if dst.payloadIndex == nil {
48+ dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex))
49+ } else {
50+ clear(dst.payloadIndex)
51+ }
52+ maps.Copy(dst.payloadIndex, from.payloadIndex)
53+ }
54+}
55+
56+// RowsEqual reports whether logical row y holds semantically equal cells in a
57+// and b (the same comparison as Cell.Equal for every column). It compares
58+// compact stored cells directly and resolves page-local style and payload IDs
59+// only when they are not both the default, so it does not allocate and does not
60+// require the two frames to share ID tables. Frames of different width, an
61+// out-of-range row, or inconsistent storage compare unequal.
62+func RowsEqual(a, b Frame, y int) bool {
63+ if a.Width != b.Width || a.Width <= 0 || a.page == nil || b.page == nil ||
64+ y < 0 || y >= a.Height || y >= b.Height ||
65+ y >= len(a.page.rows) || y >= len(b.page.rows) {
66+ return false
67+ }
68+ width := a.Width
69+ offA, offB := int(a.page.rows[y]), int(b.page.rows[y])
70+ if offA+width > len(a.page.cells) || offB+width > len(b.page.cells) {
71+ return false
72+ }
73+ rowA := a.page.cells[offA : offA+width]
74+ rowB := b.page.cells[offB : offB+width]
75+
76+ // Remember the last style ID pair that compared equal so runs of one style
77+ // cost a single table lookup.
78+ var lastStyleA, lastStyleB uint32
79+ for x := range rowA {
80+ ca, cb := rowA[x], rowB[x]
81+ if ca.rune != cb.rune || ca.flags != cb.flags {
82+ return false
83+ }
84+ if ca.styleID != 0 || cb.styleID != 0 {
85+ if ca.styleID != lastStyleA || cb.styleID != lastStyleB {
86+ if !a.storedStylesEqual(b, ca.styleID, cb.styleID) {
87+ return false
88+ }
89+ lastStyleA, lastStyleB = ca.styleID, cb.styleID
90+ }
91+ }
92+ if ca.payloadID != 0 || cb.payloadID != 0 {
93+ if ca.payloadID == 0 || cb.payloadID == 0 || a.payload(ca.payloadID) != b.payload(cb.payloadID) {
94+ return false
95+ }
96+ }
97+ }
98+ return true
99+}
100+
101+func (f Frame) storedStylesEqual(other Frame, id, otherID uint32) bool {
102+ if int(id) >= len(f.page.styles) || int(otherID) >= len(other.page.styles) {
103+ return false
104+ }
105+ sa, sb := f.page.styles[id].style, other.page.styles[otherID].style
106+ return sa == sb || sa.Equal(sb)
107+}
@@ -0,0 +1,107 @@
1+package core
2+
3+import "maps"
4+
5+// CopyFrom makes f an independent structural copy of src, like Replace, but
6+// reuses f's existing page storage (cells, row descriptors, style and payload
7+// tables and dictionaries) when f already owns a page. It therefore performs no
8+// allocation once f's page has grown to src's size.
9+//
10+// Page-local style and payload IDs and physical row rotation are copied
11+// verbatim, exactly as Clone does. Every Frame value sharing f's page observes
12+// the overwrite. An invalid or empty source leaves f unchanged. Copying a frame
13+// onto itself only normalizes the dimensions.
14+func (f *Frame) CopyFrom(src Frame) {
15+ if f == nil {
16+ return
17+ }
18+ if err := src.validateStorage(); err != nil || src.Width <= 0 || src.Height <= 0 {
19+ return
20+ }
21+ if f.page == nil {
22+ *f = src.Clone()
23+ return
24+ }
25+ f.Width, f.Height = src.Width, src.Height
26+ dst, from := f.page, src.page
27+ if dst == from {
28+ return
29+ }
30+ dst.cells = append(dst.cells[:0], from.cells...)
31+ dst.rows = append(dst.rows[:0], from.rows...)
32+ dst.styles = append(dst.styles[:0], from.styles...)
33+ dst.freeStyles = append(dst.freeStyles[:0], from.freeStyles...)
34+ dst.styleCount = from.styleCount
35+ if dst.styleIndex == nil {
36+ dst.styleIndex = make(map[Style]uint32, len(from.styleIndex))
37+ } else {
38+ clear(dst.styleIndex)
39+ }
40+ maps.Copy(dst.styleIndex, from.styleIndex)
41+ dst.payloads = append(dst.payloads[:0], from.payloads...)
42+ dst.freePayloads = append(dst.freePayloads[:0], from.freePayloads...)
43+ dst.payloadBytes = from.payloadBytes
44+ if len(from.payloadIndex) == 0 {
45+ clear(dst.payloadIndex)
46+ } else {
47+ if dst.payloadIndex == nil {
48+ dst.payloadIndex = make(map[CellPayload]uint32, len(from.payloadIndex))
49+ } else {
50+ clear(dst.payloadIndex)
51+ }
52+ maps.Copy(dst.payloadIndex, from.payloadIndex)
53+ }
54+}
55+
56+// RowsEqual reports whether logical row y holds semantically equal cells in a
57+// and b (the same comparison as Cell.Equal for every column). It compares
58+// compact stored cells directly and resolves page-local style and payload IDs
59+// only when they are not both the default, so it does not allocate and does not
60+// require the two frames to share ID tables. Frames of different width, an
61+// out-of-range row, or inconsistent storage compare unequal.
62+func RowsEqual(a, b Frame, y int) bool {
63+ if a.Width != b.Width || a.Width <= 0 || a.page == nil || b.page == nil ||
64+ y < 0 || y >= a.Height || y >= b.Height ||
65+ y >= len(a.page.rows) || y >= len(b.page.rows) {
66+ return false
67+ }
68+ width := a.Width
69+ offA, offB := int(a.page.rows[y]), int(b.page.rows[y])
70+ if offA+width > len(a.page.cells) || offB+width > len(b.page.cells) {
71+ return false
72+ }
73+ rowA := a.page.cells[offA : offA+width]
74+ rowB := b.page.cells[offB : offB+width]
75+
76+ // Remember the last style ID pair that compared equal so runs of one style
77+ // cost a single table lookup.
78+ var lastStyleA, lastStyleB uint32
79+ for x := range rowA {
80+ ca, cb := rowA[x], rowB[x]
81+ if ca.rune != cb.rune || ca.flags != cb.flags {
82+ return false
83+ }
84+ if ca.styleID != 0 || cb.styleID != 0 {
85+ if ca.styleID != lastStyleA || cb.styleID != lastStyleB {
86+ if !a.storedStylesEqual(b, ca.styleID, cb.styleID) {
87+ return false
88+ }
89+ lastStyleA, lastStyleB = ca.styleID, cb.styleID
90+ }
91+ }
92+ if ca.payloadID != 0 || cb.payloadID != 0 {
93+ if ca.payloadID == 0 || cb.payloadID == 0 || a.payload(ca.payloadID) != b.payload(cb.payloadID) {
94+ return false
95+ }
96+ }
97+ }
98+ return true
99+}
100+
101+func (f Frame) storedStylesEqual(other Frame, id, otherID uint32) bool {
102+ if int(id) >= len(f.page.styles) || int(otherID) >= len(other.page.styles) {
103+ return false
104+ }
105+ sa, sb := f.page.styles[id].style, other.page.styles[otherID].style
106+ return sa == sb || sa.Equal(sb)
107+}
A core/frame_copy_test.go +204 -0
@@ -0,0 +1,204 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func copyTestPayload(t *testing.T, grapheme, link string) CellPayload {
9+ t.Helper()
10+ p, err := NewCellPayload(grapheme, link)
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ return p
15+}
16+
17+func copyTestFrame(t *testing.T, width, height int, seed rune) Frame {
18+ t.Helper()
19+ f := NewFrame(width, height)
20+ styles := []Style{DefaultStyle(), {Bold: true, Foreground: 3, Background: -1}, {Italic: true, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}, Background: -1}}
21+ for y := range height {
22+ for x := range width {
23+ c := Cell{Rune: seed + rune((x+y)%7), Style: styles[(x+2*y)%len(styles)]}
24+ if (x+y)%5 == 0 {
25+ c.Payload = copyTestPayload(t, "e\u0301", "https://example.test/"+string(rune('a'+y%3)))
26+ }
27+ f.Set(x, y, c)
28+ }
29+ }
30+ if height > 2 {
31+ f.ScrollUp(0, height-1, 1)
32+ }
33+ return f
34+}
35+
36+func requireFramesEqual(t *testing.T, got, want Frame) {
37+ t.Helper()
38+ if got.Width != want.Width || got.Height != want.Height {
39+ t.Fatalf("size %dx%d, want %dx%d", got.Width, got.Height, want.Width, want.Height)
40+ }
41+ for y := range want.Height {
42+ if !reflect.DeepEqual(got.Row(y), want.Row(y)) {
43+ t.Fatalf("row %d = %+v, want %+v", y, got.Row(y), want.Row(y))
44+ }
45+ }
46+ if err := got.CheckInvariants(); err != nil {
47+ t.Fatal(err)
48+ }
49+}
50+
51+func TestCopyFromIsIndependentAndReusesStorage(t *testing.T) {
52+ src := copyTestFrame(t, 9, 5, 'a')
53+ var dst Frame
54+ dst.CopyFrom(src)
55+ requireFramesEqual(t, dst, src)
56+ if dst.page == src.page {
57+ t.Fatal("copy aliases source page")
58+ }
59+
60+ // Mutating the source must not affect the copy.
61+ before := dst.Clone()
62+ src.Set(0, 0, Cell{Rune: 'Z', Style: Style{Bold: true, Foreground: 9, Background: -1}})
63+ requireFramesEqual(t, dst, before)
64+
65+ // Copy over a different, larger-state destination: the page pointer and
66+ // backing arrays are reused and stale styles/payloads do not leak.
67+ other := copyTestFrame(t, 9, 5, 'k')
68+ other.Set(1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}})
69+ page := dst.page
70+ cells := &dst.page.cells[0]
71+ dst.CopyFrom(other)
72+ if dst.page != page || &dst.page.cells[0] != cells {
73+ t.Fatal("CopyFrom did not reuse destination storage")
74+ }
75+ requireFramesEqual(t, dst, other)
76+ if dst.LogicalBytes() != other.LogicalBytes() || dst.StyleCount() != other.StyleCount() {
77+ t.Fatalf("accounting mismatch: %d/%d vs %d/%d", dst.LogicalBytes(), dst.StyleCount(), other.LogicalBytes(), other.StyleCount())
78+ }
79+
80+ // The copy stays a fully functional, independent frame.
81+ dst.Set(2, 2, Cell{Rune: 'n', Style: DefaultStyle()})
82+ dst.ScrollDown(0, 4, 2)
83+ if err := dst.CheckInvariants(); err != nil {
84+ t.Fatal(err)
85+ }
86+ requireFramesEqual(t, other, withCell(copyTestFrame(t, 9, 5, 'k'), 1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}}))
87+}
88+
89+func withCell(f Frame, x, y int, c Cell) Frame { f.Set(x, y, c); return f }
90+
91+func TestCopyFromResizesAndIgnoresInvalidSource(t *testing.T) {
92+ dst := copyTestFrame(t, 4, 3, 'a')
93+ small := copyTestFrame(t, 2, 2, 'x')
94+ dst.CopyFrom(small)
95+ requireFramesEqual(t, dst, small)
96+ large := copyTestFrame(t, 12, 7, 'm')
97+ dst.CopyFrom(large)
98+ requireFramesEqual(t, dst, large)
99+
100+ before := dst.Clone()
101+ dst.CopyFrom(Frame{})
102+ dst.CopyFrom(Frame{Width: 3, Height: 3})
103+ requireFramesEqual(t, dst, before)
104+ dst.CopyFrom(dst)
105+ requireFramesEqual(t, dst, before)
106+ var nilFrame *Frame
107+ nilFrame.CopyFrom(large)
108+}
109+
110+func TestCopyFromDoesNotAllocateOnceSized(t *testing.T) {
111+ src := copyTestFrame(t, 40, 10, 'a')
112+ dst := src.Clone()
113+ dst.CopyFrom(src)
114+ if n := testing.AllocsPerRun(20, func() { dst.CopyFrom(src) }); n != 0 {
115+ t.Fatalf("CopyFrom allocated %v times", n)
116+ }
117+}
118+
119+func TestRowsEqual(t *testing.T) {
120+ a := copyTestFrame(t, 10, 4, 'a')
121+ b := a.Clone()
122+ for y := range a.Height {
123+ if !RowsEqual(a, b, y) {
124+ t.Fatalf("clone row %d differs", y)
125+ }
126+ }
127+
128+ // Equal content with different physical rotation and different ID tables.
129+ c := NewFrame(a.Width, a.Height)
130+ // Intern an unrelated style first so IDs differ from a's.
131+ c.Set(0, 0, Cell{Rune: 'x', Style: Style{Inverse: true}})
132+ for y := range a.Height {
133+ for x := range a.Width {
134+ c.Set(x, y, a.Cell(x, y))
135+ }
136+ }
137+ for y := range a.Height {
138+ if !RowsEqual(a, c, y) {
139+ t.Fatalf("semantically equal row %d reported different", y)
140+ }
141+ if got, want := RowsEqual(a, c, y), reflect.DeepEqual(a.Row(y), c.Row(y)); got != want {
142+ t.Fatalf("row %d: RowsEqual=%v DeepEqual=%v", y, got, want)
143+ }
144+ }
145+
146+ // Each kind of difference is detected on the right row only.
147+ diffs := []Cell{
148+ {Rune: 'Q', Style: a.Cell(3, 1).Style},
149+ {Rune: a.Cell(3, 1).Rune, Style: Style{Bold: true, Italic: true, Foreground: 5, Background: -1}},
150+ {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Payload: copyTestPayload(t, "z", "")},
151+ {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Continuation: true},
152+ }
153+ for i, d := range diffs {
154+ m := a.Clone()
155+ m.Set(3, 1, d)
156+ for y := range a.Height {
157+ want := y != 1
158+ if got := RowsEqual(a, m, y); got != want {
159+ t.Fatalf("diff %d row %d: got %v want %v", i, y, got, want)
160+ }
161+ }
162+ }
163+
164+ // Payload present on one side only, and differing payloads.
165+ m := a.Clone()
166+ px := -1
167+ for x := range a.Width {
168+ if a.Cell(x, 0).Payload != (CellPayload{}) {
169+ px = x
170+ break
171+ }
172+ }
173+ if px < 0 {
174+ t.Fatal("test frame row 0 has no payload")
175+ }
176+ m.Set(px, 0, Cell{Rune: a.Cell(px, 0).Rune, Style: a.Cell(px, 0).Style})
177+ if RowsEqual(a, m, 0) {
178+ t.Fatal("payload removal not detected")
179+ }
180+
181+ // Inactive RGB fields and other equivalent spellings compare equal.
182+ e1, e2 := NewFrame(2, 1), NewFrame(2, 1)
183+ s1 := Style{Bold: true, Foreground: 2, Background: -1}
184+ s2 := s1
185+ s2.ForegroundRGB = RGB{R: 9}
186+ e1.Set(0, 0, Cell{Rune: 'a', Style: s1})
187+ e2.Set(0, 0, Cell{Rune: 'a', Style: s2})
188+ if !RowsEqual(e1, e2, 0) {
189+ t.Fatal("equivalent styles reported different")
190+ }
191+
192+ // Mismatched shape or range is unequal and never panics.
193+ if RowsEqual(a, NewFrame(a.Width+1, a.Height), 0) || RowsEqual(a, b, -1) || RowsEqual(a, b, a.Height) || RowsEqual(Frame{}, Frame{}, 0) {
194+ t.Fatal("invalid comparison reported equal")
195+ }
196+}
197+
198+func TestRowsEqualDoesNotAllocate(t *testing.T) {
199+ a := copyTestFrame(t, 80, 4, 'a')
200+ b := a.Clone()
201+ if n := testing.AllocsPerRun(20, func() { RowsEqual(a, b, 2) }); n != 0 {
202+ t.Fatalf("RowsEqual allocated %v times", n)
203+ }
204+}
@@ -0,0 +1,204 @@
1+package core
2+
3+import (
4+ "reflect"
5+ "testing"
6+)
7+
8+func copyTestPayload(t *testing.T, grapheme, link string) CellPayload {
9+ t.Helper()
10+ p, err := NewCellPayload(grapheme, link)
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ return p
15+}
16+
17+func copyTestFrame(t *testing.T, width, height int, seed rune) Frame {
18+ t.Helper()
19+ f := NewFrame(width, height)
20+ styles := []Style{DefaultStyle(), {Bold: true, Foreground: 3, Background: -1}, {Italic: true, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}, Background: -1}}
21+ for y := range height {
22+ for x := range width {
23+ c := Cell{Rune: seed + rune((x+y)%7), Style: styles[(x+2*y)%len(styles)]}
24+ if (x+y)%5 == 0 {
25+ c.Payload = copyTestPayload(t, "e\u0301", "https://example.test/"+string(rune('a'+y%3)))
26+ }
27+ f.Set(x, y, c)
28+ }
29+ }
30+ if height > 2 {
31+ f.ScrollUp(0, height-1, 1)
32+ }
33+ return f
34+}
35+
36+func requireFramesEqual(t *testing.T, got, want Frame) {
37+ t.Helper()
38+ if got.Width != want.Width || got.Height != want.Height {
39+ t.Fatalf("size %dx%d, want %dx%d", got.Width, got.Height, want.Width, want.Height)
40+ }
41+ for y := range want.Height {
42+ if !reflect.DeepEqual(got.Row(y), want.Row(y)) {
43+ t.Fatalf("row %d = %+v, want %+v", y, got.Row(y), want.Row(y))
44+ }
45+ }
46+ if err := got.CheckInvariants(); err != nil {
47+ t.Fatal(err)
48+ }
49+}
50+
51+func TestCopyFromIsIndependentAndReusesStorage(t *testing.T) {
52+ src := copyTestFrame(t, 9, 5, 'a')
53+ var dst Frame
54+ dst.CopyFrom(src)
55+ requireFramesEqual(t, dst, src)
56+ if dst.page == src.page {
57+ t.Fatal("copy aliases source page")
58+ }
59+
60+ // Mutating the source must not affect the copy.
61+ before := dst.Clone()
62+ src.Set(0, 0, Cell{Rune: 'Z', Style: Style{Bold: true, Foreground: 9, Background: -1}})
63+ requireFramesEqual(t, dst, before)
64+
65+ // Copy over a different, larger-state destination: the page pointer and
66+ // backing arrays are reused and stale styles/payloads do not leak.
67+ other := copyTestFrame(t, 9, 5, 'k')
68+ other.Set(1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}})
69+ page := dst.page
70+ cells := &dst.page.cells[0]
71+ dst.CopyFrom(other)
72+ if dst.page != page || &dst.page.cells[0] != cells {
73+ t.Fatal("CopyFrom did not reuse destination storage")
74+ }
75+ requireFramesEqual(t, dst, other)
76+ if dst.LogicalBytes() != other.LogicalBytes() || dst.StyleCount() != other.StyleCount() {
77+ t.Fatalf("accounting mismatch: %d/%d vs %d/%d", dst.LogicalBytes(), dst.StyleCount(), other.LogicalBytes(), other.StyleCount())
78+ }
79+
80+ // The copy stays a fully functional, independent frame.
81+ dst.Set(2, 2, Cell{Rune: 'n', Style: DefaultStyle()})
82+ dst.ScrollDown(0, 4, 2)
83+ if err := dst.CheckInvariants(); err != nil {
84+ t.Fatal(err)
85+ }
86+ requireFramesEqual(t, other, withCell(copyTestFrame(t, 9, 5, 'k'), 1, 1, Cell{Rune: 'q', Style: Style{Attrs: AttrUnderline, Foreground: -1, Background: -1}}))
87+}
88+
89+func withCell(f Frame, x, y int, c Cell) Frame { f.Set(x, y, c); return f }
90+
91+func TestCopyFromResizesAndIgnoresInvalidSource(t *testing.T) {
92+ dst := copyTestFrame(t, 4, 3, 'a')
93+ small := copyTestFrame(t, 2, 2, 'x')
94+ dst.CopyFrom(small)
95+ requireFramesEqual(t, dst, small)
96+ large := copyTestFrame(t, 12, 7, 'm')
97+ dst.CopyFrom(large)
98+ requireFramesEqual(t, dst, large)
99+
100+ before := dst.Clone()
101+ dst.CopyFrom(Frame{})
102+ dst.CopyFrom(Frame{Width: 3, Height: 3})
103+ requireFramesEqual(t, dst, before)
104+ dst.CopyFrom(dst)
105+ requireFramesEqual(t, dst, before)
106+ var nilFrame *Frame
107+ nilFrame.CopyFrom(large)
108+}
109+
110+func TestCopyFromDoesNotAllocateOnceSized(t *testing.T) {
111+ src := copyTestFrame(t, 40, 10, 'a')
112+ dst := src.Clone()
113+ dst.CopyFrom(src)
114+ if n := testing.AllocsPerRun(20, func() { dst.CopyFrom(src) }); n != 0 {
115+ t.Fatalf("CopyFrom allocated %v times", n)
116+ }
117+}
118+
119+func TestRowsEqual(t *testing.T) {
120+ a := copyTestFrame(t, 10, 4, 'a')
121+ b := a.Clone()
122+ for y := range a.Height {
123+ if !RowsEqual(a, b, y) {
124+ t.Fatalf("clone row %d differs", y)
125+ }
126+ }
127+
128+ // Equal content with different physical rotation and different ID tables.
129+ c := NewFrame(a.Width, a.Height)
130+ // Intern an unrelated style first so IDs differ from a's.
131+ c.Set(0, 0, Cell{Rune: 'x', Style: Style{Inverse: true}})
132+ for y := range a.Height {
133+ for x := range a.Width {
134+ c.Set(x, y, a.Cell(x, y))
135+ }
136+ }
137+ for y := range a.Height {
138+ if !RowsEqual(a, c, y) {
139+ t.Fatalf("semantically equal row %d reported different", y)
140+ }
141+ if got, want := RowsEqual(a, c, y), reflect.DeepEqual(a.Row(y), c.Row(y)); got != want {
142+ t.Fatalf("row %d: RowsEqual=%v DeepEqual=%v", y, got, want)
143+ }
144+ }
145+
146+ // Each kind of difference is detected on the right row only.
147+ diffs := []Cell{
148+ {Rune: 'Q', Style: a.Cell(3, 1).Style},
149+ {Rune: a.Cell(3, 1).Rune, Style: Style{Bold: true, Italic: true, Foreground: 5, Background: -1}},
150+ {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Payload: copyTestPayload(t, "z", "")},
151+ {Rune: a.Cell(3, 1).Rune, Style: a.Cell(3, 1).Style, Continuation: true},
152+ }
153+ for i, d := range diffs {
154+ m := a.Clone()
155+ m.Set(3, 1, d)
156+ for y := range a.Height {
157+ want := y != 1
158+ if got := RowsEqual(a, m, y); got != want {
159+ t.Fatalf("diff %d row %d: got %v want %v", i, y, got, want)
160+ }
161+ }
162+ }
163+
164+ // Payload present on one side only, and differing payloads.
165+ m := a.Clone()
166+ px := -1
167+ for x := range a.Width {
168+ if a.Cell(x, 0).Payload != (CellPayload{}) {
169+ px = x
170+ break
171+ }
172+ }
173+ if px < 0 {
174+ t.Fatal("test frame row 0 has no payload")
175+ }
176+ m.Set(px, 0, Cell{Rune: a.Cell(px, 0).Rune, Style: a.Cell(px, 0).Style})
177+ if RowsEqual(a, m, 0) {
178+ t.Fatal("payload removal not detected")
179+ }
180+
181+ // Inactive RGB fields and other equivalent spellings compare equal.
182+ e1, e2 := NewFrame(2, 1), NewFrame(2, 1)
183+ s1 := Style{Bold: true, Foreground: 2, Background: -1}
184+ s2 := s1
185+ s2.ForegroundRGB = RGB{R: 9}
186+ e1.Set(0, 0, Cell{Rune: 'a', Style: s1})
187+ e2.Set(0, 0, Cell{Rune: 'a', Style: s2})
188+ if !RowsEqual(e1, e2, 0) {
189+ t.Fatal("equivalent styles reported different")
190+ }
191+
192+ // Mismatched shape or range is unequal and never panics.
193+ if RowsEqual(a, NewFrame(a.Width+1, a.Height), 0) || RowsEqual(a, b, -1) || RowsEqual(a, b, a.Height) || RowsEqual(Frame{}, Frame{}, 0) {
194+ t.Fatal("invalid comparison reported equal")
195+ }
196+}
197+
198+func TestRowsEqualDoesNotAllocate(t *testing.T) {
199+ a := copyTestFrame(t, 80, 4, 'a')
200+ b := a.Clone()
201+ if n := testing.AllocsPerRun(20, func() { RowsEqual(a, b, 2) }); n != 0 {
202+ t.Fatalf("RowsEqual allocated %v times", n)
203+ }
204+}

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