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}