v0.7.2 71ebe61

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

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