1package layout
2
3import (
4 "errors"
5 "testing"
6
7 "github.com/bnema/nefergui/internal/css"
8 "github.com/bnema/nefergui/internal/text"
9)
10
11func naturalEngine(t *testing.T) *text.Engine {
12 t.Helper()
13 catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
14 if err != nil {
15 t.Fatal(err)
16 }
17 return text.NewEngine(catalog)
18}
19
20func textNode(id, content string) *Node {
21 n := node(id, Text, css.Style{Display: css.KeywordBlock, FontSize: px(16)})
22 n.Content = content
23 return n
24}
25
26func TestNaturalColumnOfTexts(t *testing.T) {
27 engine := naturalEngine(t)
28 short, long := textNode("a", "ab"), textNode("b", "a much longer line")
29 column := node("c", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, Padding: css.LengthSides{Top: px(4), Right: px(6), Bottom: px(4), Left: px(6)}}, short, long)
30 opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine}
31 got, err := Natural(column, opts)
32 if err != nil {
33 t.Fatal(err)
34 }
35 ws, err := Natural(short, opts)
36 if err != nil {
37 t.Fatal(err)
38 }
39 wl, err := Natural(long, opts)
40 if err != nil {
41 t.Fatal(err)
42 }
43 if !(wl.W > ws.W) || ws.W <= 0 {
44 t.Fatalf("text widths short=%g long=%g", ws.W, wl.W)
45 }
46 if got.W != wl.W+12 || got.H != ws.H+wl.H+8 {
47 t.Fatalf("column = %+v, want %g x %g", got, wl.W+12, ws.H+wl.H+8)
48 }
49}
50
51func TestNaturalRowSumsWithGap(t *testing.T) {
52 engine := naturalEngine(t)
53 a, b := textNode("a", "alpha"), textNode("b", "beta")
54 opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine}
55 wa, err := Natural(a, opts)
56 if err != nil {
57 t.Fatal(err)
58 }
59 wb, err := Natural(b, opts)
60 if err != nil {
61 t.Fatal(err)
62 }
63 row := node("r", Row, css.Style{Display: css.KeywordFlex, ColumnGap: px(10)}, a, b)
64 got, err := Natural(row, opts)
65 if err != nil {
66 t.Fatal(err)
67 }
68 if got.W != wa.W+wb.W+10 || got.H != max(wa.H, wb.H) {
69 t.Fatalf("row = %+v, want W %g H %g", got, wa.W+wb.W+10, max(wa.H, wb.H))
70 }
71}
72
73func TestNaturalExplicitWidthWins(t *testing.T) {
74 engine := naturalEngine(t)
75 child := textNode("a", "alpha beta gamma delta epsilon zeta")
76 box := node("box", Box, css.Style{Display: css.KeywordBlock, Width: px(90), Padding: css.LengthSides{Left: px(5), Right: px(5)}}, child)
77 got, err := Natural(box, Options{Width: 1000, Height: 1 << 20, TextEngine: engine})
78 if err != nil {
79 t.Fatal(err)
80 }
81 if got.W != 100 {
82 t.Fatalf("width = %g, want 100 (90 content + 10 padding)", got.W)
83 }
84 if got.H <= 16 {
85 t.Fatalf("content did not wrap inside the explicit width: height %g", got.H)
86 }
87 bb := node("bb", Box, css.Style{Display: css.KeywordBlock, BoxSizing: css.KeywordBorderBox, Width: px(90), Height: px(30), Padding: css.LengthSides{Left: px(5), Right: px(5)}})
88 got, err = Natural(bb, Options{Width: 1000, Height: 1000})
89 if err != nil || got.W != 90 || got.H != 30 {
90 t.Fatalf("border-box = %+v err=%v", got, err)
91 }
92}
93
94func TestNaturalTextWrapsAtOptionsWidth(t *testing.T) {
95 engine := naturalEngine(t)
96 long := textNode("a", "alpha beta gamma delta epsilon zeta eta theta iota kappa")
97 wide, err := Natural(long, Options{Width: 2000, Height: 1 << 20, TextEngine: engine})
98 if err != nil {
99 t.Fatal(err)
100 }
101 narrow, err := Natural(long, Options{Width: 100, Height: 1 << 20, TextEngine: engine})
102 if err != nil {
103 t.Fatal(err)
104 }
105 // A wrapped line may measure slightly over the limit (103.75 for 100 here);
106 // callers clamp to the limit, as Renderer.Measure does.
107 if narrow.W >= wide.W || narrow.H <= wide.H {
108 t.Fatalf("narrow=%+v wide=%+v", narrow, wide)
109 }
110}
111
112func TestNaturalNilAndHidden(t *testing.T) {
113 if got, err := Natural(nil, Options{}); err != nil || got != (Size{}) {
114 t.Fatalf("nil: %+v %v", got, err)
115 }
116 hidden := node("h", Box, css.Style{Display: css.KeywordNone})
117 if got, err := Natural(hidden, Options{Width: 100}); err != nil || got != (Size{}) {
118 t.Fatalf("hidden: %+v %v", got, err)
119 }
120}
121
122func TestNaturalRootConstraints(t *testing.T) {
123 engine := naturalEngine(t)
124 pct := func(v float64) css.Length { return css.Length{Value: v, Unit: "%"} }
125 solid := css.KeywordSides{Top: css.KeywordSolid, Right: css.KeywordSolid, Bottom: css.KeywordSolid, Left: css.KeywordSolid}
126 // want derives the expected size from the size of the unconstrained text.
127 tests := []struct {
128 name string
129 style css.Style
130 want func(base Size) Size
131 }{
132 {"min-width raises", css.Style{MinWidth: px(300)}, func(b Size) Size { return Size{300, b.H} }},
133 {"max-width caps", css.Style{MaxWidth: px(10)}, func(b Size) Size { return Size{10, b.H} }},
134 {"min-height raises", css.Style{MinHeight: px(80)}, func(b Size) Size { return Size{b.W, 80} }},
135 {"max-height caps", css.Style{MaxHeight: px(5)}, func(b Size) Size { return Size{b.W, 5} }},
136 {"percent height is auto", css.Style{Height: pct(100)}, func(b Size) Size { return b }},
137 {"percent min-height is auto", css.Style{MinHeight: pct(50)}, func(b Size) Size { return b }},
138 {"percent max-height is auto", css.Style{MaxHeight: pct(50)}, func(b Size) Size { return b }},
139 {"padding and border", css.Style{
140 Padding: css.LengthSides{Top: px(3), Right: px(5), Bottom: px(3), Left: px(5)},
141 BorderWidth: css.LengthSides{Top: px(2), Right: px(2), Bottom: px(2), Left: px(2)},
142 BorderStyle: solid,
143 }, func(b Size) Size { return Size{b.W + 14, b.H + 10} }},
144 {"border without style is ignored", css.Style{BorderWidth: css.LengthSides{Left: px(9), Right: px(9)}}, func(b Size) Size { return b }},
145 {"margin is not part of the size", css.Style{Margin: css.LengthSides{Top: px(7), Right: px(7), Bottom: px(7), Left: px(7)}}, func(b Size) Size { return b }},
146 }
147 opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine}
148 base, err := Natural(textNode("t", "ab"), opts)
149 if err != nil {
150 t.Fatal(err)
151 }
152 if base.W <= 10 || base.H <= 5 {
153 t.Fatalf("setup: base %+v too small for the max-width/max-height cases", base)
154 }
155 for _, tc := range tests {
156 t.Run(tc.name, func(t *testing.T) {
157 style := tc.style
158 style.Display, style.FontSize = css.KeywordBlock, px(16)
159 n := textNode("t", "ab")
160 n.Style = &style
161 got, err := Natural(n, opts)
162 if err != nil {
163 t.Fatal(err)
164 }
165 if want := tc.want(base); got != want {
166 t.Fatalf("got %+v, want %+v (base %+v)", got, want, base)
167 }
168 })
169 }
170}
171
172func TestNaturalNoWrapTextRootIgnoresWidth(t *testing.T) {
173 engine := naturalEngine(t)
174 long := textNode("a", "alpha beta gamma delta epsilon zeta eta theta iota kappa")
175 wrapped, err := Natural(long, Options{Width: 100, Height: 1 << 20, TextEngine: engine})
176 if err != nil {
177 t.Fatal(err)
178 }
179 long.NoWrap = true
180 flat, err := Natural(long, Options{Width: 100, Height: 1 << 20, TextEngine: engine})
181 if err != nil {
182 t.Fatal(err)
183 }
184 if flat.W <= 100 || flat.H >= wrapped.H {
185 t.Fatalf("NoWrap=%+v wrapped=%+v: want one line wider than the limit", flat, wrapped)
186 }
187}
188
189func TestNaturalError(t *testing.T) {
190 rect := textNode("r", "x")
191 rect.HasRect = true
192 stack := node("s", Stack, css.Style{Display: css.KeywordFlex}, rect)
193 if _, err := Natural(stack, Options{Width: 100, Height: 100}); err == nil {
194 t.Fatal("flex Stack with a Rect child accepted")
195 }
196 deep := node("leaf", Box, css.Style{Display: css.KeywordBlock})
197 for i := 0; i < 300; i++ {
198 deep = node("n", Box, css.Style{Display: css.KeywordBlock}, deep)
199 }
200 if _, err := Natural(deep, Options{Width: 100, Height: 100}); !errors.Is(err, ErrDepth) {
201 t.Fatalf("deep tree: %v, want ErrDepth", err)
202 }
203}