v0.6.0 43c5a7c

layout_test.go RawHistoryFolder

1package layout
2
3import (
4	"encoding/json"
5	"image"
6	"math"
7	"os"
8	"path/filepath"
9	"reflect"
10	"testing"
11
12	"github.com/bnema/nefergui/internal/css"
13	"github.com/bnema/nefergui/internal/text"
14)
15
16func px(v float64) css.Length { return css.Length{Value: v, Unit: "px"} }
17func node(id string, k Kind, s css.Style, children ...*Node) *Node {
18	return &Node{ID: id, Kind: k, Style: &s, Children: children}
19}
20func snapshot(t *testing.T, name string, out Output) {
21	t.Helper()
22	got, err := json.MarshalIndent(out, "", "  ")
23	if err != nil {
24		t.Fatal(err)
25	}
26	path := filepath.Join("testdata", name+".json")
27	if os.Getenv("NEFERGUI_UPDATE_SNAPSHOTS") == "1" {
28		if err := os.MkdirAll("testdata", 0755); err != nil {
29			t.Fatal(err)
30		}
31		if err := os.WriteFile(path, append(got, '\n'), 0644); err != nil {
32			t.Fatal(err)
33		}
34	}
35	want, err := os.ReadFile(path)
36	if os.IsNotExist(err) {
37		t.Fatalf("snapshot missing %s; computed: %s", path, got)
38	}
39	if err != nil {
40		t.Fatal(err)
41	}
42	var a, b any
43	if err = json.Unmarshal(got, &a); err != nil {
44		t.Fatal(err)
45	}
46	if err = json.Unmarshal(want, &b); err != nil {
47		t.Fatal(err)
48	}
49	if !reflect.DeepEqual(a, b) {
50		t.Fatalf("snapshot %s differs:\n%s", name, got)
51	}
52}
53func TestNestedSnapshot(t *testing.T) {
54	e := css.Compile(css.UA(), css.Parse(`box {padding: 4px; border: 2px solid red; background: blue} image {width:50%;height:20px;margin: 3px}`))
55	root := e.Compute(&css.Element{Type: "box"}, nil)
56	child := e.Compute(&css.Element{Type: "image"}, root)
57	out, err := Layout(node("root", Box, *root.Style, node("pic", Image, *child.Style)), Options{Width: 200, Height: 100})
58	if err != nil {
59		t.Fatal(err)
60	}
61	if out.Tree.Content.X != 6 || out.Tree.Children[0].Border.W != 94 {
62		t.Fatalf("unexpected box: %+v", out.Tree)
63	}
64	snapshot(t, "nested", out)
65}
66func TestFlex(t *testing.T) {
67	s := css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordRow, ColumnGap: px(10), FlexWrap: css.KeywordWrap}
68	child := css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(12), FlexGrow: 1, FlexShrink: 1}
69	root := node("r", Row, s, node("a", Box, child), node("b", Box, child), node("c", Box, child))
70	out, err := Layout(root, Options{Width: 120, Height: 50})
71	if err != nil {
72		t.Fatal(err)
73	}
74	if len(out.Tree.Children) != 3 || out.Tree.Children[0].Border.W != 55 || out.Tree.Children[1].Border.X != 65 || out.Tree.Children[2].Border.Y != 12 {
75		t.Fatalf("flex wrap: %+v", out.Tree.Children)
76	}
77	snapshot(t, "flex", out)
78	s.FlexWrap = css.KeywordNowrap
79	root.Style = &s
80	out, err = Layout(root, Options{Width: 120, Height: 50})
81	if err != nil {
82		t.Fatal(err)
83	}
84	if math.Abs(out.Tree.Children[0].Border.W-100.0/3) > 1e-6 {
85		t.Fatalf("shrink: %v", out.Tree.Children[0].Border.W)
86	}
87}
88func TestStackScrollHit(t *testing.T) {
89	child := css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(50), BackgroundColor: css.Color{R: 1, A: 1}}
90	s := css.Style{Display: css.KeywordBlock, Width: px(30), Height: px(30), OverflowX: css.KeywordHidden, OverflowY: css.KeywordScroll}
91	root := node("scroll", Scroll, s, node("stack", Stack, css.Style{Display: css.KeywordBlock}, node("back", Box, child), node("front", Box, child)))
92	out, err := Layout(root, Options{Width: 100, Height: 100})
93	if err != nil {
94		t.Fatal(err)
95	}
96	if Hit(out.Tree, 10, 10) != "front" || Hit(out.Tree, 45, 10) == "front" {
97		t.Fatal("paint order/clip", out.Tree)
98	}
99	root.ScrollY = 10
100	out, err = Layout(root, Options{Width: 100, Height: 100})
101	if err != nil {
102		t.Fatal(err)
103	}
104	if out.Tree.ScrollY != 10 || out.Tree.Children[0].Border.Y != -10 {
105		t.Fatalf("scroll: %+v", out.Tree)
106	}
107	snapshot(t, "scroll", out)
108}
109func TestTextAndLimits(t *testing.T) {
110	m := func(w float64) (Size, []text.Line, error) {
111		if w > 20 {
112			return Size{W: 20, H: 10}, nil, nil
113		}
114		return Size{W: 20, H: 30}, nil, nil
115	}
116	s := css.Style{Display: css.KeywordBlock, Width: px(15), Padding: css.LengthSides{Left: px(2), Right: px(2)}}
117	n := node("text", Text, s)
118	n.Content = "three lines"
119	n.Measure = m
120	out, err := Layout(n, Options{Width: 50, Height: 100})
121	if err != nil {
122		t.Fatal(err)
123	}
124	if out.Tree.Border.H != 30 || out.Tree.Content.W != 15 {
125		t.Fatalf("wrap: %+v", out.Tree)
126	}
127	s.Width = px(math.NaN())
128	s.Height = px(math.Inf(1))
129	n.Style = &s
130	out, err = Layout(n, Options{Width: math.Inf(1), Height: math.NaN()})
131	if err != nil {
132		t.Fatal(err)
133	}
134	if _, err = json.Marshal(out); err != nil {
135		t.Fatal("non-finite output", err)
136	}
137}
138func TestScale(t *testing.T) {
139	for _, v := range []struct {
140		scale float64
141		x, w  int
142	}{{1, 1, 3}, {1.25, 1, 4}, {1.5, 2, 4}, {2, 2, 6}} {
143		x, _, w, _ := Physical(Rect{X: 1, Y: 0, W: 3, H: 4}, v.scale)
144		if x != v.x || w != v.w {
145			t.Fatalf("scale %v: %d,%d", v.scale, x, w)
146		}
147	}
148}
149func TestCSSMalformed(t *testing.T) {
150	sheet := css.Parse(`box {width: nope; padding: 5px; border: bad} image {height: 10px}`)
151	if len(sheet.Diagnostics) == 0 {
152		t.Fatal("missing diagnostics")
153	}
154	e := css.Compile(css.UA(), sheet)
155	root := e.Compute(&css.Element{Type: "box"}, nil)
156	pic := e.Compute(&css.Element{Type: "image"}, root)
157	out, err := Layout(node("r", Box, *root.Style, node("i", Image, *pic.Style)), Options{Width: 50, Height: 50})
158	if err != nil || out.Tree.Children[0].Border.H != 10 {
159		t.Fatalf("malformed CSS: %+v %v", out, err)
160	}
161}
162func FuzzLayout(f *testing.F) {
163	f.Add(float64(1), float64(10), float64(100))
164	f.Add(math.NaN(), math.Inf(1), float64(-1))
165	f.Fuzz(func(t *testing.T, w, p, g float64) {
166		s := css.Style{Display: css.KeywordFlex, Width: px(w), Padding: css.LengthSides{Left: px(p)}, ColumnGap: px(g)}
167		n := node("r", Row, s, node("child", Image, css.Style{Display: css.KeywordBlock}, nil))
168		out, err := Layout(n, Options{Width: 200, Height: 100})
169		if err != nil {
170			t.Fatal(err)
171		}
172		if _, err = json.Marshal(out); err != nil {
173			t.Fatal(err)
174		}
175	})
176}
177func BenchmarkLayout1000(b *testing.B) {
178	children := make([]*Node, 1000)
179	s := css.Style{Display: css.KeywordBlock, Height: px(2)}
180	for i := range children {
181		children[i] = node("child", Box, s)
182	}
183	root := node("root", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn}, children...)
184	b.ReportAllocs()
185	for i := 0; i < b.N; i++ {
186		if _, err := Layout(root, Options{Width: 600, Height: 2200}); err != nil {
187			b.Fatal(err)
188		}
189	}
190}
191
192func TestTextEngineWrap(t *testing.T) {
193	catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
194	if err != nil {
195		t.Fatal(err)
196	}
197	engine := text.NewEngine(catalog)
198	s := css.Compile(css.UA(), css.Parse("text {width: 60px; font-size: 16px}")).Compute(&css.Element{Type: "text"}, nil).Style
199	n := node("words", Text, *s)
200	n.Content = "alpha beta gamma delta"
201	out, err := Layout(n, Options{Width: 200, Height: 200, TextEngine: engine})
202	if err != nil {
203		t.Fatal(err)
204	}
205	if len(out.Tree.Lines) < 2 || out.Tree.Content.H <= 16 {
206		t.Fatalf("text did not wrap: %+v", out.Tree)
207	}
208	found := false
209	for _, cmd := range out.Display {
210		if cmd.Op == "text" {
211			found = len(cmd.Runs) > 0
212			for _, run := range cmd.Runs {
213				if run.Face == nil || run.FaceID != run.Face.ID || run.Size != 16 {
214					t.Fatalf("missing raster identity: %+v", run)
215				}
216				data, err := json.Marshal(run)
217				if err != nil {
218					t.Fatal(err)
219				}
220				var encoded map[string]any
221				if err := json.Unmarshal(data, &encoded); err != nil {
222					t.Fatal(err)
223				}
224				if _, leaked := encoded["Face"]; leaked || encoded["FaceID"] != run.FaceID || encoded["Size"] != 16.0 {
225					t.Fatalf("serialized raster identity: %s", data)
226				}
227			}
228		}
229	}
230	if !found {
231		t.Fatal("missing glyph runs")
232	}
233}
234
235func TestPaintStylesAndFlexDefaults(t *testing.T) {
236	en := css.Compile(css.UA(), css.Parse(`row {height: 24px; align-items: center; gap: 4px} box { width: 12px; height: 4px; background: red; border: 1px solid green; border-radius: 3px; outline: 2px solid blue; box-shadow: 1px 2px 2px black}`))
237	parent := en.Compute(&css.Element{Type: "row"}, nil)
238	item := en.Compute(&css.Element{Type: "box"}, parent)
239	out, err := Layout(node("row", Row, *parent.Style, node("item", Box, *item.Style)), Options{Width: 100, Height: 50})
240	if err != nil {
241		t.Fatal(err)
242	}
243	if out.Tree.Children[0].Border.Y != 9 {
244		t.Fatalf("align center: %v", out.Tree.Children[0].Border.Y)
245	}
246	ops := []string{}
247	for _, cmd := range out.Display {
248		ops = append(ops, cmd.Op)
249		if cmd.Op == "border" && (cmd.Colors.Top.G != 128.0/255 || cmd.Radii[0] != 3) {
250			t.Fatalf("border: %+v", cmd)
251		}
252	}
253	if !reflect.DeepEqual(ops, []string{"shadow", "rect", "border", "outline"}) {
254		t.Fatal(ops)
255	}
256}
257func TestFlexOverridesAndConstraints(t *testing.T) {
258	e := css.Compile(css.UA(), css.Parse(`row {display:block} box {width:50%; min-width: 60px; max-width:80px; height:10px; box-sizing:border-box; padding:5px}`))
259	p := e.Compute(&css.Element{Type: "row"}, nil)
260	ch := e.Compute(&css.Element{Type: "box"}, p)
261	out, err := Layout(node("r", Row, *p.Style, node("one", Box, *ch.Style), node("two", Box, *ch.Style)), Options{Width: 100, Height: 100})
262	if err != nil {
263		t.Fatal(err)
264	}
265	if out.Tree.Children[0].Border.W != 60 || out.Tree.Children[1].Border.Y != 10 {
266		t.Fatalf("override constraints: %+v", out.Tree.Children)
267	}
268}
269
270func TestIntrinsicImageRatio(t *testing.T) {
271	n := node("img", Image, css.Style{Display: css.KeywordBlock, Width: px(80)})
272	n.ImageSize = Size{W: 40, H: 20}
273	img := image.NewRGBA(image.Rect(0, 0, 40, 20))
274	n.Image = img
275	out, err := Layout(n, Options{Width: 100, Height: 100})
276	if err != nil {
277		t.Fatal(err)
278	}
279	if out.Tree.Border.W != 80 || out.Tree.Border.H != 40 {
280		t.Fatalf("ratio: %+v", out.Tree.Border)
281	}
282	found := false
283	for _, cmd := range out.Display {
284		if cmd.Op == "image" {
285			found = true
286			if cmd.Image != img || cmd.Rect != out.Tree.Content {
287				t.Fatalf("image command: %+v", cmd)
288			}
289		}
290	}
291	if !found {
292		t.Fatal("missing image command")
293	}
294	n.Style = &css.Style{Display: css.KeywordBlock, Height: px(10)}
295	out, err = Layout(n, Options{Width: 100, Height: 100})
296	if err != nil {
297		t.Fatal(err)
298	}
299	if out.Tree.Border.W != 20 || out.Tree.Border.H != 10 {
300		t.Fatalf("ratio: %+v", out.Tree.Border)
301	}
302}
303
304func TestSizingSpaceAndBorderStyle(t *testing.T) {
305	for _, tc := range []struct {
306		box  css.Keyword
307		want float64
308	}{{css.KeywordContentBox, 80}, {css.KeywordBorderBox, 60}} {
309		s := css.Style{Display: css.KeywordBlock, BoxSizing: tc.box, Width: px(50), MinWidth: px(60), Padding: css.LengthSides{Left: px(10), Right: px(10)}}
310		out, err := Layout(node("b", Box, s), Options{Width: 100, Height: 100})
311		if err != nil {
312			t.Fatal(err)
313		}
314		if out.Tree.Border.W != tc.want {
315			t.Fatalf("box sizing %v: %v != %v", tc.box, out.Tree.Border.W, tc.want)
316		}
317	}
318	s := css.Style{Display: css.KeywordBlock, Width: px(50), BorderWidth: css.LengthSides{Top: px(9), Right: px(9), Bottom: px(9), Left: px(9)}, BorderStyle: css.KeywordSides{Top: css.KeywordSolid}}
319	out, err := Layout(node("b", Box, s), Options{Width: 100, Height: 100})
320	if err != nil {
321		t.Fatal(err)
322	}
323	if out.Tree.Border.W != 50 || out.Tree.Content.Y != 9 || out.Tree.BorderWidths != (Edges{Top: 9}) {
324		t.Fatalf("none border reserved: %+v", out.Tree)
325	}
326	for _, cmd := range out.Display {
327		if cmd.Op == "border" && cmd.Widths != (Edges{Top: 9}) {
328			t.Fatalf("none painted: %+v", cmd)
329		}
330	}
331}
332
333func TestFlexFreezeAndInsets(t *testing.T) {
334	parent := css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordRow}
335	first := css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(10), FlexGrow: 1, FlexShrink: 1, MaxWidth: px(60)}
336	second := first
337	second.MaxWidth = css.Length{}
338	root := node("r", Row, parent, node("a", Box, first), node("b", Box, second))
339	out, err := Layout(root, Options{Width: 200, Height: 30})
340	if err != nil {
341		t.Fatal(err)
342	}
343	a, b := out.Tree.Children[0].Border, out.Tree.Children[1].Border
344	if a.W != 60 || b.W != 140 || b.X != 60 {
345		t.Fatalf("grow freeze: %+v %+v", a, b)
346	}
347	first.MaxWidth = css.Length{}
348	first.MinWidth = px(40)
349	second.MinWidth = css.Length{}
350	root.Children[0].Style = &first
351	root.Children[1].Style = &second
352	out, err = Layout(root, Options{Width: 60, Height: 30})
353	if err != nil {
354		t.Fatal(err)
355	}
356	a, b = out.Tree.Children[0].Border, out.Tree.Children[1].Border
357	if a.W != 40 || b.W != 20 || b.X != 40 {
358		t.Fatalf("shrink freeze: %+v %+v", a, b)
359	}
360	first = css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(10), Padding: css.LengthSides{Left: px(10), Right: px(10)}}
361	root.Children = root.Children[:1]
362	root.Children[0].Style = &first
363	out, err = Layout(root, Options{Width: 70, Height: 30})
364	if err != nil {
365		t.Fatal(err)
366	}
367	if out.Tree.Children[0].Border.W != 70 || out.Tree.Children[0].Content.W != 50 {
368		t.Fatalf("flex insets: %+v", out.Tree.Children[0])
369	}
370}
371
372func TestOverflowAxes(t *testing.T) {
373	s := css.Style{Display: css.KeywordBlock, Width: px(30), Height: px(30), OverflowX: css.KeywordVisible, OverflowY: css.KeywordHidden}
374	child := css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(50)}
375	n := node("parent", Box, s, node("child", Box, child))
376	n.ScrollX = 10
377	n.ScrollY = 10
378	out, err := Layout(n, Options{Width: 100, Height: 100})
379	if err != nil {
380		t.Fatal(err)
381	}
382	if out.Tree.ScrollX != 0 || out.Tree.ScrollY != 0 || out.Tree.Children[0].Border.X != 0 || out.Tree.Clip.W != 2*limit || out.Tree.Clip.H != 30 {
383		t.Fatalf("axes: %+v", out.Tree)
384	}
385	if Hit(out.Tree, 40, 10) != "child" || Hit(out.Tree, 10, 40) == "child" {
386		t.Fatalf("clip hit: %s %s", Hit(out.Tree, 40, 10), Hit(out.Tree, 10, 40))
387	}
388	if out.Display[0].Op != "clip-push" || out.Display[0].Rect.W != 2*limit {
389		t.Fatalf("clip command: %+v", out.Display[0])
390	}
391}
392
393func TestTextCSSMetrics(t *testing.T) {
394	catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
395	if err != nil {
396		t.Fatal(err)
397	}
398	engine := text.NewEngine(catalog)
399	st := css.Compile(css.UA(), css.Parse("text {width:60px;font-size:16px}")).Compute(&css.Element{Type: "text"}, nil).Style
400	n := node("text", Text, *st)
401	n.Content = "alpha beta gamma delta"
402	base, err := Layout(n, Options{Width: 100, Height: 100, TextEngine: engine})
403	if err != nil {
404		t.Fatal(err)
405	}
406	if len(base.Tree.Lines) < 2 {
407		t.Fatalf("expected wrapping: %+v", base.Tree)
408	}
409	baseLine1, baseH := base.Tree.Lines[1].Baseline, base.Tree.Border.H // values, not shared slices
410	st.LineHeightKind = css.KeywordNumber
411	st.LineHeightNumber = 2
412	n.Style = st
413	spaced, err := Layout(n, Options{Width: 100, Height: 100, TextEngine: engine})
414	if err != nil {
415		t.Fatal(err)
416	}
417	if len(spaced.Tree.Lines) < 2 || spaced.Tree.Lines[0].Height != 32 || spaced.Tree.Border.H != float64(len(spaced.Tree.Lines))*32 || spaced.Tree.Lines[1].Baseline-spaced.Tree.Lines[0].Baseline != 32 {
418		t.Fatalf("line height: %+v", spaced.Tree)
419	}
420	// The engine caches measurements: the line-height adjustment must not leak
421	// into later layouts of the same text.
422	st.LineHeightKind = css.KeywordNormal
423	n.Style = st
424	again, err := Layout(n, Options{Width: 100, Height: 100, TextEngine: engine})
425	if err != nil {
426		t.Fatal(err)
427	}
428	if again.Tree.Lines[1].Baseline != baseLine1 || again.Tree.Border.H != baseH {
429		t.Fatalf("cached measurement mutated: %v vs %v", again.Tree.Lines[1].Baseline, baseLine1)
430	}
431	st.LineHeightKind = css.KeywordNumber
432	n.Style = st
433	st.TextAlign = css.KeywordCenter
434	aligned, err := Layout(n, Options{Width: 100, Height: 100, TextEngine: engine})
435	if err != nil {
436		t.Fatal(err)
437	}
438	var original, center Command
439	for _, cmd := range spaced.Display {
440		if cmd.Op == "text" {
441			original = cmd
442		}
443	}
444	for _, cmd := range aligned.Display {
445		if cmd.Op == "text" {
446			center = cmd
447		}
448	}
449	want := safe(aligned.Tree.Content.W-aligned.Tree.Lines[0].Width) / 2
450	if want <= 0 || math.Abs(center.Runs[0].X-original.Runs[0].X-want) > 1e-6 || math.Abs(center.Runs[0].Glyphs[0].X-original.Runs[0].Glyphs[0].X-want) > 1e-6 {
451		t.Fatalf("align offset %v: %v %v", want, original.Runs[0].X, center.Runs[0].X)
452	}
453	st.LetterSpacing = px(4)
454	st.LetterSpacingNormal = false
455	st.TextAlign = css.KeywordStart
456	n.Style = st
457	letter, err := Layout(n, Options{Width: 100, Height: 100, TextEngine: engine})
458	if err != nil {
459		t.Fatal(err)
460	}
461	if letter.Tree.Lines[0].Width <= base.Tree.Lines[0].Width || letter.Tree.ContentSize.H < base.Tree.ContentSize.H {
462		t.Fatalf("letter spacing: %v -> %v", base.Tree.Lines[0].Width, letter.Tree.Lines[0].Width)
463	}
464}
465
466func TestFlexZeroFreeSpaceViolations(t *testing.T) {
467	a := css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(10), MinWidth: px(80), FlexShrink: 1}
468	b := css.Style{Display: css.KeywordBlock, Width: px(50), Height: px(10), MaxWidth: px(20), FlexShrink: 1}
469	root := node("row", Row, css.Style{Display: css.KeywordFlex}, node("min", Box, a), node("max", Box, b))
470	out, err := Layout(root, Options{Width: 100, Height: 20})
471	if err != nil {
472		t.Fatal(err)
473	}
474	first, second := out.Tree.Children[0].Border, out.Tree.Children[1].Border
475	if first.W != 80 || second.W != 20 || second.X != 80 {
476		t.Fatalf("zero-free-space violations: %+v %+v", first, second)
477	}
478}
479func TestFlexContentBoxMinLessThanInset(t *testing.T) {
480	child := css.Style{Display: css.KeywordBlock, Width: px(5), MinWidth: px(10), Padding: css.LengthSides{Left: px(10), Right: px(10)}}
481	root := node("row", Row, css.Style{Display: css.KeywordFlex}, node("child", Box, child))
482	out, err := Layout(root, Options{Width: 30, Height: 20})
483	if err != nil {
484		t.Fatal(err)
485	}
486	if got := out.Tree.Children[0].Border.W; got != 30 {
487		t.Fatalf("content-box min 10 + insets 20: got %v want 30", got)
488	}
489}
490
491func TestDirectionalTextAlignment(t *testing.T) {
492	catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
493	if err != nil {
494		t.Fatal(err)
495	}
496	engine := text.NewEngine(catalog)
497	for _, content := range []string{"سلام", "שלום", "abc"} {
498		st := css.Compile(css.UA(), css.Parse("text {width:120px;font-size:16px}")).Compute(&css.Element{Type: "text"}, nil).Style
499		n := node("text", Text, *st)
500		n.Content = content
501		st.TextAlign = css.KeywordLeft
502		n.Style = st
503		left, err := Layout(n, Options{Width: 200, Height: 100, TextEngine: engine})
504		if err != nil {
505			t.Fatal(err)
506		}
507		st.TextAlign = css.KeywordStart
508		start, err := Layout(n, Options{Width: 200, Height: 100, TextEngine: engine})
509		if err != nil {
510			t.Fatal(err)
511		}
512		st.TextAlign = css.KeywordEnd
513		end, err := Layout(n, Options{Width: 200, Height: 100, TextEngine: engine})
514		if err != nil {
515			t.Fatal(err)
516		}
517		var leftX, startX, endX float64
518		for _, cmd := range left.Display {
519			if cmd.Op == "text" {
520				leftX = cmd.Runs[0].X
521			}
522		}
523		for _, cmd := range start.Display {
524			if cmd.Op == "text" {
525				startX = cmd.Runs[0].X
526			}
527		}
528		for _, cmd := range end.Display {
529			if cmd.Op == "text" {
530				endX = cmd.Runs[0].X
531			}
532		}
533		gap := safe(start.Tree.Content.W - start.Tree.Lines[0].Width)
534		if gap <= 0 {
535			t.Fatalf("%q: no free space", content)
536		}
537		if content == "abc" {
538			if startX != leftX || math.Abs(endX-leftX-gap) > 1e-6 {
539				t.Fatalf("LTR %q: left=%v start=%v end=%v gap=%v", content, leftX, startX, endX, gap)
540			}
541		} else if math.Abs(startX-leftX-gap) > 1e-6 || endX != leftX {
542			t.Fatalf("RTL %q: left=%v start=%v end=%v gap=%v", content, leftX, startX, endX, gap)
543		}
544	}
545}
546
547func TestFlexStackRejectsRect(t *testing.T) {
548	for _, direction := range []css.Keyword{css.KeywordRow, css.KeywordColumn} {
549		for _, nested := range []bool{false, true} {
550			child := node("rect", Box, css.Style{Display: css.KeywordBlock})
551			child.Rect, child.HasRect = Rect{X: 10, Y: 20, W: 30, H: 40}, true
552			stack := node("stack", Stack, css.Style{Display: css.KeywordFlex, FlexDirection: direction}, child)
553			root := stack
554			if nested {
555				root = node("root", Row, css.Style{Display: css.KeywordFlex}, stack)
556			}
557			if _, err := Layout(root, Options{Width: 200, Height: 100}); err == nil || err.Error() != "layout: Rect is unsupported in a flex-styled Stack" {
558				t.Fatalf("direction=%v nested=%v: err=%v", direction, nested, err)
559			}
560			// Hidden children do not participate in layout or validation.
561			child.Style.Display = css.KeywordNone
562			if _, err := Layout(root, Options{Width: 200, Height: 100}); err != nil {
563				t.Fatalf("hidden Rect: %v", err)
564			}
565		}
566	}
567}
568
569func TestStackAbsoluteRect(t *testing.T) {
570	margin := css.Style{Display: css.KeywordBlock, Margin: css.LengthSides{Top: px(7), Left: px(9)}, Width: px(500), Height: px(500)}
571	a := node("a", Box, margin)
572	a.Rect, a.HasRect = Rect{X: 10.5, Y: 20, W: 30, H: 40}, true
573	flow := node("flow", Box, css.Style{Display: css.KeywordBlock, Height: px(12)})
574	stack := node("s", Stack, css.Style{Display: css.KeywordBlock, Padding: css.LengthSides{Top: px(3), Left: px(5)}}, a, flow)
575	out, err := Layout(node("root", Column, css.Style{Display: css.KeywordBlock}, stack), Options{Width: 200, Height: 100})
576	if err != nil {
577		t.Fatal(err)
578	}
579	kids := out.Tree.Children[0].Children
580	// Rect is border-box relative to the stack content box; authored size and margin do not apply.
581	if got, want := kids[0].Border, (Rect{X: 5 + 10.5, Y: 3 + 20, W: 30, H: 40}); got != want {
582		t.Fatalf("absolute border %+v want %+v", got, want)
583	}
584	// Non-Rect children keep normal stack placement at the content origin.
585	if got := kids[1].Border; got.X != 5 || got.Y != 3 || got.H != 12 {
586		t.Fatalf("flow child %+v", got)
587	}
588	// The stack's auto size covers the absolute child's extent.
589	if got := out.Tree.Children[0].ContentSize; got.W < 40.5 || got.H < 60 {
590		t.Fatalf("content size %+v", got)
591	}
592	// HasRect is ignored outside a Stack.
593	b := node("b", Box, css.Style{Display: css.KeywordBlock, Height: px(9)})
594	b.Rect, b.HasRect = Rect{X: 50, Y: 50, W: 5, H: 5}, true
595	out, err = Layout(node("col", Column, css.Style{Display: css.KeywordBlock}, b), Options{Width: 200, Height: 100})
596	if err != nil {
597		t.Fatal(err)
598	}
599	if got := out.Tree.Children[0].Border; got.X != 0 || got.H != 9 {
600		t.Fatalf("Rect leaked outside Stack: %+v", got)
601	}
602}

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