fix(layout): natural size ignores percentage heights
Natural treats percentage height, min-height and max-height of the root as auto, since the containing height is indefinite, and documents that its width may exceed options.Width. Add IndefiniteHeights for callers that lay the same root out. Check errors in the natural tests and cover root min/max, border insets, NoWrap text and layout errors.
- commit
- cb70a9ae14e92b17cb745931c8d8b92f90dc43b6
- author
- brice · · 2026-10-09 03:52 UTC
- parent
- 48b428b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +115 -4
M internal/layout/layout.go +23 -2
| @@ -221,8 +221,12 @@ func Layout(root *Node, options Options) (Output, error) { | |||
| 221 | 221 | // Natural returns the border-box size root takes when it is not stretched: the | |
| 222 | 222 | // widest and tallest envelope of its content plus its padding and border, | |
| 223 | 223 | // honoring explicit width, height, min and max like Layout. Text wraps at | |
| 224 | - | // options.Width, so the width never exceeds it unless an explicit width says | |
| 225 | - | // so. options.Height is unused. Margins are not part of the result. | |
| 224 | + | // options.Width minus the root margins, but the width may still exceed | |
| 225 | + | // options.Width when content cannot wrap (a long word, an unwrapped text, an | |
| 226 | + | // explicit width): callers clamp. The root's own margins are not part of the | |
| 227 | + | // result. Percentage height, min-height and max-height of root are treated as | |
| 228 | + | // auto, as for nested nodes (CSS: the containing height is indefinite), so | |
| 229 | + | // options.Height has no effect. | |
| 226 | 230 | func Natural(root *Node, options Options) (Size, error) { | |
| 227 | 231 | if root == nil { | |
| 228 | 232 | return Size{}, nil | |
| @@ -235,6 +239,7 @@ func Natural(root *Node, options Options) (Size, error) { | |||
| 235 | 239 | if err := validateStackRects(root, s); err != nil { | |
| 236 | 240 | return Size{}, err | |
| 237 | 241 | } | |
| 242 | + | s = IndefiniteHeights(s) | |
| 238 | 243 | availableW, availableH := safe(options.Width), safe(options.Height) | |
| 239 | 244 | margin := edges(s.Margin, availableW) | |
| 240 | 245 | padding := nonnegative(edges(s.Padding, availableW)) | |
| @@ -289,6 +294,22 @@ func Natural(root *Node, options Options) (Size, error) { | |||
| 289 | 294 | return Size{safe(math.Max(w, insetW)), safe(math.Max(h, insetH))}, nil | |
| 290 | 295 | } | |
| 291 | 296 | ||
| 297 | + | // IndefiniteHeights returns s with percentage height, min-height and max-height | |
| 298 | + | // replaced by auto (no constraint), for a root whose containing height is not | |
| 299 | + | // known, such as a surface that does not exist yet. | |
| 300 | + | func IndefiniteHeights(s css.Style) css.Style { | |
| 301 | + | if s.Height.Unit == "%" { | |
| 302 | + | s.Height = css.Length{Unit: "auto"} | |
| 303 | + | } | |
| 304 | + | if s.MinHeight.Unit == "%" { | |
| 305 | + | s.MinHeight = css.Length{} | |
| 306 | + | } | |
| 307 | + | if s.MaxHeight.Unit == "%" { | |
| 308 | + | s.MaxHeight = css.Length{} | |
| 309 | + | } | |
| 310 | + | return s | |
| 311 | + | } | |
| 312 | + | ||
| 292 | 313 | // display paints the placed tree into a list sized once from the tree. | |
| 293 | 314 | func (c context) display(root *Node, tree *Result) []Command { | |
| 294 | 315 | n := countCommands(root, tree) | |
| @@ -221,8 +221,12 @@ func Layout(root *Node, options Options) (Output, error) { | |||||
| 221 | // Natural returns the border-box size root takes when it is not stretched: the | 221 | // Natural returns the border-box size root takes when it is not stretched: the | ||
| 222 | // widest and tallest envelope of its content plus its padding and border, | 222 | // widest and tallest envelope of its content plus its padding and border, | ||
| 223 | // honoring explicit width, height, min and max like Layout. Text wraps at | 223 | // honoring explicit width, height, min and max like Layout. Text wraps at | ||
| 224 | - | // options.Width, so the width never exceeds it unless an explicit width says | 224 | + | // options.Width minus the root margins, but the width may still exceed |
| 225 | - | // so. options.Height is unused. Margins are not part of the result. | 225 | + | // options.Width when content cannot wrap (a long word, an unwrapped text, an |
| 226 | + | // explicit width): callers clamp. The root's own margins are not part of the | |||
| 227 | + | // result. Percentage height, min-height and max-height of root are treated as | |||
| 228 | + | // auto, as for nested nodes (CSS: the containing height is indefinite), so | |||
| 229 | + | // options.Height has no effect. | |||
| 226 | func Natural(root *Node, options Options) (Size, error) { | 230 | func Natural(root *Node, options Options) (Size, error) { | ||
| 227 | if root == nil { | 231 | if root == nil { | ||
| 228 | return Size{}, nil | 232 | return Size{}, nil | ||
| @@ -235,6 +239,7 @@ func Natural(root *Node, options Options) (Size, error) { | |||||
| 235 | if err := validateStackRects(root, s); err != nil { | 239 | if err := validateStackRects(root, s); err != nil { | ||
| 236 | return Size{}, err | 240 | return Size{}, err | ||
| 237 | } | 241 | } | ||
| 242 | + | s = IndefiniteHeights(s) | |||
| 238 | availableW, availableH := safe(options.Width), safe(options.Height) | 243 | availableW, availableH := safe(options.Width), safe(options.Height) | ||
| 239 | margin := edges(s.Margin, availableW) | 244 | margin := edges(s.Margin, availableW) | ||
| 240 | padding := nonnegative(edges(s.Padding, availableW)) | 245 | padding := nonnegative(edges(s.Padding, availableW)) | ||
| @@ -289,6 +294,22 @@ func Natural(root *Node, options Options) (Size, error) { | |||||
| 289 | return Size{safe(math.Max(w, insetW)), safe(math.Max(h, insetH))}, nil | 294 | return Size{safe(math.Max(w, insetW)), safe(math.Max(h, insetH))}, nil | ||
| 290 | } | 295 | } | ||
| 291 | 296 | ||||
| 297 | + | // IndefiniteHeights returns s with percentage height, min-height and max-height | |||
| 298 | + | // replaced by auto (no constraint), for a root whose containing height is not | |||
| 299 | + | // known, such as a surface that does not exist yet. | |||
| 300 | + | func IndefiniteHeights(s css.Style) css.Style { | |||
| 301 | + | if s.Height.Unit == "%" { | |||
| 302 | + | s.Height = css.Length{Unit: "auto"} | |||
| 303 | + | } | |||
| 304 | + | if s.MinHeight.Unit == "%" { | |||
| 305 | + | s.MinHeight = css.Length{} | |||
| 306 | + | } | |||
| 307 | + | if s.MaxHeight.Unit == "%" { | |||
| 308 | + | s.MaxHeight = css.Length{} | |||
| 309 | + | } | |||
| 310 | + | return s | |||
| 311 | + | } | |||
| 312 | + | ||||
| 292 | // display paints the placed tree into a list sized once from the tree. | 313 | // display paints the placed tree into a list sized once from the tree. | ||
| 293 | func (c context) display(root *Node, tree *Result) []Command { | 314 | func (c context) display(root *Node, tree *Result) []Command { | ||
| 294 | n := countCommands(root, tree) | 315 | n := countCommands(root, tree) | ||
M internal/layout/natural_test.go +92 -2
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package layout | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/nefergui/internal/css" | |
| @@ -51,8 +52,14 @@ func TestNaturalRowSumsWithGap(t *testing.T) { | |||
| 51 | 52 | engine := naturalEngine(t) | |
| 52 | 53 | a, b := textNode("a", "alpha"), textNode("b", "beta") | |
| 53 | 54 | opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine} | |
| 54 | - | wa, _ := Natural(a, opts) | |
| 55 | - | wb, _ := Natural(b, opts) | |
| 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 | + | } | |
| 56 | 63 | row := node("r", Row, css.Style{Display: css.KeywordFlex, ColumnGap: px(10)}, a, b) | |
| 57 | 64 | got, err := Natural(row, opts) | |
| 58 | 65 | if err != nil { | |
| @@ -111,3 +118,86 @@ func TestNaturalNilAndHidden(t *testing.T) { | |||
| 111 | 118 | t.Fatalf("hidden: %+v %v", got, err) | |
| 112 | 119 | } | |
| 113 | 120 | } | |
| 121 | + | ||
| 122 | + | func 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 | + | ||
| 172 | + | func 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 | + | ||
| 189 | + | func 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 | + | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package layout | 1 | package layout | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/nefergui/internal/css" | 7 | "github.com/bnema/nefergui/internal/css" | ||
| @@ -51,8 +52,14 @@ func TestNaturalRowSumsWithGap(t *testing.T) { | |||||
| 51 | engine := naturalEngine(t) | 52 | engine := naturalEngine(t) | ||
| 52 | a, b := textNode("a", "alpha"), textNode("b", "beta") | 53 | a, b := textNode("a", "alpha"), textNode("b", "beta") | ||
| 53 | opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine} | 54 | opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine} | ||
| 54 | - | wa, _ := Natural(a, opts) | 55 | + | wa, err := Natural(a, opts) |
| 55 | - | wb, _ := Natural(b, 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 | + | } | |||
| 56 | row := node("r", Row, css.Style{Display: css.KeywordFlex, ColumnGap: px(10)}, a, b) | 63 | row := node("r", Row, css.Style{Display: css.KeywordFlex, ColumnGap: px(10)}, a, b) | ||
| 57 | got, err := Natural(row, opts) | 64 | got, err := Natural(row, opts) | ||
| 58 | if err != nil { | 65 | if err != nil { | ||
| @@ -111,3 +118,86 @@ func TestNaturalNilAndHidden(t *testing.T) { | |||||
| 111 | t.Fatalf("hidden: %+v %v", got, err) | 118 | t.Fatalf("hidden: %+v %v", got, err) | ||
| 112 | } | 119 | } | ||
| 113 | } | 120 | } | ||
| 121 | + | ||||
| 122 | + | func 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 | + | ||||
| 172 | + | func 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 | + | ||||
| 189 | + | func 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 | + | } | |||