v0.7.1 e0abc49

paint_test.go RawHistoryFolder

1package layout
2
3import (
4	"image"
5	"reflect"
6	"slices"
7	"testing"
8
9	"github.com/bnema/nefergui/internal/css"
10)
11
12// Layout output is detached: neither the shared paint storage nor a later
13// Layout call, nor a mutation of the input style, may change earlier results.
14func TestPaintOutputDetached(t *testing.T) {
15	root, opts := paintFixture(t, 6)
16	first, err := Layout(root, opts)
17	if err != nil {
18		t.Fatal(err)
19	}
20	want := deepCopyDisplay(first.Display)
21	shadows := 0
22	for _, cmd := range first.Display {
23		if cmd.Shadow != nil {
24			shadows++
25		}
26	}
27	if shadows != 12 {
28		t.Fatalf("fixture shadows=%d, want 12", shadows)
29	}
30	// Mutating the input styles must not reach painted shadows.
31	for _, card := range root.Children {
32		card.Style.BoxShadow[0].Blur = px(99)
33	}
34	second, err := Layout(root, opts)
35	if err != nil {
36		t.Fatal(err)
37	}
38	if !reflect.DeepEqual(first.Display, want) {
39		t.Fatal("first output changed after style mutation and second Layout")
40	}
41	if reflect.DeepEqual(second.Display, want) {
42		t.Fatal("second output should reflect the mutated style")
43	}
44	// Writing through one command's storage must not change any other.
45	for i := range second.Display {
46		if second.Display[i].Shadow != nil {
47			second.Display[i].Shadow.X = px(-7)
48		}
49	}
50	if !reflect.DeepEqual(first.Display, want) {
51		t.Fatal("outputs share shadow storage")
52	}
53}
54
55func deepCopyDisplay(in []Command) []Command {
56	out := make([]Command, len(in))
57	for i, c := range in {
58		if c.Shadow != nil {
59			s := *c.Shadow
60			c.Shadow = &s
61		}
62		c.Runs = append([]Run(nil), c.Runs...)
63		for j := range c.Runs {
64			c.Runs[j].Glyphs = append([]Glyph(nil), c.Runs[j].Glyphs...)
65		}
66		out[i] = c
67	}
68	return out
69}
70
71// Runs and glyphs come from shared blocks; a caller appending to one must
72// reallocate rather than overwrite the next run or command.
73func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) {
74	root, opts := paintFixture(t, 3)
75	out, err := Layout(root, opts)
76	if err != nil {
77		t.Fatal(err)
78	}
79	want := deepCopyDisplay(out.Display)
80	texts := 0
81	for i := range out.Display {
82		cmd := &out.Display[i]
83		if cmd.Op != "text" {
84			continue
85		}
86		texts++
87		if len(cmd.Runs) == 0 || cap(cmd.Runs) != len(cmd.Runs) {
88			t.Fatalf("runs len=%d cap=%d", len(cmd.Runs), cap(cmd.Runs))
89		}
90		for j := range cmd.Runs {
91			if g := cmd.Runs[j].Glyphs; len(g) == 0 || cap(g) != len(g) {
92				t.Fatalf("glyphs len=%d cap=%d", len(g), cap(g))
93			}
94		}
95		_ = append(cmd.Runs, Run{Start: -1})
96		for j := range cmd.Runs {
97			_ = append(cmd.Runs[j].Glyphs, Glyph{ID: 0xdead})
98		}
99	}
100	if texts != 3 || !reflect.DeepEqual(out.Display, want) {
101		t.Fatal("append through a command overwrote neighbouring data")
102	}
103}
104
105// countCommands must match paint exactly, otherwise the single-allocation
106// guarantee silently degrades.
107func TestPaintCountsMatch(t *testing.T) {
108	root, opts := paintFixture(t, 10)
109	root.Children = append(root.Children,
110		nil,
111		&Node{ID: "hidden", Kind: Text, Content: "hidden", Style: &css.Style{Display: css.KeywordNone}},
112		&Node{ID: "image", Kind: Image, Image: image.NewRGBA(image.Rect(0, 0, 2, 2)), Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20)}},
113		&Node{ID: "outline", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), OutlineStyle: css.KeywordSolid, OutlineWidth: px(2)}},
114		&Node{ID: "radius", Kind: Box, Style: &css.Style{Display: css.KeywordBlock, Width: px(20), Height: px(20), BorderRadius: css.Radii{TopLeft: px(2)}}},
115	)
116	ctx := newContext(opts)
117	tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
118	if err != nil {
119		t.Fatal(err)
120	}
121	n := countCommands(root, tree)
122	out := ctx.display(root, tree)
123	runs, glyphs, shadows := 0, 0, 0
124	for _, cmd := range out {
125		runs += len(cmd.Runs)
126		for _, r := range cmd.Runs {
127			glyphs += len(r.Glyphs)
128		}
129		if cmd.Shadow != nil {
130			shadows++
131		}
132	}
133	if n.commands != len(out) || n.runs != runs || n.glyphs != glyphs || n.shadows != shadows {
134		t.Fatalf("counts %+v vs commands=%d runs=%d glyphs=%d shadows=%d", n, len(out), runs, glyphs, shadows)
135	}
136	// An empty arena sizes each block once from the counts: one allocation
137	// each for commands, shadows, runs and glyphs, with no growth.
138	if raceEnabled {
139		return
140	}
141	if got := testing.AllocsPerRun(10, func() {
142		*ctx.arena = Arena{}
143		ctx.display(root, tree)
144	}); got != 4 {
145		t.Fatalf("fresh display allocs=%v, want 4", got)
146	}
147}
148
149func TestPaintAllocations(t *testing.T) {
150	root, opts := paintFixture(t, 50)
151	ctx := newContext(opts)
152	tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0)
153	if err != nil {
154		t.Fatal(err)
155	}
156	// The arena's blocks are reused, so a repeated paint allocates nothing.
157	if n := testing.AllocsPerRun(20, func() { ctx.display(root, tree) }); n != 0 {
158		t.Fatalf("paint allocs=%v, want 0", n)
159	}
160}
161
162// TestLayoutArenaReuse checks that a steady layout into a reused arena
163// allocates nothing, and that alternating two arenas keeps the previous
164// Output intact while the next one is built.
165func TestLayoutArenaReuse(t *testing.T) {
166	root, opts := paintFixture(t, 20)
167	var arenas [2]Arena
168	layout := func(i int) Output {
169		o := opts
170		o.Arena = &arenas[i]
171		out, err := Layout(root, o)
172		if err != nil {
173			t.Fatal(err)
174		}
175		return out
176	}
177	prev := layout(0)
178	layout(1)
179	wantID, wantX := prev.Tree.Children[3].ID, prev.Display[5].Rect.X
180	root.Children[3].ID = "changed"
181	layout(1)
182	if prev.Tree.Children[3].ID != wantID || prev.Display[5].Rect.X != wantX {
183		t.Fatal("building into one arena changed the Output of the other")
184	}
185	if n := testing.AllocsPerRun(20, func() { layout(1) }); n != 0 {
186		t.Fatalf("steady layout allocs=%v, want 0", n)
187	}
188	// A list the caller grew with many decorations and handed back through
189	// Keep is recycled, not dropped as oversized for the layout alone.
190	decorations := make([]Command, 1000) // far beyond retainSlack times the layout's own
191	decorate := func() {
192		out := layout(1)
193		arenas[1].Keep(slices.Insert(out.Display, 0, decorations...))
194	}
195	decorate()
196	if n := testing.AllocsPerRun(20, decorate); n != 0 {
197		t.Fatalf("decorated layout allocs=%v, want 0", n)
198	}
199	// The runtime may lay out twice before one Keep (caret scrolling).
200	if n := testing.AllocsPerRun(20, func() { layout(1); decorate() }); n != 0 {
201		t.Fatalf("relayout before keep allocs=%v, want 0", n)
202	}
203	// Once decorations go away, the large list is released.
204	decorated := cap(arenas[1].commands)
205	arenas[1].Keep(layout(1).Display)
206	if layout(1); cap(arenas[1].commands) >= decorated {
207		t.Fatalf("decorated list kept after decorations went away: cap %d", cap(arenas[1].commands))
208	}
209	// Growth falls back to individual allocations once, then fits.
210	for range 30 {
211		root.Children = append(root.Children, root.Children[0])
212	}
213	layout(1)
214	if n := testing.AllocsPerRun(20, func() { layout(1) }); n != 0 {
215		t.Fatalf("allocs after growth=%v, want 0", n)
216	}
217}
218
219func TestPaintEmptyAndShadowOrder(t *testing.T) {
220	out, err := Layout(&Node{ID: "n", Kind: Box, Style: &css.Style{Display: css.KeywordBlock}}, Options{Width: 10, Height: 10})
221	if err != nil || out.Display != nil {
222		t.Fatalf("empty paint: %+v %v", out.Display, err)
223	}
224	s := css.Style{Display: css.KeywordBlock, Width: px(10), Height: px(10), BackgroundColor: css.Color{A: 1}, BoxShadow: []css.Shadow{
225		{Inset: true, Blur: px(1)}, {Blur: px(2)}, {Blur: px(3)}, {Inset: true, Blur: px(4)},
226	}}
227	out, err = Layout(node("n", Box, s), Options{Width: 10, Height: 10})
228	if err != nil {
229		t.Fatal(err)
230	}
231	var got []float64
232	var ops []string
233	for _, c := range out.Display {
234		ops = append(ops, c.Op)
235		if c.Shadow != nil {
236			got = append(got, c.Shadow.Blur.Value)
237		}
238	}
239	if !reflect.DeepEqual(got, []float64{2, 3, 1, 4}) || !reflect.DeepEqual(ops, []string{"shadow", "shadow", "rect", "shadow", "shadow"}) {
240		t.Fatalf("order ops=%v blur=%v", ops, got)
241	}
242}

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 1

Tags 12

↑↓ moveenter openesc close