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}